@bitkyc08/opencodex 2.41.0 → 2.43.0-preview.20260906

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 (260) 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-VGeQEZ_v.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 +54 -24
  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/identity.ts +8 -2
  26. package/src/adapters/kiro-calibration.ts +181 -0
  27. package/src/adapters/kiro.ts +135 -3
  28. package/src/adapters/openai-responses.ts +259 -29
  29. package/src/adapters/responses-code-mode.ts +59 -0
  30. package/src/adapters/tool-catalog-nudge.ts +1 -1
  31. package/src/adapters/xai-schema-analysis.ts +86 -0
  32. package/src/adapters/xai-tool-schema.ts +2 -87
  33. package/src/adapters/xai-web-search.ts +1 -1
  34. package/src/bridge.ts +47 -13
  35. package/src/chat/inbound.ts +11 -3
  36. package/src/claude/inbound-content-options.ts +60 -0
  37. package/src/claude/inbound-model-options.ts +142 -0
  38. package/src/claude/inbound-records.ts +7 -0
  39. package/src/claude/inbound.ts +10 -202
  40. package/src/claude/model-info.ts +45 -0
  41. package/src/cli/account-auth.ts +49 -9
  42. package/src/cli/account-extended.ts +7 -1
  43. package/src/cli/capabilities.ts +15 -4
  44. package/src/cli/claude.ts +232 -39
  45. package/src/cli/config-command.ts +9 -1
  46. package/src/cli/dispatch.ts +5 -1
  47. package/src/cli/doctor.ts +10 -0
  48. package/src/cli/effort.ts +372 -0
  49. package/src/cli/export-command.ts +3 -9
  50. package/src/cli/help.ts +1 -0
  51. package/src/cli/index.ts +13 -0
  52. package/src/cli/init.ts +4 -0
  53. package/src/cli/model-selection-guidance.ts +30 -0
  54. package/src/cli/models-runtime.ts +3 -2
  55. package/src/cli/models.ts +8 -3
  56. package/src/cli/observe.ts +3 -1
  57. package/src/cli/opencode.ts +4 -1
  58. package/src/cli/provider-runtime.ts +65 -0
  59. package/src/cli/provider.ts +8 -0
  60. package/src/cli/registry.ts +16 -2
  61. package/src/cli/runtime-api.ts +3 -1
  62. package/src/cli/star-prompt.ts +22 -6
  63. package/src/cli/status-probes.ts +168 -0
  64. package/src/cli/status.ts +5 -168
  65. package/src/clients/config-export/constants.ts +69 -0
  66. package/src/clients/config-export/contracts.ts +154 -0
  67. package/src/clients/config-export/dsh.ts +132 -0
  68. package/src/clients/config-export/fast-models.ts +29 -0
  69. package/src/clients/config-export/mcode.ts +83 -0
  70. package/src/clients/config-export/model-metadata.ts +108 -0
  71. package/src/clients/config-export/omp.ts +104 -0
  72. package/src/clients/config-export/zcode.ts +92 -0
  73. package/src/clients/config-export.ts +18 -710
  74. package/src/codex/account-lifecycle.ts +20 -3
  75. package/src/codex/account-usability.ts +2 -0
  76. package/src/codex/auth-api.ts +272 -32
  77. package/src/codex/auth-context.ts +328 -24
  78. package/src/codex/catalog/effort.ts +44 -5
  79. package/src/codex/catalog/metadata.ts +149 -14
  80. package/src/codex/catalog/native-models.ts +116 -4
  81. package/src/codex/catalog/parsing.ts +122 -8
  82. package/src/codex/catalog/provider-fetch.ts +154 -23
  83. package/src/codex/catalog/reserve.ts +52 -0
  84. package/src/codex/catalog/sync.ts +89 -16
  85. package/src/codex/catalog.ts +1 -1
  86. package/src/codex/convergence-types.ts +1 -0
  87. package/src/codex/convergence.ts +2 -0
  88. package/src/codex/data/upstream-models.json +169 -0
  89. package/src/codex/forward-transport-headers.ts +25 -0
  90. package/src/codex/inject.ts +99 -34
  91. package/src/codex/injected-marker.ts +30 -4
  92. package/src/codex/journal.ts +14 -0
  93. package/src/codex/legacy-config-keys.ts +68 -0
  94. package/src/codex/log-guard/inspect-schema.ts +137 -0
  95. package/src/codex/log-guard/inspect.ts +2 -134
  96. package/src/codex/loopback-target.ts +54 -0
  97. package/src/codex/main-account-cache.ts +63 -1
  98. package/src/codex/main-account-hard-lock.ts +52 -0
  99. package/src/codex/main-account.ts +3 -1
  100. package/src/codex/management-convergence.ts +3 -0
  101. package/src/codex/model-entitlements.ts +54 -4
  102. package/src/codex/project-config-warnings.ts +92 -2
  103. package/src/codex/prompt-layers/encoding.ts +80 -0
  104. package/src/codex/prompt-layers/paths.ts +54 -0
  105. package/src/codex/prompt-layers/revision.ts +55 -0
  106. package/src/codex/prompt-layers/toml-edit.ts +163 -0
  107. package/src/codex/prompt-layers/toml-read.ts +181 -0
  108. package/src/codex/prompt-layers.ts +14 -520
  109. package/src/codex/quota-auto-refresh-state.ts +16 -0
  110. package/src/codex/quota-auto-refresh.ts +219 -0
  111. package/src/codex/quota-types.ts +51 -0
  112. package/src/codex/quota.ts +252 -93
  113. package/src/codex/reserve-availability.ts +177 -0
  114. package/src/codex/routing.ts +28 -9
  115. package/src/codex/shim.ts +53 -11
  116. package/src/codex/subagent-model-fallback.ts +23 -3
  117. package/src/combos/failover.ts +125 -7
  118. package/src/combos/identifiers.ts +89 -0
  119. package/src/combos/index.ts +4 -0
  120. package/src/combos/resolve.ts +80 -9
  121. package/src/combos/types.ts +20 -93
  122. package/src/config/subagent-models.ts +24 -0
  123. package/src/config.ts +156 -13
  124. package/src/generated/compatibility-version.json +474 -178
  125. package/src/generated/model-metadata.ts +1 -1
  126. package/src/integrations/journal.ts +65 -4
  127. package/src/integrations/store.ts +5 -0
  128. package/src/lab/events/limits.ts +4 -0
  129. package/src/lib/destination-policy.ts +31 -2
  130. package/src/lib/errors.ts +39 -0
  131. package/src/lib/provider-outbound.ts +69 -3
  132. package/src/lib/proxy-env.ts +22 -0
  133. package/src/lib/redact-folding.ts +176 -0
  134. package/src/lib/redact.ts +2 -175
  135. package/src/lib/state-store-sweeper.ts +20 -6
  136. package/src/lib/token-estimate.ts +94 -27
  137. package/src/lib/windows-user-principal.ts +53 -5
  138. package/src/oauth/account-quota-rank.ts +40 -1
  139. package/src/oauth/anthropic-routing.ts +99 -3
  140. package/src/oauth/chatgpt-device.ts +187 -0
  141. package/src/oauth/chatgpt.ts +31 -4
  142. package/src/oauth/generic-account-failover.ts +36 -13
  143. package/src/oauth/index.ts +140 -29
  144. package/src/oauth/log.ts +3 -0
  145. package/src/oauth/login-cli.ts +5 -0
  146. package/src/oauth/meta-muse.ts +117 -15
  147. package/src/oauth/pool-settings-capability.ts +15 -4
  148. package/src/providers/api-keys.ts +8 -10
  149. package/src/providers/default-aliases.ts +39 -0
  150. package/src/providers/derive.ts +10 -2
  151. package/src/providers/fastwire.ts +36 -7
  152. package/src/providers/initial-model-selection-runtime.ts +90 -0
  153. package/src/providers/initial-model-selection.ts +120 -0
  154. package/src/providers/key-failover.ts +134 -54
  155. package/src/providers/key-store.ts +11 -1
  156. package/src/providers/label.ts +1 -1
  157. package/src/providers/model-discovery.ts +76 -0
  158. package/src/providers/model-rename-startup.ts +72 -8
  159. package/src/providers/muse-subscription-usage.ts +95 -0
  160. package/src/providers/openai-sidecar.ts +17 -5
  161. package/src/providers/openai-tiers-destination.ts +102 -0
  162. package/src/providers/openai-tiers.ts +2 -99
  163. package/src/providers/opencode-go-transport.ts +41 -0
  164. package/src/providers/quota-key-accounts.ts +141 -0
  165. package/src/providers/quota-types.ts +9 -0
  166. package/src/providers/quota.ts +625 -98
  167. package/src/providers/registry.ts +60 -17
  168. package/src/providers/xai-responses-opt-in.ts +31 -5
  169. package/src/quota/reset-activation.ts +81 -0
  170. package/src/quota/reset-detector.ts +305 -0
  171. package/src/quota/reset-notify-config.ts +162 -0
  172. package/src/quota/reset-observer.ts +125 -0
  173. package/src/quota/reset-poller.ts +160 -0
  174. package/src/quota/reset-seen-store.ts +385 -0
  175. package/src/quota/reset-sinks.ts +199 -0
  176. package/src/quota/window-mapping.ts +106 -0
  177. package/src/responses/apply-patch-envelope.ts +46 -0
  178. package/src/responses/code-mode-helper-compat.ts +39 -1
  179. package/src/responses/custom-tool-compat.ts +10 -4
  180. package/src/responses/hosted-tool-policy.ts +12 -4
  181. package/src/responses/parser-content.ts +133 -0
  182. package/src/responses/parser-text-format.ts +24 -0
  183. package/src/responses/parser-tools.ts +188 -0
  184. package/src/responses/parser.ts +3 -326
  185. package/src/responses/state.ts +124 -28
  186. package/src/router.ts +48 -13
  187. package/src/routing/analytics.ts +1 -0
  188. package/src/routing/capability.ts +17 -4
  189. package/src/server/auth-cors.ts +7 -1
  190. package/src/server/background-lifecycle.ts +23 -1
  191. package/src/server/chat-completions.ts +25 -3
  192. package/src/server/claude-messages.ts +62 -5
  193. package/src/server/effort-row.ts +1 -1
  194. package/src/server/fast-row.ts +295 -0
  195. package/src/server/gui-static.ts +30 -4
  196. package/src/server/index.ts +122 -28
  197. package/src/server/live.ts +18 -4
  198. package/src/server/management/agent-settings-routes.ts +2 -2
  199. package/src/server/management/combo-routes.ts +37 -9
  200. package/src/server/management/config-routes.ts +93 -2
  201. package/src/server/management/integration-routes.ts +108 -0
  202. package/src/server/management/model-routes.ts +13 -3
  203. package/src/server/management/model-rows.ts +20 -1
  204. package/src/server/management/native-integration-routes.ts +4 -1
  205. package/src/server/management/oauth-account-routes.ts +45 -10
  206. package/src/server/management/provider-routes.ts +34 -3
  207. package/src/server/management/quota-reset-routes.ts +57 -0
  208. package/src/server/management/route-registry.ts +7 -4
  209. package/src/server/management/shared.ts +19 -5
  210. package/src/server/management/system-routes.ts +3 -2
  211. package/src/server/management-api.ts +14 -2
  212. package/src/server/ports.ts +12 -2
  213. package/src/server/relay-eager.ts +38 -23
  214. package/src/server/relay.ts +4 -0
  215. package/src/server/request-log.ts +6 -0
  216. package/src/server/responses/codex-ws-correlation.ts +65 -0
  217. package/src/server/responses/codex-ws-exchange.ts +261 -0
  218. package/src/server/responses/codex-ws-metadata.ts +134 -0
  219. package/src/server/responses/codex-ws-pool.ts +162 -0
  220. package/src/server/responses/codex-ws-request.ts +87 -0
  221. package/src/server/responses/codex-ws-session.ts +93 -0
  222. package/src/server/responses/codex-ws-wire.ts +144 -0
  223. package/src/server/responses/collaboration.ts +41 -1
  224. package/src/server/responses/compact.ts +105 -12
  225. package/src/server/responses/core.ts +510 -57
  226. package/src/server/responses/empty-completion-guard.ts +4 -0
  227. package/src/server/responses/fetch-helpers.ts +10 -3
  228. package/src/server/responses/input-admission.ts +16 -9
  229. package/src/server/responses/responses-field-backfill.ts +1 -1
  230. package/src/server/responses/ws-upstream.ts +34 -318
  231. package/src/server/responses-custom-tool-repair.ts +20 -4
  232. package/src/server/responses-undeclared-tool-guard.ts +100 -8
  233. package/src/server/safe-response-headers.ts +23 -0
  234. package/src/server/search.ts +9 -0
  235. package/src/server/subagent-models-startup.ts +27 -0
  236. package/src/server/system-env-shell.ts +238 -0
  237. package/src/server/system-env.ts +7 -234
  238. package/src/server/ws-bridge.ts +3 -25
  239. package/src/server/xai-responses-startup.ts +21 -0
  240. package/src/service-manager-probe.ts +1 -1
  241. package/src/service.ts +55 -16
  242. package/src/types/config.ts +108 -12
  243. package/src/types/provider.ts +36 -7
  244. package/src/types/request.ts +8 -0
  245. package/src/types/tools.ts +26 -1
  246. package/src/types.ts +2 -0
  247. package/src/update/notify.ts +8 -2
  248. package/src/usage/cost.ts +38 -28
  249. package/src/usage/expected-prices.ts +34 -15
  250. package/src/usage/log.ts +2 -0
  251. package/src/usage/model-identity.ts +26 -0
  252. package/src/usage/summary.ts +15 -1
  253. package/src/vision/describe.ts +6 -0
  254. package/src/vision/image-rewrite.ts +108 -0
  255. package/src/vision/index.ts +19 -306
  256. package/src/vision/plan.ts +205 -0
  257. package/src/web-search/executor.ts +6 -0
  258. package/src/web-search/index.ts +8 -1
  259. package/gui/dist/assets/index-B2YjLA-i.css +0 -1
  260. package/gui/dist/assets/index-aPup8CKb.js +0 -112
