@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/gui/dist/assets/{index-DalshCSi.js → index-DVvcVBD_.js} +1 -1
- package/gui/dist/assets/index-xJdeQjzZ.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/adapters/anthropic.ts +29 -6
- package/src/adapters/openai-responses.ts +12 -0
- package/src/bridge.ts +21 -1
- package/src/codex-account-label.ts +34 -0
- package/src/codex-account-lifecycle.ts +21 -0
- package/src/codex-account-runtime-state.ts +13 -0
- package/src/codex-account-store.ts +355 -0
- package/src/codex-account-usability.ts +10 -0
- package/src/codex-auth-api.ts +446 -0
- package/src/codex-auth-collision.ts +66 -0
- package/src/codex-auth-context.ts +136 -0
- package/src/codex-catalog.ts +8 -2
- package/src/codex-quota.ts +130 -0
- package/src/codex-routing.ts +382 -0
- package/src/codex-websocket-registry.ts +57 -0
- package/src/config.ts +86 -26
- package/src/debug.ts +5 -4
- package/src/oauth/chatgpt.ts +150 -0
- package/src/oauth/index.ts +35 -7
- package/src/oauth/store.ts +9 -5
- package/src/privacy.ts +11 -0
- package/src/router.ts +1 -1
- package/src/server.ts +360 -23
- package/src/types.ts +32 -0
- package/src/vision/describe.ts +7 -3
- package/src/vision/index.ts +7 -3
- package/src/web-search/executor.ts +8 -3
- package/src/web-search/index.ts +3 -1
- package/src/web-search/loop.ts +6 -5
- package/src/ws-bridge.ts +56 -10
- package/gui/dist/assets/index-dCS-lwCM.css +0 -1
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
|
-
|
|
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: {
|
|
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,
|
|
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,
|
|
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:
|
|
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
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|