@bitkyc08/opencodex 2.52.0 → 2.53.0-preview.20260913

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-D7ynYo2K.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
@@ -1,3 +1,10 @@
1
+ import { CODEX_ACCOUNT_LOG_LABEL_RE } from "./account-label";
2
+ import { poolQuotaHistoryIdentity } from "./account-store";
3
+ import { estimateCodexQuotaCapacity, insufficientCodexCapacity, type CodexCapacityResult } from "./quota-capacity";
4
+ import { readUsageSnapshotForManagement } from "../usage/log";
5
+ import { capturePoolQuotaWriter } from "./account-store";
6
+ import type { PoolQuotaWriter } from "./quota-types";
7
+ import { getAccountQuotaHistory, isValidWhamHistoryObservation } from "./quota";
1
8
  import {
2
9
  ConfigMutationLockError,
3
10
  loadConfig,
@@ -41,6 +48,7 @@ import {
41
48
  } from "./account-priority";
42
49
  import {
43
50
  claimDueCodexQuotaRecoveryProbes,
51
+ codexQuotaScopeForModel,
44
52
  claimManualResetCooldowns,
45
53
  settleManualResetCooldown,
46
54
  type ManualResetCooldownClaim,
@@ -49,6 +57,7 @@ import {
49
57
  clearThreadAccountMapForAccount,
50
58
  getEffectiveActiveCodexAccountId,
51
59
  isEffectiveCodexAccountPinned,
60
+ isCodexAccountPlanExcluded,
52
61
  reconcileCodexActiveAfterExclusion,
53
62
  resetCodexRoutingForManualSelection,
54
63
  settleCodexQuotaRecoveryProbe,
@@ -58,9 +67,9 @@ import {
58
67
  MAX_ACCOUNT_PRIORITY,
59
68
  MIN_ACCOUNT_PRIORITY,
60
69
  normalizeAccountPoolStickyLimit,
61
- normalizeAccountPoolStrategy,
70
+ normalizeCodexAccountPoolStrategy,
62
71
  parseAccountPoolStickyLimit,
63
- parseAccountPoolStrategy,
72
+ parseCodexAccountPoolStrategy,
64
73
  parseAccountPriority,
65
74
  } from "./pool-rotation";
66
75
  import { checkAccountIdCollision, getMainChatgptAccountId, readCodexTokens, readCodexTokensResult } from "./auth-collision";
@@ -78,6 +87,7 @@ import {
78
87
  parseUsageQuota,
79
88
  setAccountQuotaFromParsed,
80
89
  updateAccountQuota,
90
+ withoutRetiredCodexQuota,
81
91
  type StoredAccountQuota,
82
92
  type WhamUsageResponse,
83
93
  } from "./quota";
@@ -242,49 +252,11 @@ function codexAccountPersistenceConflict(
242
252
  : undefined;
243
253
  }
244
254
 
245
- /**
246
- * The exact labels `parseUsageQuota` emits for the Codex Spark windows (quota.ts).
247
- * Matching on the label rather than on "is a custom window" is load-bearing: the same array
248
- * carries Cursor's First-party models / API usage, Anthropic's Fable / Opus / Sonnet,
249
- * Antigravity's Gem / Cla, Kimi's subscription credits and a dozen dynamic provider meters.
250
- */
251
- const CODEX_SPARK_WINDOW_LABELS = new Set([
252
- "GPT-5.3-Codex-Spark 5h",
253
- "GPT-5.3-Codex-Spark Weekly",
254
- ]);
255
-
256
- /**
257
- * Drop the Spark window unless the operator asked for it (default hidden).
258
- *
259
- * Applied at the DTO boundary, never at parse or cache time: custom windows participate in
260
- * quota-presence checks, snapshot reconciliation and capacity aggregation, so removing Spark
261
- * upstream of this point would change routing state rather than display.
262
- *
263
- * Both GUI surfaces funnel through here — the Codex Auth rows directly, and /api/provider-quotas
264
- * via listCodexAuthAccountsSnapshot — so one filter covers both. Filtering only one would leave
265
- * the other still rendering the row the operator switched off.
266
- */
267
- export function withSparkVisibility<T extends Omit<StoredAccountQuota, "updatedAt"> | StoredAccountQuota | null>(
268
- quota: T,
269
- ): T {
270
- if (!quota?.customWindows?.length) return quota;
271
- if (loadConfig().showCodexSparkQuota === true) return quota;
272
- const kept = quota.customWindows.filter(window => !CODEX_SPARK_WINDOW_LABELS.has(window.label));
273
- if (kept.length === quota.customWindows.length) return quota;
274
- // An empty list is dropped rather than serialized: an absent field and an empty array should
275
- // not be two different ways of saying "no custom windows" on the wire.
276
- const next = { ...quota } as Record<string, unknown>;
277
- if (kept.length > 0) next.customWindows = kept;
278
- else delete next.customWindows;
279
- return next as T;
280
- }
281
-
282
-
283
255
  function quotaForPlan<T extends Omit<StoredAccountQuota, "updatedAt"> | StoredAccountQuota | null>(
284
256
  quota: T,
285
257
  plan: unknown,
286
- ): T {
287
- const visible = withSparkVisibility(quota);
258
+ ): T | null {
259
+ const visible = withoutRetiredCodexQuota(quota);
288
260
  if (!visible || !isThirtyDayOnlyCodexPlan(plan)) return visible;
289
261
  const quotaWindows = visible;
290
262
  return {
@@ -379,6 +351,7 @@ export type CodexAccountReauthReason =
379
351
  | "forbidden";
380
352
 
381
353
  function poolAccountDto(
354
+ config: OcxConfig,
382
355
  account: CodexAccount,
383
356
  quotaResult: PoolQuotaResult,
384
357
  hasCredential: boolean,
@@ -413,6 +386,10 @@ function poolAccountDto(
413
386
  quota: quota ? { ...quota } : null,
414
387
  needsReauth: needsReauth || health.status === "reauth_required",
415
388
  ...(reauthReason !== undefined ? { reauthReason } : {}),
389
+ ...(isCodexAccountPlanExcluded(config, account.id) ? {
390
+ selectionExcludedReason: "plan_excluded" as const,
391
+ selectionExcludedPlan: codexPlanValue(config.codexAccounts?.find(row => row.id === account.id)?.plan),
392
+ } : {}),
416
393
  hasCredential,
417
394
  ...(quotaResult.quotaProbeSkipped ? { quotaProbeSkipped: true as const } : {}),
418
395
  ...oauthAccountHealthFields("codex", account.id, health),
@@ -1192,6 +1169,9 @@ export interface CodexAuthAccountDto {
1192
1169
  * needs the operator; `/api/oauth/accounts` already carries the same field name.
1193
1170
  */
1194
1171
  reauthReason?: CodexAccountReauthReason;
1172
+ /** Automatic selection policy only; explicit routes retain their usual auth checks. */
1173
+ selectionExcludedReason?: "plan_excluded";
1174
+ selectionExcludedPlan?: string;
1195
1175
  hasCredential: boolean;
1196
1176
  health: OAuthAccountHealth;
1197
1177
  healthLabel: OAuthHealthLabel;
@@ -1358,6 +1338,7 @@ async function recoverPoolQuotaFrom401(ctx: {
1358
1338
 
1359
1339
  const writerGeneration = captureConfigGeneration();
1360
1340
  markQuotaProbeAttempted(ctx.quotaProbeEvidence, refreshed.generation);
1341
+ const poolWriter = capturePoolQuotaWriter(accountId, refreshed);
1361
1342
  const replay = await fetch("https://chatgpt.com/backend-api/wham/usage", {
1362
1343
  headers: {
1363
1344
  Authorization: `Bearer ${refreshed.accessToken}`,
@@ -1376,7 +1357,7 @@ async function recoverPoolQuotaFrom401(ctx: {
1376
1357
  return { quota: existing ?? null, needsReauth: false, credentialGeneration: refreshed.generation };
1377
1358
  }
1378
1359
  const result = await commitPoolQuotaResponse(replay, {
1379
- accountId, existing, configuredPlan, generation: refreshed.generation, writerGeneration,
1360
+ accountId, existing, configuredPlan, generation: refreshed.generation, writerGeneration, poolWriter,
1380
1361
  mayPublish: ctx.quotaProbeEvidence.mayPublish,
1381
1362
  });
1382
1363
  return result.freshCredentialGeneration === refreshed.generation ? {
@@ -1418,11 +1399,13 @@ async function commitPoolQuotaResponse(
1418
1399
  configuredPlan: string | undefined;
1419
1400
  generation: number;
1420
1401
  writerGeneration: number;
1402
+ poolWriter?: PoolQuotaWriter;
1421
1403
  mayPublish?: () => boolean;
1422
1404
  },
1423
1405
  ): Promise<PoolQuotaResult> {
1424
1406
  const { accountId, existing, configuredPlan, generation, writerGeneration } = ctx;
1425
1407
  const data = (await resp.json()) as WhamUsageResponse;
1408
+ const observedAt = Date.now();
1426
1409
  if (ctx.mayPublish?.() === false) {
1427
1410
  return { quota: getAccountQuota(accountId), needsReauth: false, credentialGeneration: generation };
1428
1411
  }
@@ -1440,7 +1423,8 @@ async function commitPoolQuotaResponse(
1440
1423
  if (!isCodexAccountGenerationLive(accountId, generation)) {
1441
1424
  return { quota: null, needsReauth: false, credentialGeneration: generation };
1442
1425
  }
1443
- setAccountQuotaFromParsed(accountId, quota, writerGeneration);
1426
+ setAccountQuotaFromParsed(accountId, quota, writerGeneration, undefined, quota,
1427
+ ctx.poolWriter && isValidWhamHistoryObservation(data) ? { writer: ctx.poolWriter, observedAt, source: "wham", raw: quota } : undefined);
1444
1428
  return {
1445
1429
  quota: getAccountQuota(accountId),
1446
1430
  needsReauth: false,
@@ -1464,6 +1448,7 @@ async function fetchFreshPoolAccountQuota(
1464
1448
  let requestCredentialGeneration = readCodexAccountRecord(accountId)?.generation;
1465
1449
  try {
1466
1450
  const { accessToken, chatgptAccountId, generation } = await getValidToken(accountId);
1451
+ const poolWriter = capturePoolQuotaWriter(accountId, { accessToken, chatgptAccountId, generation });
1467
1452
  requestCredentialGeneration = generation;
1468
1453
  onCredentialGeneration?.(generation);
1469
1454
  markQuotaProbeAttempted(quotaProbeEvidence, generation);
@@ -1494,7 +1479,7 @@ async function fetchFreshPoolAccountQuota(
1494
1479
  return withQuotaProbeEvidence(recovered, quotaProbeEvidence);
1495
1480
  }
1496
1481
  const committed = await commitPoolQuotaResponse(resp, {
1497
- accountId, existing, configuredPlan, generation, writerGeneration,
1482
+ accountId, existing, configuredPlan, generation, writerGeneration, poolWriter,
1498
1483
  mayPublish: quotaProbeEvidence.mayPublish,
1499
1484
  });
1500
1485
  return withQuotaProbeEvidence(committed, quotaProbeEvidence);
@@ -1727,7 +1712,7 @@ export async function runCodexCooldownRecoveryProbes(config: OcxConfig, now = Da
1727
1712
  try {
1728
1713
  const result = await fetchPoolAccountQuota(claim.accountId, true, account.plan);
1729
1714
  // Defence in depth: independent scopes are already excluded at the claim site.
1730
- // Generic WHAM must never clear Spark or Reserve even if claim selection changes.
1715
+ // Generic WHAM must never clear Reserve even if claim selection changes.
1731
1716
  const recovered = (claim.scope === undefined || claim.scope === "shared")
1732
1717
  && isCompleteCodexQuotaRecoverySnapshot(result.freshQuota ?? null, result.freshPlan ?? account.plan);
1733
1718
  settleCodexQuotaRecoveryProbe(claim, recovered, {
@@ -2006,6 +1991,7 @@ export async function listCodexAuthAccountsSnapshot(
2006
1991
  const currentCredential = getCodexAccountCredential(accountId);
2007
1992
  if (!currentCredential) {
2008
1993
  return [poolAccountDto(
1994
+ runtimeConfig,
2009
1995
  currentAccount,
2010
1996
  { quota: null, needsReauth: true },
2011
1997
  false,
@@ -2026,6 +2012,7 @@ export async function listCodexAuthAccountsSnapshot(
2026
2012
  ? { ...currentAccount, plan: quotaResult.freshPlan }
2027
2013
  : currentAccount;
2028
2014
  return [poolAccountDto(
2015
+ runtimeConfig,
2029
2016
  dtoAccount,
2030
2017
  effectiveQuotaResult,
2031
2018
  true,
@@ -2069,9 +2056,9 @@ export async function listCodexAuthAccountsSnapshot(
2069
2056
  hasCredential: hasMainCredential,
2070
2057
  needsReauth: mainNeedsReauth,
2071
2058
  ...(mainReauthReason !== undefined ? { reauthReason: mainReauthReason } : {}),
2072
- quota: mainInfo.quota ? {
2073
- ...quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan),
2074
- } : null,
2059
+ quota: mainInfo.quota
2060
+ ? quotaForPlan(mainQuotaWithCarriedResetCredits(mainInfo.quota), mainInfo.plan)
2061
+ : null,
2075
2062
  ...oauthAccountHealthFields("codex", MAIN_CODEX_ACCOUNT_ID, mainHealth),
2076
2063
  };
2077
2064
  return {
@@ -2457,7 +2444,7 @@ export async function handleCodexAuthAPI(
2457
2444
  pinnedAccountId: pinnedCodexAccountId(runtimeConfig) ?? null,
2458
2445
  autoSwitchThreshold: runtimeConfig.autoSwitchThreshold ?? 80,
2459
2446
  upstreamFailoverThreshold: runtimeConfig.upstreamFailoverThreshold ?? 3,
2460
- accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy),
2447
+ accountPoolStrategy: normalizeCodexAccountPoolStrategy(runtimeConfig.accountPoolStrategy),
2461
2448
  accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit),
2462
2449
  });
2463
2450
  }
@@ -2488,12 +2475,12 @@ export async function handleCodexAuthAPI(
2488
2475
  return jsonResponse({ error: "strategy or stickyLimit required" }, 400);
2489
2476
  }
2490
2477
  const runtimeConfig = getRuntimeConfig(config);
2491
- let nextStrategy: NonNullable<ReturnType<typeof parseAccountPoolStrategy>> | undefined;
2478
+ let nextStrategy: NonNullable<ReturnType<typeof parseCodexAccountPoolStrategy>> | undefined;
2492
2479
  let nextSticky: NonNullable<ReturnType<typeof parseAccountPoolStickyLimit>> | undefined;
2493
2480
  if (body.strategy !== undefined) {
2494
- const parsed = parseAccountPoolStrategy(body.strategy);
2481
+ const parsed = parseCodexAccountPoolStrategy(body.strategy);
2495
2482
  if (parsed === null) {
2496
- return jsonResponse({ error: 'strategy must be one of: quota, round-robin, fill-first' }, 400);
2483
+ return jsonResponse({ error: 'strategy must be one of: quota, round-robin, fill-first, reset-first' }, 400);
2497
2484
  }
2498
2485
  nextStrategy = parsed;
2499
2486
  }
@@ -2509,7 +2496,7 @@ export async function handleCodexAuthAPI(
2509
2496
  saveRuntimeConfig(config, runtimeConfig);
2510
2497
  return jsonResponse({
2511
2498
  ok: true,
2512
- accountPoolStrategy: normalizeAccountPoolStrategy(runtimeConfig.accountPoolStrategy),
2499
+ accountPoolStrategy: normalizeCodexAccountPoolStrategy(runtimeConfig.accountPoolStrategy),
2513
2500
  accountPoolStickyLimit: normalizeAccountPoolStickyLimit(runtimeConfig.accountPoolStickyLimit),
2514
2501
  });
2515
2502
  }
@@ -2526,6 +2513,46 @@ export async function handleCodexAuthAPI(
2526
2513
  return jsonResponse({ ok: true });
2527
2514
  }
2528
2515
 
2516
+ if (url.pathname === "/api/codex-auth/quota/history" && req.method === "GET") {
2517
+ const accountId = url.searchParams.get("accountId");
2518
+ const rawLimit = url.searchParams.get("limit");
2519
+ if (url.searchParams.getAll("accountId").length !== 1 || !isValidCodexAccountId(accountId)
2520
+ || url.searchParams.getAll("limit").length > 1
2521
+ || [...url.searchParams.keys()].some(key => key !== "accountId" && key !== "limit")
2522
+ || (rawLimit !== null && !/^(?:[1-9]|[1-9][0-9]|1[0-9]{2}|200)$/.test(rawLimit))) {
2523
+ return jsonResponse({ error: "A stored pool accountId and optional limit from 1 to 200 are required" }, 400);
2524
+ }
2525
+ const runtimeConfig = getRuntimeConfig(config);
2526
+ const account = configuredPoolAccount(runtimeConfig, accountId);
2527
+ if (!account) return jsonResponse({ error: "Unknown pool account" }, 404);
2528
+ const identity = poolQuotaHistoryIdentity(accountId);
2529
+ const allHistory = getAccountQuotaHistory(accountId);
2530
+ const limit = rawLimit === null ? 200 : Number(rawLimit);
2531
+ const history = { ...allHistory, observations: allHistory.observations.slice(-limit), truncated: allHistory.observations.length > limit };
2532
+ const label = account.logLabel;
2533
+ const labelStillUnique = () => {
2534
+ const current = getRuntimeConfig(config);
2535
+ return configuredPoolAccount(current, accountId)?.logLabel === label
2536
+ && current.codexAccounts?.filter(row => codexAccountLogLabel(row) === label).length === 1;
2537
+ };
2538
+ let capacity: CodexCapacityResult = insufficientCodexCapacity("identity_unavailable");
2539
+ if (identity && identity === poolQuotaHistoryIdentity(accountId) && label && CODEX_ACCOUNT_LOG_LABEL_RE.test(label) && labelStillUnique()) {
2540
+ try {
2541
+ const usage = await readUsageSnapshotForManagement();
2542
+ if (poolQuotaHistoryIdentity(accountId) !== identity || !labelStillUnique()) capacity = insufficientCodexCapacity("identity_changed");
2543
+ else if (!usage.revision) capacity = insufficientCodexCapacity("ledger_unavailable");
2544
+ else if (usage.truncatedPrefixBytes > 0 || usage.entriesTruncated || usage.entriesDropped > 0) capacity = insufficientCodexCapacity("ledger_truncated");
2545
+ else capacity = estimateCodexQuotaCapacity(allHistory.observations, usage.entries, label,
2546
+ model => codexQuotaScopeForModel(model) === "shared");
2547
+ } catch { capacity = insufficientCodexCapacity("ledger_unavailable"); }
2548
+ }
2549
+ if (!configuredPoolAccount(getRuntimeConfig(config), accountId)) return jsonResponse({ error: "Unknown pool account" }, 404);
2550
+ if (identity !== poolQuotaHistoryIdentity(accountId) || (identity && label && !labelStillUnique())) {
2551
+ return jsonResponse({ accountId, ...getAccountQuotaHistory(accountId, limit), capacity: insufficientCodexCapacity("identity_changed") });
2552
+ }
2553
+ return jsonResponse({ accountId, ...history, capacity });
2554
+ }
2555
+
2529
2556
  if (url.pathname === "/api/codex-auth/quota" && req.method === "GET") {
2530
2557
  const quotas: Record<string, unknown> = {};
2531
2558
  for (const [id, q] of listAccountQuotas()) quotas[id] = q;
@@ -1,3 +1,4 @@
1
+ import type { PoolQuotaWriter } from "./quota-types";
1
2
  import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
3
  import {
3
4
  CodexCredentialGenerationConflictError,
@@ -6,6 +7,7 @@ import {
6
7
  CodexCredentialRefreshStaleError,
7
8
  getCodexAccountCredential,
8
9
  getValidCodexToken,
10
+ capturePoolQuotaWriter,
9
11
  isCodexAccountGenerationLive,
10
12
  readCodexAccountRecord,
11
13
  } from "./account-store";
@@ -120,6 +122,7 @@ export type CodexAuthContext =
120
122
  accountId: string;
121
123
  writerGeneration: number;
122
124
  generation: number;
125
+ poolQuotaWriter?: PoolQuotaWriter;
123
126
  accessToken: string;
124
127
  chatgptAccountId: string;
125
128
  /** Bypass Pool selection and suppress quota/transient failover for an exact selector. */
@@ -201,6 +204,19 @@ export class CodexPoolAuthenticationError extends Error {
201
204
  }
202
205
  }
203
206
 
207
+ export type CodexModelAvailabilityReason = "unsupported" | "temporarily_unavailable";
208
+
209
+ /** A model/account compatibility failure is not a credential failure. */
210
+ export class CodexModelAvailabilityError extends CodexPoolAuthenticationError {
211
+ reason: CodexModelAvailabilityReason;
212
+
213
+ constructor(reason: CodexModelAvailabilityReason, message: string) {
214
+ super(message);
215
+ this.name = "CodexModelAvailabilityError";
216
+ this.reason = reason;
217
+ }
218
+ }
219
+
204
220
  class CodexAccountValidationPendingError extends CodexPoolAuthenticationError {
205
221
  constructor() {
206
222
  super("Codex account validation is pending; refresh quota after recovery to validate it");
@@ -558,7 +574,7 @@ export function cooldownErrorMessage(err: CodexAccountCooldownError, accountSele
558
574
  if (err instanceof CodexMainAccountHardLockError || err instanceof CodexReserveUnavailableError) return err.message;
559
575
  const until = new Date(err.cooldownUntil).toISOString();
560
576
  const scopeLabels: Record<CodexQuotaScope, string> = {
561
- spark: "Spark quota", shared: "shared native quota", reserve: "Reserve quota",
577
+ shared: "shared native quota", reserve: "Reserve quota",
562
578
  };
563
579
  const scope = err.quotaScope ? scopeLabels[err.quotaScope] : null;
564
580
  const selected = accountSelector
@@ -700,7 +716,10 @@ export async function resolveCodexAuthContext(
700
716
  options.isDirectCallerEntitledToCodexModel ?? isDirectCallerEntitledToCodexModel
701
717
  )(headers, options.modelId);
702
718
  if (!entitled) {
703
- throw new CodexPoolAuthenticationError("The selected ChatGPT account does not support this model");
719
+ throw new CodexModelAvailabilityError(
720
+ "unsupported",
721
+ "The selected ChatGPT account does not support this model",
722
+ );
704
723
  }
705
724
  }
706
725
  if (callerMatchesObservedMain(headers)) assertMainAccountPolicy(policy);
@@ -734,7 +753,10 @@ export async function resolveCodexAuthContext(
734
753
  options.modelId,
735
754
  )?.has(MAIN_CODEX_ACCOUNT_ID) === true;
736
755
  if (!entitled) {
737
- throw new CodexPoolAuthenticationError("The selected ChatGPT account does not support this model");
756
+ throw new CodexModelAvailabilityError(
757
+ "unsupported",
758
+ "The selected ChatGPT account does not support this model",
759
+ );
738
760
  }
739
761
  }
740
762
  assertMainAccountPolicy(policy);
@@ -861,11 +883,13 @@ export async function resolveCodexAuthContext(
861
883
  return await resolveCallerOwnedMainContext();
862
884
  }
863
885
  if (fixedAccountId !== undefined) {
864
- throw new CodexPoolAuthenticationError(
865
- modelEligibleAccountIds && !modelEligibleAccountIds.has(fixedAccountId)
866
- ? "Selected Codex account does not support this model"
867
- : "Selected Codex account is unavailable",
868
- );
886
+ if (modelEligibleAccountIds && !modelEligibleAccountIds.has(fixedAccountId)) {
887
+ throw new CodexModelAvailabilityError(
888
+ "unsupported",
889
+ "Selected Codex account does not support this model",
890
+ );
891
+ }
892
+ throw new CodexPoolAuthenticationError("Selected Codex account is unavailable");
869
893
  }
870
894
  // Recovery or a turn drain deliberately makes physical main unobservable.
871
895
  // If no healthy pool route is available, report the temporary fence rather
@@ -881,13 +905,16 @@ export async function resolveCodexAuthContext(
881
905
  && (!modelEligibleAccountIds || modelEligibleAccountIds.has(MAIN_CODEX_ACCOUNT_ID))) {
882
906
  assertMainAccountPolicy(policy);
883
907
  }
884
- throw new CodexPoolAuthenticationError(
885
- modelEligibleAccountIds === undefined
886
- ? undefined
887
- : entitledAccountIds?.size === 0 && !mainModelGrantUnobserved
888
- ? "No eligible Codex account supports this model"
889
- : "Codex accounts that support this model are currently unavailable",
890
- );
908
+ if (modelEligibleAccountIds !== undefined) {
909
+ const unsupported = entitledAccountIds?.size === 0 && !mainModelGrantUnobserved;
910
+ throw new CodexModelAvailabilityError(
911
+ unsupported ? "unsupported" : "temporarily_unavailable",
912
+ unsupported
913
+ ? "No eligible Codex account supports this model"
914
+ : "Codex accounts that support this model are currently unavailable",
915
+ );
916
+ }
917
+ throw new CodexPoolAuthenticationError();
891
918
  }
892
919
  accountId = selected;
893
920
  if (accountId === MAIN_CODEX_ACCOUNT_ID) assertMainAccountPolicy(policy);
@@ -906,7 +933,8 @@ export async function resolveCodexAuthContext(
906
933
  // Model entitlement is different: sending the request would spend a turn on an account whose
907
934
  // authenticated roster already denied the model. Reassert this boundary after every selector.
908
935
  if (modelEligibleAccountIds && !modelEligibleAccountIds.has(accountId)) {
909
- throw new CodexPoolAuthenticationError(
936
+ throw new CodexModelAvailabilityError(
937
+ "unsupported",
910
938
  fixedAccountId !== undefined
911
939
  ? "Selected Codex account does not support this model"
912
940
  : "No eligible Codex account supports this model",
@@ -1030,6 +1058,7 @@ export async function resolveCodexAuthContext(
1030
1058
  accountId,
1031
1059
  writerGeneration,
1032
1060
  generation: token.generation,
1061
+ poolQuotaWriter: capturePoolQuotaWriter(accountId, token),
1033
1062
  accessToken: token.accessToken,
1034
1063
  chatgptAccountId: token.chatgptAccountId,
1035
1064
  ...(fixedAccountId !== undefined ? { fixedAccount: true } : {}),
@@ -60,7 +60,7 @@ export function nativeEffortClamp(slug: string, effort: string | undefined): str
60
60
  if (levels.length === 0) {
61
61
  // Not snapshot-covered. gpt-5.6 natives have a REAL max rung (ensureGpt56ReasoningLevels
62
62
  // restores it even off-snapshot) -> never clamp. Every other bare native (gpt-5.5,
63
- // 5.3-codex-spark, a retired slug a client still asks for, and future old-ladder slugs)
63
+ // a retired slug a client still asks for, and future old-ladder slugs)
64
64
  // really stops at xhigh — the ChatGPT backend error names exactly none..xhigh — so clamp
65
65
  // the synthetic top tier.
66
66
  return isGpt56NativeSlug(slug) ? null : "xhigh";
@@ -72,7 +72,6 @@ export {
72
72
  } from "./native-models";
73
73
 
74
74
  export const DOCUMENTED_NATIVE_OPENAI_ADDITIONS = [
75
- "gpt-5.3-codex-spark",
76
75
  "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna",
77
76
  // The shipped pin also backfills older installed Codex catalogs that predate Astra.
78
77
  NATIVE_GPT6_ASTRA_MODEL,
@@ -163,7 +162,6 @@ const NATIVE_GPT56_FAMILY = new Set<string>([
163
162
 
164
163
  export const NATIVE_OPENAI_CONTEXT_OVERRIDES: Record<string, { contextWindow?: number; maxContextWindow?: number; maxInputTokens?: number }> = {
165
164
  "gpt-5.5": { contextWindow: 272_000, maxContextWindow: 272_000 },
166
- "gpt-5.3-codex-spark": { contextWindow: 100_000, maxContextWindow: 100_000 },
167
165
  "gpt-5.6-sol": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_MAX_INPUT_TOKENS, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS },
168
166
  "gpt-5.6-terra": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_MAX_INPUT_TOKENS, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS },
169
167
  "gpt-5.6-luna": { contextWindow: NATIVE_GPT56_CONTEXT_WINDOW, maxContextWindow: NATIVE_GPT56_MAX_INPUT_TOKENS, maxInputTokens: NATIVE_GPT56_MAX_INPUT_TOKENS },
@@ -378,8 +376,7 @@ export function nativeInputModalities(slug: string): string[] {
378
376
  if (Array.isArray(upstream?.input_modalities) && upstream!.input_modalities!.length > 0) {
379
377
  return [...upstream!.input_modalities as string[]];
380
378
  }
381
- // gpt-5.3-codex-spark is not in the upstream snapshot; all supported natives are
382
- // text+image capable, so default to the family baseline rather than text-only.
379
+ // Without a pinned row, retain the native family modality baseline.
383
380
  return ["text", "image"];
384
381
  }
385
382
 
@@ -393,7 +390,7 @@ export function nativeReasoningEfforts(slug: string): string[] {
393
390
  // include ultra while Luna intentionally ends at max.
394
391
  return levels.flatMap(l => typeof l.effort === "string" ? [l.effort] : []);
395
392
  }
396
- // gpt-5.3-codex-spark is not in upstream snapshot — use the standard old-ladder default.
393
+ // Without a pinned row, use the standard old-ladder default.
397
394
  return ["low", "medium", "high", "xhigh"];
398
395
  }
399
396
 
@@ -854,7 +851,7 @@ function catalogNativeSlugs(): string[] {
854
851
  }
855
852
 
856
853
  export function listCatalogNativeSlugs(): string[] {
857
- // Ensure documented additions (e.g. gpt-5.3-codex-spark) appear even when the bundled catalog
854
+ // Ensure documented additions (e.g. gpt-6-astra) appear even when the bundled catalog
858
855
  // predates the slug — mirrors nativeOpenAiSlugs() which already merges them for /v1/models.
859
856
  return unique([...catalogNativeSlugs(), ...DOCUMENTED_NATIVE_OPENAI_ADDITIONS]);
860
857
  }
@@ -155,7 +155,7 @@ export function nativeOpenAiAliasPresentation(slug: string): { displayName: stri
155
155
  * Devlog: 260816_codexrs_multiagent_v2_and_history_perf/011 §4-bis.
156
156
  */
157
157
  export const NATIVE_OPENAI_MODELS = [
158
- "gpt-5.5", "gpt-5.3-codex-spark",
158
+ "gpt-5.5",
159
159
  "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna",
160
160
  NATIVE_DAYBREAK_BLUE_MODEL,
161
161
  NATIVE_GPT6_ASTRA_MODEL,
@@ -178,7 +178,7 @@ export const SUPPORTED_NATIVE_OPENAI_SLUGS = new Set(NATIVE_OPENAI_MODELS);
178
178
  * thing that makes a slug retired is upstream withdrawing it.
179
179
  */
180
180
  export const RETIRED_NATIVE_OPENAI_MODELS: ReadonlySet<string> = new Set([
181
- "gpt-5.4", "gpt-5.4-mini",
181
+ "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark",
182
182
  ]);
183
183
 
184
184
  /**
@@ -192,8 +192,8 @@ export const RETIRED_NATIVE_OPENAI_MODELS: ReadonlySet<string> = new Set([
192
192
  * flipped false — letting a drain silently rewrite the operator's configured subagent model.
193
193
  *
194
194
  * It is an explicit list rather than `SUPPORTED_NATIVE_OPENAI_SLUGS`, which would have widened
195
- * the sentinel to `gpt-5.5` and `gpt-5.3-codex-spark` as well. Those
196
- * models were never covered, and widening would turn "fell back and answered" into a
195
+ * the sentinel to `gpt-5.5` as well. That
196
+ * model was never covered, and widening would turn "fell back and answered" into a
197
197
  * maintenance error for the most commonly configured fallback slug in the repo. Membership is
198
198
  * the set the drain behaviour was actually reasoned about: the account-gated natives plus the
199
199
  * flagships that just left that set.
@@ -337,15 +337,6 @@ export function findSupportedNativeTemplate(catalog: RawCatalog | null): RawEntr
337
337
  ) ?? null;
338
338
  }
339
339
 
340
- /**
341
- * Native OpenAI slugs that do NOT support the Fast (priority) service tier.
342
- * Upstream may advertise service_tiers for these models, but the tier is not
343
- * actually available — strip it so the Codex UI does not offer a dead toggle.
344
- */
345
- const NO_FAST_TIER_NATIVE_SLUGS = new Set([
346
- "gpt-5.3-codex-spark",
347
- ]);
348
-
349
340
  /** Does this row already carry an `ultrafast` tier the operator put there themselves? */
350
341
  /**
351
342
  * Read the opt-in from the live config, ONCE per catalog build.
@@ -438,14 +429,6 @@ export function normalizeServiceTiers(entry: RawEntry): RawEntry {
438
429
  ? { ...tier, description: "2x speed, increased usage" } : tier,
439
430
  );
440
431
  }
441
- // Strip service tiers for models that do not actually support the Fast tier.
442
- if (typeof entry.slug === "string" && NO_FAST_TIER_NATIVE_SLUGS.has(entry.slug)) {
443
- delete entry.service_tier;
444
- delete entry.service_tiers;
445
- delete entry.default_service_tier;
446
- delete entry.additional_speed_tiers;
447
- return entry;
448
- }
449
432
  // Codex stores the user-facing config spelling as "fast", but the catalog/request
450
433
  // service tier id is "priority" in current codex-rs. Keep legacy catalogs working.
451
434
  if (entry.service_tier === "fast") entry.service_tier = "priority";
@@ -517,9 +500,8 @@ export function applyNativeOpenAiContextOverride(entry: RawEntry, limits?: Nativ
517
500
  }
518
501
  }
519
502
  // providerContextCaps.openai is a ceiling for native OpenAI rows regardless of where the
520
- // advertised window came from (#1430): preserved rows without a hardcoded override (e.g.
521
- // gpt-5.3-codex-spark) must stay under the cap too, and auto-compaction follows the capped
522
- // window.
503
+ // advertised window came from (#1430): preserved rows without a hardcoded override
504
+ // must stay under the cap too, and auto-compaction follows the capped window.
523
505
  // The per-model window narrows the same rows for the same reason.
524
506
  const currentContext = typeof entry.context_window === "number" ? entry.context_window : undefined;
525
507
  const cappedContext = narrowNativeMaxContextWindow(nativeSlug, currentContext, limits);
@@ -600,6 +600,7 @@ function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Reco
600
600
  maxOut: prov.modelMaxOutputTokens ?? null,
601
601
  autoCompact: prov.modelAutoCompactTokenLimits ?? null,
602
602
  inMod: prov.modelInputModalities ?? null,
603
+ capabilities: prov.modelCapabilities ?? null,
603
604
  re: prov.modelReasoningEfforts ?? null,
604
605
  defRe: prov.modelDefaultReasoningEfforts ?? null,
605
606
  rsSum: prov.modelSupportsReasoningSummaries ?? null,
@@ -673,7 +674,9 @@ export function configuredContextWindow(prov: OcxProviderConfig, id: string): nu
673
674
  }
674
675
 
675
676
  export function configuredInputModalities(prov: OcxProviderConfig, id: string): string[] | undefined {
676
- const modalities = modelRecordValue(prov.modelInputModalities, id);
677
+ const declared = Object.hasOwn(prov.modelCapabilities ?? {}, id)
678
+ ? prov.modelCapabilities?.[id]?.inputModalities : undefined;
679
+ const modalities = declared ?? modelRecordValue(prov.modelInputModalities, id);
677
680
  return Array.isArray(modalities) && modalities.length > 0 ? [...modalities] : undefined;
678
681
  }
679
682
 
@@ -1736,6 +1739,12 @@ async function fetchProviderModelsWithAuth(
1736
1739
  id,
1737
1740
  provider: name,
1738
1741
  ...(liveWindow ? { contextWindow: liveWindow } : {}),
1742
+ // The account catalog names the effort variants each base model has, so
1743
+ // its ladder is measured rather than assumed. Without this the entry
1744
+ // inherits the generic routed ladder and offers rungs the model rounds
1745
+ // away, and every client that keys an effort control off this field —
1746
+ // the Pi-shaped exports — renders no control at all.
1747
+ ...(liveResult.efforts[id]?.length ? { reasoningEfforts: liveResult.efforts[id] } : {}),
1739
1748
  ...catalogHintsFromProviderConfig(name, prov, id, contextCap, metadataModelIdCaseFold, captured.effectiveAlias),
1740
1749
  } as CatalogModel;
1741
1750
  });