@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,376 @@
1
+ export const PERIOD_TYPES = [
2
+ "day",
3
+ "week",
4
+ "month",
5
+ "quarter",
6
+ "half-year",
7
+ "year",
8
+ ] as const;
9
+
10
+ export type PeriodType = (typeof PERIOD_TYPES)[number];
11
+
12
+ export interface PeriodBounds {
13
+ readonly startMs: number;
14
+ readonly endMs: number;
15
+ }
16
+
17
+ /** More than 27 years of daily rows; a corrupt timestamp cannot walk forever. */
18
+ export const DEFAULT_PERIOD_ITERATION_CAP = 10_000;
19
+
20
+ export class PeriodEnumerationLimitError extends RangeError {
21
+ constructor(periodType: PeriodType, maxIterations: number) {
22
+ super(
23
+ `Calendar ${periodType} enumeration exceeded ${maxIterations} iterations.`,
24
+ );
25
+ this.name = "PeriodEnumerationLimitError";
26
+ }
27
+ }
28
+
29
+ function assertFiniteTimestamp(value: number, name: string): void {
30
+ if (!Number.isFinite(value)) {
31
+ throw new RangeError(`${name} must be a finite timestamp.`);
32
+ }
33
+ const date = new Date(value);
34
+ if (Number.isNaN(date.getTime())) {
35
+ throw new RangeError(`${name} is outside the supported date range.`);
36
+ }
37
+ }
38
+
39
+ function utcMonthBounds(
40
+ year: number,
41
+ startMonth: number,
42
+ months: number,
43
+ ): PeriodBounds {
44
+ return {
45
+ startMs: Date.UTC(year, startMonth, 1),
46
+ endMs: Date.UTC(year, startMonth + months, 1),
47
+ };
48
+ }
49
+
50
+ /** The pre-existing UTC-only implementation, kept byte-identical so every caller that omits a timezone sees no change. */
51
+ function getUtcPeriodBounds(instantMs: number, periodType: PeriodType): PeriodBounds {
52
+ const date = new Date(instantMs);
53
+ const year = date.getUTCFullYear();
54
+ const month = date.getUTCMonth();
55
+ const day = date.getUTCDate();
56
+
57
+ switch (periodType) {
58
+ case "day":
59
+ return {
60
+ startMs: Date.UTC(year, month, day),
61
+ endMs: Date.UTC(year, month, day + 1),
62
+ };
63
+ case "week": {
64
+ const mondayOffset = (date.getUTCDay() + 6) % 7;
65
+ return {
66
+ startMs: Date.UTC(year, month, day - mondayOffset),
67
+ endMs: Date.UTC(year, month, day - mondayOffset + 7),
68
+ };
69
+ }
70
+ case "month":
71
+ return utcMonthBounds(year, month, 1);
72
+ case "quarter":
73
+ return utcMonthBounds(year, Math.floor(month / 3) * 3, 3);
74
+ case "half-year":
75
+ return utcMonthBounds(year, Math.floor(month / 6) * 6, 6);
76
+ case "year":
77
+ return utcMonthBounds(year, 0, 12);
78
+ default: {
79
+ const exhaustive: never = periodType;
80
+ throw new TypeError(`Unsupported period type: ${String(exhaustive)}`);
81
+ }
82
+ }
83
+ }
84
+
85
+ interface ZonedCalendarParts {
86
+ readonly year: number;
87
+ /** Zero-based, matching `Date.UTC`'s month argument. */
88
+ readonly month: number;
89
+ readonly day: number;
90
+ readonly hour: number;
91
+ readonly minute: number;
92
+ readonly second: number;
93
+ /** ISO weekday index: 0 = Monday .. 6 = Sunday. */
94
+ readonly isoWeekdayIndex: number;
95
+ }
96
+
97
+ const ISO_WEEKDAY_INDEX: Readonly<Record<string, number>> = {
98
+ Mon: 0,
99
+ Tue: 1,
100
+ Wed: 2,
101
+ Thu: 3,
102
+ Fri: 4,
103
+ Sat: 5,
104
+ Sun: 6,
105
+ };
106
+
107
+ const ZONED_FORMATTER_CACHE = new Map<string, Intl.DateTimeFormat>();
108
+
109
+ function zonedFormatter(timeZone: string): Intl.DateTimeFormat {
110
+ const cached = ZONED_FORMATTER_CACHE.get(timeZone);
111
+ if (cached !== undefined) return cached;
112
+ let formatter: Intl.DateTimeFormat;
113
+ try {
114
+ formatter = new Intl.DateTimeFormat("en-US", {
115
+ timeZone,
116
+ hourCycle: "h23",
117
+ year: "numeric",
118
+ month: "2-digit",
119
+ day: "2-digit",
120
+ hour: "2-digit",
121
+ minute: "2-digit",
122
+ second: "2-digit",
123
+ weekday: "short",
124
+ });
125
+ } catch {
126
+ throw new RangeError(`"${timeZone}" is not a supported IANA timezone.`);
127
+ }
128
+ ZONED_FORMATTER_CACHE.set(timeZone, formatter);
129
+ return formatter;
130
+ }
131
+
132
+ function zonedCalendarParts(instantMs: number, timeZone: string): ZonedCalendarParts {
133
+ const parts = zonedFormatter(timeZone).formatToParts(new Date(instantMs));
134
+ const byType: Record<string, string> = {};
135
+ for (const part of parts) byType[part.type] = part.value;
136
+ const isoWeekdayIndex = ISO_WEEKDAY_INDEX[byType.weekday ?? ""];
137
+ if (
138
+ byType.year === undefined ||
139
+ byType.month === undefined ||
140
+ byType.day === undefined ||
141
+ byType.hour === undefined ||
142
+ byType.minute === undefined ||
143
+ byType.second === undefined ||
144
+ isoWeekdayIndex === undefined
145
+ ) {
146
+ throw new RangeError(
147
+ `Unable to resolve calendar parts for timezone "${timeZone}".`,
148
+ );
149
+ }
150
+ return {
151
+ year: Number(byType.year),
152
+ month: Number(byType.month) - 1,
153
+ day: Number(byType.day),
154
+ // A rare ICU quirk renders local midnight as hour "24" under h23; normalize it.
155
+ hour: byType.hour === "24" ? 0 : Number(byType.hour),
156
+ minute: Number(byType.minute),
157
+ second: Number(byType.second),
158
+ isoWeekdayIndex,
159
+ };
160
+ }
161
+
162
+ export interface ZonedCalendarInstant {
163
+ readonly year: number;
164
+ /** Zero-based, matching `Date.UTC`'s month argument; may overflow (e.g. -1 or 12) to roll into an adjacent year. */
165
+ readonly month: number;
166
+ readonly day: number;
167
+ readonly hour?: number;
168
+ readonly minute?: number;
169
+ readonly second?: number;
170
+ readonly millisecond?: number;
171
+ }
172
+
173
+ /**
174
+ * Resolves wall-clock calendar components in `timeZone` to the UTC instant
175
+ * (epoch ms) that displays as those components in that zone. Uses the
176
+ * standard fixed-point iteration: guess the instant assuming UTC, measure how
177
+ * far its zoned rendering drifts from the target, and correct. Two iterations
178
+ * converge for every ordinary offset change; the loop is capped so a
179
+ * pathological zone cannot spin forever.
180
+ */
181
+ export function resolveZonedInstant(
182
+ instant: ZonedCalendarInstant,
183
+ timeZone: string,
184
+ ): number {
185
+ const ms = instant.millisecond ?? 0;
186
+ const target = Date.UTC(
187
+ instant.year,
188
+ instant.month,
189
+ instant.day,
190
+ instant.hour ?? 0,
191
+ instant.minute ?? 0,
192
+ instant.second ?? 0,
193
+ ms,
194
+ );
195
+ if (timeZone === "UTC") return target;
196
+ let guess = target;
197
+ for (let iteration = 0; iteration < 4; iteration += 1) {
198
+ const zoned = zonedCalendarParts(guess, timeZone);
199
+ const zonedAsUtc = Date.UTC(
200
+ zoned.year,
201
+ zoned.month,
202
+ zoned.day,
203
+ zoned.hour,
204
+ zoned.minute,
205
+ zoned.second,
206
+ ms,
207
+ );
208
+ const offsetMs = zonedAsUtc - guess;
209
+ const next = target - offsetMs;
210
+ if (next === guess) return next;
211
+ guess = next;
212
+ }
213
+ return guess;
214
+ }
215
+
216
+ function zonedMonthBounds(
217
+ year: number,
218
+ startMonth: number,
219
+ months: number,
220
+ timeZone: string,
221
+ ): PeriodBounds {
222
+ return {
223
+ startMs: resolveZonedInstant({ year, month: startMonth, day: 1 }, timeZone),
224
+ endMs: resolveZonedInstant(
225
+ { year, month: startMonth + months, day: 1 },
226
+ timeZone,
227
+ ),
228
+ };
229
+ }
230
+
231
+ function getZonedPeriodBounds(
232
+ instantMs: number,
233
+ periodType: PeriodType,
234
+ timeZone: string,
235
+ ): PeriodBounds {
236
+ const local = zonedCalendarParts(instantMs, timeZone);
237
+ switch (periodType) {
238
+ case "day":
239
+ return {
240
+ startMs: resolveZonedInstant(
241
+ { year: local.year, month: local.month, day: local.day },
242
+ timeZone,
243
+ ),
244
+ endMs: resolveZonedInstant(
245
+ { year: local.year, month: local.month, day: local.day + 1 },
246
+ timeZone,
247
+ ),
248
+ };
249
+ case "week":
250
+ return {
251
+ startMs: resolveZonedInstant(
252
+ {
253
+ year: local.year,
254
+ month: local.month,
255
+ day: local.day - local.isoWeekdayIndex,
256
+ },
257
+ timeZone,
258
+ ),
259
+ endMs: resolveZonedInstant(
260
+ {
261
+ year: local.year,
262
+ month: local.month,
263
+ day: local.day - local.isoWeekdayIndex + 7,
264
+ },
265
+ timeZone,
266
+ ),
267
+ };
268
+ case "month":
269
+ return zonedMonthBounds(local.year, local.month, 1, timeZone);
270
+ case "quarter":
271
+ return zonedMonthBounds(
272
+ local.year,
273
+ Math.floor(local.month / 3) * 3,
274
+ 3,
275
+ timeZone,
276
+ );
277
+ case "half-year":
278
+ return zonedMonthBounds(
279
+ local.year,
280
+ Math.floor(local.month / 6) * 6,
281
+ 6,
282
+ timeZone,
283
+ );
284
+ case "year":
285
+ return zonedMonthBounds(local.year, 0, 12, timeZone);
286
+ default: {
287
+ const exhaustive: never = periodType;
288
+ throw new TypeError(`Unsupported period type: ${String(exhaustive)}`);
289
+ }
290
+ }
291
+ }
292
+
293
+ /**
294
+ * Assign an observation instant to a start-inclusive/end-exclusive calendar
295
+ * period. `timeZone` is an IANA zone identifier and defaults to `"UTC"`; the
296
+ * UTC path is untouched from the original implementation, so every caller
297
+ * that omits it keeps its exact prior behavior. Account-cost allocation must
298
+ * still split at UTC month boundaries regardless of this timezone -- see
299
+ * {@link splitAtUtcMonthBoundaries}.
300
+ */
301
+ export function getPeriodBounds(
302
+ instantMs: number,
303
+ periodType: PeriodType,
304
+ timeZone: string = "UTC",
305
+ ): PeriodBounds {
306
+ assertFiniteTimestamp(instantMs, "instantMs");
307
+ return timeZone === "UTC"
308
+ ? getUtcPeriodBounds(instantMs, periodType)
309
+ : getZonedPeriodBounds(instantMs, periodType, timeZone);
310
+ }
311
+
312
+ /**
313
+ * Splits `[startMs, endMs)` into contiguous, non-overlapping segments so that
314
+ * no segment crosses a UTC calendar-month boundary. Always uses UTC months,
315
+ * independent of any report display timezone: account-cost allocation must
316
+ * never let the display timezone change its denominator.
317
+ */
318
+ export function splitAtUtcMonthBoundaries(
319
+ startMs: number,
320
+ endMs: number,
321
+ ): readonly PeriodBounds[] {
322
+ assertFiniteTimestamp(startMs, "startMs");
323
+ assertFiniteTimestamp(endMs, "endMs");
324
+ if (endMs <= startMs) return [];
325
+ const segments: PeriodBounds[] = [];
326
+ let cursor = startMs;
327
+ let iterations = 0;
328
+ while (cursor < endMs) {
329
+ iterations += 1;
330
+ if (iterations > DEFAULT_PERIOD_ITERATION_CAP) {
331
+ throw new PeriodEnumerationLimitError("month", DEFAULT_PERIOD_ITERATION_CAP);
332
+ }
333
+ const month = getUtcPeriodBounds(cursor, "month");
334
+ const segmentEnd = Math.min(endMs, month.endMs);
335
+ segments.push({ startMs: cursor, endMs: segmentEnd });
336
+ cursor = segmentEnd;
337
+ }
338
+ return segments;
339
+ }
340
+
341
+ /**
342
+ * Enumerate completed periods from the period containing the first observation.
343
+ * The current partial period is excluded. Every stored-record-driven walk is
344
+ * capped so malformed or far-future timestamps become a reportable error.
345
+ */
346
+ export function enumerateCompletedPeriods(
347
+ periodType: PeriodType,
348
+ firstObservedAtMs: number,
349
+ nowMs: number,
350
+ options: { readonly maxIterations?: number; readonly timeZone?: string } = {},
351
+ ): readonly PeriodBounds[] {
352
+ assertFiniteTimestamp(firstObservedAtMs, "firstObservedAtMs");
353
+ assertFiniteTimestamp(nowMs, "nowMs");
354
+ const maxIterations =
355
+ options.maxIterations ?? DEFAULT_PERIOD_ITERATION_CAP;
356
+ if (!Number.isSafeInteger(maxIterations) || maxIterations < 1) {
357
+ throw new RangeError("maxIterations must be a positive safe integer.");
358
+ }
359
+ const timeZone = options.timeZone ?? "UTC";
360
+ if (nowMs <= firstObservedAtMs) return [];
361
+
362
+ const completed: PeriodBounds[] = [];
363
+ let bounds = getPeriodBounds(firstObservedAtMs, periodType, timeZone);
364
+ while (bounds.endMs <= nowMs) {
365
+ if (completed.length >= maxIterations) {
366
+ throw new PeriodEnumerationLimitError(periodType, maxIterations);
367
+ }
368
+ completed.push(bounds);
369
+ const next = getPeriodBounds(bounds.endMs, periodType, timeZone);
370
+ if (next.startMs !== bounds.endMs || next.endMs <= next.startMs) {
371
+ throw new RangeError(`Calendar ${periodType} enumeration did not advance.`);
372
+ }
373
+ bounds = next;
374
+ }
375
+ return completed;
376
+ }
@@ -0,0 +1,6 @@
1
+ declare module "pi-anthropic-oauth/src/index.ts" {
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+
4
+ const piAnthropicOAuth: (pi: ExtensionAPI) => void;
5
+ export default piAnthropicOAuth;
6
+ }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * Pre-flight account selection.
3
+ *
4
+ * Reactive routing only learns an account is unusable by spending a turn on it:
5
+ * dispatch, wait for the provider to refuse, classify, re-route, continue. Two
6
+ * common cases are knowable *before* dispatch — a credential that cannot be
7
+ * resolved at all, and one about to expire mid-turn — and this module turns
8
+ * both into a selection decision instead of a failure to recover from.
9
+ *
10
+ * Every path degrades to today's reactive behaviour. A probe that throws, an
11
+ * absent expiry, or no better alternative all yield "proceed as before"; none
12
+ * is allowed to block a turn. Being unable to improve the route is not an
13
+ * error, so a failed pre-flight is never fatal.
14
+ *
15
+ * No credential value is read here. The liveness probe returns only whether
16
+ * resolution succeeded, and expiry arrives as bounded metadata.
17
+ */
18
+
19
+ import type { AllowedFamily, MultiAccountConfig } from "./config.js";
20
+ import { isCredentialUsable } from "./credential-lifecycle.js";
21
+ import type { DiagnosticLog } from "./diagnostics.js";
22
+
23
+ /** A managed account considered for dispatch, with bounded liveness metadata. */
24
+ export interface PreflightCandidate {
25
+ readonly providerId: string;
26
+ readonly family: AllowedFamily;
27
+ /** Epoch ms of credential expiry, when the credential store reports one. */
28
+ readonly expiresAtMs?: number | undefined;
29
+ /** Whether a refresh token exists, i.e. whether expiry is recoverable. */
30
+ readonly hasRefreshToken: boolean;
31
+ /** False when runtime state has this account cooling down or invalidated. */
32
+ readonly available: boolean;
33
+ }
34
+
35
+ export type PreflightReason =
36
+ /** The active account resolved and is not near expiry. */
37
+ | "active-account-healthy"
38
+ /** Credential resolution failed; the account cannot serve this turn. */
39
+ | "active-account-unresolvable"
40
+ /** The active credential expires within the pre-emption window. */
41
+ | "active-account-near-expiry"
42
+ /** The active credential is expired and cannot be refreshed. */
43
+ | "active-account-unusable";
44
+
45
+ export interface PreflightDecision {
46
+ /** Provider to dispatch to. Equals the active account when nothing changed. */
47
+ readonly providerId: string;
48
+ /** Whether selection moved away from the active account. */
49
+ readonly switched: boolean;
50
+ readonly reason: PreflightReason;
51
+ /**
52
+ * True when the active account should be marked terminally unavailable —
53
+ * only for a proven resolution failure, never for a predicted expiry.
54
+ */
55
+ readonly invalidateActive: boolean;
56
+ }
57
+
58
+ /** Resolves an account's credential, reporting success without exposing it. */
59
+ export type LivenessProbe = (providerId: string) => boolean | Promise<boolean>;
60
+
61
+ /**
62
+ * Orders candidates by descending credential expiry, furthest-out first.
63
+ *
64
+ * With dozens of agents choosing independently from the same pool, any shared
65
+ * deterministic preference concentrates them on one account. Preferring the
66
+ * furthest-out expiry spreads load toward accounts with the most life left and
67
+ * keeps agents from converging on a credential that is about to lapse. Accounts
68
+ * with unknown expiry sort last: absent evidence of freshness, prefer an
69
+ * account whose freshness is known.
70
+ */
71
+ function byFurthestExpiry(
72
+ a: PreflightCandidate,
73
+ b: PreflightCandidate,
74
+ ): number {
75
+ // Deterministic ordering is retained intentionally; adding jitter here would
76
+ // make routing less predictable and is deferred with the spread-selection
77
+ // design issue rather than invented as part of preflight.
78
+ const aExpiry = a.expiresAtMs;
79
+ const bExpiry = b.expiresAtMs;
80
+ if (aExpiry === undefined && bExpiry === undefined) return 0;
81
+ if (aExpiry === undefined) return 1;
82
+ if (bExpiry === undefined) return -1;
83
+ return bExpiry - aExpiry;
84
+ }
85
+
86
+ /** Whether a candidate can serve a turn that starts now. */
87
+ function isEligible(
88
+ candidate: PreflightCandidate,
89
+ nowMs: number,
90
+ windowMs: number,
91
+ ): boolean {
92
+ if (!candidate.available) return false;
93
+ if (
94
+ !isCredentialUsable(
95
+ {
96
+ expiresAtMs: candidate.expiresAtMs,
97
+ hasRefreshToken: candidate.hasRefreshToken,
98
+ },
99
+ nowMs,
100
+ )
101
+ ) {
102
+ return false;
103
+ }
104
+ // A refreshable credential is never "too close to expiry": refresh renews it
105
+ // transparently, so only an unrefreshable one can lapse mid-turn.
106
+ if (candidate.hasRefreshToken) return true;
107
+ if (candidate.expiresAtMs === undefined) return true;
108
+ return candidate.expiresAtMs - nowMs > windowMs;
109
+ }
110
+
111
+ /**
112
+ * Chooses which account should serve the next turn.
113
+ *
114
+ * The active account is kept unless it is provably unable to serve — the
115
+ * conservative default, since switching costs continuity and every alternative
116
+ * is only knowable through the same imperfect metadata. A switch happens only
117
+ * when the active account fails its liveness probe, is already dead, or would
118
+ * expire mid-turn AND a healthy same-family alternative exists.
119
+ *
120
+ * Cross-family substitution is never made here: families differ in models and
121
+ * capabilities, so quietly moving a turn across them changes its meaning.
122
+ * Reactive routing already handles that under explicit operator configuration.
123
+ */
124
+ export async function selectPreflightAccount(options: {
125
+ readonly activeProviderId: string;
126
+ readonly candidates: readonly PreflightCandidate[];
127
+ readonly config: MultiAccountConfig;
128
+ readonly nowMs: number;
129
+ readonly probe?: LivenessProbe | undefined;
130
+ readonly diagnostics?: DiagnosticLog | undefined;
131
+ }): Promise<PreflightDecision> {
132
+ const { activeProviderId, config, nowMs, probe, diagnostics } = options;
133
+ const normalizedCandidates = options.candidates.map((candidate) => {
134
+ if (
135
+ candidate.expiresAtMs !== undefined &&
136
+ !Number.isFinite(candidate.expiresAtMs)
137
+ ) {
138
+ diagnostics?.record(
139
+ "warning",
140
+ "preflight.metadata",
141
+ "Credential expiry metadata was malformed; treating freshness as unknown.",
142
+ { providerId: candidate.providerId, field: "expiresAtMs" },
143
+ );
144
+ return { ...candidate, expiresAtMs: undefined };
145
+ }
146
+ return candidate;
147
+ });
148
+ const active = normalizedCandidates.find(
149
+ (candidate) => candidate.providerId === activeProviderId,
150
+ );
151
+ const unchanged = (reason: PreflightReason): PreflightDecision => ({
152
+ providerId: activeProviderId,
153
+ switched: false,
154
+ reason,
155
+ invalidateActive: false,
156
+ });
157
+
158
+ // An unknown active account is not ours to reason about; leave it alone.
159
+ if (active === undefined) return unchanged("active-account-healthy");
160
+
161
+ const resolved = await runProbe(probe, activeProviderId, diagnostics);
162
+ const usable = isCredentialUsable(
163
+ {
164
+ expiresAtMs: active.expiresAtMs,
165
+ hasRefreshToken: active.hasRefreshToken,
166
+ },
167
+ nowMs,
168
+ );
169
+ const nearExpiry =
170
+ config.preemptiveExpiryWindowMs > 0 &&
171
+ !isEligible(active, nowMs, config.preemptiveExpiryWindowMs);
172
+
173
+ let reason: PreflightReason = "active-account-healthy";
174
+ if (resolved === false) reason = "active-account-unresolvable";
175
+ else if (!usable) reason = "active-account-unusable";
176
+ else if (nearExpiry) reason = "active-account-near-expiry";
177
+ if (reason === "active-account-healthy") return unchanged(reason);
178
+
179
+ const alternative = normalizedCandidates
180
+ .filter(
181
+ (candidate) =>
182
+ candidate.providerId !== activeProviderId &&
183
+ candidate.family === active.family &&
184
+ isEligible(candidate, nowMs, config.preemptiveExpiryWindowMs),
185
+ )
186
+ .sort(byFurthestExpiry)[0];
187
+
188
+ // Only a proven resolution failure invalidates: a predicted expiry has not
189
+ // actually failed yet, and the credential may still refresh.
190
+ const invalidateActive = reason === "active-account-unresolvable";
191
+
192
+ // No alternative means proceeding with the active account and letting
193
+ // reactive routing handle any real failure — better than refusing the turn.
194
+ if (alternative === undefined) {
195
+ return {
196
+ providerId: activeProviderId,
197
+ switched: false,
198
+ reason,
199
+ invalidateActive,
200
+ };
201
+ }
202
+
203
+ return {
204
+ providerId: alternative.providerId,
205
+ switched: true,
206
+ reason,
207
+ invalidateActive,
208
+ };
209
+ }
210
+
211
+ /**
212
+ * Runs the liveness probe, treating a throw as "no information".
213
+ *
214
+ * A probe that fails tells us nothing about the credential, only about the
215
+ * probe. Reporting undefined keeps that distinct from a definite `false`, so a
216
+ * broken probe cannot invalidate a healthy account (REQ-EXPIRY-FAILSAFE-1).
217
+ */
218
+ async function runProbe(
219
+ probe: LivenessProbe | undefined,
220
+ providerId: string,
221
+ diagnostics?: DiagnosticLog,
222
+ ): Promise<boolean | undefined> {
223
+ if (probe === undefined) {
224
+ diagnostics?.record(
225
+ "warning",
226
+ "preflight.probe",
227
+ "Credential liveness probe is unavailable; continuing reactively.",
228
+ { providerId },
229
+ );
230
+ return undefined;
231
+ }
232
+ try {
233
+ const result = await probe(providerId);
234
+ if (typeof result !== "boolean") {
235
+ diagnostics?.record(
236
+ "warning",
237
+ "preflight.probe",
238
+ "Credential liveness probe returned malformed metadata; continuing reactively.",
239
+ { providerId, field: "liveness" },
240
+ );
241
+ return undefined;
242
+ }
243
+ return result;
244
+ } catch {
245
+ diagnostics?.record(
246
+ "warning",
247
+ "preflight.probe",
248
+ "Credential liveness probe failed; continuing reactively.",
249
+ { providerId },
250
+ );
251
+ return undefined;
252
+ }
253
+ }