@bitkyc08/opencodex 2.72.0 → 2.73.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 (79) hide show
  1. package/gui/dist/assets/{App-Cf-WYstE.js → App-mHGa7dE1.js} +15 -15
  2. package/gui/dist/assets/{Tray-DkM0mULy.js → Tray-BxY_RYZg.js} +1 -1
  3. package/gui/dist/assets/{index-PWrwOfio.js → index-ur_BPgK1.js} +12 -12
  4. package/gui/dist/assets/{quota-summary-D-FKtrgr.js → quota-summary-D1Vq7N9H.js} +1 -1
  5. package/gui/dist/index.html +1 -1
  6. package/package.json +1 -1
  7. package/src/adapters/cursor/native-exec.ts +28 -10
  8. package/src/adapters/devin/live-models.ts +5 -0
  9. package/src/adapters/kiro/reasoning.ts +2 -0
  10. package/src/adapters/openai-chat/messages.ts +4 -1
  11. package/src/adapters/openai-responses/passthrough.ts +12 -1
  12. package/src/adapters/openai-responses/reasoning.ts +15 -0
  13. package/src/bridge/internal.ts +12 -0
  14. package/src/cli/account-anthropic-threshold.ts +41 -0
  15. package/src/cli/account-api.ts +2 -0
  16. package/src/cli/account-extended.ts +32 -21
  17. package/src/cli/account.ts +1 -0
  18. package/src/cli/capabilities.ts +8 -4
  19. package/src/cli/index.ts +1 -1
  20. package/src/codex/catalog/build-entries.ts +6 -5
  21. package/src/codex/catalog/derive-entry.ts +3 -4
  22. package/src/codex/catalog/gather-capture.ts +2 -0
  23. package/src/codex/catalog/metadata.ts +6 -0
  24. package/src/codex/catalog/native-models.ts +17 -3
  25. package/src/codex/catalog/parsing.ts +4 -1
  26. package/src/codex/catalog/pinned-models.ts +3 -1
  27. package/src/codex/catalog/provider-models.ts +22 -2
  28. package/src/codex/data/roster-pinned-models.json +178 -1
  29. package/src/codex/model-entitlements.ts +1 -1
  30. package/src/codex/native-profile-store.ts +2 -1
  31. package/src/combos/resolve.ts +16 -1
  32. package/src/config/subagent-models.ts +30 -5
  33. package/src/generated/compatibility-version.json +95 -67
  34. package/src/generated/model-metadata.ts +4 -4
  35. package/src/lib/account-selection-events.ts +28 -0
  36. package/src/lib/errors.ts +7 -5
  37. package/src/lib/keyring-native.ts +132 -0
  38. package/src/lib/provider-outbound.ts +13 -0
  39. package/src/lib/retry-delay.ts +10 -0
  40. package/src/oauth/anthropic-account-threshold.ts +13 -0
  41. package/src/oauth/anthropic-routing.ts +223 -52
  42. package/src/oauth/index.ts +4 -2
  43. package/src/oauth/store.ts +45 -3
  44. package/src/oauth/types.ts +2 -0
  45. package/src/providers/api-key-resolve.ts +2 -4
  46. package/src/providers/codebuddy-models.ts +6 -0
  47. package/src/providers/kiro-models.ts +4 -0
  48. package/src/providers/label.ts +20 -1
  49. package/src/providers/quota/account-cache.ts +2 -1
  50. package/src/providers/quota/anthropic-cooldown-recovery.ts +118 -0
  51. package/src/providers/quota/vendor-probes-oauth.ts +84 -57
  52. package/src/providers/quota.ts +45 -46
  53. package/src/providers/registry/entries-core.ts +3 -1
  54. package/src/providers/registry/entries-extended.ts +14 -1
  55. package/src/providers/registry/model-seeds.ts +7 -1
  56. package/src/server/claude-messages.ts +84 -27
  57. package/src/server/management/anthropic-account-threshold.ts +27 -0
  58. package/src/server/management/oauth-account-routes.ts +17 -4
  59. package/src/server/management/route-registry.ts +1 -0
  60. package/src/server/messages-native-oauth.ts +20 -21
  61. package/src/server/messages-native.ts +39 -14
  62. package/src/server/relay-eager.ts +5 -2
  63. package/src/server/relay.ts +268 -82
  64. package/src/server/responses/adapter-continuation.ts +6 -0
  65. package/src/server/responses/adapter-delivery.ts +25 -7
  66. package/src/server/responses/adapter-dispatch.ts +31 -0
  67. package/src/server/responses/buffered-sse-json.ts +345 -0
  68. package/src/server/responses/core-codex-account.ts +4 -1
  69. package/src/server/responses/core-combo.ts +28 -0
  70. package/src/server/responses/core-lifetime.ts +4 -0
  71. package/src/server/responses/passthrough-delivery.ts +305 -10
  72. package/src/server/responses/passthrough-dispatch.ts +60 -25
  73. package/src/server/responses/passthrough-error.ts +3 -1
  74. package/src/server/responses/request-transport.ts +22 -5
  75. package/src/server/responses/terminal-error-redaction.ts +53 -0
  76. package/src/service/windows-taskxml.ts +20 -6
  77. package/src/service-manager-probe.ts +115 -6
  78. package/src/types/wire.ts +7 -0
  79. package/src/usage/expected-prices.ts +11 -0
