fix(ai): raise typed errors for failed finishes (#47688)

This commit is contained in:
Aiden Cline
2026-09-06 18:09:49 -05:00
committed by GitHub
Unverified
parent 0d70595338
commit 13286e883e
4 changed files with 57 additions and 7 deletions
+8
View File
@@ -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 },
+12
View File
@@ -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(
+21 -6
View File
@@ -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}`,
})
}
}),
)
+16 -1
View File
@@ -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(