@coseung2/opencodex 2.8.0-cs.13 → 2.8.0-cs.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/gui/dist/assets/index-MUpaVatk.js +67 -0
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +3 -3
  4. package/packages/ocx-notch/README.md +2 -1
  5. package/src/adapters/cursor/discovery.ts +6 -2
  6. package/src/adapters/cursor/effort-map.ts +3 -0
  7. package/src/adapters/google-antigravity-replay.ts +24 -0
  8. package/src/adapters/google.ts +16 -11
  9. package/src/chat/inbound.ts +5 -11
  10. package/src/cli/account-api.ts +9 -1
  11. package/src/cli/account-extended.ts +4 -1
  12. package/src/codex/account-label.ts +14 -1
  13. package/src/codex/account-lifecycle.ts +12 -1
  14. package/src/codex/account-namespaces.ts +21 -0
  15. package/src/codex/account-priority.ts +49 -0
  16. package/src/codex/account-store.ts +2 -1
  17. package/src/codex/auth-api.ts +108 -17
  18. package/src/codex/auth-context.ts +61 -16
  19. package/src/codex/catalog/metadata.ts +34 -12
  20. package/src/codex/catalog/parsing.ts +8 -1
  21. package/src/codex/catalog/provider-fetch.ts +24 -6
  22. package/src/codex/catalog.ts +1 -1
  23. package/src/codex/pool-rotation.ts +51 -4
  24. package/src/codex/quota.ts +154 -35
  25. package/src/codex/routing.ts +133 -33
  26. package/src/codex/warmup.ts +193 -85
  27. package/src/config.ts +84 -1
  28. package/src/lib/bounded-body.ts +13 -6
  29. package/src/lib/bun-stream-caps.ts +5 -6
  30. package/src/lib/redact.ts +13 -0
  31. package/src/oauth/index.ts +79 -12
  32. package/src/oauth/log.ts +3 -1
  33. package/src/oauth/store.ts +31 -8
  34. package/src/providers/antigravity-models.ts +53 -24
  35. package/src/providers/codex-capacity.ts +303 -0
  36. package/src/providers/model-rename-migration.ts +147 -0
  37. package/src/providers/model-rename-startup.ts +29 -0
  38. package/src/providers/quota.ts +126 -16
  39. package/src/providers/registry.ts +258 -38
  40. package/src/responses/parser.ts +19 -12
  41. package/src/responses/spill-store.ts +14 -1
  42. package/src/responses/state.ts +108 -14
  43. package/src/server/index.ts +9 -1
  44. package/src/server/management/logs-usage-routes.ts +1 -0
  45. package/src/server/management/oauth-account-routes.ts +8 -1
  46. package/src/server/relay.ts +10 -42
  47. package/src/server/request-log.ts +42 -1
  48. package/src/server/responses/compact.ts +16 -4
  49. package/src/server/responses/core.ts +217 -59
  50. package/src/server/responses/empty-completion-guard.ts +275 -0
  51. package/src/server/responses/encrypted-payload.ts +54 -39
  52. package/src/server/responses/fetch-helpers.ts +24 -3
  53. package/src/server/responses/ws-upstream.ts +318 -0
  54. package/src/server/sse-frame-buffer.ts +292 -0
  55. package/src/server/ws-bridge.ts +17 -11
  56. package/src/types.ts +8 -0
  57. package/src/usage/log.ts +24 -0
  58. package/src/usage/summary.ts +152 -2
  59. package/vendor/ocx-notch/win32-x64/ocx-notch.exe +0 -0
  60. package/gui/dist/assets/index-BucjyD4I.js +0 -67
