@bitkyc08/opencodex 2.1.8 → 2.1.9

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/src/server.ts CHANGED
@@ -1,15 +1,16 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
+ import { timingSafeEqual } from "node:crypto";
2
3
  import { extname, join } from "node:path";
3
4
  import { createAnthropicAdapter } from "./adapters/anthropic";
4
5
  import { createAzureAdapter } from "./adapters/azure";
5
6
  import { createGoogleAdapter } from "./adapters/google";
6
7
  import { createOpenAIChatAdapter } from "./adapters/openai-chat";
7
8
  import { createResponsesPassthroughAdapter } from "./adapters/openai-responses";
8
- import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse } from "./bridge";
9
+ import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "./bridge";
9
10
  import {
10
11
  buildWarmupCompletionFrames,
11
12
  buildWsErrorFrame,
12
- selectForwardHeaders,
13
+ selectForwardHeadersForAuthContext,
13
14
  sendJsonFrame,
14
15
  sendResponseToWebSocket,
15
16
  sendTextFrame,
@@ -25,12 +26,36 @@ import {
25
26
  listOAuthProviders, reconcileOAuthProviders, startLoginFlow, upsertOAuthProvider,
26
27
  } from "./oauth/index";
27
28
  import type { CatalogModel } from "./codex-catalog";
29
+ import { invalidateCodexModelsCache } from "./codex-catalog";
28
30
  import { buildWebSearchTool, planWebSearch, runWithWebSearch } from "./web-search";
29
31
  import { describeImagesInPlace, planVisionSidecar } from "./vision";
30
32
  import { removeCredential } from "./oauth/store";
31
33
  import { enrichProviderFromCatalog, listKeyLoginProviders } from "./oauth/key-providers";
32
34
  import { deriveProviderPresets } from "./providers/derive";
33
35
  import type { OcxConfig, OcxProviderConfig } from "./types";
36
+ import {
37
+ applyCodexAuthContextToProvider,
38
+ assertCodexAuthContextNotCooled,
39
+ CodexAccountCooldownError,
40
+ CodexAuthContextError,
41
+ CodexThreadAffinityExpiredError,
42
+ headersForCodexAuthContext,
43
+ isCodexAuthContextUsable,
44
+ resolveCodexAuthContext,
45
+ stripCodexRuntimeProviderFields,
46
+ type CodexAuthContext,
47
+ } from "./codex-auth-context";
48
+ export {
49
+ clearThreadAccountMap,
50
+ formatCodexProviderForLog,
51
+ resolveCodexAccountForThread,
52
+ } from "./codex-routing";
53
+ import {
54
+ formatCodexProviderForLog,
55
+ recordCodexUpstreamOutcome,
56
+ type CodexUpstreamOutcome,
57
+ } from "./codex-routing";
58
+ import { registerCodexWebSocket, unregisterCodexWebSocket } from "./codex-websocket-registry";
34
59
 
35
60
  // Single source of truth = package.json (../ from src/), so /healthz + the GUI badge match the
36
61
  // installed npm version instead of a stale hardcode.
@@ -116,11 +141,40 @@ export function resolveAdapter(providerConfig: OcxProviderConfig) {
116
141
  }
117
142
  }
118
143
 
