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