@bitkyc08/opencodex 2.42.0 → 2.43.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 (246) hide show
  1. package/AGENTS_INSTALL.md +2 -2
  2. package/README.md +31 -0
  3. package/bin/ocx.mjs +10 -1
  4. package/gui/dist/assets/index-DS1NE4Jn.css +1 -0
  5. package/gui/dist/assets/index-Djowl68T.js +112 -0
  6. package/gui/dist/index.html +2 -2
  7. package/package.json +1 -1
  8. package/src/adapters/anthropic-image-codec.ts +304 -0
  9. package/src/adapters/anthropic-image-normalize.ts +8 -298
  10. package/src/adapters/anthropic.ts +30 -7
  11. package/src/adapters/command-code.ts +7 -5
  12. package/src/adapters/cursor/desktop-executor-contract.ts +15 -0
  13. package/src/adapters/cursor/images.ts +36 -6
  14. package/src/adapters/cursor/live-transport.ts +7 -2
  15. package/src/adapters/cursor/native-exec-desktop.ts +2 -15
  16. package/src/adapters/cursor/protobuf-request.ts +13 -3
  17. package/src/adapters/cursor/tool-definitions.ts +5 -670
  18. package/src/adapters/cursor/tool-guidance.ts +236 -0
  19. package/src/adapters/cursor/tool-naming.ts +252 -0
  20. package/src/adapters/cursor/tool-schemas.ts +195 -0
  21. package/src/adapters/cursor/types.ts +6 -3
  22. package/src/adapters/exec-tool-result-normalize.ts +1 -1
  23. package/src/adapters/google-errors.ts +9 -1
  24. package/src/adapters/google.ts +1 -0
  25. package/src/adapters/kiro-calibration.ts +181 -0
  26. package/src/adapters/kiro.ts +135 -3
  27. package/src/adapters/openai-responses.ts +216 -26
  28. package/src/adapters/responses-code-mode.ts +59 -0
  29. package/src/adapters/tool-catalog-nudge.ts +1 -1
  30. package/src/adapters/xai-schema-analysis.ts +86 -0
  31. package/src/adapters/xai-tool-schema.ts +2 -87
  32. package/src/adapters/xai-web-search.ts +1 -1
  33. package/src/bridge.ts +22 -10
  34. package/src/chat/inbound.ts +11 -3
  35. package/src/claude/inbound-content-options.ts +60 -0
  36. package/src/claude/inbound-model-options.ts +142 -0
  37. package/src/claude/inbound-records.ts +7 -0
  38. package/src/claude/inbound.ts +10 -202
  39. package/src/claude/model-info.ts +45 -0
  40. package/src/cli/account-auth.ts +21 -6
  41. package/src/cli/capabilities.ts +13 -2
  42. package/src/cli/claude.ts +232 -39
  43. package/src/cli/config-command.ts +9 -1
  44. package/src/cli/dispatch.ts +5 -1
  45. package/src/cli/doctor.ts +10 -0
  46. package/src/cli/effort.ts +372 -0
  47. package/src/cli/export-command.ts +3 -9
  48. package/src/cli/help.ts +1 -0
  49. package/src/cli/index.ts +13 -0
  50. package/src/cli/init.ts +4 -0
  51. package/src/cli/model-selection-guidance.ts +30 -0
  52. package/src/cli/models-runtime.ts +3 -2
  53. package/src/cli/models.ts +8 -3
  54. package/src/cli/opencode.ts +4 -1
  55. package/src/cli/provider-runtime.ts +65 -0
  56. package/src/cli/provider.ts +8 -0
  57. package/src/cli/registry.ts +16 -2
  58. package/src/cli/runtime-api.ts +3 -1
  59. package/src/cli/star-prompt.ts +22 -6
  60. package/src/cli/status-probes.ts +168 -0
  61. package/src/cli/status.ts +5 -168
  62. package/src/clients/config-export/constants.ts +69 -0
  63. package/src/clients/config-export/contracts.ts +154 -0
  64. package/src/clients/config-export/dsh.ts +132 -0
  65. package/src/clients/config-export/fast-models.ts +29 -0
  66. package/src/clients/config-export/mcode.ts +83 -0
  67. package/src/clients/config-export/model-metadata.ts +108 -0
  68. package/src/clients/config-export/omp.ts +104 -0
  69. package/src/clients/config-export/zcode.ts +92 -0
  70. package/src/clients/config-export.ts +18 -710
  71. package/src/codex/account-lifecycle.ts +20 -3
  72. package/src/codex/account-usability.ts +2 -0
  73. package/src/codex/auth-api.ts +170 -23
  74. package/src/codex/auth-context.ts +328 -24
  75. package/src/codex/catalog/effort.ts +30 -4
  76. package/src/codex/catalog/metadata.ts +39 -9
  77. package/src/codex/catalog/native-models.ts +53 -12
  78. package/src/codex/catalog/parsing.ts +119 -5
  79. package/src/codex/catalog/provider-fetch.ts +150 -20
  80. package/src/codex/catalog/reserve.ts +52 -0
  81. package/src/codex/catalog/sync.ts +89 -16
  82. package/src/codex/convergence-types.ts +1 -0
  83. package/src/codex/convergence.ts +2 -0
  84. package/src/codex/data/upstream-models.json +1 -1
  85. package/src/codex/forward-transport-headers.ts +25 -0
  86. package/src/codex/inject.ts +3 -28
  87. package/src/codex/legacy-config-keys.ts +68 -0
  88. package/src/codex/log-guard/inspect-schema.ts +137 -0
  89. package/src/codex/log-guard/inspect.ts +2 -134
  90. package/src/codex/loopback-target.ts +54 -0
  91. package/src/codex/main-account-cache.ts +63 -1
  92. package/src/codex/main-account-hard-lock.ts +52 -0
  93. package/src/codex/main-account.ts +3 -1
  94. package/src/codex/management-convergence.ts +3 -0
  95. package/src/codex/model-entitlements.ts +54 -4
  96. package/src/codex/project-config-warnings.ts +92 -2
  97. package/src/codex/prompt-layers/encoding.ts +80 -0
  98. package/src/codex/prompt-layers/paths.ts +54 -0
  99. package/src/codex/prompt-layers/revision.ts +55 -0
  100. package/src/codex/prompt-layers/toml-edit.ts +163 -0
  101. package/src/codex/prompt-layers/toml-read.ts +181 -0
  102. package/src/codex/prompt-layers.ts +14 -520
  103. package/src/codex/quota-auto-refresh-state.ts +16 -0
  104. package/src/codex/quota-auto-refresh.ts +219 -0
  105. package/src/codex/quota-types.ts +51 -0
  106. package/src/codex/quota.ts +252 -93
  107. package/src/codex/reserve-availability.ts +177 -0
  108. package/src/codex/routing.ts +28 -9
  109. package/src/codex/shim.ts +53 -11
  110. package/src/codex/subagent-model-fallback.ts +23 -3
  111. package/src/combos/failover.ts +125 -7
  112. package/src/combos/identifiers.ts +89 -0
  113. package/src/combos/index.ts +4 -0
  114. package/src/combos/resolve.ts +80 -9
  115. package/src/combos/types.ts +20 -93
  116. package/src/config/subagent-models.ts +24 -0
  117. package/src/config.ts +156 -13
  118. package/src/generated/compatibility-version.json +454 -166
  119. package/src/generated/model-metadata.ts +1 -1
  120. package/src/integrations/journal.ts +65 -4
  121. package/src/integrations/store.ts +5 -0
  122. package/src/lab/events/limits.ts +4 -0
  123. package/src/lib/destination-policy.ts +31 -2
  124. package/src/lib/errors.ts +39 -0
  125. package/src/lib/provider-outbound.ts +69 -3
  126. package/src/lib/proxy-env.ts +22 -0
  127. package/src/lib/redact-folding.ts +176 -0
  128. package/src/lib/redact.ts +2 -175
  129. package/src/lib/state-store-sweeper.ts +20 -6
  130. package/src/lib/token-estimate.ts +94 -27
  131. package/src/lib/windows-user-principal.ts +53 -5
  132. package/src/oauth/anthropic-routing.ts +99 -3
  133. package/src/oauth/generic-account-failover.ts +36 -13
  134. package/src/oauth/index.ts +127 -26
  135. package/src/oauth/login-cli.ts +5 -0
  136. package/src/oauth/meta-muse.ts +117 -15
  137. package/src/oauth/pool-settings-capability.ts +15 -4
  138. package/src/providers/api-keys.ts +8 -10
  139. package/src/providers/default-aliases.ts +39 -0
  140. package/src/providers/derive.ts +10 -2
  141. package/src/providers/fastwire.ts +36 -7
  142. package/src/providers/initial-model-selection-runtime.ts +90 -0
  143. package/src/providers/initial-model-selection.ts +120 -0
  144. package/src/providers/key-failover.ts +134 -54
  145. package/src/providers/key-store.ts +11 -1
  146. package/src/providers/label.ts +1 -1
  147. package/src/providers/model-discovery.ts +76 -0
  148. package/src/providers/model-rename-startup.ts +72 -8
  149. package/src/providers/openai-sidecar.ts +17 -5
  150. package/src/providers/openai-tiers-destination.ts +102 -0
  151. package/src/providers/openai-tiers.ts +2 -99
  152. package/src/providers/opencode-go-transport.ts +41 -0
  153. package/src/providers/quota-key-accounts.ts +141 -0
  154. package/src/providers/quota-types.ts +9 -0
  155. package/src/providers/quota.ts +535 -104
  156. package/src/providers/registry.ts +60 -17
  157. package/src/providers/xai-responses-opt-in.ts +31 -5
  158. package/src/quota/reset-activation.ts +81 -0
  159. package/src/quota/reset-detector.ts +305 -0
  160. package/src/quota/reset-notify-config.ts +162 -0
  161. package/src/quota/reset-observer.ts +125 -0
  162. package/src/quota/reset-poller.ts +160 -0
  163. package/src/quota/reset-seen-store.ts +385 -0
  164. package/src/quota/reset-sinks.ts +199 -0
  165. package/src/quota/window-mapping.ts +106 -0
  166. package/src/responses/apply-patch-envelope.ts +46 -0
  167. package/src/responses/code-mode-helper-compat.ts +39 -1
  168. package/src/responses/custom-tool-compat.ts +10 -4
  169. package/src/responses/hosted-tool-policy.ts +12 -4
  170. package/src/responses/parser-content.ts +133 -0
  171. package/src/responses/parser-text-format.ts +24 -0
  172. package/src/responses/parser-tools.ts +188 -0
  173. package/src/responses/parser.ts +3 -326
  174. package/src/responses/state.ts +124 -28
  175. package/src/router.ts +48 -13
  176. package/src/routing/analytics.ts +1 -0
  177. package/src/routing/capability.ts +17 -4
  178. package/src/server/auth-cors.ts +7 -1
  179. package/src/server/background-lifecycle.ts +23 -1
  180. package/src/server/chat-completions.ts +25 -3
  181. package/src/server/claude-messages.ts +62 -5
  182. package/src/server/effort-row.ts +1 -1
  183. package/src/server/fast-row.ts +295 -0
  184. package/src/server/gui-static.ts +30 -4
  185. package/src/server/index.ts +107 -21
  186. package/src/server/management/agent-settings-routes.ts +2 -2
  187. package/src/server/management/combo-routes.ts +37 -9
  188. package/src/server/management/config-routes.ts +93 -2
  189. package/src/server/management/integration-routes.ts +108 -0
  190. package/src/server/management/model-routes.ts +13 -3
  191. package/src/server/management/model-rows.ts +20 -1
  192. package/src/server/management/native-integration-routes.ts +4 -1
  193. package/src/server/management/oauth-account-routes.ts +38 -10
  194. package/src/server/management/provider-routes.ts +34 -3
  195. package/src/server/management/quota-reset-routes.ts +57 -0
  196. package/src/server/management/route-registry.ts +7 -4
  197. package/src/server/management/shared.ts +19 -5
  198. package/src/server/management/system-routes.ts +3 -2
  199. package/src/server/management-api.ts +14 -2
  200. package/src/server/ports.ts +12 -2
  201. package/src/server/relay-eager.ts +38 -23
  202. package/src/server/relay.ts +4 -0
  203. package/src/server/request-log.ts +6 -0
  204. package/src/server/responses/codex-ws-correlation.ts +65 -0
  205. package/src/server/responses/codex-ws-exchange.ts +261 -0
  206. package/src/server/responses/codex-ws-metadata.ts +134 -0
  207. package/src/server/responses/codex-ws-pool.ts +162 -0
  208. package/src/server/responses/codex-ws-request.ts +87 -0
  209. package/src/server/responses/codex-ws-session.ts +93 -0
  210. package/src/server/responses/codex-ws-wire.ts +144 -0
  211. package/src/server/responses/collaboration.ts +41 -1
  212. package/src/server/responses/compact.ts +105 -12
  213. package/src/server/responses/core.ts +476 -57
  214. package/src/server/responses/fetch-helpers.ts +10 -3
  215. package/src/server/responses/input-admission.ts +16 -9
  216. package/src/server/responses/responses-field-backfill.ts +1 -1
  217. package/src/server/responses/ws-upstream.ts +34 -318
  218. package/src/server/responses-custom-tool-repair.ts +20 -4
  219. package/src/server/responses-undeclared-tool-guard.ts +100 -8
  220. package/src/server/safe-response-headers.ts +23 -0
  221. package/src/server/search.ts +9 -0
  222. package/src/server/subagent-models-startup.ts +27 -0
  223. package/src/server/system-env-shell.ts +238 -0
  224. package/src/server/system-env.ts +7 -234
  225. package/src/server/ws-bridge.ts +3 -25
  226. package/src/server/xai-responses-startup.ts +21 -0
  227. package/src/service-manager-probe.ts +1 -1
  228. package/src/service.ts +55 -16
  229. package/src/types/config.ts +108 -12
  230. package/src/types/provider.ts +36 -7
  231. package/src/types/tools.ts +26 -1
  232. package/src/types.ts +2 -0
  233. package/src/update/notify.ts +8 -2
  234. package/src/usage/cost.ts +38 -28
  235. package/src/usage/expected-prices.ts +34 -15
  236. package/src/usage/log.ts +2 -0
  237. package/src/usage/model-identity.ts +26 -0
  238. package/src/usage/summary.ts +15 -1
  239. package/src/vision/describe.ts +6 -0
  240. package/src/vision/image-rewrite.ts +108 -0
  241. package/src/vision/index.ts +19 -306
  242. package/src/vision/plan.ts +205 -0
  243. package/src/web-search/executor.ts +6 -0
  244. package/src/web-search/index.ts +8 -1
  245. package/gui/dist/assets/index-BU1tE0sr.js +0 -112
  246. package/gui/dist/assets/index-DL9-iS6J.css +0 -1
