@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.
- package/LICENSE +21 -0
- package/NOTICE +29 -0
- package/README.md +999 -0
- package/config/models/pi-multi-account.v1.json +32 -0
- package/config/subscription-plans.v1.json +122 -0
- package/package.json +76 -0
- package/packages/pi-anthropic-oauth/LICENSE +21 -0
- package/packages/pi-anthropic-oauth/package.json +54 -0
- package/packages/pi-anthropic-oauth/src/auth.ts +396 -0
- package/packages/pi-anthropic-oauth/src/context.ts +116 -0
- package/packages/pi-anthropic-oauth/src/convert.ts +303 -0
- package/packages/pi-anthropic-oauth/src/index.ts +37 -0
- package/packages/pi-anthropic-oauth/src/prompt.ts +137 -0
- package/packages/pi-anthropic-oauth/src/stream.ts +476 -0
- package/packages/pi-antigravity/LICENSE +21 -0
- package/packages/pi-antigravity/package.json +77 -0
- package/packages/pi-antigravity/src/auth/index.ts +14 -0
- package/packages/pi-antigravity/src/auth/oauth.ts +442 -0
- package/packages/pi-antigravity/src/client/client.ts +561 -0
- package/packages/pi-antigravity/src/client/index.ts +1 -0
- package/packages/pi-antigravity/src/context.ts +110 -0
- package/packages/pi-antigravity/src/diagnostics/diagnostics.ts +96 -0
- package/packages/pi-antigravity/src/diagnostics/index.ts +1 -0
- package/packages/pi-antigravity/src/image/image.ts +336 -0
- package/packages/pi-antigravity/src/image/index.ts +1 -0
- package/packages/pi-antigravity/src/index.ts +280 -0
- package/packages/pi-antigravity/src/models/discovery.ts +154 -0
- package/packages/pi-antigravity/src/models/grouping.ts +424 -0
- package/packages/pi-antigravity/src/models/index.ts +3 -0
- package/packages/pi-antigravity/src/models/models.ts +500 -0
- package/packages/pi-antigravity/src/stream/index.ts +1 -0
- package/packages/pi-antigravity/src/stream/stream.ts +1478 -0
- package/packages/pi-antigravity/src/types/enums.ts +42 -0
- package/packages/pi-antigravity/src/types/index.ts +2 -0
- package/packages/pi-antigravity/src/types/types.ts +292 -0
- package/packages/pi-antigravity/src/usage/index.ts +1 -0
- package/packages/pi-antigravity/src/usage/usage.ts +416 -0
- package/packages/pi-antigravity/src/utils/http.ts +91 -0
- package/packages/pi-antigravity/src/utils/index.ts +3 -0
- package/packages/pi-antigravity/src/utils/security.ts +73 -0
- package/packages/pi-antigravity/src/utils/util.ts +132 -0
- package/scripts/multi-account.mjs +44 -0
- package/src/account-labels.ts +223 -0
- package/src/account-plan-assignment.ts +340 -0
- package/src/account-rate-history.ts +372 -0
- package/src/anthropic-adaptive-stream.ts +531 -0
- package/src/anthropic-alias-stream.ts +140 -0
- package/src/anthropic-context-compat.ts +80 -0
- package/src/api-pricing.ts +579 -0
- package/src/bounded-file-lines.ts +97 -0
- package/src/catalog-rebinding.ts +177 -0
- package/src/catalog-registration-probe.ts +111 -0
- package/src/codex-adapter.ts +345 -0
- package/src/codex-model-defaults.ts +785 -0
- package/src/command-completions.ts +404 -0
- package/src/commands.ts +2000 -0
- package/src/compaction.ts +14 -0
- package/src/config.ts +1317 -0
- package/src/continuation.ts +569 -0
- package/src/cooldowns.ts +110 -0
- package/src/cost-digest-store.ts +332 -0
- package/src/cost-digest.ts +1044 -0
- package/src/cost-history.ts +251 -0
- package/src/cost-period-closer.ts +160 -0
- package/src/cost-report-json.ts +318 -0
- package/src/cost-report-reader.ts +368 -0
- package/src/cost-report-render.ts +207 -0
- package/src/cost-report.ts +1104 -0
- package/src/coverage-attestation.ts +397 -0
- package/src/credential-lifecycle.ts +169 -0
- package/src/credential-refresh.ts +248 -0
- package/src/declaration-notice-marker.ts +238 -0
- package/src/diagnostic-store.ts +276 -0
- package/src/diagnostics.ts +309 -0
- package/src/discovery.ts +471 -0
- package/src/duration.ts +13 -0
- package/src/error-classification.ts +256 -0
- package/src/fuzzy.ts +15 -0
- package/src/group-policy.ts +81 -0
- package/src/history-store.ts +897 -0
- package/src/index.ts +5572 -0
- package/src/lifecycle.ts +378 -0
- package/src/logical-dispatch.ts +279 -0
- package/src/logical-model-selector.ts +254 -0
- package/src/logical-model-switcher.ts +430 -0
- package/src/logical-provider-attribution.ts +544 -0
- package/src/logical-provider.ts +1237 -0
- package/src/logical-route-indicator.ts +215 -0
- package/src/machine-lease.ts +445 -0
- package/src/model-support.ts +66 -0
- package/src/models-declaration.ts +1091 -0
- package/src/openai-adapter.ts +117 -0
- package/src/openrouter-budget.ts +304 -0
- package/src/openrouter-fallback.ts +146 -0
- package/src/period-boundaries.ts +376 -0
- package/src/pi-anthropic-oauth.d.ts +6 -0
- package/src/preflight.ts +253 -0
- package/src/pricing-cache.ts +235 -0
- package/src/project-identity.ts +100 -0
- package/src/provider-registration.ts +942 -0
- package/src/rate-formula.ts +163 -0
- package/src/recovery-engine.ts +853 -0
- package/src/recovery-output.ts +837 -0
- package/src/recovery-plan.ts +239 -0
- package/src/report-range.ts +203 -0
- package/src/route-resolver.ts +789 -0
- package/src/routing-config-transaction.ts +232 -0
- package/src/routing.ts +1163 -0
- package/src/runtime-state.ts +630 -0
- package/src/session-account-groups.ts +284 -0
- package/src/session-restore.ts +287 -0
- package/src/shared-usage.ts +1392 -0
- package/src/standalone-cli.ts +720 -0
- package/src/status-view.ts +578 -0
- package/src/subscription-plan-catalog.ts +346 -0
- package/src/tier-model-resolver.ts +46 -0
- package/src/upstream-anthropic.ts +315 -0
- package/src/upstream-antigravity.ts +327 -0
- package/src/usage-fetch.ts +1634 -0
- package/src/usage.ts +1026 -0
- package/src/vendor.ts +87 -0
- package/src/warmer.ts +231 -0
- package/src/watchdog.ts +219 -0
- 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
|
+
}
|