@bitkyc08/opencodex 2.17.0 → 2.18.2

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 (37) hide show
  1. package/gui/dist/assets/{index-DOKr6RBR.js → index-CXI1262_.js} +1 -1
  2. package/gui/dist/index.html +1 -1
  3. package/package.json +1 -1
  4. package/src/adapters/client-fingerprint.ts +2 -2
  5. package/src/adapters/cursor/live-transport.ts +17 -5
  6. package/src/adapters/cursor/protobuf-events.ts +662 -20
  7. package/src/adapters/cursor/tool-definitions.ts +12 -6
  8. package/src/adapters/google.ts +21 -2
  9. package/src/bridge.ts +12 -2
  10. package/src/cli/index.ts +11 -0
  11. package/src/codex/app-server-processes.ts +3 -3
  12. package/src/codex/user-identity.ts +36 -6
  13. package/src/config.ts +0 -2
  14. package/src/generated/compatibility-version.json +37 -33
  15. package/src/lib/token-estimate.ts +19 -2
  16. package/src/lib/windows-elevation.ts +37 -0
  17. package/src/lib/windows-secret-acl.ts +7 -0
  18. package/src/lib/windows-text.ts +106 -0
  19. package/src/lib/windows-user-principal.ts +0 -2
  20. package/src/oauth/index.ts +1 -1
  21. package/src/oauth/store.ts +32 -18
  22. package/src/providers/antigravity-models.ts +25 -5
  23. package/src/providers/free-directory.ts +1 -1
  24. package/src/providers/registry.ts +4 -3
  25. package/src/server/index.ts +5 -1
  26. package/src/server/management/logs-usage-routes.ts +7 -22
  27. package/src/server/request-log.ts +48 -3
  28. package/src/server/responses/core.ts +38 -13
  29. package/src/server/responses/encrypted-payload.ts +58 -38
  30. package/src/server/responses/fetch-helpers.ts +12 -4
  31. package/src/server/responses/policy-fallback.ts +13 -2
  32. package/src/server/responses/ws-upstream.ts +115 -6
  33. package/src/service-manager-probe.ts +21 -34
  34. package/src/service.ts +233 -25
  35. package/src/tray/windows.ts +0 -2
  36. package/src/update/job.ts +2 -2
  37. package/src/usage/summary.ts +21 -4
@@ -190,6 +190,7 @@ import {
190
190
  } from "../responses-terminal-repair";
191
191
  import { isWin32EagerRewrite, selectEagerPath } from "../../lib/bun-stream-caps";
192
192
  import { cancelBodyOnAbort } from "../../lib/abort";