@@ -0,0 +1,318 @@
1
+ // Upstream WebSocket transport for the ChatGPT Codex backend.
2
+ //
3
+ // Why this exists: the Codex backend serves the responses_websockets path from
4
+ // a measurably faster queue than the plain SSE POST path. Measured 2026-08-12
5
+ // KST (same account, same payload, strictly sequential): gpt-5.6-luna TTFT p50
6
+ // ~1.0s over WS vs ~3.9s over SSE. Codex CLI itself defaults to the WS
7
+ // transport; opencodex previously always POSTed SSE, which is where its extra
8
+ // 2-3s of TTFT came from.
9
+ //
10
+ // The wrapper only swaps the transport. It dials wss:// with the same headers,
11
+ // sends the JSON body as a single `response.create` frame, and re-encodes the
12
+ // returned event frames as an SSE byte stream, so every downstream consumer
13
+ // (passthrough relay, adapter parsers, usage sniffing) is unchanged.
14
+
15
+ import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer";
16
+ import { compareBunVersions } from "../../lib/bun-stream-caps";
17
+
18
+ const CODEX_RESPONSES_HTTP_URL = "https://chatgpt.com/backend-api/codex/responses";
19
+ const CODEX_RESPONSES_WS_URL = "wss://chatgpt.com/backend-api/codex/responses";
20
+ const WS_BETA = "responses_websockets=2026-02-06";
21
+ // If the 101 never arrives (network black hole), give SSE a chance well before
22
+ // the caller's connect timeout (default 200s) would fire.
23
+ const UPGRADE_DEADLINE_MS = 10_000;
24
+ // Keep the push-based WS transport inside the same memory envelope as the
25
+ // bounded SSE relays that consume this response. Unlike fetch response bodies,
26
+ // a WebSocket cannot be paused when a ReadableStream applies backpressure, so
27
+ // an upstream that outruns the consumer must be disconnected.
28
+ export const MAX_CODEX_WS_FRAME_BYTES = MAX_CLIENT_SSE_FRAME_BYTES;
29
+ export const MAX_CODEX_WS_QUEUE_BYTES = 8 * 1024 * 1024;
30
+ export const MIN_BOUNDED_CODEX_WS_BUN_VERSION = "1.4.0";
31
+
32
+ export type BunRuntimeIdentity = {
33
+ version: string;
34
+ versionWithSha: string;
35
+ };
36
+
37
+ export type BunRuntimeGateInput = string | BunRuntimeIdentity;
38
+
39
+ const codexWsUpstreamResponses = new WeakSet<Response>();
40
+
41
+ /** True only for a successful Codex WebSocket upgrade, never an HTTP fallback. */
42
+ export function isCodexWsUpstreamResponse(response: Response): boolean {
43
+ return codexWsUpstreamResponses.has(response);
44
+ }
45
+
46
+ export function currentBunRuntimeIdentity(): BunRuntimeIdentity {
47
+ return {
48
+ version: Bun.version,
49
+ versionWithSha: Bun.version_with_sha,
50
+ };
51
+ }
52
+
53
+ function boundedRelayVersion(input: BunRuntimeGateInput): string | null {
54
+ if (typeof input === "string") return input.trim() || null;
55
+ const numericVersion = input.version.trim();
56
+ const numericMatch = /^(\d+\.\d+\.\d+)$/.exec(numericVersion);
57
+ const detailedMatch = /^v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)\s+\([0-9a-fA-F]+\)$/.exec(
58
+ input.versionWithSha.trim(),
59
+ );
60
+ if (!numericMatch || !detailedMatch) return null;
61
+ const detailedNumeric = /^(\d+\.\d+\.\d+)/.exec(detailedMatch[1])?.[1];
62
+ return detailedNumeric === numericMatch[1] ? detailedMatch[1] : null;
63
+ }
64
+
65
+ /**
66
+ * Bun 1.3.14 does not propagate a stalled HTTP response socket back to a JS
67
+ * ReadableStream producer on Windows. A real raw-TCP slow-client probe drained
68
+ * the entire upstream despite the eager relay queue; Bun 1.4.0-canary.1 stopped
69
+ * below one MiB. Prereleases still fail closed; release builds before 1.4.0
70
+ * fall back to HTTP SSE.
71
+ */
72
+ export function bunSupportsBoundedCodexWsRelay(
73
+ runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(),
74
+ ): boolean {
75
+ const version = boundedRelayVersion(runtime);
76
+ if (!version) return false;
77
+ if (/^\d+\.\d+\.\d+-/.test(version.trim())) return false;
78
+ const comparison = compareBunVersions(version, MIN_BOUNDED_CODEX_WS_BUN_VERSION);
79
+ return comparison !== null && comparison >= 0;
80
+ }
81
+
82
+ export function shouldUseCodexWsUpstream(
83
+ url: string,
84
+ init?: RequestInit,
85
+ runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(),
86
+ ): boolean {
87
+ if (!bunSupportsBoundedCodexWsRelay(runtime)) return false;
88
+ if (url !== CODEX_RESPONSES_HTTP_URL) return false;
89
+ if ((init?.method ?? "GET").toUpperCase() !== "POST") return false;
90
+ const body = init?.body;
91
+ if (typeof body !== "string") return false;
92
+ // Only root-level stream:true selects WS: JSON-mode calls keep the HTTP path
93
+ // because the WS path only speaks the event protocol, and a nested
94
+ // {"metadata":{"stream":true}} must not flip the transport. Parsing (not
95
+ // substring matching) also keeps whitespace-formatted bodies routable.
96
+ try {
97
+ const parsed = JSON.parse(body) as unknown;
98
+ return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
99
+ && (parsed as Record<string, unknown>).stream === true;
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ export function codexWsUpstreamFetch(
106
+ url: string,
107
+ init: RequestInit,
108
+ sseFallback: typeof globalThis.fetch,
109
+ runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(),
110
+ ): Promise<Response> {
111
+ if (!bunSupportsBoundedCodexWsRelay(runtime)) {
112
+ return sseFallback(url, init);
113
+ }
114
+ const signal = init.signal ?? undefined;
115
+ if (signal?.aborted) {
116
+ return Promise.reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError"));
117
+ }
118
+
119
+ let frameText: string;
120
+ try {
121
+ const body = JSON.parse(init.body as string) as Record<string, unknown>;
122
+ // The WS create frame is implicitly streaming; the backend rejects the
123
+ // HTTP-only `stream` flag inside a frame.
124
+ delete body.stream;
125
+ frameText = JSON.stringify({ ...body, type: "response.create" });
126
+ } catch {
127
+ return sseFallback(url, init);
128
+ }
129
+
130
+ const headers: Record<string, string> = {};
131
+ new Headers(init.headers ?? {}).forEach((value, key) => {
132
+ // HTTP-body framing headers do not apply to a WS handshake.
133
+ if (key === "content-type" || key === "content-length" || key === "accept" || key === "accept-encoding") return;
134
+ headers[key] = value;
135
+ });
136
+ headers["openai-beta"] = headers["openai-beta"]
137
+ ? headers["openai-beta"].includes("responses_websockets")
138
+ ? headers["openai-beta"]
139
+ : `${headers["openai-beta"]}, ${WS_BETA}`
140
+ : WS_BETA;
141
+ // A genuine caller `originator` is already in these headers via the forward
142
+ // set. Never fabricate one here: pool/forward traffic must not impersonate
143
+ // Codex CLI, per the metadata-integrity contract. (The backend's fast lane
144
+ // keys on WS + originator, so callers without the tag simply keep their own
145
+ // provenance and scheduling.)
146
+
147
+ return new Promise<Response>((resolve, reject) => {
148
+ let ws: WebSocket;
149
+ try {
150
+ // Bun accepts per-handshake headers; the DOM lib types only list protocol arrays.
151
+ ws = new WebSocket(CODEX_RESPONSES_WS_URL, { headers } as unknown as string[]);
152
+ } catch {
153
+ resolve(sseFallback(url, init));
154
+ return;
155
+ }
156
+
157
+ let opened = false;
158
+ let settledPreOpen = false;
159
+ let terminal = false;
160
+ let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
161
+ const encoder = new TextEncoder();
162
+
163
+ const failStream = (message: string) => {
164
+ if (terminal) return;
165
+ terminal = true;
166
+ try { controller?.error(new Error(message)); } catch { /* stream already done */ }
167
+ try { ws.close(); } catch { /* already closing */ }
168
+ };
169
+
170
+ const upgradeTimer = setTimeout(() => {
171
+ if (opened || settledPreOpen) return;
172
+ settledPreOpen = true;
173
+ try { ws.close(); } catch { /* already closing */ }
174
+ resolve(sseFallback(url, init));
175
+ }, UPGRADE_DEADLINE_MS);
176
+
177
+ const onAbort = () => {
178
+ if (!opened) {
179
+ if (settledPreOpen) return;
180
+ // Settle BEFORE close(): the close handler treats a pre-open close as
181
+ // an upgrade rejection and would dial the SSE fallback for a request
182
+ // the caller just cancelled.
183
+ settledPreOpen = true;
184
+ clearTimeout(upgradeTimer);
185
+ try { ws.close(); } catch { /* already closing */ }
186
+ reject(signal?.reason ?? new DOMException("The operation was aborted.", "AbortError"));
187
+ return;
188
+ }
189
+ if (controller && !terminal) {
190
+ terminal = true;
191
+ // Mirror an aborted fetch: the body read rejects with the abort reason.
192
+ try { controller.error(signal?.reason ?? new DOMException("The operation was aborted.", "AbortError")); } catch { /* stream already done */ }
193
+ }
194
+ // Error the body before close(): test doubles and some runtimes dispatch
195
+ // close synchronously, and the caller's abort reason must stay authoritative.
196
+ try { ws.close(); } catch { /* already closing */ }
197
+ };
198
+ signal?.addEventListener("abort", onAbort, { once: true });
199
+
200
+ ws.addEventListener("open", () => {
201
+ if (settledPreOpen) return;
202
+ clearTimeout(upgradeTimer);
203
+ try {
204
+ ws.send(frameText);
205
+ } catch {
206
+ // send() throwing means the frame never left, so no upstream turn
207
+ // started and the SSE resend cannot double-generate. Falling back
208
+ // (instead of erroring a synthetic 200 body) keeps the pre-stream
209
+ // HTTP error/refresh/failover machinery in charge.
210
+ settledPreOpen = true;
211
+ try { ws.close(); } catch { /* already closing */ }
212
+ resolve(sseFallback(url, init));
213
+ return;
214
+ }
215
+ opened = true;
216
+ const stream = new ReadableStream<Uint8Array>({
217
+ start(c) { controller = c; },
218
+ cancel() { try { ws.close(); } catch { /* already closing */ } },
219
+ }, new ByteLengthQueuingStrategy({ highWaterMark: MAX_CODEX_WS_QUEUE_BYTES }));
220
+ const response = new Response(stream, {
221
+ status: 200,
222
+ // The 101 response headers (x-codex-*-reset-at quota hints) are not
223
+ // exposed by Bun's WebSocket; the periodic quota poller covers those.
224
+ headers: { "content-type": "text/event-stream; charset=utf-8" },
225
+ });
226
+ codexWsUpstreamResponses.add(response);
227
+ resolve(response);
228
+ });
229
+
230
+ ws.addEventListener("message", (event) => {
231
+ if (!controller || terminal) return;
232
+ const text = typeof event.data === "string" ? event.data : "";
233
+ if (!text) return;
234
+ // UTF-8 byte length is always at least the JS string length. Reject this
235
+ // cheap lower bound before parsing so an obviously oversized frame does
236
+ // not create another large object graph.
237
+ if (text.length > MAX_CODEX_WS_FRAME_BYTES) {
238
+ failStream("codex websocket frame exceeds the response size limit");
239
+ return;
240
+ }
241
+ const encodedText = encoder.encode(text);
242
+ if (encodedText.byteLength > MAX_CODEX_WS_FRAME_BYTES) {
243
+ failStream("codex websocket frame exceeds the response size limit");
244
+ return;
245
+ }
246
+ let type: unknown;
247
+ try { type = (JSON.parse(text) as { type?: unknown }).type; } catch { return; }
248
+ if (typeof type !== "string") return;
249
+ // Relay only the event surface the SSE path produces today. WS-only
250
+ // frames (codex.rate_limits, responsesapi.websocket_timing) are dropped
251
+ // so downstream clients see exactly the stream shape they always got.
252
+ if (!type.startsWith("response.") && type !== "error") return;
253
+ const prefix = encoder.encode(`event: ${type}\ndata: `);
254
+ const suffix = encoder.encode("\n\n");
255
+ const isTerminal = type === "response.completed" || type === "response.failed"
256
+ || type === "response.incomplete" || type === "error";
257
+ // The HTTP SSE transport ends with [DONE]. The WebSocket protocol does
258
+ // not send that sentinel, so append it to the terminal frame to keep
259
+ // the existing Codex-facing stream contract unchanged in this fork.
260
+ const done = isTerminal ? encoder.encode("data: [DONE]\n\n") : undefined;
261
+ const frameBytes = prefix.byteLength + encodedText.byteLength + suffix.byteLength
262
+ + (done?.byteLength ?? 0);
263
+ if (frameBytes > MAX_CLIENT_SSE_FRAME_BYTES) {
264
+ failStream("codex websocket frame exceeds the response size limit");
265
+ return;
266
+ }
267
+ const availableBytes = controller.desiredSize ?? 0;
268
+ if (frameBytes > availableBytes) {
269
+ failStream("codex websocket response exceeded the buffered queue limit");
270
+ return;
271
+ }
272
+ const sseFrame = new Uint8Array(frameBytes);
273
+ sseFrame.set(prefix);
274
+ sseFrame.set(encodedText, prefix.byteLength);
275
+ sseFrame.set(suffix, prefix.byteLength + encodedText.byteLength);
276
+ if (done) {
277
+ sseFrame.set(done, prefix.byteLength + encodedText.byteLength + suffix.byteLength);
278
+ }
279
+ try {
280
+ controller.enqueue(sseFrame);
281
+ } catch {
282
+ failStream("codex websocket response stream closed while enqueueing");
283
+ return;
284
+ }
285
+ if (isTerminal) {
286
+ terminal = true;
287
+ try { controller.close(); } catch { /* already closed */ }
288
+ try { ws.close(); } catch { /* already closing */ }
289
+ }
290
+ });
291
+
292
+ ws.addEventListener("close", () => {
293
+ signal?.removeEventListener("abort", onAbort);
294
+ if (!opened) {
295
+ if (settledPreOpen) return;
296
+ settledPreOpen = true;
297
+ clearTimeout(upgradeTimer);
298
+ // Upgrade rejected (401/403/429/5xx). Retry over plain SSE so the real
299
+ // HTTP status reaches the existing refresh/rotation handlers. No turn
300
+ // started upstream, so the resend cannot double-generate.
301
+ resolve(sseFallback(url, init));
302
+ return;
303
+ }
304
+ if (controller && !terminal) {
305
+ terminal = true;
306
+ // Connection dropped before a Responses terminal event. A clean EOF
307
+ // here would reach clients with no response.completed/failed at all —
308
+ // relaySseWithFailedTail() only synthesizes a failed terminal when the
309
+ // body read THROWS. Error the stream like a reset TCP socket.
310
+ try { controller.error(new Error("codex websocket closed before a Responses terminal event")); } catch { /* stream already done */ }
311
+ }
312
+ });
313
+
314
+ ws.addEventListener("error", () => {
315
+ /* Bun always follows error with close; the close handler settles. */
316
+ });
317
+ });
318
+ }
@@ -0,0 +1,292 @@
1
+ export const MAX_CLIENT_SSE_FRAME_BYTES = 4 * 1024 * 1024;
2
+
3
+ const LF_LF = Uint8Array.of(10, 10);
4
+ const LF_CR_LF = Uint8Array.of(10, 13, 10);
5
+ const CR_LF_LF = Uint8Array.of(13, 10, 10);
6
+ const CR_LF_CR_LF = Uint8Array.of(13, 10, 13, 10);
7
+
8
+ export class SseFrameTooLargeError extends Error {
9
+ readonly maxBytes: number;
10
+
11
+ constructor(maxBytes: number) {
12
+ super(`upstream SSE frame exceeded ${maxBytes} bytes`);
13
+ this.name = "SseFrameTooLargeError";
14
+ this.maxBytes = maxBytes;
15
+ }
16
+ }
17
+
18
+ export class SseFrameCountLimitError extends Error {
19
+ readonly maxFrames: number;
20
+
21
+ constructor(maxFrames: number) {
22
+ super(`upstream SSE chunk exceeded ${maxFrames} frame limit`);
23
+ this.name = "SseFrameCountLimitError";
24
+ this.maxFrames = maxFrames;
25
+ }
26
+ }
27
+
28
+ export type BoundedSseFrame = {
29
+ block: Uint8Array;
30
+ delimiter: Uint8Array;
31
+ };
32
+
33
+ /**
34
+ * Classify the bytes at `index` as an SSE block delimiter.
35
+ *
36
+ * Returns the delimiter length in bytes, `0` when `index` does not start a
37
+ * delimiter, and `undefined` when more bytes are required to decide.
38
+ */
39
+ function delimiterLengthAt(
40
+ index: number,
41
+ length: number,
42
+ byteAt: (index: number) => number,
43
+ ): number | undefined {
44
+ const first = byteAt(index);
45
+ if (first === 10) {
46
+ if (index + 1 >= length) return undefined;
47
+ const second = byteAt(index + 1);
48
+ if (second === 10) return 2;
49
+ if (second !== 13) return 0;
50
+ if (index + 2 >= length) return undefined;
51
+ return byteAt(index + 2) === 10 ? 3 : 0;
52
+ }
53
+ if (first !== 13) return 0;
54
+ if (index + 1 >= length) return undefined;
55
+ if (byteAt(index + 1) !== 10) return 0;
56
+ if (index + 2 >= length) return undefined;
57
+ const third = byteAt(index + 2);
58
+ if (third === 10) return 3;
59
+ if (third !== 13) return 0;
60
+ if (index + 3 >= length) return undefined;
61
+ return byteAt(index + 3) === 10 ? 4 : 0;
62
+ }
63
+
64
+ function delimiterBytesAt(
65
+ index: number,
66
+ delimiterLength: number,
67
+ byteAt: (index: number) => number,
68
+ ): Uint8Array {
69
+ if (delimiterLength === 2) return LF_LF;
70
+ if (delimiterLength === 4) return CR_LF_CR_LF;
71
+ return byteAt(index) === 10 ? LF_CR_LF : CR_LF_LF;
72
+ }
73
+
74
+ function copyRange(
75
+ start: number,
76
+ end: number,
77
+ tailLength: number,
78
+ previousTail: Uint8Array,
79
+ chunk: Uint8Array,
80
+ ): Uint8Array {
81
+ const out = new Uint8Array(end - start);
82
+ for (let index = start; index < end; index += 1) {
83
+ out[index - start] = index < tailLength
84
+ ? previousTail[index]!
85
+ : chunk[index - tailLength]!;
86
+ }
87
+ return out;
88
+ }
89
+
90
+ /**
91
+ * True when a complete SSE block already commits a Responses terminal event.
92
+ *
93
+ * Framing errors in bytes *after* such a block must not retroactively turn an
94
+ * already-completed/failed/incomplete model turn into a transport failure. This
95
+ * helper is used only on the exceptional path, so decoding/JSON parsing has no
96
+ * cost on ordinary framing.
97
+ */
98
+ function isResponsesTerminalFrame(block: Uint8Array): boolean {
99
+ const data: string[] = [];
100
+ for (const line of new TextDecoder().decode(block).split(/\r?\n/)) {
101
+ if (!line.startsWith("data:")) continue;
102
+ const value = line.slice(5);
103
+ data.push(value.startsWith(" ") ? value.slice(1) : value);
104
+ }
105
+ if (data.length === 0) return false;
106
+ const payload = data.join("\n");
107
+ if (payload === "[DONE]") return false;
108
+ try {
109
+ const parsed = JSON.parse(payload) as { type?: unknown };
110
+ return parsed.type === "response.completed"
111
+ || parsed.type === "response.failed"
112
+ || parsed.type === "response.incomplete";
113
+ } catch {
114
+ return false;
115
+ }
116
+ }
117
+
118
+ /**
119
+ * Byte-bounded SSE block framer for client-facing protocol paths.
120
+ *
121
+ * The delimiter scanner works on raw bytes, so fragmented UTF-8 cannot change
122
+ * accounting and a hostile upstream cannot grow an unterminated JS string
123
+ * without limit. Candidate bytes live in one geometrically grown buffer rather
124
+ * than one allocation per upstream chunk, bounding both bytes and object count.
125
+ * Complete blocks are returned without their delimiter; the exact delimiter
126
+ * bytes are returned separately so callers can relay bytes unchanged.
127
+ */
128
+ export class BoundedSseFrameBuffer {
129
+ private readonly maxFrameBytes: number;
130
+ private readonly maxFramesPerFeed: number;
131
+ private delimiterTail: Uint8Array = new Uint8Array(0);
132
+ private candidate: Uint8Array = new Uint8Array(0);
133
+ private candidateBytes = 0;
134
+ private disposed = false;
135
+
136
+ constructor(maxFrameBytes = MAX_CLIENT_SSE_FRAME_BYTES) {
137
+ if (!Number.isSafeInteger(maxFrameBytes) || maxFrameBytes <= 0) {
138
+ throw new RangeError("maxFrameBytes must be a positive safe integer");
139
+ }
140
+ this.maxFrameBytes = maxFrameBytes;
141
+ // Delimiter-only input otherwise creates an object-amplification path that
142
+ // is independent of candidate bytes. Keep frame count proportional to the
143
+ // configured byte budget while leaving ample room for real Responses events.
144
+ this.maxFramesPerFeed = Math.max(1, Math.ceil(maxFrameBytes / 1024));
145
+ }
146
+
147
+ private clear(): void {
148
+ this.delimiterTail = new Uint8Array(0);
149
+ this.candidate = new Uint8Array(0);
150
+ this.candidateBytes = 0;
151
+ }
152
+
153
+ private ensureCapacity(requiredBytes: number): void {
154
+ if (this.candidate.byteLength >= requiredBytes) return;
155
+ if (requiredBytes > this.maxFrameBytes) {
156
+ throw new SseFrameTooLargeError(this.maxFrameBytes);
157
+ }
158
+ let capacity = this.candidate.byteLength === 0
159
+ ? Math.min(this.maxFrameBytes, Math.max(requiredBytes, 4096))
160
+ : this.candidate.byteLength;
161
+ while (capacity < requiredBytes) {
162
+ capacity = Math.min(this.maxFrameBytes, Math.max(requiredBytes, capacity * 2));
163
+ }
164
+ const grown = new Uint8Array(capacity);
165
+ if (this.candidateBytes > 0) {
166
+ grown.set(this.candidate.subarray(0, this.candidateBytes));
167
+ }
168
+ this.candidate = grown;
169
+ }
170
+
171
+ private retain(slice: Uint8Array): void {
172
+ if (slice.byteLength === 0) return;
173
+ const nextBytes = this.candidateBytes + slice.byteLength;
174
+ if (nextBytes > this.maxFrameBytes) {
175
+ this.clear();
176
+ this.disposed = true;
177
+ throw new SseFrameTooLargeError(this.maxFrameBytes);
178
+ }
179
+ this.ensureCapacity(nextBytes);
180
+ this.candidate.set(slice, this.candidateBytes);
181
+ this.candidateBytes = nextBytes;
182
+ }
183
+
184
+ private takeCandidate(): Uint8Array {
185
+ if (this.candidateBytes === 0) return new Uint8Array(0);
186
+ const block = this.candidate.slice(0, this.candidateBytes);
187
+ // Release the working allocation after each complete frame. This avoids
188
+ // retaining a rare multi-MiB frame allocation for the rest of a long-lived
189
+ // stream; normal small-frame allocation remains bounded by feed's frame cap.
190
+ this.candidate = new Uint8Array(0);
191
+ this.candidateBytes = 0;
192
+ return block;
193
+ }
194
+
195
+ feed(chunk: Uint8Array): BoundedSseFrame[] {
196
+ if (this.disposed) return [];
197
+ if (chunk.byteLength === 0) return [];
198
+
199
+ const frames: BoundedSseFrame[] = [];
200
+ const previousTail = this.delimiterTail;
201
+ this.delimiterTail = new Uint8Array(0);
202
+ const tailLength = previousTail.byteLength;
203
+ const totalLength = tailLength + chunk.byteLength;
204
+ const byteAt = (index: number): number => index < tailLength
205
+ ? previousTail[index]!
206
+ : chunk[index - tailLength]!;
207
+ const retainRange = (start: number, end: number): void => {
208
+ if (end <= start) return;
209
+ if (start < tailLength) {
210
+ this.retain(previousTail.subarray(start, Math.min(end, tailLength)));
211
+ }
212
+ if (end > tailLength) {
213
+ this.retain(chunk.subarray(Math.max(0, start - tailLength), end - tailLength));
214
+ }
215
+ };
216
+
217
+ try {
218
+ let index = 0;
219
+ let retainedThrough = 0;
220
+ while (index < totalLength) {
221
+ const delimiterLength = delimiterLengthAt(index, totalLength, byteAt);
222
+ if (delimiterLength === undefined) break;
223
+ if (delimiterLength > 0) {
224
+ if (frames.length >= this.maxFramesPerFeed) {
225
+ this.clear();
226
+ this.disposed = true;
227
+ throw new SseFrameCountLimitError(this.maxFramesPerFeed);
228
+ }
229
+ retainRange(retainedThrough, index);
230
+ const block = this.takeCandidate();
231
+ const delimiter = delimiterBytesAt(index, delimiterLength, byteAt);
232
+ frames.push({ block, delimiter });
233
+ index += delimiterLength;
234
+ retainedThrough = index;
235
+ continue;
236
+ }
237
+ index += 1;
238
+ }
239
+
240
+ retainRange(retainedThrough, index);
241
+ if (index < totalLength) {
242
+ this.delimiterTail = copyRange(index, totalLength, tailLength, previousTail, chunk);
243
+ }
244
+ return frames;
245
+ } catch (err) {
246
+ const framingError = err instanceof SseFrameTooLargeError
247
+ || err instanceof SseFrameCountLimitError;
248
+ if (framingError && frames.some(frame => isResponsesTerminalFrame(frame.block))) {
249
+ // A terminal frame is the Responses protocol boundary. Ignore malformed
250
+ // or oversized bytes that occur later in the same upstream chunk rather
251
+ // than retroactively replacing the committed terminal with a 502.
252
+ this.clear();
253
+ this.disposed = true;
254
+ return frames;
255
+ }
256
+ throw err;
257
+ }
258
+ }
259
+
260
+ /** Return the final unterminated block bytes and release all retained state. */
261
+ finish(): Uint8Array {
262
+ if (this.disposed) return new Uint8Array(0);
263
+ try {
264
+ this.retain(this.delimiterTail);
265
+ this.delimiterTail = new Uint8Array(0);
266
+ return this.takeCandidate();
267
+ } finally {
268
+ this.clear();
269
+ this.disposed = true;
270
+ }
271
+ }
272
+
273
+ dispose(): void {
274
+ if (this.disposed) return;
275
+ this.clear();
276
+ this.disposed = true;
277
+ }
278
+ }
279
+
280
+ export function joinSseFrameBytes(parts: readonly Uint8Array[]): Uint8Array {
281
+ let byteLength = 0;
282
+ for (const part of parts) byteLength += part.byteLength;
283
+ if (byteLength === 0) return new Uint8Array(0);
284
+ if (parts.length === 1 && parts[0]!.byteLength === byteLength) return parts[0]!;
285
+ const joined = new Uint8Array(byteLength);
286
+ let offset = 0;
287
+ for (const part of parts) {
288
+ joined.set(part, offset);
289
+ offset += part.byteLength;
290
+ }
291
+ return joined;
292
+ }
@@ -5,6 +5,7 @@ import { headersForCodexAuthContext } from "../codex/auth-context";
5
5
  import type { ResponsesTerminalStatus } from "../bridge";
6
6
  import type { DataPlaneAdmission } from "./auth-cors";
7
7
  import type { AdmissionReservation } from "../lib/admission";
8
+ import { BoundedSseFrameBuffer } from "./sse-frame-buffer";
8
9
 
9
10
  const OPEN = 1;
10
11
  type ResponsesTerminalReporter = (status: ResponsesTerminalStatus) => void;
@@ -224,7 +225,7 @@ export async function pumpResponsesSseToWebSocket(
224
225
  ws.data.cancel = cancel;
225
226
 
226
227
  const decoder = new TextDecoder();
227
- let buffer = "";
228
+ const framer = new BoundedSseFrameBuffer();
228
229
  let terminalSeen = false;
229
230
 
230
231
  const handlePayload = (payload: string): boolean => {
@@ -259,17 +260,14 @@ export async function pumpResponsesSseToWebSocket(
259
260
  while (!terminalSeen) {
260
261
  const { done, value } = await reader.read();
261
262
  if (done) break;
262
- buffer += decoder.decode(value, { stream: true });
263
- let next: { block: string; rest: string } | null;
264
- while ((next = nextSseBlock(buffer))) {
265
- buffer = next.rest;
266
- const payload = parseSseBlock(next.block);
263
+ for (const frame of framer.feed(value)) {
264
+ const payload = parseSseBlock(decoder.decode(frame.block));
267
265
  if (payload && handlePayload(payload)) break;
268
266
  }
269
267
  }
270
- buffer += decoder.decode();
271
- if (!terminalSeen && buffer.trim()) {
272
- const payload = parseSseBlock(buffer);
268
+ const tail = framer.finish();
269
+ if (!terminalSeen && tail.byteLength > 0) {
270
+ const payload = parseSseBlock(decoder.decode(tail));
273
271
  if (payload) handlePayload(payload);
274
272
  }
275
273
  if (!terminalSeen && isCurrent() && !clientCancelled) {
@@ -277,11 +275,19 @@ export async function pumpResponsesSseToWebSocket(
277
275
  sendProtocolError(ws, 502, "Upstream stream ended before response terminal event");
278
276
  }
279
277
  } catch (err) {
278
+ framer.dispose();
279
+ if (err instanceof WsSendDroppedError) throw err;
280
280
  if (!terminalSeen && isCurrent() && ws.readyState === OPEN) {
281
- if (!(err instanceof WsSendDroppedError)) reportTerminal("incomplete");
282
- sendProtocolError(ws, 502, err instanceof Error ? err.message : String(err));
281
+ reportTerminal("incomplete");
282
+ try {
283
+ sendProtocolError(ws, 502, err instanceof Error ? err.message : String(err));
284
+ } catch (sendErr) {
285
+ if (!(sendErr instanceof WsSendDroppedError)) throw sendErr;
286
+ }
283
287
  }
284
288
  } finally {
289
+ framer.dispose();
290
+ void reader.cancel().catch(() => {});
285
291
  if (ws.data.cancel === cancel) ws.data.cancel = undefined;
286
292
  }
287
293
  }