Skip to content

Commit 5880e0a

Browse files
fix(openai-chatgpt-responses): bound streaming success-body SSE reads at 16 MiB (#96762)
* fix(openai-chatgpt-responses): bound streaming success-body SSE reads at 16 MiB Apply createSseByteGuard to src/llm/providers/openai-chatgpt-responses.ts (parseSSE) so the ChatGPT Responses SSE parser cannot be exhausted by a hostile or malfunctioning endpoint that streams an unbounded SSE body. The 16 MiB cap matches the non-streaming readProviderJsonResponse cap. What changed: - src/llm/providers/openai-chatgpt-responses.ts: wrap body.getReader() in createSseByteGuard before the existing parseSSE loop. The function is also re-exported as parseSSEForTest for direct test access. - Inline test added to openai-chatgpt-responses.test.ts: hostile 1 MiB pull stream, asserts canonical overflow message, cancel(reason) was an Error instance, pullCount bounded to 17-20. Reuses the createSseByteGuard helper from src/agents/streaming-byte-guard.ts (introduced in #96701 for the anthropic-transport-stream path; same helper, same 16 MiB cap, same overflow-error shape). This PR is the third of the planned per-surface rescue series for the previously closed PR #96666, after PR #3b (#96723). No SDK surface change. No repro script committed (proof in this body). * fix(openai): bound ChatGPT error body reads * fix(openai): bound chatgpt error parsing --------- Co-authored-by: Vincent Koc <[email protected]>
1 parent 65fec9d commit 5880e0a

3 files changed

Lines changed: 248 additions & 4 deletions

File tree

src/agents/streaming-byte-guard.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/**
2+
* Bounded SSE / NDJSON stream reader guard.
3+
*
4+
* Wraps a `ReadableStreamDefaultReader<Uint8Array>` so the caller's existing
5+
* chunk-by-chunk parsing logic is unchanged, but accumulated bytes are tracked
6+
* against a hard cap. On overflow the underlying reader is cancelled and a
7+
* canonical error is thrown. Mirrors the `readResponseWithLimit` / bounded
8+
* JSON response pattern (see `src/agents/provider-http-errors.ts`).
9+
*
10+
* Internal helper for now. If extensions need it, promote to a plugin-SDK
11+
* subpath in a separate, dedicated PR with full SDK metadata sync.
12+
*/
13+
14+
export type SseStreamOverflow = {
15+
size: number;
16+
maxBytes: number;
17+
};
18+
19+
export type ReadSseStreamWithLimitOptions = {
20+
maxBytes: number;
21+
onOverflow?: (params: SseStreamOverflow) => Error;
22+
};
23+
24+
export type SseByteGuard = {
25+
read(): Promise<ReadableStreamReadResult<Uint8Array>>;
26+
cancel(reason?: unknown): Promise<void>;
27+
totalBytes(): number;
28+
overflowed(): boolean;
29+
cancelled(): boolean;
30+
};
31+
32+
export function createSseByteGuard(
33+
reader: ReadableStreamDefaultReader<Uint8Array>,
34+
opts: ReadSseStreamWithLimitOptions,
35+
): SseByteGuard {
36+
if (!Number.isFinite(opts.maxBytes) || opts.maxBytes < 0) {
37+
throw new RangeError(`maxBytes must be a non-negative finite number: ${opts.maxBytes}`);
38+
}
39+
const onOverflow =
40+
opts.onOverflow ??
41+
((params) =>
42+
new Error(`SSE stream exceeds ${params.maxBytes} bytes (received ${params.size})`));
43+
let total = 0;
44+
let overflowedFlag = false;
45+
let cancelledFlag = false;
46+
return {
47+
read: async () => {
48+
if (overflowedFlag || cancelledFlag) {
49+
return { done: true, value: undefined };
50+
}
51+
const result = await reader.read();
52+
if (result.done) {
53+
return result;
54+
}
55+
const chunkLen = result.value?.byteLength ?? 0;
56+
const next = total + chunkLen;
57+
if (next > opts.maxBytes) {
58+
overflowedFlag = true;
59+
cancelledFlag = true;
60+
const err = onOverflow({ size: next, maxBytes: opts.maxBytes });
61+
try {
62+
await reader.cancel(err);
63+
} catch {
64+
// best-effort cancellation; caller observes the overflow error
65+
}
66+
throw err;
67+
}
68+
total = next;
69+
return result;
70+
},
71+
cancel: async (reason?: unknown) => {
72+
if (overflowedFlag) {
73+
// overflow already set cancelledFlag; do not overwrite
74+
return;
75+
}
76+
cancelledFlag = true;
77+
try {
78+
await reader.cancel(reason);
79+
} catch {
80+
// best-effort cancellation
81+
}
82+
},
83+
totalBytes: () => total,
84+
overflowed: () => overflowedFlag,
85+
cancelled: () => cancelledFlag,
86+
};
87+
}

src/llm/providers/openai-chatgpt-responses.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { SYSTEM_PROMPT_CACHE_BOUNDARY } from "../../agents/system-prompt-cache-b
55
import type { Context, Model } from "../types.js";
66
import {
77
extractOpenAICodexAccountId,
8+
parseSSEForTest,
89
resetOpenAICodexWebSocketDebugStats,
910
streamOpenAICodexResponses,
1011
} from "./openai-chatgpt-responses.js";
@@ -560,4 +561,95 @@ describe("streamOpenAICodexResponses transport", () => {
560561
expect(fetchMock).toHaveBeenCalledTimes(2);
561562
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
562563
});
564+
565+
it("bounds non-OK ChatGPT response bodies before formatting API errors", async () => {
566+
const chunkSize = 1024 * 1024;
567+
const totalChunks = 32;
568+
const chunk = new TextEncoder()
569+
.encode("usage limit ".repeat(Math.ceil(chunkSize / "usage limit ".length)))
570+
.subarray(0, chunkSize);
571+
let pullCount = 0;
572+
let canceled = false;
573+
const overflowing = new ReadableStream<Uint8Array>({
574+
pull(controller) {
575+
pullCount += 1;
576+
if (pullCount > totalChunks) {
577+
controller.close();
578+
return;
579+
}
580+
controller.enqueue(chunk);
581+
},
582+
cancel() {
583+
canceled = true;
584+
},
585+
});
586+
const fetchMock = vi.fn<typeof fetch>().mockResolvedValueOnce(
587+
new Response(overflowing, {
588+
status: 400,
589+
statusText: "Bad Request",
590+
}),
591+
);
592+
vi.stubGlobal("fetch", fetchMock);
593+
594+
const stream = streamOpenAICodexResponses(model, context, {
595+
apiKey: createJwt({
596+
"https://api.openai.com/auth": {
597+
chatgpt_account_id: "acct-1",
598+
},
599+
}),
600+
transport: "sse",
601+
});
602+
603+
const result = await stream.result();
604+
605+
expect(result.stopReason).toBe("error");
606+
expect(result.errorMessage).toContain("usage limit");
607+
expect(result.errorMessage?.length).toBeLessThanOrEqual(16 * 1024);
608+
expect(canceled).toBe(true);
609+
expect(pullCount).toBeGreaterThanOrEqual(1);
610+
expect(pullCount).toBeLessThanOrEqual(3);
611+
expect(fetchMock).toHaveBeenCalledTimes(1);
612+
});
613+
});
614+
615+
describe("parseSSEForTest", () => {
616+
it("bounds streamed OpenAI ChatGPT Responses success bodies without content-length", async () => {
617+
// 1 MiB chunks; cap is 16 MiB so the bounded reader cancels well before
618+
// draining the full 32 MiB advertised body.
619+
const CHUNK = 1024 * 1024;
620+
const TOTAL = 32;
621+
let pullCount = 0;
622+
let cancelReason: unknown;
623+
const overflowing = new ReadableStream<Uint8Array>({
624+
pull(controller) {
625+
pullCount += 1;
626+
if (pullCount > TOTAL) {
627+
controller.close();
628+
return;
629+
}
630+
controller.enqueue(new Uint8Array(CHUNK));
631+
},
632+
cancel(reason) {
633+
cancelReason = reason;
634+
},
635+
});
636+
let caught: Error | null = null;
637+
try {
638+
// parseSSE expects a Response-like; pass the streaming body directly
639+
// through a minimal Response shim that only exposes .body.
640+
const response = { body: overflowing } as unknown as Response;
641+
for await (const event of parseSSEForTest(response)) {
642+
expect(event).toBeDefined();
643+
}
644+
} catch (err) {
645+
caught = err as Error;
646+
}
647+
expect(caught?.message).toMatch(
648+
/OpenAI ChatGPT Responses success body exceeded 16777216 bytes/,
649+
);
650+
expect(cancelReason).toBeInstanceOf(Error);
651+
// 16 MiB + a couple of overshoot pulls, well under 32.
652+
expect(pullCount).toBeGreaterThanOrEqual(17);
653+
expect(pullCount).toBeLessThanOrEqual(20);
654+
});
563655
});

