diff --git a/packages/ai/src/providers/mistral.bounded-stream.test.ts b/packages/ai/src/providers/mistral.bounded-stream.test.ts index 62eb6e2607fa..b0936068a807 100644 --- a/packages/ai/src/providers/mistral.bounded-stream.test.ts +++ b/packages/ai/src/providers/mistral.bounded-stream.test.ts @@ -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"); diff --git a/packages/ai/src/providers/mistral.ts b/packages/ai/src/providers/mistral.ts index 381a93929894..7dbbe1004b1c 100644 --- a/packages/ai/src/providers/mistral.ts +++ b/packages/ai/src/providers/mistral.ts @@ -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. */