144
+ function sidecarOutcomeRecorder(config: OcxConfig, authCtx: CodexAuthContext): ((outcome: CodexUpstreamOutcome) => void) | undefined {
145
+ return authCtx.kind === "pool"
146
+ ? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome)
147
+ : undefined;
148
+ }
149
+
150
+ function usesCodexForwardPoolAuth(
151
+ authCtx: CodexAuthContext,
152
+ provider: OcxProviderConfig,
153
+ ): authCtx is Extract<CodexAuthContext, { kind: "pool" }> {
154
+ return authCtx.kind === "pool" && provider.authMode === "forward" && provider.adapter === "openai-responses";
155
+ }
156
+
157
+ function codexForwardTerminalOutcomeRecorder(
158
+ config: OcxConfig,
159
+ authCtx: CodexAuthContext,
160
+ provider: OcxProviderConfig,
161
+ ): ((status: ResponsesTerminalStatus) => void) | undefined {
162
+ if (!usesCodexForwardPoolAuth(authCtx, provider)) return undefined;
163
+ return status => recordCodexUpstreamOutcome(config, authCtx.accountId, status === "completed" ? 200 : 502);
164
+ }
165
+
119
166
  async function handleResponses(
120
167
  req: Request,
121
168
  config: OcxConfig,
122
169
  logCtx: { model: string; provider: string },
123
- options: { forceEmptyResponseId?: boolean; abortSignal?: AbortSignal } = {},
170
+ options: {
171
+ forceEmptyResponseId?: boolean;
172
+ abortSignal?: AbortSignal;
173
+ authContext?: CodexAuthContext;
174
+ selectedForwardHeaders?: Headers;
175
+ recordTerminalOutcomes?: boolean;
176
+ setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus) => void) | undefined) => void;
177
+ } = {},
124
178
  ): Promise<Response> {
125
179
  let body: unknown;
126
180
  try {
@@ -155,6 +209,31 @@ async function handleResponses(
155
209
  logCtx.model = route.modelId;
156
210
  logCtx.provider = route.providerName;
157
211
 
212
+ let authCtx: CodexAuthContext;
213
+ let selectedForwardHeaders: Headers;
214
+ try {
215
+ authCtx = options.authContext ?? await resolveCodexAuthContext(req.headers, config);
216
+ selectedForwardHeaders = options.selectedForwardHeaders ?? headersForCodexAuthContext(req.headers, authCtx);
217
+ } catch (err) {
218
+ if (err instanceof CodexAccountCooldownError) {
219
+ return formatErrorResponse(429, "rate_limit_error", "Selected Codex account is cooling down");
220
+ }
221
+ if (err instanceof CodexThreadAffinityExpiredError) {
222
+ return formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session");
223
+ }
224
+ if (err instanceof CodexAuthContextError) {
225
+ const safeAccountLabel = formatCodexProviderForLog(route.providerName, err.accountId, config);
226
+ console.error(`[codex-auth] Pool account ${safeAccountLabel} token failed; reauthentication required`);
227
+ return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
228
+ }
229
+ throw err;
230
+ }
231
+ if (!isCodexAuthContextUsable(authCtx, config)) {
232
+ return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
233
+ }
234
+ route.provider = applyCodexAuthContextToProvider(route.provider, authCtx);
235
+ logCtx.provider = formatCodexProviderForLog(route.providerName, authCtx.kind === "pool" ? authCtx.accountId : null, config);
236
+
158
237
  // OAuth providers: swap in a fresh access token (auto-refreshed) as the Bearer key, so the
159
238
  // existing openai-chat / anthropic adapters authenticate with no change.
160
239
  if (route.provider.authMode === "oauth") {
@@ -168,16 +247,18 @@ async function handleResponses(
168
247
  // Vision sidecar: the routed model can't see images (provider.noVisionModels). Give it "eyes" —
169
248
  // describe each attached image with a gpt vision model via the ChatGPT passthrough and replace it
170
249
  // with text BEFORE the main call, so the text-only model can reason about it.
171
- const visionPlan = planVisionSidecar(config, route.provider, route.modelId, parsed, req.headers);
250
+ const visionPlan = planVisionSidecar(config, route.provider, route.modelId, parsed, selectedForwardHeaders, authCtx);
251
+ const recordSidecarOutcome = sidecarOutcomeRecorder(config, authCtx);
172
252
  if (visionPlan) {
173
- await describeImagesInPlace(parsed, visionPlan.forwardProvider, req.headers, visionPlan.settings, options.abortSignal);
253
+ await describeImagesInPlace(parsed, visionPlan.forwardProvider, selectedForwardHeaders, visionPlan.settings, options.abortSignal, recordSidecarOutcome);
174
254
  }
175
255
 
176
256
  const adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider);
177
257
  const adapter = resolveAdapter(adapterProvider);
258
+ const recordTerminalOutcomes = options.recordTerminalOutcomes !== false;
178
259
 
179
260
  if ("passthrough" in adapter && adapter.passthrough) {
180
- const request = adapter.buildRequest(parsed, { headers: req.headers });
261
+ const request = adapter.buildRequest(parsed, { headers: selectedForwardHeaders });
181
262
  // Abort the upstream if the client disconnects. A directly-relayed body does not propagate the
182
263
  // consumer's cancel to a signalled fetch, so we pass the signal and relay through relayWithAbort,
183
264
  // whose cancel() aborts the upstream — preventing leaked connections (RC2, passthrough path).
@@ -193,15 +274,55 @@ async function handleResponses(
193
274
  }, upstream.signal, connectMs);
194
275
  } catch (err) {
195
276
  upstream.abort();
196
- const msg = err instanceof Error && err.name === "TimeoutError"
277
+ const outcome = err instanceof Error && err.name === "TimeoutError" ? "timeout" : "connect_error";
278
+ if (usesCodexForwardPoolAuth(authCtx, route.provider)) recordCodexUpstreamOutcome(config, authCtx.accountId, outcome);
279
+ const msg = outcome === "timeout"
197
280
  ? `Provider connect timeout after ${connectMs}ms`
198
281
  : `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
199
282
  return formatErrorResponse(502, "upstream_error", msg);
200
283
  }
201
284
  const headers = sanitizePassthroughHeaders(upstreamResponse.headers);
202
285
  const isEventStream = headers.get("content-type")?.toLowerCase().includes("text/event-stream") ?? false;
286
+ const terminalRecorder = codexForwardTerminalOutcomeRecorder(config, authCtx, route.provider);
287
+ const terminalBodyWillRecord = !!terminalRecorder && upstreamResponse.ok && isEventStream;
288
+ // Capture quota from upstream response for multi-account tracking
289
+ if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
290
+ const weeklyRaw = upstreamResponse.headers.get("x-codex-secondary-used-percent");
291
+ const fiveHourRaw = upstreamResponse.headers.get("x-codex-primary-used-percent");
292
+ const monthlyRaw = upstreamResponse.headers.get("x-codex-tertiary-used-percent");
293
+ const weeklyResetRaw = upstreamResponse.headers.get("x-codex-secondary-reset-at");
294
+ const fiveHourResetRaw = upstreamResponse.headers.get("x-codex-primary-reset-at");
295
+ const monthlyResetRaw = upstreamResponse.headers.get("x-codex-tertiary-reset-at");
296
+ const retryAfterRaw = upstreamResponse.headers.get("retry-after");
297
+ if (weeklyRaw || fiveHourRaw || monthlyRaw) {
298
+ const { updateAccountQuota } = await import("./codex-auth-api");
299
+ updateAccountQuota(
300
+ authCtx.accountId,
301
+ weeklyRaw,
302
+ fiveHourRaw,
303
+ weeklyResetRaw,
304
+ fiveHourResetRaw,
305
+ monthlyRaw,
306
+ monthlyResetRaw,
307
+ );
308
+ }
309
+ if (terminalBodyWillRecord) {
310
+ options.setTerminalOutcomeRecorder?.(terminalRecorder);
311
+ } else {
312
+ recordCodexUpstreamOutcome(config, authCtx.accountId, upstreamResponse.status, {
313
+ retryAfter: retryAfterRaw,
314
+ resetAt: [fiveHourResetRaw, weeklyResetRaw, monthlyResetRaw],
315
+ });
316
+ }
317
+ }
318
+
203
319
  const body = isEventStream
204
- ? relaySseWithHeartbeat(upstreamResponse.body, upstream)
320
+ ? relaySseWithHeartbeat(
321
+ upstreamResponse.body,
322
+ upstream,
323
+ 15_000,
324
+ terminalBodyWillRecord && recordTerminalOutcomes ? terminalRecorder : undefined,
325
+ )
205
326
  : relayWithAbort(upstreamResponse.body, upstream);
206
327
  return new Response(body, {
207
328
  status: upstreamResponse.status,
@@ -212,18 +333,19 @@ async function handleResponses(
212
333
  // Web-search sidecar: Codex enabled web_search but this is a routed (non-OpenAI) model that can't
213
334
  // run it server-side. Expose web_search as a function tool and run searches via the gpt-mini sidecar
214
335
  // through the ChatGPT passthrough, looping until the model answers. Otherwise take the normal path.
215
- const wsPlan = planWebSearch(config, parsed, false, req.headers, route.provider, route.modelId);
336
+ const wsPlan = planWebSearch(config, parsed, false, selectedForwardHeaders, route.provider, route.modelId, authCtx);
216
337
  if (wsPlan) {
217
338
  parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()];
218
339
  return runWithWebSearch({
219
340
  parsed, adapter,
220
341
  forwardProvider: wsPlan.forwardProvider,
221
342
  hostedTool: wsPlan.hostedTool,
222
- incomingHeaders: req.headers,
343
+ selectedForwardHeaders,
223
344
  settings: wsPlan.settings,
224
345
  maxSearches: wsPlan.maxSearches,
225
346
  forceEmptyResponseId: true,
226
347
  abortSignal: options.abortSignal,
348
+ recordSidecarOutcome,
227
349
  });
228
350
  }
229
351
 
@@ -231,7 +353,7 @@ async function handleResponses(
231
353
  linkAbortSignal(upstream, options.abortSignal);
232
354
  const connectMs = config.connectTimeoutMs ?? 30_000;
233
355
 
234
- const request = adapter.buildRequest(parsed, { headers: req.headers });
356
+ const request = adapter.buildRequest(parsed, { headers: selectedForwardHeaders });
235
357
  let upstreamResponse: Response;
236
358
  try {
237
359
  upstreamResponse = await fetchWithHeaderTimeout(request.url, {
@@ -366,16 +488,80 @@ export function relayWithAbort(
366
488
  });
367
489
  }
368
490
 
491
+ function nextSseBlock(buffer: string): { block: string; rest: string } | null {
492
+ const match = buffer.match(/\r?\n\r?\n/);
493
+ if (!match || match.index === undefined) return null;
494
+ return {
495
+ block: buffer.slice(0, match.index),
496
+ rest: buffer.slice(match.index + match[0].length),
497
+ };
498
+ }
499
+
500
+ function sseDataPayload(block: string): string | null {
501
+ const data: string[] = [];
502
+ for (const line of block.split(/\r?\n/)) {
503
+ if (!line.startsWith("data:")) continue;
504
+ const value = line.slice(5);
505
+ data.push(value.startsWith(" ") ? value.slice(1) : value);
506
+ }
507
+ return data.length > 0 ? data.join("\n") : null;
508
+ }
509
+
510
+ function terminalStatusFromSsePayload(payload: string): ResponsesTerminalStatus | null {
511
+ if (payload === "[DONE]") return null;
512
+ try {
513
+ const json = JSON.parse(payload) as { type?: unknown };
514
+ switch (json.type) {
515
+ case "response.completed":
516
+ return "completed";
517
+ case "response.failed":
518
+ return "failed";
519
+ case "response.incomplete":
520
+ return "incomplete";
521
+ default:
522
+ return null;
523
+ }
524
+ } catch {
525
+ return null;
526
+ }
527
+ }
528
+
369
529
  export function relaySseWithHeartbeat(
370
530
  body: ReadableStream<Uint8Array> | null,
371
531
  upstream: AbortController,
372
532
  heartbeatMs = 15_000,
533
+ onTerminal?: (status: ResponsesTerminalStatus) => void,
373
534
  ): ReadableStream<Uint8Array> | null {
374
535
  if (!body) return null;
375
536
  const reader = body.getReader();
537
+ const decoder = new TextDecoder();
376
538
  const heartbeat = new TextEncoder().encode(": opencodex keepalive\n\n");
377
539
  let timer: ReturnType<typeof setInterval> | undefined;
378
540
  let closed = false;
541
+ let clientCancelled = false;
542
+ let terminalReported = false;
543
+ let buffer = "";
544
+
545
+ const reportTerminal = (status: ResponsesTerminalStatus) => {
546
+ if (terminalReported || clientCancelled || closed) return;
547
+ terminalReported = true;
548
+ onTerminal?.(status);
549
+ };
550
+
551
+ const inspectPayload = (payload: string | null) => {
552
+ if (!payload) return;
553
+ const status = terminalStatusFromSsePayload(payload);
554
+ if (status) reportTerminal(status);
555
+ };
556
+
557
+ const inspectChunk = (value: Uint8Array) => {
558
+ buffer += decoder.decode(value, { stream: true });
559
+ let next: { block: string; rest: string } | null;
560
+ while ((next = nextSseBlock(buffer))) {
561
+ buffer = next.rest;
562
+ inspectPayload(sseDataPayload(next.block));
563
+ }
564
+ };
379
565
 
380
566
  const cleanup = () => {
381
567
  closed = true;
@@ -398,17 +584,23 @@ export function relaySseWithHeartbeat(
398
584
  try {
399
585
  const { done, value } = await reader.read();
400
586
  if (done) {
587
+ buffer += decoder.decode();
588
+ if (buffer.trim()) inspectPayload(sseDataPayload(buffer));
589
+ if (!terminalReported && !clientCancelled) reportTerminal("incomplete");
401
590
  cleanup();
402
591
  controller.close();
403
592
  return;
404
593
  }
594
+ inspectChunk(value);
405
595
  controller.enqueue(value);
406
596
  } catch (err) {
597
+ if (!clientCancelled) reportTerminal("incomplete");
407
598
  cleanup();
408
599
  try { controller.error(err); } catch { /* already torn down */ }
409
600
  }
410
601
  },
411
602
  cancel(reason) {
603
+ clientCancelled = true;
412
604
  cleanup();
413
605
  upstream.abort(reason);
414
606
  reader.cancel(reason).catch(() => {});
@@ -444,11 +636,11 @@ export function sanitizePassthroughHeaders(upstream: Headers): Headers {
444
636
 
445
637
  let _corsOrigin = "http://localhost:10100";
446
638
  function setCorsOrigin(port: number): void { _corsOrigin = `http://localhost:${port}`; }
447
- function corsHeaders(): Record<string, string> {
639
+ export function corsHeaders(): Record<string, string> {
448
640
  return {
449
641
  "Access-Control-Allow-Origin": _corsOrigin,
450
642
  "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS",
451
- "Access-Control-Allow-Headers": "Content-Type, Authorization",
643
+ "Access-Control-Allow-Headers": "Content-Type, Authorization, X-OpenCodex-API-Key",
452
644
  };
453
645
  }
454
646
 
@@ -467,6 +659,93 @@ function isLocalOrigin(req: Request): boolean {
467
659
  return origin === localhostOrigin || origin === loopbackOrigin;
468
660
  }
469
661
 
662
+ function configuredApiAuthToken(_config: OcxConfig): string | undefined {
663
+ const token = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
664
+ return token || undefined;
665
+ }
666
+
667
+ export function isLoopbackHostname(hostname: string | undefined): boolean {
668
+ const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase();
669
+ return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]";
670
+ }
671
+
672
+ export function isApiAuthRequired(config: OcxConfig): boolean {
673
+ return !isLoopbackHostname(config.hostname);
674
+ }
675
+
676
+ export function assertServerAuthConfig(config: OcxConfig): void {
677
+ if (isApiAuthRequired(config) && !configuredApiAuthToken(config)) {
678
+ throw new Error("OPENCODEX_API_AUTH_TOKEN is required when binding opencodex to a non-loopback hostname");
679
+ }
680
+ }
681
+
682
+ export function hasValidApiAuth(req: Request, config: OcxConfig): boolean {
683
+ if (!isApiAuthRequired(config)) return true;
684
+ const expected = configuredApiAuthToken(config);
685
+ const actual = req.headers.get("x-opencodex-api-key")?.trim();
686
+ if (!expected || !actual) return false;
687
+ const enc = new TextEncoder();
688
+ const expectedBytes = enc.encode(expected);
689
+ const actualBytes = enc.encode(actual);
690
+ return expectedBytes.length === actualBytes.length && timingSafeEqual(actualBytes, expectedBytes);
691
+ }
692
+
693
+ function requireApiAuth(req: Request, config: OcxConfig, kind: "management" | "data-plane"): Response | null {
694
+ if (hasValidApiAuth(req, config)) return null;
695
+ if (kind === "management") return jsonResponse({ error: "opencodex API key required" }, 401);
696
+ return formatErrorResponse(401, "authentication_error", "opencodex API key required");
697
+ }
698
+
699
+ function copyIfDefined<K extends keyof OcxProviderConfig>(
700
+ out: Record<string, unknown>,
701
+ provider: OcxProviderConfig,
702
+ key: K,
703
+ ): void {
704
+ const value = provider[key];
705
+ if (value !== undefined) out[key as string] = value as unknown;
706
+ }
707
+
708
+ export function safeConfigDTO(config: OcxConfig): unknown {
709
+ const providers: Record<string, Record<string, unknown>> = {};
710
+ for (const [name, provider] of Object.entries(config.providers)) {
711
+ const dto: Record<string, unknown> = {
712
+ adapter: provider.adapter,
713
+ baseUrl: provider.baseUrl,
714
+ hasApiKey: !!provider.apiKey,
715
+ hasHeaders: !!provider.headers && Object.keys(provider.headers).length > 0,
716
+ };
717
+ for (const key of [
718
+ "defaultModel",
719
+ "authMode",
720
+ "liveModels",
721
+ "models",
722
+ "contextWindow",
723
+ "modelContextWindows",
724
+ "reasoningEfforts",
725
+ "modelReasoningEfforts",
726
+ "noVisionModels",
727
+ "noReasoningModels",
728
+ "noTemperatureModels",
729
+ "noTopPModels",
730
+ "noPenaltyModels",
731
+ "autoToolChoiceOnlyModels",
732
+ "preserveReasoningContentModels",
733
+ "escapeBuiltinToolNames",
734
+ ] as const) {
735
+ copyIfDefined(dto, provider, key);
736
+ }
737
+ providers[name] = dto;
738
+ }
739
+ return {
740
+ port: config.port,
741
+ hostname: config.hostname ?? "127.0.0.1",
742
+ defaultProvider: config.defaultProvider,
743
+ codexAutoStart: codexAutoStartEnabled(config),
744
+ websockets: config.websockets,
745
+ providers,
746
+ };
747
+ }
748
+
470
749
  async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): Promise<Response | null> {
471
750
  if ((req.method === "POST" || req.method === "PUT" || req.method === "DELETE") && !isLocalOrigin(req)) {
472
751
  return jsonResponse({ error: "cross-origin request blocked" }, 403);
@@ -481,12 +760,7 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
481
760
  }
482
761
 
483
762
  if (url.pathname === "/api/config" && req.method === "GET") {
484
- const safeConfig = JSON.parse(JSON.stringify(config));
485
- safeConfig.codexAutoStart = codexAutoStartEnabled(config);
486
- for (const prov of Object.values(safeConfig.providers as Record<string, OcxProviderConfig>)) {
487
- if (prov.apiKey) prov.apiKey = prov.apiKey.slice(0, 8) + "...";
488
- }
489
- return jsonResponse(safeConfig);
763
+ return jsonResponse(safeConfigDTO(config));
490
764
  }
491
765
 
492
766
  if (url.pathname === "/api/config" && req.method === "PUT") {
@@ -561,7 +835,7 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
561
835
  let body: { name?: string; provider?: OcxProviderConfig; setDefault?: boolean };
562
836
  try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
563
837
  const name = body.name?.trim();
564
- const prov = body.provider;
838
+ const prov = body.provider ? stripCodexRuntimeProviderFields(body.provider) : undefined;
565
839
  if (!name || !prov?.adapter || !prov?.baseUrl) {
566
840
  return jsonResponse({ error: "name, provider.adapter and provider.baseUrl are required" }, 400);
567
841
  }
@@ -572,6 +846,8 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
572
846
  config.providers[name] = prov;
573
847
  if (body.setDefault) config.defaultProvider = name;
574
848
  save(config);
849
+ const { clearModelCache } = await import("./model-cache");
850
+ clearModelCache(name);
575
851
  await refreshCodexCatalogBestEffort();
576
852
  return jsonResponse({ success: true, name });
577
853
  }
@@ -582,7 +858,8 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
582
858
  const { saveConfig: save } = await import("./config");
583
859
  delete config.providers[name];
584
860
  save(config);
585
- // Drop its models from Codex's catalog immediately (re-sync + cache bust) so removal is live.
861
+ const { clearModelCache: clearCache } = await import("./model-cache");
862
+ clearCache(name);
586
863
  await refreshCodexCatalogBestEffort();
587
864
  return jsonResponse({ success: true });
588
865
  }
@@ -694,6 +971,11 @@ async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): P
694
971
  return jsonResponse({ success: true, message: "Proxy stopping, native Codex restored." });
695
972
  }
696
973
 
974
+ if (url.pathname.startsWith("/api/codex-auth/")) {
975
+ const { handleCodexAuthAPI } = await import("./codex-auth-api");
976
+ return handleCodexAuthAPI(req, url, config);
977
+ }
978
+
697
979
  return null;
698
980
  }
