@bitkyc08/opencodex 2.6.26-preview.20260705 → 2.6.28-preview.20260707
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/bin/ocx.mjs +4 -4
- package/gui/dist/assets/index-ByGC8-Bm.css +1 -0
- package/gui/dist/assets/index-CkV5xFA8.js +15 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/adapters/anthropic-image-guard.ts +195 -0
- package/src/adapters/anthropic.ts +85 -14
- package/src/adapters/cursor/cursor-errors.ts +2 -2
- package/src/adapters/cursor/live-transport.ts +1 -1
- package/src/adapters/cursor/transport-retry.ts +2 -2
- package/src/adapters/google-errors.ts +1 -1
- package/src/adapters/google-http.ts +1 -1
- package/src/adapters/google-truncation.ts +1 -1
- package/src/adapters/google.ts +1 -1
- package/src/adapters/kiro-errors.ts +1 -1
- package/src/adapters/kiro-retry.ts +1 -1
- package/src/adapters/kiro-truncation.ts +1 -1
- package/src/adapters/kiro.ts +1 -1
- package/src/adapters/openai-chat.ts +12 -2
- package/src/adapters/openai-responses.ts +126 -3
- package/src/bridge.ts +164 -7
- package/src/{doctor.ts → cli/doctor.ts} +21 -4
- package/src/{cli-help.ts → cli/help.ts} +1 -1
- package/src/cli/index.ts +584 -0
- package/src/{init.ts → cli/init.ts} +6 -6
- package/src/{cli-models.ts → cli/models.ts} +2 -2
- package/src/{cli-provider.ts → cli/provider.ts} +12 -8
- package/src/{star-prompt.ts → cli/star-prompt.ts} +1 -1
- package/src/{cli-status.ts → cli/status.ts} +7 -7
- package/src/cli.ts +9 -575
- package/src/{codex-account-label.ts → codex/account-label.ts} +1 -1
- package/src/{codex-account-lifecycle.ts → codex/account-lifecycle.ts} +6 -6
- package/src/{codex-account-store.ts → codex/account-store.ts} +2 -2
- package/src/{codex-account-usability.ts → codex/account-usability.ts} +4 -4
- package/src/{codex-auth-api.ts → codex/auth-api.ts} +18 -18
- package/src/{codex-auth-collision.ts → codex/auth-collision.ts} +4 -4
- package/src/{codex-auth-context.ts → codex/auth-context.ts} +9 -9
- package/src/{codex-catalog.ts → codex/catalog.ts} +49 -20
- package/src/codex/history-migration-guardian.ts +102 -0
- package/src/{codex-history-provider.ts → codex/history-provider.ts} +111 -7
- package/src/{codex-home.ts → codex/home.ts} +1 -1
- package/src/{codex-inject.ts → codex/inject.ts} +204 -26
- package/src/{codex-journal.ts → codex/journal.ts} +2 -2
- package/src/{codex-main-account.ts → codex/main-account.ts} +2 -2
- package/src/{model-cache.ts → codex/model-cache.ts} +1 -1
- package/src/{codex-paths.ts → codex/paths.ts} +2 -2
- package/src/{codex-plugins-doctor.ts → codex/plugins-doctor.ts} +2 -2
- package/src/{codex-refresh.ts → codex/refresh.ts} +4 -4
- package/src/{codex-routing.ts → codex/routing.ts} +8 -8
- package/src/{codex-shim.ts → codex/shim.ts} +6 -5
- package/src/{codex-sync.ts → codex/sync.ts} +4 -4
- package/src/{codex-websocket-registry.ts → codex/websocket-registry.ts} +1 -1
- package/src/config.ts +2 -0
- package/src/generated/jawcode-model-metadata.ts +2 -0
- package/src/{bun-runtime.ts → lib/bun-runtime.ts} +1 -1
- package/src/{crash-guard.ts → lib/crash-guard.ts} +1 -1
- package/src/{process-control.ts → lib/process-control.ts} +1 -1
- package/src/{service-secrets.ts → lib/service-secrets.ts} +1 -1
- package/src/oauth/callback-server.ts +1 -1
- package/src/oauth/google-antigravity.ts +7 -4
- package/src/oauth/index.ts +67 -16
- package/src/oauth/login-cli.ts +2 -2
- package/src/oauth/store.ts +236 -20
- package/src/oauth/token-guardian.ts +24 -20
- package/src/oauth/types.ts +16 -0
- package/src/providers/api-keys.ts +121 -0
- package/src/{provider-context-cap.ts → providers/context-cap.ts} +1 -1
- package/src/providers/derive.ts +2 -0
- package/src/providers/key-failover.ts +145 -0
- package/src/{provider-label.ts → providers/label.ts} +1 -1
- package/src/{provider-quota.ts → providers/quota.ts} +12 -7
- package/src/providers/registry.ts +66 -4
- package/src/responses/compaction.ts +117 -0
- package/src/responses/parser.ts +89 -13
- package/src/responses/reasoning-envelope.ts +52 -0
- package/src/responses/schema.ts +15 -3
- package/src/responses/state.ts +117 -2
- package/src/router.ts +2 -0
- package/src/server/auth-cors.ts +231 -0
- package/src/server/index.ts +523 -0
- package/src/server/lifecycle.ts +73 -0
- package/src/server/management-api.ts +628 -0
- package/src/{proxy-liveness.ts → server/proxy-liveness.ts} +1 -1
- package/src/server/relay.ts +534 -0
- package/src/server/request-decompress.ts +46 -0
- package/src/server/request-log.ts +310 -0
- package/src/server/responses.ts +775 -0
- package/src/{ws-bridge.ts → server/ws-bridge.ts} +44 -13
- package/src/service.ts +19 -11
- package/src/types.ts +27 -0
- package/src/{update.ts → update/index.ts} +5 -5
- package/src/{update-job.ts → update/job.ts} +7 -6
- package/src/{update-notify.ts → update/notify.ts} +3 -3
- package/src/{usage-debug.ts → usage/debug.ts} +3 -3
- package/src/{usage-log.ts → usage/log.ts} +3 -3
- package/src/{usage-summary.ts → usage/summary.ts} +3 -3
- package/src/{usage-totals.ts → usage/totals.ts} +1 -1
- package/src/vision/describe.ts +3 -3
- package/src/vision/index.ts +21 -1
- package/src/web-search/executor.ts +4 -4
- package/src/web-search/index.ts +1 -1
- package/src/web-search/loop.ts +84 -24
- package/gui/dist/assets/index-BcHhxo1I.css +0 -1
- package/gui/dist/assets/index-DCC1q_Jx.js +0 -15
- package/src/server.ts +0 -2501
- /package/src/{codex-account-runtime-state.ts → codex/account-runtime-state.ts} +0 -0
- /package/src/{codex-quota.ts → codex/quota.ts} +0 -0
- /package/src/{abort.ts → lib/abort.ts} +0 -0
- /package/src/{debug.ts → lib/debug.ts} +0 -0
- /package/src/{errors.ts → lib/errors.ts} +0 -0
- /package/src/{open-url.ts → lib/open-url.ts} +0 -0
- /package/src/{privacy.ts → lib/privacy.ts} +0 -0
- /package/src/{redact.ts → lib/redact.ts} +0 -0
- /package/src/{sidecar-tracker.ts → lib/sidecar-tracker.ts} +0 -0
- /package/src/{upstream-retry.ts → lib/upstream-retry.ts} +0 -0
- /package/src/{win-paths.ts → lib/win-paths.ts} +0 -0
- /package/src/{ports.ts → server/ports.ts} +0 -0
package/src/server.ts
DELETED
|
@@ -1,2501 +0,0 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import { timingSafeEqual } from "node:crypto";
|
|
3
|
-
import { bridgeToResponsesSSE, buildResponseJSON, formatErrorResponse, type ResponsesTerminalStatus } from "./bridge";
|
|
4
|
-
import { markActivity } from "./sidecar-tracker";
|
|
5
|
-
import {
|
|
6
|
-
buildWarmupCompletionFrames,
|
|
7
|
-
buildWsErrorFrame,
|
|
8
|
-
selectForwardHeaders,
|
|
9
|
-
sendJsonFrame,
|
|
10
|
-
sendResponseToWebSocket,
|
|
11
|
-
sendTextFrame,
|
|
12
|
-
type WsData,
|
|
13
|
-
} from "./ws-bridge";
|
|
14
|
-
import type { Server, ServerWebSocket } from "bun";
|
|
15
|
-
import {
|
|
16
|
-
DEFAULT_SUBAGENT_MODELS,
|
|
17
|
-
codexAutoStartEnabled,
|
|
18
|
-
applyProxyEnv,
|
|
19
|
-
getConfigPath,
|
|
20
|
-
hasOwnProvider,
|
|
21
|
-
isValidProviderName,
|
|
22
|
-
loadConfig,
|
|
23
|
-
providerBaseUrlConfigError,
|
|
24
|
-
providerHeadersConfigError,
|
|
25
|
-
saveConfig,
|
|
26
|
-
websocketsEnabled,
|
|
27
|
-
} from "./config";
|
|
28
|
-
import { parseRequest } from "./responses/parser";
|
|
29
|
-
import { expandPreviousResponseInput, previousResponseConversationId, rememberResponseState } from "./responses/state";
|
|
30
|
-
import { routeModel } from "./router";
|
|
31
|
-
import { namespacedToolName } from "./types";
|
|
32
|
-
import {
|
|
33
|
-
clearLoginState, getLoginStatus, getOAuthCredentialProjectId, getValidAccessToken, isOAuthProvider,
|
|
34
|
-
listOAuthProviders, reconcileOAuthProviders, startLoginFlow, UnsupportedOAuthProviderError, upsertOAuthProvider,
|
|
35
|
-
} from "./oauth/index";
|
|
36
|
-
import type { CatalogModel } from "./codex-catalog";
|
|
37
|
-
import { invalidateCodexModelsCache, readCodexCatalogPath } from "./codex-catalog";
|
|
38
|
-
import { CODEX_CONFIG_PATH, readRootTomlString } from "./codex-paths";
|
|
39
|
-
import { buildWebSearchTool, planWebSearch, runWithWebSearch } from "./web-search";
|
|
40
|
-
import { describeImagesInPlace, planVisionSidecar } from "./vision";
|
|
41
|
-
import { removeCredential } from "./oauth/store";
|
|
42
|
-
import { enrichProviderFromCatalog, listKeyLoginProviders } from "./oauth/key-providers";
|
|
43
|
-
import { deriveProviderPresets } from "./providers/derive";
|
|
44
|
-
import { createAdapterEventQueue } from "./adapters/run-turn-queue";
|
|
45
|
-
import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig } from "./types";
|
|
46
|
-
import type { OcxUsage } from "./types";
|
|
47
|
-
import { DEFAULT_PROVIDER_CONTEXT_CAP, globalContextCapValue, providerContextCap, providerContextCaps, setAllProviderContextCaps, setGlobalContextCapValue, setProviderContextCap } from "./provider-context-cap";
|
|
48
|
-
import {
|
|
49
|
-
appendUsageEntry,
|
|
50
|
-
readUsageEntries,
|
|
51
|
-
usageForFinalLog,
|
|
52
|
-
usageStatusForFinalLog,
|
|
53
|
-
usageTotalTokens,
|
|
54
|
-
type UsageStatus,
|
|
55
|
-
} from "./usage-log";
|
|
56
|
-
import { parseRange, summarizeUsage } from "./usage-summary";
|
|
57
|
-
import { fetchProviderQuotaReports } from "./provider-quota";
|
|
58
|
-
import {
|
|
59
|
-
appendUsageDebug,
|
|
60
|
-
isUsageDebugEnabled,
|
|
61
|
-
truncateForDebug,
|
|
62
|
-
USAGE_DEBUG_BODY_SAMPLE_BYTES,
|
|
63
|
-
type UsageDebugBodyKind,
|
|
64
|
-
} from "./usage-debug";
|
|
65
|
-
import {
|
|
66
|
-
applyCodexAuthContextToProvider,
|
|
67
|
-
CodexAccountCooldownError,
|
|
68
|
-
CodexAuthContextError,
|
|
69
|
-
CodexThreadAffinityExpiredError,
|
|
70
|
-
headersForCodexAuthContext,
|
|
71
|
-
isCodexAuthContextUsable,
|
|
72
|
-
resolveCodexAuthContext,
|
|
73
|
-
stripCodexRuntimeProviderFields,
|
|
74
|
-
type CodexAuthContext,
|
|
75
|
-
} from "./codex-auth-context";
|
|
76
|
-
export {
|
|
77
|
-
clearThreadAccountMap,
|
|
78
|
-
formatCodexProviderForLog,
|
|
79
|
-
resolveCodexAccountForThread,
|
|
80
|
-
} from "./codex-routing";
|
|
81
|
-
import {
|
|
82
|
-
formatCodexProviderForLog,
|
|
83
|
-
recordCodexUpstreamOutcome,
|
|
84
|
-
type CodexUpstreamOutcome,
|
|
85
|
-
} from "./codex-routing";
|
|
86
|
-
import { registerCodexWebSocket, unregisterCodexWebSocket, updateCodexWebSocketAuthContext } from "./codex-websocket-registry";
|
|
87
|
-
import { resolveGuiFilePath, rootFallbackPayload, serveGuiFile } from "./server/gui-static";
|
|
88
|
-
import { fetchWithResetRetry } from "./upstream-retry";
|
|
89
|
-
export { resolveGuiFilePath, rootFallbackPayload } from "./server/gui-static";
|
|
90
|
-
import { resolveAdapter, resolveWireProtocolOverride } from "./server/adapter-resolve";
|
|
91
|
-
export { resolveAdapter } from "./server/adapter-resolve";
|
|
92
|
-
|
|
93
|
-
// ---------------------------------------------------------------------------
|
|
94
|
-
// Active turn tracking + graceful shutdown drain
|
|
95
|
-
// ---------------------------------------------------------------------------
|
|
96
|
-
|
|
97
|
-
const activeTurns = new Set<AbortController>();
|
|
98
|
-
let draining = false;
|
|
99
|
-
const MAX_WS_FRAME_BYTES = 50 * 1024 * 1024;
|
|
100
|
-
const WEBSOCKET_IDLE_TIMEOUT_SECONDS = 0;
|
|
101
|
-
const nativePassthroughSseResponses = new WeakSet<Response>();
|
|
102
|
-
|
|
103
|
-
export interface RequestLogContext {
|
|
104
|
-
model: string;
|
|
105
|
-
provider: string;
|
|
106
|
-
requestedModel?: string;
|
|
107
|
-
requestedEffort?: string;
|
|
108
|
-
requestedServiceTier?: string;
|
|
109
|
-
requestedSpeedLabel?: string;
|
|
110
|
-
configuredServiceTier?: string;
|
|
111
|
-
configuredSpeedLabel?: string;
|
|
112
|
-
modelSupportsServiceTier?: boolean;
|
|
113
|
-
responseServiceTier?: string;
|
|
114
|
-
resolvedModel?: string;
|
|
115
|
-
usage?: OcxUsage;
|
|
116
|
-
usageLogInputTokens?: number;
|
|
117
|
-
usageDebugBodyKind?: UsageDebugBodyKind;
|
|
118
|
-
usageDebugBodySample?: string;
|
|
119
|
-
usageDebugContentType?: string;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
export function registerTurn(ac: AbortController): void { activeTurns.add(ac); }
|
|
123
|
-
export function unregisterTurn(ac: AbortController): void { activeTurns.delete(ac); }
|
|
124
|
-
export function isDraining(): boolean { return draining; }
|
|
125
|
-
export function getActiveTurnCount(): number { return activeTurns.size; }
|
|
126
|
-
|
|
127
|
-
export function trackStreamLifetime(
|
|
128
|
-
body: ReadableStream<Uint8Array>,
|
|
129
|
-
ac: AbortController,
|
|
130
|
-
onDone?: () => void,
|
|
131
|
-
): ReadableStream<Uint8Array> {
|
|
132
|
-
registerTurn(ac);
|
|
133
|
-
const reader = body.getReader();
|
|
134
|
-
let closed = false;
|
|
135
|
-
const finish = () => {
|
|
136
|
-
if (closed) return;
|
|
137
|
-
closed = true;
|
|
138
|
-
unregisterTurn(ac);
|
|
139
|
-
onDone?.();
|
|
140
|
-
};
|
|
141
|
-
return new ReadableStream<Uint8Array>({
|
|
142
|
-
async pull(controller) {
|
|
143
|
-
try {
|
|
144
|
-
const { done, value } = await reader.read();
|
|
145
|
-
if (done) { finish(); controller.close(); return; }
|
|
146
|
-
controller.enqueue(value);
|
|
147
|
-
} catch (err) {
|
|
148
|
-
finish();
|
|
149
|
-
try { controller.error(err); } catch { /* already closed */ }
|
|
150
|
-
}
|
|
151
|
-
},
|
|
152
|
-
cancel(reason) {
|
|
153
|
-
finish();
|
|
154
|
-
ac.abort(reason);
|
|
155
|
-
reader.cancel(reason).catch(() => {});
|
|
156
|
-
},
|
|
157
|
-
});
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
let _serverRef: ReturnType<typeof Bun.serve> | undefined;
|
|
161
|
-
|
|
162
|
-
export async function drainAndShutdown(
|
|
163
|
-
server: ReturnType<typeof Bun.serve> | undefined,
|
|
164
|
-
timeoutMs: number,
|
|
165
|
-
): Promise<void> {
|
|
166
|
-
const s = server ?? _serverRef;
|
|
167
|
-
draining = true;
|
|
168
|
-
const deadline = Date.now() + timeoutMs;
|
|
169
|
-
while (activeTurns.size > 0 && Date.now() < deadline) {
|
|
170
|
-
await Bun.sleep(100);
|
|
171
|
-
}
|
|
172
|
-
if (activeTurns.size > 0) {
|
|
173
|
-
console.log(`⚠️ Aborting ${activeTurns.size} in-flight turn(s) after ${timeoutMs}ms deadline`);
|
|
174
|
-
for (const ac of activeTurns) {
|
|
175
|
-
ac.abort(new Error("server shutdown"));
|
|
176
|
-
}
|
|
177
|
-
activeTurns.clear();
|
|
178
|
-
}
|
|
179
|
-
s?.stop(true);
|
|
180
|
-
draining = false;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
// Single source of truth = package.json (../ from src/), so /healthz + the GUI badge match the
|
|
184
|
-
// installed npm version instead of a stale hardcode.
|
|
185
|
-
const VERSION = (() => {
|
|
186
|
-
try {
|
|
187
|
-
return JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version as string;
|
|
188
|
-
} catch {
|
|
189
|
-
return "0.0.0";
|
|
190
|
-
}
|
|
191
|
-
})();
|
|
192
|
-
|
|
193
|
-
// GUI static serving extracted to ./server/gui-static. Re-exported below to keep the
|
|
194
|
-
// "../src/server" import surface stable for tests/callers.
|
|
195
|
-
|
|
196
|
-
// Adapter resolution + wire-protocol override extracted to ./server/adapter-resolve.
|
|
197
|
-
|
|
198
|
-
function buildToolBridgeMaps(parsed: OcxParsedRequest): {
|
|
199
|
-
toolNsMap: Map<string, { namespace: string; name: string }>;
|
|
200
|
-
freeformToolNames: Set<string>;
|
|
201
|
-
toolSearchToolNames: Set<string>;
|
|
202
|
-
} {
|
|
203
|
-
const toolNsMap = new Map<string, { namespace: string; name: string }>();
|
|
204
|
-
const freeformToolNames = new Set<string>();
|
|
205
|
-
const toolSearchToolNames = new Set<string>();
|
|
206
|
-
for (const t of parsed.context.tools ?? []) {
|
|
207
|
-
if (t.namespace) toolNsMap.set(namespacedToolName(t.namespace, t.name), { namespace: t.namespace, name: t.name });
|
|
208
|
-
if (t.freeform) freeformToolNames.add(t.name);
|
|
209
|
-
if (t.toolSearch) toolSearchToolNames.add(t.name);
|
|
210
|
-
}
|
|
211
|
-
return { toolNsMap, freeformToolNames, toolSearchToolNames };
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
function sidecarOutcomeRecorder(config: OcxConfig, authCtx: CodexAuthContext): ((outcome: CodexUpstreamOutcome) => void) | undefined {
|
|
215
|
-
return authCtx.kind === "pool" || authCtx.kind === "main-pool"
|
|
216
|
-
? outcome => recordCodexUpstreamOutcome(config, authCtx.accountId, outcome)
|
|
217
|
-
: undefined;
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
/** Account id to attribute log labels / upstream outcomes to (pool + rotation-injected main). */
|
|
221
|
-
function codexLogAccountId(authCtx: CodexAuthContext): string | null {
|
|
222
|
-
return authCtx.kind === "pool" || authCtx.kind === "main-pool" ? authCtx.accountId : null;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function usesCodexForwardPoolAuth(
|
|
226
|
-
authCtx: CodexAuthContext,
|
|
227
|
-
provider: OcxProviderConfig,
|
|
228
|
-
): authCtx is Extract<CodexAuthContext, { kind: "pool" | "main-pool" }> {
|
|
229
|
-
return (authCtx.kind === "pool" || authCtx.kind === "main-pool")
|
|
230
|
-
&& provider.authMode === "forward" && provider.adapter === "openai-responses";
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
function codexForwardTerminalOutcomeRecorder(
|
|
234
|
-
config: OcxConfig,
|
|
235
|
-
authCtx: CodexAuthContext,
|
|
236
|
-
provider: OcxProviderConfig,
|
|
237
|
-
): ((status: ResponsesTerminalStatus) => void) | undefined {
|
|
238
|
-
if (!usesCodexForwardPoolAuth(authCtx, provider)) return undefined;
|
|
239
|
-
return status => recordCodexUpstreamOutcome(config, authCtx.accountId, status === "completed" ? 200 : 502);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
async function handleResponses(
|
|
243
|
-
req: Request,
|
|
244
|
-
config: OcxConfig,
|
|
245
|
-
logCtx: RequestLogContext,
|
|
246
|
-
options: {
|
|
247
|
-
forceEmptyResponseId?: boolean;
|
|
248
|
-
abortSignal?: AbortSignal;
|
|
249
|
-
authContext?: CodexAuthContext;
|
|
250
|
-
selectedForwardHeaders?: Headers;
|
|
251
|
-
recordTerminalOutcomes?: boolean;
|
|
252
|
-
setTerminalOutcomeRecorder?: (recorder: ((status: ResponsesTerminalStatus) => void) | undefined) => void;
|
|
253
|
-
onNativePassthroughTerminal?: (status: ResponsesTerminalStatus) => void;
|
|
254
|
-
onNativePassthroughCancel?: () => void;
|
|
255
|
-
} = {},
|
|
256
|
-
): Promise<Response> {
|
|
257
|
-
let body: unknown;
|
|
258
|
-
try {
|
|
259
|
-
body = await req.json();
|
|
260
|
-
} catch {
|
|
261
|
-
return formatErrorResponse(400, "invalid_request_error", "Invalid JSON body");
|
|
262
|
-
}
|
|
263
|
-
body = expandPreviousResponseInput(body);
|
|
264
|
-
|
|
265
|
-
let parsed;
|
|
266
|
-
try {
|
|
267
|
-
parsed = parseRequest(body);
|
|
268
|
-
parsed._cursorConversationId = previousResponseConversationId(parsed.previousResponseId);
|
|
269
|
-
} catch (err) {
|
|
270
|
-
return formatErrorResponse(400, "invalid_request_error", err instanceof Error ? err.message : String(err));
|
|
271
|
-
}
|
|
272
|
-
logCtx.requestedModel = parsed.modelId;
|
|
273
|
-
logCtx.requestedEffort = parsed.options.reasoning;
|
|
274
|
-
logCtx.requestedServiceTier = parsed.options.serviceTier;
|
|
275
|
-
logCtx.requestedSpeedLabel = requestLogSpeedLabel(parsed.options.serviceTier);
|
|
276
|
-
logCtx.configuredServiceTier = readConfiguredCodexServiceTier();
|
|
277
|
-
logCtx.configuredSpeedLabel = requestLogSpeedLabel(logCtx.configuredServiceTier);
|
|
278
|
-
|
|
279
|
-
let route;
|
|
280
|
-
try {
|
|
281
|
-
route = routeModel(config, parsed.modelId);
|
|
282
|
-
} catch (err) {
|
|
283
|
-
return formatErrorResponse(404, "invalid_request_error", err instanceof Error ? err.message : String(err));
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
// Apply the routed model id upstream: routing may strip a "<provider>/" namespace
|
|
287
|
-
// (e.g. "opencode-go/deepseek-v4-pro" → "deepseek-v4-pro"). Adapters read parsed.modelId,
|
|
288
|
-
// and the passthrough adapter serializes _rawBody, so rewrite both.
|
|
289
|
-
if (route.modelId !== parsed.modelId) {
|
|
290
|
-
if (parsed._rawBody && typeof parsed._rawBody === "object") {
|
|
291
|
-
(parsed._rawBody as { model?: string }).model = route.modelId;
|
|
292
|
-
}
|
|
293
|
-
parsed.modelId = route.modelId;
|
|
294
|
-
}
|
|
295
|
-
logCtx.model = route.modelId;
|
|
296
|
-
logCtx.provider = route.providerName;
|
|
297
|
-
logCtx.modelSupportsServiceTier = catalogModelSupportsServiceTier(
|
|
298
|
-
route.modelId,
|
|
299
|
-
logCtx.requestedServiceTier ?? logCtx.configuredServiceTier,
|
|
300
|
-
);
|
|
301
|
-
|
|
302
|
-
let authCtx: CodexAuthContext;
|
|
303
|
-
let selectedForwardHeaders: Headers;
|
|
304
|
-
try {
|
|
305
|
-
authCtx = options.authContext ?? await resolveCodexAuthContext(req.headers, config);
|
|
306
|
-
selectedForwardHeaders = options.selectedForwardHeaders ?? headersForCodexAuthContext(req.headers, authCtx);
|
|
307
|
-
} catch (err) {
|
|
308
|
-
if (err instanceof CodexAccountCooldownError) {
|
|
309
|
-
return formatErrorResponse(429, "rate_limit_error", "Selected Codex account is cooling down");
|
|
310
|
-
}
|
|
311
|
-
if (err instanceof CodexThreadAffinityExpiredError) {
|
|
312
|
-
return formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session");
|
|
313
|
-
}
|
|
314
|
-
if (err instanceof CodexAuthContextError) {
|
|
315
|
-
const safeAccountLabel = formatCodexProviderForLog(route.providerName, err.accountId, config);
|
|
316
|
-
console.error(`[codex-auth] Pool account ${safeAccountLabel} token failed; reauthentication required`);
|
|
317
|
-
return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
|
|
318
|
-
}
|
|
319
|
-
throw err;
|
|
320
|
-
}
|
|
321
|
-
if (!isCodexAuthContextUsable(authCtx, config)) {
|
|
322
|
-
return formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication");
|
|
323
|
-
}
|
|
324
|
-
route.provider = applyCodexAuthContextToProvider(route.provider, authCtx);
|
|
325
|
-
logCtx.provider = formatCodexProviderForLog(route.providerName, codexLogAccountId(authCtx), config);
|
|
326
|
-
|
|
327
|
-
// OAuth providers: swap in a fresh access token (auto-refreshed) as the Bearer key, so the
|
|
328
|
-
// existing openai-chat / anthropic adapters authenticate with no change.
|
|
329
|
-
if (route.provider.authMode === "oauth") {
|
|
330
|
-
try {
|
|
331
|
-
route.provider = { ...route.provider, apiKey: await getValidAccessToken(route.providerName) };
|
|
332
|
-
// Antigravity (cloud-code-assist) needs the discovered Cloud Code Assist project id in the
|
|
333
|
-
// CCA envelope; the server injects only the bare token, so pull project from the credential.
|
|
334
|
-
if (route.provider.googleMode === "cloud-code-assist" && !route.provider.project) {
|
|
335
|
-
const projectId = getOAuthCredentialProjectId(route.providerName);
|
|
336
|
-
if (projectId) route.provider = { ...route.provider, project: projectId };
|
|
337
|
-
}
|
|
338
|
-
} catch (err) {
|
|
339
|
-
if (err instanceof UnsupportedOAuthProviderError) {
|
|
340
|
-
return formatErrorResponse(
|
|
341
|
-
400,
|
|
342
|
-
"invalid_request_error",
|
|
343
|
-
`${err.message}. Remove or reconfigure provider '${route.providerName}' in ${getConfigPath()}.`,
|
|
344
|
-
);
|
|
345
|
-
}
|
|
346
|
-
return formatErrorResponse(401, "authentication_error", err instanceof Error ? err.message : String(err));
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// Vision sidecar: the routed model can't see images (provider.noVisionModels). Give it "eyes" —
|
|
351
|
-
// describe each attached image with a gpt vision model via the ChatGPT passthrough and replace it
|
|
352
|
-
// with text BEFORE the main call, so the text-only model can reason about it.
|
|
353
|
-
const visionPlan = planVisionSidecar(config, route.provider, route.modelId, parsed, selectedForwardHeaders, authCtx);
|
|
354
|
-
const recordSidecarOutcome = sidecarOutcomeRecorder(config, authCtx);
|
|
355
|
-
if (visionPlan) {
|
|
356
|
-
await describeImagesInPlace(parsed, visionPlan.forwardProvider, selectedForwardHeaders, visionPlan.settings, options.abortSignal, recordSidecarOutcome);
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
const adapterProvider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider);
|
|
360
|
-
const adapter = resolveAdapter(adapterProvider, config.cacheRetention);
|
|
361
|
-
const recordTerminalOutcomes = options.recordTerminalOutcomes !== false;
|
|
362
|
-
|
|
363
|
-
if ("passthrough" in adapter && adapter.passthrough) {
|
|
364
|
-
const request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders });
|
|
365
|
-
// Abort the upstream if the client disconnects. A directly-relayed body does not propagate the
|
|
366
|
-
// consumer's cancel to a signalled fetch, so we pass the signal and relay through relayWithAbort,
|
|
367
|
-
// whose cancel() aborts the upstream — preventing leaked connections (RC2, passthrough path).
|
|
368
|
-
const upstream = new AbortController();
|
|
369
|
-
linkAbortSignal(upstream, options.abortSignal);
|
|
370
|
-
const connectMs = config.connectTimeoutMs ?? 200_000;
|
|
371
|
-
let upstreamResponse: Response;
|
|
372
|
-
try {
|
|
373
|
-
upstreamResponse = await fetchWithResetRetry(
|
|
374
|
-
() => fetchWithHeaderTimeout(request.url, {
|
|
375
|
-
method: request.method,
|
|
376
|
-
headers: request.headers,
|
|
377
|
-
body: request.body,
|
|
378
|
-
}, upstream.signal, connectMs),
|
|
379
|
-
{ abortSignal: upstream.signal, label: safeHostLabel(request.url) },
|
|
380
|
-
);
|
|
381
|
-
} catch (err) {
|
|
382
|
-
upstream.abort();
|
|
383
|
-
const outcome = err instanceof Error && err.name === "TimeoutError" ? "timeout" : "connect_error";
|
|
384
|
-
if (usesCodexForwardPoolAuth(authCtx, route.provider)) recordCodexUpstreamOutcome(config, authCtx.accountId, outcome);
|
|
385
|
-
const msg = outcome === "timeout"
|
|
386
|
-
? `Provider connect timeout after ${connectMs}ms`
|
|
387
|
-
: `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
|
|
388
|
-
return formatErrorResponse(502, "upstream_error", msg);
|
|
389
|
-
}
|
|
390
|
-
const headers = sanitizePassthroughHeaders(upstreamResponse.headers);
|
|
391
|
-
const resolvedModel = headers.get("openai-model")?.trim();
|
|
392
|
-
if (resolvedModel) logCtx.resolvedModel = resolvedModel;
|
|
393
|
-
if (isUsageDebugEnabled()) {
|
|
394
|
-
const upstreamContentType = upstreamResponse.headers.get("content-type");
|
|
395
|
-
if (upstreamContentType) logCtx.usageDebugContentType = upstreamContentType;
|
|
396
|
-
}
|
|
397
|
-
// The chatgpt backend may omit Content-Type on SSE responses. Fall back to
|
|
398
|
-
// treating a successful body as SSE when the caller requested streaming.
|
|
399
|
-
const passthroughCt = headers.get("content-type")?.toLowerCase();
|
|
400
|
-
const isEventStream = passthroughCt?.includes("text/event-stream")
|
|
401
|
-
|| (upstreamResponse.ok && !!upstreamResponse.body && !passthroughCt && parsed.stream);
|
|
402
|
-
const terminalRecorder = codexForwardTerminalOutcomeRecorder(config, authCtx, route.provider);
|
|
403
|
-
const terminalBodyWillRecord = !!terminalRecorder && upstreamResponse.ok && isEventStream;
|
|
404
|
-
// Capture quota from upstream response for multi-account tracking
|
|
405
|
-
if (usesCodexForwardPoolAuth(authCtx, route.provider)) {
|
|
406
|
-
const weeklyRaw = upstreamResponse.headers.get("x-codex-secondary-used-percent");
|
|
407
|
-
const fiveHourRaw = upstreamResponse.headers.get("x-codex-primary-used-percent");
|
|
408
|
-
const monthlyRaw = upstreamResponse.headers.get("x-codex-tertiary-used-percent");
|
|
409
|
-
const weeklyResetRaw = upstreamResponse.headers.get("x-codex-secondary-reset-at");
|
|
410
|
-
const fiveHourResetRaw = upstreamResponse.headers.get("x-codex-primary-reset-at");
|
|
411
|
-
const monthlyResetRaw = upstreamResponse.headers.get("x-codex-tertiary-reset-at");
|
|
412
|
-
const retryAfterRaw = upstreamResponse.headers.get("retry-after");
|
|
413
|
-
if (weeklyRaw || fiveHourRaw || monthlyRaw) {
|
|
414
|
-
const { updateAccountQuota } = await import("./codex-auth-api");
|
|
415
|
-
updateAccountQuota(
|
|
416
|
-
authCtx.accountId,
|
|
417
|
-
weeklyRaw,
|
|
418
|
-
fiveHourRaw,
|
|
419
|
-
weeklyResetRaw,
|
|
420
|
-
fiveHourResetRaw,
|
|
421
|
-
monthlyRaw,
|
|
422
|
-
monthlyResetRaw,
|
|
423
|
-
);
|
|
424
|
-
}
|
|
425
|
-
if (terminalBodyWillRecord) {
|
|
426
|
-
options.setTerminalOutcomeRecorder?.(status => {
|
|
427
|
-
terminalRecorder(status);
|
|
428
|
-
options.onNativePassthroughTerminal?.(status);
|
|
429
|
-
});
|
|
430
|
-
} else {
|
|
431
|
-
recordCodexUpstreamOutcome(config, authCtx.accountId, upstreamResponse.status, {
|
|
432
|
-
retryAfter: retryAfterRaw,
|
|
433
|
-
resetAt: [fiveHourResetRaw, weeklyResetRaw, monthlyResetRaw],
|
|
434
|
-
});
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
// Bun#32111 workaround: passthrough SSE uses tee()+native relay to avoid the
|
|
439
|
-
// async-pull segfault on Windows. Branch[0] goes directly to the Response (Bun
|
|
440
|
-
// native relay, never enters JS Sink.write); branch[1] is consumed in the
|
|
441
|
-
// background for terminal-outcome/quota inspection only.
|
|
442
|
-
if (isEventStream && upstreamResponse.body) {
|
|
443
|
-
const [nativeBody, inspectBody] = upstreamResponse.body.tee();
|
|
444
|
-
const turnAc = new AbortController();
|
|
445
|
-
linkAbortSignal(upstream, turnAc.signal);
|
|
446
|
-
registerTurn(turnAc);
|
|
447
|
-
if (recordTerminalOutcomes) {
|
|
448
|
-
// A real terminal was parsed from the (teed) inspection stream — record it as the outcome
|
|
449
|
-
// even if the client has already disconnected: the turn genuinely reached that terminal, so
|
|
450
|
-
// it must log as completed/failed, not be dropped or downgraded to a cancel (#44). A pure
|
|
451
|
-
// client-cancel (no terminal seen) is finalized separately via consumeForInspection's onCancel.
|
|
452
|
-
const reportNativeTerminal = (status: ResponsesTerminalStatus) => {
|
|
453
|
-
terminalRecorder?.(status);
|
|
454
|
-
options.onNativePassthroughTerminal?.(status);
|
|
455
|
-
};
|
|
456
|
-
consumeForInspection(
|
|
457
|
-
inspectBody,
|
|
458
|
-
reportNativeTerminal,
|
|
459
|
-
turnAc.signal,
|
|
460
|
-
() => unregisterTurn(turnAc),
|
|
461
|
-
logCtx,
|
|
462
|
-
() => options.onNativePassthroughCancel?.(),
|
|
463
|
-
);
|
|
464
|
-
} else {
|
|
465
|
-
consumeForResponseLogMetadata(inspectBody, logCtx, turnAc.signal, () => unregisterTurn(turnAc));
|
|
466
|
-
}
|
|
467
|
-
if (!headers.has("content-type")) headers.set("content-type", "text/event-stream");
|
|
468
|
-
// win32 must keep the pure native relay (Bun#32111 JS-sink segfault); elsewhere a JS pull
|
|
469
|
-
// relay is established practice (relayWithAbort, relaySseWithHeartbeat) and lets a
|
|
470
|
-
// mid-stream reset end with a clean response.failed terminal instead of a raw socket error.
|
|
471
|
-
const clientBody = process.platform === "win32"
|
|
472
|
-
? nativeBody
|
|
473
|
-
: relaySseWithFailedTail(nativeBody, upstream);
|
|
474
|
-
return markNativePassthroughSseResponse(new Response(clientBody, {
|
|
475
|
-
status: upstreamResponse.status,
|
|
476
|
-
headers,
|
|
477
|
-
}));
|
|
478
|
-
}
|
|
479
|
-
if (headers.get("content-type")?.toLowerCase().includes("application/json")) {
|
|
480
|
-
const text = await upstreamResponse.text();
|
|
481
|
-
inspectResponseLogJson(logCtx, text);
|
|
482
|
-
return new Response(text, {
|
|
483
|
-
status: upstreamResponse.status,
|
|
484
|
-
statusText: upstreamResponse.statusText,
|
|
485
|
-
headers,
|
|
486
|
-
});
|
|
487
|
-
}
|
|
488
|
-
const body = relayWithAbort(upstreamResponse.body, upstream);
|
|
489
|
-
const turnAc = new AbortController();
|
|
490
|
-
const tracked = body ? trackStreamLifetime(body, turnAc) : null;
|
|
491
|
-
return new Response(tracked, {
|
|
492
|
-
status: upstreamResponse.status,
|
|
493
|
-
headers,
|
|
494
|
-
});
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
if (adapter.runTurn) {
|
|
498
|
-
const runTurnAbort = new AbortController();
|
|
499
|
-
linkAbortSignal(runTurnAbort, options.abortSignal);
|
|
500
|
-
const queue = createAdapterEventQueue();
|
|
501
|
-
const runTurn = async (): Promise<void> => {
|
|
502
|
-
try {
|
|
503
|
-
await adapter.runTurn?.(
|
|
504
|
-
parsed,
|
|
505
|
-
{ headers: selectedForwardHeaders, abortSignal: runTurnAbort.signal },
|
|
506
|
-
queue.push,
|
|
507
|
-
);
|
|
508
|
-
} catch (err) {
|
|
509
|
-
queue.push({
|
|
510
|
-
type: "error",
|
|
511
|
-
message: err instanceof Error ? err.message : String(err),
|
|
512
|
-
});
|
|
513
|
-
} finally {
|
|
514
|
-
queue.close();
|
|
515
|
-
}
|
|
516
|
-
};
|
|
517
|
-
|
|
518
|
-
const { toolNsMap, freeformToolNames, toolSearchToolNames } = buildToolBridgeMaps(parsed);
|
|
519
|
-
if (parsed.stream) {
|
|
520
|
-
void runTurn();
|
|
521
|
-
const sseStream = bridgeToResponsesSSE(
|
|
522
|
-
queue.stream(), parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
|
|
523
|
-
() => {
|
|
524
|
-
runTurnAbort.abort();
|
|
525
|
-
queue.close();
|
|
526
|
-
}, 2_000,
|
|
527
|
-
{
|
|
528
|
-
...(options.forceEmptyResponseId ? { responseId: "" } : {}),
|
|
529
|
-
stallTimeoutSec: config.stallTimeoutSec,
|
|
530
|
-
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
531
|
-
onCompletedResponse: response => rememberResponseState(parsed._rawBody, response, parsed._cursorConversationId),
|
|
532
|
-
},
|
|
533
|
-
);
|
|
534
|
-
const bridgeTurnAc = new AbortController();
|
|
535
|
-
const trackedSse = trackStreamLifetime(sseStream, bridgeTurnAc);
|
|
536
|
-
return new Response(trackedSse, {
|
|
537
|
-
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" },
|
|
538
|
-
});
|
|
539
|
-
}
|
|
540
|
-
|
|
541
|
-
await runTurn();
|
|
542
|
-
const events = await queue.collect();
|
|
543
|
-
const json = buildResponseJSON(events, parsed.modelId, {
|
|
544
|
-
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
545
|
-
toolNsMap,
|
|
546
|
-
freeformToolNames,
|
|
547
|
-
toolSearchToolNames,
|
|
548
|
-
});
|
|
549
|
-
rememberResponseState(parsed._rawBody, json, parsed._cursorConversationId);
|
|
550
|
-
return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
// Web-search sidecar: Codex enabled web_search but this is a routed (non-OpenAI) model that can't
|
|
554
|
-
// run it server-side. Expose web_search as a function tool and run searches via the gpt-mini sidecar
|
|
555
|
-
// through the ChatGPT passthrough, looping until the model answers. Otherwise take the normal path.
|
|
556
|
-
const wsPlan = planWebSearch(config, parsed, false, selectedForwardHeaders, route.provider, route.modelId, authCtx);
|
|
557
|
-
if (wsPlan) {
|
|
558
|
-
parsed.context.tools = [...(parsed.context.tools ?? []), buildWebSearchTool()];
|
|
559
|
-
return runWithWebSearch({
|
|
560
|
-
parsed, adapter,
|
|
561
|
-
forwardProvider: wsPlan.forwardProvider,
|
|
562
|
-
hostedTool: wsPlan.hostedTool,
|
|
563
|
-
selectedForwardHeaders,
|
|
564
|
-
settings: wsPlan.settings,
|
|
565
|
-
maxSearches: wsPlan.maxSearches,
|
|
566
|
-
forceEmptyResponseId: true,
|
|
567
|
-
abortSignal: options.abortSignal,
|
|
568
|
-
recordSidecarOutcome,
|
|
569
|
-
});
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
const upstream = new AbortController();
|
|
573
|
-
const cleanupUpstreamAbort = linkAbortSignal(upstream, options.abortSignal);
|
|
574
|
-
const connectMs = config.connectTimeoutMs ?? 200_000;
|
|
575
|
-
|
|
576
|
-
const request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders });
|
|
577
|
-
if (typeof request.usageLog?.inputTokens === "number") {
|
|
578
|
-
logCtx.usageLogInputTokens = request.usageLog.inputTokens;
|
|
579
|
-
}
|
|
580
|
-
let upstreamResponse: Response;
|
|
581
|
-
try {
|
|
582
|
-
upstreamResponse = adapter.fetchResponse
|
|
583
|
-
? await adapter.fetchResponse(request, { abortSignal: upstream.signal, timeoutMs: connectMs })
|
|
584
|
-
: await fetchWithResetRetry(
|
|
585
|
-
() => fetchWithHeaderTimeout(request.url, {
|
|
586
|
-
method: request.method, headers: request.headers, body: request.body,
|
|
587
|
-
}, upstream.signal, connectMs),
|
|
588
|
-
{ abortSignal: upstream.signal, label: safeHostLabel(request.url) },
|
|
589
|
-
);
|
|
590
|
-
} catch (err) {
|
|
591
|
-
cleanupUpstreamAbort();
|
|
592
|
-
upstream.abort();
|
|
593
|
-
const msg = err instanceof Error && err.name === "TimeoutError"
|
|
594
|
-
? `Provider connect timeout after ${connectMs}ms`
|
|
595
|
-
: `Provider unreachable: ${err instanceof Error ? err.message : String(err)}`;
|
|
596
|
-
return formatErrorResponse(502, "upstream_error", msg);
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
if (!upstreamResponse.ok) {
|
|
600
|
-
const errorText = await upstreamResponse.text().catch(() => "unknown error");
|
|
601
|
-
cleanupUpstreamAbort();
|
|
602
|
-
return formatErrorResponse(upstreamResponse.status, "upstream_error", `Provider error ${upstreamResponse.status}: ${errorText.slice(0, 500)}`);
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
if (parsed.stream) {
|
|
606
|
-
const eventStream = adapter.parseStream(upstreamResponse);
|
|
607
|
-
const { toolNsMap, freeformToolNames, toolSearchToolNames } = buildToolBridgeMaps(parsed);
|
|
608
|
-
const sseStream = bridgeToResponsesSSE(
|
|
609
|
-
eventStream, parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames,
|
|
610
|
-
() => upstream.abort(), 2_000,
|
|
611
|
-
{
|
|
612
|
-
...(options.forceEmptyResponseId ? { responseId: "" } : {}),
|
|
613
|
-
stallTimeoutSec: config.stallTimeoutSec,
|
|
614
|
-
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
615
|
-
onCompletedResponse: response => rememberResponseState(parsed._rawBody, response, parsed._cursorConversationId),
|
|
616
|
-
},
|
|
617
|
-
);
|
|
618
|
-
const bridgeTurnAc = new AbortController();
|
|
619
|
-
const trackedSse = trackStreamLifetime(sseStream, bridgeTurnAc, cleanupUpstreamAbort);
|
|
620
|
-
return new Response(trackedSse, {
|
|
621
|
-
headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" },
|
|
622
|
-
});
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
if (adapter.parseResponse) {
|
|
626
|
-
let events: AdapterEvent[];
|
|
627
|
-
try {
|
|
628
|
-
events = await adapter.parseResponse(upstreamResponse);
|
|
629
|
-
} finally {
|
|
630
|
-
cleanupUpstreamAbort();
|
|
631
|
-
}
|
|
632
|
-
const { toolNsMap, freeformToolNames, toolSearchToolNames } = buildToolBridgeMaps(parsed);
|
|
633
|
-
const json = buildResponseJSON(events, parsed.modelId, {
|
|
634
|
-
hideThinkingSummary: parsed.options.hideThinkingSummary,
|
|
635
|
-
toolNsMap,
|
|
636
|
-
freeformToolNames,
|
|
637
|
-
toolSearchToolNames,
|
|
638
|
-
});
|
|
639
|
-
rememberResponseState(parsed._rawBody, json, parsed._cursorConversationId);
|
|
640
|
-
return new Response(JSON.stringify(json), { headers: { "Content-Type": "application/json" } });
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
return formatErrorResponse(500, "internal_error", "Non-streaming not supported by this adapter");
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
export function linkAbortSignal(upstream: AbortController, signal?: AbortSignal): () => void {
|
|
647
|
-
if (!signal) return () => {};
|
|
648
|
-
if (signal.aborted) {
|
|
649
|
-
upstream.abort(signal.reason);
|
|
650
|
-
return () => {};
|
|
651
|
-
}
|
|
652
|
-
const onAbort = () => upstream.abort(signal.reason);
|
|
653
|
-
signal.addEventListener("abort", onAbort, { once: true });
|
|
654
|
-
return () => signal.removeEventListener("abort", onAbort);
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
export function disableResponsesRequestTimeout(req: Request, server: Pick<Server<WsData>, "timeout"> | undefined): boolean {
|
|
658
|
-
if (!server) return false;
|
|
659
|
-
try {
|
|
660
|
-
server.timeout(req, 0);
|
|
661
|
-
return true;
|
|
662
|
-
} catch {
|
|
663
|
-
return false;
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
|
|
667
|
-
/** Host-only label for retry logs — never leaks path/query/credentials. */
|
|
668
|
-
function safeHostLabel(url: string): string {
|
|
669
|
-
try {
|
|
670
|
-
return new URL(url).host;
|
|
671
|
-
} catch {
|
|
672
|
-
return "upstream";
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
async function fetchWithHeaderTimeout(
|
|
677
|
-
url: string,
|
|
678
|
-
init: Omit<RequestInit, "signal">,
|
|
679
|
-
abortSignal: AbortSignal,
|
|
680
|
-
timeoutMs: number,
|
|
681
|
-
): Promise<Response> {
|
|
682
|
-
const timeout = new AbortController();
|
|
683
|
-
const timer = setTimeout(() => {
|
|
684
|
-
if (!timeout.signal.aborted) timeout.abort(new DOMException("Timeout elapsed", "TimeoutError"));
|
|
685
|
-
}, timeoutMs);
|
|
686
|
-
try {
|
|
687
|
-
return await fetch(url, {
|
|
688
|
-
...init,
|
|
689
|
-
signal: AbortSignal.any([abortSignal, timeout.signal]),
|
|
690
|
-
});
|
|
691
|
-
} finally {
|
|
692
|
-
clearTimeout(timer);
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
export interface RequestLogEntry {
|
|
697
|
-
requestId: string;
|
|
698
|
-
timestamp: number;
|
|
699
|
-
model: string;
|
|
700
|
-
provider: string;
|
|
701
|
-
requestedModel?: string;
|
|
702
|
-
requestedEffort?: string;
|
|
703
|
-
requestedServiceTier?: string;
|
|
704
|
-
requestedSpeedLabel?: string;
|
|
705
|
-
configuredServiceTier?: string;
|
|
706
|
-
configuredSpeedLabel?: string;
|
|
707
|
-
modelSupportsServiceTier?: boolean;
|
|
708
|
-
responseServiceTier?: string;
|
|
709
|
-
resolvedModel?: string;
|
|
710
|
-
status: number;
|
|
711
|
-
durationMs: number;
|
|
712
|
-
errorCode?: string;
|
|
713
|
-
terminalStatus?: ResponsesTerminalStatus;
|
|
714
|
-
closeReason?: "terminal" | "client_cancel" | "non_stream";
|
|
715
|
-
usageStatus: UsageStatus;
|
|
716
|
-
usage?: OcxUsage;
|
|
717
|
-
totalTokens?: number;
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
const requestLog: RequestLogEntry[] = [];
|
|
721
|
-
const MAX_LOG_SIZE = 200;
|
|
722
|
-
let requestLogSeq = 0;
|
|
723
|
-
|
|
724
|
-
function addRequestLog(entry: RequestLogEntry) {
|
|
725
|
-
requestLog.push(entry);
|
|
726
|
-
if (requestLog.length > MAX_LOG_SIZE) requestLog.shift();
|
|
727
|
-
try {
|
|
728
|
-
appendUsageEntry({
|
|
729
|
-
requestId: entry.requestId,
|
|
730
|
-
timestamp: entry.timestamp,
|
|
731
|
-
provider: entry.provider,
|
|
732
|
-
model: entry.model,
|
|
733
|
-
...(entry.resolvedModel ? { resolvedModel: entry.resolvedModel } : {}),
|
|
734
|
-
status: entry.status,
|
|
735
|
-
durationMs: entry.durationMs,
|
|
736
|
-
usageStatus: entry.usageStatus,
|
|
737
|
-
...(entry.usage ? { usage: entry.usage } : {}),
|
|
738
|
-
...(entry.totalTokens !== undefined ? { totalTokens: entry.totalTokens } : {}),
|
|
739
|
-
});
|
|
740
|
-
} catch {
|
|
741
|
-
/* request logging must never fail a user request */
|
|
742
|
-
}
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
export function nextRequestLogId(timestamp = Date.now()): string {
|
|
746
|
-
requestLogSeq = (requestLogSeq % 1_000_000) + 1;
|
|
747
|
-
return `ocx-${timestamp.toString(36)}-${requestLogSeq.toString(36)}`;
|
|
748
|
-
}
|
|
749
|
-
|
|
750
|
-
export function requestLogErrorCode(status: number): string | undefined {
|
|
751
|
-
if (status >= 200 && status < 400) return undefined;
|
|
752
|
-
if (status === 400 || status === 409) return "invalid_request_error";
|
|
753
|
-
if (status === 401 || status === 403) return "invalid_api_key";
|
|
754
|
-
if (status === 429) return "rate_limit_exceeded";
|
|
755
|
-
if (status === 499) return "client_closed_request";
|
|
756
|
-
if (status === 503) return "server_is_overloaded";
|
|
757
|
-
if (status >= 500) return "upstream_server_error";
|
|
758
|
-
return `http_${status}`;
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
export function requestLogSpeedLabel(serviceTier: string | undefined): string | undefined {
|
|
762
|
-
const normalized = serviceTier?.trim().toLowerCase();
|
|
763
|
-
if (normalized === "priority" || normalized === "fast") return "fast";
|
|
764
|
-
return undefined;
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
function readConfiguredCodexServiceTier(): string | undefined {
|
|
768
|
-
try {
|
|
769
|
-
if (!existsSync(CODEX_CONFIG_PATH)) return undefined;
|
|
770
|
-
return readRootTomlString(readFileSync(CODEX_CONFIG_PATH, "utf-8"), "service_tier") ?? undefined;
|
|
771
|
-
} catch {
|
|
772
|
-
return undefined;
|
|
773
|
-
}
|
|
774
|
-
}
|
|
775
|
-
|
|
776
|
-
function catalogModelSupportsServiceTier(modelId: string, serviceTier: string | undefined): boolean | undefined {
|
|
777
|
-
if (!serviceTier) return undefined;
|
|
778
|
-
const requestTier = serviceTier.trim().toLowerCase() === "fast" ? "priority" : serviceTier.trim();
|
|
779
|
-
try {
|
|
780
|
-
const catalogPath = readCodexCatalogPath();
|
|
781
|
-
if (!existsSync(catalogPath)) return undefined;
|
|
782
|
-
const catalog = JSON.parse(readFileSync(catalogPath, "utf-8")) as { models?: unknown };
|
|
783
|
-
const models = Array.isArray(catalog.models) ? catalog.models : [];
|
|
784
|
-
const entry = models.find(model => {
|
|
785
|
-
if (!model || typeof model !== "object") return false;
|
|
786
|
-
return (model as { slug?: unknown; id?: unknown }).slug === modelId
|
|
787
|
-
|| (model as { slug?: unknown; id?: unknown }).id === modelId;
|
|
788
|
-
});
|
|
789
|
-
if (!entry || typeof entry !== "object") return undefined;
|
|
790
|
-
const tiers = (entry as { service_tiers?: unknown }).service_tiers;
|
|
791
|
-
return Array.isArray(tiers) && tiers.some(tier => (
|
|
792
|
-
tier && typeof tier === "object" && (tier as { id?: unknown }).id === requestTier
|
|
793
|
-
));
|
|
794
|
-
} catch {
|
|
795
|
-
return undefined;
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
function applyResponseLogMetadata(logCtx: RequestLogContext, payload: unknown): void {
|
|
800
|
-
if (!payload || typeof payload !== "object") return;
|
|
801
|
-
const source = "response" in payload && typeof (payload as { response?: unknown }).response === "object"
|
|
802
|
-
? (payload as { response?: unknown }).response
|
|
803
|
-
: payload;
|
|
804
|
-
if (!source || typeof source !== "object") return;
|
|
805
|
-
const model = (source as { model?: unknown }).model;
|
|
806
|
-
if (typeof model === "string" && model.trim()) logCtx.resolvedModel = model;
|
|
807
|
-
const serviceTier = (source as { service_tier?: unknown }).service_tier;
|
|
808
|
-
if (typeof serviceTier === "string" && serviceTier.trim()) logCtx.responseServiceTier = serviceTier;
|
|
809
|
-
const usage = usageFromResponsesPayload((source as { usage?: unknown }).usage);
|
|
810
|
-
if (usage) logCtx.usage = usage;
|
|
811
|
-
}
|
|
812
|
-
|
|
813
|
-
export function usageFromResponsesPayload(usage: unknown): OcxUsage | undefined {
|
|
814
|
-
if (!usage || typeof usage !== "object") return undefined;
|
|
815
|
-
const raw = usage as {
|
|
816
|
-
input_tokens?: unknown;
|
|
817
|
-
output_tokens?: unknown;
|
|
818
|
-
input_tokens_details?: { cached_tokens?: unknown };
|
|
819
|
-
output_tokens_details?: { reasoning_tokens?: unknown };
|
|
820
|
-
total_tokens?: unknown;
|
|
821
|
-
prompt_tokens?: unknown;
|
|
822
|
-
completion_tokens?: unknown;
|
|
823
|
-
prompt_tokens_details?: { cached_tokens?: unknown };
|
|
824
|
-
completion_tokens_details?: { reasoning_tokens?: unknown };
|
|
825
|
-
};
|
|
826
|
-
if (typeof raw.input_tokens === "number" && typeof raw.output_tokens === "number") {
|
|
827
|
-
return {
|
|
828
|
-
inputTokens: raw.input_tokens,
|
|
829
|
-
outputTokens: raw.output_tokens,
|
|
830
|
-
...(typeof raw.total_tokens === "number" ? { totalTokens: raw.total_tokens } : {}),
|
|
831
|
-
...(typeof raw.input_tokens_details?.cached_tokens === "number"
|
|
832
|
-
? { cachedInputTokens: raw.input_tokens_details.cached_tokens }
|
|
833
|
-
: {}),
|
|
834
|
-
...(typeof raw.output_tokens_details?.reasoning_tokens === "number"
|
|
835
|
-
? { reasoningOutputTokens: raw.output_tokens_details.reasoning_tokens }
|
|
836
|
-
: {}),
|
|
837
|
-
};
|
|
838
|
-
}
|
|
839
|
-
if (typeof raw.prompt_tokens === "number" && typeof raw.completion_tokens === "number") {
|
|
840
|
-
return {
|
|
841
|
-
inputTokens: raw.prompt_tokens,
|
|
842
|
-
outputTokens: raw.completion_tokens,
|
|
843
|
-
...(typeof raw.total_tokens === "number" ? { totalTokens: raw.total_tokens } : {}),
|
|
844
|
-
...(typeof raw.prompt_tokens_details?.cached_tokens === "number"
|
|
845
|
-
? { cachedInputTokens: raw.prompt_tokens_details.cached_tokens }
|
|
846
|
-
: {}),
|
|
847
|
-
...(typeof raw.completion_tokens_details?.reasoning_tokens === "number"
|
|
848
|
-
? { reasoningOutputTokens: raw.completion_tokens_details.reasoning_tokens }
|
|
849
|
-
: {}),
|
|
850
|
-
};
|
|
851
|
-
}
|
|
852
|
-
return undefined;
|
|
853
|
-
}
|
|
854
|
-
|
|
855
|
-
function inspectResponseLogJson(logCtx: RequestLogContext, text: string): void {
|
|
856
|
-
try {
|
|
857
|
-
applyResponseLogMetadata(logCtx, JSON.parse(text));
|
|
858
|
-
} catch {
|
|
859
|
-
/* body may not be JSON; request log metadata is best-effort only */
|
|
860
|
-
}
|
|
861
|
-
if (isUsageDebugEnabled() && logCtx.usageDebugBodyKind === undefined) {
|
|
862
|
-
logCtx.usageDebugBodyKind = "json";
|
|
863
|
-
logCtx.usageDebugBodySample = truncateForDebug(text);
|
|
864
|
-
}
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
function inspectResponseLogSsePayload(logCtx: RequestLogContext, payload: string | null): void {
|
|
868
|
-
if (!payload || payload.trim() === "[DONE]") return;
|
|
869
|
-
const debugEnabled = isUsageDebugEnabled();
|
|
870
|
-
const sseAlreadyMarked = logCtx.usageDebugBodyKind === "sse";
|
|
871
|
-
try {
|
|
872
|
-
applyResponseLogMetadata(logCtx, JSON.parse(payload));
|
|
873
|
-
} catch {
|
|
874
|
-
/* SSE block payload may not be JSON; metadata inspection is best-effort */
|
|
875
|
-
}
|
|
876
|
-
if (debugEnabled) {
|
|
877
|
-
if (!sseAlreadyMarked) {
|
|
878
|
-
logCtx.usageDebugBodyKind = "sse";
|
|
879
|
-
logCtx.usageDebugBodySample = truncateForDebug(payload);
|
|
880
|
-
} else if (typeof logCtx.usageDebugBodySample === "string"
|
|
881
|
-
&& logCtx.usageDebugBodySample.length < USAGE_DEBUG_BODY_SAMPLE_BYTES) {
|
|
882
|
-
const combined = `${logCtx.usageDebugBodySample}\n${payload}`;
|
|
883
|
-
logCtx.usageDebugBodySample = truncateForDebug(combined);
|
|
884
|
-
}
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
function httpStatusForTerminalStatus(status: ResponsesTerminalStatus): number {
|
|
889
|
-
return status === "completed" ? 200 : 502;
|
|
890
|
-
}
|
|
891
|
-
|
|
892
|
-
function addFinalRequestLog(
|
|
893
|
-
requestId: string,
|
|
894
|
-
start: number,
|
|
895
|
-
logCtx: RequestLogContext,
|
|
896
|
-
status: number,
|
|
897
|
-
meta?: Pick<RequestLogEntry, "terminalStatus" | "closeReason">,
|
|
898
|
-
addLog: (entry: RequestLogEntry) => void = addRequestLog,
|
|
899
|
-
): void {
|
|
900
|
-
const errorCode = requestLogErrorCode(status);
|
|
901
|
-
const finalUsage = usageForFinalLog(logCtx.provider, logCtx.usage);
|
|
902
|
-
const usageFallback = !finalUsage && typeof logCtx.usageLogInputTokens === "number"
|
|
903
|
-
? { inputTokens: logCtx.usageLogInputTokens, outputTokens: 0, estimated: true }
|
|
904
|
-
: undefined;
|
|
905
|
-
const loggedUsage = finalUsage && typeof logCtx.usageLogInputTokens === "number"
|
|
906
|
-
? { ...finalUsage, inputTokens: Math.max(finalUsage.inputTokens, logCtx.usageLogInputTokens) }
|
|
907
|
-
: (finalUsage ?? usageFallback);
|
|
908
|
-
const usageStatus = usageStatusForFinalLog(loggedUsage);
|
|
909
|
-
const totalTokens = usageTotalTokens(loggedUsage);
|
|
910
|
-
addLog({
|
|
911
|
-
requestId,
|
|
912
|
-
timestamp: start,
|
|
913
|
-
model: logCtx.model,
|
|
914
|
-
provider: logCtx.provider,
|
|
915
|
-
...(logCtx.requestedModel ? { requestedModel: logCtx.requestedModel } : {}),
|
|
916
|
-
...(logCtx.requestedEffort ? { requestedEffort: logCtx.requestedEffort } : {}),
|
|
917
|
-
...(logCtx.requestedServiceTier ? { requestedServiceTier: logCtx.requestedServiceTier } : {}),
|
|
918
|
-
...(logCtx.requestedSpeedLabel ? { requestedSpeedLabel: logCtx.requestedSpeedLabel } : {}),
|
|
919
|
-
...(logCtx.configuredServiceTier ? { configuredServiceTier: logCtx.configuredServiceTier } : {}),
|
|
920
|
-
...(logCtx.configuredSpeedLabel ? { configuredSpeedLabel: logCtx.configuredSpeedLabel } : {}),
|
|
921
|
-
...(logCtx.modelSupportsServiceTier !== undefined ? { modelSupportsServiceTier: logCtx.modelSupportsServiceTier } : {}),
|
|
922
|
-
...(logCtx.responseServiceTier ? { responseServiceTier: logCtx.responseServiceTier } : {}),
|
|
923
|
-
...(logCtx.resolvedModel ? { resolvedModel: logCtx.resolvedModel } : {}),
|
|
924
|
-
status,
|
|
925
|
-
durationMs: Date.now() - start,
|
|
926
|
-
...(errorCode ? { errorCode } : {}),
|
|
927
|
-
...(meta?.terminalStatus ? { terminalStatus: meta.terminalStatus } : {}),
|
|
928
|
-
...(meta?.closeReason ? { closeReason: meta.closeReason } : {}),
|
|
929
|
-
usageStatus,
|
|
930
|
-
...(loggedUsage ? { usage: loggedUsage } : {}),
|
|
931
|
-
...(totalTokens !== undefined ? { totalTokens } : {}),
|
|
932
|
-
});
|
|
933
|
-
if (isUsageDebugEnabled()) {
|
|
934
|
-
appendUsageDebug({
|
|
935
|
-
ts: Date.now(),
|
|
936
|
-
requestId,
|
|
937
|
-
provider: logCtx.provider,
|
|
938
|
-
model: logCtx.model,
|
|
939
|
-
upstreamContentType: logCtx.usageDebugContentType ?? null,
|
|
940
|
-
upstreamStatus: status,
|
|
941
|
-
bodyKind: logCtx.usageDebugBodyKind ?? "none",
|
|
942
|
-
bodySample: logCtx.usageDebugBodySample ?? "",
|
|
943
|
-
extractedUsage: loggedUsage ?? null,
|
|
944
|
-
});
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
export function filterRequestLogs(logs: RequestLogEntry[], params: URLSearchParams): RequestLogEntry[] {
|
|
949
|
-
let filtered = logs;
|
|
950
|
-
const provider = params.get("provider")?.trim();
|
|
951
|
-
if (provider) filtered = filtered.filter(entry => entry.provider === provider);
|
|
952
|
-
const status = params.get("status")?.trim().toLowerCase();
|
|
953
|
-
if (status) {
|
|
954
|
-
filtered = /^[1-5]xx$/.test(status)
|
|
955
|
-
? filtered.filter(entry => Math.floor(entry.status / 100) === Number(status[0]))
|
|
956
|
-
: filtered.filter(entry => String(entry.status) === status);
|
|
957
|
-
}
|
|
958
|
-
const tailRaw = params.get("tail")?.trim();
|
|
959
|
-
if (tailRaw) {
|
|
960
|
-
const tail = Number.parseInt(tailRaw, 10);
|
|
961
|
-
if (Number.isFinite(tail) && tail > 0) filtered = filtered.slice(-Math.min(tail, MAX_LOG_SIZE));
|
|
962
|
-
}
|
|
963
|
-
return filtered;
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
/**
|
|
967
|
-
* Relay an upstream body verbatim while wiring client-cancel -> upstream.abort(). A body returned
|
|
968
|
-
* directly from fetch does NOT propagate the consumer's cancel to a signalled fetch, so a client
|
|
969
|
-
* disconnect would leak the upstream connection. Pumping through this stream (whose cancel() aborts
|
|
970
|
-
* the upstream) fixes the leak with zero byte changes — passthrough fidelity is preserved (RC2).
|
|
971
|
-
*/
|
|
972
|
-
export function relayWithAbort(
|
|
973
|
-
body: ReadableStream<Uint8Array> | null,
|
|
974
|
-
upstream: AbortController,
|
|
975
|
-
): ReadableStream<Uint8Array> | null {
|
|
976
|
-
if (!body) return null;
|
|
977
|
-
const reader = body.getReader();
|
|
978
|
-
return new ReadableStream<Uint8Array>({
|
|
979
|
-
async pull(controller) {
|
|
980
|
-
try {
|
|
981
|
-
const { done, value } = await reader.read();
|
|
982
|
-
if (done) {
|
|
983
|
-
controller.close();
|
|
984
|
-
return;
|
|
985
|
-
}
|
|
986
|
-
controller.enqueue(value);
|
|
987
|
-
} catch (err) {
|
|
988
|
-
try { controller.error(err); } catch { /* already torn down */ }
|
|
989
|
-
}
|
|
990
|
-
},
|
|
991
|
-
cancel(reason) {
|
|
992
|
-
// Client disconnected: abort the upstream fetch and release the reader so we do not leak it.
|
|
993
|
-
upstream.abort(reason);
|
|
994
|
-
reader.cancel(reason).catch(() => {});
|
|
995
|
-
},
|
|
996
|
-
});
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
/**
|
|
1000
|
-
* Relay a passthrough SSE body like relayWithAbort, but convert a MID-STREAM failure (upstream
|
|
1001
|
-
* reset after headers) into a clean terminal: any partial block is closed off, then a synthetic
|
|
1002
|
-
* `response.failed` event and `data: [DONE]` are emitted and the stream closes. Without this the
|
|
1003
|
-
* client sees a raw socket teardown with no terminal SSE event. Deliberately NOT a resend: the
|
|
1004
|
-
* upstream already committed the request (duplicate-completion risk — same policy as cursor's
|
|
1005
|
-
* committed=non-replayable transport retry).
|
|
1006
|
-
*/
|
|
1007
|
-
export function relaySseWithFailedTail(
|
|
1008
|
-
body: ReadableStream<Uint8Array>,
|
|
1009
|
-
upstream: AbortController,
|
|
1010
|
-
): ReadableStream<Uint8Array> {
|
|
1011
|
-
const reader = body.getReader();
|
|
1012
|
-
const encoder = new TextEncoder();
|
|
1013
|
-
return new ReadableStream<Uint8Array>({
|
|
1014
|
-
async pull(controller) {
|
|
1015
|
-
try {
|
|
1016
|
-
const { done, value } = await reader.read();
|
|
1017
|
-
if (done) {
|
|
1018
|
-
controller.close();
|
|
1019
|
-
return;
|
|
1020
|
-
}
|
|
1021
|
-
controller.enqueue(value);
|
|
1022
|
-
} catch (err) {
|
|
1023
|
-
const failure = {
|
|
1024
|
-
type: "upstream_error",
|
|
1025
|
-
code: "upstream_reset",
|
|
1026
|
-
message: `Upstream stream terminated unexpectedly: ${err instanceof Error ? err.message : String(err)}`,
|
|
1027
|
-
};
|
|
1028
|
-
const payload = JSON.stringify({
|
|
1029
|
-
type: "response.failed",
|
|
1030
|
-
response: { status: "failed", error: failure, last_error: failure },
|
|
1031
|
-
});
|
|
1032
|
-
try {
|
|
1033
|
-
// Leading blank line terminates a partial SSE block so the failed frame parses cleanly.
|
|
1034
|
-
controller.enqueue(encoder.encode(`\n\nevent: response.failed\ndata: ${payload}\n\ndata: [DONE]\n\n`));
|
|
1035
|
-
controller.close();
|
|
1036
|
-
} catch { /* client already torn down */ }
|
|
1037
|
-
upstream.abort();
|
|
1038
|
-
}
|
|
1039
|
-
},
|
|
1040
|
-
cancel(reason) {
|
|
1041
|
-
upstream.abort(reason);
|
|
1042
|
-
reader.cancel(reason).catch(() => {});
|
|
1043
|
-
},
|
|
1044
|
-
});
|
|
1045
|
-
}
|
|
1046
|
-
|
|
1047
|
-
function nextSseBlock(buffer: string): { block: string; rest: string } | null {
|
|
1048
|
-
const match = buffer.match(/\r?\n\r?\n/);
|
|
1049
|
-
if (!match || match.index === undefined) return null;
|
|
1050
|
-
return {
|
|
1051
|
-
block: buffer.slice(0, match.index),
|
|
1052
|
-
rest: buffer.slice(match.index + match[0].length),
|
|
1053
|
-
};
|
|
1054
|
-
}
|
|
1055
|
-
|
|
1056
|
-
function sseDataPayload(block: string): string | null {
|
|
1057
|
-
const data: string[] = [];
|
|
1058
|
-
for (const line of block.split(/\r?\n/)) {
|
|
1059
|
-
if (!line.startsWith("data:")) continue;
|
|
1060
|
-
const value = line.slice(5);
|
|
1061
|
-
data.push(value.startsWith(" ") ? value.slice(1) : value);
|
|
1062
|
-
}
|
|
1063
|
-
return data.length > 0 ? data.join("\n") : null;
|
|
1064
|
-
}
|
|
1065
|
-
|
|
1066
|
-
function terminalStatusFromSsePayload(payload: string): ResponsesTerminalStatus | null {
|
|
1067
|
-
if (payload === "[DONE]") return null;
|
|
1068
|
-
try {
|
|
1069
|
-
const json = JSON.parse(payload) as { type?: unknown };
|
|
1070
|
-
switch (json.type) {
|
|
1071
|
-
case "response.completed":
|
|
1072
|
-
return "completed";
|
|
1073
|
-
case "response.failed":
|
|
1074
|
-
return "failed";
|
|
1075
|
-
case "response.incomplete":
|
|
1076
|
-
return "incomplete";
|
|
1077
|
-
default:
|
|
1078
|
-
return null;
|
|
1079
|
-
}
|
|
1080
|
-
} catch {
|
|
1081
|
-
return null;
|
|
1082
|
-
}
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
function trackSseForRequestLog(
|
|
1086
|
-
body: ReadableStream<Uint8Array>,
|
|
1087
|
-
onTerminal: (status: ResponsesTerminalStatus) => void,
|
|
1088
|
-
onCancel: () => void,
|
|
1089
|
-
logCtx?: RequestLogContext,
|
|
1090
|
-
): ReadableStream<Uint8Array> {
|
|
1091
|
-
const reader = body.getReader();
|
|
1092
|
-
const decoder = new TextDecoder();
|
|
1093
|
-
let buffer = "";
|
|
1094
|
-
let terminalReported = false;
|
|
1095
|
-
|
|
1096
|
-
const reportTerminal = (status: ResponsesTerminalStatus) => {
|
|
1097
|
-
if (terminalReported) return;
|
|
1098
|
-
terminalReported = true;
|
|
1099
|
-
onTerminal(status);
|
|
1100
|
-
};
|
|
1101
|
-
|
|
1102
|
-
const inspectPayload = (payload: string | null) => {
|
|
1103
|
-
if (!payload) return;
|
|
1104
|
-
if (logCtx) inspectResponseLogSsePayload(logCtx, payload);
|
|
1105
|
-
const status = terminalStatusFromSsePayload(payload);
|
|
1106
|
-
if (status) reportTerminal(status);
|
|
1107
|
-
};
|
|
1108
|
-
|
|
1109
|
-
const inspectChunk = (value: Uint8Array) => {
|
|
1110
|
-
buffer += decoder.decode(value, { stream: true });
|
|
1111
|
-
let next: { block: string; rest: string } | null;
|
|
1112
|
-
while ((next = nextSseBlock(buffer))) {
|
|
1113
|
-
buffer = next.rest;
|
|
1114
|
-
inspectPayload(sseDataPayload(next.block));
|
|
1115
|
-
}
|
|
1116
|
-
};
|
|
1117
|
-
|
|
1118
|
-
return new ReadableStream<Uint8Array>({
|
|
1119
|
-
async pull(controller) {
|
|
1120
|
-
try {
|
|
1121
|
-
const { done, value } = await reader.read();
|
|
1122
|
-
if (done) {
|
|
1123
|
-
buffer += decoder.decode();
|
|
1124
|
-
if (buffer.trim()) inspectPayload(sseDataPayload(buffer));
|
|
1125
|
-
if (!terminalReported) reportTerminal("incomplete");
|
|
1126
|
-
controller.close();
|
|
1127
|
-
return;
|
|
1128
|
-
}
|
|
1129
|
-
inspectChunk(value);
|
|
1130
|
-
controller.enqueue(value);
|
|
1131
|
-
} catch (err) {
|
|
1132
|
-
if (!terminalReported) reportTerminal("incomplete");
|
|
1133
|
-
try { controller.error(err); } catch { /* already torn down */ }
|
|
1134
|
-
}
|
|
1135
|
-
},
|
|
1136
|
-
cancel(reason) {
|
|
1137
|
-
onCancel();
|
|
1138
|
-
reader.cancel(reason).catch(() => {});
|
|
1139
|
-
},
|
|
1140
|
-
});
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
export function responseWithDeferredRequestLog(
|
|
1144
|
-
response: Response,
|
|
1145
|
-
requestId: string,
|
|
1146
|
-
start: number,
|
|
1147
|
-
logCtx: RequestLogContext,
|
|
1148
|
-
addLog: (entry: RequestLogEntry) => void = addRequestLog,
|
|
1149
|
-
): Response {
|
|
1150
|
-
const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";
|
|
1151
|
-
if (isUsageDebugEnabled() && !logCtx.usageDebugContentType && contentType) {
|
|
1152
|
-
logCtx.usageDebugContentType = contentType;
|
|
1153
|
-
}
|
|
1154
|
-
if (isNativePassthroughSseResponse(response)) {
|
|
1155
|
-
return response;
|
|
1156
|
-
}
|
|
1157
|
-
if (!response.body || !contentType.includes("text/event-stream")) {
|
|
1158
|
-
if (response.body && contentType.includes("application/json")) {
|
|
1159
|
-
const finalizeJsonLog = async () => {
|
|
1160
|
-
const text = await response.text();
|
|
1161
|
-
inspectResponseLogJson(logCtx, text);
|
|
1162
|
-
addFinalRequestLog(requestId, start, logCtx, response.status, { closeReason: "non_stream" }, addLog);
|
|
1163
|
-
return text;
|
|
1164
|
-
};
|
|
1165
|
-
const body = new ReadableStream<Uint8Array>({
|
|
1166
|
-
async start(controller) {
|
|
1167
|
-
try {
|
|
1168
|
-
controller.enqueue(new TextEncoder().encode(await finalizeJsonLog()));
|
|
1169
|
-
controller.close();
|
|
1170
|
-
} catch (err) {
|
|
1171
|
-
addFinalRequestLog(requestId, start, logCtx, 502, { closeReason: "non_stream" }, addLog);
|
|
1172
|
-
try { controller.error(err); } catch { /* already torn down */ }
|
|
1173
|
-
}
|
|
1174
|
-
},
|
|
1175
|
-
});
|
|
1176
|
-
return new Response(body, {
|
|
1177
|
-
status: response.status,
|
|
1178
|
-
statusText: response.statusText,
|
|
1179
|
-
headers: response.headers,
|
|
1180
|
-
});
|
|
1181
|
-
}
|
|
1182
|
-
if (isUsageDebugEnabled() && logCtx.usageDebugBodyKind === undefined) {
|
|
1183
|
-
logCtx.usageDebugBodyKind = response.body ? "other" : "none";
|
|
1184
|
-
}
|
|
1185
|
-
addFinalRequestLog(requestId, start, logCtx, response.status, { closeReason: "non_stream" }, addLog);
|
|
1186
|
-
return response;
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1189
|
-
let logged = false;
|
|
1190
|
-
const body = trackSseForRequestLog(
|
|
1191
|
-
response.body,
|
|
1192
|
-
status => {
|
|
1193
|
-
if (logged) return;
|
|
1194
|
-
logged = true;
|
|
1195
|
-
addFinalRequestLog(requestId, start, logCtx, httpStatusForTerminalStatus(status), {
|
|
1196
|
-
terminalStatus: status,
|
|
1197
|
-
closeReason: "terminal",
|
|
1198
|
-
}, addLog);
|
|
1199
|
-
},
|
|
1200
|
-
() => {
|
|
1201
|
-
if (logged) return;
|
|
1202
|
-
logged = true;
|
|
1203
|
-
addFinalRequestLog(requestId, start, logCtx, 499, { closeReason: "client_cancel" }, addLog);
|
|
1204
|
-
},
|
|
1205
|
-
logCtx,
|
|
1206
|
-
);
|
|
1207
|
-
return new Response(body, {
|
|
1208
|
-
status: response.status,
|
|
1209
|
-
statusText: response.statusText,
|
|
1210
|
-
headers: response.headers,
|
|
1211
|
-
});
|
|
1212
|
-
}
|
|
1213
|
-
|
|
1214
|
-
function markNativePassthroughSseResponse(response: Response): Response {
|
|
1215
|
-
nativePassthroughSseResponses.add(response);
|
|
1216
|
-
return response;
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
function isNativePassthroughSseResponse(response: Response): boolean {
|
|
1220
|
-
return nativePassthroughSseResponses.has(response);
|
|
1221
|
-
}
|
|
1222
|
-
|
|
1223
|
-
export function relaySseWithHeartbeat(
|
|
1224
|
-
body: ReadableStream<Uint8Array> | null,
|
|
1225
|
-
upstream: AbortController,
|
|
1226
|
-
heartbeatMs = 15_000,
|
|
1227
|
-
onTerminal?: (status: ResponsesTerminalStatus) => void,
|
|
1228
|
-
options?: { onStart?: () => void; onDone?: () => void },
|
|
1229
|
-
): ReadableStream<Uint8Array> | null {
|
|
1230
|
-
if (!body) return null;
|
|
1231
|
-
const reader = body.getReader();
|
|
1232
|
-
const decoder = new TextDecoder();
|
|
1233
|
-
const heartbeat = new TextEncoder().encode(": opencodex keepalive\n\n");
|
|
1234
|
-
let timer: ReturnType<typeof setInterval> | undefined;
|
|
1235
|
-
let closed = false;
|
|
1236
|
-
let clientCancelled = false;
|
|
1237
|
-
let terminalReported = false;
|
|
1238
|
-
let buffer = "";
|
|
1239
|
-
|
|
1240
|
-
const reportTerminal = (status: ResponsesTerminalStatus) => {
|
|
1241
|
-
if (terminalReported || clientCancelled || closed) return;
|
|
1242
|
-
terminalReported = true;
|
|
1243
|
-
onTerminal?.(status);
|
|
1244
|
-
};
|
|
1245
|
-
|
|
1246
|
-
const inspectPayload = (payload: string | null) => {
|
|
1247
|
-
if (!payload) return;
|
|
1248
|
-
const status = terminalStatusFromSsePayload(payload);
|
|
1249
|
-
if (status) reportTerminal(status);
|
|
1250
|
-
};
|
|
1251
|
-
|
|
1252
|
-
const inspectChunk = (value: Uint8Array) => {
|
|
1253
|
-
buffer += decoder.decode(value, { stream: true });
|
|
1254
|
-
let next: { block: string; rest: string } | null;
|
|
1255
|
-
while ((next = nextSseBlock(buffer))) {
|
|
1256
|
-
buffer = next.rest;
|
|
1257
|
-
inspectPayload(sseDataPayload(next.block));
|
|
1258
|
-
}
|
|
1259
|
-
};
|
|
1260
|
-
|
|
1261
|
-
const cleanup = () => {
|
|
1262
|
-
if (closed) return;
|
|
1263
|
-
closed = true;
|
|
1264
|
-
if (timer) clearInterval(timer);
|
|
1265
|
-
timer = undefined;
|
|
1266
|
-
options?.onDone?.();
|
|
1267
|
-
};
|
|
1268
|
-
|
|
1269
|
-
return new ReadableStream<Uint8Array>({
|
|
1270
|
-
start(controller) {
|
|
1271
|
-
options?.onStart?.();
|
|
1272
|
-
timer = setInterval(() => {
|
|
1273
|
-
if (closed) return;
|
|
1274
|
-
try {
|
|
1275
|
-
controller.enqueue(heartbeat);
|
|
1276
|
-
} catch {
|
|
1277
|
-
cleanup();
|
|
1278
|
-
}
|
|
1279
|
-
}, heartbeatMs);
|
|
1280
|
-
},
|
|
1281
|
-
async pull(controller) {
|
|
1282
|
-
try {
|
|
1283
|
-
const { done, value } = await reader.read();
|
|
1284
|
-
if (done) {
|
|
1285
|
-
buffer += decoder.decode();
|
|
1286
|
-
if (buffer.trim()) inspectPayload(sseDataPayload(buffer));
|
|
1287
|
-
if (!terminalReported && !clientCancelled) reportTerminal("incomplete");
|
|
1288
|
-
cleanup();
|
|
1289
|
-
controller.close();
|
|
1290
|
-
return;
|
|
1291
|
-
}
|
|
1292
|
-
inspectChunk(value);
|
|
1293
|
-
controller.enqueue(value);
|
|
1294
|
-
} catch (err) {
|
|
1295
|
-
if (!clientCancelled) reportTerminal("incomplete");
|
|
1296
|
-
cleanup();
|
|
1297
|
-
try { controller.error(err); } catch { /* already torn down */ }
|
|
1298
|
-
}
|
|
1299
|
-
},
|
|
1300
|
-
cancel(reason) {
|
|
1301
|
-
clientCancelled = true;
|
|
1302
|
-
cleanup();
|
|
1303
|
-
upstream.abort(reason);
|
|
1304
|
-
reader.cancel(reason).catch(() => {});
|
|
1305
|
-
},
|
|
1306
|
-
});
|
|
1307
|
-
}
|
|
1308
|
-
|
|
1309
|
-
/**
|
|
1310
|
-
* Background-consume an SSE stream purely for terminal-outcome inspection (quota tracking).
|
|
1311
|
-
* Does not produce output; safe to ignore errors (the client-facing stream is separate).
|
|
1312
|
-
*/
|
|
1313
|
-
export function consumeForInspection(
|
|
1314
|
-
body: ReadableStream<Uint8Array>,
|
|
1315
|
-
onTerminal: (status: ResponsesTerminalStatus) => void,
|
|
1316
|
-
signal?: AbortSignal,
|
|
1317
|
-
onDone?: () => void,
|
|
1318
|
-
logCtx?: RequestLogContext,
|
|
1319
|
-
onCancel?: () => void,
|
|
1320
|
-
): void {
|
|
1321
|
-
const reader = body.getReader();
|
|
1322
|
-
const decoder = new TextDecoder();
|
|
1323
|
-
let buffer = "";
|
|
1324
|
-
let reported = false;
|
|
1325
|
-
let cancelled = false;
|
|
1326
|
-
if (signal) {
|
|
1327
|
-
if (signal.aborted) {
|
|
1328
|
-
// Aborted before we could read anything (Codex disconnects the instant it finishes reading).
|
|
1329
|
-
// Finalize as a client-cancel and release the turn — the early return skips pump()'s finally,
|
|
1330
|
-
// so onDone/onCancel must run here or the entry is silently dropped (#44).
|
|
1331
|
-
cancelled = true;
|
|
1332
|
-
reader.cancel(signal.reason).catch(() => {});
|
|
1333
|
-
onCancel?.();
|
|
1334
|
-
onDone?.();
|
|
1335
|
-
return;
|
|
1336
|
-
}
|
|
1337
|
-
signal.addEventListener("abort", () => {
|
|
1338
|
-
// Mid-drain disconnect: record a client-cancel entry (idempotent downstream) instead of the
|
|
1339
|
-
// suppressed onTerminal path. onDone still fires via pump()'s finally after the read rejects.
|
|
1340
|
-
cancelled = true;
|
|
1341
|
-
reader.cancel(signal.reason).catch(() => {});
|
|
1342
|
-
onCancel?.();
|
|
1343
|
-
}, { once: true });
|
|
1344
|
-
}
|
|
1345
|
-
const pump = async () => {
|
|
1346
|
-
try {
|
|
1347
|
-
for (;;) {
|
|
1348
|
-
const { done, value } = await reader.read();
|
|
1349
|
-
if (done) {
|
|
1350
|
-
buffer += decoder.decode();
|
|
1351
|
-
if (buffer.trim() && !reported) {
|
|
1352
|
-
const payload = sseDataPayload(buffer);
|
|
1353
|
-
if (logCtx) inspectResponseLogSsePayload(logCtx, payload);
|
|
1354
|
-
if (payload) {
|
|
1355
|
-
const status = terminalStatusFromSsePayload(payload);
|
|
1356
|
-
if (status) { reported = true; onTerminal(status); }
|
|
1357
|
-
}
|
|
1358
|
-
}
|
|
1359
|
-
if (!reported && !cancelled) onTerminal("incomplete");
|
|
1360
|
-
return;
|
|
1361
|
-
}
|
|
1362
|
-
buffer += decoder.decode(value, { stream: true });
|
|
1363
|
-
let next: { block: string; rest: string } | null;
|
|
1364
|
-
while ((next = nextSseBlock(buffer))) {
|
|
1365
|
-
buffer = next.rest;
|
|
1366
|
-
if (!reported) {
|
|
1367
|
-
const payload = sseDataPayload(next.block);
|
|
1368
|
-
if (logCtx) inspectResponseLogSsePayload(logCtx, payload);
|
|
1369
|
-
if (payload) {
|
|
1370
|
-
const status = terminalStatusFromSsePayload(payload);
|
|
1371
|
-
if (status) { reported = true; onTerminal(status); }
|
|
1372
|
-
}
|
|
1373
|
-
}
|
|
1374
|
-
}
|
|
1375
|
-
}
|
|
1376
|
-
} catch {
|
|
1377
|
-
if (!reported && !cancelled) onTerminal("incomplete");
|
|
1378
|
-
} finally {
|
|
1379
|
-
onDone?.();
|
|
1380
|
-
}
|
|
1381
|
-
};
|
|
1382
|
-
pump();
|
|
1383
|
-
}
|
|
1384
|
-
|
|
1385
|
-
function consumeForResponseLogMetadata(
|
|
1386
|
-
body: ReadableStream<Uint8Array>,
|
|
1387
|
-
logCtx: RequestLogContext,
|
|
1388
|
-
signal?: AbortSignal,
|
|
1389
|
-
onDone?: () => void,
|
|
1390
|
-
): void {
|
|
1391
|
-
const reader = body.getReader();
|
|
1392
|
-
const decoder = new TextDecoder();
|
|
1393
|
-
let buffer = "";
|
|
1394
|
-
if (signal) {
|
|
1395
|
-
if (signal.aborted) {
|
|
1396
|
-
reader.cancel(signal.reason).catch(() => {});
|
|
1397
|
-
onDone?.();
|
|
1398
|
-
return;
|
|
1399
|
-
}
|
|
1400
|
-
signal.addEventListener("abort", () => {
|
|
1401
|
-
reader.cancel(signal.reason).catch(() => {});
|
|
1402
|
-
}, { once: true });
|
|
1403
|
-
}
|
|
1404
|
-
const pump = async () => {
|
|
1405
|
-
try {
|
|
1406
|
-
for (;;) {
|
|
1407
|
-
const { done, value } = await reader.read();
|
|
1408
|
-
if (done) {
|
|
1409
|
-
buffer += decoder.decode();
|
|
1410
|
-
if (buffer.trim()) inspectResponseLogSsePayload(logCtx, sseDataPayload(buffer));
|
|
1411
|
-
return;
|
|
1412
|
-
}
|
|
1413
|
-
buffer += decoder.decode(value, { stream: true });
|
|
1414
|
-
let next: { block: string; rest: string } | null;
|
|
1415
|
-
while ((next = nextSseBlock(buffer))) {
|
|
1416
|
-
buffer = next.rest;
|
|
1417
|
-
inspectResponseLogSsePayload(logCtx, sseDataPayload(next.block));
|
|
1418
|
-
}
|
|
1419
|
-
}
|
|
1420
|
-
} catch {
|
|
1421
|
-
/* metadata inspection must not affect the client-facing stream */
|
|
1422
|
-
} finally {
|
|
1423
|
-
onDone?.();
|
|
1424
|
-
}
|
|
1425
|
-
};
|
|
1426
|
-
pump();
|
|
1427
|
-
}
|
|
1428
|
-
|
|
1429
|
-
/**
|
|
1430
|
-
* Bun's fetch auto-decompresses the response body but leaves the upstream `content-encoding`
|
|
1431
|
-
* (and a now-stale `content-length`) on `response.headers`. Relaying those with the already-decoded
|
|
1432
|
-
* body makes the caller (Codex) double-decode / truncate → "stream error" on every gpt passthrough.
|
|
1433
|
-
* Drop encoding + hop-by-hop headers; relay everything else (content-type, etc.) verbatim.
|
|
1434
|
-
*/
|
|
1435
|
-
export function sanitizePassthroughHeaders(upstream: Headers): Headers {
|
|
1436
|
-
const DROP = new Set([
|
|
1437
|
-
"content-encoding",
|
|
1438
|
-
"content-length",
|
|
1439
|
-
"transfer-encoding",
|
|
1440
|
-
"connection",
|
|
1441
|
-
"keep-alive",
|
|
1442
|
-
"proxy-authenticate",
|
|
1443
|
-
"proxy-authorization",
|
|
1444
|
-
"set-cookie",
|
|
1445
|
-
"set-cookie2",
|
|
1446
|
-
"te",
|
|
1447
|
-
"trailer",
|
|
1448
|
-
"upgrade",
|
|
1449
|
-
]);
|
|
1450
|
-
const out = new Headers();
|
|
1451
|
-
upstream.forEach((value, key) => {
|
|
1452
|
-
if (!DROP.has(key.toLowerCase())) out.set(key, value);
|
|
1453
|
-
});
|
|
1454
|
-
return out;
|
|
1455
|
-
}
|
|
1456
|
-
|
|
1457
|
-
let _corsOrigin = "http://localhost:10100";
|
|
1458
|
-
function setCorsOrigin(port: number): void { _corsOrigin = `http://localhost:${port}`; }
|
|
1459
|
-
function configuredPort(): string {
|
|
1460
|
-
try { return new URL(_corsOrigin).port; } catch { return "10100"; }
|
|
1461
|
-
}
|
|
1462
|
-
|
|
1463
|
-
function parseHttpHost(value: string | null): { hostname: string; port: string } | null {
|
|
1464
|
-
if (!value) return null;
|
|
1465
|
-
try {
|
|
1466
|
-
const parsed = new URL(`http://${value}`);
|
|
1467
|
-
return { hostname: parsed.hostname.toLowerCase(), port: parsed.port };
|
|
1468
|
-
} catch {
|
|
1469
|
-
return null;
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
|
-
|
|
1473
|
-
function isLoopbackRequestHost(value: string | null): boolean {
|
|
1474
|
-
const parsed = parseHttpHost(value);
|
|
1475
|
-
if (!parsed) return true;
|
|
1476
|
-
if (!isLoopbackHostname(parsed.hostname)) return false;
|
|
1477
|
-
return parsed.port === "" || parsed.port === configuredPort();
|
|
1478
|
-
}
|
|
1479
|
-
|
|
1480
|
-
function isLoopbackOriginValue(value: string): boolean {
|
|
1481
|
-
try {
|
|
1482
|
-
const parsed = new URL(value);
|
|
1483
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
|
|
1484
|
-
return isLoopbackHostname(parsed.hostname);
|
|
1485
|
-
} catch {
|
|
1486
|
-
return false;
|
|
1487
|
-
}
|
|
1488
|
-
}
|
|
1489
|
-
|
|
1490
|
-
function isSameOriginAsRequest(req: Request, origin: string): boolean {
|
|
1491
|
-
try {
|
|
1492
|
-
return origin === new URL(req.url).origin;
|
|
1493
|
-
} catch {
|
|
1494
|
-
return false;
|
|
1495
|
-
}
|
|
1496
|
-
}
|
|
1497
|
-
|
|
1498
|
-
function isAllowedRequestOrigin(req: Request, config: OcxConfig): boolean {
|
|
1499
|
-
function isExtraAllowedOrigin(origin: string, cfg: OcxConfig): boolean {
|
|
1500
|
-
if (!cfg.corsAllowOrigins?.length) return false;
|
|
1501
|
-
return cfg.corsAllowOrigins.some(allowed => {
|
|
1502
|
-
try {
|
|
1503
|
-
return new URL(allowed).origin === new URL(origin).origin;
|
|
1504
|
-
} catch {
|
|
1505
|
-
return allowed === origin;
|
|
1506
|
-
}
|
|
1507
|
-
});
|
|
1508
|
-
}
|
|
1509
|
-
const origin = req.headers.get("Origin");
|
|
1510
|
-
if (!isApiAuthRequired(config)) {
|
|
1511
|
-
if (!isLoopbackRequestHost(req.headers.get("Host"))) return false;
|
|
1512
|
-
return !origin || isLoopbackOriginValue(origin) || isExtraAllowedOrigin(origin, config);
|
|
1513
|
-
}
|
|
1514
|
-
return !origin || isLoopbackOriginValue(origin) || isSameOriginAsRequest(req, origin) || isExtraAllowedOrigin(origin, config);
|
|
1515
|
-
}
|
|
1516
|
-
|
|
1517
|
-
export function corsHeaders(req?: Request, config?: OcxConfig): Record<string, string> {
|
|
1518
|
-
const origin = req?.headers.get("Origin");
|
|
1519
|
-
const allowOrigin = origin && req && config && isAllowedRequestOrigin(req, config) ? origin : _corsOrigin;
|
|
1520
|
-
return {
|
|
1521
|
-
"Access-Control-Allow-Origin": allowOrigin,
|
|
1522
|
-
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
|
|
1523
|
-
"Access-Control-Allow-Headers": "Content-Type, Authorization, X-OpenCodex-API-Key",
|
|
1524
|
-
"Vary": "Origin",
|
|
1525
|
-
};
|
|
1526
|
-
}
|
|
1527
|
-
|
|
1528
|
-
function withCors(response: Response, req: Request, config: OcxConfig): Response {
|
|
1529
|
-
const headers = new Headers(response.headers);
|
|
1530
|
-
for (const [name, value] of Object.entries(corsHeaders(req, config))) {
|
|
1531
|
-
headers.set(name, value);
|
|
1532
|
-
}
|
|
1533
|
-
return new Response(response.body, {
|
|
1534
|
-
status: response.status,
|
|
1535
|
-
statusText: response.statusText,
|
|
1536
|
-
headers,
|
|
1537
|
-
});
|
|
1538
|
-
}
|
|
1539
|
-
|
|
1540
|
-
function jsonResponse(data: unknown, status = 200, req?: Request, config?: OcxConfig): Response {
|
|
1541
|
-
return new Response(JSON.stringify(data), {
|
|
1542
|
-
status,
|
|
1543
|
-
headers: { "Content-Type": "application/json", ...corsHeaders(req, config) },
|
|
1544
|
-
});
|
|
1545
|
-
}
|
|
1546
|
-
|
|
1547
|
-
function configuredApiAuthToken(_config: OcxConfig): string | undefined {
|
|
1548
|
-
const token = process.env.OPENCODEX_API_AUTH_TOKEN?.trim();
|
|
1549
|
-
return token || undefined;
|
|
1550
|
-
}
|
|
1551
|
-
|
|
1552
|
-
export function isLoopbackHostname(hostname: string | undefined): boolean {
|
|
1553
|
-
const normalized = (hostname ?? "127.0.0.1").trim().toLowerCase();
|
|
1554
|
-
return normalized === "" || normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1" || normalized === "[::1]";
|
|
1555
|
-
}
|
|
1556
|
-
|
|
1557
|
-
export function isApiAuthRequired(config: OcxConfig): boolean {
|
|
1558
|
-
return !isLoopbackHostname(config.hostname);
|
|
1559
|
-
}
|
|
1560
|
-
|
|
1561
|
-
export function assertServerAuthConfig(config: OcxConfig): void {
|
|
1562
|
-
if (isApiAuthRequired(config) && !configuredApiAuthToken(config)) {
|
|
1563
|
-
throw new Error("OPENCODEX_API_AUTH_TOKEN is required when binding opencodex to a non-loopback hostname");
|
|
1564
|
-
}
|
|
1565
|
-
}
|
|
1566
|
-
|
|
1567
|
-
export function hasValidApiAuth(req: Request, config: OcxConfig): boolean {
|
|
1568
|
-
if (!isApiAuthRequired(config)) return true;
|
|
1569
|
-
const actual = req.headers.get("x-opencodex-api-key")?.trim()
|
|
1570
|
-
|| req.headers.get("authorization")?.replace(/^Bearer\s+/i, "").trim();
|
|
1571
|
-
if (!actual) return false;
|
|
1572
|
-
// Check env-based token
|
|
1573
|
-
const expected = configuredApiAuthToken(config);
|
|
1574
|
-
if (expected) {
|
|
1575
|
-
const enc = new TextEncoder();
|
|
1576
|
-
const expectedBytes = enc.encode(expected);
|
|
1577
|
-
const actualBytes = enc.encode(actual);
|
|
1578
|
-
if (expectedBytes.length === actualBytes.length && timingSafeEqual(actualBytes, expectedBytes)) return true;
|
|
1579
|
-
}
|
|
1580
|
-
// Check config-based API keys
|
|
1581
|
-
if (config.apiKeys?.length) {
|
|
1582
|
-
const enc = new TextEncoder();
|
|
1583
|
-
const actualBytes = enc.encode(actual);
|
|
1584
|
-
for (const k of config.apiKeys) {
|
|
1585
|
-
const keyBytes = enc.encode(k.key);
|
|
1586
|
-
if (keyBytes.length === actualBytes.length && timingSafeEqual(actualBytes, keyBytes)) return true;
|
|
1587
|
-
}
|
|
1588
|
-
}
|
|
1589
|
-
return false;
|
|
1590
|
-
}
|
|
1591
|
-
|
|
1592
|
-
function requireApiAuth(req: Request, config: OcxConfig, kind: "management" | "data-plane"): Response | null {
|
|
1593
|
-
if (hasValidApiAuth(req, config)) return null;
|
|
1594
|
-
if (kind === "management") return jsonResponse({ error: "opencodex API key required" }, 401);
|
|
1595
|
-
return formatErrorResponse(401, "authentication_error", "opencodex API key required");
|
|
1596
|
-
}
|
|
1597
|
-
|
|
1598
|
-
function providerManagementConfigError(name: string, provider: OcxProviderConfig): string | null {
|
|
1599
|
-
const baseUrlError = providerBaseUrlConfigError(provider.baseUrl);
|
|
1600
|
-
if (baseUrlError) return `provider ${name} ${baseUrlError}`;
|
|
1601
|
-
const headersError = providerHeadersConfigError(provider.headers);
|
|
1602
|
-
if (headersError) return `provider ${name} ${headersError}`;
|
|
1603
|
-
if (provider.authMode === "forward") {
|
|
1604
|
-
const normalizedName = name.trim().toLowerCase();
|
|
1605
|
-
const base = provider.baseUrl.replace(/\/+$/, "");
|
|
1606
|
-
const isBuiltInChatGptForward = (normalizedName === "openai" || normalizedName === "chatgpt")
|
|
1607
|
-
&& provider.adapter === "openai-responses"
|
|
1608
|
-
&& base === "https://chatgpt.com/backend-api/codex";
|
|
1609
|
-
if (isBuiltInChatGptForward) return null;
|
|
1610
|
-
return `provider ${name} uses reserved authMode "forward"; configure ChatGPT passthrough via the built-in provider`;
|
|
1611
|
-
}
|
|
1612
|
-
return null;
|
|
1613
|
-
}
|
|
1614
|
-
|
|
1615
|
-
function publicProviderBaseUrl(baseUrl: string): string {
|
|
1616
|
-
try {
|
|
1617
|
-
const parsed = new URL(baseUrl.trim());
|
|
1618
|
-
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "(invalid URL)";
|
|
1619
|
-
parsed.username = "";
|
|
1620
|
-
parsed.password = "";
|
|
1621
|
-
parsed.search = "";
|
|
1622
|
-
parsed.hash = "";
|
|
1623
|
-
return parsed.toString().replace(/\/$/, baseUrl.endsWith("/") ? "/" : "");
|
|
1624
|
-
} catch {
|
|
1625
|
-
return "(invalid URL)";
|
|
1626
|
-
}
|
|
1627
|
-
}
|
|
1628
|
-
|
|
1629
|
-
function copyIfDefined<K extends keyof OcxProviderConfig>(
|
|
1630
|
-
out: Record<string, unknown>,
|
|
1631
|
-
provider: OcxProviderConfig,
|
|
1632
|
-
key: K,
|
|
1633
|
-
): void {
|
|
1634
|
-
const value = provider[key];
|
|
1635
|
-
if (value !== undefined) out[key as string] = value as unknown;
|
|
1636
|
-
}
|
|
1637
|
-
|
|
1638
|
-
export function safeConfigDTO(config: OcxConfig): unknown {
|
|
1639
|
-
const providers: Record<string, Record<string, unknown>> = {};
|
|
1640
|
-
for (const [name, provider] of Object.entries(config.providers)) {
|
|
1641
|
-
const dto: Record<string, unknown> = {
|
|
1642
|
-
adapter: provider.adapter,
|
|
1643
|
-
baseUrl: publicProviderBaseUrl(provider.baseUrl),
|
|
1644
|
-
hasApiKey: !!provider.apiKey,
|
|
1645
|
-
hasHeaders: !!provider.headers && Object.keys(provider.headers).length > 0,
|
|
1646
|
-
};
|
|
1647
|
-
for (const key of [
|
|
1648
|
-
"defaultModel",
|
|
1649
|
-
"disabled",
|
|
1650
|
-
"authMode",
|
|
1651
|
-
"liveModels",
|
|
1652
|
-
"models",
|
|
1653
|
-
"contextWindow",
|
|
1654
|
-
"modelContextWindows",
|
|
1655
|
-
"reasoningEfforts",
|
|
1656
|
-
"modelReasoningEfforts",
|
|
1657
|
-
"noVisionModels",
|
|
1658
|
-
"noReasoningModels",
|
|
1659
|
-
"noTemperatureModels",
|
|
1660
|
-
"noTopPModels",
|
|
1661
|
-
"noPenaltyModels",
|
|
1662
|
-
"autoToolChoiceOnlyModels",
|
|
1663
|
-
"preserveReasoningContentModels",
|
|
1664
|
-
"escapeBuiltinToolNames",
|
|
1665
|
-
] as const) {
|
|
1666
|
-
copyIfDefined(dto, provider, key);
|
|
1667
|
-
}
|
|
1668
|
-
providers[name] = dto;
|
|
1669
|
-
}
|
|
1670
|
-
return {
|
|
1671
|
-
port: config.port,
|
|
1672
|
-
hostname: config.hostname ?? "127.0.0.1",
|
|
1673
|
-
defaultProvider: config.defaultProvider,
|
|
1674
|
-
codexAutoStart: codexAutoStartEnabled(config),
|
|
1675
|
-
websockets: config.websockets,
|
|
1676
|
-
providers,
|
|
1677
|
-
};
|
|
1678
|
-
}
|
|
1679
|
-
|
|
1680
|
-
async function handleManagementAPI(req: Request, url: URL, config: OcxConfig): Promise<Response | null> {
|
|
1681
|
-
if (!isAllowedRequestOrigin(req, config)) {
|
|
1682
|
-
return jsonResponse({ error: "cross-origin request blocked" }, 403, req, config);
|
|
1683
|
-
}
|
|
1684
|
-
async function refreshCodexCatalogBestEffort(): Promise<void> {
|
|
1685
|
-
try {
|
|
1686
|
-
const { refreshCodexModelCatalog } = await import("./codex-refresh");
|
|
1687
|
-
await refreshCodexModelCatalog(config);
|
|
1688
|
-
} catch {
|
|
1689
|
-
/* catalog absent */
|
|
1690
|
-
}
|
|
1691
|
-
}
|
|
1692
|
-
|
|
1693
|
-
if (url.pathname === "/api/config" && req.method === "GET") {
|
|
1694
|
-
return jsonResponse(safeConfigDTO(config));
|
|
1695
|
-
}
|
|
1696
|
-
|
|
1697
|
-
if (url.pathname === "/api/config" && req.method === "PUT") {
|
|
1698
|
-
return jsonResponse({ error: "Full config PUT is disabled. Use /api/providers POST for provider changes." }, 405);
|
|
1699
|
-
}
|
|
1700
|
-
|
|
1701
|
-
if (url.pathname === "/api/settings" && req.method === "GET") {
|
|
1702
|
-
return jsonResponse({
|
|
1703
|
-
codexAutoStart: codexAutoStartEnabled(config),
|
|
1704
|
-
port: config.port,
|
|
1705
|
-
hostname: config.hostname ?? "127.0.0.1",
|
|
1706
|
-
});
|
|
1707
|
-
}
|
|
1708
|
-
|
|
1709
|
-
if (url.pathname === "/api/settings" && req.method === "PUT") {
|
|
1710
|
-
let body: { codexAutoStart?: unknown };
|
|
1711
|
-
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
1712
|
-
if (typeof body.codexAutoStart !== "boolean") {
|
|
1713
|
-
return jsonResponse({ error: "codexAutoStart boolean is required" }, 400);
|
|
1714
|
-
}
|
|
1715
|
-
config.codexAutoStart = body.codexAutoStart;
|
|
1716
|
-
saveConfig(config);
|
|
1717
|
-
return jsonResponse({ ok: true, codexAutoStart: codexAutoStartEnabled(config) });
|
|
1718
|
-
}
|
|
1719
|
-
|
|
1720
|
-
if (url.pathname === "/api/sync" && req.method === "POST") {
|
|
1721
|
-
const { syncModelsToCodex } = await import("./codex-sync");
|
|
1722
|
-
const result = await syncModelsToCodex(undefined, config, null);
|
|
1723
|
-
return jsonResponse({
|
|
1724
|
-
...result,
|
|
1725
|
-
staleAppServerHint: "If Codex App still shows an older model list, restart its long-lived app-server process after sync.",
|
|
1726
|
-
}, result.ok ? 200 : 500);
|
|
1727
|
-
}
|
|
1728
|
-
|
|
1729
|
-
if (url.pathname === "/api/update/check" && req.method === "GET") {
|
|
1730
|
-
const { checkForUpdate, normalizeUpdateChannel } = await import("./update-job");
|
|
1731
|
-
const rawTag = url.searchParams.get("tag");
|
|
1732
|
-
if (rawTag && rawTag !== "latest" && rawTag !== "preview") {
|
|
1733
|
-
return jsonResponse({ error: "tag must be latest or preview" }, 400);
|
|
1734
|
-
}
|
|
1735
|
-
return jsonResponse(checkForUpdate(normalizeUpdateChannel(rawTag)));
|
|
1736
|
-
}
|
|
1737
|
-
|
|
1738
|
-
if (url.pathname === "/api/update/run" && req.method === "POST") {
|
|
1739
|
-
const { normalizeUpdateChannel, startUpdateJob, UpdateJobError } = await import("./update-job");
|
|
1740
|
-
let body: { tag?: unknown; restart?: unknown };
|
|
1741
|
-
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
1742
|
-
if (body.tag !== undefined && body.tag !== "latest" && body.tag !== "preview") {
|
|
1743
|
-
return jsonResponse({ error: "tag must be latest or preview" }, 400);
|
|
1744
|
-
}
|
|
1745
|
-
if (body.restart !== undefined && typeof body.restart !== "boolean") {
|
|
1746
|
-
return jsonResponse({ error: "restart boolean is required" }, 400);
|
|
1747
|
-
}
|
|
1748
|
-
try {
|
|
1749
|
-
return jsonResponse({ ok: true, job: startUpdateJob(normalizeUpdateChannel(body.tag as string | undefined), body.restart !== false) });
|
|
1750
|
-
} catch (err) {
|
|
1751
|
-
if (err instanceof UpdateJobError) {
|
|
1752
|
-
return jsonResponse({ error: err.message, code: err.code }, err.status);
|
|
1753
|
-
}
|
|
1754
|
-
return jsonResponse({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
1755
|
-
}
|
|
1756
|
-
}
|
|
1757
|
-
|
|
1758
|
-
if (url.pathname === "/api/update/status" && req.method === "GET") {
|
|
1759
|
-
const { readUpdateJob } = await import("./update-job");
|
|
1760
|
-
const job = readUpdateJob(url.searchParams.get("jobId"));
|
|
1761
|
-
if (!job) return jsonResponse({ error: "update job not found" }, 404);
|
|
1762
|
-
return jsonResponse({ ok: true, job });
|
|
1763
|
-
}
|
|
1764
|
-
|
|
1765
|
-
if (url.pathname === "/api/sidecar-settings" && req.method === "GET") {
|
|
1766
|
-
const ws = config.webSearchSidecar ?? {};
|
|
1767
|
-
const vs = config.visionSidecar ?? {};
|
|
1768
|
-
return jsonResponse({
|
|
1769
|
-
webSearch: { model: ws.model ?? "gpt-5.4-mini", reasoning: ws.reasoning ?? "low" },
|
|
1770
|
-
vision: { model: vs.model ?? "gpt-5.4-mini" },
|
|
1771
|
-
});
|
|
1772
|
-
}
|
|
1773
|
-
|
|
1774
|
-
if (url.pathname === "/api/sidecar-settings" && req.method === "PUT") {
|
|
1775
|
-
let body: { webSearch?: { model?: string; reasoning?: string }; vision?: { model?: string } };
|
|
1776
|
-
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
1777
|
-
if (body.webSearch) {
|
|
1778
|
-
config.webSearchSidecar = { ...config.webSearchSidecar };
|
|
1779
|
-
if (typeof body.webSearch.model === "string") config.webSearchSidecar.model = body.webSearch.model;
|
|
1780
|
-
if (typeof body.webSearch.reasoning === "string") config.webSearchSidecar.reasoning = body.webSearch.reasoning;
|
|
1781
|
-
}
|
|
1782
|
-
if (body.vision) {
|
|
1783
|
-
config.visionSidecar = { ...config.visionSidecar };
|
|
1784
|
-
if (typeof body.vision.model === "string") config.visionSidecar.model = body.vision.model;
|
|
1785
|
-
}
|
|
1786
|
-
saveConfig(config);
|
|
1787
|
-
const ws = config.webSearchSidecar ?? {};
|
|
1788
|
-
const vs = config.visionSidecar ?? {};
|
|
1789
|
-
return jsonResponse({
|
|
1790
|
-
ok: true,
|
|
1791
|
-
webSearch: { model: ws.model ?? "gpt-5.4-mini", reasoning: ws.reasoning ?? "low" },
|
|
1792
|
-
vision: { model: vs.model ?? "gpt-5.4-mini" },
|
|
1793
|
-
});
|
|
1794
|
-
}
|
|
1795
|
-
|
|
1796
|
-
if (url.pathname === "/api/logs" && req.method === "GET") {
|
|
1797
|
-
return jsonResponse(filterRequestLogs(requestLog, url.searchParams));
|
|
1798
|
-
}
|
|
1799
|
-
|
|
1800
|
-
if (url.pathname === "/api/usage" && req.method === "GET") {
|
|
1801
|
-
const range = parseRange(url.searchParams.get("range"));
|
|
1802
|
-
const now = Date.now();
|
|
1803
|
-
try {
|
|
1804
|
-
return jsonResponse(summarizeUsage(readUsageEntries(), range, now));
|
|
1805
|
-
} catch {
|
|
1806
|
-
return jsonResponse({
|
|
1807
|
-
range,
|
|
1808
|
-
since: null,
|
|
1809
|
-
generatedAt: now,
|
|
1810
|
-
summary: {
|
|
1811
|
-
requests: 0,
|
|
1812
|
-
measuredRequests: 0,
|
|
1813
|
-
reportedRequests: 0,
|
|
1814
|
-
unreportedRequests: 0,
|
|
1815
|
-
unsupportedRequests: 0,
|
|
1816
|
-
estimatedRequests: 0,
|
|
1817
|
-
inputTokens: 0,
|
|
1818
|
-
outputTokens: 0,
|
|
1819
|
-
cachedInputTokens: 0,
|
|
1820
|
-
reasoningOutputTokens: 0,
|
|
1821
|
-
totalTokens: 0,
|
|
1822
|
-
coverageRatio: 0,
|
|
1823
|
-
},
|
|
1824
|
-
days: [],
|
|
1825
|
-
models: [],
|
|
1826
|
-
providers: [],
|
|
1827
|
-
error: "read_failed",
|
|
1828
|
-
});
|
|
1829
|
-
}
|
|
1830
|
-
}
|
|
1831
|
-
|
|
1832
|
-
if (url.pathname === "/api/provider-quotas" && req.method === "GET") {
|
|
1833
|
-
const forceRefresh = url.searchParams.get("refresh") === "1" || url.searchParams.get("refresh") === "true";
|
|
1834
|
-
return jsonResponse(await fetchProviderQuotaReports(config, forceRefresh));
|
|
1835
|
-
}
|
|
1836
|
-
|
|
1837
|
-
if (url.pathname === "/api/providers" && req.method === "GET") {
|
|
1838
|
-
return jsonResponse(Object.entries(config.providers).map(([name, p]) => ({
|
|
1839
|
-
name, adapter: p.adapter, baseUrl: publicProviderBaseUrl(p.baseUrl), defaultModel: p.defaultModel,
|
|
1840
|
-
hasApiKey: !!p.apiKey,
|
|
1841
|
-
disabled: p.disabled === true,
|
|
1842
|
-
})));
|
|
1843
|
-
}
|
|
1844
|
-
|
|
1845
|
-
// Add (or overwrite) a single provider. Merges into the live in-memory config and
|
|
1846
|
-
// persists — existing providers' real keys are never round-tripped (unlike PUT /api/config,
|
|
1847
|
-
// which would re-save the masked keys from GET). Live routing picks it up immediately.
|
|
1848
|
-
if (url.pathname === "/api/providers" && req.method === "POST") {
|
|
1849
|
-
let body: { name?: string; provider?: OcxProviderConfig; setDefault?: boolean };
|
|
1850
|
-
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
1851
|
-
const name = body.name?.trim();
|
|
1852
|
-
const prov = body.provider ? stripCodexRuntimeProviderFields(body.provider) : undefined;
|
|
1853
|
-
if (!name || !prov?.adapter || !prov?.baseUrl) {
|
|
1854
|
-
return jsonResponse({ error: "name, provider.adapter and provider.baseUrl are required" }, 400);
|
|
1855
|
-
}
|
|
1856
|
-
if (!isValidProviderName(name)) {
|
|
1857
|
-
return jsonResponse({ error: "provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key" }, 400);
|
|
1858
|
-
}
|
|
1859
|
-
const providerError = providerManagementConfigError(name, prov);
|
|
1860
|
-
if (providerError) return jsonResponse({ error: providerError }, 400);
|
|
1861
|
-
// Catalog providers (e.g. ollama-cloud) carry a models + vision/reasoning classification the GUI
|
|
1862
|
-
// doesn't send — merge it in so the sidecars are gated correctly.
|
|
1863
|
-
enrichProviderFromCatalog(name, prov);
|
|
1864
|
-
const { saveConfig: save } = await import("./config");
|
|
1865
|
-
config.providers[name] = prov;
|
|
1866
|
-
if (body.setDefault) config.defaultProvider = name;
|
|
1867
|
-
save(config);
|
|
1868
|
-
const { clearModelCache } = await import("./model-cache");
|
|
1869
|
-
clearModelCache(name);
|
|
1870
|
-
await refreshCodexCatalogBestEffort();
|
|
1871
|
-
return jsonResponse({ success: true, name });
|
|
1872
|
-
}
|
|
1873
|
-
|
|
1874
|
-
if (url.pathname === "/api/providers" && req.method === "PATCH") {
|
|
1875
|
-
const name = url.searchParams.get("name")?.trim();
|
|
1876
|
-
if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
|
|
1877
|
-
let body: { disabled?: unknown };
|
|
1878
|
-
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
1879
|
-
if (typeof body.disabled !== "boolean") return jsonResponse({ error: "disabled boolean is required" }, 400);
|
|
1880
|
-
if (body.disabled && name === config.defaultProvider) {
|
|
1881
|
-
return jsonResponse({ error: "cannot disable the default provider; set another default first" }, 400);
|
|
1882
|
-
}
|
|
1883
|
-
const { saveConfig: save } = await import("./config");
|
|
1884
|
-
config.providers[name] = { ...config.providers[name], disabled: body.disabled };
|
|
1885
|
-
save(config);
|
|
1886
|
-
await refreshCodexCatalogBestEffort();
|
|
1887
|
-
return jsonResponse({ success: true, name, disabled: body.disabled });
|
|
1888
|
-
}
|
|
1889
|
-
|
|
1890
|
-
if (url.pathname === "/api/providers" && req.method === "DELETE") {
|
|
1891
|
-
const name = url.searchParams.get("name")?.trim();
|
|
1892
|
-
if (!name || !isValidProviderName(name) || !hasOwnProvider(config.providers, name)) return jsonResponse({ error: "unknown provider" }, 404);
|
|
1893
|
-
if (name === config.defaultProvider) return jsonResponse({ error: "cannot delete the default provider; set another default first" }, 400);
|
|
1894
|
-
const { saveConfig: save } = await import("./config");
|
|
1895
|
-
delete config.providers[name];
|
|
1896
|
-
setProviderContextCap(config, name, false);
|
|
1897
|
-
save(config);
|
|
1898
|
-
const { clearModelCache: clearCache } = await import("./model-cache");
|
|
1899
|
-
clearCache(name);
|
|
1900
|
-
await refreshCodexCatalogBestEffort();
|
|
1901
|
-
return jsonResponse({ success: true });
|
|
1902
|
-
}
|
|
1903
|
-
|
|
1904
|
-
if (url.pathname === "/api/models" && req.method === "GET") {
|
|
1905
|
-
const models = await fetchAllModels(config);
|
|
1906
|
-
const disabled = new Set(config.disabledModels ?? []);
|
|
1907
|
-
return jsonResponse(models.map(m => {
|
|
1908
|
-
const namespaced = `${m.provider}/${m.id}`;
|
|
1909
|
-
const contextCap = providerContextCap(config, m.provider);
|
|
1910
|
-
return {
|
|
1911
|
-
...m,
|
|
1912
|
-
namespaced,
|
|
1913
|
-
disabled: disabled.has(namespaced),
|
|
1914
|
-
...(contextCap !== undefined ? { contextCap, contextCapped: m.contextCapped === true } : {}),
|
|
1915
|
-
};
|
|
1916
|
-
}));
|
|
1917
|
-
}
|
|
1918
|
-
|
|
1919
|
-
if (url.pathname === "/api/provider-context-caps" && req.method === "GET") {
|
|
1920
|
-
return jsonResponse({ cap: DEFAULT_PROVIDER_CONTEXT_CAP, value: globalContextCapValue(config), caps: providerContextCaps(config) });
|
|
1921
|
-
}
|
|
1922
|
-
|
|
1923
|
-
if (url.pathname === "/api/provider-context-caps" && req.method === "PUT") {
|
|
1924
|
-
let body: { provider?: unknown; enabled?: unknown; value?: unknown; setAll?: unknown };
|
|
1925
|
-
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
1926
|
-
const { saveConfig: save } = await import("./config");
|
|
1927
|
-
const { clearModelCache } = await import("./model-cache");
|
|
1928
|
-
const respond = () => jsonResponse({ ok: true, cap: DEFAULT_PROVIDER_CONTEXT_CAP, value: globalContextCapValue(config), caps: providerContextCaps(config) });
|
|
1929
|
-
|
|
1930
|
-
// Branch 1: set the global cap value and re-point every enabled provider to it.
|
|
1931
|
-
if (body.value !== undefined) {
|
|
1932
|
-
if (typeof body.value !== "number" || !Number.isFinite(body.value) || body.value <= 0) {
|
|
1933
|
-
return jsonResponse({ error: "value must be a positive number" }, 400);
|
|
1934
|
-
}
|
|
1935
|
-
const affected = Object.keys(providerContextCaps(config));
|
|
1936
|
-
setGlobalContextCapValue(config, body.value);
|
|
1937
|
-
save(config);
|
|
1938
|
-
for (const provider of affected) clearModelCache(provider);
|
|
1939
|
-
await refreshCodexCatalogBestEffort();
|
|
1940
|
-
return respond();
|
|
1941
|
-
}
|
|
1942
|
-
|
|
1943
|
-
// Branch 2: enable/clear the cap for every provider at once.
|
|
1944
|
-
if (body.setAll !== undefined) {
|
|
1945
|
-
if (typeof body.setAll !== "boolean") {
|
|
1946
|
-
return jsonResponse({ error: "setAll must be a boolean" }, 400);
|
|
1947
|
-
}
|
|
1948
|
-
const before = Object.keys(providerContextCaps(config));
|
|
1949
|
-
const names = Object.keys(config.providers);
|
|
1950
|
-
setAllProviderContextCaps(config, names, body.setAll);
|
|
1951
|
-
save(config);
|
|
1952
|
-
for (const provider of new Set([...before, ...names])) clearModelCache(provider);
|
|
1953
|
-
await refreshCodexCatalogBestEffort();
|
|
1954
|
-
return respond();
|
|
1955
|
-
}
|
|
1956
|
-
|
|
1957
|
-
// Branch 3: existing per-provider toggle (enable writes the current global value).
|
|
1958
|
-
if (typeof body.provider !== "string" || typeof body.enabled !== "boolean") {
|
|
1959
|
-
return jsonResponse({ error: "provider string and enabled boolean are required" }, 400);
|
|
1960
|
-
}
|
|
1961
|
-
const provider = body.provider.trim();
|
|
1962
|
-
if (!isValidProviderName(provider)) {
|
|
1963
|
-
return jsonResponse({ error: "provider name must use letters, numbers, dot, underscore, or hyphen and cannot be a reserved object key" }, 400);
|
|
1964
|
-
}
|
|
1965
|
-
if (!hasOwnProvider(config.providers, provider)) {
|
|
1966
|
-
return jsonResponse({ error: "unknown provider" }, 404);
|
|
1967
|
-
}
|
|
1968
|
-
setProviderContextCap(config, provider, body.enabled);
|
|
1969
|
-
save(config);
|
|
1970
|
-
clearModelCache(provider);
|
|
1971
|
-
await refreshCodexCatalogBestEffort();
|
|
1972
|
-
return respond();
|
|
1973
|
-
}
|
|
1974
|
-
|
|
1975
|
-
// Enable/disable models: which routed models Codex sees. PUT hides them from the catalog +
|
|
1976
|
-
// /v1/models and invalidates Codex's 5-min models cache so it applies on the next turn.
|
|
1977
|
-
if (url.pathname === "/api/disabled-models" && req.method === "PUT") {
|
|
1978
|
-
let body: { models?: unknown };
|
|
1979
|
-
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
1980
|
-
const disabled = Array.isArray(body.models) ? body.models.filter((m): m is string => typeof m === "string") : [];
|
|
1981
|
-
config.disabledModels = disabled;
|
|
1982
|
-
const { saveConfig: save } = await import("./config");
|
|
1983
|
-
save(config);
|
|
1984
|
-
await refreshCodexCatalogBestEffort();
|
|
1985
|
-
return jsonResponse({ ok: true, disabled });
|
|
1986
|
-
}
|
|
1987
|
-
|
|
1988
|
-
// Which providers support real OAuth login (drives the GUI's "Log in with …" buttons).
|
|
1989
|
-
if (url.pathname === "/api/oauth/providers" && req.method === "GET") {
|
|
1990
|
-
return jsonResponse({ providers: listOAuthProviders() });
|
|
1991
|
-
}
|
|
1992
|
-
|
|
1993
|
-
// API-key "login" providers (open dashboard → paste key). Drives the GUI's key-provider picker.
|
|
1994
|
-
if (url.pathname === "/api/key-providers" && req.method === "GET") {
|
|
1995
|
-
return jsonResponse({ providers: listKeyLoginProviders() });
|
|
1996
|
-
}
|
|
1997
|
-
|
|
1998
|
-
// Complete GUI picker presets, derived from the canonical provider registry. The GUI is a
|
|
1999
|
-
// standalone Vite package, so it consumes this runtime view instead of importing repo-root src.
|
|
2000
|
-
if (url.pathname === "/api/provider-presets" && req.method === "GET") {
|
|
2001
|
-
return jsonResponse({ providers: deriveProviderPresets() });
|
|
2002
|
-
}
|
|
2003
|
-
|
|
2004
|
-
// Subagent model picker: which ≤5 routed models Codex's spawn_agent advertises (it shows the
|
|
2005
|
-
// first 5 routed catalog entries). PUT reorders the injected catalog so the chosen ones lead.
|
|
2006
|
-
if (url.pathname === "/api/subagent-models" && req.method === "GET") {
|
|
2007
|
-
const models = await fetchAllModels(config);
|
|
2008
|
-
const disabled = new Set(config.disabledModels ?? []);
|
|
2009
|
-
// Native gpt (passthrough) are also valid subagent picks — they're picker-visible models in the
|
|
2010
|
-
// catalog, just buried by priority. List them first so the user can feature them over routed.
|
|
2011
|
-
const { listCatalogNativeSlugs } = await import("./codex-catalog");
|
|
2012
|
-
const available = [
|
|
2013
|
-
...listCatalogNativeSlugs(),
|
|
2014
|
-
...models.map(m => `${m.provider}/${m.id}`),
|
|
2015
|
-
].filter(ns => !disabled.has(ns));
|
|
2016
|
-
return jsonResponse({ chosen: config.subagentModels ?? [], available });
|
|
2017
|
-
}
|
|
2018
|
-
if (url.pathname === "/api/subagent-models" && req.method === "PUT") {
|
|
2019
|
-
let body: { models?: unknown };
|
|
2020
|
-
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
2021
|
-
const chosen = Array.isArray(body.models) ? body.models.filter((m): m is string => typeof m === "string").slice(0, 5) : [];
|
|
2022
|
-
config.subagentModels = chosen;
|
|
2023
|
-
const { saveConfig: save } = await import("./config");
|
|
2024
|
-
save(config);
|
|
2025
|
-
await refreshCodexCatalogBestEffort();
|
|
2026
|
-
return jsonResponse({ ok: true, applied: chosen });
|
|
2027
|
-
}
|
|
2028
|
-
|
|
2029
|
-
// Per-provider catalog allowlist (issue #52): when a provider has a non-empty selectedModels list,
|
|
2030
|
-
// only those ids ship to Codex's catalog / /v1/models. GET returns the CURRENT selection plus the
|
|
2031
|
-
// FULL available set per provider (unfiltered — the picker needs everything to choose from).
|
|
2032
|
-
if (url.pathname === "/api/selected-models" && req.method === "GET") {
|
|
2033
|
-
const models = await fetchAllModels(config);
|
|
2034
|
-
const available: Record<string, string[]> = {};
|
|
2035
|
-
for (const m of models) (available[m.provider] ??= []).push(m.id);
|
|
2036
|
-
const selected: Record<string, string[]> = {};
|
|
2037
|
-
for (const [name, prov] of Object.entries(config.providers)) {
|
|
2038
|
-
if (Array.isArray(prov.selectedModels) && prov.selectedModels.length > 0) selected[name] = [...prov.selectedModels];
|
|
2039
|
-
}
|
|
2040
|
-
return jsonResponse({ selected, available });
|
|
2041
|
-
}
|
|
2042
|
-
if (url.pathname === "/api/selected-models" && req.method === "PUT") {
|
|
2043
|
-
let body: { provider?: unknown; models?: unknown };
|
|
2044
|
-
try { body = await req.json(); } catch { return jsonResponse({ error: "invalid JSON body" }, 400); }
|
|
2045
|
-
const provider = typeof body.provider === "string" ? body.provider : "";
|
|
2046
|
-
if (!provider || !hasOwnProvider(config.providers, provider)) {
|
|
2047
|
-
return jsonResponse({ error: "unknown provider" }, provider ? 404 : 400);
|
|
2048
|
-
}
|
|
2049
|
-
const models = Array.isArray(body.models)
|
|
2050
|
-
? [...new Set(body.models.filter((m): m is string => typeof m === "string"))]
|
|
2051
|
-
: [];
|
|
2052
|
-
// Empty list clears the allowlist (provider reverts to exposing all models).
|
|
2053
|
-
if (models.length > 0) config.providers[provider].selectedModels = models;
|
|
2054
|
-
else delete config.providers[provider].selectedModels;
|
|
2055
|
-
const { saveConfig: save } = await import("./config");
|
|
2056
|
-
save(config);
|
|
2057
|
-
await refreshCodexCatalogBestEffort();
|
|
2058
|
-
return jsonResponse({ ok: true, provider, selected: models });
|
|
2059
|
-
}
|
|
2060
|
-
|
|
2061
|
-
// OAuth login (xai now; anthropic/kimi in cycle 2). Starts the flow and returns the auth URL;
|
|
2062
|
-
// the provider's loopback callback server (inside this process) captures the redirect in the
|
|
2063
|
-
// background, then the credential is persisted. The GUI opens the URL and polls /api/oauth/status.
|
|
2064
|
-
if (url.pathname === "/api/oauth/login" && req.method === "POST") {
|
|
2065
|
-
const body = await req.json().catch(() => ({})) as { provider?: string };
|
|
2066
|
-
const provider = (body.provider ?? "").trim().toLowerCase();
|
|
2067
|
-
if (!isOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
2068
|
-
try {
|
|
2069
|
-
const { url: authUrl, instructions } = await startLoginFlow(provider);
|
|
2070
|
-
upsertOAuthProvider(config, provider); // mutate LIVE config — routing sees it without restart
|
|
2071
|
-
if (authUrl) {
|
|
2072
|
-
// Open the browser server-side (the proxy runs on the user's machine) — the GUI's
|
|
2073
|
-
// window.open is popup-blocked because it runs after an await, not a direct click.
|
|
2074
|
-
const { openUrl } = await import("./open-url");
|
|
2075
|
-
openUrl(authUrl);
|
|
2076
|
-
}
|
|
2077
|
-
return jsonResponse({ url: authUrl, instructions });
|
|
2078
|
-
} catch (err) {
|
|
2079
|
-
return jsonResponse({ error: err instanceof Error ? err.message : String(err) }, 409);
|
|
2080
|
-
}
|
|
2081
|
-
}
|
|
2082
|
-
|
|
2083
|
-
if (url.pathname === "/api/oauth/status" && req.method === "GET") {
|
|
2084
|
-
const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
|
|
2085
|
-
return jsonResponse(getLoginStatus(provider));
|
|
2086
|
-
}
|
|
2087
|
-
|
|
2088
|
-
if (url.pathname === "/api/oauth/logout" && req.method === "POST") {
|
|
2089
|
-
const provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase();
|
|
2090
|
-
if (!isOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400);
|
|
2091
|
-
removeCredential(provider);
|
|
2092
|
-
clearLoginState(provider);
|
|
2093
|
-
return jsonResponse({ success: true });
|
|
2094
|
-
}
|
|
2095
|
-
|
|
2096
|
-
// ---------------------------------------------------------------------------
|
|
2097
|
-
// API Keys management
|
|
2098
|
-
// ---------------------------------------------------------------------------
|
|
2099
|
-
if (url.pathname === "/api/keys" && req.method === "GET") {
|
|
2100
|
-
const keys = config.apiKeys ?? [];
|
|
2101
|
-
return jsonResponse({ keys: keys.map(k => ({ id: k.id, name: k.name, prefix: k.key.slice(0, 8) + "...", createdAt: k.createdAt })), endpoint: `http://${config.hostname ?? "127.0.0.1"}:${config.port ?? 10100}/v1/responses` }, 200, req, config);
|
|
2102
|
-
}
|
|
2103
|
-
|
|
2104
|
-
if (url.pathname === "/api/keys" && req.method === "POST") {
|
|
2105
|
-
const body = await req.json() as { name?: string };
|
|
2106
|
-
const name = (body.name ?? "").trim() || "default";
|
|
2107
|
-
// Generate key from provider keys hash + random salt
|
|
2108
|
-
const providerKeys = Object.values(config.providers).map(p => p.apiKey ?? "").filter(Boolean).join("|");
|
|
2109
|
-
const salt = crypto.randomUUID();
|
|
2110
|
-
const hashInput = `${providerKeys}|${salt}|${Date.now()}`;
|
|
2111
|
-
const hashBuf = new Bun.CryptoHasher("sha256").update(hashInput).digest();
|
|
2112
|
-
const key = "ocx_" + Buffer.from(hashBuf).toString("hex").slice(0, 40);
|
|
2113
|
-
const entry = { id: crypto.randomUUID(), name, key, createdAt: new Date().toISOString() };
|
|
2114
|
-
config.apiKeys = [...(config.apiKeys ?? []), entry];
|
|
2115
|
-
saveConfig(config);
|
|
2116
|
-
return jsonResponse({ id: entry.id, name: entry.name, key: entry.key, createdAt: entry.createdAt }, 201, req, config);
|
|
2117
|
-
}
|
|
2118
|
-
|
|
2119
|
-
if (url.pathname === "/api/keys" && req.method === "DELETE") {
|
|
2120
|
-
const body = await req.json() as { id?: string };
|
|
2121
|
-
if (!body.id) return jsonResponse({ error: "id required" }, 400, req, config);
|
|
2122
|
-
config.apiKeys = (config.apiKeys ?? []).filter(k => k.id !== body.id);
|
|
2123
|
-
saveConfig(config);
|
|
2124
|
-
return jsonResponse({ success: true }, 200, req, config);
|
|
2125
|
-
}
|
|
2126
|
-
|
|
2127
|
-
if (url.pathname === "/api/stop" && req.method === "POST") {
|
|
2128
|
-
const { restoreNativeCodex } = await import("./codex-inject");
|
|
2129
|
-
const { stopServiceIfInstalled } = await import("./service");
|
|
2130
|
-
stopServiceIfInstalled();
|
|
2131
|
-
restoreNativeCodex();
|
|
2132
|
-
setTimeout(async () => {
|
|
2133
|
-
await drainAndShutdown(undefined, config.shutdownTimeoutMs ?? 5000);
|
|
2134
|
-
process.exit(0);
|
|
2135
|
-
}, 200);
|
|
2136
|
-
return jsonResponse({ success: true, message: "Proxy stopping, native Codex restored." });
|
|
2137
|
-
}
|
|
2138
|
-
|
|
2139
|
-
if (url.pathname.startsWith("/api/codex-auth/")) {
|
|
2140
|
-
const { handleCodexAuthAPI } = await import("./codex-auth-api");
|
|
2141
|
-
return handleCodexAuthAPI(req, url, config);
|
|
2142
|
-
}
|
|
2143
|
-
|
|
2144
|
-
return null;
|
|
2145
|
-
}
|
|
2146
|
-
|
|
2147
|
-
/**
|
|
2148
|
-
* Live routed-provider models for the proxy's /api/* and /v1/models endpoints. Delegates to the
|
|
2149
|
-
* canonical, TTL-cached `gatherRoutedModels` (single source of truth) — so the GUI/codex endpoints
|
|
2150
|
-
* share the same fetch, the same per-provider cache (dedups Codex's frequent /v1/models polling),
|
|
2151
|
-
* and the same stale fallback when a provider blips, instead of a parallel uncached copy.
|
|
2152
|
-
*/
|
|
2153
|
-
async function fetchAllModels(config: OcxConfig): Promise<CatalogModel[]> {
|
|
2154
|
-
const { gatherRoutedModels } = await import("./codex-catalog");
|
|
2155
|
-
return gatherRoutedModels(config);
|
|
2156
|
-
}
|
|
2157
|
-
|
|
2158
|
-
export function startServer(port?: number) {
|
|
2159
|
-
const config = loadConfig();
|
|
2160
|
-
applyProxyEnv(config);
|
|
2161
|
-
assertServerAuthConfig(config);
|
|
2162
|
-
// Refresh OAuth provider presets (models/noReasoningModels) from the registry so a proxy update
|
|
2163
|
-
// adding/dropping models reaches existing configs on start — not just fresh installs.
|
|
2164
|
-
reconcileOAuthProviders(config);
|
|
2165
|
-
// Ensure the ChatGPT passthrough provider exists so gpt-* models route correctly.
|
|
2166
|
-
if (!config.providers["chatgpt"]) {
|
|
2167
|
-
upsertOAuthProvider(config, "chatgpt");
|
|
2168
|
-
saveConfig(config);
|
|
2169
|
-
}
|
|
2170
|
-
// Seed default featured subagent models on first run only (UNSET → defaults). A user-set list,
|
|
2171
|
-
// even [], is left alone so GUI removals persist.
|
|
2172
|
-
if (config.subagentModels === undefined) {
|
|
2173
|
-
config.subagentModels = [...DEFAULT_SUBAGENT_MODELS];
|
|
2174
|
-
saveConfig(config);
|
|
2175
|
-
}
|
|
2176
|
-
invalidateCodexModelsCache();
|
|
2177
|
-
|
|
2178
|
-
const listenPort = port ?? config.port ?? 10100;
|
|
2179
|
-
setCorsOrigin(listenPort);
|
|
2180
|
-
|
|
2181
|
-
// Canonicalize an explicit "localhost" bind to IPv4 so it matches the injected base_url (which
|
|
2182
|
-
// resolves localhost→127.0.0.1): on Windows `localhost` resolves ::1-first, but the injected URL
|
|
2183
|
-
// is 127.0.0.1, so binding literal "localhost" would reintroduce the F4 refusal. Wildcards
|
|
2184
|
-
// (0.0.0.0/::) and specific hosts are left untouched so intentional exposure is preserved.
|
|
2185
|
-
const bindHost = /^localhost$/i.test(config.hostname ?? "") ? "127.0.0.1" : (config.hostname ?? "127.0.0.1");
|
|
2186
|
-
|
|
2187
|
-
const server: Server<WsData> = Bun.serve<WsData>({
|
|
2188
|
-
port: listenPort,
|
|
2189
|
-
hostname: bindHost,
|
|
2190
|
-
idleTimeout: 255,
|
|
2191
|
-
async fetch(req, requestServer): Promise<Response> {
|
|
2192
|
-
const url = new URL(req.url);
|
|
2193
|
-
markActivity(`${req.method} ${url.pathname}`);
|
|
2194
|
-
|
|
2195
|
-
if (req.method === "OPTIONS") {
|
|
2196
|
-
if (!isAllowedRequestOrigin(req, config)) {
|
|
2197
|
-
return new Response(null, { status: 403, headers: corsHeaders() });
|
|
2198
|
-
}
|
|
2199
|
-
return new Response(null, { status: 204, headers: corsHeaders(req, config) });
|
|
2200
|
-
}
|
|
2201
|
-
|
|
2202
|
-
// Responses WebSocket (phase 120.2). Codex upgrades the same /v1/responses path; auth is
|
|
2203
|
-
// handshake-time only, so capture inbound headers and thread them into the pipeline.
|
|
2204
|
-
if (url.pathname === "/v1/responses" && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
2205
|
-
if (draining) {
|
|
2206
|
-
return new Response("Service shutting down", { status: 503, headers: { ...corsHeaders(req, config), "Retry-After": "5" } });
|
|
2207
|
-
}
|
|
2208
|
-
const apiAuthError = requireApiAuth(req, config, "data-plane");
|
|
2209
|
-
if (apiAuthError) return withCors(apiAuthError, req, config);
|
|
2210
|
-
if (!isAllowedRequestOrigin(req, config)) {
|
|
2211
|
-
return withCors(formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin"), req, config);
|
|
2212
|
-
}
|
|
2213
|
-
let authCtx: CodexAuthContext;
|
|
2214
|
-
try {
|
|
2215
|
-
authCtx = await resolveCodexAuthContext(req.headers, config);
|
|
2216
|
-
} catch (err) {
|
|
2217
|
-
if (err instanceof CodexAccountCooldownError) {
|
|
2218
|
-
return withCors(formatErrorResponse(429, "rate_limit_error", "Selected Codex account is cooling down"), req, config);
|
|
2219
|
-
}
|
|
2220
|
-
if (err instanceof CodexThreadAffinityExpiredError) {
|
|
2221
|
-
return withCors(formatErrorResponse(409, "invalid_request_error", "Codex thread account affinity expired; start a new session"), req, config);
|
|
2222
|
-
}
|
|
2223
|
-
if (err instanceof CodexAuthContextError) {
|
|
2224
|
-
const safeAccountLabel = formatCodexProviderForLog("chatgpt", err.accountId, config);
|
|
2225
|
-
console.error(`[codex-auth] Pool account ${safeAccountLabel} token failed during websocket upgrade; reauthentication required`);
|
|
2226
|
-
return withCors(formatErrorResponse(401, "authentication_error", "Selected Codex account needs reauthentication"), req, config);
|
|
2227
|
-
}
|
|
2228
|
-
throw err;
|
|
2229
|
-
}
|
|
2230
|
-
if (server.upgrade(req, {
|
|
2231
|
-
data: {
|
|
2232
|
-
headers: selectForwardHeaders(req.headers),
|
|
2233
|
-
authContext: authCtx,
|
|
2234
|
-
},
|
|
2235
|
-
})) return undefined as unknown as Response;
|
|
2236
|
-
return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, config);
|
|
2237
|
-
}
|
|
2238
|
-
|
|
2239
|
-
if (url.pathname === "/healthz" && req.method === "GET") {
|
|
2240
|
-
// service/pid/port let CLI liveness reject foreign 200s and verify pid identity.
|
|
2241
|
-
return jsonResponse({ status: "ok", service: "opencodex", version: VERSION, uptime: process.uptime(), pid: process.pid, port: listenPort }, 200, req, config);
|
|
2242
|
-
}
|
|
2243
|
-
|
|
2244
|
-
if (url.pathname.startsWith("/api/")) {
|
|
2245
|
-
const apiAuthError = requireApiAuth(req, config, "management");
|
|
2246
|
-
if (apiAuthError) return withCors(apiAuthError, req, config);
|
|
2247
|
-
const mgmtResponse = await handleManagementAPI(req, url, config);
|
|
2248
|
-
if (mgmtResponse) return withCors(mgmtResponse, req, config);
|
|
2249
|
-
}
|
|
2250
|
-
|
|
2251
|
-
if (url.pathname === "/v1/models" && req.method === "GET") {
|
|
2252
|
-
const apiAuthError = requireApiAuth(req, config, "data-plane");
|
|
2253
|
-
if (apiAuthError) return withCors(apiAuthError, req, config);
|
|
2254
|
-
if (!isAllowedRequestOrigin(req, config)) {
|
|
2255
|
-
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
|
|
2256
|
-
}
|
|
2257
|
-
const goModels = await fetchAllModels(config);
|
|
2258
|
-
const { buildCatalogEntries, loadCatalogTemplate, nativeOpenAiSlugs, orderForSubagents, filterCatalogVisibleModels } = await import("./codex-catalog");
|
|
2259
|
-
const nativeSlugs = nativeOpenAiSlugs();
|
|
2260
|
-
const goEnabled = filterCatalogVisibleModels(goModels, config);
|
|
2261
|
-
const goOrdered = orderForSubagents(goEnabled, config.subagentModels);
|
|
2262
|
-
if (url.searchParams.has("client_version")) {
|
|
2263
|
-
// Codex client → Codex catalog shape: native gpt + namespaced routed models,
|
|
2264
|
-
// cloned from a native template so required fields (base_instructions, etc.) are present.
|
|
2265
|
-
// Pass the subagent picks so featured models lead by priority (matches the on-disk file).
|
|
2266
|
-
return jsonResponse({ models: buildCatalogEntries(loadCatalogTemplate(), nativeSlugs, goOrdered, config.subagentModels, websocketsEnabled(config)) }, 200, req, config);
|
|
2267
|
-
}
|
|
2268
|
-
// OpenAI list shape: native gpt bare + routed models namespaced "<provider>/<id>"
|
|
2269
|
-
const data = [
|
|
2270
|
-
...nativeSlugs.map(id => ({ id, object: "model", created: 0, owned_by: "openai" })),
|
|
2271
|
-
...goOrdered.map(m => ({ id: `${m.provider}/${m.id}`, object: "model", created: 0, owned_by: m.owned_by ?? m.provider })),
|
|
2272
|
-
];
|
|
2273
|
-
return jsonResponse({ object: "list", data }, 200, req, config);
|
|
2274
|
-
}
|
|
2275
|
-
|
|
2276
|
-
if (url.pathname === "/v1/responses" && req.method === "POST") {
|
|
2277
|
-
disableResponsesRequestTimeout(req, requestServer);
|
|
2278
|
-
if (draining) {
|
|
2279
|
-
return new Response("Service shutting down", { status: 503, headers: { ...corsHeaders(req, config), "Retry-After": "5" } });
|
|
2280
|
-
}
|
|
2281
|
-
const apiAuthError = requireApiAuth(req, config, "data-plane");
|
|
2282
|
-
if (apiAuthError) return withCors(apiAuthError, req, config);
|
|
2283
|
-
if (!isAllowedRequestOrigin(req, config)) {
|
|
2284
|
-
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, config);
|
|
2285
|
-
}
|
|
2286
|
-
const start = Date.now();
|
|
2287
|
-
const requestId = nextRequestLogId(start);
|
|
2288
|
-
const logCtx = { model: "unknown", provider: "unknown" };
|
|
2289
|
-
let logged = false;
|
|
2290
|
-
const finalizeNativePassthroughLog = (
|
|
2291
|
-
status: number,
|
|
2292
|
-
meta: { terminalStatus?: ResponsesTerminalStatus; closeReason: "terminal" | "client_cancel" },
|
|
2293
|
-
) => {
|
|
2294
|
-
if (logged) return;
|
|
2295
|
-
logged = true;
|
|
2296
|
-
addFinalRequestLog(requestId, start, logCtx, status, meta);
|
|
2297
|
-
};
|
|
2298
|
-
const response = await handleResponses(req, config, logCtx, {
|
|
2299
|
-
abortSignal: req.signal,
|
|
2300
|
-
onNativePassthroughTerminal: status => {
|
|
2301
|
-
finalizeNativePassthroughLog(httpStatusForTerminalStatus(status), {
|
|
2302
|
-
terminalStatus: status,
|
|
2303
|
-
closeReason: "terminal",
|
|
2304
|
-
});
|
|
2305
|
-
},
|
|
2306
|
-
onNativePassthroughCancel: () => {
|
|
2307
|
-
finalizeNativePassthroughLog(499, { closeReason: "client_cancel" });
|
|
2308
|
-
},
|
|
2309
|
-
});
|
|
2310
|
-
return withCors(responseWithDeferredRequestLog(response, requestId, start, logCtx), req, config);
|
|
2311
|
-
}
|
|
2312
|
-
|
|
2313
|
-
const guiFile = serveGuiFile(url.pathname);
|
|
2314
|
-
if (guiFile) return guiFile;
|
|
2315
|
-
if (url.pathname === "/" && req.method === "GET") {
|
|
2316
|
-
return jsonResponse(rootFallbackPayload());
|
|
2317
|
-
}
|
|
2318
|
-
|
|
2319
|
-
return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
|
|
2320
|
-
},
|
|
2321
|
-
websocket: {
|
|
2322
|
-
idleTimeout: WEBSOCKET_IDLE_TIMEOUT_SECONDS,
|
|
2323
|
-
// Responses WebSocket data plane (phase 120.2). Re-frames the same SSE pipeline onto the
|
|
2324
|
-
// socket: parse response.create → run handleResponses unchanged → pump its SSE body as WS
|
|
2325
|
-
// Text frames. response.processed is a no-op ack. close() aborts the upstream (RC2 parity).
|
|
2326
|
-
open(ws: ServerWebSocket<WsData>) {
|
|
2327
|
-
registerCodexWebSocket(ws);
|
|
2328
|
-
},
|
|
2329
|
-
message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
|
|
2330
|
-
const rawBytes = typeof raw === "string" ? Buffer.byteLength(raw) : raw.byteLength;
|
|
2331
|
-
if (rawBytes > MAX_WS_FRAME_BYTES) {
|
|
2332
|
-
sendJsonFrame(ws, buildWsErrorFrame(413, {
|
|
2333
|
-
type: "invalid_request_error",
|
|
2334
|
-
message: "WebSocket response.create frame is too large",
|
|
2335
|
-
}));
|
|
2336
|
-
ws.close(1009, "message too large");
|
|
2337
|
-
return;
|
|
2338
|
-
}
|
|
2339
|
-
let frame: Record<string, unknown>;
|
|
2340
|
-
try {
|
|
2341
|
-
frame = JSON.parse(typeof raw === "string" ? raw : raw.toString()) as Record<string, unknown>;
|
|
2342
|
-
} catch {
|
|
2343
|
-
return; // text-only contract; ignore unparseable frames
|
|
2344
|
-
}
|
|
2345
|
-
if (frame.type === "response.processed") return; // ack — no-op
|
|
2346
|
-
if (frame.type !== "response.create") return;
|
|
2347
|
-
markActivity("ws response.create");
|
|
2348
|
-
|
|
2349
|
-
ws.data.cancel?.();
|
|
2350
|
-
const turnId = (ws.data.turnId ?? 0) + 1;
|
|
2351
|
-
ws.data.turnId = turnId;
|
|
2352
|
-
const isCurrent = () => ws.data.turnId === turnId;
|
|
2353
|
-
const turnAbort = new AbortController();
|
|
2354
|
-
const cancelTurn = () => {
|
|
2355
|
-
turnAbort.abort("websocket turn superseded or closed");
|
|
2356
|
-
};
|
|
2357
|
-
ws.data.cancel = cancelTurn;
|
|
2358
|
-
|
|
2359
|
-
if (frame.generate === false) {
|
|
2360
|
-
for (const payload of buildWarmupCompletionFrames(frame)) {
|
|
2361
|
-
if (!isCurrent()) return;
|
|
2362
|
-
sendTextFrame(ws, payload);
|
|
2363
|
-
}
|
|
2364
|
-
if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined;
|
|
2365
|
-
return;
|
|
2366
|
-
}
|
|
2367
|
-
|
|
2368
|
-
const payload: Record<string, unknown> = { ...frame };
|
|
2369
|
-
delete payload.type;
|
|
2370
|
-
registerTurn(turnAbort);
|
|
2371
|
-
void (async () => {
|
|
2372
|
-
const start = Date.now();
|
|
2373
|
-
const requestId = nextRequestLogId(start);
|
|
2374
|
-
const logCtx = { model: "unknown", provider: "unknown" };
|
|
2375
|
-
let logged = false;
|
|
2376
|
-
const finalizeLog = (status: number) => {
|
|
2377
|
-
if (logged) return;
|
|
2378
|
-
logged = true;
|
|
2379
|
-
addFinalRequestLog(requestId, start, logCtx, status);
|
|
2380
|
-
};
|
|
2381
|
-
const baseHeaders = ws.data.headers ?? new Headers();
|
|
2382
|
-
let authCtx: CodexAuthContext;
|
|
2383
|
-
let selectedForwardHeaders: Headers;
|
|
2384
|
-
try {
|
|
2385
|
-
authCtx = await resolveCodexAuthContext(baseHeaders, config);
|
|
2386
|
-
selectedForwardHeaders = headersForCodexAuthContext(baseHeaders, authCtx);
|
|
2387
|
-
updateCodexWebSocketAuthContext(ws, authCtx);
|
|
2388
|
-
} catch (err) {
|
|
2389
|
-
if (!isCurrent()) return;
|
|
2390
|
-
if (err instanceof CodexAccountCooldownError) {
|
|
2391
|
-
finalizeLog(429);
|
|
2392
|
-
sendJsonFrame(ws, buildWsErrorFrame(429, {
|
|
2393
|
-
type: "rate_limit_error",
|
|
2394
|
-
message: "Selected Codex account is cooling down",
|
|
2395
|
-
}));
|
|
2396
|
-
return;
|
|
2397
|
-
}
|
|
2398
|
-
if (err instanceof CodexThreadAffinityExpiredError) {
|
|
2399
|
-
finalizeLog(409);
|
|
2400
|
-
sendJsonFrame(ws, buildWsErrorFrame(409, {
|
|
2401
|
-
type: "invalid_request_error",
|
|
2402
|
-
message: "Codex thread account affinity expired; start a new session",
|
|
2403
|
-
}));
|
|
2404
|
-
return;
|
|
2405
|
-
}
|
|
2406
|
-
if (err instanceof CodexAuthContextError) {
|
|
2407
|
-
const safeAccountLabel = formatCodexProviderForLog("chatgpt", err.accountId, config);
|
|
2408
|
-
console.error(`[codex-auth] Pool account ${safeAccountLabel} token failed during websocket turn; reauthentication required`);
|
|
2409
|
-
finalizeLog(401);
|
|
2410
|
-
sendJsonFrame(ws, buildWsErrorFrame(401, {
|
|
2411
|
-
type: "authentication_error",
|
|
2412
|
-
message: "Selected Codex account needs reauthentication",
|
|
2413
|
-
}));
|
|
2414
|
-
return;
|
|
2415
|
-
}
|
|
2416
|
-
finalizeLog(502);
|
|
2417
|
-
sendJsonFrame(ws, buildWsErrorFrame(502, {
|
|
2418
|
-
type: "proxy_error",
|
|
2419
|
-
message: err instanceof Error ? err.message : String(err),
|
|
2420
|
-
}));
|
|
2421
|
-
return;
|
|
2422
|
-
}
|
|
2423
|
-
const fwd = new Headers({ "content-type": "application/json" });
|
|
2424
|
-
selectedForwardHeaders.forEach((value, key) => fwd.set(key, value));
|
|
2425
|
-
const req = new Request("http://localhost/v1/responses", {
|
|
2426
|
-
method: "POST",
|
|
2427
|
-
headers: fwd,
|
|
2428
|
-
body: JSON.stringify({ ...payload, stream: true }),
|
|
2429
|
-
});
|
|
2430
|
-
try {
|
|
2431
|
-
let terminalRecorder: ((status: ResponsesTerminalStatus) => void) | undefined;
|
|
2432
|
-
const response = await handleResponses(req, config, logCtx, {
|
|
2433
|
-
forceEmptyResponseId: true,
|
|
2434
|
-
abortSignal: turnAbort.signal,
|
|
2435
|
-
authContext: authCtx,
|
|
2436
|
-
selectedForwardHeaders,
|
|
2437
|
-
recordTerminalOutcomes: false,
|
|
2438
|
-
setTerminalOutcomeRecorder: recorder => {
|
|
2439
|
-
terminalRecorder = recorder;
|
|
2440
|
-
},
|
|
2441
|
-
});
|
|
2442
|
-
await sendResponseToWebSocket(ws, response, isCurrent, {
|
|
2443
|
-
onTerminal: status => {
|
|
2444
|
-
terminalRecorder?.(status);
|
|
2445
|
-
finalizeLog(httpStatusForTerminalStatus(status));
|
|
2446
|
-
},
|
|
2447
|
-
});
|
|
2448
|
-
if (!logged) finalizeLog(turnAbort.signal.aborted ? 499 : response.status);
|
|
2449
|
-
} catch (err) {
|
|
2450
|
-
if (!isCurrent()) return;
|
|
2451
|
-
try {
|
|
2452
|
-
if (err instanceof CodexAccountCooldownError) {
|
|
2453
|
-
finalizeLog(429);
|
|
2454
|
-
sendJsonFrame(ws, buildWsErrorFrame(429, {
|
|
2455
|
-
type: "rate_limit_error",
|
|
2456
|
-
message: "Selected Codex account is cooling down",
|
|
2457
|
-
}));
|
|
2458
|
-
return;
|
|
2459
|
-
}
|
|
2460
|
-
finalizeLog(502);
|
|
2461
|
-
sendJsonFrame(ws, buildWsErrorFrame(502, {
|
|
2462
|
-
type: "proxy_error",
|
|
2463
|
-
message: err instanceof Error ? err.message : String(err),
|
|
2464
|
-
}));
|
|
2465
|
-
} catch {
|
|
2466
|
-
/* socket already gone or send dropped */
|
|
2467
|
-
}
|
|
2468
|
-
} finally {
|
|
2469
|
-
unregisterTurn(turnAbort);
|
|
2470
|
-
if (!logged && turnAbort.signal.aborted) finalizeLog(499);
|
|
2471
|
-
if (ws.data.cancel === cancelTurn) ws.data.cancel = undefined;
|
|
2472
|
-
}
|
|
2473
|
-
})();
|
|
2474
|
-
},
|
|
2475
|
-
close(ws: ServerWebSocket<WsData>) {
|
|
2476
|
-
unregisterCodexWebSocket(ws);
|
|
2477
|
-
ws.data.cancel?.(); // RC2: abort the upstream when the client disconnects
|
|
2478
|
-
},
|
|
2479
|
-
},
|
|
2480
|
-
});
|
|
2481
|
-
|
|
2482
|
-
_serverRef = server;
|
|
2483
|
-
const actualPort = server.port ?? listenPort;
|
|
2484
|
-
setCorsOrigin(actualPort);
|
|
2485
|
-
|
|
2486
|
-
console.log(`🚀 opencodex proxy running on http://localhost:${actualPort}`);
|
|
2487
|
-
console.log(` POST /v1/responses → provider translation`);
|
|
2488
|
-
console.log(` GET /healthz → health check`);
|
|
2489
|
-
console.log(` GET /api/* → management API`);
|
|
2490
|
-
console.log(` GET / → GUI dashboard`);
|
|
2491
|
-
|
|
2492
|
-
// Prime pool-account quota in the background so the rotation engine has real
|
|
2493
|
-
// usage scores from the first routing decision, even when the dashboard is
|
|
2494
|
-
// never opened (the common CLI/WSL case). Fire-and-forget: never blocks the
|
|
2495
|
-
// listener, and a blocked network silently no-ops (see Phase 30 diagnostics).
|
|
2496
|
-
import("./codex-auth-api")
|
|
2497
|
-
.then(({ primeCodexPoolQuotas }) => primeCodexPoolQuotas(config, "startup"))
|
|
2498
|
-
.catch(() => {});
|
|
2499
|
-
|
|
2500
|
-
return server;
|
|
2501
|
-
}
|