Skip to content

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

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-5adapter: 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.

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