Repository navigation
[Bug] Google adapter: non-conforming part fields cross the AdapterEvent boundary (nameless tool calls, non-string text) #2233
Description
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:
- A
functionCallthat is not a record ("x",5,[]) reachespart.functionCall.nameas
undefinedand the adapter emits atool_call_startwith no name. - A
functionCall.namethat is absent, non-string, empty, or whitespace is passed through
verbatim —name: undefined,name: 5,name: "",name: " "all become a dispatched tool
call. - A non-string
part.textbecomestext_delta.textas 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
functionCall.args. Google's wire type for arguments is a structured value, not OpenAI's JSON
string, andJSON.stringify(part.functionCall.args ?? {})already tolerates every shape without
losing information. Constraining it would be importing an OpenAI-shaped assumption, not fixing a
defect. [WRONG BRANCH] fix(zcode): use openai-compatible provider protocol to restore prefix caching #2216-era freeform-argument work touches this area, so I left it entirely alone.- Container shapes —
partsnot an array, an element that is not an object,contentnot a
record, a non-record candidate. Those are [Bug] Google adapter: candidate, content and parts containers escape the #1332 nested-shape rule #2231, fixed in fix(google): validate candidate, content and part containers (#2231) #2232. part.textthat is an empty string. Already falsy, already skipped, already correct.
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
github-actions commentedon Aug 20, 2026
Issue reopened
The report now contains the information required by the automated check. Thanks for updating it.
lidge-jun commentedon Aug 20, 2026
리뷰 · 우선순위 58 / 80
컨테이너는 #2231/#2232 범위고 이건 그 안쪽임. 지금
devgooglePartTextEvent(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이 작성했습니다
Ingwannu commentedon Aug 21, 2026
Confirmed and implemented in #2246 on exact
dev@826a1b7a4fa79483ca56bc4d868a8095b4c07394, following the owner Grok policy exactly.functionCall.argsremains unchangedVerification: 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.
lidge-jun commentedon Aug 21, 2026
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.