@@ -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";
@@ -34,7 +35,7 @@ import { loginXai, refreshXaiToken, XAI_LOCAL_CLI_DETACH_WARNING, XaiTokenReques
34
35
  import { ANTHROPIC_OAUTH_BETA, AnthropicTokenError, loginAnthropic, refreshAnthropicToken } from "./anthropic";
35
36
  import { loginKimi, refreshKimiToken } from "./kimi";
36
37
  import { loginNous, NousTokenError, refreshNousToken, clearNousRefreshIntent, RefreshIntentIOError } from "./nous";
37
- import { loginChatGPT, refreshChatGPTToken } from "./chatgpt";
38
+ import { loginChatGPT, refreshChatGPTToken, type ChatGPTLoginFlow } from "./chatgpt";
38
39
  import { loginAntigravity, refreshAntigravityToken } from "./google-antigravity";
39
40
  import { loginCursor, refreshCursorToken } from "./cursor";
40
41
  import { loginGithubCopilot, refreshGithubCopilotToken, validateCopilotApiBaseUrl } from "./github-copilot";
@@ -161,7 +162,17 @@ function verdictKey(p:string,a:string,c:OAuthCredentials){return `${p}\0${a}\0${
161
162
  function cached(p:string,a:string,c:OAuthCredentials,now:()=>number){const k=verdictKey(p,a,c),u=permanentRefreshFailures.get(k);if(u===undefined)return false;if(u<=now()){permanentRefreshFailures.delete(k);return false;}return true;}
162
163
  export function sweepExpiredXaiPermanentFailureVerdicts(now=Date.now()):number{let removed=0;for(const[key,until]of permanentRefreshFailures){if(until>now)continue;permanentRefreshFailures.delete(key);removed+=1;}return removed;}
163
164
 
164
- export interface LoginOpts { forceLogin?: boolean; /** When set, persist into this account slot and require matching identity. */ reauthAccountId?: string }
165
+ export interface LoginOpts {
166
+ forceLogin?: boolean;
167
+ /** When set, persist into this account slot and require matching identity. */
168
+ reauthAccountId?: string;
169
+ /**
170
+ * ChatGPT only: `device` selects the deviceauth grant instead of the
171
+ * localhost:1455 callback flow, for hosts with no browser or no loopback
172
+ * listener (#3366). Ignored by every other provider.
173
+ */
174
+ flow?: ChatGPTLoginFlow;
175
+ }
165
176
 
166
177
  export interface LoginFlowLifecycle {
167
178
  /** Runs after background credential/config persistence settles, before status becomes done. */
@@ -282,7 +293,7 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
282
293
  defaultRefreshPolicy: "lazy-only",
283
294
  },
284
295
  chatgpt: {
285
- login: loginChatGPT,
296
+ login: (ctrl, opts) => loginChatGPT(ctrl, { forceLogin: opts?.forceLogin, flow: opts?.flow }),
286
297
  refresh: (rt) => refreshChatGPTToken(rt),
287
298
  providerConfig: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const },
288
299
  defaultModel: "gpt-5.4",
@@ -1237,42 +1248,134 @@ function migrateLegacyAntigravityStaticCatalog(config: OcxConfig): boolean {
1237
1248
  return true;
1238
1249
  }
1239
1250
 
1240
- export function reconcileOAuthProviders(config: OcxConfig): boolean {
1241
- let changed = migrateLegacyAntigravityStaticCatalog(config);
1242
- 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);
1243
1272
  const def = OAUTH_PROVIDERS[name];
1244
1273
  if (name === "command-code" && isLegacyCommandCodeStaticCatalog(prov)) {
1245
1274
  // The former experimental preset was the exact three-model seed above. It was not a user
1246
1275
  // choice to disable discovery, so promote only that shape to the account live catalog.
1247
1276
  prov.liveModels = true;
1248
- changed = true;
1249
1277
  }
1250
- if (!def || prov.authMode !== "oauth") continue;
1251
- const preset = def.providerConfig;
1252
- for (const field of OAUTH_RECONCILE_FIELDS) {
1253
- if (JSON.stringify(prov[field]) === JSON.stringify(preset[field])) continue;
1254
- if (preset[field] !== undefined) {
1255
- prov[field] = cloneProviderField(preset[field]) as never;
1256
- } else {
1257
- 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;
1258
1297
  }
1259
- changed = true;
1260
- }
1261
- if (prov.liveModels === undefined && preset.liveModels !== undefined) {
1262
- prov.liveModels = preset.liveModels;
1263
- changed = true;
1264
1298
  }
1265
- // Heal a defaultModel that no longer exists in the refreshed list (e.g. a deprecated snapshot).
1266
- // Skip providers without a static preset `models` list: for live-discovery providers
1267
- // (e.g. command-code OAuth) the account-scoped catalog is not enumerable here, so any
1268
- // persisted defaultModel is a user selection and must not be overwritten by the seed.
1269
- if (prov.defaultModel && preset.defaultModel && preset.models && preset.models.length > 0 && !(prov.models ?? []).includes(prov.defaultModel)) {
1270
- prov.defaultModel = preset.defaultModel;
1299
+ if (JSON.stringify(prov) !== beforeProvider) {
1271
1300
  changed = true;
1301
+ touchedProviders.add(name);
1272
1302
  }
1273
1303
  }
1274
- if (changed) saveConfig(config);
1275
- 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;
1276
1379
  }
1277
1380
 
1278
1381
  /** Runtime guards: provider config is intentionally passthrough, so persisted fields may be malformed. */
@@ -1343,6 +1446,13 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
1343
1446
  if (existing?.commandCodeVersion !== undefined) {
1344
1447
  next.commandCodeVersion = existing.commandCodeVersion;
1345
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
+ }
1346
1456
  // User-configured price overlays are operator data, not preset state; a
1347
1457
  // re-login, add-account, or reauth must not silently drop them from the
1348
1458
  // Logs/Usage estimates.
@@ -1379,6 +1489,7 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
1379
1489
  if (previousModeAllowsKey) next.authMode = "key";
1380
1490
  }
1381
1491
  }
1492
+ initializeProviderModelSelection(provider, next, existing, config);
1382
1493
  config.providers[provider] = next;
1383
1494
  }
1384
1495
 
package/src/oauth/log.ts CHANGED
@@ -23,6 +23,9 @@ const FORBIDDEN_NORMALIZED = new Set([
23
23
  "oauth_code",
24
24
  "code_verifier",
25
25
  "clientsecret",
26
+ // Device-flow polling handle. Not a token, but it is the bearer of an
27
+ // in-flight authorization and must not be logged.
28
+ "device_auth_id",
26
29
  ]);
27
30
 
28
31
  function isForbiddenFieldKey(key: string): boolean {
@@ -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);