Skip to content

[Bug] Google adapter: candidate, content and parts containers escape the #1332 nested-shape rule #2231

Description

@snowyukitty

Client or integration

Not client-specific — reproduced by driving the adapter directly with a synthetic Response.

Area

Provider adapter · Streaming / SSE parsing · Non-streaming response parsing

Summary

#1332 established the rule that a malformed nested payload inside a well-formed frame is a
claimed response, not padding, and must fail closed through the adapter's structured error channel.
It applied that rule to the streaming Google candidate and to OpenAI Chat tool calls on both
paths. Three containers in src/adapters/google.ts were left behind, and one of them is a site my
own report in #1325 explicitly cleared.

1. content.parts is consumed unchecked on both paths. candidate.content?.parts is cast to
GoogleResponsePart[] and iterated directly, so a container that is not an array of objects either
throws a raw TypeError out of the adapter or is consumed as nothing. parts: "txt" is the
nastiest of these: a string is iterable, so it is walked character by character and the turn
completes empty.

2. candidate.content is never inspected at all. content?.parts reads undefined from any
non-record, so a candidate shaped content: [{ parts: [{ text: "…" }] }] — a plausible shape from
anything that mirrors the request's repeated contents — silently drops its own text and reports a
successful empty turn.

3. The buffered path never got #1332's candidate guard. parseResponse checks
!candidates?.length and then reads candidates[0] without a record check, so a
claimed-but-malformed candidate becomes a bare done. The streaming path returns a terminal
google response contained invalid candidates for the same input.

Correction to my own earlier report. #1325 listed google.ts:809 as "checked and clean —
Google's non-stream path needs no change", on the grounds that it returns a normal done for
{"candidates":[null]} instead of throwing. That was true about the throw and wrong about the
conclusion: it returns a normal done because it silently drops the candidate. That is the exact
outcome #1332's rationale names — "silently skipping it could strand a tool turn" — so it is the
same defect, not an exemption from it.

These are found by audit, not from a live capture; same bar as #1325, which was accepted on shape
reasoning plus reproductions. Verified on dev at 03735eca, with #1240, #1249, #1266 and #1332
all present.

# Path Trigger (inside a well-formed frame) Observed on dev@03735eca
A stream candidates[0].content.parts is {} TypeError: {} is not iterable
B stream parts is [null] TypeError: null is not an object (evaluating 'part.thoughtSignature')
C stream parts is 5 TypeError: number is not iterable
D stream parts is "txt" no error — the string is iterated by character, turn completes empty
E stream parts is [5] no error — the element is skipped, turn completes empty
F buffered parts is {} TypeError: {} is not iterable
G buffered parts is [null] TypeError: null is not an object (evaluating 'part.thoughtSignature')
H buffered parts is 5 TypeError: number is not iterable
I buffered parts is "txt" no error — turn completes empty
J buffered parts is [5] no error — turn completes empty
K buffered candidates is [null] [done] — streaming returns invalid candidates for the same input
L buffered candidates is [5] [done]
M buffered candidates is "abc" [done]"abc".length is 3, so the emptiness check passes and candidates[0] is the character "a"
N stream content is [{ parts: [{ text: "lost" }] }] [heartbeat,done] — the claimed text is dropped and the turn succeeds
O buffered same as N [done]
P buffered content is "txt" [done]

A–C and F–H throw out of the adapter. The buffered call site (src/server/responses/core.ts, the
parseResponse branch) wraps the call in try { … } finally with no catch, so the
TypeError leaves the turn unstructured rather than becoming an adapter error event.

D, E, I, J and K–P are the quieter half: the request is reported as a successful, empty turn. N and
O are the ones where a complete answer actually arrived and was discarded.

