@bitkyc08/opencodex 2.6.26-preview.20260705 → 2.6.28-preview.20260707

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 (118) hide show
  1. package/README.md +1 -0
  2. package/bin/ocx.mjs +4 -4
  3. package/gui/dist/assets/index-ByGC8-Bm.css +1 -0
  4. package/gui/dist/assets/index-CkV5xFA8.js +15 -0
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +4 -4
  7. package/src/adapters/anthropic-image-guard.ts +195 -0
  8. package/src/adapters/anthropic.ts +85 -14
  9. package/src/adapters/cursor/cursor-errors.ts +2 -2
  10. package/src/adapters/cursor/live-transport.ts +1 -1
  11. package/src/adapters/cursor/transport-retry.ts +2 -2
  12. package/src/adapters/google-errors.ts +1 -1
  13. package/src/adapters/google-http.ts +1 -1
  14. package/src/adapters/google-truncation.ts +1 -1
  15. package/src/adapters/google.ts +1 -1
  16. package/src/adapters/kiro-errors.ts +1 -1
  17. package/src/adapters/kiro-retry.ts +1 -1
  18. package/src/adapters/kiro-truncation.ts +1 -1
  19. package/src/adapters/kiro.ts +1 -1
  20. package/src/adapters/openai-chat.ts +12 -2
  21. package/src/adapters/openai-responses.ts +126 -3
  22. package/src/bridge.ts +164 -7
  23. package/src/{doctor.ts → cli/doctor.ts} +21 -4
  24. package/src/{cli-help.ts → cli/help.ts} +1 -1
  25. package/src/cli/index.ts +584 -0
  26. package/src/{init.ts → cli/init.ts} +6 -6
  27. package/src/{cli-models.ts → cli/models.ts} +2 -2
  28. package/src/{cli-provider.ts → cli/provider.ts} +12 -8
  29. package/src/{star-prompt.ts → cli/star-prompt.ts} +1 -1
  30. package/src/{cli-status.ts → cli/status.ts} +7 -7
  31. package/src/cli.ts +9 -575
  32. package/src/{codex-account-label.ts → codex/account-label.ts} +1 -1
  33. package/src/{codex-account-lifecycle.ts → codex/account-lifecycle.ts} +6 -6
  34. package/src/{codex-account-store.ts → codex/account-store.ts} +2 -2
  35. package/src/{codex-account-usability.ts → codex/account-usability.ts} +4 -4
  36. package/src/{codex-auth-api.ts → codex/auth-api.ts} +18 -18
  37. package/src/{codex-auth-collision.ts → codex/auth-collision.ts} +4 -4
  38. package/src/{codex-auth-context.ts → codex/auth-context.ts} +9 -9
  39. package/src/{codex-catalog.ts → codex/catalog.ts} +49 -20
  40. package/src/codex/history-migration-guardian.ts +102 -0
  41. package/src/{codex-history-provider.ts → codex/history-provider.ts} +111 -7
  42. package/src/{codex-home.ts → codex/home.ts} +1 -1
  43. package/src/{codex-inject.ts → codex/inject.ts} +204 -26
  44. package/src/{codex-journal.ts → codex/journal.ts} +2 -2
  45. package/src/{codex-main-account.ts → codex/main-account.ts} +2 -2
  46. package/src/{model-cache.ts → codex/model-cache.ts} +1 -1
  47. package/src/{codex-paths.ts → codex/paths.ts} +2 -2
  48. package/src/{codex-plugins-doctor.ts → codex/plugins-doctor.ts} +2 -2
  49. package/src/{codex-refresh.ts → codex/refresh.ts} +4 -4
  50. package/src/{codex-routing.ts → codex/routing.ts} +8 -8
  51. package/src/{codex-shim.ts → codex/shim.ts} +6 -5
  52. package/src/{codex-sync.ts → codex/sync.ts} +4 -4
  53. package/src/{codex-websocket-registry.ts → codex/websocket-registry.ts} +1 -1
  54. package/src/config.ts +2 -0
  55. package/src/generated/jawcode-model-metadata.ts +2 -0
  56. package/src/{bun-runtime.ts → lib/bun-runtime.ts} +1 -1
  57. package/src/{crash-guard.ts → lib/crash-guard.ts} +1 -1
  58. package/src/{process-control.ts → lib/process-control.ts} +1 -1
  59. package/src/{service-secrets.ts → lib/service-secrets.ts} +1 -1
  60. package/src/oauth/callback-server.ts +1 -1
  61. package/src/oauth/google-antigravity.ts +7 -4
  62. package/src/oauth/index.ts +67 -16
  63. package/src/oauth/login-cli.ts +2 -2
  64. package/src/oauth/store.ts +236 -20
  65. package/src/oauth/token-guardian.ts +24 -20
  66. package/src/oauth/types.ts +16 -0
  67. package/src/providers/api-keys.ts +121 -0
  68. package/src/{provider-context-cap.ts → providers/context-cap.ts} +1 -1
  69. package/src/providers/derive.ts +2 -0
  70. package/src/providers/key-failover.ts +145 -0
  71. package/src/{provider-label.ts → providers/label.ts} +1 -1
  72. package/src/{provider-quota.ts → providers/quota.ts} +12 -7
  73. package/src/providers/registry.ts +66 -4
  74. package/src/responses/compaction.ts +117 -0
  75. package/src/responses/parser.ts +89 -13
  76. package/src/responses/reasoning-envelope.ts +52 -0
  77. package/src/responses/schema.ts +15 -3
  78. package/src/responses/state.ts +117 -2
  79. package/src/router.ts +2 -0
  80. package/src/server/auth-cors.ts +231 -0
  81. package/src/server/index.ts +523 -0
  82. package/src/server/lifecycle.ts +73 -0
  83. package/src/server/management-api.ts +628 -0
  84. package/src/{proxy-liveness.ts → server/proxy-liveness.ts} +1 -1
  85. package/src/server/relay.ts +534 -0
  86. package/src/server/request-decompress.ts +46 -0
  87. package/src/server/request-log.ts +310 -0
  88. package/src/server/responses.ts +775 -0
  89. package/src/{ws-bridge.ts → server/ws-bridge.ts} +44 -13
  90. package/src/service.ts +19 -11
  91. package/src/types.ts +27 -0
  92. package/src/{update.ts → update/index.ts} +5 -5
  93. package/src/{update-job.ts → update/job.ts} +7 -6
  94. package/src/{update-notify.ts → update/notify.ts} +3 -3
  95. package/src/{usage-debug.ts → usage/debug.ts} +3 -3
  96. package/src/{usage-log.ts → usage/log.ts} +3 -3
  97. package/src/{usage-summary.ts → usage/summary.ts} +3 -3
  98. package/src/{usage-totals.ts → usage/totals.ts} +1 -1
  99. package/src/vision/describe.ts +3 -3
  100. package/src/vision/index.ts +21 -1
  101. package/src/web-search/executor.ts +4 -4
  102. package/src/web-search/index.ts +1 -1
  103. package/src/web-search/loop.ts +84 -24
  104. package/gui/dist/assets/index-BcHhxo1I.css +0 -1
  105. package/gui/dist/assets/index-DCC1q_Jx.js +0 -15
  106. package/src/server.ts +0 -2501
  107. /package/src/{codex-account-runtime-state.ts → codex/account-runtime-state.ts} +0 -0
  108. /package/src/{codex-quota.ts → codex/quota.ts} +0 -0
  109. /package/src/{abort.ts → lib/abort.ts} +0 -0
  110. /package/src/{debug.ts → lib/debug.ts} +0 -0
  111. /package/src/{errors.ts → lib/errors.ts} +0 -0
  112. /package/src/{open-url.ts → lib/open-url.ts} +0 -0
  113. /package/src/{privacy.ts → lib/privacy.ts} +0 -0
  114. /package/src/{redact.ts → lib/redact.ts} +0 -0
  115. /package/src/{sidecar-tracker.ts → lib/sidecar-tracker.ts} +0 -0
  116. /package/src/{upstream-retry.ts → lib/upstream-retry.ts} +0 -0
  117. /package/src/{win-paths.ts → lib/win-paths.ts} +0 -0
  118. /package/src/{ports.ts → server/ports.ts} +0 -0
