@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.
Files changed (249) hide show
  1. package/AGENTS_INSTALL.md +64 -0
  2. package/README.md +28 -1
  3. package/bin/ocx.mjs +382 -209
  4. package/gui/dist/assets/App-CH6C5H7x.js +50 -0
  5. package/gui/dist/assets/Tray-CLZh48fM.js +1 -0
  6. package/gui/dist/assets/index-_bpvxJu0.css +1 -0
  7. package/gui/dist/assets/index-wpTOyepx.js +86 -0
  8. package/gui/dist/assets/usage-companion-chart-a0N58rRI.js +1 -0
  9. package/gui/dist/favicon.png +0 -0
  10. package/gui/dist/index.html +2 -2
  11. package/gui/dist/provider-icons/stepfun-color.svg +1 -0
  12. package/package.json +5 -1
  13. package/src/adapters/anthropic.ts +16 -0
  14. package/src/adapters/coding-agent/protocol.ts +36 -6
  15. package/src/adapters/coding-agent/turn.ts +10 -2
  16. package/src/adapters/command-code.ts +2 -1
  17. package/src/adapters/cursor/catalog.ts +51 -7
  18. package/src/adapters/cursor/protobuf-request.ts +6 -3
  19. package/src/adapters/cursor/request-builder.ts +13 -3
  20. package/src/adapters/cursor.ts +11 -2
  21. package/src/adapters/declaration-carrier.ts +45 -0
  22. package/src/adapters/devin.ts +75 -23
  23. package/src/adapters/google-antigravity-wire.ts +5 -2
  24. package/src/adapters/google-errors.ts +7 -1
  25. package/src/adapters/google.ts +29 -5
  26. package/src/adapters/image.ts +4 -1
  27. package/src/adapters/input-media-guard.ts +21 -9
  28. package/src/adapters/kiro/usage.ts +3 -2
  29. package/src/adapters/kiro-tool-fallback.ts +1 -1
  30. package/src/adapters/ollama-native.ts +6 -0
  31. package/src/adapters/openai-chat/developer-role.ts +61 -0
  32. package/src/adapters/openai-chat/messages.ts +46 -27
  33. package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
  34. package/src/adapters/openai-chat/passthrough.ts +33 -9
  35. package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
  36. package/src/adapters/openai-chat.ts +18 -57
  37. package/src/adapters/openai-responses/passthrough.ts +2 -0
  38. package/src/adapters/registry.ts +3 -2
  39. package/src/adapters/run-turn-queue.ts +178 -29
  40. package/src/adapters/xai-web-search.ts +16 -1
  41. package/src/bridge/errors.ts +8 -2
  42. package/src/bridge/response-json.ts +9 -1
  43. package/src/bridge/sse.ts +10 -0
  44. package/src/chat/inbound.ts +141 -5
  45. package/src/claude/desktop-3p.ts +7 -1
  46. package/src/claude/desktop-first-party.ts +183 -0
  47. package/src/claude/desktop-gateway-state.ts +41 -0
  48. package/src/claude/inbound-content-options.ts +6 -0
  49. package/src/claude/inbound.ts +32 -6
  50. package/src/claude/intercept/connect-proxy.ts +179 -0
  51. package/src/claude/intercept/listener.ts +122 -0
  52. package/src/claude/intercept/local-ca.ts +298 -0
  53. package/src/claude/intercept/runtime.ts +98 -0
  54. package/src/claude/intercept/settings.ts +189 -0
  55. package/src/cli/access.ts +87 -0
  56. package/src/cli/account-auth.ts +19 -0
  57. package/src/cli/capabilities.ts +31 -0
  58. package/src/cli/claude-desktop.ts +206 -16
  59. package/src/cli/codex-shim-autorestore.ts +3 -0
  60. package/src/cli/companion.ts +56 -0
  61. package/src/cli/dispatch.ts +43 -4
  62. package/src/cli/ensure-desired-integrations.ts +43 -5
  63. package/src/cli/help.ts +7 -9
  64. package/src/cli/index.ts +200 -61
  65. package/src/cli/init.ts +8 -0
  66. package/src/cli/integrations.ts +7 -1
  67. package/src/cli/registry.ts +41 -2
  68. package/src/cli/resolve.ts +230 -0
  69. package/src/cli/root.ts +24 -1
  70. package/src/cli/start-ownership-publication.ts +56 -0
  71. package/src/cli/status-probes.ts +2 -18
  72. package/src/cli/status.ts +62 -0
  73. package/src/cli/stop-report.ts +143 -0
  74. package/src/cli/uninstall-plan.ts +9 -0
  75. package/src/client/machine-listener.ts +2 -5
  76. package/src/clients/aside-profiles.ts +4 -0
  77. package/src/clients/config-export/zcode-store.ts +157 -0
  78. package/src/clients/config-export.ts +36 -0
  79. package/src/codex/app-server-processes.ts +72 -40
  80. package/src/codex/auth-api/login-flow.ts +6 -1
  81. package/src/codex/autostart-health.ts +28 -0
  82. package/src/codex/catalog/build-entries.ts +2 -2
  83. package/src/codex/catalog/effort.ts +3 -3
  84. package/src/codex/catalog/provider-models.ts +24 -15
  85. package/src/codex/catalog/retained-sync.ts +2 -2
  86. package/src/codex/convergence.ts +2 -2
  87. package/src/codex/history-provider.ts +12 -1
  88. package/src/codex/inject/config-toml.ts +41 -6
  89. package/src/codex/inject/paginated-openai-compat.ts +90 -0
  90. package/src/codex/inject.ts +18 -15
  91. package/src/codex/injected-marker.ts +18 -0
  92. package/src/codex/main-account.ts +6 -0
  93. package/src/codex/model-cache.ts +52 -6
  94. package/src/codex/model-entitlement-admission.ts +59 -0
  95. package/src/codex/model-entitlements.ts +87 -44
  96. package/src/codex/native-main-admission.ts +83 -0
  97. package/src/codex/routing/health-store.ts +39 -0
  98. package/src/codex/routing/selection.ts +37 -1
  99. package/src/codex/routing.ts +5 -41
  100. package/src/codex/shim-templates.ts +29 -3
  101. package/src/companion/settings.ts +132 -0
  102. package/src/config/atomic-write.ts +117 -5
  103. package/src/config/load-degrade.ts +34 -7
  104. package/src/config/process-state.ts +1 -1
  105. package/src/config/schema/config-schema.ts +27 -1
  106. package/src/config/schema/leaf-validators.ts +47 -0
  107. package/src/config.ts +1 -1
  108. package/src/generated/compatibility-version.json +418 -174
  109. package/src/integrations/config-io.ts +44 -10
  110. package/src/integrations/merge.ts +120 -13
  111. package/src/integrations/mutation-plan.ts +124 -18
  112. package/src/integrations/registry.ts +38 -0
  113. package/src/integrations/state.ts +78 -45
  114. package/src/integrations/target.ts +208 -0
  115. package/src/integrations/writer.ts +49 -11
  116. package/src/lab/conformance/fixture-provider.ts +5 -0
  117. package/src/lib/browser-launch-notice.ts +59 -0
  118. package/src/lib/bun-runtime.ts +6 -2
  119. package/src/lib/debug.ts +40 -0
  120. package/src/lib/open-url.ts +51 -7
  121. package/src/lib/package-tree-integrity.ts +2 -1
  122. package/src/lib/package-version.ts +8 -0
  123. package/src/lib/provider-egress.ts +310 -0
  124. package/src/lib/provider-outbound.ts +59 -14
  125. package/src/lib/proxy-env.ts +82 -7
  126. package/src/lib/request-execution-budget.ts +72 -0
  127. package/src/lib/request-failure-attribution.ts +183 -0
  128. package/src/lib/request-failure-model.ts +236 -0
  129. package/src/lib/request-resend-gate.ts +138 -0
  130. package/src/lib/standalone.ts +16 -0
  131. package/src/lib/upstream-retry.ts +167 -16
  132. package/src/lib/winsw.ts +2 -2
  133. package/src/oauth/index.ts +24 -1
  134. package/src/oauth/login-cli.ts +80 -29
  135. package/src/providers/api-key-resolve.ts +133 -0
  136. package/src/providers/api-key-selection.ts +5 -1
  137. package/src/providers/key-failover.ts +31 -1
  138. package/src/providers/key-store.ts +34 -110
  139. package/src/providers/model-rename-fields.ts +147 -0
  140. package/src/providers/model-rename-migration.ts +124 -37
  141. package/src/providers/quota/vendor-probes-key.ts +37 -22
  142. package/src/providers/reasoning-metadata.ts +43 -18
  143. package/src/providers/registry/entries-core.ts +9 -4
  144. package/src/providers/registry/entries-extended.ts +29 -4
  145. package/src/providers/registry/model-seeds.ts +47 -10
  146. package/src/providers/xai-transport.ts +12 -1
  147. package/src/reasoning-effort.ts +8 -0
  148. package/src/responses/function-call-compat.ts +38 -1
  149. package/src/responses/inline-document.ts +65 -0
  150. package/src/responses/input-media.ts +42 -8
  151. package/src/responses/muse-tool-name-alias.ts +19 -0
  152. package/src/responses/parser-content.ts +8 -2
  153. package/src/responses/parser-tools.ts +3 -0
  154. package/src/responses/parser.ts +3 -1
  155. package/src/responses/schema.ts +3 -0
  156. package/src/router.ts +17 -2
  157. package/src/server/admission-model-scope.ts +219 -0
  158. package/src/server/audio-live.ts +9 -3
  159. package/src/server/audio-upstream.ts +18 -0
  160. package/src/server/auth-cors.ts +26 -0
  161. package/src/server/chat-completions.ts +55 -2
  162. package/src/server/chat-native.ts +19 -4
  163. package/src/server/claude-messages.ts +55 -17
  164. package/src/server/grok-responses-snapshot-repair.ts +113 -11
  165. package/src/server/gui-freshness.ts +103 -0
  166. package/src/server/gui-static.ts +7 -9
  167. package/src/server/images.ts +59 -6
  168. package/src/server/index/claude-intercept-lifecycle.ts +49 -0
  169. package/src/server/index/serve-options.ts +56 -10
  170. package/src/server/index/spend-ledger-lifecycle.ts +34 -8
  171. package/src/server/index/startup-warnings.ts +24 -0
  172. package/src/server/index.ts +21 -28
  173. package/src/server/lifecycle.ts +4 -4
  174. package/src/server/live-call-bindings.ts +6 -0
  175. package/src/server/live.ts +88 -3
  176. package/src/server/management/agent-settings-routes.ts +121 -36
  177. package/src/server/management/companion-routes.ts +77 -0
  178. package/src/server/management/logs-usage-routes.ts +19 -0
  179. package/src/server/management/native-integration-routes.ts +103 -6
  180. package/src/server/management/oauth-account-routes.ts +45 -7
  181. package/src/server/management/route-registry.ts +6 -0
  182. package/src/server/management/shared.ts +18 -1
  183. package/src/server/management/usage-timeline-routes.ts +44 -0
  184. package/src/server/management-api.ts +8 -9
  185. package/src/server/proxy-liveness.ts +75 -0
  186. package/src/server/relay.ts +19 -2
  187. package/src/server/request-log-failure-attribution.ts +99 -0
  188. package/src/server/request-log.ts +114 -0
  189. package/src/server/request-metrics.ts +92 -30
  190. package/src/server/responses/codex-ws-wire.ts +34 -8
  191. package/src/server/responses/combo-stream-preflight.ts +168 -6
  192. package/src/server/responses/compact.ts +11 -0
  193. package/src/server/responses/core-opaque-recovery.ts +90 -0
  194. package/src/server/responses/fetch-helpers.ts +124 -8
  195. package/src/server/responses/input-admission.ts +10 -0
  196. package/src/server/responses/passthrough-delivery.ts +14 -1
  197. package/src/server/responses/passthrough-dispatch.ts +179 -35
  198. package/src/server/responses/passthrough-error.ts +27 -8
  199. package/src/server/responses/request-prepare.ts +42 -1
  200. package/src/server/responses/request-send-budget.ts +12 -0
  201. package/src/server/responses/request-transport.ts +24 -4
  202. package/src/server/responses/reset-replay.ts +108 -0
  203. package/src/server/responses-request-tool-scope.ts +214 -0
  204. package/src/server/responses-undeclared-tool-guard.ts +4 -1
  205. package/src/server/search.ts +25 -1
  206. package/src/server/usage-ledger-retention.ts +73 -0
  207. package/src/service/cli.ts +48 -2
  208. package/src/service/health.ts +3 -2
  209. package/src/service/install-state-contract.d.mts +27 -0
  210. package/src/service/install-state-contract.mjs +34 -0
  211. package/src/service/launchd.ts +1 -1
  212. package/src/service/orchestration.ts +2 -4
  213. package/src/service/ownership-compatibility.ts +164 -0
  214. package/src/service/ownership-mutation-lease.d.mts +32 -0
  215. package/src/service/ownership-mutation-lease.mjs +211 -0
  216. package/src/service/repair.ts +45 -1
  217. package/src/service/state-lock.ts +269 -0
  218. package/src/service/state-record.d.mts +36 -0
  219. package/src/service/state-record.mjs +138 -0
  220. package/src/service/state.ts +582 -68
  221. package/src/service/windows-taskxml.ts +11 -10
  222. package/src/service.ts +7 -3
  223. package/src/tray/windows-tray.ps1 +1 -1
  224. package/src/types/config.ts +37 -0
  225. package/src/types/provider.ts +73 -0
  226. package/src/types/request.ts +28 -2
  227. package/src/types/tools.ts +19 -0
  228. package/src/types.ts +3 -0
  229. package/src/update/index.ts +207 -63
  230. package/src/update/job.ts +9 -5
  231. package/src/update/ownership-transaction.ts +47 -0
  232. package/src/update/restart-ownership.ts +54 -0
  233. package/src/update/runtime-ownership.d.mts +40 -0
  234. package/src/update/runtime-ownership.mjs +122 -0
  235. package/src/usage/attempt-delivery.ts +198 -0
  236. package/src/usage/cache-diagnostic.ts +305 -0
  237. package/src/usage/failure-fingerprint.ts +118 -0
  238. package/src/usage/failure-projection-cache.ts +174 -0
  239. package/src/usage/failure-projection.ts +174 -0
  240. package/src/usage/ledger-retention.ts +165 -0
  241. package/src/usage/log.ts +126 -79
  242. package/src/usage/request-outcome.ts +150 -0
  243. package/src/usage/retention-contract.ts +28 -0
  244. package/src/usage/summary.ts +2 -2
  245. package/src/usage/telemetry-contract.ts +237 -0
  246. package/src/usage/timeline.ts +236 -0
  247. package/src/web-search/alpha-search.ts +21 -1
  248. package/gui/dist/assets/index-BTuCbqQd.css +0 -1
  249. 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
