diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 0895ad07e8..c97bd7bae4 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -116,6 +116,15 @@ of the HTTP retry loop. opaque `thoughtSignature` values so tool-result continuations retain Gemini reasoning continuity. The signature cache is snapshotted to the config directory, so continuations also survive proxy restarts. +- **Malformed response shapes fail closed.** A claimed candidate, its `content`, or its + `content.parts` that is not the documented container terminates the turn with a + `google response contained invalid …` error naming the structural reason and the offending + value's type — never its contents. Absence is handled separately from corruption: an absent, + `null` or empty `content` or `parts` still completes the turn normally, a streaming chunk whose + `candidates` is absent, `null` or empty is skipped so the turn completes on a later terminal + frame, and a buffered response that carries no candidate at all returns + `google response contained no candidates`. A root `data: null` keepalive frame is still skipped as + padding. - **Inline image output:** when the model is one of the explicit image-capable chat IDs (`gemini-3.1-flash-image`, `gemini-2.0-flash-preview-image-generation`, or `gemini-3-pro-image-preview`), the adapter sends `responseModalities: ["TEXT", "IMAGE"]`. diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 5ce985dd4b..a24cb33b39 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -372,6 +372,79 @@ function googlePartTextEvent(part: GoogleResponsePart): AdapterEvent | undefined : { type: "text_delta", text: part.text }; } +interface InvalidGoogleShapeDiagnostic { + reason: + | "candidates_not_array" + | "candidate_not_object" + | "content_not_object" + | "parts_not_array" + | "part_not_object"; + partIndex?: number; + valueType: string; +} + +function isGoogleRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function googleStructuralValueType(value: unknown): string { + if (value === null) return "null"; + return Array.isArray(value) ? "array" : typeof value; +} + +/** + * A candidate's `content` is claimed model output inside a well-formed frame, so it is governed by + * the #1332 nested-shape rule (fail closed) rather than #1240's root-frame padding rule (skip). + * + * Absence stays legal, and so does one encoding of it: an empty array is how a JSON writer with no + * distinct empty-object form spells an empty `content`, and it already behaves as "no parts". A + * NON-empty array is the opposite case — `content?.parts` silently reads `undefined` from it, so a + * candidate shaped `content: [{ parts: [...] }]` dropped its own text and completed as an empty + * turn. + */ +function diagnoseGoogleContent(content: unknown): InvalidGoogleShapeDiagnostic | undefined { + if (content === undefined || content === null || isGoogleRecord(content)) return undefined; + if (Array.isArray(content) && content.length === 0) return undefined; + return { reason: "content_not_object", valueType: googleStructuralValueType(content) }; +} + +/** + * `content.parts` sits one rung below the candidate guard added in #1332, and both parsers + * consumed it unchecked: `for (const part of {})` throws `{} is not iterable`, and a `[null]` + * element throws on `part.thoughtSignature`. A `parts` that is absent or `null` keeps its existing + * skip — only a present, non-null container is validated. + */ +function diagnoseGoogleParts(parts: unknown): InvalidGoogleShapeDiagnostic | undefined { + if (!Array.isArray(parts)) { + return { reason: "parts_not_array", valueType: googleStructuralValueType(parts) }; + } + for (let partIndex = 0; partIndex < parts.length; partIndex++) { + const part: unknown = parts[partIndex]; + if (!isGoogleRecord(part)) { + return { reason: "part_not_object", partIndex, valueType: googleStructuralValueType(part) }; + } + } + return undefined; +} + +function invalidGoogleShapeEvent( + diagnostic: InvalidGoogleShapeDiagnostic, +): Extract { + const at = diagnostic.partIndex !== undefined ? `; partIndex=${diagnostic.partIndex}` : ""; + // The subject names the rung that failed, so an operator reading a log can tell a broken + // candidate list from a well-formed candidate whose parts are broken. The candidate subject keeps + // the exact wording #1332 introduced as its prefix, so an existing grep still matches. + const subject = diagnostic.reason === "candidates_not_array" || diagnostic.reason === "candidate_not_object" + ? "candidates" + : diagnostic.reason === "content_not_object" + ? "content" + : "content parts"; + return { + type: "error", + message: `google response contained invalid ${subject} (${diagnostic.reason}${at}; valueType=${diagnostic.valueType})`, + }; +} + export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapter { // Per-request closure: resolveAdapter builds a fresh adapter per request (server.ts), so buildRequest // can stash the CCA model/session for parseStream's reasoning-replay observation. @@ -669,22 +742,34 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte sawTerminalSignal = true; } const rawCandidates = root.candidates; - if (rawCandidates === undefined) return "continue"; + // `null` is an absence encoding, not corruption, and terminating on it is the #1219 + // failure mode one rung in: a `{"candidates":null}` frame arriving between a content + // delta and the finish chunk killed a turn whose answer had already fully arrived. An + // absent key and an empty array are already skipped here; `null` joins them. A non-null + // non-array container is still claimed structure the parser cannot read, and stays + // terminal. + if (rawCandidates === undefined || rawCandidates === null) return "continue"; if (!Array.isArray(rawCandidates)) { - yield { type: "error", message: "google response contained invalid candidates" }; + yield invalidGoogleShapeEvent({ + reason: "candidates_not_array", + valueType: googleStructuralValueType(rawCandidates), + }); return "terminate"; } if (rawCandidates.length === 0) return "continue"; const rawCandidate = rawCandidates[0]; - if (rawCandidate === null || typeof rawCandidate !== "object" || Array.isArray(rawCandidate)) { + if (!isGoogleRecord(rawCandidate)) { // Unlike a root `data: null` keepalive, this is a claimed response candidate. Treat it // as terminal protocol corruption so the turn cannot complete after silently losing // a candidate or tool call (#1325). - yield { type: "error", message: "google response contained invalid candidates" }; + yield invalidGoogleShapeEvent({ + reason: "candidate_not_object", + valueType: googleStructuralValueType(rawCandidate), + }); return "terminate"; } const candidate = rawCandidate as { - content?: { parts?: unknown[] }; + content?: unknown; finishReason?: string; }; @@ -693,7 +778,24 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte sawTerminalSignal = true; } - const parts = candidate.content?.parts as GoogleResponsePart[] | undefined; + // One rung below the candidate guard above, same rule: this is claimed content, not + // padding, so it fails closed rather than being iterated or silently dropped (#1325). + const rawContent: unknown = candidate.content; + const invalidContent = diagnoseGoogleContent(rawContent); + if (invalidContent) { + yield invalidGoogleShapeEvent(invalidContent); + return "terminate"; + } + const rawParts: unknown = isGoogleRecord(rawContent) ? rawContent.parts : undefined; + let parts: GoogleResponsePart[] | undefined; + if (rawParts !== undefined && rawParts !== null) { + const invalidParts = diagnoseGoogleParts(rawParts); + if (invalidParts) { + yield invalidGoogleShapeEvent(invalidParts); + return "terminate"; + } + parts = rawParts as GoogleResponsePart[]; + } // Record Gemini thought signatures for the next stateless tool-result turn. Vertex and // Antigravity use separate model namespaces so opaque provider state cannot cross routes. const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel; @@ -924,23 +1026,52 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } const events: AdapterEvent[] = []; - const candidates = json.candidates as { content?: { parts?: GoogleResponsePart[] }; finishReason?: string }[] | undefined; + const rawCandidates: unknown = json.candidates; + // Parity with the streaming path, which has rejected a non-array `candidates` since #1332. + // Buffered accepted `"abc"` outright (`"abc".length` is 3, so the emptiness check below + // passed and `candidates[0]` was the character `"a"`), and reported `{}`/`5` as an absent + // candidate list rather than a malformed one. + if (rawCandidates !== undefined && rawCandidates !== null && !Array.isArray(rawCandidates)) { + return finish([invalidGoogleShapeEvent({ + reason: "candidates_not_array", + valueType: googleStructuralValueType(rawCandidates), + })]); + } + const candidates = rawCandidates as { finishReason?: string }[] | undefined; if (!candidates?.length) { return finish([{ type: "error", message: "google response contained no candidates" }]); } + const rawCandidate: unknown = candidates[0]; + if (!isGoogleRecord(rawCandidate)) { + // The streaming parser already treats this as terminal protocol corruption (#1325/#1332). + // Buffered returned a bare `done`, so a claimed-but-malformed candidate was reported to + // the caller as a successful empty turn. + return finish([invalidGoogleShapeEvent({ + reason: "candidate_not_object", + valueType: googleStructuralValueType(rawCandidate), + })]); + } + const candidate = rawCandidate as { content?: unknown; finishReason?: string }; let toolCallsStarted = 0; const imageBudget = createImageBudget(); - if (candidates?.[0]?.content?.parts) { + const rawContent: unknown = candidate.content; + const invalidContent = diagnoseGoogleContent(rawContent); + if (invalidContent) return finish([invalidGoogleShapeEvent(invalidContent)]); + const rawParts: unknown = isGoogleRecord(rawContent) ? rawContent.parts : undefined; + if (rawParts !== undefined && rawParts !== null) { + const invalidParts = diagnoseGoogleParts(rawParts); + if (invalidParts) return finish([invalidGoogleShapeEvent(invalidParts)]); + const parts = rawParts as GoogleResponsePart[]; // Non-streaming Google-family response: observe thought signatures for the next turn, // using the same transport-scoped namespace as the streaming path. const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel; const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession; if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex") && replayModel && replaySession) { - observeAntigravityReplay(replayModel, replaySession, candidates[0].content.parts as unknown[]); + observeAntigravityReplay(replayModel, replaySession, parts as unknown[]); } let pendingThoughtSig: string | undefined; - for (const part of candidates[0].content.parts) { + for (const part of parts) { const sig = part.thoughtSignature ?? part.thought_signature; if (part.thought === true && sig && isLikelyRealThoughtSignature(sig)) { pendingThoughtSig = sig; @@ -979,8 +1110,8 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // Fail-closed truncation, same as the stream path: a non-stream turn cut off mid tool call // (MAX_TOKENS / MALFORMED_FUNCTION_CALL) surfaces an error instead of a silent done. if ((provider.googleMode === "vertex" || provider.googleMode === "cloud-code-assist") - && isVertexTruncatedTurn(candidates?.[0]?.finishReason, toolCallsStarted)) { - return finish([{ type: "error", message: vertexTruncationErrorMessage(candidates?.[0]?.finishReason) }]); + && isVertexTruncatedTurn(candidate.finishReason, toolCallsStarted)) { + return finish([{ type: "error", message: vertexTruncationErrorMessage(candidate.finishReason) }]); } const usage = json.usageMetadata as Record | undefined; @@ -988,7 +1119,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // must carry its stop reason, or the bridge sees a clean `done` and reports the truncated // turn as completed — and, on a compaction turn, installs the half-written summary as // replacement history (#422). - const finishReason = candidates?.[0]?.finishReason as string | undefined; + const finishReason = candidate.finishReason as string | undefined; const stopReason = finishReason === "MAX_TOKENS" ? "max_tokens" : ["SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII"].includes(finishReason ?? "") diff --git a/tests/google-hardening.test.ts b/tests/google-hardening.test.ts index 207dddd8bc..89d06a14c5 100644 --- a/tests/google-hardening.test.ts +++ b/tests/google-hardening.test.ts @@ -122,11 +122,202 @@ describe("google provider hardening", () => { expect(events).toEqual([{ type: "error", - message: "google response contained invalid candidates", + message: "google response contained invalid candidates (candidate_not_object; valueType=null)", }]); expect(events.some(event => event.type === "done")).toBe(false); }); + // `content.parts` sits one rung below the candidate guard above. It is claimed model output + // inside a well-formed frame, so it follows the same fail-closed rule rather than #1240's + // root-frame padding rule. Before this, each of these escaped as a raw TypeError. + const invalidPartsCases: [string, unknown, string][] = [ + ["an object container", {}, "google response contained invalid content parts (parts_not_array; valueType=object)"], + ["a number container", 5, "google response contained invalid content parts (parts_not_array; valueType=number)"], + ["a string container", "txt", "google response contained invalid content parts (parts_not_array; valueType=string)"], + ["a null element", [null], "google response contained invalid content parts (part_not_object; partIndex=0; valueType=null)"], + ["a number element", [5], "google response contained invalid content parts (part_not_object; partIndex=0; valueType=number)"], + ["an array element", [[]], "google response contained invalid content parts (part_not_object; partIndex=0; valueType=array)"], + ["a bad element after a good one", [{ text: "hi" }, null], "google response contained invalid content parts (part_not_object; partIndex=1; valueType=null)"], + ]; + + for (const [label, parts, message] of invalidPartsCases) { + test(`${label} in content.parts is a terminal stream error`, async () => { + const events = await collect(createGoogleAdapter(provider()).parseStream( + sseResponse([ + { candidates: [{ content: { parts } }] }, + { candidates: [{ finishReason: "STOP" }] }, + ]), + )); + + expect(events).toEqual([{ type: "error", message }]); + expect(events.some(event => event.type === "done")).toBe(false); + }); + + test(`${label} in content.parts is a terminal non-streaming error`, async () => { + const events = await createGoogleAdapter(provider()).parseResponse!( + new Response(JSON.stringify({ candidates: [{ content: { parts }, finishReason: "STOP" }] }), { status: 200 }), + ); + + expect(events).toEqual([{ type: "error", message }]); + expect(events.some(event => event.type === "done")).toBe(false); + }); + } + + // `content` itself has the same status as `parts`: claimed output the parser cannot read. The + // one tolerated non-record form is an empty array, which is how a JSON writer with no distinct + // empty-object form spells an empty `content`. A NON-empty array is where the payload used to + // disappear: `content?.parts` reads `undefined` from it, so the candidate completed empty. + const invalidContentCases: [string, unknown, string][] = [ + ["a number", 5, "google response contained invalid content (content_not_object; valueType=number)"], + ["a string", "txt", "google response contained invalid content (content_not_object; valueType=string)"], + ["a boolean", true, "google response contained invalid content (content_not_object; valueType=boolean)"], + ["a non-empty array holding the payload", [{ parts: [{ text: "lost" }] }], "google response contained invalid content (content_not_object; valueType=array)"], + ]; + + for (const [label, content, message] of invalidContentCases) { + test(`${label} as candidate content is a terminal stream error`, async () => { + const events = await collect(createGoogleAdapter(provider()).parseStream( + sseResponse([ + { candidates: [{ content }] }, + { candidates: [{ finishReason: "STOP" }] }, + ]), + )); + + expect(events).toEqual([{ type: "error", message }]); + expect(events.some(event => event.type === "done")).toBe(false); + }); + + test(`${label} as candidate content is a terminal non-streaming error`, async () => { + const events = await createGoogleAdapter(provider()).parseResponse!( + new Response(JSON.stringify({ candidates: [{ content, finishReason: "STOP" }] }), { status: 200 }), + ); + + expect(events).toEqual([{ type: "error", message }]); + expect(events.some(event => event.type === "done")).toBe(false); + }); + } + + test("a null candidates container mid-stream is absence, not corruption", async () => { + // The #1219 shape one rung in: before this, the frame between the content delta and the + // finish chunk terminated a turn whose answer had already fully arrived. + const events = await collect(createGoogleAdapter(provider()).parseStream( + sseResponse([ + { candidates: [{ content: { parts: [{ text: "PONG" }] } }] }, + { candidates: null }, + { candidates: [{ finishReason: "STOP" }] }, + ]), + )); + + expect(events).toContainEqual({ type: "text_delta", text: "PONG" }); + expect(events.at(-1)?.type).toBe("done"); + expect(events.some(event => event.type === "error")).toBe(false); + }); + + test("a stream of nothing but null candidates still fails closed", async () => { + // Absence is not a terminal signal, so the truncation guard still owns this stream: skipping + // the frames must not turn a stream that never finished into a successful empty turn. + const events = await collect(createGoogleAdapter(provider()).parseStream( + sseResponse([{ candidates: null }, { candidates: null }]), + )); + + expect(events.at(-1)).toEqual({ + type: "error", + message: "upstream stream ended without a terminal signal — possible truncation", + }); + expect(events.some(event => event.type === "done")).toBe(false); + }); + + test("a malformed nested candidate is a terminal non-streaming error too", async () => { + // The streaming parser has rejected these since #1332; the buffered parser returned a bare + // `done`, reporting a claimed-but-malformed candidate to the caller as a successful empty turn. + for (const [candidates, valueType] of [[[null], "null"], [[5], "number"], [["x"], "string"], [[[]], "array"]] as const) { + const events = await createGoogleAdapter(provider()).parseResponse!( + new Response(JSON.stringify({ candidates }), { status: 200 }), + ); + + expect(events).toEqual([{ + type: "error", + message: `google response contained invalid candidates (candidate_not_object; valueType=${valueType})`, + }]); + } + }); + + test("a non-array candidates container is rejected rather than counted", async () => { + // `"abc".length` is 3, so the emptiness check passed and `candidates[0]` was the character + // `"a"`; `{}` and `5` were reported as an absent candidate list rather than a malformed one. + for (const [candidates, valueType] of [["abc", "string"], [{}, "object"], [5, "number"], [true, "boolean"]] as const) { + const streamEvents = await collect(createGoogleAdapter(provider()).parseStream( + sseResponse([{ candidates }, { candidates: [{ finishReason: "STOP" }] }]), + )); + const responseEvents = await createGoogleAdapter(provider()).parseResponse!( + new Response(JSON.stringify({ candidates }), { status: 200 }), + ); + + const expected = [{ + type: "error", + message: `google response contained invalid candidates (candidates_not_array; valueType=${valueType})`, + }]; + expect(streamEvents).toEqual(expected); + expect(responseEvents).toEqual(expected); + } + }); + + test("absent, null and empty containers stay legal on both paths", async () => { + // Absence is not corruption: a finish-only chunk, an explicit `null`, and an empty array are + // all ordinary shapes and must keep completing the turn. + for (const candidate of [ + { finishReason: "STOP" }, + { content: null, finishReason: "STOP" }, + { content: [], finishReason: "STOP" }, + { content: {}, finishReason: "STOP" }, + { content: { parts: null }, finishReason: "STOP" }, + { content: { parts: [] }, finishReason: "STOP" }, + ]) { + const streamEvents = await collect(createGoogleAdapter(provider()).parseStream( + sseResponse([{ candidates: [candidate] }]), + )); + const responseEvents = await createGoogleAdapter(provider()).parseResponse!( + new Response(JSON.stringify({ candidates: [candidate] }), { status: 200 }), + ); + + expect(streamEvents.some(event => event.type === "error")).toBe(false); + expect(streamEvents.at(-1)?.type).toBe("done"); + expect(responseEvents).toEqual([{ type: "done", usage: undefined }]); + } + }); + + test("an absent candidates list is still reported as absent, not malformed", async () => { + for (const body of [{}, { candidates: null }, { candidates: [] }]) { + const events = await createGoogleAdapter(provider()).parseResponse!( + new Response(JSON.stringify(body), { status: 200 }), + ); + + expect(events).toEqual([{ type: "error", message: "google response contained no candidates" }]); + } + }); + + test("well-formed parts still stream and buffer unchanged", async () => { + const payload = { + candidates: [{ + content: { parts: [{ text: "visible" }, { functionCall: { name: "lookup", args: { q: 1 } } }] }, + finishReason: "STOP", + }], + }; + + const streamEvents = await collect(createGoogleAdapter(provider()).parseStream(sseResponse([payload]))); + const responseEvents = await createGoogleAdapter(provider()).parseResponse!( + new Response(JSON.stringify(payload), { status: 200 }), + ); + + for (const events of [streamEvents, responseEvents]) { + expect(events).toContainEqual({ type: "text_delta", text: "visible" }); + expect(events.some(event => event.type === "tool_call_start" && event.name === "lookup")).toBe(true); + expect(events).toContainEqual({ type: "tool_call_delta", arguments: JSON.stringify({ q: 1 }) }); + expect(events.at(-1)?.type).toBe("done"); + expect(events.some(event => event.type === "error")).toBe(false); + } + }); + test("EOF residual data frame without a trailing newline is parsed", async () => { const events = await collect(createGoogleAdapter(provider()).parseStream( new Response('data:{"candidates":[{"content":{"parts":[{"text":"final"}]},"finishReason":"STOP"}]}', {