src/llm/providers/openai-chatgpt-responses.ts

Lines changed: 69 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
resolveTimerTimeoutMs,
2626
clampTimerTimeoutMs,
2727
} from "@openclaw/normalization-core/number-coercion";
28+
import { createSseByteGuard } from "../../agents/streaming-byte-guard.js";
2829
import { stripSystemPromptCacheBoundary } from "../../agents/system-prompt-cache-boundary.js";
2930
import { getEnvApiKey } from "../env-api-keys.js";
3031
import { clampThinkingLevel } from "../model-utils.js";
@@ -66,6 +67,8 @@ const RETRY_AFTER_HTTP_DATE_RE =
6667
/^(?:(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun), \d{2} (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) \d{4} \d{2}:\d{2}:\d{2} GMT|(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), \d{2}-(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)-\d{2} \d{2}:\d{2}:\d{2} GMT|(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun) (?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec) [ \d]\d \d{2}:\d{2}:\d{2} \d{4})$/;
6768
const CODEX_TOOL_CALL_PROVIDERS = new Set(["openai", "opencode"]);
6869
const WEBSOCKET_MESSAGE_TOO_BIG_CLOSE_CODE = 1009;
70+
const OPENAI_CHATGPT_RESPONSES_ERROR_BODY_MAX_BYTES = 16 * 1024;
71+
const OPENAI_CHATGPT_RESPONSES_SUCCESS_BODY_MAX_BYTES = 16 * 1024 * 1024;
6972

