@rayadesu/dsh-llm-billing 0.3.16 → 0.3.18
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.
- package/README.i18n.yaml +2 -2
- package/README.md +19 -8
- package/README.zh.md +20 -9
- package/icon.svg +14 -0
- package/lib/index.js +749 -416
- package/lib/typert.host.js +17 -7
- package/lib/typert.remote-client.js +17 -7
- package/lib/types/balance.d.ts +10 -0
- package/lib/types/balance.js +79 -13
- package/lib/types/beijing-time.d.ts +52 -0
- package/lib/types/beijing-time.js +88 -0
- package/lib/types/billing.d.ts +27 -388
- package/lib/types/billing.js +28 -627
- package/lib/types/cache.d.ts +110 -0
- package/lib/types/cache.js +155 -0
- package/lib/types/fold.d.ts +93 -0
- package/lib/types/fold.js +188 -0
- package/lib/types/index.d.ts +1 -1
- package/lib/types/index.js +1 -1
- package/lib/types/persistence.d.ts +113 -0
- package/lib/types/persistence.js +63 -0
- package/lib/types/pricing-table.d.ts +43 -0
- package/lib/types/pricing-table.js +118 -0
- package/lib/types/pricing.d.ts +203 -0
- package/lib/types/pricing.js +203 -0
- package/lib/types/projection.js +14 -0
- package/lib/types/session-lineage.d.ts +70 -0
- package/lib/types/session-lineage.js +99 -0
- package/lib/types/spend.d.ts +58 -0
- package/lib/types/spend.js +190 -0
- package/lib/types/today-spend.d.ts +27 -233
- package/lib/types/today-spend.js +73 -254
- package/lib/types/types.d.ts +28 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +21 -17
|
@@ -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
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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,
|
|
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";
|
package/lib/types/index.js
CHANGED
|
@@ -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
|
-
* (
|
|
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}.
|