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.

Activity

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