7073
const CODEX_RESPONSE_STATUSES = new Set<CodexResponseStatus>([
7174
"completed",
@@ -339,7 +342,7 @@ export const streamOpenAICodexResponses: StreamFunction<
339342
break;
340343
}
341344

342-
const errorText = await response.text();
345+
const errorText = await readChatGptResponsesErrorTextLimited(response);
343346
if (attempt < MAX_RETRIES && isRetryableError(response.status, errorText)) {
344347
let delayMs = BASE_DELAY_MS * 2 ** attempt;
345348

@@ -722,12 +725,23 @@ async function* parseSSE(response: Response): AsyncGenerator<Record<string, unkn
722725
}
723726

724727
const reader = response.body.getReader();
728+
// Cap the streaming 200 success-body read at 16 MiB, mirroring the
729+
// non-streaming `readProviderJsonResponse` cap so a hostile or
730+
// malfunctioning ChatGPT Responses endpoint cannot exhaust memory by
731+
// streaming an unbounded SSE body.
732+
const guard = createSseByteGuard(reader, {
733+
maxBytes: OPENAI_CHATGPT_RESPONSES_SUCCESS_BODY_MAX_BYTES,
734+
onOverflow: ({ size, maxBytes }) =>
735+
new Error(
736+
`OpenAI ChatGPT Responses success body exceeded ${maxBytes} bytes (received ${size})`,
737+
),
738+
});
725739
const decoder = new TextDecoder();
726740
let buffer = "";
727741

728742
try {
729743
while (true) {
730-
const { done, value } = await reader.read();
744+
const { done, value } = await guard.read();
731745
if (done) {
732746
break;
733747
}
@@ -760,14 +774,18 @@ async function* parseSSE(response: Response): AsyncGenerator<Record<string, unkn
760774
}
761775
} finally {
762776
try {
763-
await reader.cancel();
777+
await guard.cancel();
764778
} catch {}
765779
try {
766780
reader.releaseLock();
767781
} catch {}
768782
}
769783
}
770784

785+
// Test-only re-export of the bounded SSE parser. Mirrors
786+
// `parseAnthropicSseBodyForTest` / `iterateSseMessagesForTest` patterns.
787+
export const parseSSEForTest = parseSSE;
788+
771789
// ============================================================================
772790
// WebSocket Parsing
773791
// ============================================================================
@@ -1521,10 +1539,57 @@ async function processWebSocketStream(
15211539
// Error Handling
15221540
// ============================================================================
15231541

1542+
async function readChatGptResponsesErrorTextLimited(response: Response): Promise<string> {
1543+
const reader = response.body?.getReader();
1544+
if (!reader) {
1545+
return "";
1546+
}
1547+
1548+
const decoder = new TextDecoder();
1549+
let total = 0;
1550+
let text = "";
1551+
let reachedLimit = false;
1552+
1553+
try {
1554+
while (true) {
1555+
const { value, done } = await reader.read();
1556+
if (done) {
1557+
break;
1558+
}
1559+
if (!value || value.byteLength === 0) {
1560+
continue;
1561+
}
1562+
const remaining = OPENAI_CHATGPT_RESPONSES_ERROR_BODY_MAX_BYTES - total;
1563+
if (remaining <= 0) {
1564+
reachedLimit = true;
1565+
break;
1566+
}
1567+
const chunk = value.byteLength > remaining ? value.subarray(0, remaining) : value;
1568+
total += chunk.byteLength;
1569+
text += decoder.decode(chunk, { stream: true });
1570+
if (total >= OPENAI_CHATGPT_RESPONSES_ERROR_BODY_MAX_BYTES) {
1571+
reachedLimit = true;
1572+
break;
1573+
}
1574+
}
1575+
text += decoder.decode();
1576+
} finally {
1577+
if (reachedLimit) {
1578+
// This provider module is browser-safe, so keep error-body capping on Web APIs.
1579+
await reader.cancel().catch(() => {});
1580+
}
1581+
try {
1582+
reader.releaseLock();
1583+
} catch {}
1584+
}
1585+
1586+
return text;
1587+
}
1588+
15241589
async function parseErrorResponse(
15251590
response: Response,
15261591
): Promise<{ message: string; friendlyMessage?: string }> {
1527-
const raw = await response.text();
1592+
const raw = await readChatGptResponsesErrorTextLimited(response);
15281593
let message = raw || response.statusText || "Request failed";
15291594
let friendlyMessage: string | undefined;
15301595

0 commit comments

Comments
 (0)