@bitkyc08/opencodex 2.60.0 → 2.61.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/AGENTS_INSTALL.md +64 -0
- package/README.md +28 -1
- package/bin/ocx.mjs +382 -209
- package/gui/dist/assets/App-CH6C5H7x.js +50 -0
- package/gui/dist/assets/Tray-CLZh48fM.js +1 -0
- package/gui/dist/assets/index-_bpvxJu0.css +1 -0
- package/gui/dist/assets/index-wpTOyepx.js +86 -0
- package/gui/dist/assets/usage-companion-chart-a0N58rRI.js +1 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/stepfun-color.svg +1 -0
- package/package.json +5 -1
- package/src/adapters/anthropic.ts +16 -0
- package/src/adapters/coding-agent/protocol.ts +36 -6
- package/src/adapters/coding-agent/turn.ts +10 -2
- package/src/adapters/command-code.ts +2 -1
- package/src/adapters/cursor/catalog.ts +51 -7
- package/src/adapters/cursor/protobuf-request.ts +6 -3
- package/src/adapters/cursor/request-builder.ts +13 -3
- package/src/adapters/cursor.ts +11 -2
- package/src/adapters/declaration-carrier.ts +45 -0
- package/src/adapters/devin.ts +75 -23
- package/src/adapters/google-antigravity-wire.ts +5 -2
- package/src/adapters/google-errors.ts +7 -1
- package/src/adapters/google.ts +29 -5
- package/src/adapters/image.ts +4 -1
- package/src/adapters/input-media-guard.ts +21 -9
- package/src/adapters/kiro/usage.ts +3 -2
- package/src/adapters/kiro-tool-fallback.ts +1 -1
- package/src/adapters/ollama-native.ts +6 -0
- package/src/adapters/openai-chat/developer-role.ts +61 -0
- package/src/adapters/openai-chat/messages.ts +46 -27
- package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
- package/src/adapters/openai-chat/passthrough.ts +33 -9
- package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
- package/src/adapters/openai-chat.ts +18 -57
- package/src/adapters/openai-responses/passthrough.ts +2 -0
- package/src/adapters/registry.ts +3 -2
- package/src/adapters/run-turn-queue.ts +178 -29
- package/src/adapters/xai-web-search.ts +16 -1
- package/src/bridge/errors.ts +8 -2
- package/src/bridge/response-json.ts +9 -1
- package/src/bridge/sse.ts +10 -0
- package/src/chat/inbound.ts +141 -5
- package/src/claude/desktop-3p.ts +7 -1
- package/src/claude/desktop-first-party.ts +183 -0
- package/src/claude/desktop-gateway-state.ts +41 -0
- package/src/claude/inbound-content-options.ts +6 -0
- package/src/claude/inbound.ts +32 -6
- package/src/claude/intercept/connect-proxy.ts +179 -0
- package/src/claude/intercept/listener.ts +122 -0
- package/src/claude/intercept/local-ca.ts +298 -0
- package/src/claude/intercept/runtime.ts +98 -0
- package/src/claude/intercept/settings.ts +189 -0
- package/src/cli/access.ts +87 -0
- package/src/cli/account-auth.ts +19 -0
- package/src/cli/capabilities.ts +31 -0
- package/src/cli/claude-desktop.ts +206 -16
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/companion.ts +56 -0
- package/src/cli/dispatch.ts +43 -4
- package/src/cli/ensure-desired-integrations.ts +43 -5
- package/src/cli/help.ts +7 -9
- package/src/cli/index.ts +200 -61
- package/src/cli/init.ts +8 -0
- package/src/cli/integrations.ts +7 -1
- package/src/cli/registry.ts +41 -2
- package/src/cli/resolve.ts +230 -0
- package/src/cli/root.ts +24 -1
- package/src/cli/start-ownership-publication.ts +56 -0
- package/src/cli/status-probes.ts +2 -18
- package/src/cli/status.ts +62 -0
- package/src/cli/stop-report.ts +143 -0
- package/src/cli/uninstall-plan.ts +9 -0
- package/src/client/machine-listener.ts +2 -5
- package/src/clients/aside-profiles.ts +4 -0
- package/src/clients/config-export/zcode-store.ts +157 -0
- package/src/clients/config-export.ts +36 -0
- package/src/codex/app-server-processes.ts +72 -40
- package/src/codex/auth-api/login-flow.ts +6 -1
- package/src/codex/autostart-health.ts +28 -0
- package/src/codex/catalog/build-entries.ts +2 -2
- package/src/codex/catalog/effort.ts +3 -3
- package/src/codex/catalog/provider-models.ts +24 -15
- package/src/codex/catalog/retained-sync.ts +2 -2
- package/src/codex/convergence.ts +2 -2
- package/src/codex/history-provider.ts +12 -1
- package/src/codex/inject/config-toml.ts +41 -6
- package/src/codex/inject/paginated-openai-compat.ts +90 -0
- package/src/codex/inject.ts +18 -15
- package/src/codex/injected-marker.ts +18 -0
- package/src/codex/main-account.ts +6 -0
- package/src/codex/model-cache.ts +52 -6
- package/src/codex/model-entitlement-admission.ts +59 -0
- package/src/codex/model-entitlements.ts +87 -44
- package/src/codex/native-main-admission.ts +83 -0
- package/src/codex/routing/health-store.ts +39 -0
- package/src/codex/routing/selection.ts +37 -1
- package/src/codex/routing.ts +5 -41
- package/src/codex/shim-templates.ts +29 -3
- package/src/companion/settings.ts +132 -0
- package/src/config/atomic-write.ts +117 -5
- package/src/config/load-degrade.ts +34 -7
- package/src/config/process-state.ts +1 -1
- package/src/config/schema/config-schema.ts +27 -1
- package/src/config/schema/leaf-validators.ts +47 -0
- package/src/config.ts +1 -1
- package/src/generated/compatibility-version.json +418 -174
- package/src/integrations/config-io.ts +44 -10
- package/src/integrations/merge.ts +120 -13
- package/src/integrations/mutation-plan.ts +124 -18
- package/src/integrations/registry.ts +38 -0
- package/src/integrations/state.ts +78 -45
- package/src/integrations/target.ts +208 -0
- package/src/integrations/writer.ts +49 -11
- package/src/lab/conformance/fixture-provider.ts +5 -0
- package/src/lib/browser-launch-notice.ts +59 -0
- package/src/lib/bun-runtime.ts +6 -2
- package/src/lib/debug.ts +40 -0
- package/src/lib/open-url.ts +51 -7
- package/src/lib/package-tree-integrity.ts +2 -1
- package/src/lib/package-version.ts +8 -0
- package/src/lib/provider-egress.ts +310 -0
- package/src/lib/provider-outbound.ts +59 -14
- package/src/lib/proxy-env.ts +82 -7
- package/src/lib/request-execution-budget.ts +72 -0
- package/src/lib/request-failure-attribution.ts +183 -0
- package/src/lib/request-failure-model.ts +236 -0
- package/src/lib/request-resend-gate.ts +138 -0
- package/src/lib/standalone.ts +16 -0
- package/src/lib/upstream-retry.ts +167 -16
- package/src/lib/winsw.ts +2 -2
- package/src/oauth/index.ts +24 -1
- package/src/oauth/login-cli.ts +80 -29
- package/src/providers/api-key-resolve.ts +133 -0
- package/src/providers/api-key-selection.ts +5 -1
- package/src/providers/key-failover.ts +31 -1
- package/src/providers/key-store.ts +34 -110
- package/src/providers/model-rename-fields.ts +147 -0
- package/src/providers/model-rename-migration.ts +124 -37
- package/src/providers/quota/vendor-probes-key.ts +37 -22
- package/src/providers/reasoning-metadata.ts +43 -18
- package/src/providers/registry/entries-core.ts +9 -4
- package/src/providers/registry/entries-extended.ts +29 -4
- package/src/providers/registry/model-seeds.ts +47 -10
- package/src/providers/xai-transport.ts +12 -1
- package/src/reasoning-effort.ts +8 -0
- package/src/responses/function-call-compat.ts +38 -1
- package/src/responses/inline-document.ts +65 -0
- package/src/responses/input-media.ts +42 -8
- package/src/responses/muse-tool-name-alias.ts +19 -0
- package/src/responses/parser-content.ts +8 -2
- package/src/responses/parser-tools.ts +3 -0
- package/src/responses/parser.ts +3 -1
- package/src/responses/schema.ts +3 -0
- package/src/router.ts +17 -2
- package/src/server/admission-model-scope.ts +219 -0
- package/src/server/audio-live.ts +9 -3
- package/src/server/audio-upstream.ts +18 -0
- package/src/server/auth-cors.ts +26 -0
- package/src/server/chat-completions.ts +55 -2
- package/src/server/chat-native.ts +19 -4
- package/src/server/claude-messages.ts +55 -17
- package/src/server/grok-responses-snapshot-repair.ts +113 -11
- package/src/server/gui-freshness.ts +103 -0
- package/src/server/gui-static.ts +7 -9
- package/src/server/images.ts +59 -6
- package/src/server/index/claude-intercept-lifecycle.ts +49 -0
- package/src/server/index/serve-options.ts +56 -10
- package/src/server/index/spend-ledger-lifecycle.ts +34 -8
- package/src/server/index/startup-warnings.ts +24 -0
- package/src/server/index.ts +21 -28
- package/src/server/lifecycle.ts +4 -4
- package/src/server/live-call-bindings.ts +6 -0
- package/src/server/live.ts +88 -3
- package/src/server/management/agent-settings-routes.ts +121 -36
- package/src/server/management/companion-routes.ts +77 -0
- package/src/server/management/logs-usage-routes.ts +19 -0
- package/src/server/management/native-integration-routes.ts +103 -6
- package/src/server/management/oauth-account-routes.ts +45 -7
- package/src/server/management/route-registry.ts +6 -0
- package/src/server/management/shared.ts +18 -1
- package/src/server/management/usage-timeline-routes.ts +44 -0
- package/src/server/management-api.ts +8 -9
- package/src/server/proxy-liveness.ts +75 -0
- package/src/server/relay.ts +19 -2
- package/src/server/request-log-failure-attribution.ts +99 -0
- package/src/server/request-log.ts +114 -0
- package/src/server/request-metrics.ts +92 -30
- package/src/server/responses/codex-ws-wire.ts +34 -8
- package/src/server/responses/combo-stream-preflight.ts +168 -6
- package/src/server/responses/compact.ts +11 -0
- package/src/server/responses/core-opaque-recovery.ts +90 -0
- package/src/server/responses/fetch-helpers.ts +124 -8
- package/src/server/responses/input-admission.ts +10 -0
- package/src/server/responses/passthrough-delivery.ts +14 -1
- package/src/server/responses/passthrough-dispatch.ts +179 -35
- package/src/server/responses/passthrough-error.ts +27 -8
- package/src/server/responses/request-prepare.ts +42 -1
- package/src/server/responses/request-send-budget.ts +12 -0
- package/src/server/responses/request-transport.ts +24 -4
- package/src/server/responses/reset-replay.ts +108 -0
- package/src/server/responses-request-tool-scope.ts +214 -0
- package/src/server/responses-undeclared-tool-guard.ts +4 -1
- package/src/server/search.ts +25 -1
- package/src/server/usage-ledger-retention.ts +73 -0
- package/src/service/cli.ts +48 -2
- package/src/service/health.ts +3 -2
- package/src/service/install-state-contract.d.mts +27 -0
- package/src/service/install-state-contract.mjs +34 -0
- package/src/service/launchd.ts +1 -1
- package/src/service/orchestration.ts +2 -4
- package/src/service/ownership-compatibility.ts +164 -0
- package/src/service/ownership-mutation-lease.d.mts +32 -0
- package/src/service/ownership-mutation-lease.mjs +211 -0
- package/src/service/repair.ts +45 -1
- package/src/service/state-lock.ts +269 -0
- package/src/service/state-record.d.mts +36 -0
- package/src/service/state-record.mjs +138 -0
- package/src/service/state.ts +582 -68
- package/src/service/windows-taskxml.ts +11 -10
- package/src/service.ts +7 -3
- package/src/tray/windows-tray.ps1 +1 -1
- package/src/types/config.ts +37 -0
- package/src/types/provider.ts +73 -0
- package/src/types/request.ts +28 -2
- package/src/types/tools.ts +19 -0
- package/src/types.ts +3 -0
- package/src/update/index.ts +207 -63
- package/src/update/job.ts +9 -5
- package/src/update/ownership-transaction.ts +47 -0
- package/src/update/restart-ownership.ts +54 -0
- package/src/update/runtime-ownership.d.mts +40 -0
- package/src/update/runtime-ownership.mjs +122 -0
- package/src/usage/attempt-delivery.ts +198 -0
- package/src/usage/cache-diagnostic.ts +305 -0
- package/src/usage/failure-fingerprint.ts +118 -0
- package/src/usage/failure-projection-cache.ts +174 -0
- package/src/usage/failure-projection.ts +174 -0
- package/src/usage/ledger-retention.ts +165 -0
- package/src/usage/log.ts +126 -79
- package/src/usage/request-outcome.ts +150 -0
- package/src/usage/retention-contract.ts +28 -0
- package/src/usage/summary.ts +2 -2
- package/src/usage/telemetry-contract.ts +237 -0
- package/src/usage/timeline.ts +236 -0
- package/src/web-search/alpha-search.ts +21 -1
- package/gui/dist/assets/index-BTuCbqQd.css +0 -1
- package/gui/dist/assets/index-DoBVdPHP.js +0 -134
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Server } from "bun";
|
|
2
|
+
import {
|
|
3
|
+
startClaudeIntercept,
|
|
4
|
+
type ClaudeInterceptHandle,
|
|
5
|
+
type StartClaudeInterceptOptions,
|
|
6
|
+
} from "../../claude/intercept/runtime";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Owns the Claude intercept pair (CONNECT proxy + TLS listener) on behalf of `startServer`.
|
|
10
|
+
* The pair is an optional integration: a bind failure degrades to a warning, never to a
|
|
11
|
+
* startup failure, because every other client keeps working without it. `startServer` stays
|
|
12
|
+
* synchronous, so the start is fire-and-forget and `stop()` awaits whatever it produced.
|
|
13
|
+
*/
|
|
14
|
+
export interface ClaudeInterceptLifecycle<T> {
|
|
15
|
+
/** True once the TLS listener has bound and `requestServer` is it. */
|
|
16
|
+
ownsListener(requestServer: Server<T>): boolean;
|
|
17
|
+
start(options: StartClaudeInterceptOptions<T>): void;
|
|
18
|
+
stop(): Promise<void>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createClaudeInterceptLifecycle<T>(): ClaudeInterceptLifecycle<T> {
|
|
22
|
+
let listener: Server<T> | null = null;
|
|
23
|
+
let pending: Promise<ClaudeInterceptHandle<T> | null> = Promise.resolve(null);
|
|
24
|
+
return {
|
|
25
|
+
ownsListener: requestServer => listener !== null && requestServer === listener,
|
|
26
|
+
start(options) {
|
|
27
|
+
const dispatch = options.dispatch;
|
|
28
|
+
pending = startClaudeIntercept<T>({
|
|
29
|
+
...options,
|
|
30
|
+
dispatch: (req, requestServer) => {
|
|
31
|
+
listener ??= requestServer;
|
|
32
|
+
return dispatch(req, requestServer);
|
|
33
|
+
},
|
|
34
|
+
}).then(handle => {
|
|
35
|
+
if (handle) {
|
|
36
|
+
listener = handle.listener;
|
|
37
|
+
console.log(`🔐 Claude intercept proxy active on http://127.0.0.1:${handle.proxyPort} (CONNECT api.anthropic.com → local TLS)`);
|
|
38
|
+
}
|
|
39
|
+
return handle;
|
|
40
|
+
}).catch((error: unknown) => {
|
|
41
|
+
console.warn(`⚠ Claude intercept proxy could not start: ${error instanceof Error ? error.message : String(error)}`);
|
|
42
|
+
return null;
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
async stop() {
|
|
46
|
+
await (await pending)?.stop();
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
} from "./startup-warnings";
|
|
25
25
|
|
|
26
26
|
import { remoteWorkspaceEnabled } from "../../remote-control/workspace-activation";
|
|
27
|
+
import { isClaudeInterceptedPath } from "../../claude/intercept/listener";
|
|
27
28
|
import { markActivity } from "../../lib/sidecar-tracker";
|
|
28
29
|
import { knownModelIdsForProvider } from "../../router";
|
|
29
30
|
import {
|
|
@@ -49,8 +50,8 @@ import { MAIN_CODEX_ACCOUNT_ID } from "../../codex/main-account";
|
|
|
49
50
|
import {
|
|
50
51
|
availableAccountGatedNativeModels,
|
|
51
52
|
codexModelEntitlementStateForAccount,
|
|
52
|
-
resolveCodexModelEntitlements,
|
|
53
53
|
} from "../../codex/model-entitlements";
|
|
54
|
+
import { resolveAdmittedCodexModelEntitlements } from "../../codex/model-entitlement-admission";
|
|
54
55
|
import { CatalogGatherBusyError } from "../../codex/catalog/provider-fetch";
|
|
55
56
|
import {
|
|
56
57
|
registerCodexWebSocket,
|
|
@@ -100,6 +101,7 @@ import {
|
|
|
100
101
|
withCors,
|
|
101
102
|
withManagementCors,
|
|
102
103
|
} from "../auth-cors";
|
|
104
|
+
import { resolveAdmissionModelScope, routeAllowedByScope } from "../admission-model-scope";
|
|
103
105
|
import {
|
|
104
106
|
disableResponsesRequestTimeout,
|
|
105
107
|
handleResponses,
|
|
@@ -196,7 +198,16 @@ import { readyProtocolMetadata } from "../../remote/protocol";
|
|
|
196
198
|
import { modelCapabilityFields } from "../models-capabilities";
|
|
197
199
|
import { createWebsocketHandler } from "./websocket-handler";
|
|
198
200
|
|
|
199
|
-
export type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management";
|
|
201
|
+
export type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management" | "claude-intercept";
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Routes the Claude intercept TLS listener may reach. Everything else on that socket is relayed
|
|
205
|
+
* to the real upstream by the listener itself, so a request that lands here with another path
|
|
206
|
+
* is a bug, not a client — refuse it.
|
|
207
|
+
*/
|
|
208
|
+
export function claudeInterceptRouteAllowed(url: URL, req: Request): boolean {
|
|
209
|
+
return isClaudeInterceptedPath(url.pathname, req.method);
|
|
210
|
+
}
|
|
200
211
|
|
|
201
212
|
export interface ServeOptionsContext {
|
|
202
213
|
readonly server: Server<WsData>;
|
|
@@ -300,12 +311,24 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
300
311
|
config,
|
|
301
312
|
);
|
|
302
313
|
}
|
|
314
|
+
if (ingress === "claude-intercept" && !claudeInterceptRouteAllowed(codexCompatibleUrl(req.url), req)) {
|
|
315
|
+
return withCors(
|
|
316
|
+
formatErrorResponse(404, "not_found", `Unknown endpoint: ${req.method} ${new URL(req.url).pathname}`),
|
|
317
|
+
req,
|
|
318
|
+
loopbackPolicy(),
|
|
319
|
+
);
|
|
320
|
+
}
|
|
303
321
|
// Auth and CORS decisions below read `policy`, not `config`. For the public listener the
|
|
304
322
|
// two are the same object, so its behaviour is unchanged; for the loopback listener the
|
|
305
323
|
// view substitutes 127.0.0.1 as the bind address, which is what routes it through the
|
|
306
324
|
// same code path a plain loopback bind has always taken — Host-header check included.
|
|
307
325
|
// Routing, provider selection and response bodies keep using `config`.
|
|
308
|
-
|
|
326
|
+
// The Claude intercept listener is loopback by construction (the CONNECT proxy binds
|
|
327
|
+
// 127.0.0.1 and the request was rewritten onto a loopback origin), and its callers carry
|
|
328
|
+
// Anthropic credentials, not opencodex admission tokens — so it takes the loopback view too.
|
|
329
|
+
const policy: RequestPolicyView = ingress === "unauthenticated-loopback" || ingress === "claude-intercept"
|
|
330
|
+
? loopbackPolicy()
|
|
331
|
+
: config;
|
|
309
332
|
const url = codexCompatibleUrl(req.url);
|
|
310
333
|
markActivity(`${req.method} ${url.pathname}`);
|
|
311
334
|
|
|
@@ -819,7 +842,13 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
819
842
|
// Codex sends its own client_version on this request, and upstream filters the
|
|
820
843
|
// entitlement roster by it. Passing it through is what stops an entitled account
|
|
821
844
|
// being told it cannot use models a newer client can (#2886).
|
|
822
|
-
|
|
845
|
+
// The request signal fences the credential phase too: a client that has already
|
|
846
|
+
// gone away must not keep a native-main token refresh alive, and its late result
|
|
847
|
+
// must not commit on behalf of a request that no longer exists.
|
|
848
|
+
resolveAdmittedCodexModelEntitlements(config, {
|
|
849
|
+
clientVersion: url.searchParams.get("client_version"),
|
|
850
|
+
signal: req.signal,
|
|
851
|
+
}),
|
|
823
852
|
]);
|
|
824
853
|
} catch (error) {
|
|
825
854
|
if (error instanceof CatalogGatherBusyError) {
|
|
@@ -1101,6 +1130,17 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
1101
1130
|
return disabledModels.has(id) ? [] : [{ id, metadataId }];
|
|
1102
1131
|
})
|
|
1103
1132
|
);
|
|
1133
|
+
// What a scoped key may see, filtered by the same predicate that refuses
|
|
1134
|
+
// it on the data plane, so the catalog and the send path cannot disagree.
|
|
1135
|
+
// This is a convenience, never the boundary: hiding a row only stops a
|
|
1136
|
+
// client that reads the catalog first, which is why the refusal lives on
|
|
1137
|
+
// the request path and this filter reuses it rather than replacing it.
|
|
1138
|
+
// Filtering happens where the resolved provider and model are still in
|
|
1139
|
+
// hand -- a published id is a selector, and re-resolving one here would
|
|
1140
|
+
// re-run combo selection just to render a list.
|
|
1141
|
+
const listScope = resolveAdmissionModelScope(config, admission);
|
|
1142
|
+
const listAllows = (providerName: string, modelId: string): boolean =>
|
|
1143
|
+
routeAllowedByScope(listScope, { providerName, modelId });
|
|
1104
1144
|
// The projection is opt-in. Keep the default path free of Cursor install detection,
|
|
1105
1145
|
// and resolve the bundle table once for the whole list rather than once per row.
|
|
1106
1146
|
const effortRowsEnabled = config.cursorEffortRows === true;
|
|
@@ -1133,7 +1173,9 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
1133
1173
|
effortRowKnownIds,
|
|
1134
1174
|
));
|
|
1135
1175
|
};
|
|
1136
|
-
const routedRows = await Promise.all(uniqueCatalogModelsForRawPublicList(goOrdered)
|
|
1176
|
+
const routedRows = await Promise.all(uniqueCatalogModelsForRawPublicList(goOrdered)
|
|
1177
|
+
.filter(m => listAllows(m.provider, m.id))
|
|
1178
|
+
.map(async m => {
|
|
1137
1179
|
// Same rule as the anthropic branch: with the global fast switch on, a client
|
|
1138
1180
|
// that has no Fast toggle is offered the fast identity directly. An operator
|
|
1139
1181
|
// alias is an explicit decision and still wins.
|
|
@@ -1180,8 +1222,12 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
1180
1222
|
));
|
|
1181
1223
|
}));
|
|
1182
1224
|
const data = [
|
|
1183
|
-
...visibleNatives
|
|
1184
|
-
|
|
1225
|
+
...visibleNatives
|
|
1226
|
+
.filter(id => listAllows(OPENAI_CODEX_PROVIDER_ID, id))
|
|
1227
|
+
.flatMap(id => expandedNativeModelRow(id)),
|
|
1228
|
+
...visibleAccountNatives
|
|
1229
|
+
.filter(({ metadataId }) => listAllows(OPENAI_CODEX_PROVIDER_ID, metadataId))
|
|
1230
|
+
.flatMap(({ id, metadataId }) => expandedNativeModelRow(id, metadataId)),
|
|
1185
1231
|
...routedRows.flat(),
|
|
1186
1232
|
];
|
|
1187
1233
|
return jsonResponse({ object: "list", data }, 200, req, policy);
|
|
@@ -1246,7 +1292,7 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
1246
1292
|
};
|
|
1247
1293
|
const endpoint = url.pathname.endsWith("/edits") ? "edits" as const : "generations" as const;
|
|
1248
1294
|
return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
|
|
1249
|
-
const response = await handleImages(req, config, endpoint, logCtx, turnAdmissionLease);
|
|
1295
|
+
const response = await handleImages(req, config, endpoint, logCtx, turnAdmissionLease, admission);
|
|
1250
1296
|
addFinalRequestLog(requestId, start, logCtx, response.status, response.status === 499 ? { closeReason: "client_cancel" } : undefined);
|
|
1251
1297
|
return withCors(response, req, policy);
|
|
1252
1298
|
}, { requestId, start, logCtx });
|
|
@@ -1534,7 +1580,7 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
1534
1580
|
return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => {
|
|
1535
1581
|
const response = audioClient
|
|
1536
1582
|
? await handleExternalLive(req, config, logCtx, { client: audioClient, lease: turnAdmissionLease, bindings: liveCallBindings })
|
|
1537
|
-
: await handleLive(req, config, logCtx, turnAdmissionLease);
|
|
1583
|
+
: await handleLive(req, config, logCtx, turnAdmissionLease, admission);
|
|
1538
1584
|
addFinalRequestLog(
|
|
1539
1585
|
requestId,
|
|
1540
1586
|
start,
|
|
@@ -1609,7 +1655,7 @@ export function createServeOptions(ctx: ServeOptionsContext) {
|
|
|
1609
1655
|
: liveSidebandTarget && audioClient
|
|
1610
1656
|
? await resolveExternalLiveSocket(audioClient, config, logCtx, liveSidebandTarget, { lease: turnAdmissionLease, bindings: liveCallBindings, signal: acquisition?.signal })
|
|
1611
1657
|
: liveSidebandTarget
|
|
1612
|
-
? await resolveLiveSidebandUpgrade(req, config, logCtx, liveSidebandTarget, turnAdmissionLease)
|
|
1658
|
+
? await resolveLiveSidebandUpgrade(req, config, logCtx, liveSidebandTarget, turnAdmissionLease, admission)
|
|
1613
1659
|
: formatErrorResponse(401, "authentication_error", "opencodex API key required");
|
|
1614
1660
|
} catch (error) {
|
|
1615
1661
|
try { releaseAcquisition(); }
|
|
@@ -8,11 +8,27 @@ import {
|
|
|
8
8
|
spendPolicyFromConfig,
|
|
9
9
|
} from "../../lib/spend-reservation-ledger";
|
|
10
10
|
|
|
11
|
+
const failedStartRollbacks = new WeakMap<object, Promise<void>>();
|
|
12
|
+
|
|
13
|
+
export function recordFailedStartRollback(error: unknown, rollback: Promise<void>): void {
|
|
14
|
+
if ((typeof error === "object" && error !== null) || typeof error === "function") {
|
|
15
|
+
failedStartRollbacks.set(error, rollback);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Keep an outer ownership lease until a synchronous start failure has closed every listener. */
|
|
20
|
+
export function waitForFailedStartRollback(error: unknown): Promise<void> {
|
|
21
|
+
if ((typeof error === "object" && error !== null) || typeof error === "function") {
|
|
22
|
+
return failedStartRollbacks.get(error) ?? Promise.resolve();
|
|
23
|
+
}
|
|
24
|
+
return Promise.resolve();
|
|
25
|
+
}
|
|
26
|
+
|
|
11
27
|
export interface SpendLedgerServerLifecycle {
|
|
12
28
|
configure(spend: OcxSpendConfig | undefined): void;
|
|
13
29
|
track<T extends { stop(closeActiveConnections?: boolean): void | Promise<void> }>(server: T): T;
|
|
14
30
|
release(): void;
|
|
15
|
-
releaseAfterFailedStart(): void
|
|
31
|
+
releaseAfterFailedStart(): Promise<void>;
|
|
16
32
|
}
|
|
17
33
|
|
|
18
34
|
/** Acquire before config loading so every later startup failure has one rollback owner. */
|
|
@@ -39,28 +55,38 @@ export function acquireSpendLedgerServerLifecycle(configDir: string): SpendLedge
|
|
|
39
55
|
return server;
|
|
40
56
|
},
|
|
41
57
|
release,
|
|
42
|
-
releaseAfterFailedStart(): void {
|
|
58
|
+
releaseAfterFailedStart(): Promise<void> {
|
|
43
59
|
// Every listener that came up is stopped, newest first, and the lease is held until those
|
|
44
60
|
// stops have actually SETTLED. Bun's Server.stop(true) returns a promise that resolves
|
|
45
61
|
// once connections are closed, so discarding it handed the state directory back while a
|
|
46
62
|
// listener could still be serving, which is the one thing single-writer ownership exists
|
|
47
63
|
// to prevent.
|
|
48
64
|
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
65
|
+
// Listener shutdown starts synchronously, while the returned promise lets a caller that
|
|
66
|
+
// owns a broader mutation lease keep it until every close has settled. startServer itself
|
|
67
|
+
// remains synchronous. Rollback failures are reported beside the startup error by the
|
|
68
|
+
// outer ownership transaction, without claiming the listener is gone.
|
|
52
69
|
const settling: Promise<unknown>[] = [];
|
|
70
|
+
const failures: unknown[] = [];
|
|
53
71
|
for (const stop of failedStartStops.splice(0).reverse()) {
|
|
54
72
|
try {
|
|
55
73
|
const pending = stop();
|
|
56
74
|
if (pending !== undefined) settling.push(Promise.resolve(pending));
|
|
57
|
-
} catch {
|
|
75
|
+
} catch (failure) { failures.push(failure); }
|
|
58
76
|
}
|
|
59
77
|
const finish = (): void => {
|
|
60
78
|
try { release(); } catch { /* same: the startup error is the one that matters */ }
|
|
61
79
|
};
|
|
62
|
-
if (settling.length === 0) {
|
|
63
|
-
|
|
80
|
+
if (settling.length === 0) {
|
|
81
|
+
if (failures.length > 0) return Promise.reject(new AggregateError(failures, "failed-start listener rollback was uncertain"));
|
|
82
|
+
finish();
|
|
83
|
+
return Promise.resolve();
|
|
84
|
+
}
|
|
85
|
+
return Promise.allSettled(settling).then(results => {
|
|
86
|
+
for (const result of results) if (result.status === "rejected") failures.push(result.reason);
|
|
87
|
+
if (failures.length > 0) throw new AggregateError(failures, "failed-start listener rollback was uncertain");
|
|
88
|
+
finish();
|
|
89
|
+
});
|
|
64
90
|
},
|
|
65
91
|
};
|
|
66
92
|
}
|
|
@@ -56,6 +56,11 @@ import {
|
|
|
56
56
|
type PackageTreeIntegrityGuard,
|
|
57
57
|
} from "../../lib/package-tree-integrity";
|
|
58
58
|
import type { LiveSidebandWebSocketFactory } from "./live-sideband";
|
|
59
|
+
import {
|
|
60
|
+
MAX_CONFIGURABLE_INBOUND_BODY_BYTES,
|
|
61
|
+
MIN_CONFIGURABLE_INBOUND_BODY_BYTES,
|
|
62
|
+
resolveInboundBodyLimitBytes,
|
|
63
|
+
} from "../request-decompress";
|
|
59
64
|
|
|
60
65
|
// GUI static serving extracted to ./server/gui-static. Re-exported below to keep the
|
|
61
66
|
// "../src/server" import surface stable for tests/callers.
|
|
@@ -205,6 +210,25 @@ export function warnAgentTaskRecoveryStartup(config: {
|
|
|
205
210
|
console.warn(" Recovered plaintext assignment data is retained only in a bounded, process-local in-memory cache; exact fidelity is not guaranteed and the path depends on undocumented backend behavior.");
|
|
206
211
|
}
|
|
207
212
|
|
|
213
|
+
/**
|
|
214
|
+
* Resolved once, before any listener binds. The clamp is silent inside the resolver so it
|
|
215
|
+
* stays pure and per-request cheap; the operator is told here instead, once, because a
|
|
216
|
+
* config value that was quietly reduced is exactly the thing they would otherwise debug
|
|
217
|
+
* against the wrong limit.
|
|
218
|
+
*/
|
|
219
|
+
export function resolveInboundBodyLimitWithWarning(config: { maxInboundBodyBytes?: number }): number {
|
|
220
|
+
const limit = resolveInboundBodyLimitBytes(config.maxInboundBodyBytes);
|
|
221
|
+
const requested = config.maxInboundBodyBytes;
|
|
222
|
+
if (requested !== undefined && requested > 0 && requested !== limit) {
|
|
223
|
+
console.warn(
|
|
224
|
+
`[server] maxInboundBodyBytes=${requested} is outside the supported range `
|
|
225
|
+
+ `[${MIN_CONFIGURABLE_INBOUND_BODY_BYTES}, ${MAX_CONFIGURABLE_INBOUND_BODY_BYTES}]; `
|
|
226
|
+
+ `using ${limit} bytes.`,
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
return limit;
|
|
230
|
+
}
|
|
231
|
+
|
|
208
232
|
export function warnPlaintextV2AgentMessagesStartup(config: { plaintextV2AgentMessages?: boolean }): void {
|
|
209
233
|
if (config.plaintextV2AgentMessages !== true) return;
|
|
210
234
|
console.warn("⚠️ Experimental plaintext V2 agent messages are enabled.");
|
package/src/server/index.ts
CHANGED
|
@@ -69,11 +69,6 @@ import { runDevinProviderMergeStartupMigration } from "../providers/devin-provid
|
|
|
69
69
|
import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers";
|
|
70
70
|
import { providerCodexAccountMode } from "../providers/registry";
|
|
71
71
|
import type { StorageCleanupPolicy } from "../types";
|
|
72
|
-
import {
|
|
73
|
-
MAX_CONFIGURABLE_INBOUND_BODY_BYTES,
|
|
74
|
-
MIN_CONFIGURABLE_INBOUND_BODY_BYTES,
|
|
75
|
-
resolveInboundBodyLimitBytes,
|
|
76
|
-
} from "./request-decompress";
|
|
77
72
|
import { MAIN_CODEX_ACCOUNT_ID } from "../codex/main-account";
|
|
78
73
|
export {
|
|
79
74
|
clearThreadAccountMap,
|
|
@@ -116,6 +111,7 @@ import {
|
|
|
116
111
|
type RequestLogEntry,
|
|
117
112
|
} from "./request-log";
|
|
118
113
|
import { sessionLaneIdFromRequest } from "./request-log-conversation";
|
|
114
|
+
import { setUsageLedgerRetention } from "./usage-ledger-retention";
|
|
119
115
|
import { admitHttpWorkflowTurn, workflowDecisionRefusalResponse, type WorkflowRefusalLog } from "./workflow-refusal";
|
|
120
116
|
export {
|
|
121
117
|
addFinalRequestLog,
|
|
@@ -203,13 +199,15 @@ import {
|
|
|
203
199
|
} from "../lib/package-tree-integrity";
|
|
204
200
|
import { detectInstall } from "../update/index";
|
|
205
201
|
import { createServeOptions, type ServerIngress } from "./index/serve-options";
|
|
206
|
-
import {
|
|
207
|
-
import {
|
|
202
|
+
import { createClaudeInterceptLifecycle } from "./index/claude-intercept-lifecycle";
|
|
203
|
+
import { inspectStartupOwnership, resolveInboundBodyLimitWithWarning, setStartupCacheInvalidationWrite, warnAgentTaskRecoveryStartup, warnPlaintextV2AgentMessagesStartup, type StartServerDeps } from "./index/startup-warnings";
|
|
204
|
+
import { acquireSpendLedgerServerLifecycle, recordFailedStartRollback, type SpendLedgerServerLifecycle } from "./index/spend-ledger-lifecycle";
|
|
205
|
+
export { waitForFailedStartRollback } from "./index/spend-ledger-lifecycle";
|
|
208
206
|
|
|
209
207
|
export function startServer(port?: number, deps: StartServerDeps = {}): Server<WsData> {
|
|
210
208
|
const spendLedgerLifecycle = acquireSpendLedgerServerLifecycle(getConfigDir());
|
|
211
209
|
try { return startServerWithSpendLedgerOwner(port, deps, spendLedgerLifecycle); }
|
|
212
|
-
catch (error) { spendLedgerLifecycle.releaseAfterFailedStart(); throw error; }
|
|
210
|
+
catch (error) { recordFailedStartRollback(error, spendLedgerLifecycle.releaseAfterFailedStart()); throw error; }
|
|
213
211
|
}
|
|
214
212
|
|
|
215
213
|
function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartServerDeps, spendLedgerLifecycle: SpendLedgerServerLifecycle): Server<WsData> {
|
|
@@ -307,6 +305,9 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
|
|
|
307
305
|
enforceAppOwnedMemoryBudget();
|
|
308
306
|
// Observe-only mode still journals physical sends, so every server owns before configuring.
|
|
309
307
|
spendLedgerLifecycle.configure(config.spend);
|
|
308
|
+
// After ownership: a second server on the same home is refused above, so the process running
|
|
309
|
+
// this line is the only one appending to usage.jsonl and the only one that may compact it.
|
|
310
|
+
setUsageLedgerRetention(config.usageLedgerMaxBytes);
|
|
310
311
|
registerCodexCooldownRecoveryProbeWorker(config);
|
|
311
312
|
// Issue #42 Phase 3: opt-in archived auto-cleanup (default OFF). Unref'd hourly
|
|
312
313
|
// tick for daily/weekly; startup evaluation is fire-and-forget after listen.
|
|
@@ -627,26 +628,13 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
|
|
|
627
628
|
let server: Server<WsData>;
|
|
628
629
|
let loopbackServer: Server<WsData> | null = null;
|
|
629
630
|
let managementIngressServer: Server<WsData> | null = null;
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
// stays pure and per-request cheap; the operator is told here instead, once, because a
|
|
633
|
-
// config value that was quietly reduced is exactly the thing they would otherwise debug
|
|
634
|
-
// against the wrong limit.
|
|
635
|
-
const inboundBodyLimitBytes = resolveInboundBodyLimitBytes(config.maxInboundBodyBytes);
|
|
636
|
-
const requestedInboundBodyLimit = config.maxInboundBodyBytes;
|
|
637
|
-
if (requestedInboundBodyLimit !== undefined
|
|
638
|
-
&& requestedInboundBodyLimit > 0
|
|
639
|
-
&& requestedInboundBodyLimit !== inboundBodyLimitBytes) {
|
|
640
|
-
console.warn(
|
|
641
|
-
`[server] maxInboundBodyBytes=${requestedInboundBodyLimit} is outside the supported range `
|
|
642
|
-
+ `[${MIN_CONFIGURABLE_INBOUND_BODY_BYTES}, ${MAX_CONFIGURABLE_INBOUND_BODY_BYTES}]; `
|
|
643
|
-
+ `using ${inboundBodyLimitBytes} bytes.`,
|
|
644
|
-
);
|
|
645
|
-
}
|
|
631
|
+
const claudeIntercept = createClaudeInterceptLifecycle<WsData>();
|
|
632
|
+
const inboundBodyLimitBytes = resolveInboundBodyLimitWithWarning(config);
|
|
646
633
|
|
|
647
634
|
function ingressForServer(requestServer: Server<WsData>): ServerIngress {
|
|
648
635
|
if (requestServer === loopbackServer) return "unauthenticated-loopback";
|
|
649
636
|
if (requestServer === managementIngressServer) return "hub-management";
|
|
637
|
+
if (claudeIntercept.ownsListener(requestServer)) return "claude-intercept";
|
|
650
638
|
return "public";
|
|
651
639
|
}
|
|
652
640
|
let backgroundLifecycle: ReturnType<typeof acquireServerBackgroundLifecycle> | null = null;
|
|
@@ -748,6 +736,10 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
|
|
|
748
736
|
throw new AuxiliaryListenerBindError("hub.managementIngress", managementIngressPort, "127.0.0.1", error);
|
|
749
737
|
}
|
|
750
738
|
}
|
|
739
|
+
claudeIntercept.start({
|
|
740
|
+
config, publicPort: server.port ?? listenPort, requestedPort: listenPort, maxRequestBodySize: inboundBodyLimitBytes,
|
|
741
|
+
dispatch: (req, requestServer) => serveOptions.fetch(req, requestServer),
|
|
742
|
+
});
|
|
751
743
|
} catch (error) {
|
|
752
744
|
unregisterQuotaAutoRefresh?.();
|
|
753
745
|
userCostOverlayReconciler?.stop();
|
|
@@ -776,6 +768,7 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
|
|
|
776
768
|
...(managementIngressRef
|
|
777
769
|
? [() => managementIngressRef.stop(closeActiveConnections)]
|
|
778
770
|
: []),
|
|
771
|
+
() => claudeIntercept.stop(),
|
|
779
772
|
async () => { await remoteWorkspaceShutdown?.(); },
|
|
780
773
|
async () => {
|
|
781
774
|
try {
|
|
@@ -785,16 +778,16 @@ function startServerWithSpendLedgerOwner(port: number | undefined, deps: StartSe
|
|
|
785
778
|
}
|
|
786
779
|
},
|
|
787
780
|
],
|
|
788
|
-
async
|
|
781
|
+
async listenersStopped => {
|
|
789
782
|
try {
|
|
790
783
|
await backgroundLifecycle.release();
|
|
791
784
|
await releaseNativeMainStartupLifecycle(server);
|
|
792
785
|
} finally {
|
|
793
786
|
// icacls.exe from hardenConfigDir() holds the config dir open; a caller that
|
|
794
787
|
// removes the dir right after stop() settles would hit EPERM/EBUSY on Windows
|
|
795
|
-
// otherwise.
|
|
796
|
-
//
|
|
797
|
-
try { spendLedgerLifecycle.release(); }
|
|
788
|
+
// otherwise. Config hardening still flushes when an earlier release rejects. The
|
|
789
|
+
// spend owner is retained when a listener stop failed because the socket may live.
|
|
790
|
+
try { if (listenersStopped) spendLedgerLifecycle.release(); }
|
|
798
791
|
finally { await flushConfigDirHardening(startupConfigDir); }
|
|
799
792
|
}
|
|
800
793
|
},
|
package/src/server/lifecycle.ts
CHANGED
|
@@ -357,12 +357,12 @@ export function getServerListenPort(): number | undefined {
|
|
|
357
357
|
* caller sees the same result before a replacement binds the port. Swallowing it would let
|
|
358
358
|
* `drainAndShutdown` report success while a socket is still held.
|
|
359
359
|
*
|
|
360
|
-
* `always` runs after the listeners regardless of their outcome
|
|
361
|
-
* reported set rather than replacing it.
|
|
360
|
+
* `always` runs after the listeners regardless of their outcome and receives whether every
|
|
361
|
+
* listener stop succeeded. Its own failure joins the reported set rather than replacing it.
|
|
362
362
|
*/
|
|
363
363
|
export async function runListenerShutdown(
|
|
364
364
|
steps: Array<() => Promise<void>>,
|
|
365
|
-
always: () => Promise<void>,
|
|
365
|
+
always: (listenersStopped: boolean) => Promise<void>,
|
|
366
366
|
): Promise<void> {
|
|
367
367
|
const failures: unknown[] = [];
|
|
368
368
|
// Close admission and start connection-owner cleanup before waiting for any drain.
|
|
@@ -372,7 +372,7 @@ export async function runListenerShutdown(
|
|
|
372
372
|
if (result.status === "rejected") failures.push(result.reason);
|
|
373
373
|
}
|
|
374
374
|
try {
|
|
375
|
-
await always();
|
|
375
|
+
await always(failures.length === 0);
|
|
376
376
|
} catch (error) {
|
|
377
377
|
failures.push(error);
|
|
378
378
|
}
|
|
@@ -9,6 +9,12 @@ export interface LiveCallBinding {
|
|
|
9
9
|
upstreamCallId: string;
|
|
10
10
|
joinStyle: "frameless-path" | "realtime-query";
|
|
11
11
|
providerName: string;
|
|
12
|
+
/**
|
|
13
|
+
* The model this call settled on when it was created. A join names no model
|
|
14
|
+
* of its own, so this is what a per-key scope is judged against on reconnect
|
|
15
|
+
* — without it, a call created for one model could be rejoined as another.
|
|
16
|
+
*/
|
|
17
|
+
model?: string;
|
|
12
18
|
accountId?: string;
|
|
13
19
|
chatgptAccountId?: string;
|
|
14
20
|
keyedCredentialDigest?: string;
|
package/src/server/live.ts
CHANGED
|
@@ -44,7 +44,8 @@ import { cancelBodyOnAbort, signalWithTimeout } from "../lib/abort";
|
|
|
44
44
|
import { sidecarEnter } from "../lib/sidecar-tracker";
|
|
45
45
|
import type { OcxConfig } from "../types";
|
|
46
46
|
import { resolveFirstUsableOpenAiSidecar, selectOpenAiImagesProvider } from "../providers/openai-sidecar";
|
|
47
|
-
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } from "./auth-cors";
|
|
47
|
+
import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential, type DataPlaneAdmission } from "./auth-cors";
|
|
48
|
+
import { admissionScopeDenial } from "./admission-model-scope";
|
|
48
49
|
import type { RequestLogContext } from "./request-log";
|
|
49
50
|
import { codexLogAccountId } from "./responses";
|
|
50
51
|
import type { AdmissionLease } from "../lib/admission";
|
|
@@ -550,6 +551,62 @@ async function readRequestBodyCapped(req: Request, maxBytes: number): Promise<Ar
|
|
|
550
551
|
}
|
|
551
552
|
}
|
|
552
553
|
|
|
554
|
+
/**
|
|
555
|
+
* Who is asking and for which live model, as far as a per-key scope is concerned.
|
|
556
|
+
*
|
|
557
|
+
* This path has no router to resolve a destination, so the model is read where
|
|
558
|
+
* the client states it — the call-create session, or a standalone socket's own
|
|
559
|
+
* query — and the provider is whichever OpenAI upstream this relay settles on.
|
|
560
|
+
* `model` is undefined when nobody stated one: a call-create that sends no
|
|
561
|
+
* session model, or a join onto a call this compatibility path never recorded.
|
|
562
|
+
*/
|
|
563
|
+
export interface LiveScopeDestination {
|
|
564
|
+
admission?: DataPlaneAdmission;
|
|
565
|
+
model: string | undefined;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* The live model a call-create body names, or undefined when it names none.
|
|
570
|
+
*
|
|
571
|
+
* Both inbound shapes carry it at `session.model` — JSON directly, multipart in
|
|
572
|
+
* the `session` field — and this path relays the body upstream unchanged, so
|
|
573
|
+
* the string is the destination rather than a selector the proxy rewrites. A
|
|
574
|
+
* body that states nothing readable leaves the model to the upstream, which is
|
|
575
|
+
* a destination no model list can describe.
|
|
576
|
+
*/
|
|
577
|
+
export async function liveCallCreateModel(body: ArrayBuffer, contentType: string): Promise<string | undefined> {
|
|
578
|
+
try {
|
|
579
|
+
let session: unknown;
|
|
580
|
+
if (contentType.toLowerCase().includes("multipart/form-data")) {
|
|
581
|
+
const form = await new Response(body, { headers: { "content-type": contentType } }).formData();
|
|
582
|
+
const raw = form.get("session");
|
|
583
|
+
session = typeof raw === "string" ? JSON.parse(raw) : undefined;
|
|
584
|
+
} else {
|
|
585
|
+
session = (JSON.parse(new TextDecoder().decode(body)) as { session?: unknown } | null)?.session;
|
|
586
|
+
}
|
|
587
|
+
const model = (session as { model?: unknown } | null | undefined)?.model;
|
|
588
|
+
return typeof model === "string" && model.trim() ? model.trim() : undefined;
|
|
589
|
+
} catch {
|
|
590
|
+
return undefined;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* The model a sideband upgrade is for, or undefined when the request names none.
|
|
596
|
+
*
|
|
597
|
+
* A standalone session states it in the query it forwards. A join names nothing
|
|
598
|
+
* of its own: the call it attaches to chose a model at create time, and only a
|
|
599
|
+
* recorded binding can say which. This compatibility path keeps no such record,
|
|
600
|
+
* so a native join is undefined here and the external path in `audio-live.ts`
|
|
601
|
+
* supplies the model its binding stored.
|
|
602
|
+
*/
|
|
603
|
+
export function liveSidebandModel(target: LiveSidebandTarget): string | undefined {
|
|
604
|
+
if (target.style === "realtime-standalone" || target.style === "frameless-standalone") {
|
|
605
|
+
return new URLSearchParams(target.query).get("model")?.trim() || undefined;
|
|
606
|
+
}
|
|
607
|
+
return undefined;
|
|
608
|
+
}
|
|
609
|
+
|
|
553
610
|
/**
|
|
554
611
|
* Resolve OpenAI/ChatGPT auth + headers for live HTTP or sideband WebSocket relays.
|
|
555
612
|
* Shared by call-create and sideband so pool token override stays consistent.
|
|
@@ -559,6 +616,7 @@ export async function resolveLiveRelay(
|
|
|
559
616
|
config: OcxConfig,
|
|
560
617
|
logCtx: RequestLogContext,
|
|
561
618
|
turnAdmissionLease?: AdmissionLease,
|
|
619
|
+
destination?: LiveScopeDestination,
|
|
562
620
|
): Promise<LiveRelayTarget | Response> {
|
|
563
621
|
try {
|
|
564
622
|
validateForwardAdmissionCredential(req.headers, config);
|
|
@@ -624,8 +682,22 @@ export async function resolveLiveRelay(
|
|
|
624
682
|
|
|
625
683
|
// Client protocol headers first so provider/auth headers below always win on conflict.
|
|
626
684
|
const headers: Record<string, string> = clientProtocolHeaders(req.headers);
|
|
685
|
+
const scopedModel = destination?.model;
|
|
627
686
|
if (forward) {
|
|
628
687
|
const { provider } = forward;
|
|
688
|
+
// The upstream is settled here and voice bills it for whatever model this
|
|
689
|
+
// request carries. Refuse before any of it is sent, and give back the probe
|
|
690
|
+
// lease the resolution took. A join states no model and this path keeps no
|
|
691
|
+
// record of the call it attaches to, so a key with a model list is refused
|
|
692
|
+
// there rather than admitted against an assumed default.
|
|
693
|
+
const denial = admissionScopeDenial(config, destination?.admission, scopedModel, {
|
|
694
|
+
providerName: forward.providerName,
|
|
695
|
+
modelId: scopedModel,
|
|
696
|
+
});
|
|
697
|
+
if (denial) {
|
|
698
|
+
forward.releaseProbeLease?.();
|
|
699
|
+
return denial;
|
|
700
|
+
}
|
|
629
701
|
if (provider.headers) Object.assign(headers, provider.headers);
|
|
630
702
|
for (const [name, value] of forward.headers) headers[name] = value;
|
|
631
703
|
logCtx.model = "gpt-live";
|
|
@@ -640,6 +712,11 @@ export async function resolveLiveRelay(
|
|
|
640
712
|
if (forwardAuthError) return forwardAuthError;
|
|
641
713
|
if (candidates.keyed) {
|
|
642
714
|
const { provider, apiKey, providerName } = candidates.keyed;
|
|
715
|
+
const denial = admissionScopeDenial(config, destination?.admission, scopedModel, {
|
|
716
|
+
providerName,
|
|
717
|
+
modelId: scopedModel,
|
|
718
|
+
});
|
|
719
|
+
if (denial) return denial;
|
|
643
720
|
if (provider.headers) Object.assign(headers, provider.headers);
|
|
644
721
|
headers.authorization = `Bearer ${apiKey}`;
|
|
645
722
|
logCtx.provider = providerName;
|
|
@@ -663,13 +740,17 @@ export async function handleLive(
|
|
|
663
740
|
config: OcxConfig,
|
|
664
741
|
logCtx: RequestLogContext,
|
|
665
742
|
turnAdmissionLease?: AdmissionLease,
|
|
743
|
+
admission?: DataPlaneAdmission,
|
|
666
744
|
): Promise<Response> {
|
|
667
745
|
const inboundContentType = req.headers.get("content-type") ?? "application/octet-stream";
|
|
668
746
|
const inboundBodyOrError = await readRequestBodyCapped(req, LIVE_REQUEST_MAX_BYTES);
|
|
669
747
|
if (inboundBodyOrError instanceof Response) return inboundBodyOrError;
|
|
670
748
|
const inboundBody = inboundBodyOrError;
|
|
671
749
|
|
|
672
|
-
const relay = await resolveLiveRelay(req, config, logCtx, turnAdmissionLease
|
|
750
|
+
const relay = await resolveLiveRelay(req, config, logCtx, turnAdmissionLease, {
|
|
751
|
+
admission,
|
|
752
|
+
model: await liveCallCreateModel(inboundBody, inboundContentType),
|
|
753
|
+
});
|
|
673
754
|
if (relay instanceof Response) return relay;
|
|
674
755
|
|
|
675
756
|
const headers: Record<string, string> = { ...relay.headers };
|
|
@@ -766,8 +847,12 @@ export async function resolveLiveSidebandUpgrade(
|
|
|
766
847
|
logCtx: RequestLogContext,
|
|
767
848
|
target: LiveSidebandTarget,
|
|
768
849
|
turnAdmissionLease?: AdmissionLease,
|
|
850
|
+
admission?: DataPlaneAdmission,
|
|
769
851
|
): Promise<{ headers: Record<string, string>; upstreamWsUrl: string; recordOutcome?: LiveRelayTarget["recordOutcome"] } | Response> {
|
|
770
|
-
const relay = await resolveLiveRelay(req, config, logCtx, turnAdmissionLease
|
|
852
|
+
const relay = await resolveLiveRelay(req, config, logCtx, turnAdmissionLease, {
|
|
853
|
+
admission,
|
|
854
|
+
model: liveSidebandModel(target),
|
|
855
|
+
});
|
|
771
856
|
if (relay instanceof Response) return relay;
|
|
772
857
|
return {
|
|
773
858
|
headers: relay.headers,
|