@@ -148,30 +148,51 @@ export function hasFailoverAccountQuorum(providerName: string, now = Date.now())
148
148
  }
149
149
 
150
150
  /**
151
- * Whether generic rotation is active for this provider.
151
+ * Whether REACTIVE 429 rotation is active for this provider.
152
152
  *
153
- * Precedence, most specific first:
153
+ * Presence is the only rule: two or more eligible stored accounts. The
154
+ * `oauthAccountFailover.enabled` booleans no longer suppress it.
154
155
  *
155
- * 1. `providers.<name>.oauthAccountFailover.enabled` an operator may accept rotation on one
156
- * provider and refuse it on another, because provider terms differ.
157
- * 2. `oauthAccountFailover.enabled` the global switch. Anyone who already wrote `false` keeps
158
- * strict single-account behaviour across this change.
159
- * 3. Presence: 2 or more eligible stored accounts (#2568d, owner decision).
156
+ * That is a deliberate narrowing of #2568d. Rotation here runs only after upstream has already
157
+ * refused the request, so the choice the old knob offered was between "retry on the second
158
+ * account you deliberately logged in" and "return a 429 while that account sits idle". The
159
+ * second is a defect, not a preference — and an operator who does not want rotation expresses
160
+ * that by not storing a second account, exactly as they do for `apiKeyPool`.
160
161
  *
161
- * Only an explicit boolean overrides presence. A malformed value falls through instead of
162
- * throwing, because a typo in a knob must not take a provider out of service.
162
+ * The knob is not gone. It still governs {@link isProactivePreferenceEnabled}, which decides
163
+ * whether a HEALTHY request may be steered to a different account before dispatch — a real
164
+ * behavioural choice that remains refusable — and it still carries `strategy` and
165
+ * `autoSwitchThreshold`.
163
166
  */