699
981
 
@@ -710,15 +992,23 @@ async function fetchAllModels(config: OcxConfig): Promise<CatalogModel[]> {
710
992
 
711
993
  export function startServer(port?: number) {
712
994
  const config = loadConfig();
995
+ assertServerAuthConfig(config);
713
996
  // Refresh OAuth provider presets (models/noReasoningModels) from the registry so a proxy update
714
997
  // adding/dropping models reaches existing configs on start — not just fresh installs.
715
998
  reconcileOAuthProviders(config);
999
+ // Ensure the ChatGPT passthrough provider exists so gpt-* models route correctly.
1000
+ if (!config.providers["chatgpt"]) {
1001
+ upsertOAuthProvider(config, "chatgpt");
1002
+ saveConfig(config);
1003
+ }
716
1004
  // Seed default featured subagent models on first run only (UNSET → defaults). A user-set list,
717
1005
  // even [], is left alone so GUI removals persist.
718
1006
  if (config.subagentModels === undefined) {
719
1007
  config.subagentModels = [...DEFAULT_SUBAGENT_MODELS];
720
1008
  saveConfig(config);
721
1009
  }
1010
+ invalidateCodexModelsCache();
1011
+
722
1012
  const listenPort = port ?? config.port ?? 10100;
723
1013
  setCorsOrigin(listenPort);
724
1014
 
@@ -736,10 +1026,34 @@ export function startServer(port?: number) {
736
1026
  // Responses WebSocket (phase 120.2). Codex upgrades the same /v1/responses path; auth is
737
1027
  // handshake-time only, so capture inbound headers and thread them into the pipeline.
738
1028
  if (url.pathname === "/v1/responses" && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
1029
+ const apiAuthError = requireApiAuth(req, config, "data-plane");
1030
+ if (apiAuthError) return apiAuthError;
739
1031
  if (!isLocalOrigin(req)) {
740
1032
  return formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin");
741
1033
  }
742
- if (server.upgrade(req, { data: { headers: selectForwardHeaders(req.headers) } })) return undefined as unknown as Response;
1034
+ let authCtx: CodexAuthContext;
1035
+ try {
1036
+ authCtx = await resolveCodexAuthContext(req.headers, config);
1037
+ } catch (err) {
1038
+ if (err instanceof CodexAccountCooldownError) {
1039
+ return formatErrorResponse(429, "rate_limit_error", "Selected Codex account is cooling down");
1040
+ }
1041
+ if (err instanceof CodexThreadAffinityExpiredError) {
1042
+ return formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session");
1043
+ }
1044
+ if (err instanceof CodexAuthContextError) {
1045
+ const safeAccountLabel = formatCodexProviderForLog("chatgpt", err.accountId, config);
1046
+ console.error(`[codex-auth] Pool account ${safeAccountLabel} token failed during websocket upgrade; reauthentication required`);
1047
+ return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
1048
+ }
1049
+ throw err;
1050
+ }
1051
+ if (server.upgrade(req, {
1052
+ data: {
1053
+ headers: selectForwardHeadersForAuthContext(req.headers, authCtx),
1054
+ authContext: authCtx,
1055
+ },
1056
+ })) return undefined as unknown as Response;
743
1057
  return formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed");
744
1058
  }
745
1059
 
@@ -748,6 +1062,8 @@ export function startServer(port?: number) {
748
1062
  }
749
1063
 
750
1064
  if (url.pathname.startsWith("/api/")) {
1065
+ const apiAuthError = requireApiAuth(req, config, "management");
1066
+ if (apiAuthError) return apiAuthError;
751
1067
  const mgmtResponse = await handleManagementAPI(req, url, config);
752
1068
  if (mgmtResponse) return mgmtResponse;
753
1069
  }
@@ -774,6 +1090,8 @@ export function startServer(port?: number) {
774
1090
  }
775
1091
 
776
1092
  if (url.pathname === "/v1/responses" && req.method === "POST") {
1093
+ const apiAuthError = requireApiAuth(req, config, "data-plane");
1094
+ if (apiAuthError) return apiAuthError;
777
1095
  if (!isLocalOrigin(req)) {
778
1096
  return formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked");
779
1097
  }
@@ -799,6 +1117,9 @@ export function startServer(port?: number) {
799
1117
  // Responses WebSocket data plane (phase 120.2). Re-frames the same SSE pipeline onto the
800
1118
  // socket: parse response.create → run handleResponses unchanged → pump its SSE body as WS
801
1119
  // Text frames. response.processed is a no-op ack. close() aborts the upstream (RC2 parity).
1120
+ open(ws: ServerWebSocket<WsData>) {
1121
+ registerCodexWebSocket(ws);
1122
+ },
802
1123
  message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
803
1124
  let frame: Record<string, unknown>;
804
1125
  try {
@@ -840,14 +1161,29 @@ export function startServer(port?: number) {
840
1161
  body: JSON.stringify({ ...payload, stream: true }),
841
1162
  });
842
1163
  try {
1164
+ assertCodexAuthContextNotCooled(ws.data.authContext);
1165
+ let terminalRecorder: ((status: ResponsesTerminalStatus) => void) | undefined;
843
1166
  const response = await handleResponses(req, config, logCtx, {
844
1167
  forceEmptyResponseId: true,
845
1168
  abortSignal: turnAbort.signal,
1169
+ authContext: ws.data.authContext,
1170
+ selectedForwardHeaders: ws.data.headers,
1171
+ recordTerminalOutcomes: false,
1172
+ setTerminalOutcomeRecorder: recorder => {
1173
+ terminalRecorder = recorder;
1174
+ },
846
1175
  });
847
- await sendResponseToWebSocket(ws, response, isCurrent);
1176
+ await sendResponseToWebSocket(ws, response, isCurrent, { onTerminal: terminalRecorder });
848
1177
  } catch (err) {
849
1178
  if (!isCurrent()) return;
850
1179
  try {
1180
+ if (err instanceof CodexAccountCooldownError) {
1181
+ sendJsonFrame(ws, buildWsErrorFrame(429, {
1182
+ type: "rate_limit_error",
1183
+ message: "Selected Codex account is cooling down",
1184
+ }));
1185
+ return;
1186
+ }
851
1187
  sendJsonFrame(ws, buildWsErrorFrame(502, {
852
1188
  type: "proxy_error",
853
1189
  message: err instanceof Error ? err.message : String(err),
@@ -861,6 +1197,7 @@ export function startServer(port?: number) {
861
1197
  })();
862
1198
  },
863
1199
  close(ws: ServerWebSocket<WsData>) {
1200
+ unregisterCodexWebSocket(ws);
864
1201
  ws.data.cancel?.(); // RC2: abort the upstream when the client disconnects
865
1202
  },
866
1203
  },
package/src/types.ts CHANGED
@@ -200,6 +200,14 @@ export interface OcxConfig {
200
200
  webSearchSidecar?: OcxWebSearchSidecarConfig;
201
201
  /** Vision sidecar: describe images via a gpt vision model so text-only models can "see" them. */
202
202
  visionSidecar?: OcxVisionSidecarConfig;
203
+ /** Codex multi-account pool. */
204
+ codexAccounts?: CodexAccount[];
205
+ /** Active pool account id for next session. undefined = main (passthrough as-is). */
206
+ activeCodexAccountId?: string;
207
+ /** Auto-switch threshold (0-100). Default 80. 0 = disabled. */
208
+ autoSwitchThreshold?: number;
209
+ /** Consecutive non-2xx upstream responses before switching future new threads. Default 3. 0 = disabled. */
210
+ upstreamFailoverThreshold?: number;
203
211
  }
204
212
 
205
213
  export interface OcxVisionSidecarConfig {
@@ -285,3 +293,27 @@ export interface OcxProviderConfig {
285
293
  */
286
294
  noVisionModels?: string[];
287
295
  }
296
+
297
+ export interface CodexAccount {
298
+ id: string;
299
+ email: string;
300
+ plan?: string;
301
+ chatgptAccountId?: string;
302
+ logLabel?: string;
303
+ isMain: boolean;
304
+ }
305
+
306
+ export interface CodexAccountCredentials {
307
+ accessToken: string;
308
+ refreshToken: string;
309
+ expiresAt: number;
310
+ chatgptAccountId: string;
311
+ }
312
+
313
+ export interface CodexAccountCredentialRecord {
314
+ credential?: CodexAccountCredentials;
315
+ generation: number;
316
+ refreshGrantFingerprint?: string;
317
+ deletedAt?: number;
318
+ replacedAt?: number;
319
+ }