@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.
- package/gui/dist/assets/{index-DOKr6RBR.js → index-CXI1262_.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/client-fingerprint.ts +2 -2
- package/src/adapters/cursor/live-transport.ts +17 -5
- package/src/adapters/cursor/protobuf-events.ts +662 -20
- package/src/adapters/cursor/tool-definitions.ts +12 -6
- package/src/adapters/google.ts +21 -2
- package/src/bridge.ts +12 -2
- package/src/cli/index.ts +11 -0
- package/src/codex/app-server-processes.ts +3 -3
- package/src/codex/user-identity.ts +36 -6
- package/src/config.ts +0 -2
- package/src/generated/compatibility-version.json +37 -33
- package/src/lib/token-estimate.ts +19 -2
- package/src/lib/windows-elevation.ts +37 -0
- package/src/lib/windows-secret-acl.ts +7 -0
- package/src/lib/windows-text.ts +106 -0
- package/src/lib/windows-user-principal.ts +0 -2
- package/src/oauth/index.ts +1 -1
- package/src/oauth/store.ts +32 -18
- package/src/providers/antigravity-models.ts +25 -5
- package/src/providers/free-directory.ts +1 -1
- package/src/providers/registry.ts +4 -3
- package/src/server/index.ts +5 -1
- package/src/server/management/logs-usage-routes.ts +7 -22
- package/src/server/request-log.ts +48 -3
- package/src/server/responses/core.ts +38 -13
- package/src/server/responses/encrypted-payload.ts +58 -38
- package/src/server/responses/fetch-helpers.ts +12 -4
- package/src/server/responses/policy-fallback.ts +13 -2
- package/src/server/responses/ws-upstream.ts +115 -6
- package/src/service-manager-probe.ts +21 -34
- package/src/service.ts +233 -25
- package/src/tray/windows.ts +0 -2
- package/src/update/job.ts +2 -2
- 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
|
-
|
|
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,
|
|
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,
|
|
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.
|
|
2637
|
-
//
|
|
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,
|
|
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,
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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
|
-
|
|
305
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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 {
|
|
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(
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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") {
|