193
+ import { isCodexWsUpstreamResponse, type BunRuntimeGateInput } from "./ws-upstream";
193
194
  import {
194
195
  createResponsesItemIdPayloadRewrite,
195
196
  hasResponsesItemIdRepair,
@@ -418,6 +419,7 @@ interface CodexPoolAccountRetryArgs {
418
419
  // needs the inbound scope or the retry could land on a different wire than the
419
420
  // first attempt.
420
421
  inboundWire?: InboundWire;
422
+ codexWsRuntimeIdentity?: BunRuntimeGateInput;
421
423
  translatorBudget: TranslatorBudget;
422
424
  turnAdmissionLease?: AdmissionLease;
423
425
  };
@@ -590,7 +592,7 @@ async function retryCodexPoolOnAlternateAccount(
590
592
  upstream.signal,
591
593
  connectMs,
592
594
  stream,
593
- providerFetch(route.provider),
595
+ providerFetch(route.provider, options.codexWsRuntimeIdentity),
594
596
  // Credential-bearing forward send: never follow a redirect into a
595
597
  // dead-host rejection after the credential was seen (#914).
596
598
  route.provider.authMode === "forward",
@@ -719,6 +721,8 @@ export interface ConsumedComboFailure {
719
721
 
720
722
  export interface HandleResponsesOptions {
721
723
  turnAdmissionLease?: AdmissionLease;
724
+ /** Called at most once after the complete client body is read and accepted for dispatch. */
725
+ onRequestBodyRead?: () => void;
722
726
  forceEmptyResponseId?: boolean;
723
727
  abortSignal?: AbortSignal;
724
728
  /** One-shot TTFT callback: first non-empty model output observed (WP4). */
@@ -730,6 +734,8 @@ export interface HandleResponsesOptions {
730
734
  onNativePassthroughCancel?: () => void;
731
735
  /** Internal deterministic clock/timer seam for provider terminal repair. */
732
736
  responsesTerminalRepairScheduler?: ResponsesTerminalRepairScheduler;
737
+ /** Internal deterministic runtime-identity seam for Codex upstream WS selection tests. */
738
+ codexWsRuntimeIdentity?: BunRuntimeGateInput;
733
739
  /**
734
740
  * When true, body `prompt_cache_key` is a Claude Desktop shared cache cohort
735
741
  * (system/tools hash), not a per-session id — do not use it for Anthropic pool affinity.
@@ -1495,11 +1501,20 @@ async function handleResponsesInner(
1495
1501
  try {
1496
1502
  body = await readJsonRequestBody(req, translatorBudget);
1497
1503
  } catch (err) {
1504
+ if (options.abortSignal?.aborted || req.signal.aborted) {
1505
+ return clientCancelledResponse();
1506
+ }
1498
1507
  return decodeRequestErrorResponse(err, "responses");
1499
1508
  }
1500
1509
  const comboId = !options.comboAttempt ? comboIdFromRawBody(body, config) : null;
1501
1510
  if (comboId && Object.hasOwn(config.combos ?? {}, comboId)) {
1502
- return handleComboResponses(req, body, comboId, config, logCtx, options);
1511
+ options.onRequestBodyRead?.();
1512
+ return handleComboResponses(req, body, comboId, config, logCtx, {
1513
+ ...options,
1514
+ // The original request body was accepted above. Combo children are synthetic
1515
+ // replays and must not repeat the caller-owned timeout transition.
1516
+ onRequestBodyRead: undefined,
1517
+ });
1503
1518
  }
1504
1519
  let unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask(
1505
1520
  (body as { input?: unknown } | undefined)?.input,
@@ -1555,6 +1570,7 @@ async function handleResponsesInner(
1555
1570
  }
1556
1571
  return formatErrorResponse(400, "invalid_request_error", err instanceof Error ? err.message : String(err));
1557
1572
  }
1573
+ options.onRequestBodyRead?.();
1558
1574
  const responseStateOptions = (force = false): { force?: boolean; clientThreadId?: string } => ({
1559
1575
  ...(force ? { force: true } : {}),
1560
1576
  ...(parsed._clientThreadId ? { clientThreadId: parsed._clientThreadId } : {}),
@@ -2265,7 +2281,8 @@ async function handleResponsesInner(
2265
2281
  method: request.method,
2266
2282
  headers: request.headers,
2267
2283
  body: request.body,
2268
- }, recovery), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider),
2284
+ }, recovery), upstream.signal, connectMs, parsed.stream,
2285
+ providerFetch(route.provider, options.codexWsRuntimeIdentity),
2269
2286
  route.provider.authMode === "forward")
2270
2287
  // Every real attempt response — including an intermediate 5xx the
2271
2288
  // retry wrapper replaces — proves the host was reached (#914 review).
@@ -2326,7 +2343,8 @@ async function handleResponsesInner(
2326
2343
  method: request.method,
2327
2344
  headers: request.headers,
2328
2345
  body: request.body,
2329
- }, recovery), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider),
2346
+ }, recovery), upstream.signal, connectMs, parsed.stream,
2347
+ providerFetch(route.provider, options.codexWsRuntimeIdentity),
2330
2348
  route.provider.authMode === "forward")
2331
2349
  .then(res => {
2332
2350
  settleObservedHostResponse();
@@ -2573,9 +2591,14 @@ async function handleResponsesInner(
2573
2591
  needsClientRewrite,
2574
2592
  config.streamMode ?? "auto",
2575
2593
  );
2594
+ // A successful Codex WS upgrade is a push source. If it entered tee(),
2595
+ // the inspection branch could drain continuously while the slow client
2596
+ // branch retained bytes without a bound. Force the existing bounded,
2597
+ // single-reader relay before tee; HTTP fallback responses stay unmarked.
2598
+ const forceCodexWsEagerRelay = isCodexWsUpstreamResponse(upstreamResponse);
2576
2599
  const inlineEagerRewrite = needsClientRewrite
2577
- && (win32EagerRewrite || eagerPath?.useEagerRelay === true);
2578
- if (eagerPath?.useEagerRelay || win32EagerRewrite) {
2600
+ && (forceCodexWsEagerRelay || win32EagerRewrite || eagerPath?.useEagerRelay === true);
2601
+ if (forceCodexWsEagerRelay || eagerPath?.useEagerRelay || win32EagerRewrite) {
2579
2602
  const turnAc = new AbortController();
2580
2603
  linkAbortSignal(upstream, turnAc.signal);
2581
2604
  registerTurn(turnAc, options.turnAdmissionLease);
@@ -2633,9 +2656,9 @@ async function handleResponsesInner(
2633
2656
  onDone: () => unregisterTurn(turnAc),
2634
2657
  }, inlineEagerRewrite ? { rewriteBudget: translatorBudget } : undefined);
2635
2658
  // When selected, this relay closes response.completed even if upstream
2636
- // keeps the connection alive. Windows forced-rewrite traffic and Darwin
2637
- // explicit eager traffic apply client rewrites inline rather than via
2638
- // the tee()+JS-pull chain.
2659
+ // keeps the connection alive. Marked Codex WS traffic, Windows
2660
+ // forced-rewrite traffic, and Darwin explicit eager traffic apply
2661
+ // client rewrites inline rather than via the tee()+JS-pull chain.
2639
2662
  if (!headers.has("content-type")) headers.set("content-type", "text/event-stream");
2640
2663
  return markEagerRelaySseResponse(
2641
2664
  markNativePassthroughSseResponse(new Response(eagerBody, {
@@ -2925,7 +2948,7 @@ async function handleResponsesInner(
2925
2948
  : clampImageMaxRounds(config.images?.videoMaxRounds ?? 2),
2926
2949
  connectTimeoutMs: config.connectTimeoutMs ?? 200_000,
2927
2950
  stallTimeoutSec: config.stallTimeoutSec,
2928
- fetchImpl: providerFetch(route.provider),
2951
+ fetchImpl: providerFetch(route.provider, options.codexWsRuntimeIdentity),
2929
2952
  onRequestBuilt: request => recordAdapterReasoning(logCtx, request),
2930
2953
  ...(vidPlan?.timeoutMs ? { videoTimeoutMs: vidPlan.timeoutMs } : {}),
2931
2954
  onUsage: usage => {
@@ -3249,7 +3272,8 @@ async function handleResponsesInner(
3249
3272
  method: builtInitialRequest.method,
3250
3273
  headers: builtInitialRequest.headers,
3251
3274
  body: builtInitialRequest.body,
3252
- }, recovery), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider));
3275
+ }, recovery), upstream.signal, connectMs, parsed.stream,
3276
+ providerFetch(route.provider, options.codexWsRuntimeIdentity));
3253
3277
  },
3254
3278
  { abortSignal: upstream.signal, label: safeHostLabel(builtInitialRequest.url) },
3255
3279
  );
@@ -3328,7 +3352,8 @@ async function handleResponsesInner(
3328
3352
  ? await activeAdapter.fetchResponse(retryRequest, { abortSignal: upstream.signal, timeoutMs: connectMs, stream: parsed.stream })
3329
3353
  : await fetchWithHeaderTimeout(retryRequest.url, {
3330
3354
  method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body,
3331
- }, upstream.signal, connectMs, parsed.stream, providerFetch(route.provider));
3355
+ }, upstream.signal, connectMs, parsed.stream,
3356
+ providerFetch(route.provider, options.codexWsRuntimeIdentity));
3332
3357
  } finally {
3333
3358
  retryRequest.releaseBodyObservation?.();
3334
3359
  }
@@ -3652,7 +3677,7 @@ async function handleResponsesInner(
3652
3677
  upstream.signal,
3653
3678
  connectMs,
3654
3679
  nextParsed.stream,
3655
- providerFetch(route.provider),
3680
+ providerFetch(route.provider, options.codexWsRuntimeIdentity),
3656
3681
  );
3657
3682
  },
3658
3683
  { abortSignal: upstream.signal, label: safeHostLabel(builtContinuationRequest.url) },
@@ -265,47 +265,67 @@ export function hasEncryptedContentPart(content: unknown): boolean {
265
265
  export function sanitizeEncryptedContentInPlace(input: unknown): number {
266
266
  if (!Array.isArray(input)) return 0;
267
267
  let rewritten = 0;
268
- const visit = (node: unknown): number => {
269
- const before = rewritten;
270
- if (Array.isArray(node)) {
271
- for (let i = 0; i < node.length; i += 1) {
272
- const child = node[i] as unknown;
273
- if (
274
- child && typeof child === "object"
275
- && (child as { type?: unknown }).type === "encrypted_content"
276
- && typeof (child as { encrypted_content?: unknown }).encrypted_content === "string"
277
- ) {
278
- const payload = (child as { encrypted_content: string }).encrypted_content;
279
- if (!looksLikeBackendCiphertext(payload)) {
280
- const parts = encryptedSlotParts(payload);
281
- node.splice(i, 1, ...parts);
282
- i += parts.length - 1;
283
- rewritten += 1;
284
- continue;
285
- }
286
- }
287
- const childRewrites = visit(child);
288
- if (
289
- childRewrites > 0
290
- && child && typeof child === "object"
291
- && (child as { type?: unknown }).type === "agent_message"
292
- && !hasEncryptedContentPart((child as { content?: unknown }).content)
293
- ) {
294
- const message = child as { type: string; role?: string; id?: unknown; author?: unknown; recipient?: unknown };
295
- message.type = "message";
296
- message.role = "user";
297
- delete message.id;
298
- delete message.author;
299
- delete message.recipient;
268
+ type VisitFrame =
269
+ | { kind: "visit"; node: unknown }
270
+ | { kind: "array"; node: unknown[]; index: number }
271
+ | { kind: "object"; values: unknown[]; index: number }
272
+ | { kind: "agent"; message: Record<string, unknown>; rewrittenBefore: number };
273
+ const stack: VisitFrame[] = [{ kind: "visit", node: input }];
274
+
275
+ while (stack.length > 0) {
276
+ const frame = stack.pop()!;
277
+ if (frame.kind === "visit") {
278
+ if (Array.isArray(frame.node)) {
279
+ stack.push({ kind: "array", node: frame.node, index: 0 });
280
+ } else if (frame.node && typeof frame.node === "object") {
281
+ stack.push({ kind: "object", values: Object.values(frame.node), index: 0 });
282
+ }
283
+ continue;
284
+ }
285
+
286
+ if (frame.kind === "array") {
287
+ if (frame.index >= frame.node.length) continue;
288
+ const child = frame.node[frame.index] as unknown;
289
+ if (
290
+ child && typeof child === "object"
291
+ && (child as { type?: unknown }).type === "encrypted_content"
292
+ && typeof (child as { encrypted_content?: unknown }).encrypted_content === "string"
293
+ ) {
294
+ const payload = (child as { encrypted_content: string }).encrypted_content;
295
+ if (!looksLikeBackendCiphertext(payload)) {
296
+ const parts = encryptedSlotParts(payload);
297
+ frame.node.splice(frame.index, 1, ...parts);
298
+ rewritten += 1;
299
+ stack.push({ kind: "array", node: frame.node, index: frame.index + parts.length });
300
+ continue;
300
301
  }
301
302
  }
302
- return rewritten - before;
303
+ stack.push({ kind: "array", node: frame.node, index: frame.index + 1 });
304
+ if (child && typeof child === "object" && (child as { type?: unknown }).type === "agent_message") {
305
+ stack.push({ kind: "agent", message: child as Record<string, unknown>, rewrittenBefore: rewritten });
306
+ }
307
+ stack.push({ kind: "visit", node: child });
308
+ continue;
303
309
  }
304
- if (node && typeof node === "object") {
305
- for (const value of Object.values(node)) visit(value);
310
+
311
+ if (frame.kind === "object") {
312
+ if (frame.index >= frame.values.length) continue;
313
+ stack.push({ kind: "object", values: frame.values, index: frame.index + 1 });
314
+ stack.push({ kind: "visit", node: frame.values[frame.index] });
315
+ continue;
306
316
  }
307
- return rewritten - before;
308
- };
309
- visit(input);
317
+
318
+ if (
319
+ rewritten > frame.rewrittenBefore
320
+ && frame.message.type === "agent_message"
321
+ && !hasEncryptedContentPart(frame.message.content)
322
+ ) {
323
+ frame.message.type = "message";
324
+ frame.message.role = "user";
325
+ delete frame.message.id;
326
+ delete frame.message.author;
327
+ delete frame.message.recipient;
328
+ }
329
+ }
310
330
  return rewritten;
311
331
  }
@@ -1,5 +1,10 @@
1
1
  import type { Server } from "bun";
2
- import { codexWsUpstreamFetch, shouldUseCodexWsUpstream } from "./ws-upstream";
2
+ import {
3
+ codexWsUpstreamFetch,
4
+ currentBunRuntimeIdentity,
5
+ shouldUseCodexWsUpstream,
6
+ type BunRuntimeGateInput,
7
+ } from "./ws-upstream";
3
8
  import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "../../bridge";
4
9
  import {
5
10
  getConfigPath,
@@ -131,14 +136,17 @@ export function safeOriginLabel(url: string): string {
131
136
 
132
137
 
133
138
 
134
- export function providerFetch(provider: OcxProviderConfig): typeof globalThis.fetch {
139
+ export function providerFetch(
140
+ provider: OcxProviderConfig,
141
+ runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(),
142
+ ): typeof globalThis.fetch {
135
143
  const base = (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? globalThis.fetch;
136
144
  // ChatGPT Codex backend: streaming turns ride the responses_websockets
137
145
  // transport (measured ~3s faster TTFT than the SSE POST queue); everything
138
146
  // else keeps the provider's HTTP fetch. See ws-upstream.ts for the details.
139
147
  const wrapped = (input: Parameters<typeof globalThis.fetch>[0], init?: RequestInit) => {
140
- if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init)) {
141
- return codexWsUpstreamFetch(input, init, base);
148
+ if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init, runtime)) {
149
+ return codexWsUpstreamFetch(input, init, base, runtime);
142
150
  }
143
151
  return base(input, init);
144
152
  };
@@ -114,6 +114,17 @@ export async function handleResponsesWithPolicyFallback(
114
114
  deps: PolicyFallbackDeps = {},
115
115
  ): Promise<Response> {
116
116
  const runCore = deps.runCore ?? handleResponsesCore;
117
+ let requestBodyReadNotified = false;
118
+ const coreOptions: CoreOptions = options.onRequestBodyRead
119
+ ? {
120
+ ...options,
121
+ onRequestBodyRead: () => {
122
+ if (requestBodyReadNotified) return;
123
+ requestBodyReadNotified = true;
124
+ options.onRequestBodyRead?.();
125
+ },
126
+ }
127
+ : options;
117
128
  let rawBody: Record<string, unknown> | null = null;
118
129
  try {
119
130
  const parsed = await readJsonRequestBody(req.clone());
@@ -122,7 +133,7 @@ export async function handleResponsesWithPolicyFallback(
122
133
  // Core owns the client-facing parse/decompression error.
123
134
  }
124
135
 
125
- let response = await runCore(req, config, logCtx, options);
136
+ let response = await runCore(req, config, logCtx, coreOptions);
126
137
  const initialTrace = logCtx.routeDecision;
127
138
  const initialRequestedModel = logCtx.requestedModel;
128
139
  if (!rawBody || !isPolicyDecision(initialTrace)) return response;
@@ -140,7 +151,7 @@ export async function handleResponsesWithPolicyFallback(
140
151
  finishFailedPolicyAttempt(logCtx, response.status);
141
152
  const retryRequest = requestWithCandidate(req, rawBody, next);
142
153
  try {
143
- response = await runCore(retryRequest, config, logCtx, options);
154
+ response = await runCore(retryRequest, config, logCtx, coreOptions);
144
155
  } finally {
145
156
  logCtx.requestedModel = initialRequestedModel;
146
157
  logCtx.routeDecision = initialTrace;
@@ -12,14 +12,79 @@
12
12
  // returned event frames as an SSE byte stream, so every downstream consumer
13
13
  // (passthrough relay, adapter parsers, usage sniffing) is unchanged.
14
14
 
15
+ import { MAX_CLIENT_SSE_FRAME_BYTES } from "../sse-frame-buffer";
16
+ import { compareBunVersions } from "../../lib/bun-stream-caps";
17
+
15
18
  const CODEX_RESPONSES_HTTP_URL = "https://chatgpt.com/backend-api/codex/responses";
16
19
  const CODEX_RESPONSES_WS_URL = "wss://chatgpt.com/backend-api/codex/responses";
17
20
  const WS_BETA = "responses_websockets=2026-02-06";
18
21
  // If the 101 never arrives (network black hole), give SSE a chance well before
19
22
  // the caller's connect timeout (default 200s) would fire.
20
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
+ }
21
52
 
22
- export function shouldUseCodexWsUpstream(url: string, init?: RequestInit): boolean {
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;
23
88
  if (url !== CODEX_RESPONSES_HTTP_URL) return false;
24
89
  if ((init?.method ?? "GET").toUpperCase() !== "POST") return false;
25
90
  const body = init?.body;
@@ -41,7 +106,11 @@ export function codexWsUpstreamFetch(
41
106
  url: string,
42
107
  init: RequestInit,
43
108
  sseFallback: typeof globalThis.fetch,
109
+ runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(),
44
110
  ): Promise<Response> {
111
+ if (!bunSupportsBoundedCodexWsRelay(runtime)) {
112
+ return sseFallback(url, init);
113
+ }
45
114
  const signal = init.signal ?? undefined;
46
115
  if (signal?.aborted) {
47
116
  return Promise.reject(signal.reason ?? new DOMException("The operation was aborted.", "AbortError"));
@@ -91,6 +160,13 @@ export function codexWsUpstreamFetch(
91
160
  let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
92
161
  const encoder = new TextEncoder();
93
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
+
94
170
  const upgradeTimer = setTimeout(() => {
95
171
  if (opened || settledPreOpen) return;
96
172
  settledPreOpen = true;
@@ -110,12 +186,14 @@ export function codexWsUpstreamFetch(
110
186
  reject(signal?.reason ?? new DOMException("The operation was aborted.", "AbortError"));
111
187
  return;
112
188
  }
113
- try { ws.close(); } catch { /* already closing */ }
114
189
  if (controller && !terminal) {
115
190
  terminal = true;
116
191
  // Mirror an aborted fetch: the body read rejects with the abort reason.
117
192
  try { controller.error(signal?.reason ?? new DOMException("The operation was aborted.", "AbortError")); } catch { /* stream already done */ }
118
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 */ }
119
197
  };
120
198
  signal?.addEventListener("abort", onAbort, { once: true });
121
199
 
@@ -138,19 +216,33 @@ export function codexWsUpstreamFetch(
138
216
  const stream = new ReadableStream<Uint8Array>({
139
217
  start(c) { controller = c; },
140
218
  cancel() { try { ws.close(); } catch { /* already closing */ } },
141
- });
142
- resolve(new Response(stream, {
219
+ }, new ByteLengthQueuingStrategy({ highWaterMark: MAX_CODEX_WS_QUEUE_BYTES }));
220
+ const response = new Response(stream, {
143
221
  status: 200,
144
222
  // The 101 response headers (x-codex-*-reset-at quota hints) are not
145
223
  // exposed by Bun's WebSocket; the periodic quota poller covers those.
146
224
  headers: { "content-type": "text/event-stream; charset=utf-8" },
147
- }));
225
+ });
226
+ codexWsUpstreamResponses.add(response);
227
+ resolve(response);
148
228
  });
