Skip to content

[Bug] Google adapter: non-conforming part fields cross the AdapterEvent boundary (nameless tool calls, non-string text) #2233

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

This is the piece I deliberately kept out of #2231 / #2232, filed on its own because the fix shape
is a policy call rather than a parity fix, and I would rather leave that call to you than guess it.

#2232 validates the containerscandidates, content, content.parts. Inside a well-formed
part, the values the adapter actually emits are still unchecked, so three shapes cross the
AdapterEvent boundary as something other than what the type declares:

  1. A functionCall that is not a record ("x", 5, []) reaches part.functionCall.name as
    undefined and the adapter emits a tool_call_start with no name.
  2. A functionCall.name that is absent, non-string, empty, or whitespace is passed through
    verbatim — name: undefined, name: 5, name: "", name: " " all become a dispatched tool
    call.
  3. A non-string part.text becomes text_delta.text as a number, object or array — and with
    thought: true, reasoning_raw_delta.text.

AdapterEvent declares both text and name as string. src/adapters/openai-chat.ts now
rejects the equivalent shapes (diagnoseInvalidToolCalls reasons
tool_call_function_name_invalid / _blank, plus unnamedToolCallEvent, whose message is
explicit: "upstream streamed a tool call without a function name — cannot dispatch"). The Google
adapter has no equivalent.

Why this is not simply "port the openai-chat rule". OpenAI Chat assembles a tool call across
streamed deltas, so "the name never arrived by flush time" is a well-defined terminal condition and
tests/openai-chat-parallel-stream.test.ts had to be re-negotiated around it. Google delivers a
tool call whole, in one part — there is no assembly window and no "it may still arrive". That
makes the rejection easier to justify here, but it also means the openai-chat precedent is not
automatically the right disposition, and a nameless call could plausibly be dropped rather than made
terminal. Which of terminate / drop / pass-through you want is your call, so there is no PR.

Found by audit, not from a live capture — same bar as #1325 and #2231. Verified on dev@03735eca.

# parts element Emitted on dev@03735eca
A { functionCall: "x" } tool_call_start(name=undefined), tool_call_delta("{}"), tool_call_end, done
B { functionCall: 5 } same
C { functionCall: [] } same
D { functionCall: "x" }, buffered same
E { functionCall: { args: {} } } (no name) tool_call_start(name=undefined)
F { functionCall: { name: 5, args: {} } } tool_call_start(name=5)
G { functionCall: { name: "", args: {} } } tool_call_start(name="")
H { functionCall: { name: " ", args: {} } } tool_call_start(name=" ")
I { functionCall: { name: 5, args: {} } }, buffered tool_call_start(name=5)
J { text: 5 } text_delta(text=5)
K { text: { a: 1 } } text_delta(text={"a":1})
L { text: [1, 2] } text_delta(text=[1,2])
M { text: 5, thought: true } reasoning_raw_delta(text=5)
N { text: 5 }, buffered text_delta(text=5)

Nothing throws — that is the point. These are all quiet: a nameless or numerically-named tool call
goes on to the bridge, and a numeric text_delta flows into whatever concatenates it downstream.

Deliberately excluded

Reproduction

Save as tests/zz-google-field-probe.test.ts on dev, run
bun scripts/test.ts tests/zz-google-field-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));

function show(e: any) {
  if (e.type === "error") return `error(${e.message})`;
  if (e.type === "tool_call_start") return `tool_call_start(name=${JSON.stringify(e.name)})`;
  if (e.type === "text_delta") return `text_delta(text=${JSON.stringify(e.text)})`;
  if (e.type === "reasoning_raw_delta") return `reasoning_raw_delta(text=${JSON.stringify(e.text)})`;
  if (e.type === "tool_call_delta") return `tool_call_delta(${JSON.stringify(e.arguments)})`;
  return e.type;
}

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