164
167
  export function isGenericOAuthFailoverEnabled(
165
168
  config: OcxConfig,
166
169
  providerName: string,
167
170
  now = Date.now(),
168
171
  ): boolean {
172
+ const provider = config.providers?.[providerName];
173
+ if (!provider || !isGenericFailoverProvider(providerName, provider)) return false;
174
+ return hasFailoverAccountQuorum(providerName, now);
175
+ }
176
+
177
+ /**
178
+ * Whether the pre-dispatch account PREFERENCE may run for this provider.
179
+ *
180
+ * Unlike reactive rotation, this moves a request that upstream has not refused, so it stays
181
+ * refusable: an explicit provider value wins over the global default, and a global `false`
182
+ * turns it off only when the provider has no override. A malformed value falls through rather
183
+ * than taking a provider out of service.
184
+ */
185
+ function isProactivePreferenceEnabled(config: OcxConfig, providerName: string, now: number): boolean {
169
186
  const provider = config.providers?.[providerName];
170
187
  if (!provider || !isGenericFailoverProvider(providerName, provider)) return false;
171
188
  const perProvider = provider.oauthAccountFailover?.enabled;
172
- if (typeof perProvider === "boolean") return perProvider;
173
- const global = config.oauthAccountFailover?.enabled;
174
- if (typeof global === "boolean") return global;
189
+ // Preserve the published narrow-over-broad precedence. A provider-specific true may
190
+ // opt this provider into proactive preference even when the global default is false;
191
+ // a provider-specific false refuses it even when the global setting is true.
192
+ if (typeof perProvider === "boolean") {
193
+ return perProvider && hasFailoverAccountQuorum(providerName, now);
194
+ }
195
+ if (config.oauthAccountFailover?.enabled === false) return false;
175
196
  return hasFailoverAccountQuorum(providerName, now);
176
197
  }
177
198
 
