@centerforagenticai/pi-multi-account 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +29 -0
  3. package/README.md +999 -0
  4. package/config/models/pi-multi-account.v1.json +32 -0
  5. package/config/subscription-plans.v1.json +122 -0
  6. package/package.json +76 -0
  7. package/packages/pi-anthropic-oauth/LICENSE +21 -0
  8. package/packages/pi-anthropic-oauth/package.json +54 -0
  9. package/packages/pi-anthropic-oauth/src/auth.ts +396 -0
  10. package/packages/pi-anthropic-oauth/src/context.ts +116 -0
  11. package/packages/pi-anthropic-oauth/src/convert.ts +303 -0
  12. package/packages/pi-anthropic-oauth/src/index.ts +37 -0
  13. package/packages/pi-anthropic-oauth/src/prompt.ts +137 -0
  14. package/packages/pi-anthropic-oauth/src/stream.ts +476 -0
  15. package/packages/pi-antigravity/LICENSE +21 -0
  16. package/packages/pi-antigravity/package.json +77 -0
  17. package/packages/pi-antigravity/src/auth/index.ts +14 -0
  18. package/packages/pi-antigravity/src/auth/oauth.ts +442 -0
  19. package/packages/pi-antigravity/src/client/client.ts +561 -0
  20. package/packages/pi-antigravity/src/client/index.ts +1 -0
  21. package/packages/pi-antigravity/src/context.ts +110 -0
  22. package/packages/pi-antigravity/src/diagnostics/diagnostics.ts +96 -0
  23. package/packages/pi-antigravity/src/diagnostics/index.ts +1 -0
  24. package/packages/pi-antigravity/src/image/image.ts +336 -0
  25. package/packages/pi-antigravity/src/image/index.ts +1 -0
  26. package/packages/pi-antigravity/src/index.ts +280 -0
  27. package/packages/pi-antigravity/src/models/discovery.ts +154 -0
  28. package/packages/pi-antigravity/src/models/grouping.ts +424 -0
  29. package/packages/pi-antigravity/src/models/index.ts +3 -0
  30. package/packages/pi-antigravity/src/models/models.ts +500 -0
  31. package/packages/pi-antigravity/src/stream/index.ts +1 -0
  32. package/packages/pi-antigravity/src/stream/stream.ts +1478 -0
  33. package/packages/pi-antigravity/src/types/enums.ts +42 -0
  34. package/packages/pi-antigravity/src/types/index.ts +2 -0
  35. package/packages/pi-antigravity/src/types/types.ts +292 -0
  36. package/packages/pi-antigravity/src/usage/index.ts +1 -0
  37. package/packages/pi-antigravity/src/usage/usage.ts +416 -0
  38. package/packages/pi-antigravity/src/utils/http.ts +91 -0
  39. package/packages/pi-antigravity/src/utils/index.ts +3 -0
  40. package/packages/pi-antigravity/src/utils/security.ts +73 -0
  41. package/packages/pi-antigravity/src/utils/util.ts +132 -0
  42. package/scripts/multi-account.mjs +44 -0
  43. package/src/account-labels.ts +223 -0
  44. package/src/account-plan-assignment.ts +340 -0
  45. package/src/account-rate-history.ts +372 -0
  46. package/src/anthropic-adaptive-stream.ts +531 -0
  47. package/src/anthropic-alias-stream.ts +140 -0
  48. package/src/anthropic-context-compat.ts +80 -0
  49. package/src/api-pricing.ts +579 -0
  50. package/src/bounded-file-lines.ts +97 -0
  51. package/src/catalog-rebinding.ts +177 -0
  52. package/src/catalog-registration-probe.ts +111 -0
  53. package/src/codex-adapter.ts +345 -0
  54. package/src/codex-model-defaults.ts +785 -0
  55. package/src/command-completions.ts +404 -0
  56. package/src/commands.ts +2000 -0
  57. package/src/compaction.ts +14 -0
  58. package/src/config.ts +1317 -0
  59. package/src/continuation.ts +569 -0
  60. package/src/cooldowns.ts +110 -0
  61. package/src/cost-digest-store.ts +332 -0
  62. package/src/cost-digest.ts +1044 -0
  63. package/src/cost-history.ts +251 -0
  64. package/src/cost-period-closer.ts +160 -0
  65. package/src/cost-report-json.ts +318 -0
  66. package/src/cost-report-reader.ts +368 -0
  67. package/src/cost-report-render.ts +207 -0
  68. package/src/cost-report.ts +1104 -0
  69. package/src/coverage-attestation.ts +397 -0
  70. package/src/credential-lifecycle.ts +169 -0
  71. package/src/credential-refresh.ts +248 -0
  72. package/src/declaration-notice-marker.ts +238 -0
  73. package/src/diagnostic-store.ts +276 -0
  74. package/src/diagnostics.ts +309 -0
  75. package/src/discovery.ts +471 -0
  76. package/src/duration.ts +13 -0
  77. package/src/error-classification.ts +256 -0
  78. package/src/fuzzy.ts +15 -0
  79. package/src/group-policy.ts +81 -0
  80. package/src/history-store.ts +897 -0
  81. package/src/index.ts +5572 -0
  82. package/src/lifecycle.ts +378 -0
  83. package/src/logical-dispatch.ts +279 -0
  84. package/src/logical-model-selector.ts +254 -0
  85. package/src/logical-model-switcher.ts +430 -0
  86. package/src/logical-provider-attribution.ts +544 -0
  87. package/src/logical-provider.ts +1237 -0
  88. package/src/logical-route-indicator.ts +215 -0
  89. package/src/machine-lease.ts +445 -0
  90. package/src/model-support.ts +66 -0
  91. package/src/models-declaration.ts +1091 -0
  92. package/src/openai-adapter.ts +117 -0
  93. package/src/openrouter-budget.ts +304 -0
  94. package/src/openrouter-fallback.ts +146 -0
  95. package/src/period-boundaries.ts +376 -0
  96. package/src/pi-anthropic-oauth.d.ts +6 -0
  97. package/src/preflight.ts +253 -0
  98. package/src/pricing-cache.ts +235 -0
  99. package/src/project-identity.ts +100 -0
  100. package/src/provider-registration.ts +942 -0
  101. package/src/rate-formula.ts +163 -0
  102. package/src/recovery-engine.ts +853 -0
  103. package/src/recovery-output.ts +837 -0
  104. package/src/recovery-plan.ts +239 -0
  105. package/src/report-range.ts +203 -0
  106. package/src/route-resolver.ts +789 -0
  107. package/src/routing-config-transaction.ts +232 -0
  108. package/src/routing.ts +1163 -0
  109. package/src/runtime-state.ts +630 -0
  110. package/src/session-account-groups.ts +284 -0
  111. package/src/session-restore.ts +287 -0
  112. package/src/shared-usage.ts +1392 -0
  113. package/src/standalone-cli.ts +720 -0
  114. package/src/status-view.ts +578 -0
  115. package/src/subscription-plan-catalog.ts +346 -0
  116. package/src/tier-model-resolver.ts +46 -0
  117. package/src/upstream-anthropic.ts +315 -0
  118. package/src/upstream-antigravity.ts +327 -0
  119. package/src/usage-fetch.ts +1634 -0
  120. package/src/usage.ts +1026 -0
  121. package/src/vendor.ts +87 -0
  122. package/src/warmer.ts +231 -0
  123. package/src/watchdog.ts +219 -0
  124. package/src/window-history.ts +270 -0
