@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
@@ -0,0 +1,630 @@
1
+ import { isManagedFamily, type ManagedFamily } from "./config.js";
2
+ import {
3
+ classifyAccountIdentity,
4
+ shouldClearAccountState,
5
+ } from "./credential-lifecycle.js";
6
+
7
+ /** Numeric revision exposed by maintained, non-secret public credential metadata. */
8
+ export type CredentialRevision = number;
9
+
10
+ export type CooldownReason =
11
+ | "quota"
12
+ | "rate-limit"
13
+ | "auth-transient"
14
+ | "permission"
15
+ | "transport"
16
+ | "unknown";
17
+
18
+ export interface NumericServerHint {
19
+ readonly retryAfterSeconds?: number;
20
+ readonly resetAtMs?: number;
21
+ }
22
+
23
+ export interface CooldownRecord {
24
+ readonly providerId: string;
25
+ readonly family: ManagedFamily;
26
+ readonly reason: CooldownReason;
27
+ readonly untilMs: number;
28
+ readonly serverHint?: NumericServerHint;
29
+ }
30
+
31
+ export interface InvalidatedAccount {
32
+ readonly providerId: string;
33
+ readonly family: ManagedFamily;
34
+ readonly reason: "terminal-auth-failure";
35
+ readonly invalidatedAtMs: number;
36
+ }
37
+
38
+ export type ContinuationRoutingReason =
39
+ | "quota"
40
+ | "rate-limit"
41
+ | "terminal-auth-failure"
42
+ | "permission"
43
+ | "model-not-found"
44
+ | "metered-last-resort";
45
+
46
+ /** Managed account family or the one explicitly permitted metered bridge. */
47
+ export type ContinuationDestinationFamily = ManagedFamily | "openrouter";
48
+
49
+ /** One session-local, one-shot destination for the next unified dispatch. */
50
+ export interface LogicalRoutePin {
51
+ readonly generation: number;
52
+ readonly destinationProviderId: string;
53
+ readonly destinationFamily: ManagedFamily;
54
+ readonly requestedModelId: string;
55
+ }
56
+
57
+ export type LogicalRoutePinInput = Omit<LogicalRoutePin, "generation">;
58
+
59
+ const runtimeReferenceBrand: unique symbol = Symbol("RuntimeReference");
60
+
61
+ /**
62
+ * How long limit errors stay relevant when judging whether a usage snapshot is
63
+ * lying. Matches the Sarrius reference (`index.ts:581`, verified 2026-08-05),
64
+ * whose comment records the failure this prevents: "Trusting usage as ground
65
+ * truth then reports the account 'free now', schedules a ~1s retry, gets 429
66
+ * again, and loops."
67
+ */
68
+ export const LIMIT_STREAK_WINDOW_MS = 15 * 60 * 1000;
69
+
70
+ /**
71
+ * Limit errors required inside the window before the usage snapshot is
72
+ * distrusted. TWO, per Sarrius `index.ts:579-580` and `:2258-2265`: one 429 is
73
+ * ordinary under concurrency and the plain cooldown already covers it, so a
74
+ * single sample must not shrink the usable fleet.
75
+ */
76
+ export const LIMIT_STREAK_TRUST_THRESHOLD = 2;
77
+
78
+ /** Process-local identity minted by one RuntimeState without caller content. */
79
+ export type RuntimeReference = Readonly<{
80
+ readonly localId: string;
81
+ readonly [runtimeReferenceBrand]: true;
82
+ }>;
83
+
84
+ /**
85
+ * Content-free exactly-once marker. References are state-minted local identities;
86
+ * neither a host identifier nor prompt/message content belongs here.
87
+ */
88
+ export interface ContinuationGuard {
89
+ readonly failedTurnRef: RuntimeReference;
90
+ readonly destinationProviderId: string;
91
+ readonly destinationFamily: ContinuationDestinationFamily;
92
+ readonly routingReason: ContinuationRoutingReason;
93
+ readonly guardSetAtMs: number;
94
+ }
95
+
96
+ export interface WatchdogSnapshot {
97
+ readonly continuationTurnRef: RuntimeReference;
98
+ readonly dispatchedAtMs: number;
99
+ readonly lastProgressAtMs: number;
100
+ readonly toolRunning: boolean;
101
+ }
102
+
103
+ /** A queue marker uses a state-minted identity and retains no host input. */
104
+ export interface QueuedInputReference {
105
+ readonly inputRef: RuntimeReference;
106
+ readonly queuedAtMs: number;
107
+ }
108
+
109
+ export interface RuntimeStateSnapshot {
110
+ readonly cooldowns: readonly CooldownRecord[];
111
+ readonly invalidatedAccounts: readonly InvalidatedAccount[];
112
+ readonly continuationGuards: readonly ContinuationGuard[];
113
+ readonly watchdogs: readonly WatchdogSnapshot[];
114
+ readonly queuedInputs: readonly QueuedInputReference[];
115
+ readonly inputPaused: boolean;
116
+ }
117
+
118
+ export function isCanonicalManagedProviderId(
119
+ providerId: string,
120
+ family: ManagedFamily,
121
+ ): boolean {
122
+ if (!isManagedFamily(family)) return false;
123
+ if (providerId === family) return true;
124
+ const prefix = `${family}-account-`;
125
+ if (!providerId.startsWith(prefix)) return false;
126
+ const suffix = providerId.slice(prefix.length);
127
+ if (!/^[1-9]\d*$/.test(suffix)) return false;
128
+ const slotIndex = Number(suffix);
129
+ return Number.isSafeInteger(slotIndex) && slotIndex >= 2;
130
+ }
131
+
132
+ function assertManagedProviderId(
133
+ providerId: string,
134
+ family: ManagedFamily,
135
+ ): void {
136
+ if (!isCanonicalManagedProviderId(providerId, family)) {
137
+ throw new TypeError("providerId must be canonical for its managed family.");
138
+ }
139
+ }
140
+
141
+ function projectServerHint(
142
+ hint: NumericServerHint | undefined,
143
+ ): NumericServerHint | undefined {
144
+ if (hint === undefined) return undefined;
145
+ const projected: { retryAfterSeconds?: number; resetAtMs?: number } = {};
146
+ if (hint.retryAfterSeconds !== undefined) {
147
+ projected.retryAfterSeconds = hint.retryAfterSeconds;
148
+ }
149
+ if (hint.resetAtMs !== undefined) projected.resetAtMs = hint.resetAtMs;
150
+ return Object.freeze(projected);
151
+ }
152
+
153
+ function assertTimestamp(value: number, name: string): void {
154
+ if (!Number.isFinite(value) || value < 0) {
155
+ throw new TypeError(`${name} must be a finite non-negative timestamp.`);
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Entirely process-local routing state. It intentionally has no persistence,
161
+ * generic metadata bag, raw error field, or content-bearing queue field.
162
+ */
163
+ export class RuntimeState {
164
+ readonly #cooldowns = new Map<string, CooldownRecord>();
165
+ readonly #invalidatedAccounts = new Map<string, InvalidatedAccount>();
166
+ /**
167
+ * Limit-error timestamps per provider, newest last, used to distrust a usage
168
+ * snapshot the provider itself contradicts.
169
+ *
170
+ * WHY THIS EXISTS. The usage endpoint tracks the QUOTA window and cannot see a
171
+ * SESSION limit, so an account can report `utilization: 0` while every request
172
+ * returns 429. Observed live on 2026-08-05: base `anthropic` held
173
+ * `{utilization: 0, utilizationSource: "usage-endpoint"}` refreshed 4.7 minutes
174
+ * earlier while the operator was taking a 429 on every attempt, and the
175
+ * operator surface truthfully rendered "limits: 0% used". Selection therefore
176
+ * kept choosing the one account the provider was refusing.
177
+ */
178
+ readonly #limitErrors = new Map<string, number[]>();
179
+ readonly #credentialRevisions = new Map<string, CredentialRevision>();
180
+ /** Stable account fingerprint per slot; absent when it cannot be derived. */
181
+ readonly #accountIdentities = new Map<string, string>();
182
+ readonly #continuationGuards = new Map<RuntimeReference, ContinuationGuard>();
183
+ readonly #watchdogs = new Map<RuntimeReference, WatchdogSnapshot>();
184
+ readonly #queuedInputs = new Map<RuntimeReference, QueuedInputReference>();
185
+ readonly #issuedReferences = new Set<RuntimeReference>();
186
+ #referenceCounter = 0;
187
+ #logicalRoutePinGeneration = 0;
188
+ #logicalRoutePin: LogicalRoutePin | undefined;
189
+ #inputPaused = false;
190
+
191
+ /** Mints an identity from local state only; no caller value can enter it. */
192
+ mintReference(): RuntimeReference {
193
+ const next = this.#referenceCounter + 1;
194
+ if (!Number.isSafeInteger(next)) {
195
+ throw new RangeError(
196
+ "runtime reference counter exhausted safe integers.",
197
+ );
198
+ }
199
+ const reference: RuntimeReference = Object.freeze({
200
+ localId: `runtime-ref-${next}`,
201
+ [runtimeReferenceBrand]: true as const,
202
+ });
203
+ this.#referenceCounter = next;
204
+ this.#issuedReferences.add(reference);
205
+ return reference;
206
+ }
207
+
208
+ setCooldown(record: CooldownRecord): void {
209
+ assertManagedProviderId(record.providerId, record.family);
210
+ assertTimestamp(record.untilMs, "untilMs");
211
+ const serverHint = projectServerHint(record.serverHint);
212
+ this.#cooldowns.set(
213
+ record.providerId,
214
+ Object.freeze({
215
+ providerId: record.providerId,
216
+ family: record.family,
217
+ reason: record.reason,
218
+ untilMs: record.untilMs,
219
+ ...(serverHint === undefined ? {} : { serverHint }),
220
+ }),
221
+ );
222
+ }
223
+
224
+ /**
225
+ * Runs one synchronous cooldown write set and returns an ownership-aware undo.
226
+ * The closure retains only canonical ids and cooldown records, never messages,
227
+ * credentials, fingerprints, or other caller objects.
228
+ */
229
+ runReversibleCooldownMutation<T>(
230
+ providerIds: readonly string[],
231
+ mutate: () => T,
232
+ ): { readonly value: T; readonly rollback: () => void } {
233
+ const ids = [...new Set(providerIds)];
234
+ for (const providerId of ids) {
235
+ if (typeof providerId !== "string" || providerId.length === 0) {
236
+ throw new TypeError("providerIds must contain non-empty strings.");
237
+ }
238
+ }
239
+ const before = new Map(
240
+ ids.map((providerId) => [providerId, this.#cooldowns.get(providerId)]),
241
+ );
242
+ let value: T;
243
+ try {
244
+ value = mutate();
245
+ } catch (error) {
246
+ for (const providerId of ids) {
247
+ const previous = before.get(providerId);
248
+ if (previous === undefined) this.#cooldowns.delete(providerId);
249
+ else this.#cooldowns.set(providerId, previous);
250
+ }
251
+ throw error;
252
+ }
253
+ const written = new Map(
254
+ ids.map((providerId) => [providerId, this.#cooldowns.get(providerId)]),
255
+ );
256
+ let active = true;
257
+ return {
258
+ value,
259
+ rollback: () => {
260
+ if (!active) return;
261
+ active = false;
262
+ for (let index = ids.length - 1; index >= 0; index -= 1) {
263
+ const providerId = ids[index]!;
264
+ if (this.#cooldowns.get(providerId) !== written.get(providerId)) continue;
265
+ const previous = before.get(providerId);
266
+ if (previous === undefined) this.#cooldowns.delete(providerId);
267
+ else this.#cooldowns.set(providerId, previous);
268
+ }
269
+ },
270
+ };
271
+ }
272
+
273
+ getCooldown(providerId: string, nowMs: number): CooldownRecord | undefined {
274
+ const record = this.#cooldowns.get(providerId);
275
+ if (record && record.untilMs <= nowMs) {
276
+ this.#cooldowns.delete(providerId);
277
+ return undefined;
278
+ }
279
+ return record;
280
+ }
281
+
282
+ /** Reads cooldown state without pruning an expired record. */
283
+ peekCooldown(providerId: string, nowMs: number): CooldownRecord | undefined {
284
+ const record = this.#cooldowns.get(providerId);
285
+ return record !== undefined && record.untilMs > nowMs ? record : undefined;
286
+ }
287
+
288
+ clearCooldown(providerId: string): void {
289
+ this.#cooldowns.delete(providerId);
290
+ }
291
+
292
+ invalidateAccount(record: InvalidatedAccount): void {
293
+ assertManagedProviderId(record.providerId, record.family);
294
+ assertTimestamp(record.invalidatedAtMs, "invalidatedAtMs");
295
+ this.#invalidatedAccounts.set(
296
+ providerIdKey(record.providerId),
297
+ Object.freeze({
298
+ providerId: record.providerId,
299
+ family: record.family,
300
+ reason: "terminal-auth-failure",
301
+ invalidatedAtMs: record.invalidatedAtMs,
302
+ }),
303
+ );
304
+ }
305
+
306
+ getInvalidation(providerId: string): InvalidatedAccount | undefined {
307
+ return this.#invalidatedAccounts.get(providerIdKey(providerId));
308
+ }
309
+
310
+ markAuthenticationSuccess(providerId: string): void {
311
+ this.#invalidatedAccounts.delete(providerIdKey(providerId));
312
+ this.#cooldowns.delete(providerId);
313
+ // A success is the provider contradicting our distrust, so the streak is
314
+ // retired. Without this the account would stay untrusted until the window
315
+ // aged out, long after it started serving again.
316
+ this.#limitErrors.delete(providerId);
317
+ }
318
+
319
+ /**
320
+ * A credential resolver returning a value proves only that auth material is
321
+ * available. It may clear terminal auth invalidation, but it cannot prove that
322
+ * an upstream cooldown or repeated 429 has recovered.
323
+ */
324
+ markCredentialResolutionSuccess(providerId: string): void {
325
+ this.#invalidatedAccounts.delete(providerIdKey(providerId));
326
+ }
327
+
328
+ /**
329
+ * Record a limit error (429 / quota) so repeated ones can override a usage
330
+ * snapshot that still claims headroom.
331
+ */
332
+ recordLimitError(providerId: string, nowMs: number): void {
333
+ assertTimestamp(nowMs, "nowMs");
334
+ const within = (this.#limitErrors.get(providerId) ?? []).filter(
335
+ (at) => nowMs - at < LIMIT_STREAK_WINDOW_MS,
336
+ );
337
+ within.push(nowMs);
338
+ // Bounded: only the streak threshold matters, so never accumulate history.
339
+ this.#limitErrors.set(providerId, within.slice(-LIMIT_STREAK_TRUST_THRESHOLD));
340
+ }
341
+
342
+ /**
343
+ * True when this provider has produced enough limit errors inside the window
344
+ * that its usage snapshot should no longer be believed.
345
+ *
346
+ * A SINGLE limit error is deliberately not enough. One 429 is normal under
347
+ * concurrency and is already handled by the ordinary cooldown; distrusting a
348
+ * snapshot on one sample would shrink the usable fleet on noise. The threshold
349
+ * follows the Sarrius reference (`index.ts:577-581`, accounting at
350
+ * `:2258-2265`), which reached the same conclusion after shipping the naive
351
+ * version.
352
+ */
353
+ isUsageSnapshotUntrusted(providerId: string, nowMs: number): boolean {
354
+ const within = (this.#limitErrors.get(providerId) ?? []).filter(
355
+ (at) => nowMs - at < LIMIT_STREAK_WINDOW_MS,
356
+ );
357
+ if (within.length !== this.#limitErrors.get(providerId)?.length) {
358
+ // Prune lazily so a long-idle provider does not keep stale timestamps.
359
+ if (within.length === 0) this.#limitErrors.delete(providerId);
360
+ else this.#limitErrors.set(providerId, within);
361
+ }
362
+ return within.length >= LIMIT_STREAK_TRUST_THRESHOLD;
363
+ }
364
+
365
+ /** Reads usage distrust without pruning expired limit observations. */
366
+ peekUsageSnapshotUntrusted(providerId: string, nowMs: number): boolean {
367
+ const within = (this.#limitErrors.get(providerId) ?? []).filter(
368
+ (at) => nowMs - at < LIMIT_STREAK_WINDOW_MS,
369
+ );
370
+ return within.length >= LIMIT_STREAK_TRUST_THRESHOLD;
371
+ }
372
+
373
+ /**
374
+ * Clears terminal invalidation only when maintained public metadata exposes a
375
+ * changed numeric revision. No credential value or derived fingerprint is read.
376
+ *
377
+ * A revision change means the stored credential was replaced, which includes a
378
+ * routine token refresh. That is enough to retry a terminal auth failure — the
379
+ * new credential may well authenticate — but it is NOT enough to drop a
380
+ * cooldown, which belongs to the account rather than the credential. Use
381
+ * {@link observeAccountIdentity} for that distinction.
382
+ */
383
+ observeCredentialRevision(
384
+ providerId: string,
385
+ family: ManagedFamily,
386
+ revision: CredentialRevision,
387
+ ): boolean {
388
+ assertManagedProviderId(providerId, family);
389
+ if (!Number.isSafeInteger(revision) || revision < 0) {
390
+ throw new TypeError(
391
+ "credential revision must be a non-negative safe integer.",
392
+ );
393
+ }
394
+ const key = providerIdKey(providerId);
395
+ const previous = this.#credentialRevisions.get(key);
396
+ this.#credentialRevisions.set(key, revision);
397
+ if (previous !== undefined && previous !== revision) {
398
+ return this.#invalidatedAccounts.delete(key);
399
+ }
400
+ return false;
401
+ }
402
+
403
+ /**
404
+ * Records the account identity now occupying a slot, clearing that slot's
405
+ * accumulated routing state only when the account has provably changed.
406
+ *
407
+ * Cooldowns and terminal invalidations describe a real upstream account, so a
408
+ * routine OAuth refresh — which rotates the token but not the account — must
409
+ * leave them intact: a server-side rate limit is not lifted by minting a new
410
+ * token, and forgetting the cooldown routes traffic straight back into the
411
+ * limit. Only a genuine re-login to a DIFFERENT account makes prior state
412
+ * irrelevant.
413
+ *
414
+ * `fingerprint` must be a stable account identifier derived from non-secret
415
+ * claims, or undefined when identity cannot be established (Anthropic's
416
+ * opaque tokens). Undefined is treated as "cannot prove a change" and retains
417
+ * state, never as a change. Returns true when state was cleared.
418
+ */
419
+ observeAccountIdentity(
420
+ providerId: string,
421
+ family: ManagedFamily,
422
+ fingerprint: string | undefined,
423
+ ): boolean {
424
+ assertManagedProviderId(providerId, family);
425
+ const key = providerIdKey(providerId);
426
+ const change = classifyAccountIdentity(
427
+ this.#accountIdentities.get(key),
428
+ fingerprint,
429
+ );
430
+ if (fingerprint !== undefined)
431
+ this.#accountIdentities.set(key, fingerprint);
432
+ if (!shouldClearAccountState(change)) return false;
433
+ this.#cooldowns.delete(providerId);
434
+ this.#invalidatedAccounts.delete(key);
435
+ return true;
436
+ }
437
+
438
+ resetAccount(providerId: string): void {
439
+ const key = providerIdKey(providerId);
440
+ this.#cooldowns.delete(providerId);
441
+ this.#invalidatedAccounts.delete(key);
442
+ this.#credentialRevisions.delete(key);
443
+ this.#accountIdentities.delete(key);
444
+ }
445
+
446
+ resetRouting(): void {
447
+ this.#cooldowns.clear();
448
+ this.#invalidatedAccounts.clear();
449
+ this.#credentialRevisions.clear();
450
+ this.#accountIdentities.clear();
451
+ }
452
+
453
+ establishContinuationGuard(guard: ContinuationGuard): boolean {
454
+ if (guard.destinationFamily === "openrouter") {
455
+ if (guard.destinationProviderId !== "openrouter") {
456
+ throw new TypeError(
457
+ "metered continuation destination must use provider openrouter.",
458
+ );
459
+ }
460
+ } else {
461
+ assertManagedProviderId(
462
+ guard.destinationProviderId,
463
+ guard.destinationFamily,
464
+ );
465
+ }
466
+ this.#assertIssuedReference(guard.failedTurnRef, "failedTurnRef");
467
+ assertTimestamp(guard.guardSetAtMs, "guardSetAtMs");
468
+ if (this.#continuationGuards.has(guard.failedTurnRef)) return false;
469
+ this.#continuationGuards.set(
470
+ guard.failedTurnRef,
471
+ Object.freeze({
472
+ failedTurnRef: guard.failedTurnRef,
473
+ destinationProviderId: guard.destinationProviderId,
474
+ destinationFamily: guard.destinationFamily,
475
+ routingReason: guard.routingReason,
476
+ guardSetAtMs: guard.guardSetAtMs,
477
+ }),
478
+ );
479
+ return true;
480
+ }
481
+
482
+ clearContinuationGuard(failedTurnRef: RuntimeReference): void {
483
+ this.#assertIssuedReference(failedTurnRef, "failedTurnRef");
484
+ this.#continuationGuards.delete(failedTurnRef);
485
+ this.#releaseReferenceIfUnused(failedTurnRef);
486
+ }
487
+
488
+ setWatchdog(snapshot: WatchdogSnapshot): void {
489
+ this.#assertIssuedReference(
490
+ snapshot.continuationTurnRef,
491
+ "continuationTurnRef",
492
+ );
493
+ assertTimestamp(snapshot.dispatchedAtMs, "dispatchedAtMs");
494
+ assertTimestamp(snapshot.lastProgressAtMs, "lastProgressAtMs");
495
+ this.#watchdogs.set(
496
+ snapshot.continuationTurnRef,
497
+ Object.freeze({
498
+ continuationTurnRef: snapshot.continuationTurnRef,
499
+ dispatchedAtMs: snapshot.dispatchedAtMs,
500
+ lastProgressAtMs: snapshot.lastProgressAtMs,
501
+ toolRunning: snapshot.toolRunning,
502
+ }),
503
+ );
504
+ }
505
+
506
+ clearWatchdog(continuationTurnRef: RuntimeReference): void {
507
+ this.#assertIssuedReference(continuationTurnRef, "continuationTurnRef");
508
+ this.#watchdogs.delete(continuationTurnRef);
509
+ this.#releaseReferenceIfUnused(continuationTurnRef);
510
+ }
511
+
512
+ queueInput(reference: QueuedInputReference): void {
513
+ this.#assertIssuedReference(reference.inputRef, "inputRef");
514
+ assertTimestamp(reference.queuedAtMs, "queuedAtMs");
515
+ this.#queuedInputs.set(
516
+ reference.inputRef,
517
+ Object.freeze({
518
+ inputRef: reference.inputRef,
519
+ queuedAtMs: reference.queuedAtMs,
520
+ }),
521
+ );
522
+ }
523
+
524
+ setInputPaused(paused: boolean): void {
525
+ this.#inputPaused = paused;
526
+ }
527
+
528
+ setLogicalRoutePin(input: LogicalRoutePinInput): LogicalRoutePin {
529
+ assertManagedProviderId(input.destinationProviderId, input.destinationFamily);
530
+ if (typeof input.requestedModelId !== "string" || input.requestedModelId.length === 0) {
531
+ throw new TypeError("requestedModelId must be a non-empty string.");
532
+ }
533
+ const generation = this.#advanceLogicalRoutePinGeneration();
534
+ const pin = Object.freeze({
535
+ generation,
536
+ destinationProviderId: input.destinationProviderId,
537
+ destinationFamily: input.destinationFamily,
538
+ requestedModelId: input.requestedModelId,
539
+ });
540
+ this.#logicalRoutePin = pin;
541
+ return pin;
542
+ }
543
+
544
+ getLogicalRoutePin(): LogicalRoutePin | undefined {
545
+ return this.#logicalRoutePin;
546
+ }
547
+
548
+ consumeLogicalRoutePin(
549
+ expectedGeneration: number,
550
+ requestedModelId: string,
551
+ ): LogicalRoutePin | undefined {
552
+ const pin = this.#logicalRoutePin;
553
+ this.#logicalRoutePin = undefined;
554
+ if (
555
+ pin === undefined ||
556
+ pin.generation !== expectedGeneration ||
557
+ pin.requestedModelId !== requestedModelId
558
+ ) {
559
+ return undefined;
560
+ }
561
+ return pin;
562
+ }
563
+
564
+ clearLogicalRoutePin(): void {
565
+ this.#logicalRoutePin = undefined;
566
+ }
567
+
568
+ clearPendingActivity(): void {
569
+ this.#advanceLogicalRoutePinGeneration();
570
+ this.#logicalRoutePin = undefined;
571
+ this.#continuationGuards.clear();
572
+ this.#watchdogs.clear();
573
+ this.#queuedInputs.clear();
574
+ this.#issuedReferences.clear();
575
+ this.#inputPaused = false;
576
+ }
577
+
578
+ clearAll(): void {
579
+ this.resetRouting();
580
+ this.clearPendingActivity();
581
+ }
582
+
583
+ snapshot(nowMs: number): RuntimeStateSnapshot {
584
+ for (const providerId of this.#cooldowns.keys()) {
585
+ this.getCooldown(providerId, nowMs);
586
+ }
587
+ return {
588
+ cooldowns: [...this.#cooldowns.values()],
589
+ invalidatedAccounts: [...this.#invalidatedAccounts.values()],
590
+ continuationGuards: [...this.#continuationGuards.values()],
591
+ watchdogs: [...this.#watchdogs.values()],
592
+ queuedInputs: [...this.#queuedInputs.values()],
593
+ inputPaused: this.#inputPaused,
594
+ };
595
+ }
596
+
597
+ #advanceLogicalRoutePinGeneration(): number {
598
+ const next = this.#logicalRoutePinGeneration + 1;
599
+ if (!Number.isSafeInteger(next)) {
600
+ throw new RangeError("logical route pin generation exhausted safe integers.");
601
+ }
602
+ this.#logicalRoutePinGeneration = next;
603
+ return next;
604
+ }
605
+
606
+ #assertIssuedReference(reference: RuntimeReference, name: string): void {
607
+ if (
608
+ typeof reference !== "object" ||
609
+ reference === null ||
610
+ !this.#issuedReferences.has(reference)
611
+ ) {
612
+ throw new TypeError(`${name} must be minted by this RuntimeState.`);
613
+ }
614
+ }
615
+
616
+ #releaseReferenceIfUnused(reference: RuntimeReference): void {
617
+ if (
618
+ this.#continuationGuards.has(reference) ||
619
+ this.#watchdogs.has(reference) ||
620
+ this.#queuedInputs.has(reference)
621
+ ) {
622
+ return;
623
+ }
624
+ this.#issuedReferences.delete(reference);
625
+ }
626
+ }
627
+
628
+ function providerIdKey(providerId: string): string {
629
+ return providerId;
630
+ }