diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index 855ab32a19b..dfeee340a7f 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -639,6 +639,14 @@ const step = (state: ParserState, event: GeminiEvent) => { : state.usage, } const candidate = event.candidates?.[0] + if (candidate?.finishReason && mapFinishReason(candidate.finishReason, state.hasToolCalls) === "error") + return Effect.fail( + ProviderShared.eventError( + state.route, + `Gemini stopped with ${candidate.finishReason}`, + ProviderShared.encodeJson(event), + ), + ) if (!candidate?.content) return Effect.succeed([ { ...nextState, finishReason: candidate?.finishReason ?? nextState.finishReason }, diff --git a/packages/ai/src/protocols/mistral-chat.ts b/packages/ai/src/protocols/mistral-chat.ts index 7ff0d905af6..18f09e1100b 100644 --- a/packages/ai/src/protocols/mistral-chat.ts +++ b/packages/ai/src/protocols/mistral-chat.ts @@ -9,6 +9,8 @@ import { AIError, InvalidProviderOutputError, LLMEvent, + ProviderInternalError, + UnknownProviderError, Usage, type FinishReasonDetails, type LLMRequest, @@ -700,6 +702,16 @@ const step = Effect.fn("MistralChat.step")(function* (state: ParserState, event: normalized: mapFinishReason(choice.finish_reason), raw: choice.finish_reason, } + if (finishReason.normalized === "error") { + const details = { + message: `Mistral Chat stopped with ${finishReason.raw}`, + body: ProviderShared.encodeJson(event), + } + return yield* new AIError({ + reason: + finishReason.raw === "network_error" ? new ProviderInternalError(details) : new UnknownProviderError(details), + }) + } const incomplete = finishReason.normalized === "length" || finishReason.normalized === "content-filter" if (!incomplete && Object.keys(withTools.pendingTools).length > 0) return yield* ProviderShared.eventError( diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts index 436748ab159..a2f5e53eac1 100644 --- a/packages/ai/test/provider/gemini.test.ts +++ b/packages/ai/test/provider/gemini.test.ts @@ -1643,19 +1643,14 @@ describe("Gemini route", () => { }), ) - it.effect("maps current blocking and invalid-output finish reasons", () => + it.effect("preserves blocking finishes and rejects invalid-output finish reasons", () => Effect.gen(function* () { const reasons = [ ["MODEL_ARMOR", "content-filter"], ["IMAGE_PROHIBITED_CONTENT", "content-filter"], ["IMAGE_RECITATION", "content-filter"], ["LANGUAGE", "content-filter"], - ["UNEXPECTED_TOOL_CALL", "error"], - ["NO_IMAGE", "error"], ["IMAGE_OTHER", "unknown"], - ["TOO_MANY_TOOL_CALLS", "error"], - ["MISSING_THOUGHT_SIGNATURE", "error"], - ["MALFORMED_RESPONSE", "error"], ] as const for (const [raw, normalized] of reasons) { @@ -1666,6 +1661,26 @@ describe("Gemini route", () => { ) expect(response.finishReason).toEqual({ normalized, raw }) } + + for (const raw of [ + "MALFORMED_FUNCTION_CALL", + "UNEXPECTED_TOOL_CALL", + "NO_IMAGE", + "TOO_MANY_TOOL_CALLS", + "MISSING_THOUGHT_SIGNATURE", + "MALFORMED_RESPONSE", + ]) { + const event = { candidates: [{ finishReason: raw, finishMessage: "Provider detail" }], responseId: "failure" } + const error = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseEvents(event))), + Effect.flip, + ) + expect(error).toMatchObject({ + _tag: "AI.Error", + reason: { _tag: "InvalidProviderOutput", body: JSON.stringify(event), http: { status: 200 } }, + message: `Gemini stopped with ${raw}`, + }) + } }), ) diff --git a/packages/ai/test/provider/mistral-chat.test.ts b/packages/ai/test/provider/mistral-chat.test.ts index 934b2c5a775..2f4ef107f3b 100644 --- a/packages/ai/test/provider/mistral-chat.test.ts +++ b/packages/ai/test/provider/mistral-chat.test.ts @@ -600,7 +600,6 @@ describe("Mistral Chat", () => { ["stop", "stop"], ["model_length", "length"], ["tool_calls", "tool-calls"], - ["error", "error"], ["future_reason", "unknown"], ] as const) { const response = yield* LLMClient.generate(request).pipe( @@ -609,6 +608,22 @@ describe("Mistral Chat", () => { expect(response.finishReason).toEqual({ normalized, raw }) } + for (const [raw, tag] of [ + ["error", "UnknownProvider"], + ["network_error", "ProviderInternal"], + ] as const) { + const event = { ...chunk({}, raw), diagnostics: { trace: "failure" } } + const error = yield* LLMClient.generate(request).pipe( + Effect.provide(fixedResponse(sseEvents(event))), + Effect.flip, + ) + expect(error).toMatchObject({ + _tag: "AI.Error", + reason: { _tag: tag, body: JSON.stringify(event), http: { status: 200 } }, + message: `Mistral Chat stopped with ${raw}`, + }) + } + const truncated = yield* LLMClient.generate(request).pipe( Effect.provide( fixedResponse(