Skip to content

[Bug]: SSE frame that parses to null escapes the malformed-frame guard and crashes all three stream adapters #1219

Closed

Description

@brunoflma

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, google and anthropic, 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

  1. github-actions commented on Aug 7, 2026

    @github-actions
    Contributor

    Automated translation bookkeeping — detected language: English.

  2. added
    bugSomething isn't working
    streamingSSE, WebSocket, terminal stream frames
    toolstool_calls, MCP, web-search / sidecar tools
    on Aug 7, 2026
  3. brunoflma commented on Aug 7, 2026

    @brunoflma
    Author

    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):

    data: {"id":"msg_…","object":"chat.completion.chunk","model":"claude-opus-5","choices":[{"delta":{"content":"P"},"finish_reason":null,"index":0}],"usage":null}
    
    data: {"id":"msg_…","object":"chat.completion.chunk","model":"claude-opus-5","choices":[{"delta":{"content":"ONG"},"finish_reason":null,"index":0}],"usage":null}
    
    data: null
    
    data: {"id":"msg_…","object":"chat.completion.chunk","model":"claude-opus-5","choices":[{"delta":{},"finish_reason":"stop","index":0}],"usage":null}
    
    data: [DONE]
    

    So data: null is not a corrupt or truncated stream. It is a benign padding frame in the middle of a healthy response, and the legitimate finish_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-sol through 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:

    FAILED  5320ms  status=200  AGR-OAI/claude-opus-5  -> "PONG"
        ERR {"message":"malformed upstream SSE data frame","type":"invalid_request_error"}
    

    Text PONG present, response failed. That is strictly better than the TypeError crash — 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.ts and google.ts, the non-record branch should be return "continue", not a terminal error:

    let parsedFrame: unknown;
    try {
      parsedFrame = JSON.parse(payload);
    } catch {
      yield { type: "error", message: "malformed upstream SSE data frame" };
      return "terminate";
    }
    // A non-record frame carries nothing this adapter can consume. Skip it: terminating here
    // would discard a finish_reason chunk and [DONE] that may still be in flight.
    if (parsedFrame === null || typeof parsedFrame !== "object" || Array.isArray(parsedFrame)) {
      return "continue";
    }
    const chunk = parsedFrame as Record<string, unknown>;

    Two notes on the shape of this:

    • anthropic.ts already had the right instinct — its catch does debugDroppedFrame(...) + continue. It only needs the shape check added alongside, keeping the same skip semantics. The divergence between the three adapters is what made openai-chat/google terminate where anthropic would have recovered.
    • In google.ts, leave sawAnyFrame = true after 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:

    [openai-chat]
      data-null : events = [{"type":"error","message":"upstream stream ended without a terminal signal ([DONE] or finish_reason) — possible truncation"}]
    [google]
      data-null : events = [{"type":"heartbeat"},{"type":"error","message":"upstream stream ended without a terminal signal — possible truncation"}]
    [anthropic]
      data-null : events = [{"type":"error","message":"upstream stream ended before message_stop — possible truncation"}]
    

    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):

    Route one-word probe multi-token probe
    AGR/gpt-5.6-sol OK 2.1s 2, 3, 5, 7, 11 / DONE
    AGR/claude-opus-5 OK 4.0s 2, 3, 5, 7, 11 / DONE
    AGR/claude-opus-4-8 OK 3.4s 2, 3, 5, 7, 11 / DONE
    AGR-OAI/gpt-5.6-sol OK 2.2s 2, 3, 5, 7, 11 / DONE
    AGR-OAI/claude-opus-5 OK 7.8s 2, 3, 5, 7, 11 / DONE
    AGR-OAI/claude-opus-4-8 OK 3.6s 2, 3, 5, 7, 11 / DONE

    (AGR = adapter: anthropic against https://agentrouter.org; AGR-OAI = adapter: openai-chat against https://agentrouter.org/v1. Same key, same models, only the wire format differs.)

    All six complete with response.completed and correct content. Before the shape check, the three AGR-OAI rows crashed with the TypeError; 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 the catch, and no server-side log line when it fires. Only the recommended remedy changes: skip, don't terminate.

  4. added 3 commits that reference this issue on Aug 7, 2026
    b01a623
    8585263
    f155138
  5. Ingwannu commented on Aug 8, 2026

    @Ingwannu
    Owner

    Confirmed as a real adapter bug, and the follow-up capture materially changes the correct fix.

    The production-safe contract is:

    • valid JSON that is not a record (null, scalar, or array) must never be dereferenced;
    • OpenAI Chat and Google must skip such padding frames and keep reading, because the captured healthy stream places data: null before its real finish_reason and [DONE];
    • Anthropic and the web-search parser should keep their existing tolerant/drop behavior;
    • a stream made only of skipped frames must still fail through the existing empty/truncation guard.

    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-dev rebase before approval.

    I am leaving this issue open until the corrected cross-adapter behavior lands on dev.

  6. added a commit that references this issue on Aug 8, 2026
    965dd99
  7. added a commit that references this issue on Aug 8, 2026
  8. lidge-jun commented on Aug 8, 2026

    @lidge-jun
    Owner

    Fixed on dev by #1240 (merge 2f0dc7cb6).

    All four SSE parsers now reject a non-record frame before dereferencing it, and a data: null between 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.

    main does not carry this yet — it ships in the next release.

  9. added 3 commits that reference this issue on Sep 17, 2026
    7b34d08
    553a452
    c9714d2
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 workingstreamingSSE, WebSocket, terminal stream framestoolstool_calls, MCP, web-search / sidecar tools

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions