@bitkyc08/opencodex 2.52.0 → 2.53.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 (236) hide show
  1. package/gui/dist/assets/index-BBOZWGB6.css +1 -0
  2. package/gui/dist/assets/index-BlO4Yl6q.js +128 -0
  3. package/gui/dist/index.html +2 -2
  4. package/native/remote-workspace-helper/Cargo.lock +130 -0
  5. package/native/remote-workspace-helper/Cargo.toml +24 -0
  6. package/native/remote-workspace-helper/src/main.rs +49 -0
  7. package/native/remote-workspace-helper/src/protocol.rs +246 -0
  8. package/native/remote-workspace-helper/src/sandbox/macos.rs +19 -0
  9. package/native/remote-workspace-helper/src/sandbox/mod.rs +77 -0
  10. package/native/remote-workspace-helper/src/sandbox/windows.rs +15 -0
  11. package/package.json +6 -1
  12. package/src/adapters/anthropic-image-normalize.ts +30 -2
  13. package/src/adapters/anthropic.ts +1 -1
  14. package/src/adapters/base.ts +8 -2
  15. package/src/adapters/cursor/cursor-errors.ts +12 -0
  16. package/src/adapters/cursor/thread-continuity.ts +93 -0
  17. package/src/adapters/cursor.ts +104 -73
  18. package/src/adapters/devin/cloud-direct/chat.ts +312 -23
  19. package/src/adapters/devin/cloud-direct/metadata.ts +31 -2
  20. package/src/adapters/devin/live-models.ts +70 -3
  21. package/src/adapters/devin.ts +281 -21
  22. package/src/adapters/google-wire-compiler.ts +14 -6
  23. package/src/adapters/google.ts +22 -8
  24. package/src/adapters/kiro/adapter.ts +316 -0
  25. package/src/adapters/kiro/conversation.ts +136 -0
  26. package/src/adapters/kiro/payload.ts +432 -0
  27. package/src/adapters/kiro/reasoning.ts +56 -0
  28. package/src/adapters/kiro/stream.ts +1153 -0
  29. package/src/adapters/kiro/usage.ts +223 -0
  30. package/src/adapters/kiro/wire.ts +76 -0
  31. package/src/adapters/kiro.ts +8 -2319
  32. package/src/adapters/mimo-free.ts +1 -1
  33. package/src/adapters/openai-chat-images.ts +101 -0
  34. package/src/adapters/openai-chat.ts +201 -181
  35. package/src/adapters/openai-responses.ts +92 -224
  36. package/src/adapters/registry.ts +0 -7
  37. package/src/adapters/run-turn-queue.ts +13 -6
  38. package/src/bridge.ts +14 -15
  39. package/src/chat/inbound.ts +29 -4
  40. package/src/chat/outbound.ts +145 -107
  41. package/src/claude/desktop-profile.ts +4 -6
  42. package/src/cli/account-api.ts +14 -0
  43. package/src/cli/account-extended.ts +1 -1
  44. package/src/cli/account-history.ts +60 -0
  45. package/src/cli/account-main.ts +80 -0
  46. package/src/cli/account.ts +11 -3
  47. package/src/cli/capabilities.ts +113 -0
  48. package/src/cli/catalog.ts +109 -0
  49. package/src/cli/dispatch.ts +9 -0
  50. package/src/cli/help.ts +2 -0
  51. package/src/cli/index.ts +2 -2
  52. package/src/cli/observe.ts +28 -1
  53. package/src/cli/opencode.ts +42 -8
  54. package/src/cli/provider-runtime.ts +11 -1
  55. package/src/cli/provider.ts +22 -2
  56. package/src/cli/registry.ts +21 -0
  57. package/src/cli/remote-workspace.ts +154 -0
  58. package/src/cli/status.ts +39 -7
  59. package/src/cli/usage-report.ts +14 -2
  60. package/src/client/hub-client.ts +34 -0
  61. package/src/client/hub-state.ts +9 -1
  62. package/src/codex/account-store.ts +78 -0
  63. package/src/codex/auth-api.ts +81 -54
  64. package/src/codex/auth-context.ts +45 -16
  65. package/src/codex/catalog/effort.ts +1 -1
  66. package/src/codex/catalog/metadata.ts +3 -6
  67. package/src/codex/catalog/native-models.ts +4 -4
  68. package/src/codex/catalog/parsing.ts +2 -20
  69. package/src/codex/catalog/provider-fetch.ts +10 -1
  70. package/src/codex/catalog/remote.ts +233 -0
  71. package/src/codex/catalog/sync.ts +403 -35
  72. package/src/codex/convergence.ts +1 -1
  73. package/src/codex/history-manifest.ts +36 -0
  74. package/src/codex/history-provider.ts +32 -5
  75. package/src/codex/inject.ts +9 -0
  76. package/src/codex/main-account.ts +113 -0
  77. package/src/codex/main-device-reauth-api.ts +89 -0
  78. package/src/codex/main-device-reauth.ts +217 -0
  79. package/src/codex/native-residue.ts +9 -2
  80. package/src/codex/quota-auto-refresh.ts +3 -2
  81. package/src/codex/quota-capacity.ts +98 -0
  82. package/src/codex/quota-history.ts +160 -0
  83. package/src/codex/quota-types.ts +8 -0
  84. package/src/codex/quota.ts +118 -91
  85. package/src/codex/refresh.ts +2 -1
  86. package/src/codex/routing.ts +90 -17
  87. package/src/codex/sync.ts +33 -4
  88. package/src/combos/request.ts +19 -1
  89. package/src/config/multi-agent-surface.ts +61 -0
  90. package/src/config/provider-validation.ts +176 -0
  91. package/src/config.ts +213 -11
  92. package/src/generated/compatibility-version.json +436 -168
  93. package/src/images/loop.ts +119 -36
  94. package/src/lib/admission.ts +12 -6
  95. package/src/lib/redact.ts +7 -0
  96. package/src/lib/translator-budget.ts +4 -3
  97. package/src/lib/windows-atomic-replace.ts +1 -0
  98. package/src/lib/windows-elevation.ts +1 -1
  99. package/src/oauth/chatgpt-device.ts +62 -5
  100. package/src/oauth/devin/cli-import.ts +130 -0
  101. package/src/oauth/devin.ts +63 -8
  102. package/src/oauth/index.ts +29 -14
  103. package/src/oauth/kiro.ts +18 -6
  104. package/src/oauth/login-cli.ts +9 -1
  105. package/src/oauth/meta-muse-device.ts +464 -0
  106. package/src/oauth/meta-muse.ts +123 -32
  107. package/src/oauth/pool-kernel.ts +9 -0
  108. package/src/oauth/pool-settings-capability.ts +2 -2
  109. package/src/oauth/store.ts +57 -0
  110. package/src/oauth/types.ts +31 -0
  111. package/src/providers/derive.ts +13 -3
  112. package/src/providers/devin-cli-authmode-migration.ts +57 -35
  113. package/src/providers/devin-provider-merge-migration.ts +240 -0
  114. package/src/providers/muse-key-quota.ts +117 -0
  115. package/src/providers/muse-subscription-usage.ts +14 -2
  116. package/src/providers/openai-sidecar.ts +25 -3
  117. package/src/providers/opencode-zen-rate-limit.ts +58 -0
  118. package/src/providers/provider-id-rewrite.ts +20 -5
  119. package/src/providers/quota-types.ts +12 -0
  120. package/src/providers/quota.ts +143 -102
  121. package/src/providers/reasoning-metadata.ts +543 -0
  122. package/src/providers/registry.ts +80 -49
  123. package/src/reasoning-effort.ts +26 -2
  124. package/src/remote/hub-usage.ts +32 -0
  125. package/src/remote-control/index.ts +192 -41
  126. package/src/remote-control/workspace-activation.ts +9 -0
  127. package/src/remote-control/workspace-agent-connection.ts +366 -0
  128. package/src/remote-control/workspace-claude-runtime.ts +243 -0
  129. package/src/remote-control/workspace-codex-runtime.ts +531 -0
  130. package/src/remote-control/workspace-codex-sandbox.ts +115 -0
  131. package/src/remote-control/workspace-command-runner.ts +748 -0
  132. package/src/remote-control/workspace-coordinator.ts +231 -0
  133. package/src/remote-control/workspace-device.ts +585 -0
  134. package/src/remote-control/workspace-executable.ts +43 -0
  135. package/src/remote-control/workspace-executor.ts +397 -0
  136. package/src/remote-control/workspace-hub.ts +519 -0
  137. package/src/remote-control/workspace-pi-runtime.ts +382 -0
  138. package/src/remote-control/workspace-process.ts +129 -0
  139. package/src/remote-control/workspace-rpc.ts +304 -0
  140. package/src/remote-control/workspace-runtime.ts +60 -0
  141. package/src/remote-control/workspace-secret-store.ts +39 -0
  142. package/src/remote-control/workspace-sessions.ts +799 -0
  143. package/src/remote-control/workspace-tool-bridge.ts +192 -0
  144. package/src/responses/code-mode-helper-compat.ts +22 -3
  145. package/src/responses/hosted-tool-policy.ts +0 -1
  146. package/src/responses/muse-tool-name-alias.ts +379 -0
  147. package/src/responses/plaintext-v2-agent-messages.ts +902 -0
  148. package/src/router.ts +7 -0
  149. package/src/routing/compatibility/behavior.ts +0 -1
  150. package/src/server/audio-client.ts +64 -0
  151. package/src/server/audio-dictation.ts +91 -0
  152. package/src/server/audio-live.ts +185 -0
  153. package/src/server/audio-transcriptions.ts +183 -0
  154. package/src/server/audio-upstream.ts +153 -0
  155. package/src/server/auth-cors.ts +61 -2
  156. package/src/server/chat-completions.ts +1 -1
  157. package/src/server/chat-native-sse.ts +92 -48
  158. package/src/server/chat-native.ts +37 -15
  159. package/src/server/hub-usage.ts +57 -0
  160. package/src/server/images.ts +4 -0
  161. package/src/server/index.ts +722 -57
  162. package/src/server/lifecycle.ts +5 -6
  163. package/src/server/live-call-bindings.ts +60 -0
  164. package/src/server/live.ts +12 -1
  165. package/src/server/management/agent-settings-routes.ts +25 -4
  166. package/src/server/management/api-access.ts +37 -0
  167. package/src/server/management/api-key-usage.ts +7 -2
  168. package/src/server/management/config-routes.ts +1 -18
  169. package/src/server/management/context.ts +15 -0
  170. package/src/server/management/logs-usage-routes.ts +2 -0
  171. package/src/server/management/oauth-account-routes.ts +39 -12
  172. package/src/server/management/provider-routes.ts +125 -2
  173. package/src/server/management/remote-workspace-routes.ts +140 -0
  174. package/src/server/management/route-registry.ts +15 -0
  175. package/src/server/management/usage-aggregate-cache.ts +14 -15
  176. package/src/server/management/usage-summary-cache.ts +2 -0
  177. package/src/server/management-api.ts +23 -0
  178. package/src/server/ports.ts +17 -0
  179. package/src/server/relay-eager.ts +4 -1
  180. package/src/server/relay.ts +70 -10
  181. package/src/server/request-decompress.ts +6 -3
  182. package/src/server/responses/agent-task-recovery.ts +25 -32
  183. package/src/server/responses/codex-auth-error.ts +11 -0
  184. package/src/server/responses/codex-ws-exchange.ts +52 -3
  185. package/src/server/responses/codex-ws-wire.ts +55 -0
  186. package/src/server/responses/compact.ts +9 -1
  187. package/src/server/responses/core.ts +337 -73
  188. package/src/server/responses/encrypted-payload.ts +45 -2
  189. package/src/server/responses/ws-upstream.ts +4 -1
  190. package/src/server/responses-self-named-namespace-scrub.ts +1 -3
  191. package/src/server/responses-undeclared-tool-guard.ts +1 -1
  192. package/src/server/search.ts +3 -0
  193. package/src/server/sse-payload-rewrite.ts +136 -51
  194. package/src/server/ws-bridge.ts +35 -1
  195. package/src/service/cli.ts +372 -0
  196. package/src/service/diagnostics.ts +340 -0
  197. package/src/service/guards.ts +303 -0
  198. package/src/service/health.ts +222 -0
  199. package/src/service/launchd.ts +853 -0
  200. package/src/service/orchestration.ts +617 -0
  201. package/src/service/repair.ts +334 -0
  202. package/src/service/state.ts +363 -0
  203. package/src/service/systemd.ts +229 -0
  204. package/src/service/windows-ops.ts +690 -0
  205. package/src/service/windows-scheduler.ts +769 -0
  206. package/src/service/windows-taskxml.ts +613 -0
  207. package/src/service.ts +22 -5550
  208. package/src/storage/cleanup/db.ts +258 -0
  209. package/src/storage/cleanup/execute.ts +358 -0
  210. package/src/storage/cleanup/paths.ts +189 -0
  211. package/src/storage/cleanup/pending.ts +140 -0
  212. package/src/storage/cleanup/preview.ts +292 -0
  213. package/src/storage/cleanup/reconcile.ts +347 -0
  214. package/src/storage/cleanup/restore.ts +932 -0
  215. package/src/storage/cleanup/satellite.ts +474 -0
  216. package/src/storage/cleanup/staging.ts +129 -0
  217. package/src/storage/cleanup/types.ts +98 -0
  218. package/src/storage/cleanup.ts +49 -3127
  219. package/src/types/accounts.ts +2 -0
  220. package/src/types/config.ts +13 -12
  221. package/src/types/provider.ts +37 -0
  222. package/src/types/request.ts +2 -0
  223. package/src/types/tools.ts +17 -5
  224. package/src/types.ts +1 -0
  225. package/src/usage/expected-prices.ts +127 -0
  226. package/src/usage/log.ts +58 -1
  227. package/src/vision/eligibility.ts +13 -2
  228. package/src/web-search/loop.ts +56 -3
  229. package/gui/dist/assets/index-CWXut3rG.js +0 -115
  230. package/gui/dist/assets/index-EdoPnm9_.css +0 -1
  231. package/src/adapters/devin-cli/acp.ts +0 -204
  232. package/src/adapters/devin-cli/adapter.ts +0 -345
  233. package/src/adapters/devin-cli/binary.ts +0 -69
  234. package/src/adapters/devin-cli/models.ts +0 -57
  235. package/src/oauth/devin-cli.ts +0 -149
  236. package/src/server/responses-reasoning-summary-rewrite.ts +0 -178
