@rayadesu/dsh-llm-billing 0.3.15 → 0.3.17

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.
@@ -0,0 +1,203 @@
1
+ /**
2
+ * DeepSeek pricing: the peak/off-peak pricing table, the per-event pricing, and
3
+ * the resolved-billing shape. Pure functions over token usage and the pricing
4
+ * table, so the Remote gateway stays transport-free and the whole spend is
5
+ * testable without a key.
6
+ *
7
+ * The per-event pricing lives in {@link priceEvent} / {@link priceUsage}; the
8
+ * fold state machine that consumes it (`BillingFolder` / {@link applyBillingEvent})
9
+ * lives in `fold.ts`, and every scan path prices through these primitives so a
10
+ * pricing-table change cannot drift one path from the others.
11
+ * @module @rayadesu/dsh-llm-billing/pricing
12
+ */
13
+ import { beijingPartsOf } from "./beijing-time.js";
14
+ import { DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS } from "./pricing-table.js";
15
+ // Declared here before the table moved out; re-exported so the package's public
16
+ // surface and its tests keep importing them from the billing entry point.
17
+ export { DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, FLASH_SERIES_RATE_CHANGE_AT, V4_PRO_ROUTE_SWITCH_AT } from "./pricing-table.js";
18
+ /**
19
+ * Resolve optional configuration to a pricing table, defaulting omitted or
20
+ * empty rows to the published rates. Schemastery materializes an absent
21
+ * `z.array` as `[]` rather than `undefined`, so emptiness — not just absence —
22
+ * selects the defaults. Explicit non-empty rows override the same model; a
23
+ * supplied non-empty `models` list is authoritative.
24
+ *
25
+ * Rows sharing a model are that model's rate revisions, kept in ascending
26
+ * `effectiveFrom` order (an undated base revision first). Two rows declaring
27
+ * the same effective instant are one revision and the later row wins — the
28
+ * historical override rule — so re-declaring a model can neither duplicate a
29
+ * revision nor install a second undated base.
30
+ * @param config - optional raw billing configuration.
31
+ * @returns the resolved table (per model: its revisions plus the newest rates) and peak-hour windows.
32
+ */
33
+ export function resolveBilling(config) {
34
+ const peakHours = config?.peakHours !== undefined && config.peakHours.length > 0
35
+ ? config.peakHours
36
+ : DEFAULT_PEAK_HOURS;
37
+ const rows = config?.models !== undefined && config.models.length > 0
38
+ ? config.models
39
+ : DEFAULT_MODEL_PRICING;
40
+ const schedules = new Map();
41
+ for (const row of rows) {
42
+ // Spelled out per branch: `exactOptionalPropertyTypes` forbids handing an
43
+ // explicit `undefined` to an optional field.
44
+ const revision = row.effectiveFrom === undefined
45
+ ? { peak: row.peak, offPeak: row.offPeak }
46
+ : { effectiveFrom: row.effectiveFrom, peak: row.peak, offPeak: row.offPeak };
47
+ const revisions = schedules.get(row.model);
48
+ if (revisions === undefined) {
49
+ schedules.set(row.model, [revision]);
50
+ continue;
51
+ }
52
+ const duplicate = revisions.findIndex(candidate => candidate.effectiveFrom === revision.effectiveFrom);
53
+ if (duplicate >= 0)
54
+ revisions[duplicate] = revision;
55
+ else
56
+ revisions.push(revision);
57
+ }
58
+ const models = new Map();
59
+ for (const [model, revisions] of schedules) {
60
+ revisions.sort((left, right) => (left.effectiveFrom ?? Number.NEGATIVE_INFINITY) - (right.effectiveFrom ?? Number.NEGATIVE_INFINITY));
61
+ const newest = revisions[revisions.length - 1];
62
+ models.set(model, { peak: newest.peak, offPeak: newest.offPeak, revisions });
63
+ }
64
+ return { peakHours, models };
65
+ }
66
+ /**
67
+ * The rate revision in effect at one instant: the newest revision that took
68
+ * effect at or before it. Revisions are ascending, so the scan stops at the
69
+ * first future one. An instant before the earliest dated revision bills at that
70
+ * earliest revision — a model with only dated rows is never left unpriced.
71
+ */
72
+ function ratesAt(revisions, time) {
73
+ let chosen = revisions[0];
74
+ for (let index = 1; index < revisions.length; index += 1) {
75
+ const revision = revisions[index];
76
+ if (revision.effectiveFrom === undefined || revision.effectiveFrom > time)
77
+ break;
78
+ chosen = revision;
79
+ }
80
+ return chosen;
81
+ }
82
+ /** Whether a Beijing (hour, weekday) pair falls inside any peak-hour window. */
83
+ function isPeakParts(billing, hour, weekday) {
84
+ if (weekday === 0 || weekday === 6)
85
+ return false;
86
+ return billing.peakHours.some(({ start, end }) => hour >= start && hour < end);
87
+ }
88
+ /**
89
+ * Whether a timestamp falls inside any peak-hour window (Beijing time,
90
+ * weekdays Monday–Friday only). Weekends (Saturday and Sunday) are always
91
+ * off-peak, matching the published peak-hours rule.
92
+ * @param billing - resolved pricing with peak-hour windows.
93
+ * @param now - the moment to classify.
94
+ * @returns true during a weekday peak hour.
95
+ */
96
+ export function isPeak(billing, now) {
97
+ const { hour, weekday } = beijingPartsOf(now.getTime());
98
+ return isPeakParts(billing, hour, weekday);
99
+ }
100
+ /**
101
+ * Price one event at the official per-model rates, applying the peak/off-peak
102
+ * table by its Beijing-time hour and weekday (peak windows apply Monday–Friday
103
+ * only; weekends are off-peak) and the rate revision in effect at its own
104
+ * timestamp. Each `assistant/message` event with usage
105
+ * contributes cache-hit input, cache-miss input (uncached input plus cache
106
+ * writes), and output (reasoning included) tokens at the rate of its own
107
+ * timestamp; a model with usage but no pricing row contributes nothing.
108
+ * @param event - the event to price.
109
+ * @param billing - resolved pricing with peak-hour windows.
110
+ * @param names - model id → display label.
111
+ * @returns the priced contribution, or `undefined` when the event has no priced usage.
112
+ */
113
+ export function priceEvent(event, billing, names) {
114
+ return priceEventAt(beijingPartsOf(event.time), event, billing, names);
115
+ }
116
+ /**
117
+ * Price one event at the official per-model rates using a precomputed
118
+ * Beijing-time view — the day-filtering and pricing of one event share a
119
+ * single timezone parse (see {@link beijingPartsOf}). Semantics are identical
120
+ * to {@link priceEvent}.
121
+ * @param parts - the event's Beijing-time view.
122
+ * @param event - the event to price.
123
+ * @param billing - resolved pricing with peak-hour windows.
124
+ * @param names - model id → display label.
125
+ * @returns the priced contribution, or `undefined` when the event has no priced usage.
126
+ */
127
+ export function priceEventAt(parts, event, billing, names) {
128
+ if (event.type !== 'assistant/message')
129
+ return undefined;
130
+ const reported = event.data.usage;
131
+ if (reported === undefined)
132
+ return undefined;
133
+ return priceUsage(parts, reported, event.data.message.source.model, billing, names);
134
+ }
135
+ /** Every token one usage sample reports, whether or not it gets priced. */
136
+ export function usageTokens(usage) {
137
+ return usage.inputTokens + usage.outputTokens + (usage.cacheReadTokens ?? 0) + (usage.cacheWriteTokens ?? 0);
138
+ }
139
+ /** Max distinct model ids remembered for the once-only warning. */
140
+ const UNPRICED_WARN_LIMIT = 64;
141
+ /** Model ids already reported, so a long-running host logs each one once. */
142
+ const reportedUnpricedModels = new Set();
143
+ /**
144
+ * Report a model carrying no rate row, at most once per id (and at most
145
+ * {@link UNPRICED_WARN_LIMIT} ids per process). Every figure reads as zero when
146
+ * the upstream model is unknown, which looks exactly like "no usage" rather
147
+ * than a missing row — the one thing this warning exists to separate.
148
+ *
149
+ * Deliberately NOT guessed: model ids are chosen by whoever ships the model, so
150
+ * a similar-looking name says nothing about a similar price — and a wrong number
151
+ * that LOOKS authoritative is worse than a visible gap. The fix is one row under
152
+ * `billing.models`, which is exactly what the warning points at.
153
+ * @param model - the unlisted wire model id.
154
+ */
155
+ export function reportUnpricedModel(model) {
156
+ if (reportedUnpricedModels.has(model) || reportedUnpricedModels.size >= UNPRICED_WARN_LIMIT)
157
+ return;
158
+ reportedUnpricedModels.add(model);
159
+ console.warn(`[llm-billing] no pricing row for model "${model}" — its usage is NOT billed; add its row under billing.models to price it`);
160
+ }
161
+ /**
162
+ * Price one provider-reported usage sample for one model at the rates of the
163
+ * sample's own Beijing-time hour and weekday — the peak or off-peak price of
164
+ * the rate revision in effect at the sample's own timestamp (a re-priced series
165
+ * bills its history at the rates that applied then). `undefined` when the model
166
+ * has no pricing row.
167
+ * @param parts - the sample's Beijing-time view.
168
+ * @param usage - the reported token buckets.
169
+ * @param model - the wire model id the sample belongs to.
170
+ * @param billing - resolved pricing with peak-hour windows.
171
+ * @param names - model id → display label.
172
+ * @returns the priced contribution, or `undefined` when the model has no rate row.
173
+ */
174
+ export function priceUsage(parts, usage, model, billing, names) {
175
+ const pricing = billing.models.get(model);
176
+ if (pricing === undefined)
177
+ return undefined;
178
+ const peak = isPeakParts(billing, parts.hour, parts.weekday);
179
+ const revision = ratesAt(pricing.revisions, parts.time);
180
+ const price = peak ? revision.peak : revision.offPeak;
181
+ const hit = usage.cacheReadTokens ?? 0;
182
+ const miss = usage.inputTokens + (usage.cacheWriteTokens ?? 0);
183
+ const output = usage.outputTokens;
184
+ const hitCost = (hit * price.cacheHitInput) / 1_000_000;
185
+ const missCost = (miss * price.cacheMissInput) / 1_000_000;
186
+ const outputCost = (output * price.output) / 1_000_000;
187
+ const cost = hitCost + missCost + outputCost;
188
+ return {
189
+ dayKey: parts.dayKey,
190
+ model,
191
+ displayName: names.get(model) ?? model,
192
+ cost,
193
+ peakCost: peak ? cost : 0,
194
+ offPeakCost: peak ? 0 : cost,
195
+ cacheHitInputTokens: hit,
196
+ cacheMissInputTokens: miss,
197
+ outputTokens: output,
198
+ cacheHitInputCost: hitCost,
199
+ cacheMissInputCost: missCost,
200
+ outputCost,
201
+ };
202
+ }
203
+ //# sourceMappingURL=pricing.js.map
@@ -33,9 +33,23 @@ const modelRowSchema = z.object({
33
33
  cacheMissInputCost: z.number().nonnegative(),
34
34
  outputCost: z.number().nonnegative(),
35
35
  }).strict();
