@rayadesu/dsh-llm-billing 0.2.1 → 0.2.3
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/lib/index.js +661 -3178
- package/lib/invariant.js +3 -3
- package/lib/typert.host.js +31 -19
- package/lib/typert.remote-client.d.ts +3 -4
- package/lib/typert.remote-client.js +31 -19
- package/lib/types/balance.d.ts +14 -6
- package/lib/types/balance.js +9 -5
- package/lib/types/billing.d.ts +105 -7
- package/lib/types/billing.js +199 -72
- package/lib/types/index.d.ts +27 -5
- package/lib/types/index.js +91 -54
- package/lib/types/invariant.d.ts +2 -2
- package/lib/types/invariant.js +3 -3
- package/lib/types/projection.d.ts +69 -0
- package/lib/types/projection.js +88 -0
- package/lib/types/today-spend.d.ts +142 -0
- package/lib/types/today-spend.js +255 -0
- package/lib/types/types.d.ts +3 -3
- package/lib/types/types.js +1 -1
- package/package.json +20 -17
package/lib/types/billing.js
CHANGED
|
@@ -3,9 +3,20 @@
|
|
|
3
3
|
* pricing. Pure functions over session events and the pricing table, so the
|
|
4
4
|
* Remote gateway stays transport-free and the whole spend is testable without
|
|
5
5
|
* a key.
|
|
6
|
-
*
|
|
6
|
+
*
|
|
7
|
+
* The per-event pricing lives in {@link priceEvent} and the fold in the
|
|
8
|
+
* {@link SpendAccumulator}: the events-scan paths ({@link computeSessionSpend},
|
|
9
|
+
* {@link computeTodaySpend}), the session-projection unit (`billingTodaySpend`
|
|
10
|
+
* in projection.ts), and the scanner's single-pass events path all price
|
|
11
|
+
* through the same primitives, so a pricing-table change cannot drift one path
|
|
12
|
+
* from the others.
|
|
13
|
+
* @module @rayadesu/dsh-llm-billing/billing
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Published peak-hour windows (Beijing time): 09:00–12:00 and 14:00–18:00,
|
|
17
|
+
* applied on weekdays (Monday–Friday) only — weekends are always off-peak
|
|
18
|
+
* (effective 2026-08-23).
|
|
7
19
|
*/
|
|
8
|
-
/** Published peak-hour windows (Beijing time): 09:00–12:00 and 14:00–18:00. */
|
|
9
20
|
export const DEFAULT_PEAK_HOURS = [
|
|
10
21
|
{ start: 9, end: 12 },
|
|
11
22
|
{ start: 14, end: 18 },
|
|
@@ -22,6 +33,13 @@ export const DEFAULT_MODEL_PRICING = [
|
|
|
22
33
|
peak: { cacheHitInput: 0.30, cacheMissInput: 9.0, output: 27.0 },
|
|
23
34
|
offPeak: { cacheHitInput: 0.15, cacheMissInput: 4.5, output: 13.5 },
|
|
24
35
|
},
|
|
36
|
+
// deepseek-v4-flash-vision-exp bills at the same rates as deepseek-v4-flash;
|
|
37
|
+
// images are converted to tokens at the same per-token price.
|
|
38
|
+
{
|
|
39
|
+
model: 'deepseek-v4-flash-vision-exp',
|
|
40
|
+
peak: { cacheHitInput: 0.10, cacheMissInput: 3.0, output: 9.0 },
|
|
41
|
+
offPeak: { cacheHitInput: 0.05, cacheMissInput: 1.5, output: 4.5 },
|
|
42
|
+
},
|
|
25
43
|
];
|
|
26
44
|
/**
|
|
27
45
|
* Resolve optional configuration to a pricing table, defaulting omitted or
|
|
@@ -44,27 +62,184 @@ export function resolveBilling(config) {
|
|
|
44
62
|
models.set(row.model, { peak: row.peak, offPeak: row.offPeak });
|
|
45
63
|
return { peakHours, models };
|
|
46
64
|
}
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
65
|
+
/**
|
|
66
|
+
* Derive the Beijing hour, weekday, and calendar-day key of one timestamp from
|
|
67
|
+
* a single shifted `Date` — every timezone-sensitive read shares this one
|
|
68
|
+
* implementation, so the pieces cannot drift apart.
|
|
69
|
+
* @param time - epoch milliseconds.
|
|
70
|
+
*/
|
|
71
|
+
function beijingParts(time) {
|
|
72
|
+
const shifted = new Date(time + 8 * 3_600_000);
|
|
73
|
+
return {
|
|
74
|
+
hour: shifted.getUTCHours(),
|
|
75
|
+
weekday: shifted.getUTCDay(),
|
|
76
|
+
dayKey: shifted.toISOString().slice(0, 10),
|
|
77
|
+
};
|
|
50
78
|
}
|
|
51
79
|
/** The Beijing (Asia/Shanghai, UTC+8, no DST) calendar-day key of a timestamp. */
|
|
52
|
-
function beijingDayKey(now) {
|
|
53
|
-
return
|
|
80
|
+
export function beijingDayKey(now) {
|
|
81
|
+
return beijingParts(now.getTime()).dayKey;
|
|
82
|
+
}
|
|
83
|
+
/** Whether a Beijing (hour, weekday) pair falls inside any peak-hour window. */
|
|
84
|
+
function isPeakParts(billing, hour, weekday) {
|
|
85
|
+
if (weekday === 0 || weekday === 6)
|
|
86
|
+
return false;
|
|
87
|
+
return billing.peakHours.some(({ start, end }) => hour >= start && hour < end);
|
|
54
88
|
}
|
|
55
89
|
/**
|
|
56
|
-
* Whether a timestamp falls inside any peak-hour window (Beijing time
|
|
90
|
+
* Whether a timestamp falls inside any peak-hour window (Beijing time,
|
|
91
|
+
* weekdays Monday–Friday only). Weekends (Saturday and Sunday) are always
|
|
92
|
+
* off-peak, matching the published peak-hours rule.
|
|
57
93
|
* @param billing - resolved pricing with peak-hour windows.
|
|
58
94
|
* @param now - the moment to classify.
|
|
59
|
-
* @returns true during peak
|
|
95
|
+
* @returns true during a weekday peak hour.
|
|
60
96
|
*/
|
|
61
97
|
export function isPeak(billing, now) {
|
|
62
|
-
const hour =
|
|
63
|
-
return billing
|
|
98
|
+
const { hour, weekday } = beijingParts(now.getTime());
|
|
99
|
+
return isPeakParts(billing, hour, weekday);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Price one event at the official per-model rates, applying the peak/off-peak
|
|
103
|
+
* table by its Beijing-time hour and weekday (peak windows apply Monday–Friday
|
|
104
|
+
* only; weekends are off-peak). Each `assistant/message` event with usage
|
|
105
|
+
* contributes cache-hit input, cache-miss input (uncached input plus cache
|
|
106
|
+
* writes), and output (reasoning included) tokens at the rate of its own
|
|
107
|
+
* timestamp; a model with usage but no pricing row contributes nothing (the
|
|
108
|
+
* published table prices only the two V4 rows).
|
|
109
|
+
* @param event - the event to price.
|
|
110
|
+
* @param billing - resolved pricing with peak-hour windows.
|
|
111
|
+
* @param names - model id → display label.
|
|
112
|
+
* @returns the priced contribution, or `undefined` when the event has no priced usage.
|
|
113
|
+
*/
|
|
114
|
+
export function priceEvent(event, billing, names) {
|
|
115
|
+
if (event.type !== 'assistant/message')
|
|
116
|
+
return undefined;
|
|
117
|
+
const reported = event.data.usage;
|
|
118
|
+
if (reported === undefined)
|
|
119
|
+
return undefined;
|
|
120
|
+
const model = event.data.message.source.model;
|
|
121
|
+
const pricing = billing.models.get(model);
|
|
122
|
+
if (pricing === undefined)
|
|
123
|
+
return undefined;
|
|
124
|
+
const { hour, weekday, dayKey } = beijingParts(event.time);
|
|
125
|
+
const peak = isPeakParts(billing, hour, weekday);
|
|
126
|
+
const price = peak ? pricing.peak : pricing.offPeak;
|
|
127
|
+
const hit = reported.cacheReadTokens ?? 0;
|
|
128
|
+
const miss = reported.inputTokens + (reported.cacheWriteTokens ?? 0);
|
|
129
|
+
const output = reported.outputTokens;
|
|
130
|
+
const hitCost = (hit * price.cacheHitInput) / 1_000_000;
|
|
131
|
+
const missCost = (miss * price.cacheMissInput) / 1_000_000;
|
|
132
|
+
const outputCost = (output * price.output) / 1_000_000;
|
|
133
|
+
const cost = hitCost + missCost + outputCost;
|
|
134
|
+
return {
|
|
135
|
+
dayKey,
|
|
136
|
+
model,
|
|
137
|
+
displayName: names.get(model) ?? model,
|
|
138
|
+
cost,
|
|
139
|
+
peakCost: peak ? cost : 0,
|
|
140
|
+
offPeakCost: peak ? 0 : cost,
|
|
141
|
+
cacheHitInputTokens: hit,
|
|
142
|
+
cacheMissInputTokens: miss,
|
|
143
|
+
outputTokens: output,
|
|
144
|
+
cacheHitInputCost: hitCost,
|
|
145
|
+
cacheMissInputCost: missCost,
|
|
146
|
+
outputCost,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
/** A spend with no priced usage. */
|
|
150
|
+
export function emptyTodaySpend() {
|
|
151
|
+
return { total: 0, models: [] };
|
|
152
|
+
}
|
|
153
|
+
/** The today-spend shape of a single priced contribution. */
|
|
154
|
+
function contributionModel(priced) {
|
|
155
|
+
return {
|
|
156
|
+
model: priced.model,
|
|
157
|
+
displayName: priced.displayName,
|
|
158
|
+
cost: priced.cost,
|
|
159
|
+
peakCost: priced.peakCost,
|
|
160
|
+
offPeakCost: priced.offPeakCost,
|
|
161
|
+
cacheHitInputTokens: priced.cacheHitInputTokens,
|
|
162
|
+
cacheMissInputTokens: priced.cacheMissInputTokens,
|
|
163
|
+
outputTokens: priced.outputTokens,
|
|
164
|
+
cacheHitInputCost: priced.cacheHitInputCost,
|
|
165
|
+
cacheMissInputCost: priced.cacheMissInputCost,
|
|
166
|
+
outputCost: priced.outputCost,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/** Sum two model rows of the same model (pure). */
|
|
170
|
+
function mergeModelRows(left, right) {
|
|
171
|
+
return {
|
|
172
|
+
model: left.model,
|
|
173
|
+
displayName: left.displayName,
|
|
174
|
+
cost: left.cost + right.cost,
|
|
175
|
+
peakCost: left.peakCost + right.peakCost,
|
|
176
|
+
offPeakCost: left.offPeakCost + right.offPeakCost,
|
|
177
|
+
cacheHitInputTokens: left.cacheHitInputTokens + right.cacheHitInputTokens,
|
|
178
|
+
cacheMissInputTokens: left.cacheMissInputTokens + right.cacheMissInputTokens,
|
|
179
|
+
outputTokens: left.outputTokens + right.outputTokens,
|
|
180
|
+
cacheHitInputCost: left.cacheHitInputCost + right.cacheHitInputCost,
|
|
181
|
+
cacheMissInputCost: left.cacheMissInputCost + right.cacheMissInputCost,
|
|
182
|
+
outputCost: left.outputCost + right.outputCost,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Mutable model-row accumulator behind every spend fold. Rows keep first-seen
|
|
187
|
+
* model order — the same shape a pure `addEventContribution` chain produces —
|
|
188
|
+
* so the single-pass scan path and the pure public paths cannot diverge. One
|
|
189
|
+
* `Map` lookup per contribution instead of a per-event array copy: the huge
|
|
190
|
+
* event-log folds allocate one row object per model, not one intermediate
|
|
191
|
+
* array per event.
|
|
192
|
+
*/
|
|
193
|
+
export class SpendAccumulator {
|
|
194
|
+
rows = new Map();
|
|
195
|
+
total = 0;
|
|
196
|
+
/** Add one priced contribution. */
|
|
197
|
+
add(priced) {
|
|
198
|
+
const row = contributionModel(priced);
|
|
199
|
+
const existing = this.rows.get(priced.model);
|
|
200
|
+
this.rows.set(priced.model, existing === undefined ? row : mergeModelRows(existing, row));
|
|
201
|
+
this.total += priced.cost;
|
|
202
|
+
}
|
|
203
|
+
/** The folded spend; the accumulator stays usable afterwards. */
|
|
204
|
+
finish() {
|
|
205
|
+
return { total: this.total, models: [...this.rows.values()] };
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Merge one priced event's contribution into an accumulator spend (pure:
|
|
210
|
+
* returns a new spend, never mutates its input).
|
|
211
|
+
* @param spend - the accumulator (per session and day, or across sessions).
|
|
212
|
+
* @param priced - the priced contribution to add.
|
|
213
|
+
* @returns the merged spend.
|
|
214
|
+
*/
|
|
215
|
+
export function addEventContribution(spend, priced) {
|
|
216
|
+
const row = contributionModel(priced);
|
|
217
|
+
const rows = spend.models.map(existing => existing.model === priced.model ? mergeModelRows(existing, row) : existing);
|
|
218
|
+
if (!rows.some(existing => existing.model === priced.model))
|
|
219
|
+
rows.push(row);
|
|
220
|
+
return { total: spend.total + priced.cost, models: rows };
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Sum two spends (per session and day, or across sessions) into one (pure:
|
|
224
|
+
* returns a new spend, never mutates its inputs).
|
|
225
|
+
* @param target - the accumulator spend.
|
|
226
|
+
* @param source - the spend to add.
|
|
227
|
+
* @returns the summed spend.
|
|
228
|
+
*/
|
|
229
|
+
export function mergeTodaySpend(target, source) {
|
|
230
|
+
const rows = new Map();
|
|
231
|
+
for (const row of target.models)
|
|
232
|
+
rows.set(row.model, row);
|
|
233
|
+
for (const row of source.models) {
|
|
234
|
+
const existing = rows.get(row.model);
|
|
235
|
+
rows.set(row.model, existing === undefined ? row : mergeModelRows(existing, row));
|
|
236
|
+
}
|
|
237
|
+
return { total: target.total + source.total, models: [...rows.values()] };
|
|
64
238
|
}
|
|
65
239
|
/**
|
|
66
240
|
* Price a set of billed events at the official per-model rates, applying the
|
|
67
|
-
* peak/off-peak table per event by its Beijing-time hour
|
|
241
|
+
* peak/off-peak table per event by its Beijing-time hour and weekday (peak
|
|
242
|
+
* windows apply Monday–Friday only; weekends are off-peak). Each
|
|
68
243
|
* `assistant/message` event with usage contributes cache-hit input, cache-miss
|
|
69
244
|
* input (uncached input plus cache writes), and output (reasoning included)
|
|
70
245
|
* tokens at the rate of its own timestamp, with the three component costs
|
|
@@ -72,69 +247,19 @@ export function isPeak(billing, now) {
|
|
|
72
247
|
* published table prices only the two V4 rows).
|
|
73
248
|
* @param events - the events to price.
|
|
74
249
|
* @param billing - resolved pricing with peak-hour windows.
|
|
75
|
-
* @param
|
|
250
|
+
* @param names - model id → display label.
|
|
251
|
+
* @param dayKey - when provided, only events on this Beijing calendar day contribute.
|
|
76
252
|
* @returns the total cost plus one row per priced model.
|
|
77
253
|
*/
|
|
78
|
-
function priceEvents(events, billing,
|
|
79
|
-
const
|
|
80
|
-
const rows = new Map();
|
|
254
|
+
function priceEvents(events, billing, names, dayKey) {
|
|
255
|
+
const accumulator = new SpendAccumulator();
|
|
81
256
|
for (const event of events) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const reported = event.data.usage;
|
|
85
|
-
if (reported === undefined)
|
|
86
|
-
continue;
|
|
87
|
-
const model = event.data.message.source.model;
|
|
88
|
-
const pricing = billing.models.get(model);
|
|
89
|
-
if (pricing === undefined)
|
|
257
|
+
const priced = priceEvent(event, billing, names);
|
|
258
|
+
if (priced === undefined || (dayKey !== undefined && priced.dayKey !== dayKey))
|
|
90
259
|
continue;
|
|
91
|
-
|
|
92
|
-
const price = peak ? pricing.peak : pricing.offPeak;
|
|
93
|
-
const hit = reported.cacheReadTokens ?? 0;
|
|
94
|
-
const miss = reported.inputTokens + (reported.cacheWriteTokens ?? 0);
|
|
95
|
-
const output = reported.outputTokens;
|
|
96
|
-
const hitCost = (hit * price.cacheHitInput) / 1_000_000;
|
|
97
|
-
const missCost = (miss * price.cacheMissInput) / 1_000_000;
|
|
98
|
-
const outputCost = (output * price.output) / 1_000_000;
|
|
99
|
-
const cost = hitCost + missCost + outputCost;
|
|
100
|
-
let row = rows.get(model);
|
|
101
|
-
if (row === undefined) {
|
|
102
|
-
row = {
|
|
103
|
-
cacheHitInputTokens: 0, cacheMissInputTokens: 0, outputTokens: 0,
|
|
104
|
-
cost: 0, peakCost: 0, offPeakCost: 0,
|
|
105
|
-
cacheHitInputCost: 0, cacheMissInputCost: 0, outputCost: 0,
|
|
106
|
-
};
|
|
107
|
-
rows.set(model, row);
|
|
108
|
-
}
|
|
109
|
-
row.cacheHitInputTokens += hit;
|
|
110
|
-
row.cacheMissInputTokens += miss;
|
|
111
|
-
row.outputTokens += output;
|
|
112
|
-
row.cost += cost;
|
|
113
|
-
row.cacheHitInputCost += hitCost;
|
|
114
|
-
row.cacheMissInputCost += missCost;
|
|
115
|
-
row.outputCost += outputCost;
|
|
116
|
-
if (peak)
|
|
117
|
-
row.peakCost += cost;
|
|
118
|
-
else
|
|
119
|
-
row.offPeakCost += cost;
|
|
260
|
+
accumulator.add(priced);
|
|
120
261
|
}
|
|
121
|
-
|
|
122
|
-
model,
|
|
123
|
-
displayName: names.get(model) ?? model,
|
|
124
|
-
cost: row.cost,
|
|
125
|
-
peakCost: row.peakCost,
|
|
126
|
-
offPeakCost: row.offPeakCost,
|
|
127
|
-
cacheHitInputTokens: row.cacheHitInputTokens,
|
|
128
|
-
cacheMissInputTokens: row.cacheMissInputTokens,
|
|
129
|
-
outputTokens: row.outputTokens,
|
|
130
|
-
cacheHitInputCost: row.cacheHitInputCost,
|
|
131
|
-
cacheMissInputCost: row.cacheMissInputCost,
|
|
132
|
-
outputCost: row.outputCost,
|
|
133
|
-
}));
|
|
134
|
-
return {
|
|
135
|
-
total: models.reduce((sum, model) => sum + model.cost, 0),
|
|
136
|
-
models,
|
|
137
|
-
};
|
|
262
|
+
return accumulator.finish();
|
|
138
263
|
}
|
|
139
264
|
/**
|
|
140
265
|
* Price one session's complete event log at the official per-model rates.
|
|
@@ -144,7 +269,8 @@ function priceEvents(events, billing, catalog) {
|
|
|
144
269
|
* @returns the session's total cost plus one row per priced model.
|
|
145
270
|
*/
|
|
146
271
|
export function computeSessionSpend(events, billing, catalog) {
|
|
147
|
-
|
|
272
|
+
const names = new Map(catalog.map(model => [model.id, model.name]));
|
|
273
|
+
return priceEvents(events, billing, names);
|
|
148
274
|
}
|
|
149
275
|
/**
|
|
150
276
|
* Price every event whose Beijing-time calendar day is the day of `now`,
|
|
@@ -158,6 +284,7 @@ export function computeSessionSpend(events, billing, catalog) {
|
|
|
158
284
|
*/
|
|
159
285
|
export function computeTodaySpend(events, billing, catalog, now = new Date()) {
|
|
160
286
|
const day = beijingDayKey(now);
|
|
161
|
-
|
|
287
|
+
const names = new Map(catalog.map(model => [model.id, model.name]));
|
|
288
|
+
return priceEvents(events, billing, names, day);
|
|
162
289
|
}
|
|
163
290
|
//# sourceMappingURL=billing.js.map
|
package/lib/types/index.d.ts
CHANGED
|
@@ -4,15 +4,33 @@
|
|
|
4
4
|
* the credential/environment seams, prices each session's billed usage with the
|
|
5
5
|
* peak/off-peak table, and exposes the `billing` Remote (`getBalance`, the
|
|
6
6
|
* per-session `getSessionSpend`, and the all-sessions `getTodaySpend`).
|
|
7
|
-
*
|
|
7
|
+
*
|
|
8
|
+
* Today's spend never scans every session log per request: a 60-second
|
|
9
|
+
* Beijing-day cache with in-flight coalescing serves the message-triggered
|
|
10
|
+
* reads, the manual refresh may bypass the time window (`force`), and the
|
|
11
|
+
* computation behind a miss reads only sessions whose persisted revision
|
|
12
|
+
* changed since the last resolution (see today-spend.ts). When the
|
|
13
|
+
* session-projection registry is composed, the plugin additionally registers
|
|
14
|
+
* the `billingTodaySpend` projection unit, which folds each session's spend
|
|
15
|
+
* eagerly and lets cold reads ride the projection-cache ladder.
|
|
16
|
+
*
|
|
17
|
+
* A per-session spend cache makes the badge's turn-settled recompute
|
|
18
|
+
* incremental: session logs are append-only and chronological (the same
|
|
19
|
+
* assumption the projection unit makes), so the spend is reused while the log
|
|
20
|
+
* length is unchanged, and only the appended tail is priced when it grows.
|
|
21
|
+
* @module @rayadesu/dsh-llm-billing
|
|
8
22
|
*/
|
|
9
23
|
import type { Context } from '@deepseek-ai/cordis';
|
|
10
24
|
import z from '@deepseek-ai/schemastery';
|
|
11
25
|
import type { BillingConfig } from './billing.ts';
|
|
12
26
|
export { DeepSeekBalanceGateway, fetchDeepSeekBalance, parseDeepSeekBalance } from './balance.ts';
|
|
13
|
-
export { computeSessionSpend, computeTodaySpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, isPeak, resolveBilling, } from './billing.ts';
|
|
14
|
-
export type { BillingConfig, BillingConfigModel, DeepSeekModelPricing, DeepSeekTokenPrice, PeakHourWindow, ResolvedBilling, } from './billing.ts';
|
|
27
|
+
export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, isPeak, mergeTodaySpend, priceEvent, resolveBilling, SpendAccumulator, } from './billing.ts';
|
|
28
|
+
export type { BillingConfig, BillingConfigModel, BillingEventContribution, DeepSeekModelPricing, DeepSeekTokenPrice, PeakHourWindow, ResolvedBilling, } from './billing.ts';
|
|
15
29
|
export type * from './types.ts';
|
|
30
|
+
export { BILLING_UNIT_KEY, billingTodaySpendDefinition, foldBillingUnit } from './projection.ts';
|
|
31
|
+
export type { BillingUnitState } from './projection.ts';
|
|
32
|
+
export { TodaySpendCache, TodaySpendScanner } from './today-spend.ts';
|
|
33
|
+
export type { ScannerPersistedHeader, ScannerSession, TodaySpendScannerDeps } from './today-spend.ts';
|
|
16
34
|
export declare const name = "llm-billing";
|
|
17
35
|
/** Public API default; deployments may point elsewhere via $DEEPSEEK_BASE_URL. */
|
|
18
36
|
export declare const PUBLIC_BASE_URL = "https://api.deepseek.com";
|
|
@@ -34,12 +52,16 @@ export interface Config {
|
|
|
34
52
|
apiKeyEnv?: string;
|
|
35
53
|
/** Endpoint base; defaults to `$DEEPSEEK_BASE_URL`, then `https://api.deepseek.com`. */
|
|
36
54
|
baseURL?: string;
|
|
37
|
-
/** Advisory display rows, in presentation order; defaults to V4 Flash and V4
|
|
55
|
+
/** Advisory display rows, in presentation order; defaults to V4 Flash, V4 Pro, and V4 Flash Vision Exp. */
|
|
38
56
|
models?: BillingModel[];
|
|
39
|
-
/** Pricing table and peak-hour windows; omission uses the published defaults. */
|
|
57
|
+
/** Pricing table and peak-hour windows; omission uses the published defaults. Peak windows apply weekdays (Monday–Friday) only; weekends are always off-peak. */
|
|
40
58
|
billing?: BillingConfig;
|
|
41
59
|
}
|
|
42
60
|
export declare const Config: z<Config>;
|
|
61
|
+
/** How often a Beijing-day "today spend" value may be recomputed (60s). */
|
|
62
|
+
export declare const TODAY_SPEND_CACHE_MS = 60000;
|
|
63
|
+
/** Hard cap on today's events collected by the events scan path. */
|
|
64
|
+
export declare const TODAY_SPEND_MAX_EVENTS = 200000;
|
|
43
65
|
/**
|
|
44
66
|
* Register the `billing` Remote under the `billing` namespace.
|
|
45
67
|
* @param ctx - owning plugin context.
|
package/lib/types/index.js
CHANGED
|
@@ -4,16 +4,34 @@
|
|
|
4
4
|
* the credential/environment seams, prices each session's billed usage with the
|
|
5
5
|
* peak/off-peak table, and exposes the `billing` Remote (`getBalance`, the
|
|
6
6
|
* per-session `getSessionSpend`, and the all-sessions `getTodaySpend`).
|
|
7
|
-
*
|
|
7
|
+
*
|
|
8
|
+
* Today's spend never scans every session log per request: a 60-second
|
|
9
|
+
* Beijing-day cache with in-flight coalescing serves the message-triggered
|
|
10
|
+
* reads, the manual refresh may bypass the time window (`force`), and the
|
|
11
|
+
* computation behind a miss reads only sessions whose persisted revision
|
|
12
|
+
* changed since the last resolution (see today-spend.ts). When the
|
|
13
|
+
* session-projection registry is composed, the plugin additionally registers
|
|
14
|
+
* the `billingTodaySpend` projection unit, which folds each session's spend
|
|
15
|
+
* eagerly and lets cold reads ride the projection-cache ladder.
|
|
16
|
+
*
|
|
17
|
+
* A per-session spend cache makes the badge's turn-settled recompute
|
|
18
|
+
* incremental: session logs are append-only and chronological (the same
|
|
19
|
+
* assumption the projection unit makes), so the spend is reused while the log
|
|
20
|
+
* length is unchanged, and only the appended tail is priced when it grows.
|
|
21
|
+
* @module @rayadesu/dsh-llm-billing
|
|
8
22
|
*/
|
|
9
23
|
import z from '@deepseek-ai/schemastery';
|
|
10
24
|
import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm';
|
|
11
25
|
import { credentialRef } from '@deepseek-ai/dsh-credentials';
|
|
12
26
|
import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment';
|
|
13
27
|
import { DeepSeekBalanceGateway, fetchDeepSeekBalance } from "./balance.js";
|
|
14
|
-
import { computeSessionSpend,
|
|
28
|
+
import { computeSessionSpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, mergeTodaySpend, resolveBilling, } from "./billing.js";
|
|
29
|
+
import { billingTodaySpendDefinition } from "./projection.js";
|
|
30
|
+
import { TodaySpendCache, TodaySpendScanner } from "./today-spend.js";
|
|
15
31
|
export { DeepSeekBalanceGateway, fetchDeepSeekBalance, parseDeepSeekBalance } from "./balance.js";
|
|
16
|
-
export { computeSessionSpend, computeTodaySpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, isPeak, resolveBilling, } from "./billing.js";
|
|
32
|
+
export { addEventContribution, beijingDayKey, computeSessionSpend, computeTodaySpend, DEFAULT_MODEL_PRICING, DEFAULT_PEAK_HOURS, emptyTodaySpend, isPeak, mergeTodaySpend, priceEvent, resolveBilling, SpendAccumulator, } from "./billing.js";
|
|
33
|
+
export { BILLING_UNIT_KEY, billingTodaySpendDefinition, foldBillingUnit } from "./projection.js";
|
|
34
|
+
export { TodaySpendCache, TodaySpendScanner } from "./today-spend.js";
|
|
17
35
|
export const name = 'llm-billing';
|
|
18
36
|
const DEFAULT_API_KEY_ENV = 'DEEPSEEK_API_KEY';
|
|
19
37
|
const BASE_URL_ENV = 'DEEPSEEK_BASE_URL';
|
|
@@ -22,6 +40,7 @@ export const PUBLIC_BASE_URL = 'https://api.deepseek.com';
|
|
|
22
40
|
const DEFAULT_MODELS = [
|
|
23
41
|
{ id: 'deepseek-v4-flash', name: 'DeepSeek-V4-Flash' },
|
|
24
42
|
{ id: 'deepseek-v4-pro', name: 'DeepSeek-V4-Pro' },
|
|
43
|
+
{ id: 'deepseek-v4-flash-vision-exp', name: 'DeepSeek-V4-Flash-Vision-Exp' },
|
|
25
44
|
];
|
|
26
45
|
const billingModel = z.object({
|
|
27
46
|
id: z.string().required(),
|
|
@@ -49,9 +68,14 @@ export const Config = z.object({
|
|
|
49
68
|
models: z.array(billingModel).default(DEFAULT_MODELS),
|
|
50
69
|
billing: billingConfig,
|
|
51
70
|
});
|
|
71
|
+
/** How often a Beijing-day "today spend" value may be recomputed (60s). */
|
|
72
|
+
export const TODAY_SPEND_CACHE_MS = 60_000;
|
|
73
|
+
/** Hard cap on today's events collected by the events scan path. */
|
|
74
|
+
export const TODAY_SPEND_MAX_EVENTS = 200_000;
|
|
52
75
|
/**
|
|
53
76
|
* Read one session's event log: the live SessionStore first, then the
|
|
54
|
-
* persistence backend for a flushed session
|
|
77
|
+
* persistence backend for a flushed session (inspected directly by id — no
|
|
78
|
+
* header listing).
|
|
55
79
|
* @param ctx - plugin context carrying the SessionStore and optional persistence.
|
|
56
80
|
* @param sessionId - the session to read.
|
|
57
81
|
* @returns the session's complete event log.
|
|
@@ -64,52 +88,14 @@ async function sessionEvents(ctx, sessionId) {
|
|
|
64
88
|
return live.events;
|
|
65
89
|
const persistence = ctx.get('sessionPersistence');
|
|
66
90
|
if (persistence !== undefined) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
continue;
|
|
70
|
-
const inspection = await persistence.inspect(sessionId);
|
|
71
|
-
return inspection.events;
|
|
91
|
+
try {
|
|
92
|
+
return (await persistence.inspect(sessionId)).events;
|
|
72
93
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* Read every session's event log, concatenated: each live SessionStore
|
|
78
|
-
* session first (its log may hold events not yet flushed), then each persisted
|
|
79
|
-
* session that is not live, so no event is counted twice. Events are appended
|
|
80
|
-
* one at a time: spreading a very large log into `push(...)` exceeds the
|
|
81
|
-
* engine's argument limit and throws a stack RangeError.
|
|
82
|
-
* @param ctx - plugin context carrying the SessionStore and optional persistence.
|
|
83
|
-
* @returns every session's complete event log, concatenated.
|
|
84
|
-
*/
|
|
85
|
-
async function allSessionEvents(ctx) {
|
|
86
|
-
const events = [];
|
|
87
|
-
const sessions = ctx.get('sessions');
|
|
88
|
-
const liveIds = new Set();
|
|
89
|
-
if (sessions !== undefined) {
|
|
90
|
-
for (const session of sessions.list()) {
|
|
91
|
-
liveIds.add(session.id);
|
|
92
|
-
for (const event of session.events)
|
|
93
|
-
events.push(event);
|
|
94
|
+
catch (error) {
|
|
95
|
+
throw new LlmError(`llm-billing: session ${sessionId} not found`, 'NOT_FOUND', { cause: error });
|
|
94
96
|
}
|
|
95
97
|
}
|
|
96
|
-
|
|
97
|
-
if (persistence !== undefined) {
|
|
98
|
-
for (const header of await persistence.list()) {
|
|
99
|
-
if (liveIds.has(header.id))
|
|
100
|
-
continue;
|
|
101
|
-
try {
|
|
102
|
-
const inspection = await persistence.inspect(header.id);
|
|
103
|
-
for (const event of inspection.events)
|
|
104
|
-
events.push(event);
|
|
105
|
-
}
|
|
106
|
-
catch (error) {
|
|
107
|
-
// One unreadable session must not blank the whole-day aggregate.
|
|
108
|
-
ctx.logger.warn(`llm-billing: skipping unreadable session ${header.id}: ${String(error)}`);
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
return events;
|
|
98
|
+
throw new LlmError(`llm-billing: session ${sessionId} not found`, 'NOT_FOUND');
|
|
113
99
|
}
|
|
114
100
|
/**
|
|
115
101
|
* Register the `billing` Remote under the `billing` namespace.
|
|
@@ -140,16 +126,67 @@ export function apply(ctx, config) {
|
|
|
140
126
|
const apiKey = await resolveApiKey();
|
|
141
127
|
return fetchDeepSeekBalance(baseURL(), apiKey);
|
|
142
128
|
};
|
|
129
|
+
const billing = resolveBilling(config.billing);
|
|
130
|
+
const catalog = (config.models ?? DEFAULT_MODELS).map(model => ({ id: model.id, name: model.name ?? model.id }));
|
|
131
|
+
// Per-session incremental spend cache: a session log is append-only and
|
|
132
|
+
// chronological (the same assumption the projection unit makes), so a spend
|
|
133
|
+
// computed for `count` events stays valid while the log length is unchanged,
|
|
134
|
+
// and only the appended tail needs pricing when it grows. A pricing-table
|
|
135
|
+
// change does not retroactively reprice (same caveat as the projection
|
|
136
|
+
// path); the map is capped so an unbounded session-id space cannot grow it
|
|
137
|
+
// without bound.
|
|
138
|
+
const sessionSpendCache = new Map();
|
|
143
139
|
const fetchSessionSpend = async (sessionId) => {
|
|
144
|
-
const
|
|
145
|
-
const
|
|
146
|
-
|
|
140
|
+
const events = await sessionEvents(ctx, sessionId);
|
|
141
|
+
const cached = sessionSpendCache.get(sessionId);
|
|
142
|
+
if (cached !== undefined && cached.count === events.length)
|
|
143
|
+
return cached.spend;
|
|
144
|
+
if (cached !== undefined && cached.count < events.length) {
|
|
145
|
+
const spend = mergeTodaySpend(cached.spend, computeSessionSpend(events.slice(cached.count), billing, catalog));
|
|
146
|
+
sessionSpendCache.set(sessionId, { count: events.length, spend });
|
|
147
|
+
return spend;
|
|
148
|
+
}
|
|
149
|
+
const spend = computeSessionSpend(events, billing, catalog);
|
|
150
|
+
if (sessionSpendCache.size >= 1024)
|
|
151
|
+
sessionSpendCache.clear();
|
|
152
|
+
sessionSpendCache.set(sessionId, { count: events.length, spend });
|
|
153
|
+
return spend;
|
|
147
154
|
};
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
155
|
+
// Plan C: register the per-session spend projection unit on the projection
|
|
156
|
+
// registry. Registration is lazy — it happens on the first projection-path
|
|
157
|
+
// scan, not through `ctx.inject` (whose plugin-mount wait would also engage
|
|
158
|
+
// the test-invariant host in suites that never provide the registry). The
|
|
159
|
+
// registry builds cells lazily over the in-memory log, so events committed
|
|
160
|
+
// before registration are folded on first touch; without the registry the
|
|
161
|
+
// events path serves today's spend.
|
|
162
|
+
const unit = billingTodaySpendDefinition(billing, catalog);
|
|
163
|
+
let unitRegistered = false;
|
|
164
|
+
const ensureUnit = () => {
|
|
165
|
+
if (unitRegistered)
|
|
166
|
+
return;
|
|
167
|
+
const registry = ctx.get('sessionProjections');
|
|
168
|
+
if (registry === undefined)
|
|
169
|
+
return;
|
|
170
|
+
registry.register(unit);
|
|
171
|
+
unitRegistered = true;
|
|
152
172
|
};
|
|
173
|
+
// Plans A1–A3: 60s Beijing-day cache with in-flight coalescing and a force
|
|
174
|
+
// bypass, over a revision-gated scanner (projection path when the registry
|
|
175
|
+
// is composed, events path otherwise).
|
|
176
|
+
const scanner = new TodaySpendScanner({
|
|
177
|
+
sessions: () => ctx.get('sessions'),
|
|
178
|
+
persistence: () => ctx.get('sessionPersistence'),
|
|
179
|
+
projections: () => ctx.get('sessionProjections'),
|
|
180
|
+
projectionCache: () => ctx.get('sessionProjectionCache'),
|
|
181
|
+
ensureUnit,
|
|
182
|
+
unit,
|
|
183
|
+
maxEvents: TODAY_SPEND_MAX_EVENTS,
|
|
184
|
+
logger: ctx.logger,
|
|
185
|
+
billing,
|
|
186
|
+
catalog,
|
|
187
|
+
});
|
|
188
|
+
const todayCache = new TodaySpendCache(dayKey => scanner.scan(dayKey), TODAY_SPEND_CACHE_MS);
|
|
189
|
+
const fetchTodaySpend = async (force = false) => todayCache.get(force);
|
|
153
190
|
new DeepSeekBalanceGateway(ctx, { fetchBalance, fetchSessionSpend, fetchTodaySpend });
|
|
154
191
|
}
|
|
155
192
|
//# sourceMappingURL=index.js.map
|
package/lib/types/invariant.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Package-owned invariant companion for `@
|
|
3
|
-
* @module @
|
|
2
|
+
* Package-owned invariant companion for `@rayadesu/dsh-llm-billing`.
|
|
3
|
+
* @module @rayadesu/dsh-llm-billing/invariant
|
|
4
4
|
*/
|
|
5
5
|
import type { Context } from '@deepseek-ai/cordis';
|
|
6
6
|
/** Cordis companion plugin name. */
|
package/lib/types/invariant.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Package-owned invariant companion for `@
|
|
3
|
-
* @module @
|
|
2
|
+
* Package-owned invariant companion for `@rayadesu/dsh-llm-billing`.
|
|
3
|
+
* @module @rayadesu/dsh-llm-billing/invariant
|
|
4
4
|
*/
|
|
5
|
-
const PACKAGE_NAME = '@
|
|
5
|
+
const PACKAGE_NAME = '@rayadesu/dsh-llm-billing';
|
|
6
6
|
/** Cordis companion plugin name. */
|
|
7
7
|
export const name = 'llm-billing-invariant';
|
|
8
8
|
/** Service required before the companion can reserve package ownership. */
|