@bitkyc08/opencodex 2.52.0-preview.20260911 → 2.52.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 (166) hide show
  1. package/gui/dist/assets/index-CWXut3rG.js +115 -0
  2. package/gui/dist/assets/index-EdoPnm9_.css +1 -0
  3. package/gui/dist/index.html +2 -2
  4. package/gui/dist/provider-icons/devin.svg +49 -0
  5. package/gui/dist/provider-icons/omo.svg +42 -0
  6. package/package.json +3 -1
  7. package/src/AGENTS.md +1 -1
  8. package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +0 -1
  9. package/src/adapters/command-code.ts +0 -1
  10. package/src/adapters/cursor/checkpoint-store.ts +37 -0
  11. package/src/adapters/cursor/live-transport.ts +74 -2
  12. package/src/adapters/cursor.ts +6 -0
  13. package/src/adapters/devin/cloud-direct/auth.ts +264 -0
  14. package/src/adapters/devin/cloud-direct/catalog.ts +306 -0
  15. package/src/adapters/devin/cloud-direct/chat.ts +1274 -0
  16. package/src/adapters/devin/cloud-direct/index.ts +65 -0
  17. package/src/adapters/devin/cloud-direct/metadata.ts +134 -0
  18. package/src/adapters/devin/cloud-direct/wire.ts +206 -0
  19. package/src/adapters/devin/live-models.ts +133 -0
  20. package/src/adapters/devin-cli/acp.ts +204 -0
  21. package/src/adapters/devin-cli/adapter.ts +345 -0
  22. package/src/adapters/devin-cli/binary.ts +69 -0
  23. package/src/adapters/devin-cli/models.ts +57 -0
  24. package/src/adapters/devin.ts +326 -0
  25. package/src/adapters/google.ts +1 -1
  26. package/src/adapters/openai-chat.ts +31 -10
  27. package/src/adapters/openai-responses.ts +16 -1
  28. package/src/adapters/registry.ts +25 -1
  29. package/src/bridge.ts +8 -2
  30. package/src/claude/inbound-cache-stabilize.ts +130 -0
  31. package/src/claude/inbound.ts +45 -5
  32. package/src/cli/account-auth.ts +60 -1
  33. package/src/cli/account-extended.ts +23 -30
  34. package/src/cli/account.ts +2 -1
  35. package/src/cli/capabilities.ts +50 -8
  36. package/src/cli/config-command.ts +2 -2
  37. package/src/cli/dispatch.ts +14 -2
  38. package/src/cli/export-command.ts +11 -25
  39. package/src/cli/help.ts +2 -2
  40. package/src/cli/opencode.ts +5 -0
  41. package/src/cli/registry.ts +15 -4
  42. package/src/clients/config-export/cline.ts +71 -0
  43. package/src/clients/config-export/contracts.ts +10 -1
  44. package/src/clients/config-export/model-metadata.ts +33 -0
  45. package/src/clients/config-export/zcode.ts +23 -12
  46. package/src/clients/config-export.ts +156 -4
  47. package/src/codex/account-store.ts +7 -2
  48. package/src/codex/auth-context.ts +1 -1
  49. package/src/codex/catalog/effort.ts +4 -3
  50. package/src/codex/catalog/metadata.ts +8 -4
  51. package/src/codex/catalog/native-models.ts +24 -4
  52. package/src/codex/catalog/parsing.ts +19 -1
  53. package/src/codex/catalog/provider-fetch.ts +57 -0
  54. package/src/codex/catalog/sync.ts +1 -1
  55. package/src/codex/context-compat.ts +97 -0
  56. package/src/codex/context-owner.ts +201 -0
  57. package/src/codex/history-provider.ts +161 -14
  58. package/src/codex/inject-coordination.ts +3 -2
  59. package/src/codex/inject.ts +128 -9
  60. package/src/codex/pool-rotation.ts +8 -292
  61. package/src/codex/quota.ts +41 -12
  62. package/src/codex/retired-model-migration.ts +41 -0
  63. package/src/codex/routing.ts +327 -22
  64. package/src/codex/warmup.ts +2 -2
  65. package/src/combos/failover.ts +5 -0
  66. package/src/combos/resolve.ts +11 -15
  67. package/src/config.ts +46 -14
  68. package/src/generated/compatibility-version.json +293 -113
  69. package/src/grok/grpc-web.ts +120 -0
  70. package/src/grok/reset-coupon-ledger.ts +139 -0
  71. package/src/grok/reset-coupons.ts +278 -0
  72. package/src/integrations/catalog-refresh.ts +1 -1
  73. package/src/integrations/cline-document.ts +73 -0
  74. package/src/integrations/cline-io.ts +149 -0
  75. package/src/integrations/cline-transaction.ts +42 -0
  76. package/src/integrations/config-io.ts +21 -2
  77. package/src/integrations/journal.ts +43 -1
  78. package/src/integrations/omp-yaml-source.ts +123 -4
  79. package/src/integrations/ownership-policy.ts +7 -0
  80. package/src/integrations/ownership.ts +36 -0
  81. package/src/integrations/registry.ts +27 -0
  82. package/src/integrations/state.ts +5 -2
  83. package/src/integrations/store.ts +6 -0
  84. package/src/integrations/writer.ts +53 -9
  85. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  86. package/src/lib/abort.ts +36 -0
  87. package/src/lib/local-destinations.ts +1 -1
  88. package/src/lib/upstream-retry.ts +36 -1
  89. package/src/oauth/account-quota-rank.ts +11 -0
  90. package/src/oauth/callback-server.ts +10 -5
  91. package/src/oauth/devin/api-base.ts +63 -0
  92. package/src/oauth/devin/login.ts +1 -0
  93. package/src/oauth/devin/register-user.ts +186 -0
  94. package/src/oauth/devin/types.ts +71 -0
  95. package/src/oauth/devin-cli.ts +149 -0
  96. package/src/oauth/devin.ts +170 -0
  97. package/src/oauth/generic-account-failover.ts +169 -1
  98. package/src/oauth/index.ts +20 -1
  99. package/src/oauth/login-cli.ts +19 -5
  100. package/src/oauth/pool-kernel.ts +321 -0
  101. package/src/oauth/pool-settings-capability.ts +127 -9
  102. package/src/oauth/store.ts +7 -3
  103. package/src/oauth/token-guardian.ts +1 -1
  104. package/src/providers/codebuddy-models.ts +0 -3
  105. package/src/providers/command-code-efforts.ts +23 -4
  106. package/src/providers/default-aliases.ts +4 -0
  107. package/src/providers/derive.ts +8 -0
  108. package/src/providers/devin-cli-authmode-migration.ts +57 -0
  109. package/src/providers/key-failover.ts +175 -0
  110. package/src/providers/model-rename-startup.ts +21 -1
  111. package/src/providers/qoder-models.ts +0 -1
  112. package/src/providers/quota-key-accounts.ts +50 -0
  113. package/src/providers/quota-routing-cache.ts +65 -6
  114. package/src/providers/quota-types.ts +7 -0
  115. package/src/providers/quota.ts +113 -30
  116. package/src/providers/registry.ts +237 -77
  117. package/src/providers/stale-context-window-migration.ts +92 -0
  118. package/src/providers/zai-responses-migration.ts +45 -0
  119. package/src/quota/reset-observer.ts +2 -1
  120. package/src/quota/reset-seen-store.ts +9 -1
  121. package/src/remote-control/crypto.ts +442 -0
  122. package/src/remote-control/host.ts +175 -0
  123. package/src/remote-control/index.ts +100 -0
  124. package/src/remote-control/protocol.ts +200 -0
  125. package/src/remote-control/relay.ts +162 -0
  126. package/src/remote-control/workspace-agent-protocol.ts +246 -0
  127. package/src/remote-control/workspace-rpc-framing.ts +128 -0
  128. package/src/remote-control/workspace-tools.ts +237 -0
  129. package/src/remote-control/workspace-utf8.ts +24 -0
  130. package/src/responses/custom-tool-compat.ts +23 -0
  131. package/src/router.ts +6 -1
  132. package/src/routing/compatibility/behavior.ts +3 -0
  133. package/src/server/adapter-resolve.ts +6 -2
  134. package/src/server/auth-cors.ts +71 -6
  135. package/src/server/chat-completions.ts +5 -3
  136. package/src/server/chat-native.ts +9 -0
  137. package/src/server/claude-messages.ts +25 -30
  138. package/src/server/context-history.ts +207 -0
  139. package/src/server/images.ts +28 -1
  140. package/src/server/index.ts +42 -22
  141. package/src/server/live.ts +2 -1
  142. package/src/server/management/agent-settings-routes.ts +7 -1
  143. package/src/server/management/config-routes.ts +3 -3
  144. package/src/server/management/grok-coupon-routes.ts +287 -0
  145. package/src/server/management/integration-routes.ts +6 -1
  146. package/src/server/management/oauth-account-routes.ts +124 -7
  147. package/src/server/management/provider-routes.ts +77 -2
  148. package/src/server/management/route-registry.ts +6 -1
  149. package/src/server/management-api.ts +7 -0
  150. package/src/server/request-log.ts +4 -0
  151. package/src/server/responses/codex-ws-exchange.ts +90 -14
  152. package/src/server/responses/codex-ws-wire.ts +51 -1
  153. package/src/server/responses/compact.ts +8 -1
  154. package/src/server/responses/core.ts +188 -22
  155. package/src/server/responses/ws-upstream.ts +1 -1
  156. package/src/server/responses-undeclared-tool-guard.ts +261 -12
  157. package/src/server/zai-responses-startup.ts +21 -0
  158. package/src/types/config.ts +33 -3
  159. package/src/types/provider.ts +54 -4
  160. package/src/types/request.ts +1 -1
  161. package/src/types/tools.ts +36 -6
  162. package/src/update/job.ts +33 -11
  163. package/src/vision/plan.ts +40 -37
  164. package/src/web-search/index.ts +1 -0
  165. package/gui/dist/assets/index-BoBRSehJ.css +0 -1
  166. package/gui/dist/assets/index-Dx0xv2EA.js +0 -115
