@rayadesu/dsh-llm-billing 0.3.7 → 0.3.9
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 +15 -11
- package/README.zh.md +15 -11
- package/lib/index.js +924 -382
- package/lib/typert.host.js +49 -5
- package/lib/typert.remote-client.d.ts +5 -3
- package/lib/typert.remote-client.js +49 -5
- package/lib/types/balance.d.ts +33 -15
- package/lib/types/balance.js +28 -13
- package/lib/types/billing.d.ts +217 -21
- package/lib/types/billing.js +445 -71
- package/lib/types/index.d.ts +14 -4
- package/lib/types/index.js +213 -70
- package/lib/types/projection.d.ts +24 -25
- package/lib/types/projection.js +30 -31
- package/lib/types/today-spend.d.ts +138 -62
- package/lib/types/today-spend.js +209 -261
- package/lib/types/types.d.ts +30 -5
- package/lib/types/types.js +8 -0
- package/package.json +5 -1
package/lib/types/billing.js
CHANGED
|
@@ -28,6 +28,14 @@ export const DEFAULT_MODEL_PRICING = [
|
|
|
28
28
|
peak: { cacheHitInput: 0.10, cacheMissInput: 3.0, output: 9.0 },
|
|
29
29
|
offPeak: { cacheHitInput: 0.05, cacheMissInput: 1.5, output: 4.5 },
|
|
30
30
|
},
|
|
31
|
+
// deepseek-v4.1-flash-expires-on-0910 bills at the same rates as
|
|
32
|
+
// deepseek-v4-flash; image inputs are converted to tokens at the same
|
|
33
|
+
// per-token price.
|
|
34
|
+
{
|
|
35
|
+
model: 'deepseek-v4.1-flash-expires-on-0910',
|
|
36
|
+
peak: { cacheHitInput: 0.10, cacheMissInput: 3.0, output: 9.0 },
|
|
37
|
+
offPeak: { cacheHitInput: 0.05, cacheMissInput: 1.5, output: 4.5 },
|
|
38
|
+
},
|
|
31
39
|
{
|
|
32
40
|
model: 'deepseek-v4-pro',
|
|
33
41
|
peak: { cacheHitInput: 0.30, cacheMissInput: 9.0, output: 27.0 },
|
|
@@ -40,6 +48,17 @@ export const DEFAULT_MODEL_PRICING = [
|
|
|
40
48
|
peak: { cacheHitInput: 0.10, cacheMissInput: 3.0, output: 9.0 },
|
|
41
49
|
offPeak: { cacheHitInput: 0.05, cacheMissInput: 1.5, output: 4.5 },
|
|
42
50
|
},
|
|
51
|
+
// MiMo-V2.5 series (Xiaomi): flat rate, no peak/off-peak distinction.
|
|
52
|
+
{
|
|
53
|
+
model: 'mimo-v2.5-pro',
|
|
54
|
+
peak: { cacheHitInput: 0.025, cacheMissInput: 3.0, output: 6.0 },
|
|
55
|
+
offPeak: { cacheHitInput: 0.025, cacheMissInput: 3.0, output: 6.0 },
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
model: 'mimo-v2.5',
|
|
59
|
+
peak: { cacheHitInput: 0.02, cacheMissInput: 1.0, output: 2.0 },
|
|
60
|
+
offPeak: { cacheHitInput: 0.02, cacheMissInput: 1.0, output: 2.0 },
|
|
61
|
+
},
|
|
43
62
|
];
|
|
44
63
|
/**
|
|
45
64
|
* Resolve optional configuration to a pricing table, defaulting omitted or
|
|
@@ -62,23 +81,64 @@ export function resolveBilling(config) {
|
|
|
62
81
|
models.set(row.model, { peak: row.peak, offPeak: row.offPeak });
|
|
63
82
|
return { peakHours, models };
|
|
64
83
|
}
|
|
84
|
+
/** Beijing is a fixed UTC+8 offset with no DST. */
|
|
85
|
+
const BEIJING_OFFSET_MS = 8 * 3_600_000;
|
|
86
|
+
/** Milliseconds in one day. */
|
|
87
|
+
const DAY_MS = 86_400_000;
|
|
88
|
+
/** Epoch day of 1970-01-01 in the civil-date algorithm below. */
|
|
89
|
+
const CIVIL_EPOCH_DAY = 719_468;
|
|
90
|
+
/** Two-digit zero pad for a calendar field. */
|
|
91
|
+
function pad2(value) {
|
|
92
|
+
return value < 10 ? `0${value}` : String(value);
|
|
93
|
+
}
|
|
65
94
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
|
|
95
|
+
* Civil date of an epoch day (Howard Hinnant's days-from-civil inverse):
|
|
96
|
+
* pure integer arithmetic, no `Date` allocation and no ISO-string slicing.
|
|
97
|
+
*/
|
|
98
|
+
function civilDateOf(epochDay) {
|
|
99
|
+
const shifted = epochDay + CIVIL_EPOCH_DAY;
|
|
100
|
+
const era = Math.floor(shifted / 146_097);
|
|
101
|
+
const dayOfEra = shifted - era * 146_097;
|
|
102
|
+
const yearOfEra = Math.floor((dayOfEra - Math.floor(dayOfEra / 1_460) + Math.floor(dayOfEra / 36_524) - Math.floor(dayOfEra / 146_096)) / 365);
|
|
103
|
+
const year = yearOfEra + era * 400;
|
|
104
|
+
const dayOfYear = dayOfEra - (365 * yearOfEra + Math.floor(yearOfEra / 4) - Math.floor(yearOfEra / 100));
|
|
105
|
+
const monthPrime = Math.floor((5 * dayOfYear + 2) / 153);
|
|
106
|
+
const month = monthPrime + (monthPrime < 10 ? 3 : -9);
|
|
107
|
+
return {
|
|
108
|
+
// January/February belong to the civil year AFTER the era year.
|
|
109
|
+
year: month <= 2 ? year + 1 : year,
|
|
110
|
+
month,
|
|
111
|
+
day: dayOfYear - Math.floor((153 * monthPrime + 2) / 5) + 1,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Derive the Beijing hour, weekday, and calendar-day key of one timestamp with
|
|
116
|
+
* pure integer arithmetic — every timezone-sensitive read shares this one
|
|
117
|
+
* implementation, so the pieces cannot drift apart. Callers that filter by
|
|
118
|
+
* day and then price the same event reuse the returned view, so each event is
|
|
119
|
+
* parsed exactly once. (The hot fold path runs this per committed event; the
|
|
120
|
+
* previous `Date` + `toISOString().slice()` version allocated a `Date` and a
|
|
121
|
+
* 24-character string per call.)
|
|
69
122
|
* @param time - epoch milliseconds.
|
|
123
|
+
* @throws {RangeError} when `time` is not a finite number.
|
|
70
124
|
*/
|
|
71
|
-
function
|
|
72
|
-
|
|
125
|
+
export function beijingPartsOf(time) {
|
|
126
|
+
if (!Number.isFinite(time))
|
|
127
|
+
throw new RangeError(`billing: event time is not finite (${String(time)})`);
|
|
128
|
+
const shifted = time + BEIJING_OFFSET_MS;
|
|
129
|
+
const epochDay = Math.floor(shifted / DAY_MS);
|
|
130
|
+
const msOfDay = shifted - epochDay * DAY_MS;
|
|
131
|
+
const civil = civilDateOf(epochDay);
|
|
73
132
|
return {
|
|
74
|
-
hour:
|
|
75
|
-
|
|
76
|
-
|
|
133
|
+
hour: Math.floor(msOfDay / 3_600_000),
|
|
134
|
+
// 1970-01-01 was a Thursday (4).
|
|
135
|
+
weekday: ((epochDay + 4) % 7 + 7) % 7,
|
|
136
|
+
dayKey: `${civil.year}-${pad2(civil.month)}-${pad2(civil.day)}`,
|
|
77
137
|
};
|
|
78
138
|
}
|
|
79
139
|
/** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
|
|
80
140
|
export function beijingDayKey(now) {
|
|
81
|
-
return
|
|
141
|
+
return beijingPartsOf(now.getTime()).dayKey;
|
|
82
142
|
}
|
|
83
143
|
/**
|
|
84
144
|
* The durable inherited-prefix boundary of one session: the number of leading
|
|
@@ -123,7 +183,7 @@ function isPeakParts(billing, hour, weekday) {
|
|
|
123
183
|
* @returns true during a weekday peak hour.
|
|
124
184
|
*/
|
|
125
185
|
export function isPeak(billing, now) {
|
|
126
|
-
const { hour, weekday } =
|
|
186
|
+
const { hour, weekday } = beijingPartsOf(now.getTime());
|
|
127
187
|
return isPeakParts(billing, hour, weekday);
|
|
128
188
|
}
|
|
129
189
|
/**
|
|
@@ -132,35 +192,60 @@ export function isPeak(billing, now) {
|
|
|
132
192
|
* only; weekends are off-peak). Each `assistant/message` event with usage
|
|
133
193
|
* contributes cache-hit input, cache-miss input (uncached input plus cache
|
|
134
194
|
* writes), and output (reasoning included) tokens at the rate of its own
|
|
135
|
-
* timestamp; a model with usage but no pricing row contributes nothing
|
|
136
|
-
* published table prices only the two V4 rows).
|
|
195
|
+
* timestamp; a model with usage but no pricing row contributes nothing.
|
|
137
196
|
* @param event - the event to price.
|
|
138
197
|
* @param billing - resolved pricing with peak-hour windows.
|
|
139
198
|
* @param names - model id → display label.
|
|
140
199
|
* @returns the priced contribution, or `undefined` when the event has no priced usage.
|
|
141
200
|
*/
|
|
142
201
|
export function priceEvent(event, billing, names) {
|
|
202
|
+
return priceEventAt(beijingPartsOf(event.time), event, billing, names);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Price one event at the official per-model rates using a precomputed
|
|
206
|
+
* Beijing-time view — the day-filtering and pricing of one event share a
|
|
207
|
+
* single timezone parse (see {@link beijingPartsOf}). Semantics are identical
|
|
208
|
+
* to {@link priceEvent}.
|
|
209
|
+
* @param parts - the event's Beijing-time view.
|
|
210
|
+
* @param event - the event to price.
|
|
211
|
+
* @param billing - resolved pricing with peak-hour windows.
|
|
212
|
+
* @param names - model id → display label.
|
|
213
|
+
* @returns the priced contribution, or `undefined` when the event has no priced usage.
|
|
214
|
+
*/
|
|
215
|
+
export function priceEventAt(parts, event, billing, names) {
|
|
143
216
|
if (event.type !== 'assistant/message')
|
|
144
217
|
return undefined;
|
|
145
218
|
const reported = event.data.usage;
|
|
146
219
|
if (reported === undefined)
|
|
147
220
|
return undefined;
|
|
148
|
-
|
|
221
|
+
return priceUsage(parts, reported, event.data.message.source.model, billing, names);
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Price one provider-reported usage sample for one model at the rates of the
|
|
225
|
+
* sample's own Beijing-time hour and weekday. `undefined` when the model has
|
|
226
|
+
* no pricing row.
|
|
227
|
+
* @param parts - the sample's Beijing-time view.
|
|
228
|
+
* @param usage - the reported token buckets.
|
|
229
|
+
* @param model - the wire model id the sample belongs to.
|
|
230
|
+
* @param billing - resolved pricing with peak-hour windows.
|
|
231
|
+
* @param names - model id → display label.
|
|
232
|
+
* @returns the priced contribution, or `undefined` when the model has no rate row.
|
|
233
|
+
*/
|
|
234
|
+
export function priceUsage(parts, usage, model, billing, names) {
|
|
149
235
|
const pricing = billing.models.get(model);
|
|
150
236
|
if (pricing === undefined)
|
|
151
237
|
return undefined;
|
|
152
|
-
const
|
|
153
|
-
const peak = isPeakParts(billing, hour, weekday);
|
|
238
|
+
const peak = isPeakParts(billing, parts.hour, parts.weekday);
|
|
154
239
|
const price = peak ? pricing.peak : pricing.offPeak;
|
|
155
|
-
const hit =
|
|
156
|
-
const miss =
|
|
157
|
-
const output =
|
|
240
|
+
const hit = usage.cacheReadTokens ?? 0;
|
|
241
|
+
const miss = usage.inputTokens + (usage.cacheWriteTokens ?? 0);
|
|
242
|
+
const output = usage.outputTokens;
|
|
158
243
|
const hitCost = (hit * price.cacheHitInput) / 1_000_000;
|
|
159
244
|
const missCost = (miss * price.cacheMissInput) / 1_000_000;
|
|
160
245
|
const outputCost = (output * price.output) / 1_000_000;
|
|
161
246
|
const cost = hitCost + missCost + outputCost;
|
|
162
247
|
return {
|
|
163
|
-
dayKey,
|
|
248
|
+
dayKey: parts.dayKey,
|
|
164
249
|
model,
|
|
165
250
|
displayName: names.get(model) ?? model,
|
|
166
251
|
cost,
|
|
@@ -233,6 +318,202 @@ export class SpendAccumulator {
|
|
|
233
318
|
return { total: this.total, models: [...this.rows.values()] };
|
|
234
319
|
}
|
|
235
320
|
}
|
|
321
|
+
/** The additive inverse of one spend (pure): used to replace a priced sample. */
|
|
322
|
+
export function negateSpend(spend) {
|
|
323
|
+
const negate = (value) => -value;
|
|
324
|
+
return {
|
|
325
|
+
total: negate(spend.total),
|
|
326
|
+
models: spend.models.map(row => ({
|
|
327
|
+
...row,
|
|
328
|
+
cost: negate(row.cost),
|
|
329
|
+
peakCost: negate(row.peakCost),
|
|
330
|
+
offPeakCost: negate(row.offPeakCost),
|
|
331
|
+
cacheHitInputTokens: negate(row.cacheHitInputTokens),
|
|
332
|
+
cacheMissInputTokens: negate(row.cacheMissInputTokens),
|
|
333
|
+
outputTokens: negate(row.outputTokens),
|
|
334
|
+
cacheHitInputCost: negate(row.cacheHitInputCost),
|
|
335
|
+
cacheMissInputCost: negate(row.cacheMissInputCost),
|
|
336
|
+
outputCost: negate(row.outputCost),
|
|
337
|
+
})),
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Subtract one spend from another (pure). Rows that cancel out completely are
|
|
342
|
+
* dropped so a replaced sample leaves no zero row behind.
|
|
343
|
+
* @param target - the spend to subtract from.
|
|
344
|
+
* @param source - the spend to remove.
|
|
345
|
+
* @returns the difference.
|
|
346
|
+
*/
|
|
347
|
+
export function subtractSpend(target, source) {
|
|
348
|
+
const rows = new Map();
|
|
349
|
+
for (const row of target.models)
|
|
350
|
+
rows.set(row.model, row);
|
|
351
|
+
for (const row of source.models) {
|
|
352
|
+
const existing = rows.get(row.model);
|
|
353
|
+
if (existing === undefined)
|
|
354
|
+
continue;
|
|
355
|
+
const next = mergeModelRows(existing, negateSpend({ total: 0, models: [row] }).models[0]);
|
|
356
|
+
if (next.cost === 0 && next.cacheHitInputTokens === 0 && next.cacheMissInputTokens === 0 && next.outputTokens === 0) {
|
|
357
|
+
rows.delete(row.model);
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
rows.set(row.model, next);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return { total: target.total - source.total, models: [...rows.values()] };
|
|
364
|
+
}
|
|
365
|
+
/** The empty fold state for one fork boundary. */
|
|
366
|
+
export function emptyBillingFoldState(inheritedEventCount = 0) {
|
|
367
|
+
return {
|
|
368
|
+
dayKey: '',
|
|
369
|
+
spend: emptyTodaySpend(),
|
|
370
|
+
session: emptyTodaySpend(),
|
|
371
|
+
inheritedEventCount,
|
|
372
|
+
model: '',
|
|
373
|
+
last: null,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
/** Whether an unknown value looks like a provider usage report. */
|
|
377
|
+
function isTokenUsage(value) {
|
|
378
|
+
if (typeof value !== 'object' || value === null)
|
|
379
|
+
return false;
|
|
380
|
+
const candidate = value;
|
|
381
|
+
return typeof candidate.inputTokens === 'number' && typeof candidate.outputTokens === 'number';
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* The last `usage` sample embedded in an event's stream, if any. `assistant/
|
|
385
|
+
* attempt` and the embedded streams are newer than the plugin's npm baseline,
|
|
386
|
+
* so the stream is read structurally (a failed/retried attempt reports its
|
|
387
|
+
* usage only there).
|
|
388
|
+
*/
|
|
389
|
+
function streamUsageOf(event) {
|
|
390
|
+
const stream = event.data === undefined
|
|
391
|
+
? undefined
|
|
392
|
+
: event.data.stream;
|
|
393
|
+
if (!Array.isArray(stream))
|
|
394
|
+
return undefined;
|
|
395
|
+
for (let index = stream.length - 1; index >= 0; index -= 1) {
|
|
396
|
+
const chunk = stream[index]?.chunk;
|
|
397
|
+
if (chunk === undefined || chunk.type !== 'usage')
|
|
398
|
+
continue;
|
|
399
|
+
return isTokenUsage(chunk.usage) ? chunk.usage : undefined;
|
|
400
|
+
}
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|
|
403
|
+
/** The contribution as a one-row spend (the shape a sample keeps for replacement). */
|
|
404
|
+
function contributionSpend(priced) {
|
|
405
|
+
return { total: priced.cost, models: [contributionModel(priced)] };
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Fold one committed event into a session's billed-spend state.
|
|
409
|
+
*
|
|
410
|
+
* Priced samples come from `assistant/message` (its own reported usage, or the
|
|
411
|
+
* stream's last usage chunk) and `assistant/attempt` (the stream's last usage
|
|
412
|
+
* chunk, priced with the model of the latest `request/header`, since an
|
|
413
|
+
* attempt carries no route). A sample for the same `(turn, step)` replaces the
|
|
414
|
+
* previous one; `llm/retry-started` closes the replacement slot so a retried
|
|
415
|
+
* attempt adds. Every other event is inert and returns the same state
|
|
416
|
+
* reference.
|
|
417
|
+
* @param state - the previous fold state.
|
|
418
|
+
* @param event - the committed event.
|
|
419
|
+
* @param billing - resolved pricing with peak-hour windows.
|
|
420
|
+
* @param names - model id → display label.
|
|
421
|
+
* @returns the next state (the same reference when nothing was priced).
|
|
422
|
+
*/
|
|
423
|
+
export function applyBillingEvent(state, event, billing, names) {
|
|
424
|
+
if (event.seq < state.inheritedEventCount)
|
|
425
|
+
return state;
|
|
426
|
+
// `assistant/attempt`, `llm/retry-started`, and the embedded stream are all
|
|
427
|
+
// newer than the npm baseline this package builds against, so their fields
|
|
428
|
+
// are read structurally.
|
|
429
|
+
const type = event.type;
|
|
430
|
+
if (type === 'request/header') {
|
|
431
|
+
const model = event.data
|
|
432
|
+
?.header?.config?.model;
|
|
433
|
+
return typeof model === 'string' && model.length > 0 && model !== state.model ? { ...state, model } : state;
|
|
434
|
+
}
|
|
435
|
+
const data = event.data;
|
|
436
|
+
if (type === 'llm/retry-started') {
|
|
437
|
+
if (typeof data?.turn !== 'number' || typeof data.step !== 'number')
|
|
438
|
+
return state;
|
|
439
|
+
const last = state.last;
|
|
440
|
+
if (last === null || last.turn !== data.turn || last.step !== data.step)
|
|
441
|
+
return state;
|
|
442
|
+
return { ...state, last: null };
|
|
443
|
+
}
|
|
444
|
+
if (type !== 'assistant/message' && type !== 'assistant/attempt')
|
|
445
|
+
return state;
|
|
446
|
+
const usage = (type === 'assistant/message' ? data?.usage : undefined) ?? streamUsageOf(event);
|
|
447
|
+
if (!isTokenUsage(usage))
|
|
448
|
+
return state;
|
|
449
|
+
const model = type === 'assistant/message' ? data?.message?.source?.model : state.model;
|
|
450
|
+
if (typeof model !== 'string' || model.length === 0)
|
|
451
|
+
return state;
|
|
452
|
+
const priced = priceUsage(beijingPartsOf(event.time), usage, model, billing, names);
|
|
453
|
+
if (priced === undefined)
|
|
454
|
+
return state;
|
|
455
|
+
let session = state.session;
|
|
456
|
+
let spend = state.spend;
|
|
457
|
+
let dayKey = state.dayKey;
|
|
458
|
+
const last = state.last;
|
|
459
|
+
const turn = typeof data?.turn === 'number' ? data.turn : 0;
|
|
460
|
+
const step = typeof data?.step === 'number' ? data.step : 0;
|
|
461
|
+
if (last !== null && last.turn === turn && last.step === step) {
|
|
462
|
+
session = subtractSpend(session, last.spend);
|
|
463
|
+
if (last.dayKey === dayKey)
|
|
464
|
+
spend = subtractSpend(spend, last.spend);
|
|
465
|
+
}
|
|
466
|
+
session = addEventContribution(session, priced);
|
|
467
|
+
if (dayKey === priced.dayKey) {
|
|
468
|
+
spend = addEventContribution(spend, priced);
|
|
469
|
+
}
|
|
470
|
+
else if (dayKey === '' || priced.dayKey > dayKey) {
|
|
471
|
+
// The session log is append-only and chronological, so a strictly older
|
|
472
|
+
// day cannot legally follow; ignore it for the latest-day state (the
|
|
473
|
+
// whole-session total still accrues).
|
|
474
|
+
dayKey = priced.dayKey;
|
|
475
|
+
spend = addEventContribution(emptyTodaySpend(), priced);
|
|
476
|
+
}
|
|
477
|
+
return {
|
|
478
|
+
...state,
|
|
479
|
+
dayKey,
|
|
480
|
+
spend,
|
|
481
|
+
session,
|
|
482
|
+
last: { turn, step, dayKey: priced.dayKey, spend: contributionSpend(priced) },
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Mutable wrapper over {@link applyBillingEvent} for the pure pricing paths:
|
|
487
|
+
* feed events in order, read the folded spend.
|
|
488
|
+
*/
|
|
489
|
+
export class BillingFolder {
|
|
490
|
+
billing;
|
|
491
|
+
state;
|
|
492
|
+
/**
|
|
493
|
+
* @param billing - resolved pricing with peak-hour windows.
|
|
494
|
+
* @param catalog - model display rows, in presentation order.
|
|
495
|
+
* @param inheritedEventCount - fork boundary to skip (default 0).
|
|
496
|
+
*/
|
|
497
|
+
constructor(billing, catalog, inheritedEventCount = 0) {
|
|
498
|
+
this.billing = billing;
|
|
499
|
+
this.names = new Map(catalog.map(model => [model.id, model.name]));
|
|
500
|
+
this.state = emptyBillingFoldState(inheritedEventCount);
|
|
501
|
+
}
|
|
502
|
+
names;
|
|
503
|
+
/** Fold one event. */
|
|
504
|
+
add(event) {
|
|
505
|
+
this.state = applyBillingEvent(this.state, event, this.billing, this.names);
|
|
506
|
+
}
|
|
507
|
+
/** Fold every event, in order. */
|
|
508
|
+
addAll(events) {
|
|
509
|
+
for (const event of events)
|
|
510
|
+
this.add(event);
|
|
511
|
+
}
|
|
512
|
+
/** The folded state (live reference; do not mutate). */
|
|
513
|
+
get fold() {
|
|
514
|
+
return this.state;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
236
517
|
/**
|
|
237
518
|
* Merge one priced event's contribution into an accumulator spend (pure:
|
|
238
519
|
* returns a new spend, never mutates its input).
|
|
@@ -265,36 +546,11 @@ export function mergeTodaySpend(target, source) {
|
|
|
265
546
|
return { total: target.total + source.total, models: [...rows.values()] };
|
|
266
547
|
}
|
|
267
548
|
/**
|
|
268
|
-
* Price
|
|
269
|
-
*
|
|
270
|
-
*
|
|
271
|
-
*
|
|
272
|
-
*
|
|
273
|
-
* tokens at the rate of its own timestamp, with the three component costs
|
|
274
|
-
* carried separately; a model with usage but no pricing row is omitted (the
|
|
275
|
-
* published table prices only the two V4 rows).
|
|
276
|
-
* @param events - the events to price.
|
|
277
|
-
* @param billing - resolved pricing with peak-hour windows.
|
|
278
|
-
* @param names - model id → display label.
|
|
279
|
-
* @param dayKey - when provided, only events on this Beijing calendar day contribute.
|
|
280
|
-
* @param startSeq - when provided, only events with `seq >= startSeq` contribute
|
|
281
|
-
* (a forked session's inherited prefix, `seq < startSeq`, is skipped).
|
|
282
|
-
* @returns the total cost plus one row per priced model.
|
|
283
|
-
*/
|
|
284
|
-
function priceEvents(events, billing, names, dayKey, startSeq = 0) {
|
|
285
|
-
const accumulator = new SpendAccumulator();
|
|
286
|
-
for (const event of events) {
|
|
287
|
-
if (event.seq < startSeq)
|
|
288
|
-
continue;
|
|
289
|
-
const priced = priceEvent(event, billing, names);
|
|
290
|
-
if (priced === undefined || (dayKey !== undefined && priced.dayKey !== dayKey))
|
|
291
|
-
continue;
|
|
292
|
-
accumulator.add(priced);
|
|
293
|
-
}
|
|
294
|
-
return accumulator.finish();
|
|
295
|
-
}
|
|
296
|
-
/**
|
|
297
|
-
* Price one session's complete event log at the official per-model rates.
|
|
549
|
+
* Price one session's complete event log at the official per-model rates,
|
|
550
|
+
* with DSH's attempt semantics: every provider-reported sample (an
|
|
551
|
+
* `assistant/message`'s usage, or an `assistant/attempt`'s stream usage)
|
|
552
|
+
* contributes, a later sample for the same `(turn, step)` replaces the earlier
|
|
553
|
+
* one, and `llm/retry-started` makes the retried attempt add.
|
|
298
554
|
* @param events - one session's complete event log.
|
|
299
555
|
* @param billing - resolved pricing with peak-hour windows.
|
|
300
556
|
* @param catalog - model display rows, in presentation order.
|
|
@@ -305,17 +561,18 @@ function priceEvents(events, billing, names, dayKey, startSeq = 0) {
|
|
|
305
561
|
* @returns the session's total cost plus one row per priced model.
|
|
306
562
|
*/
|
|
307
563
|
export function computeSessionSpend(events, billing, catalog, startSeq = 0) {
|
|
308
|
-
const
|
|
309
|
-
|
|
564
|
+
const folder = new BillingFolder(billing, catalog, startSeq);
|
|
565
|
+
folder.addAll(events);
|
|
566
|
+
return folder.fold.session;
|
|
310
567
|
}
|
|
311
568
|
/**
|
|
312
|
-
* Price one completed Turn's billed usage
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
569
|
+
* Price one completed Turn's billed usage, identified by its closing
|
|
570
|
+
* assistant message id. The turn's events are those between its `turn/start`
|
|
571
|
+
* and `turn/end` (both matched by the message's own turn coordinate), priced
|
|
572
|
+
* with the same attempt semantics as {@link computeSessionSpend}. A message
|
|
573
|
+
* that cannot be located, a turn without bracketing `turn/start` / `turn/end`
|
|
574
|
+
* events (for example after compaction), or a session with no priced usage
|
|
575
|
+
* prices to zero.
|
|
319
576
|
* @param events - one session's complete event log.
|
|
320
577
|
* @param billing - resolved pricing with peak-hour windows.
|
|
321
578
|
* @param catalog - model display rows, in presentation order.
|
|
@@ -323,7 +580,18 @@ export function computeSessionSpend(events, billing, catalog, startSeq = 0) {
|
|
|
323
580
|
* @returns the turn's total cost in CNY.
|
|
324
581
|
*/
|
|
325
582
|
export function computeTurnSpend(events, billing, catalog, messageId) {
|
|
326
|
-
|
|
583
|
+
return { total: turnCostOf(events, billing, catalog, messageId) };
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* The total cost of the Turn containing `messageId`, folded with the shared
|
|
587
|
+
* attempt semantics (see {@link applyBillingEvent}).
|
|
588
|
+
* @param events - one session's complete event log.
|
|
589
|
+
* @param billing - resolved pricing with peak-hour windows.
|
|
590
|
+
* @param catalog - model display rows, in presentation order.
|
|
591
|
+
* @param messageId - one assistant message inside the Turn.
|
|
592
|
+
* @returns the Turn's total cost in CNY, or 0 when the Turn cannot be located.
|
|
593
|
+
*/
|
|
594
|
+
function turnCostOf(events, billing, catalog, messageId) {
|
|
327
595
|
let turn;
|
|
328
596
|
for (const event of events) {
|
|
329
597
|
if (event.type !== 'assistant/message')
|
|
@@ -334,8 +602,8 @@ export function computeTurnSpend(events, billing, catalog, messageId) {
|
|
|
334
602
|
break;
|
|
335
603
|
}
|
|
336
604
|
if (turn === undefined)
|
|
337
|
-
return
|
|
338
|
-
const
|
|
605
|
+
return 0;
|
|
606
|
+
const folder = new BillingFolder(billing, catalog);
|
|
339
607
|
let active = false;
|
|
340
608
|
for (const event of events) {
|
|
341
609
|
if (event.type === 'turn/start' && event.data.turn === turn) {
|
|
@@ -346,17 +614,117 @@ export function computeTurnSpend(events, billing, catalog, messageId) {
|
|
|
346
614
|
break;
|
|
347
615
|
if (!active)
|
|
348
616
|
continue;
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
617
|
+
folder.add(event);
|
|
618
|
+
}
|
|
619
|
+
return folder.fold.session.total;
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* Incremental single-pass fold of one session's completed-Turn costs, keyed by
|
|
623
|
+
* the id of every assistant message inside each Turn. Feeding the fold only
|
|
624
|
+
* the appended tail keeps a growing session's map current in O(new events)
|
|
625
|
+
* instead of re-scanning the whole log per message.
|
|
626
|
+
*
|
|
627
|
+
* Semantics are exactly {@link computeTurnSpend}'s: a Turn is the
|
|
628
|
+
* `turn/start`..`turn/end` range (matched by the event's own turn coordinate),
|
|
629
|
+
* every priced event inside it contributes at its own timestamp's rate, and a
|
|
630
|
+
* message outside any bracket contributes nothing.
|
|
631
|
+
*/
|
|
632
|
+
export class SessionTurnSpendFolder {
|
|
633
|
+
billing;
|
|
634
|
+
catalog;
|
|
635
|
+
rows = [];
|
|
636
|
+
ids = [];
|
|
637
|
+
/** Events of the open Turn, folded with the shared attempt semantics on close. */
|
|
638
|
+
events = [];
|
|
639
|
+
open = false;
|
|
640
|
+
/** Events already fed; a shorter log resets the fold. */
|
|
641
|
+
cursor = 0;
|
|
642
|
+
/**
|
|
643
|
+
* @param billing - resolved pricing with peak-hour windows.
|
|
644
|
+
* @param catalog - model display rows, in presentation order.
|
|
645
|
+
*/
|
|
646
|
+
constructor(billing, catalog) {
|
|
647
|
+
this.billing = billing;
|
|
648
|
+
this.catalog = catalog;
|
|
649
|
+
}
|
|
650
|
+
/** How many events have been folded so far (the host's incremental cursor). */
|
|
651
|
+
get processed() {
|
|
652
|
+
return this.cursor;
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Fold every event from the cursor to the end of the log. A log shorter than
|
|
656
|
+
* the cursor (rewritten session) restarts the fold from an empty state.
|
|
657
|
+
* @param events - the session's complete event log, in seq order.
|
|
658
|
+
*/
|
|
659
|
+
feed(events) {
|
|
660
|
+
if (events.length < this.cursor)
|
|
661
|
+
this.reset();
|
|
662
|
+
for (let index = this.cursor; index < events.length; index += 1) {
|
|
663
|
+
const event = events[index];
|
|
664
|
+
if (event.type === 'turn/start') {
|
|
665
|
+
this.open = true;
|
|
666
|
+
this.ids = [];
|
|
667
|
+
this.events = [];
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
if (event.type === 'turn/end') {
|
|
671
|
+
if (this.open) {
|
|
672
|
+
const folder = new BillingFolder(this.billing, this.catalog);
|
|
673
|
+
folder.addAll(this.events);
|
|
674
|
+
const total = folder.fold.session.total;
|
|
675
|
+
for (const messageId of this.ids)
|
|
676
|
+
this.rows.push({ messageId, total });
|
|
677
|
+
}
|
|
678
|
+
this.open = false;
|
|
679
|
+
this.ids = [];
|
|
680
|
+
this.events = [];
|
|
681
|
+
continue;
|
|
682
|
+
}
|
|
683
|
+
if (!this.open)
|
|
684
|
+
continue;
|
|
685
|
+
if (event.type === 'assistant/message')
|
|
686
|
+
this.ids.push(event.data.message.id);
|
|
687
|
+
this.events.push(event);
|
|
688
|
+
}
|
|
689
|
+
this.cursor = events.length;
|
|
352
690
|
}
|
|
353
|
-
|
|
691
|
+
/** The folded map; the fold stays usable afterwards. */
|
|
692
|
+
finish() {
|
|
693
|
+
return { turns: [...this.rows] };
|
|
694
|
+
}
|
|
695
|
+
/** Drop the fold state so the next feed starts from the log's beginning. */
|
|
696
|
+
reset() {
|
|
697
|
+
this.rows.length = 0;
|
|
698
|
+
this.ids = [];
|
|
699
|
+
this.events = [];
|
|
700
|
+
this.open = false;
|
|
701
|
+
this.cursor = 0;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Price every completed Turn of one session in a single pass (the pure
|
|
706
|
+
* equivalent of {@link SessionTurnSpendFolder}).
|
|
707
|
+
* @param events - one session's complete event log.
|
|
708
|
+
* @param billing - resolved pricing with peak-hour windows.
|
|
709
|
+
* @param catalog - model display rows, in presentation order.
|
|
710
|
+
* @returns one row per assistant message inside a completed Turn, in log order.
|
|
711
|
+
*/
|
|
712
|
+
export function computeSessionTurnSpends(events, billing, catalog) {
|
|
713
|
+
const folder = new SessionTurnSpendFolder(billing, catalog);
|
|
714
|
+
folder.feed(events);
|
|
715
|
+
return folder.finish();
|
|
354
716
|
}
|
|
355
717
|
/**
|
|
356
|
-
* Price
|
|
357
|
-
*
|
|
358
|
-
*
|
|
359
|
-
*
|
|
718
|
+
* Price one session's log for the Beijing-time calendar day of `now`. Events
|
|
719
|
+
* after the reference day are ignored; the fold's latest-day state then
|
|
720
|
+
* answers the query exactly (empty when the session's latest priced day is not
|
|
721
|
+
* the reference day). Pricing follows {@link applyBillingEvent} (attempt
|
|
722
|
+
* samples with same-step replacement).
|
|
723
|
+
*
|
|
724
|
+
* The fold's `(turn, step)` replacement slot is per session, so callers must
|
|
725
|
+
* pass ONE session's log; aggregate across sessions with
|
|
726
|
+
* {@link mergeTodaySpend}.
|
|
727
|
+
* @param events - one session's complete event log.
|
|
360
728
|
* @param billing - resolved pricing with peak-hour windows.
|
|
361
729
|
* @param catalog - model display rows, in presentation order.
|
|
362
730
|
* @param now - the reference moment whose Beijing-time calendar day is "today".
|
|
@@ -364,7 +732,13 @@ export function computeTurnSpend(events, billing, catalog, messageId) {
|
|
|
364
732
|
*/
|
|
365
733
|
export function computeTodaySpend(events, billing, catalog, now = new Date()) {
|
|
366
734
|
const day = beijingDayKey(now);
|
|
367
|
-
const
|
|
368
|
-
|
|
735
|
+
const folder = new BillingFolder(billing, catalog);
|
|
736
|
+
for (const event of events) {
|
|
737
|
+
// Only events up to the reference day can contribute.
|
|
738
|
+
if (beijingPartsOf(event.time).dayKey > day)
|
|
739
|
+
continue;
|
|
740
|
+
folder.add(event);
|
|
741
|
+
}
|
|
742
|
+
return folder.fold.dayKey === day ? folder.fold.spend : emptyTodaySpend();
|
|
369
743
|
}
|
|
370
744
|
//# sourceMappingURL=billing.js.map
|
package/lib/types/index.d.ts
CHANGED
|
@@ -24,8 +24,8 @@ import type { Context } from '@deepseek-ai/cordis';
|
|
|
24
24
|
import z from '@deepseek-ai/schemastery';
|
|
25
25
|
import type { BillingConfig } from './billing.ts';
|
|
26
26
|
export { DeepSeekBalanceGateway, fetchDeepSeekBalance, parseDeepSeekBalance } from './balance.ts';
|
|
27
|
-
export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, computeTurnSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, forkBoundaryOf, isPeak, isSeededSession, mergeTodaySpend, priceEvent, resolveBilling, SpendAccumulator, } from './billing.ts';
|
|
28
|
-
export type { BillingConfig, BillingConfigModel, BillingEventContribution, DeepSeekModelPricing, DeepSeekTokenPrice, PeakHourWindow, ResolvedBilling, } from './billing.ts';
|
|
27
|
+
export { addEventContribution, applyBillingEvent, beijingDayKey, BillingFolder, computeSessionSpend, computeSessionTurnSpends, computeTodaySpend, computeTurnSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyBillingFoldState, emptyTodaySpend, forkBoundaryOf, isPeak, isSeededSession, mergeTodaySpend, negateSpend, priceEvent, priceUsage, resolveBilling, SessionTurnSpendFolder, SpendAccumulator, subtractSpend, } from './billing.ts';
|
|
28
|
+
export type { BillingConfig, BillingConfigModel, BillingEventContribution, BillingFoldSample, BillingFoldState, DeepSeekModelPricing, DeepSeekTokenPrice, PeakHourWindow, ResolvedBilling, } from './billing.ts';
|
|
29
29
|
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';
|
|
@@ -52,7 +52,7 @@ export interface Config {
|
|
|
52
52
|
apiKeyEnv?: string;
|
|
53
53
|
/** Endpoint base; defaults to `$DEEPSEEK_BASE_URL`, then `https://api.deepseek.com`. */
|
|
54
54
|
baseURL?: string;
|
|
55
|
-
/** Advisory display rows, in presentation order; defaults to V4 Flash, V4 Pro, and V4 Flash Vision Exp. */
|
|
55
|
+
/** Advisory display rows, in presentation order; defaults to V4 Flash, V4.1 Flash, V4 Pro, and V4 Flash Vision Exp. */
|
|
56
56
|
models?: BillingModel[];
|
|
57
57
|
/** Pricing table and peak-hour windows; omission uses the published defaults. Peak windows apply weekdays (Monday–Friday) only; weekends are always off-peak. */
|
|
58
58
|
billing?: BillingConfig;
|
|
@@ -62,8 +62,18 @@ export declare const Config: z<Config>;
|
|
|
62
62
|
export declare const TODAY_SPEND_CACHE_MS = 60000;
|
|
63
63
|
/** Hard cap on today's events collected by the events scan path. */
|
|
64
64
|
export declare const TODAY_SPEND_MAX_EVENTS = 200000;
|
|
65
|
+
/** Max session-spend rows kept for incremental recompute before eviction. */
|
|
66
|
+
export declare const SESSION_SPEND_CACHE_LIMIT = 1024;
|
|
67
|
+
/** Max session-id entries kept in the per-turn-cost fold cache before eviction. */
|
|
68
|
+
export declare const SESSION_TURN_SPEND_CACHE_LIMIT = 64;
|
|
69
|
+
/** How long one balance snapshot is reused before the host refetches it (15s). */
|
|
70
|
+
export declare const BALANCE_CACHE_MS = 15000;
|
|
71
|
+
/** Hard cap on one `/user/balance` request (5s); a hung endpoint never blocks the badge. */
|
|
72
|
+
export declare const BALANCE_TIMEOUT_MS = 5000;
|
|
65
73
|
/**
|
|
66
|
-
* Register the `billing` Remote under the `billing` namespace.
|
|
74
|
+
* Register the `billing` Remote under the `billing` namespace. Assembly only:
|
|
75
|
+
* facts resolve once, each loader owns its caches, and the gateway receives
|
|
76
|
+
* the bound thunks.
|
|
67
77
|
* @param ctx - owning plugin context.
|
|
68
78
|
* @param config - validated plugin config.
|
|
69
79
|
*/
|