@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,578 @@
1
+ import type { ManagedFamily } from "./config.js";
2
+ import type { ProviderType } from "./vendor.js";
3
+ import { formatDuration } from "./duration.js";
4
+ import type { UnsupportedModelPair } from "./model-support.js";
5
+ import { GAP_WINDOW_ID, type UsageFetchStatus } from "./usage-fetch.js";
6
+ import type { UsageQuotaProjection, UsageSnapshot } from "./usage.js";
7
+ import type { WindowSample } from "./window-history.js";
8
+ import {
9
+ computeCostAggregate,
10
+ getLatestWindowSamples,
11
+ projectUsageQuota,
12
+ } from "./usage.js";
13
+
14
+ /**
15
+ * Human-facing renderer for `/multi-account status`.
16
+ *
17
+ * The previous renderer emitted raw JSON, which forced an operator to read
18
+ * `coolingUntilMs` epochs and `"usage": undefined` holes. This module owns
19
+ * presentation only: it never reads credentials, never performs I/O, and never
20
+ * mutates routing state.
21
+ */
22
+
23
+ /** Display labels for the managed families, in stable display order. */
24
+ const FAMILY_LABELS: Readonly<Record<ManagedFamily, string>> = Object.freeze({
25
+ anthropic: "Anthropic (Claude)",
26
+ "openai-codex": "OpenAI Codex (GPT)",
27
+ "google-antigravity": "Google Antigravity",
28
+ openai: "OpenAI (API)",
29
+ });
30
+
31
+ const FAMILY_ORDER: readonly ManagedFamily[] = Object.freeze([
32
+ "anthropic",
33
+ "openai-codex",
34
+ "google-antigravity",
35
+ "openai",
36
+ ]);
37
+
38
+ /** Human-facing labels for the live provider type of a managed account. */
39
+ const PROVIDER_TYPE_LABELS: Readonly<Record<ProviderType, string>> =
40
+ Object.freeze({
41
+ subscription: "subscription",
42
+ "owning-vendor-api": "API key",
43
+ openrouter: "OpenRouter",
44
+ });
45
+
46
+ /** Renders a bounded provider-type suffix, or an empty string when unknown. */
47
+ function providerTypeSuffix(providerType: ProviderType | undefined): string {
48
+ return providerType === undefined
49
+ ? ""
50
+ : ` — ${PROVIDER_TYPE_LABELS[providerType]}`;
51
+ }
52
+
53
+ /** Fraction of a limit at or below which an account is "approaching" it. */
54
+ const LOW_HEADROOM_FRACTION = 0.15;
55
+
56
+ /** A single account's presentation-ready state. */
57
+ export interface AccountStatusView {
58
+ readonly providerId: string;
59
+ readonly family: ManagedFamily;
60
+ /**
61
+ * How this account reaches its models, derived live from the discovered
62
+ * credential type. Presentation-only; never a routing input in this child.
63
+ */
64
+ readonly providerType?: ProviderType;
65
+ readonly active: boolean;
66
+ readonly disabled: boolean;
67
+ readonly unavailable: boolean;
68
+ readonly coolingUntilMs?: number;
69
+ /** Recent repeated 429s prove the provider's usage reading is not actionable. */
70
+ readonly usageUntrusted?: boolean;
71
+ readonly usage?: UsageSnapshot;
72
+ /** Bounded state of detached authoritative usage fetching for this account. */
73
+ readonly usageFetch?: UsageFetchStatus;
74
+ /** Model id in use, shown only for the active account. */
75
+ readonly activeModelId?: string;
76
+ /** True when every model advertised by this account was rejected this session. */
77
+ readonly allModelsUnsupported?: boolean;
78
+ /** Operator-facing account label, when one is configured or derived. */
79
+ readonly label?: string;
80
+ /**
81
+ * Bounded credential expiry. Reported even for accounts idle this session,
82
+ * because Pi's lazy refresh means an unused credential silently ages out.
83
+ */
84
+ readonly expiresAtMs?: number;
85
+ /**
86
+ * Derived, non-reversible identity of the underlying account. Two slots
87
+ * sharing one means the operator has the same account logged in twice.
88
+ *
89
+ * Never a credential value: derived at the auth boundary and only the derived
90
+ * string travels. Undefined for opaque-token families, which is why an
91
+ * Anthropic duplicate cannot be detected this way and a Codex one can.
92
+ */
93
+ readonly accountFingerprint?: string;
94
+ }
95
+
96
+ export interface StatusViewInput {
97
+ readonly accounts: readonly AccountStatusView[];
98
+ readonly nowMs: number;
99
+ readonly declarationNotice?: {
100
+ readonly condition: "stale";
101
+ readonly remedy: string;
102
+ readonly status: "mismatched" | "unreadable";
103
+ };
104
+ /** True when the caller scoped output to a single account. */
105
+ readonly scoped?: boolean;
106
+ /** Session-local account/model pairs that the provider reported unavailable. */
107
+ readonly unsupportedModels?: readonly UnsupportedModelPair[];
108
+ }
109
+
110
+ /**
111
+ * Groups slots whose derived account identity matches.
112
+ *
113
+ * Works on the DERIVED fingerprint the status input already carries, never on a
114
+ * credential value. A slot with no fingerprint is never grouped, so an
115
+ * opaque-token family yields no false positives rather than guessing.
116
+ */
117
+ function duplicateSlotGroups(
118
+ accounts: readonly AccountStatusView[],
119
+ ): ReadonlyArray<readonly string[]> {
120
+ const byFingerprint = new Map<string, string[]>();
121
+ for (const account of accounts) {
122
+ const fingerprint = account.accountFingerprint;
123
+ if (fingerprint === undefined || fingerprint.length === 0) continue;
124
+ const group = byFingerprint.get(fingerprint);
125
+ if (group) group.push(account.providerId);
126
+ else byFingerprint.set(fingerprint, [account.providerId]);
127
+ }
128
+ return [...byFingerprint.values()].filter((group) => group.length > 1);
129
+ }
130
+
131
+ /** Coarse health, ordered most-severe first. */
132
+ export type AccountHealth =
133
+ | "unavailable"
134
+ | "disabled"
135
+ | "cooling"
136
+ | "rate-limited"
137
+ | "exhausted"
138
+ | "low-headroom"
139
+ | "ready";
140
+
141
+ function formatCount(value: number): string {
142
+ if (value < 1_000) return String(value);
143
+ if (value < 1_000_000) {
144
+ const thousands = value / 1_000;
145
+ return `${thousands >= 100 ? Math.round(thousands) : thousands.toFixed(1)}k`;
146
+ }
147
+ const millions = value / 1_000_000;
148
+ return `${millions >= 100 ? Math.round(millions) : millions.toFixed(1)}M`;
149
+ }
150
+
151
+ /**
152
+ * True when a server-reported remaining budget has fallen into the bottom
153
+ * {@link LOW_HEADROOM_FRACTION} of the largest value observed this session.
154
+ *
155
+ * Providers report REMAINING counts but not the ceiling, so "approaching the
156
+ * limit" is inferred against the peak seen for that account. This is an
157
+ * observation-derived hint, never an authoritative quota reading.
158
+ */
159
+ function isLowHeadroom(
160
+ usage: UsageSnapshot | undefined,
161
+ quota: UsageQuotaProjection,
162
+ ): boolean {
163
+ if (!usage || quota.status !== "fresh") return false;
164
+ const { remainingRequests, remainingTokens } = quota;
165
+ const { observedPeakRequests, observedPeakTokens } = usage;
166
+ const lowRequests =
167
+ remainingRequests !== undefined &&
168
+ observedPeakRequests !== undefined &&
169
+ observedPeakRequests > 0 &&
170
+ remainingRequests / observedPeakRequests <= LOW_HEADROOM_FRACTION;
171
+ const lowTokens =
172
+ remainingTokens !== undefined &&
173
+ observedPeakTokens !== undefined &&
174
+ observedPeakTokens > 0 &&
175
+ remainingTokens / observedPeakTokens <= LOW_HEADROOM_FRACTION;
176
+ return lowRequests || lowTokens;
177
+ }
178
+
179
+ export function accountHealth(
180
+ account: AccountStatusView,
181
+ nowMs: number,
182
+ ): AccountHealth {
183
+ if (account.unavailable) return "unavailable";
184
+ if (account.disabled) return "disabled";
185
+ if (account.coolingUntilMs !== undefined && account.coolingUntilMs > nowMs) {
186
+ return "cooling";
187
+ }
188
+ if (account.usageUntrusted) return "rate-limited";
189
+ const quota = projectUsageQuota(account.usage, nowMs);
190
+ if (
191
+ quota.status === "fresh" &&
192
+ (quota.remainingRequests === 0 ||
193
+ quota.remainingTokens === 0 ||
194
+ (quota.utilization !== undefined && quota.utilization >= 1))
195
+ ) {
196
+ return "exhausted";
197
+ }
198
+ if (isLowHeadroom(account.usage, quota)) return "low-headroom";
199
+ return "ready";
200
+ }
201
+
202
+ function healthLabel(
203
+ health: AccountHealth,
204
+ account: AccountStatusView,
205
+ nowMs: number,
206
+ ): string {
207
+ switch (health) {
208
+ case "unavailable":
209
+ return "unavailable (re-auth needed)";
210
+ case "disabled":
211
+ return "disabled by operator";
212
+ case "cooling":
213
+ return `cooling down, ~${formatDuration((account.coolingUntilMs ?? nowMs) - nowMs)} left`;
214
+ case "rate-limited":
215
+ return "rate limited; recent 429s override the usage reading";
216
+ case "exhausted":
217
+ return "exhausted";
218
+ case "low-headroom":
219
+ return "ready, but approaching its limit";
220
+ case "ready":
221
+ return "ready";
222
+ }
223
+ }
224
+
225
+ /**
226
+ * Describes credential freshness. Unlike limit data this is available for every
227
+ * account, used or not, so an idle account still reports something actionable.
228
+ */
229
+ function credentialLine(
230
+ account: AccountStatusView,
231
+ nowMs: number,
232
+ ): string | undefined {
233
+ if (account.expiresAtMs === undefined) return undefined;
234
+ const remaining = account.expiresAtMs - nowMs;
235
+ if (remaining <= 0) return "credential EXPIRED — sign in again";
236
+ return `credential expires in ${formatDuration(remaining)}`;
237
+ }
238
+
239
+ /**
240
+ * Renders what the provider actually told us about remaining budget. Returns
241
+ * undefined when nothing is known about this account's budget.
242
+ *
243
+ * `utilization` leads because it is the only figure that answers "can I use
244
+ * this account" directly. Providers report what is LEFT rather than a ceiling,
245
+ * so remaining counts appear only when no percentage is available — a bare
246
+ * "400k tokens left" means little without the budget it came from.
247
+ */
248
+ function limitLine(
249
+ account: AccountStatusView,
250
+ nowMs: number,
251
+ ): string | undefined {
252
+ const usage = projectUsageQuota(account.usage, nowMs);
253
+ if (usage.status !== "fresh") return undefined;
254
+ const parts: string[] = [];
255
+ if (usage.utilization !== undefined) {
256
+ parts.push(`${Math.round(usage.utilization * 100)}% used`);
257
+ } else {
258
+ if (usage.remainingRequests !== undefined) {
259
+ parts.push(`${formatCount(usage.remainingRequests)} requests left`);
260
+ }
261
+ if (usage.remainingTokens !== undefined) {
262
+ parts.push(`${formatCount(usage.remainingTokens)} tokens left`);
263
+ }
264
+ }
265
+ if (
266
+ usage.recoveryAtMs !== undefined &&
267
+ Number.isFinite(usage.recoveryAtMs) &&
268
+ usage.recoveryAtMs > nowMs
269
+ ) {
270
+ parts.push(`resets in ${formatDuration(usage.recoveryAtMs - nowMs)}`);
271
+ }
272
+ return parts.length > 0 ? parts.join(" · ") : undefined;
273
+ }
274
+
275
+ /** Age beyond which a reading is worth flagging as possibly out of date. */
276
+ const STALE_READING_MS = 10 * 60 * 1000;
277
+
278
+ /**
279
+ * A single caveat line, emitted ONLY when something is genuinely off.
280
+ *
281
+ * Routine provenance — which endpoint answered, how fresh a current reading is,
282
+ * that fetching is working — is noise that crowds out the figure an operator
283
+ * came to read. It earns a line only when the number should not be taken at
284
+ * face value: stale, fetching stopped, or observed by a peer rather than here.
285
+ */
286
+ function usageCaveatLine(
287
+ account: AccountStatusView,
288
+ nowMs: number,
289
+ ): string | undefined {
290
+ const fetch = account.usageFetch;
291
+ if (fetch && !fetch.enabled) {
292
+ return "usage fetching disabled by config — figures may be incomplete";
293
+ }
294
+ if (fetch?.disabled) {
295
+ return `usage fetching stopped (${fetch.disabledReason ?? "repeated failures"}) — showing retained data`;
296
+ }
297
+ const usage = account.usage;
298
+ if (!usage) return undefined;
299
+ const quota = projectUsageQuota(usage, nowMs);
300
+ if (quota.status === "stale") {
301
+ return "usage observation is stale — retained quota is not used for health";
302
+ }
303
+ const ageMs =
304
+ usage.observationScope === "fleet"
305
+ ? (usage.ageMs ?? 0)
306
+ : Math.max(0, nowMs - usage.snapshotAtMs);
307
+ if (ageMs > STALE_READING_MS) {
308
+ return `reading is ${ageLabel(ageMs)} — may be out of date`;
309
+ }
310
+ if (usage.observationScope === "fleet") {
311
+ return `observed by ${usage.observerId ?? "another agent"} (${ageLabel(ageMs)})`;
312
+ }
313
+ return undefined;
314
+ }
315
+
316
+ function ageLabel(ageMs: number): string {
317
+ return `${formatDuration(ageMs)} ago`;
318
+ }
319
+
320
+ function usedLine(usage: UsageSnapshot | undefined): string | undefined {
321
+ if (!usage) return undefined;
322
+ const total = usage.inputTokens + usage.outputTokens;
323
+ if (total === 0) return undefined;
324
+ if (usage.observationScope === "fleet") {
325
+ return `fleet-observed usage: ${formatCount(total)} tokens (${formatCount(usage.inputTokens)} in / ${formatCount(usage.outputTokens)} out)`;
326
+ }
327
+ return `used this session: ${formatCount(total)} tokens (${formatCount(usage.inputTokens)} in / ${formatCount(usage.outputTokens)} out)`;
328
+ }
329
+
330
+ /**
331
+ * REQ-WINDOW-RENDER: Render one window as percentage remaining with reset time.
332
+ * Shape: "5h 73% left / 2h14m"
333
+ * REQ-NO-IDENTIFYING-DATA: windowId is included but must not be a human-identifying label.
334
+ */
335
+ function renderWindow(sample: WindowSample, nowMs: number): string {
336
+ const parts: string[] = [];
337
+
338
+ // Window identifier (safe - this is a provider-supplied window ID, not user data)
339
+ parts.push(`[${sample.windowId}]`);
340
+
341
+ // Percentage remaining
342
+ if (sample.remainingFraction !== undefined) {
343
+ const percentRemaining = Math.round(sample.remainingFraction * 100);
344
+ parts.push(`${percentRemaining}% left`);
345
+ }
346
+
347
+ // Reset time
348
+ if (sample.resetAtMs !== undefined && sample.resetAtMs > nowMs) {
349
+ parts.push(`resets in ${formatDuration(sample.resetAtMs - nowMs)}`);
350
+ } else if (sample.resetAtMs !== undefined && sample.resetAtMs <= nowMs) {
351
+ parts.push("reset overdue");
352
+ }
353
+
354
+ return parts.join(" · ");
355
+ }
356
+
357
+ /**
358
+ * REQ-WINDOW-RENDER: Render all windows for an account, one line per window.
359
+ * Returns undefined if no window data is available.
360
+ */
361
+ function windowsLine(
362
+ providerId: string,
363
+ nowMs: number,
364
+ latestSamples: ReadonlyMap<string, readonly WindowSample[]>,
365
+ ): string | undefined {
366
+ const accountSamples = latestSamples.get(providerId) ?? [];
367
+ if (accountSamples.length === 0) return undefined;
368
+
369
+ const lines: string[] = [];
370
+ for (const sample of accountSamples) {
371
+ const { windowId } = sample;
372
+ // `__gap__` is an internal sentinel (usage-fetch.ts:503) marking a stretch
373
+ // of time the fetcher could not cover. It is bookkeeping, not a provider
374
+ // window, and rendering it verbatim put a double-underscore token in front
375
+ // of the operator that reads like broken markup and names nothing they can
376
+ // act on. Suppressed here rather than at the source, because the record is
377
+ // load-bearing for coverage accounting.
378
+ if (windowId === GAP_WINDOW_ID) continue;
379
+ if (sample.usable) {
380
+ lines.push(renderWindow(sample, nowMs));
381
+ } else {
382
+ lines.push(`[${windowId}] ${sample.unusableReason ?? "unusable"}`);
383
+ }
384
+ }
385
+
386
+ return lines.length > 0 ? `windows: ${lines.join(", ")}` : undefined;
387
+ }
388
+
389
+ /**
390
+ * REQ-COVERAGE-STATE: Render cost aggregate with coverage label.
391
+ * REQ-COST-INCOMPLETE: NO unqualified total when coverage is not complete.
392
+ */
393
+ function costAggregateLine(
394
+ periodStartMs: number,
395
+ periodEndMs: number,
396
+ ): string | undefined {
397
+ const aggregate = computeCostAggregate(periodStartMs, periodEndMs);
398
+
399
+ const parts: string[] = [];
400
+
401
+ // Coverage state always leads
402
+ parts.push(`coverage: ${aggregate.coverageState}`);
403
+
404
+ // REQ-COVERAGE-STATE: NO unqualified total unless coverage is complete
405
+ if (aggregate.coverageState === "complete") {
406
+ parts.push(`total: $${aggregate.completeTotal.toFixed(4)}`);
407
+ } else {
408
+ // Partial/unknown: show what we have with explicit qualification
409
+ parts.push(`complete records: $${aggregate.completeTotal.toFixed(4)}`);
410
+ }
411
+
412
+ // REQ-COST-INCOMPLETE: surface partial and unpriced counts separately
413
+ if (aggregate.partialCount > 0) {
414
+ parts.push(`partial: ${aggregate.partialCount}`);
415
+ }
416
+ if (aggregate.unpricedCount > 0) {
417
+ parts.push(`unpriced: ${aggregate.unpricedCount}`);
418
+ }
419
+
420
+ return parts.join(" · ");
421
+ }
422
+
423
+ function renderAccount(
424
+ account: AccountStatusView,
425
+ nowMs: number,
426
+ indent: string,
427
+ latestWindowSamples: ReadonlyMap<string, readonly WindowSample[]>,
428
+ ): string[] {
429
+ const health = accountHealth(account, nowMs);
430
+ const marker =
431
+ health === "ready" ? "•" : health === "low-headroom" ? "!" : "×";
432
+ const named = account.label
433
+ ? `${account.providerId} (${account.label})`
434
+ : account.providerId;
435
+ const typeTag =
436
+ account.providerType === undefined
437
+ ? ""
438
+ : ` [${PROVIDER_TYPE_LABELS[account.providerType]}]`;
439
+ const lines = [
440
+ `${indent}${marker} ${named}${typeTag} — ${healthLabel(health, account, nowMs)}`,
441
+ ];
442
+ const credential = credentialLine(account, nowMs);
443
+ if (credential) lines.push(`${indent} ${credential}`);
444
+ const limits = limitLine(account, nowMs);
445
+ if (limits) lines.push(`${indent} ${limits}`);
446
+ const windows = windowsLine(account.providerId, nowMs, latestWindowSamples);
447
+ if (windows) lines.push(`${indent} ${windows}`);
448
+ const used = usedLine(account.usage);
449
+ if (used) lines.push(`${indent} ${used}`);
450
+ const caveat = usageCaveatLine(account, nowMs);
451
+ if (caveat) lines.push(`${indent} ${caveat}`);
452
+ if (account.allModelsUnsupported) {
453
+ lines.push(
454
+ `${indent} no advertised models available — every model was rejected this session`,
455
+ );
456
+ }
457
+ if (!account.usage) {
458
+ lines.push(`${indent} no usage data yet for this account`);
459
+ }
460
+ return lines;
461
+ }
462
+
463
+ /**
464
+ * Renders the operator-facing status report: the active account first with its
465
+ * limit posture, then every other account grouped by family.
466
+ */
467
+ export function renderStatus(input: StatusViewInput): string {
468
+ const { accounts, nowMs } = input;
469
+ const declarationBanner =
470
+ input.declarationNotice === undefined
471
+ ? undefined
472
+ : input.declarationNotice.status === "mismatched"
473
+ ? `Managed model declaration is stale; logical routing is using the live catalog. Run ${input.declarationNotice.remedy} to re-sync it.`
474
+ : `LOGICAL ROUTING OFF: The managed model declaration is unreadable. Run ${input.declarationNotice.remedy}.`;
475
+ if (accounts.length === 0) {
476
+ const message =
477
+ "No managed accounts are available. Run /multi-account rediscover.";
478
+ return declarationBanner === undefined
479
+ ? message
480
+ : `${declarationBanner}\n${message}`;
481
+ }
482
+
483
+ const active = accounts.find((account) => account.active);
484
+ const others = accounts.filter((account) => !account.active);
485
+ const sections: string[] =
486
+ declarationBanner === undefined ? [] : [declarationBanner];
487
+
488
+ if (active) {
489
+ const health = accountHealth(active, nowMs);
490
+ const header = [
491
+ "ACTIVE ACCOUNT",
492
+ ` ${active.providerId} [${FAMILY_LABELS[active.family]}${providerTypeSuffix(active.providerType)}]`,
493
+ ...(active.label ? [` account: ${active.label}`] : []),
494
+ ...(active.activeModelId ? [` model: ${active.activeModelId}`] : []),
495
+ ` status: ${healthLabel(health, active, nowMs)}`,
496
+ ];
497
+ const credential = credentialLine(active, nowMs);
498
+ if (credential) header.push(` ${credential}`);
499
+ const limits = limitLine(active, nowMs);
500
+ header.push(limits ? ` limits: ${limits}` : " limits: no usage data yet");
501
+ const used = usedLine(active.usage);
502
+ if (used) header.push(` ${used}`);
503
+ const caveat = usageCaveatLine(active, nowMs);
504
+ if (caveat) header.push(` ${caveat}`);
505
+ if (active.allModelsUnsupported) {
506
+ header.push(
507
+ " no advertised models available — every model was rejected this session",
508
+ );
509
+ }
510
+ sections.push(header.join("\n"));
511
+ } else {
512
+ sections.push(
513
+ "ACTIVE ACCOUNT\n none — the current model is not a managed account.",
514
+ );
515
+ }
516
+
517
+ if (others.length > 0) {
518
+ const latestWindowSamples = getLatestWindowSamples();
519
+ const ready = others.filter(
520
+ (account) => accountHealth(account, nowMs) === "ready",
521
+ ).length;
522
+ const grouped: string[] = [
523
+ `OTHER ACCOUNTS (${ready} of ${others.length} ready)`,
524
+ ];
525
+ for (const family of FAMILY_ORDER) {
526
+ const members = others.filter((account) => account.family === family);
527
+ if (members.length === 0) continue;
528
+ grouped.push(` ${FAMILY_LABELS[family]}`);
529
+ for (const member of members) {
530
+ grouped.push(
531
+ ...renderAccount(member, nowMs, " ", latestWindowSamples),
532
+ );
533
+ }
534
+ }
535
+ sections.push(grouped.join("\n"));
536
+ }
537
+
538
+ // REQ-COVERAGE-STATE: Add cost aggregate section
539
+ // Use last 24 hours as the period
540
+ const periodStartMs = nowMs - 24 * 60 * 60 * 1000;
541
+ const costLine = costAggregateLine(periodStartMs, nowMs);
542
+ if (costLine) {
543
+ sections.push(`COST AGGREGATE (last 24 hours)\n ${costLine}`);
544
+ }
545
+ sections.push(
546
+ "Other agents on this machine share these accounts. Figures fetched from\n" +
547
+ "the provider include their consumption; figures derived from this\n" +
548
+ "session's own responses do not.",
549
+ );
550
+
551
+ const unsupported = input.unsupportedModels ?? [];
552
+ if (unsupported.length > 0) {
553
+ const lines = ["UNSUPPORTED ACCOUNT/MODEL PAIRS THIS SESSION"];
554
+ for (const pair of unsupported) {
555
+ lines.push(` ${pair.providerId} cannot serve ${pair.modelId}`);
556
+ }
557
+ sections.push(lines.join("\n"));
558
+ }
559
+
560
+ // Two slots backed by one account halve the capacity the operator believes
561
+ // they have: failover routes from a rate-limited slot to its twin, which is
562
+ // the same exhausted account, and burns a turn discovering that. Nothing
563
+ // reported this before, so it failed silently.
564
+ const duplicates = duplicateSlotGroups(input.accounts);
565
+ if (duplicates.length > 0) {
566
+ const lines = ["DUPLICATE ACCOUNTS"];
567
+ for (const group of duplicates) {
568
+ lines.push(` same account: ${group.join(", ")}`);
569
+ }
570
+ lines.push(
571
+ " These slots share one account, so they share its rate limit.",
572
+ " Failover between them cannot help; log one in to a different account.",
573
+ );
574
+ sections.push(lines.join("\n"));
575
+ }
576
+
577
+ return sections.join("\n\n");
578
+ }