@@ -0,0 +1,13 @@
1
+ import type { OcxConfig } from "../types";
2
+ import type { ProviderAccount } from "./types";
3
+
4
+ /** Shared strict boundary for persisted rows and management writes. Zero is not exhaustion. */
5
+ export function parseAnthropicAccountThreshold(value: unknown): number | null {
6
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 && value <= 100 ? value : null;
7
+ }
8
+
9
+ /** Missing or malformed legacy metadata inherits; concrete zero must survive nullish fallback. */
10
+ export function effectiveAnthropicAccountThreshold(config: OcxConfig, account?: Pick<ProviderAccount, "autoSwitchThresholdOverride">): number {
11
+ return parseAnthropicAccountThreshold(account?.autoSwitchThresholdOverride)
12
+ ?? parseAnthropicAccountThreshold(config.anthropicAccountPool?.autoSwitchThreshold) ?? 80;
13
+ }
@@ -34,6 +34,14 @@ import type { OcxAccountPoolQuotaWindow, OcxAccountPoolRotationStrategy, OcxConf
34
34
  import { sweepExpiredOnWrite } from "../lib/state-store-sweeper";
35
35
  import { retainedUtf8Bytes } from "../lib/admission";
36
36
  import { routeCandidates, type AnthropicRouteDecision } from "./anthropic-model-routes";
37
+ import type { ProviderQuota } from "../providers/quota-types";
38
+ import {
39
+ anthropicCooldownGeneration,
40
+ clearAnthropicCooldownGenerations,
41
+ noteAnthropicCooldownMutation,
42
+ } from "../providers/quota/anthropic-cooldown-recovery";
43
+ import { subscribeAccountSelections, subscribeOAuthAccountPauseChanges, subscribeOAuthAccountRoutingPolicyChanges } from "../lib/account-selection-events";
44
+ import { effectiveAnthropicAccountThreshold } from "./anthropic-account-threshold";
37
45
 
38
46
  /**
39
47
  * The read side of a `Headers` object, so a caller can pass the live upstream response's
@@ -57,6 +65,16 @@ export const ANTHROPIC_POOL_MAX_FAILOVERS_PER_REQUEST = 3;
57
65
 
58
66
  export type AnthropicAccountPoolConfig = NonNullable<OcxConfig["anthropicAccountPool"]>;
59
67
 
68
+ /** Local admission refusal across async dispatch; never treat it as provider reachability evidence. */
69
+ export class AnthropicAccountCooldownError extends Error {
70
+ constructor(readonly retryAfterSeconds: number | null, readonly routePosition?: number) {
71
+ super(routePosition === undefined
72
+ ? "All Anthropic OAuth accounts are temporarily rate-limited"
73
+ : "Anthropic OAuth accounts for this model route are temporarily rate-limited");
74
+ this.name = "AnthropicAccountCooldownError";
75
+ }
76
+ }
77
+
60
78
  /**
61
79
  * Where a cooldown's length came from. Same vocabulary as `CodexCooldownSource`, because it
62
80
  * answers the same question for the same reason: `retry-after` is upstream answering THIS
@@ -66,10 +84,15 @@ export type AnthropicAccountPoolConfig = NonNullable<OcxConfig["anthropicAccount
66
84
  * `retry-after` would report a drained five-hour window as request-rate throttling.
67
85
  */
68
86
  type AnthropicCooldownSource = "retry-after" | "reset-derived" | "default";
87
+ type AnthropicQuotaWindow = "five-hour" | "weekly";
69
88
 
70
89
  interface AccountHealth {
71
90
  cooldownUntil: number;
72
91
  cooldownSource: AnthropicCooldownSource;
92
+ /** Windows whose rejected status established a reset-derived cooldown. */
93
+ rejectedQuotaWindows?: AnthropicQuotaWindow[];
94
+ /** Monotonic fence so an older quota probe cannot erase a newer refusal. */
95
+ cooldownGeneration: number;
73
96
  }
74
97
 
75
98
  interface AffinityEntry {
@@ -105,6 +128,11 @@ export function anthropicAutoSwitchThreshold(config: OcxConfig): number {
105
128
  return DEFAULT_AUTO_SWITCH_THRESHOLD;
106
129
  }
107
130
 
131
+ /** Read live policy at selection, not a credential snapshot captured before an await. */
132
+ export function anthropicAccountAutoSwitchThreshold(config: OcxConfig, accountId: string): number {
133
+ return effectiveAnthropicAccountThreshold(config, getAccountSet(PROVIDER)?.accounts.find(row => row.id === accountId));
134
+ }
135
+
108
136
  /** Strict parse for management APIs — returns null instead of defaulting. */
109
137
  export function parseAccountPoolQuotaWindow(raw: unknown): OcxAccountPoolQuotaWindow | null {
110
138
  if (typeof raw === "string" && VALID_QUOTA_WINDOWS.has(raw as OcxAccountPoolQuotaWindow)) {
@@ -140,11 +168,16 @@ function parseRetryAfterMs(value: string | null | undefined, now: number): numbe
140
168
  }
141
169
 
142
170
  /** Only rejected windows constrain recovery; all must reopen, so take the latest reset. */
143
- function parseRateLimitResetMs(headers: AnthropicRateLimitHeaders | null | undefined, now: number): number | undefined {
171
+ function parseRateLimitReset(
172
+ headers: AnthropicRateLimitHeaders | null | undefined,
173
+ now: number,
174
+ ): { delayMs: number; rejectedQuotaWindows: AnthropicQuotaWindow[] } | undefined {
144
175
  if (!headers) return undefined;
145
176
  let latest: number | undefined;
146
- for (const window of ["5h", "7d"] as const) {
177
+ const rejectedQuotaWindows: AnthropicQuotaWindow[] = [];
178
+ for (const [window, quotaWindow] of [["5h", "five-hour"], ["7d", "weekly"]] as const) {
147
179
  if (headers.get(`anthropic-ratelimit-unified-${window}-status`)?.trim() !== "rejected") continue;
180
+ rejectedQuotaWindows.push(quotaWindow);
148
181
  const resetSeconds = Number(headers.get(`anthropic-ratelimit-unified-${window}-reset`)?.trim());
149
182
  if (!Number.isFinite(resetSeconds) || resetSeconds <= 0) continue;
150
183
  const resetAt = resetSeconds * 1000;
@@ -152,7 +185,55 @@ function parseRateLimitResetMs(headers: AnthropicRateLimitHeaders | null | undef
152
185
  if (latest === undefined || resetAt > latest) latest = resetAt;
153
186
  }
154
187
  if (latest === undefined) return undefined;
155
- return latest - now;
188
+ return { delayMs: latest - now, rejectedQuotaWindows };
189
+ }
190
+
191
+ export type AnthropicCooldownRecoveryClaim = Readonly<{
192
+ accountId: string;
193
+ cooldownGeneration: number;
194
+ claimedAt: number;
195
+ }>;
196
+
197
+ export type AnthropicCooldownRecoverySettlement = "cleared" | "retained" | "superseded";
198
+
199
+ /**
200
+ * Capture the exact reset-derived refusal a fresh usage probe is allowed to recover.
201
+ *
202
+ * The generation fence matters because the usage request is asynchronous: a newer 429 may
203
+ * arrive while it is in flight, and an older answer must never erase that newer refusal.
204
+ */
205
+ export function captureAnthropicCooldownRecovery(
206
+ accountId: string,
207
+ now = Date.now(),
208
+ ): AnthropicCooldownRecoveryClaim | null {
209
+ const entry = upstreamHealth.get(accountId);
210
+ if (!entry || entry.cooldownUntil <= now || entry.cooldownSource !== "reset-derived") return null;
211
+ return { accountId, cooldownGeneration: entry.cooldownGeneration, claimedAt: now };
212
+ }
213
+
214
+ /**
215
+ * Clear only the claimed reset-derived cooldown when a fresh, complete usage result proves
216
+ * headroom in every window that upstream previously reported as rejected.
217
+ */
218
+ export function settleAnthropicCooldownRecovery(
219
+ claim: AnthropicCooldownRecoveryClaim,
220
+ quota: ProviderQuota,
221
+ ): AnthropicCooldownRecoverySettlement {
222
+ const entry = upstreamHealth.get(claim.accountId);
223
+ if (!entry || entry.cooldownSource !== "reset-derived"
224
+ || entry.cooldownGeneration !== claim.cooldownGeneration
225
+ || anthropicCooldownGeneration(claim.accountId) !== claim.cooldownGeneration
226
+ || !Number.isFinite(quota.updatedAt) || quota.updatedAt < claim.claimedAt) return "superseded";
227
+ const windows = entry.rejectedQuotaWindows;
228
+ if (!windows?.length) return "retained";
229
+ const recovered = windows.every(window => {
230
+ const percent = window === "five-hour" ? quota.fiveHourPercent : quota.weeklyPercent;
231
+ return typeof percent === "number" && Number.isFinite(percent) && percent >= 0 && percent < 100;
232
+ });
233
+ if (!recovered) return "retained";
234
+ upstreamHealth.delete(claim.accountId);
235
+ noteAnthropicCooldownMutation(claim.accountId);
236
+ return "cleared";
156
237
  }
157
238
 
158
239
  export function getAnthropicAccountHealthSnapshot(
@@ -163,13 +244,16 @@ export function getAnthropicAccountHealthSnapshot(
163
244
  if (!entry) return null;
164
245
  if (entry.cooldownUntil <= now) {
165
246
  upstreamHealth.delete(accountId);
247
+ noteAnthropicCooldownMutation(accountId);
166
248
  return null;
167
249
  }
168
250
  return { cooldownUntil: entry.cooldownUntil, cooldownSource: entry.cooldownSource };
169
251
  }
170
252
 
171
253
  export function clearAnthropicAccountCooldown(accountId: string): boolean {
172
- return upstreamHealth.delete(accountId);
254
+ const cleared = upstreamHealth.delete(accountId);
255
+ if (cleared) noteAnthropicCooldownMutation(accountId);
256
+ return cleared;
173
257
  }
174
258
 
175
259
  export function sweepExpiredAnthropicRoutingHealth(now = Date.now()): number {
@@ -177,6 +261,7 @@ export function sweepExpiredAnthropicRoutingHealth(now = Date.now()): number {
177
261
  for (const [accountId, health] of upstreamHealth) {
178
262
  if (health.cooldownUntil > now) continue;
179
263
  upstreamHealth.delete(accountId);
264
+ noteAnthropicCooldownMutation(accountId);
180
265
  removed += 1;
181
266
  }
182
267
  return removed;
@@ -185,6 +270,7 @@ export function sweepExpiredAnthropicRoutingHealth(now = Date.now()): number {
185
270
  /** Test / logout helper. */
186
271
  export function clearAnthropicAccountPoolState(): void {
187
272
  upstreamHealth.clear();
273
+ clearAnthropicCooldownGenerations();
188
274
  sessionAffinity.clear();
189
275
  manualPreference = undefined;
190
276
  quorumCache = null;
@@ -266,7 +352,7 @@ export function getEligibleAnthropicAccounts(now = Date.now()): string[] {
266
352
  if (!set) return [];
267
353
  return set.accounts
268
354
  .filter(account =>
269
- account.needsReauth !== true
355
+ account.paused !== true && account.needsReauth !== true
270
356
  && !isCooled(account.id, now)
271
357
  && isPoolCredentialUsable(account.id, now))
272
358
  .map(account => account.id);
@@ -301,6 +387,25 @@ export function getEligibleAnthropicAccounts(now = Date.now()): string[] {
301
387
  const QUORUM_CACHE_TTL_MS = 2_000;
302
388
 
303
389
  let quorumCache: { value: boolean; readAt: number } | null = null;
390
+ // Pause changes eligibility, not health. Do not reset cooldowns or cancel sent turns.
391
+ subscribeOAuthAccountPauseChanges(provider => { if (provider === PROVIDER) quorumCache = null; });
392
+ // A threshold write must fence in-flight automatic proposals, but it does not
393
+ // revoke an operator's one-dispatch choice. Rebase only that still-owned choice;
394
+ // an intervening account change clears it, so an ABA selection is not resurrected.
395
+ subscribeOAuthAccountRoutingPolicyChanges(event => {
396
+ if (event.provider !== PROVIDER || !manualPreference) return;
397
+ if (manualPreference.accountId !== event.before.accountId
398
+ || manualPreference.revision !== event.before.revision) return;
399
+ manualPreference = event.after.accountId === manualPreference.accountId ? { ...event.after } : null;
400
+ });
401
+ // Any non-policy selection generation supersedes the pending one-shot choice.
402
+ // Threshold mutations rebase it first, before this generic notification runs.
403
+ subscribeAccountSelections(event => {
404
+ if (event.provider !== PROVIDER || event.kind !== "oauth" || !manualPreference) return;
405
+ const current = captureOAuthAccountSelection(PROVIDER);
406
+ if (current?.accountId !== manualPreference.accountId
407
+ || current.revision !== manualPreference.revision) manualPreference = null;
408
+ });
304
409
 
305
410
  /**
306
411
  * Whether a 429 has somewhere to go: two or more accounts that could serve traffic if asked.
@@ -331,7 +436,7 @@ export function hasAnthropicFailoverQuorum(now = Date.now()): boolean {
331
436
  if (set) {
332
437
  let usable = 0;
333
438
  for (const account of set.accounts) {
334
- if (account.needsReauth === true) continue;
439
+ if (account.paused === true || account.needsReauth === true) continue;
335
440
  if (!isPoolCredentialUsable(account.id, now)) continue;
336
441
  if (++usable >= 2) { value = true; break; }
337
442
  }
@@ -355,8 +460,8 @@ export function getAnthropicPoolRetryAfterSeconds(now = Date.now(), decision: An
355
460
  && !getEligibleAnthropicAccounts(now).some(id => decision.accounts.includes(id));
356
461
  let earliest: number | null = null;
357
462
  for (const account of set.accounts) {
463
+ if (account.paused === true || account.needsReauth === true || !isPoolCredentialUsable(account.id, now)) continue;
358
464
  if (decision && !fallbackExpanded && !decision.accounts.includes(account.id)) continue;
359
- if (fallbackExpanded && (account.needsReauth === true || !isPoolCredentialUsable(account.id, now))) continue;
360
465
  const snap = getAnthropicAccountHealthSnapshot(account.id, now);
361
466
  if (!snap?.cooldownUntil) continue;
362
467
  if (earliest === null || snap.cooldownUntil < earliest) earliest = snap.cooldownUntil;
@@ -388,8 +493,15 @@ function compareScoredAccounts(a: ScoredAccount, b: ScoredAccount): number {
388
493
  function pickLowestUsage(config: OcxConfig, excludeId: string | undefined, now: number, decision: AnthropicRouteDecision | null = null): string | null {
389
494
  const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
390
495
  const unfiltered = routeCandidates(getEligibleAnthropicAccounts(now), decision).filter(id => id !== excludeId);
391
- const available = window === "weekly" ? unfiltered.filter(id => !exhausted5h(id)) : unfiltered;
392
- const eligible = available.length > 0 ? available : unfiltered;
496
+ const available = window === "weekly" ? unfiltered.filter(id => !exhausted5h(id)
497
+ || isAnthropicAccountPoolEnabled(config) && anthropicAccountAutoSwitchThreshold(config, id) === 0) : unfiltered;
498
+ const availableOrFallback = available.length > 0 ? available : unfiltered;
499
+ // Thresholds are preferences, never eligibility. Keep the old lowest-usage fallback
500
+ // when every candidate is drained, and keep pool-off reactive recovery policy inert.
501
+ const hasKnownUnderThreshold = isAnthropicAccountPoolEnabled(config)
502
+ && availableOrFallback.some(id => hasKnownUsage(config, id) && isActiveUnderFillFirstThreshold(config, id));
503
+ const eligible = hasKnownUnderThreshold
504
+ ? availableOrFallback.filter(id => isActiveUnderFillFirstThreshold(config, id)) : availableOrFallback;
393
505
  if (eligible.length === 0) return null;
394
506
  const scored: ScoredAccount[] = eligible.map(accountId => ({
395
507
  accountId,
@@ -422,7 +534,8 @@ function pickNextFillFirstAnthropicAccount(
422
534
  decision: AnthropicRouteDecision | null,
423
535
  ): string | null {
424
536
  const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
425
- const available = window === "weekly" ? eligible.filter(id => !exhausted5h(id)) : eligible;
537
+ const available = window === "weekly" ? eligible.filter(id => !exhausted5h(id)
538
+ || anthropicAccountAutoSwitchThreshold(config, id) === 0) : eligible;
426
539
  const candidates = available.length > 0 ? available : eligible;
427
540
  if (candidates.length === 0) return null;
428
541
  const routeOrder = usesDeclaredRouteOrder(eligible, decision);
@@ -486,6 +599,7 @@ export type AnthropicAccountSelectionReason =
486
599
  | "manual"
487
600
  | "fill-first"
488
601
  | "none"
602
+ | "paused"
489
603
  | "all-cooled";
490
604
 
491
605
  export interface AnthropicAccountSelection {
@@ -503,7 +617,7 @@ function anthropicPoolStrategy(config: OcxConfig): OcxAccountPoolRotationStrateg
503
617
  }
504
618
 
505
619
  function isActiveUnderFillFirstThreshold(config: OcxConfig, accountId: string): boolean {
506
- const threshold = anthropicAutoSwitchThreshold(config);
620
+ const threshold = anthropicAccountAutoSwitchThreshold(config, accountId);
507
621
  if (threshold <= 0) return true;
508
622
  const window = anthropicQuotaWindow(anthropicAccountPoolConfig(config));
509
623
  if (window === "weekly" && exhausted5h(accountId)) return false;
@@ -568,7 +682,7 @@ function pickUnboundStrategyAccount(
568
682
 
569
683
  /**
570
684
  * Resolve which Anthropic OAuth account should serve this session.
571
- * When the pool is disabled, always returns the store's active account.
685
+ * When the pool is disabled, retain the active account unless the operator paused it.
572
686
  */
573
687
  export function resolveAnthropicAccountForSession(
574
688
  sessionKey: string | null | undefined,
@@ -579,6 +693,11 @@ export function resolveAnthropicAccountForSession(
579
693
  pruneExpiredAffinity(now);
580
694
  const set = getAccountSet(PROVIDER);
581
695
  if (!set || set.accounts.length === 0) return { accountId: null, reason: "none", ...(decision ? { routePosition: decision.position } : {}) };
696
+ const scoped = decision && !decision.fallback
697
+ ? set.accounts.filter(account => decision.accounts.includes(account.id)) : set.accounts;
698
+ if (scoped.length > 0 && scoped.every(account => account.paused === true)) {
699
+ return { accountId: null, reason: "paused", routePosition: decision?.position };
700
+ }
582
701
 
583
702
  if (manualPreference === undefined) {
584
703
  manualPreference = set.selectionRevision !== undefined
@@ -586,23 +705,24 @@ export function resolveAnthropicAccountForSession(
586
705
  : null;
587
706
  }
588
707
 
589
- if (!isAnthropicAccountPoolEnabled(config)) {
590
- return { accountId: set.activeAccountId, reason: "pool-disabled" };
708
+ const eligible = routeCandidates(getEligibleAnthropicAccounts(now), decision);
709
+ if (eligible.length === 0) {
710
+ // Pause, removal and reauthentication are not cooldown evidence. Classify only
711
+ // usable members of this strict route (or the ordinary pool after fallback widens).
712
+ const recoverable = set.accounts.filter(account =>
713
+ (!decision || decision.fallback || decision.accounts.includes(account.id))
714
+ && account.paused !== true && account.needsReauth !== true && isPoolCredentialUsable(account.id, now));
715
+ const cooled = recoverable.length > 0 && recoverable.every(account => isCooled(account.id, now));
716
+ if (cooled || decision) return { accountId: null, reason: cooled ? "all-cooled" : "none", routePosition: decision?.position };
591
717
  }
592
718
 
593
- const eligible = routeCandidates(getEligibleAnthropicAccounts(now), decision);
594
- if (decision && eligible.length === 0) {
595
- let cooled: boolean;
596
- if (decision.fallback) {
597
- // A removed route member is not a candidate, but fallback can still use the
598
- // ordinary pool once those accounts recover. Only usable stored accounts count.
599
- const ordinary = set.accounts.filter(account =>
600
- account.needsReauth !== true && isPoolCredentialUsable(account.id, now));
601
- cooled = ordinary.length > 0 && ordinary.every(account => isCooled(account.id, now));
602
- } else {
603
- cooled = decision.accounts.every(id => set.accounts.some(account => account.id === id && isCooled(id, now)));
719
+ if (!isAnthropicAccountPoolEnabled(config)) {
720
+ const active = set.accounts.find(account => account.id === set.activeAccountId);
721
+ // Disabled proactive rotation does not authorize a paused slot or an entirely cooled pool.
722
+ if (active?.paused || isCooled(set.activeAccountId, now)) {
723
+ return { accountId: eligible[0] ?? null, reason: eligible.length > 0 ? "only-eligible" : "none" };
604
724
  }
605
- return { accountId: null, reason: cooled ? "all-cooled" : "none", routePosition: decision.position };
725
+ return { accountId: set.activeAccountId, reason: "pool-disabled" };
606
726
  }
607
727
 
608
728
  // A manual choice is a one-dispatch preference, not a lower-priority quota hint.
@@ -626,7 +746,7 @@ export function resolveAnthropicAccountForSession(
626
746
  if (key) {
627
747
  const affined = sessionAffinity.get(key);
628
748
  if (affined && now - affined.lastUsedAt <= AFFINITY_IDLE_TTL_MS) {
629
- const stillThere = set.accounts.some(a => a.id === affined.accountId && a.needsReauth !== true);
749
+ const stillThere = set.accounts.some(a => a.id === affined.accountId && a.paused !== true && a.needsReauth !== true);
630
750
  const stillUsable = stillThere && !isCooled(affined.accountId, now)
631
751
  && isPoolCredentialUsable(affined.accountId, now);
632
752
  if (stillUsable && eligible.includes(affined.accountId)) {
@@ -656,7 +776,7 @@ export function resolveAnthropicAccountForSession(
656
776
  return { accountId: strategyPick.accountId, reason: strategyPick.reason, routePosition: decision?.position };
657
777
  }
658
778
 
659
- const threshold = anthropicAutoSwitchThreshold(config);
779
+ const threshold = anthropicAccountAutoSwitchThreshold(config, set.activeAccountId);
660
780
  const activeOk = set.accounts.some(a => a.id === set.activeAccountId && a.needsReauth !== true)
661
781
  && !isCooled(set.activeAccountId, now)
662
782
  && eligible.includes(set.activeAccountId);
@@ -694,13 +814,36 @@ export function resolveAnthropicAccountForSession(
694
814
  }
695
815
 
696
816
  if (!accountId) {
697
- const anyCooled = set.accounts.some(a => (!decision || decision.accounts.includes(a.id)) && isCooled(a.id, now));
817
+ const anyCooled = set.accounts.some(a => !a.paused && (!decision || decision.accounts.includes(a.id)) && isCooled(a.id, now));
698
818
  return { accountId: null, reason: anyCooled ? "all-cooled" : "none", routePosition: decision?.position };
699
819
  }
700
820
 
701
821
  return { accountId, reason, routePosition: decision?.position };
702
822
  }
703
823
 
824
+ /** Shared local refusal policy for Responses and native Messages after asynchronous waits. */
825
+ export async function resolveAnthropicDispatchAccountId(
826
+ config: OcxConfig,
827
+ sessionKey: string | null = null,
828
+ decision: AnthropicRouteDecision | null = null,
829
+ ): Promise<string> {
830
+ const now = Date.now();
831
+ const selection = resolveAnthropicAccountForSession(sessionKey, config, now, decision);
832
+ if (selection.reason === "all-cooled") {
833
+ throw new AnthropicAccountCooldownError(getAnthropicPoolRetryAfterSeconds(now, decision), decision?.position);
834
+ }
835
+ if (selection.reason === "paused" || !selection.accountId || !getEligibleAnthropicAccounts(now).includes(selection.accountId)) {
836
+ const { OAuthAccountPausedError, OAuthLoginRequiredError } = await import("./index");
837
+ const active = getAccountSet(PROVIDER)?.activeAccountId;
838
+ // Pool-off fresh admission resolves the active credential first. Preserve that
839
+ // paused-active refusal when no usable survivor could take over from it.
840
+ if (selection.reason === "paused" || (!isAnthropicAccountPoolEnabled(config) && active
841
+ && getAccountCredentialWithStatus(PROVIDER, active)?.paused)) throw new OAuthAccountPausedError();
842
+ throw new OAuthLoginRequiredError(PROVIDER);
843
+ }
844
+ return selection.accountId;
845
+ }
846
+
704
847
  export function bindAnthropicSessionAffinity(
705
848
  sessionKey: string | null | undefined,
706
849
  accountId: string,
@@ -736,50 +879,68 @@ export function rotateAnthropicAccountOn429(
736
879
  rateLimitHeaders?: AnthropicRateLimitHeaders | null,
737
880
  decision: AnthropicRouteDecision | null = null,
738
881
  ): string | null {
882
+ if (!recordAnthropicAccount429(config, failedAccountId, retryAfterHeader, now, rateLimitHeaders)) return null;
883
+
884
+ // The pool's strategy is a PROACTIVE policy. When the pool is disabled, reactive
885
+ // presence-only recovery must not silently reactivate round-robin/fill-first merely
886
+ // because those dormant values remain in config. The quota picker is the neutral
887
+ // recovery policy already used by the default strategy.
888
+ const next = isAnthropicAccountPoolEnabled(config)
889
+ ? pickAlternateAnthropicAccount(config, failedAccountId, now, decision)
890
+ : pickLowestUsage(config, failedAccountId, now);
891
+ if (!next) {
892
+ console.warn(`[anthropic-pool] ${decision ? `route:#${decision.position} ` : ""}no eligible replacement; returning 429`);
893
+ return null;
894
+ }
895
+
896
+ console.warn(
897
+ `[anthropic-pool] ${decision ? `route:#${decision.position} ` : ""}429 on ${formatAnthropicAccountOrdinal(failedAccountId)}; failing over to ${formatAnthropicAccountOrdinal(next)}`,
898
+ );
899
+ return next;
900
+ }
901
+
902
+ /** Record the account refusal even when this request has no remaining retry sends. */
903
+ export function recordAnthropicAccount429(
904
+ config: OcxConfig,
905
+ failedAccountId: string,
906
+ retryAfterHeader: string | null | undefined,
907
+ now = Date.now(),
908
+ rateLimitHeaders?: AnthropicRateLimitHeaders | null,
909
+ ): boolean {
739
910
  // Reactive 429 failover is NOT gated on the pool flag. That flag buys PROACTIVE routing --
740
911
  // session affinity, quota-ranked new-session selection, autoSwitchThreshold, strategy -- all
741
912
  // of which move a HEALTHY request and stay opt-in. Rotating away from an account upstream has
742
913
  // just rate-limited is a different thing: it only ever runs after a refusal, and stranding a
743
914
  // 429 while a second logged-in account sits idle is a defect, not a configuration choice.
744
915
  // Presence is the activation rule, the same one an apiKeyPool of two keys already uses.
745
- if (!isAnthropicAccountPoolEnabled(config) && !hasAnthropicFailoverQuorum(now)) return null;
916
+ // A sent turn may finish after its account is paused; its one remaining successor is
917
+ // still a valid reactive recovery even though the current unpaused quorum is now one.
918
+ if (!isAnthropicAccountPoolEnabled(config) && !hasAnthropicFailoverQuorum(now)
919
+ && !getAccountCredentialWithStatus(PROVIDER, failedAccountId)?.paused) return false;
746
920
 
747
921
  // Retry-After first: it is the header written FOR this decision. The rejected window's
748
922
  // reset is the fallback, because a 429 that omits Retry-After still carries it -- and
749
923
  // without that fallback such a refusal cools for the 60s default and the exhausted
750
924
  // account is back in the rotation a minute later.
751
925
  const parsedRetry = parseRetryAfterMs(retryAfterHeader, now);
752
- const resetDerived = parsedRetry === undefined ? parseRateLimitResetMs(rateLimitHeaders, now) : undefined;
753
- const cooldownMs = parsedRetry ?? resetDerived ?? DEFAULT_COOLDOWN_MS;
926
+ const resetDerived = parsedRetry === undefined ? parseRateLimitReset(rateLimitHeaders, now) : undefined;
927
+ const cooldownMs = parsedRetry ?? resetDerived?.delayMs ?? DEFAULT_COOLDOWN_MS;
928
+ const cooldownGeneration = noteAnthropicCooldownMutation(failedAccountId);
754
929
  upstreamHealth.set(failedAccountId, {
755
930
  cooldownUntil: now + cooldownMs,
756
931
  cooldownSource: parsedRetry !== undefined
757
932
  ? "retry-after"
758
933
  : resetDerived !== undefined ? "reset-derived" : "default",
934
+ ...(resetDerived ? { rejectedQuotaWindows: resetDerived.rejectedQuotaWindows } : {}),
935
+ cooldownGeneration,
759
936
  });
760
937
  sweepExpiredOnWrite(now);
761
938
  clearAnthropicSessionAffinityForAccount(failedAccountId);
762
939
  notePoolRotationFailure(POOL_KEY_ANTHROPIC, failedAccountId);
763
- // A rotation means the roster in use just changed; do not answer the next activation question
764
- // from a count read taken before the failure.
940
+ // The refused account changed the eligible roster even when no retry send remains.
765
941
  quorumCache = null;
766
942
 
767
- // The pool's strategy is a PROACTIVE policy. When the pool is disabled, reactive
768
- // presence-only recovery must not silently reactivate round-robin/fill-first merely
769
- // because those dormant values remain in config. The quota picker is the neutral
770
- // recovery policy already used by the default strategy.
771
- const next = isAnthropicAccountPoolEnabled(config)
772
- ? pickAlternateAnthropicAccount(config, failedAccountId, now, decision)
773
- : pickLowestUsage(config, failedAccountId, now);
774
- if (!next) {
775
- console.warn(`[anthropic-pool] ${decision ? `route:#${decision.position} ` : ""}no eligible replacement; returning 429`);
776
- return null;
777
- }
778
-
779
- console.warn(
780
- `[anthropic-pool] ${decision ? `route:#${decision.position} ` : ""}429 on ${formatAnthropicAccountOrdinal(failedAccountId)}; failing over to ${formatAnthropicAccountOrdinal(next)}`,
781
- );
782
- return next;
943
+ return true;
783
944
  }
784
945
 
785
946
  export interface AnthropicSelectionRoutingOptions {
@@ -813,7 +974,7 @@ export function commitAnthropicSelectionRouting(
813
974
  committed: OAuthAccountSelection,
814
975
  options: AnthropicSelectionRoutingOptions,
815
976
  ): boolean {
816
- if (committed.accountId !== accountId || (options.routeDecision
977
+ if (getAccountCredentialWithStatus(PROVIDER, accountId)?.paused || committed.accountId !== accountId || (options.routeDecision
817
978
  && !routeCandidates(getEligibleAnthropicAccounts(), options.routeDecision).includes(accountId))) return false;
818
979
  const current = captureOAuthAccountSelection(PROVIDER);
819
980
  if (current?.accountId !== committed.accountId || current.revision !== committed.revision) return false;
@@ -857,7 +1018,12 @@ export function resetAnthropicRoutingForManualSelection(accountId: string): void
857
1018
  * as quota probes).
858
1019
  */
859
1020
  export async function getAnthropicPoolAccessToken(accountId: string): Promise<string> {
860
- const stored = getAccountCredential(PROVIDER, accountId);
1021
+ const row = getAccountCredentialWithStatus(PROVIDER, accountId);
1022
+ if (row?.paused) {
1023
+ const { OAuthAccountPausedError } = await import("./index");
1024
+ throw new OAuthAccountPausedError();
1025
+ }
1026
+ const stored = row?.credential;
861
1027
  if (!stored) {
862
1028
  const { OAuthLoginRequiredError } = await import("./index");
863
1029
  throw new OAuthLoginRequiredError(PROVIDER);
@@ -874,6 +1040,10 @@ export async function getAnthropicPoolAccessToken(accountId: string): Promise<st
874
1040
  export async function getAnthropicPoolAccessSnapshot(accountId: string): Promise<OAuthAccessSnapshot> {
875
1041
  const accessToken = await getAnthropicPoolAccessToken(accountId);
876
1042
  const row = getAccountCredentialWithStatus(PROVIDER, accountId);
1043
+ if (row?.paused) {
1044
+ const { OAuthAccountPausedError } = await import("./index");
1045
+ throw new OAuthAccountPausedError();
1046
+ }
877
1047
  if (!row || row.needsReauth || row.credential.access !== accessToken
878
1048
  || row.credential.expires <= Date.now()) {
879
1049
  throw new Error("Anthropic pool credential changed during account selection");
@@ -887,6 +1057,7 @@ export async function getAnthropicPoolAccessSnapshot(accountId: string): Promise
887
1057
  */
888
1058
  export function canRefreshAnthropicPoolAccount(accountId: string): boolean {
889
1059
  const set = getAccountSet(PROVIDER);
1060
+ if (set?.accounts.find(account => account.id === accountId)?.paused) return false;
890
1061
  const cred = getAccountCredential(PROVIDER, accountId);
891
1062
  if (!cred) return false;
892
1063
  if (cred.source !== "local-cli") return true;
@@ -925,8 +925,10 @@ export async function refreshAnthropicAccountWithLock(
925
925
  const now = deps.now ?? Date.now;
926
926
  const guard = await (deps.intentLock ?? createOAuthRefreshIntentLock(provider, accountId)).acquire();
927
927
  try {
928
- const stored = getAccountCredential(provider, accountId);
929
- if (!stored) throw new OAuthLoginRequiredError(provider);
928
+ const row = getAccountCredentialWithStatus(provider, accountId);
929
+ if (!row) throw new OAuthLoginRequiredError(provider);
930
+ if (row.paused) throw new OAuthAccountPausedError();
931
+ const stored = row.credential;
930
932
  const account = getAccountSet(provider)?.accounts.find(candidate => candidate.id === accountId);
931
933
  const generation = credentialGeneration(stored);
932
934
  let pendingIntent = readOAuthRefreshIntent(provider, accountId);
@@ -27,13 +27,14 @@ import { atomicWriteFileNoFollowUnclaimed } from "../config/atomic-write";
27
27
  import { assertNotRealHomeUnderTest } from "../lib/test-home-guard";
28
28
  import { recordOwnedConfigPath } from "../lib/config-ownership";
29
29
  import { MAX_PENDING_OAUTH_MUTATIONS } from "../lib/translator-budget";
30
- import { publishAccountSelection, publishOAuthAccountPauseChange } from "../lib/account-selection-events";
30
+ import { publishAccountSelection, publishOAuthAccountPauseChange, publishOAuthAccountRoutingPolicyChange } from "../lib/account-selection-events";
31
31
  import {
32
32
  captureConfigGeneration,
33
33
  type GenerationContext,
34
34
  } from "../lib/state-store-sweeper";
35
35
  import { validateCopilotApiBaseUrl } from "./github-copilot";
36
36
  import { validateDevinApiBaseUrl } from "./devin/api-base";
37
+ import { parseAnthropicAccountThreshold } from "./anthropic-account-threshold";
37
38
  import type { OAuthAccountSelection, OAuthCredentialSource, OAuthCredentials, ProviderAccount, ProviderAccountSet } from "./types";
38
39
 
39
40
  export type AuthStore = Record<string, ProviderAccountSet>;
@@ -626,6 +627,8 @@ function normalizeAccount(value: unknown): ProviderAccount | null {
626
627
  if (typeof candidate.alias === "string" && candidate.alias.trim()) account.alias = candidate.alias.trim();
627
628
  if (candidate.needsReauth === true) account.needsReauth = true;
628
629
  if (candidate.paused === true) account.paused = true;
630
+ const threshold = parseAnthropicAccountThreshold(candidate.autoSwitchThresholdOverride);
631
+ if (threshold !== null) account.autoSwitchThresholdOverride = threshold;
629
632
  if (typeof candidate.addedAt === "number") account.addedAt = candidate.addedAt;
630
633
  if (typeof candidate.loginId === "string"
631
634
  && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(candidate.loginId)) {
@@ -783,7 +786,7 @@ function serializeMutation<T>(work: () => Promise<T>, retainedValues: readonly u
783
786
  drainOAuthMutations();
784
787
  return result;
785
788
  }
786
- export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValues: readonly unknown[] = [], options?: { waitMs?: number; assertBeforePersist?: () => void; scrubLegacyBackup?: (result: T) => readonly string[]; finalizeResult?: (result: T, store: AuthStore) => void }):Promise<T>{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{
789
+ export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValues: readonly unknown[] = [], options?: { waitMs?: number; assertBeforePersist?: () => void; scrubLegacyBackup?: (result: T) => readonly string[]; finalizeResult?: (result: T, store: AuthStore) => void; afterPersist?: (result: T) => void }):Promise<T>{return serializeMutation(async()=>{const guard=await createOAuthFileLock({path:getAuthStoreLockPath(),staleAfterMs:30000}).acquire();try{
787
790
  const { store, hadLegacy } = loadAuthStoreInternal();
788
791
  if (hadLegacy) backupLegacyOnce();
789
792
  const selections = new Map(Object.entries(store).map(([provider, set]) => [provider, {
@@ -821,6 +824,9 @@ export function mutateStore<T>(fn:(store:AuthStore)=>T|Promise<T>, retainedValue
821
824
  options?.finalizeResult?.(result, store);
822
825
  persist(store);
823
826
  if (scrubbedProviders.length > 0) scrubLegacyBackup(scrubbedProviders);
827
+ // A committed observer may establish ordering before the generic selection
828
+ // publication, but its failure can never turn a durable write into a reported failure.
829
+ try { options?.afterPersist?.(result); } catch { /* The authoritative write already committed. */ }
824
830
  for (const provider of changedProviders) publishAccountSelection(provider, "oauth");
825
831
  return result;
826
832
  }finally{guard.release();}}, retainedValues, options?.waitMs);
@@ -1224,6 +1230,39 @@ export type SetAccountPausedResult =
1224
1230
  | { status: "unchanged"; activeAccountId: string; activeAccountChanged: boolean }
1225
1231
  | { status: "not-found" };
1226
1232
 
1233
+ /** Serialize policy with refresh/removal; stale pre-wait selection proposals must retry. */
1234
+ export async function setAnthropicAccountThreshold(
1235
+ accountId: string,
1236
+ threshold: number | null,
1237
+ options: { assertBeforePersist?: () => void } = {},
1238
+ ): Promise<boolean> {
1239
+ const normalizedThreshold = threshold === null ? null : parseAnthropicAccountThreshold(threshold);
1240
+ if (threshold !== null && normalizedThreshold === null) {
1241
+ throw new Error("threshold must be an integer 0-100 or null");
1242
+ }
1243
+ const result = await mutateStore(store => {
1244
+ const set = store.anthropic;
1245
+ const account = set?.accounts.find(row => row.id === accountId);
1246
+ if (!set || !account) return { status: "not-found" as const };
1247
+ if ((account.autoSwitchThresholdOverride ?? null) === normalizedThreshold) return { status: "unchanged" as const };
1248
+ const before = accountSelection(set);
1249
+ if (normalizedThreshold === null) delete account.autoSwitchThresholdOverride;
1250
+ else account.autoSwitchThresholdOverride = normalizedThreshold;
1251
+ set.selectionRevision = randomUUID();
1252
+ return { status: "updated" as const, before, after: accountSelection(set) };
1253
+ }, [accountId, normalizedThreshold], { assertBeforePersist: options.assertBeforePersist, afterPersist: result => {
1254
+ if (result.status !== "updated") return;
1255
+ // Publish the policy-owned transition before the generic selection event. This
1256
+ // preserves an exact previous-revision manual intent without opening an ABA gap.
1257
+ publishOAuthAccountRoutingPolicyChange(Object.freeze({
1258
+ provider: "anthropic",
1259
+ before: Object.freeze(result.before),
1260
+ after: Object.freeze(result.after),
1261
+ }));
1262
+ } });
1263
+ return result.status !== "not-found";
1264
+ }
1265
+
1227
1266
  /** Persist an operator pause and move an active account to the next usable unpaused slot when available. */
1228
1267
  export async function setAccountPaused(
1229
1268
  provider: string,
@@ -1323,6 +1362,7 @@ export async function replaceProviderAccountSet(
1323
1362
  ...(account.alias ? { alias: account.alias } : {}),
1324
1363
  ...(account.needsReauth ? { needsReauth: true } : {}),
1325
1364
  ...(account.paused ? { paused: true } : {}),
1365
+ ...(account.autoSwitchThresholdOverride !== undefined ? { autoSwitchThresholdOverride: account.autoSwitchThresholdOverride } : {}),
1326
1366
  ...(account.addedAt !== undefined ? { addedAt: account.addedAt } : {}),
1327
1367
  ...(account.loginId ? { loginId: account.loginId } : {}),
1328
1368
  })),
@@ -1380,4 +1420,6 @@ export async function markAccountNeedsReauth(
1380
1420
  }
1381
1421
 
1382
1422
  export async function mergeAccountCredential(provider:string,accountId:string,credential:OAuthCredentials,opts:{expectedGeneration?:string;afterPrePersistRead?:()=>void|Promise<void>;assertOwnership?: (store: AuthStore) => void}={}):Promise<{superseded:false}|{superseded:true;stored:OAuthCredentials}>{const safe=normalizeCredential(credential);if(!safe)throw new Error("Refusing to persist invalid OAuth credential");return await mutateStore(async store=>{await opts.afterPrePersistRead?.();const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account)throw new Error(`OAuth account disappeared before persist: ${provider}`);if(opts.expectedGeneration!==undefined&&credentialGeneration(account.credential)!==opts.expectedGeneration)return{superseded:true,stored:account.credential};opts.assertOwnership?.(store);account.credential=safe;delete account.needsReauth;return{superseded:false};},[provider,accountId,safe,opts.expectedGeneration]);}
1383
- export async function markAccountNeedsReauthIfGeneration(provider:string,accountId:string,generation:string,writerGeneration=captureConfigGeneration()):Promise<boolean>{const key=oauthAccountKey(provider,accountId);if(writerGeneration<lastReconciledGeneration&&!liveOAuthAccountKeys.has(key))return false;return await mutateStore(store=>{const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account?.credential||credentialGeneration(account.credential)!==generation)return false;if(writerGeneration<lastReconciledGeneration&&!liveOAuthAccountKeys.has(key))return false;account.needsReauth=true;return true;},[provider,accountId,generation]);}
1423
+ // A late refresh failure must not change an operator-paused account's health. Check under
1424
+ // the mutation lock, not before awaiting it, so a concurrent pause cannot be overwritten.
1425
+ export async function markAccountNeedsReauthIfGeneration(provider:string,accountId:string,generation:string,writerGeneration=captureConfigGeneration()):Promise<boolean>{const key=oauthAccountKey(provider,accountId);if(writerGeneration<lastReconciledGeneration&&!liveOAuthAccountKeys.has(key))return false;return await mutateStore(store=>{const account=store[provider]?.accounts.find(x=>x.id===accountId);if(!account?.credential||account.paused||credentialGeneration(account.credential)!==generation)return false;if(writerGeneration<lastReconciledGeneration&&!liveOAuthAccountKeys.has(key))return false;account.needsReauth=true;return true;},[provider,accountId,generation]);}
@@ -90,6 +90,8 @@ export interface ProviderAccount {
90
90
  needsReauth?: boolean;
91
91
  /** Operator exclusion from generic OAuth account selection until explicitly resumed. */
92
92
  paused?: boolean;
93
+ /** Anthropic-only usage-switch override; absent inherits its pool default, zero disables it. */
94
+ autoSwitchThresholdOverride?: number;
93
95
  addedAt?: number;
94
96
  }
95
97