@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.
- package/README.i18n.yaml +2 -2
- package/README.md +18 -7
- package/README.zh.md +19 -8
- package/icon.svg +7 -0
- package/lib/index.js +791 -423
- 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 -614
- 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 +8 -5
- 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 +5 -1
package/lib/types/billing.js
CHANGED
|
@@ -1,233 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* DeepSeek billing: the
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* a key.
|
|
2
|
+
* DeepSeek billing aggregation: the session/turn/today spend computations
|
|
3
|
+
* over the pricing engine (`pricing.ts`) and the per-event fold state machine
|
|
4
|
+
* (`fold.ts`). Pure functions over session events, so the whole spend is
|
|
5
|
+
* testable without a key and the Remote gateway stays transport-free.
|
|
6
6
|
*
|
|
7
|
-
* The
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* through the same primitives, so a pricing-table change cannot drift one path
|
|
12
|
-
* from the others.
|
|
7
|
+
* The fold in {@link BillingFolder} / {@link applyBillingEvent} (see `fold.ts`)
|
|
8
|
+
* and the per-event pricing in `pricing.ts` are the single source of truth the
|
|
9
|
+
* events-scan paths, the session-projection unit, and the scanner all price
|
|
10
|
+
* through, so a pricing-table change cannot drift one path from the others.
|
|
13
11
|
* @module @rayadesu/dsh-llm-billing/billing
|
|
14
12
|
*/
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
* Inclusive epoch ms of the published V4 Flash series re-pricing:
|
|
26
|
-
* 2026-09-10 12:00 Beijing time (UTC+8, no DST) = 04:00 UTC. Samples before
|
|
27
|
-
* this instant keep the base rates; samples at or after it bill at the second
|
|
28
|
-
* revision.
|
|
29
|
-
*/
|
|
30
|
-
export const FLASH_SERIES_RATE_CHANGE_AT = Date.UTC(2026, 8, 10, 4, 0, 0);
|
|
31
|
-
/**
|
|
32
|
-
* Inclusive epoch ms of the announced V4 Pro route switch: 2026-09-14 12:00
|
|
33
|
-
* Beijing time (UTC+8, no DST) = 04:00 UTC. From that instant the V4 Pro route
|
|
34
|
-
* is served by V4.1 Flash and billed at the V4.1 Flash rates.
|
|
35
|
-
*/
|
|
36
|
-
export const V4_PRO_ROUTE_SWITCH_AT = Date.UTC(2026, 8, 14, 4, 0, 0);
|
|
37
|
-
/** The V4 Flash series' base rates (effective 2026-08-17), CNY per 1M tokens. */
|
|
38
|
-
const FLASH_BASE_RATES = {
|
|
39
|
-
peak: { cacheHitInput: 0.10, cacheMissInput: 3.0, output: 9.0 },
|
|
40
|
-
offPeak: { cacheHitInput: 0.05, cacheMissInput: 1.5, output: 4.5 },
|
|
41
|
-
};
|
|
42
|
-
/**
|
|
43
|
-
* The V4 Flash series' second revision (effective
|
|
44
|
-
* {@link FLASH_SERIES_RATE_CHANGE_AT}): off-peak 0.02 / 1.0 / 4.0, peak at
|
|
45
|
-
* twice those prices.
|
|
46
|
-
*/
|
|
47
|
-
const FLASH_REPRICED_RATES = {
|
|
48
|
-
effectiveFrom: FLASH_SERIES_RATE_CHANGE_AT,
|
|
49
|
-
peak: { cacheHitInput: 0.04, cacheMissInput: 2.0, output: 8.0 },
|
|
50
|
-
offPeak: { cacheHitInput: 0.02, cacheMissInput: 1.0, output: 4.0 },
|
|
51
|
-
};
|
|
52
|
-
/**
|
|
53
|
-
* The V4.1 Flash rates as they reach the retired V4 Pro route from
|
|
54
|
-
* {@link V4_PRO_ROUTE_SWITCH_AT}: the same price pair as the flash series'
|
|
55
|
-
* second revision, carried at its own effective instant.
|
|
56
|
-
*/
|
|
57
|
-
const V4_PRO_SWITCHED_RATES = {
|
|
58
|
-
effectiveFrom: V4_PRO_ROUTE_SWITCH_AT,
|
|
59
|
-
peak: FLASH_REPRICED_RATES.peak,
|
|
60
|
-
offPeak: FLASH_REPRICED_RATES.offPeak,
|
|
61
|
-
};
|
|
62
|
-
/**
|
|
63
|
-
* Official peak/off-peak rates (CNY per 1M tokens) per model, as dated
|
|
64
|
-
* revisions. Base rows are the schedule effective 2026-08-17; the V4 Flash
|
|
65
|
-
* series (V4.1 Flash, V4 Flash, V4 Flash Vision Exp) carries the second
|
|
66
|
-
* revision effective 2026-09-10 12:00 Beijing, and the V4 Pro row the V4.1
|
|
67
|
-
* Flash rates from its announced route switch (2026-09-14 12:00 Beijing) —
|
|
68
|
-
* the MiMo-V2.5 series is untouched by either adjustment. Rows sharing a model
|
|
69
|
-
* are that model's rate history.
|
|
70
|
-
*/
|
|
71
|
-
export const DEFAULT_MODEL_PRICING = [
|
|
72
|
-
// deepseek-flash is the V4.1 Flash route, DSH's default catalog entry; image
|
|
73
|
-
// inputs are converted to tokens at the same per-token price.
|
|
74
|
-
{ model: 'deepseek-flash', ...FLASH_BASE_RATES },
|
|
75
|
-
{ model: 'deepseek-flash', ...FLASH_REPRICED_RATES },
|
|
76
|
-
{ model: 'deepseek-v4-flash', ...FLASH_BASE_RATES },
|
|
77
|
-
{ model: 'deepseek-v4-flash', ...FLASH_REPRICED_RATES },
|
|
78
|
-
// deepseek-v4.1-flash-expires-on-0910 was the V4.1 Flash preview route,
|
|
79
|
-
// retired when the model was released on 2026-09-10; its rows stay so the
|
|
80
|
-
// logs that used it keep pricing.
|
|
81
|
-
{ model: 'deepseek-v4.1-flash-expires-on-0910', ...FLASH_BASE_RATES },
|
|
82
|
-
{ model: 'deepseek-v4.1-flash-expires-on-0910', ...FLASH_REPRICED_RATES },
|
|
83
|
-
{
|
|
84
|
-
model: 'deepseek-v4-pro',
|
|
85
|
-
peak: { cacheHitInput: 0.30, cacheMissInput: 9.0, output: 27.0 },
|
|
86
|
-
offPeak: { cacheHitInput: 0.15, cacheMissInput: 4.5, output: 13.5 },
|
|
87
|
-
},
|
|
88
|
-
// From the announced route switch V4 Pro is served by V4.1 Flash and billed
|
|
89
|
-
// at the V4.1 Flash rates.
|
|
90
|
-
{ model: 'deepseek-v4-pro', ...V4_PRO_SWITCHED_RATES },
|
|
91
|
-
// deepseek-v4-flash-vision-exp bills at the same rates as deepseek-v4-flash;
|
|
92
|
-
// images are converted to tokens at the same per-token price.
|
|
93
|
-
{ model: 'deepseek-v4-flash-vision-exp', ...FLASH_BASE_RATES },
|
|
94
|
-
{ model: 'deepseek-v4-flash-vision-exp', ...FLASH_REPRICED_RATES },
|
|
95
|
-
// MiMo-V2.5 series (Xiaomi): flat rate, no peak/off-peak distinction.
|
|
96
|
-
{
|
|
97
|
-
model: 'mimo-v2.5-pro',
|
|
98
|
-
peak: { cacheHitInput: 0.025, cacheMissInput: 3.0, output: 6.0 },
|
|
99
|
-
offPeak: { cacheHitInput: 0.025, cacheMissInput: 3.0, output: 6.0 },
|
|
100
|
-
},
|
|
101
|
-
{
|
|
102
|
-
model: 'mimo-v2.5',
|
|
103
|
-
peak: { cacheHitInput: 0.02, cacheMissInput: 1.0, output: 2.0 },
|
|
104
|
-
offPeak: { cacheHitInput: 0.02, cacheMissInput: 1.0, output: 2.0 },
|
|
105
|
-
},
|
|
106
|
-
];
|
|
107
|
-
/**
|
|
108
|
-
* Resolve optional configuration to a pricing table, defaulting omitted or
|
|
109
|
-
* empty rows to the published rates. Schemastery materializes an absent
|
|
110
|
-
* `z.array` as `[]` rather than `undefined`, so emptiness — not just absence —
|
|
111
|
-
* selects the defaults. Explicit non-empty rows override the same model; a
|
|
112
|
-
* supplied non-empty `models` list is authoritative.
|
|
113
|
-
*
|
|
114
|
-
* Rows sharing a model are that model's rate revisions, kept in ascending
|
|
115
|
-
* `effectiveFrom` order (an undated base revision first). Two rows declaring
|
|
116
|
-
* the same effective instant are one revision and the later row wins — the
|
|
117
|
-
* historical override rule — so re-declaring a model can neither duplicate a
|
|
118
|
-
* revision nor install a second undated base.
|
|
119
|
-
* @param config - optional raw billing configuration.
|
|
120
|
-
* @returns the resolved table (per model: its revisions plus the newest rates) and peak-hour windows.
|
|
121
|
-
*/
|
|
122
|
-
export function resolveBilling(config) {
|
|
123
|
-
const peakHours = config?.peakHours !== undefined && config.peakHours.length > 0
|
|
124
|
-
? config.peakHours
|
|
125
|
-
: DEFAULT_PEAK_HOURS;
|
|
126
|
-
const rows = config?.models !== undefined && config.models.length > 0
|
|
127
|
-
? config.models
|
|
128
|
-
: DEFAULT_MODEL_PRICING;
|
|
129
|
-
const schedules = new Map();
|
|
130
|
-
for (const row of rows) {
|
|
131
|
-
// Spelled out per branch: `exactOptionalPropertyTypes` forbids handing an
|
|
132
|
-
// explicit `undefined` to an optional field.
|
|
133
|
-
const revision = row.effectiveFrom === undefined
|
|
134
|
-
? { peak: row.peak, offPeak: row.offPeak }
|
|
135
|
-
: { effectiveFrom: row.effectiveFrom, peak: row.peak, offPeak: row.offPeak };
|
|
136
|
-
const revisions = schedules.get(row.model);
|
|
137
|
-
if (revisions === undefined) {
|
|
138
|
-
schedules.set(row.model, [revision]);
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
141
|
-
const duplicate = revisions.findIndex(candidate => candidate.effectiveFrom === revision.effectiveFrom);
|
|
142
|
-
if (duplicate >= 0)
|
|
143
|
-
revisions[duplicate] = revision;
|
|
144
|
-
else
|
|
145
|
-
revisions.push(revision);
|
|
146
|
-
}
|
|
147
|
-
const models = new Map();
|
|
148
|
-
for (const [model, revisions] of schedules) {
|
|
149
|
-
revisions.sort((left, right) => (left.effectiveFrom ?? Number.NEGATIVE_INFINITY) - (right.effectiveFrom ?? Number.NEGATIVE_INFINITY));
|
|
150
|
-
const newest = revisions[revisions.length - 1];
|
|
151
|
-
models.set(model, { peak: newest.peak, offPeak: newest.offPeak, revisions });
|
|
152
|
-
}
|
|
153
|
-
return { peakHours, models };
|
|
154
|
-
}
|
|
155
|
-
/**
|
|
156
|
-
* The rate revision in effect at one instant: the newest revision that took
|
|
157
|
-
* effect at or before it. Revisions are ascending, so the scan stops at the
|
|
158
|
-
* first future one. An instant before the earliest dated revision bills at that
|
|
159
|
-
* earliest revision — a model with only dated rows is never left unpriced.
|
|
160
|
-
*/
|
|
161
|
-
function ratesAt(revisions, time) {
|
|
162
|
-
let chosen = revisions[0];
|
|
163
|
-
for (let index = 1; index < revisions.length; index += 1) {
|
|
164
|
-
const revision = revisions[index];
|
|
165
|
-
if (revision.effectiveFrom === undefined || revision.effectiveFrom > time)
|
|
166
|
-
break;
|
|
167
|
-
chosen = revision;
|
|
168
|
-
}
|
|
169
|
-
return chosen;
|
|
170
|
-
}
|
|
171
|
-
/** Beijing is a fixed UTC+8 offset with no DST. */
|
|
172
|
-
const BEIJING_OFFSET_MS = 8 * 3_600_000;
|
|
173
|
-
/** Milliseconds in one day. */
|
|
174
|
-
const DAY_MS = 86_400_000;
|
|
175
|
-
/** Epoch day of 1970-01-01 in the civil-date algorithm below. */
|
|
176
|
-
const CIVIL_EPOCH_DAY = 719_468;
|
|
177
|
-
/** Two-digit zero pad for a calendar field. */
|
|
178
|
-
function pad2(value) {
|
|
179
|
-
return value < 10 ? `0${value}` : String(value);
|
|
180
|
-
}
|
|
181
|
-
/**
|
|
182
|
-
* Civil date of an epoch day (Howard Hinnant's days-from-civil inverse):
|
|
183
|
-
* pure integer arithmetic, no `Date` allocation and no ISO-string slicing.
|
|
184
|
-
*/
|
|
185
|
-
function civilDateOf(epochDay) {
|
|
186
|
-
const shifted = epochDay + CIVIL_EPOCH_DAY;
|
|
187
|
-
const era = Math.floor(shifted / 146_097);
|
|
188
|
-
const dayOfEra = shifted - era * 146_097;
|
|
189
|
-
const yearOfEra = Math.floor((dayOfEra - Math.floor(dayOfEra / 1_460) + Math.floor(dayOfEra / 36_524) - Math.floor(dayOfEra / 146_096)) / 365);
|
|
190
|
-
const year = yearOfEra + era * 400;
|
|
191
|
-
const dayOfYear = dayOfEra - (365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100));
|
|
192
|
-
const monthPrime = Math.floor((5 * dayOfYear + 2) / 153);
|
|
193
|
-
const month = monthPrime + (monthPrime < 10 ? 3 : -9);
|
|
194
|
-
return {
|
|
195
|
-
// January/February belong to the civil year AFTER the era year.
|
|
196
|
-
year: month <= 2 ? year + 1 : year,
|
|
197
|
-
month,
|
|
198
|
-
day: dayOfYear - Math.floor((153 * monthPrime + 2) / 5) + 1,
|
|
199
|
-
};
|
|
200
|
-
}
|
|
201
|
-
/**
|
|
202
|
-
* Derive the Beijing hour, weekday, and calendar-day key of one timestamp with
|
|
203
|
-
* pure integer arithmetic — every timezone-sensitive read shares this one
|
|
204
|
-
* implementation, so the pieces cannot drift apart. Callers that filter by
|
|
205
|
-
* day and then price the same event reuse the returned view, so each event is
|
|
206
|
-
* parsed exactly once. (The hot fold path runs this per committed event; the
|
|
207
|
-
* previous `Date` + `toISOString().slice()` version allocated a `Date` and a
|
|
208
|
-
* 24-character string per call.)
|
|
209
|
-
* @param time - epoch milliseconds.
|
|
210
|
-
* @throws {RangeError} when `time` is not a finite number.
|
|
211
|
-
*/
|
|
212
|
-
export function beijingPartsOf(time) {
|
|
213
|
-
if (!Number.isFinite(time))
|
|
214
|
-
throw new RangeError(`billing: event time is not finite (${String(time)})`);
|
|
215
|
-
const shifted = time + BEIJING_OFFSET_MS;
|
|
216
|
-
const epochDay = Math.floor(shifted / DAY_MS);
|
|
217
|
-
const msOfDay = shifted - epochDay * DAY_MS;
|
|
218
|
-
const civil = civilDateOf(epochDay);
|
|
219
|
-
return {
|
|
220
|
-
time,
|
|
221
|
-
hour: Math.floor(msOfDay / 3_600_000),
|
|
222
|
-
// 1970-01-01 was a Thursday (4).
|
|
223
|
-
weekday: ((epochDay + 4) % 7 + 7) % 7,
|
|
224
|
-
dayKey: `${civil.year}-${pad2(civil.month)}-${pad2(civil.day)}`,
|
|
225
|
-
};
|
|
226
|
-
}
|
|
227
|
-
/** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
|
|
228
|
-
export function beijingDayKey(now) {
|
|
229
|
-
return beijingPartsOf(now.getTime()).dayKey;
|
|
230
|
-
}
|
|
13
|
+
import { beijingDayKey, beijingDayRangeOfKey } from "./beijing-time.js";
|
|
14
|
+
import { emptyTodaySpend } from "./spend.js";
|
|
15
|
+
import { BillingFolder } from "./fold.js";
|
|
16
|
+
// Re-exported so the package's public surface and its tests keep importing
|
|
17
|
+
// them from this module: the pricing engine, the fold state machine, the
|
|
18
|
+
// Beijing-time helpers, and the spend algebra.
|
|
19
|
+
export * from "./beijing-time.js";
|
|
20
|
+
export * from "./spend.js";
|
|
21
|
+
export * from "./pricing.js";
|
|
22
|
+
export * from "./fold.js";
|
|
231
23
|
/**
|
|
232
24
|
* The durable inherited-prefix boundary of one session: the number of leading
|
|
233
25
|
* events it inherited verbatim from its fork source, 0 for a session created
|
|
@@ -235,9 +27,9 @@ export function beijingDayKey(now) {
|
|
|
235
27
|
* in its session state; every event with `seq < seedLength` is a copy of an
|
|
236
28
|
* event already billed in that source session, so pricing must skip them or
|
|
237
29
|
* the same model output is counted once per copy. Accepts the durable field
|
|
238
|
-
* of
|
|
239
|
-
* @param source - the session,
|
|
240
|
-
*
|
|
30
|
+
* of the live-session and handle surfaces (see {@link ForkBoundarySource}).
|
|
31
|
+
* @param source - the session, header slice, or opened handle carrying the
|
|
32
|
+
* boundary; `undefined` reads as 0.
|
|
241
33
|
* @returns the inherited-prefix length; 0 for an unseeded session.
|
|
242
34
|
*/
|
|
243
35
|
export function forkBoundaryOf(source) {
|
|
@@ -246,9 +38,9 @@ export function forkBoundaryOf(source) {
|
|
|
246
38
|
const inherited = source.inheritedEventCount;
|
|
247
39
|
if (inherited !== undefined && Number.isSafeInteger(inherited))
|
|
248
40
|
return inherited;
|
|
249
|
-
return source.header?.seedLength ?? source.
|
|
41
|
+
return source.header?.seedLength ?? source.seedLength ?? 0;
|
|
250
42
|
}
|
|
251
|
-
/** Whether a durable header marks a fork-inherited (seeded) session across
|
|
43
|
+
/** Whether a durable header marks a fork-inherited (seeded) session across the live-session and handle surfaces. */
|
|
252
44
|
export function isSeededSession(header) {
|
|
253
45
|
if (header === undefined)
|
|
254
46
|
return false;
|
|
@@ -256,387 +48,6 @@ export function isSeededSession(header) {
|
|
|
256
48
|
return true;
|
|
257
49
|
return (header.seedLength ?? 0) > 0;
|
|
258
50
|
}
|
|
259
|
-
/** Whether a Beijing (hour, weekday) pair falls inside any peak-hour window. */
|
|
260
|
-
function isPeakParts(billing, hour, weekday) {
|
|
261
|
-
if (weekday === 0 || weekday === 6)
|
|
262
|
-
return false;
|
|
263
|
-
return billing.peakHours.some(({ start, end }) => hour >= start && hour < end);
|
|
264
|
-
}
|
|
265
|
-
/**
|
|
266
|
-
* Whether a timestamp falls inside any peak-hour window (Beijing time,
|
|
267
|
-
* weekdays Monday–Friday only). Weekends (Saturday and Sunday) are always
|
|
268
|
-
* off-peak, matching the published peak-hours rule.
|
|
269
|
-
* @param billing - resolved pricing with peak-hour windows.
|
|
270
|
-
* @param now - the moment to classify.
|
|
271
|
-
* @returns true during a weekday peak hour.
|
|
272
|
-
*/
|
|
273
|
-
export function isPeak(billing, now) {
|
|
274
|
-
const { hour, weekday } = beijingPartsOf(now.getTime());
|
|
275
|
-
return isPeakParts(billing, hour, weekday);
|
|
276
|
-
}
|
|
277
|
-
/**
|
|
278
|
-
* Price one event at the official per-model rates, applying the peak/off-peak
|
|
279
|
-
* table by its Beijing-time hour and weekday (peak windows apply Monday–Friday
|
|
280
|
-
* only; weekends are off-peak) and the rate revision in effect at its own
|
|
281
|
-
* timestamp. Each `assistant/message` event with usage
|
|
282
|
-
* contributes cache-hit input, cache-miss input (uncached input plus cache
|
|
283
|
-
* writes), and output (reasoning included) tokens at the rate of its own
|
|
284
|
-
* timestamp; a model with usage but no pricing row contributes nothing.
|
|
285
|
-
* @param event - the event to price.
|
|
286
|
-
* @param billing - resolved pricing with peak-hour windows.
|
|
287
|
-
* @param names - model id → display label.
|
|
288
|
-
* @returns the priced contribution, or `undefined` when the event has no priced usage.
|
|
289
|
-
*/
|
|
290
|
-
export function priceEvent(event, billing, names) {
|
|
291
|
-
return priceEventAt(beijingPartsOf(event.time), event, billing, names);
|
|
292
|
-
}
|
|
293
|
-
/**
|
|
294
|
-
* Price one event at the official per-model rates using a precomputed
|
|
295
|
-
* Beijing-time view — the day-filtering and pricing of one event share a
|
|
296
|
-
* single timezone parse (see {@link beijingPartsOf}). Semantics are identical
|
|
297
|
-
* to {@link priceEvent}.
|
|
298
|
-
* @param parts - the event's Beijing-time view.
|
|
299
|
-
* @param event - the event to price.
|
|
300
|
-
* @param billing - resolved pricing with peak-hour windows.
|
|
301
|
-
* @param names - model id → display label.
|
|
302
|
-
* @returns the priced contribution, or `undefined` when the event has no priced usage.
|
|
303
|
-
*/
|
|
304
|
-
export function priceEventAt(parts, event, billing, names) {
|
|
305
|
-
if (event.type !== 'assistant/message')
|
|
306
|
-
return undefined;
|
|
307
|
-
const reported = event.data.usage;
|
|
308
|
-
if (reported === undefined)
|
|
309
|
-
return undefined;
|
|
310
|
-
return priceUsage(parts, reported, event.data.message.source.model, billing, names);
|
|
311
|
-
}
|
|
312
|
-
/**
|
|
313
|
-
* Price one provider-reported usage sample for one model at the rates of the
|
|
314
|
-
* sample's own Beijing-time hour and weekday — the peak or off-peak price of
|
|
315
|
-
* the rate revision in effect at the sample's own timestamp (a re-priced series
|
|
316
|
-
* bills its history at the rates that applied then). `undefined` when the model
|
|
317
|
-
* has no pricing row.
|
|
318
|
-
* @param parts - the sample's Beijing-time view.
|
|
319
|
-
* @param usage - the reported token buckets.
|
|
320
|
-
* @param model - the wire model id the sample belongs to.
|
|
321
|
-
* @param billing - resolved pricing with peak-hour windows.
|
|
322
|
-
* @param names - model id → display label.
|
|
323
|
-
* @returns the priced contribution, or `undefined` when the model has no rate row.
|
|
324
|
-
*/
|
|
325
|
-
export function priceUsage(parts, usage, model, billing, names) {
|
|
326
|
-
const pricing = billing.models.get(model);
|
|
327
|
-
if (pricing === undefined)
|
|
328
|
-
return undefined;
|
|
329
|
-
const peak = isPeakParts(billing, parts.hour, parts.weekday);
|
|
330
|
-
const revision = ratesAt(pricing.revisions, parts.time);
|
|
331
|
-
const price = peak ? revision.peak : revision.offPeak;
|
|
332
|
-
const hit = usage.cacheReadTokens ?? 0;
|
|
333
|
-
const miss = usage.inputTokens + (usage.cacheWriteTokens ?? 0);
|
|
334
|
-
const output = usage.outputTokens;
|
|
335
|
-
const hitCost = (hit * price.cacheHitInput) / 1_000_000;
|
|
336
|
-
const missCost = (miss * price.cacheMissInput) / 1_000_000;
|
|
337
|
-
const outputCost = (output * price.output) / 1_000_000;
|
|
338
|
-
const cost = hitCost + missCost + outputCost;
|
|
339
|
-
return {
|
|
340
|
-
dayKey: parts.dayKey,
|
|
341
|
-
model,
|
|
342
|
-
displayName: names.get(model) ?? model,
|
|
343
|
-
cost,
|
|
344
|
-
peakCost: peak ? cost : 0,
|
|
345
|
-
offPeakCost: peak ? 0 : cost,
|
|
346
|
-
cacheHitInputTokens: hit,
|
|
347
|
-
cacheMissInputTokens: miss,
|
|
348
|
-
outputTokens: output,
|
|
349
|
-
cacheHitInputCost: hitCost,
|
|
350
|
-
cacheMissInputCost: missCost,
|
|
351
|
-
outputCost,
|
|
352
|
-
};
|
|
353
|
-
}
|
|
354
|
-
/** A spend with no priced usage. */
|
|
355
|
-
export function emptyTodaySpend() {
|
|
356
|
-
return { total: 0, models: [] };
|
|
357
|
-
}
|
|
358
|
-
/** The today-spend shape of a single priced contribution. */
|
|
359
|
-
function contributionModel(priced) {
|
|
360
|
-
return {
|
|
361
|
-
model: priced.model,
|
|
362
|
-
displayName: priced.displayName,
|
|
363
|
-
cost: priced.cost,
|
|
364
|
-
peakCost: priced.peakCost,
|
|
365
|
-
offPeakCost: priced.offPeakCost,
|
|
366
|
-
cacheHitInputTokens: priced.cacheHitInputTokens,
|
|
367
|
-
cacheMissInputTokens: priced.cacheMissInputTokens,
|
|
368
|
-
outputTokens: priced.outputTokens,
|
|
369
|
-
cacheHitInputCost: priced.cacheHitInputCost,
|
|
370
|
-
cacheMissInputCost: priced.cacheMissInputCost,
|
|
371
|
-
outputCost: priced.outputCost,
|
|
372
|
-
};
|
|
373
|
-
}
|
|
374
|
-
/** Sum two model rows of the same model (pure). */
|
|
375
|
-
function mergeModelRows(left, right) {
|
|
376
|
-
return {
|
|
377
|
-
model: left.model,
|
|
378
|
-
displayName: left.displayName,
|
|
379
|
-
cost: left.cost + right.cost,
|
|
380
|
-
peakCost: left.peakCost + right.peakCost,
|
|
381
|
-
offPeakCost: left.offPeakCost + right.offPeakCost,
|
|
382
|
-
cacheHitInputTokens: left.cacheHitInputTokens + right.cacheHitInputTokens,
|
|
383
|
-
cacheMissInputTokens: left.cacheMissInputTokens + right.cacheMissInputTokens,
|
|
384
|
-
outputTokens: left.outputTokens + right.outputTokens,
|
|
385
|
-
cacheHitInputCost: left.cacheHitInputCost + right.cacheHitInputCost,
|
|
386
|
-
cacheMissInputCost: left.cacheMissInputCost + right.cacheMissInputCost,
|
|
387
|
-
outputCost: left.outputCost + right.outputCost,
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
/**
|
|
391
|
-
* Mutable model-row accumulator behind every spend fold. Rows keep first-seen
|
|
392
|
-
* model order — the same shape a pure `addEventContribution` chain produces —
|
|
393
|
-
* so the single-pass scan path and the pure public paths cannot diverge. One
|
|
394
|
-
* `Map` lookup per contribution instead of a per-event array copy: the huge
|
|
395
|
-
* event-log folds allocate one row object per model, not one intermediate
|
|
396
|
-
* array per event.
|
|
397
|
-
*/
|
|
398
|
-
export class SpendAccumulator {
|
|
399
|
-
rows = new Map();
|
|
400
|
-
total = 0;
|
|
401
|
-
/** Add one priced contribution. */
|
|
402
|
-
add(priced) {
|
|
403
|
-
const row = contributionModel(priced);
|
|
404
|
-
const existing = this.rows.get(priced.model);
|
|
405
|
-
this.rows.set(priced.model, existing === undefined ? row : mergeModelRows(existing, row));
|
|
406
|
-
this.total += priced.cost;
|
|
407
|
-
}
|
|
408
|
-
/** The folded spend; the accumulator stays usable afterwards. */
|
|
409
|
-
finish() {
|
|
410
|
-
return { total: this.total, models: [...this.rows.values()] };
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
/** The additive inverse of one spend (pure): used to replace a priced sample. */
|
|
414
|
-
export function negateSpend(spend) {
|
|
415
|
-
const negate = (value) => -value;
|
|
416
|
-
return {
|
|
417
|
-
total: negate(spend.total),
|
|
418
|
-
models: spend.models.map(row => ({
|
|
419
|
-
...row,
|
|
420
|
-
cost: negate(row.cost),
|
|
421
|
-
peakCost: negate(row.peakCost),
|
|
422
|
-
offPeakCost: negate(row.offPeakCost),
|
|
423
|
-
cacheHitInputTokens: negate(row.cacheHitInputTokens),
|
|
424
|
-
cacheMissInputTokens: negate(row.cacheMissInputTokens),
|
|
425
|
-
outputTokens: negate(row.outputTokens),
|
|
426
|
-
cacheHitInputCost: negate(row.cacheHitInputCost),
|
|
427
|
-
cacheMissInputCost: negate(row.cacheMissInputCost),
|
|
428
|
-
outputCost: negate(row.outputCost),
|
|
429
|
-
})),
|
|
430
|
-
};
|
|
431
|
-
}
|
|
432
|
-
/**
|
|
433
|
-
* Subtract one spend from another (pure). Rows that cancel out completely are
|
|
434
|
-
* dropped so a replaced sample leaves no zero row behind.
|
|
435
|
-
* @param target - the spend to subtract from.
|
|
436
|
-
* @param source - the spend to remove.
|
|
437
|
-
* @returns the difference.
|
|
438
|
-
*/
|
|
439
|
-
export function subtractSpend(target, source) {
|
|
440
|
-
const rows = new Map();
|
|
441
|
-
for (const row of target.models)
|
|
442
|
-
rows.set(row.model, row);
|
|
443
|
-
for (const row of source.models) {
|
|
444
|
-
const existing = rows.get(row.model);
|
|
445
|
-
if (existing === undefined)
|
|
446
|
-
continue;
|
|
447
|
-
const next = mergeModelRows(existing, negateSpend({ total: 0, models: [row] }).models[0]);
|
|
448
|
-
if (next.cost === 0 && next.cacheHitInputTokens === 0 && next.cacheMissInputTokens === 0 && next.outputTokens === 0) {
|
|
449
|
-
rows.delete(row.model);
|
|
450
|
-
}
|
|
451
|
-
else {
|
|
452
|
-
rows.set(row.model, next);
|
|
453
|
-
}
|
|
454
|
-
}
|
|
455
|
-
return { total: target.total - source.total, models: [...rows.values()] };
|
|
456
|
-
}
|
|
457
|
-
/** The empty fold state for one fork boundary. */
|
|
458
|
-
export function emptyBillingFoldState(inheritedEventCount = 0) {
|
|
459
|
-
return {
|
|
460
|
-
dayKey: '',
|
|
461
|
-
spend: emptyTodaySpend(),
|
|
462
|
-
session: emptyTodaySpend(),
|
|
463
|
-
inheritedEventCount,
|
|
464
|
-
model: '',
|
|
465
|
-
last: null,
|
|
466
|
-
};
|
|
467
|
-
}
|
|
468
|
-
/** Whether an unknown value looks like a provider usage report. */
|
|
469
|
-
function isTokenUsage(value) {
|
|
470
|
-
if (typeof value !== 'object' || value === null)
|
|
471
|
-
return false;
|
|
472
|
-
const candidate = value;
|
|
473
|
-
return typeof candidate.inputTokens === 'number' && typeof candidate.outputTokens === 'number';
|
|
474
|
-
}
|
|
475
|
-
/**
|
|
476
|
-
* The last `usage` sample embedded in an event's stream, if any. `assistant/
|
|
477
|
-
* attempt` and the embedded streams are newer than the plugin's npm baseline,
|
|
478
|
-
* so the stream is read structurally (a failed/retried attempt reports its
|
|
479
|
-
* usage only there).
|
|
480
|
-
*/
|
|
481
|
-
function streamUsageOf(event) {
|
|
482
|
-
const stream = event.data === undefined
|
|
483
|
-
? undefined
|
|
484
|
-
: event.data.stream;
|
|
485
|
-
if (!Array.isArray(stream))
|
|
486
|
-
return undefined;
|
|
487
|
-
for (let index = stream.length - 1; index >= 0; index -= 1) {
|
|
488
|
-
const chunk = stream[index]?.chunk;
|
|
489
|
-
if (chunk === undefined || chunk.type !== 'usage')
|
|
490
|
-
continue;
|
|
491
|
-
return isTokenUsage(chunk.usage) ? chunk.usage : undefined;
|
|
492
|
-
}
|
|
493
|
-
return undefined;
|
|
494
|
-
}
|
|
495
|
-
/** The contribution as a one-row spend (the shape a sample keeps for replacement). */
|
|
496
|
-
function contributionSpend(priced) {
|
|
497
|
-
return { total: priced.cost, models: [contributionModel(priced)] };
|
|
498
|
-
}
|
|
499
|
-
/**
|
|
500
|
-
* Fold one committed event into a session's billed-spend state.
|
|
501
|
-
*
|
|
502
|
-
* Priced samples come from `assistant/message` (its own reported usage, or the
|
|
503
|
-
* stream's last usage chunk) and `assistant/attempt` (the stream's last usage
|
|
504
|
-
* chunk, priced with the model of the latest `request/header`, since an
|
|
505
|
-
* attempt carries no route). A sample for the same `(turn, step)` replaces the
|
|
506
|
-
* previous one; `llm/retry-started` closes the replacement slot so a retried
|
|
507
|
-
* attempt adds. Every other event is inert and returns the same state
|
|
508
|
-
* reference.
|
|
509
|
-
* @param state - the previous fold state.
|
|
510
|
-
* @param event - the committed event.
|
|
511
|
-
* @param billing - resolved pricing with peak-hour windows.
|
|
512
|
-
* @param names - model id → display label.
|
|
513
|
-
* @returns the next state (the same reference when nothing was priced).
|
|
514
|
-
*/
|
|
515
|
-
export function applyBillingEvent(state, event, billing, names) {
|
|
516
|
-
if (event.seq < state.inheritedEventCount)
|
|
517
|
-
return state;
|
|
518
|
-
// `assistant/attempt`, `llm/retry-started`, and the embedded stream are all
|
|
519
|
-
// newer than the npm baseline this package builds against, so their fields
|
|
520
|
-
// are read structurally.
|
|
521
|
-
const type = event.type;
|
|
522
|
-
if (type === 'request/header') {
|
|
523
|
-
const model = event.data
|
|
524
|
-
?.header?.config?.model;
|
|
525
|
-
return typeof model === 'string' && model.length > 0 && model !== state.model ? { ...state, model } : state;
|
|
526
|
-
}
|
|
527
|
-
const data = event.data;
|
|
528
|
-
if (type === 'llm/retry-started') {
|
|
529
|
-
if (typeof data?.turn !== 'number' || typeof data.step !== 'number')
|
|
530
|
-
return state;
|
|
531
|
-
const last = state.last;
|
|
532
|
-
if (last === null || last.turn !== data.turn || last.step !== data.step)
|
|
533
|
-
return state;
|
|
534
|
-
return { ...state, last: null };
|
|
535
|
-
}
|
|
536
|
-
if (type !== 'assistant/message' && type !== 'assistant/attempt')
|
|
537
|
-
return state;
|
|
538
|
-
const usage = (type === 'assistant/message' ? data?.usage : undefined) ?? streamUsageOf(event);
|
|
539
|
-
if (!isTokenUsage(usage))
|
|
540
|
-
return state;
|
|
541
|
-
const model = type === 'assistant/message' ? data?.message?.source?.model : state.model;
|
|
542
|
-
if (typeof model !== 'string' || model.length === 0)
|
|
543
|
-
return state;
|
|
544
|
-
const priced = priceUsage(beijingPartsOf(event.time), usage, model, billing, names);
|
|
545
|
-
if (priced === undefined)
|
|
546
|
-
return state;
|
|
547
|
-
let session = state.session;
|
|
548
|
-
let spend = state.spend;
|
|
549
|
-
let dayKey = state.dayKey;
|
|
550
|
-
const last = state.last;
|
|
551
|
-
const turn = typeof data?.turn === 'number' ? data.turn : 0;
|
|
552
|
-
const step = typeof data?.step === 'number' ? data.step : 0;
|
|
553
|
-
if (last !== null && last.turn === turn && last.step === step) {
|
|
554
|
-
session = subtractSpend(session, last.spend);
|
|
555
|
-
if (last.dayKey === dayKey)
|
|
556
|
-
spend = subtractSpend(spend, last.spend);
|
|
557
|
-
}
|
|
558
|
-
session = addEventContribution(session, priced);
|
|
559
|
-
if (dayKey === priced.dayKey) {
|
|
560
|
-
spend = addEventContribution(spend, priced);
|
|
561
|
-
}
|
|
562
|
-
else if (dayKey === '' || priced.dayKey > dayKey) {
|
|
563
|
-
// The session log is append-only and chronological, so a strictly older
|
|
564
|
-
// day cannot legally follow; ignore it for the latest-day state (the
|
|
565
|
-
// whole-session total still accrues).
|
|
566
|
-
dayKey = priced.dayKey;
|
|
567
|
-
spend = addEventContribution(emptyTodaySpend(), priced);
|
|
568
|
-
}
|
|
569
|
-
return {
|
|
570
|
-
...state,
|
|
571
|
-
dayKey,
|
|
572
|
-
spend,
|
|
573
|
-
session,
|
|
574
|
-
last: { turn, step, dayKey: priced.dayKey, spend: contributionSpend(priced) },
|
|
575
|
-
};
|
|
576
|
-
}
|
|
577
|
-
/**
|
|
578
|
-
* Mutable wrapper over {@link applyBillingEvent} for the pure pricing paths:
|
|
579
|
-
* feed events in order, read the folded spend.
|
|
580
|
-
*/
|
|
581
|
-
export class BillingFolder {
|
|
582
|
-
billing;
|
|
583
|
-
state;
|
|
584
|
-
/**
|
|
585
|
-
* @param billing - resolved pricing with peak-hour windows.
|
|
586
|
-
* @param catalog - model display rows, in presentation order.
|
|
587
|
-
* @param inheritedEventCount - fork boundary to skip (default 0).
|
|
588
|
-
*/
|
|
589
|
-
constructor(billing, catalog, inheritedEventCount = 0) {
|
|
590
|
-
this.billing = billing;
|
|
591
|
-
this.names = new Map(catalog.map(model => [model.id, model.name]));
|
|
592
|
-
this.state = emptyBillingFoldState(inheritedEventCount);
|
|
593
|
-
}
|
|
594
|
-
names;
|
|
595
|
-
/** Fold one event. */
|
|
596
|
-
add(event) {
|
|
597
|
-
this.state = applyBillingEvent(this.state, event, this.billing, this.names);
|
|
598
|
-
}
|
|
599
|
-
/** Fold every event, in order. */
|
|
600
|
-
addAll(events) {
|
|
601
|
-
for (const event of events)
|
|
602
|
-
this.add(event);
|
|
603
|
-
}
|
|
604
|
-
/** The folded state (live reference; do not mutate). */
|
|
605
|
-
get fold() {
|
|
606
|
-
return this.state;
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
/**
|
|
610
|
-
* Merge one priced event's contribution into an accumulator spend (pure:
|
|
611
|
-
* returns a new spend, never mutates its input).
|
|
612
|
-
* @param spend - the accumulator (per session and day, or across sessions).
|
|
613
|
-
* @param priced - the priced contribution to add.
|
|
614
|
-
* @returns the merged spend.
|
|
615
|
-
*/
|
|
616
|
-
export function addEventContribution(spend, priced) {
|
|
617
|
-
const row = contributionModel(priced);
|
|
618
|
-
const rows = spend.models.map(existing => existing.model === priced.model ? mergeModelRows(existing, row) : existing);
|
|
619
|
-
if (!rows.some(existing => existing.model === priced.model))
|
|
620
|
-
rows.push(row);
|
|
621
|
-
return { total: spend.total + priced.cost, models: rows };
|
|
622
|
-
}
|
|
623
|
-
/**
|
|
624
|
-
* Sum two spends (per session and day, or across sessions) into one (pure:
|
|
625
|
-
* returns a new spend, never mutates its inputs).
|
|
626
|
-
* @param target - the accumulator spend.
|
|
627
|
-
* @param source - the spend to add.
|
|
628
|
-
* @returns the summed spend.
|
|
629
|
-
*/
|
|
630
|
-
export function mergeTodaySpend(target, source) {
|
|
631
|
-
const rows = new Map();
|
|
632
|
-
for (const row of target.models)
|
|
633
|
-
rows.set(row.model, row);
|
|
634
|
-
for (const row of source.models) {
|
|
635
|
-
const existing = rows.get(row.model);
|
|
636
|
-
rows.set(row.model, existing === undefined ? row : mergeModelRows(existing, row));
|
|
637
|
-
}
|
|
638
|
-
return { total: target.total + source.total, models: [...rows.values()] };
|
|
639
|
-
}
|
|
640
51
|
/**
|
|
641
52
|
* Price one session's complete event log at the official per-model rates,
|
|
642
53
|
* with DSH's attempt semantics: every provider-reported sample (an
|
|
@@ -824,10 +235,13 @@ export function computeSessionTurnSpends(events, billing, catalog) {
|
|
|
824
235
|
*/
|
|
825
236
|
export function computeTodaySpend(events, billing, catalog, now = new Date()) {
|
|
826
237
|
const day = beijingDayKey(now);
|
|
238
|
+
// Events past the reference day cannot contribute: one numeric bound for the
|
|
239
|
+
// scan instead of a date string per event.
|
|
240
|
+
const dayRange = beijingDayRangeOfKey(day);
|
|
241
|
+
const dayEnd = dayRange?.end ?? Number.POSITIVE_INFINITY;
|
|
827
242
|
const folder = new BillingFolder(billing, catalog);
|
|
828
243
|
for (const event of events) {
|
|
829
|
-
|
|
830
|
-
if (beijingPartsOf(event.time).dayKey > day)
|
|
244
|
+
if (event.time >= dayEnd)
|
|
831
245
|
continue;
|
|
832
246
|
folder.add(event);
|
|
833
247
|
}
|