From eda6d774bfc03a0a65f2173fd3d6188301973438 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 24 Aug 2026 17:23:35 -0500 Subject: [PATCH] fix(ai): ignore unknown Gemini response parts (#44745) --- packages/ai/src/protocols/gemini.ts | 33 ++++++++++++++-- packages/ai/test/provider/gemini.test.ts | 48 ++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 4 deletions(-) diff --git a/packages/ai/src/protocols/gemini.ts b/packages/ai/src/protocols/gemini.ts index 3ccf8eafcae..21106a91446 100644 --- a/packages/ai/src/protocols/gemini.ts +++ b/packages/ai/src/protocols/gemini.ts @@ -1,4 +1,4 @@ -import { Effect, Schema } from "effect" +import { Effect, Option, Schema } from "effect" import { Tool } from "@opencode-ai/schema/tool" import { Route } from "../route/client.js" import { Auth } from "../route/auth.js" @@ -125,6 +125,7 @@ const GeminiContentPart = Schema.Union([ GeminiFunctionCallPart, GeminiFunctionResponsePart, ]) +const decodeGeminiContentPart = Schema.decodeUnknownOption(GeminiContentPart) const GeminiContent = Schema.Struct({ role: optionalNull(Schema.Literals(["user", "model"])), @@ -132,6 +133,11 @@ const GeminiContent = Schema.Struct({ }) type GeminiContent = Schema.Schema.Type +const GeminiResponseContent = Schema.Struct({ + role: optionalNull(Schema.Literals(["user", "model"])), + parts: optionalNull(Schema.Array(Schema.Unknown)), +}) + const GeminiSystemInstruction = Schema.Struct({ parts: Schema.Array(Schema.Struct({ text: Schema.String })), }) @@ -200,7 +206,7 @@ const GeminiUsage = Schema.Struct({ type GeminiUsage = Schema.Schema.Type const GeminiCandidate = Schema.Struct({ - content: optionalNull(GeminiContent), + content: optionalNull(GeminiResponseContent), finishReason: optionalNull(Schema.String), }) @@ -222,6 +228,7 @@ const GeminiEvent = Schema.Struct({ type GeminiEvent = Schema.Schema.Type interface ParserState { + readonly route: string readonly finishReason?: string readonly hasToolCalls: boolean readonly promptFeedback?: GeminiPromptFeedback @@ -598,7 +605,21 @@ const step = (state: ParserState, event: GeminiEvent) => { // Supplier ids must be tracked across chunks of the same response, not just within one event's parts. const seenCallIds = new Set(nextState.seenCallIds) - for (const part of candidate.content.parts ?? []) { + for (const input of candidate.content.parts ?? []) { + if ( + ProviderShared.isRecord(input) && + !("text" in input) && + !("inlineData" in input) && + !("functionCall" in input) && + !("functionResponse" in input) + ) + continue + const decoded = decodeGeminiContentPart(input) + if (Option.isNone(decoded)) + return Effect.fail( + ProviderShared.eventError(ADAPTER, `Invalid ${state.route} stream event`, ProviderShared.encodeJson(event)), + ) + const part = decoded.value const signature = "thoughtSignature" in part && part.thoughtSignature ? part.thoughtSignature : undefined // Gemini attaches replay signatures to thought parts, visible text, or function calls; // each block kind must retain the signature attached to its own parts. @@ -691,7 +712,11 @@ export const protocol = Protocol.make({ }, stream: { event: Protocol.jsonEvent(GeminiEvent), - initial: () => ({ hasToolCalls: false, lifecycle: Lifecycle.initial() }), + initial: (request) => ({ + route: `${request.model.provider}/${request.model.route.id}`, + hasToolCalls: false, + lifecycle: Lifecycle.initial(), + }), step, onHalt: finish, }, diff --git a/packages/ai/test/provider/gemini.test.ts b/packages/ai/test/provider/gemini.test.ts index 8aeaf400e20..afb8296cc47 100644 --- a/packages/ai/test/provider/gemini.test.ts +++ b/packages/ai/test/provider/gemini.test.ts @@ -906,6 +906,54 @@ describe("Gemini route", () => { }), ) + it.effect("ignores unknown response parts", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents({ + candidates: [ + { + content: { + role: "model", + parts: [ + { text: "Hello " }, + { executableCode: { language: "PYTHON", code: "print('ignored')" } }, + { text: "world" }, + ], + }, + finishReason: "STOP", + }, + ], + }), + ), + ), + ) + + expect(response.text).toBe("Hello world") + expect(response.finishReason).toEqual({ normalized: "stop", raw: "STOP" }) + }), + ) + + it.effect("rejects malformed recognized response parts", () => + Effect.gen(function* () { + const error = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents({ + candidates: [{ content: { role: "model", parts: [{ text: 42 }] } }], + }), + ), + ), + Effect.flip, + ) + + expect(error).toBeInstanceOf(AIError) + expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" }) + expect(error.message).toContain("Invalid google/gemini stream event") + }), + ) + it.effect("preserves thoughtSignature for reasoning and tool-call continuation", () => Effect.gen(function* () { const body = sseEvents({