Repository navigation
[Bug]: SSE frame that parses to null escapes the malformed-frame guard and crashes all three stream adapters #1219
Description
Client or integration
Claude Code through the OpenCodex proxy (streaming /v1/responses), web-search enabled.
Area
Adapters · Streaming / SSE parsing · Web-search loop
Summary
All three stream adapters cast JSON.parse(payload) to Record<string, unknown> and immediately dereference it. JSON.parse("null") does not throw — it returns null — so the surrounding try/catch never fires and the next property access crashes the adapter mid-stream.
Live symptom, on AGR-OAI/claude-opus-5 (adapter: openai-chat, https://agentrouter.org/v1) with web-search on:
stream disconnected before completion: Web-search adapter stream protocol error:
adapter threw: null is not an object (evaluating 'chunk.error')
The Web-search adapter prefix is misleading — web-search/progress-stream.ts:316 only wraps whatever the adapter threw. The defect is in the adapters and reaches any streamed request; the web-search loop merely makes it frequent, because that path is where I see repeated upstream instability ([upstream-retry] connection reset (web-search-loop) — retrying (2/3), 8 occurrences in service.log).
Affected call sites — same defect, three files:
| File | Unguarded parse | Crashing access | Thrown message |
|---|---|---|---|
src/adapters/openai-chat.ts |
:941 |
chunk.error :950 |
null is not an object (evaluating 'chunk.error') |
src/adapters/google.ts |
:495 |
chunk.error :503 |
null is not an object (evaluating 'chunk.error') |
src/adapters/anthropic.ts |
:989 |
data.type :995 |
null is not an object (evaluating 'data.type') |
Each already has a catch that emits malformed upstream SSE data frame (or drops the frame, in anthropic.ts). That guard covers syntactically invalid payloads only; a syntactically valid payload deserializing to null walks straight past it.
Reproduction
No network and no config changes — the adapters are driven directly with a synthetic Response. Save as repro-null-chunk.ts and run with the bundled Bun (node_modules/@bitkyc08/opencodex/node_modules/bun/bin/bun.exe repro-null-chunk.ts); adjust PKG to your install path.
const PKG = "<...>/node_modules/@bitkyc08/opencodex/src";
const { createOpenAIChatAdapter } = await import(`${PKG}/adapters/openai-chat.ts`);
const { createGoogleAdapter } = await import(`${PKG}/adapters/google.ts`);
const { createAnthropicAdapter } = await import(`${PKG}/adapters/anthropic.ts`);
const { createTranslatorBudget } = await import(`${PKG}/lib/translator-budget.ts`);
const providerFor = (adapter: string) => ({
adapter, baseUrl: "https://example.invalid/v1", authMode: "key", apiKey: "sk-test",
}) as any;
const ADAPTERS: Record<string, () => any> = {
"openai-chat": () => createOpenAIChatAdapter(providerFor("openai-chat")),
"google ": () => createGoogleAdapter(providerFor("google")),
"anthropic ": () => createAnthropicAdapter(providerFor("anthropic")),
};
async function run(name: string, label: string, sse: string) {
const res = new Response(sse, { status: 200, headers: { "content-type": "text/event-stream" } });
const events: unknown[] = [];
try {
for await (const ev of ADAPTERS[name]().parseStream(res, createTranslatorBudget())) events.push(ev);
console.log(` ${label}: no throw, events = ${JSON.stringify(events)}`);
} catch (e) {
console.log(` ${label}: THREW -> ${e instanceof Error ? e.message : String(e)}`);
}
}
for (const name of Object.keys(ADAPTERS)) {
console.log(`[${name.trim()}]`);
await run(name, "invalid-json", 'data: {not json\n\n'); // control — handled correctly
await run(name, "data-null ", "data: null\n\n"); // bug
}Actual output on v2.10.2:
[openai-chat]
invalid-json: no throw, events = [{"type":"error","message":"malformed upstream SSE data frame"}]
data-null : THREW -> null is not an object (evaluating 'chunk.error')
[google]
invalid-json: no throw, events = [{"type":"error","message":"malformed upstream SSE data frame"}]
data-null : THREW -> null is not an object (evaluating 'chunk.error')
[anthropic]
invalid-json: no throw, events = [{"type":"error","message":"upstream stream ended before message_stop — possible truncation"}]
data-null : THREW -> null is not an object (evaluating 'data.type')
The invalid-json control passing on every adapter is the point: the existing guard works, it just does not cover this input class. Expected for data-null is the same terminal malformed upstream SSE data frame event, not an escaping TypeError.
Proposed patch
Validate the parse result instead of asserting it. For openai-chat.ts (google.ts is identical; anthropic.ts wants continue + debugDroppedFrame to match its existing handling):
let parsed: unknown;
try {
parsed = JSON.parse(payload);
} catch {
yield { type: "error", message: "malformed upstream SSE data frame" };
return "terminate";
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
yield { type: "error", message: "malformed upstream SSE data frame" };
return "terminate";
}
const chunk = parsed as Record<string, unknown>;null is the case seen in the wild, but the same cast also lets data: 42, data: "x", data: true and data: [] through as non-records, so the guard is worth writing against the shape rather than against null alone.
Version
2.10.2 (npm @bitkyc08/opencodex), bundled Bun runtime.
Operating system
Windows 11, Codex runtime 0.146.1 under the OpenCodex proxy.
Provider and model
- Observed:
AGR-OAI/claude-opus-5—adapter: openai-chat,baseUrl: https://agentrouter.org/v1, streaming, web-search enabled. - Reproduced adapter-locally for
openai-chat,googleandanthropic, so it is not provider-specific.
Additional note — the failure leaves no server-side trace
The error surfaces to the client, but service.log contains no matching line: chunk.error, adapter threw and stream protocol all return zero hits after the failure. Without the client-side message there is nothing to diagnose from. A debugProviderDiagnostic on the malformed-frame path would help here regardless of the fix.
Checks
- I searched existing issues and documentation.
- I removed secrets, tokens, account details, request credentials, and personal data.
Activity
github-actions commentedon Aug 7, 2026
Automated translation bookkeeping — detected language: English.
brunoflma commentedon Aug 7, 2026
Correcting my own proposed patch —
return "terminate"is wrong for the case that actually occurs in the wild, and I have the upstream capture now.The real frame
I captured the raw SSE from the provider that triggered this (
https://agentrouter.org/v1, OpenAI-format route,claude-opus-5, with the client headers the route requires):So
data: nullis not a corrupt or truncated stream. It is a benign padding frame in the middle of a healthy response, and the legitimatefinish_reason: "stop"chunk and[DONE]arrive right after it.Worth noting for classification: on this same route the frame appears only when serving Claude models.
gpt-5.6-solthrough the identical endpoint never emits it.Why the patch I proposed is wrong
Terminating on that frame throws away the finish chunk and
[DONE]. The content has already been fully received, and the request still fails. With my original patch applied I got exactly that:Text
PONGpresent, response failed. That is strictly better than theTypeErrorcrash — it is classified and does not escape the adapter — but it is still a failure where the upstream did nothing wrong.Corrected patch
Skip the frame instead. For
openai-chat.tsandgoogle.ts, the non-record branch should bereturn "continue", not a terminal error:Two notes on the shape of this:
anthropic.tsalready had the right instinct — itscatchdoesdebugDroppedFrame(...)+continue. It only needs the shape check added alongside, keeping the same skip semantics. The divergence between the three adapters is what madeopenai-chat/googleterminate whereanthropicwould have recovered.google.ts, leavesawAnyFrame = trueafter the guard, so skipped frames do not count. A stream consisting only of null frames then still reports as empty rather than as a silent success.Skipping does not weaken the truncation detection, because that is enforced separately by the terminal-signal check. Verified against the same harness from the issue body — a stream containing only
data: null:A genuinely truncated stream is still caught and still classified honestly; only the benign mid-stream frame is now tolerated.
End-to-end verification
Six routes on the affected provider, streaming, two rounds each (a one-word probe and a multi-token one to catch mid-stream truncation):
AGR/gpt-5.6-sol2, 3, 5, 7, 11 / DONEAGR/claude-opus-52, 3, 5, 7, 11 / DONEAGR/claude-opus-4-82, 3, 5, 7, 11 / DONEAGR-OAI/gpt-5.6-sol2, 3, 5, 7, 11 / DONEAGR-OAI/claude-opus-52, 3, 5, 7, 11 / DONEAGR-OAI/claude-opus-4-82, 3, 5, 7, 11 / DONE(
AGR=adapter: anthropicagainsthttps://agentrouter.org;AGR-OAI=adapter: openai-chatagainsthttps://agentrouter.org/v1. Same key, same models, only the wire format differs.)All six complete with
response.completedand correct content. Before the shape check, the threeAGR-OAIrows crashed with theTypeError; with the terminate version they failed cleanly; with the skip version they pass.The everything-else in the issue body stands — three unguarded call sites,
JSON.parse("null")walking past thecatch, and no server-side log line when it fires. Only the recommended remedy changes: skip, don't terminate.fix(sse): treat a data frame that parses to a non-record as malformed
fix(sse): treat a data frame that parses to a non-record as malformed
fix(sse): treat a data frame that parses to a non-record as malformed
Ingwannu commentedon Aug 8, 2026
Confirmed as a real adapter bug, and the follow-up capture materially changes the correct fix.
The production-safe contract is:
null, scalar, or array) must never be dereferenced;data: nullbefore its realfinish_reasonand[DONE];I reviewed #1240 and requested those exact semantic/test changes; its current OpenAI/Google branches terminate on the padding frame and would still fail the reporter’s real stream. #1249 is a separate, valid empty-
data:heartbeat fix; its code passed focused review but needs a current-devrebase before approval.I am leaving this issue open until the corrected cross-adapter behavior lands on
dev.fix(sse): treat a data frame that parses to a non-record as malformed
Merge pull request #1240 from snowyukitty/fix/adapters-null-sse-frame
lidge-jun commentedon Aug 8, 2026
Fixed on
devby #1240 (merge2f0dc7cb6).All four SSE parsers now reject a non-record frame before dereferencing it, and a
data: nullbetween valid chunks is skipped rather than terminating the stream — so the finish chunk and[DONE]that follow are still delivered. An all-non-record stream still fails closed.Thanks for the report, and for the follow-up correction that the null frame appears mid-stream; that detail is what made terminating the wrong fix.
maindoes not carry this yet — it ships in the next release.fix(google): validate candidate, content and part containers
fix(sse): treat a data frame that parses to a non-record as malformed
Merge pull request lidge-jun#1240 from snowyukitty/fix/adapters-null-…
fix(google): validate candidate, content and part containers