@@ -265,7 +286,9 @@ export function preferredInitialAccount(
265
286
  providerName: string,
266
287
  now = Date.now(),
267
288
  ): string | null {
268
- if (!isGenericOAuthFailoverEnabled(config, providerName)) return null;
289
+ // The PROACTIVE predicate, not the reactive one: this steers a request upstream has not
290
+ // refused, so `oauthAccountFailover.enabled: false` must still be able to refuse it.
291
+ if (!isProactivePreferenceEnabled(config, providerName, now)) return null;
269
292
  // This runs on the initial resolution of EVERY request, and `loadAuthStore` has no
270
293
  // cache: each call chmods the config dir, chmods the secret, reads the whole file and
271
294
  // normalizes it (store.ts:136-151). So the store is consulted at most ONCE here, behind
@@ -1,7 +1,8 @@
1
1
  import type { KiroOAuthMetadata, OAuthController, OAuthCredentials } from "./types";
2
+ import { initializeProviderModelSelection } from "../providers/initial-model-selection";
2
3
  import { parseCallbackInput } from "./callback-server";
3
4
  import type { OcxConfig, OcxProviderConfig, RefreshPolicy } from "../types";
4
- import { ConfigMutationLockError, loadConfig, saveConfig } from "../config";
5
+ import { ConfigMutationLockError, loadConfig, mutatePersistedConfig, saveConfig } from "../config";
5
6
  import { resolveProviderApiKey } from "../providers/key-store";
6
7
  import { maskEmail } from "../lib/privacy";
7
8
  import { KiroTokenRefreshError, environmentKiroRoutingMetadata, loginKiro, refreshKiroToken, settleKiroLoginTransaction } from "./kiro";
@@ -1247,42 +1248,134 @@ function migrateLegacyAntigravityStaticCatalog(config: OcxConfig): boolean {
1247
1248
  return true;
1248
1249
  }
1249
1250
 
1250
- export function reconcileOAuthProviders(config: OcxConfig): boolean {
1251
- let changed = migrateLegacyAntigravityStaticCatalog(config);
1252
- for (const [name, prov] of Object.entries(config.providers)) {
1251
+ interface OAuthReconcileProjection {
1252
+ config: OcxConfig;
1253
+ changed: boolean;
1254
+ touchedProviders: string[];
1255
+ touchedAntigravityVersion: boolean;
1256
+ }
1257
+
1258
+ /** Pure projection over a clone: apply every reconciliation rule and report what it touched. */
1259
+ function projectOAuthProviderReconciliation(config: OcxConfig): OAuthReconcileProjection {
1260
+ const projected = structuredClone(config);
1261
+ const touchedProviders = new Set<string>();
1262
+ const beforeAntigravity = JSON.stringify(projected.providers[GOOGLE_ANTIGRAVITY_PROVIDER]);
1263
+ const beforeAntigravityVersion = projected.googleAntigravityStaticCatalogVersion;
1264
+ let changed = migrateLegacyAntigravityStaticCatalog(projected);
1265
+ if (JSON.stringify(projected.providers[GOOGLE_ANTIGRAVITY_PROVIDER]) !== beforeAntigravity) {
1266
+ touchedProviders.add(GOOGLE_ANTIGRAVITY_PROVIDER);
1267
+ }
1268
+ const touchedAntigravityVersion = projected.googleAntigravityStaticCatalogVersion !== beforeAntigravityVersion;
1269
+
1270
+ for (const [name, prov] of Object.entries(projected.providers)) {
1271
+ const beforeProvider = JSON.stringify(prov);
1253
1272
  const def = OAUTH_PROVIDERS[name];
1254
1273
  if (name === "command-code" && isLegacyCommandCodeStaticCatalog(prov)) {
1255
1274
  // The former experimental preset was the exact three-model seed above. It was not a user
1256
1275
  // choice to disable discovery, so promote only that shape to the account live catalog.
1257
1276
  prov.liveModels = true;
1258
- changed = true;
1259
1277
  }
1260
- if (!def || prov.authMode !== "oauth") continue;
1261
- const preset = def.providerConfig;
1262
- for (const field of OAUTH_RECONCILE_FIELDS) {
1263
- if (JSON.stringify(prov[field]) === JSON.stringify(preset[field])) continue;
1264
- if (preset[field] !== undefined) {
1265
- prov[field] = cloneProviderField(preset[field]) as never;
1266
- } else {
1267
- delete prov[field];
1278
+ if (def && prov.authMode === "oauth") {
1279
+ const preset = def.providerConfig;
1280
+ for (const field of OAUTH_RECONCILE_FIELDS) {
1281
+ if (JSON.stringify(prov[field]) === JSON.stringify(preset[field])) continue;
1282
+ if (preset[field] !== undefined) {
1283
+ prov[field] = cloneProviderField(preset[field]) as never;
1284
+ } else {
1285
+ delete prov[field];
1286
+ }
1287
+ }
1288
+ if (prov.liveModels === undefined && preset.liveModels !== undefined) {
1289
+ prov.liveModels = preset.liveModels;
1290
+ }
1291
+ // Heal a defaultModel that no longer exists in the refreshed list (e.g. a deprecated snapshot).
1292
+ // Skip providers without a static preset `models` list: for live-discovery providers
1293
+ // (e.g. command-code OAuth) the account-scoped catalog is not enumerable here, so any
1294
+ // persisted defaultModel is a user selection and must not be overwritten by the seed.
1295
+ if (prov.defaultModel && preset.defaultModel && preset.models && preset.models.length > 0 && !(prov.models ?? []).includes(prov.defaultModel)) {
1296
+ prov.defaultModel = preset.defaultModel;
1268
1297
  }
1269
- changed = true;
1270
- }
1271
- if (prov.liveModels === undefined && preset.liveModels !== undefined) {
1272
- prov.liveModels = preset.liveModels;
1273
- changed = true;
1274
1298
  }
1275
- // Heal a defaultModel that no longer exists in the refreshed list (e.g. a deprecated snapshot).
1276
- // Skip providers without a static preset `models` list: for live-discovery providers
1277
- // (e.g. command-code OAuth) the account-scoped catalog is not enumerable here, so any
1278
- // persisted defaultModel is a user selection and must not be overwritten by the seed.
1279
- if (prov.defaultModel && preset.defaultModel && preset.models && preset.models.length > 0 && !(prov.models ?? []).includes(prov.defaultModel)) {
1280
- prov.defaultModel = preset.defaultModel;
1299
+ if (JSON.stringify(prov) !== beforeProvider) {
1281
1300
  changed = true;
1301
+ touchedProviders.add(name);
1282
1302
  }
1283
1303
  }
1284
- if (changed) saveConfig(config);
1285
- return changed;
1304
+
1305
+ return {
1306
+ config: projected,
1307
+ changed,
1308
+ touchedProviders: [...touchedProviders],
1309
+ touchedAntigravityVersion,
1310
+ };
1311
+ }
1312
+
1313
+ /**
1314
+ * Copy only the keys the projection actually touched back onto the caller's live object.
1315
+ *
1316
+ * Deliberately key-by-key rather than a wholesale clear-and-reassign: a live reference held
1317
+ * elsewhere to an untouched provider sub-object must survive startup reconciliation.
1318
+ */
1319
+ function adoptOAuthReconciliation(config: OcxConfig, projection: OAuthReconcileProjection): void {
1320
+ for (const name of projection.touchedProviders) {
1321
+ const provider = projection.config.providers[name];
1322
+ if (provider) config.providers[name] = structuredClone(provider);
1323
+ else delete config.providers[name];
1324
+ }
1325
+ if (projection.touchedAntigravityVersion) {
1326
+ config.googleAntigravityStaticCatalogVersion = projection.config.googleAntigravityStaticCatalogVersion;
1327
+ }
1328
+ }
1329
+
1330
+ /**
1331
+ * Union the keys the on-disk rebase touched with the keys the live projection touched.
1332
+ *
1333
+ * The rebase runs against the persisted snapshot, which may already carry a reconciliation
1334
+ * another process committed. Adopting only its touched set would leave the live object stale
1335
+ * for a key it decided was already correct on disk.
1336
+ */
1337
+ function withOAuthReconciliationTouchedKeys(
1338
+ projection: OAuthReconcileProjection,
1339
+ required: OAuthReconcileProjection,
1340
+ ): OAuthReconcileProjection {
1341
+ return {
1342
+ ...projection,
1343
+ touchedProviders: [...new Set([...projection.touchedProviders, ...required.touchedProviders])],
1344
+ touchedAntigravityVersion: projection.touchedAntigravityVersion || required.touchedAntigravityVersion,
1345
+ };
1346
+ }
1347
+
1348
+ /**
1349
+ * Refresh OAuth provider presets against the registry, rebasing the write on the persisted config.
1350
+ *
1351
+ * This runs on the boot path (`startServer`), so persistence failure must never be fatal: a
1352
+ * missing, malformed or contended config degrades to a warning plus an in-memory adopt, exactly
1353
+ * as every other `mutatePersistedConfig` consumer does (`src/storage/policy.ts`,
1354
+ * `src/codex/plan-from-token.ts`, `src/server/management/agent-settings-routes.ts`). Throwing
1355
+ * here would take the whole proxy down over a config file the operator can still repair.
1356
+ */
1357
+ export function reconcileOAuthProviders(config: OcxConfig, persist = true): boolean {
1358
+ const projection = projectOAuthProviderReconciliation(config);
1359
+ if (!projection.changed) return false;
1360
+ if (!persist) {
1361
+ adoptOAuthReconciliation(config, projection);
1362
+ return true;
1363
+ }
1364
+ const outcome = mutatePersistedConfig(fresh => {
1365
+ const next = projectOAuthProviderReconciliation(fresh);
1366
+ if (next.changed) adoptOAuthReconciliation(fresh, next);
1367
+ return { changed: next.changed, value: next };
1368
+ });
1369
+ if (outcome.status === "unavailable") {
1370
+ console.warn(
1371
+ `[opencodex] OAuth provider reconciliation could not be persisted (${outcome.reason}); `
1372
+ + "applying it in memory for this run only.",
1373
+ );
1374
+ adoptOAuthReconciliation(config, projection);
1375
+ return true;
1376
+ }
1377
+ adoptOAuthReconciliation(config, withOAuthReconciliationTouchedKeys(outcome.value, projection));
1378
+ return true;
1286
1379
  }
1287
1380
 
1288
1381
  /** Runtime guards: provider config is intentionally passthrough, so persisted fields may be malformed. */
@@ -1353,6 +1446,13 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
1353
1446
  if (existing?.commandCodeVersion !== undefined) {
1354
1447
  next.commandCodeVersion = existing.commandCodeVersion;
1355
1448
  }
1449
+ // Reauth/add-account refreshes credentials, not the operator's post-upgrade wire choice.
1450
+ if (provider === "xai") {
1451
+ if (existing?.modelAdapters !== undefined) next.modelAdapters = { ...existing.modelAdapters };
1452
+ if (existing?.xaiResponsesDefaultVersion !== undefined) {
1453
+ next.xaiResponsesDefaultVersion = existing.xaiResponsesDefaultVersion;
1454
+ }
1455
+ }
1356
1456
  // User-configured price overlays are operator data, not preset state; a
1357
1457
  // re-login, add-account, or reauth must not silently drop them from the
1358
1458
  // Logs/Usage estimates.
@@ -1389,6 +1489,7 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
1389
1489
  if (previousModeAllowsKey) next.authMode = "key";
1390
1490
  }
1391
1491
  }
1492
+ initializeProviderModelSelection(provider, next, existing, config);
1392
1493
  config.providers[provider] = next;
1393
1494
  }
1394
1495
 
@@ -1,4 +1,6 @@
1
1
  import * as readline from "node:readline";
2
+ import { modelSelectionGuidance } from "../cli/model-selection-guidance";
3
+ import { initializeProviderModelSelection } from "../providers/initial-model-selection";
2
4
  import { openUrl } from "../lib/open-url";
3
5
  import { loadConfig, saveConfig } from "../config";
4
6
  import { findLiveProxy } from "../server/proxy-liveness";
@@ -93,6 +95,7 @@ async function handleOAuthLogin(name: string): Promise<void> {
93
95
  }
94
96
  const reload = await notifyRunningProxyAfterOAuthLogin(name);
95
97
  console.log(`\n✅ Logged in to ${name}. Try: ocx sync`);
98
+ for (const line of modelSelectionGuidance(name)) console.log(line);
96
99
  warnIfLiveReloadSkipped(reload);
97
100
  }
98
101
 
@@ -156,6 +159,7 @@ export async function commitKeyLoginProvider(
156
159
  onLiveReload?: (result: LocalProviderReloadResult | null) => void,
157
160
  ): Promise<OcxProviderConfig> {
158
161
  const mergedProvider = mergeKeyLoginProviderRow(provider, config.providers[name]);
162
+ initializeProviderModelSelection(name, mergedProvider, config.providers[name], config);
159
163
  config.providers[name] = mergedProvider;
160
164
  saveConfig(config);
161
165
  // Evaluate the reload BEFORE the optional call: `onLiveReload?.(await ...)` short-circuits
@@ -211,6 +215,7 @@ async function handleKeyLogin(name: string): Promise<void> {
211
215
  let reload: LocalProviderReloadResult | null = null;
212
216
  await commitKeyLoginProvider(config, name, provider, result => { reload = result; });
213
217
  console.log(`✅ ${def.label} added. Try: ocx sync`);
218
+ for (const line of modelSelectionGuidance(name)) console.log(line);
214
219
  warnIfLiveReloadSkipped(reload);
215
220
  }
216
221
 
@@ -45,7 +45,7 @@ const CONSENT_WARNING = [
45
45
  "Meta scopes the Muse Code credential to the Muse Code CLI.",
46
46
  "Using it here is UNSUPPORTED: Meta does not authorize subscription coverage outside its own CLI,",
47
47
  "how these calls settle is not observable from the API, and you should treat every call as billable.",
48
- "The imported key is copied into OpenCodex's auth store (~/.opencodex/auth.json, 0600).",
48
+ "The key you import or paste is copied into OpenCodex's auth store (~/.opencodex/auth.json, 0600).",
49
49
  "Supported alternative: the meta-model provider with your own key (META_MODEL_API_KEY).",
50
50
  ].join(" ");
51
51
 
@@ -114,6 +114,43 @@ async function defaultReadKeychain(signal?: AbortSignal): Promise<string | null>
114
114
  const INSTALL_HINT =
115
115
  "Install it from https://dev.meta.ai/install.sh, run `muse login`, then retry.";
116
116
 
117
+ /**
118
+ * Where a user without the CLI gets a key by hand.
119
+ *
120
+ * The Muse Code API key is visible in Meta's own developer console, so a host with no
121
+ * CLI is not out of options ??it is out of an IMPORT path. That distinction is the whole
122
+ * reason this branch exists: refusing a platform because our importer cannot read its
123
+ * store, while the vendor hands the same key to the user in a browser, is a limitation
124
+ * of the importer being reported as a limitation of the platform.
125
+ */
126
+ const MANUAL_KEY_URL = "https://dev.meta.ai";
127
+
128
+ /**
129
+ * Accept a hand-entered Muse Code API key.
130
+ *
131
+ * Every guarantee the import path makes still holds here, because they are enforced
132
+ * BELOW this function rather than inside it: the same `LLM|` grammar check, the same
133
+ * live validation against the Model API, and the same consent warning, which has
134
+ * already fired before any of this runs. What is missing is only the pointer, so the
135
+ * credential carries no email and `source` is `manual` rather than `local-cli`.
136
+ */
137
+ async function manualKeyCredential(
138
+ ctrl: OAuthController,
139
+ reason: string,
140
+ ): Promise<string | null> {
141
+ if (!ctrl.onManualCodeInput) return null;
142
+ // Resolve the login flow first so the GUI renders its paste field; otherwise
143
+ // onManualCodeInput blocks and the dashboard never sees a response (kiro.ts:405).
144
+ ctrl.onAuth?.({
145
+ url: MANUAL_KEY_URL,
146
+ instructions:
147
+ `${reason} Sign in at ${MANUAL_KEY_URL}, copy your Muse Code API key, and paste it below.`,
148
+ });
149
+ ctrl.onProgress?.(`Paste a Muse Code API key from ${MANUAL_KEY_URL} (it starts with "LLM|").`);
150
+ const pasted = (await ctrl.onManualCodeInput()).trim();
151
+ return pasted.length > 0 ? pasted : null;
152
+ }
153
+
117
154
  function normalizedEmail(value: unknown): string | undefined {
118
155
  if (typeof value !== "string") return undefined;
119
156
  const trimmed = value.trim().toLowerCase();
@@ -133,11 +170,29 @@ export async function loginMetaMuse(
133
170
  ctrl.onProgress?.(CONSENT_WARNING);
134
171
 
135
172
  const platform = deps.platform ?? process.platform;
173
+ // Off darwin there is no store this importer can read: Meta ships no native Windows
174
+ // CLI, and the Linux credential shape has never been measured. That is a limitation
175
+ // of the IMPORT, not of the platform ??the same key is visible in Meta's console ??
176
+ // so these hosts get a paste field instead of a dead end. The pasted key then goes
177
+ // through the identical grammar check and live validation as an imported one.
136
178
  if (platform !== "darwin") {
137
- throw new Error(
138
- "Meta Muse Code login is macOS-only: the CLI stores its credential in the macOS Keychain, "
139
- + "and no other platform's storage has been verified. Use the meta-model provider with your own key instead.",
140
- );
179
+ // The two platforms are unavailable for DIFFERENT reasons, and saying so matters:
180
+ // Meta ships no Windows build at all, while the Linux CLI exists and only its
181
+ // credential storage is unmeasured. Collapsing them into "no CLI here" would tell
182
+ // a Linux user something false about their own machine.
183
+ const reason = platform === "win32"
184
+ ? "Meta ships no native Windows Muse Code CLI, so there is no credential to import."
185
+ : "The Muse Code CLI runs here, but where it stores its credential has not been measured, "
186
+ + "so importing one is refused rather than guessed.";
187
+ const pasted = await manualKeyCredential(ctrl, reason);
188
+ if (pasted === null) {
189
+ throw new Error(
190
+ `${reason} This client cannot prompt for a key, so run \`ocx login meta-muse\` from the CLI `
191
+ + `or the dashboard and paste yours from ${MANUAL_KEY_URL}, `
192
+ + "or use the meta-model provider with your own key (META_MODEL_API_KEY).",
193
+ );
194
+ }
195
+ return await validatedMetaMuseCredential(pasted, ctrl, deps, undefined, "manual");
141
196
  }
142
197
 
143
198
  const pointerRaw = await (deps.readPointer ?? defaultReadPointer)();
@@ -178,15 +233,48 @@ export async function loginMetaMuse(
178
233
  }
179
234
 
180
235
  // access_token is present but 401s against the Model API (003 §B) — never fall back to it.
181
- const apiKey = sanitizeApiKeyValue(secret.api_key);
236
+ return await validatedMetaMuseCredential(
237
+ secret.api_key,
238
+ ctrl,
239
+ deps,
240
+ normalizedEmail(meta.user_email),
241
+ "local-cli",
242
+ );
243
+ }
244
+
245
+ /**
246
+ * The single gate every credential passes, imported or pasted.
247
+ *
248
+ * Both paths share it deliberately. A pasted key that skipped the grammar check or the
249
+ * live validation would be a weaker credential wearing the same provider id, and the
250
+ * difference would surface only as a 401 in the middle of a session.
251
+ */
252
+ async function validatedMetaMuseCredential(
253
+ candidate: unknown,
254
+ ctrl: OAuthController,
255
+ deps: MuseImportDeps,
256
+ email: string | undefined,
257
+ source: "local-cli" | "manual",
258
+ ): Promise<OAuthCredentials> {
259
+ const retry = source === "manual"
260
+ ? `Copy it again from ${MANUAL_KEY_URL}.`
261
+ : "Run `muse login` again.";
262
+ const apiKey = sanitizeApiKeyValue(candidate);
182
263
  if (!apiKey) {
183
- throw new Error("The Muse Code Keychain entry carries no usable API key. Run `muse login` again.");
264
+ // The macOS wording is preserved verbatim. Extraction is a refactor, and a refactor
265
+ // that quietly rewrites a user-facing error is a behavior change in disguise.
266
+ throw new Error(source === "manual"
267
+ ? `The pasted Muse Code credential carries no usable API key. ${retry}`
268
+ : "The Muse Code Keychain entry carries no usable API key. Run `muse login` again.");
184
269
  }
185
270
  if (!/^LLM\|\d+\|[A-Za-z0-9_-]{10,}$/.test(apiKey)) {
186
- throw new Error("The Muse Code credential is not in the expected Meta API key format. Run `muse login` again.");
271
+ throw new Error(source === "manual"
272
+ ? `The pasted Muse Code credential is not in the expected Meta API key format. ${retry}`
273
+ : "The Muse Code credential is not in the expected Meta API key format. Run `muse login` again.");
187
274
  }
188
-
189
- ctrl.onProgress?.("Validating the imported Meta credential");
275
+ ctrl.onProgress?.(source === "manual"
276
+ ? "Validating the pasted Meta credential..."
277
+ : "Validating the imported Meta credential...");
190
278
  const fetchImpl = deps.fetchImpl ?? fetch;
191
279
  // ctrl.signal is OPTIONAL and the CLI controller supplies none: AbortSignal.any([undefined])
192
280
  // throws a TypeError, which would fail every CLI login right after the warning printed.
@@ -205,7 +293,7 @@ export async function loginMetaMuse(
205
293
  }
206
294
  if (!response.ok) {
207
295
  throw new Error(
208
- `The Muse Code credential was rejected by the Meta Model API (HTTP ${response.status}). Run \`muse login\` again.`,
296
+ `The Muse Code credential was rejected by the Meta Model API (HTTP ${response.status}). ${retry}`,
209
297
  );
210
298
  }
211
299
 
@@ -216,8 +304,8 @@ export async function loginMetaMuse(
216
304
  expires: Number.MAX_SAFE_INTEGER,
217
305
  // `email`, not `accountId`: the account list masks email for display, and store.ts
218
306
  // already falls back to it for slot identity, so multi-account still works.
219
- ...(normalizedEmail(meta.user_email) ? { email: normalizedEmail(meta.user_email) } : {}),
220
- source: "local-cli",
307
+ ...(email ? { email } : {}),
308
+ source,
221
309
  };
222
310
  }
223
311
 
@@ -228,8 +316,22 @@ export async function loginMetaMuse(
228
316
  * the slot being refreshed, so if the user ran `muse login` with a DIFFERENT account in
229
317
  * between, a re-import would silently overwrite one stored identity with another. Only an
230
318
  * explicit login may import.
319
+ *
320
+ * It also does not assert a source. `merged()` in index.ts keeps the previous source for
321
+ * anything that is not `local-cli`, so returning `local-cli` here would relabel a
322
+ * hand-pasted key as an imported one on its first refresh and misreport where the
323
+ * credential came from.
231
324
  */
232
- export async function refreshMetaMuseToken(apiKey: string): Promise<OAuthCredentials> {
325
+ export async function refreshMetaMuseToken(
326
+ apiKey: string,
327
+ _signal?: AbortSignal,
328
+ credential?: OAuthCredentials,
329
+ ): Promise<OAuthCredentials> {
233
330
  if (!apiKey) throw new Error("Meta Muse Code API key missing; run `ocx login meta-muse`");
234
- return { access: apiKey, refresh: apiKey, expires: Number.MAX_SAFE_INTEGER, source: "local-cli" };
331
+ return {
332
+ access: apiKey,
333
+ refresh: apiKey,
334
+ expires: Number.MAX_SAFE_INTEGER,
335
+ source: credential?.source === "manual" ? "manual" : "local-cli",
336
+ };
235
337
  }
@@ -6,8 +6,13 @@ import type { OcxProviderConfig } from "../types";
6
6
  *
7
7
  * `codex` and `anthropic` keep their own routes and storage untouched. `generic` is every
8
8
  * other OAuth provider the generic failover module admits; its settings persist on
9
- * `providers.<name>.oauthAccountFailover`. Settings stored for a generic provider are a
10
- * declared contract the selector can consume in a later slice; today they change nothing.
9
+ * `providers.<name>.oauthAccountFailover`.
10
+ *
11
+ * `strategy` and `autoSwitchThreshold` are still a declared contract the selector does not
12
+ * consume — that is what `inert` reports. `enabled` is NOT inert any more: an explicit
13
+ * `false` refuses the pre-dispatch account preference (`preferredInitialAccount`). What it can
14
+ * no longer do is refuse reactive 429 rotation, which activates on account presence and is not
15
+ * disableable.
11
16
  */
12
17
  export type PoolSettingsKind = "codex" | "anthropic" | "generic";
13
18
 
@@ -37,7 +42,14 @@ export interface GenericPoolSettingsDto {
37
42
  enabled: boolean | null;
38
43
  strategy: GenericPoolStrategy | null;
39
44
  autoSwitchThreshold: number | null;
40
- /** Slice-1 marker: persisted, not yet consumed by the selector. */
45
+ /**
46
+ * Slice-1 marker for `strategy` and `autoSwitchThreshold` only: persisted, not yet consumed
47
+ * by the selector.
48
+ *
49
+ * It deliberately does NOT describe `enabled`, which governs the pre-dispatch preference.
50
+ * Widening it to the whole DTO would tell a dashboard that `enabled` changes nothing, which
51
+ * has been false since reactive and proactive activation were split.
52
+ */
41
53
  inert: true;
42
54
  }
43
55
 
@@ -52,4 +64,3 @@ export function genericPoolSettingsDto(name: string, provider: OcxProviderConfig
52
64
  inert: true,
53
65
  };
54
66
  }
55
-
@@ -4,13 +4,14 @@
4
4
  * `provider.apiKey` stays the single source of truth for routing — it always mirrors the
5
5
  * ACTIVE pool entry, so the router/adapters never learn about the pool. The pool itself
6
6
  * lives in `provider.apiKeyPool` in config.json (same file that already holds apiKey).
7
- * A provider with a legacy bare `apiKey` is seeded into a one-entry pool on first touch.
7
+ * A legacy bare `apiKey` is projected as one row on reads and seeded on first mutation.
8
8
  */
9
9
  import { createHash } from "node:crypto";
10
10
  import { saveConfigPreservingClaudeCode } from "../config";
11
11
  import type { OcxConfig, OcxProviderConfig } from "../types";
12
+ import type { AccountQuotaFields } from "./quota-types";
12
13
 
13
- export interface ProviderApiKeyInfo {
14
+ export interface ProviderApiKeyInfo extends AccountQuotaFields {
14
15
  id: string;
15
16
  label?: string;
16
17
  /** First/last 4 chars only; env references (`${VAR}`) are shown verbatim (not secrets). */
@@ -57,17 +58,14 @@ function ensurePool(provider: OcxProviderConfig): NonNullable<OcxProviderConfig[
57
58
  return provider.apiKeyPool;
58
59
  }
59
60
 
60
- function activeEntryId(provider: OcxProviderConfig): string | null {
61
- const pool = provider.apiKeyPool ?? [];
62
- if (pool.length === 0) return null;
63
- return (pool.find(e => e.key === provider.apiKey) ?? pool[0]!).id;
64
- }
65
-
66
61
  export function listProviderApiKeys(config: OcxConfig, name: string): { activeId: string | null; keys: ProviderApiKeyInfo[] } {
67
62
  const provider = config.providers[name];
68
63
  if (!provider || !isKeyAuthProvider(provider)) return { activeId: null, keys: [] };
69
- const pool = ensurePool(provider);
70
- const activeId = activeEntryId(provider);
64
+ // A GET projects a legacy key without seeding/mutating live configuration.
65
+ const pool = provider.apiKeyPool?.length
66
+ ? provider.apiKeyPool
67
+ : provider.apiKey ? [{ id: apiKeyPoolEntryId(provider.apiKey), key: provider.apiKey }] : [];
68
+ const activeId = (pool.find(entry => entry.key === provider.apiKey) ?? pool[0])?.id ?? null;
71
69
  return {
72
70
  activeId,
73
71
  keys: pool.map(entry => ({
@@ -1,3 +1,42 @@
1
+
2
+ import { PROVIDER_REGISTRY } from "./registry";
3
+
4
+ export function effectiveProviderAlias(
5
+ providerName: string,
6
+ provider?: Pick<OcxProviderConfig, "alias">,
7
+ config?: Pick<OcxConfig, "providers">,
8
+ ): string | undefined {
9
+ if (provider && provider.alias !== undefined) {
10
+ const trimmed = provider.alias.trim();
11
+ return trimmed.length > 0 ? trimmed : undefined;
12
+ }
13
+ const regAlias = PROVIDER_REGISTRY.find(e => e.id === providerName)?.alias;
14
+ if (!regAlias) return undefined;
15
+ if (config?.providers) {
16
+ const lower = regAlias.toLowerCase();
17
+ const claimedByOther = Object.entries(config.providers).some(([name, p]) =>
18
+ name !== providerName && typeof p.alias === "string" && p.alias.trim().toLowerCase() === lower
19
+ );
20
+ if (claimedByOther) return undefined;
21
+ }
22
+ return regAlias;
23
+ }
24
+
25
+ export function effectiveProviderAliasDecision(
26
+ providerName: string,
27
+ provider?: Pick<OcxProviderConfig, "alias">,
28
+ config?: Pick<OcxConfig, "providers">,
29
+ ): string | null | undefined {
30
+ const active = effectiveProviderAlias(providerName, provider, config);
31
+ if (active !== undefined) return active;
32
+ const hasRegistryAlias = Boolean(PROVIDER_REGISTRY.find(e => e.id === providerName)?.alias);
33
+ const hasConfiguredAlias = provider?.alias !== undefined;
34
+ if (hasRegistryAlias || hasConfiguredAlias) {
35
+ return null;
36
+ }
37
+ return undefined;
38
+ }
39
+
1
40
  import type { OcxConfig, OcxProviderConfig } from "../types";
2
41
 
3
42
  export const MODEL_ALIAS_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
@@ -218,6 +218,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon
218
218
  baseUrl: entry.baseUrl,
219
219
  ...(entry.apiKeyTransport !== undefined ? { apiKeyTransport: entry.apiKeyTransport } : {}),
220
220
  ...(entry.responsesPath ? { responsesPath: entry.responsesPath } : {}),
221
+ ...(entry.alias ? { alias: entry.alias } : {}),
221
222
  // Preserve the registry auth kind verbatim (including "local") so fail-closed gates that
222
223
  // distinguish local runtimes from API-key providers keep working after the seed round-trip.
223
224
  authMode: entry.authKind,
@@ -403,7 +404,7 @@ function serviceTierModelDefaultsFor(
403
404
  * The catalog hint pass must not read PROVIDER_REGISTRY: a gather flight captures its registry
404
405
  * authority up front and forbids any later read, so consulting the registry per model turned
405
406
  * every hint pass into a post-lookup read and dropped a custom-destination flight's own
406
- * discovery result (tests/codex-gather-authority.test.ts).
407
+ * discovery result (tests/codex-integration/codex-gather-authority.test.ts).
407
408
  *
408
409
  * Registry values go in first so an explicit user entry still wins, matching
409
410
  * `applyReasoningSummaryDefaults`. `supportsVerbosity` is the provider-wide default, expanded
@@ -490,7 +491,14 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig
490
491
  if ((!prov.reasoningEfforts || hasLegacyClinePassReasoningEfforts(name, prov)) && seed.reasoningEfforts) {
491
492
  prov.reasoningEfforts = [...seed.reasoningEfforts];
492
493
  }
493
- if (!prov.modelReasoningEfforts && seed.modelReasoningEfforts) prov.modelReasoningEfforts = cloneRecordOfArrays(seed.modelReasoningEfforts);
494
+ // Per-model fill for the same reason as modelInputModalities above: an all-or-nothing
495
+ // copy let ONE customized model hide the registry's ladder for every other model on the
496
+ // provider. That split the two planes apart — routing merges these maps per key
497
+ // (mergeRecordFill in src/router.ts), so the wire honored the effort while /v1/models and
498
+ // every client export showed no effort control at all.
499
+ if (seed.modelReasoningEfforts) {
500
+ prov.modelReasoningEfforts = fillRecordOfArrays(seed.modelReasoningEfforts, prov.modelReasoningEfforts);
501
+ }
494
502
  if (!prov.modelDefaultReasoningEfforts && seed.modelDefaultReasoningEfforts) prov.modelDefaultReasoningEfforts = { ...seed.modelDefaultReasoningEfforts };
495
503
  if (!prov.reasoningEffortMap && seed.reasoningEffortMap) prov.reasoningEffortMap = { ...seed.reasoningEffortMap };
496
504
  if (!prov.modelReasoningEffortMap && seed.modelReasoningEffortMap) prov.modelReasoningEffortMap = cloneNestedRecord(seed.modelReasoningEffortMap);