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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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