- const policy: RequestPolicyView = ingress === "unauthenticated-loopback" ? loopbackPolicy() : config;
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
- resolveCodexModelEntitlements(config, { clientVersion: url.searchParams.get("client_version") }),
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).map(async m => {
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.flatMap(id => expandedNativeModelRow(id)),
1184
- ...visibleAccountNatives.flatMap(({ id, metadataId }) => expandedNativeModelRow(id, metadataId)),
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
- // This stays synchronous and returns void on purpose: startServer must not become async,
50
- // so the wait is a continuation rather than an await. Rollback failures are contained
51
- // because the startup error that brought us here is the one worth reporting.
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 { /* a rollback failure must not replace the startup error that caused it */ }
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) { finish(); return; }
63
- void Promise.allSettled(settling).then(finish);
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.");
@@ -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 { inspectStartupOwnership, setStartupCacheInvalidationWrite, warnAgentTaskRecoveryStartup, warnPlaintextV2AgentMessagesStartup, type StartServerDeps } from "./index/startup-warnings";
207
- import { acquireSpendLedgerServerLifecycle, type SpendLedgerServerLifecycle } from "./index/spend-ledger-lifecycle";
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
- // Resolved once, before any listener binds. The clamp is silent inside the resolver so it
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. Runs even when an earlier release rejected — that rejection still
796
- // propagates, but not before the child is drained.
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
  },
@@ -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, and its own failure joins the
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;
@@ -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,