149
229
 
150
230
  ws.addEventListener("message", (event) => {
151
231
  if (!controller || terminal) return;
152
232
  const text = typeof event.data === "string" ? event.data : "";
153
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
+ }
154
246
  let type: unknown;
155
247
  try { type = (JSON.parse(text) as { type?: unknown }).type; } catch { return; }
156
248
  if (typeof type !== "string") return;
@@ -158,9 +250,26 @@ export function codexWsUpstreamFetch(
158
250
  // frames (codex.rate_limits, responsesapi.websocket_timing) are dropped
159
251
  // so downstream clients see exactly the stream shape they always got.
160
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 frameBytes = prefix.byteLength + encodedText.byteLength + suffix.byteLength;
256
+ if (frameBytes > MAX_CLIENT_SSE_FRAME_BYTES) {
257
+ failStream("codex websocket frame exceeds the response size limit");
258
+ return;
259
+ }
260
+ const availableBytes = controller.desiredSize ?? 0;
261
+ if (frameBytes > availableBytes) {
262
+ failStream("codex websocket response exceeded the buffered queue limit");
263
+ return;
264
+ }
265
+ const sseFrame = new Uint8Array(frameBytes);
266
+ sseFrame.set(prefix);
267
+ sseFrame.set(encodedText, prefix.byteLength);
268
+ sseFrame.set(suffix, prefix.byteLength + encodedText.byteLength);
161
269
  try {
162
- controller.enqueue(encoder.encode(`event: ${type}\ndata: ${text}\n\n`));
270
+ controller.enqueue(sseFrame);
163
271
  } catch {
272
+ failStream("codex websocket response stream closed while enqueueing");
164
273
  return;
165
274
  }
166
275
  if (type === "response.completed" || type === "response.failed" || type === "response.incomplete" || type === "error") {