@centerforagenticai/pi-multi-account 0.1.1

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 (124) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +29 -0
  3. package/README.md +999 -0
  4. package/config/models/pi-multi-account.v1.json +32 -0
  5. package/config/subscription-plans.v1.json +122 -0
  6. package/package.json +76 -0
  7. package/packages/pi-anthropic-oauth/LICENSE +21 -0
  8. package/packages/pi-anthropic-oauth/package.json +54 -0
  9. package/packages/pi-anthropic-oauth/src/auth.ts +396 -0
  10. package/packages/pi-anthropic-oauth/src/context.ts +116 -0
  11. package/packages/pi-anthropic-oauth/src/convert.ts +303 -0
  12. package/packages/pi-anthropic-oauth/src/index.ts +37 -0
  13. package/packages/pi-anthropic-oauth/src/prompt.ts +137 -0
  14. package/packages/pi-anthropic-oauth/src/stream.ts +476 -0
  15. package/packages/pi-antigravity/LICENSE +21 -0
  16. package/packages/pi-antigravity/package.json +77 -0
  17. package/packages/pi-antigravity/src/auth/index.ts +14 -0
  18. package/packages/pi-antigravity/src/auth/oauth.ts +442 -0
  19. package/packages/pi-antigravity/src/client/client.ts +561 -0
  20. package/packages/pi-antigravity/src/client/index.ts +1 -0
  21. package/packages/pi-antigravity/src/context.ts +110 -0
  22. package/packages/pi-antigravity/src/diagnostics/diagnostics.ts +96 -0
  23. package/packages/pi-antigravity/src/diagnostics/index.ts +1 -0
  24. package/packages/pi-antigravity/src/image/image.ts +336 -0
  25. package/packages/pi-antigravity/src/image/index.ts +1 -0
  26. package/packages/pi-antigravity/src/index.ts +280 -0
  27. package/packages/pi-antigravity/src/models/discovery.ts +154 -0
  28. package/packages/pi-antigravity/src/models/grouping.ts +424 -0
  29. package/packages/pi-antigravity/src/models/index.ts +3 -0
  30. package/packages/pi-antigravity/src/models/models.ts +500 -0
  31. package/packages/pi-antigravity/src/stream/index.ts +1 -0
  32. package/packages/pi-antigravity/src/stream/stream.ts +1478 -0
  33. package/packages/pi-antigravity/src/types/enums.ts +42 -0
  34. package/packages/pi-antigravity/src/types/index.ts +2 -0
  35. package/packages/pi-antigravity/src/types/types.ts +292 -0
  36. package/packages/pi-antigravity/src/usage/index.ts +1 -0
  37. package/packages/pi-antigravity/src/usage/usage.ts +416 -0
  38. package/packages/pi-antigravity/src/utils/http.ts +91 -0
  39. package/packages/pi-antigravity/src/utils/index.ts +3 -0
  40. package/packages/pi-antigravity/src/utils/security.ts +73 -0
  41. package/packages/pi-antigravity/src/utils/util.ts +132 -0
  42. package/scripts/multi-account.mjs +44 -0
  43. package/src/account-labels.ts +223 -0
  44. package/src/account-plan-assignment.ts +340 -0
  45. package/src/account-rate-history.ts +372 -0
  46. package/src/anthropic-adaptive-stream.ts +531 -0
  47. package/src/anthropic-alias-stream.ts +140 -0
  48. package/src/anthropic-context-compat.ts +80 -0
  49. package/src/api-pricing.ts +579 -0
  50. package/src/bounded-file-lines.ts +97 -0
  51. package/src/catalog-rebinding.ts +177 -0
  52. package/src/catalog-registration-probe.ts +111 -0
  53. package/src/codex-adapter.ts +345 -0
  54. package/src/codex-model-defaults.ts +785 -0
  55. package/src/command-completions.ts +404 -0
  56. package/src/commands.ts +2000 -0
  57. package/src/compaction.ts +14 -0
  58. package/src/config.ts +1317 -0
  59. package/src/continuation.ts +569 -0
  60. package/src/cooldowns.ts +110 -0
  61. package/src/cost-digest-store.ts +332 -0
  62. package/src/cost-digest.ts +1044 -0
  63. package/src/cost-history.ts +251 -0
  64. package/src/cost-period-closer.ts +160 -0
  65. package/src/cost-report-json.ts +318 -0
  66. package/src/cost-report-reader.ts +368 -0
  67. package/src/cost-report-render.ts +207 -0
  68. package/src/cost-report.ts +1104 -0
  69. package/src/coverage-attestation.ts +397 -0
  70. package/src/credential-lifecycle.ts +169 -0
  71. package/src/credential-refresh.ts +248 -0
  72. package/src/declaration-notice-marker.ts +238 -0
  73. package/src/diagnostic-store.ts +276 -0
  74. package/src/diagnostics.ts +309 -0
  75. package/src/discovery.ts +471 -0
  76. package/src/duration.ts +13 -0
  77. package/src/error-classification.ts +256 -0
  78. package/src/fuzzy.ts +15 -0
  79. package/src/group-policy.ts +81 -0
  80. package/src/history-store.ts +897 -0
  81. package/src/index.ts +5572 -0
  82. package/src/lifecycle.ts +378 -0
  83. package/src/logical-dispatch.ts +279 -0
  84. package/src/logical-model-selector.ts +254 -0
  85. package/src/logical-model-switcher.ts +430 -0
  86. package/src/logical-provider-attribution.ts +544 -0
  87. package/src/logical-provider.ts +1237 -0
  88. package/src/logical-route-indicator.ts +215 -0
  89. package/src/machine-lease.ts +445 -0
  90. package/src/model-support.ts +66 -0
  91. package/src/models-declaration.ts +1091 -0
  92. package/src/openai-adapter.ts +117 -0
  93. package/src/openrouter-budget.ts +304 -0
  94. package/src/openrouter-fallback.ts +146 -0
  95. package/src/period-boundaries.ts +376 -0
  96. package/src/pi-anthropic-oauth.d.ts +6 -0
  97. package/src/preflight.ts +253 -0
  98. package/src/pricing-cache.ts +235 -0
  99. package/src/project-identity.ts +100 -0
  100. package/src/provider-registration.ts +942 -0
  101. package/src/rate-formula.ts +163 -0
  102. package/src/recovery-engine.ts +853 -0
  103. package/src/recovery-output.ts +837 -0
  104. package/src/recovery-plan.ts +239 -0
  105. package/src/report-range.ts +203 -0
  106. package/src/route-resolver.ts +789 -0
  107. package/src/routing-config-transaction.ts +232 -0
  108. package/src/routing.ts +1163 -0
  109. package/src/runtime-state.ts +630 -0
  110. package/src/session-account-groups.ts +284 -0
  111. package/src/session-restore.ts +287 -0
  112. package/src/shared-usage.ts +1392 -0
  113. package/src/standalone-cli.ts +720 -0
  114. package/src/status-view.ts +578 -0
  115. package/src/subscription-plan-catalog.ts +346 -0
  116. package/src/tier-model-resolver.ts +46 -0
  117. package/src/upstream-anthropic.ts +315 -0
  118. package/src/upstream-antigravity.ts +327 -0
  119. package/src/usage-fetch.ts +1634 -0
  120. package/src/usage.ts +1026 -0
  121. package/src/vendor.ts +87 -0
  122. package/src/warmer.ts +231 -0
  123. package/src/watchdog.ts +219 -0
  124. package/src/window-history.ts +270 -0