@@ -0,0 +1,523 @@
1
+ import { markActivity } from "../lib/sidecar-tracker";
2
+ import {
3
+ buildWarmupCompletionFrames,
4
+ buildWsErrorFrame,
5
+ selectForwardHeaders,
6
+ sendJsonFrame,
7
+ sendResponseToWebSocket,
8
+ sendTextFrame,
9
+ type WsData,
10
+ } from "./ws-bridge";
11
+ import type { Server, ServerWebSocket } from "bun";
12
+ import {
13
+ DEFAULT_SUBAGENT_MODELS,
14
+ applyProxyEnv,
15
+ loadConfig,
16
+ saveConfig,
17
+ websocketsEnabled,
18
+ } from "../config";
19
+ import {
20
+ clearLoginState, getLoginStatus, getOAuthCredentialProjectId, getValidAccessToken, isOAuthProvider,
21
+ listOAuthProviders, reconcileOAuthProviders, startLoginFlow, UnsupportedOAuthProviderError, upsertOAuthProvider,
22
+ } from "../oauth";
23
+ import { invalidateCodexModelsCache } from "../codex/catalog";
24
+ import {
25
+ CodexAccountCooldownError,
26
+ CodexAuthContextError,
27
+ CodexThreadAffinityExpiredError,
28
+ headersForCodexAuthContext,
29
+ resolveCodexAuthContext,
30
+ type CodexAuthContext,
31
+ } from "../codex/auth-context";
32
+ export {
33
+ clearThreadAccountMap,
34
+ formatCodexProviderForLog,
35
+ resolveCodexAccountForThread,
36
+ } from "../codex/routing";
37
+ import { formatCodexProviderForLog } from "../codex/routing";
38
+ import { registerCodexWebSocket, unregisterCodexWebSocket, updateCodexWebSocketAuthContext } from "../codex/websocket-registry";
39
+ import { resolveGuiFilePath, rootFallbackPayload, serveGuiFile } from "./gui-static";
40
+ export { resolveGuiFilePath, rootFallbackPayload } from "./gui-static";
41
+ export { resolveAdapter } from "./adapter-resolve";
42
+ import { formatErrorResponse, type ResponsesTerminalStatus } from "../bridge";
43
+ import {
44
+ drainAndShutdown,
45
+ getActiveTurnCount,
46
+ isDraining,
47
+ registerTurn,
48
+ setServerRef,
49
+ trackStreamLifetime,
50
+ unregisterTurn,
51
+ } from "./lifecycle";
52
+ export {
53
+ drainAndShutdown,
54
+ getActiveTurnCount,
55
+ isDraining,
56
+ registerTurn,
57
+ trackStreamLifetime,
58
+ unregisterTurn,
59
+ } from "./lifecycle";
60
+ import {
61
+ addFinalRequestLog,
62
+ httpStatusForTerminalStatus,
63
+ inspectResponseLogSsePayload,
64
+ nextRequestLogId,
65
+ type RequestLogContext,
66
+ type RequestLogEntry,
67
+ } from "./request-log";
68
+ export {
69
+ addFinalRequestLog,
70
+ filterRequestLogs,
71
+ httpStatusForTerminalStatus,
72
+ nextRequestLogId,
73
+ requestLogErrorCode,
74
+ requestLogSpeedLabel,
75
+ usageFromResponsesPayload,
76
+ type RequestLogContext,
77
+ type RequestLogEntry,
78
+ } from "./request-log";
79
+ import {
80
+ consumeForInspection,
81
+ relaySseWithHeartbeat,
82
+ relayWithAbort,
83
+ responseWithDeferredRequestLog,
84
+ sanitizePassthroughHeaders,
85
+ } from "./relay";
86
+ export {
87
+ consumeForInspection,
88
+ relaySseWithFailedTail,
89
+ relaySseWithHeartbeat,
90
+ relayWithAbort,
91
+ responseWithDeferredRequestLog,
92
+ sanitizePassthroughHeaders,
93
+ } from "./relay";
94
+ import {
95
+ assertServerAuthConfig,
96
+ corsHeaders,
97
+ hasValidApiAuth,
98
+ isAllowedRequestOrigin,
99
+ isApiAuthRequired,
100
+ isLoopbackHostname,
101
+ jsonResponse,
102
+ requireApiAuth,
103
+ safeConfigDTO,
104
+ setCorsOrigin,
105
+ withCors,
106
+ } from "./auth-cors";
107
+ export {
108
+ assertServerAuthConfig,
109
+ corsHeaders,
110
+ hasValidApiAuth,
111
+ isApiAuthRequired,
112
+ isLoopbackHostname,
113
+ jsonResponse,
114
+ safeConfigDTO,
115
+ } from "./auth-cors";
116
+ import { disableResponsesRequestTimeout, handleResponses, handleResponsesCompact } from "./responses";
117
+ export { disableResponsesRequestTimeout, linkAbortSignal } from "./responses";
118
+ import { fetchAllModels, handleManagementAPI, VERSION } from "./management-api";
119
+
120
+ const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
121
+ const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;
122
+
123
+ // GUI static serving extracted to ./server/gui-static. Re-exported below to keep the
124
+ // "../src/server" import surface stable for tests/callers.
125
+
126
+ // Adapter resolution + wire-protocol override extracted to ./server/adapter-resolve.
127
+
128
+ // Source invariant for tests/passthrough-abort.test.ts after the pure module split:
129
+ // if (isEventStream && upstreamResponse.body) {
130
+ // upstreamResponse.body.tee()
131
+ // process.platform === "win32"
132
+ // ? nativeBody
133
+ // relaySseWithFailedTail(nativeBody, upstream)
134
+ // new Response(clientBody
135
+ // markNativePassthroughSseResponse
136
+ // const body = relayWithAbort(upstreamResponse.body, upstream);
137
+ // function responseWithDeferredRequestLog
138
+ // isNativePassthroughSseResponse(response)
139
+ // trackSseForRequestLog(
140
+ // export function relaySseWithHeartbeat
141
+
142
+ export function startServer(port?: number) {
143
+ const config = loadConfig();
144
+ applyProxyEnv(config);
145
+ assertServerAuthConfig(config);
146
+ // Refresh OAuth provider presets (models/noReasoningModels) from the registry so a proxy update
147
+ // adding/dropping models reaches existing configs on start — not just fresh installs.
148
+ reconcileOAuthProviders(config);
149
+ // Ensure the ChatGPT passthrough provider exists so gpt-* models route correctly.
150
+ if (!config.providers["chatgpt"]) {
151
+ upsertOAuthProvider(config, "chatgpt");
152
+ saveConfig(config);
153
+ }
154
+ // Seed default featured subagent models on first run only (UNSET → defaults). A user-set list,
155
+ // even [], is left alone so GUI removals persist.
156
+ if (config.subagentModels === undefined) {
157
+ config.subagentModels = [...DEFAULT_SUBAGENT_MODELS];
158
+ saveConfig(config);
159
+ }
160
+ invalidateCodexModelsCache();
161
+
162
+ const listenPort = port ?? config.port ?? 10100;
163
+ setCorsOrigin(listenPort);
164
+
165
+ // Canonicalize an explicit "localhost" bind to IPv4 so it matches the injected base_url (which
166
+ // resolves localhost→127.0.0.1): on Windows `localhost` resolves ::1-first, but the injected URL
167
+ // is 127.0.0.1, so binding literal "localhost" would reintroduce the F4 refusal. Wildcards
168
+ // (0.0.0.0/::) and specific hosts are left untouched so intentional exposure is preserved.
169
+ const bindHost = /^localhost$/i.test(config.hostname ?? "") ? "127.0.0.1" : (config.hostname ?? "127.0.0.1");
170
+
171
+ const server: Server<WsData> = Bun.serve<WsData>({
172
+ port: listenPort,
173
+ hostname: bindHost,
174
+ idleTimeout: 255,
175
+ async fetch(req, requestServer): Promise<Response> {
176
+ const url = new URL(req.url);
177
+ markActivity(`${req.method} ${url.pathname}`);
178
+
179
+ if (req.method === "OPTIONS") {
180
+ if (!isAllowedRequestOrigin(req, config)) {
181
+ return new Response(null, { status: 403, headers: corsHeaders() });
182
+ }
183
+ return new Response(null, { status: 204, headers: corsHeaders(req, config) });
184
+ }
185
+
186
+ // Responses WebSocket (phase 120.2). Codex upgrades the same /v1/responses path; auth is
187
+ // handshake-time only, so capture inbound headers and thread them into the pipeline.
188
+ if (url.pathname === "/v1/responses" && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
189
+ if (isDraining()) {
190
+ return new Response("Service shutting down", { status: 503, headers: { ...corsHeaders(req, config), "Retry-After": "5" } });
191
+ }
192
+ const apiAuthError = requireApiAuth(req, config, "data-plane");
193
+ if (apiAuthError) return withCors(apiAuthError, req, config);
194
+ if (!isAllowedRequestOrigin(req, config)) {
195
+ return withCors(formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin"), req, config);
196
+ }
197
+ // WS transport gate: Codex's built-in `openai` provider hardcodes supports_websockets=true,
198
+ // so under Design B it always tries the WS transport first. When the feature is off, reject
199
+ // the upgrade with 426 — codex-rs maps a connect-time UPGRADE_REQUIRED to a clean
200
+ // session-scoped HTTP fallback (client.rs WebsocketStreamOutcome::FallbackToHttp) instead of
201
+ // surfacing broken-pipe errors from sockets a "disabled" feature would otherwise accept.
202
+ if (!websocketsEnabled(config)) {
203
+ return withCors(formatErrorResponse(426, "upgrade_required", "Responses WebSocket transport is disabled; use HTTP"), req, config);
204
+ }
205
+ let authCtx: CodexAuthContext;
206
+ try {
207
+ authCtx = await resolveCodexAuthContext(req.headers, config);
208
+ } catch (err) {
209
+ if (err instanceof CodexAccountCooldownError) {
210
+ return withCors(formatErrorResponse(429, "rate_limit_error", "Selected Codex account is cooling down"), req, config);
211
+ }
212
+ if (err instanceof CodexThreadAffinityExpiredError) {
213
+ return withCors(formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session"), req, config);
214
+ }
215
+ if (err instanceof CodexAuthContextError) {
216
+ const safeAccountLabel = formatCodexProviderForLog("chatgpt", err.accountId, config);
217
+ console.error(`[codex-auth] Pool account ${safeAccountLabel} token failed during websocket upgrade; reauthentication required`);
218
+ return withCors(formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"), req, config);
219
+ }
220
+ throw err;
221
+ }
222
+ if (server.upgrade(req, {
223
+ data: {
224
+ headers: selectForwardHeaders(req.headers),
225
+ authContext: authCtx,
226
+ },
227
+ })) return undefined as unknown as Response;
228
+ return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, config);
229
+ }
230
+
231
+ if (url.pathname === "/healthz" && req.method === "GET") {
232
+ // service/pid/port let CLI liveness reject foreign 200s and verify pid identity.
233
+ return jsonResponse({ status: "ok", service: "opencodex", version: VERSION, uptime: process.uptime(), pid: process.pid, port: listenPort }, 200, req, config);
234
+ }
235
+
236
+ if (url.pathname.startsWith("/api/")) {
237
+ const apiAuthError = requireApiAuth(req, config, "management");
238
+ if (apiAuthError) return withCors(apiAuthError, req, config);
239
+ const mgmtResponse = await handleManagementAPI(req, url, config);
240
+ if (mgmtResponse) return withCors(mgmtResponse, req, config);
241
+ }
242
+
243
+ if (url.pathname === "/v1/models" && req.method === "GET") {
244
+ const apiAuthError = requireApiAuth(req, config, "data-plane");
245
+ if (apiAuthError) return withCors(apiAuthError, req, config);
246
+ if (!isAllowedRequestOrigin(req, config)) {
247
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
248
+ }
249
+ const goModels = await fetchAllModels(config);
250
+ const { buildCatalogEntries, loadCatalogTemplate, nativeOpenAiSlugs, orderForSubagents, filterCatalogVisibleModels } = await import("../codex/catalog");
251
+ const nativeSlugs = nativeOpenAiSlugs();
252
+ const goEnabled = filterCatalogVisibleModels(goModels, config);
253
+ const goOrdered = orderForSubagents(goEnabled, config.subagentModels);
254
+ if (url.searchParams.has("client_version")) {
255
+ // Codex client → Codex catalog shape: native gpt + namespaced routed models,
256
+ // cloned from a native template so required fields (base_instructions, etc.) are present.
257
+ // Pass the subagent picks so featured models lead by priority (matches the on-disk file).
258
+ return jsonResponse({ models: buildCatalogEntries(loadCatalogTemplate(), nativeSlugs, goOrdered, config.subagentModels, websocketsEnabled(config)) }, 200, req, config);
259
+ }
260
+ // OpenAI list shape: native gpt bare + routed models namespaced "<provider>/<id>"
261
+ const data = [
262
+ ...nativeSlugs.map(id => ({ id, object: "model", created: 0, owned_by: "openai" })),
263
+ ...goOrdered.map(m => ({ id: `${m.provider}/${m.id}`, object: "model", created: 0, owned_by: m.owned_by ?? m.provider })),
264
+ ];
265
+ return jsonResponse({ object: "list", data }, 200, req, config);
266
+ }
267
+
268
+ // Remote compaction v1 (codex-rs with Feature::RemoteCompactionV2 off — the default).
269
+ // Must be matched BEFORE the /v1/responses POST branch never sees it (distinct path) and
270
+ // before the /v1/* 404 guard below.
271
+ if (url.pathname === "/v1/responses/compact" && req.method === "POST") {
272
+ if (isDraining()) {
273
+ return new Response("Service shutting down", { status: 503, headers: { ...corsHeaders(req, config), "Retry-After": "5" } });
274
+ }
275
+ const apiAuthError = requireApiAuth(req, config, "data-plane");
276
+ if (apiAuthError) return withCors(apiAuthError, req, config);
277
+ if (!isAllowedRequestOrigin(req, config)) {
278
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
279
+ }
280
+ return withCors(await handleResponsesCompact(req, config), req, config);
281
+ }
282
+
283
+ if (url.pathname === "/v1/responses" && req.method === "POST") {
284
+ disableResponsesRequestTimeout(req, requestServer);
285
+ if (isDraining()) {
286
+ return new Response("Service shutting down", { status: 503, headers: { ...corsHeaders(req, config), "Retry-After": "5" } });
287
+ }
288
+ const apiAuthError = requireApiAuth(req, config, "data-plane");
289
+ if (apiAuthError) return withCors(apiAuthError, req, config);
290
+ if (!isAllowedRequestOrigin(req, config)) {
291
+ return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
292
+ }
293
+ const start = Date.now();
294
+ const requestId = nextRequestLogId(start);
295
+ const logCtx = { model: "unknown", provider: "unknown" };
296
+ let logged = false;
297
+ const finalizeNativePassthroughLog = (
298
+ status: number,
299
+ meta: { terminalStatus?: ResponsesTerminalStatus; closeReason: "terminal" | "client_cancel" },
300
+ ) => {
301
+ if (logged) return;
302
+ logged = true;
303
+ addFinalRequestLog(requestId, start, logCtx, status, meta);
304
+ };
305
+ const response = await handleResponses(req, config, logCtx, {
306
+ abortSignal: req.signal,
307
+ onNativePassthroughTerminal: status => {
308
+ finalizeNativePassthroughLog(httpStatusForTerminalStatus(status), {
309
+ terminalStatus: status,
310
+ closeReason: "terminal",
311
+ });
312
+ },
313
+ onNativePassthroughCancel: () => {
314
+ finalizeNativePassthroughLog(499, { closeReason: "client_cancel" });
315
+ },
316
+ });
317
+ return withCors(responseWithDeferredRequestLog(response, requestId, start, logCtx), req, config);
318
+ }
319
+
320
+ // Data-plane guard: unknown /v1/* paths must fail with JSON 404, never fall through to the
321
+ // GUI static handler (extensionless paths would get index.html with HTTP 200 and codex-rs
322
+ // endpoint clients — alpha/search, images/*, memories/*, realtime/* — would surface confusing
323
+ // serde decode errors instead of a clean not-found).
324
+ if (url.pathname.startsWith("/v1/")) {
325
+ return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
326
+ }
327
+
328
+ const guiFile = serveGuiFile(url.pathname);
329
+ if (guiFile) return guiFile;
330
+ if (url.pathname === "/" && req.method === "GET") {
331
+ return jsonResponse(rootFallbackPayload());
332
+ }
333
+
334
+ return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
335
+ },
336
+ websocket: {
337
+ idleTimeout: WEBSOCKET_IDLE_TIMEOUT_SECONDS,
338
+ // Responses WebSocket data plane (phase 120.2). Re-frames the same SSE pipeline onto the
339
+ // socket: parse response.create → run handleResponses unchanged → pump its SSE body as WS
340
+ // Text frames. response.processed is a no-op ack. close() aborts the upstream (RC2 parity).
341
+ open(ws: ServerWebSocket<WsData>) {
342
+ registerCodexWebSocket(ws);
343
+ },
344
+ message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
345
+ const rawBytes = typeof raw === "string" ? Buffer.byteLength(raw) : raw.byteLength;
346
+ if (rawBytes > MAX_WS_FRAME_BYTES) {
347
+ sendJsonFrame(ws, buildWsErrorFrame(413, {
348
+ type: "invalid_request_error",
349
+ message: "WebSocket response.create frame is too large",
350
+ }));
351
+ ws.close(1009, "message too large");
352
+ return;
353
+ }
354
+ let frame: Record<string, unknown>;
355
+ try {
356
+ frame = JSON.parse(typeof raw === "string" ? raw : raw.toString()) as Record<string, unknown>;
357
+ } catch {
358
+ return; // text-only contract; ignore unparseable frames
359
+ }
360
+ if (frame.type === "response.processed") return; // ack — no-op
361
+ if (frame.type !== "response.create") return;
362
+ markActivity("ws response.create");
363
+
364
+ ws.data.cancel?.();
365
+ const turnId = (ws.data.turnId ?? 0) + 1;
366
+ ws.data.turnId = turnId;
367
+ const isCurrent = () => ws.data.turnId === turnId;
368
+ const turnAbort = new AbortController();
369
+ const cancelTurn = () => {
370
+ turnAbort.abort("websocket turn superseded or closed");
371
+ };
372
+ ws.data.cancel = cancelTurn;
373
+
374
+ if (frame.generate === false) {
375
+ for (const payload of buildWarmupCompletionFrames(frame)) {
376
+ if (!isCurrent()) return;
377
+ sendTextFrame(ws, payload);
378
+ }
379
+ if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined;
380
+ return;
381
+ }
382
+
383
+ const payload: Record<string, unknown> = { ...frame };
384
+ delete payload.type;
385
+ registerTurn(turnAbort);
386
+ void (async () => {
387
+ const start = Date.now();
388
+ const requestId = nextRequestLogId(start);
389
+ const logCtx = { model: "unknown", provider: "unknown" };
390
+ let logged = false;
391
+ const finalizeLog = (
392
+ status: number,
393
+ meta?: Pick<RequestLogEntry, "terminalStatus" | "closeReason">,
394
+ ) => {
395
+ if (logged) return;
396
+ logged = true;
397
+ addFinalRequestLog(requestId, start, logCtx, status, meta);
398
+ };
399
+ const baseHeaders = ws.data.headers ?? new Headers();
400
+ let authCtx: CodexAuthContext;
401
+ let selectedForwardHeaders: Headers;
402
+ try {
403
+ authCtx = await resolveCodexAuthContext(baseHeaders, config);
404
+ selectedForwardHeaders = headersForCodexAuthContext(baseHeaders, authCtx);
405
+ updateCodexWebSocketAuthContext(ws, authCtx);
406
+ } catch (err) {
407
+ if (!isCurrent()) return;
408
+ if (err instanceof CodexAccountCooldownError) {
409
+ finalizeLog(429);
410
+ sendJsonFrame(ws, buildWsErrorFrame(429, {
411
+ type: "rate_limit_error",
412
+ message: "Selected Codex account is cooling down",
413
+ }));
414
+ return;
415
+ }
416
+ if (err instanceof CodexThreadAffinityExpiredError) {
417
+ finalizeLog(409);
418
+ sendJsonFrame(ws, buildWsErrorFrame(409, {
419
+ type: "invalid_request_error",
420
+ message: "Codex thread account affinity expired; start a new session",
421
+ }));
422
+ return;
423
+ }
424
+ if (err instanceof CodexAuthContextError) {
425
+ const safeAccountLabel = formatCodexProviderForLog("chatgpt", err.accountId, config);
426
+ console.error(`[codex-auth] Pool account ${safeAccountLabel} token failed during websocket turn; reauthentication required`);
427
+ finalizeLog(401);
428
+ sendJsonFrame(ws, buildWsErrorFrame(401, {
429
+ type: "authentication_error",
430
+ message: "Selected Codex account needs reauthentication",
431
+ }));
432
+ return;
433
+ }
434
+ finalizeLog(502);
435
+ sendJsonFrame(ws, buildWsErrorFrame(502, {
436
+ type: "proxy_error",
437
+ message: err instanceof Error ? err.message : String(err),
438
+ }));
439
+ return;
440
+ }
441
+ const fwd = new Headers({ "content-type": "application/json" });
442
+ selectedForwardHeaders.forEach((value, key) => fwd.set(key, value));
443
+ const req = new Request("http://localhost/v1/responses", {
444
+ method: "POST",
445
+ headers: fwd,
446
+ body: JSON.stringify({ ...payload, stream: true }),
447
+ });
448
+ try {
449
+ let terminalRecorder: ((status: ResponsesTerminalStatus) => void) | undefined;
450
+ const response = await handleResponses(req, config, logCtx, {
451
+ forceEmptyResponseId: true,
452
+ abortSignal: turnAbort.signal,
453
+ authContext: authCtx,
454
+ selectedForwardHeaders,
455
+ recordTerminalOutcomes: false,
456
+ setTerminalOutcomeRecorder: recorder => {
457
+ terminalRecorder = recorder;
458
+ },
459
+ });
460
+ await sendResponseToWebSocket(ws, response, isCurrent, {
461
+ onSsePayload: payload => inspectResponseLogSsePayload(logCtx, payload),
462
+ onTerminal: status => {
463
+ terminalRecorder?.(status);
464
+ finalizeLog(httpStatusForTerminalStatus(status), {
465
+ terminalStatus: status,
466
+ closeReason: "terminal",
467
+ });
468
+ },
469
+ });
470
+ if (!logged) finalizeLog(turnAbort.signal.aborted ? 499 : response.status);
471
+ } catch (err) {
472
+ if (!isCurrent()) return;
473
+ try {
474
+ if (err instanceof CodexAccountCooldownError) {
475
+ finalizeLog(429);
476
+ sendJsonFrame(ws, buildWsErrorFrame(429, {
477
+ type: "rate_limit_error",
478
+ message: "Selected Codex account is cooling down",
479
+ }));
480
+ return;
481
+ }
482
+ finalizeLog(502);
483
+ sendJsonFrame(ws, buildWsErrorFrame(502, {
484
+ type: "proxy_error",
485
+ message: err instanceof Error ? err.message : String(err),
486
+ }));
487
+ } catch {
488
+ /* socket already gone or send dropped */
489
+ }
490
+ } finally {
491
+ unregisterTurn(turnAbort);
492
+ if (!logged && turnAbort.signal.aborted) finalizeLog(499);
493
+ if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined;
494
+ }
495
+ })();
496
+ },
497
+ close(ws: ServerWebSocket<WsData>) {
498
+ unregisterCodexWebSocket(ws);
499
+ ws.data.cancel?.(); // RC2: abort the upstream when the client disconnects
500
+ },
501
+ },
502
+ });
503
+
504
+ setServerRef(server);
505
+ const actualPort = server.port ?? listenPort;
506
+ setCorsOrigin(actualPort);
507
+
508
+ console.log(`🚀 opencodex proxy running on http://localhost:${actualPort}`);
509
+ console.log(` POST /v1/responses → provider translation`);
510
+ console.log(` GET /healthz → health check`);
511
+ console.log(` GET /api/* → management API`);
512
+ console.log(` GET / → GUI dashboard`);
513
+
514
+ // Prime pool-account quota in the background so the rotation engine has real
515
+ // usage scores from the first routing decision, even when the dashboard is
516
+ // never opened (the common CLI/WSL case). Fire-and-forget: never blocks the
517
+ // listener, and a blocked network silently no-ops (see Phase 30 diagnostics).
518
+ import("../codex/auth-api")
519
+ .then(({ primeCodexPoolQuotas }) => primeCodexPoolQuotas(config, "startup"))
520
+ .catch(() => {});
521
+
522
+ return server;
523
+ }
@@ -0,0 +1,73 @@
1
+ import { flushResponseState } from "../responses/state";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Active turn tracking + graceful shutdown drain
5
+ // ---------------------------------------------------------------------------
6
+
7
+ const activeTurns = new Set<AbortController>();
8
+ let draining = false;
9
+ let _serverRef: ReturnType<typeof Bun.serve> | undefined;
10
+
11
+ export function setServerRef(server: ReturnType<typeof Bun.serve> | undefined): void { _serverRef = server; }
12
+ export function setDraining(value: boolean): void { draining = value; }
13
+ export function registerTurn(ac: AbortController): void { activeTurns.add(ac); }
14
+ export function unregisterTurn(ac: AbortController): void { activeTurns.delete(ac); }
15
+ export function isDraining(): boolean { return draining; }
16
+ export function getActiveTurnCount(): number { return activeTurns.size; }
17
+
18
+ export function trackStreamLifetime(
19
+ body: ReadableStream<Uint8Array>,
20
+ ac: AbortController,
21
+ onDone?: () => void,
22
+ ): ReadableStream<Uint8Array> {
23
+ registerTurn(ac);
24
+ const reader = body.getReader();
25
+ let closed = false;
26
+ const finish = () => {
27
+ if (closed) return;
28
+ closed = true;
29
+ unregisterTurn(ac);
30
+ onDone?.();
31
+ };
32
+ return new ReadableStream<Uint8Array>({
33
+ async pull(controller) {
34
+ try {
35
+ const { done, value } = await reader.read();
36
+ if (done) { finish(); controller.close(); return; }
37
+ controller.enqueue(value);
38
+ } catch (err) {
39
+ finish();
40
+ try { controller.error(err); } catch { /* already closed */ }
41
+ }
42
+ },
43
+ cancel(reason) {
44
+ finish();
45
+ ac.abort(reason);
46
+ reader.cancel(reason).catch(() => {});
47
+ },
48
+ });
49
+ }
50
+
51
+ export async function drainAndShutdown(
52
+ server: ReturnType<typeof Bun.serve> | undefined,
53
+ timeoutMs: number,
54
+ ): Promise<void> {
55
+ const s = server ?? _serverRef;
56
+ draining = true;
57
+ const deadline = Date.now() + timeoutMs;
58
+ while (activeTurns.size > 0 && Date.now() < deadline) {
59
+ await Bun.sleep(100);
60
+ }
61
+ if (activeTurns.size > 0) {
62
+ console.warn(`⚠️ Aborting ${activeTurns.size} in-flight turn(s) after ${timeoutMs}ms deadline`);
63
+ for (const ac of activeTurns) {
64
+ ac.abort(new Error("server shutdown"));
65
+ }
66
+ activeTurns.clear();
67
+ }
68
+ // Debounced replay-state snapshot may still be pending; flush so the last completed turn's
69
+ // previous_response_id chain survives the restart this shutdown is usually part of.
70
+ flushResponseState();
71
+ s?.stop(true);
72
+ draining = false;
73
+ }