package/src/usage.ts ADDED
@@ -0,0 +1,1026 @@
1
+ import type { AllowedFamily } from "./config.js";
2
+ import { formatDuration } from "./duration.js";
3
+ import {
4
+ EXHAUSTION_HOLD_MS,
5
+ SHARED_USAGE_TTL_MS,
6
+ type SharedUsageStore,
7
+ type SharedUsageRateLimit,
8
+ type SharedUsageSnapshot,
9
+ } from "./shared-usage.js";
10
+ import { isCanonicalManagedProviderId } from "./runtime-state.js";
11
+ // The one exhaustion rule, imported rather than restated. A second definition
12
+ // here would be a second notion of exhaustion, free to drift from the one
13
+ // selection enforces -- the drift this defect class has already produced twice.
14
+ // `routing.ts` does not import this module, so this adds no cycle.
15
+ import { snapshotIndicatesExhaustion } from "./routing.js";
16
+ import {
17
+ iterateHistory,
18
+ type HistoryRecordEnvelope,
19
+ } from "./history-store.js";
20
+ import type { CostRecordPayload } from "./cost-history.js";
21
+ import {
22
+ readLatestWindowHistory,
23
+ readWindowHistory,
24
+ type WindowSample,
25
+ } from "./window-history.js";
26
+ import {
27
+ computeCoverageState,
28
+ type CoverageState,
29
+ } from "./coverage-attestation.js";
30
+
31
+ export interface TokenUsageObservation {
32
+ readonly inputTokens?: number;
33
+ readonly outputTokens?: number;
34
+ readonly cacheCreationInputTokens?: number;
35
+ readonly cacheReadInputTokens?: number;
36
+ }
37
+
38
+ export interface RateLimitObservation extends SharedUsageRateLimit {}
39
+
40
+ export interface UsageObservation {
41
+ readonly providerId: string;
42
+ readonly family: AllowedFamily;
43
+ readonly observedAtMs: number;
44
+ readonly tokens?: TokenUsageObservation;
45
+ readonly rateLimit?: RateLimitObservation;
46
+ }
47
+
48
+ export interface UsageSnapshot {
49
+ readonly providerId: string;
50
+ readonly family: AllowedFamily;
51
+ readonly snapshotAtMs: number;
52
+ /** Present only for a peer-only snapshot read from the machine store. */
53
+ readonly observationScope?: "session" | "fleet";
54
+ readonly observerId?: string;
55
+ readonly ageMs?: number;
56
+ /**
57
+ * True when this snapshot is older than the shared store's freshness window
58
+ * and was returned only so an operator surface can show a retained reading
59
+ * instead of claiming no data exists. Routing must not treat it as current.
60
+ */
61
+ readonly stale?: boolean;
62
+ /** The latest fresh peer observation, retained alongside this session's data. */
63
+ readonly fleetObserved?: FleetUsageSnapshot;
64
+ readonly inputTokens: number;
65
+ readonly outputTokens: number;
66
+ readonly cacheCreationInputTokens: number;
67
+ readonly cacheReadInputTokens: number;
68
+ readonly remainingRequests?: number;
69
+ readonly remainingTokens?: number;
70
+ readonly recoveryAtMs?: number;
71
+ /**
72
+ * Largest `remainingRequests` seen for this account. Providers report what is
73
+ * LEFT but never the ceiling, so the peak is the only available proxy for a
74
+ * budget denominator. Monotonic within a window; a reset that reports a higher
75
+ * remaining count raises it.
76
+ */
77
+ readonly observedPeakRequests?: number;
78
+ /** Largest `remainingTokens` seen for this account. See observedPeakRequests. */
79
+ readonly observedPeakTokens?: number;
80
+ readonly utilization?: number;
81
+ /** When utilization itself was observed; token-only updates do not advance it. */
82
+ readonly utilizationObservedAtMs?: number;
83
+ /** When the cohesive rate-limit observation was recorded. */
84
+ readonly quotaObservedAtMs?: number;
85
+ readonly utilizationSource?: RateLimitObservation["utilizationSource"];
86
+ }
87
+
88
+ export interface FleetUsageSnapshot {
89
+ readonly providerId: string;
90
+ readonly family: AllowedFamily;
91
+ readonly snapshotAtMs: number;
92
+ readonly ageMs: number;
93
+ readonly observerId: string;
94
+ readonly remainingRequests?: number;
95
+ readonly remainingTokens?: number;
96
+ readonly recoveryAtMs?: number;
97
+ readonly utilization?: number;
98
+ readonly utilizationSource?: RateLimitObservation["utilizationSource"];
99
+ }
100
+
101
+ export type UsageQuotaProjection =
102
+ | Readonly<{
103
+ status: "fresh";
104
+ utilization?: number;
105
+ remainingRequests?: number;
106
+ remainingTokens?: number;
107
+ recoveryAtMs?: number;
108
+ }>
109
+ | Readonly<{ status: "stale" }>
110
+ | Readonly<{ status: "missing" }>;
111
+
112
+ type CohesiveQuotaObservation = Readonly<{
113
+ observedAtMs: number;
114
+ remainingRequests?: number;
115
+ remainingTokens?: number;
116
+ recoveryAtMs?: number;
117
+ utilization?: number;
118
+ utilizationSource?: RateLimitObservation["utilizationSource"];
119
+ }>;
120
+
121
+ type QuotaSelection = Readonly<{
122
+ projection: UsageQuotaProjection;
123
+ candidate?: CohesiveQuotaObservation;
124
+ }>;
125
+
126
+ const TOKEN_FIELDS = [
127
+ "inputTokens",
128
+ "outputTokens",
129
+ "cacheCreationInputTokens",
130
+ "cacheReadInputTokens",
131
+ ] as const;
132
+
133
+ type TokenField = (typeof TOKEN_FIELDS)[number];
134
+
135
+ function count(value: number | undefined, name: string): number {
136
+ if (value === undefined) return 0;
137
+ if (!Number.isSafeInteger(value) || value < 0) {
138
+ throw new TypeError(`${name} must be a non-negative safe integer.`);
139
+ }
140
+ return value;
141
+ }
142
+
143
+ function optionalCount(
144
+ value: number | undefined,
145
+ name: string,
146
+ ): number | undefined {
147
+ if (value === undefined) return undefined;
148
+ return count(value, name);
149
+ }
150
+
151
+ function optionalTimestamp(value: number | undefined): number | undefined {
152
+ if (value === undefined) return undefined;
153
+ if (!Number.isFinite(value) || value < 0) {
154
+ throw new TypeError(
155
+ "recoveryAtMs must be a finite non-negative timestamp.",
156
+ );
157
+ }
158
+ return value;
159
+ }
160
+
161
+ /** Retains the largest observed value, ignoring absent readings. */
162
+ function highWaterMark(
163
+ next: number | undefined,
164
+ previous: number | undefined,
165
+ ): number | undefined {
166
+ if (next === undefined) return previous;
167
+ if (previous === undefined) return next;
168
+ return Math.max(next, previous);
169
+ }
170
+
171
+ function validDisplayUtilization(value: number | undefined): value is number {
172
+ return (
173
+ typeof value === "number" &&
174
+ Number.isFinite(value) &&
175
+ value >= 0 &&
176
+ value <= 1
177
+ );
178
+ }
179
+
180
+ function validDisplayCount(value: number | undefined): value is number {
181
+ return Number.isSafeInteger(value) && (value as number) >= 0;
182
+ }
183
+
184
+ function validDisplayRecovery(value: number | undefined): value is number {
185
+ return typeof value === "number" && Number.isFinite(value) && value >= 0;
186
+ }
187
+
188
+ function hasQuotaEvidence(candidate: CohesiveQuotaObservation): boolean {
189
+ return (
190
+ candidate.remainingRequests !== undefined ||
191
+ candidate.remainingTokens !== undefined ||
192
+ candidate.recoveryAtMs !== undefined ||
193
+ candidate.utilization !== undefined ||
194
+ candidate.utilizationSource !== undefined
195
+ );
196
+ }
197
+
198
+ function compareOptionalCount(
199
+ left: number | undefined,
200
+ right: number | undefined,
201
+ ): number {
202
+ const leftRanked = validDisplayCount(left);
203
+ const rightRanked = validDisplayCount(right);
204
+ if (leftRanked && !rightRanked) return -1;
205
+ if (!leftRanked && rightRanked) return 1;
206
+ if (!leftRanked || !rightRanked) return 0;
207
+ return left - right;
208
+ }
209
+
210
+ function selectDisplayCandidate(
211
+ candidates: readonly CohesiveQuotaObservation[],
212
+ ): CohesiveQuotaObservation | undefined {
213
+ const utilizationCandidates = candidates.filter((candidate) =>
214
+ validDisplayUtilization(candidate.utilization),
215
+ );
216
+ if (utilizationCandidates.length > 0) {
217
+ return utilizationCandidates.reduce((selected, candidate) => {
218
+ if ((candidate.utilization ?? 0) > (selected.utilization ?? 0)) return candidate;
219
+ if (
220
+ candidate.utilization === selected.utilization &&
221
+ candidate.observedAtMs > selected.observedAtMs
222
+ ) {
223
+ return candidate;
224
+ }
225
+ return selected;
226
+ });
227
+ }
228
+ const countCandidates = candidates.filter(
229
+ (candidate) =>
230
+ validDisplayCount(candidate.remainingRequests) ||
231
+ validDisplayCount(candidate.remainingTokens),
232
+ );
233
+ if (countCandidates.length === 0) return undefined;
234
+ return countCandidates.reduce((selected, candidate) => {
235
+ const requestOrder = compareOptionalCount(
236
+ candidate.remainingRequests,
237
+ selected.remainingRequests,
238
+ );
239
+ if (requestOrder < 0) return candidate;
240
+ if (requestOrder > 0) return selected;
241
+ const tokenOrder = compareOptionalCount(
242
+ candidate.remainingTokens,
243
+ selected.remainingTokens,
244
+ );
245
+ if (tokenOrder < 0) return candidate;
246
+ if (tokenOrder > 0) return selected;
247
+ return candidate.observedAtMs > selected.observedAtMs ? candidate : selected;
248
+ });
249
+ }
250
+
251
+ function projectionFromCandidate(
252
+ candidate: CohesiveQuotaObservation,
253
+ ): UsageQuotaProjection {
254
+ return {
255
+ status: "fresh",
256
+ ...(validDisplayUtilization(candidate.utilization)
257
+ ? { utilization: candidate.utilization }
258
+ : {}),
259
+ ...(validDisplayCount(candidate.remainingRequests)
260
+ ? { remainingRequests: candidate.remainingRequests }
261
+ : {}),
262
+ ...(validDisplayCount(candidate.remainingTokens)
263
+ ? { remainingTokens: candidate.remainingTokens }
264
+ : {}),
265
+ ...(validDisplayRecovery(candidate.recoveryAtMs)
266
+ ? { recoveryAtMs: candidate.recoveryAtMs }
267
+ : {}),
268
+ };
269
+ }
270
+
271
+ function selectQuotaProjection(
272
+ candidates: readonly CohesiveQuotaObservation[],
273
+ nowMs: number,
274
+ ): QuotaSelection {
275
+ const quotaCandidates = candidates.filter(hasQuotaEvidence);
276
+ if (quotaCandidates.length === 0) return { projection: { status: "missing" } };
277
+ const freshCandidates = quotaCandidates.filter(
278
+ (candidate) => nowMs - candidate.observedAtMs <= SHARED_USAGE_TTL_MS,
279
+ );
280
+ const selected = selectDisplayCandidate(freshCandidates);
281
+ if (selected !== undefined) {
282
+ return { projection: projectionFromCandidate(selected), candidate: selected };
283
+ }
284
+ const retained = selectDisplayCandidate(quotaCandidates) ??
285
+ quotaCandidates.reduce((newest, candidate) =>
286
+ candidate.observedAtMs > newest.observedAtMs ? candidate : newest,
287
+ );
288
+ return { projection: { status: "stale" }, candidate: retained };
289
+ }
290
+
291
+ function quotaFromShared(
292
+ snapshot: SharedUsageSnapshot | undefined,
293
+ ): CohesiveQuotaObservation | undefined {
294
+ if (snapshot === undefined) return undefined;
295
+ return {
296
+ observedAtMs: snapshot.snapshotAtMs,
297
+ ...(snapshot.remainingRequests === undefined
298
+ ? {}
299
+ : { remainingRequests: snapshot.remainingRequests }),
300
+ ...(snapshot.remainingTokens === undefined
301
+ ? {}
302
+ : { remainingTokens: snapshot.remainingTokens }),
303
+ ...(snapshot.recoveryAtMs === undefined
304
+ ? {}
305
+ : { recoveryAtMs: snapshot.recoveryAtMs }),
306
+ ...(snapshot.utilization === undefined
307
+ ? {}
308
+ : { utilization: snapshot.utilization }),
309
+ ...(snapshot.utilizationSource === undefined
310
+ ? {}
311
+ : { utilizationSource: snapshot.utilizationSource }),
312
+ };
313
+ }
314
+
315
+ function checkedTotal(
316
+ previous: number,
317
+ increment: number,
318
+ name: string,
319
+ ): number {
320
+ const total = previous + increment;
321
+ if (!Number.isSafeInteger(total) || total < 0) {
322
+ throw new RangeError(
323
+ `${name} cumulative total exceeds safe integer range.`,
324
+ );
325
+ }
326
+ return total;
327
+ }
328
+
329
+ /** Process-local aggregate ledger; no per-turn or content-bearing records. */
330
+ export class UsageLedger {
331
+ readonly #snapshots = new Map<string, UsageSnapshot>();
332
+ readonly #quotaObservations = new Map<string, CohesiveQuotaObservation>();
333
+ /**
334
+ * Accounts whose exhaustion holds this process ignores, and from when.
335
+ *
336
+ * The operator's `clear` must reach every automatic path, and a durable
337
+ * exhaustion hold is one. But a hold is machine-global: deleting the record
338
+ * would revoke it for every other session on the machine, and those peers
339
+ * may still be refusing work on that account. So the override is
340
+ * PROCESS-LOCAL -- this session stops honouring holds installed up to the
341
+ * moment of the clear, and peers keep theirs.
342
+ *
343
+ * Stored as the clear time rather than a boolean so a LATER failure still
344
+ * installs a hold this session honours. A boolean would make one `clear`
345
+ * exempt the account from exhaustion holds for the life of the process,
346
+ * which is a permanent effect from a transient instruction.
347
+ */
348
+ readonly #holdOverrides = new Map<string, number>();
349
+ readonly #sharedStore: SharedUsageStore | undefined;
350
+ readonly #now: () => number;
351
+
352
+ constructor(
353
+ options: {
354
+ readonly sharedStore?: SharedUsageStore;
355
+ readonly now?: () => number;
356
+ } = {},
357
+ ) {
358
+ this.#sharedStore = options.sharedStore;
359
+ this.#now = options.now ?? Date.now;
360
+ }
361
+
362
+ record(observation: UsageObservation): UsageSnapshot {
363
+ if (
364
+ !isCanonicalManagedProviderId(observation.providerId, observation.family)
365
+ ) {
366
+ throw new TypeError(
367
+ "usage providerId must be canonical for its managed family.",
368
+ );
369
+ }
370
+ if (
371
+ !Number.isFinite(observation.observedAtMs) ||
372
+ observation.observedAtMs < 0
373
+ ) {
374
+ throw new TypeError(
375
+ "observedAtMs must be a finite non-negative timestamp.",
376
+ );
377
+ }
378
+ const previous = this.#snapshots.get(observation.providerId);
379
+ if (previous && previous.family !== observation.family) {
380
+ throw new TypeError(
381
+ "provider family cannot change within a usage ledger.",
382
+ );
383
+ }
384
+
385
+ const increments = Object.fromEntries(
386
+ TOKEN_FIELDS.map((field) => [
387
+ field,
388
+ count(observation.tokens?.[field], field),
389
+ ]),
390
+ ) as Record<TokenField, number>;
391
+ const remainingRequests = optionalCount(
392
+ observation.rateLimit?.remainingRequests,
393
+ "remainingRequests",
394
+ );
395
+ const remainingTokens = optionalCount(
396
+ observation.rateLimit?.remainingTokens,
397
+ "remainingTokens",
398
+ );
399
+ const recoveryAtMs = optionalTimestamp(observation.rateLimit?.recoveryAtMs);
400
+ const previousQuota = this.#quotaObservations.get(observation.providerId);
401
+ const acceptsQuota =
402
+ observation.rateLimit !== undefined &&
403
+ (previousQuota === undefined || observation.observedAtMs >= previousQuota.observedAtMs);
404
+ const nextQuota: CohesiveQuotaObservation | undefined = acceptsQuota
405
+ ? Object.freeze({
406
+ observedAtMs: observation.observedAtMs,
407
+ ...(remainingRequests === undefined ? {} : { remainingRequests }),
408
+ ...(remainingTokens === undefined ? {} : { remainingTokens }),
409
+ ...(recoveryAtMs === undefined ? {} : { recoveryAtMs }),
410
+ ...(observation.rateLimit?.utilization === undefined
411
+ ? {}
412
+ : { utilization: observation.rateLimit.utilization }),
413
+ ...(observation.rateLimit?.utilizationSource === undefined
414
+ ? {}
415
+ : { utilizationSource: observation.rateLimit.utilizationSource }),
416
+ })
417
+ : previousQuota;
418
+ const latestRemainingRequests = nextQuota?.remainingRequests;
419
+ const latestRemainingTokens = nextQuota?.remainingTokens;
420
+ const latestRecoveryAtMs = nextQuota?.recoveryAtMs;
421
+ const latestUtilization = nextQuota?.utilization;
422
+ const latestUtilizationSource = nextQuota?.utilizationSource;
423
+ const latestUtilizationObservedAtMs =
424
+ latestUtilization === undefined ? undefined : nextQuota?.observedAtMs;
425
+ const snapshotAtMs = Math.max(
426
+ observation.observedAtMs,
427
+ previous?.snapshotAtMs ?? observation.observedAtMs,
428
+ );
429
+ const peakRequests = highWaterMark(
430
+ latestRemainingRequests,
431
+ previous?.observedPeakRequests,
432
+ );
433
+ const peakTokens = highWaterMark(
434
+ latestRemainingTokens,
435
+ previous?.observedPeakTokens,
436
+ );
437
+ const totals = Object.fromEntries(
438
+ TOKEN_FIELDS.map((field) => [
439
+ field,
440
+ checkedTotal(previous?.[field] ?? 0, increments[field], field),
441
+ ]),
442
+ ) as Record<TokenField, number>;
443
+
444
+ const snapshot: UsageSnapshot = Object.freeze({
445
+ providerId: observation.providerId,
446
+ family: observation.family,
447
+ snapshotAtMs,
448
+ inputTokens: totals.inputTokens,
449
+ outputTokens: totals.outputTokens,
450
+ cacheCreationInputTokens: totals.cacheCreationInputTokens,
451
+ cacheReadInputTokens: totals.cacheReadInputTokens,
452
+ ...(latestRemainingRequests === undefined
453
+ ? {}
454
+ : { remainingRequests: latestRemainingRequests }),
455
+ ...(latestRemainingTokens === undefined
456
+ ? {}
457
+ : { remainingTokens: latestRemainingTokens }),
458
+ ...(latestRecoveryAtMs === undefined
459
+ ? {}
460
+ : { recoveryAtMs: latestRecoveryAtMs }),
461
+ ...(peakRequests === undefined
462
+ ? {}
463
+ : { observedPeakRequests: peakRequests }),
464
+ ...(peakTokens === undefined ? {} : { observedPeakTokens: peakTokens }),
465
+ ...(latestUtilization === undefined
466
+ ? {}
467
+ : { utilization: latestUtilization }),
468
+ ...(latestUtilizationObservedAtMs === undefined
469
+ ? {}
470
+ : { utilizationObservedAtMs: latestUtilizationObservedAtMs }),
471
+ ...(nextQuota === undefined
472
+ ? {}
473
+ : { quotaObservedAtMs: nextQuota.observedAtMs }),
474
+ ...(latestUtilizationSource === undefined
475
+ ? {}
476
+ : { utilizationSource: latestUtilizationSource }),
477
+ });
478
+ this.#snapshots.set(observation.providerId, snapshot);
479
+ if (nextQuota !== undefined) {
480
+ this.#quotaObservations.set(observation.providerId, nextQuota);
481
+ }
482
+ try {
483
+ this.#sharedStore?.append({
484
+ providerId: observation.providerId,
485
+ family: observation.family,
486
+ observedAtMs: observation.observedAtMs,
487
+ observerId: this.#sharedStore.observerId,
488
+ ...(observation.tokens === undefined
489
+ ? {}
490
+ : { tokens: observation.tokens }),
491
+ ...(observation.rateLimit === undefined
492
+ ? {}
493
+ : { rateLimit: observation.rateLimit }),
494
+ });
495
+ } catch {
496
+ // Shared state is an optimization; a failed append never fails a turn.
497
+ }
498
+ return snapshot;
499
+ }
500
+
501
+ #quotaSelection(
502
+ providerId: string,
503
+ family: AllowedFamily,
504
+ nowMs: number,
505
+ ): QuotaSelection {
506
+ const aggregate = this.#sharedStore?.aggregate(providerId, family, nowMs);
507
+ const candidates = [
508
+ this.#quotaObservations.get(providerId),
509
+ quotaFromShared(aggregate?.session),
510
+ quotaFromShared(aggregate?.fleet),
511
+ quotaFromShared(aggregate?.stale),
512
+ ].filter((candidate): candidate is CohesiveQuotaObservation => candidate !== undefined);
513
+ return selectQuotaProjection(candidates, nowMs);
514
+ }
515
+
516
+ quotaDisplay(providerId: string, nowMs = this.#now()): UsageQuotaProjection {
517
+ if (!Number.isFinite(nowMs) || nowMs < 0) return { status: "missing" };
518
+ const family = this.#snapshots.get(providerId)?.family ?? this.#familyForProvider(providerId);
519
+ if (family === undefined) return { status: "missing" };
520
+ return this.#quotaSelection(providerId, family, nowMs).projection;
521
+ }
522
+
523
+ hasFreshHeaderObservation(
524
+ providerId: string,
525
+ family: AllowedFamily,
526
+ nowMs = this.#now(),
527
+ ): boolean {
528
+ if (!Number.isFinite(nowMs) || nowMs < 0) return false;
529
+ const aggregate = this.#sharedStore?.aggregate(providerId, family, nowMs);
530
+ const candidates = [
531
+ this.#quotaObservations.get(providerId),
532
+ quotaFromShared(aggregate?.session),
533
+ quotaFromShared(aggregate?.fleet),
534
+ ].filter((candidate): candidate is CohesiveQuotaObservation => candidate !== undefined);
535
+ return candidates.some(
536
+ (candidate) =>
537
+ candidate.utilizationSource === "rate-limit-header" &&
538
+ nowMs - candidate.observedAtMs <= SHARED_USAGE_TTL_MS,
539
+ );
540
+ }
541
+
542
+ get(providerId: string): UsageSnapshot | undefined {
543
+ const local = this.#snapshots.get(providerId);
544
+ const family = local?.family ?? this.#familyForProvider(providerId);
545
+ if (family === undefined) return local;
546
+ if (this.#sharedStore === undefined && !this.#quotaObservations.has(providerId)) {
547
+ return local;
548
+ }
549
+ const nowMs = this.#now();
550
+ const aggregate = this.#sharedStore?.aggregate(providerId, family, nowMs);
551
+ const sharedBase = aggregate?.session ?? aggregate?.fleet ?? aggregate?.stale;
552
+ if (local === undefined && sharedBase === undefined) return undefined;
553
+
554
+ const source: UsageSnapshot = local ?? {
555
+ providerId: sharedBase!.providerId,
556
+ family: sharedBase!.family,
557
+ snapshotAtMs: sharedBase!.snapshotAtMs,
558
+ observationScope: aggregate?.session === sharedBase ? "session" : "fleet",
559
+ observerId: sharedBase!.observerId,
560
+ ageMs: sharedBase!.ageMs,
561
+ inputTokens: sharedBase!.inputTokens,
562
+ outputTokens: sharedBase!.outputTokens,
563
+ cacheCreationInputTokens: sharedBase!.cacheCreationInputTokens,
564
+ cacheReadInputTokens: sharedBase!.cacheReadInputTokens,
565
+ };
566
+ const {
567
+ remainingRequests: _remainingRequests,
568
+ remainingTokens: _remainingTokens,
569
+ recoveryAtMs: _recoveryAtMs,
570
+ utilization: _utilization,
571
+ utilizationObservedAtMs: _utilizationObservedAtMs,
572
+ quotaObservedAtMs: _quotaObservedAtMs,
573
+ utilizationSource: _utilizationSource,
574
+ stale: _stale,
575
+ fleetObserved: _fleetObserved,
576
+ ...base
577
+ } = source;
578
+ const selection = this.#quotaSelection(providerId, family, nowMs);
579
+ const candidate = selection.candidate;
580
+ const fleet = aggregate?.fleet;
581
+ return {
582
+ ...base,
583
+ ...(selection.projection.status === "stale" ? { stale: true } : {}),
584
+ ...(candidate === undefined
585
+ ? {}
586
+ : {
587
+ quotaObservedAtMs: candidate.observedAtMs,
588
+ ...(validDisplayCount(candidate.remainingRequests)
589
+ ? { remainingRequests: candidate.remainingRequests }
590
+ : {}),
591
+ ...(validDisplayCount(candidate.remainingTokens)
592
+ ? { remainingTokens: candidate.remainingTokens }
593
+ : {}),
594
+ ...(validDisplayRecovery(candidate.recoveryAtMs)
595
+ ? { recoveryAtMs: candidate.recoveryAtMs }
596
+ : {}),
597
+ ...(validDisplayUtilization(candidate.utilization)
598
+ ? {
599
+ utilization: candidate.utilization,
600
+ utilizationObservedAtMs: candidate.observedAtMs,
601
+ }
602
+ : {}),
603
+ ...(candidate.utilizationSource === undefined
604
+ ? {}
605
+ : { utilizationSource: candidate.utilizationSource }),
606
+ }),
607
+ ...(local === undefined || fleet === undefined
608
+ ? {}
609
+ : {
610
+ fleetObserved: {
611
+ providerId: fleet.providerId,
612
+ family: fleet.family,
613
+ snapshotAtMs: fleet.snapshotAtMs,
614
+ ageMs: fleet.ageMs,
615
+ observerId: fleet.observerId,
616
+ ...(fleet.remainingRequests === undefined
617
+ ? {}
618
+ : { remainingRequests: fleet.remainingRequests }),
619
+ ...(fleet.remainingTokens === undefined
620
+ ? {}
621
+ : { remainingTokens: fleet.remainingTokens }),
622
+ ...(fleet.recoveryAtMs === undefined
623
+ ? {}
624
+ : { recoveryAtMs: fleet.recoveryAtMs }),
625
+ ...(fleet.utilization === undefined
626
+ ? {}
627
+ : { utilization: fleet.utilization }),
628
+ ...(fleet.utilizationSource === undefined
629
+ ? {}
630
+ : { utilizationSource: fleet.utilizationSource }),
631
+ },
632
+ }),
633
+ };
634
+ }
635
+
636
+ /**
637
+ * The usage reading ROUTING must consult before it spends a turn.
638
+ *
639
+ * Routing used to read a `fleetUsage` method that returned
640
+ * `aggregate().fleet`, which is peer-only by definition, so a session could
641
+ * not avoid the account it had itself just measured as spent: it polled the
642
+ * usage endpoint, recorded utilization 1 with a recovery three days out, and
643
+ * routed the next turn straight back to that account because its own reading
644
+ * was invisible to selection. Observed live on `openai-codex`, twice in
645
+ * ninety seconds, while the operator surface showed the truth at the same
646
+ * instant (#97). That method is gone; this is its replacement.
647
+ *
648
+ * NEITHER OBSERVER WINS BY POSITION. `session ?? fleet` would close that
649
+ * defect and open its mirror image: a healthy self-reading would hide a
650
+ * fresh peer reading that says the account is spent, which is a route the
651
+ * old peer-only read would have refused. `fleet ?? session` restores the
652
+ * original defect. There is no safe positional order, so the two fresh readings are
653
+ * arbitrated by WHAT THEY SAY: whichever reports exhaustion is believed.
654
+ *
655
+ * That follows the same instinct as {@link selectDisplayCandidate}, which
656
+ * prefers the most exhausted fresh reading rather than the closest one --
657
+ * added after a live account showed `0% used` from a local response while
658
+ * its fresh endpoint record said `0% left`. Routing deserves at least the
659
+ * caution the display already has.
660
+ *
661
+ * NOT the same rule, though, and the difference is deliberate.
662
+ * `selectDisplayCandidate` ranks by utilization and ignores count-only
663
+ * candidates whenever any utilization candidate exists, because it is
664
+ * choosing one number to show. This asks a yes-or-no question through
665
+ * `snapshotIndicatesExhaustion`, for which a remaining count of zero is
666
+ * exhaustion just as much as a utilization of 1. A reading the display would
667
+ * pass over can still decide a route.
668
+ *
669
+ * `durableExhaustion` outranks both: it is the only element that survives
670
+ * the TTL, and a fresher token-only record must not displace a known future
671
+ * recovery.
672
+ *
673
+ * `stale` is deliberately excluded. Beyond a durable recovery time a
674
+ * retained reading is presentational; letting an hour-old token count gate
675
+ * routing would be a new defect rather than a fix.
676
+ */
677
+ routingUsage(
678
+ providerId: string,
679
+ family: AllowedFamily,
680
+ nowMs = this.#now(),
681
+ ): SharedUsageSnapshot | undefined {
682
+ const aggregate = this.#sharedStore?.aggregate(providerId, family, nowMs);
683
+ if (aggregate?.durableExhaustion !== undefined) {
684
+ return aggregate.durableExhaustion;
685
+ }
686
+ const session = aggregate?.session;
687
+ const fleet = aggregate?.fleet;
688
+ if (session === undefined) return fleet;
689
+ if (fleet === undefined) return session;
690
+ // Exhaustion is the claim worth acting on, so a reading that reports it
691
+ // is believed over one that does not, whoever observed it. With both or
692
+ // neither exhausted, our own reading is the more direct evidence.
693
+ if (
694
+ snapshotIndicatesExhaustion(fleet, nowMs, "all-observed") &&
695
+ !snapshotIndicatesExhaustion(session, nowMs, "all-observed")
696
+ ) {
697
+ return fleet;
698
+ }
699
+ return session;
700
+ }
701
+
702
+ /**
703
+ * End of an active exhaustion hold for this account, or undefined.
704
+ *
705
+ * Deliberately separate from `routingUsage`. A hold is not a usage reading
706
+ * and must not compete with one for selection: it says "requests were being
707
+ * refused recently" while a snapshot says "this is what the window looked
708
+ * like". Routing takes the later of this and any authoritative recovery
709
+ * time, so returning it alongside the snapshot keeps both facts intact
710
+ * rather than having one silently win.
711
+ */
712
+ activeExhaustionHoldUntilMs(
713
+ providerId: string,
714
+ family: AllowedFamily,
715
+ nowMs = this.#now(),
716
+ ): number | undefined {
717
+ const holdUntilMs = this.#sharedStore?.activeExhaustionHoldUntilMs(
718
+ providerId,
719
+ family,
720
+ nowMs,
721
+ );
722
+ if (holdUntilMs === undefined) return undefined;
723
+ // An operator `clear` in THIS process stops us honouring holds that were
724
+ // already installed when it ran. A hold from a LATER failure is honoured
725
+ // again: the override is a point in time, not a permanent exemption.
726
+ // Peers keep honouring the record either way, because it is not deleted.
727
+ //
728
+ // `holdUntilMs - EXHAUSTION_HOLD_MS` recovers the failure time from the
729
+ // reported deadline without a second store read. The writer always sets
730
+ // exactly that span (asserted by the hold-duration test), and the
731
+ // store's own read bound refuses any record claiming more.
732
+ const clearedAtMs = this.#holdOverrides.get(providerId);
733
+ if (
734
+ clearedAtMs !== undefined &&
735
+ holdUntilMs - EXHAUSTION_HOLD_MS <= clearedAtMs
736
+ ) {
737
+ return undefined;
738
+ }
739
+ return holdUntilMs;
740
+ }
741
+
742
+ /**
743
+ * Installs a bounded hold after the provider refused a request for quota or
744
+ * rate-limit reasons.
745
+ *
746
+ * Called at the accepted terminal classification rather than at settlement,
747
+ * so the hold is visible to peers as soon as the failure is known.
748
+ *
749
+ * ONLY for a refusal that carries no recovery time. When the provider says
750
+ * when it will recover -- `retry-after`, or a reset timestamp -- that is
751
+ * authoritative and a 60-minute guess must not override it. A `retry-after: 5`
752
+ * response means five seconds, and holding that account for an hour would
753
+ * shrink the usable fleet on a refusal the provider already told us was
754
+ * momentary. The hold exists for the silent case, where nothing says when to
755
+ * come back.
756
+ *
757
+ * Fail-soft like every other shared-store write: a hold that cannot be
758
+ * persisted degrades to today's behaviour, and must never fail the turn that
759
+ * discovered the exhaustion.
760
+ */
761
+ recordExhaustionHold(
762
+ providerId: string,
763
+ family: AllowedFamily,
764
+ failedAtMs: number,
765
+ ): void {
766
+ try {
767
+ this.#sharedStore?.append({
768
+ recordType: "usage-exhaustion-hold",
769
+ tokens: null,
770
+ providerId,
771
+ family,
772
+ observedAtMs: failedAtMs,
773
+ observerId: this.#sharedStore.observerId,
774
+ failedAtMs,
775
+ holdUntilMs: failedAtMs + EXHAUSTION_HOLD_MS,
776
+ });
777
+ } catch {
778
+ // Shared state is an optimization; a failed append never fails a turn.
779
+ }
780
+ }
781
+
782
+ #familyForProvider(providerId: string): AllowedFamily | undefined {
783
+ if (isCanonicalManagedProviderId(providerId, "anthropic"))
784
+ return "anthropic";
785
+ if (isCanonicalManagedProviderId(providerId, "openai-codex"))
786
+ return "openai-codex";
787
+ // Without this, `get()` on an account this process never itself recorded
788
+ // (a peer process's antigravity slot, or a cold read before this process's
789
+ // first poll) fell through to `return local` at line ~545 while `local` was
790
+ // still undefined -- silently hiding real shared/fleet quota data rather
791
+ // than reading it, for this family alone.
792
+ if (isCanonicalManagedProviderId(providerId, "google-antigravity"))
793
+ return "google-antigravity";
794
+ return undefined;
795
+ }
796
+
797
+ snapshots(): readonly UsageSnapshot[] {
798
+ return [...this.#snapshots.values()];
799
+ }
800
+
801
+ /**
802
+ * Clears process-local usage state for an account, or every account.
803
+ *
804
+ * Also stops this process honouring exhaustion holds already installed for
805
+ * those accounts. The shared records are deliberately NOT deleted: they are
806
+ * machine-global, and peers may still be taking refusals on that account.
807
+ * An operator clearing state in one session should not silently revoke a
808
+ * hold another session is relying on.
809
+ */
810
+ clear(providerId?: string, nowMs = this.#now()): void {
811
+ if (providerId === undefined) {
812
+ this.#snapshots.clear();
813
+ this.#quotaObservations.clear();
814
+ for (const id of this.#sharedStore
815
+ ?.readExhaustionHolds()
816
+ .map((hold) => hold.providerId) ?? []) {
817
+ this.#holdOverrides.set(id, nowMs);
818
+ }
819
+ } else {
820
+ this.#snapshots.delete(providerId);
821
+ this.#quotaObservations.delete(providerId);
822
+ this.#holdOverrides.set(providerId, nowMs);
823
+ }
824
+ }
825
+ }
826
+
827
+ export function projectUsageQuota(
828
+ snapshot: UsageSnapshot | undefined,
829
+ nowMs: number,
830
+ ): UsageQuotaProjection {
831
+ if (snapshot === undefined || !Number.isFinite(nowMs) || nowMs < 0) {
832
+ return { status: "missing" };
833
+ }
834
+ const hasEvidence =
835
+ snapshot.remainingRequests !== undefined ||
836
+ snapshot.remainingTokens !== undefined ||
837
+ snapshot.recoveryAtMs !== undefined ||
838
+ snapshot.utilization !== undefined ||
839
+ snapshot.utilizationSource !== undefined;
840
+ if (!hasEvidence) return { status: "missing" };
841
+ const observedAtMs =
842
+ snapshot.quotaObservedAtMs ??
843
+ snapshot.utilizationObservedAtMs ??
844
+ snapshot.snapshotAtMs;
845
+ if (snapshot.stale === true || nowMs - observedAtMs > SHARED_USAGE_TTL_MS) {
846
+ return { status: "stale" };
847
+ }
848
+ const candidate: CohesiveQuotaObservation = {
849
+ observedAtMs,
850
+ ...(snapshot.remainingRequests === undefined
851
+ ? {}
852
+ : { remainingRequests: snapshot.remainingRequests }),
853
+ ...(snapshot.remainingTokens === undefined
854
+ ? {}
855
+ : { remainingTokens: snapshot.remainingTokens }),
856
+ ...(snapshot.recoveryAtMs === undefined
857
+ ? {}
858
+ : { recoveryAtMs: snapshot.recoveryAtMs }),
859
+ ...(snapshot.utilization === undefined
860
+ ? {}
861
+ : { utilization: snapshot.utilization }),
862
+ ...(snapshot.utilizationSource === undefined
863
+ ? {}
864
+ : { utilizationSource: snapshot.utilizationSource }),
865
+ };
866
+ return selectDisplayCandidate([candidate]) === undefined
867
+ ? { status: "stale" }
868
+ : projectionFromCandidate(candidate);
869
+ }
870
+
871
+ export function formatUsageSnapshot(
872
+ snapshot: UsageSnapshot,
873
+ nowMs: number,
874
+ ): string {
875
+ const parts = [
876
+ `${snapshot.providerId} — ${snapshot.inputTokens} input, ${snapshot.outputTokens} output`,
877
+ `${snapshot.cacheCreationInputTokens} cache-create, ${snapshot.cacheReadInputTokens} cache-read`,
878
+ ];
879
+ if (snapshot.remainingRequests !== undefined) {
880
+ parts.push(`${snapshot.remainingRequests} requests remaining`);
881
+ }
882
+ if (snapshot.remainingTokens !== undefined) {
883
+ parts.push(`${snapshot.remainingTokens} tokens remaining`);
884
+ }
885
+ if (snapshot.recoveryAtMs !== undefined) {
886
+ parts.push(`recovers in ${formatDuration(snapshot.recoveryAtMs - nowMs)}`);
887
+ }
888
+ const hasQuota =
889
+ snapshot.remainingRequests !== undefined ||
890
+ snapshot.remainingTokens !== undefined ||
891
+ snapshot.recoveryAtMs !== undefined ||
892
+ snapshot.utilization !== undefined ||
893
+ snapshot.utilizationSource !== undefined;
894
+ if (hasQuota) {
895
+ const observedAtMs =
896
+ snapshot.quotaObservedAtMs ??
897
+ snapshot.utilizationObservedAtMs ??
898
+ snapshot.snapshotAtMs;
899
+ const ageMs = Math.max(0, nowMs - observedAtMs);
900
+ const stale = snapshot.stale === true || ageMs > SHARED_USAGE_TTL_MS;
901
+ parts.push(
902
+ `quota observed ${formatDuration(ageMs)} ago${stale ? " (stale)" : ""}`,
903
+ );
904
+ }
905
+ return parts.join("; ");
906
+ }
907
+ /**
908
+ * Cost record with observation timestamp for aggregation.
909
+ * REQ-COST-INCOMPLETE: preserves status classification.
910
+ */
911
+ export interface CostRecordWithTimestamp extends CostRecordPayload {
912
+ readonly observedAtMs: number;
913
+ }
914
+
915
+ /**
916
+ * Read all cost records from the history store.
917
+ * REQ-COST-INCOMPLETE: separates complete, partial, and unpriced records.
918
+ */
919
+ function* iterateCostHistory(options?: {
920
+ readonly costHistoryPath?: string;
921
+ }): Generator<CostRecordWithTimestamp> {
922
+ for (const record of iterateHistory("cost-delta", options)) {
923
+ if (record.recordType !== "cost-delta") continue;
924
+ const envelope = record as HistoryRecordEnvelope;
925
+ if (typeof envelope.payload !== "object" || envelope.payload === null) {
926
+ continue;
927
+ }
928
+ const payload = envelope.payload as CostRecordPayload;
929
+ if (typeof payload.status !== "string") continue;
930
+ yield { ...payload, observedAtMs: envelope.observedAtMs };
931
+ }
932
+ }
933
+
934
+ export function readCostHistory(options?: {
935
+ readonly costHistoryPath?: string;
936
+ }): readonly CostRecordWithTimestamp[] {
937
+ return [...iterateCostHistory(options)];
938
+ }
939
+
940
+ /**
941
+ * Aggregate cost records for a period.
942
+ * REQ-COST-INCOMPLETE: only `complete` records contribute to the principal aggregate.
943
+ * REQ-COVERAGE-STATE: returns coverage state for the requested period.
944
+ */
945
+ export interface CostAggregate {
946
+ readonly coverageState: CoverageState;
947
+ readonly periodStartMs: number;
948
+ readonly periodEndMs: number;
949
+ /** Sum of `complete` records only. */
950
+ readonly completeTotal: number;
951
+ /** Count of `partial` records (excluded from total). */
952
+ readonly partialCount: number;
953
+ /** Count of `unpriced` records (excluded from total). */
954
+ readonly unpricedCount: number;
955
+ }
956
+
957
+ export function computeCostAggregate(
958
+ periodStartMs: number,
959
+ periodEndMs: number,
960
+ extensionDir?: string,
961
+ ): CostAggregate {
962
+ const coverageResult = computeCoverageState(
963
+ periodStartMs,
964
+ periodEndMs,
965
+ extensionDir,
966
+ );
967
+ const records = iterateCostHistory(
968
+ extensionDir
969
+ ? { costHistoryPath: `${extensionDir}/cost-history.ndjson` }
970
+ : undefined,
971
+ );
972
+
973
+ let completeTotal = 0;
974
+ let partialCount = 0;
975
+ let unpricedCount = 0;
976
+
977
+ for (const record of records) {
978
+ if (
979
+ record.observedAtMs < periodStartMs ||
980
+ record.observedAtMs > periodEndMs
981
+ )
982
+ continue;
983
+
984
+ if (record.status === "complete") {
985
+ completeTotal += record.cost.total;
986
+ } else if (record.status === "partial") {
987
+ partialCount++;
988
+ } else if (record.status === "unpriced") {
989
+ unpricedCount++;
990
+ }
991
+ }
992
+
993
+ return {
994
+ coverageState: coverageResult.state,
995
+ periodStartMs,
996
+ periodEndMs,
997
+ completeTotal,
998
+ partialCount,
999
+ unpricedCount,
1000
+ };
1001
+ }
1002
+
1003
+ /**
1004
+ * Expose window samples for status rendering.
1005
+ * REQ-WINDOW-MULTI: returns all windows with their identities.
1006
+ */
1007
+ export function getWindowSamples(
1008
+ extensionDir?: string,
1009
+ ): readonly WindowSample[] {
1010
+ return readWindowHistory(
1011
+ extensionDir
1012
+ ? { windowHistoryPath: `${extensionDir}/window-history.ndjson` }
1013
+ : undefined,
1014
+ );
1015
+ }
1016
+
1017
+ /** Fold retained status data to one latest sample per provider/window identity. */
1018
+ export function getLatestWindowSamples(
1019
+ extensionDir?: string,
1020
+ ): ReadonlyMap<string, readonly WindowSample[]> {
1021
+ return readLatestWindowHistory(
1022
+ extensionDir
1023
+ ? { windowHistoryPath: `${extensionDir}/window-history.ndjson` }
1024
+ : undefined,
1025
+ );
1026
+ }