Where

  • src/adapters/google.ts — streaming: const parts = candidate.content?.parts as GoogleResponsePart[] | undefined;
    then for (const part of parts). The candidate guard above it (fix(adapters): reject malformed nested response shapes #1332) stops one level short of
    this, and nothing looks at content itself.
  • src/adapters/google.ts — buffered: const candidates = json.candidates as {...}[] | undefined;
    if (!candidates?.length)candidates[0].content.parts. Neither json.candidates being a
    non-array nor candidates[0] being a non-record is checked.

Checked and clean — not every site is affected

  • observeAntigravityReplay (src/adapters/google-antigravity-replay.ts) does not crash on any of
    these: it early-returns on !Array.isArray(parts) and skips elements failing
    !raw || typeof raw !== "object". Note that this predicate does not exclude arrays, so it is
    crash-safe for the shapes above rather than a strict record check — and its disposition is skip,
    which is the fix(sse): treat a data frame that parses to a non-record as malformed (#1219) #1240 padding rule, not the fix(adapters): reject malformed nested response shapes #1332 claimed-content rule. It is the right guard shape
    to copy and the wrong disposition to copy.
  • Buffered candidates: {} and candidates: 5 already return a structured error
    (google response contained no candidates), because .length is undefined. The message
    misclassifies them as absent rather than malformed, but nothing crashes.
  • Absence is genuinely fine everywhere and must stay that way: absent content, content: null,
    content: [], content: {}, parts: null, parts: [], candidates: null and candidates: []
    all behave sensibly today. content: [] is worth calling out — it is how a JSON writer with no
    distinct empty-object form spells an empty content, and it already means "no parts".
  • The root-frame padding behaviour from fix(sse): treat a data frame that parses to a non-record as malformed (#1219) #1240 (data: null between deltas) is a different rung and
    is not involved in any of the above.

Reproduction

Save as tests/zz-google-shape-probe.test.ts on dev, run
bun scripts/test.ts tests/zz-google-shape-probe.test.ts, then delete it.

import { test } from "bun:test";
import { createGoogleAdapter } from "../src/adapters/google";
import { withTestTranslatorBudget } from "./helpers/translator-budget";

const g = { adapter: "google", baseUrl: "https://x.test", apiKey: "k", authMode: "key" } as any;
const adapter = () => withTestTranslatorBudget(createGoogleAdapter(g));

async function stream(label: string, chunk: unknown) {
  try {
    const out: string[] = [];
    const body = `data: ${JSON.stringify(chunk)}\n\ndata: {"candidates":[{"finishReason":"STOP"}]}\n\n`;
    for await (const e of adapter().parseStream(new Response(body, { headers: { "content-type": "text/event-stream" } }))) {
      out.push(e.type === "error" ? `error(${(e as any).message})` : e.type);
    }
    console.log(`  ${label}: no throw -> [${out.join(",")}]`);
  } catch (e) { console.log(`  ${label}: THREW -> ${(e as Error).message}`); }
}

async function buffered(label: string, body: unknown) {
  try {
    const ev = await adapter().parseResponse!(new Response(JSON.stringify(body)));
    console.log(`  ${label}: no throw -> [${ev.map((e: any) => e.type === "error" ? `error(${e.message})` : e.type).join(",")}]`);
  } catch (e) { console.log(`  ${label}: THREW -> ${(e as Error).message}`); }
}

test("google response shape probe", async () => {
  console.log("-- content.parts, streaming --");
  await stream("A parts:{}          ", { candidates: [{ content: { parts: {} } }] });
  await stream("B parts:[null]      ", { candidates: [{ content: { parts: [null] } }] });
  await stream("C parts:5           ", { candidates: [{ content: { parts: 5 } }] });
  await stream("D parts:\"txt\"       ", { candidates: [{ content: { parts: "txt" } }] });
  await stream("E parts:[5]         ", { candidates: [{ content: { parts: [5] } }] });
  console.log("-- content.parts, buffered --");
  await buffered("F parts:{}          ", { candidates: [{ content: { parts: {} } }] });
  await buffered("G parts:[null]      ", { candidates: [{ content: { parts: [null] } }] });
  await buffered("H parts:5           ", { candidates: [{ content: { parts: 5 } }] });
  await buffered("I parts:\"txt\"       ", { candidates: [{ content: { parts: "txt" } }] });
  await buffered("J parts:[5]         ", { candidates: [{ content: { parts: [5] } }] });
  console.log("-- candidates, buffered vs streaming --");
  await stream("K candidates:[null] ", { candidates: [null] });
  await buffered("L candidates:[null] ", { candidates: [null] });
  await buffered("M candidates:[5]    ", { candidates: [5] });
  await buffered("N candidates:\"abc\"  ", { candidates: "abc" });
  await buffered("O candidates:{}     ", { candidates: {} });
  console.log("-- candidate.content container --");
  await stream("P content:[{parts}] ", { candidates: [{ content: [{ parts: [{ text: "lost" }] }] }] });
  await buffered("Q content:[{parts}] ", { candidates: [{ content: [{ parts: [{ text: "lost" }] }], finishReason: "STOP" }] });
  await buffered("R content:\"txt\"     ", { candidates: [{ content: "txt", finishReason: "STOP" }] });
  console.log("-- shapes that must stay legal --");
  await buffered("S parts:[] empty    ", { candidates: [{ content: { parts: [] }, finishReason: "STOP" }] });
  await buffered("T content:null      ", { candidates: [{ content: null, finishReason: "STOP" }] });
  await buffered("U candidates:[]     ", { candidates: [] });
});

Logs or error output

-- content.parts, streaming --
  A parts:{}          : THREW -> {} is not iterable
  B parts:[null]      : THREW -> null is not an object (evaluating 'part.thoughtSignature')
  C parts:5           : THREW -> number is not iterable
  D parts:"txt"       : no throw -> [heartbeat,done]
  E parts:[5]         : no throw -> [heartbeat,done]
-- content.parts, buffered --
  F parts:{}          : THREW -> {} is not iterable
  G parts:[null]      : THREW -> null is not an object (evaluating 'part.thoughtSignature')
  H parts:5           : THREW -> number is not iterable
  I parts:"txt"       : no throw -> [done]
  J parts:[5]         : no throw -> [done]
-- candidates, buffered vs streaming --
  K candidates:[null] : no throw -> [error(google response contained invalid candidates)]
  L candidates:[null] : no throw -> [done]
  M candidates:[5]    : no throw -> [done]
  N candidates:"abc"  : no throw -> [done]
  O candidates:{}     : no throw -> [error(google response contained no candidates)]
-- candidate.content container --
  P content:[{parts}] : no throw -> [heartbeat,done]
  Q content:[{parts}] : no throw -> [done]
  R content:"txt"     : no throw -> [done]
-- shapes that must stay legal --
  S parts:[] empty    : no throw -> [done]
  T content:null      : no throw -> [done]
  U candidates:[]     : no throw -> [error(google response contained no candidates)]

(The probe's letters run A–U; the table above uses its own A–P for the defective cases only.)

Version

dev at 03735eca (verified), Bun 1.3.14, Windows 11.

Operating system

Windows 11 Pro 24H2 (10.0.26200). Reproduced through the repository's own isolated test runner
(bun scripts/test.ts) against the adapter directly, so the defect is platform-independent — the
parser control flow is the same on every platform.

Notes

Published Gemini/Vertex/Antigravity responses type Content.parts as Part[], so none of these
shapes should come from a first-party backend. The exposure is a Gemini-compatible third-party
baseUrl, or a CCA response envelope produced by something other than Google.

A PR follows that applies the #1332 rule to all three containers. There is a separate question
I am deliberately not folding into it: fields inside a well-formed part are still untyped on the
way out — a functionCall that is not a record, or one with a missing, blank or non-string name,
currently produces a tool_call_start with name: undefined, and a non-string part.text reaches
text_delta as a number or object. OpenAI Chat now rejects the equivalent shapes via
diagnoseInvalidToolCalls / unnamedToolCallEvent, but Google assembles tool calls per-part rather
than incrementally, so whether the same disposition is correct here is a policy call rather than a
parity fix. I will file that separately with its own reproductions unless you would rather it rode
along.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingstreamingSSE, WebSocket, terminal stream framestoolstool_calls, MCP, web-search / sidecar tools

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions