@bitkyc08/opencodex 2.55.0-preview.20260914 → 2.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/gui/dist/assets/{index-DH2PUHqr.js → index-D4zuyIxQ.js} +1 -1
- package/gui/dist/index.html +1 -1
- package/package.json +2 -1
- package/src/adapters/base.ts +21 -0
- package/src/adapters/cursor/transport-retry.ts +46 -1
- package/src/adapters/cursor.ts +4 -0
- package/src/adapters/kiro/adapter.ts +42 -1
- package/src/adapters/kiro-retry.ts +23 -4
- package/src/adapters/openai-chat/errors.ts +116 -0
- package/src/adapters/openai-chat/messages.ts +346 -0
- package/src/adapters/openai-chat/passthrough.ts +146 -0
- package/src/adapters/openai-chat/response-events.ts +117 -0
- package/src/adapters/openai-chat/tool-call-validation.ts +200 -0
- package/src/adapters/openai-chat/tool-schema.ts +477 -0
- package/src/adapters/openai-chat/wire.ts +50 -0
- package/src/adapters/openai-chat.ts +33 -1445
- package/src/adapters/openai-responses/canonical-forward.ts +202 -0
- package/src/adapters/openai-responses/image-gen.ts +406 -0
- package/src/adapters/openai-responses/internal.ts +3 -0
- package/src/adapters/openai-responses/passthrough.ts +611 -0
- package/src/adapters/openai-responses/prompt-cache.ts +83 -0
- package/src/adapters/openai-responses/reasoning.ts +220 -0
- package/src/adapters/openai-responses/request-strips.ts +185 -0
- package/src/adapters/openai-responses/tool-output-recovery.ts +509 -0
- package/src/adapters/openai-responses/tool-schema.ts +293 -0
- package/src/adapters/openai-responses/web-search.ts +156 -0
- package/src/adapters/openai-responses.ts +4 -2625
- package/src/bridge/errors.ts +34 -0
- package/src/bridge/internal.ts +174 -0
- package/src/bridge/response-json.ts +624 -0
- package/src/bridge/sse.ts +1444 -0
- package/src/bridge.ts +5 -2204
- package/src/chat/inbound.ts +12 -1
- package/src/codex/account-lifecycle.ts +3 -0
- package/src/codex/account-store.ts +71 -9
- package/src/codex/auth-api/account-list.ts +507 -0
- package/src/codex/auth-api/http.ts +32 -0
- package/src/codex/auth-api/login-flow.ts +554 -0
- package/src/codex/auth-api/login-state.ts +64 -0
- package/src/codex/auth-api/main-account-probe.ts +331 -0
- package/src/codex/auth-api/pool-mode-gate.ts +274 -0
- package/src/codex/auth-api/pool-quota-probe.ts +512 -0
- package/src/codex/auth-api/reset-credit-service.ts +422 -0
- package/src/codex/auth-api/routes.ts +425 -0
- package/src/codex/auth-api/runtime-config.ts +48 -0
- package/src/codex/auth-api.ts +27 -3118
- package/src/codex/auth-context.ts +95 -28
- package/src/codex/catalog/auto-review.ts +507 -0
- package/src/codex/catalog/build-entries.ts +981 -0
- package/src/codex/catalog/combo-member.ts +375 -0
- package/src/codex/catalog/derive-entry.ts +229 -0
- package/src/codex/catalog/effort.ts +0 -1
- package/src/codex/catalog/gated-native-warn.ts +63 -0
- package/src/codex/catalog/gather-capture.ts +533 -0
- package/src/codex/catalog/model-hints.ts +691 -0
- package/src/codex/catalog/model-visibility.ts +304 -0
- package/src/codex/catalog/provider-fetch.ts +52 -2942
- package/src/codex/catalog/provider-models.ts +685 -0
- package/src/codex/catalog/restore.ts +132 -0
- package/src/codex/catalog/retained-sync.ts +706 -0
- package/src/codex/catalog/routed-gather.ts +858 -0
- package/src/codex/catalog/subagent-roster.ts +176 -0
- package/src/codex/catalog/sync.ts +52 -2698
- package/src/codex/inject/config-toml.ts +563 -0
- package/src/codex/inject/remove.ts +192 -0
- package/src/codex/inject/restore.ts +540 -0
- package/src/codex/inject/routing-classify.ts +109 -0
- package/src/codex/inject/routing-target.ts +125 -0
- package/src/codex/inject.ts +81 -1436
- package/src/codex/lineage.ts +458 -0
- package/src/codex/pool-refresh-backoff.ts +152 -0
- package/src/codex/routing/active-account.ts +194 -0
- package/src/codex/routing/cooldown-math.ts +275 -0
- package/src/codex/routing/health-store.ts +402 -0
- package/src/codex/routing/probe-lease.ts +358 -0
- package/src/codex/routing/selection.ts +703 -0
- package/src/codex/routing/thread-affinity.ts +538 -0
- package/src/codex/routing.ts +353 -2234
- package/src/codex/shim-fingerprint.ts +223 -0
- package/src/codex/shim-inspect.ts +175 -0
- package/src/codex/shim-probe.ts +367 -0
- package/src/codex/shim-restore-lock.ts +169 -0
- package/src/codex/shim-state-file.ts +151 -0
- package/src/codex/shim-templates.ts +265 -0
- package/src/codex/shim.ts +48 -1268
- package/src/config/diagnostics.ts +705 -0
- package/src/config/feature-flags.ts +55 -0
- package/src/config/live-reconcile.ts +403 -0
- package/src/config/load-degrade.ts +880 -0
- package/src/config/mutation-lock.ts +244 -0
- package/src/config/openai-tier-backup.ts +268 -0
- package/src/config/persist-unlocked.ts +92 -0
- package/src/config/proxy-env.ts +188 -0
- package/src/config/salvage.ts +244 -0
- package/src/config/schema/config-schema.ts +640 -0
- package/src/config/schema/leaf-validators.ts +855 -0
- package/src/config/warn-memo.ts +28 -0
- package/src/config.ts +234 -4481
- package/src/generated/compatibility-version.json +539 -39
- package/src/lib/request-execution-budget.ts +69 -20
- package/src/lib/spend-reservation-ledger.ts +940 -0
- package/src/lib/upstream-retry.ts +55 -11
- package/src/lib/workflow-budget.ts +553 -30
- package/src/providers/quota/account-cache.ts +441 -0
- package/src/providers/quota/antigravity.ts +295 -0
- package/src/providers/quota/report-cache.ts +320 -0
- package/src/providers/quota/vendor-probes-key.ts +1243 -0
- package/src/providers/quota/vendor-probes-oauth.ts +590 -0
- package/src/providers/quota.ts +324 -3079
- package/src/providers/registry/entries-core.ts +1221 -0
- package/src/providers/registry/entries-extended.ts +1204 -0
- package/src/providers/registry/model-seeds.ts +908 -0
- package/src/providers/registry/types.ts +352 -0
- package/src/providers/registry.ts +24 -3536
- package/src/responses/continuation-ownership.ts +29 -0
- package/src/responses/state/replay-fingerprint.ts +80 -0
- package/src/responses/state/snapshot-codec.ts +104 -0
- package/src/responses/state/spill-failure.ts +118 -0
- package/src/responses/state/spill-queue.ts +665 -0
- package/src/responses/state/temp-recovery.ts +257 -0
- package/src/responses/state.ts +82 -1143
- package/src/routing/identity-domains.ts +449 -0
- package/src/routing/probe-lease.ts +511 -0
- package/src/server/index/bounded-request.ts +88 -0
- package/src/server/index/live-sideband.ts +565 -0
- package/src/server/index/serve-options.ts +1766 -0
- package/src/server/index/startup-warnings.ts +213 -0
- package/src/server/index/websocket-handler.ts +335 -0
- package/src/server/index.ts +40 -2547
- package/src/server/management/route-registry.ts +26 -23
- package/src/server/management/shared.ts +8 -5
- package/src/server/management/workflow-budget-routes.ts +133 -0
- package/src/server/management-api.ts +12 -0
- package/src/server/request-log-conversation.ts +9 -7
- package/src/server/request-log.ts +245 -1
- package/src/server/responses/account-change-state.ts +233 -0
- package/src/server/responses/adapter-continuation.ts +514 -0
- package/src/server/responses/adapter-delivery.ts +214 -0
- package/src/server/responses/adapter-dispatch.ts +971 -0
- package/src/server/responses/compact.ts +59 -4
- package/src/server/responses/completion-policy.ts +33 -0
- package/src/server/responses/core-auth.ts +527 -0
- package/src/server/responses/core-codex-account.ts +859 -0
- package/src/server/responses/core-combo-failure.ts +210 -0
- package/src/server/responses/core-combo.ts +707 -0
- package/src/server/responses/core-errors.ts +152 -0
- package/src/server/responses/core-lifetime.ts +95 -0
- package/src/server/responses/core-normalize.ts +350 -0
- package/src/server/responses/core-opaque-recovery.ts +380 -0
- package/src/server/responses/core-options.ts +159 -0
- package/src/server/responses/core-replay.ts +225 -0
- package/src/server/responses/core.ts +192 -8893
- package/src/server/responses/passthrough-delivery.ts +856 -0
- package/src/server/responses/passthrough-dispatch.ts +1476 -0
- package/src/server/responses/passthrough-execution.ts +54 -0
- package/src/server/responses/request-prepare.ts +970 -0
- package/src/server/responses/request-send-budget.ts +164 -0
- package/src/server/responses/request-sidecar-auth.ts +149 -0
- package/src/server/responses/request-transport.ts +744 -0
- package/src/server/responses/response-effects.ts +157 -0
- package/src/server/responses/run-turn-execution.ts +448 -0
- package/src/server/responses/sidecar-execution.ts +469 -0
- package/src/server/responses-image-gen-repair.ts +1 -1
- package/src/server/workflow-refusal.ts +84 -0
- package/src/types/config.ts +30 -0
- package/src/usage/log.ts +146 -0
- package/src/usage/summary.ts +171 -21
|
@@ -0,0 +1,1766 @@
|
|
|
1
|
+
import type { Server, ServerWebSocket } from "bun";
|
|
2
|
+
import type { StartServerDeps } from "./startup-warnings";
|
|
3
|
+
import {
|
|
4
|
+
GUI_PAIRING_EXCHANGE_BODY_LIMIT,
|
|
5
|
+
REMOTE_WORKSPACE_PAIRING_BODY_LIMIT,
|
|
6
|
+
readBoundedRequestText,
|
|
7
|
+
withRemoteCatalogKeyId,
|
|
8
|
+
} from "./bounded-request";
|
|
9
|
+
import {
|
|
10
|
+
LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS,
|
|
11
|
+
MAX_WS_FRAME_BYTES,
|
|
12
|
+
WEBSOCKET_IDLE_TIMEOUT_SECONDS,
|
|
13
|
+
attachLiveSidebandUpstream,
|
|
14
|
+
closeLiveSideband,
|
|
15
|
+
closeLiveSidebandBeforeUpgrade,
|
|
16
|
+
enqueueLiveSidebandPendingFrame,
|
|
17
|
+
exceedsLiveSidebandFrameByteLimit,
|
|
18
|
+
openLiveSidebandUpstream,
|
|
19
|
+
sendUpstreamFrame,
|
|
20
|
+
webSocketFrameBytes,
|
|
21
|
+
} from "./live-sideband";
|
|
22
|
+
import {
|
|
23
|
+
withRequestLogId,
|
|
24
|
+
} from "./startup-warnings";
|
|
25
|
+
|
|
26
|
+
import { remoteWorkspaceEnabled } from "../../remote-control/workspace-activation";
|
|
27
|
+
import { markActivity } from "../../lib/sidecar-tracker";
|
|
28
|
+
import { knownModelIdsForProvider } from "../../router";
|
|
29
|
+
import {
|
|
30
|
+
buildWarmupCompletionFrames,
|
|
31
|
+
buildWsErrorFrame,
|
|
32
|
+
selectForwardHeaders,
|
|
33
|
+
sendJsonFrame,
|
|
34
|
+
buildResponsesWsData,
|
|
35
|
+
sendResponseToWebSocket,
|
|
36
|
+
sendTextFrame,
|
|
37
|
+
type WsData,
|
|
38
|
+
} from "../ws-bridge";
|
|
39
|
+
import { websocketsEnabled } from "../../config";
|
|
40
|
+
import { grokDefaultReasoningEffort } from "../../grok/effort";
|
|
41
|
+
import { OPENAI_CODEX_PROVIDER_ID } from "../../providers/openai-tiers";
|
|
42
|
+
import { providerCodexAccountMode } from "../../providers/registry";
|
|
43
|
+
import {
|
|
44
|
+
codexAccountNamespaceEntries,
|
|
45
|
+
isMainCodexAccountTarget,
|
|
46
|
+
} from "../../codex/account-namespaces";
|
|
47
|
+
import { MAIN_CODEX_ACCOUNT_ID } from "../../codex/main-account";
|
|
48
|
+
import {
|
|
49
|
+
availableAccountGatedNativeModels,
|
|
50
|
+
codexModelEntitlementStateForAccount,
|
|
51
|
+
resolveCodexModelEntitlements,
|
|
52
|
+
} from "../../codex/model-entitlements";
|
|
53
|
+
import { CatalogGatherBusyError } from "../../codex/catalog/provider-fetch";
|
|
54
|
+
import {
|
|
55
|
+
registerCodexWebSocket,
|
|
56
|
+
tryReserveCodexWebSocket,
|
|
57
|
+
unregisterCodexWebSocket,
|
|
58
|
+
updateCodexWebSocketAuthContext,
|
|
59
|
+
} from "../../codex/websocket-registry";
|
|
60
|
+
import {
|
|
61
|
+
rootFallbackPayload,
|
|
62
|
+
serveGuiFile,
|
|
63
|
+
serveSessionBootstrap,
|
|
64
|
+
} from "../gui-static";
|
|
65
|
+
import {
|
|
66
|
+
formatErrorResponse,
|
|
67
|
+
type ResponsesTerminalStatus,
|
|
68
|
+
} from "../../bridge";
|
|
69
|
+
import {
|
|
70
|
+
isDraining,
|
|
71
|
+
registerTurn,
|
|
72
|
+
tryAdmitTurn,
|
|
73
|
+
unregisterTurn,
|
|
74
|
+
type ActiveTurnLease,
|
|
75
|
+
} from "../lifecycle";
|
|
76
|
+
import {
|
|
77
|
+
addFinalRequestLog,
|
|
78
|
+
httpStatusForRequestLogTerminal,
|
|
79
|
+
inspectResponseLogSsePayload,
|
|
80
|
+
nextRequestLogId,
|
|
81
|
+
recordFirstOutput,
|
|
82
|
+
type RequestLogContext,
|
|
83
|
+
type RequestLogEntry,
|
|
84
|
+
} from "../request-log";
|
|
85
|
+
import { sessionLaneIdFromRequest } from "../request-log-conversation";
|
|
86
|
+
import { responseWithDeferredRequestLog } from "../relay";
|
|
87
|
+
import {
|
|
88
|
+
corsHeaders,
|
|
89
|
+
managementCorsHeaders,
|
|
90
|
+
isAllowedRequestOrigin,
|
|
91
|
+
isAllowedManagementOrigin,
|
|
92
|
+
isApiAuthRequired,
|
|
93
|
+
jsonResponse,
|
|
94
|
+
admissionFields,
|
|
95
|
+
resolveApiAuth,
|
|
96
|
+
resolveResponsesApiAuth,
|
|
97
|
+
type RequestPolicyView,
|
|
98
|
+
withCors,
|
|
99
|
+
withManagementCors,
|
|
100
|
+
} from "../auth-cors";
|
|
101
|
+
import {
|
|
102
|
+
disableResponsesRequestTimeout,
|
|
103
|
+
handleResponses,
|
|
104
|
+
handleResponsesCompact,
|
|
105
|
+
} from "../responses";
|
|
106
|
+
import {
|
|
107
|
+
handleClaudeCountTokens,
|
|
108
|
+
handleClaudeMessages,
|
|
109
|
+
} from "../claude-messages";
|
|
110
|
+
import { handleChatCompletions } from "../chat-completions";
|
|
111
|
+
import { anthropicErrorResponse } from "../../claude/outbound";
|
|
112
|
+
import {
|
|
113
|
+
buildDesktop3pRegistry,
|
|
114
|
+
generateDesktop3pModels,
|
|
115
|
+
} from "../../claude/desktop-3p";
|
|
116
|
+
import { buildDesktopDiscoveryInputs } from "../../claude/desktop-discovery-inputs";
|
|
117
|
+
import { handleImages } from "../images";
|
|
118
|
+
import {
|
|
119
|
+
handleLive,
|
|
120
|
+
logLiveSidebandFrame,
|
|
121
|
+
parseLiveSidebandTarget,
|
|
122
|
+
resolveLiveSidebandUpgrade,
|
|
123
|
+
} from "../live";
|
|
124
|
+
import { handleAudioTranscriptions } from "../audio-transcriptions";
|
|
125
|
+
import {
|
|
126
|
+
resolveAudioAdmission,
|
|
127
|
+
TRANSCRIPTION_MODEL,
|
|
128
|
+
} from "../audio-upstream";
|
|
129
|
+
import { resolveAudioClient } from "../audio-client";
|
|
130
|
+
import { resolveDictationSocket } from "../audio-dictation";
|
|
131
|
+
import {
|
|
132
|
+
handleExternalLive,
|
|
133
|
+
resolveExternalLiveSocket,
|
|
134
|
+
} from "../audio-live";
|
|
135
|
+
import {
|
|
136
|
+
EXTERNAL_CALL_PREFIX,
|
|
137
|
+
type LiveCallBindings,
|
|
138
|
+
} from "../live-call-bindings";
|
|
139
|
+
import { clearableDeadline } from "../../lib/abort";
|
|
140
|
+
import { handleSearch } from "../search";
|
|
141
|
+
import { handleContextHistory } from "../context-history";
|
|
142
|
+
import {
|
|
143
|
+
codexCompatibleUrl,
|
|
144
|
+
contextEndpoint,
|
|
145
|
+
contextRelayActivated,
|
|
146
|
+
} from "../../codex/context-compat";
|
|
147
|
+
import {
|
|
148
|
+
fetchAllModels,
|
|
149
|
+
handleManagementAPI,
|
|
150
|
+
VERSION,
|
|
151
|
+
type ManagementApiDeps,
|
|
152
|
+
} from "../management-api";
|
|
153
|
+
import {
|
|
154
|
+
issueGuiSession,
|
|
155
|
+
managementPrincipal,
|
|
156
|
+
requireManagementAuth,
|
|
157
|
+
type ManagementAuthState,
|
|
158
|
+
type ManagementSessionControl,
|
|
159
|
+
} from "../management-auth";
|
|
160
|
+
import {
|
|
161
|
+
LOCAL_ATTESTATION_CHALLENGE_HEADER,
|
|
162
|
+
LOCAL_ATTESTATION_PROOF_HEADER,
|
|
163
|
+
createLocalAttestationProof,
|
|
164
|
+
} from "../../lib/local-management-attestation";
|
|
165
|
+
import { SYSTEM_RESTART_CAPABILITY_VERSION } from "../../lib/system-restart-contract";
|
|
166
|
+
import { LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION } from "../../lib/local-provider-reload-contract";
|
|
167
|
+
import {
|
|
168
|
+
GUI_PAIR_BROWSER_ORIGIN_HEADER,
|
|
169
|
+
GUI_PAIR_CAPABILITY_VERSION,
|
|
170
|
+
GUI_PAIR_PATH,
|
|
171
|
+
} from "../../lib/gui-pair-capability";
|
|
172
|
+
import {
|
|
173
|
+
GuiPairingGrantRateLimitError,
|
|
174
|
+
consumeGuiPairingGrant,
|
|
175
|
+
createGuiPairingGrant,
|
|
176
|
+
} from "../gui-session";
|
|
177
|
+
import { recordCursorSeen } from "../../integrations/cursor-seen";
|
|
178
|
+
import { detectCursorInstalls } from "../../integrations/cursor-detect";
|
|
179
|
+
import { loadCursorEffortTable } from "../../integrations/cursor-effort-table";
|
|
180
|
+
import {
|
|
181
|
+
expandCursorEffortRow,
|
|
182
|
+
knownEffortRowIds,
|
|
183
|
+
} from "../effort-row";
|
|
184
|
+
import {
|
|
185
|
+
catalogFastRowEligible,
|
|
186
|
+
expandFastRow,
|
|
187
|
+
} from "../fast-row";
|
|
188
|
+
import type { OcxConfig } from "../../types";
|
|
189
|
+
import type { PackageTreeIntegrityGuard } from "../../lib/package-tree-integrity";
|
|
190
|
+
import type { ReadinessGate } from "../readiness";
|
|
191
|
+
import type { WorkflowRefusalLog } from "../workflow-refusal";
|
|
192
|
+
|
|
193
|
+
import { readyProtocolMetadata } from "../../remote/protocol";
|
|
194
|
+
import { modelCapabilityFields } from "../models-capabilities";
|
|
195
|
+
import { createWebsocketHandler } from "./websocket-handler";
|
|
196
|
+
|
|
197
|
+
export type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management";
|
|
198
|
+
|
|
199
|
+
export interface ServeOptionsContext {
|
|
200
|
+
readonly server: Server<WsData>;
|
|
201
|
+
readonly boundPort: number | null;
|
|
202
|
+
readonly remoteWorkspaceStopping: boolean;
|
|
203
|
+
|
|
204
|
+
drainingResponse: (req: Request, policy: RequestPolicyView) => Response;
|
|
205
|
+
ingressForServer: (requestServer: Server<WsData>) => ServerIngress;
|
|
206
|
+
loopbackRouteAllowed: (url: URL, req: Request) => boolean;
|
|
207
|
+
managementIngressRouteAllowed: (url: URL, req: Request) => boolean;
|
|
208
|
+
packageTreeChangedResponse: (
|
|
209
|
+
req: Request,
|
|
210
|
+
policy: RequestPolicyView,
|
|
211
|
+
message: string,
|
|
212
|
+
) => Response;
|
|
213
|
+
serverBusyResponse: (
|
|
214
|
+
req: Request,
|
|
215
|
+
resource: string,
|
|
216
|
+
policy: RequestPolicyView,
|
|
217
|
+
) => Response;
|
|
218
|
+
runAdmittedHttpTurn: (
|
|
219
|
+
req: Request,
|
|
220
|
+
policy: RequestPolicyView,
|
|
221
|
+
work: (lease: ActiveTurnLease) => Promise<Response>,
|
|
222
|
+
refusalLog?: WorkflowRefusalLog,
|
|
223
|
+
) => Promise<Response>;
|
|
224
|
+
|
|
225
|
+
config: OcxConfig;
|
|
226
|
+
inboundBodyLimitBytes: number;
|
|
227
|
+
listenPort: number;
|
|
228
|
+
liveCallBindings: LiveCallBindings;
|
|
229
|
+
loadRemoteWorkspaceRuntime: () => Promise<
|
|
230
|
+
typeof import("../../remote-control/workspace-runtime")
|
|
231
|
+
>;
|
|
232
|
+
localAttestationSecret: string;
|
|
233
|
+
loopbackPolicy: () => RequestPolicyView;
|
|
234
|
+
managementApiDeps: ManagementApiDeps;
|
|
235
|
+
managementAuth: ManagementAuthState;
|
|
236
|
+
managementSessionControl: ManagementSessionControl;
|
|
237
|
+
packageTreeIntegrity: PackageTreeIntegrityGuard;
|
|
238
|
+
readinessGate: ReadinessGate;
|
|
239
|
+
|
|
240
|
+
deps: StartServerDeps;
|
|
241
|
+
port: number | undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function createServeOptions(ctx: ServeOptionsContext) {
|
|
245
|
+
const {
|
|
246
|
+
drainingResponse,
|
|
247
|
+
ingressForServer,
|
|
248
|
+
loopbackRouteAllowed,
|
|
249
|
+
managementIngressRouteAllowed,
|
|
250
|
+
packageTreeChangedResponse,
|
|
251
|
+
serverBusyResponse,
|
|
252
|
+
runAdmittedHttpTurn,
|
|
253
|
+
config,
|
|
254
|
+
inboundBodyLimitBytes,
|
|
255
|
+
listenPort,
|
|
256
|
+
liveCallBindings,
|
|
257
|
+
loadRemoteWorkspaceRuntime,
|
|
258
|
+
localAttestationSecret,
|
|
259
|
+
loopbackPolicy,
|
|
260
|
+
managementApiDeps,
|
|
261
|
+
managementAuth,
|
|
262
|
+
managementSessionControl,
|
|
263
|
+
packageTreeIntegrity,
|
|
264
|
+
readinessGate,
|
|
265
|
+
deps,
|
|
266
|
+
port,
|
|
267
|
+
} = ctx;
|
|
268
|
+
void port;
|
|
269
|
+
const serveOptions = {
|
|
270
|
+
idleTimeout: 255,
|
|
271
|
+
// Bun rejects an oversized body before `fetch` runs, so the listener has to be raised
|
|
272
|
+
// with the admission limit or the opt-in would do nothing. Fixed at bind time: a live
|
|
273
|
+
// `maxInboundBodyBytes` edit needs a restart, which the config doc states.
|
|
274
|
+
maxRequestBodySize: inboundBodyLimitBytes,
|
|
275
|
+
async fetch(req: Request, requestServer: Server<WsData>): Promise<Response> {
|
|
276
|
+
const ingress = ingressForServer(requestServer);
|
|
277
|
+
// The unauthenticated loopback listener (#1102) serves a fixed allowlist and nothing
|
|
278
|
+
// else. Rejecting here, before any handler runs, is what keeps the surface from growing
|
|
279
|
+
// silently when a route is added below.
|
|
280
|
+
if (ingress === "unauthenticated-loopback" && !loopbackRouteAllowed(codexCompatibleUrl(req.url), req)) {
|
|
281
|
+
return withCors(
|
|
282
|
+
formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${new URL(req.url).pathname}`),
|
|
283
|
+
req,
|
|
284
|
+
loopbackPolicy(),
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
// Tailscale Serve terminates only on this separately bound loopback socket. Reject before
|
|
288
|
+
// dispatch so no data, readiness, health, WebSocket, or unknown-static handler can run.
|
|
289
|
+
if (ingress === "hub-management" && !managementIngressRouteAllowed(codexCompatibleUrl(req.url), req)) {
|
|
290
|
+
return withCors(
|
|
291
|
+
formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${new URL(req.url).pathname}`),
|
|
292
|
+
req,
|
|
293
|
+
config,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
// Auth and CORS decisions below read `policy`, not `config`. For the public listener the
|
|
297
|
+
// two are the same object, so its behaviour is unchanged; for the loopback listener the
|
|
298
|
+
// view substitutes 127.0.0.1 as the bind address, which is what routes it through the
|
|
299
|
+
// same code path a plain loopback bind has always taken — Host-header check included.
|
|
300
|
+
// Routing, provider selection and response bodies keep using `config`.
|
|
301
|
+
const policy: RequestPolicyView = ingress === "unauthenticated-loopback" ? loopbackPolicy() : config;
|
|
302
|
+
const url = codexCompatibleUrl(req.url);
|
|
303
|
+
markActivity(`${req.method} ${url.pathname}`);
|
|
304
|
+
|
|
305
|
+
// Readiness is exact-GET on the literal /readyz path. Compare the DECODED
|
|
306
|
+
// pathname so an encoded variant like /readyz%2F (which decodes to
|
|
307
|
+
// /readyz/) cannot bypass the exact-path rejection and reach the GUI
|
|
308
|
+
// fallback (serveGuiFile decodes the pathname and would serve index.html
|
|
309
|
+
// with 200). Malformed percent-sequences fall back to the raw pathname,
|
|
310
|
+
// which still cannot match the exact literal below.
|
|
311
|
+
let readyzPath: string | undefined;
|
|
312
|
+
try {
|
|
313
|
+
const decoded = decodeURIComponent(url.pathname);
|
|
314
|
+
if (decoded === "/readyz" || decoded === "/readyz/") readyzPath = decoded;
|
|
315
|
+
} catch { /* malformed encoding — not a readiness path */ }
|
|
316
|
+
|
|
317
|
+
const packageTreeStatus = packageTreeIntegrity.status();
|
|
318
|
+
if (!packageTreeStatus.ok && (
|
|
319
|
+
url.pathname === "/healthz"
|
|
320
|
+
|| readyzPath !== undefined
|
|
321
|
+
|| url.pathname.startsWith("/v1/")
|
|
322
|
+
)) {
|
|
323
|
+
const message = "OpenCodex package files changed while this proxy was running; restart OpenCodex before retrying.";
|
|
324
|
+
const response = url.pathname === "/healthz" || readyzPath !== undefined
|
|
325
|
+
? jsonResponse({
|
|
326
|
+
status: "restart_required",
|
|
327
|
+
service: "opencodex",
|
|
328
|
+
version: VERSION,
|
|
329
|
+
uptime: process.uptime(),
|
|
330
|
+
pid: process.pid,
|
|
331
|
+
port: ctx.boundPort ?? requestServer.port ?? listenPort,
|
|
332
|
+
error: { code: "package_tree_changed", message },
|
|
333
|
+
}, 503, req, policy)
|
|
334
|
+
: packageTreeChangedResponse(req, policy, message);
|
|
335
|
+
const headers = new Headers(response.headers);
|
|
336
|
+
headers.set("Retry-After", "5");
|
|
337
|
+
return new Response(response.body, { status: 503, headers });
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (req.method === "OPTIONS") {
|
|
341
|
+
// /readyz is exact-GET only; OPTIONS (like POST and the trailing-slash
|
|
342
|
+
// path) must answer the deterministic JSON 404, never the generic 204
|
|
343
|
+
// preflight response that the SPA fallback would otherwise allow.
|
|
344
|
+
if (readyzPath !== undefined) {
|
|
345
|
+
return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy);
|
|
346
|
+
}
|
|
347
|
+
const managementPreflight = url.pathname.startsWith("/api/");
|
|
348
|
+
const allowed = managementPreflight
|
|
349
|
+
? isAllowedManagementOrigin(req, config)
|
|
350
|
+
: isAllowedRequestOrigin(req, policy);
|
|
351
|
+
if (!allowed) {
|
|
352
|
+
return new Response(null, { status: 403, headers: corsHeaders() });
|
|
353
|
+
}
|
|
354
|
+
return new Response(null, {
|
|
355
|
+
status: 204,
|
|
356
|
+
headers: managementPreflight ? managementCorsHeaders(req, config) : corsHeaders(req, policy),
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// An OCX-only executor exchanges one short-lived pairing code for a device-scoped
|
|
361
|
+
// token. This is intentionally outside /api: management auth belongs to the browser
|
|
362
|
+
// that created the grant, while the new device owns only that one-time code.
|
|
363
|
+
if (url.pathname === "/remote-workspace/pair" && req.method === "POST") {
|
|
364
|
+
if (!remoteWorkspaceEnabled(config)) {
|
|
365
|
+
return Response.json({ error: "Remote Workspace is not enabled on this OpenCodex instance." }, { status: 404 });
|
|
366
|
+
}
|
|
367
|
+
// Browser JavaScript must use the authenticated dashboard route. Refusing Origin-bearing
|
|
368
|
+
// requests leaves this exchange to an explicit OCX device process and avoids turning a
|
|
369
|
+
// copied pairing code into a cross-site enrollment action.
|
|
370
|
+
if (req.headers.get("origin") !== null) {
|
|
371
|
+
return Response.json({ error: "Remote Workspace device pairing does not accept browser-origin requests." }, {
|
|
372
|
+
status: 403,
|
|
373
|
+
headers: { "cache-control": "no-store" },
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
const [{ remoteWorkspaceHubForConfig }, { RemoteWorkspacePairingRateLimitError }] = await Promise.all([
|
|
377
|
+
loadRemoteWorkspaceRuntime(),
|
|
378
|
+
import("../../remote-control/workspace-hub"),
|
|
379
|
+
]);
|
|
380
|
+
if (ctx.remoteWorkspaceStopping) return Response.json({ error: "Remote Workspace is stopping." }, { status: 503 });
|
|
381
|
+
const hub = deps.managementApi?.remoteWorkspaceHub ?? remoteWorkspaceHubForConfig(config);
|
|
382
|
+
// A loopback socket alone cannot prove that Tailscale Serve supplied its identity header:
|
|
383
|
+
// another local process can connect directly and forge it. Pairing therefore uses only the
|
|
384
|
+
// kernel-observed peer on every listener; proxied management users intentionally share the
|
|
385
|
+
// loopback bucket rather than gaining a header-rotation bypass.
|
|
386
|
+
const peer = requestServer.requestIP(req)?.address ?? "unknown";
|
|
387
|
+
const pairingSource = `${ingress}:${peer}`;
|
|
388
|
+
const rateLimitResponse = (error: unknown): Response | null => {
|
|
389
|
+
if (!(error instanceof RemoteWorkspacePairingRateLimitError)) return null;
|
|
390
|
+
return Response.json({ error: "Remote Workspace pairing is temporarily rate limited." }, {
|
|
391
|
+
status: 429,
|
|
392
|
+
headers: {
|
|
393
|
+
"cache-control": "no-store",
|
|
394
|
+
"retry-after": String(error.retryAfterSeconds),
|
|
395
|
+
},
|
|
396
|
+
});
|
|
397
|
+
};
|
|
398
|
+
try {
|
|
399
|
+
// Check the existing source block before reading or parsing an attacker-controlled body.
|
|
400
|
+
// pairDevice checks again after the await and records only code-shaped authentication
|
|
401
|
+
// failures, so malformed JSON cannot allocate one limiter entry per request.
|
|
402
|
+
hub.assertPairingSourceAllowed(pairingSource);
|
|
403
|
+
} catch (error) {
|
|
404
|
+
const limited = rateLimitResponse(error);
|
|
405
|
+
if (limited) return limited;
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
const declaredLength = Number(req.headers.get("content-length") ?? "0");
|
|
409
|
+
if (!Number.isFinite(declaredLength) || declaredLength > REMOTE_WORKSPACE_PAIRING_BODY_LIMIT) {
|
|
410
|
+
return Response.json({ error: "Remote Workspace pairing body is too large." }, { status: 413 });
|
|
411
|
+
}
|
|
412
|
+
const text = await readBoundedRequestText(req, REMOTE_WORKSPACE_PAIRING_BODY_LIMIT);
|
|
413
|
+
if (text === null) return Response.json({ error: "Remote Workspace pairing body is too large." }, { status: 413 });
|
|
414
|
+
if (ctx.remoteWorkspaceStopping) return Response.json({ error: "Remote Workspace is stopping." }, { status: 503 });
|
|
415
|
+
let body: unknown;
|
|
416
|
+
try { body = JSON.parse(text); }
|
|
417
|
+
catch { return Response.json({ error: "Invalid Remote Workspace pairing request." }, { status: 400 }); }
|
|
418
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
419
|
+
return Response.json({ error: "Invalid Remote Workspace pairing request." }, { status: 400 });
|
|
420
|
+
}
|
|
421
|
+
const record = body as Record<string, unknown>;
|
|
422
|
+
const required = ["code", "name", "platform", "publicKey", "roots"];
|
|
423
|
+
const allowed = new Set([...required, "capabilities"]);
|
|
424
|
+
if (required.some(key => !Object.hasOwn(record, key))
|
|
425
|
+
|| Object.keys(record).some(key => !allowed.has(key))) {
|
|
426
|
+
return Response.json({ error: "Invalid Remote Workspace pairing request." }, { status: 400 });
|
|
427
|
+
}
|
|
428
|
+
try {
|
|
429
|
+
const paired = hub.pairDevice(record, pairingSource);
|
|
430
|
+
return Response.json(paired, { status: 201, headers: { "cache-control": "no-store" } });
|
|
431
|
+
} catch (error) {
|
|
432
|
+
const limited = rateLimitResponse(error);
|
|
433
|
+
if (limited) return limited;
|
|
434
|
+
const message = error instanceof Error ? error.message : "Remote Workspace pairing failed.";
|
|
435
|
+
const conflict = /already in use|limit reached/i.test(message);
|
|
436
|
+
return Response.json({ error: message }, {
|
|
437
|
+
status: conflict ? 409 : 401,
|
|
438
|
+
headers: { "cache-control": "no-store" },
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Each executor holds one device-scoped bearer and opens one outbound WSS. The token is
|
|
444
|
+
// authenticated only at upgrade and never enters ws.data; subsequent frames are bound to
|
|
445
|
+
// the device identity and per-session signed E2EE handshake.
|
|
446
|
+
if (url.pathname === "/remote-workspace/agent" && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
447
|
+
if (!remoteWorkspaceEnabled(config) || req.headers.get("origin") !== null) {
|
|
448
|
+
return Response.json({ error: "Remote Workspace agent upgrade refused." }, { status: 403 });
|
|
449
|
+
}
|
|
450
|
+
const authorization = req.headers.get("authorization") ?? "";
|
|
451
|
+
const match = /^Bearer (ocxrw_[A-Za-z0-9_-]{43})$/.exec(authorization);
|
|
452
|
+
if (!match) return Response.json({ error: "Remote Workspace device authentication required." }, { status: 401 });
|
|
453
|
+
const { remoteWorkspaceHubForConfig } = await loadRemoteWorkspaceRuntime();
|
|
454
|
+
const { RemoteWorkspaceHubAgentConnection } = await import("../../remote-control/workspace-agent-connection");
|
|
455
|
+
if (ctx.remoteWorkspaceStopping) return Response.json({ error: "Remote Workspace is stopping." }, { status: 503 });
|
|
456
|
+
const hub = deps.managementApi?.remoteWorkspaceHub ?? remoteWorkspaceHubForConfig(config);
|
|
457
|
+
const device = hub.authenticateDeviceToken(match[1]!);
|
|
458
|
+
if (!device) return Response.json({ error: "Remote Workspace device authentication failed." }, { status: 401 });
|
|
459
|
+
const upgraded = requestServer.upgrade(req, {
|
|
460
|
+
data: {
|
|
461
|
+
kind: "remote-workspace-agent",
|
|
462
|
+
remoteWorkspaceOpen: socket => {
|
|
463
|
+
const connection = new RemoteWorkspaceHubAgentConnection({
|
|
464
|
+
deviceId: device.id,
|
|
465
|
+
devicePublicKey: device.publicKey,
|
|
466
|
+
hubIdentity: hub.identity(),
|
|
467
|
+
capabilities: device.capabilities,
|
|
468
|
+
onCapabilities: capabilities => hub.updateDeviceCapabilities(device.id, capabilities),
|
|
469
|
+
socket: {
|
|
470
|
+
send: value => {
|
|
471
|
+
if (socket.send(value) === 0) throw new Error("remote workspace socket send dropped");
|
|
472
|
+
},
|
|
473
|
+
close: (code, reason) => socket.close(code, reason),
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
hub.attachConnection(device.id, connection);
|
|
477
|
+
socket.data.remoteWorkspaceClose = () => hub.detachConnection(device.id, connection);
|
|
478
|
+
return connection;
|
|
479
|
+
},
|
|
480
|
+
} satisfies WsData,
|
|
481
|
+
});
|
|
482
|
+
return upgraded
|
|
483
|
+
? undefined as unknown as Response
|
|
484
|
+
: Response.json({ error: "Remote Workspace WebSocket upgrade failed." }, { status: 426 });
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Responses WebSocket (phase 120.2). Codex upgrades the same /v1/responses path; auth is
|
|
488
|
+
// handshake-time only, so capture inbound headers and thread them into the pipeline.
|
|
489
|
+
if (url.pathname === "/v1/responses" && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
490
|
+
if (isDraining()) {
|
|
491
|
+
return drainingResponse(req, policy);
|
|
492
|
+
}
|
|
493
|
+
const admission = resolveResponsesApiAuth(req, policy);
|
|
494
|
+
if (!admission) {
|
|
495
|
+
return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
496
|
+
}
|
|
497
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
498
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin"), req, policy);
|
|
499
|
+
}
|
|
500
|
+
// WS transport gate: Codex's built-in `openai` provider hardcodes supports_websockets=true,
|
|
501
|
+
// so under Design B it always tries the WS transport first. When the feature is off, reject
|
|
502
|
+
// the upgrade with 426 — codex-rs maps a connect-time UPGRADE_REQUIRED to a clean
|
|
503
|
+
// session-scoped HTTP fallback (client.rs WebsocketStreamOutcome::FallbackToHttp) instead of
|
|
504
|
+
// surfacing broken-pipe errors from sockets a "disabled" feature would otherwise accept.
|
|
505
|
+
if (!websocketsEnabled(config)) {
|
|
506
|
+
return withCors(formatErrorResponse(426, "upgrade_required", "Responses WebSocket transport is disabled; use HTTP"), req, policy);
|
|
507
|
+
}
|
|
508
|
+
const websocketLease = tryReserveCodexWebSocket();
|
|
509
|
+
if (!websocketLease) return serverBusyResponse(req, "Codex WebSockets", policy);
|
|
510
|
+
// Upgrade on the server that RECEIVED this request, not the captured `server`
|
|
511
|
+
// binding. They are the same object for the public listener, but the
|
|
512
|
+
// unauthenticated loopback listener (#1102) is a second Bun.serve, and handing its
|
|
513
|
+
// request to the public server's upgrade would fail or cross sockets.
|
|
514
|
+
if (requestServer.upgrade(req, {
|
|
515
|
+
data: buildResponsesWsData(
|
|
516
|
+
selectForwardHeaders(req.headers),
|
|
517
|
+
admission,
|
|
518
|
+
websocketLease,
|
|
519
|
+
sessionLaneIdFromRequest(req.headers),
|
|
520
|
+
),
|
|
521
|
+
})) return undefined as unknown as Response;
|
|
522
|
+
websocketLease.release();
|
|
523
|
+
return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, policy);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
if (url.pathname === "/healthz" && req.method === "GET") {
|
|
527
|
+
// service/pid/port let CLI liveness reject foreign 200s and verify pid identity.
|
|
528
|
+
const healthPort = ctx.server.port ?? listenPort;
|
|
529
|
+
const response = jsonResponse({
|
|
530
|
+
status: "ok",
|
|
531
|
+
service: "opencodex",
|
|
532
|
+
version: VERSION,
|
|
533
|
+
uptime: process.uptime(),
|
|
534
|
+
pid: process.pid,
|
|
535
|
+
port: healthPort,
|
|
536
|
+
restartCapability: SYSTEM_RESTART_CAPABILITY_VERSION,
|
|
537
|
+
providerReloadCapability: LOCAL_PROVIDER_RELOAD_CAPABILITY_VERSION,
|
|
538
|
+
guiPairCapability: GUI_PAIR_CAPABILITY_VERSION,
|
|
539
|
+
}, 200, req, policy);
|
|
540
|
+
const challenge = req.headers.get(LOCAL_ATTESTATION_CHALLENGE_HEADER);
|
|
541
|
+
if (challenge) {
|
|
542
|
+
const proof = createLocalAttestationProof(localAttestationSecret, challenge, process.pid, healthPort);
|
|
543
|
+
if (proof) response.headers.set(LOCAL_ATTESTATION_PROOF_HEADER, proof);
|
|
544
|
+
}
|
|
545
|
+
return response;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Readiness: like /healthz this is exact GET and unauthenticated (so a client can
|
|
549
|
+
// back off BEFORE knowing the admission token), but stricter than liveness. The
|
|
550
|
+
// body carries only sanitized identity + the fixed status enum; the sync message,
|
|
551
|
+
// warning text, catalog path, provider output, and account data are never exposed.
|
|
552
|
+
// POST or "/readyz/" must NOT match (exact pathname + GET method): answer them
|
|
553
|
+
// with a JSON 404 here so they can never be silently accepted by the GUI SPA
|
|
554
|
+
// fallback (which would serve index.html with HTTP 200 once gui/dist exists).
|
|
555
|
+
if (readyzPath !== undefined) {
|
|
556
|
+
if (readyzPath !== "/readyz" || req.method !== "GET") {
|
|
557
|
+
return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy);
|
|
558
|
+
}
|
|
559
|
+
// A draining proxy must never advertise ready: every data-plane branch
|
|
560
|
+
// answers drainingResponse while isDraining() is set, but the one-shot
|
|
561
|
+
// readiness gate is not mutated on shutdown (it is owned by the startup
|
|
562
|
+
// sync). Report pending so `ocx ready --wait` and external supervisors
|
|
563
|
+
// keep polling instead of promoting a proxy that is draining.
|
|
564
|
+
const status = isDraining() ? "pending" : readinessGate.getStatus();
|
|
565
|
+
const body = {
|
|
566
|
+
service: "opencodex",
|
|
567
|
+
version: VERSION,
|
|
568
|
+
uptime: process.uptime(),
|
|
569
|
+
pid: process.pid,
|
|
570
|
+
port: ctx.boundPort ?? listenPort,
|
|
571
|
+
status,
|
|
572
|
+
...readyProtocolMetadata(config, req),
|
|
573
|
+
};
|
|
574
|
+
if (status === "ready") {
|
|
575
|
+
return jsonResponse(body, 200, req, policy);
|
|
576
|
+
}
|
|
577
|
+
// Pending/failed: 503 with a conservative Retry-After so well-behaved clients
|
|
578
|
+
// (and `ocx ready --wait`) back off instead of hot-looping.
|
|
579
|
+
const resp = jsonResponse(body, 503, req, policy);
|
|
580
|
+
const headers = new Headers(resp.headers);
|
|
581
|
+
headers.set("Retry-After", "1");
|
|
582
|
+
return new Response(resp.body, { status: 503, headers });
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
if (url.pathname.startsWith("/api/")) {
|
|
586
|
+
const localManagementAuth = {
|
|
587
|
+
attestationSecret: localAttestationSecret,
|
|
588
|
+
pid: process.pid,
|
|
589
|
+
port: ctx.boundPort ?? requestServer.port ?? listenPort,
|
|
590
|
+
};
|
|
591
|
+
const apiAuthError = requireManagementAuth(req, managementAuth, config, localManagementAuth);
|
|
592
|
+
if (apiAuthError) return withManagementCors(apiAuthError, req, config);
|
|
593
|
+
// Which credential passed the gate, resolved from the same session table the
|
|
594
|
+
// gate used. Consent-bearing routes need this: request headers are forgeable
|
|
595
|
+
// by anything holding the admin token, the credential is not.
|
|
596
|
+
const principal = managementPrincipal(req, managementAuth, config, localManagementAuth) ?? undefined;
|
|
597
|
+
if (url.pathname === GUI_PAIR_PATH) {
|
|
598
|
+
if (req.method !== "POST" || principal !== "gui-pair-capability" || !managementAuth.available) {
|
|
599
|
+
return withManagementCors(Response.json({ error: "GUI pairing capability required" }, { status: 403 }), req, config);
|
|
600
|
+
}
|
|
601
|
+
try {
|
|
602
|
+
const grant = createGuiPairingGrant(
|
|
603
|
+
req.headers.get(GUI_PAIR_BROWSER_ORIGIN_HEADER) ?? "",
|
|
604
|
+
config,
|
|
605
|
+
managementAuth,
|
|
606
|
+
);
|
|
607
|
+
return withManagementCors(Response.json(grant, {
|
|
608
|
+
status: 201,
|
|
609
|
+
headers: { "Cache-Control": "no-store" },
|
|
610
|
+
}), req, config);
|
|
611
|
+
} catch (error) {
|
|
612
|
+
const status = error instanceof GuiPairingGrantRateLimitError ? 429 : 403;
|
|
613
|
+
return withManagementCors(Response.json({ error: "GUI pairing grant refused" }, {
|
|
614
|
+
status,
|
|
615
|
+
...(status === 429 ? { headers: { "Retry-After": "60" } } : {}),
|
|
616
|
+
}), req, config);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
const mgmtResponse = await handleManagementAPI(req, url, config, managementApiDeps, principal, managementSessionControl);
|
|
620
|
+
if (mgmtResponse) return withManagementCors(mgmtResponse, req, config);
|
|
621
|
+
return withManagementCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
if (url.pathname === "/v1/catalog" && (req.method === "GET" || req.method === "HEAD")) {
|
|
625
|
+
// #809: remote Codex clients need the model catalog, and the only prior source was
|
|
626
|
+
// GET /api/catalog behind management auth — so operators had to hand out an admin
|
|
627
|
+
// token to read a list of models. This route fixes that on the data plane instead of
|
|
628
|
+
// widening /api/*, which stays exactly as restricted as before.
|
|
629
|
+
//
|
|
630
|
+
// resolveApiAuth (not resolveResponsesApiAuth) for the same reason /v1/models uses
|
|
631
|
+
// it: nothing here forwards a caller credential upstream, so accepting the dedicated
|
|
632
|
+
// header, a recognized bearer, or x-api-key is safe — and rejecting x-api-key would
|
|
633
|
+
// 401 Anthropic-SDK clients holding a perfectly valid data credential.
|
|
634
|
+
const admission = resolveApiAuth(req, policy);
|
|
635
|
+
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
636
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
637
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
|
|
638
|
+
}
|
|
639
|
+
const { serializePersistedCatalog, persistedCodexVersion, MAX_REMOTE_CATALOG_BYTES } = await import("../catalog-download");
|
|
640
|
+
const serialized = await serializePersistedCatalog();
|
|
641
|
+
if (serialized.body === null) {
|
|
642
|
+
// Built directly rather than through formatErrorResponse: that helper derives
|
|
643
|
+
// `code` from the status and message via classifyError, and these two need stable,
|
|
644
|
+
// specific codes. `catalog_not_found` in particular is what lets a caller — and
|
|
645
|
+
// tests/server/api-key-attribution.test.ts — tell "this route exists and has no catalog"
|
|
646
|
+
// apart from "this route is gone", which is the difference between admission proof
|
|
647
|
+
// and a vacuous pass.
|
|
648
|
+
return withCors(
|
|
649
|
+
new Response(JSON.stringify({
|
|
650
|
+
error: { type: "invalid_request_error", code: "catalog_not_found", message: "no materialized catalog is available" },
|
|
651
|
+
}), {
|
|
652
|
+
status: 404,
|
|
653
|
+
headers: { "content-type": "application/json" },
|
|
654
|
+
}),
|
|
655
|
+
req,
|
|
656
|
+
policy,
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
// Size policy belongs to this route, not the shared serializer: the management route
|
|
660
|
+
// must keep its existing behavior for a catalog of any supported size.
|
|
661
|
+
if (serialized.bytes !== undefined && serialized.bytes > MAX_REMOTE_CATALOG_BYTES) {
|
|
662
|
+
return withCors(
|
|
663
|
+
new Response(JSON.stringify({
|
|
664
|
+
error: { type: "server_error", code: "catalog_too_large", message: "catalog exceeds the maximum served size" },
|
|
665
|
+
}), {
|
|
666
|
+
status: 507,
|
|
667
|
+
headers: { "content-type": "application/json" },
|
|
668
|
+
}),
|
|
669
|
+
req,
|
|
670
|
+
policy,
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
const headers: Record<string, string> = {
|
|
674
|
+
"content-type": "application/json",
|
|
675
|
+
// Identity-varying content behind a credential: never let a shared cache keep it,
|
|
676
|
+
// and never hand out a validator it could revalidate with. `no-cache` alone does
|
|
677
|
+
// not prevent storage — it forces revalidation, and the revalidation is exactly
|
|
678
|
+
// what would cross identities here, because this body varies by key type and key
|
|
679
|
+
// id while the ETag would be derived from bytes alone. A store keyed on URL plus
|
|
680
|
+
// validator could then serve one credential's representation to another. Proving
|
|
681
|
+
// an identity-partitioned cache key across every intermediary in the path is a
|
|
682
|
+
// much larger commitment than the bandwidth a 304 saves on this payload, so this
|
|
683
|
+
// route declines the trade: no-store, no ETag, no 304.
|
|
684
|
+
//
|
|
685
|
+
// GET /api/catalog keeps its validator. That route is management-authenticated
|
|
686
|
+
// and loopback-scoped, and its representation does not vary by data-key identity.
|
|
687
|
+
"cache-control": "no-store",
|
|
688
|
+
};
|
|
689
|
+
const version = await persistedCodexVersion();
|
|
690
|
+
if (version) headers["x-opencodex-codex-version"] = version;
|
|
691
|
+
// No conditional handling: with no validator emitted, an If-None-Match on this route
|
|
692
|
+
// can only have been guessed or copied from elsewhere, and honoring it would
|
|
693
|
+
// reintroduce the cross-identity path above. Every request gets the full body.
|
|
694
|
+
if (serialized.bytes !== undefined) headers["content-length"] = String(serialized.bytes);
|
|
695
|
+
// HEAD returns identical status and headers with no body.
|
|
696
|
+
return withRemoteCatalogKeyId(
|
|
697
|
+
withCors(
|
|
698
|
+
new Response(req.method === "HEAD" ? null : serialized.body, { status: 200, headers }),
|
|
699
|
+
req,
|
|
700
|
+
policy,
|
|
701
|
+
),
|
|
702
|
+
admission,
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (url.pathname === "/v1/usage" && req.method === "GET") {
|
|
707
|
+
const { handleHubUsage } = await import("../hub-usage");
|
|
708
|
+
return handleHubUsage(req, config, policy);
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (url.pathname === "/v1/hub-state" && (req.method === "GET" || req.method === "HEAD")) {
|
|
712
|
+
// #4236: a connected client had no way to learn which providers this hub can actually
|
|
713
|
+
// serve, so `ocx status` on the client reported the CLIENT's empty credential store as
|
|
714
|
+
// if it were the truth — "xai ✗ not logged in" on a machine whose hub has xAI logged
|
|
715
|
+
// in. The fix is one least-privilege data-plane read, in the /v1/catalog (#809)
|
|
716
|
+
// tradition: same admission resolver, same origin check, no parameters, no caller
|
|
717
|
+
// credential forwarded upstream, and a body of booleans plus model ids. Widening
|
|
718
|
+
// `/api/*` or handing the client an admin token to read `GET /api/providers` would
|
|
719
|
+
// have traded a reporting defect for a credential one.
|
|
720
|
+
//
|
|
721
|
+
// What it discloses beyond /v1/catalog and /v1/models, exactly: `hasCredential`,
|
|
722
|
+
// `loggedIn`, `authMode`, the featured roster, and the NAME and adapter of an ENABLED
|
|
723
|
+
// provider those routes omit for want of a usable credential — which is the point of
|
|
724
|
+
// the route. A `disabled` provider is NOT exported (`buildHubState` drops it), because
|
|
725
|
+
// the catalog filters it out too and naming it here would be the only place a data key
|
|
726
|
+
// learns of it.
|
|
727
|
+
//
|
|
728
|
+
// Placed between /v1/catalog and /v1/models so all three least-privilege client reads
|
|
729
|
+
// stay in sight of each other.
|
|
730
|
+
const admission = resolveApiAuth(req, policy);
|
|
731
|
+
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
732
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
733
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
|
|
734
|
+
}
|
|
735
|
+
// Role gate AFTER admission, deliberately: answering an unauthenticated caller would
|
|
736
|
+
// turn this into a free "is that machine a hub?" probe. A standalone or client install
|
|
737
|
+
// gains no surface at all — the route simply does not exist there.
|
|
738
|
+
//
|
|
739
|
+
// Built, not formatErrorResponse'd, for the same reason /v1/catalog builds its 404: the
|
|
740
|
+
// code has to distinguish "this route exists and this host is not a hub" from "this
|
|
741
|
+
// build has no such route", which is the difference between admission proof and a
|
|
742
|
+
// vacuous pass in tests/server/api-key-attribution.test.ts.
|
|
743
|
+
if (config.runtimeRole !== "hub") {
|
|
744
|
+
return withCors(
|
|
745
|
+
new Response(JSON.stringify({
|
|
746
|
+
error: {
|
|
747
|
+
type: "invalid_request_error",
|
|
748
|
+
code: "hub_state_not_a_hub",
|
|
749
|
+
message: "hub state is served only by a host whose runtimeRole is hub",
|
|
750
|
+
},
|
|
751
|
+
}), { status: 404, headers: { "content-type": "application/json" } }),
|
|
752
|
+
req,
|
|
753
|
+
policy,
|
|
754
|
+
);
|
|
755
|
+
}
|
|
756
|
+
const { buildHubState } = await import("../hub-state");
|
|
757
|
+
const { MAX_HUB_STATE_BYTES } = await import("../../remote/hub-state");
|
|
758
|
+
const { oauthLoginSummary } = await import("../../oauth");
|
|
759
|
+
// `true` masks emails, but the projection drops the field entirely; passing the mask
|
|
760
|
+
// anyway means a future refactor that starts copying fields cannot leak a raw address.
|
|
761
|
+
const body = JSON.stringify(buildHubState(config, oauthLoginSummary(true), VERSION));
|
|
762
|
+
const bytes = Buffer.byteLength(body);
|
|
763
|
+
if (bytes > MAX_HUB_STATE_BYTES) {
|
|
764
|
+
return withCors(
|
|
765
|
+
new Response(JSON.stringify({
|
|
766
|
+
error: { type: "server_error", code: "hub_state_too_large", message: "hub state exceeds the maximum served size" },
|
|
767
|
+
}), { status: 507, headers: { "content-type": "application/json" } }),
|
|
768
|
+
req,
|
|
769
|
+
policy,
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
return withCors(
|
|
773
|
+
new Response(req.method === "HEAD" ? null : body, {
|
|
774
|
+
status: 200,
|
|
775
|
+
headers: {
|
|
776
|
+
"content-type": "application/json",
|
|
777
|
+
// Varies by credential-bearing identity and by live login state: never cached,
|
|
778
|
+
// and no validator to revalidate with (same rule as /v1/catalog).
|
|
779
|
+
"cache-control": "no-store",
|
|
780
|
+
"content-length": String(bytes),
|
|
781
|
+
},
|
|
782
|
+
}),
|
|
783
|
+
req,
|
|
784
|
+
policy,
|
|
785
|
+
);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
if (url.pathname === "/v1/models" && req.method === "GET") {
|
|
789
|
+
// #809: the catalog read sits immediately before model discovery because it shares
|
|
790
|
+
// that route's admission rationale exactly. Keep them adjacent so a future change to
|
|
791
|
+
// one is made in sight of the other.
|
|
792
|
+
// Model discovery never forwards Authorization upstream, so the broader admission
|
|
793
|
+
// set (Authorization / x-api-key / x-opencodex-api-key) is safe here and required by
|
|
794
|
+
// remote OpenAI-style bearer clients and Claude gateway discovery (anthropic-version).
|
|
795
|
+
const admission = resolveApiAuth(req, policy);
|
|
796
|
+
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
797
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
798
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
|
|
799
|
+
}
|
|
800
|
+
const wantsDesktopConfig = url.searchParams.get("format") === "desktop-config";
|
|
801
|
+
if (wantsDesktopConfig && (url.searchParams.get("ids") === "cli" || url.searchParams.has("client_version"))) {
|
|
802
|
+
return jsonResponse({ error: "Desktop config format cannot use CLI or client-version selectors" }, 400, req, policy);
|
|
803
|
+
}
|
|
804
|
+
// The Integrations page reports whether a Cursor client has reached this proxy; the
|
|
805
|
+
// recorder keeps only a bounded User-Agent value and a timestamp, in memory.
|
|
806
|
+
recordCursorSeen(req.headers);
|
|
807
|
+
let goModels;
|
|
808
|
+
let modelEntitlements;
|
|
809
|
+
try {
|
|
810
|
+
[goModels, modelEntitlements] = await Promise.all([
|
|
811
|
+
fetchAllModels(config),
|
|
812
|
+
// Codex sends its own client_version on this request, and upstream filters the
|
|
813
|
+
// entitlement roster by it. Passing it through is what stops an entitled account
|
|
814
|
+
// being told it cannot use models a newer client can (#2886).
|
|
815
|
+
resolveCodexModelEntitlements(config, { clientVersion: url.searchParams.get("client_version") }),
|
|
816
|
+
]);
|
|
817
|
+
} catch (error) {
|
|
818
|
+
if (error instanceof CatalogGatherBusyError) {
|
|
819
|
+
return withCors(new Response(JSON.stringify({ error: { type: "server_error", code: "catalog_busy", message: error.message } }), {
|
|
820
|
+
status: 503,
|
|
821
|
+
headers: { "content-type": "application/json", "Retry-After": "1" },
|
|
822
|
+
}), req, policy);
|
|
823
|
+
}
|
|
824
|
+
throw error;
|
|
825
|
+
}
|
|
826
|
+
const { accountBoundNativeOpenAiSlugsBySelector, applyNativeVisibility, buildCatalogEntries, configuredNativeAliasSlugs, desktopAllowlistSuppressedNativeSlugs, disabledNativeSlugs, exactComboCatalogSlugs, loadCatalogTemplate, NATIVE_OPENAI_MODELS, nativeContextLimits, nativeInputModalities, nativeOpenAiContextWindow, nativeOpenAiMaxOutputTokens, nativeOpenAiContextTier, nativeOpenAiSlugs, nativeReasoningEfforts, nativeDefaultReasoningEffort, shouldIncludeAccountBoundNativeOpenAi, shouldIncludeNativeOpenAi, uniqueCatalogModelsForRawPublicList, visibleCodexAccountSelectors, visibleNativeSlugs, desktopVisibleNativeSlugs } = await import("../../codex/catalog");
|
|
827
|
+
const { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } = await import("../../codex/catalog/native-models");
|
|
828
|
+
const includeNativeOpenAi = shouldIncludeNativeOpenAi(config);
|
|
829
|
+
const includeAccountBoundNativeOpenAi = shouldIncludeAccountBoundNativeOpenAi(config);
|
|
830
|
+
const bareEligibleAccountIds = providerCodexAccountMode(
|
|
831
|
+
OPENAI_CODEX_PROVIDER_ID,
|
|
832
|
+
config.providers[OPENAI_CODEX_PROVIDER_ID],
|
|
833
|
+
) === "direct" ? new Set([MAIN_CODEX_ACCOUNT_ID]) : undefined;
|
|
834
|
+
const availableBareGatedNativeSlugs = availableAccountGatedNativeModels(
|
|
835
|
+
modelEntitlements,
|
|
836
|
+
bareEligibleAccountIds,
|
|
837
|
+
);
|
|
838
|
+
const availableAccountGatedNativeSlugs = availableAccountGatedNativeModels(modelEntitlements);
|
|
839
|
+
const availableBareNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => (
|
|
840
|
+
!ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug)
|
|
841
|
+
));
|
|
842
|
+
const availableAccountNativeSlugs = NATIVE_OPENAI_MODELS.filter(slug => (
|
|
843
|
+
!ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableAccountGatedNativeSlugs.has(slug)
|
|
844
|
+
));
|
|
845
|
+
const nativeSlugs = includeNativeOpenAi
|
|
846
|
+
? nativeOpenAiSlugs().filter(slug => (
|
|
847
|
+
!ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug) || availableBareGatedNativeSlugs.has(slug)
|
|
848
|
+
))
|
|
849
|
+
: [];
|
|
850
|
+
const disabledNatives = disabledNativeSlugs(config);
|
|
851
|
+
const disabledModels = new Set(config.disabledModels ?? []);
|
|
852
|
+
const exactComboSlugs = exactComboCatalogSlugs(config);
|
|
853
|
+
const shadowedNativeSlugs = configuredNativeAliasSlugs(config);
|
|
854
|
+
const suppressedBareNativeSlugs = new Set([
|
|
855
|
+
...desktopAllowlistSuppressedNativeSlugs(config),
|
|
856
|
+
...[...ACCOUNT_GATED_NATIVE_OPENAI_MODELS].filter(slug => !availableBareGatedNativeSlugs.has(slug)),
|
|
857
|
+
]);
|
|
858
|
+
const accountSelectors = includeAccountBoundNativeOpenAi
|
|
859
|
+
? visibleCodexAccountSelectors(config)
|
|
860
|
+
: [];
|
|
861
|
+
const accountTargets = new Map(codexAccountNamespaceEntries(config));
|
|
862
|
+
const accountNativeSlugsBySelector = includeAccountBoundNativeOpenAi
|
|
863
|
+
? new Map([...accountBoundNativeOpenAiSlugsBySelector(config)].map(([selector, slugs]) => {
|
|
864
|
+
const target = accountTargets.get(selector);
|
|
865
|
+
const accountId = target && isMainCodexAccountTarget(target) ? MAIN_CODEX_ACCOUNT_ID : target;
|
|
866
|
+
return [selector, slugs.filter(slug => (
|
|
867
|
+
!ACCOUNT_GATED_NATIVE_OPENAI_MODELS.has(slug)
|
|
868
|
+
|| (accountId !== undefined
|
|
869
|
+
&& codexModelEntitlementStateForAccount(modelEntitlements, accountId, slug) === "granted")
|
|
870
|
+
))] as const;
|
|
871
|
+
}))
|
|
872
|
+
: new Map<string, readonly string[]>();
|
|
873
|
+
const accountNativeSlugs = [...new Set(
|
|
874
|
+
[...accountNativeSlugsBySelector.values()].flatMap(slugs => [...slugs]),
|
|
875
|
+
)];
|
|
876
|
+
const desktopInputs = buildDesktopDiscoveryInputs({
|
|
877
|
+
config, models: goModels, modelEntitlements,
|
|
878
|
+
desktopNativeCandidates: desktopVisibleNativeSlugs(config),
|
|
879
|
+
});
|
|
880
|
+
const desktopNativeSlugs = desktopInputs.nativeSlugs;
|
|
881
|
+
const goOrdered = desktopInputs.routedModels;
|
|
882
|
+
// Claude Code / Claude Desktop gateway model discovery (GET /v1/models with
|
|
883
|
+
// Anthropic-style headers; 003 G1-G8 + devlog 131). Entries use the official
|
|
884
|
+
// ModelInfo shape incl. capabilities (effort ladder / thinking) — Desktop 3P can
|
|
885
|
+
// only learn capabilities through discovery, and Claude Code 2.1.207 strips the
|
|
886
|
+
// extra fields (backward-safe). Ids are the claude-opus-4-8-{code} Desktop
|
|
887
|
+
// aliases; legacy claude-ocx-* ids keep decoding via resolveAlias. Detection:
|
|
888
|
+
// anthropic-version header (Claude Code sends it) or explicit ?flavor=anthropic.
|
|
889
|
+
// Codex catalog (client_version) and the OpenAI list shape below stay byte-identical.
|
|
890
|
+
const wantsAnthropicList = wantsDesktopConfig || req.headers.get("anthropic-version") !== null
|
|
891
|
+
|| url.searchParams.get("flavor") === "anthropic";
|
|
892
|
+
/**
|
|
893
|
+
* Whether a NATIVE slug may carry a Fast sibling.
|
|
894
|
+
*
|
|
895
|
+
* Both halves are required. Upstream asserts the tier per model — the same
|
|
896
|
+
* `additional_speed_tiers` the Codex picker's own toggle is built from — but an
|
|
897
|
+
* operator capability override or the final wire resolution can still make the
|
|
898
|
+
* route ineligible, and `decideTier` would then drop the tier the row advertised.
|
|
899
|
+
*
|
|
900
|
+
* Declared here, above the Claude discovery call, because that call reads it while
|
|
901
|
+
* the raw OpenAI mapper further down does too; defining it there would leave this
|
|
902
|
+
* use in its temporal dead zone.
|
|
903
|
+
*/
|
|
904
|
+
const nativeFastEligible = (metadataId: string): boolean =>
|
|
905
|
+
catalogFastRowEligible(config, { provider: OPENAI_CODEX_PROVIDER_ID, id: metadataId, native: true });
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Whether a routed catalog row may carry a Fast sibling.
|
|
909
|
+
*
|
|
910
|
+
* A combo is its own namespace with no `config.providers` entry — declaring a
|
|
911
|
+
* provider named `combo` is rejected (combos/types.ts:191) — so provider lookup
|
|
912
|
+
* cannot classify it. Its aggregated `supportsServiceTier` is already true only
|
|
913
|
+
* when EVERY member supports the tier (aggregation.ts:201), which is the right
|
|
914
|
+
* rule for a row that fans out to all of them.
|
|
915
|
+
*
|
|
916
|
+
* Declared beside nativeFastEligible, above the Claude discovery call that reads
|
|
917
|
+
* both; defining it near the raw OpenAI mapper below would leave that use in its
|
|
918
|
+
* temporal dead zone.
|
|
919
|
+
*/
|
|
920
|
+
const catalogRowFastEligible = (m: { provider: string; id: string; supportsServiceTier?: boolean }): boolean =>
|
|
921
|
+
catalogFastRowEligible(config, m);
|
|
922
|
+
|
|
923
|
+
if (wantsAnthropicList && !url.searchParams.has("client_version")) {
|
|
924
|
+
if (wantsDesktopConfig) {
|
|
925
|
+
const models = config.claudeCode?.enabled === false ? [] : generateDesktop3pModels(
|
|
926
|
+
desktopInputs.nativeSlugs, desktopInputs.routedModels,
|
|
927
|
+
config.claudeCode?.desktopProfile, desktopInputs.nativeContextCap,
|
|
928
|
+
);
|
|
929
|
+
const response = jsonResponse({ version: 1, models }, 200, req, policy);
|
|
930
|
+
response.headers.set("Cache-Control", "no-store");
|
|
931
|
+
return response;
|
|
932
|
+
}
|
|
933
|
+
if (config.claudeCode?.enabled === false) return jsonResponse({ data: [] }, 200, req, policy);
|
|
934
|
+
// Build Desktop 3P registry so inbound alias resolution works for subsequent requests.
|
|
935
|
+
buildDesktop3pRegistry(
|
|
936
|
+
desktopNativeSlugs,
|
|
937
|
+
desktopInputs.routedModels,
|
|
938
|
+
config.claudeCode?.desktopProfile,
|
|
939
|
+
desktopInputs.nativeContextCap,
|
|
940
|
+
);
|
|
941
|
+
const { buildAnthropicModelInfos } = await import("../../claude/model-info");
|
|
942
|
+
const { resolveAutoContext } = await import("../../claude/context-windows");
|
|
943
|
+
const { activeDesktop3pAlias } = await import("../../claude/desktop-3p");
|
|
944
|
+
// Per-surface id family (devlog 050): explicit ?ids= wins; otherwise the
|
|
945
|
+
// Claude Code CLI discovery UA (`claude-code/<version>`, binary n_()) gets
|
|
946
|
+
// readable claude-ocx ids and every other client (Desktop 3P) keeps the
|
|
947
|
+
// hashed family its config was written with. Unknown UA -> hashed (safe).
|
|
948
|
+
const idsParam = url.searchParams.get("ids");
|
|
949
|
+
const idStyle = idsParam === "cli"
|
|
950
|
+
? "readable" as const
|
|
951
|
+
: idsParam === "desktop"
|
|
952
|
+
? "desktop3p" as const
|
|
953
|
+
: (/^claude-code\//i.test(req.headers.get("user-agent") ?? "") ? "readable" as const : "desktop3p" as const);
|
|
954
|
+
const data = buildAnthropicModelInfos(
|
|
955
|
+
desktopNativeSlugs,
|
|
956
|
+
goOrdered,
|
|
957
|
+
resolveAutoContext(config.claudeCode),
|
|
958
|
+
idStyle,
|
|
959
|
+
activeDesktop3pAlias,
|
|
960
|
+
desktopInputs.nativeContextCap,
|
|
961
|
+
config.fastMode,
|
|
962
|
+
// Explicit opt-out omits the Fast predicate.
|
|
963
|
+
config.fastRows !== false
|
|
964
|
+
? (model: { provider: string; id: string; supportsServiceTier?: boolean }) =>
|
|
965
|
+
model.provider === "native"
|
|
966
|
+
? nativeFastEligible(model.id)
|
|
967
|
+
: catalogRowFastEligible(model)
|
|
968
|
+
: undefined,
|
|
969
|
+
{ modelPickerOrder: config.modelPickerOrder, featured: config.subagentModels },
|
|
970
|
+
);
|
|
971
|
+
return jsonResponse({ data }, 200, req, policy);
|
|
972
|
+
}
|
|
973
|
+
if (url.searchParams.has("client_version")) {
|
|
974
|
+
// Codex client → Codex catalog shape: native gpt + namespaced routed models,
|
|
975
|
+
// cloned from a native template so required fields (base_instructions, etc.) are present.
|
|
976
|
+
// Pass the subagent picks so featured models lead by priority (matches the on-disk file).
|
|
977
|
+
// Disabled natives stay in the catalog shape with visibility "hide" (mirrors the
|
|
978
|
+
// on-disk sync; codex-rs keeps them out of the picker itself).
|
|
979
|
+
const maMode = config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default";
|
|
980
|
+
// Account rows use the same hidden-inclusive supported set as on-disk sync. This lets a
|
|
981
|
+
// newly re-enabled native reappear under each selector before the next sync, while the
|
|
982
|
+
// no-selector path keeps nativeOpenAiSlugs()'s existing visibility-sensitive behavior.
|
|
983
|
+
const catalogNativeSlugs = accountSelectors.length > 0
|
|
984
|
+
? [...new Set([
|
|
985
|
+
...availableAccountNativeSlugs,
|
|
986
|
+
...accountNativeSlugs,
|
|
987
|
+
])]
|
|
988
|
+
: nativeSlugs;
|
|
989
|
+
const entries = buildCatalogEntries(
|
|
990
|
+
loadCatalogTemplate(),
|
|
991
|
+
catalogNativeSlugs,
|
|
992
|
+
goOrdered,
|
|
993
|
+
config.subagentModels,
|
|
994
|
+
websocketsEnabled(config),
|
|
995
|
+
maMode as "v1" | "default" | "v2",
|
|
996
|
+
exactComboSlugs,
|
|
997
|
+
accountSelectors,
|
|
998
|
+
suppressedBareNativeSlugs,
|
|
999
|
+
new Set(),
|
|
1000
|
+
nativeContextLimits(config),
|
|
1001
|
+
accountNativeSlugs,
|
|
1002
|
+
accountNativeSlugsBySelector,
|
|
1003
|
+
config.keepNativeChatGptOnV1 === true,
|
|
1004
|
+
config.modelPickerOrder,
|
|
1005
|
+
);
|
|
1006
|
+
return jsonResponse({
|
|
1007
|
+
models: applyNativeVisibility(
|
|
1008
|
+
entries,
|
|
1009
|
+
disabledModels,
|
|
1010
|
+
accountSelectors.length > 0,
|
|
1011
|
+
new Set(accountNativeSlugs),
|
|
1012
|
+
),
|
|
1013
|
+
}, 200, req, policy);
|
|
1014
|
+
}
|
|
1015
|
+
// OpenAI list shape: native gpt bare + routed models namespaced "<provider>/<id>"
|
|
1016
|
+
// (pure availability list — disabled natives are omitted entirely).
|
|
1017
|
+
// Grok Build discovers models through this endpoint too, and its model picker only
|
|
1018
|
+
// enables /effort for entries that advertise the reasoning ladder in the Grok model
|
|
1019
|
+
// catalog shape (supports_reasoning_effort + reasoning_efforts[]). The Codex catalog
|
|
1020
|
+
// branch above already carries the same ladders, so mirror them here — native rows
|
|
1021
|
+
// from the upstream snapshot, routed rows from the configured provider tiers. The
|
|
1022
|
+
// default uses the same canonical fallback as the Codex catalog resolver
|
|
1023
|
+
// (configured default, then medium, then high, then the first tier). Extra fields
|
|
1024
|
+
// are ignored by plain OpenAI clients.
|
|
1025
|
+
const grokEffortOption = (value: string, isDefault: boolean) => ({
|
|
1026
|
+
value,
|
|
1027
|
+
label: `${value[0].toUpperCase()}${value.slice(1)} Effort`,
|
|
1028
|
+
...(isDefault ? { default: true } : {}),
|
|
1029
|
+
});
|
|
1030
|
+
const grokEffortFields = (efforts: string[], configuredDefault?: string) => {
|
|
1031
|
+
const defaultEffort = grokDefaultReasoningEffort(efforts, configuredDefault);
|
|
1032
|
+
if (defaultEffort === undefined) return {};
|
|
1033
|
+
return {
|
|
1034
|
+
supports_reasoning_effort: true,
|
|
1035
|
+
reasoning_effort: defaultEffort,
|
|
1036
|
+
reasoning_efforts: efforts.map(effort => grokEffortOption(effort, effort === defaultEffort)),
|
|
1037
|
+
};
|
|
1038
|
+
};
|
|
1039
|
+
// Cursor's local-agent runtime (Private Inference build) reads api_types + capabilities
|
|
1040
|
+
// to enable its effort control; every other consumer ignores them. See
|
|
1041
|
+
// src/server/models-capabilities.ts.
|
|
1042
|
+
const nativeLimits = nativeContextLimits(config);
|
|
1043
|
+
const nativeContextInput = (metadataId: string) => {
|
|
1044
|
+
const tier = nativeOpenAiContextTier(metadataId, nativeLimits);
|
|
1045
|
+
return tier
|
|
1046
|
+
? { contextWindow: tier.defaultWindow, longContextWindow: tier.longWindow }
|
|
1047
|
+
: { contextWindow: nativeOpenAiContextWindow(metadataId, nativeLimits) };
|
|
1048
|
+
};
|
|
1049
|
+
const nativeModelRow = (id: string, metadataId = id) => ({
|
|
1050
|
+
id,
|
|
1051
|
+
object: "model",
|
|
1052
|
+
created: 0,
|
|
1053
|
+
owned_by: "openai",
|
|
1054
|
+
...grokEffortFields(
|
|
1055
|
+
nativeReasoningEfforts(metadataId),
|
|
1056
|
+
nativeDefaultReasoningEffort(metadataId),
|
|
1057
|
+
),
|
|
1058
|
+
...modelCapabilityFields({
|
|
1059
|
+
reasoningEfforts: nativeReasoningEfforts(metadataId),
|
|
1060
|
+
// Cursor "Max Mode": advertise the family's default/long pair (272k/922k for
|
|
1061
|
+
// GPT-5.6) so the client can pick per request; without a tier, the effective
|
|
1062
|
+
// window is the only value.
|
|
1063
|
+
...nativeContextInput(metadataId),
|
|
1064
|
+
maxOutputTokens: nativeOpenAiMaxOutputTokens(metadataId),
|
|
1065
|
+
inputModalities: nativeInputModalities(metadataId),
|
|
1066
|
+
}),
|
|
1067
|
+
});
|
|
1068
|
+
// Resolved once per request, not per model: the global fast switch offers the fast
|
|
1069
|
+
// identity to clients that have no Fast toggle of their own. Null when the switch is
|
|
1070
|
+
// off, so the row mapper does no work and loads no adapter module.
|
|
1071
|
+
const cursorFastIdForListing = config.fastMode === true
|
|
1072
|
+
? await (async () => {
|
|
1073
|
+
const { cursorFastIdFor } = await import("../../adapters/cursor/catalog");
|
|
1074
|
+
return (modelId: string, provider = "cursor") => provider === "cursor" ? cursorFastIdFor(modelId) : undefined;
|
|
1075
|
+
})()
|
|
1076
|
+
: null;
|
|
1077
|
+
// Selector-active discovery follows the same complete supported set as the Codex catalog
|
|
1078
|
+
// for both bare and qualified rows. Without selectors, the live catalog continues to own
|
|
1079
|
+
// bare availability.
|
|
1080
|
+
const selectorNativeSlugs = accountSelectors.length > 0
|
|
1081
|
+
? availableBareNativeSlugs.filter(slug => !disabledNatives.has(slug))
|
|
1082
|
+
: [];
|
|
1083
|
+
const bareSelectorNativeSlugs = accountSelectors.length > 0
|
|
1084
|
+
? selectorNativeSlugs
|
|
1085
|
+
: [];
|
|
1086
|
+
const visibleNatives = includeNativeOpenAi
|
|
1087
|
+
? accountSelectors.length > 0
|
|
1088
|
+
? bareSelectorNativeSlugs.filter(slug => !shadowedNativeSlugs.has(slug))
|
|
1089
|
+
: visibleNativeSlugs(config)
|
|
1090
|
+
: [];
|
|
1091
|
+
const visibleAccountNatives = accountSelectors.flatMap(selector =>
|
|
1092
|
+
(accountNativeSlugsBySelector.get(selector) ?? []).filter(metadataId => !disabledNatives.has(metadataId)).flatMap(metadataId => {
|
|
1093
|
+
const id = `${selector}/${metadataId}`;
|
|
1094
|
+
return disabledModels.has(id) ? [] : [{ id, metadataId }];
|
|
1095
|
+
})
|
|
1096
|
+
);
|
|
1097
|
+
// The projection is opt-in. Keep the default path free of Cursor install detection,
|
|
1098
|
+
// and resolve the bundle table once for the whole list rather than once per row.
|
|
1099
|
+
const effortRowsEnabled = config.cursorEffortRows === true;
|
|
1100
|
+
// Explicit opt-out skips policy resolution and additional rows.
|
|
1101
|
+
const fastRowsEnabled = config.fastRows !== false;
|
|
1102
|
+
// One inventory serves both grammars; building it twice would double the work on a
|
|
1103
|
+
// hot path for no benefit.
|
|
1104
|
+
const effortRowKnownIds = effortRowsEnabled || fastRowsEnabled
|
|
1105
|
+
? knownEffortRowIds(config)
|
|
1106
|
+
: undefined;
|
|
1107
|
+
const privateInference = effortRowsEnabled
|
|
1108
|
+
? detectCursorInstalls().find(install => install.build === "private-inference")
|
|
1109
|
+
: undefined;
|
|
1110
|
+
const cursorEffortTable = effortRowsEnabled
|
|
1111
|
+
? (deps.managementApi?.loadCursorEffortTable ?? loadCursorEffortTable)(privateInference)
|
|
1112
|
+
: null;
|
|
1113
|
+
const expandedNativeModelRow = (id: string, metadataId = id) => {
|
|
1114
|
+
const reasoningEfforts = nativeReasoningEfforts(metadataId);
|
|
1115
|
+
return expandCursorEffortRow(nativeModelRow(id, metadataId), reasoningEfforts, config, {
|
|
1116
|
+
knownIds: effortRowKnownIds,
|
|
1117
|
+
table: cursorEffortTable,
|
|
1118
|
+
supportsReasoning: reasoningEfforts.length > 0,
|
|
1119
|
+
}).flatMap(row => expandFastRow(
|
|
1120
|
+
row,
|
|
1121
|
+
// Only the BASE row earns a fast sibling. An effort row already spent the
|
|
1122
|
+
// grammar, and the parser requires the stripped base to be routable, so
|
|
1123
|
+
// `<base>--<effort>--fast` would publish a row no ingress can resolve.
|
|
1124
|
+
row.id === id && nativeFastEligible(metadataId),
|
|
1125
|
+
config,
|
|
1126
|
+
effortRowKnownIds,
|
|
1127
|
+
));
|
|
1128
|
+
};
|
|
1129
|
+
const routedRows = await Promise.all(uniqueCatalogModelsForRawPublicList(goOrdered).map(async m => {
|
|
1130
|
+
// Same rule as the anthropic branch: with the global fast switch on, a client
|
|
1131
|
+
// that has no Fast toggle is offered the fast identity directly. An operator
|
|
1132
|
+
// alias is an explicit decision and still wins.
|
|
1133
|
+
const fastModelId = cursorFastIdForListing?.(m.id, m.provider);
|
|
1134
|
+
const publicId = m.alias ?? `${m.provider}/${fastModelId ?? m.id}`;
|
|
1135
|
+
const isCombo = m.provider === "combo" && exactComboSlugs.has(publicId);
|
|
1136
|
+
const provider = config.providers[m.provider];
|
|
1137
|
+
const effective = provider
|
|
1138
|
+
? (await import("../../providers/default-aliases")).effectiveModelAliases(
|
|
1139
|
+
config,
|
|
1140
|
+
provider,
|
|
1141
|
+
knownModelIdsForProvider(m.provider, provider, config),
|
|
1142
|
+
).get(m.id)
|
|
1143
|
+
: undefined;
|
|
1144
|
+
const row = {
|
|
1145
|
+
id: publicId,
|
|
1146
|
+
object: "model",
|
|
1147
|
+
created: 0,
|
|
1148
|
+
// This endpoint is an OpenAI-compatible inbound contract. Some clients use
|
|
1149
|
+
// owned_by as an adapter selector, so a virtual combo must name that wire
|
|
1150
|
+
// adapter rather than the internal catalog authority marker.
|
|
1151
|
+
owned_by: isCombo ? "openai" : (m.owned_by ?? m.provider),
|
|
1152
|
+
...(isCombo ? { is_combo: true } : {}),
|
|
1153
|
+
...(effective ? { alias_of: `${provider?.alias || m.provider}/${effective.alias}` } : {}),
|
|
1154
|
+
...grokEffortFields(m.reasoningEfforts ?? [], m.defaultReasoningEffort),
|
|
1155
|
+
...modelCapabilityFields({
|
|
1156
|
+
reasoningEfforts: m.reasoningEfforts,
|
|
1157
|
+
// contextWindow is already the post-cap effective value; contextCap is the raw
|
|
1158
|
+
// operator knob and over-reports models whose real window sits below it.
|
|
1159
|
+
contextWindow: m.contextWindow,
|
|
1160
|
+
maxOutputTokens: m.maxOutputTokens,
|
|
1161
|
+
inputModalities: m.inputModalities,
|
|
1162
|
+
}),
|
|
1163
|
+
};
|
|
1164
|
+
return expandCursorEffortRow(row, m.reasoningEfforts, config, {
|
|
1165
|
+
knownIds: effortRowKnownIds,
|
|
1166
|
+
table: cursorEffortTable,
|
|
1167
|
+
supportsReasoning: (m.reasoningEfforts ?? []).length > 0,
|
|
1168
|
+
}).flatMap(expanded => expandFastRow(
|
|
1169
|
+
expanded,
|
|
1170
|
+
expanded.id === row.id && catalogRowFastEligible(m),
|
|
1171
|
+
config,
|
|
1172
|
+
effortRowKnownIds,
|
|
1173
|
+
));
|
|
1174
|
+
}));
|
|
1175
|
+
const data = [
|
|
1176
|
+
...visibleNatives.flatMap(id => expandedNativeModelRow(id)),
|
|
1177
|
+
...visibleAccountNatives.flatMap(({ id, metadataId }) => expandedNativeModelRow(id, metadataId)),
|
|
1178
|
+
...routedRows.flat(),
|
|
1179
|
+
];
|
|
1180
|
+
return jsonResponse({ object: "list", data }, 200, req, policy);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// Remote compaction v1 (codex-rs with Feature::RemoteCompactionV2 off — the default).
|
|
1184
|
+
// Must be matched BEFORE the /v1/responses POST branch never sees it (distinct path) and
|
|
1185
|
+
// before the /v1/* 404 guard below.
|
|
1186
|
+
if (url.pathname === "/v1/responses/compact" && req.method === "POST") {
|
|
1187
|
+
if (isDraining()) {
|
|
1188
|
+
return drainingResponse(req, policy);
|
|
1189
|
+
}
|
|
1190
|
+
const admission = resolveResponsesApiAuth(req, policy);
|
|
1191
|
+
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
1192
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
1193
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
|
|
1194
|
+
}
|
|
1195
|
+
const start = Date.now();
|
|
1196
|
+
const requestId = nextRequestLogId(start);
|
|
1197
|
+
const logCtx: RequestLogContext = {
|
|
1198
|
+
model: "unknown",
|
|
1199
|
+
provider: "unknown",
|
|
1200
|
+
...admissionFields(admission),
|
|
1201
|
+
inboundProtocol: "responses",
|
|
1202
|
+
};
|
|
1203
|
+
return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
|
|
1204
|
+
let response: Response;
|
|
1205
|
+
try {
|
|
1206
|
+
response = await handleResponsesCompact(req, config, logCtx, turnAdmissionLease, admission, {
|
|
1207
|
+
onRequestBodyRead: () => disableResponsesRequestTimeout(req, requestServer),
|
|
1208
|
+
});
|
|
1209
|
+
} catch {
|
|
1210
|
+
response = formatErrorResponse(500, "server_error", "Unexpected compact request failure");
|
|
1211
|
+
}
|
|
1212
|
+
addFinalRequestLog(requestId, start, logCtx, response.status,
|
|
1213
|
+
response.status === 499 ? { closeReason: "client_cancel" } : undefined);
|
|
1214
|
+
return withCors(response, req, policy);
|
|
1215
|
+
}, { requestId, start, logCtx });
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
if (
|
|
1219
|
+
req.method === "POST"
|
|
1220
|
+
&& (url.pathname === "/v1/images/generations" || url.pathname === "/v1/images/edits")
|
|
1221
|
+
) {
|
|
1222
|
+
disableResponsesRequestTimeout(req, requestServer);
|
|
1223
|
+
if (isDraining()) {
|
|
1224
|
+
return drainingResponse(req, policy);
|
|
1225
|
+
}
|
|
1226
|
+
const admission = resolveApiAuth(req, policy);
|
|
1227
|
+
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
1228
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
1229
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
|
|
1230
|
+
}
|
|
1231
|
+
const start = Date.now();
|
|
1232
|
+
const requestId = nextRequestLogId(start);
|
|
1233
|
+
const logCtx: RequestLogContext = {
|
|
1234
|
+
model: "image_gen",
|
|
1235
|
+
provider: "unknown",
|
|
1236
|
+
...admissionFields(admission),
|
|
1237
|
+
};
|
|
1238
|
+
const endpoint = url.pathname.endsWith("/edits") ? "edits" as const : "generations" as const;
|
|
1239
|
+
return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
|
|
1240
|
+
const response = await handleImages(req, config, endpoint, logCtx, turnAdmissionLease);
|
|
1241
|
+
addFinalRequestLog(requestId, start, logCtx, response.status, response.status === 499 ? { closeReason: "client_cancel" } : undefined);
|
|
1242
|
+
return withCors(response, req, policy);
|
|
1243
|
+
}, { requestId, start, logCtx });
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
if (req.method === "GET" && url.pathname.startsWith("/v1/opencodex/artifacts/")) {
|
|
1247
|
+
const admission = resolveApiAuth(req, policy);
|
|
1248
|
+
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
1249
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
1250
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
|
|
1251
|
+
}
|
|
1252
|
+
const id = decodeURIComponent(url.pathname.slice("/v1/opencodex/artifacts/".length));
|
|
1253
|
+
const { resolveArtifactPath } = await import("../../images/artifacts");
|
|
1254
|
+
const artifactPath = resolveArtifactPath(id);
|
|
1255
|
+
if (!artifactPath) {
|
|
1256
|
+
return withCors(formatErrorResponse(404, "not_found", "artifact not found"), req, policy);
|
|
1257
|
+
}
|
|
1258
|
+
const file = Bun.file(artifactPath);
|
|
1259
|
+
const ext = artifactPath.split(".").pop()?.toLowerCase();
|
|
1260
|
+
const contentType =
|
|
1261
|
+
ext === "png" ? "image/png"
|
|
1262
|
+
: ext === "jpg" || ext === "jpeg" ? "image/jpeg"
|
|
1263
|
+
: ext === "webp" ? "image/webp"
|
|
1264
|
+
: ext === "gif" ? "image/gif"
|
|
1265
|
+
: "application/octet-stream";
|
|
1266
|
+
return withCors(new Response(file, {
|
|
1267
|
+
status: 200,
|
|
1268
|
+
headers: {
|
|
1269
|
+
"content-type": contentType,
|
|
1270
|
+
"cache-control": "private, max-age=3600",
|
|
1271
|
+
"x-content-type-options": "nosniff",
|
|
1272
|
+
},
|
|
1273
|
+
}), req, policy);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
if (contextEndpoint(url.pathname) !== undefined && req.method === "POST" && contextRelayActivated()) {
|
|
1277
|
+
// No timeout disable here. The relay is a bounded JSON round trip that owns one deadline
|
|
1278
|
+
// from entry; removing the idle timeout first would let an unfinished body hold an
|
|
1279
|
+
// admitted turn slot indefinitely, before that deadline ever starts.
|
|
1280
|
+
if (isDraining()) {
|
|
1281
|
+
return drainingResponse(req, policy);
|
|
1282
|
+
}
|
|
1283
|
+
const admission = resolveApiAuth(req, policy);
|
|
1284
|
+
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
1285
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
1286
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
|
|
1287
|
+
}
|
|
1288
|
+
const start = Date.now();
|
|
1289
|
+
const requestId = nextRequestLogId(start);
|
|
1290
|
+
const logCtx: RequestLogContext = {
|
|
1291
|
+
model: "context_history",
|
|
1292
|
+
provider: "unknown",
|
|
1293
|
+
...admissionFields(admission),
|
|
1294
|
+
};
|
|
1295
|
+
return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
|
|
1296
|
+
const response = await handleContextHistory(req, config, logCtx, contextEndpoint(url.pathname)!,
|
|
1297
|
+
turnAdmissionLease, admission, () => resolveApiAuth(req, policy));
|
|
1298
|
+
addFinalRequestLog(requestId, start, logCtx, response.status,
|
|
1299
|
+
response.status === 499 ? { closeReason: "client_cancel" } : undefined);
|
|
1300
|
+
return withCors(response, req, policy);
|
|
1301
|
+
}, { requestId, start, logCtx });
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
if (url.pathname === "/v1/alpha/search" && req.method === "POST") {
|
|
1305
|
+
disableResponsesRequestTimeout(req, requestServer);
|
|
1306
|
+
if (isDraining()) {
|
|
1307
|
+
return drainingResponse(req, policy);
|
|
1308
|
+
}
|
|
1309
|
+
const admission = resolveApiAuth(req, policy);
|
|
1310
|
+
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
1311
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
1312
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
|
|
1313
|
+
}
|
|
1314
|
+
const start = Date.now();
|
|
1315
|
+
const requestId = nextRequestLogId(start);
|
|
1316
|
+
const logCtx: RequestLogContext = {
|
|
1317
|
+
model: "web_search",
|
|
1318
|
+
provider: "unknown",
|
|
1319
|
+
...admissionFields(admission),
|
|
1320
|
+
};
|
|
1321
|
+
return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
|
|
1322
|
+
const response = await handleSearch(req, config, logCtx, turnAdmissionLease, admission);
|
|
1323
|
+
addFinalRequestLog(requestId, start, logCtx, response.status,
|
|
1324
|
+
response.status === 499 ? { closeReason: "client_cancel" } : undefined);
|
|
1325
|
+
return withCors(response, req, policy);
|
|
1326
|
+
}, { requestId, start, logCtx });
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
if (url.pathname === "/v1/responses" && req.method === "POST") {
|
|
1330
|
+
if (isDraining()) {
|
|
1331
|
+
return drainingResponse(req, policy);
|
|
1332
|
+
}
|
|
1333
|
+
const admission = resolveResponsesApiAuth(req, policy);
|
|
1334
|
+
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
1335
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
1336
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
|
|
1337
|
+
}
|
|
1338
|
+
const start = Date.now();
|
|
1339
|
+
const requestId = nextRequestLogId(start);
|
|
1340
|
+
const logCtx: RequestLogContext = {
|
|
1341
|
+
model: "unknown",
|
|
1342
|
+
provider: "unknown",
|
|
1343
|
+
...admissionFields(admission),
|
|
1344
|
+
inboundProtocol: "responses",
|
|
1345
|
+
};
|
|
1346
|
+
if (req.headers.get("x-opencodex-grok") === "1") logCtx.surface = "grok";
|
|
1347
|
+
let logged = false;
|
|
1348
|
+
const finalizeNativePassthroughLog = (
|
|
1349
|
+
status: number,
|
|
1350
|
+
meta: { terminalStatus?: ResponsesTerminalStatus; closeReason: "terminal" | "client_cancel" },
|
|
1351
|
+
) => {
|
|
1352
|
+
if (logged) return;
|
|
1353
|
+
logged = true;
|
|
1354
|
+
addFinalRequestLog(requestId, start, logCtx, status, meta);
|
|
1355
|
+
};
|
|
1356
|
+
return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
|
|
1357
|
+
const response = await handleResponses(req, config, logCtx, {
|
|
1358
|
+
turnAdmissionLease,
|
|
1359
|
+
admission,
|
|
1360
|
+
onRequestBodyRead: () => disableResponsesRequestTimeout(req, requestServer),
|
|
1361
|
+
abortSignal: req.signal,
|
|
1362
|
+
onFirstOutput: () => recordFirstOutput(logCtx, start),
|
|
1363
|
+
onNativePassthroughTerminal: status => {
|
|
1364
|
+
finalizeNativePassthroughLog(httpStatusForRequestLogTerminal(status, logCtx), {
|
|
1365
|
+
terminalStatus: status,
|
|
1366
|
+
closeReason: "terminal",
|
|
1367
|
+
});
|
|
1368
|
+
},
|
|
1369
|
+
onNativePassthroughCancel: () => {
|
|
1370
|
+
finalizeNativePassthroughLog(499, { closeReason: "client_cancel" });
|
|
1371
|
+
},
|
|
1372
|
+
});
|
|
1373
|
+
return withRequestLogId(
|
|
1374
|
+
withCors(responseWithDeferredRequestLog(response, requestId, start, logCtx), req, policy),
|
|
1375
|
+
requestId,
|
|
1376
|
+
);
|
|
1377
|
+
}, { requestId, start, logCtx });
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// Anthropic Messages inbound (Claude Code). count_tokens FIRST (longer path).
|
|
1381
|
+
// Claude Code posts `/v1/messages?beta=true` — pathname match ignores the query (003 G9).
|
|
1382
|
+
if (url.pathname === "/v1/messages/count_tokens" && req.method === "POST") {
|
|
1383
|
+
if (isDraining()) {
|
|
1384
|
+
return drainingResponse(req, policy);
|
|
1385
|
+
}
|
|
1386
|
+
const admission = resolveApiAuth(req, policy);
|
|
1387
|
+
if (!admission) {
|
|
1388
|
+
return withCors(anthropicErrorResponse(401, "opencodex API key required", "authentication_error"), req, policy);
|
|
1389
|
+
}
|
|
1390
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
1391
|
+
return withCors(anthropicErrorResponse(403, "cross-origin data-plane request blocked", "permission_error"), req, policy);
|
|
1392
|
+
}
|
|
1393
|
+
return runAdmittedHttpTurn(req, policy, async () => withCors(
|
|
1394
|
+
await handleClaudeCountTokens(req, config, policy),
|
|
1395
|
+
req,
|
|
1396
|
+
policy,
|
|
1397
|
+
));
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
if (url.pathname === "/v1/messages" && req.method === "POST") {
|
|
1401
|
+
disableResponsesRequestTimeout(req, requestServer);
|
|
1402
|
+
if (isDraining()) {
|
|
1403
|
+
return drainingResponse(req, policy);
|
|
1404
|
+
}
|
|
1405
|
+
const admission = resolveApiAuth(req, policy);
|
|
1406
|
+
if (!admission) {
|
|
1407
|
+
return withCors(anthropicErrorResponse(401, "opencodex API key required", "authentication_error"), req, policy);
|
|
1408
|
+
}
|
|
1409
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
1410
|
+
return withCors(anthropicErrorResponse(403, "cross-origin data-plane request blocked", "permission_error"), req, policy);
|
|
1411
|
+
}
|
|
1412
|
+
const start = Date.now();
|
|
1413
|
+
const requestId = nextRequestLogId(start);
|
|
1414
|
+
const logCtx: RequestLogContext = {
|
|
1415
|
+
model: "unknown",
|
|
1416
|
+
provider: "unknown",
|
|
1417
|
+
...admissionFields(admission),
|
|
1418
|
+
inboundProtocol: "messages",
|
|
1419
|
+
};
|
|
1420
|
+
// Logging is finalized inside handleClaudeMessages (Responses-vocab tap on the
|
|
1421
|
+
// pre-translation stream + native passthrough callbacks) — do not re-wrap the
|
|
1422
|
+
// translated Anthropic stream here.
|
|
1423
|
+
return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => withCors(
|
|
1424
|
+
await handleClaudeMessages(req, config, logCtx, { requestId, start, turnAdmissionLease, admission }, policy),
|
|
1425
|
+
req,
|
|
1426
|
+
policy,
|
|
1427
|
+
), { requestId, start, logCtx });
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// OpenAI Chat Completions inbound (GitHub Copilot App / OpenAI-compatible clients).
|
|
1431
|
+
if (url.pathname === "/v1/chat/completions" && req.method === "POST") {
|
|
1432
|
+
disableResponsesRequestTimeout(req, requestServer);
|
|
1433
|
+
if (isDraining()) {
|
|
1434
|
+
return drainingResponse(req, policy);
|
|
1435
|
+
}
|
|
1436
|
+
const admission = resolveResponsesApiAuth(req, policy);
|
|
1437
|
+
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
1438
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
1439
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
|
|
1440
|
+
}
|
|
1441
|
+
const start = Date.now();
|
|
1442
|
+
const requestId = nextRequestLogId(start);
|
|
1443
|
+
const logCtx: RequestLogContext = {
|
|
1444
|
+
model: "unknown",
|
|
1445
|
+
provider: "unknown",
|
|
1446
|
+
...admissionFields(admission),
|
|
1447
|
+
inboundProtocol: "chat",
|
|
1448
|
+
};
|
|
1449
|
+
// `policy`, not `config`: this route is now served on the unauthenticated loopback
|
|
1450
|
+
// listener too (#4236), and only the receiving listener's view produces CORS headers
|
|
1451
|
+
// that match the admission decision made above.
|
|
1452
|
+
return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => withCors(
|
|
1453
|
+
await handleChatCompletions(req, config, logCtx, { requestId, start, turnAdmissionLease, admission }),
|
|
1454
|
+
req,
|
|
1455
|
+
policy,
|
|
1456
|
+
), { requestId, start, logCtx });
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
if (url.pathname === "/v1/audio/transcriptions" && req.method === "POST") {
|
|
1460
|
+
disableResponsesRequestTimeout(req, requestServer);
|
|
1461
|
+
if (isDraining()) return drainingResponse(req, policy);
|
|
1462
|
+
const admission = resolveAudioAdmission(req.headers, config);
|
|
1463
|
+
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
1464
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
1465
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin audio request blocked"), req, policy);
|
|
1466
|
+
}
|
|
1467
|
+
const start = Date.now();
|
|
1468
|
+
const requestId = nextRequestLogId(start);
|
|
1469
|
+
const logCtx: RequestLogContext = { model: TRANSCRIPTION_MODEL, provider: "unknown", ...admissionFields(admission) };
|
|
1470
|
+
return runAdmittedHttpTurn(req, policy, async lease => {
|
|
1471
|
+
const response = await handleAudioTranscriptions(req, config, logCtx, admission, lease);
|
|
1472
|
+
addFinalRequestLog(requestId, start, logCtx, response.status);
|
|
1473
|
+
return withCors(response, req, policy);
|
|
1474
|
+
}, { requestId, start, logCtx });
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// ChatGPT / Codex App voice (GPT‑Live / Frameless Bidi) + OpenAI Realtime call-create.
|
|
1478
|
+
// Clients hit either /v1/live (Frameless App) or /v1/realtime/calls (codex RealtimeCallClient /
|
|
1479
|
+
// public Realtime API). Sideband WS joins are handled just below.
|
|
1480
|
+
if (
|
|
1481
|
+
req.method === "POST"
|
|
1482
|
+
&& (url.pathname === "/v1/live" || url.pathname === "/v1/realtime/calls")
|
|
1483
|
+
) {
|
|
1484
|
+
disableResponsesRequestTimeout(req, requestServer);
|
|
1485
|
+
if (isDraining()) {
|
|
1486
|
+
return drainingResponse(req, policy);
|
|
1487
|
+
}
|
|
1488
|
+
const audioClient = resolveAudioClient(req, config);
|
|
1489
|
+
if (audioClient instanceof Response) return withCors(audioClient, req, policy);
|
|
1490
|
+
const admission = audioClient?.admission ?? resolveApiAuth(req, policy);
|
|
1491
|
+
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
1492
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
1493
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "cross-origin data-plane request blocked"), req, policy);
|
|
1494
|
+
}
|
|
1495
|
+
const start = Date.now();
|
|
1496
|
+
const requestId = nextRequestLogId(start);
|
|
1497
|
+
const logCtx: RequestLogContext = {
|
|
1498
|
+
model: "gpt-live",
|
|
1499
|
+
provider: "unknown",
|
|
1500
|
+
...admissionFields(admission),
|
|
1501
|
+
};
|
|
1502
|
+
return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
|
|
1503
|
+
const response = audioClient
|
|
1504
|
+
? await handleExternalLive(req, config, logCtx, { client: audioClient, lease: turnAdmissionLease, bindings: liveCallBindings })
|
|
1505
|
+
: await handleLive(req, config, logCtx, turnAdmissionLease);
|
|
1506
|
+
addFinalRequestLog(
|
|
1507
|
+
requestId,
|
|
1508
|
+
start,
|
|
1509
|
+
logCtx,
|
|
1510
|
+
response.status,
|
|
1511
|
+
response.status === 499 ? { closeReason: "client_cancel" } : undefined,
|
|
1512
|
+
);
|
|
1513
|
+
return withCors(response, req, policy);
|
|
1514
|
+
}, { requestId, start, logCtx });
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// Voice / Realtime WebSocket relay. Sideband joins: Frameless /v1/live/{callId};
|
|
1518
|
+
// Realtime v1 /v1/realtime?call_id= (or /v1/realtime/calls/{callId}). Standalone
|
|
1519
|
+
// sessions (codex-rs thread/realtime/start, WebSocket transport — the desktop voice
|
|
1520
|
+
// path): /v1/realtime?intent=quicksilver&model= and /v1/live?model=.
|
|
1521
|
+
// Transparent bidirectional relay.
|
|
1522
|
+
const liveSidebandTarget = req.headers.get("upgrade")?.toLowerCase() === "websocket"
|
|
1523
|
+
? parseLiveSidebandTarget(url.pathname, url.searchParams, url.search.replace(/^\?/, ""))
|
|
1524
|
+
: null;
|
|
1525
|
+
const dictationSocket = url.pathname === "/v1/audio/transcriptions/stream"
|
|
1526
|
+
&& req.headers.get("upgrade")?.toLowerCase() === "websocket";
|
|
1527
|
+
if (liveSidebandTarget || dictationSocket) {
|
|
1528
|
+
if (isDraining()) {
|
|
1529
|
+
return drainingResponse(req, policy);
|
|
1530
|
+
}
|
|
1531
|
+
const audioClient = resolveAudioClient(req, config, dictationSocket);
|
|
1532
|
+
if (audioClient instanceof Response) return withCors(audioClient, req, policy);
|
|
1533
|
+
if (!audioClient && liveSidebandTarget && "callId" in liveSidebandTarget
|
|
1534
|
+
&& liveSidebandTarget.callId.startsWith(EXTERNAL_CALL_PREFIX)) {
|
|
1535
|
+
return withCors(formatErrorResponse(401, "authentication_error", "Live call requires its creator API key"), req, policy);
|
|
1536
|
+
}
|
|
1537
|
+
const admission = audioClient?.admission ?? resolveApiAuth(req, policy);
|
|
1538
|
+
if (!admission) return withCors(formatErrorResponse(401, "authentication_error", "opencodex API key required"), req, policy);
|
|
1539
|
+
if (!isAllowedRequestOrigin(req, policy)) {
|
|
1540
|
+
return withCors(formatErrorResponse(403, "origin_rejected", "WebSocket upgrade blocked: non-local Origin"), req, policy);
|
|
1541
|
+
}
|
|
1542
|
+
const start = Date.now();
|
|
1543
|
+
const requestId = nextRequestLogId(start);
|
|
1544
|
+
const logCtx: RequestLogContext = {
|
|
1545
|
+
model: "gpt-live",
|
|
1546
|
+
provider: "unknown",
|
|
1547
|
+
...admissionFields(admission),
|
|
1548
|
+
};
|
|
1549
|
+
const turnAdmissionLease = tryAdmitTurn(sessionLaneIdFromRequest(req.headers));
|
|
1550
|
+
if (!turnAdmissionLease) return serverBusyResponse(req, "active turns", policy);
|
|
1551
|
+
const audioController = audioClient ? new AbortController() : undefined;
|
|
1552
|
+
if (audioController) registerTurn(audioController, turnAdmissionLease);
|
|
1553
|
+
const acquisition = audioController
|
|
1554
|
+
? clearableDeadline(120_000, AbortSignal.any([req.signal, audioController.signal])) : undefined;
|
|
1555
|
+
const releaseAcquisition = () => {
|
|
1556
|
+
acquisition?.clear();
|
|
1557
|
+
if (audioController) unregisterTurn(audioController);
|
|
1558
|
+
else turnAdmissionLease.release();
|
|
1559
|
+
};
|
|
1560
|
+
let resolved;
|
|
1561
|
+
try {
|
|
1562
|
+
resolved = dictationSocket && audioClient
|
|
1563
|
+
? await resolveDictationSocket(audioClient, config, logCtx, turnAdmissionLease, acquisition?.signal)
|
|
1564
|
+
: liveSidebandTarget && audioClient
|
|
1565
|
+
? await resolveExternalLiveSocket(audioClient, config, logCtx, liveSidebandTarget, { lease: turnAdmissionLease, bindings: liveCallBindings, signal: acquisition?.signal })
|
|
1566
|
+
: liveSidebandTarget
|
|
1567
|
+
? await resolveLiveSidebandUpgrade(req, config, logCtx, liveSidebandTarget, turnAdmissionLease)
|
|
1568
|
+
: formatErrorResponse(401, "authentication_error", "opencodex API key required");
|
|
1569
|
+
} catch (error) {
|
|
1570
|
+
releaseAcquisition();
|
|
1571
|
+
throw error;
|
|
1572
|
+
}
|
|
1573
|
+
if (acquisition?.signal.aborted) {
|
|
1574
|
+
try { if (!(resolved instanceof Response) && "finish" in resolved) resolved.finish(); }
|
|
1575
|
+
finally { releaseAcquisition(); }
|
|
1576
|
+
return withCors(formatErrorResponse(req.signal.aborted ? 499 : acquisition.didExpire() ? 504 : 503,
|
|
1577
|
+
"upstream_error", acquisition.didExpire() ? "Audio connection timed out" : "Audio connection canceled"), req, policy);
|
|
1578
|
+
}
|
|
1579
|
+
if (resolved instanceof Response) {
|
|
1580
|
+
releaseAcquisition();
|
|
1581
|
+
addFinalRequestLog(requestId, start, logCtx, resolved.status);
|
|
1582
|
+
return withCors(resolved, req, policy);
|
|
1583
|
+
}
|
|
1584
|
+
const audio = "finish" in resolved ? resolved : undefined;
|
|
1585
|
+
const finish = audio ? (outcome?: number | "timeout" | "connect_error") => {
|
|
1586
|
+
try { audio.finish(outcome); }
|
|
1587
|
+
finally { releaseAcquisition(); }
|
|
1588
|
+
} : undefined;
|
|
1589
|
+
const discardUpgrade = () => {
|
|
1590
|
+
if (finish) finish();
|
|
1591
|
+
else releaseAcquisition();
|
|
1592
|
+
};
|
|
1593
|
+
if (req.signal.aborted) {
|
|
1594
|
+
discardUpgrade();
|
|
1595
|
+
return withCors(formatErrorResponse(499, "client_closed_request", "Audio connection canceled"), req, policy);
|
|
1596
|
+
}
|
|
1597
|
+
const upstreamHandshake = await openLiveSidebandUpstream(
|
|
1598
|
+
resolved.upstreamWsUrl,
|
|
1599
|
+
resolved.headers,
|
|
1600
|
+
(url, headers) => (deps.liveSidebandWebSocketFactory ?? ((socketUrl, socketHeaders, protocols) => (
|
|
1601
|
+
new WebSocket(socketUrl, { headers: socketHeaders, protocols } as unknown as string[])
|
|
1602
|
+
)))(url, headers, audio?.protocols),
|
|
1603
|
+
LIVE_SIDEBAND_UPSTREAM_OPEN_TIMEOUT_MS,
|
|
1604
|
+
req.signal,
|
|
1605
|
+
);
|
|
1606
|
+
if (!upstreamHandshake.ok) {
|
|
1607
|
+
if (upstreamHandshake.socket) {
|
|
1608
|
+
closeLiveSidebandBeforeUpgrade(upstreamHandshake.socket, () => discardUpgrade());
|
|
1609
|
+
} else {
|
|
1610
|
+
discardUpgrade();
|
|
1611
|
+
}
|
|
1612
|
+
addFinalRequestLog(requestId, start, logCtx, upstreamHandshake.status);
|
|
1613
|
+
console.error("[live] sideband upstream handshake failed: " + upstreamHandshake.message);
|
|
1614
|
+
return withCors(
|
|
1615
|
+
formatErrorResponse(upstreamHandshake.status, upstreamHandshake.code, upstreamHandshake.message),
|
|
1616
|
+
req,
|
|
1617
|
+
policy,
|
|
1618
|
+
);
|
|
1619
|
+
}
|
|
1620
|
+
const handoffFailure = upstreamHandshake.handoff.failure();
|
|
1621
|
+
if (handoffFailure || upstreamHandshake.socket.readyState !== WebSocket.OPEN) {
|
|
1622
|
+
closeLiveSidebandBeforeUpgrade(upstreamHandshake.socket, () => discardUpgrade());
|
|
1623
|
+
const failure = handoffFailure ?? {
|
|
1624
|
+
status: 502,
|
|
1625
|
+
code: "upstream_error",
|
|
1626
|
+
message: "voice upstream closed before client upgrade",
|
|
1627
|
+
};
|
|
1628
|
+
addFinalRequestLog(requestId, start, logCtx, failure.status);
|
|
1629
|
+
return withCors(formatErrorResponse(failure.status, failure.code, failure.message), req, policy);
|
|
1630
|
+
}
|
|
1631
|
+
let upgraded = false;
|
|
1632
|
+
try {
|
|
1633
|
+
upgraded = requestServer.upgrade(req, {
|
|
1634
|
+
...(audioClient?.protocol ? { headers: { "sec-websocket-protocol": audioClient.protocol } } : {}),
|
|
1635
|
+
data: {
|
|
1636
|
+
kind: "live-sideband",
|
|
1637
|
+
liveUpstream: upstreamHandshake.socket,
|
|
1638
|
+
liveUpstreamUrl: resolved.upstreamWsUrl,
|
|
1639
|
+
liveUpstreamHeaders: resolved.headers,
|
|
1640
|
+
liveUpstreamHandoff: upstreamHandshake.handoff,
|
|
1641
|
+
admission,
|
|
1642
|
+
liveUpstreamProtocols: audio?.protocols,
|
|
1643
|
+
liveValidateFrame: audio?.validateFrame,
|
|
1644
|
+
liveMaxSessionMs: audio?.maxSessionMs,
|
|
1645
|
+
liveFinish: finish,
|
|
1646
|
+
liveAbortSignal: audioController?.signal,
|
|
1647
|
+
livePending: [],
|
|
1648
|
+
livePendingBytes: 0,
|
|
1649
|
+
liveOpened: true,
|
|
1650
|
+
liveTurnAdmissionLease: turnAdmissionLease,
|
|
1651
|
+
} satisfies WsData,
|
|
1652
|
+
});
|
|
1653
|
+
} catch {
|
|
1654
|
+
try {
|
|
1655
|
+
upstreamHandshake.handoff.take();
|
|
1656
|
+
} catch {
|
|
1657
|
+
/* ignore */
|
|
1658
|
+
}
|
|
1659
|
+
closeLiveSidebandBeforeUpgrade(upstreamHandshake.socket, () => discardUpgrade());
|
|
1660
|
+
return withCors(formatErrorResponse(502, "upstream_error", "Audio WebSocket upgrade failed"), req, policy);
|
|
1661
|
+
}
|
|
1662
|
+
if (upgraded) {
|
|
1663
|
+
acquisition?.clear();
|
|
1664
|
+
addFinalRequestLog(requestId, start, logCtx, 101);
|
|
1665
|
+
return undefined as unknown as Response;
|
|
1666
|
+
}
|
|
1667
|
+
try {
|
|
1668
|
+
upstreamHandshake.handoff.take();
|
|
1669
|
+
} catch {
|
|
1670
|
+
/* ignore */
|
|
1671
|
+
}
|
|
1672
|
+
closeLiveSidebandBeforeUpgrade(upstreamHandshake.socket, () => discardUpgrade());
|
|
1673
|
+
return withCors(formatErrorResponse(426, "upgrade_required", "WebSocket upgrade failed"), req, policy);
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
// Data-plane guard: unknown /v1/* paths must fail with JSON 404, never fall through to the
|
|
1677
|
+
// GUI static handler (extensionless paths would get index.html with HTTP 200 and codex-rs
|
|
1678
|
+
// endpoint clients — memories/*, realtime/* — would surface confusing
|
|
1679
|
+
// serde decode errors instead of a clean not-found).
|
|
1680
|
+
if (url.pathname.startsWith("/v1/")) {
|
|
1681
|
+
return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy);
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
if (url.pathname === "/opencodex-session") {
|
|
1685
|
+
if (req.method === "GET") {
|
|
1686
|
+
const session = issueGuiSession(req, config, managementAuth, {
|
|
1687
|
+
trustedTailscaleIngress: ingress === "hub-management",
|
|
1688
|
+
});
|
|
1689
|
+
return session
|
|
1690
|
+
? withManagementCors(serveSessionBootstrap(session), req, config)
|
|
1691
|
+
: withManagementCors(new Response(null, { status: 401, headers: { "Cache-Control": "no-store" } }), req, config);
|
|
1692
|
+
}
|
|
1693
|
+
if (req.method === "POST") {
|
|
1694
|
+
// This endpoint is reachable WITHOUT a credential — that is the point of a pairing
|
|
1695
|
+
// exchange — so the body limit has to hold against a caller who controls the
|
|
1696
|
+
// framing. A declared Content-Length is a claim, not a bound: omit the header and
|
|
1697
|
+
// `Number(null ?? "0")` is 0, send `Transfer-Encoding: chunked` and there is no
|
|
1698
|
+
// header at all. Both used to pass the pre-check and land in `req.text()`, which
|
|
1699
|
+
// buffers whatever arrives. The post-check then measured a string the process had
|
|
1700
|
+
// already been forced to hold.
|
|
1701
|
+
//
|
|
1702
|
+
// So the declared length is only a cheap early reject, and the real bound is
|
|
1703
|
+
// applied while reading: stop at limit+1 bytes and never accumulate more.
|
|
1704
|
+
const declaredLength = Number(req.headers.get("content-length") ?? "0");
|
|
1705
|
+
if (!Number.isFinite(declaredLength) || declaredLength > GUI_PAIRING_EXCHANGE_BODY_LIMIT) {
|
|
1706
|
+
return withManagementCors(Response.json({ error: "pairing exchange body too large" }, { status: 413, headers: { "Cache-Control": "no-store" } }), req, config);
|
|
1707
|
+
}
|
|
1708
|
+
const bounded = await readBoundedRequestText(req, GUI_PAIRING_EXCHANGE_BODY_LIMIT);
|
|
1709
|
+
if (bounded === null) {
|
|
1710
|
+
return withManagementCors(Response.json({ error: "pairing exchange body too large" }, { status: 413, headers: { "Cache-Control": "no-store" } }), req, config);
|
|
1711
|
+
}
|
|
1712
|
+
const text = bounded;
|
|
1713
|
+
let body: unknown;
|
|
1714
|
+
try {
|
|
1715
|
+
body = JSON.parse(text);
|
|
1716
|
+
} catch {
|
|
1717
|
+
return withManagementCors(Response.json({ error: "invalid pairing exchange body" }, { status: 400, headers: { "Cache-Control": "no-store" } }), req, config);
|
|
1718
|
+
}
|
|
1719
|
+
if (!body || typeof body !== "object" || Array.isArray(body)
|
|
1720
|
+
|| Object.keys(body as Record<string, unknown>).length !== 1
|
|
1721
|
+
|| typeof (body as Record<string, unknown>).grant !== "string") {
|
|
1722
|
+
return withManagementCors(Response.json({ error: "invalid pairing exchange body" }, { status: 400, headers: { "Cache-Control": "no-store" } }), req, config);
|
|
1723
|
+
}
|
|
1724
|
+
const pairing = managementAuth.available
|
|
1725
|
+
? consumeGuiPairingGrant(req, body, config, managementAuth, Date.now(), {
|
|
1726
|
+
ingress: ingress === "hub-management" ? "hub-management" : "public",
|
|
1727
|
+
peerAddress: requestServer.requestIP(req)?.address ?? null,
|
|
1728
|
+
tailscaleUser: ingress === "hub-management" ? req.headers.get("Tailscale-User-Login") : null,
|
|
1729
|
+
browserOrigin: req.headers.get("Origin") ?? "",
|
|
1730
|
+
})
|
|
1731
|
+
: null;
|
|
1732
|
+
if (pairing && "allowed" in pairing) {
|
|
1733
|
+
return withManagementCors(Response.json({ error: "pairing exchange refused" }, {
|
|
1734
|
+
status: 429,
|
|
1735
|
+
headers: { "Cache-Control": "no-store", "Retry-After": String(pairing.retryAfterSeconds) },
|
|
1736
|
+
}), req, config);
|
|
1737
|
+
}
|
|
1738
|
+
return pairing
|
|
1739
|
+
? withManagementCors(serveSessionBootstrap(pairing), req, config)
|
|
1740
|
+
: withManagementCors(new Response(null, { status: 401, headers: { "Cache-Control": "no-store" } }), req, config);
|
|
1741
|
+
}
|
|
1742
|
+
return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, policy);
|
|
1743
|
+
}
|
|
1744
|
+
const guiSessionCandidate = req.method === "GET" && (url.pathname === "/" || !url.pathname.includes("."))
|
|
1745
|
+
? issueGuiSession(req, config, managementAuth, {
|
|
1746
|
+
trustedTailscaleIngress: ingress === "hub-management",
|
|
1747
|
+
})
|
|
1748
|
+
: null;
|
|
1749
|
+
const guiFile = serveGuiFile(
|
|
1750
|
+
url.pathname,
|
|
1751
|
+
undefined,
|
|
1752
|
+
guiSessionCandidate ?? undefined,
|
|
1753
|
+
config.runtimeRole ?? "standalone",
|
|
1754
|
+
isApiAuthRequired(config),
|
|
1755
|
+
);
|
|
1756
|
+
if (guiFile) return guiFile;
|
|
1757
|
+
if (url.pathname === "/" && req.method === "GET") {
|
|
1758
|
+
return jsonResponse(rootFallbackPayload());
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
return withCors(formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${url.pathname}`), req, config);
|
|
1762
|
+
},
|
|
1763
|
+
websocket: createWebsocketHandler(ctx),
|
|
1764
|
+
} as const;
|
|
1765
|
+
return serveOptions;
|
|
1766
|
+
}
|