Repository navigation
[Bug] Nested field shapes bypass the #1219 frame guard and crash both adapter paths #1325
Description
Client or integration
Not client-specific — reproduced by driving the adapters directly with a synthetic Response.
Area
Adapters · Streaming / SSE parsing · Non-streaming response parsing
Summary
#1219 / #1240 fixed the frame root: JSON.parse(payload) was cast to Record<string, unknown> and dereferenced, so data: null crashed the parser. The same "cast, then dereference" pattern survives one level deeper, on fields inside an otherwise well-formed frame. A frame that is a valid JSON object still crashes the adapter when a nested field has an unexpected shape.
These are found by audit, not from a live capture. Unlike data: null — which agentrouter.org demonstrably emits as padding — I have no evidence any provider sends these. Filing because the defect class and blast radius are identical to #1219 (an escaping TypeError mid-stream, which the surrounding try/catch cannot classify), and because the guards are cheap.
Verified on dev at 79831c90, with the #1240 fix already present.
| # | File | Line | Trigger | Thrown |
|---|---|---|---|---|
| A | src/adapters/google.ts |
:583 |
{"candidates":[null]} |
null is not an object (evaluating 'candidates[0].finishReason') |
| B | src/adapters/openai-chat.ts |
:1040 |
delta.tool_calls not an array |
{} is not iterable |
| C | src/adapters/openai-chat.ts |
:1041 |
delta.tool_calls: [null] |
null is not an object (evaluating 'tc.index') |
| D | src/adapters/openai-chat.ts |
:1226 |
non-stream tool_calls not an array |
{} is not iterable |
| E | src/adapters/openai-chat.ts |
:1226 |
non-stream tool_calls: [null] |
null is not an object (evaluating 'tc.id') |
| F | src/adapters/openai-chat.ts |
:1227 |
non-stream element without function |
undefined is not an object (evaluating 'tc.function.name') |
D–F are in parseResponse, not parseStream — the non-streaming path has the same defect, so a fix confined to the streaming parsers would be incomplete.
In each case the cast asserts a shape that is never checked: root.candidates as {...}[] at google.ts:580 (!candidates?.length guards the array, not its elements), and delta.tool_calls as {...}[] / msg.tool_calls as {...}[] at openai-chat.ts:1038 and :1224.
Checked and clean — not every nested site is affected. google.ts:809 reads candidates?.[0]?.content?.parts with optional chaining, so the non-streaming Google path returns a normal done for {"candidates":[null]} rather than throwing. Google's non-stream path needs no change.
Reproduction
Save as tests/zz-nested-probe.test.ts on dev and run bun scripts/test.ts tests/zz-nested-probe.test.ts, then delete it.
import { test } from "bun:test";
import { createGoogleAdapter } from "../src/adapters/google";
import { createOpenAIChatAdapter } from "../src/adapters/openai-chat";
import { withTestTranslatorBudget } from "./helpers/translator-budget";
const g = { adapter: "google", baseUrl: "https://x.test", apiKey: "k", authMode: "key" } as any;
const o = { adapter: "openai-chat", baseUrl: "https://x.test/v1", apiKey: "k", authMode: "key" } as any;
async function stream(label: string, adapter: any, body: string) {
try {
const out: string[] = [];
for await (const e of adapter.parseStream(new Response(body, { headers: { "content-type": "text/event-stream" } }))) out.push(e.type);
console.log(` ${label}: no throw -> [${out.join(",")}]`);
} catch (e) { console.log(` ${label}: THREW -> ${(e as Error).message}`); }
}
async function nonStream(label: string, adapter: any, body: unknown) {
try {
const ev = await adapter.parseResponse(new Response(JSON.stringify(body)));
console.log(` ${label}: no throw -> ${ev.map((e: any) => e.type).join(",")}`);
} catch (e) { console.log(` ${label}: THREW -> ${(e as Error).message}`); }
}
test("nested shape probe", async () => {
await stream("A google candidates:[null] ", withTestTranslatorBudget(createGoogleAdapter(g)), 'data: {"candidates":[null]}\n\n');
await stream("B openai tool_calls object ", withTestTranslatorBudget(createOpenAIChatAdapter(o)), 'data: {"choices":[{"delta":{"tool_calls":{"a":1}}}]}\n\n');
await stream("C openai tool_calls [null] ", withTestTranslatorBudget(createOpenAIChatAdapter(o)), 'data: {"choices":[{"delta":{"tool_calls":[null]}}]}\n\n');
const oa = withTestTranslatorBudget(createOpenAIChatAdapter(o));
await nonStream("D openai NS tool_calls object", oa, { choices: [{ message: { role: "assistant", tool_calls: { a: 1 } } }] });
await nonStream("E openai NS tool_calls [null]", oa, { choices: [{ message: { role: "assistant", tool_calls: [null] } }] });
await nonStream("F openai NS no .function ", oa, { choices: [{ message: { role: "assistant", tool_calls: [{ id: "x" }] } }] });
await nonStream("G google NS candidates:[null]", withTestTranslatorBudget(createGoogleAdapter(g)), { candidates: [null] });
});Logs or error output
A google candidates:[null] : THREW -> null is not an object (evaluating 'candidates[0].finishReason')
B openai tool_calls object : THREW -> {} is not iterable
C openai tool_calls [null] : THREW -> null is not an object (evaluating 'tc.index')
D openai NS tool_calls object: THREW -> {} is not iterable
E openai NS tool_calls [null]: THREW -> null is not an object (evaluating 'tc.id')
F openai NS no .function : THREW -> undefined is not an object (evaluating 'tc.function.name')
G google NS candidates:[null]: no throw -> done <- correctly guarded, no change needed
Version
dev at 79831c90 (with #1240 merged as 2f0dc7cb).
Operating system
Windows 11. Not platform-specific — the adapters are driven directly, no network and no config.
Provider and model
None. Reproduced adapter-locally for openai-chat and google.
Redacted configuration
Not applicable — synthetic Response objects, no config and no credentials.
Additional note on how to fix
Deliberately not opening a PR for this yet. The shape of the fix is a real decision, not a mechanical one, and it is yours to make:
- the
#1240precedent for the frame root was skip, do not terminate, because the frame turned out to be benign padding. For a malformedtool_callsthe safer default is probably the opposite, since silently dropping a tool call can strand a turn — but that is a judgement about which failure is worse, and I would rather not guess it into the codebase; google.ts:809shows the cheapest idiom already in use here (optional chaining), which may be all A needs.
Happy to write it once you say which way you want it, or to leave it entirely. Also happy for it to be closed as won't-fix given there is no field evidence — the audit is on the record either way.
Checks
- I searched existing issues and documentation.
- I removed secrets, tokens, account details, request credentials, and personal data.
Activity
github-actions commentedon Aug 9, 2026
Automated translation bookkeeping — detected language: English.
Ingwannu commentedon Aug 9, 2026
Maintainer reproduction on current
dev(3e5cd537) confirms all six reported shapes: Googlecandidates: [null], OpenAI Chat streamingtool_callsas a record or[null], and the three non-streamingtool_callsvariants all escape as JavaScript exceptions.I am treating the nested case differently from the root
data: nullpadding fixed in #1240. A malformed nested candidate/tool-call payload is part of a claimed response and silently skipping it could strand a tool turn. The fix will therefore fail closed through the adapters existing error representation instead of throwing:I will add focused regressions for A-F and link the implementation PR here once the focused suites and typecheck pass.
Ingwannu commentedon Aug 9, 2026
Implementation is now available in draft PR #1332, targeting dev.\n\nThe patch rejects the six reproduced malformed nested shapes through structured adapter errors while preserving benign root null padding. The rebased exact head passes typecheck and the 83 focused Google/OpenAI Chat regression and compatibility tests.\n\nThe full pre-rebase suite completed 10,130 passing tests with one unrelated local-environment failure in the Codex shim token test; that same failure reproduces in isolation and does not touch adapter code. I requested review from @lidge-jun and @Wibias. The PR remains draft and will not be self-approved or self-merged.
fix(openai-chat): refuse to emit an unnamed streamed tool call (#1531)
fix: tolerate null id/name fields in streamed tool-call fragments
fix(openai-chat): refuse to emit an unnamed streamed tool call (lidge…