36
+ /**
37
+ * The advisory tally of usage that could not be billed at its own rate (see
38
+ * `DeepSeekTodaySpend.unpriced`). Declared here because the
39
+ * unit's schemas are `.strict()`: an undeclared key REJECTS the whole unit
40
+ * rather than being stripped, so adding these fields to the fold without
41
+ * declaring them here would fail every projection fold the moment a sample
42
+ * went unpriced.
43
+ */
44
+ const usageTallySchema = z.object({
45
+ events: z.number().int().nonnegative(),
46
+ tokens: z.number().int().nonnegative(),
47
+ models: z.array(z.string()),
48
+ }).strict();
36
49
  const todaySpendSchema = z.object({
37
50
  total: z.number().nonnegative(),
38
51
  models: z.array(modelRowSchema),
52
+ unpriced: usageTallySchema.optional(),
39
53
  }).strict();
40
54
  const billingUnitSchema = z.object({
41
55
  dayKey: z.string(),
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Session lineage and titles: which session delegated which, where a subtree's
3
+ * spend belongs, and the title a ranking row is labelled with. Split out of
4
+ * `today-spend.ts` because neither the cache nor the scan strategies care
5
+ * about lineage for their own sake — they only ask for it.
6
+ * @module @rayadesu/dsh-llm-billing/session-lineage
7
+ */
8
+ import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session';
9
+ import type { DeepSeekTodaySessionSpend } from './types.ts';
10
+ export declare function foldSessionTitle(events: readonly SessionEvent[]): string | null;
11
+ /**
12
+ * Structural slice of a session's durable lineage: the header fields DSH
13
+ * stamps on a delegation child (`origin: 'subagent'`, the delegating session's
14
+ * id, and the depth that survives persistence). All three are read
15
+ * structurally, so the scanner works on every runtime family — a log written
16
+ * before the fields existed simply carries none of them and reads as a
17
+ * top-level session.
18
+ */
19
+ export interface SessionLineage {
20
+ /** The session this one was forked from or delegated by; absent for a top-level session. */
21
+ readonly parentSession?: SessionId;
22
+ /** DSH's subagent-child classification (`childSessionMeta` stamps it). */
23
+ readonly origin?: 'subagent';
24
+ /** Delegation depth: absent (zero) at the top level, parent depth + 1 for a subagent child. */
25
+ readonly delegationDepth?: number;
26
+ }
27
+ /**
28
+ * Whether one session's durable header marks it as a subagent child. Either
29
+ * marker is enough: `origin` is DSH's navigation classification and
30
+ * `delegationDepth` is its persisted recursion budget, so a header carrying
31
+ * only the depth (or only the origin) is still a delegation child. A session
32
+ * created without either — an ordinary session, a user fork, or a cold resume
33
+ * — is top-level.
34
+ * @param header - the session's lineage slice; `undefined` reads as top-level.
35
+ * @returns true when the session was created as a subagent child.
36
+ */
37
+ export declare function isSubagentSession(header: SessionLineage | undefined): boolean;
38
+ /**
39
+ * The top-level session one session's ranking row belongs to: the session
40
+ * itself for a top-level session, and for a subagent child the first ancestor
41
+ * up the `parentSession` chain that is not itself a subagent child. A
42
+ * multi-generation delegation (a subagent that spawned subagents) therefore
43
+ * lands on the same root row as its parent, and a child whose parent header is
44
+ * unknown is attributed to the parent id its own header names — the parent is
45
+ * authoritative even when its log is not part of this scan.
46
+ * @param id - the session whose row is being attributed.
47
+ * @param lineage - lineage of every session this scan saw, by id.
48
+ * @returns the session id whose ranking row the input belongs to.
49
+ */
50
+ export declare function topLevelSessionOf(id: SessionId, lineage: ReadonlyMap<SessionId, SessionLineage>): SessionId;
51
+ /**
52
+ * Fold every subagent child's row into the top-level row it belongs to
53
+ * ({@link topLevelSessionOf}), so the ranking lists conversations rather than
54
+ * every delegation a conversation started. A child's spend is added to its
55
+ * ancestor's `total`; the ancestor's `ownTotal` keeps its own spend only. A
56
+ * top-level session whose own day was empty but whose subagents priced
57
+ * something still gets a row (with `ownTotal` 0), carrying the title the scan
58
+ * resolved for it in `titles`.
59
+ * @param rows - one row per session that priced something today (own spends).
60
+ * @param lineage - lineage of every session this scan saw, by id.
61
+ * @param titles - resolved display titles by session id; a session absent from
62
+ * the map has no resolved title and its created row reports `null`.
63
+ * @returns the merged rows, sorted by `total` descending.
64
+ */
65
+ export declare function rollUpSubagentSpend(rows: readonly DeepSeekTodaySessionSpend[], lineage: ReadonlyMap<SessionId, SessionLineage>, titles?: ReadonlyMap<SessionId, string | null>): DeepSeekTodaySessionSpend[];
66
+ /**
67
+ * Structural slice of a live session's header: the fork boundary of both DSH
68
+ * runtime families plus the delegation lineage the ranking roll-up reads.
69
+ */
70
+ //# sourceMappingURL=session-lineage.d.ts.map
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Session lineage and titles: which session delegated which, where a subtree's
3
+ * spend belongs, and the title a ranking row is labelled with. Split out of
4
+ * `today-spend.ts` because neither the cache nor the scan strategies care
5
+ * about lineage for their own sake — they only ask for it.
6
+ * @module @rayadesu/dsh-llm-billing/session-lineage
7
+ */
8
+ export function foldSessionTitle(events) {
9
+ for (let index = events.length - 1; index >= 0; index--) {
10
+ const event = events[index];
11
+ // `session/title` joined the SessionEvent union after the npm
12
+ // 0.1.1-rc.2 baseline this package builds against; read its payload
13
+ // through the structural escape hatch (runtime logs carry it).
14
+ if (event.type !== 'session/title')
15
+ continue;
16
+ const data = event.data;
17
+ return typeof data.title === 'string' ? data.title : null;
18
+ }
19
+ return null;
20
+ }
21
+ /**
22
+ * Whether one session's durable header marks it as a subagent child. Either
23
+ * marker is enough: `origin` is DSH's navigation classification and
24
+ * `delegationDepth` is its persisted recursion budget, so a header carrying
25
+ * only the depth (or only the origin) is still a delegation child. A session
26
+ * created without either — an ordinary session, a user fork, or a cold resume
27
+ * — is top-level.
28
+ * @param header - the session's lineage slice; `undefined` reads as top-level.
29
+ * @returns true when the session was created as a subagent child.
30
+ */
31
+ export function isSubagentSession(header) {
32
+ if (header === undefined)
33
+ return false;
34
+ return header.origin === 'subagent' || (header.delegationDepth ?? 0) > 0;
35
+ }
36
+ /**
37
+ * The top-level session one session's ranking row belongs to: the session
38
+ * itself for a top-level session, and for a subagent child the first ancestor
39
+ * up the `parentSession` chain that is not itself a subagent child. A
40
+ * multi-generation delegation (a subagent that spawned subagents) therefore
41
+ * lands on the same root row as its parent, and a child whose parent header is
42
+ * unknown is attributed to the parent id its own header names — the parent is
43
+ * authoritative even when its log is not part of this scan.
44
+ * @param id - the session whose row is being attributed.
45
+ * @param lineage - lineage of every session this scan saw, by id.
46
+ * @returns the session id whose ranking row the input belongs to.
47
+ */
48
+ export function topLevelSessionOf(id, lineage) {
49
+ let current = id;
50
+ // A malformed log could claim a delegation cycle; each step visits a
51
+ // distinct ancestor, so a repeat ends the walk instead of spinning.
52
+ const seen = new Set([current]);
53
+ for (;;) {
54
+ const header = lineage.get(current);
55
+ if (!isSubagentSession(header))
56
+ return current;
57
+ const parent = header?.parentSession;
58
+ if (parent === undefined || seen.has(parent))
59
+ return current;
60
+ seen.add(parent);
61
+ current = parent;
62
+ }
63
+ }
64
+ /**
65
+ * Fold every subagent child's row into the top-level row it belongs to
66
+ * ({@link topLevelSessionOf}), so the ranking lists conversations rather than
67
+ * every delegation a conversation started. A child's spend is added to its
68
+ * ancestor's `total`; the ancestor's `ownTotal` keeps its own spend only. A
69
+ * top-level session whose own day was empty but whose subagents priced
70
+ * something still gets a row (with `ownTotal` 0), carrying the title the scan
71
+ * resolved for it in `titles`.
72
+ * @param rows - one row per session that priced something today (own spends).
73
+ * @param lineage - lineage of every session this scan saw, by id.
74
+ * @param titles - resolved display titles by session id; a session absent from
75
+ * the map has no resolved title and its created row reports `null`.
76
+ * @returns the merged rows, sorted by `total` descending.
77
+ */
78
+ export function rollUpSubagentSpend(rows, lineage, titles = new Map()) {
79
+ const merged = new Map();
80
+ for (const row of rows) {
81
+ const target = topLevelSessionOf(row.sessionId, lineage);
82
+ const carried = merged.get(target);
83
+ if (carried === undefined) {
84
+ // A row's own session keeps its own total as `ownTotal`; a row created
85
+ // for an ancestor that priced nothing today carries zero there.
86
+ merged.set(target, target === row.sessionId
87
+ ? row
88
+ : { sessionId: target, title: titles.get(target) ?? null, total: row.total, ownTotal: 0 });
89
+ continue;
90
+ }
91
+ merged.set(target, { ...carried, total: carried.total + row.total });
92
+ }
93
+ return [...merged.values()].sort((left, right) => right.total - left.total);
94
+ }
95
+ /**
96
+ * Structural slice of a live session's header: the fork boundary of both DSH
97
+ * runtime families plus the delegation lineage the ranking roll-up reads.
98
+ */
99
+ //# sourceMappingURL=session-lineage.js.map
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Spend arithmetic: how two spends combine, how one is inverted, and how the
3
+ * samples that could NOT be priced are tallied beside them. Pure, and knowing
4
+ * nothing about rates, timezones or event shapes — the pricing engine
5
+ * (`billing.ts`) owns all of that.
6
+ * @module @rayadesu/dsh-llm-billing/spend
7
+ */
8
+ import type { BillingEventContribution } from './pricing.ts';
9
+ import type { DeepSeekTodaySpend } from './types.ts';
10
+ /** A spend with no priced usage. */
11
+ export declare function emptyTodaySpend(): DeepSeekTodaySpend;
12
+ /** Note one sample that matched no pricing row and was therefore not billed. */
13
+ export declare function noteUnpriced(spend: DeepSeekTodaySpend, model: string, tokens: number): DeepSeekTodaySpend;
14
+ /**
15
+ * Mutable model-row accumulator behind every spend fold. Rows keep first-seen
16
+ * model order — the same shape a pure `addEventContribution` chain produces —
17
+ * so the single-pass scan path and the pure public paths cannot diverge. One
18
+ * `Map` lookup per contribution instead of a per-event array copy: the huge
19
+ * event-log folds allocate one row object per model, not one intermediate
20
+ * array per event.
21
+ */
22
+ export declare class SpendAccumulator {
23
+ private readonly rows;
24
+ private total;
25
+ /** Add one priced contribution. */
26
+ add(priced: BillingEventContribution): void;
27
+ /** The folded spend; the accumulator stays usable afterwards. */
28
+ finish(): DeepSeekTodaySpend;
29
+ }
30
+ /** The additive inverse of one spend (pure): used to replace a priced sample. */
31
+ export declare function negateSpend(spend: DeepSeekTodaySpend): DeepSeekTodaySpend;
32
+ /**
33
+ * Subtract one spend from another (pure). Rows that cancel out completely are
34
+ * dropped so a replaced sample leaves no zero row behind.
35
+ * @param target - the spend to subtract from.
36
+ * @param source - the spend to remove.
37
+ * @returns the difference.
38
+ */
39
+ export declare function subtractSpend(target: DeepSeekTodaySpend, source: DeepSeekTodaySpend): DeepSeekTodaySpend;
40
+ /** The contribution as a one-row spend (the shape a sample keeps for replacement). */
41
+ export declare function contributionSpend(priced: BillingEventContribution): DeepSeekTodaySpend;
42
+ /**
43
+ * Merge one priced event's contribution into an accumulator spend (pure:
44
+ * returns a new spend, never mutates its input).
45
+ * @param spend - the accumulator (per session and day, or across sessions).
46
+ * @param priced - the priced contribution to add.
47
+ * @returns the merged spend.
48
+ */
49
+ export declare function addEventContribution(spend: DeepSeekTodaySpend, priced: BillingEventContribution): DeepSeekTodaySpend;
50
+ /**
51
+ * Sum two spends (per session and day, or across sessions) into one (pure:
52
+ * returns a new spend, never mutates its inputs).
53
+ * @param target - the accumulator spend.
54
+ * @param source - the spend to add.
55
+ * @returns the summed spend.
56
+ */
57
+ export declare function mergeTodaySpend(target: DeepSeekTodaySpend, source: DeepSeekTodaySpend): DeepSeekTodaySpend;
58
+ //# sourceMappingURL=spend.d.ts.map
@@ -0,0 +1,190 @@
1
+ /**
2
+ * Spend arithmetic: how two spends combine, how one is inverted, and how the
3
+ * samples that could NOT be priced are tallied beside them. Pure, and knowing
4
+ * nothing about rates, timezones or event shapes — the pricing engine
5
+ * (`billing.ts`) owns all of that.
6
+ * @module @rayadesu/dsh-llm-billing/spend
7
+ */
8
+ /** A spend with no priced usage. */
9
+ export function emptyTodaySpend() {
10
+ return { total: 0, models: [] };
11
+ }
12
+ /** Sum two usage tallies (pure); an absent side stays absent. */
13
+ function mergeUsageTally(left, right) {
14
+ if (left === undefined)
15
+ return right;
16
+ if (right === undefined)
17
+ return left;
18
+ return {
19
+ events: left.events + right.events,
20
+ tokens: left.tokens + right.tokens,
21
+ models: [...new Set([...left.models, ...right.models])],
22
+ };
23
+ }
24
+ /** Add one affected sample to a tally (pure). */
25
+ function addUsageTally(previous, model, tokens) {
26
+ const models = previous?.models ?? [];
27
+ return {
28
+ events: (previous?.events ?? 0) + 1,
29
+ tokens: (previous?.tokens ?? 0) + tokens,
30
+ models: models.includes(model) ? models : [...models, model],
31
+ };
32
+ }
33
+ /**
34
+ * The advisory tallies carried BESIDE the priced rows, summed over `spends` as
35
+ * a partial you can spread into any spend literal. Keys stay absent rather
36
+ * than becoming `undefined` (`exactOptionalPropertyTypes`), so a fully priced
37
+ * spend stays structurally identical to what every existing test asserts.
38
+ */
39
+ function usageTalliesOf(spends) {
40
+ let unpriced;
41
+ for (const spend of spends)
42
+ unpriced = mergeUsageTally(unpriced, spend.unpriced);
43
+ return { ...(unpriced === undefined ? {} : { unpriced }) };
44
+ }
45
+ /** Note one sample that matched no pricing row and was therefore not billed. */
46
+ export function noteUnpriced(spend, model, tokens) {
47
+ return {
48
+ total: spend.total,
49
+ models: spend.models,
50
+ unpriced: addUsageTally(spend.unpriced, model, tokens),
51
+ };
52
+ }
53
+ /** The today-spend shape of a single priced contribution. */
54
+ function contributionModel(priced) {
55
+ return {
56
+ model: priced.model,
57
+ displayName: priced.displayName,
58
+ cost: priced.cost,
59
+ peakCost: priced.peakCost,
60
+ offPeakCost: priced.offPeakCost,
61
+ cacheHitInputTokens: priced.cacheHitInputTokens,
62
+ cacheMissInputTokens: priced.cacheMissInputTokens,
63
+ outputTokens: priced.outputTokens,
64
+ cacheHitInputCost: priced.cacheHitInputCost,
65
+ cacheMissInputCost: priced.cacheMissInputCost,
66
+ outputCost: priced.outputCost,
67
+ };
68
+ }
69
+ /** Sum two model rows of the same model (pure). */
70
+ function mergeModelRows(left, right) {
71
+ return {
72
+ model: left.model,
73
+ displayName: left.displayName,
74
+ cost: left.cost + right.cost,
75
+ peakCost: left.peakCost + right.peakCost,
76
+ offPeakCost: left.offPeakCost + right.offPeakCost,
77
+ cacheHitInputTokens: left.cacheHitInputTokens + right.cacheHitInputTokens,
78
+ cacheMissInputTokens: left.cacheMissInputTokens + right.cacheMissInputTokens,
79
+ outputTokens: left.outputTokens + right.outputTokens,
80
+ cacheHitInputCost: left.cacheHitInputCost + right.cacheHitInputCost,
81
+ cacheMissInputCost: left.cacheMissInputCost + right.cacheMissInputCost,
82
+ outputCost: left.outputCost + right.outputCost,
83
+ };
84
+ }
85
+ /**
86
+ * Mutable model-row accumulator behind every spend fold. Rows keep first-seen
87
+ * model order — the same shape a pure `addEventContribution` chain produces —
88
+ * so the single-pass scan path and the pure public paths cannot diverge. One
89
+ * `Map` lookup per contribution instead of a per-event array copy: the huge
90
+ * event-log folds allocate one row object per model, not one intermediate
91
+ * array per event.
92
+ */
93
+ export class SpendAccumulator {
94
+ rows = new Map();
95
+ total = 0;
96
+ /** Add one priced contribution. */
97
+ add(priced) {
98
+ const row = contributionModel(priced);
99
+ const existing = this.rows.get(priced.model);
100
+ this.rows.set(priced.model, existing === undefined ? row : mergeModelRows(existing, row));
101
+ this.total += priced.cost;
102
+ }
103
+ /** The folded spend; the accumulator stays usable afterwards. */
104
+ finish() {
105
+ return { total: this.total, models: [...this.rows.values()] };
106
+ }
107
+ }
108
+ /** The additive inverse of one spend (pure): used to replace a priced sample. */
109
+ export function negateSpend(spend) {
110
+ const negate = (value) => -value;
111
+ return {
112
+ total: negate(spend.total),
113
+ // Carried, not inverted: the tallies are counts of what was missed, not
114
+ // billed amounts, so subtracting a priced sample cannot cancel them.
115
+ ...usageTalliesOf([spend]),
116
+ models: spend.models.map(row => ({
117
+ ...row,
118
+ cost: negate(row.cost),
119
+ peakCost: negate(row.peakCost),
120
+ offPeakCost: negate(row.offPeakCost),
121
+ cacheHitInputTokens: negate(row.cacheHitInputTokens),
122
+ cacheMissInputTokens: negate(row.cacheMissInputTokens),
123
+ outputTokens: negate(row.outputTokens),
124
+ cacheHitInputCost: negate(row.cacheHitInputCost),
125
+ cacheMissInputCost: negate(row.cacheMissInputCost),
126
+ outputCost: negate(row.outputCost),
127
+ })),
128
+ };
129
+ }
130
+ /**
131
+ * Subtract one spend from another (pure). Rows that cancel out completely are
132
+ * dropped so a replaced sample leaves no zero row behind.
133
+ * @param target - the spend to subtract from.
134
+ * @param source - the spend to remove.
135
+ * @returns the difference.
136
+ */
137
+ export function subtractSpend(target, source) {
138
+ const rows = new Map();
139
+ for (const row of target.models)
140
+ rows.set(row.model, row);
141
+ for (const row of source.models) {
142
+ const existing = rows.get(row.model);
143
+ if (existing === undefined)
144
+ continue;
145
+ const next = mergeModelRows(existing, negateSpend({ total: 0, models: [row] }).models[0]);
146
+ if (next.cost === 0 && next.cacheHitInputTokens === 0 && next.cacheMissInputTokens === 0 && next.outputTokens === 0) {
147
+ rows.delete(row.model);
148
+ }
149
+ else {
150
+ rows.set(row.model, next);
151
+ }
152
+ }
153
+ return { total: target.total - source.total, models: [...rows.values()], ...usageTalliesOf([target]) };
154
+ }
155
+ /** The contribution as a one-row spend (the shape a sample keeps for replacement). */
156
+ export function contributionSpend(priced) {
157
+ return { total: priced.cost, models: [contributionModel(priced)] };
158
+ }
159
+ /**
160
+ * Merge one priced event's contribution into an accumulator spend (pure:
161
+ * returns a new spend, never mutates its input).
162
+ * @param spend - the accumulator (per session and day, or across sessions).
163
+ * @param priced - the priced contribution to add.
164
+ * @returns the merged spend.
165
+ */
166
+ export function addEventContribution(spend, priced) {
167
+ const row = contributionModel(priced);
168
+ const rows = spend.models.map(existing => existing.model === priced.model ? mergeModelRows(existing, row) : existing);
169
+ if (!rows.some(existing => existing.model === priced.model))
170
+ rows.push(row);
171
+ return { total: spend.total + priced.cost, models: rows, ...usageTalliesOf([spend]) };
172
+ }
173
+ /**
174
+ * Sum two spends (per session and day, or across sessions) into one (pure:
175
+ * returns a new spend, never mutates its inputs).
176
+ * @param target - the accumulator spend.
177
+ * @param source - the spend to add.
178
+ * @returns the summed spend.
179
+ */
180
+ export function mergeTodaySpend(target, source) {
181
+ const rows = new Map();
182
+ for (const row of target.models)
183
+ rows.set(row.model, row);
184
+ for (const row of source.models) {
185
+ const existing = rows.get(row.model);
186
+ rows.set(row.model, existing === undefined ? row : mergeModelRows(existing, row));
187
+ }
188
+ return { total: target.total + source.total, models: [...rows.values()], ...usageTalliesOf([target, source]) };
189
+ }
190
+ //# sourceMappingURL=spend.js.map