@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.
- package/README.md +1 -0
- package/bin/ocx.mjs +4 -4
- package/gui/dist/assets/index-ByGC8-Bm.css +1 -0
- package/gui/dist/assets/index-CkV5xFA8.js +15 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/adapters/anthropic-image-guard.ts +195 -0
- package/src/adapters/anthropic.ts +85 -14
- package/src/adapters/cursor/cursor-errors.ts +2 -2
- package/src/adapters/cursor/live-transport.ts +1 -1
- package/src/adapters/cursor/transport-retry.ts +2 -2
- package/src/adapters/google-errors.ts +1 -1
- package/src/adapters/google-http.ts +1 -1
- package/src/adapters/google-truncation.ts +1 -1
- package/src/adapters/google.ts +1 -1
- package/src/adapters/kiro-errors.ts +1 -1
- package/src/adapters/kiro-retry.ts +1 -1
- package/src/adapters/kiro-truncation.ts +1 -1
- package/src/adapters/kiro.ts +1 -1
- package/src/adapters/openai-chat.ts +12 -2
- package/src/adapters/openai-responses.ts +126 -3
- package/src/bridge.ts +164 -7
- package/src/{doctor.ts → cli/doctor.ts} +21 -4
- package/src/{cli-help.ts → cli/help.ts} +1 -1
- package/src/cli/index.ts +584 -0
- package/src/{init.ts → cli/init.ts} +6 -6
- package/src/{cli-models.ts → cli/models.ts} +2 -2
- package/src/{cli-provider.ts → cli/provider.ts} +12 -8
- package/src/{star-prompt.ts → cli/star-prompt.ts} +1 -1
- package/src/{cli-status.ts → cli/status.ts} +7 -7
- package/src/cli.ts +9 -575
- package/src/{codex-account-label.ts → codex/account-label.ts} +1 -1
- package/src/{codex-account-lifecycle.ts → codex/account-lifecycle.ts} +6 -6
- package/src/{codex-account-store.ts → codex/account-store.ts} +2 -2
- package/src/{codex-account-usability.ts → codex/account-usability.ts} +4 -4
- package/src/{codex-auth-api.ts → codex/auth-api.ts} +18 -18
- package/src/{codex-auth-collision.ts → codex/auth-collision.ts} +4 -4
- package/src/{codex-auth-context.ts → codex/auth-context.ts} +9 -9
- package/src/{codex-catalog.ts → codex/catalog.ts} +49 -20
- package/src/codex/history-migration-guardian.ts +102 -0
- package/src/{codex-history-provider.ts → codex/history-provider.ts} +111 -7
- package/src/{codex-home.ts → codex/home.ts} +1 -1
- package/src/{codex-inject.ts → codex/inject.ts} +204 -26
- package/src/{codex-journal.ts → codex/journal.ts} +2 -2
- package/src/{codex-main-account.ts → codex/main-account.ts} +2 -2
- package/src/{model-cache.ts → codex/model-cache.ts} +1 -1
- package/src/{codex-paths.ts → codex/paths.ts} +2 -2
- package/src/{codex-plugins-doctor.ts → codex/plugins-doctor.ts} +2 -2
- package/src/{codex-refresh.ts → codex/refresh.ts} +4 -4
- package/src/{codex-routing.ts → codex/routing.ts} +8 -8
- package/src/{codex-shim.ts → codex/shim.ts} +6 -5
- package/src/{codex-sync.ts → codex/sync.ts} +4 -4
- package/src/{codex-websocket-registry.ts → codex/websocket-registry.ts} +1 -1
- package/src/config.ts +2 -0
- package/src/generated/jawcode-model-metadata.ts +2 -0
- package/src/{bun-runtime.ts → lib/bun-runtime.ts} +1 -1
- package/src/{crash-guard.ts → lib/crash-guard.ts} +1 -1
- package/src/{process-control.ts → lib/process-control.ts} +1 -1
- package/src/{service-secrets.ts → lib/service-secrets.ts} +1 -1
- package/src/oauth/callback-server.ts +1 -1
- package/src/oauth/google-antigravity.ts +7 -4
- package/src/oauth/index.ts +67 -16
- package/src/oauth/login-cli.ts +2 -2
- package/src/oauth/store.ts +236 -20
- package/src/oauth/token-guardian.ts +24 -20
- package/src/oauth/types.ts +16 -0
- package/src/providers/api-keys.ts +121 -0
- package/src/{provider-context-cap.ts → providers/context-cap.ts} +1 -1
- package/src/providers/derive.ts +2 -0
- package/src/providers/key-failover.ts +145 -0
- package/src/{provider-label.ts → providers/label.ts} +1 -1
- package/src/{provider-quota.ts → providers/quota.ts} +12 -7
- package/src/providers/registry.ts +66 -4
- package/src/responses/compaction.ts +117 -0
- package/src/responses/parser.ts +89 -13
- package/src/responses/reasoning-envelope.ts +52 -0
- package/src/responses/schema.ts +15 -3
- package/src/responses/state.ts +117 -2
- package/src/router.ts +2 -0
- package/src/server/auth-cors.ts +231 -0
- package/src/server/index.ts +523 -0
- package/src/server/lifecycle.ts +73 -0
- package/src/server/management-api.ts +628 -0
- package/src/{proxy-liveness.ts → server/proxy-liveness.ts} +1 -1
- package/src/server/relay.ts +534 -0
- package/src/server/request-decompress.ts +46 -0
- package/src/server/request-log.ts +310 -0
- package/src/server/responses.ts +775 -0
- package/src/{ws-bridge.ts → server/ws-bridge.ts} +44 -13
- package/src/service.ts +19 -11
- package/src/types.ts +27 -0
- package/src/{update.ts → update/index.ts} +5 -5
- package/src/{update-job.ts → update/job.ts} +7 -6
- package/src/{update-notify.ts → update/notify.ts} +3 -3
- package/src/{usage-debug.ts → usage/debug.ts} +3 -3
- package/src/{usage-log.ts → usage/log.ts} +3 -3
- package/src/{usage-summary.ts → usage/summary.ts} +3 -3
- package/src/{usage-totals.ts → usage/totals.ts} +1 -1
- package/src/vision/describe.ts +3 -3
- package/src/vision/index.ts +21 -1
- package/src/web-search/executor.ts +4 -4
- package/src/web-search/index.ts +1 -1
- package/src/web-search/loop.ts +84 -24
- package/gui/dist/assets/index-BcHhxo1I.css +0 -1
- package/gui/dist/assets/index-DCC1q_Jx.js +0 -15
- package/src/server.ts +0 -2501
- /package/src/{codex-account-runtime-state.ts → codex/account-runtime-state.ts} +0 -0
- /package/src/{codex-quota.ts → codex/quota.ts} +0 -0
- /package/src/{abort.ts → lib/abort.ts} +0 -0
- /package/src/{debug.ts → lib/debug.ts} +0 -0
- /package/src/{errors.ts → lib/errors.ts} +0 -0
- /package/src/{open-url.ts → lib/open-url.ts} +0 -0
- /package/src/{privacy.ts → lib/privacy.ts} +0 -0
- /package/src/{redact.ts → lib/redact.ts} +0 -0
- /package/src/{sidecar-tracker.ts → lib/sidecar-tracker.ts} +0 -0
- /package/src/{upstream-retry.ts → lib/upstream-retry.ts} +0 -0
- /package/src/{win-paths.ts → lib/win-paths.ts} +0 -0
- /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
|
+
}
|