package/src/routing.ts ADDED
@@ -0,0 +1,1163 @@
1
+ import type {
2
+ AllowedFamily,
3
+ ManagedFamily,
4
+ MultiAccountConfig,
5
+ } from "./config.js";
6
+ import { isAllowedFamily, isManagedFamily } from "./config.js";
7
+ import { createCooldownRecord } from "./cooldowns.js";
8
+ import {
9
+ isCredentialUsable,
10
+ type CredentialUsability,
11
+ } from "./credential-lifecycle.js";
12
+ import type { CredentialType } from "./discovery.js";
13
+ import {
14
+ classifyFailure,
15
+ type FailureClassification,
16
+ type ProviderFailureSignal,
17
+ } from "./error-classification.js";
18
+ import type { ModelSupportRegistry } from "./model-support.js";
19
+ import { resolveTierModel } from "./tier-model-resolver.js";
20
+ import { providerTypeFor, sameVendor } from "./vendor.js";
21
+ import {
22
+ type RuntimeState,
23
+ isCanonicalManagedProviderId,
24
+ type CredentialRevision,
25
+ } from "./runtime-state.js";
26
+
27
+ export interface SharedUsageHint {
28
+ readonly remainingRequests?: number;
29
+ readonly remainingTokens?: number;
30
+ readonly recoveryAtMs?: number;
31
+ /**
32
+ * End of a bounded local hold installed after the provider reported
33
+ * exhaustion without a recovery time. Distinct from `recoveryAtMs`, which is
34
+ * the provider's own word: this is an admitted guess, and exclusion runs to
35
+ * whichever of the two is later.
36
+ */
37
+ readonly holdUntilMs?: number;
38
+ readonly utilization?: number;
39
+ readonly ageMs?: number;
40
+ }
41
+
42
+ export interface ManagedAccount<F extends ManagedFamily = ManagedFamily> {
43
+ readonly providerId: string;
44
+ readonly family: F;
45
+ readonly credentialType?: CredentialType;
46
+ /**
47
+ * The observation routing acts on, supplied by the caller.
48
+ *
49
+ * Named for the peer-only reading it once carried. It now also carries this
50
+ * session's own observation, and a known future recovery time regardless of
51
+ * age, because excluding both is what let routing spend turns on an account
52
+ * it had already measured as exhausted (#97). Stale token data is still
53
+ * omitted before routing reaches here.
54
+ */
55
+ readonly fleetUsage?: SharedUsageHint;
56
+ /** Optional revision from maintained public, non-secret metadata only. */
57
+ readonly credentialRevision?: CredentialRevision;
58
+ /**
59
+ * Bounded, value-free credential usability metadata. When supplied, an
60
+ * account whose credential is provably dead (expired with no refresh token)
61
+ * is excluded from selection. Omitted means "unknown", which keeps the
62
+ * account eligible.
63
+ */
64
+ readonly credential?: CredentialUsability;
65
+ /**
66
+ * Stable account fingerprint derived from non-secret claims, when derivable.
67
+ * Lets routing state survive token rotation and clear only on a genuine
68
+ * account substitution.
69
+ */
70
+ readonly accountFingerprint?: string;
71
+ /** Model ids advertised by this account's provider catalog, when known. */
72
+ readonly modelIds?: readonly string[];
73
+ }
74
+
75
+ export type SubscriptionManagedAccount = ManagedAccount<AllowedFamily>;
76
+
77
+ export type AvailableRouteCandidate =
78
+ | {
79
+ readonly destination: SubscriptionManagedAccount;
80
+ readonly routeKind: "same-family" | "cross-family";
81
+ }
82
+ | {
83
+ readonly destination: ManagedAccount;
84
+ readonly routeKind: "owning-vendor-api";
85
+ readonly resolvedModelId: string;
86
+ };
87
+
88
+ export type SelectedRoute = AvailableRouteCandidate & {
89
+ readonly status: "selected";
90
+ readonly classification: FailureClassification;
91
+ };
92
+
93
+ export interface PausedRoute {
94
+ readonly status: "paused";
95
+ readonly classification: FailureClassification;
96
+ /** Null means every known alternative is unavailable without a timed recovery. */
97
+ readonly earliestRecoveryAtMs: number | null;
98
+ readonly retryAfterMs: number | null;
99
+ }
100
+
101
+ export type ReactiveRouteDecision = SelectedRoute | PausedRoute;
102
+
103
+ export interface HealthSelectionDecision {
104
+ readonly providerId: string;
105
+ readonly switched: boolean;
106
+ readonly reason:
107
+ | "active-account-healthy"
108
+ | "active-account-unsupported"
109
+ | "active-account-not-live"
110
+ | "active-account-exhausted";
111
+ }
112
+
113
+ function accountCanServeModel(
114
+ account: ManagedAccount,
115
+ modelId: string | undefined,
116
+ modelSupport: ModelSupportRegistry | undefined,
117
+ ): boolean {
118
+ if (
119
+ modelId !== undefined &&
120
+ modelSupport !== undefined &&
121
+ modelSupport.isUnsupported(account.providerId, modelId)
122
+ ) {
123
+ return false;
124
+ }
125
+ if (
126
+ modelId !== undefined &&
127
+ account.modelIds !== undefined &&
128
+ !account.modelIds.includes(modelId)
129
+ ) {
130
+ return false;
131
+ }
132
+ return true;
133
+ }
134
+
135
+ function isSubscriptionManagedAccount(
136
+ account: ManagedAccount,
137
+ ): account is SubscriptionManagedAccount {
138
+ return (
139
+ isAllowedFamily(account.family) &&
140
+ providerTypeFor(account.family, account.credentialType ?? "unknown") ===
141
+ "subscription"
142
+ );
143
+ }
144
+
145
+ type OwningVendorManagedAccount = ManagedAccount<"anthropic" | "openai">;
146
+
147
+ function isOwningVendorApiAccount(
148
+ account: ManagedAccount,
149
+ ): account is OwningVendorManagedAccount {
150
+ return (
151
+ account.family !== "openai-codex" &&
152
+ providerTypeFor(account.family, account.credentialType ?? "unknown") ===
153
+ "owning-vendor-api"
154
+ );
155
+ }
156
+
157
+ function prioritizeModelServingAccounts<T extends ManagedAccount>(
158
+ accounts: readonly T[],
159
+ modelId: string | undefined,
160
+ modelSupport: ModelSupportRegistry | undefined,
161
+ ): readonly T[] {
162
+ if (modelId === undefined) return accounts;
163
+ const serving: T[] = [];
164
+ const fallback: T[] = [];
165
+ for (const account of accounts) {
166
+ (accountCanServeModel(account, modelId, modelSupport)
167
+ ? serving
168
+ : fallback
169
+ ).push(account);
170
+ }
171
+ return [...serving, ...fallback];
172
+ }
173
+
174
+ /**
175
+ * The exhaustion signals both selection paths must respect.
176
+ *
177
+ * Extracted because the two guards had drifted: the reactive path checked usage
178
+ * distrust and fleet exhaustion, the proactive one did not, so preflight could
179
+ * hand a turn to an account that `routeAfterFailure` would have refused -- an
180
+ * account at utilization 1 with a known future recovery was ineligible AFTER a
181
+ * failure and eligible BEFORE one. The turn was spent, failed, and only then
182
+ * routed correctly, which is the wasted round-trip preflight exists to prevent.
183
+ *
184
+ * One predicate rather than two matching lists: two definitions that must agree
185
+ * is exactly how they drifted, and is the same defect class already fixed twice
186
+ * in the usage snapshot path (048a276, 75755c2).
187
+ *
188
+ * ORDER IS LOAD-BEARING. `isUsageSnapshotUntrusted` runs BEFORE the snapshot is
189
+ * read, because the snapshot is the thing being disbelieved. Observed live on
190
+ * 2026-08-05: base `anthropic` reported `utilization: 0` from the usage endpoint
191
+ * while returning 429 on every request, because that endpoint tracks the QUOTA
192
+ * window and cannot see a SESSION limit. Reversing these two lines restores that
193
+ * bug.
194
+ */
195
+ type ExhaustionSignalScope = "all-observed" | "provider-reported-only";
196
+
197
+ export function snapshotIndicatesExhaustion(
198
+ fleet: SharedUsageHint | undefined,
199
+ nowMs: number,
200
+ scope: ExhaustionSignalScope,
201
+ ): boolean {
202
+ return (
203
+ fleet?.remainingRequests === 0 ||
204
+ fleet?.remainingTokens === 0 ||
205
+ (scope === "all-observed" &&
206
+ fleet?.utilization !== undefined &&
207
+ fleet.utilization >= 1) ||
208
+ // Exclusion runs to the later of the provider's own recovery time and a
209
+ // local hold: max(holdUntilMs, recoveryAtMs). Each is checked against now
210
+ // independently, which is that maximum without computing it. A healthy
211
+ // reading clears neither, and an authoritative recovery *earlier* than
212
+ // the hold does not shorten it -- the hold exists precisely for the case
213
+ // where the endpoint's view disagrees with what requests actually do.
214
+ (fleet?.recoveryAtMs !== undefined && fleet.recoveryAtMs > nowMs) ||
215
+ (fleet?.holdUntilMs !== undefined && fleet.holdUntilMs > nowMs)
216
+ );
217
+ }
218
+
219
+ function accountExhausted(
220
+ account: ManagedAccount,
221
+ state: RuntimeState,
222
+ nowMs: number,
223
+ scope: ExhaustionSignalScope = "all-observed",
224
+ ): boolean {
225
+ if (state.isUsageSnapshotUntrusted(account.providerId, nowMs)) return true;
226
+ return snapshotIndicatesExhaustion(account.fleetUsage, nowMs, scope);
227
+ }
228
+
229
+ /**
230
+ * Eligibility for PROACTIVE pre-dispatch selection.
231
+ *
232
+ * Tightening this cannot park a turn: when no alternative qualifies, the caller
233
+ * falls through to `active-account-healthy` and stays put, which is the previous
234
+ * behaviour. Only `routeAfterFailure` can park.
235
+ */
236
+ function accountAvailableForSelection(
237
+ account: ManagedAccount,
238
+ state: RuntimeState,
239
+ nowMs: number,
240
+ ): boolean {
241
+ return (
242
+ isCredentialUsable(
243
+ account.credential ?? { hasRefreshToken: false },
244
+ nowMs,
245
+ ) &&
246
+ !accountExhausted(account, state, nowMs) &&
247
+ state.getInvalidation(account.providerId) === undefined &&
248
+ state.getCooldown(account.providerId, nowMs) === undefined
249
+ );
250
+ }
251
+
252
+ /**
253
+ * Selects a same-family account only when a concrete health/catalog signal
254
+ * justifies changing the deterministic active choice. Unknown signals leave
255
+ * provider order untouched.
256
+ */
257
+ export function selectHealthAwareAccount(options: {
258
+ readonly activeProviderId: string;
259
+ readonly modelId?: string;
260
+ readonly accounts: readonly SubscriptionManagedAccount[];
261
+ readonly state: RuntimeState;
262
+ readonly nowMs: number;
263
+ readonly modelSupport?: ModelSupportRegistry;
264
+ readonly liveProviders?: ReadonlyMap<string, boolean | undefined>;
265
+ }): HealthSelectionDecision {
266
+ const {
267
+ activeProviderId,
268
+ modelId,
269
+ accounts,
270
+ state,
271
+ nowMs,
272
+ modelSupport,
273
+ liveProviders,
274
+ } = options;
275
+ const active = accounts.find(
276
+ (account) => account.providerId === activeProviderId,
277
+ );
278
+ if (active === undefined) {
279
+ return {
280
+ providerId: activeProviderId,
281
+ switched: false,
282
+ reason: "active-account-healthy",
283
+ };
284
+ }
285
+ const activeSupports = accountCanServeModel(active, modelId, modelSupport);
286
+ const activeLive = liveProviders?.get(activeProviderId);
287
+ const hasCatalogSignal = modelSupport !== undefined && modelId !== undefined;
288
+ const hasLiveSignal =
289
+ liveProviders !== undefined &&
290
+ [...liveProviders.values()].some((value) => value !== undefined);
291
+ const activeExhausted = accountExhausted(
292
+ active,
293
+ state,
294
+ nowMs,
295
+ // Reversible decision: active pre-dispatch selection excludes utilization-only
296
+ // exhaustion because a false positive can strand a working turn, which is worse
297
+ // than the wasted turn this change prevents. Authoritative provenance or provider
298
+ // evidence proving utilization cannot be a local optimistic estimate would
299
+ // justify including it later.
300
+ "provider-reported-only",
301
+ );
302
+ const alternative = accounts.find(
303
+ (account) =>
304
+ account.providerId !== activeProviderId &&
305
+ account.family === active.family &&
306
+ accountCanServeModel(account, modelId, modelSupport) &&
307
+ accountAvailableForSelection(account, state, nowMs) &&
308
+ (liveProviders?.get(account.providerId) === true || !hasLiveSignal),
309
+ );
310
+ if (activeSupports && activeLive !== false) {
311
+ if (!hasLiveSignal && !activeExhausted) {
312
+ return {
313
+ providerId: activeProviderId,
314
+ switched: false,
315
+ reason: "active-account-healthy",
316
+ };
317
+ }
318
+ if (
319
+ alternative === undefined ||
320
+ (!activeExhausted && activeLive !== undefined)
321
+ ) {
322
+ return {
323
+ providerId: activeProviderId,
324
+ switched: false,
325
+ reason: "active-account-healthy",
326
+ };
327
+ }
328
+ return {
329
+ providerId: alternative.providerId,
330
+ switched: true,
331
+ reason:
332
+ activeExhausted && (activeLive !== undefined || !hasLiveSignal)
333
+ ? "active-account-exhausted"
334
+ : "active-account-not-live",
335
+ };
336
+ }
337
+
338
+ if (alternative === undefined || (!activeExhausted && !hasCatalogSignal && !hasLiveSignal)) {
339
+ return {
340
+ providerId: activeProviderId,
341
+ switched: false,
342
+ reason: "active-account-healthy",
343
+ };
344
+ }
345
+ return {
346
+ providerId: alternative.providerId,
347
+ switched: true,
348
+ reason: activeSupports
349
+ ? "active-account-not-live"
350
+ : "active-account-unsupported",
351
+ };
352
+ }
353
+
354
+ function isCanonicalProviderId(account: ManagedAccount): boolean {
355
+ return (
356
+ isManagedFamily(account.family) &&
357
+ isCanonicalManagedProviderId(account.providerId, account.family)
358
+ );
359
+ }
360
+
361
+ function firstCanonicalAccount(
362
+ account: ManagedAccount,
363
+ seen: Set<string>,
364
+ ): ManagedAccount | undefined {
365
+ if (!isCanonicalProviderId(account) || seen.has(account.providerId)) return undefined;
366
+ seen.add(account.providerId);
367
+ return account;
368
+ }
369
+
370
+ /**
371
+ * Canonicalizes and dedupes accounts into the routing projection WITHOUT
372
+ * touching RuntimeState. This is the pure half of {@link normalizedAccounts}:
373
+ * it is the only projection safe to run inside a reversible cooldown mutation,
374
+ * because {@link RuntimeState.runReversibleCooldownMutation} can restore only
375
+ * cooldown records. Observing identity or credential revision here would mutate
376
+ * `#accountIdentities`, `#credentialRevisions`, terminal invalidations, and even
377
+ * unrelated cooldowns, none of which the rollback can undo.
378
+ */
379
+ function projectCanonicalAccounts(
380
+ accounts: readonly ManagedAccount[],
381
+ ): readonly ManagedAccount[] {
382
+ const seen = new Set<string>();
383
+ const result: ManagedAccount[] = [];
384
+ for (const account of accounts) {
385
+ if (firstCanonicalAccount(account, seen) === undefined) continue;
386
+ result.push({
387
+ providerId: account.providerId,
388
+ family: account.family,
389
+ ...(account.credentialType === undefined
390
+ ? {}
391
+ : { credentialType: account.credentialType }),
392
+ ...(account.fleetUsage === undefined
393
+ ? {}
394
+ : { fleetUsage: account.fleetUsage }),
395
+ ...(account.credentialRevision === undefined
396
+ ? {}
397
+ : { credentialRevision: account.credentialRevision }),
398
+ ...(account.credential === undefined
399
+ ? {}
400
+ : { credential: account.credential }),
401
+ ...(account.accountFingerprint === undefined
402
+ ? {}
403
+ : { accountFingerprint: account.accountFingerprint }),
404
+ ...(account.modelIds === undefined ? {} : { modelIds: account.modelIds }),
405
+ });
406
+ }
407
+ return result;
408
+ }
409
+
410
+ function normalizedAccounts(
411
+ accounts: readonly ManagedAccount[],
412
+ state: RuntimeState,
413
+ ): readonly ManagedAccount[] {
414
+ const projected = projectCanonicalAccounts(accounts);
415
+ for (const account of projected) {
416
+ if (account.credentialRevision !== undefined) {
417
+ state.observeCredentialRevision(
418
+ account.providerId,
419
+ account.family,
420
+ account.credentialRevision,
421
+ );
422
+ }
423
+ // Observed unconditionally: an absent fingerprint is meaningful (identity
424
+ // is not derivable) and must not be mistaken for an account change.
425
+ state.observeAccountIdentity(
426
+ account.providerId,
427
+ account.family,
428
+ account.accountFingerprint,
429
+ );
430
+ }
431
+ return projected;
432
+ }
433
+
434
+ /**
435
+ * Whether an account may receive a request now.
436
+ *
437
+ * Beyond the runtime signals (terminal invalidation, active cooldown), an
438
+ * account whose credential is provably dead is excluded outright: dispatching
439
+ * to it can only fail, wasting a turn and risking failure heuristics against an
440
+ * account that merely needs a login. An account that is near expiry but still
441
+ * refreshable stays eligible — refresh is the normal path.
442
+ */
443
+ function isAvailable(
444
+ account: ManagedAccount,
445
+ state: RuntimeState,
446
+ nowMs: number,
447
+ ): boolean {
448
+ // REQ-FAILOVER-USAGE-TRUST plus fleet exhaustion, shared with the proactive
449
+ // path so the two cannot disagree about what "exhausted" means.
450
+ return accountAvailableWithStateReaders(
451
+ account,
452
+ state,
453
+ nowMs,
454
+ state.getCooldown.bind(state),
455
+ state.isUsageSnapshotUntrusted.bind(state),
456
+ );
457
+ }
458
+
459
+ /**
460
+ * Finds any managed OAuth account that can receive a request now.
461
+ *
462
+ * This deliberately ignores family-chain policy: callers use it only to prove
463
+ * that a metered provider is NOT the last resort, or to leave that metered
464
+ * bridge once any subscription account recovers. It never selects OpenRouter
465
+ * because canonical managed-provider validation excludes it.
466
+ */
467
+ export function selectAvailableManagedAccount(options: {
468
+ readonly accounts: readonly ManagedAccount[];
469
+ readonly state: RuntimeState;
470
+ readonly nowMs: number;
471
+ readonly preferredProviderId?: string;
472
+ }): ManagedAccount | undefined {
473
+ const seen = new Set<string>();
474
+ const available = options.accounts.filter((account) => {
475
+ if (firstCanonicalAccount(account, seen) === undefined) return false;
476
+ return isAvailable(account, options.state, options.nowMs);
477
+ });
478
+ return (
479
+ available.find(
480
+ (account) => account.providerId === options.preferredProviderId,
481
+ ) ?? available[0]
482
+ );
483
+ }
484
+
485
+ /**
486
+ * Finds a recovered account without mutating failure state and without widening
487
+ * the route beyond the family policy that parked the turn. Same-family recovery
488
+ * keeps its configured priority; cross-family recovery is considered only for
489
+ * an explicitly declared directional chain.
490
+ */
491
+ export function selectAvailableRecoveryAccount(options: {
492
+ readonly accounts: readonly ManagedAccount[];
493
+ readonly state: RuntimeState;
494
+ readonly nowMs: number;
495
+ readonly originFamily: AllowedFamily;
496
+ readonly requestedModelId?: string;
497
+ readonly config: MultiAccountConfig;
498
+ readonly preferredProviderId?: string;
499
+ readonly modelId?: string;
500
+ readonly preferredModelId?: string;
501
+ readonly modelSupport?: ModelSupportRegistry;
502
+ }): AvailableRouteCandidate | undefined {
503
+ const candidates = selectAvailableRouteCandidates(options);
504
+ return (
505
+ candidates.find(
506
+ (candidate) =>
507
+ candidate.destination.providerId === options.preferredProviderId,
508
+ ) ?? candidates[0]
509
+ );
510
+ }
511
+
512
+ /**
513
+ * Returns every account that may receive a reactive continuation, in routing
514
+ * priority order, without mutating failure state.
515
+ *
516
+ * Selection rejection uses this same policy after the first destination fails
517
+ * host selection. Scanning the raw catalog there can retry the account that
518
+ * just failed, choose an exhausted account, or cross families without an
519
+ * explicit directional chain.
520
+ */
521
+ export function selectAvailableRouteCandidates(options: {
522
+ readonly accounts: readonly ManagedAccount[];
523
+ readonly state: RuntimeState;
524
+ readonly nowMs: number;
525
+ readonly originFamily: AllowedFamily;
526
+ readonly requestedModelId?: string;
527
+ readonly config: MultiAccountConfig;
528
+ readonly failedProviderId?: string;
529
+ readonly excludedProviderIds?: ReadonlySet<string>;
530
+ /** Hard same-family constraint after account-local model-not-found evidence. */
531
+ readonly modelId?: string;
532
+ /** Soft same-family preference for preserving the failed turn's model. */
533
+ readonly preferredModelId?: string;
534
+ readonly modelSupport?: ModelSupportRegistry;
535
+ }): readonly AvailableRouteCandidate[] {
536
+ const seen = new Set<string>();
537
+ const available = options.accounts.filter((account) => {
538
+ if (firstCanonicalAccount(account, seen) === undefined) return false;
539
+ return (
540
+ account.providerId !== options.failedProviderId &&
541
+ !options.excludedProviderIds?.has(account.providerId) &&
542
+ isAvailable(account, options.state, options.nowMs)
543
+ );
544
+ });
545
+ const subscriptions = available.filter(isSubscriptionManagedAccount);
546
+ const sameFamily = options.config.sameFamilyFailover
547
+ ? subscriptions.filter((account) => account.family === options.originFamily)
548
+ : [];
549
+ const eligibleSameFamily =
550
+ options.modelId === undefined
551
+ ? sameFamily
552
+ : sameFamily.filter((account) =>
553
+ accountCanServeModel(
554
+ account,
555
+ options.modelId,
556
+ options.modelSupport,
557
+ ),
558
+ );
559
+ const tier1 = prioritizeModelServingAccounts(
560
+ eligibleSameFamily,
561
+ options.preferredModelId,
562
+ options.modelSupport,
563
+ ).map(
564
+ (destination): AvailableRouteCandidate => ({
565
+ destination,
566
+ routeKind: "same-family",
567
+ }),
568
+ );
569
+ const tier2: AvailableRouteCandidate[] = [];
570
+ if (options.requestedModelId !== undefined) {
571
+ for (const destination of available) {
572
+ if (!isOwningVendorApiAccount(destination)) continue;
573
+ if (!sameVendor(destination.family, options.originFamily)) continue;
574
+ const resolvedModelId = resolveTierModel(
575
+ options.requestedModelId,
576
+ destination.family,
577
+ destination.modelIds ?? [],
578
+ options.config.tierModelMap,
579
+ );
580
+ if (
581
+ resolvedModelId === undefined ||
582
+ !accountCanServeModel(
583
+ destination,
584
+ resolvedModelId,
585
+ options.modelSupport,
586
+ )
587
+ ) {
588
+ continue;
589
+ }
590
+ tier2.push({
591
+ destination,
592
+ routeKind: "owning-vendor-api",
593
+ resolvedModelId,
594
+ });
595
+ }
596
+ }
597
+ const crossVendorSubscriptions = subscriptions
598
+ .filter((account) =>
599
+ crossFamilyAllowed(
600
+ options.originFamily,
601
+ account.family,
602
+ options.config,
603
+ ),
604
+ )
605
+ .map(
606
+ (destination): AvailableRouteCandidate => ({
607
+ destination,
608
+ routeKind: "cross-family",
609
+ }),
610
+ );
611
+ return [...tier1, ...tier2, ...crossVendorSubscriptions];
612
+ }
613
+
614
+ /**
615
+ * Finds a managed account that keeps OpenRouter a true last resort.
616
+ *
617
+ * Amendment 1 intentionally preserves the legacy rule that any available
618
+ * subscription blocks the metered rung. Owning-vendor accounts block only when
619
+ * the origin model resolves to a live supported catalog entry.
620
+ */
621
+ export function selectManagedLastResortCandidate(options: {
622
+ readonly accounts: readonly ManagedAccount[];
623
+ readonly state: RuntimeState;
624
+ readonly nowMs: number;
625
+ readonly originFamily: AllowedFamily;
626
+ readonly requestedModelId?: string;
627
+ readonly config: MultiAccountConfig;
628
+ readonly preferredProviderId?: string;
629
+ readonly excludedProviderIds?: ReadonlySet<string>;
630
+ readonly modelSupport?: ModelSupportRegistry;
631
+ }): AvailableRouteCandidate | undefined {
632
+ const seen = new Set<string>();
633
+ const candidates: AvailableRouteCandidate[] = [];
634
+ for (const destination of options.accounts) {
635
+ if (firstCanonicalAccount(destination, seen) === undefined) continue;
636
+ if (options.excludedProviderIds?.has(destination.providerId)) continue;
637
+ if (!isAvailable(destination, options.state, options.nowMs)) continue;
638
+ if (isSubscriptionManagedAccount(destination)) {
639
+ candidates.push({
640
+ destination,
641
+ routeKind:
642
+ destination.family === options.originFamily
643
+ ? "same-family"
644
+ : "cross-family",
645
+ });
646
+ continue;
647
+ }
648
+ if (
649
+ !isOwningVendorApiAccount(destination) ||
650
+ !sameVendor(destination.family, options.originFamily) ||
651
+ options.requestedModelId === undefined
652
+ ) {
653
+ continue;
654
+ }
655
+ const resolvedModelId = resolveTierModel(
656
+ options.requestedModelId,
657
+ destination.family,
658
+ destination.modelIds ?? [],
659
+ options.config.tierModelMap,
660
+ );
661
+ if (
662
+ resolvedModelId === undefined ||
663
+ !accountCanServeModel(destination, resolvedModelId, options.modelSupport)
664
+ ) {
665
+ continue;
666
+ }
667
+ candidates.push({
668
+ destination,
669
+ routeKind: "owning-vendor-api",
670
+ resolvedModelId,
671
+ });
672
+ }
673
+ return (
674
+ candidates.find(
675
+ (candidate) =>
676
+ candidate.destination.providerId === options.preferredProviderId,
677
+ ) ?? candidates[0]
678
+ );
679
+ }
680
+
681
+ function crossFamilyAllowed(
682
+ from: AllowedFamily,
683
+ to: AllowedFamily,
684
+ config: MultiAccountConfig,
685
+ ): boolean {
686
+ return (
687
+ config.crossFamilyChainEnabled &&
688
+ config.crossFamilyChains.some(
689
+ (chain) => chain.from === from && chain.to === to,
690
+ )
691
+ );
692
+ }
693
+
694
+ type CooldownReader = (
695
+ providerId: string,
696
+ nowMs: number,
697
+ ) => ReturnType<RuntimeState["getCooldown"]>;
698
+ type UsageDistrustReader = (providerId: string, nowMs: number) => boolean;
699
+
700
+ /**
701
+ * The one eligibility rule, shared by every caller that asks "can this account
702
+ * receive a request now".
703
+ *
704
+ * Provider-reported exhaustion arrives as a parameter rather than being read
705
+ * here, because callers learn it differently: a physical `ManagedAccount`
706
+ * carries a fleet usage snapshot, while a logical account carries a boolean the
707
+ * provider already reported. Everything after that point — dead credentials,
708
+ * usage distrust, invalidation and cooldown — is identical for both, and must
709
+ * stay that way. Adding a second copy of any of these checks elsewhere would
710
+ * let two notions of "eligible" drift apart.
711
+ */
712
+ function accountEligibleCore(options: {
713
+ readonly providerId: string;
714
+ readonly providerReportedExhausted: boolean;
715
+ /**
716
+ * False only when the credential is known to be unusable. Callers that hold
717
+ * the credential itself pass it instead; this is for callers that were given
718
+ * the verdict rather than the secret.
719
+ */
720
+ readonly authenticated?: boolean | undefined;
721
+ readonly credential: ManagedAccount["credential"];
722
+ readonly state: RuntimeState;
723
+ readonly nowMs: number;
724
+ readonly readCooldown: CooldownReader;
725
+ readonly isUsageSnapshotUntrusted: UsageDistrustReader;
726
+ }): boolean {
727
+ if (options.authenticated === false) return false;
728
+ if (
729
+ options.credential !== undefined &&
730
+ !isCredentialUsable(options.credential, options.nowMs)
731
+ ) {
732
+ return false;
733
+ }
734
+ if (options.isUsageSnapshotUntrusted(options.providerId, options.nowMs)) {
735
+ return false;
736
+ }
737
+ if (options.providerReportedExhausted) return false;
738
+ return (
739
+ options.state.getInvalidation(options.providerId) === undefined &&
740
+ options.readCooldown(options.providerId, options.nowMs) === undefined
741
+ );
742
+ }
743
+
744
+ function accountAvailableWithStateReaders(
745
+ account: ManagedAccount,
746
+ state: RuntimeState,
747
+ nowMs: number,
748
+ readCooldown: CooldownReader,
749
+ isUsageSnapshotUntrusted: UsageDistrustReader,
750
+ ): boolean {
751
+ return accountEligibleCore({
752
+ providerId: account.providerId,
753
+ providerReportedExhausted: snapshotIndicatesExhaustion(
754
+ account.fleetUsage,
755
+ nowMs,
756
+ "all-observed",
757
+ ),
758
+ credential: account.credential,
759
+ state,
760
+ nowMs,
761
+ readCooldown,
762
+ isUsageSnapshotUntrusted,
763
+ });
764
+ }
765
+
766
+ /**
767
+ * The account facts the logical provider knows about one physical account.
768
+ *
769
+ * Deliberately narrower than {@link ManagedAccount}: the logical view carries
770
+ * no credential material, so dead-credential status arrives already reduced to
771
+ * `authenticated: false` by the caller that legitimately holds the credential.
772
+ */
773
+ export interface LogicalEligibilityAccount {
774
+ readonly providerId: string;
775
+ /** The provider itself reported this account exhausted from a usage snapshot. */
776
+ readonly exhausted?: boolean | undefined;
777
+ /** False when the credential is present but provably unusable. */
778
+ readonly authenticated?: boolean | undefined;
779
+ }
780
+
781
+ /**
782
+ * Whether the logical provider may dispatch to this physical account now.
783
+ *
784
+ * This is the same rule {@link selectAvailableManagedAccount} applies, minus
785
+ * the canonical-provider-id gate, which exists to keep non-managed providers
786
+ * out of physical routing and would reject the logical view's account ids.
787
+ *
788
+ * It binds the mutation-free `peek` readers, never the recording `get` ones: a
789
+ * preflight question must not park an account as a side effect of being asked.
790
+ *
791
+ * With no `state`, cooldown, invalidation and usage distrust are treated as
792
+ * absent rather than as blocking. A caller that has no runtime state has not
793
+ * observed a reason to exclude anything, and failing closed on all of them
794
+ * would make every account ineligible and no request possible.
795
+ */
796
+ /** Stands in when a caller supplied no runtime state; it observes nothing. */
797
+ const NO_OBSERVED_STATE = {
798
+ getInvalidation: () => undefined,
799
+ } as unknown as RuntimeState;
800
+
801
+ export function logicalAccountEligible(
802
+ account: LogicalEligibilityAccount,
803
+ state: RuntimeState | undefined,
804
+ nowMs: number,
805
+ ): boolean {
806
+ // Both signals go through the one shared rule rather than being tested
807
+ // beside it. A second exhaustion test here would be a second notion of
808
+ // exhaustion, free to drift from the one the physical path enforces.
809
+ //
810
+ // With no runtime state, cooldown, invalidation and usage distrust are
811
+ // absent rather than blocking, so the readers report nothing and the
812
+ // invalidation lookup finds nothing. Nothing is fabricated to force a
813
+ // verdict: the inputs say only what was actually observed.
814
+ return accountEligibleCore({
815
+ providerId: account.providerId,
816
+ providerReportedExhausted: account.exhausted === true,
817
+ authenticated: account.authenticated,
818
+ credential: undefined,
819
+ state: state ?? NO_OBSERVED_STATE,
820
+ nowMs,
821
+ readCooldown:
822
+ state === undefined ? () => undefined : state.peekCooldown.bind(state),
823
+ isUsageSnapshotUntrusted:
824
+ state === undefined
825
+ ? () => false
826
+ : state.peekUsageSnapshotUntrusted.bind(state),
827
+ });
828
+ }
829
+
830
+
831
+ function exactAccountAvailable(
832
+ account: ManagedAccount,
833
+ state: RuntimeState,
834
+ nowMs: number,
835
+ modelId: string,
836
+ modelSupport: ModelSupportRegistry | undefined,
837
+ ): boolean {
838
+ if (!isCanonicalProviderId(account)) return false;
839
+ if (account.modelIds === undefined || !account.modelIds.includes(modelId)) {
840
+ return false;
841
+ }
842
+ if (modelSupport?.isUnsupported(account.providerId, modelId)) return false;
843
+ return accountAvailableWithStateReaders(
844
+ account,
845
+ state,
846
+ nowMs,
847
+ state.peekCooldown.bind(state),
848
+ state.peekUsageSnapshotUntrusted.bind(state),
849
+ );
850
+ }
851
+
852
+ /**
853
+ * Exact-model routing for read-only logical consumers. Unlike the reactive
854
+ * selector, this path requires positive catalog membership and never selects a
855
+ * catalog head for an unknown model.
856
+ */
857
+ export function selectExactModelRouteCandidates(options: {
858
+ readonly accounts: readonly SubscriptionManagedAccount[];
859
+ readonly state: RuntimeState;
860
+ readonly nowMs: number;
861
+ readonly family: AllowedFamily;
862
+ readonly config: MultiAccountConfig;
863
+ readonly modelId: string;
864
+ readonly preferredProviderId?: string;
865
+ readonly excludedProviderIds?: ReadonlySet<string>;
866
+ readonly providerId?: string;
867
+ readonly modelSupport?: ModelSupportRegistry;
868
+ }): readonly SubscriptionManagedAccount[] {
869
+ const seen = new Set<string>();
870
+ const available = options.accounts.filter((account) => {
871
+ if (firstCanonicalAccount(account, seen) === undefined) return false;
872
+ if (
873
+ options.providerId !== undefined &&
874
+ account.providerId !== options.providerId
875
+ ) {
876
+ return false;
877
+ }
878
+ if (options.excludedProviderIds?.has(account.providerId)) return false;
879
+ return exactAccountAvailable(
880
+ account,
881
+ options.state,
882
+ options.nowMs,
883
+ options.modelId,
884
+ options.modelSupport,
885
+ );
886
+ });
887
+
888
+ if (options.providerId !== undefined) return available;
889
+
890
+ const sameFamily = options.config.sameFamilyFailover
891
+ ? available.filter((account) => account.family === options.family)
892
+ : [];
893
+ const crossFamily = available.filter((account) =>
894
+ crossFamilyAllowed(options.family, account.family, options.config),
895
+ );
896
+ const ordered = [...sameFamily, ...crossFamily];
897
+ const preferred = ordered.find(
898
+ (account) => account.providerId === options.preferredProviderId,
899
+ );
900
+ return preferred === undefined
901
+ ? ordered
902
+ : [preferred, ...ordered.filter((account) => account !== preferred)];
903
+ }
904
+
905
+ function chooseAlternative(options: {
906
+ failed: ManagedAccount;
907
+ accounts: readonly ManagedAccount[];
908
+ state: RuntimeState;
909
+ config: MultiAccountConfig;
910
+ nowMs: number;
911
+ classification: FailureClassification;
912
+ originFamily: AllowedFamily;
913
+ requestedModelId?: string;
914
+ modelId?: string;
915
+ preferredModelId?: string;
916
+ modelSupport?: ModelSupportRegistry;
917
+ }): ReactiveRouteDecision {
918
+ const {
919
+ failed,
920
+ accounts,
921
+ state,
922
+ config,
923
+ nowMs,
924
+ classification,
925
+ originFamily,
926
+ requestedModelId,
927
+ modelId,
928
+ preferredModelId,
929
+ modelSupport,
930
+ } = options;
931
+ const candidates = selectAvailableRouteCandidates({
932
+ accounts,
933
+ state,
934
+ nowMs,
935
+ originFamily,
936
+ ...(requestedModelId === undefined ? {} : { requestedModelId }),
937
+ config,
938
+ failedProviderId: failed.providerId,
939
+ ...(modelId === undefined ? {} : { modelId }),
940
+ ...(preferredModelId === undefined ? {} : { preferredModelId }),
941
+ ...(modelSupport === undefined ? {} : { modelSupport }),
942
+ });
943
+ const candidate = candidates[0];
944
+ if (candidate !== undefined) {
945
+ return {
946
+ status: "selected",
947
+ ...candidate,
948
+ classification,
949
+ };
950
+ }
951
+
952
+ const eligible = accounts.filter((account) => {
953
+ if (account.providerId === failed.providerId) return false;
954
+ if (isSubscriptionManagedAccount(account)) {
955
+ if (account.family === originFamily) {
956
+ return (
957
+ config.sameFamilyFailover &&
958
+ (modelId === undefined ||
959
+ accountCanServeModel(account, modelId, modelSupport))
960
+ );
961
+ }
962
+ return crossFamilyAllowed(originFamily, account.family, config);
963
+ }
964
+ if (
965
+ !isOwningVendorApiAccount(account) ||
966
+ !sameVendor(account.family, originFamily) ||
967
+ requestedModelId === undefined
968
+ ) {
969
+ return false;
970
+ }
971
+ const resolvedModelId = resolveTierModel(
972
+ requestedModelId,
973
+ account.family,
974
+ account.modelIds ?? [],
975
+ config.tierModelMap,
976
+ );
977
+ return (
978
+ resolvedModelId !== undefined &&
979
+ accountCanServeModel(account, resolvedModelId, modelSupport)
980
+ );
981
+ });
982
+ const recoveryTimes = eligible
983
+ .map((account) => state.getCooldown(account.providerId, nowMs)?.untilMs)
984
+ .filter((until): until is number => until !== undefined && until > nowMs);
985
+ const earliestRecoveryAtMs =
986
+ recoveryTimes.length === 0 ? null : Math.min(...recoveryTimes);
987
+ return {
988
+ status: "paused",
989
+ classification,
990
+ earliestRecoveryAtMs,
991
+ retryAfterMs:
992
+ earliestRecoveryAtMs === null ? null : earliestRecoveryAtMs - nowMs,
993
+ };
994
+ }
995
+
996
+ function applyFailureCooldown(options: {
997
+ readonly failedAccount: ManagedAccount;
998
+ readonly normalizedAccounts: readonly ManagedAccount[];
999
+ readonly classification: FailureClassification;
1000
+ readonly state: RuntimeState;
1001
+ readonly config: MultiAccountConfig;
1002
+ readonly nowMs: number;
1003
+ }): boolean {
1004
+ const cooldown = createCooldownRecord({
1005
+ providerId: options.failedAccount.providerId,
1006
+ family: options.failedAccount.family,
1007
+ classification: options.classification,
1008
+ nowMs: options.nowMs,
1009
+ configuredMaxMs: options.config.cooldownMaxMs,
1010
+ });
1011
+ if (cooldown === undefined) return false;
1012
+ options.state.setCooldown(cooldown);
1013
+ const failedFingerprint = options.normalizedAccounts.find(
1014
+ (account) => account.providerId === options.failedAccount.providerId,
1015
+ )?.accountFingerprint;
1016
+ if (failedFingerprint !== undefined && failedFingerprint.length > 0) {
1017
+ for (const sibling of options.normalizedAccounts) {
1018
+ if (sibling.providerId === options.failedAccount.providerId) continue;
1019
+ if (sibling.accountFingerprint !== failedFingerprint) continue;
1020
+ options.state.setCooldown({
1021
+ ...cooldown,
1022
+ providerId: sibling.providerId,
1023
+ family: sibling.family,
1024
+ });
1025
+ }
1026
+ }
1027
+ return true;
1028
+ }
1029
+
1030
+ /**
1031
+ * Synchronously records only the cooldown part of a provider failure.
1032
+ * Selection and terminal invalidation remain settlement-only decisions.
1033
+ */
1034
+ export function recordFailureCooldown(options: {
1035
+ readonly failedAccount: ManagedAccount;
1036
+ readonly accounts: readonly ManagedAccount[];
1037
+ readonly failure: ProviderFailureSignal;
1038
+ readonly state: RuntimeState;
1039
+ readonly config: MultiAccountConfig;
1040
+ readonly nowMs: number;
1041
+ }): boolean {
1042
+ if (!isCanonicalProviderId(options.failedAccount)) {
1043
+ throw new TypeError("failedAccount must be a canonical managed provider.");
1044
+ }
1045
+ const classification = classifyFailure(options.failure);
1046
+ if (classification.accountAction !== "cooldown-and-route") return false;
1047
+ return applyFailureCooldown({
1048
+ failedAccount: options.failedAccount,
1049
+ // Pure projection ONLY: this runs inside the reversible cooldown mutation,
1050
+ // which can restore cooldown records but not identity/invalidation state.
1051
+ // `normalizedAccounts` would observe identity here and permanently clear
1052
+ // invalidations and unrelated cooldowns that a rollback could not restore.
1053
+ normalizedAccounts: projectCanonicalAccounts(options.accounts),
1054
+ classification,
1055
+ state: options.state,
1056
+ config: options.config,
1057
+ nowMs: options.nowMs,
1058
+ });
1059
+ }
1060
+
1061
+ /**
1062
+ * Applies a classified post-request failure and chooses a reactive route. This
1063
+ * function never calls Pi setModel and therefore cannot perform proactive
1064
+ * pre-request selection; lifecycle code must explicitly apply the returned
1065
+ * decision and preserve Pi's boolean/throw semantics.
1066
+ */
1067
+ export function routeAfterFailure(options: {
1068
+ failedAccount: ManagedAccount;
1069
+ accounts: readonly ManagedAccount[];
1070
+ failure: ProviderFailureSignal;
1071
+ originFamily: AllowedFamily;
1072
+ requestedModelId?: string;
1073
+ state: RuntimeState;
1074
+ config: MultiAccountConfig;
1075
+ nowMs: number;
1076
+ modelSupport?: ModelSupportRegistry;
1077
+ /** The same attempt already wrote its synchronous cooldown before host retry. */
1078
+ alreadyCooled?: boolean;
1079
+ }): ReactiveRouteDecision {
1080
+ const {
1081
+ failedAccount,
1082
+ failure,
1083
+ originFamily,
1084
+ requestedModelId,
1085
+ state,
1086
+ config,
1087
+ nowMs,
1088
+ modelSupport,
1089
+ alreadyCooled = false,
1090
+ } = options;
1091
+ if (!isCanonicalProviderId(failedAccount)) {
1092
+ throw new TypeError("failedAccount must be a canonical managed provider.");
1093
+ }
1094
+ const classification = classifyFailure(failure);
1095
+ if (
1096
+ classification.category === "config" &&
1097
+ failure.code === "model_not_found" &&
1098
+ modelSupport !== undefined &&
1099
+ failure.modelId !== undefined
1100
+ ) {
1101
+ modelSupport.markUnsupported({
1102
+ providerId: failedAccount.providerId,
1103
+ family: failedAccount.family,
1104
+ modelId: failure.modelId,
1105
+ observedAtMs: nowMs,
1106
+ });
1107
+ }
1108
+
1109
+ // Observe the slot's current identity first. A fresh login may clear state
1110
+ // from the previous account, but the failure being handled now must still
1111
+ // create its own cooldown or invalidation afterward.
1112
+ const normalized = normalizedAccounts(options.accounts, state);
1113
+ if (classification.accountAction === "invalidate-and-route") {
1114
+ state.invalidateAccount({
1115
+ providerId: failedAccount.providerId,
1116
+ family: failedAccount.family,
1117
+ reason: "terminal-auth-failure",
1118
+ invalidatedAtMs: nowMs,
1119
+ });
1120
+ state.clearCooldown(failedAccount.providerId);
1121
+ } else if (
1122
+ classification.accountAction === "cooldown-and-route" &&
1123
+ !alreadyCooled
1124
+ ) {
1125
+ applyFailureCooldown({
1126
+ failedAccount,
1127
+ normalizedAccounts: normalized,
1128
+ classification,
1129
+ state,
1130
+ config,
1131
+ nowMs,
1132
+ });
1133
+ }
1134
+ const accounts = normalized.some(
1135
+ (account) => account.providerId === failedAccount.providerId,
1136
+ )
1137
+ ? normalized
1138
+ : [failedAccount, ...normalized];
1139
+ const failedIsOriginSubscription =
1140
+ isSubscriptionManagedAccount(failedAccount) &&
1141
+ failedAccount.family === originFamily;
1142
+ return chooseAlternative({
1143
+ failed: failedAccount,
1144
+ accounts,
1145
+ state,
1146
+ config,
1147
+ nowMs,
1148
+ classification,
1149
+ originFamily,
1150
+ ...(requestedModelId === undefined ? {} : { requestedModelId }),
1151
+ ...(failure.code === "model_not_found" &&
1152
+ failure.modelId !== undefined &&
1153
+ failedIsOriginSubscription
1154
+ ? { modelId: failure.modelId }
1155
+ : {}),
1156
+ ...(requestedModelId === undefined
1157
+ ? failure.modelId === undefined
1158
+ ? {}
1159
+ : { preferredModelId: failure.modelId }
1160
+ : { preferredModelId: requestedModelId }),
1161
+ ...(modelSupport === undefined ? {} : { modelSupport }),
1162
+ });
1163
+ }