async function buffered(label: string, parts: unknown) {
  try {
    const ev = await adapter().parseResponse!(new Response(JSON.stringify({ candidates: [{ content: { parts }, finishReason: "STOP" }] })));
    console.log(`  ${label}: [${ev.map(show).join(",")}]`);
  } catch (e) { console.log(`  ${label}: THREW -> ${(e as Error).message}`); }
}

test("google part field probe", async () => {
  console.log("-- functionCall container --");
  await stream("A  fc:\"x\"            ", [{ functionCall: "x" }]);
  await stream("B  fc:5               ", [{ functionCall: 5 }]);
  await stream("C  fc:[]              ", [{ functionCall: [] }]);
  await buffered("D  fc:\"x\" (buffered) ", [{ functionCall: "x" }]);
  console.log("-- functionCall.name --");
  await stream("E  name absent        ", [{ functionCall: { args: {} } }]);
  await stream("F  name:5             ", [{ functionCall: { name: 5, args: {} } }]);
  await stream("G  name:\"\"            ", [{ functionCall: { name: "", args: {} } }]);
  await stream("H  name:\"   \"         ", [{ functionCall: { name: "   ", args: {} } }]);
  await buffered("I  name:5 (buffered)  ", [{ functionCall: { name: 5, args: {} } }]);
  console.log("-- part.text --");
  await stream("J  text:5             ", [{ text: 5 }]);
  await stream("K  text:{a:1}         ", [{ text: { a: 1 } }]);
  await stream("L  text:[1,2]         ", [{ text: [1, 2] }]);
  await stream("M  text:5, thought    ", [{ text: 5, thought: true }]);
  await buffered("N  text:5 (buffered)  ", [{ text: 5 }]);
  console.log("-- control: well-formed --");
  await stream("O  good               ", [{ text: "hi" }, { functionCall: { name: "lookup", args: { q: 1 } } }]);
});

Logs or error output

-- functionCall container --
  A  fc:"x"            : [tool_call_start(name=undefined),tool_call_delta("{}"),tool_call_end,done]
  B  fc:5               : [tool_call_start(name=undefined),tool_call_delta("{}"),tool_call_end,done]
  C  fc:[]              : [tool_call_start(name=undefined),tool_call_delta("{}"),tool_call_end,done]
  D  fc:"x" (buffered) : [tool_call_start(name=undefined),tool_call_delta("{}"),tool_call_end,done]
-- functionCall.name --
  E  name absent        : [tool_call_start(name=undefined),tool_call_delta("{}"),tool_call_end,done]
  F  name:5             : [tool_call_start(name=5),tool_call_delta("{}"),tool_call_end,done]
  G  name:""            : [tool_call_start(name=""),tool_call_delta("{}"),tool_call_end,done]
  H  name:"   "         : [tool_call_start(name="   "),tool_call_delta("{}"),tool_call_end,done]
  I  name:5 (buffered)  : [tool_call_start(name=5),tool_call_delta("{}"),tool_call_end,done]
-- part.text --
  J  text:5             : [text_delta(text=5),done]
  K  text:{a:1}         : [text_delta(text={"a":1}),done]
  L  text:[1,2]         : [text_delta(text=[1,2]),done]
  M  text:5, thought    : [reasoning_raw_delta(text=5),done]
  N  text:5 (buffered)  : [text_delta(text=5),done]
-- control: well-formed --
  O  good               : [text_delta(text="hi"),tool_call_start(name="lookup"),tool_call_delta("{\"q\":1}"),tool_call_end,done]

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

No PR, on purpose. If you tell me which disposition you want — terminal structured error like
openai-chat, silent drop of the offending part, or coerce-and-continue — I will write it with
regressions for all fourteen shapes on both paths. It is also perfectly reasonable to close this as
won't-fix: no first-party Google backend produces these, and the exposure is a Gemini-compatible
third-party baseUrl or a non-Google CCA response envelope.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingtoolstool_calls, MCP, web-search / sidecar tools

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions