Skip to content

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

Closed

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 containers — candidates, 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

  1. added
    bugSomething isn't working
    toolstool_calls, MCP, web-search / sidecar tools
    on Aug 20, 2026
  2. github-actions commented on Aug 20, 2026

    @github-actions
    Contributor

    Issue reopened

    The report now contains the information required by the automated check. Thanks for updating it.

  3. lidge-jun commented on Aug 20, 2026

    @lidge-jun
    Owner

    리뷰 · 우선순위 58 / 80

    컨테이너는 #2231/#2232 범위고 이건 그 안쪽임. 지금 dev googlePartTextEvent (src/adapters/google.ts:368-372)가 if (!part.text)만 봄. 숫자 5, 객체, 배열은 truthy라 text_delta.text에 비문자열이 들어감. thought: true면 reasoning_raw_delta. 스트림 :736-748 / 버퍼 :964-974는 if (part.functionCall)만. 문자열/숫자/배열이 truthy라 .name이 undefined인 tool_call_start가 브릿지로 감. AdapterEvent는 name/text를 string으로 선언함. 본문 14케이스 로그가 그 경로랑 맞음. throw는 없음. 조용히 계약이 깨짐.

    openai-chat은 이미 거절함. unnamedToolCallEvent (src/adapters/openai-chat.ts:359-364) 메시지가 cannot dispatch. diagnoseInvalidToolCalls가 tool_call_function_name_invalid / _blank. 구글은 툴콜을 한 파트에 통째로 줌. 조립 창이 없어서 거절은 더 쉽고, 드롭도 가능함. 패스스루는 아님. 이름 없는 콜이 브릿지로 가면 잘못된 툴이 실행될 수 있음. ㅋㅋ 그게 제일 큼.

    저자가 PR을 안 올린 게 맞음. 정책 콜임. terminate / drop / coerce. coerce는 비문자열 text를 이어붙이면 쓰레기 생김. 본문이 functionCall.args는 JSON.stringify가 이미 버티니까 건들지 말라는 것도 맞음. #2216 프리폼이랑 섞지 말 것.

    1st-party Google이 이 셰이프를 안 낸다는 말도 일리 있음. 노출은 호환 baseUrl / 이상한 CCA 엔벨로프. 그래서 점수는 컨테이너 구멍(#2231)보다 낮음. 그래도 브릿지 계약이라 won't-fix로 바로 닫기엔 이름 없는 툴콜이 큼. 텍스트 비문자열은 drop이 안전함.

    #2232에 접지 말 것. 그 PR은 컨테이너만이고 체크리스트 4/4. 여기 정책을 거기 넣으면 리뷰가 다시 열림. types.ts/config.ts 스플릿, #2188, #2190 x_search, #2217/#2227 와이어 기본 무관. 2.28 블로커 아님.

    해결방안: 툴 이름은 openai-chat처럼 터미널 에러. 비문자열 text는 파트 drop (coerce 금지). args는 그대로. 정책 확정 전에 PR 올리지 말 것. #2232 머지 후에 후속.

    이 댓글은 grok-bot이 작성했습니다

  4. Ingwannu commented on Aug 21, 2026

    @Ingwannu
    Owner

    Confirmed and implemented in #2246 on exact dev@826a1b7a4fa79483ca56bc4d868a8095b4c07394, following the owner Grok policy exactly.

    • malformed or blank Google function-call identity is now a terminal structured error before replay observation or dispatch
    • non-string optional text is dropped without coercion
    • structured functionCall.args remains unchanged
    • streaming and buffered paths share the same contract

    Verification: Google hardening 78/78, adjacent Google suites 186/186, typecheck and privacy scan passed. I requested review from @lidge-jun and @Wibias. The PR remains draft and I will not self-approve or self-merge; #2233 stays open until an independent maintainer accepts and lands the fix.

  5. lidge-jun commented on Aug 21, 2026

    @lidge-jun
    Owner

    Fixed on dev via #2246 (rebased onto the current head, google suites 86/0 + tsc + privacy green): a present functionCall must be an object with a nonblank string name — otherwise the turn terminates with a structured adapter error before thought-signature observation or tool dispatch; non-string part.text is dropped without coercion; functionCall.args passes through unchanged. Ships in 2.28.0.

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