@@ -18,7 +18,7 @@ import {
18
18
  seedPoolRotationAccount,
19
19
  selectPriorityTier,
20
20
  } from "./pool-rotation";
21
- import { CODEX_EXHAUSTED_USAGE_PERCENT, CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota } from "./quota";
21
+ import { CODEX_EXHAUSTED_USAGE_PERCENT, CODEX_UNKNOWN_USAGE_SCORE, getAccountQuota, resetAtToMs } from "./quota";
22
22
  import { codexPlanKey, isThirtyDayOnlyCodexPlan } from "./plan";
23
23
  import {
24
24
  MAIN_CODEX_ACCOUNT_ID,
@@ -63,6 +63,25 @@ type CodexUpstreamHealth = {
63
63
  lastFailureAt?: number;
64
64
  /** Hard cooldown (quota 429). Survives a later 2xx; blocks auth + selection. */
65
65
  cooldownUntil?: number;
66
+ /**
67
+ * How long a quota refusal keeps selection away from this account (or this native quota
68
+ * group), as opposed to how long it is hard-blocked.
69
+ *
70
+ * The two are deliberately different lengths. {@link CODEX_MAX_RESET_DERIVED_COOLDOWN_MS}
71
+ * caps the hard cooldown at 15 minutes because a reset announcement is advisory and plan
72
+ * quota usually frees up before it — an account must stay reachable so the pool can find
73
+ * that out (#433). The window the refusal announced is not 15 minutes, though, so once the
74
+ * cooldown lapses the account is selectable again while its burst window is still spent,
75
+ * and the strategy picks it straight back: this proxy reads a weekly bar a burst limit never
76
+ * touches, so a refused account still scores as the coolest in the pool. Every request then
77
+ * earns the same 429 until the process restarts, which is the only thing that drops this map.
78
+ *
79
+ * So the announcement governs avoidance and the cap still governs blocking. Avoidance is soft
80
+ * in the {@link softAvoidUntil} sense: it reorders the pool and releases a bound thread, and
81
+ * the last-resort paths still reach the account when nothing else can serve, so one pessimistic
82
+ * announcement cannot stall routing.
83
+ */
84
+ quotaAvoidUntil?: number;
66
85
  /** When the current cooldown was recorded; origin of the probe interval clock. */
67
86
  cooldownSince?: number;
68
87
  /**
@@ -112,6 +131,12 @@ const CODEX_MAX_QUOTA_COOLDOWN_MS = 24 * 60 * 60_000;
112
131
  * the Retry-After ceiling (#433).
113
132
  */
114
133
  const CODEX_MAX_RESET_DERIVED_COOLDOWN_MS = 15 * 60_000;
134
+ /**
135
+ * Ceiling on quota-refusal avoidance. Generous enough to cover a full five-hour burst window,
136
+ * tight enough that a weekly or monthly reset four days out cannot take an account out of
137
+ * rotation for the {@link CODEX_MAX_QUOTA_COOLDOWN_MS} day the Retry-After ceiling allows.
138
+ */
139
+ const CODEX_MAX_QUOTA_AVOID_MS = 6 * 60 * 60_000;
115
140
  /** Minimum gap between probe leases for one cooled-down account. */
116
141
  export const CODEX_QUOTA_PROBE_INTERVAL_MS = 5 * 60_000;
117
142
  export const CODEX_FAILURE_WINDOW_MS = 5 * 60_000;
@@ -315,14 +340,30 @@ export function clearThreadAccountMapForAccount(accountId: string): void {
315
340
  }
316
341
 
317
342
  export function clearCodexUpstreamHealth(): void {
343
+ // Operator preferences are routing state, not health, but they live and die with the same
344
+ // reset points. Leaving them behind lets a selection from one context suppress the
345
+ // automatic cursor in the next one.
346
+ manualPreference.clear();
318
347
  upstreamHealth.clear();
319
348
  quotaScopedHealth.clear();
320
349
  runtimeActiveCodexAccountId = undefined;
350
+ // The reconcile watermark is part of this state, not something that outlives it. Keeping
351
+ // it across a full reset is incoherent: there is no health left to protect, yet
352
+ // recordCodexUpstreamOutcome would still drop a writer whose generation predates the
353
+ // watermark for any account missing from the equally stale live set. Left behind, it also
354
+ // leaks between test files, which is how it was found.
355
+ lastReconciledGeneration = 0;
356
+ liveHealthAccountIds = new Set();
321
357
  }
322
358
 
323
359
  export function clearCodexUpstreamHealthForAccount(accountId: string): void {
324
360
  upstreamHealth.delete(accountId);
325
361
  quotaScopedHealth.delete(accountId);
362
+ // Deletion is the third operator exit, next to pause and exclusion, and it is the one
363
+ // with no reconcile path behind it: once the account is gone nothing can succeed on it,
364
+ // so an unspent preference naming it would suppress the automatic cursor for every other
365
+ // account until the process restarts.
366
+ forgetManualPreference(accountId);
326
367
  }
327
368
 
328
369
  export function reconcileCodexRoutingHealth(context: GenerationContext): number {
@@ -338,6 +379,14 @@ export function reconcileCodexRoutingHealth(context: GenerationContext): number
338
379
  quotaScopedHealth.delete(accountId);
339
380
  removed += 1;
340
381
  }
382
+ // Sweep preferences the same way, for the account set this generation actually has. The
383
+ // delete path above is the direct route; this is the one that catches an account removed
384
+ // by an edit the runtime never saw. Deliberately not counted in `removed`, which reports
385
+ // health rows.
386
+ for (const [poolKey, preferred] of manualPreference) {
387
+ if (context.codexAccountIds.has(preferred)) continue;
388
+ manualPreference.delete(poolKey);
389
+ }
341
390
  liveHealthAccountIds = new Set(context.codexAccountIds);
342
391
  lastReconciledGeneration = context.generation;
343
392
  return removed;
@@ -405,9 +454,11 @@ export function computeCodexUsageScore(quota: {
405
454
  * A short-only reading that proves the account is blocked NOW.
406
455
  *
407
456
  * Freshness is not optional. `getAccountQuota` performs no expiry check, partial updates
408
- * carry the old short tuple forward, and disk hydration accepts a persisted reading for
457
+ * carry a still-open short tuple forward, and disk hydration accepts a persisted reading for
409
458
  * hours — so scoring 100 from `shortPercent` alone would keep excluding an account whose
410
- * five-hour window has since reset. That is #3029 pointed the other way: the issue is that
459
+ * five-hour window has since reset. Merge no longer carries an elapsed shortResetAt, but an
460
+ * explicit incoming elapsed tuple is still stored, and a missing reset cannot be aged there.
461
+ * That is #3029 pointed the other way: the issue is that
411
462
  * an exhausted account stays selected, and "a recovered account stays excluded" trades one
412
463
  * unusable pool for another.
413
464
  *
@@ -433,12 +484,9 @@ function isTerminalShortWindow(
433
484
  const age = now - observedAt;
434
485
  return age >= 0 && age <= TERMINAL_SHORT_WINDOW_FRESHNESS_MS;
435
486
  }
436
- // Both units reach storage: `normalizeResetAt` does not scale, and the GUI disambiguates
437
- // by magnitude at read time. A comparison written against one assumption is off by 1000x
438
- // against the other, and in the seconds-read-as-milliseconds direction every terminal
439
- // reading looks like it reset in 1970 — a fix that passes its own test and does nothing.
440
- const resetAtMs = resetAt < 10_000_000_000 ? resetAt * 1000 : resetAt;
441
- return resetAtMs > now;
487
+ // Seconds and milliseconds both reach storage, so the split lives in one place next to the
488
+ // merge that also ages a stored reset instant (`resetAtToMs`, src/codex/quota.ts).
489
+ return resetAtToMs(resetAt) > now;
442
490
  }
443
491
 
444
492
  export function classifyCodexUpstreamOutcome(
@@ -523,6 +571,51 @@ export function computeQuotaCooldown(meta: CodexUpstreamOutcomeMeta = {}): {
523
571
  return { until: now + CODEX_DEFAULT_QUOTA_COOLDOWN_MS, source: "default" };
524
572
  }
525
573
 
574
+ /**
575
+ * When the pool should stop preferring an account after it refused on quota.
576
+ *
577
+ * The earliest window the refusal actually announced, bounded by {@link CODEX_MAX_QUOTA_AVOID_MS},
578
+ * and never shorter than the cooldown the same refusal produced — a Retry-After directive that
579
+ * outlasts every announcement still governs.
580
+ */
581
+ function quotaAvoidUntilFor(meta: CodexUpstreamOutcomeMeta, now: number, cooldownUntil: number): number {
582
+ const values = Array.isArray(meta.resetAt) ? meta.resetAt : [meta.resetAt];
583
+ let announced: number | undefined;
584
+ for (const value of values) {
585
+ const timestamp = resetTimestampMs(value);
586
+ if (timestamp === undefined) continue;
587
+ const delay = timestamp - now;
588
+ if (delay <= 0) continue;
589
+ const until = now + Math.min(delay, CODEX_MAX_QUOTA_AVOID_MS);
590
+ if (announced === undefined || until < announced) announced = until;
591
+ }
592
+ return Math.max(cooldownUntil, announced ?? 0);
593
+ }
594
+
595
+ /** Live quota-refusal avoidance for an account, including the lane the request belongs to. */
596
+ function codexQuotaAvoidUntil(
597
+ accountId: string,
598
+ quotaScope: CodexQuotaScope | undefined,
599
+ now: number,
600
+ ): number | null {
601
+ const live = (value: number | undefined): number | null =>
602
+ typeof value === "number" && Number.isFinite(value) && value > now ? value : null;
603
+ const account = live(upstreamHealth.get(accountId)?.quotaAvoidUntil);
604
+ const scoped = quotaScope === undefined
605
+ ? null
606
+ : live(scopedHealthFor(accountId, quotaScope)?.quotaAvoidUntil);
607
+ if (account === null) return scoped;
608
+ return scoped === null ? account : Math.max(account, scoped);
609
+ }
610
+
611
+ function isCodexQuotaAvoided(
612
+ accountId: string,
613
+ quotaScope: CodexQuotaScope | undefined,
614
+ now: number,
615
+ ): boolean {
616
+ return codexQuotaAvoidUntil(accountId, quotaScope, now) !== null;
617
+ }
618
+
526
619
  export function computeQuotaCooldownUntil(meta: CodexUpstreamOutcomeMeta = {}): number {
527
620
  return computeQuotaCooldown(meta).until;
528
621
  }
@@ -764,6 +857,10 @@ function settleCooldownRecoveryLease(claim: CooldownRecoveryLease, recovered: bo
764
857
  cooldownSource: _source,
765
858
  probeLeaseId: _leaseId,
766
859
  probeLeaseGeneration: _leaseGeneration,
860
+ // "The quota window moved" is a statement about the whole refusal, so the avoidance it
861
+ // announced goes with the block it produced. Leaving it would make this escape hatch stop
862
+ // escaping: the account would still be passed over by every selection it is meant to win.
863
+ quotaAvoidUntil: _avoid,
767
864
  ...rest
768
865
  } = health;
769
866
  upstreamHealth.set(claim.accountId, {
@@ -871,6 +968,14 @@ export function resetCodexRoutingForManualSelection(accountId: string): void {
871
968
  clearThreadAccountMap();
872
969
  // Manual selection is the operator source of truth — drop any automatic runtime cursor.
873
970
  runtimeActiveCodexAccountId = undefined;
971
+ // Record the pick as an unspent one-shot on the SHARED scope only. An independent scope
972
+ // gets no entry on purpose: every write site the guard protects is already skipped for
973
+ // independent scopes, so an entry there would be state nothing reads — and state nothing
974
+ // reads is what the next reader mistakes for a rule.
975
+ //
976
+ // Seeding happens ONLY here. A pool-driven promote must never create or move a preference,
977
+ // or the pool would manufacture an operator intent nobody expressed.
978
+ manualPreference.set(POOL_KEY_CODEX, accountId);
874
979
  // Seed the RR ring so the next unbound new session honors the manually selected account
875
980
  // under round-robin (affinity-cleared threads / null threadId). Fill-first already follows
876
981
  // config.activeCodexAccountId, which the caller persists before invoking this.
@@ -880,11 +985,27 @@ export function resetCodexRoutingForManualSelection(accountId: string): void {
880
985
  seedPoolRotationAccount(codexPoolKeyForScope(scope), accountId);
881
986
  }
882
987
  }
988
+ // Quota avoidance is a preference, like the soft avoid dropped above, and an operator naming
989
+ // this account has overruled it. The hard cooldown is the part that survives.
990
+ const overrule = (health: CodexUpstreamHealth) => {
991
+ const { quotaAvoidUntil: _avoid, ...retained } = preservedCooldownFields(health);
992
+ return retained;
993
+ };
883
994
  const current = upstreamHealth.get(accountId);
884
- if (!current) return;
885
- const preserved = preservedCooldownFields(current);
886
- if (Object.keys(preserved).length === 0) upstreamHealth.delete(accountId);
887
- else upstreamHealth.set(accountId, { consecutiveFailures: 0, ...preserved });
995
+ if (current) {
996
+ const retained = overrule(current);
997
+ if (Object.keys(retained).length === 0) upstreamHealth.delete(accountId);
998
+ else upstreamHealth.set(accountId, { consecutiveFailures: 0, ...retained });
999
+ }
1000
+ // A reset-derived refusal records its avoidance on the SCOPED map and returns before the
1001
+ // account-wide entry is written, so naming the account has to reach that map too. Stopping
1002
+ // at `upstreamHealth` — and returning early when it holds nothing — overruled nothing in
1003
+ // the case that produces the avoidance this function exists to overrule.
1004
+ for (const [scope, health] of [...(quotaScopedHealth.get(accountId) ?? [])]) {
1005
+ const retained = overrule(health);
1006
+ if (Object.keys(retained).length === 0) deleteScopedHealth(accountId, scope);
1007
+ else setScopedHealth(accountId, scope, { consecutiveFailures: 0, ...retained });
1008
+ }
888
1009
  }
889
1010
 
890
1011
  export function getCodexAccountCooldownUntil(accountId: string, now = Date.now()): number | null {
@@ -953,18 +1074,31 @@ export function isCodexAccountInCooldown(accountId: string, now = Date.now()): b
953
1074
  * bumps it in {@link recordCodexUpstreamOutcome}, so the bump here is not load-bearing
954
1075
  * today and is kept so the invariant survives a future change that retains the lease.
955
1076
  *
956
- * Returns false when the account carried no live cooldown (already expired or never set).
1077
+ * Returns false when the account carried neither a live cooldown nor a live avoidance window.
1078
+ * The window outlives the cooldown by design — the cooldown caps at fifteen minutes and the
1079
+ * window runs up to six hours — so the moment an operator actually reaches for this escape
1080
+ * hatch is usually after the cooldown lapsed and only the window is still keeping the account
1081
+ * out of rotation. Refusing to look at the window then would leave the hatch shut in the one
1082
+ * case it exists for.
957
1083
  */
958
1084
  export function clearCodexAccountCooldown(accountId: string, now = Date.now()): boolean {
959
1085
  const clear = (health: CodexUpstreamHealth): CodexUpstreamHealth | null => {
960
1086
  const cooldownUntil = health.cooldownUntil;
961
- if (typeof cooldownUntil !== "number" || !Number.isFinite(cooldownUntil) || cooldownUntil <= now) return null;
1087
+ const liveCooldown = typeof cooldownUntil === "number" && Number.isFinite(cooldownUntil) && cooldownUntil > now;
1088
+ const avoidUntil = health.quotaAvoidUntil;
1089
+ const liveAvoidance = typeof avoidUntil === "number" && Number.isFinite(avoidUntil) && avoidUntil > now;
1090
+ if (!liveCooldown && !liveAvoidance) return null;
962
1091
  const {
963
1092
  cooldownUntil: _until,
964
1093
  cooldownSince: _since,
965
1094
  cooldownSource: _source,
966
1095
  probeLeaseId: _leaseId,
967
1096
  probeLeaseGeneration: _leaseGeneration,
1097
+ // Same reasoning as the probe recovery above: "the quota window moved" is a statement
1098
+ // about the whole refusal, so the avoidance it announced goes with the block it
1099
+ // produced. Keeping it would leave this escape hatch not escaping, because selection
1100
+ // would still pass over the account for as long as the announced window runs.
1101
+ quotaAvoidUntil: _avoid,
968
1102
  ...rest
969
1103
  } = health;
970
1104
  return {
@@ -1058,6 +1192,7 @@ function isCodexAccountSelectable(
1058
1192
  return !isCodexAccountPaused(config, accountId)
1059
1193
  && !isCodexAccountPlanExcluded(config, accountId)
1060
1194
  && getCodexQuotaHealthSnapshot(accountId, quotaScope, now) === null
1195
+ && !isCodexQuotaAvoided(accountId, quotaScope, now)
1061
1196
  && !isCodexAccountSoftAvoided(accountId, now)
1062
1197
  && isCodexAccountUsable(config, accountId, selectionOptions);
1063
1198
  }
@@ -1297,6 +1432,7 @@ function getEligiblePoolAccounts(
1297
1432
  && (!skipFailoverReadyCandidates || !shouldFailover(config, account.id, now)))
1298
1433
  .filter(account => getCodexQuotaHealthSnapshot(account.id, quotaScope, now) === null)
1299
1434
  .filter(account => !isCodexAccountSoftAvoided(account.id, now))
1435
+ .filter(account => !isCodexQuotaAvoided(account.id, quotaScope, now))
1300
1436
  .filter(account => isCodexAccountUsable(config, account.id, selectionOptions))
1301
1437
  .map(account => account.id);
1302
1438
  // The main Codex account is not stored in config.codexAccounts; include it as a
@@ -1307,6 +1443,12 @@ function getEligiblePoolAccounts(
1307
1443
  && (!isAccountNeedsReauth(MAIN_CODEX_ACCOUNT_ID) || hasMainAccountRefreshGrant())
1308
1444
  && getCodexQuotaHealthSnapshot(MAIN_CODEX_ACCOUNT_ID, quotaScope, now) === null
1309
1445
  && !isCodexAccountSoftAvoided(MAIN_CODEX_ACCOUNT_ID, now)
1446
+ // The main login is not in `config.codexAccounts`, so it never passes through the
1447
+ // filters above and this is the only place an avoidance window can exclude it. Without
1448
+ // this the window a refusal announced applies to the pool but not to the account that
1449
+ // earned it: the cooldown caps at fifteen minutes, the window runs up to six hours, and
1450
+ // in between the main account returns as a first-class candidate.
1451
+ && !isCodexQuotaAvoided(MAIN_CODEX_ACCOUNT_ID, quotaScope, now)
1310
1452
  && (!skipFailoverReadyCandidates || !shouldFailover(config, MAIN_CODEX_ACCOUNT_ID, now))
1311
1453
  && isCodexAccountUsable(config, MAIN_CODEX_ACCOUNT_ID, selectionOptions)
1312
1454
  ) {
@@ -1467,7 +1609,10 @@ function pickUnboundStrategyAccount(
1467
1609
  picked = pickRoundRobinAccount(poolKey, eligible, limit);
1468
1610
  if (!picked) return null;
1469
1611
  if (commitSharedActive) {
1470
- if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, picked);
1612
+ if (!isIndependentCodexQuotaScope(quotaScope)
1613
+ && !manualPreferenceBlocks(codexPoolKeyForScope(quotaScope), picked)) {
1614
+ rememberActiveCodexAccount(config, picked);
1615
+ }
1471
1616
  }
1472
1617
  if (commitAffinity && threadId) bindThreadAffinity(threadId, picked, now, quotaScope);
1473
1618
  notePoolRotationSuccess(poolKey, picked, limit);
@@ -1478,7 +1623,10 @@ function pickUnboundStrategyAccount(
1478
1623
  picked = pickFillFirstCodexAccount(config, now, quotaScope, selectionOptions);
1479
1624
  if (!picked) return null;
1480
1625
  if (commitSharedActive) {
1481
- if (!isIndependentCodexQuotaScope(quotaScope)) rememberActiveCodexAccount(config, picked);
1626
+ if (!isIndependentCodexQuotaScope(quotaScope)
1627
+ && !manualPreferenceBlocks(codexPoolKeyForScope(quotaScope), picked)) {
1628
+ rememberActiveCodexAccount(config, picked);
1629
+ }
1482
1630
  }
1483
1631
  if (commitAffinity && threadId) bindThreadAffinity(threadId, picked, now, quotaScope);
1484
1632
  return picked;
@@ -1622,6 +1770,53 @@ export function pickAlternateCodexAccount(
1622
1770
  }
1623
1771
 
1624
1772
  /** Effective active: automatic runtime cursor, else operator/persisted selection. */
1773
+ /**
1774
+ * Unspent operator selections, keyed by pool scope.
1775
+ *
1776
+ * Codex has no account-side equivalent of the Anthropic `selectionRevision`, so staleness
1777
+ * cannot be detected by comparing values: a pool-driven promote legitimately moves the
1778
+ * persisted active account, and reading that as staleness would silently spend the
1779
+ * operator's one-shot. Invalidation is keyed to the OPERATOR path instead — another manual
1780
+ * selection, the account leaving the pool, or a successful dispatch on it.
1781
+ */
1782
+ const manualPreference = new Map<string, string>();
1783
+
1784
+ /**
1785
+ * Spend the one-shot for a pool scope once a dispatch on that account actually succeeded.
1786
+ * This is the Codex analogue of `commitAnthropicSelectionRouting`, which Codex lacks.
1787
+ *
1788
+ * Wiring this BEFORE the guard below is not a style choice. Measured: with the guard in
1789
+ * place and no consume site, the first manual selection freezes the automatic cursor
1790
+ * permanently and 15 of 69 rotation tests fail.
1791
+ */
1792
+ function consumeManualPreference(accountId: string, poolKey: string): void {
1793
+ if (manualPreference.get(poolKey) === accountId) manualPreference.delete(poolKey);
1794
+ }
1795
+
1796
+ /**
1797
+ * Drop an account's preference in every scope. Pause and exclusion do not route through
1798
+ * `resetCodexRoutingForManualSelection`, so without this a preference could outlive the
1799
+ * account it names and keep suppressing the automatic cursor.
1800
+ */
1801
+ function forgetManualPreference(accountId: string): void {
1802
+ for (const [poolKey, preferred] of manualPreference) {
1803
+ if (preferred === accountId) manualPreference.delete(poolKey);
1804
+ }
1805
+ }
1806
+
1807
+ /**
1808
+ * True while an unspent operator selection for this scope names a DIFFERENT account than
1809
+ * the automatic pick about to be recorded.
1810
+ *
1811
+ * Callers pass their own scope: an independent quota scope keeps its own entry and must
1812
+ * never read the shared one. The failover promote does NOT consult this — see its call
1813
+ * site for why.
1814
+ */
1815
+ function manualPreferenceBlocks(poolKey: string, accountId: string): boolean {
1816
+ const preferred = manualPreference.get(poolKey);
1817
+ return preferred !== undefined && preferred !== accountId;
1818
+ }
1819
+
1625
1820
  export function getEffectiveActiveCodexAccountId(config: OcxConfig): string | undefined {
1626
1821
  return runtimeActiveCodexAccountId ?? config.activeCodexAccountId;
1627
1822
  }
@@ -1690,6 +1885,10 @@ export function reconcileCodexActiveAfterExclusion(
1690
1885
  now = Date.now(),
1691
1886
  ): string | null {
1692
1887
  const wasEffective = (getEffectiveActiveCodexAccountId(config) ?? MAIN_CODEX_ACCOUNT_ID) === excludedAccountId;
1888
+ // Exclusion does not route through resetCodexRoutingForManualSelection, so the one-shot is
1889
+ // revoked here too. A preference naming an account that can no longer serve would keep
1890
+ // suppressing the automatic cursor with no way to clear it.
1891
+ forgetManualPreference(excludedAccountId);
1693
1892
  if (config.activeCodexAccountId === excludedAccountId) {
1694
1893
  config.activeCodexAccountId = undefined;
1695
1894
  }
@@ -1892,6 +2091,45 @@ export function resolveCodexAccountForThread(
1892
2091
  return resolution.status === "selected" ? resolution.accountId : null;
1893
2092
  }
1894
2093
 
2094
+ function carriesQuotaRefusal(health: CodexUpstreamHealth | undefined): boolean {
2095
+ return health?.lastFailureStatus === 429 || health?.lastFailureStatus === 402;
2096
+ }
2097
+
2098
+ /**
2099
+ * Has this account refused a request on quota without serving one since?
2100
+ *
2101
+ * Thread affinity is a prompt-cache optimization and every rule around it is a preference:
2102
+ * `autoSwitchThreshold` is a hint that an account is getting busy, and `pool.cacheAffinity`
2103
+ * deliberately raises that bar further. A refusal is not a preference, and once the account has
2104
+ * told THIS thread it cannot serve, the binding has nothing left to optimize.
2105
+ *
2106
+ * The distinction matters because the cooldown a 429 writes is deliberately short. A reset
2107
+ * announcement is advisory — plan quota routinely frees up before the advertised instant — so
2108
+ * {@link CODEX_MAX_RESET_DERIVED_COOLDOWN_MS} caps it at 15 minutes. The five-hour window that
2109
+ * announcement describes is not capped, so an account whose burst window is spent looks
2110
+ * selectable again long before it is. For an unbound request that is correct: going back to find
2111
+ * out is how the pool learns the window moved. For a BOUND thread it is a loop with no exit —
2112
+ * the cooldown lapses, the account still scores lowest on the only window this proxy has a
2113
+ * reading for (its weekly bar, untouched by a burst limit), the thread rebinds, and earns the
2114
+ * identical 429. Cleared affinity does not help: the next request re-derives the same choice.
2115
+ * From the Codex side that reads exactly as reported — a new session rotates normally while an
2116
+ * existing one is locked to an exhausted account until the proxy is restarted, because a restart
2117
+ * is the only thing that drops the binding and the stale health together.
2118
+ *
2119
+ * `lastFailureStatus` is the right evidence because of when it ends: {@link preservedCooldownFields}
2120
+ * strips it from every recovery write, so it survives exactly until the account actually serves a
2121
+ * request again. Nothing here blocks that — selection is untouched, so unbound traffic still probes
2122
+ * the account and the first success releases every thread this refused.
2123
+ *
2124
+ * Scope follows where the refusal was recorded. An account-wide throttle lands in
2125
+ * `upstreamHealth` and releases every lane; a reset-derived refusal lands against one native
2126
+ * quota group, so a spent Spark window still cannot displace the same thread's Terra binding.
2127
+ */
2128
+ function hasUnrecoveredCodexQuotaRefusal(accountId: string, quotaScope?: CodexQuotaScope): boolean {
2129
+ if (carriesQuotaRefusal(upstreamHealth.get(accountId))) return true;
2130
+ return quotaScope !== undefined && carriesQuotaRefusal(scopedHealthFor(accountId, quotaScope));
2131
+ }
2132
+
1895
2133
  function previewReusableAffinityAccount(
1896
2134
  entry: ThreadAffinityEntry | undefined,
1897
2135
  config: OcxConfig,
@@ -1904,6 +2142,7 @@ function previewReusableAffinityAccount(
1904
2142
  || isThreadAffinityExpired(entry, now)
1905
2143
  || !isThreadAffinityGenerationLive(entry)
1906
2144
  || !isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions)
2145
+ || hasUnrecoveredCodexQuotaRefusal(entry.accountId, quotaScope)
1907
2146
  || shouldFailover(config, entry.accountId, now)
1908
2147
  ) {
1909
2148
  return null;
@@ -1918,7 +2157,9 @@ function previewReusableAffinityAccount(
1918
2157
  getPoolAccountPlanForSelection(config, entry.accountId, selectionOptions),
1919
2158
  now,
1920
2159
  );
1921
- if (!isUnknownUsage(usage) && usage >= threshold) {
2160
+ // Preview must agree with resolve: this is the second copy of the same rule, and the
2161
+ // suite asserts the two answer identically.
2162
+ if (mayRebindAffinityForQuota(config, entry.accountId, usage, threshold, selectionOptions)) {
1922
2163
  const best = pickLowerUsageAccount(
1923
2164
  config,
1924
2165
  entry.accountId,
@@ -1935,6 +2176,32 @@ function previewReusableAffinityAccount(
1935
2176
  return entry.accountId;
1936
2177
  }
1937
2178
 
2179
+ /**
2180
+ * May a LIVE binding be moved for quota reasons?
2181
+ *
2182
+ * Default: yes once usage crosses `autoSwitchThreshold`, which is the historical rule.
2183
+ *
2184
+ * With `pool.cacheAffinity` on, the bar becomes genuine exhaustion. Moving a bound
2185
+ * conversation discards the prompt cache warmed on its account, so a threshold crossing -- a
2186
+ * hint that the account is getting busy -- does not justify paying that cost; the account has
2187
+ * to be unable to serve. Deliberately NOT `hasCodexQuotaHeadroom`, which reads
2188
+ * `usage < autoSwitchThreshold` and would reproduce the old rule under a new name.
2189
+ */
2190
+ function mayRebindAffinityForQuota(
2191
+ config: OcxConfig,
2192
+ accountId: string,
2193
+ usage: number,
2194
+ threshold: number,
2195
+ selectionOptions?: CodexAccountUsabilityOptions,
2196
+ ): boolean {
2197
+ const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold;
2198
+ if (config.pool?.cacheAffinity !== true) return overThreshold;
2199
+ // The usable half is already guaranteed by both callers, which gate on
2200
+ // isCodexAccountSelectable; kept explicit so the predicate reads correctly on its own.
2201
+ return !isCodexAccountUsable(config, accountId, selectionOptions)
2202
+ || (!isUnknownUsage(usage) && usage >= 100);
2203
+ }
2204
+
1938
2205
  /**
1939
2206
  * Re-evaluate an affined account under the quota strategy. Returns a strictly
1940
2207
  * cooler replacement, or null when the current binding should remain.
@@ -1955,15 +2222,18 @@ function reevaluateAffinityQuota(
1955
2222
  now,
1956
2223
  )
1957
2224
  : 0;
1958
- const overThreshold = threshold > 0 && !isUnknownUsage(usage) && usage >= threshold;
2225
+ // One bar, used for BOTH the rebind decision and the re-score interval. Keying the short
2226
+ // circuit off the old threshold while the rebind bar moved would re-score a bound thread on
2227
+ // every request through the whole 80-99% band instead of once a minute.
2228
+ const mayRebind = mayRebindAffinityForQuota(config, entry.accountId, usage, threshold, selectionOptions);
1959
2229
  if (
1960
- !overThreshold
2230
+ !mayRebind
1961
2231
  && now - entry.lastReevalAt < CODEX_THREAD_AFFINITY_REEVAL_INTERVAL_MS
1962
2232
  ) {
1963
2233
  return null;
1964
2234
  }
1965
2235
  entry.lastReevalAt = now;
1966
- if (!overThreshold) return null;
2236
+ if (!mayRebind) return null;
1967
2237
  const best = pickLowerUsageAccount(
1968
2238
  config,
1969
2239
  entry.accountId,
@@ -2106,6 +2376,7 @@ export function resolveCodexAccountForThreadDetailed(
2106
2376
  const detourReusable = !isThreadAffinityExpired(detourEntry, now)
2107
2377
  && isThreadAffinityGenerationLive(detourEntry)
2108
2378
  && isCodexAccountSelectable(config, detourEntry.accountId, now, quotaScope, selectionOptions)
2379
+ && !hasUnrecoveredCodexQuotaRefusal(detourEntry.accountId, quotaScope)
2109
2380
  && !shouldFailover(config, detourEntry.accountId, now);
2110
2381
  if (detourReusable) {
2111
2382
  detourEntry.lastUsedAt = now;
@@ -2143,11 +2414,16 @@ export function resolveCodexAccountForThreadDetailed(
2143
2414
  const selectableForRequest = selectableForSharedState
2144
2415
  && isCodexAccountSelectable(config, entry.accountId, now, quotaScope, selectionOptions);
2145
2416
  const failoverReady = shouldFailover(config, entry.accountId, now);
2417
+ // A quota refusal outranks every affinity preference, including `pool.cacheAffinity`:
2418
+ // the account has already told this thread it cannot serve it.
2419
+ const quotaRefused = hasUnrecoveredCodexQuotaRefusal(entry.accountId, quotaScope);
2146
2420
  const healthyForSharedAffinity = selectableForSharedState
2147
2421
  && hasCodexQuotaHeadroom(config, entry.accountId, sharedSelectionOptions, now)
2422
+ && !quotaRefused
2148
2423
  && !failoverReady;
2149
2424
  if (
2150
2425
  selectableForRequest
2426
+ && !quotaRefused
2151
2427
  // Affined threads must leave a failing account once the streak trips failover
2152
2428
  // (soft-avoid covers the first-hit case; this catches post-avoid residual streaks).
2153
2429
  && !failoverReady
@@ -2210,6 +2486,13 @@ export function resolveCodexAccountForThreadDetailed(
2210
2486
  && !preserveSharedSelectionForModelDetour
2211
2487
  && !isIndependentCodexQuotaScope(quotaScope)
2212
2488
  ) {
2489
+ // NOT guarded by manualPreferenceBlocks, unlike preemption below. Measured: guarding
2490
+ // it fails 8 cases in tests/codex-integration/codex-routing.test.ts, because a model
2491
+ // detour is not the pool exercising discretion — the operator's account cannot serve
2492
+ // this model at all. Under a rotating strategy this promote only moves the
2493
+ // process-local cursor to whoever is actually serving and releases the pin; the
2494
+ // operator's persisted activeCodexAccountId is left untouched either way, which is
2495
+ // the thing the preference exists to protect.
2213
2496
  promoteActiveCodexAccount(config, strategyPick);
2214
2497
  }
2215
2498
  return { status: "selected", accountId: strategyPick };
@@ -2283,7 +2566,10 @@ export function resolveCodexAccountForThreadDetailed(
2283
2566
  !preserveSharedSelectionForModelDetour
2284
2567
  && !isIndependentCodexQuotaScope(quotaScope)
2285
2568
  ) {
2286
- rememberActiveCodexAccount(config, preempted);
2569
+ // Preemption is an automatic pick competing with the operator, so it yields.
2570
+ if (!manualPreferenceBlocks(POOL_KEY_CODEX, preempted)) {
2571
+ rememberActiveCodexAccount(config, preempted);
2572
+ }
2287
2573
  }
2288
2574
  active = preempted;
2289
2575
  }
@@ -2354,6 +2640,9 @@ export function recordCodexUpstreamOutcome(
2354
2640
  */
2355
2641
  dropSpentCredentialFailure(accountId);
2356
2642
  if (outcomeClass === "success") {
2643
+ // The operator's one-shot is spent by a dispatch that actually worked, and only by that.
2644
+ // A failed lookup leaves it unspent so the intent survives the failure.
2645
+ consumeManualPreference(accountId, codexPoolKeyForScope(quotaScope));
2357
2646
  const scopedProbe = meta.probeQuotaScope
2358
2647
  ? scopedHealthFor(accountId, meta.probeQuotaScope)
2359
2648
  : undefined;
@@ -2364,6 +2653,20 @@ export function recordCodexUpstreamOutcome(
2364
2653
  setScopedHealth(accountId, meta.probeQuotaScope, withProbeLeaseReleased(scopedProbe, now));
2365
2654
  }
2366
2655
  }
2656
+ // A served request is what ends the refusal marker the quota branch left on this lane.
2657
+ // The probe contract above owns the scoped COOLDOWN; this owns only the field
2658
+ // {@link hasUnrecoveredCodexQuotaRefusal} reads, which would otherwise keep threads away
2659
+ // from an account that is demonstrably serving them again. The account-wide marker needs
2660
+ // no equivalent: every recovery write below runs it through preservedCooldownFields.
2661
+ const refusedScope = quotaScope ? scopedHealthFor(accountId, quotaScope) : undefined;
2662
+ if (quotaScope && refusedScope && carriesQuotaRefusal(refusedScope)) {
2663
+ const {
2664
+ lastFailureStatus: _refusal, lastFailureAt: _refusedAt, quotaAvoidUntil: _avoid, ...retained
2665
+ } = refusedScope;
2666
+ // A live cooldown and its probe bookkeeping survive; an entry that held nothing else goes.
2667
+ if (Object.keys(retained).length > 1) setScopedHealth(accountId, quotaScope, retained);
2668
+ else deleteScopedHealth(accountId, quotaScope);
2669
+ }
2367
2670
  const current = upstreamHealth.get(accountId);
2368
2671
  const cooldownUntil = getCodexAccountCooldownUntil(accountId, now);
2369
2672
  // A leased probe that is still on its own cooldown generation proves the
@@ -2507,6 +2810,7 @@ export function recordCodexUpstreamOutcome(
2507
2810
  lastFailureStatus,
2508
2811
  lastFailureAt: now,
2509
2812
  cooldownUntil: until,
2813
+ quotaAvoidUntil: quotaAvoidUntilFor(meta, now, until),
2510
2814
  cooldownSince: now,
2511
2815
  cooldownSource: source,
2512
2816
  cooldownGeneration,
@@ -2555,6 +2859,7 @@ export function recordCodexUpstreamOutcome(
2555
2859
  lastFailureStatus,
2556
2860
  lastFailureAt: now,
2557
2861
  cooldownUntil: until,
2862
+ quotaAvoidUntil: quotaAvoidUntilFor(meta, now, until),
2558
2863
  cooldownSince: now,
2559
2864
  cooldownSource: source,
2560
2865
  cooldownGeneration,
@@ -27,8 +27,8 @@ export interface CodexWarmupOptions {
27
27
  }
28
28
 
29
29
  const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
30
- const DEFAULT_MODEL = "gpt-5.4-mini";
31
- const FALLBACK_MODELS = ["gpt-5.5", "gpt-5.6-luna"];
30
+ const DEFAULT_MODEL = "gpt-5.6-luna";
31
+ const FALLBACK_MODELS = ["gpt-5.5"];
32
32
  const isRetryableWarmupStatus = (status?: number): boolean => status === 400 || status === 404;
33
33
  const DEFAULT_TIMEOUT_MS = 30_000;
34
34
  const MAX_TIMEOUT_MS = 0x7fff_ffff;
@@ -1,5 +1,6 @@
1
1
  import { parseResetCooldownMs } from "../codex/routing";
2
2
  import { classifyError, isCyberPolicyCode } from "../lib/errors";
3
+ import { isNonReplayableUpstreamCode } from "../lib/upstream-retry";
3
4
  import type { OcxComboTarget } from "../types";
4
5
  import { targetKey } from "./types";
5
6
  import {
@@ -413,6 +414,10 @@ export function comboFailureDecision(
413
414
  ): ComboFailureDecision {
414
415
  if (status === 499) return "stop";
415
416
  if (message.toLowerCase().includes("origin_rejected")) return "stop";
417
+ // The origin may already be executing this turn (the Codex WebSocket relay sent the create
418
+ // frame and never saw a response event). Hopping would send the same request to a second
419
+ // target while the first may still be generating; the honest status goes to the client.
420
+ if (isNonReplayableUpstreamCode(options?.code)) return "stop";
416
421
  // Cyber policy is a hard non-retryable refusal — honor structured code even when
417
422
  // classificationText was truncated before the JSON code field.
418
423
  if (isCyberPolicyCode(options?.code)) return "stop";