@bitkyc08/opencodex 2.5.6 → 2.6.0

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 (51) hide show
  1. package/README.ko.md +17 -6
  2. package/README.md +19 -7
  3. package/README.zh-CN.md +12 -3
  4. package/assets/architecture.png +0 -0
  5. package/assets/banner.png +0 -0
  6. package/assets/codex-app-picker.png +0 -0
  7. package/bin/ocx.mjs +88 -2
  8. package/bin/package-main.mjs +9 -0
  9. package/gui/dist/assets/index-BS4X1QDi.js +9 -0
  10. package/gui/dist/assets/{index-CKqUwc02.css → index-BwvDb198.css} +1 -1
  11. package/gui/dist/index.html +2 -2
  12. package/package.json +20 -6
  13. package/src/adapters/anthropic.ts +16 -5
  14. package/src/adapters/google.ts +9 -2
  15. package/src/adapters/openai-chat.ts +13 -5
  16. package/src/bun-runtime.ts +22 -1
  17. package/src/cli-help.ts +111 -0
  18. package/src/cli-status.ts +164 -0
  19. package/src/cli.ts +77 -186
  20. package/src/codex-account-store.ts +47 -8
  21. package/src/codex-auth-api.ts +111 -54
  22. package/src/codex-auth-collision.ts +5 -0
  23. package/src/codex-catalog.ts +24 -12
  24. package/src/codex-history-provider.ts +29 -13
  25. package/src/codex-inject.ts +46 -29
  26. package/src/codex-journal.ts +77 -13
  27. package/src/codex-quota.ts +11 -3
  28. package/src/codex-routing.ts +14 -4
  29. package/src/codex-shim.ts +71 -24
  30. package/src/codex-websocket-registry.ts +20 -4
  31. package/src/config.ts +138 -4
  32. package/src/init.ts +7 -2
  33. package/src/oauth/callback-server.ts +22 -15
  34. package/src/oauth/index.ts +18 -4
  35. package/src/oauth/login-cli.ts +8 -1
  36. package/src/oauth/store.ts +2 -1
  37. package/src/process-control.ts +36 -0
  38. package/src/provider-label.ts +8 -0
  39. package/src/responses/parser.ts +18 -1
  40. package/src/router.ts +61 -5
  41. package/src/server.ts +878 -94
  42. package/src/service-secrets.ts +6 -0
  43. package/src/service.ts +293 -28
  44. package/src/types.ts +26 -1
  45. package/src/update.ts +16 -9
  46. package/src/usage-debug.ts +65 -0
  47. package/src/usage-log.ts +62 -0
  48. package/src/usage-summary.ts +0 -0
  49. package/src/ws-bridge.ts +2 -2
  50. package/gui/README.md +0 -73
  51. package/gui/dist/assets/index-CSUvRNAX.js +0 -9
package/src/server.ts CHANGED
@@ -1,6 +1,6 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { timingSafeEqual } from "node:crypto";
3
- import { extname, join } from "node:path";
3
+ import { extname, isAbsolute, join, relative, resolve } from "node:path";
4
4
  import { createAnthropicAdapter } from "./adapters/anthropic";
5
5
  import { createAzureAdapter } from "./adapters/azure";
6
6
  import { createGoogleAdapter } from "./adapters/google";
@@ -10,32 +10,57 @@ import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type Resp
10
10
  import {
11
11
  buildWarmupCompletionFrames,
12
12
  buildWsErrorFrame,
13
- selectForwardHeadersForAuthContext,
13
+ selectForwardHeaders,
14
14
  sendJsonFrame,
15
15
  sendResponseToWebSocket,
16
16
  sendTextFrame,
17
17
  type WsData,
18
18
  } from "./ws-bridge";
19
- import type { ServerWebSocket } from "bun";
20
- import { DEFAULT_SUBAGENT_MODELS, codexAutoStartEnabled, loadConfig, saveConfig, websocketsEnabled } from "./config";
19
+ import type { Server, ServerWebSocket } from "bun";
20
+ import {
21
+ DEFAULT_SUBAGENT_MODELS,
22
+ codexAutoStartEnabled,
23
+ getConfigPath,
24
+ hasOwnProvider,
25
+ isValidProviderName,
26
+ loadConfig,
27
+ saveConfig,
28
+ websocketsEnabled,
29
+ } from "./config";
21
30
  import { parseRequest } from "./responses/parser";
22
31
  import { routeModel } from "./router";
23
32
  import { namespacedToolName } from "./types";
24
33
  import {
25
34
  clearLoginState, getLoginStatus, getValidAccessToken, isOAuthProvider,
26
- listOAuthProviders, reconcileOAuthProviders, startLoginFlow, upsertOAuthProvider,
35
+ listOAuthProviders, reconcileOAuthProviders, startLoginFlow, UnsupportedOAuthProviderError, upsertOAuthProvider,
27
36
  } from "./oauth/index";
28
37
  import type { CatalogModel } from "./codex-catalog";
29
- import { invalidateCodexModelsCache } from "./codex-catalog";
38
+ import { invalidateCodexModelsCache, readCodexCatalogPath } from "./codex-catalog";
39
+ import { CODEX_CONFIG_PATH, readRootTomlString } from "./codex-paths";
30
40
  import { buildWebSearchTool, planWebSearch, runWithWebSearch } from "./web-search";
31
41
  import { describeImagesInPlace, planVisionSidecar } from "./vision";
32
42
  import { removeCredential } from "./oauth/store";
33
43
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "./oauth/key-providers";
34
44
  import { deriveProviderPresets } from "./providers/derive";
