fix(mistral): report provider terminal failures instead of silent success (#129256)

This commit is contained in:
Peter Steinberger
2026-08-25 04:56:32 -07:00
committed by GitHub
parent 2e54fe7116
commit ebfd80ff91
2 changed files with 24 additions and 27 deletions
@@ -169,7 +169,7 @@ describe("Mistral bounded-stream-read direct (synthetic ReadableStream)", () =>
});
type MistralTerminalFixture = {
finishReason: "stop" | "length" | "error" | "tool_calls" | null;
finishReason: string | null;
done: boolean;
abort?: boolean;
toolArguments?: string[];
@@ -269,6 +269,8 @@ describe("Mistral terminal ownership through the installed SDK and real HTTP/SSE
{ name: "EOF without a provider terminal", finishReason: null, done: false },
{ name: "DONE without a provider terminal", finishReason: null, done: true },
{ name: "a provider error terminal", finishReason: "error", done: true },
{ name: "a filtered provider terminal", finishReason: "content_filter", done: true },
{ name: "an unknown provider terminal", finishReason: "provider_guardrail", done: true },
{ name: "malformed arguments on a tool terminal", finishReason: "tool_calls", done: true },
] as const)("rejects $name without executable calls", async (fixture) => {
const { result, events } = await streamMistralTerminalFixture({
@@ -279,24 +281,31 @@ describe("Mistral terminal ownership through the installed SDK and real HTTP/SSE
expect(result.stopReason).toBe("error");
if (fixture.finishReason === null) {
expect(result.errorMessage).toBe("Mistral stream ended without a terminal finish reason");
} else if (fixture.finishReason === "tool_calls") {
expect(result.errorMessage).toContain("invalid JSON arguments");
} else {
expect(result.errorMessage).toBeTruthy();
expect(result.errorMessage).toBe(`Provider finish_reason: ${fixture.finishReason}`);
}
expect(events).toContain("error");
expect(events).not.toContain("toolcall_end");
expect(result.content).not.toContainEqual(expect.objectContaining({ type: "toolCall" }));
expect(result.content).toContainEqual({ type: "text", text: "Safe partial answer" });
});
it.each(["length", "stop"] as const)(
"preserves a %s terminal and visible text without finalizing partial tools",
async (finishReason) => {
it.each([
{ finishReason: "length", stopReason: "length" },
{ finishReason: "model_length", stopReason: "length" },
{ finishReason: "stop", stopReason: "stop" },
] as const)(
"preserves a $finishReason terminal and visible text without finalizing partial tools",
async ({ finishReason, stopReason }) => {
const { result, events } = await streamMistralTerminalFixture({
finishReason,
done: true,
toolArguments: ['{"action":"delete_all"'],
text: "Safe partial answer",
});
expect(result.stopReason).toBe(finishReason);
expect(result.stopReason).toBe(stopReason);
expect(result.content).toEqual([{ type: "text", text: "Safe partial answer" }]);
expect(events).not.toContain("toolcall_end");
expect(events).toContain("done");
+9 -21
View File
@@ -23,7 +23,6 @@ import type {
Message,
Model,
SimpleStreamOptions,
StopReason,
StreamFunction,
StreamOptions,
TextContent,
@@ -43,6 +42,7 @@ import { projectProviderError } from "../utils/provider-error.js";
import { sanitizeSurrogates } from "../utils/sanitize-unicode.js";
import { createSseByteGuard } from "../utils/streaming-byte-guard.js";
import { stripSystemPromptCacheBoundary } from "../utils/system-prompt-cache-boundary.js";
import { mapOpenAIStopReason } from "./openai-stop-reason.js";
import { buildBaseOptions, clampMaxTokensToModel } from "./simple-options.js";
import {
describeToolResultMediaPlaceholder,
@@ -206,7 +206,7 @@ export const streamMistral: StreamFunction<"mistral-conversations", MistralOptio
}
if (output.stopReason === "aborted" || output.stopReason === "error") {
throw new Error("An unknown error occurred");
throw new Error(output.errorMessage ?? "An unknown error occurred");
}
stream.push({ type: "done", reason: output.stopReason, message: output });
@@ -619,7 +619,13 @@ async function consumeChatStream(
if (choice.finishReason) {
terminalFinishReason = choice.finishReason;
output.stopReason = mapChatStopReason(choice.finishReason);
const { stopReason, errorMessage } = mapOpenAIStopReason(
choice.finishReason === "model_length" ? "length" : choice.finishReason,
);
output.stopReason = stopReason;
if (errorMessage) {
output.errorMessage = errorMessage;
}
}
const delta = choice.delta;
@@ -1018,22 +1024,4 @@ function mapToolChoice(
};
}
function mapChatStopReason(reason: string | null): StopReason {
if (reason === null) {
return "stop";
}
switch (reason) {
case "stop":
return "stop";
case "length":
case "model_length":
return "length";
case "tool_calls":
return "toolUse";
case "error":
return "error";
default:
return "stop";
}
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */