@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,219 @@
1
+ import type { DataPlaneAdmission } from "./auth-cors";
2
+ import type { OcxApiKeyEntry, OcxConfig } from "../types";
3
+
4
+ /**
5
+ * Per-admission-key model and provider scope.
6
+ *
7
+ * A hub that serves several clients with their own `ocx_data_…` keys needs a
8
+ * mail or cron key to be unable to spend a coding key's Grok or Claude quota.
9
+ * Hub-level model selection cannot express that: it hides a model from
10
+ * everyone or from no one (#5049).
11
+ *
12
+ * Two rules decide what this is and is not:
13
+ *
14
+ * A scope names DESTINATIONS, not selectors. It is evaluated against the
15
+ * resolved route — the provider and model a turn will actually bill — never
16
+ * against the string the client sent. A client-supplied label is not a
17
+ * permission subject: alias resolution, a combo pick, a policy fallback and a
18
+ * compaction override all rewrite that string, so a scope checked before them
19
+ * would authorize one destination and reach another.
20
+ *
21
+ * A scope is never management authority. It narrows which models an inference
22
+ * key may call and grants nothing else; reading or editing a scope stays on
23
+ * the management credential.
24
+ */
25
+ export interface AdmissionModelScope {
26
+ /** Resolved provider names this key may reach. Empty means every provider. */
27
+ readonly providers: readonly string[];
28
+ /** Resolved destinations this key may reach. Empty means every model. */
29
+ readonly models: readonly string[];
30
+ }
31
+
32
+ /** The resolved destination a scope decision is made about. */
33
+ export interface ScopedRoute {
34
+ readonly providerName: string;
35
+ readonly modelId: string;
36
+ }
37
+
38
+ const normalize = (value: string): string => value.trim().toLowerCase();
39
+
40
+ /** Does this scope admit this provider on its own terms? */
41
+ function providerAllowedByScope(scope: AdmissionModelScope, providerName: string): boolean {
42
+ return scope.providers.length === 0 || scope.providers.includes(normalize(providerName));
43
+ }
44
+
45
+ function normalizedList(values: readonly string[] | undefined): readonly string[] {
46
+ if (!Array.isArray(values)) return [];
47
+ const seen = new Set<string>();
48
+ for (const value of values) {
49
+ if (typeof value !== "string") continue;
50
+ const normalized = normalize(value);
51
+ if (normalized) seen.add(normalized);
52
+ }
53
+ return [...seen];
54
+ }
55
+
56
+ /** Read the scope a stored key declares. An entry with neither list is unrestricted. */
57
+ export function admissionModelScopeOf(entry: Pick<OcxApiKeyEntry, "allowedProviders" | "allowedModels">): AdmissionModelScope | undefined {
58
+ const providers = normalizedList(entry.allowedProviders);
59
+ const models = normalizedList(entry.allowedModels);
60
+ return providers.length === 0 && models.length === 0 ? undefined : { providers, models };
61
+ }
62
+
63
+ /**
64
+ * The scope that applies to one request, or undefined when nothing is scoped.
65
+ *
66
+ * Only a configured key carries a scope. The environment token and loopback
67
+ * admission have no stored record to attach one to, so they stay unrestricted;
68
+ * an operator who wants them narrowed issues a configured key instead.
69
+ */
70
+ export function resolveAdmissionModelScope(
71
+ config: Pick<OcxConfig, "apiKeys">,
72
+ admission: DataPlaneAdmission | undefined,
73
+ ): AdmissionModelScope | undefined {
74
+ if (!admission || admission.kind !== "configured") return undefined;
75
+ const entry = (config.apiKeys ?? []).find(key => key.id === admission.keyId);
76
+ return entry ? admissionModelScopeOf(entry) : undefined;
77
+ }
78
+
79
+ /**
80
+ * Does this scope admit this resolved destination?
81
+ *
82
+ * The two lists are independent conditions and both must hold when both are
83
+ * declared: a key allowed one provider and one model may not reach that
84
+ * model on a different provider, which is what a combo child or a policy
85
+ * fallback would otherwise do while the requested selector stayed the same.
86
+ *
87
+ * A model entry matches the bare resolved model id or the fully qualified
88
+ * `provider/model` form, so an operator can scope one model everywhere or
89
+ * pin it to a single provider without a second field.
90
+ */
91
+ export function routeAllowedByScope(
92
+ scope: AdmissionModelScope | undefined,
93
+ route: ScopedRoute,
94
+ ): boolean {
95
+ if (!scope) return true;
96
+ const model = normalize(route.modelId);
97
+ if (!providerAllowedByScope(scope, route.providerName)) return false;
98
+ if (scope.models.length === 0) return true;
99
+ return scope.models.includes(model) || scope.models.includes(normalize(route.providerName) + "/" + model);
100
+ }
101
+
102
+ /**
103
+ * A request that asked for a destination its key may not reach.
104
+ *
105
+ * Carries the selector the client sent rather than the destination it resolved
106
+ * to: the caller needs to know which of its own requests was refused, and a
107
+ * key that may not reach a provider has no business learning that an alias it
108
+ * named points there. The resolved destination goes to the server log.
109
+ */
110
+ export class AdmissionModelDeniedError extends Error {
111
+ readonly requestedModel: string;
112
+ readonly deniedProvider: string;
113
+ readonly deniedModel: string;
114
+ constructor(requestedModel: string, route: ScopedRoute) {
115
+ super("model " + requestedModel + " is not allowed for this API key");
116
+ this.name = "AdmissionModelDeniedError";
117
+ this.requestedModel = requestedModel;
118
+ this.deniedProvider = route.providerName;
119
+ this.deniedModel = route.modelId;
120
+ }
121
+ }
122
+
123
+ /** Stable wire type for a scope refusal. */
124
+ export const MODEL_NOT_ALLOWED_FOR_KEY = "model_not_allowed_for_key";
125
+
126
+ /**
127
+ * How a refusal names a destination whose model nobody stated.
128
+ *
129
+ * This is message vocabulary, never a model id. It is not compared against
130
+ * `allowedModels`, so an operator who copies it out of a refusal into a list
131
+ * cannot grant "whatever the upstream picks" — the one destination a model list
132
+ * is unable to describe.
133
+ */
134
+ export const UNNAMED_DESTINATION_MODEL = "(unnamed)";
135
+
136
+ /** The HTTP body a scope refusal returns. 403: authenticated, not permitted. */
137
+ export function admissionModelDeniedBody(error: AdmissionModelDeniedError): {
138
+ error: { type: string; message: string; model: string };
139
+ } {
140
+ return {
141
+ error: {
142
+ type: MODEL_NOT_ALLOWED_FOR_KEY,
143
+ message: error.message,
144
+ model: error.requestedModel,
145
+ },
146
+ };
147
+ }
148
+
149
+ /**
150
+ * The refusal a scoped request gets: 403, not 404.
151
+ *
152
+ * The key authenticated; it simply may not reach this destination. Reporting
153
+ * "not found" instead would tell a client its credential is wrong and invite
154
+ * it to retry with another, and would make an operator's own denial
155
+ * indistinguishable from a typo in the model name.
156
+ */
157
+ export function admissionModelDeniedResponse(error: AdmissionModelDeniedError): Response {
158
+ return new Response(JSON.stringify(admissionModelDeniedBody(error)), {
159
+ status: 403,
160
+ headers: { "Content-Type": "application/json" },
161
+ });
162
+ }
163
+
164
+ /**
165
+ * Refuse a resolved destination this key may not reach.
166
+ *
167
+ * Every request-path site that produces or re-produces a route calls this, so
168
+ * the direct name, an alias, a combo child, a policy or subagent fallback and
169
+ * a compaction override are all checked at the point they become concrete
170
+ * rather than once at the front door.
171
+ */
172
+ export function assertRouteAllowedByScope(
173
+ scope: AdmissionModelScope | undefined,
174
+ requestedModel: string,
175
+ route: ScopedRoute,
176
+ ): void {
177
+ if (!routeAllowedByScope(scope, route)) {
178
+ throw new AdmissionModelDeniedError(requestedModel, route);
179
+ }
180
+ }
181
+
182
+ /**
183
+ * The refusal a non-routed data-plane surface returns, or undefined when the
184
+ * destination is permitted.
185
+ *
186
+ * The Responses path resolves a route inside a try/catch and throws from the
187
+ * point the destination becomes concrete. The endpoints beside it -- images,
188
+ * audio, voice and the search relay -- have no router and no such boundary:
189
+ * each picks its own upstream inline and returns a Response. This is the same
190
+ * predicate against the same kind of resolved destination, shaped for that
191
+ * control flow, so a key that may not reach a provider is refused identically
192
+ * whichever surface it asked through.
193
+ *
194
+ * `destination.modelId` is undefined when nobody named the model this request
195
+ * will run: the relay copies the body and the upstream picks, or a voice join
196
+ * attaches to a call this process never recorded. A key with a model list is
197
+ * then refused, because no entry in that list can describe the destination; a
198
+ * key scoped only by provider is judged on the provider alone.
199
+ */
200
+ export function admissionScopeDenial(
201
+ config: Pick<OcxConfig, "apiKeys">,
202
+ admission: DataPlaneAdmission | undefined,
203
+ requestedModel: string | undefined,
204
+ destination: { readonly providerName: string; readonly modelId: string | undefined },
205
+ ): Response | undefined {
206
+ const scope = resolveAdmissionModelScope(config, admission);
207
+ if (!scope) return undefined;
208
+ const allowed = destination.modelId === undefined
209
+ ? providerAllowedByScope(scope, destination.providerName) && scope.models.length === 0
210
+ : routeAllowedByScope(scope, { providerName: destination.providerName, modelId: destination.modelId });
211
+ if (allowed) return undefined;
212
+ return admissionModelDeniedResponse(new AdmissionModelDeniedError(
213
+ requestedModel ?? UNNAMED_DESTINATION_MODEL,
214
+ {
215
+ providerName: destination.providerName,
216
+ modelId: destination.modelId ?? UNNAMED_DESTINATION_MODEL,
217
+ },
218
+ ));
219
+ }
@@ -11,7 +11,8 @@ import { LIVE_AUDIO_MODEL, resolveAudioUpstream, type AudioUpstream } from "./au
11
11
  import { registerTurn, unregisterTurn } from "./lifecycle";