@@ -10,7 +10,7 @@ import { clearAccountNeedsReauth, isAccountNeedsReauth, markAccountNeedsReauth }
10
10
  import {
11
11
  POOL_KEY_CODEX,
12
12
  normalizeAccountPoolStickyLimit,
13
- normalizeAccountPoolStrategy,
13
+ normalizeCodexAccountPoolStrategy,
14
14
  notePoolRotationFailure,
15
15
  notePoolRotationSuccess,
16
16
  peekRoundRobinAccount,
@@ -18,7 +18,13 @@ import {
18
18
  seedPoolRotationAccount,
19
19
  selectPriorityTier,
20
20
  } from "./pool-rotation";
21
- import { CODEX_EXHAUSTED_USAGE_PERCENT, CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota, resetAtToMs } from "./quota";
21
+ import {
22
+ CODEX_EXHAUSTED_USAGE_PERCENT,
23
+ CODEX_UNKNOWN_USAGE_SCORE,
24
+ getAccountQuota,
25
+ isRetiredCodexSparkModel,
26
+ resetAtToMs,
27
+ } from "./quota";
22
28
  import { codexPlanKey, isThirtyDayOnlyCodexPlan } from "./plan";
23
29
  import {
24
30
  MAIN_CODEX_ACCOUNT_ID,
@@ -199,7 +205,7 @@ export type CodexCooldownSource = "retry-after" | "reset-derived" | "default";
199
205
  * Add a new explicit group here only when its independent upstream quota is
200
206
  * confirmed, so shared limits never receive cross-model bypasses.
201
207
  */
202
- export type CodexQuotaScope = "shared" | "spark" | "reserve";
208
+ export type CodexQuotaScope = "shared" | "reserve";
203
209
 
204
210
  export type CodexQuotaRecoveryProbeClaim = {
205
211
  accountId: string;
@@ -218,7 +224,7 @@ export type CodexQuotaRecoveryProbeProof = {
218
224
  /**
219
225
  * Requests without a resolved native model retain the historic one-account-per-
220
226
  * thread behavior. Requests with a known quota scope get an independent
221
- * affinity so a Spark failover cannot displace the same thread's Terra/Luna
227
+ * affinity so a Reserve failover cannot displace the same thread's Terra/Luna
222
228
  * account (and vice versa).
223
229
  */
224
230
  type BaseThreadAffinityScope = CodexQuotaScope | "legacy";
@@ -233,7 +239,6 @@ function isModelDetourAffinityScope(scope: ThreadAffinityScope): scope is ModelD
233
239
  }
234
240
 
235
241
  const NATIVE_MODEL_QUOTA_SCOPES: Readonly<Record<string, CodexQuotaScope>> = {
236
- "gpt-5.3-codex-spark": "spark",
237
242
  [NATIVE_RESERVE_MODEL]: "reserve",
238
243
  };
239
244
 
@@ -688,7 +693,7 @@ export function claimDueCodexQuotaRecoveryProbes(
688
693
  { scope: undefined, health: upstreamHealth.get(account.id) },
689
694
  ...[...(quotaScopedHealth.get(account.id) ?? [])].map(([scope, health]) => ({ scope, health })),
690
695
  ].filter((entry): entry is { scope?: CodexQuotaScope; health: CodexUpstreamHealth } =>
691
- // Generic WHAM evidence can recover only ordinary quota, never Spark or Reserve.
696
+ // Generic WHAM evidence can recover only ordinary quota, never Reserve.
692
697
  // Do not spend this account's one claim per pass on an independent scope and
693
698
  // delay the shared scope that the response can actually recover.
694
699
  (entry.scope === undefined || entry.scope === "shared")
@@ -1169,7 +1174,7 @@ function excludedCodexPoolPlanKeys(config: OcxConfig): ReadonlySet<string> | und
1169
1174
  * selection-only drain so routing never reads the fenced native credential for it, so a rule that
1170
1175
  * covered main would disagree with itself between drain and ordinary routing.
1171
1176
  */
1172
- function isCodexAccountPlanExcluded(
1177
+ export function isCodexAccountPlanExcluded(
1173
1178
  config: OcxConfig,
1174
1179
  accountId: string,
1175
1180
  precomputed?: ReadonlySet<string>,
@@ -1474,6 +1479,12 @@ function listEligibleCodexAccountIds(
1474
1479
  return getEligiblePoolAccounts(config, undefined, now, quotaScope, selectionOptions);
1475
1480
  }
1476
1481
 
1482
+ /** Shared reset timestamps are not evidence for independent model-quota groups. */
1483
+ function accountPoolStrategyForScope(config: OcxConfig, quotaScope?: CodexQuotaScope) {
1484
+ const strategy = normalizeCodexAccountPoolStrategy(config.accountPoolStrategy);
1485
+ return strategy === "reset-first" && isIndependentCodexQuotaScope(quotaScope) ? "quota" : strategy;
1486
+ }
1487
+
1477
1488
  function stickyLimitForConfig(config: OcxConfig): number {
1478
1489
  return normalizeAccountPoolStickyLimit(config.accountPoolStickyLimit);
1479
1490
  }
@@ -1505,6 +1516,32 @@ function hasCodexQuotaHeadroom(
1505
1516
  return usage < threshold;
1506
1517
  }
1507
1518
 
1519
+ /** Earliest future shared short/weekly reset; missing evidence and ties use usage order. */
1520
+ function pickResetFirstCodexAccount(
1521
+ config: OcxConfig,
1522
+ ids: readonly string[],
1523
+ now: number,
1524
+ selectionOptions?: CodexAccountUsabilityOptions,
1525
+ ): string | null {
1526
+ const available = ids.filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now));
1527
+ if (available.length === 0) return pickLowestUsageAmong(config, ids, selectionOptions, now);
1528
+ let earliest = Number.POSITIVE_INFINITY;
1529
+ let candidates: string[] = [];
1530
+ for (const id of available) {
1531
+ const quota = getAccountQuota(id);
1532
+ const resets = [quota?.shortResetAt, quota?.weeklyResetAt]
1533
+ .filter((reset): reset is number => typeof reset === "number" && Number.isFinite(reset))
1534
+ .map(resetAtToMs)
1535
+ .filter(reset => reset > now);
1536
+ const next = Math.min(...resets);
1537
+ if (next < earliest) {
1538
+ earliest = next;
1539
+ candidates = [id];
1540
+ } else if (next === earliest) candidates.push(id);
1541
+ }
1542
+ return pickLowestUsageAmong(config, candidates, selectionOptions, now);
1543
+ }
1544
+
1508
1545
  /**
1509
1546
  * Fill-first: keep selectable active under threshold; otherwise advance to the next
1510
1547
  * eligible id in stable sorted order after the current active (wrapping).
@@ -1595,7 +1632,7 @@ function pickUnboundStrategyAccount(
1595
1632
  commitSharedActive = commit,
1596
1633
  commitAffinity = commit,
1597
1634
  ): string | null {
1598
- const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
1635
+ const strategy = accountPoolStrategyForScope(config, quotaScope);
1599
1636
  if (strategy === "quota") return null;
1600
1637
  const poolKey = codexPoolKeyForScope(quotaScope);
1601
1638
 
@@ -1619,8 +1656,10 @@ function pickUnboundStrategyAccount(
1619
1656
  return picked;
1620
1657
  }
1621
1658
 
1622
- if (strategy === "fill-first") {
1623
- picked = pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions);
1659
+ if (strategy === "fill-first" || strategy === "reset-first") {
1660
+ picked = strategy === "reset-first"
1661
+ ? pickResetFirstCodexAccount(config, listEligibleCodexAccountIds(config, now, quotaScope, selectionOptions), now, selectionOptions)
1662
+ : pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions);
1624
1663
  if (!picked) return null;
1625
1664
  if (commitSharedActive) {
1626
1665
  if (!isIndependentCodexQuotaScope(quotaScope)
@@ -1753,7 +1792,7 @@ export function pickAlternateCodexAccount(
1753
1792
  quotaScope?: CodexQuotaScope,
1754
1793
  selectionOptions?: CodexAccountUsabilityOptions,
1755
1794
  ): string | null {
1756
- const strategy = normalizeAccountPoolStrategy(config.accountPoolStrategy);
1795
+ const strategy = accountPoolStrategyForScope(config, quotaScope);
1757
1796
  // The exclusion is passed into eligibility rather than post-filtered off its
1758
1797
  // result: when the excluded account is the only healthy member of the top
1759
1798
  // tier, the tier walk must be free to descend instead of selecting that tier
@@ -1766,6 +1805,9 @@ export function pickAlternateCodexAccount(
1766
1805
  const eligible = getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions);
1767
1806
  return pickNextFillFirstCodexAccount(config, excludeId, eligible, now, selectionOptions);
1768
1807
  }
1808
+ if (strategy === "reset-first") {
1809
+ return pickResetFirstCodexAccount(config, getEligiblePoolAccounts(config, excludeId, now, quotaScope, selectionOptions), now, selectionOptions);
1810
+ }
1769
1811
  return pickLowestUsageCodexAccount(config, excludeId, now, quotaScope, selectionOptions);
1770
1812
  }
1771
1813
 
@@ -1862,7 +1904,7 @@ function setActiveCodexAccount(config: OcxConfig, accountId: string): void {
1862
1904
 
1863
1905
  /** Quota strategy persists; RR/fill-first keep a process-local cursor only. */
1864
1906
  function promoteActiveCodexAccount(config: OcxConfig, accountId: string): void {
1865
- if (normalizeAccountPoolStrategy(config.accountPoolStrategy) === "quota") {
1907
+ if (normalizeCodexAccountPoolStrategy(config.accountPoolStrategy) === "quota") {
1866
1908
  setActiveCodexAccount(config, accountId);
1867
1909
  return;
1868
1910
  }
@@ -2147,9 +2189,12 @@ function previewReusableAffinityAccount(
2147
2189
  ) {
2148
2190
  return null;
2149
2191
  }
2192
+ if (accountPoolStrategyForScope(config, quotaScope) === "reset-first") {
2193
+ return resetFirstAffinityReplacement(entry, config, now, quotaScope, selectionOptions) ?? entry.accountId;
2194
+ }
2150
2195
  // Quota strategy only: non-quota strategies keep affinity for ongoing threads
2151
2196
  // (new-session-only rotation — docs / affinity policy A).
2152
- if (normalizeAccountPoolStrategy(config.accountPoolStrategy) === "quota") {
2197
+ if (accountPoolStrategyForScope(config, quotaScope) === "quota") {
2153
2198
  const threshold = config.autoSwitchThreshold ?? 80;
2154
2199
  if (threshold > 0) {
2155
2200
  const usage = computeCodexUsageScore(
@@ -2202,6 +2247,23 @@ function mayRebindAffinityForQuota(
2202
2247
  || (!isUnknownUsage(usage) && usage >= 100);
2203
2248
  }
2204
2249
 
2250
+ /** Reset ordering may move a binding only under the existing cache-affinity release policy. */
2251
+ function resetFirstAffinityReplacement(
2252
+ entry: ThreadAffinityEntry,
2253
+ config: OcxConfig,
2254
+ now: number,
2255
+ quotaScope?: CodexQuotaScope,
2256
+ selectionOptions?: CodexAccountUsabilityOptions,
2257
+ ): string | null {
2258
+ const threshold = config.autoSwitchThreshold ?? 80;
2259
+ if (threshold <= 0) return null;
2260
+ const usage = computeCodexUsageScore(getAccountQuota(entry.accountId), getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions), now);
2261
+ if (!mayRebindAffinityForQuota(config, entry.accountId, usage, threshold, selectionOptions)) return null;
2262
+ const candidates = getEligiblePoolAccounts(config, entry.accountId, now, quotaScope, selectionOptions, true)
2263
+ .filter(id => hasCodexQuotaHeadroom(config, id, selectionOptions, now));
2264
+ return pickResetFirstCodexAccount(config, candidates, now, selectionOptions);
2265
+ }
2266
+
2205
2267
  /**
2206
2268
  * Re-evaluate an affined account under the quota strategy. Returns a strictly
2207
2269
  * cooler replacement, or null when the current binding should remain.
@@ -2213,7 +2275,13 @@ function reevaluateAffinityQuota(
2213
2275
  quotaScope?: CodexQuotaScope,
2214
2276
  selectionOptions?: CodexAccountUsabilityOptions,
2215
2277
  ): string | null {
2216
- if (normalizeAccountPoolStrategy(config.accountPoolStrategy) !== "quota") return null;
2278
+ const strategy = accountPoolStrategyForScope(config, quotaScope);
2279
+ if (strategy === "reset-first") {
2280
+ const replacement = resetFirstAffinityReplacement(entry, config, now, quotaScope, selectionOptions);
2281
+ if (replacement || now - entry.lastReevalAt >= CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS) entry.lastReevalAt = now;
2282
+ return replacement;
2283
+ }
2284
+ if (strategy !== "quota") return null;
2217
2285
  const threshold = config.autoSwitchThreshold ?? 80;
2218
2286
  const usage = threshold > 0
2219
2287
  ? computeCodexUsageScore(
@@ -2306,6 +2374,7 @@ export function previewCodexAccountForRequest(
2306
2374
  else if (
2307
2375
  hasConfiguredPoolAccount(config, active, selectionOptions)
2308
2376
  && !isCodexAccountPaused(config, active)
2377
+ && !isCodexAccountPlanExcluded(config, active)
2309
2378
  ) return active;
2310
2379
  else return null;
2311
2380
  }
@@ -2440,7 +2509,7 @@ export function resolveCodexAccountForThreadDetailed(
2440
2509
  const cooler = reevaluateAffinityQuota(entry, config, now, quotaScope, selectionOptions);
2441
2510
  if (cooler) {
2442
2511
  if (!isIndependentCodexQuotaScope(quotaScope)) {
2443
- setActiveCodexAccount(config, cooler);
2512
+ promoteActiveCodexAccount(config, cooler);
2444
2513
  }
2445
2514
  bindThreadAffinity(threadId, cooler, now, quotaScope); // rebinds + resets clocks
2446
2515
  return { status: "selected", accountId: cooler };
@@ -2548,6 +2617,7 @@ export function resolveCodexAccountForThreadDetailed(
2548
2617
  } else if (
2549
2618
  hasConfiguredPoolAccount(config, active, selectionOptions)
2550
2619
  && !isCodexAccountPaused(config, active)
2620
+ && !isCodexAccountPlanExcluded(config, active)
2551
2621
  ) {
2552
2622
  return { status: "selected", accountId: active };
2553
2623
  } else {
@@ -2628,6 +2698,9 @@ export function recordCodexUpstreamOutcome(
2628
2698
  if (writerGeneration < lastReconciledGeneration && !liveHealthAccountIds.has(accountId)) return;
2629
2699
  const now = meta.now ?? Date.now();
2630
2700
  const outcomeClass = classifyCodexUpstreamOutcome(outcome, meta.denial);
2701
+ // Reject retired quota evidence before stale-credential cleanup or any shared mutation.
2702
+ if (outcomeClass === "quota" && isRetiredCodexSparkModel(meta.modelId)
2703
+ && computeQuotaCooldown(meta).source === "reset-derived") return;
2631
2704
  const quotaScope = codexQuotaScopeForModel(meta.modelId);
2632
2705
  /*
2633
2706
  * Spend a stale credential failure BEFORE any branch reads health (#2892 gap 4 review).
@@ -2799,7 +2872,7 @@ export function recordCodexUpstreamOutcome(
2799
2872
  const { until, source } = computeQuotaCooldown(meta);
2800
2873
  // A reset timestamp is an advisory quota-window announcement. When the
2801
2874
  // selected native model belongs to a confirmed independent group, preserve
2802
- // it there so a different group (Spark versus the shared native quota) can
2875
+ // it there so a different group (Reserve versus the shared native quota) can
2803
2876
  // still reach upstream. Explicit Retry-After/default 429s remain account-wide.
2804
2877
  if (source === "reset-derived" && quotaScope) {
2805
2878
  const prior = scopedHealthFor(accountId, quotaScope);
@@ -2824,7 +2897,7 @@ export function recordCodexUpstreamOutcome(
2824
2897
  });
2825
2898
  // The shared native scope is the existing account-wide native behavior:
2826
2899
  // threads must leave it and new requests should prefer an eligible account.
2827
- // Spark remains isolated so a same-account Terra/Luna combo fallback can run.
2900
+ // Reserve remains isolated so a same-account Terra/Luna combo fallback can run.
2828
2901
  if (quotaScope === "shared" && !meta.fixedAccount) {
2829
2902
  clearThreadAccountMapForAccount(accountId);
2830
2903
  notePoolRotationFailure(POOL_KEY_CODEX, accountId);
package/src/codex/sync.ts CHANGED
@@ -19,7 +19,7 @@ export interface CodexSyncResult {
19
19
  /**
20
20
  * `skipped` is policy truth, never evidence that Codex was written.
21
21
  * `catalog-only` means an explicit sync refreshed the catalog/cache while
22
- * Codex injection stayed OFF; config and history were not touched.
22
+ * config/history injection was skipped (OFF, externally owned, or protected history).
23
23
  */
24
24
  status: "applied" | "skipped" | "catalog-only" | "refused";
25
25
  ok: boolean;
@@ -32,6 +32,12 @@ export interface CodexSyncResult {
32
32
  catalogExists: boolean;
33
33
  catalogWritten: boolean;
34
34
  cacheSynced: boolean;
35
+ /**
36
+ * Whether the catalog owner committed a validated catalog or refused the
37
+ * refresh. Only a `catalog-only` result carries it; `ok` already answers the
38
+ * question for callers that do not care which half refused.
39
+ */
40
+ refreshOutcome?: "committed" | "refused";
35
41
  message: string;
36
42
  warning?: string;
37
43
  comboOmissions?: ComboCatalogOmission[];
@@ -45,8 +51,8 @@ export interface CodexSyncOptions {
45
51
  * Explicit `ocx sync` is also the refresh path for side profiles that consume
46
52
  * the OpenCodex catalog without injection. When set, the sync still refreshes
47
53
  * the catalog and models cache even if the Codex integration toggle is OFF or
48
- * an external `model_provider` owns config.toml. Config/history injection is
49
- * skipped in those cases, so the behavior is harmless to a native home.
54
+ * an external `model_provider` owns config.toml, or paginated history refuses
55
+ * injection. Config/history injection is skipped in those cases.
50
56
  */
51
57
  catalogEvenWhenNotInjected?: boolean;
52
58
  }
@@ -203,6 +209,25 @@ export async function syncModelsToCodex(
203
209
  // working catalog/cache into the partial result of an otherwise unnecessary refresh.
204
210
  const preflight = await deps.injectCodexConfig(p, config, { validateOnly: true });
205
211
  if (!preflight.success) {
212
+ // Explicit model refresh does not require legacy history relabeling. Keep the
213
+ // injector's refusal intact and publish only through the existing catalog owner.
214
+ // Unattended sync and other config/integrity refusals retain their hard failure.
215
+ if (catalogEvenWhenNotInjected
216
+ && preflight.historyPreflightFailureReason === "history_paginated_requires_native_writer") {
217
+ applyProxyEnv(config);
218
+ const refreshed = await refreshCatalogForSync(config, deps, undefined, log);
219
+ const ok = refreshed.refreshOutcome === "committed" && refreshed.catalogExists;
220
+ const message = ok
221
+ ? "Model catalog synchronized; Codex config and conversation history left unchanged because paginated history requires its native writer."
222
+ : "Model catalog refresh did not complete; Codex config and conversation history were left unchanged.";
223
+ reportCodexHomeTarget(log, deps.collectCodexHomeDiagnostic ?? collectOrcaCodexHomeDiagnostic);
224
+ return {
225
+ ...refreshed,
226
+ status: "catalog-only",
227
+ ok,
228
+ message,
229
+ };
230
+ }
206
231
  log?.error(preflight.message);
207
232
  reportCodexHomeTarget(log, deps.collectCodexHomeDiagnostic ?? collectOrcaCodexHomeDiagnostic);
208
233
  return {
@@ -308,6 +333,7 @@ async function refreshCatalogForSync(
308
333
  catalogWritten: boolean;
309
334
  cacheSynced: boolean;
310
335
  comboOmissions: ComboCatalogOmission[];
336
+ refreshOutcome?: "committed" | "refused";
311
337
  warning?: string;
312
338
  }> {
313
339
  let added = 0;
@@ -316,9 +342,11 @@ async function refreshCatalogForSync(
316
342
  let catalogWritten = false;
317
343
  let cacheSynced = false;
318
344
  let warning: string | undefined;
345
+ let refreshOutcome: "committed" | "refused" | undefined;
319
346
  let comboOmissions: ComboCatalogOmission[] = [];
320
347
  try {
321
348
  const cat = await deps.refreshCodexModelCatalog(config, undefined, catalogOptions);
349
+ refreshOutcome = cat.refreshOutcome;
322
350
  added = cat.added;
323
351
  catalogExists = cat.catalogExists;
324
352
  catalogWritten = cat.catalogWritten;
@@ -340,5 +368,6 @@ async function refreshCatalogForSync(
340
368
  warning = `catalog sync skipped: ${e instanceof Error ? e.message : String(e)}`;
341
369
  log?.error(warning);
342
370
  }
343
- return { added, catalogPath, catalogExists, catalogWritten, cacheSynced, comboOmissions, ...(warning ? { warning } : {}) };
371
+ return { added, catalogPath, catalogExists, catalogWritten, cacheSynced, comboOmissions,
372
+ ...(refreshOutcome ? { refreshOutcome } : {}), ...(warning ? { warning } : {}) };
344
373
  }
@@ -1,4 +1,4 @@
1
- import type { OcxComboDefaultEffort, OcxComboTarget, OcxConfig } from "../types";
1
+ import type { OcxComboDefaultEffort, OcxComboReasoningEffortMode, OcxComboTarget, OcxConfig } from "../types";
2
2
  import { resolveEffortAtOrBelow } from "../reasoning-effort";
3
3
  import { resolveComboId } from "./types";
4
4
 
@@ -59,9 +59,14 @@ export function concreteComboRequestBody(
59
59
  target: Pick<OcxComboTarget, "provider" | "model">,
60
60
  defaultEffort: OcxComboDefaultEffort | null,
61
61
  targetReasoningEfforts: readonly string[] | undefined,
62
+ reasoningEffortMode: OcxComboReasoningEffortMode = "strict",
62
63
  ): Record<string, unknown> {
63
64
  const clone = structuredClone(body) as Record<string, unknown>;
64
65
  clone.model = `${target.provider}/${target.model}`;
66
+ if (targetReasoningEfforts?.length === 0
67
+ || (reasoningEffortMode === "adaptive" && targetReasoningEfforts === undefined)) {
68
+ stripUnsupportedReasoningControls(clone);
69
+ }
65
70
  if (!defaultEffort) return clone;
66
71
  const reasoning = clone.reasoning;
67
72
  const needsDefault = reasoning === undefined || (
@@ -104,3 +109,16 @@ export function concreteComboRequestBody(
104
109
  }
105
110
  return clone;
106
111
  }
112
+
113
+ function stripUnsupportedReasoningControls(body: Record<string, unknown>): void {
114
+ const reasoning = body.reasoning;
115
+ if (reasoning && typeof reasoning === "object" && !Array.isArray(reasoning)) {
116
+ const next = { ...(reasoning as Record<string, unknown>) };
117
+ delete next.effort;
118
+ if (Object.keys(next).length > 0) body.reasoning = next;
119
+ else delete body.reasoning;
120
+ }
121
+ delete body.reasoning_effort;
122
+ delete body.thinking_budget;
123
+ delete body.thinking;
124
+ }
@@ -0,0 +1,61 @@
1
+ import type { MultiAgentMode } from "../codex/catalog/parsing";
2
+ import type { OcxConfig } from "../types";
3
+
4
+ /**
5
+ * Bump to raise the sub-agent surface advisory again for installs that already answered
6
+ * the previous one. A version rather than a boolean, so that the day the upstream
7
+ * encrypted-task limitation is fixed, the notice saying so reaches the same operators
8
+ * without a second config key.
9
+ */
10
+ export const MULTI_AGENT_SURFACE_ADVISORY_VERSION = 1;
11
+
12
+ /** The published explanation both advisory surfaces link to. */
13
+ export const SUBAGENT_SURFACE_GUIDE_URL = "https://opencodex.me/guides/subagent-v1-default/";
14
+
15
+ /**
16
+ * The surface an operator effectively runs. An absent key still means base: the key is
17
+ * deleted when base is selected, so its absence cannot be read as "never configured".
18
+ * What changed in this release is the written default, not this resolution.
19
+ */
20
+ export function resolveMultiAgentMode(config: Pick<OcxConfig, "multiAgentMode">): MultiAgentMode {
21
+ return config.multiAgentMode === "v1" || config.multiAgentMode === "v2" ? config.multiAgentMode : "default";
22
+ }
23
+
24
+ type AdvisoryConfig = Pick<OcxConfig, "multiAgentMode" | "multiAgentSurfaceAdvisoryVersion">;
25
+
26
+ /**
27
+ * True when this install should be told once that v1 is now the default surface.
28
+ *
29
+ * A v1 install is silent because it has nothing to decide. A fresh install is silent
30
+ * because `getDefaultConfig()` writes the current version alongside the v1 default. What
31
+ * is left is the case this exists for: an install that predates the change and is running
32
+ * base or v2, where a ChatGPT-native parent cannot hand a task to a routed child.
33
+ */
34
+ export function multiAgentSurfaceAdvisoryRequired(config: AdvisoryConfig): boolean {
35
+ if (resolveMultiAgentMode(config) === "v1") return false;
36
+ return (config.multiAgentSurfaceAdvisoryVersion ?? 0) < MULTI_AGENT_SURFACE_ADVISORY_VERSION;
37
+ }
38
+
39
+ export type MultiAgentSurfaceAdvisory = {
40
+ /** Whether the dashboard should raise the notice. */
41
+ required: boolean;
42
+ /** The surface stored today. */
43
+ mode: MultiAgentMode;
44
+ /** What the notice recommends. */
45
+ recommended: "v1";
46
+ /** The advisory revision a client acknowledges. */
47
+ version: number;
48
+ /** Where the notice sends a reader for the explanation. */
49
+ docsUrl: string;
50
+ };
51
+
52
+ /** Response-only projection; none of this is a writable config field. */
53
+ export function multiAgentSurfaceAdvisory(config: AdvisoryConfig): MultiAgentSurfaceAdvisory {
54
+ return {
55
+ required: multiAgentSurfaceAdvisoryRequired(config),
56
+ mode: resolveMultiAgentMode(config),
57
+ recommended: "v1",
58
+ version: MULTI_AGENT_SURFACE_ADVISORY_VERSION,
59
+ docsUrl: SUBAGENT_SURFACE_GUIDE_URL,
60
+ };
61
+ }
@@ -5,12 +5,14 @@ import {
5
5
  MODEL_DISCOVERY_MAX_MODELS,
6
6
  } from "../providers/model-discovery-limits";
7
7
  import { isDeclaredReasoningEffort, modelRecordValue } from "../reasoning-effort";
8
+ import { encodeRoutedModelId } from "../providers/slug-codec";
8
9
  import {
9
10
  isWirePinnedModel,
10
11
  MODEL_ADAPTER_OVERRIDE_ALLOWED,
11
12
  REASONING_SUMMARY_DELIVERY_VALUES,
12
13
  UPSTREAM_HTTP_VERSION_VALUES,
13
14
  type OcxProviderConfig,
15
+ type ModelCapabilities,
14
16
  } from "../types";
15
17
 
16
18
  const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
@@ -233,6 +235,85 @@ export function modelDisplayNamesConfigError(
233
235
  return null;
234
236
  }
235
237
 
238
+ /** Characters that make a Codex catalog selector ambiguous or unrepresentable. */
239
+ export const AUTO_REVIEW_MODEL_CONTROL_CHARS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\s]/;
240
+
241
+ /** Validate one auto-review target (provider-wide value or map value). */
242
+ export function autoReviewModelTargetConfigError(
243
+ value: unknown,
244
+ field = "autoReviewModel",
245
+ allowClear = false,
246
+ ): string | null {
247
+ if (value === undefined || (allowClear && (value === null || value === ""))) return null;
248
+ if (typeof value !== "string") return `${field} must be a string`;
249
+ const trimmed = value.trim();
250
+ if (!trimmed) return `${field} must be nonblank`;
251
+ if (trimmed.length > 1024 || AUTO_REVIEW_MODEL_CONTROL_CHARS.test(trimmed)) {
252
+ return `${field} must be a catalog selector without whitespace or control characters`;
253
+ }
254
+ return null;
255
+ }
256
+
257
+ /** True when the value is a valid Codex catalog auto-review selector. */
258
+ export function isValidAutoReviewModel(value: unknown): value is string {
259
+ return typeof value === "string" && autoReviewModelTargetConfigError(value) === null;
260
+ }
261
+
262
+ /** Canonical model key used for map matching, duplicate detection, and route tombstones. */
263
+ export function canonicalAutoReviewModelKey(modelId: string): string {
264
+ return encodeRoutedModelId(modelId.trim());
265
+ }
266
+
267
+ /** Validate a per-model auto-review override map. */
268
+ export function autoReviewModelOverridesConfigError(
269
+ value: unknown,
270
+ field = "autoReviewModelOverrides",
271
+ allowTombstones = false,
272
+ ): string | null {
273
+ if (value === undefined) return null;
274
+ if (value === null && allowTombstones) return null;
275
+ if (!value || typeof value !== "object" || Array.isArray(value)) return `${field} must be a plain object`;
276
+ const prototype = Object.getPrototypeOf(value);
277
+ if (prototype !== Object.prototype && prototype !== null) {
278
+ return `${field} must be a plain object with own properties`;
279
+ }
280
+ const entries = Object.entries(value);
281
+ if (entries.length > MODEL_DISCOVERY_MAX_MODELS) {
282
+ return `${field} must contain at most ${MODEL_DISCOVERY_MAX_MODELS} entries`;
283
+ }
284
+ const canonicalKeys = new Set<string>();
285
+ for (const [modelId, target] of entries) {
286
+ if (!isValidModelDiscoveryModelId(modelId) || ["__proto__", "prototype", "constructor"].includes(modelId)) {
287
+ return `${field} keys must be valid non-reserved model ids`;
288
+ }
289
+ const safeModelId = JSON.stringify(redactSecretString(modelId));
290
+ const canonicalKey = canonicalAutoReviewModelKey(modelId);
291
+ if (canonicalKeys.has(canonicalKey)) {
292
+ return `${field} keys must be unique after trimming and slash normalization`;
293
+ }
294
+ canonicalKeys.add(canonicalKey);
295
+ if (allowTombstones && (target === null || target === "")) continue;
296
+ const targetError = autoReviewModelTargetConfigError(target, `${field}.${safeModelId}`);
297
+ if (targetError) return targetError;
298
+ }
299
+ return null;
300
+ }
301
+
302
+ /** Normalize a persisted auto-review override map (trim, drop blanks, keep insertion order). */
303
+ export function normalizeAutoReviewModelOverrides(value: unknown): Record<string, string> | undefined {
304
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
305
+ const out = Object.create(null) as Record<string, string>;
306
+ for (const [modelId, target] of Object.entries(value)) {
307
+ const key = modelId.trim();
308
+ if (!key) continue;
309
+ if (target === null || typeof target !== "string") continue;
310
+ const trimmed = target.trim();
311
+ if (!trimmed) continue;
312
+ out[key] = trimmed;
313
+ }
314
+ return Object.keys(out).length > 0 ? out : undefined;
315
+ }
316
+
236
317
  /** Validate the management DTO boundary for the opt-in empty-tool-output annotation. */
237
318
  export function providerEmptyToolOutputConfigError(name: string, provider: unknown): string | null {
238
319
  const raw = provider as Record<string, unknown> | null | undefined;
@@ -295,3 +376,98 @@ export function modelAdapterRecordConfigError(
295
376
  }
296
377
  return null;
297
378
  }
379
+
380
+
381
+ function capabilityRecord(value: unknown): value is Record<string, unknown> {
382
+ return value !== null && typeof value === "object" && !Array.isArray(value)
383
+ && [Object.prototype, null].includes(Object.getPrototypeOf(value));
384
+ }
385
+
386
+ /** Strict writes; only PATCH may carry deletion tombstones. Model IDs are exact. */
387
+ export function modelCapabilitiesConfigError(value: unknown, allowTombstones = false): string | null {
388
+ if (value === undefined || (allowTombstones && value === null)) return null;
389
+ if (!capabilityRecord(value)) return "modelCapabilities must be a plain object";
390
+ if (Object.keys(value).length > MODEL_DISCOVERY_MAX_MODELS) return "modelCapabilities has too many models";
391
+ for (const [id, row] of Object.entries(value)) {
392
+ if (!isValidModelDiscoveryModelId(id) || ["__proto__", "prototype", "constructor"].includes(id)) {
393
+ return "modelCapabilities keys must be exact non-reserved model ids without surrounding whitespace";
394
+ }
395
+ if (allowTombstones && row === null) continue;
396
+ if (!capabilityRecord(row)) return "modelCapabilities rows must be plain objects";
397
+ for (const [axis, declaration] of Object.entries(row)) {
398
+ if (!["inputModalities", "contextTier", "video"].includes(axis)) return "modelCapabilities contains an unknown axis";
399
+ if (allowTombstones && declaration === null) continue;
400
+ if (axis === "inputModalities") {
401
+ if (!Array.isArray(declaration) || declaration.length === 0
402
+ || declaration.some(item => typeof item !== "string" || !["text", "image", "audio", "video"].includes(item))) {
403
+ return "modelCapabilities inputModalities must be a nonempty array of text, image, audio or video";
404
+ }
405
+ } else if (axis === "contextTier") {
406
+ if (declaration !== "default" && declaration !== "long_context") return "modelCapabilities contextTier must be default or long_context";
407
+ } else {
408
+ if (!capabilityRecord(declaration) || Object.keys(declaration).some(key => key !== "processing")) {
409
+ return "modelCapabilities video must be a plain object containing only processing";
410
+ }
411
+ if (Object.hasOwn(declaration, "processing") && declaration.processing !== "static" && declaration.processing !== "agentic"
412
+ && !(allowTombstones && declaration.processing === null)) return "modelCapabilities video processing must be static or agentic";
413
+ }
414
+ }
415
+ }
416
+ return null;
417
+ }
418
+
419
+ /** Merge a validated patch without sharing nested objects with the live provider. */
420
+ export function mergeModelCapabilities(
421
+ current: Record<string, ModelCapabilities> | undefined,
422
+ patch: unknown,
423
+ ): Record<string, ModelCapabilities> | undefined {
424
+ if (patch === null) return undefined;
425
+ const next = Object.fromEntries(Object.entries(current ?? {}).map(([id, row]) => [id, structuredClone(row)]));
426
+ if (patch !== undefined) for (const [id, raw] of Object.entries(patch as Record<string, Record<string, unknown> | null>)) {
427
+ if (raw === null) { delete next[id]; continue; }
428
+ const row: ModelCapabilities = Object.hasOwn(next, id) ? next[id]! : {};
429
+ for (const [axis, value] of Object.entries(raw)) {
430
+ if (axis === "inputModalities") {
431
+ if (value === null) delete row.inputModalities;
432
+ else row.inputModalities = [...(value as NonNullable<ModelCapabilities["inputModalities"]>)];
433
+ } else if (axis === "contextTier") {
434
+ if (value === null) delete row.contextTier;
435
+ else row.contextTier = value as ModelCapabilities["contextTier"];
436
+ } else if (axis === "video") {
437
+ if (value === null) delete row.video;
438
+ else {
439
+ const video = { ...(row.video ?? {}) };
440
+ const change = value as { processing?: "static" | "agentic" | null };
441
+ if (Object.hasOwn(change, "processing")) {
442
+ if (change.processing === null) delete video.processing;
443
+ else video.processing = change.processing;
444
+ }
445
+ if (Object.keys(video).length) row.video = video;
446
+ else delete row.video;
447
+ }
448
+ }
449
+ }
450
+ if (Object.keys(row).length) next[id] = row;
451
+ else delete next[id];
452
+ }
453
+ return Object.keys(next).length ? next : undefined;
454
+ }
455
+
456
+ /** Load-only repair preserves independent valid axes; malformed explicit modalities restrict to text. */
457
+ export function sanitizeModelCapabilitiesForLoad(value: unknown): Record<string, ModelCapabilities> | undefined {
458
+ if (!capabilityRecord(value)) return undefined;
459
+ const rows: Record<string, ModelCapabilities> = Object.create(null);
460
+ for (const [id, raw] of Object.entries(value).slice(0, MODEL_DISCOVERY_MAX_MODELS)) {
461
+ if (!capabilityRecord(raw) || !isValidModelDiscoveryModelId(id) || ["__proto__", "prototype", "constructor"].includes(id)) continue;
462
+ const row: Record<string, unknown> = {};
463
+ for (const axis of ["inputModalities", "contextTier", "video"] as const) {
464
+ if (!Object.hasOwn(raw, axis)) continue;
465
+ const declaration = raw[axis];
466
+ if (modelCapabilitiesConfigError({ [id]: { [axis]: declaration } }) === null) row[axis] = declaration;
467
+ else if (axis === "inputModalities") row.inputModalities = ["text"];
468
+ }
469
+ const normalized = mergeModelCapabilities(undefined, { [id]: row });
470
+ if (normalized?.[id]) rows[id] = normalized[id];
471
+ }
472
+ return Object.keys(rows).length ? rows : undefined;
473
+ }