@rayadesu/dsh-llm-billing 0.3.16 → 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,110 @@
1
+ /**
2
+ * The read-path cache: one 60-second Beijing-day window per process with
3
+ * in-flight coalescing and stale-while-revalidate, plus the bounded fan-out
4
+ * helper the cold scan parallelises with. Split out of `today-spend.ts` — it is
5
+ * the one piece that knows nothing about sessions at all.
6
+ * @module @rayadesu/dsh-llm-billing/cache
7
+ */
8
+ import type { DeepSeekTodaySpend } from './types.ts';
9
+ /**
10
+ * Bounded parallel fan-out: run `run` over `items` with at most `limit` in
11
+ * flight. A shared index counter hands each worker its next job, so the
12
+ * dispatch is O(n) overall (array `shift()` would be O(n) per pop). The cold
13
+ * scan parallelises its session reads through this.
14
+ */
15
+ export declare function withConcurrency<T>(items: readonly T[], limit: number, run: (item: T) => Promise<void>): Promise<void>;
16
+ /**
17
+ * How many sessions one uninterrupted slice of a scan may fold before the scan
18
+ * hands the host's event loop back.
19
+ */
20
+ export declare const SCAN_YIELD_SESSIONS = 8;
21
+ /**
22
+ * Hand the host's event loop back between session folds.
23
+ *
24
+ * The folds are synchronous CPU work — the pricing fold and `foldSessionTitle`
25
+ * walk a whole log without an `await` — and they run on the same main loop that
26
+ * serves the GUI's own round trips (switching model, creating a session). A
27
+ * scan that never yields blocks those requests for its entire duration, which
28
+ * is exactly what a cold scan did. `setImmediate` schedules the continuation
29
+ * for the check phase, so the poll phase is served first: pending I/O — the
30
+ * GUI's requests among it — completes before the next slice of folding.
31
+ */
32
+ export declare function yieldToEventLoop(): Promise<void>;
33
+ /**
34
+ * The A1 cache: one Beijing-day key + a 60s window, an in-flight promise that
35
+ * coalesces concurrent misses, a stale-while-revalidate path so a lapsed window
36
+ * never makes a plain reader wait on the day's scan, and a `force` bypass that
37
+ * makes a reader wait for a fresh scan instead. Cross-day invalidation is
38
+ * automatic (the day key changes, and the previous day's value is never
39
+ * served); a failed scan leaves the previous value in place and retries on the
40
+ * next call.
41
+ * @typeParam T - the cached aggregate's value shape (the spend or its
42
+ * per-session breakdown).
43
+ */
44
+ export declare class TodaySpendCache<T = DeepSeekTodaySpend> {
45
+ private readonly scan;
46
+ private readonly ttlMs;
47
+ private readonly now;
48
+ private cachedDayKey;
49
+ private cachedValue;
50
+ private cachedAt;
51
+ private inFlight;
52
+ /**
53
+ * @param scan - the aggregate computation behind a miss.
54
+ * @param ttlMs - time window in milliseconds (default 60 000).
55
+ * @param now - clock source (injectable for tests).
56
+ */
57
+ constructor(scan: (dayKey: string) => Promise<T>, ttlMs?: number, now?: () => Date);
58
+ /**
59
+ * Read today's spend, cached per Beijing day within the TTL window.
60
+ *
61
+ * Two reads share this entry point, and `force` is the switch between them —
62
+ * its two values are exactly the two policies a caller can want, so there is
63
+ * no third mode:
64
+ *
65
+ * - **plain (`force: false`)** — stale-while-revalidate. A value for the
66
+ * queried day is returned AT ONCE; once its window has lapsed the scan that
67
+ * refreshes it runs behind the answer. This is the read for everything the
68
+ * user did not just cause: a mount, a panel open, a poll, a session switch.
69
+ * - **forced (`force: true`)** — recompute now and WAIT: both the window and
70
+ * the stale shortcut are bypassed. This is the read for a manual refresh and
71
+ * for a settled turn, which have just changed the answer and must not be
72
+ * handed the value from before they ran. A plain read there would report the
73
+ * day's pre-turn figure one turn late.
74
+ *
75
+ * A day with no value yet (the first read of the day, or a Beijing-day
76
+ * rollover — yesterday's total is never served as today's) waits either way,
77
+ * because there is nothing honest to show. A failed revalidation leaves the
78
+ * previous value in place, and a pass already in flight is joined rather than
79
+ * duplicated.
80
+ * @param force - `true` recomputes now and waits; `false` serves the value on
81
+ * hand and refreshes behind it.
82
+ * @returns today's spend.
83
+ */
84
+ get(force?: boolean): Promise<T>;
85
+ /**
86
+ * Run one scan and publish its result.
87
+ *
88
+ * `cachedAt` is stamped when the scan COMPLETES, not when it starts: a pass
89
+ * slower than the TTL would otherwise be born expired, and every read after
90
+ * it would start another pass back-to-back.
91
+ * @param dayKey - the Beijing day the pass aggregates.
92
+ * @returns the freshly scanned value.
93
+ */
94
+ private run;
95
+ }
96
+ /** Max session-ids kept in the scanner's cold-resolution cache before eviction. */
97
+ export declare const COLD_RESOLVE_CACHE_LIMIT = 1024;
98
+ /** Max session-ids kept in the scanner's cold-failure cache before eviction. */
99
+ export declare const COLD_FAILED_CACHE_LIMIT = 1024;
100
+ /**
101
+ * How long a failed cold resolution is remembered before it is retried even
102
+ * though the log is unchanged. Most read failures are transient (a lock held
103
+ * during a concurrent write), so remembering them forever would cost a session
104
+ * for the rest of the process.
105
+ */
106
+ export declare const COLD_FAILED_RETRY_MS = 300000;
107
+ /** Bounded parallel fan-out for cold-session resolution. */
108
+ export declare const COLD_RESOLVE_CONCURRENCY = 8;
109
+ /** The live SessionStore slice a scan reads (resolved once per scan). */
110
+ //# sourceMappingURL=cache.d.ts.map
@@ -0,0 +1,155 @@
1
+ /**
2
+ * The read-path cache: one 60-second Beijing-day window per process with
3
+ * in-flight coalescing and stale-while-revalidate, plus the bounded fan-out
4
+ * helper the cold scan parallelises with. Split out of `today-spend.ts` — it is
5
+ * the one piece that knows nothing about sessions at all.
6
+ * @module @rayadesu/dsh-llm-billing/cache
7
+ */
8
+ import { beijingDayKey } from "./billing.js";
9
+ /**
10
+ * Bounded parallel fan-out: run `run` over `items` with at most `limit` in
11
+ * flight. A shared index counter hands each worker its next job, so the
12
+ * dispatch is O(n) overall (array `shift()` would be O(n) per pop). The cold
13
+ * scan parallelises its session reads through this.
14
+ */
15
+ export async function withConcurrency(items, limit, run) {
16
+ const total = items.length;
17
+ let next = 0;
18
+ await Promise.all(Array.from({ length: Math.min(limit, total) }, async () => {
19
+ for (let job = next; job < total; job = next) {
20
+ next += 1;
21
+ await run(items[job]);
22
+ }
23
+ }));
24
+ }
25
+ /**
26
+ * How many sessions one uninterrupted slice of a scan may fold before the scan
27
+ * hands the host's event loop back.
28
+ */
29
+ export const SCAN_YIELD_SESSIONS = 8;
30
+ /**
31
+ * Hand the host's event loop back between session folds.
32
+ *
33
+ * The folds are synchronous CPU work — the pricing fold and `foldSessionTitle`
34
+ * walk a whole log without an `await` — and they run on the same main loop that
35
+ * serves the GUI's own round trips (switching model, creating a session). A
36
+ * scan that never yields blocks those requests for its entire duration, which
37
+ * is exactly what a cold scan did. `setImmediate` schedules the continuation
38
+ * for the check phase, so the poll phase is served first: pending I/O — the
39
+ * GUI's requests among it — completes before the next slice of folding.
40
+ */
41
+ export function yieldToEventLoop() {
42
+ return new Promise((resolve) => { setImmediate(resolve); });
43
+ }
44
+ /**
45
+ * The A1 cache: one Beijing-day key + a 60s window, an in-flight promise that
46
+ * coalesces concurrent misses, a stale-while-revalidate path so a lapsed window
47
+ * never makes a plain reader wait on the day's scan, and a `force` bypass that
48
+ * makes a reader wait for a fresh scan instead. Cross-day invalidation is
49
+ * automatic (the day key changes, and the previous day's value is never
50
+ * served); a failed scan leaves the previous value in place and retries on the
51
+ * next call.
52
+ * @typeParam T - the cached aggregate's value shape (the spend or its
53
+ * per-session breakdown).
54
+ */
55
+ export class TodaySpendCache {
56
+ scan;
57
+ ttlMs;
58
+ now;
59
+ cachedDayKey;
60
+ cachedValue;
61
+ cachedAt = 0;
62
+ inFlight;
63
+ /**
64
+ * @param scan - the aggregate computation behind a miss.
65
+ * @param ttlMs - time window in milliseconds (default 60 000).
66
+ * @param now - clock source (injectable for tests).
67
+ */
68
+ constructor(scan, ttlMs = 60_000, now = () => new Date()) {
69
+ this.scan = scan;
70
+ this.ttlMs = ttlMs;
71
+ this.now = now;
72
+ }
73
+ /**
74
+ * Read today's spend, cached per Beijing day within the TTL window.
75
+ *
76
+ * Two reads share this entry point, and `force` is the switch between them —
77
+ * its two values are exactly the two policies a caller can want, so there is
78
+ * no third mode:
79
+ *
80
+ * - **plain (`force: false`)** — stale-while-revalidate. A value for the
81
+ * queried day is returned AT ONCE; once its window has lapsed the scan that
82
+ * refreshes it runs behind the answer. This is the read for everything the
83
+ * user did not just cause: a mount, a panel open, a poll, a session switch.
84
+ * - **forced (`force: true`)** — recompute now and WAIT: both the window and
85
+ * the stale shortcut are bypassed. This is the read for a manual refresh and
86
+ * for a settled turn, which have just changed the answer and must not be
87
+ * handed the value from before they ran. A plain read there would report the
88
+ * day's pre-turn figure one turn late.
89
+ *
90
+ * A day with no value yet (the first read of the day, or a Beijing-day
91
+ * rollover — yesterday's total is never served as today's) waits either way,
92
+ * because there is nothing honest to show. A failed revalidation leaves the
93
+ * previous value in place, and a pass already in flight is joined rather than
94
+ * duplicated.
95
+ * @param force - `true` recomputes now and waits; `false` serves the value on
96
+ * hand and refreshes behind it.
97
+ * @returns today's spend.
98
+ */
99
+ get(force = false) {
100
+ const now = this.now();
101
+ const dayKey = beijingDayKey(now);
102
+ // Only the queried day's value is servable: yesterday's total is not
103
+ // today's, so a rollover blocks exactly like a cold read.
104
+ const cached = this.cachedDayKey === dayKey ? this.cachedValue : undefined;
105
+ if (!force && cached !== undefined) {
106
+ if (now.getTime() - this.cachedAt < this.ttlMs)
107
+ return Promise.resolve(cached);
108
+ // Stale, same day: serve it now and refresh behind it. The background
109
+ // pass must not surface as an unhandled rejection.
110
+ if (this.inFlight === undefined)
111
+ void this.run(dayKey).catch(() => undefined);
112
+ return Promise.resolve(cached);
113
+ }
114
+ // No value for the queried day (or a forced read): a scan already in
115
+ // flight is fresh by definition, so a forced caller joins it instead of
116
+ // starting a second pass.
117
+ if (this.inFlight !== undefined)
118
+ return this.inFlight;
119
+ return this.run(dayKey);
120
+ }
121
+ /**
122
+ * Run one scan and publish its result.
123
+ *
124
+ * `cachedAt` is stamped when the scan COMPLETES, not when it starts: a pass
125
+ * slower than the TTL would otherwise be born expired, and every read after
126
+ * it would start another pass back-to-back.
127
+ * @param dayKey - the Beijing day the pass aggregates.
128
+ * @returns the freshly scanned value.
129
+ */
130
+ run(dayKey) {
131
+ const run = this.scan(dayKey).then((value) => {
132
+ this.cachedDayKey = dayKey;
133
+ this.cachedValue = value;
134
+ this.cachedAt = this.now().getTime();
135
+ return value;
136
+ }).finally(() => { this.inFlight = undefined; });
137
+ this.inFlight = run;
138
+ return run;
139
+ }
140
+ }
141
+ /** Max session-ids kept in the scanner's cold-resolution cache before eviction. */
142
+ export const COLD_RESOLVE_CACHE_LIMIT = 1024;
143
+ /** Max session-ids kept in the scanner's cold-failure cache before eviction. */
144
+ export const COLD_FAILED_CACHE_LIMIT = 1024;
145
+ /**
146
+ * How long a failed cold resolution is remembered before it is retried even
147
+ * though the log is unchanged. Most read failures are transient (a lock held
148
+ * during a concurrent write), so remembering them forever would cost a session
149
+ * for the rest of the process.
150
+ */
151
+ export const COLD_FAILED_RETRY_MS = 300_000;
152
+ /** Bounded parallel fan-out for cold-session resolution. */
153
+ export const COLD_RESOLVE_CONCURRENCY = 8;
154
+ /** The live SessionStore slice a scan reads (resolved once per scan). */
155
+ //# sourceMappingURL=cache.js.map
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The single-pass fold state machine that turns a committed event log into a
3
+ * session's billed spend. Consumes the pricing primitives in `pricing.ts`
4
+ * (`priceUsage` / `reportUnpricedModel`) and the spend algebra in `spend.ts`
5
+ * (`addEventContribution` / `subtractSpend` / `noteUnpriced` / `emptyTodaySpend`);
6
+ * every scan path prices through {@link applyBillingEvent} so a pricing-table
7
+ * change cannot drift one path from the others.
8
+ * @module @rayadesu/dsh-llm-billing/fold
9
+ */
10
+ import type { SessionEvent } from '@deepseek-ai/dsh-session';
11
+ import type { DeepSeekTodaySpend } from './types.ts';
12
+ import type { ResolvedBilling } from './pricing.ts';
13
+ /**
14
+ * One priced attempt sample kept for same-step replacement: DSH can report the
15
+ * same `(turn, step)` twice (an `assistant/attempt` stream and the
16
+ * `assistant/message` that assembles from it), and a later sample replaces the
17
+ * earlier one instead of adding to it. `llm/retry-started` clears the slot, so
18
+ * a retried attempt adds rather than replaces (both requests were billed).
19
+ */
20
+ export interface BillingFoldSample {
21
+ /** Turn of the producing attempt. */
22
+ turn: number;
23
+ /** Step of the producing attempt. */
24
+ step: number;
25
+ /** Beijing day of the sample's timestamp. */
26
+ dayKey: string;
27
+ /** The sample's contribution as a one-row spend (subtracted on replacement). */
28
+ spend: DeepSeekTodaySpend;
29
+ }
30
+ /**
31
+ * Plain-JSON fold state of one session's billed spend: the latest priced day,
32
+ * the whole-session total, the fork boundary, the model of the latest request
33
+ * (needed to price an `assistant/attempt`, which carries no route), and the
34
+ * last sample kept for replacement.
35
+ */
36
+ export interface BillingFoldState {
37
+ /** Beijing-time calendar-day key of `spend`; `''` for no priced usage. */
38
+ dayKey: string;
39
+ /** The spend of the session's latest priced Beijing day (own events only). */
40
+ spend: DeepSeekTodaySpend;
41
+ /** The spend of the session's OWN events across every day. */
42
+ session: DeepSeekTodaySpend;
43
+ /** Fork-inherited prefix length; events below it belong to the source session. */
44
+ inheritedEventCount: number;
45
+ /** Wire model of the latest `request/header`; `''` before the first one. */
46
+ model: string;
47
+ /** Latest priced attempt sample, for same-step replacement. */
48
+ last: BillingFoldSample | null;
49
+ }
50
+ /** The empty fold state for one fork boundary. */
51
+ export declare function emptyBillingFoldState(inheritedEventCount?: number): BillingFoldState;
52
+ /**
53
+ * Fold one committed event into a session's billed-spend state.
54
+ *
55
+ * Priced samples come from `assistant/message` (its own reported usage, or the
56
+ * stream's last usage chunk) and `assistant/attempt` (the stream's last usage
57
+ * chunk, priced with the model of the latest `request/header`, since an
58
+ * attempt carries no route). A sample for the same `(turn, step)` replaces the
59
+ * previous one; `llm/retry-started` closes the replacement slot so a retried
60
+ * attempt adds. Every other event is inert and returns the same state
61
+ * reference.
62
+ * @param state - the previous fold state.
63
+ * @param event - the committed event.
64
+ * @param billing - resolved pricing with peak-hour windows.
65
+ * @param names - model id → display label.
66
+ * @returns the next state (the same reference when nothing was priced).
67
+ */
68
+ export declare function applyBillingEvent(state: BillingFoldState, event: SessionEvent, billing: ResolvedBilling, names: ReadonlyMap<string, string>): BillingFoldState;
69
+ /**
70
+ * Mutable wrapper over {@link applyBillingEvent} for the pure pricing paths:
71
+ * feed events in order, read the folded spend.
72
+ */
73
+ export declare class BillingFolder {
74
+ private readonly billing;
75
+ private state;
76
+ /**
77
+ * @param billing - resolved pricing with peak-hour windows.
78
+ * @param catalog - model display rows, in presentation order.
79
+ * @param inheritedEventCount - fork boundary to skip (default 0).
80
+ */
81
+ constructor(billing: ResolvedBilling, catalog: readonly {
82
+ id: string;
83
+ name: string;
84
+ }[], inheritedEventCount?: number);
85
+ private readonly names;
86
+ /** Fold one event. */
87
+ add(event: SessionEvent): void;
88
+ /** Fold every event, in order. */
89
+ addAll(events: readonly SessionEvent[]): void;
90
+ /** The folded state (live reference; do not mutate). */
91
+ get fold(): BillingFoldState;
92
+ }
93
+ //# sourceMappingURL=fold.d.ts.map
@@ -0,0 +1,188 @@
1
+ /**
2
+ * The single-pass fold state machine that turns a committed event log into a
3
+ * session's billed spend. Consumes the pricing primitives in `pricing.ts`
4
+ * (`priceUsage` / `reportUnpricedModel`) and the spend algebra in `spend.ts`
5
+ * (`addEventContribution` / `subtractSpend` / `noteUnpriced` / `emptyTodaySpend`);
6
+ * every scan path prices through {@link applyBillingEvent} so a pricing-table
7
+ * change cannot drift one path from the others.
8
+ * @module @rayadesu/dsh-llm-billing/fold
9
+ */
10
+ import { beijingPartsOf } from "./beijing-time.js";
11
+ import { priceUsage, reportUnpricedModel, usageTokens } from "./pricing.js";
12
+ import { addEventContribution, contributionSpend, emptyTodaySpend, noteUnpriced, subtractSpend } from "./spend.js";
13
+ /** The empty fold state for one fork boundary. */
14
+ export function emptyBillingFoldState(inheritedEventCount = 0) {
15
+ return {
16
+ dayKey: '',
17
+ spend: emptyTodaySpend(),
18
+ session: emptyTodaySpend(),
19
+ inheritedEventCount,
20
+ model: '',
21
+ last: null,
22
+ };
23
+ }
24
+ /** Whether an unknown value looks like a provider usage report. */
25
+ function isTokenUsage(value) {
26
+ if (typeof value !== 'object' || value === null)
27
+ return false;
28
+ const candidate = value;
29
+ return typeof candidate.inputTokens === 'number' && typeof candidate.outputTokens === 'number';
30
+ }
31
+ /**
32
+ * The last `usage` sample embedded in an event's stream, if any. `assistant/
33
+ * attempt` and the embedded streams are newer than the plugin's npm baseline,
34
+ * so the stream is read structurally (a failed/retried attempt reports its
35
+ * usage only there).
36
+ */
37
+ function streamUsageOf(event) {
38
+ const stream = event.data === undefined
39
+ ? undefined
40
+ : event.data.stream;
41
+ if (!Array.isArray(stream))
42
+ return undefined;
43
+ for (let index = stream.length - 1; index >= 0; index -= 1) {
44
+ const chunk = stream[index]?.chunk;
45
+ if (chunk === undefined || chunk.type !== 'usage')
46
+ continue;
47
+ return isTokenUsage(chunk.usage) ? chunk.usage : undefined;
48
+ }
49
+ return undefined;
50
+ }
51
+ /**
52
+ * Fold one committed event into a session's billed-spend state.
53
+ *
54
+ * Priced samples come from `assistant/message` (its own reported usage, or the
55
+ * stream's last usage chunk) and `assistant/attempt` (the stream's last usage
56
+ * chunk, priced with the model of the latest `request/header`, since an
57
+ * attempt carries no route). A sample for the same `(turn, step)` replaces the
58
+ * previous one; `llm/retry-started` closes the replacement slot so a retried
59
+ * attempt adds. Every other event is inert and returns the same state
60
+ * reference.
61
+ * @param state - the previous fold state.
62
+ * @param event - the committed event.
63
+ * @param billing - resolved pricing with peak-hour windows.
64
+ * @param names - model id → display label.
65
+ * @returns the next state (the same reference when nothing was priced).
66
+ */
67
+ export function applyBillingEvent(state, event, billing, names) {
68
+ if (event.seq < state.inheritedEventCount)
69
+ return state;
70
+ // `assistant/attempt`, `llm/retry-started`, and the embedded stream are all
71
+ // newer than the npm baseline this package builds against, so their fields
72
+ // are read structurally.
73
+ const type = event.type;
74
+ if (type === 'request/header') {
75
+ const model = event.data
76
+ ?.header?.config?.model;
77
+ return typeof model === 'string' && model.length > 0 && model !== state.model ? { ...state, model } : state;
78
+ }
79
+ const data = event.data;
80
+ if (type === 'llm/retry-started') {
81
+ if (typeof data?.turn !== 'number' || typeof data.step !== 'number')
82
+ return state;
83
+ const last = state.last;
84
+ if (last === null || last.turn !== data.turn || last.step !== data.step)
85
+ return state;
86
+ return { ...state, last: null };
87
+ }
88
+ if (type !== 'assistant/message' && type !== 'assistant/attempt')
89
+ return state;
90
+ const usage = (type === 'assistant/message' ? data?.usage : undefined) ?? streamUsageOf(event);
91
+ if (!isTokenUsage(usage))
92
+ return state;
93
+ const model = type === 'assistant/message' ? data?.message?.source?.model : state.model;
94
+ if (typeof model !== 'string' || model.length === 0)
95
+ return state;
96
+ const parts = beijingPartsOf(event.time);
97
+ const rows = billing.models.get(model);
98
+ if (rows === undefined) {
99
+ // Unlisted upstream model: no rate row means no price, and no price is
100
+ // guessed — a similar-looking id says nothing about a similar price, and a
101
+ // wrong number that LOOKS authoritative is worse than a visible gap. The
102
+ // usage is recorded rather than dropped, so today's ¥0 can say WHY.
103
+ reportUnpricedModel(model);
104
+ const tokens = usageTokens(usage);
105
+ // Mirror the priced day rule so the tally reaches the day view: an
106
+ // unpriced sample opens the day when the fold has none yet and rolls it
107
+ // forward. Otherwise the very case this exists for — a session whose
108
+ // EVERY sample is unpriced — would keep reading as "no usage at all"
109
+ // with nothing recorded anywhere the today figure is read from.
110
+ const opens = state.dayKey === '' || parts.dayKey > state.dayKey;
111
+ const sameDay = parts.dayKey === state.dayKey;
112
+ return {
113
+ ...state,
114
+ dayKey: opens ? parts.dayKey : state.dayKey,
115
+ spend: opens
116
+ ? noteUnpriced(emptyTodaySpend(), model, tokens)
117
+ : sameDay ? noteUnpriced(state.spend, model, tokens) : state.spend,
118
+ session: noteUnpriced(state.session, model, tokens),
119
+ };
120
+ }
121
+ const priced = priceUsage(parts, usage, model, billing, names);
122
+ // Unreachable — the row was resolved just above — but the lookup's own type
123
+ // says `| undefined`, so the branch is spelled out rather than asserted away.
124
+ if (priced === undefined)
125
+ return state;
126
+ let session = state.session;
127
+ let spend = state.spend;
128
+ let dayKey = state.dayKey;
129
+ const last = state.last;
130
+ const turn = typeof data?.turn === 'number' ? data.turn : 0;
131
+ const step = typeof data?.step === 'number' ? data.step : 0;
132
+ if (last !== null && last.turn === turn && last.step === step) {
133
+ session = subtractSpend(session, last.spend);
134
+ if (last.dayKey === dayKey)
135
+ spend = subtractSpend(spend, last.spend);
136
+ }
137
+ session = addEventContribution(session, priced);
138
+ if (dayKey === priced.dayKey) {
139
+ spend = addEventContribution(spend, priced);
140
+ }
141
+ else if (dayKey === '' || priced.dayKey > dayKey) {
142
+ // The session log is append-only and chronological, so a strictly older
143
+ // day cannot legally follow; ignore it for the latest-day state (the
144
+ // whole-session total still accrues).
145
+ dayKey = priced.dayKey;
146
+ spend = addEventContribution(emptyTodaySpend(), priced);
147
+ }
148
+ return {
149
+ ...state,
150
+ dayKey,
151
+ spend,
152
+ session,
153
+ last: { turn, step, dayKey: priced.dayKey, spend: contributionSpend(priced) },
154
+ };
155
+ }
156
+ /**
157
+ * Mutable wrapper over {@link applyBillingEvent} for the pure pricing paths:
158
+ * feed events in order, read the folded spend.
159
+ */
160
+ export class BillingFolder {
161
+ billing;
162
+ state;
163
+ /**
164
+ * @param billing - resolved pricing with peak-hour windows.
165
+ * @param catalog - model display rows, in presentation order.
166
+ * @param inheritedEventCount - fork boundary to skip (default 0).
167
+ */
168
+ constructor(billing, catalog, inheritedEventCount = 0) {
169
+ this.billing = billing;
170
+ this.names = new Map(catalog.map(model => [model.id, model.name]));
171
+ this.state = emptyBillingFoldState(inheritedEventCount);
172
+ }
173
+ names;
174
+ /** Fold one event. */
175
+ add(event) {
176
+ this.state = applyBillingEvent(this.state, event, this.billing, this.names);
177
+ }
178
+ /** Fold every event, in order. */
179
+ addAll(events) {
180
+ for (const event of events)
181
+ this.add(event);
182
+ }
183
+ /** The folded state (live reference; do not mutate). */
184
+ get fold() {
185
+ return this.state;
186
+ }
187
+ }
188
+ //# sourceMappingURL=fold.js.map
@@ -30,7 +30,7 @@ export type * from './types.ts';
30
30
  export { BILLING_UNIT_KEY, billingTodaySpendDefinition, foldBillingUnit, foldOwnBilling } from './projection.ts';