12
12
  import {
13
13
  backendJsonBodyFromApiMultipart, buildLiveSidebandUpstreamWsUrl, forwardLiveUrl, keyedLiveUrl,
14
- LIVE_CLIENT_PROTOCOL_HEADERS, LIVE_REQUEST_MAX_BYTES, LIVE_RESPONSE_MAX_BYTES, readBodyCapped,
14
+ LIVE_CLIENT_PROTOCOL_HEADERS, LIVE_REQUEST_MAX_BYTES, LIVE_RESPONSE_MAX_BYTES, liveSidebandModel,
15
+ readBodyCapped,
15
16
  type LiveSidebandTarget,
16
17
  } from "./live";
17
18
  import { LIVE_CALL_TTL_MS, LiveCallBindings, upstreamLiveCallId } from "./live-call-bindings";
@@ -115,6 +116,8 @@ export async function handleExternalLive(
115
116
  owner: options.client.owner, upstreamCallId: callId,
116
117
  joinStyle: frameless ? "frameless-path" : "realtime-query",
117
118
  providerName: relay.providerName,
119
+ // A join carries no model of its own; this is the one the call settled on.
120
+ model,
118
121
  accountId: context ? context.kind === "main" ? callerOwned ? undefined : MAIN_CODEX_ACCOUNT_ID : context.accountId : undefined,
119
122
  chatgptAccountId: new Headers(relay.headers).get("chatgpt-account-id") ?? undefined,
120
123
  keyedCredentialDigest: relay.keyed ? createHash("sha256").update(new Headers(relay.headers).get("authorization") ?? "").digest("hex") : undefined,
@@ -150,13 +153,16 @@ export async function resolveExternalLiveSocket(
150
153
  }
151
154
  let upstreamTarget = binding ? { style: binding.joinStyle, callId: binding.upstreamCallId } as LiveSidebandTarget : target;
152
155
  const frameless = upstreamTarget.style === "frameless-path" || upstreamTarget.style === "frameless-standalone";
153
- let model = LIVE_AUDIO_MODEL;
154
156
  if (upstreamTarget.style === "frameless-standalone") {
155
157
  const query = new URLSearchParams(upstreamTarget.query);
156
158
  if (!query.has("model") || query.get("model") === "gpt-live-1") query.set("model", LIVE_AUDIO_MODEL);
157
- model = query.get("model") ?? LIVE_AUDIO_MODEL;
158
159
  upstreamTarget = { ...upstreamTarget, query: query.toString() };
159
160
  }
161
+ // What this socket will actually run: the model a bound call settled on when
162
+ // this same key created it, or the one a standalone session forwards in its
163
+ // own query. Reading only the Frameless query left a Realtime standalone
164
+ // socket judged as the default while its query carried another model.
165
+ const model = binding?.model ?? liveSidebandModel(upstreamTarget) ?? LIVE_AUDIO_MODEL;
160
166
  const relay = await resolveAudioUpstream(client.headers, config, log, {
161
167
  admission: client.admission, model, lease: options.lease,
162
168
  signal: options.signal,
@@ -14,6 +14,7 @@ import { formatCodexProviderForLog } from "../codex/routing";
14
14
  import type { AdmissionLease } from "../lib/admission";
15
15
  import { captureExplicitOpenAiCallerAuth, resolveFirstUsableOpenAiSidecar, selectOpenAiImagesProvider } from "../providers/openai-sidecar";
16
16
  import type { OcxConfig } from "../types";
17
+ import { admissionScopeDenial } from "./admission-model-scope";
17
18
  import {
18
19
  isProxyAdmissionSecret,
19
20
  resolveDataPlaneAdmissionSecret,
@@ -119,6 +120,18 @@ export async function resolveAudioUpstream(
119
120
  return formatErrorResponse(401, "authentication_error", "Selected audio account is unavailable");
120
121
  }
121
122
  validateForwardAdmissionCredential(selected, config);
123
+ // Every audio surface — file transcription, the dictation socket, voice
124
+ // call-create and the sideband join — resolves its upstream here, and
125
+ // `options.model` is the model that upstream will run, not a selector the
126
+ // caller can rewrite afterwards. One check therefore covers all of them.
127
+ const forwardDenial = admissionScopeDenial(config, options.admission, options.model, {
128
+ providerName: candidate.providerName,
129
+ modelId: options.model,
130
+ });
131
+ if (forwardDenial) {
132
+ releaseCodexAuthContextProbeLease(context);
133
+ return forwardDenial;
134
+ }
122
135
  log.provider = formatCodexProviderForLog(candidate.providerName, context.accountId, config);
123
136
  log.model = options.model;
124
137
  return {
@@ -136,6 +149,11 @@ export async function resolveAudioUpstream(
136
149
  const selected = new Headers(provider.headers);
137
150
  selected.set("authorization", `Bearer ${apiKey}`);
138
151
  validateForwardAdmissionCredential(selected, config);
152
+ const keyedDenial = admissionScopeDenial(config, options.admission, options.model, {
153
+ providerName,
154
+ modelId: options.model,
155
+ });
156
+ if (keyedDenial) return keyedDenial;
139
157
  log.provider = providerName;
140
158
  log.model = options.model;
141
159
  return {
@@ -11,6 +11,7 @@ import {
11
11
  providerWebSearchBridgeConfigError,
12
12
  requestPacingConfigError,
13
13
  retryOn429PolicyConfigError,
14
+ retryOnResetPolicyConfigError,
14
15
  sanitizeModelCostsForDisplay,
15
16
  } from "../config";
16
17
  import {
@@ -29,6 +30,7 @@ import {
29
30
  upstreamHttpVersionConfigError,
30
31
  } from "../config/provider-validation";
31
32
  import { providerDestinationConfigError } from "../lib/destination-policy";
33
+ import { providerEgressConfigError } from "../lib/provider-egress";
32
34
  import { redactSecretString } from "../lib/redact";
33
35
  import { DECLARABLE_HOSTED_TOOL_TYPES } from "../responses/hosted-tool-policy";
34
36
  import { effectiveGoogleMode, getProviderRegistryEntry, providerCodexAccountMode, providerMatchesRegistryTransport, registryEntryForProviderDestination } from "../providers/registry";
@@ -722,6 +724,10 @@ export function providerManagementConfigError(
722
724
  delete canonicalCandidate.modelCosts;
723
725
  // requestPacing is a user-owned transport overlay, not part of the canonical seed.
724
726
  delete canonicalCandidate.requestPacing;
727
+ // retryOnReset is the same kind of overlay: it tunes how this provider's own Responses
728
+ // sends recover, not what the canonical forward seed is. Validated below
729
+ // (retryOnResetPolicyConfigError).
730
+ delete canonicalCandidate.retryOnReset;
725
731
  // Context windows are the same kind of user-owned overlay as requestPacing: the operator
726
732
  // narrowing what their own native rows advertise. They can only ever LOWER the measured
727
733
  // window (see nativeOpenAiContextWindow), so admitting them cannot widen what the proxy
@@ -768,6 +774,10 @@ export function providerManagementConfigError(
768
774
  // it before it reaches the management API response.
769
775
  return `provider ${JSON.stringify(redactSecretString(name))} ${retryOn429Error}`;
770
776
  }
777
+ const retryOnResetError = retryOnResetPolicyConfigError(raw.retryOnReset);
778
+ if (retryOnResetError) {
779
+ return `provider ${JSON.stringify(redactSecretString(name))} ${retryOnResetError}`;
780
+ }
771
781
  const requestPacingError = requestPacingConfigError(raw.requestPacing);
772
782
  if (requestPacingError) {
773
783
  return `provider ${JSON.stringify(redactSecretString(name))} ${requestPacingError}`;
@@ -780,6 +790,13 @@ export function providerManagementConfigError(
780
790
  if (upstreamHttpVersionError) {
781
791
  return `provider ${JSON.stringify(redactSecretString(name))} ${upstreamHttpVersionError}`;
782
792
  }
793
+ // Per-provider egress shares one definition with the transports and the config loader, so a
794
+ // value the dashboard accepts is one a request can actually leave by. The message never
795
+ // echoes the value: a proxy URL routinely embeds `user:password@`.
796
+ const egressError = providerEgressConfigError(typed);
797
+ if (egressError) {
798
+ return `provider ${JSON.stringify(redactSecretString(name))} ${egressError}`;
799
+ }
783
800
  const modelCostsError = providerModelCostsConfigError(raw.modelCosts);
784
801
  if (modelCostsError) {
785
802
  // The provider name is caller-controlled and can be token-shaped; redact and JSON-escape
@@ -947,6 +964,13 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
947
964
  decodesNativeCompactionBlobs: "editor",
948
965
  allowEncryptedV2AgentTasks: "editor",
949
966
  allowPrivateNetwork: "editor",
967
+ // A proxy URL routinely embeds `user:password@`, so it never reaches the dashboard DTO and
968
+ // the editor may not write it. `ocx config set` and the config file remain the way to set
969
+ // it, which is the same boundary `apiKey` sits behind and for the same reason.
970
+ proxy: "redacted",
971
+ // A bypass list names destinations, carries no credential, and is only meaningful next to a
972
+ // route the operator can already see.
973
+ noProxy: "editor",
950
974
  upstreamHttpVersion: "editor",
951
975
  upstreamWebsocket: "editor",
952
976
  directGeminiWireRenames: "editor",
@@ -1024,6 +1048,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
1024
1048
  pinParallelToolCallsFalse: "editor",
1025
1049
  terminalContinuationGuard: "editor",
1026
1050
  openaiChatEofTolerance: "editor",
1051
+ foldDeveloperRoleToSystem: "editor",
1027
1052
  promptCacheKey: "editor",
1028
1053
  chatServiceTier: "editor",
1029
1054
  responsesItemIdRepair: "editor",
@@ -1033,6 +1058,7 @@ const PROVIDER_CONFIG_FIELD_POLICY = {
1033
1058
  showThinkingSummary: "editor",
1034
1059
  retryOn429: "editor",
1035
1060
  transientRetryOn5xx: "editor",
1061
+ retryOnReset: "editor",
1036
1062
  reasoningSplitModels: "editor",
1037
1063
  reasoningDetailsModels: "editor",
1038
1064
  thinkingToggleModels: "editor",
@@ -22,6 +22,15 @@ import {
22
22
  import { classifyError, cyberPolicyErrorType, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode } from "../lib/errors";
23
23
  import { redactSecretString } from "../lib/redact";
24
24
  import { resolveClientRetryAfter } from "../lib/retry-after";
25
+ import {
26
+ applyReplayRefusalClientHeaders,
27
+ isReplayRefusalCode,
28
+ isReplayRefusalResponse,
29
+ REPLAY_REFUSAL_CLIENT_HEADERS,
30
+ REPLAY_REFUSED_STATUS,
31
+ retainReplayRefusal,
32
+ UPSTREAM_RESET_REPLAY_REFUSED_CODE,
33
+ } from "../lib/upstream-retry";
25
34
  import { estimateTokens } from "../lib/token-estimate";
26
35
  import { captureRouteStaticPolicy, NoEligiblePolicyCandidateError, UnknownRoutingPolicyError, routeModel } from "../router";
27
36
  import { evidenceFromBody } from "../routing/request-evidence";
@@ -46,6 +55,12 @@ import { providerConsumesCallerAuthorization } from "../providers/caller-authori
46
55
  import { captureExplicitOpenAiCallerAuth } from "../providers/openai-sidecar";
47
56
  import { captureCallerDirectAuth } from "../providers/caller-authorization";
48
57
  import type { AdmissionLease } from "../lib/admission";
58
+ import {
59
+ admissionModelDeniedResponse,
60
+ AdmissionModelDeniedError,
61
+ assertRouteAllowedByScope,
62
+ resolveAdmissionModelScope,
63
+ } from "./admission-model-scope";
49
64
  import type { DataPlaneAdmission } from "./auth-cors";
50
65
  import { tryClaimNativeMainProfileForTurn } from "../codex/native-main-admission";
51
66
  import {
@@ -150,6 +165,10 @@ async function handleChatCompletionsWithBudget(
150
165
  let chatNativeRoute: ReturnType<typeof routeModel> | null = null;
151
166
  try {
152
167
  const route = routeModel(config, chatBody.model as string, evidenceFromBody(chatBody));
168
+ // The native Chat lane sends without re-entering the Responses path, so it
169
+ // has to apply the key's scope itself. Translated traffic is checked where
170
+ // every rewrite converges instead.
171
+ assertRouteAllowedByScope(resolveAdmissionModelScope(config, logIds?.admission), requestedModel, route);
153
172
  // Preserve the routed destination for Go recognition, then settle the wire before
154
173
  // deriving protocol-scoped affinity. Recognition must not inspect the flipped adapter.
155
174
  const routedProvider = route.provider;
@@ -182,6 +201,11 @@ async function handleChatCompletionsWithBudget(
182
201
  // effort, failover, and per-attempt telemetry run before any native Chat send.
183
202
  if (!route.combo && !effortRow && isNativeChatRouteEligible(route, chatBody, config)) chatNativeRoute = route;
184
203
  } catch (err) {
204
+ if (err instanceof AdmissionModelDeniedError) {
205
+ logCtx.requestedModel = requestedModel;
206
+ if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 403, { closeReason: "non_stream" });
207
+ return admissionModelDeniedResponse(err);
208
+ }
185
209
  if (err instanceof UnknownRoutingPolicyError) {
186
210
  logCtx.requestedModel = requestedModel;
187
211
  if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, 404, { closeReason: "non_stream" });
@@ -404,9 +428,19 @@ async function handleChatCompletionsWithBudget(
404
428
  : "invalid_request_error"),
405
429
  message,
406
430
  );
431
+ // The same verdict the native Chat surface reads, from the same two places: the response
432
+ // this wrapper still holds, and the code a body kept through an intermediate formatter.
433
+ // Not the status -- a refusal and a real rate limit are both 429, which is the whole
434
+ // reason this surface used to report one as the other.
435
+ const replayRefusal = isReplayRefusalResponse(upstream) || isReplayRefusalCode(upstreamCode);
407
436
  if (isCyberPolicyCode(upstreamCode) || classified.code === CYBER_POLICY_ERROR_CODE) {
408
437
  classified.code = CYBER_POLICY_ERROR_CODE;
409
438
  classified.type = cyberPolicyErrorType(upstreamType);
439
+ } else if (replayRefusal) {
440
+ // 429 classifies as a rate limit, which already carries a code, so the empty-code branch
441
+ // below could never restore this one -- the translated client was told the provider
442
+ // throttled the turn, and handed a two-second wait to send it again.
443
+ classified.code = UPSTREAM_RESET_REPLAY_REFUSED_CODE;
410
444
  } else if (upstreamCode === "model_not_found") {
411
445
  // Structured model_not_found must win over classifyError's generic remaps.
412
446
  classified.code = "model_not_found";
@@ -414,8 +448,10 @@ async function handleChatCompletionsWithBudget(
414
448
  } else if (upstreamCode !== undefined && upstreamCode !== null && classified.code == null) {
415
449
  classified.code = upstreamCode;
416
450
  }
417
- const status = isCyberPolicyCode(classified.code) ? 400 : upstream.status;
418
- const retryAfter = isCyberPolicyCode(classified.code)
451
+ const status = isCyberPolicyCode(classified.code) ? 400
452
+ : replayRefusal ? REPLAY_REFUSED_STATUS
453
+ : upstream.status;
454
+ const retryAfter = isCyberPolicyCode(classified.code) || replayRefusal
419
455
  ? undefined
420
456
  : resolveClientRetryAfter({
421
457
  status: upstream.status,
@@ -434,9 +470,12 @@ async function handleChatCompletionsWithBudget(
434
470
  headers: {
435
471
  "Content-Type": "application/json",
436
472
  ...(retryAfter ? { "Retry-After": retryAfter } : {}),
473
+ ...(replayRefusal ? REPLAY_REFUSAL_CLIENT_HEADERS : {}),
437
474
  },
438
475
  });
476
+ if (replayRefusal) retainReplayRefusal(rewritten);
439
477
  return logIds
478
+ // Deferred logging re-wraps this response and carries the verdict with it.
440
479
  ? responseWithDeferredRequestLog(rewritten, logIds.requestId, logIds.start, logCtx)
441
480
  : rewritten;
442
481
  }
@@ -503,11 +542,25 @@ async function handleChatCompletionsWithBudget(
503
542
  } else if (isCyberPolicyCode(error?.code) || classified.code === CYBER_POLICY_ERROR_CODE) {
504
543
  classified.code = CYBER_POLICY_ERROR_CODE;
505
544
  classified.type = cyberPolicyErrorType(error?.type);
545
+ } else if (isReplayRefusalCode(error?.code)) {
546
+ // The refusal can also arrive as a failed Responses envelope rather than a non-2xx.
547
+ // Reporting that as the 502 below would invite the four resends the refusal prevents.
548
+ classified.code = UPSTREAM_RESET_REPLAY_REFUSED_CODE;
506
549
  } else if (error?.code === "model_not_found") {
507
550
  // Same deliberate preserve as the non-OK path: structured code beats generic classify.
508
551
  classified.code = "model_not_found";
509
552
  classified.type = "invalid_request_error";
510
553
  }
554
+ if (isReplayRefusalCode(classified.code)) {
555
+ const refusal = chatCompletionsErrorResponse(
556
+ REPLAY_REFUSED_STATUS, message, classified.type, classified.code,
557
+ );
558
+ const headers = new Headers(refusal.headers);
559
+ applyReplayRefusalClientHeaders(headers);
560
+ return finishJson(retainReplayRefusal(
561
+ new Response(refusal.body, { status: refusal.status, headers }),
562
+ ));
563
+ }
511
564
  return finishJson(chatCompletionsErrorResponse(
512
565
  classified.code === "translation_buffer_limit"
513
566
  ? 502
@@ -29,6 +29,9 @@ import {
29
29
  isReplayRefusalCode,
30
30
  isReplayRefusalResponse,
31
31
  prepareSameTarget429Wait,
32
+ REPLAY_REFUSAL_CLIENT_HEADERS,
33
+ REPLAY_REFUSED_STATUS,
34
+ retainReplayRefusal,
32
35
  type UpstreamSendRecovery,
33
36
  UPSTREAM_RESET_REPLAY_REFUSED_CODE,
34
37
  } from "../lib/upstream-retry";
@@ -359,6 +362,11 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
359
362
  applyUpstreamRecoveryInit({
360
363
  ...init, method: request.method, headers, body: request.body,
361
364
  }, transportRecovery),
365
+ // Reselection can replace the provider transport and the wire shape, so the
366
+ // egress route is bound to the provider this send actually uses. Omitting it
367
+ // here would let a provider transport bypass its configured route entirely,
368
+ // because that transport wins over the executor that carries the binding.
369
+ { providerName: route.providerName, provider: activeProvider },
362
370
  );
363
371
  if (!dispatched.ok) await recordKeyAttemptFailure(logCtx, dispatched, init.signal ?? upstream.signal);
364
372
  return dispatched;
@@ -492,10 +500,13 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
492
500
  : response.status >= 500 ? "server_error" : "invalid_request_error"),
493
501
  clientMessage,
494
502
  );
503
+ // The verdict is read once, from the response that carries it and from the code a
504
+ // re-wrapped body kept -- never from the status, which a real rate limit shares.
505
+ const replayRefusal = isReplayRefusalResponse(response) || isReplayRefusalCode(upstreamCode);
495
506
  if (isCyberPolicyCode(upstreamCode) || classified.code === CYBER_POLICY_ERROR_CODE) {
496
507
  classified.code = CYBER_POLICY_ERROR_CODE;
497
508
  classified.type = cyberPolicyErrorType(upstreamType);
498
- } else if (isReplayRefusalResponse(response) || isReplayRefusalCode(upstreamCode)) {
509
+ } else if (replayRefusal) {
499
510
  // 429 classifies as a rate limit and a rate limit already carries a code, so the branch
500
511
  // below -- which only fills an EMPTY code -- could never restore this one. Without it the
501
512
  // client is told the provider throttled the turn, when what happened is that this proxy
@@ -507,11 +518,13 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
507
518
  } else if (upstreamCode !== undefined && upstreamCode !== null && classified.code == null) {
508
519
  classified.code = upstreamCode;
509
520
  }
510
- const status = isCyberPolicyCode(classified.code) ? 400 : response.status;
521
+ const status = isCyberPolicyCode(classified.code) ? 400
522
+ : replayRefusal ? REPLAY_REFUSED_STATUS
523
+ : response.status;
511
524
  // A refusal this proxy made has no wait to report. Synthesizing one here would hand the
512
525
  // client the default two-second retry for a rate limit that never happened, which is the
513
526
  // duplicate send the refusal exists to prevent.
514
- const retryAfter = isCyberPolicyCode(classified.code) || isReplayRefusalCode(classified.code)
527
+ const retryAfter = isCyberPolicyCode(classified.code) || replayRefusal
515
528
  ? undefined
516
529
  : resolveClientRetryAfter({
517
530
  status: response.status,
@@ -519,13 +532,15 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio
519
532
  upstreamRetryAfter: response.headers.get("retry-after"),
520
533
  });
521
534
  finishLog(status, classified.message);
522
- return new Response(JSON.stringify(chatCompletionsErrorBody(status, classified.message, classified.type, classified.code)), {
535
+ const rewritten = new Response(JSON.stringify(chatCompletionsErrorBody(status, classified.message, classified.type, classified.code)), {
523
536
  status,
524
537
  headers: {
525
538
  "Content-Type": "application/json",
526
539
  ...(retryAfter ? { "Retry-After": retryAfter } : {}),
540
+ ...(replayRefusal ? REPLAY_REFUSAL_CLIENT_HEADERS : {}),
527
541
  },
528
542
  });
543
+ return replayRefusal ? retainReplayRefusal(rewritten) : rewritten;
529
544
  }
530
545
 
531
546
  const contentType = response.headers.get("content-type")?.toLowerCase() ?? "";