diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index dd78463979c..6d3a157955f 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -609,18 +609,27 @@ const finish = (state: ParserState): ReadonlyArray => { } const step = (state: ParserState, event: GeminiEvent) => { - if (ProviderShared.isRecord(event.error) && typeof event.error.message === "string") { + if (ProviderShared.isRecord(event.error)) { const body = ProviderShared.encodeJson(event) return Effect.fail( new AIError({ reason: classifyProviderFailure({ - message: event.error.message, + message: + typeof event.error.message === "string" && event.error.message.length > 0 + ? event.error.message + : typeof event.error.status === "string" && event.error.status.length > 0 + ? event.error.status + : "Gemini provider error", status: typeof event.error.code === "number" ? event.error.code : undefined, rawBody: body, }), }), ) } + if ("error" in event) + return Effect.fail( + ProviderShared.eventError(state.route, `Invalid ${state.route} stream event`, ProviderShared.encodeJson(event)), + ) const nextState = { ...state, promptFeedback: event.promptFeedback ?? state.promptFeedback, diff --git a/packages/ai/test/provider/error-retention.test.ts b/packages/ai/test/provider/error-retention.test.ts index 20638a38a70..d958a34d276 100644 --- a/packages/ai/test/provider/error-retention.test.ts +++ b/packages/ai/test/provider/error-retention.test.ts @@ -62,6 +62,62 @@ describe("provider error retention", () => { ) } + it.effect("classifies a message-less Gemini 429 and retains its event and HTTP context", () => + Effect.gen(function* () { + const body = JSON.stringify({ + error: { code: 429, status: "RESOURCE_EXHAUSTED", details: { opaque: [1, 2] } }, + trace: { opaque: "outer" }, + }) + const error = yield* LLMClient.generate( + LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }), + ).pipe( + Effect.provide( + fixedResponse(sseEvents(body), { + headers: { "content-type": "text/event-stream", "x-provider-trace": "trace-1" }, + }), + ), + Effect.flip, + ) + + expect(error.message).toBe("RESOURCE_EXHAUSTED") + expect(error.reason._tag).toBe("RateLimit") + expect(error.reason.body).toBe(body) + expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-provider-trace": "trace-1" } }) + expect(error.reason.http?.url).toStartWith("https://provider.test/") + }), + ) + + it.effect("rejects a malformed non-record Gemini error", () => + Effect.gen(function* () { + const body = JSON.stringify({ error: "RESOURCE_EXHAUSTED", trace: { opaque: "outer" } }) + const error = yield* LLMClient.generate( + LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }), + ).pipe(Effect.provide(fixedResponse(sseEvents(body))), Effect.flip) + + expect(error.reason._tag).toBe("InvalidProviderOutput") + expect(error.message).toContain("Invalid google/gemini stream event") + expect(error.reason.body).toBe(body) + expect(error.reason.http?.status).toBe(200) + }), + ) + + it.effect("rejects and retains an explicit null Gemini error", () => + Effect.gen(function* () { + const body = JSON.stringify({ error: null, trace: { opaque: "outer" } }) + const error = yield* LLMClient.generate( + LLM.request({ model: Google.configure(options).model("gemini"), prompt: "hello" }), + ).pipe( + Effect.provide(fixedResponse(sseEvents(body), { headers: { "x-provider-trace": "trace-null" } })), + Effect.flip, + ) + + expect(error.reason._tag).toBe("InvalidProviderOutput") + expect(error.reason.body).toBe(body) + expect(error.reason.http).toMatchObject({ status: 200, headers: { "x-provider-trace": "trace-null" } }) + expect(error.reason.http?.url).toStartWith("https://provider.test/") + }), + ) + it.effect("retains malformed provider frames and the original decode cause", () => Effect.gen(function* () { const body = '{"type":"error","error":{"message":42,"opaque":{"nested":true}},"trace":"outer"}'