31
31
  export type { BillingUnitFold, BillingUnitState } from './projection.ts';
32
32
  export { delegatedSpendOf, foldSessionTitle, isSubagentSession, liveSessionEvents, persistenceInspect, persistenceListSnapshots, rollUpSubagentSpend, TodaySpendCache, TodaySpendScanner, topLevelSessionOf, } from './today-spend.ts';
33
- export type { ColdResolution, ScannerPersistedHeader, ScannerPersistence, ScannerPersistenceHandle, ScannerPersistenceLegacy, ScannerPersistedRead, ScannerSession, SessionHeaderSlice, SessionLineage, TodaySpendScannerDeps, } from './today-spend.ts';
33
+ export type { ColdResolution, ScannerPersistedHeader, ScannerPersistence, ScannerPersistenceHandle, ScannerPersistedRead, ScannerSession, SessionHeaderSlice, SessionLineage, TodaySpendScannerDeps, } from './today-spend.ts';
34
34
  export declare const name = "llm-billing";
35
35
  /** Public API default; deployments may point elsewhere via $DEEPSEEK_BASE_URL. */
36
36
  export declare const PUBLIC_BASE_URL = "https://api.deepseek.com";
@@ -116,7 +116,7 @@ function evictOldest(map, limit) {
116
116
  /**
117
117
  * Read one session's event log and durable seed boundary: the live
118
118
  * SessionStore first, then the persistence backend for a flushed session
119
- * (inspected directly by id — no header listing). The live-session surface
119
+ * (opened directly by id — no header listing). The live-session surface
120
120
  * is read structurally across both DSH runtime families — `Session.events`
121
121
  * (≤ 0.1.1-rc.2) or `Session.snapshotEvents()` + `Session.inheritedEventCount`
122
122
  * (0.1.2-alpha.4+) — via {@link liveSessionEvents} / {@link forkBoundaryOf}.