@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,318 @@
1
+ import type {
2
+ CostReport,
3
+ CostReportApiEquivalent,
4
+ CostReportPeriod,
5
+ SubsidizationPoint,
6
+ SubsidizationSeries,
7
+ } from "./cost-report.js";
8
+ import type { PeriodType } from "./period-boundaries.js";
9
+
10
+ /**
11
+ * Bump on any breaking field change to {@link CostReportJson}. A consumer
12
+ * (the standalone `cost --format json` command, or any other JSON reader of
13
+ * this pure projection) reads this before trusting the document's shape.
14
+ */
15
+ export const COST_REPORT_JSON_SCHEMA_VERSION = 1;
16
+
17
+ /**
18
+ * The requested-scope summary carried at the top of {@link CostReportJson},
19
+ * mirroring the text renderer's leading block: the saved Pi model-price
20
+ * estimate and the independently calculated API-equivalent estimate are
21
+ * always both present and separately labeled, even when their amounts match.
22
+ * `note` explains that relationship in prose for a human reading raw JSON;
23
+ * neither `money` field is ever combined into one spend total, and neither is
24
+ * an actual provider bill, payment, refund, tax record, or bank charge -- see
25
+ * {@link CostReportJson.limitations}.
26
+ */
27
+ export interface CostReportJsonSummary {
28
+ readonly scope: {
29
+ readonly periodType: PeriodType;
30
+ readonly startMs: number;
31
+ readonly endMs: number;
32
+ readonly throughMs: number;
33
+ readonly completed: boolean;
34
+ readonly coverage: CostReportPeriod["coverage"];
35
+ };
36
+ readonly money: {
37
+ /** Pi's own saved per-response cost for the requested scope, unchanged from `current.retainedCostUsd`. */
38
+ readonly piModelPriceEstimateUsd: number;
39
+ /** The independently calculated, tier-aware estimate for the requested scope, unchanged from `current.apiEquivalent`. */
40
+ readonly apiEquivalentEstimate: CostReportApiEquivalent;
41
+ };
42
+ readonly note: string;
43
+ }
44
+
45
+ /** When this document was generated, and which pricing snapshot instants its API-equivalent estimates were computed against. */
46
+ export interface CostReportJsonProvenance {
47
+ readonly generatedAtMs: number;
48
+ readonly generatedAt: string;
49
+ readonly pricingSnapshotAtMs: readonly number[];
50
+ }
51
+
52
+ /** The `"priced"` member of {@link SubsidizationPoint}, isolated so {@link CostReportJsonSubsidizationPoint} can override just its `coverage` field. */
53
+ type PricedSubsidizationPoint = Extract<SubsidizationPoint, { readonly status: "priced" }>;
54
+
55
+ /**
56
+ * The legacy subsidization point as serialized for `--format json`.
57
+ * Identical to {@link SubsidizationPoint} -- plus an explicit
58
+ * `comparisonSuppressed: false` -- when `status` is not `"priced"` or
59
+ * `coverage` is `"complete"`. When `status` is `"priced"` and `coverage` is
60
+ * `"partial"` or `"unknown"`, `ratio` and `deltaFromPrevious` are withheld:
61
+ * the underlying retained data or its completeness classification cannot
62
+ * support a definitive comparison for that window, so `comparisonSuppressed`
63
+ * is `true` and `suppressedReason` names why. `apiEquivalentEstimateUsd` and
64
+ * `apportionedSubscriptionUsd` -- the two figures a shown ratio would have
65
+ * divided -- are always still present either way. This withholds only the
66
+ * ratio itself, never a value {@link SubsidizationPoint} did not already
67
+ * carry: `buildCostReport` and `SubsidizationPoint` itself are unchanged.
68
+ */
69
+ export type CostReportJsonSubsidizationPoint =
70
+ | ((
71
+ | Extract<SubsidizationPoint, { readonly status: "gap" }>
72
+ | Extract<SubsidizationPoint, { readonly status: "unpriced" }>
73
+ | (Omit<PricedSubsidizationPoint, "coverage"> & { readonly coverage: "complete" })
74
+ ) & { readonly comparisonSuppressed: false })
75
+ | {
76
+ readonly periodStartMs: number;
77
+ readonly periodEndMs: number;
78
+ readonly status: "priced";
79
+ readonly coverage: "partial" | "unknown";
80
+ readonly apiEquivalentEstimateUsd: number;
81
+ readonly apportionedSubscriptionUsd: number;
82
+ readonly comparisonSuppressed: true;
83
+ readonly suppressedReason: string;
84
+ };
85
+
86
+ /** {@link SubsidizationSeries}, with every point run through {@link CostReportJsonSubsidizationPoint}'s ratio-suppression rule. */
87
+ export interface CostReportJsonSubsidizationSeries {
88
+ readonly canonicalAccountId: string;
89
+ readonly monthlySubscriptionUsd: number;
90
+ readonly observedPeriods: number;
91
+ readonly completed: readonly CostReportJsonSubsidizationPoint[];
92
+ readonly current?: CostReportJsonSubsidizationPoint;
93
+ }
94
+
95
+ /**
96
+ * One versioned, documented JSON value for the pure `CostReport` projection.
97
+ * Every money, coverage, scope, and comparison field is a direct, unrenamed
98
+ * view of {@link CostReport} -- `current`, `completed`, `accountValue`, and
99
+ * `legacyUnattributed` are the exact same typed values `buildCostReport`
100
+ * produces, reorganized under one schema-versioned wrapper plus a
101
+ * requested-scope `summary` and a `limitations` list. `subsidization` is the
102
+ * one exception: it is the same points with a bare ratio withheld under
103
+ * incomplete coverage (see {@link CostReportJsonSubsidizationPoint}), a
104
+ * serialization decision, not a second computed value. Otherwise this is a
105
+ * serialization and documentation surface, never a second computed report
106
+ * type: it introduces no value this module did not already receive from
107
+ * `CostReport`.
108
+ */
109
+ export interface CostReportJson {
110
+ readonly schemaVersion: typeof COST_REPORT_JSON_SCHEMA_VERSION;
111
+ readonly provenance: CostReportJsonProvenance;
112
+ readonly summary: CostReportJsonSummary;
113
+ readonly current: CostReportPeriod;
114
+ readonly completed: CostReport["completed"];
115
+ readonly accountValue: NonNullable<CostReport["accountValue"]>;
116
+ readonly subsidization: readonly CostReportJsonSubsidizationSeries[];
117
+ readonly legacyUnattributed: CostReport["legacyUnattributed"];
118
+ readonly limitations: readonly string[];
119
+ }
120
+
121
+ /**
122
+ * Reuses the spec's own approved billing-claim boundary language verbatim so
123
+ * the JSON document and the product description never drift: neither
124
+ * estimate is an actual provider charge, the recorded rate and allocated
125
+ * account cost are not an invoice/payment/refund/tax record/bank charge, and
126
+ * comparisons never claim a real subsidy, saving, or provider revenue.
127
+ */
128
+ const BASE_LIMITATION =
129
+ "Neither the Pi model-price estimate nor the API-equivalent estimate is an actual provider charge. The recorded account rate and allocated account cost are not an invoice, payment, refund, tax record, or bank charge. Differences, ratios, and value multiples compare report values only and do not claim an actual subsidy, saving, provider revenue, or private-contract economics.";
130
+
131
+ function sortedUniqueNumbers(values: readonly number[]): readonly number[] {
132
+ return [...new Set(values)].sort((left, right) => left - right);
133
+ }
134
+
135
+ function pricingSnapshotAtMs(report: CostReport): readonly number[] {
136
+ return sortedUniqueNumbers([
137
+ ...report.current.apiEquivalent.rateAsOfMs,
138
+ ...report.completed.flatMap((period) => period.apiEquivalent.rateAsOfMs),
139
+ ]);
140
+ }
141
+
142
+ function summaryNote(current: CostReportPeriod): string {
143
+ const lead =
144
+ "The saved Pi model-price estimate and the independently calculated API-equivalent estimate are reported separately below.";
145
+ if (current.apiEquivalent.status !== "priced") {
146
+ return `${lead} The API-equivalent estimate is currently unpriced for this scope.`;
147
+ }
148
+ return current.apiEquivalent.estimatedUsd === current.retainedCostUsd
149
+ ? `${lead} They happen to match for this window; they remain two independently produced figures.`
150
+ : lead;
151
+ }
152
+
153
+ /**
154
+ * Serializes one legacy subsidization point, withholding `ratio` and
155
+ * `deltaFromPrevious` when `status` is `"priced"` and `coverage` is not
156
+ * `"complete"`. See {@link CostReportJsonSubsidizationPoint}.
157
+ *
158
+ * `deltaFromPrevious` (cost-report.ts's `withConsecutiveDeltas`) is a
159
+ * difference of two ratios, so even when this point's own `coverage` is
160
+ * `"complete"` the delta is only as reliable as `previous` -- the
161
+ * immediately preceding point in the same `completed` array, never an
162
+ * earlier complete point reached by bridging a gap. When `previous` is
163
+ * missing or not itself a `"priced"`/`"complete"` point, `deltaFromPrevious`
164
+ * is omitted from the output rather than spread through and labeled
165
+ * `comparisonSuppressed: false`; `ratio` and every other field of this
166
+ * point remain valid and present.
167
+ */
168
+ function toJsonSubsidizationPoint(
169
+ point: SubsidizationPoint,
170
+ previous?: SubsidizationPoint,
171
+ ): CostReportJsonSubsidizationPoint {
172
+ if (point.status === "gap") {
173
+ return { ...point, status: point.status, comparisonSuppressed: false };
174
+ }
175
+ if (point.status === "unpriced") {
176
+ return { ...point, status: point.status, comparisonSuppressed: false };
177
+ }
178
+ if (point.coverage === "complete") {
179
+ const deltaBasisComplete =
180
+ previous !== undefined && previous.status === "priced" && previous.coverage === "complete";
181
+ if (deltaBasisComplete || point.deltaFromPrevious === undefined) {
182
+ return { ...point, coverage: point.coverage, comparisonSuppressed: false };
183
+ }
184
+ const { deltaFromPrevious: _omittedDelta, ...pointWithoutDelta } = point;
185
+ return { ...pointWithoutDelta, coverage: point.coverage, comparisonSuppressed: false };
186
+ }
187
+ return {
188
+ periodStartMs: point.periodStartMs,
189
+ periodEndMs: point.periodEndMs,
190
+ status: "priced",
191
+ coverage: point.coverage,
192
+ apiEquivalentEstimateUsd: point.apiEquivalentEstimateUsd,
193
+ apportionedSubscriptionUsd: point.apportionedSubscriptionUsd,
194
+ comparisonSuppressed: true,
195
+ suppressedReason: `legacy-subsidization-${point.coverage}-coverage`,
196
+ };
197
+ }
198
+
199
+ function toJsonSubsidizationSeries(
200
+ series: SubsidizationSeries,
201
+ ): CostReportJsonSubsidizationSeries {
202
+ return {
203
+ canonicalAccountId: series.canonicalAccountId,
204
+ monthlySubscriptionUsd: series.monthlySubscriptionUsd,
205
+ observedPeriods: series.observedPeriods,
206
+ completed: series.completed.map((point, index) =>
207
+ toJsonSubsidizationPoint(point, series.completed[index - 1]),
208
+ ),
209
+ ...(series.current === undefined
210
+ ? {}
211
+ : { current: toJsonSubsidizationPoint(series.current) }),
212
+ };
213
+ }
214
+
215
+ /** One line disclosing that the legacy subsidization ratio is withheld for one or more periods whose coverage is not complete -- never left to a header disclaimer alone. */
216
+ function subsidizationSuppressionLimitations(report: CostReport): readonly string[] {
217
+ const hasSuppressedPoint = report.subsidization.some((series) =>
218
+ [series.current, ...series.completed].some(
219
+ (point) => point !== undefined && point.status === "priced" && point.coverage !== "complete",
220
+ ),
221
+ );
222
+ return hasSuppressedPoint
223
+ ? [
224
+ "Legacy subsidization comparison is withheld for one or more periods whose coverage is not complete; their apportioned subscription and API-equivalent estimate remain reported without a ratio.",
225
+ ]
226
+ : [];
227
+ }
228
+
229
+ /** One line per account-level limitation already disclosed by `CostReport` itself: a suppressed or conditional comparison, or unknown allocation coverage. */
230
+ function accountValueLimitations(report: CostReport): readonly string[] {
231
+ const limitations: string[] = [];
232
+ for (const entry of report.accountValue ?? []) {
233
+ if (entry.comparison.status === "suppressed") {
234
+ limitations.push(
235
+ `${entry.canonicalAccountId}: comparison suppressed (${entry.comparison.reason}).`,
236
+ );
237
+ } else if (entry.comparison.status === "conditional-lower-bound") {
238
+ limitations.push(
239
+ `${entry.canonicalAccountId}: comparison is a conditional lower bound (${entry.comparison.premise}).`,
240
+ );
241
+ }
242
+ if (entry.allocationHasUnknownCoverage) {
243
+ limitations.push(
244
+ `${entry.canonicalAccountId}: allocated account cost has unknown coverage for part of this window.`,
245
+ );
246
+ }
247
+ }
248
+ return limitations;
249
+ }
250
+
251
+ function buildLimitations(report: CostReport): readonly string[] {
252
+ const limitations: string[] = [BASE_LIMITATION];
253
+ if (report.current.coverage !== "complete") {
254
+ limitations.push(
255
+ `Requested-scope coverage is "${report.current.coverage}": part or all of the requested window has no immutable retained data.`,
256
+ );
257
+ }
258
+ limitations.push(...accountValueLimitations(report));
259
+ if (report.legacyUnattributed.length > 0) {
260
+ limitations.push(
261
+ "Legacy unattributed retained cost exists for responses saved without a project key.",
262
+ );
263
+ }
264
+ if (report.subsidization.length > 0) {
265
+ limitations.push(
266
+ "Subsidization figures use the legacy flat monthly-subscription value, superseded by recorded account rate for new comparisons.",
267
+ );
268
+ limitations.push(...subsidizationSuppressionLimitations(report));
269
+ }
270
+ return limitations;
271
+ }
272
+
273
+ /**
274
+ * Builds the documented {@link CostReportJson} value for `report`. Pure: no
275
+ * I/O, no clock read, no mutation, and no field not already present on
276
+ * `report` itself.
277
+ */
278
+ export function buildCostReportJson(report: CostReport): CostReportJson {
279
+ const current = report.current;
280
+ return {
281
+ schemaVersion: COST_REPORT_JSON_SCHEMA_VERSION,
282
+ provenance: {
283
+ generatedAtMs: report.generatedAtMs,
284
+ generatedAt: new Date(report.generatedAtMs).toISOString(),
285
+ pricingSnapshotAtMs: pricingSnapshotAtMs(report),
286
+ },
287
+ summary: {
288
+ scope: {
289
+ periodType: report.periodType,
290
+ startMs: current.periodStartMs,
291
+ endMs: current.periodEndMs,
292
+ throughMs: current.throughMs,
293
+ completed: current.completed,
294
+ coverage: current.coverage,
295
+ },
296
+ money: {
297
+ piModelPriceEstimateUsd: current.retainedCostUsd,
298
+ apiEquivalentEstimate: current.apiEquivalent,
299
+ },
300
+ note: summaryNote(current),
301
+ },
302
+ current,
303
+ completed: report.completed,
304
+ accountValue: report.accountValue ?? [],
305
+ subsidization: report.subsidization.map(toJsonSubsidizationSeries),
306
+ legacyUnattributed: report.legacyUnattributed,
307
+ limitations: buildLimitations(report),
308
+ };
309
+ }
310
+
311
+ /**
312
+ * Renders `report` as one versioned JSON document with no surrounding prose,
313
+ * for the standalone report's `--format json` output (wired by a later
314
+ * node).
315
+ */
316
+ export function renderCostReportJson(report: CostReport): string {
317
+ return JSON.stringify(buildCostReportJson(report), null, 2);
318
+ }
@@ -0,0 +1,368 @@
1
+ import type { AccountRateRecord } from "./account-rate-history.js";
2
+ import type { PiCatalogSnapshot } from "./api-pricing.js";
3
+ import { isAllowedFamily, type MultiAccountConfig } from "./config.js";
4
+ import {
5
+ classifyProviderId,
6
+ isProviderSlotWithinAccountLimit,
7
+ } from "./discovery.js";
8
+ import {
9
+ defaultCostDigestPaths,
10
+ CostDigestStore,
11
+ } from "./cost-digest-store.js";
12
+ import {
13
+ summarizeCostHistoryForReport,
14
+ type CostDigestRow,
15
+ } from "./cost-digest.js";
16
+ import { buildCostReport, type CostReport } from "./cost-report.js";
17
+ import { iterateHistory } from "./history-store.js";
18
+ import { getPeriodBounds, PERIOD_TYPES, type PeriodType } from "./period-boundaries.js";
19
+ import {
20
+ defaultPricingCachePaths,
21
+ OpenRouterPricingCache,
22
+ } from "./pricing-cache.js";
23
+ import {
24
+ resolveCustomRange,
25
+ type GranularitySegment,
26
+ type RetainedGranularity,
27
+ } from "./report-range.js";
28
+
29
+ /**
30
+ * A request for the six existing calendar periods (a bare {@link PeriodType},
31
+ * always UTC and byte-identical to the existing slash/tool compatibility
32
+ * path), the same six periods with an explicit report timezone (the
33
+ * `"period"` kind), an exact custom range described by raw `--from`/`--to`-
34
+ * shaped bounds (see {@link resolveCustomRange}), or all available retained
35
+ * history. A `"period"` request dispatches directly to
36
+ * {@link buildCostReport}'s existing `periodType`/`timeZone` bounds path --
37
+ * never through `"custom"` -- so a non-UTC named period keeps its correct
38
+ * zoned calendar bounds and "current unfinished period" semantics (a
39
+ * populated `completed` series, a still-open `current` period) instead of
40
+ * collapsing to a single exact-bounds custom window. The reader resolves a
41
+ * `"custom"` request's raw bounds and satisfiability against the real
42
+ * retained store itself, exactly as {@link resolveCustomRange} and
43
+ * {@link assertRangeSatisfiable} are documented to be used.
44
+ */
45
+ export type CostReportRangeRequest =
46
+ | PeriodType
47
+ | {
48
+ readonly kind: "period";
49
+ readonly periodType: PeriodType;
50
+ readonly timeZone?: string;
51
+ }
52
+ | {
53
+ readonly kind: "custom";
54
+ readonly fromRaw: string;
55
+ readonly toRaw: string;
56
+ readonly timeZone?: string;
57
+ }
58
+ | { readonly kind: "all-history" };
59
+
60
+ /**
61
+ * Thrown when a `"custom"` {@link CostReportRangeRequest} fails syntax
62
+ * validation or the retained store cannot satisfy its requested precision.
63
+ * `exitCode` mirrors {@link ReportRangeResult}'s own stable exit codes (2 for
64
+ * invalid syntax, 3 for unsatisfiable precision) so a later CLI layer can map
65
+ * this exception onto its own process exit without re-deriving the reason.
66
+ */
67
+ export class CostReportRangeRequestError extends Error {
68
+ readonly exitCode: 2 | 3;
69
+ constructor(exitCode: 2 | 3, reason: string) {
70
+ super(reason);
71
+ this.name = "CostReportRangeRequestError";
72
+ this.exitCode = exitCode;
73
+ }
74
+ }
75
+
76
+ export type CostReportReader = (request: CostReportRangeRequest) => CostReport;
77
+
78
+ /**
79
+ * Projects a live `monthlySubscriptionUsd` map to the subset within the current
80
+ * validated account limit.
81
+ *
82
+ * Each key is classified through `classifyProviderId()` and checked against
83
+ * `isProviderSlotWithinAccountLimit(slot, accountLimit)` before its price is
84
+ * copied. A key that does not classify or is above the current limit is dropped,
85
+ * so an in-range-for-maximum key above the current configured limit, or a
86
+ * hostile in-memory slot-33 key that bypassed config parsing, cannot activate a
87
+ * subscription-value series in the report. The result is frozen and retains no
88
+ * reference to the source object.
89
+ */
90
+ export function projectMonthlySubscriptionUsdForReport(
91
+ monthlySubscriptionUsd: Readonly<Record<string, number>>,
92
+ accountLimit: number,
93
+ ): Readonly<Record<string, number>> {
94
+ const projected: Record<string, number> = {};
95
+ for (const [providerId, monthlyCost] of Object.entries(monthlySubscriptionUsd)) {
96
+ const slot = classifyProviderId({ providerId, credentialType: "unknown" });
97
+ if (
98
+ slot === null ||
99
+ !isProviderSlotWithinAccountLimit(slot, accountLimit) ||
100
+ // Subscription-only report sink: an owning-vendor-api `openai` cost key
101
+ // must not enter the subscription-subsidization report, mirroring the
102
+ // parseMonthlySubscriptionUsd config guard.
103
+ !isAllowedFamily(slot.family)
104
+ ) {
105
+ continue;
106
+ }
107
+ projected[providerId] = monthlyCost;
108
+ }
109
+ return Object.freeze(projected);
110
+ }
111
+
112
+ /**
113
+ * Projects a live `accountRateHistory` map to the subset within the current
114
+ * validated account limit, mirroring {@link projectMonthlySubscriptionUsdForReport}.
115
+ * Each key is classified and checked against the same account-limit and
116
+ * allowed-family guards before its records are copied, so a hostile
117
+ * in-memory slot-33 key that bypassed config parsing cannot inject an
118
+ * out-of-range account rate into the report. The result is frozen and
119
+ * retains no reference to the source object.
120
+ */
121
+ export function projectAccountRateHistoryForReport(
122
+ accountRateHistory: Readonly<Record<string, readonly AccountRateRecord[]>>,
123
+ accountLimit: number,
124
+ ): Readonly<Record<string, readonly AccountRateRecord[]>> {
125
+ const projected: Record<string, readonly AccountRateRecord[]> = {};
126
+ for (const [providerId, records] of Object.entries(accountRateHistory)) {
127
+ const slot = classifyProviderId({ providerId, credentialType: "unknown" });
128
+ if (
129
+ slot === null ||
130
+ !isProviderSlotWithinAccountLimit(slot, accountLimit) ||
131
+ !isAllowedFamily(slot.family)
132
+ ) {
133
+ continue;
134
+ }
135
+ projected[providerId] = records;
136
+ }
137
+ return Object.freeze(projected);
138
+ }
139
+
140
+ const PERIOD_TYPE_RANK: ReadonlyMap<PeriodType, number> = new Map(
141
+ PERIOD_TYPES.map((periodType, index) => [periodType, index]),
142
+ );
143
+
144
+ /**
145
+ * Describes which granularity is actually retained across `[startMs, endMs)`,
146
+ * derived from real, already-closed digest rows plus the still-open current
147
+ * day's live per-response detail -- never from a fabricated or assumed
148
+ * grain. A stretch covered by no digest row and outside the still-open
149
+ * current day is a genuine coverage gap and is simply absent from the
150
+ * result: {@link assertRangeSatisfiable} in `report-range.ts` then correctly
151
+ * refuses to let a caller slice inside it, rather than this function
152
+ * guessing a granularity for time nothing was ever retained at.
153
+ *
154
+ * A closed digest row is retained forever once written (the digest store
155
+ * never deletes a child once a parent rollup exists), so an old day that was
156
+ * closed while its underlying raw per-response records were still fresh
157
+ * keeps reporting "day" granularity even long after those raw records
158
+ * expired from the separate, bounded-retention raw history log -- this
159
+ * function only reads the immutable digest rows and the live window, never
160
+ * the raw log's own retention state.
161
+ */
162
+ export function deriveRetainedGranularitySegments(input: {
163
+ readonly digestRows: readonly CostDigestRow[];
164
+ /** The start of the still-open current day, or `undefined` when no live window applies. */
165
+ readonly liveRawFromMs: number | undefined;
166
+ readonly nowMs: number;
167
+ }): readonly GranularitySegment[] {
168
+ const boundaries = new Set<number>();
169
+ for (const row of input.digestRows) {
170
+ boundaries.add(row.periodStartMs);
171
+ boundaries.add(row.periodEndMs);
172
+ }
173
+ if (input.liveRawFromMs !== undefined) {
174
+ boundaries.add(input.liveRawFromMs);
175
+ boundaries.add(input.nowMs);
176
+ }
177
+ const sorted = [...boundaries].sort((left, right) => left - right);
178
+ const raw: Array<{
179
+ readonly startMs: number;
180
+ readonly endMs: number;
181
+ readonly granularity: RetainedGranularity;
182
+ }> = [];
183
+ for (let index = 0; index < sorted.length - 1; index += 1) {
184
+ const segStart = sorted[index]!;
185
+ const segEnd = sorted[index + 1]!;
186
+ if (segEnd <= segStart) continue;
187
+ if (
188
+ input.liveRawFromMs !== undefined &&
189
+ segStart >= input.liveRawFromMs &&
190
+ segEnd <= input.nowMs
191
+ ) {
192
+ raw.push({ startMs: segStart, endMs: segEnd, granularity: "raw" });
193
+ continue;
194
+ }
195
+ let finestRank: number | undefined;
196
+ for (const row of input.digestRows) {
197
+ if (row.periodStartMs > segStart || row.periodEndMs < segEnd) continue;
198
+ const rank = PERIOD_TYPE_RANK.get(row.periodType);
199
+ if (rank !== undefined && (finestRank === undefined || rank < finestRank)) {
200
+ finestRank = rank;
201
+ }
202
+ }
203
+ if (finestRank !== undefined) {
204
+ raw.push({
205
+ startMs: segStart,
206
+ endMs: segEnd,
207
+ granularity: PERIOD_TYPES[finestRank]!,
208
+ });
209
+ }
210
+ }
211
+ const merged: GranularitySegment[] = [];
212
+ for (const segment of raw) {
213
+ const previous = merged[merged.length - 1];
214
+ if (
215
+ previous !== undefined &&
216
+ previous.granularity === segment.granularity &&
217
+ previous.endMs === segment.startMs
218
+ ) {
219
+ merged[merged.length - 1] = { ...previous, endMs: segment.endMs };
220
+ } else {
221
+ merged.push(segment);
222
+ }
223
+ }
224
+ return merged;
225
+ }
226
+
227
+ export type RetainedHistoryCoverageReader = () => readonly GranularitySegment[];
228
+
229
+ /**
230
+ * Bind {@link deriveRetainedGranularitySegments} to the current Pi agent
231
+ * directory's real, immutable digest store -- the "all available history"
232
+ * read path. Performs no network request and writes nothing.
233
+ */
234
+ export function createDefaultRetainedHistoryCoverageReader(options: {
235
+ readonly now?: () => number;
236
+ }): RetainedHistoryCoverageReader {
237
+ const digestStore = new CostDigestStore({ path: defaultCostDigestPaths().path });
238
+ const now = options.now ?? Date.now;
239
+ return () => {
240
+ const nowMs = now();
241
+ const today = getPeriodBounds(nowMs, "day");
242
+ return deriveRetainedGranularitySegments({
243
+ digestRows: digestStore.read(),
244
+ liveRawFromMs: today.startMs,
245
+ nowMs,
246
+ });
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Bind the read-only cost-report surfaces to the current Pi agent directory.
252
+ * The returned reader performs no network request and writes nothing; the
253
+ * machine-leased period closer is invoked separately before this reader.
254
+ */
255
+ export function createDefaultCostReportReader(options: {
256
+ readonly config: () => Pick<
257
+ MultiAccountConfig,
258
+ "accountLimit" | "projectLabels" | "monthlySubscriptionUsd" | "accountRateHistory"
259
+ >;
260
+ readonly now?: () => number;
261
+ /**
262
+ * Supplies Pi's installed model catalog cost data for per-response
263
+ * tier-aware pricing (see `api-pricing.ts`). This adapter never fetches or
264
+ * derives that snapshot itself; when omitted, the read path prices
265
+ * responses only from the retained OpenRouter rate snapshot, exactly as it
266
+ * did before per-response tier pricing existed.
267
+ */
268
+ readonly piCatalog?: () => PiCatalogSnapshot | undefined;
269
+ }): CostReportReader {
270
+ const digestStore = new CostDigestStore({ path: defaultCostDigestPaths().path });
271
+ const pricingCache = new OpenRouterPricingCache({
272
+ ...defaultPricingCachePaths(),
273
+ ...(options.now === undefined ? {} : { now: options.now }),
274
+ });
275
+ const now = options.now ?? Date.now;
276
+ // The same "all available history" read path a caller can bind directly
277
+ // (see `createDefaultRetainedHistoryCoverageReader`'s own doc comment):
278
+ // reused here so a "custom" request's satisfiability is checked against
279
+ // the real retained store, never a caller-asserted or absent set of
280
+ // segments.
281
+ const coverageReader = createDefaultRetainedHistoryCoverageReader(
282
+ options.now === undefined ? {} : { now: options.now },
283
+ );
284
+ return (request) => {
285
+ const nowMs = now();
286
+ const pricing = pricingCache.readStatus();
287
+ const piCatalog = options.piCatalog?.();
288
+ const history = summarizeCostHistoryForReport(
289
+ iterateHistory("cost-delta"),
290
+ nowMs,
291
+ pricing,
292
+ piCatalog,
293
+ );
294
+ const config = options.config();
295
+ const baseInput = {
296
+ digestRows: digestStore.read(),
297
+ // Only today's still-open window has per-response raw detail left to
298
+ // filter by; a resolved sub-day-precise range clipped inside today
299
+ // (see `provisionalCurrentDayRows` in `cost-report.ts`) rebuilds its
300
+ // provisional row from these instead of slicing the pre-aggregated
301
+ // `currentDayDigestRows` below, which has no per-observation
302
+ // timestamps left once summed.
303
+ observations: history.currentDayObservations,
304
+ gaps: history.currentDayGaps,
305
+ currentDayDigestRows: history.currentDayRows,
306
+ ...(history.earliestAttributedObservedAtMs === undefined
307
+ ? {}
308
+ : {
309
+ earliestRawObservationAtMs:
310
+ history.earliestAttributedObservedAtMs,
311
+ }),
312
+ rawAccountIds: history.accountIds,
313
+ groupedLegacyUnattributed: history.legacyUnattributed,
314
+ pricing,
315
+ nowMs,
316
+ projectLabels: config.projectLabels,
317
+ monthlySubscriptionUsd: projectMonthlySubscriptionUsdForReport(
318
+ config.monthlySubscriptionUsd,
319
+ config.accountLimit,
320
+ ),
321
+ accountRateHistory: projectAccountRateHistoryForReport(
322
+ config.accountRateHistory ?? {},
323
+ config.accountLimit,
324
+ ),
325
+ } as const;
326
+
327
+ if (typeof request === "string") {
328
+ return buildCostReport({ ...baseInput, periodType: request });
329
+ }
330
+ if (request.kind === "period") {
331
+ return buildCostReport({
332
+ ...baseInput,
333
+ periodType: request.periodType,
334
+ ...(request.timeZone === undefined ? {} : { timeZone: request.timeZone }),
335
+ });
336
+ }
337
+ // `periodType` below is a nominal placeholder for both branches: neither
338
+ // `buildCostReport`'s `"custom"` nor `"all-history"` range mode reads it
339
+ // for bounds (see `BuildCostReportInput.range`); it only keeps
340
+ // `CostReport.periodType`'s existing required shape until a later node
341
+ // gives a range-mode report its own dedicated label.
342
+ if (request.kind === "custom") {
343
+ const resolved = resolveCustomRange({
344
+ fromRaw: request.fromRaw,
345
+ toRaw: request.toRaw,
346
+ ...(request.timeZone === undefined ? {} : { timeZone: request.timeZone }),
347
+ retainedGranularity: coverageReader(),
348
+ });
349
+ if (resolved.status !== "resolved") {
350
+ throw new CostReportRangeRequestError(resolved.exitCode, resolved.reason);
351
+ }
352
+ return buildCostReport({
353
+ ...baseInput,
354
+ periodType: "day",
355
+ range: {
356
+ kind: "custom",
357
+ startMs: resolved.startMs,
358
+ endMs: resolved.endMs,
359
+ },
360
+ });
361
+ }
362
+ return buildCostReport({
363
+ ...baseInput,
364
+ periodType: "day",
365
+ range: { kind: "all-history" },
366
+ });
367
+ };
368
+ }