35
- import type { OcxConfig, OcxProviderConfig } from "./types";
45
+ import type { AdapterEvent, OcxConfig, OcxProviderConfig } from "./types";
46
+ import type { OcxUsage } from "./types";
47
+ import {
48
+ appendUsageEntry,
49
+ readUsageEntries,
50
+ usageStatusForFinalLog,
51
+ usageTotalTokens,
52
+ type UsageStatus,
53
+ } from "./usage-log";
54
+ import { parseRange, summarizeUsage } from "./usage-summary";
55
+ import {
56
+ appendUsageDebug,
57
+ isUsageDebugEnabled,
58
+ truncateForDebug,
59
+ USAGE_DEBUG_BODY_SAMPLE_BYTES,
60
+ type UsageDebugBodyKind,
61
+ } from "./usage-debug";
36
62
  import {
37
63
  applyCodexAuthContextToProvider,
38
- assertCodexAuthContextNotCooled,
39
64
  CodexAccountCooldownError,
40
65
  CodexAuthContextError,
41
66
  CodexThreadAffinityExpiredError,
@@ -55,7 +80,7 @@ import {
55
80
  recordCodexUpstreamOutcome,
56
81
  type CodexUpstreamOutcome,
57
82
  } from "./codex-routing";
58
- import { registerCodexWebSocket, unregisterCodexWebSocket } from "./codex-websocket-registry";
83
+ import { registerCodexWebSocket, unregisterCodexWebSocket, updateCodexWebSocketAuthContext } from "./codex-websocket-registry";
59
84
 
60
85
  // ---------------------------------------------------------------------------
61
86
  // Active turn tracking + graceful shutdown drain
@@ -63,6 +88,26 @@ import { registerCodexWebSocket, unregisterCodexWebSocket } from "./codex-websoc
63
88
 
64
89
  const activeTurns = new Set<AbortController>();
65
90
  let draining = false;
91
+ const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
92
+ const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;
93
+ const nativePassthroughSseResponses = new WeakSet<Response>();
94
+
95
+ export interface RequestLogContext {
96
+ model: string;
97
+ provider: string;
98
+ requestedModel?: string;
99
+ requestedServiceTier?: string;
100
+ requestedSpeedLabel?: string;
101
+ configuredServiceTier?: string;
102
+ configuredSpeedLabel?: string;
103
+ modelSupportsServiceTier?: boolean;
104
+ responseServiceTier?: string;
105
+ resolvedModel?: string;
106
+ usage?: OcxUsage;
107
+ usageDebugBodyKind?: UsageDebugBodyKind;
108
+ usageDebugBodySample?: string;
109
+ usageDebugContentType?: string;
110
+ }
66
111
 
67
112
  export function registerTurn(ac: AbortController): void { activeTurns.add(ac); }
68
113
  export function unregisterTurn(ac: AbortController): void { activeTurns.delete(ac); }
@@ -72,22 +117,30 @@ export function getActiveTurnCount(): number { return activeTurns.size; }
72
117
  export function trackStreamLifetime(
73
118
  body: ReadableStream<Uint8Array>,
74
119
  ac: AbortController,
120
+ onDone?: () => void,
75
121
  ): ReadableStream<Uint8Array> {
76
122
  registerTurn(ac);
77
123
  const reader = body.getReader();
124
+ let closed = false;
125
+ const finish = () => {
126
+ if (closed) return;
127
+ closed = true;
128
+ unregisterTurn(ac);
129
+ onDone?.();
130
+ };
78
131
  return new ReadableStream<Uint8Array>({
79
132
  async pull(controller) {
80
133
  try {
81
134
  const { done, value } = await reader.read();
82
- if (done) { unregisterTurn(ac); controller.close(); return; }
135
+ if (done) { finish(); controller.close(); return; }
83
136
  controller.enqueue(value);
84
137
  } catch (err) {
85
- unregisterTurn(ac);
138
+ finish();
86
139
  try { controller.error(err); } catch { /* already closed */ }
87
140
  }
88
141
  },
89
142
  cancel(reason) {
90
- unregisterTurn(ac);
143
+ finish();
91
144
  ac.abort(reason);
92
145
  reader.cancel(reason).catch(() => {});
93
146
  },
@@ -144,17 +197,43 @@ function findGuiDist(): string | null {
144
197
  return null;
145
198
  }
146
199
 
200
+ export function resolveGuiFilePath(guiDist: string, pathname: string): string | null {
201
+ let decodedPath: string;
202
+ try {
203
+ decodedPath = decodeURIComponent(pathname);
204
+ } catch {
205
+ return null;
206
+ }
207
+ if (decodedPath.includes("\0")) return null;
208
+
209
+ const relativePath = decodedPath === "/" || decodedPath === ""
210
+ ? "index.html"
211
+ : decodedPath.replace(/\\/g, "/").replace(/^\/+/, "");
212
+ const root = resolve(guiDist);
213
+ const filePath = resolve(root, relativePath);
214
+ const rel = relative(root, filePath);
215
+ if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) return null;
216
+ return filePath;
217
+ }
218
+
219
+ function isFile(path: string): boolean {
220
+ try {
221
+ return statSync(path).isFile();
222
+ } catch {
223
+ return false;
224
+ }
225
+ }
226
+
147
227
  function serveGuiFile(pathname: string): Response | null {
148
228
  const guiDist = findGuiDist();
149
229
  if (!guiDist) return null;
150
- const filePath = pathname === "/" || pathname === ""
151
- ? join(guiDist, "index.html")
152
- : join(guiDist, pathname);
230
+ const filePath = resolveGuiFilePath(guiDist, pathname);
231
+ if (!filePath) return null;
153
232
 
154
- if (!existsSync(filePath)) {
233
+ if (!isFile(filePath)) {
155
234
  if (!extname(pathname)) {
156
235
  const indexPath = join(guiDist, "index.html");
157
- if (existsSync(indexPath)) {
236
+ if (isFile(indexPath)) {
158
237
  return new Response(Bun.file(indexPath), {
159
238
  headers: { "Content-Type": "text/html" },
160
239
  });
@@ -243,7 +322,7 @@ function codexForwardTerminalOutcomeRecorder(
243
322
  async function handleResponses(
244
323
  req: Request,
245
324
  config: OcxConfig,
246
- logCtx: { model: string; provider: string },
325
+ logCtx: RequestLogContext,
247
326
  options: {
248
327
  forceEmptyResponseId?: boolean;
249
328
  abortSignal?: AbortSignal;
@@ -251,6 +330,8 @@ async function handleResponses(
251
330
  selectedForwardHeaders?: Headers;
252
331
  recordTerminalOutcomes?: boolean;
253
332
  setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus) => void) | undefined) => void;
333
+ onNativePassthroughTerminal?: (status: ResponsesTerminalStatus) => void;
334
+ onNativePassthroughCancel?: () => void;
254
335
  } = {},
255
336
  ): Promise<Response> {
256
337
  let body: unknown;
@@ -266,6 +347,11 @@ async function handleResponses(
266
347
  } catch (err) {
267
348
  return formatErrorResponse(400, "invalid_request_error", err instanceof Error ? err.message : String(err));
268
349
  }
350
+ logCtx.requestedModel = parsed.modelId;
351
+ logCtx.requestedServiceTier = parsed.options.serviceTier;
352
+ logCtx.requestedSpeedLabel = requestLogSpeedLabel(parsed.options.serviceTier);
353
+ logCtx.configuredServiceTier = readConfiguredCodexServiceTier();
354
+ logCtx.configuredSpeedLabel = requestLogSpeedLabel(logCtx.configuredServiceTier);
269
355
 
270
356
  let route;
271
357
  try {
@@ -285,6 +371,10 @@ async function handleResponses(
285
371
  }
286
372
  logCtx.model = route.modelId;
287
373
  logCtx.provider = route.providerName;
374
+ logCtx.modelSupportsServiceTier = catalogModelSupportsServiceTier(
375
+ route.modelId,
376
+ logCtx.requestedServiceTier ?? logCtx.configuredServiceTier,
377
+ );
288
378
 
289
379
  let authCtx: CodexAuthContext;
290
380
  let selectedForwardHeaders: Headers;
@@ -317,6 +407,13 @@ async function handleResponses(
317
407
  try {
318
408
  route.provider = { ...route.provider, apiKey: await getValidAccessToken(route.providerName) };
319
409
  } catch (err) {
410
+ if (err instanceof UnsupportedOAuthProviderError) {
411
+ return formatErrorResponse(
412
+ 400,
413
+ "invalid_request_error",
414
+ `${err.message}. Remove or reconfigure provider '${route.providerName}' in ${getConfigPath()}.`,
415
+ );
416
+ }
320
417
  return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err));
321
418
  }
322
419
  }
@@ -359,7 +456,17 @@ async function handleResponses(
359
456
  return formatErrorResponse(502, "upstream_error", msg);
360
457
  }
361
458
  const headers = sanitizePassthroughHeaders(upstreamResponse.headers);
362
- const isEventStream = headers.get("content-type")?.toLowerCase().includes("text/event-stream") ?? false;
459
+ const resolvedModel = headers.get("openai-model")?.trim();
460
+ if (resolvedModel) logCtx.resolvedModel = resolvedModel;
461
+ if (isUsageDebugEnabled()) {
462
+ const upstreamContentType = upstreamResponse.headers.get("content-type");
463
+ if (upstreamContentType) logCtx.usageDebugContentType = upstreamContentType;
464
+ }
465
+ // The chatgpt backend may omit Content-Type on SSE responses. Fall back to
466
+ // treating a successful body as SSE when the caller requested streaming.
467
+ const passthroughCt = headers.get("content-type")?.toLowerCase();
468
+ const isEventStream = passthroughCt?.includes("text/event-stream")
469
+ || (upstreamResponse.ok && !!upstreamResponse.body && !passthroughCt && parsed.stream);
363
470
  const terminalRecorder = codexForwardTerminalOutcomeRecorder(config, authCtx, route.provider);
364
471
  const terminalBodyWillRecord = !!terminalRecorder && upstreamResponse.ok && isEventStream;
365
472
  // Capture quota from upstream response for multi-account tracking
@@ -384,7 +491,10 @@ async function handleResponses(
384
491
  );
385
492
  }
386
493
  if (terminalBodyWillRecord) {
387
- options.setTerminalOutcomeRecorder?.(terminalRecorder);
494
+ options.setTerminalOutcomeRecorder?.(status => {
495
+ terminalRecorder(status);
496
+ options.onNativePassthroughTerminal?.(status);
497
+ });
388
498
  } else {
389
499
  recordCodexUpstreamOutcome(config, authCtx.accountId, upstreamResponse.status, {
390
500
  retryAfter: retryAfterRaw,
@@ -400,14 +510,34 @@ async function handleResponses(
400
510
  if (isEventStream && upstreamResponse.body) {
401
511
  const [nativeBody, inspectBody] = upstreamResponse.body.tee();
402
512
  const turnAc = new AbortController();
403
- const trackedNative = trackStreamLifetime(nativeBody, turnAc);
404
- if (terminalBodyWillRecord && recordTerminalOutcomes && terminalRecorder) {
405
- consumeForInspection(inspectBody, terminalRecorder);
513
+ linkAbortSignal(upstream, turnAc.signal);
514
+ registerTurn(turnAc);
515
+ if (terminalBodyWillRecord && recordTerminalOutcomes) {
516
+ const recordTerminal = terminalRecorder;
517
+ const reportNativeTerminal = (status: ResponsesTerminalStatus) => {
518
+ if (options.abortSignal?.aborted) {
519
+ options.onNativePassthroughCancel?.();
520
+ return;
521
+ }
522
+ recordTerminal(status);
523
+ options.onNativePassthroughTerminal?.(status);
524
+ };
525
+ consumeForInspection(inspectBody, reportNativeTerminal, turnAc.signal, () => unregisterTurn(turnAc), logCtx);
406
526
  } else {
407
- inspectBody.cancel().catch(() => {});
527
+ consumeForResponseLogMetadata(inspectBody, logCtx, turnAc.signal, () => unregisterTurn(turnAc));
408
528
  }
409
- return new Response(trackedNative, {
529
+ if (!headers.has("content-type")) headers.set("content-type", "text/event-stream");
530
+ return markNativePassthroughSseResponse(new Response(nativeBody, {
531
+ status: upstreamResponse.status,
532
+ headers,
533
+ }));
534
+ }
535
+ if (headers.get("content-type")?.toLowerCase().includes("application/json")) {
536
+ const text = await upstreamResponse.text();
537
+ inspectResponseLogJson(logCtx, text);
538
+ return new Response(text, {
410
539
  status: upstreamResponse.status,
540
+ statusText: upstreamResponse.statusText,
411
541
  headers,
412
542
  });
413
543
  }
@@ -440,7 +570,7 @@ async function handleResponses(
440
570
  }
441
571
 
442
572
  const upstream = new AbortController();
443
- linkAbortSignal(upstream, options.abortSignal);
573
+ const cleanupUpstreamAbort = linkAbortSignal(upstream, options.abortSignal);
444
574
  const connectMs = config.connectTimeoutMs ?? 30_000;
445
575
 
446
576
  const request = adapter.buildRequest(parsed, { headers: selectedForwardHeaders });
@@ -450,6 +580,7 @@ async function handleResponses(
450
580
  method: request.method, headers: request.headers, body: request.body,
451
581
  }, upstream.signal, connectMs);
452
582
  } catch (err) {
583
+ cleanupUpstreamAbort();
453
584
  upstream.abort();
454
585
  const msg = err instanceof Error && err.name === "TimeoutError"
455
586
  ? `Provider connect timeout after ${connectMs}ms`
@@ -459,6 +590,7 @@ async function handleResponses(
459
590
 
460
591
  if (!upstreamResponse.ok) {
461
592
  const errorText = await upstreamResponse.text().catch(() => "unknown error");
593
+ cleanupUpstreamAbort();
462
594
  return formatErrorResponse(upstreamResponse.status, "upstream_error", `Provider error ${upstreamResponse.status}: ${errorText.slice(0, 500)}`);
463
595
  }
464
596
 
@@ -482,14 +614,19 @@ async function handleResponses(
482
614
  },
483
615
  );
484
616
  const bridgeTurnAc = new AbortController();
485
- const trackedSse = trackStreamLifetime(sseStream, bridgeTurnAc);
617
+ const trackedSse = trackStreamLifetime(sseStream, bridgeTurnAc, cleanupUpstreamAbort);
486
618
  return new Response(trackedSse, {
487
619
  headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" },
488
620
  });
489
621
  }
490
622
 
491
623
  if (adapter.parseResponse) {
492
- const events = await adapter.parseResponse(upstreamResponse);
624
+ let events: AdapterEvent[];
625
+ try {
626
+ events = await adapter.parseResponse(upstreamResponse);
627
+ } finally {
628
+ cleanupUpstreamAbort();
629
+ }
493
630
  const toolNsMap = new Map<string, { namespace: string; name: string }>();
494
631
  const freeformToolNames = new Set<string>();
495
632
  const toolSearchToolNames = new Set<string>();
@@ -510,13 +647,25 @@ async function handleResponses(
510
647
  return formatErrorResponse(500, "internal_error", "Non-streaming not supported by this adapter");
511
648
  }
512
649
 
513
- export function linkAbortSignal(upstream: AbortController, signal?: AbortSignal): void {
514
- if (!signal) return;
650
+ export function linkAbortSignal(upstream: AbortController, signal?: AbortSignal): () => void {
651
+ if (!signal) return () => {};
515
652
  if (signal.aborted) {
516
653
  upstream.abort(signal.reason);
517
- return;
654
+ return () => {};
655
+ }
656
+ const onAbort = () => upstream.abort(signal.reason);
657
+ signal.addEventListener("abort", onAbort, { once: true });
658
+ return () => signal.removeEventListener("abort", onAbort);
659
+ }
660
+
661
+ export function disableResponsesRequestTimeout(req: Request, server: Pick<Server<WsData>, "timeout"> | undefined): boolean {
662
+ if (!server) return false;
663
+ try {
664
+ server.timeout(req, 0);
665
+ return true;
666
+ } catch {
667
+ return false;
518
668
  }
519
- signal.addEventListener("abort", () => upstream.abort(signal.reason), { once: true });
520
669
  }
521
670
 
522
671
  async function fetchWithHeaderTimeout(
@@ -544,9 +693,22 @@ export interface RequestLogEntry {
544
693
  timestamp: number;
545
694
  model: string;
546
695
  provider: string;
696
+ requestedModel?: string;
697
+ requestedServiceTier?: string;
698
+ requestedSpeedLabel?: string;
699
+ configuredServiceTier?: string;
700
+ configuredSpeedLabel?: string;
701
+ modelSupportsServiceTier?: boolean;
702
+ responseServiceTier?: string;
703
+ resolvedModel?: string;
547
704
  status: number;
548
705
  durationMs: number;
549
706
  errorCode?: string;
707
+ terminalStatus?: ResponsesTerminalStatus;
708
+ closeReason?: "terminal" | "client_cancel" | "non_stream";
709
+ usageStatus: UsageStatus;
710
+ usage?: OcxUsage;
711
+ totalTokens?: number;
550
712
  }
551
713
 
552
714
  const requestLog: RequestLogEntry[] = [];
@@ -556,6 +718,22 @@ let requestLogSeq = 0;
556
718
  function addRequestLog(entry: RequestLogEntry) {
557
719
  requestLog.push(entry);
558
720
  if (requestLog.length > MAX_LOG_SIZE) requestLog.shift();
721
+ try {
722
+ appendUsageEntry({
723
+ requestId: entry.requestId,
724
+ timestamp: entry.timestamp,
725
+ provider: entry.provider,
726
+ model: entry.model,
727
+ ...(entry.resolvedModel ? { resolvedModel: entry.resolvedModel } : {}),
728
+ status: entry.status,
729
+ durationMs: entry.durationMs,
730
+ usageStatus: entry.usageStatus,
731
+ ...(entry.usage ? { usage: entry.usage } : {}),
732
+ ...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}),
733
+ });
734
+ } catch {
735
+ /* request logging must never fail a user request */
736
+ }
559
737
  }
560
738
 
561
739
  export function nextRequestLogId(timestamp = Date.now()): string {
@@ -568,11 +746,188 @@ export function requestLogErrorCode(status: number): string | undefined {
568
746
  if (status === 400 || status === 409) return "invalid_request_error";
569
747
  if (status === 401 || status === 403) return "invalid_api_key";
570
748
  if (status === 429) return "rate_limit_exceeded";
749
+ if (status === 499) return "client_closed_request";
571
750
  if (status === 503) return "server_is_overloaded";
572
751
  if (status >= 500) return "upstream_server_error";
573
752
  return `http_${status}`;
574
753
  }
575
754
 
755
+ export function requestLogSpeedLabel(serviceTier: string | undefined): string | undefined {
756
+ const normalized = serviceTier?.trim().toLowerCase();
757
+ if (normalized === "priority" || normalized === "fast") return "fast";
758
+ return undefined;
759
+ }
760
+
761
+ function readConfiguredCodexServiceTier(): string | undefined {
762
+ try {
763
+ if (!existsSync(CODEX_CONFIG_PATH)) return undefined;
764
+ return readRootTomlString(readFileSync(CODEX_CONFIG_PATH, "utf-8"), "service_tier") ?? undefined;
765
+ } catch {
766
+ return undefined;
767
+ }
768
+ }
769
+
770
+ function catalogModelSupportsServiceTier(modelId: string, serviceTier: string | undefined): boolean | undefined {
771
+ if (!serviceTier) return undefined;
772
+ const requestTier = serviceTier.trim().toLowerCase() === "fast" ? "priority" : serviceTier.trim();
773
+ try {
774
+ const catalogPath = readCodexCatalogPath();
775
+ if (!existsSync(catalogPath)) return undefined;
776
+ const catalog = JSON.parse(readFileSync(catalogPath, "utf-8")) as { models?: unknown };
777
+ const models = Array.isArray(catalog.models) ? catalog.models : [];
778
+ const entry = models.find(model => {
779
+ if (!model || typeof model !== "object") return false;
780
+ return (model as { slug?: unknown; id?: unknown }).slug === modelId
781
+ || (model as { slug?: unknown; id?: unknown }).id === modelId;
782
+ });
783
+ if (!entry || typeof entry !== "object") return undefined;
784
+ const tiers = (entry as { service_tiers?: unknown }).service_tiers;
785
+ return Array.isArray(tiers) && tiers.some(tier => (
786
+ tier && typeof tier === "object" && (tier as { id?: unknown }).id === requestTier
787
+ ));
788
+ } catch {
789
+ return undefined;
790
+ }
791
+ }
792
+
793
+ function applyResponseLogMetadata(logCtx: RequestLogContext, payload: unknown): void {
794
+ if (!payload || typeof payload !== "object") return;
795
+ const source = "response" in payload && typeof (payload as { response?: unknown }).response === "object"
796
+ ? (payload as { response?: unknown }).response
797
+ : payload;
798
+ if (!source || typeof source !== "object") return;
799
+ const model = (source as { model?: unknown }).model;
800
+ if (typeof model === "string" && model.trim()) logCtx.resolvedModel = model;
801
+ const serviceTier = (source as { service_tier?: unknown }).service_tier;
802
+ if (typeof serviceTier === "string" && serviceTier.trim()) logCtx.responseServiceTier = serviceTier;
803
+ const usage = usageFromResponsesPayload((source as { usage?: unknown }).usage);
804
+ if (usage) logCtx.usage = usage;
805
+ }
806
+
807
+ export function usageFromResponsesPayload(usage: unknown): OcxUsage | undefined {
808
+ if (!usage || typeof usage !== "object") return undefined;
809
+ const raw = usage as {
810
+ input_tokens?: unknown;
811
+ output_tokens?: unknown;
812
+ input_tokens_details?: { cached_tokens?: unknown };
813
+ output_tokens_details?: { reasoning_tokens?: unknown };
814
+ prompt_tokens?: unknown;
815
+ completion_tokens?: unknown;
816
+ prompt_tokens_details?: { cached_tokens?: unknown };
817
+ completion_tokens_details?: { reasoning_tokens?: unknown };
818
+ };
819
+ if (typeof raw.input_tokens === "number" && typeof raw.output_tokens === "number") {
820
+ return {
821
+ inputTokens: raw.input_tokens,
822
+ outputTokens: raw.output_tokens,
823
+ ...(typeof raw.input_tokens_details?.cached_tokens === "number"
824
+ ? { cachedInputTokens: raw.input_tokens_details.cached_tokens }
825
+ : {}),
826
+ ...(typeof raw.output_tokens_details?.reasoning_tokens === "number"
827
+ ? { reasoningOutputTokens: raw.output_tokens_details.reasoning_tokens }
828
+ : {}),
829
+ };
830
+ }
831
+ if (typeof raw.prompt_tokens === "number" && typeof raw.completion_tokens === "number") {
832
+ return {
833
+ inputTokens: raw.prompt_tokens,
834
+ outputTokens: raw.completion_tokens,
835
+ ...(typeof raw.prompt_tokens_details?.cached_tokens === "number"
836
+ ? { cachedInputTokens: raw.prompt_tokens_details.cached_tokens }
837
+ : {}),
838
+ ...(typeof raw.completion_tokens_details?.reasoning_tokens === "number"
839
+ ? { reasoningOutputTokens: raw.completion_tokens_details.reasoning_tokens }
840
+ : {}),
841
+ };
842
+ }
843
+ return undefined;
844
+ }
845
+
846
+ function inspectResponseLogJson(logCtx: RequestLogContext, text: string): void {
847
+ try {
848
+ applyResponseLogMetadata(logCtx, JSON.parse(text));
849
+ } catch {
850
+ /* body may not be JSON; request log metadata is best-effort only */
851
+ }
852
+ if (isUsageDebugEnabled() && logCtx.usageDebugBodyKind === undefined) {
853
+ logCtx.usageDebugBodyKind = "json";
854
+ logCtx.usageDebugBodySample = truncateForDebug(text);
855
+ }
856
+ }
857
+
858
+ function inspectResponseLogSsePayload(logCtx: RequestLogContext, payload: string | null): void {
859
+ if (!payload || payload.trim() === "[DONE]") return;
860
+ const debugEnabled = isUsageDebugEnabled();
861
+ const sseAlreadyMarked = logCtx.usageDebugBodyKind === "sse";
862
+ try {
863
+ applyResponseLogMetadata(logCtx, JSON.parse(payload));
864
+ } catch {
865
+ /* SSE block payload may not be JSON; metadata inspection is best-effort */
866
+ }
867
+ if (debugEnabled) {
868
+ if (!sseAlreadyMarked) {
869
+ logCtx.usageDebugBodyKind = "sse";
870
+ logCtx.usageDebugBodySample = truncateForDebug(payload);
871
+ } else if (typeof logCtx.usageDebugBodySample === "string"
872
+ && logCtx.usageDebugBodySample.length < USAGE_DEBUG_BODY_SAMPLE_BYTES) {
873
+ const combined = `${logCtx.usageDebugBodySample}\n${payload}`;
874
+ logCtx.usageDebugBodySample = truncateForDebug(combined);
875
+ }
876
+ }
877
+ }
878
+
879
+ function httpStatusForTerminalStatus(status: ResponsesTerminalStatus): number {
880
+ return status === "completed" ? 200 : 502;
881
+ }
882
+
883
+ function addFinalRequestLog(
884
+ requestId: string,
885
+ start: number,
886
+ logCtx: RequestLogContext,
887
+ status: number,
888
+ meta?: Pick<RequestLogEntry, "terminalStatus" | "closeReason">,
889
+ addLog: (entry: RequestLogEntry) => void = addRequestLog,
890
+ ): void {
891
+ const errorCode = requestLogErrorCode(status);
892
+ const usageStatus = usageStatusForFinalLog(logCtx.usage);
893
+ const totalTokens = usageTotalTokens(logCtx.usage);
894
+ addLog({
895
+ requestId,
896
+ timestamp: start,
897
+ model: logCtx.model,
898
+ provider: logCtx.provider,
899
+ ...(logCtx.requestedModel ? { requestedModel: logCtx.requestedModel } : {}),
900
+ ...(logCtx.requestedServiceTier ? { requestedServiceTier: logCtx.requestedServiceTier } : {}),
901
+ ...(logCtx.requestedSpeedLabel ? { requestedSpeedLabel: logCtx.requestedSpeedLabel } : {}),
902
+ ...(logCtx.configuredServiceTier ? { configuredServiceTier: logCtx.configuredServiceTier } : {}),
903
+ ...(logCtx.configuredSpeedLabel ? { configuredSpeedLabel: logCtx.configuredSpeedLabel } : {}),
904
+ ...(logCtx.modelSupportsServiceTier !== undefined ? { modelSupportsServiceTier: logCtx.modelSupportsServiceTier } : {}),
905
+ ...(logCtx.responseServiceTier ? { responseServiceTier: logCtx.responseServiceTier } : {}),
906
+ ...(logCtx.resolvedModel ? { resolvedModel: logCtx.resolvedModel } : {}),
907
+ status,
908
+ durationMs: Date.now() - start,
909
+ ...(errorCode ? { errorCode } : {}),
910
+ ...(meta?.terminalStatus ? { terminalStatus: meta.terminalStatus } : {}),
911
+ ...(meta?.closeReason ? { closeReason: meta.closeReason } : {}),
912
+ usageStatus,
913
+ ...(logCtx.usage ? { usage: logCtx.usage } : {}),
914
+ ...(totalTokens !== undefined ? { totalTokens } : {}),
915
+ });
916
+ if (isUsageDebugEnabled()) {
917
+ appendUsageDebug({
918
+ ts: Date.now(),
919
+ requestId,
920
+ provider: logCtx.provider,
921
+ model: logCtx.model,
922
+ upstreamContentType: logCtx.usageDebugContentType ?? null,
923
+ upstreamStatus: status,
924
+ bodyKind: logCtx.usageDebugBodyKind ?? "none",
925
+ bodySample: logCtx.usageDebugBodySample ?? "",
926
+ extractedUsage: logCtx.usage ?? null,
927
+ });
928
+ }
929
+ }
930
+
576
931
  export function filterRequestLogs(logs: RequestLogEntry[], params: URLSearchParams): RequestLogEntry[] {
577
932
  let filtered = logs;
578
933
  const provider = params.get("provider")?.trim();
@@ -662,11 +1017,150 @@ function terminalStatusFromSsePayload(payload: string): ResponsesTerminalStatus
662
1017
  }
663
1018
  }
664
1019
 
1020
+ function trackSseForRequestLog(
1021
+ body: ReadableStream<Uint8Array>,
1022
+ onTerminal: (status: ResponsesTerminalStatus) => void,
1023
+ onCancel: () => void,
1024
+ logCtx?: RequestLogContext,
1025
+ ): ReadableStream<Uint8Array> {
1026
+ const reader = body.getReader();
1027
+ const decoder = new TextDecoder();
1028
+ let buffer = "";
1029
+ let terminalReported = false;
1030
+
1031
+ const reportTerminal = (status: ResponsesTerminalStatus) => {
1032
+ if (terminalReported) return;
1033
+ terminalReported = true;
1034
+ onTerminal(status);
1035
+ };
1036
+
1037
+ const inspectPayload = (payload: string | null) => {
1038
+ if (!payload) return;
1039
+ if (logCtx) inspectResponseLogSsePayload(logCtx, payload);
1040
+ const status = terminalStatusFromSsePayload(payload);
1041
+ if (status) reportTerminal(status);
1042
+ };
1043
+
1044
+ const inspectChunk = (value: Uint8Array) => {
1045
+ buffer += decoder.decode(value, { stream: true });
1046
+ let next: { block: string; rest: string } | null;
1047
+ while ((next = nextSseBlock(buffer))) {
1048
+ buffer = next.rest;
1049
+ inspectPayload(sseDataPayload(next.block));
1050
+ }
1051
+ };
1052
+
1053
+ return new ReadableStream<Uint8Array>({
1054
+ async pull(controller) {
1055
+ try {
1056
+ const { done, value } = await reader.read();
1057
+ if (done) {
1058
+ buffer += decoder.decode();
1059
+ if (buffer.trim()) inspectPayload(sseDataPayload(buffer));
1060
+ if (!terminalReported) reportTerminal("incomplete");
1061
+ controller.close();
1062
+ return;
1063
+ }
1064
+ inspectChunk(value);
1065
+ controller.enqueue(value);
1066
+ } catch (err) {
1067
+ if (!terminalReported) reportTerminal("incomplete");
1068
+ try { controller.error(err); } catch { /* already torn down */ }
1069
+ }
1070
+ },
1071
+ cancel(reason) {
1072
+ onCancel();
1073
+ reader.cancel(reason).catch(() => {});
1074
+ },
1075
+ });
1076
+ }
1077
+
1078
+ export function responseWithDeferredRequestLog(
1079
+ response: Response,
1080
+ requestId: string,
1081
+ start: number,
1082
+ logCtx: RequestLogContext,
1083
+ addLog: (entry: RequestLogEntry) => void = addRequestLog,
1084
+ ): Response {
1085
+ const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
1086
+ if (isUsageDebugEnabled() && !logCtx.usageDebugContentType && contentType) {
1087
+ logCtx.usageDebugContentType = contentType;
1088
+ }
1089
+ if (isNativePassthroughSseResponse(response)) {
1090
+ return response;
1091
+ }
1092
+ if (!response.body || !contentType.includes("text/event-stream")) {
1093
+ if (response.body && contentType.includes("application/json")) {
1094
+ const finalizeJsonLog = async () => {
1095
+ const text = await response.text();
1096
+ inspectResponseLogJson(logCtx, text);
1097
+ addFinalRequestLog(requestId, start, logCtx, response.status, { closeReason: "non_stream" }, addLog);
1098
+ return text;
1099
+ };
1100
+ const body = new ReadableStream<Uint8Array>({
1101
+ async start(controller) {
1102
+ try {
1103
+ controller.enqueue(new TextEncoder().encode(await finalizeJsonLog()));
1104
+ controller.close();
1105
+ } catch (err) {
1106
+ addFinalRequestLog(requestId, start, logCtx, 502, { closeReason: "non_stream" }, addLog);
1107
+ try { controller.error(err); } catch { /* already torn down */ }
1108
+ }
1109
+ },
1110
+ });
1111
+ return new Response(body, {
1112
+ status: response.status,
1113
+ statusText: response.statusText,
1114
+ headers: response.headers,
1115
+ });
1116
+ }
1117
+ if (isUsageDebugEnabled() && logCtx.usageDebugBodyKind === undefined) {
1118
+ logCtx.usageDebugBodyKind = response.body ? "other" : "none";
1119
+ }
1120
+ addFinalRequestLog(requestId, start, logCtx, response.status, { closeReason: "non_stream" }, addLog);
1121
+ return response;
1122
+ }
1123
+
1124
+ let logged = false;
1125
+ const body = trackSseForRequestLog(
1126
+ response.body,
1127
+ status => {
1128
+ if (logged) return;
1129
+ logged = true;
1130
+ addFinalRequestLog(requestId, start, logCtx, httpStatusForTerminalStatus(status), {
1131
+ terminalStatus: status,
1132
+ closeReason: "terminal",
1133
+ }, addLog);
1134
+ },
1135
+ () => {
1136
+ if (logged) return;
1137
+ logged = true;
1138
+ addFinalRequestLog(requestId, start, logCtx, 499, { closeReason: "client_cancel" }, addLog);
1139
+ },
1140
+ logCtx,
1141
+ );
1142
+ return new Response(body, {
1143
+ status: response.status,
1144
+ statusText: response.statusText,
1145
+ headers: response.headers,
1146
+ });
1147
+ }
1148
+
1149
+ function markNativePassthroughSseResponse(response: Response): Response {
1150
+ nativePassthroughSseResponses.add(response);
1151
+ return response;
1152
+ }
1153
+
1154
+ function isNativePassthroughSseResponse(response: Response): boolean {
1155
+ return nativePassthroughSseResponses.has(response);
1156
+ }
1157
+
665
1158
  export function relaySseWithHeartbeat(
666
1159
  body: ReadableStream<Uint8Array> | null,
667
1160
  upstream: AbortController,
668
1161
  heartbeatMs = 15_000,
669
1162
  onTerminal?: (status: ResponsesTerminalStatus) => void,
1163
+ options?: { onStart?: () => void; onDone?: () => void },
670
1164
  ): ReadableStream<Uint8Array> | null {
671
1165
  if (!body) return null;
672
1166
  const reader = body.getReader();
@@ -700,13 +1194,16 @@ export function relaySseWithHeartbeat(
700
1194
  };
701
1195
 
702
1196
  const cleanup = () => {
1197
+ if (closed) return;
703
1198
  closed = true;
704
1199
  if (timer) clearInterval(timer);
705
1200
  timer = undefined;
1201
+ options?.onDone?.();
706
1202
  };
707
1203
 
708
1204
  return new ReadableStream<Uint8Array>({
709
1205
  start(controller) {
1206
+ options?.onStart?.();
710
1207
  timer = setInterval(() => {
711
1208
  if (closed) return;
712
1209
  try {
@@ -751,11 +1248,26 @@ export function relaySseWithHeartbeat(
751
1248
  function consumeForInspection(
752
1249
  body: ReadableStream<Uint8Array>,
753
1250
  onTerminal: (status: ResponsesTerminalStatus) => void,
1251
+ signal?: AbortSignal,
1252
+ onDone?: () => void,
1253
+ logCtx?: RequestLogContext,
754
1254
  ): void {
755
1255
  const reader = body.getReader();
756
1256
  const decoder = new TextDecoder();
757
1257
  let buffer = "";
758
1258
  let reported = false;
1259
+ let cancelled = false;
1260
+ if (signal) {
1261
+ if (signal.aborted) {
1262
+ cancelled = true;
1263
+ reader.cancel(signal.reason).catch(() => {});
1264
+ return;
1265
+ }
1266
+ signal.addEventListener("abort", () => {
1267
+ cancelled = true;
1268
+ reader.cancel(signal.reason).catch(() => {});
1269
+ }, { once: true });
1270
+ }
759
1271
  const pump = async () => {
760
1272
  try {
761
1273
  for (;;) {
@@ -764,12 +1276,13 @@ function consumeForInspection(
764
1276
  buffer += decoder.decode();
765
1277
  if (buffer.trim() && !reported) {
766
1278
  const payload = sseDataPayload(buffer);
1279
+ if (logCtx) inspectResponseLogSsePayload(logCtx, payload);
767
1280
  if (payload) {
768
1281
  const status = terminalStatusFromSsePayload(payload);
769
1282
  if (status) { reported = true; onTerminal(status); }
770
1283
  }
771
1284
  }
772
- if (!reported) onTerminal("incomplete");
1285
+ if (!reported && !cancelled) onTerminal("incomplete");
773
1286
  return;
774
1287
  }
775
1288
  buffer += decoder.decode(value, { stream: true });
@@ -778,6 +1291,7 @@ function consumeForInspection(
778
1291
  buffer = next.rest;
779
1292
  if (!reported) {
780
1293
  const payload = sseDataPayload(next.block);
1294
+ if (logCtx) inspectResponseLogSsePayload(logCtx, payload);
781
1295
  if (payload) {
782
1296
  const status = terminalStatusFromSsePayload(payload);
783
1297
  if (status) { reported = true; onTerminal(status); }
@@ -786,7 +1300,53 @@ function consumeForInspection(
786
1300
  }
787
1301
  }
788
1302
  } catch {
789
- if (!reported) onTerminal("incomplete");
1303
+ if (!reported && !cancelled) onTerminal("incomplete");
1304
+ } finally {
1305
+ onDone?.();
1306
+ }
1307
+ };
1308
+ pump();
1309
+ }
1310
+
1311
+ function consumeForResponseLogMetadata(
1312
+ body: ReadableStream<Uint8Array>,
1313
+ logCtx: RequestLogContext,
1314
+ signal?: AbortSignal,
1315
+ onDone?: () => void,
1316
+ ): void {
1317
+ const reader = body.getReader();
1318
+ const decoder = new TextDecoder();
1319
+ let buffer = "";
1320
+ if (signal) {
1321
+ if (signal.aborted) {
1322
+ reader.cancel(signal.reason).catch(() => {});
1323
+ onDone?.();
1324
+ return;
1325
+ }
1326
+ signal.addEventListener("abort", () => {
1327
+ reader.cancel(signal.reason).catch(() => {});
1328
+ }, { once: true });
1329
+ }
1330
+ const pump = async () => {
1331
+ try {
1332
+ for (;;) {
1333
+ const { done, value } = await reader.read();
1334
+ if (done) {
1335
+ buffer += decoder.decode();
1336
+ if (buffer.trim()) inspectResponseLogSsePayload(logCtx, sseDataPayload(buffer));
1337
+ return;
1338
+ }
1339
+ buffer += decoder.decode(value, { stream: true });
1340
+ let next: { block: string; rest: string } | null;
1341
+ while ((next = nextSseBlock(buffer))) {
1342
+ buffer = next.rest;
1343
+ inspectResponseLogSsePayload(logCtx, sseDataPayload(next.block));
1344
+ }
1345
+ }
1346
+ } catch {
1347
+ /* metadata inspection must not affect the client-facing stream */
1348
+ } finally {
1349
+ onDone?.();
790
1350
  }
791
1351
  };
792
1352
  pump();
@@ -807,6 +1367,8 @@ export function sanitizePassthroughHeaders(upstream: Headers): Headers {
807
1367
  "keep-alive",
808
1368
  "proxy-authenticate",
809
1369
  "proxy-authorization",
1370
+ "set-cookie",
1371
+ "set-cookie2",
810
1372
  "te",
811
1373
  "trailer",
812
1374
  "upgrade",
@@ -820,27 +1382,83 @@ export function sanitizePassthroughHeaders(upstream: Headers): Headers {
820
1382
 
821
1383
  let _corsOrigin = "http://localhost:10100";
822
1384
  function setCorsOrigin(port: number): void { _corsOrigin = `http://localhost:${port}`; }
823
- export function corsHeaders(): Record<string, string> {
1385
+ function configuredPort(): string {
1386
+ try { return new URL(_corsOrigin).port; } catch { return "10100"; }
1387
+ }
1388
+
1389
+ function parseHttpHost(value: string | null): { hostname: string; port: string } | null {
1390
+ if (!value) return null;
1391
+ try {
1392
+ const parsed = new URL(`http://${value}`);
1393
+ return { hostname: parsed.hostname.toLowerCase(), port: parsed.port };
1394
+ } catch {
1395
+ return null;
1396
+ }
1397
+ }
1398
+
1399
+ function isLoopbackRequestHost(value: string | null): boolean {
1400
+ const parsed = parseHttpHost(value);
1401
+ if (!parsed) return true;
1402
+ if (!isLoopbackHostname(parsed.hostname)) return false;
1403
+ return parsed.port === "" || parsed.port === configuredPort();
1404
+ }
1405
+
1406
+ function isLoopbackOriginValue(value: string): boolean {
1407
+ try {
1408
+ const parsed = new URL(value);
1409
+ if (parsed.protocol !== "http:") return false;
1410
+ if (!isLoopbackHostname(parsed.hostname)) return false;
1411
+ return parsed.port === configuredPort();
1412
+ } catch {
1413
+ return false;
1414
+ }
1415
+ }
1416
+
1417
+ function isSameOriginAsRequest(req: Request, origin: string): boolean {
1418
+ try {
1419
+ return origin === new URL(req.url).origin;
1420
+ } catch {
1421
+ return false;
1422
+ }
1423
+ }
1424
+
1425
+ function isAllowedRequestOrigin(req: Request, config: OcxConfig): boolean {
1426
+ const origin = req.headers.get("Origin");
1427
+ if (!isApiAuthRequired(config)) {
1428
+ if (!isLoopbackRequestHost(req.headers.get("Host"))) return false;
1429
+ return !origin || isLoopbackOriginValue(origin);
1430
+ }
1431
+ return !origin || isLoopbackOriginValue(origin) || isSameOriginAsRequest(req, origin);
1432
+ }
1433
+
1434
+ export function corsHeaders(req?: Request, config?: OcxConfig): Record<string, string> {
1435
+ const origin = req?.headers.get("Origin");
1436
+ const allowOrigin = origin && req && config && isAllowedRequestOrigin(req, config) ? origin : _corsOrigin;
824
1437
  return {
825
- "Access-Control-Allow-Origin": _corsOrigin,
1438
+ "Access-Control-Allow-Origin": allowOrigin,
826
1439
  "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
827
1440
  "Access-Control-Allow-Headers": "Content-Type, Authorization, X-OpenCodex-API-Key",
1441
+ "Vary": "Origin",
828
1442
  };
829
1443
  }
830
1444
 
831
- function jsonResponse(data: unknown, status = 200): Response {
832
- return new Response(JSON.stringify(data), {
833
- status,
834
- headers: { "Content-Type": "application/json", ...corsHeaders() },
1445
+ function withCors(response: Response, req: Request, config: OcxConfig): Response {
1446
+ const headers = new Headers(response.headers);
1447
+ for (const [name, value] of Object.entries(corsHeaders(req, config))) {
1448
+ headers.set(name, value);
1449
+ }
1450
+ return new Response(response.body, {
1451
+ status: response.status,
1452
+ statusText: response.statusText,
1453
+ headers,
835
1454
  });
836
1455
  }
837
1456
 
838
- function isLocalOrigin(req: Request): boolean {
839
- const origin = req.headers.get("Origin");
840
- if (!origin) return true;
841
- const localhostOrigin = _corsOrigin;
842
- const loopbackOrigin = _corsOrigin.replace("localhost", "127.0.0.1");
843
- return origin === localhostOrigin || origin === loopbackOrigin;
1457
+ function jsonResponse(data: unknown, status = 200, req?: Request, config?: OcxConfig): Response {
1458
+ return new Response(JSON.stringify(data), {
1459
+ status,
1460
+ headers: { "Content-Type": "application/json", ...corsHeaders(req, config) },
1461
+ });
844
1462
  }
845
1463
 
846
1464
  function configuredApiAuthToken(_config: OcxConfig): string | undefined {
@@ -880,6 +1498,47 @@ function requireApiAuth(req: Request, config: OcxConfig, kind: "management" | "d
880
1498
  return formatErrorResponse(401, "authentication_error", "opencodex API key required");
881
1499
  }
882
1500
 
1501
+ function providerManagementConfigError(name: string, provider: OcxProviderConfig): string | null {
1502
+ const baseUrlError = providerBaseUrlConfigError(provider.baseUrl);
1503
+ if (baseUrlError) return `provider ${name} ${baseUrlError}`;
1504
+ if (provider.authMode === "forward") {
1505
+ const normalizedName = name.trim().toLowerCase();
1506
+ const base = provider.baseUrl.replace(/\/+$/, "");
1507
+ const isBuiltInChatGptForward = (normalizedName === "openai" || normalizedName === "chatgpt")
1508
+ && provider.adapter === "openai-responses"
1509
+ && base === "https://chatgpt.com/backend-api/codex";
1510
+ if (isBuiltInChatGptForward) return null;
1511
+ return `provider ${name} uses reserved authMode "forward"; configure ChatGPT passthrough via the built-in provider`;
1512
+ }
1513
+ return null;
1514
+ }
1515
+
1516
+ function providerBaseUrlConfigError(baseUrl: string): string | null {
1517
+ try {
1518
+ const parsed = new URL(baseUrl.trim());
1519
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "baseUrl must be an http(s) URL";
1520
+ if (parsed.username || parsed.password) return "baseUrl must not include embedded credentials";
1521
+ if (parsed.search || parsed.hash) return "baseUrl must not include query strings or fragments";
1522
+ } catch {
1523
+ return "baseUrl must be a valid URL";
1524
+ }
1525
+ return null;
1526
+ }
1527
+
1528
+ function publicProviderBaseUrl(baseUrl: string): string {
1529
+ try {
1530
+ const parsed = new URL(baseUrl.trim());
1531
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "(invalid URL)";
1532
+ parsed.username = "";
1533
+ parsed.password = "";
1534
+ parsed.search = "";
1535
+ parsed.hash = "";
1536
+ return parsed.toString().replace(/\/$/, baseUrl.endsWith("/") ? "/" : "");
1537
+ } catch {
1538
+ return "(invalid URL)";
1539
+ }
1540
+ }
1541
+
883
1542
  function copyIfDefined<K extends keyof OcxProviderConfig>(
884
1543
  out: Record<string, unknown>,
885
1544
  provider: OcxProviderConfig,
@@ -894,7 +1553,7 @@ export function safeConfigDTO(config: OcxConfig): unknown {
894
1553
  for (const [name, provider] of Object.entries(config.providers)) {
895
1554
  const dto: Record<string, unknown> = {
896
1555
  adapter: provider.adapter,
897
- baseUrl: provider.baseUrl,
1556
+ baseUrl: publicProviderBaseUrl(provider.baseUrl),
898
1557
  hasApiKey: !!provider.apiKey,
899
1558
  hasHeaders: !!provider.headers && Object.keys(provider.headers).length > 0,
900
1559
  };
@@ -931,8 +1590,8 @@ export function safeConfigDTO(config: OcxConfig): unknown {
931
1590
  }
932
1591
 
933
1592
  async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): Promise<Response | null> {
934
- if ((req.method === "POST" || req.method === "PUT" || req.method === "DELETE") && !isLocalOrigin(req)) {
935
- return jsonResponse({ error: "cross-origin request blocked" }, 403);
1593
+ if (!isAllowedRequestOrigin(req, config)) {
1594
+ return jsonResponse({ error: "cross-origin request blocked" }, 403, req, config);
936
1595
  }
937
1596
  async function refreshCodexCatalogBestEffort(): Promise<void> {
938
1597
  try {
@@ -1005,9 +1664,40 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
1005
1664
  return jsonResponse(filterRequestLogs(requestLog, url.searchParams));
1006
1665
  }
1007
1666
 
1667
+ if (url.pathname === "/api/usage" && req.method === "GET") {
1668
+ const range = parseRange(url.searchParams.get("range"));
1669
+ const now = Date.now();
1670
+ try {
1671
+ return jsonResponse(summarizeUsage(readUsageEntries(), range, now));
1672
+ } catch {
1673
+ return jsonResponse({
1674
+ range,
1675
+ since: null,
1676
+ generatedAt: now,
1677
+ summary: {
1678
+ requests: 0,
1679
+ reportedRequests: 0,
1680
+ unreportedRequests: 0,
1681
+ unsupportedRequests: 0,
1682
+ estimatedRequests: 0,
1683
+ inputTokens: 0,
1684
+ outputTokens: 0,
1685
+ cachedInputTokens: 0,
1686
+ reasoningOutputTokens: 0,
1687
+ totalTokens: 0,
1688
+ coverageRatio: 0,
1689
+ },
1690
+ days: [],
1691
+ models: [],
1692
+ providers: [],
1693
+ error: "read_failed",
1694
+ });
1695
+ }
1696
+ }
1697
+
1008
1698
  if (url.pathname === "/api/providers" && req.method === "GET") {
1009
1699
  return jsonResponse(Object.entries(config.providers).map(([name, p]) => ({
1010
- name, adapter: p.adapter, baseUrl: p.baseUrl, defaultModel: p.defaultModel,
1700
+ name, adapter: p.adapter, baseUrl: publicProviderBaseUrl(p.baseUrl), defaultModel: p.defaultModel,
1011
1701
  hasApiKey: !!p.apiKey,
1012
1702
  })));
1013
1703
  }
@@ -1023,6 +1713,11 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
1023
1713
  if (!name || !prov?.adapter || !prov?.baseUrl) {
1024
1714
  return jsonResponse({ error: "name, provider.adapter and provider.baseUrl are required" }, 400);
1025
1715
  }
1716
+ if (!isValidProviderName(name)) {
1717
+ return jsonResponse({ error: "provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key" }, 400);
1718
+ }
1719
+ const providerError = providerManagementConfigError(name, prov);
1720
+ if (providerError) return jsonResponse({ error: providerError }, 400);
1026
1721
  // Catalog providers (e.g. ollama-cloud) carry a models + vision/reasoning classification the GUI
1027
1722
  // doesn't send — merge it in so the sidecars are gated correctly.
1028
1723
  enrichProviderFromCatalog(name, prov);
@@ -1038,7 +1733,8 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
1038
1733
 
1039
1734
  if (url.pathname === "/api/providers" && req.method === "DELETE") {
1040
1735
  const name = url.searchParams.get("name")?.trim();
1041
- if (!name || !config.providers[name]) return jsonResponse({ error: "unknown provider" }, 404);
1736
+ if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
1737
+ if (name === config.defaultProvider) return jsonResponse({ error: "cannot delete the default provider; set another default first" }, 400);
1042
1738
  const { saveConfig: save } = await import("./config");
1043
1739
  delete config.providers[name];
1044
1740
  save(config);
@@ -1199,66 +1895,74 @@ export function startServer(port?: number) {
1199
1895
  const listenPort = port ?? config.port ?? 10100;
1200
1896
  setCorsOrigin(listenPort);
1201
1897
 
1202
- const server = Bun.serve<WsData>({
1898
+ const server: Server<WsData> = Bun.serve<WsData>({
1203
1899
  port: listenPort,
1204
1900
  hostname: config.hostname ?? "127.0.0.1",
1205
1901
  idleTimeout: 255,
1206
- async fetch(req) {
1902
+ async fetch(req, requestServer): Promise<Response> {
1207
1903
  const url = new URL(req.url);
1208
1904
 
1209
1905
  if (req.method === "OPTIONS") {
1210
- return new Response(null, { status: 204, headers: corsHeaders() });
1906
+ if (!isAllowedRequestOrigin(req, config)) {
1907
+ return new Response(null, { status: 403, headers: corsHeaders() });
1908
+ }
1909
+ return new Response(null, { status: 204, headers: corsHeaders(req, config) });
1211
1910
  }
1212
1911
 
1213
1912
  // Responses WebSocket (phase 120.2). Codex upgrades the same /v1/responses path; auth is
1214
1913
  // handshake-time only, so capture inbound headers and thread them into the pipeline.
1215
1914
  if (url.pathname === "/v1/responses" && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
1216
1915
  if (draining) {
1217
- return new Response("Service shutting down", { status: 503, headers: { ...corsHeaders(), "Retry-After": "5" } });
1916
+ return new Response("Service shutting down", { status: 503, headers: { ...corsHeaders(req, config), "Retry-After": "5" } });
1218
1917
  }
1219
1918
  const apiAuthError = requireApiAuth(req, config, "data-plane");
1220
- if (apiAuthError) return apiAuthError;
1221
- if (!isLocalOrigin(req)) {
1222
- return formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin");
1919
+ if (apiAuthError) return withCors(apiAuthError, req, config);
1920
+ if (!isAllowedRequestOrigin(req, config)) {
1921
+ return withCors(formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin"), req, config);
1223
1922
  }
1224
1923
  let authCtx: CodexAuthContext;
1225
1924
  try {
1226
1925
  authCtx = await resolveCodexAuthContext(req.headers, config);
1227
1926
  } catch (err) {
1228
1927
  if (err instanceof CodexAccountCooldownError) {
1229
- return formatErrorResponse(429, "rate_limit_error", "Selected Codex account is cooling down");
1928
+ return withCors(formatErrorResponse(429, "rate_limit_error", "Selected Codex account is cooling down"), req, config);
1230
1929
  }
1231
1930
  if (err instanceof CodexThreadAffinityExpiredError) {
1232
- return formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session");
1931
+ return withCors(formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session"), req, config);
1233
1932
  }
1234
1933
  if (err instanceof CodexAuthContextError) {
1235
1934
  const safeAccountLabel = formatCodexProviderForLog("chatgpt", err.accountId, config);
1236
1935
  console.error(`[codex-auth] Pool account ${safeAccountLabel} token failed during websocket upgrade; reauthentication required`);
1237
- return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
1936
+ return withCors(formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"), req, config);
1238
1937
  }
1239
1938
  throw err;
1240
1939
  }
1241
1940
  if (server.upgrade(req, {
1242
1941
  data: {
1243
- headers: selectForwardHeadersForAuthContext(req.headers, authCtx),
1942
+ headers: selectForwardHeaders(req.headers),
1244
1943
  authContext: authCtx,
1245
1944
  },
1246
1945
  })) return undefined as unknown as Response;
1247
- return formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed");
1946
+ return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, config);
1248
1947
  }
1249
1948
 
1250
1949
  if (url.pathname === "/healthz" && req.method === "GET") {
1251
- return jsonResponse({ status: "ok", version: VERSION, uptime: process.uptime() });
1950
+ return jsonResponse({ status: "ok", version: VERSION, uptime: process.uptime() }, 200, req, config);
1252
1951
  }
1253
1952
 
1254
1953
  if (url.pathname.startsWith("/api/")) {
1255
1954
  const apiAuthError = requireApiAuth(req, config, "management");
1256
- if (apiAuthError) return apiAuthError;
1955
+ if (apiAuthError) return withCors(apiAuthError, req, config);
1257
1956
  const mgmtResponse = await handleManagementAPI(req, url, config);
1258
- if (mgmtResponse) return mgmtResponse;
1957
+ if (mgmtResponse) return withCors(mgmtResponse, req, config);
1259
1958
  }
1260
1959
 
1261
1960
  if (url.pathname === "/v1/models" && req.method === "GET") {
1961
+ const apiAuthError = requireApiAuth(req, config, "data-plane");
1962
+ if (apiAuthError) return withCors(apiAuthError, req, config);
1963
+ if (!isAllowedRequestOrigin(req, config)) {
1964
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
1965
+ }
1262
1966
  const goModels = await fetchAllModels(config);
1263
1967
  const { buildCatalogEntries, loadCatalogTemplate, nativeOpenAiSlugs, orderForSubagents } = await import("./codex-catalog");
1264
1968
  const nativeSlugs = nativeOpenAiSlugs();
@@ -1269,40 +1973,51 @@ export function startServer(port?: number) {
1269
1973
  // Codex client → Codex catalog shape: native gpt + namespaced routed models,
1270
1974
  // cloned from a native template so required fields (base_instructions, etc.) are present.
1271
1975
  // Pass the subagent picks so featured models lead by priority (matches the on-disk file).
1272
- return jsonResponse({ models: buildCatalogEntries(loadCatalogTemplate(), nativeSlugs, goOrdered, config.subagentModels, websocketsEnabled(config)) });
1976
+ return jsonResponse({ models: buildCatalogEntries(loadCatalogTemplate(), nativeSlugs, goOrdered, config.subagentModels, websocketsEnabled(config)) }, 200, req, config);
1273
1977
  }
1274
1978
  // OpenAI list shape: native gpt bare + routed models namespaced "<provider>/<id>"
1275
1979
  const data = [
1276
1980
  ...nativeSlugs.map(id => ({ id, object: "model", created: 0, owned_by: "openai" })),
1277
1981
  ...goOrdered.map(m => ({ id: `${m.provider}/${m.id}`, object: "model", created: 0, owned_by: m.owned_by ?? m.provider })),
1278
1982
  ];
1279
- return jsonResponse({ object: "list", data });
1983
+ return jsonResponse({ object: "list", data }, 200, req, config);
1280
1984
  }
1281
1985
 
1282
1986
  if (url.pathname === "/v1/responses" && req.method === "POST") {
1987
+ disableResponsesRequestTimeout(req, requestServer);
1283
1988
  if (draining) {
1284
- return new Response("Service shutting down", { status: 503, headers: { ...corsHeaders(), "Retry-After": "5" } });
1989
+ return new Response("Service shutting down", { status: 503, headers: { ...corsHeaders(req, config), "Retry-After": "5" } });
1285
1990
  }
1286
1991
  const apiAuthError = requireApiAuth(req, config, "data-plane");
1287
- if (apiAuthError) return apiAuthError;
1288
- if (!isLocalOrigin(req)) {
1289
- return formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked");
1992
+ if (apiAuthError) return withCors(apiAuthError, req, config);
1993
+ if (!isAllowedRequestOrigin(req, config)) {
1994
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
1290
1995
  }
1291
1996
  const start = Date.now();
1292
1997
  const requestId = nextRequestLogId(start);
1293
1998
  const logCtx = { model: "unknown", provider: "unknown" };
1294
- const response = await handleResponses(req, config, logCtx);
1295
- const errorCode = requestLogErrorCode(response.status);
1296
- addRequestLog({
1297
- requestId,
1298
- timestamp: start,
1299
- model: logCtx.model,
1300
- provider: logCtx.provider,
1301
- status: response.status,
1302
- durationMs: Date.now() - start,
1303
- ...(errorCode ? { errorCode } : {}),
1999
+ let logged = false;
2000
+ const finalizeNativePassthroughLog = (
2001
+ status: number,
2002
+ meta: { terminalStatus?: ResponsesTerminalStatus; closeReason: "terminal" | "client_cancel" },
2003
+ ) => {
2004
+ if (logged) return;
2005
+ logged = true;
2006
+ addFinalRequestLog(requestId, start, logCtx, status, meta);
2007
+ };
2008
+ const response = await handleResponses(req, config, logCtx, {
2009
+ abortSignal: req.signal,
2010
+ onNativePassthroughTerminal: status => {
2011
+ finalizeNativePassthroughLog(httpStatusForTerminalStatus(status), {
2012
+ terminalStatus: status,
2013
+ closeReason: "terminal",
2014
+ });
2015
+ },
2016
+ onNativePassthroughCancel: () => {
2017
+ finalizeNativePassthroughLog(499, { closeReason: "client_cancel" });
2018
+ },
1304
2019
  });
1305
- return response;
2020
+ return withCors(responseWithDeferredRequestLog(response, requestId, start, logCtx), req, config);
1306
2021
  }
1307
2022
 
1308
2023
  const guiFile = serveGuiFile(url.pathname);
@@ -1311,9 +2026,10 @@ export function startServer(port?: number) {
1311
2026
  return jsonResponse(rootFallbackPayload());
1312
2027
  }
1313
2028
 
1314
- return formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`);
2029
+ return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
1315
2030
  },
1316
2031
  websocket: {
2032
+ idleTimeout: WEBSOCKET_IDLE_TIMEOUT_SECONDS,
1317
2033
  // Responses WebSocket data plane (phase 120.2). Re-frames the same SSE pipeline onto the
1318
2034
  // socket: parse response.create → run handleResponses unchanged → pump its SSE body as WS
1319
2035
  // Text frames. response.processed is a no-op ack. close() aborts the upstream (RC2 parity).
@@ -1321,6 +2037,15 @@ export function startServer(port?: number) {
1321
2037
  registerCodexWebSocket(ws);
1322
2038
  },
1323
2039
  message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
2040
+ const rawBytes = typeof raw === "string" ? Buffer.byteLength(raw) : raw.byteLength;
2041
+ if (rawBytes > MAX_WS_FRAME_BYTES) {
2042
+ sendJsonFrame(ws, buildWsErrorFrame(413, {
2043
+ type: "invalid_request_error",
2044
+ message: "WebSocket response.create frame is too large",
2045
+ }));
2046
+ ws.close(1009, "message too large");
2047
+ return;
2048
+ }
1324
2049
  let frame: Record<string, unknown>;
1325
2050
  try {
1326
2051
  frame = JSON.parse(typeof raw === "string" ? raw : raw.toString()) as Record<string, unknown>;
@@ -1351,41 +2076,97 @@ export function startServer(port?: number) {
1351
2076
 
1352
2077
  const payload: Record<string, unknown> = { ...frame };
1353
2078
  delete payload.type;
1354
- const wsTurnAc = new AbortController();
1355
- registerTurn(wsTurnAc);
2079
+ registerTurn(turnAbort);
1356
2080
  void (async () => {
2081
+ const start = Date.now();
2082
+ const requestId = nextRequestLogId(start);
1357
2083
  const logCtx = { model: "unknown", provider: "unknown" };
2084
+ let logged = false;
2085
+ const finalizeLog = (status: number) => {
2086
+ if (logged) return;
2087
+ logged = true;
2088
+ addFinalRequestLog(requestId, start, logCtx, status);
2089
+ };
2090
+ const baseHeaders = ws.data.headers ?? new Headers();
2091
+ let authCtx: CodexAuthContext;
2092
+ let selectedForwardHeaders: Headers;
2093
+ try {
2094
+ authCtx = await resolveCodexAuthContext(baseHeaders, config);
2095
+ selectedForwardHeaders = headersForCodexAuthContext(baseHeaders, authCtx);
2096
+ updateCodexWebSocketAuthContext(ws, authCtx);
2097
+ } catch (err) {
2098
+ if (!isCurrent()) return;
2099
+ if (err instanceof CodexAccountCooldownError) {
2100
+ finalizeLog(429);
2101
+ sendJsonFrame(ws, buildWsErrorFrame(429, {
2102
+ type: "rate_limit_error",
2103
+ message: "Selected Codex account is cooling down",
2104
+ }));
2105
+ return;
2106
+ }
2107
+ if (err instanceof CodexThreadAffinityExpiredError) {
2108
+ finalizeLog(409);
2109
+ sendJsonFrame(ws, buildWsErrorFrame(409, {
2110
+ type: "invalid_request_error",
2111
+ message: "Codex thread account affinity expired; start a new session",
2112
+ }));
2113
+ return;
2114
+ }
2115
+ if (err instanceof CodexAuthContextError) {
2116
+ const safeAccountLabel = formatCodexProviderForLog("chatgpt", err.accountId, config);
2117
+ console.error(`[codex-auth] Pool account ${safeAccountLabel} token failed during websocket turn; reauthentication required`);
2118
+ finalizeLog(401);
2119
+ sendJsonFrame(ws, buildWsErrorFrame(401, {
2120
+ type: "authentication_error",
2121
+ message: "Selected Codex account needs reauthentication",
2122
+ }));
2123
+ return;
2124
+ }
2125
+ finalizeLog(502);
2126
+ sendJsonFrame(ws, buildWsErrorFrame(502, {
2127
+ type: "proxy_error",
2128
+ message: err instanceof Error ? err.message : String(err),
2129
+ }));
2130
+ return;
2131
+ }
1358
2132
  const fwd = new Headers({ "content-type": "application/json" });
1359
- ws.data.headers?.forEach((value, key) => fwd.set(key, value));
2133
+ selectedForwardHeaders.forEach((value, key) => fwd.set(key, value));
1360
2134
  const req = new Request("http://localhost/v1/responses", {
1361
2135
  method: "POST",
1362
2136
  headers: fwd,
1363
2137
  body: JSON.stringify({ ...payload, stream: true }),
1364
2138
  });
1365
2139
  try {
1366
- assertCodexAuthContextNotCooled(ws.data.authContext);
1367
2140
  let terminalRecorder: ((status: ResponsesTerminalStatus) => void) | undefined;
1368
2141
  const response = await handleResponses(req, config, logCtx, {
1369
2142
  forceEmptyResponseId: true,
1370
2143
  abortSignal: turnAbort.signal,
1371
- authContext: ws.data.authContext,
1372
- selectedForwardHeaders: ws.data.headers,
2144
+ authContext: authCtx,
2145
+ selectedForwardHeaders,
1373
2146
  recordTerminalOutcomes: false,
1374
2147
  setTerminalOutcomeRecorder: recorder => {
1375
2148
  terminalRecorder = recorder;
1376
2149
  },
1377
2150
  });
1378
- await sendResponseToWebSocket(ws, response, isCurrent, { onTerminal: terminalRecorder });
2151
+ await sendResponseToWebSocket(ws, response, isCurrent, {
2152
+ onTerminal: status => {
2153
+ terminalRecorder?.(status);
2154
+ finalizeLog(httpStatusForTerminalStatus(status));
2155
+ },
2156
+ });
2157
+ if (!logged) finalizeLog(turnAbort.signal.aborted ? 499 : response.status);
1379
2158
  } catch (err) {
1380
2159
  if (!isCurrent()) return;
1381
2160
  try {
1382
2161
  if (err instanceof CodexAccountCooldownError) {
2162
+ finalizeLog(429);
1383
2163
  sendJsonFrame(ws, buildWsErrorFrame(429, {
1384
2164
  type: "rate_limit_error",
1385
2165
  message: "Selected Codex account is cooling down",
1386
2166
  }));
1387
2167
  return;
1388
2168
  }
2169
+ finalizeLog(502);
1389
2170
  sendJsonFrame(ws, buildWsErrorFrame(502, {
1390
2171
  type: "proxy_error",
1391
2172
  message: err instanceof Error ? err.message : String(err),
@@ -1394,7 +2175,8 @@ export function startServer(port?: number) {
1394
2175
  /* socket already gone or send dropped */
1395
2176
  }
1396
2177
  } finally {
1397
- unregisterTurn(wsTurnAc);
2178
+ unregisterTurn(turnAbort);
2179
+ if (!logged && turnAbort.signal.aborted) finalizeLog(499);
1398
2180
  if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined;
1399
2181
  }
1400
2182
  })();
@@ -1407,8 +2189,10 @@ export function startServer(port?: number) {
1407
2189
  });
1408
2190
 
1409
2191
  _serverRef = server;
2192
+ const actualPort = server.port ?? listenPort;
2193
+ setCorsOrigin(actualPort);
1410
2194
 
1411
- console.log(`🚀 opencodex proxy running on http://localhost:${listenPort}`);
2195
+ console.log(`🚀 opencodex proxy running on http://localhost:${actualPort}`);
1412
2196
  console.log(` POST /v1/responses → provider translation`);
1413
2197
  console.log(` GET /healthz → health check`);
1414
2198
  console.log(` GET /api/* → management API`);