@kenz1117/dsh-ui-usage-billing 0.1.0
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/LICENSE +21 -0
- package/README.md +156 -0
- package/lib/client.js +1746 -0
- package/lib/index.js +698 -0
- package/lib/invariant.js +15 -0
- package/lib/types/aggregate.d.ts +66 -0
- package/lib/types/client/TrendChart.d.ts +22 -0
- package/lib/types/client/UsageBilling.d.ts +40 -0
- package/lib/types/client/apply.d.ts +24 -0
- package/lib/types/client/index.d.ts +11 -0
- package/lib/types/client/locales.d.ts +6 -0
- package/lib/types/client/pricing.d.ts +118 -0
- package/lib/types/index.d.ts +28 -0
- package/lib/types/invariant.d.ts +11 -0
- package/package.json +86 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,698 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
//#region lib/types/client/pricing.js
|
|
5
|
+
/**
|
|
6
|
+
* Billing engine: per-model price tables and token-usage cost estimation.
|
|
7
|
+
*
|
|
8
|
+
* Each model's price table uses its NATIVE currency: domestic providers
|
|
9
|
+
* (DeepSeek, 智谱, 通义…) publish RMB prices and store them directly;
|
|
10
|
+
* overseas providers (OpenAI, Google, xAI, Meta) publish USD.
|
|
11
|
+
* Cost is always computed and displayed in CNY — only USD-priced models go
|
|
12
|
+
* through the exchange rate, never domestic ones.
|
|
13
|
+
*
|
|
14
|
+
* Google-style two-band billing is modeled per model: Gemini's Flex tier
|
|
15
|
+
* prices spare-capacity traffic at -50%; DeepSeek V4 splits peak
|
|
16
|
+
* (09:00-12:00 / 14:00-18:00 Beijing) at 2x the off-peak rate. The estimator
|
|
17
|
+
* mixes both bands by a configured peak share ({@link DEFAULT_PEAK_SHARE}).
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* USD → CNY rate for display. Source: China Foreign Exchange Trade System
|
|
21
|
+
* mid-rate 6.7878 on 2026-08-14; rounded to 6.79. Only applies to overseas
|
|
22
|
+
* USD-priced models — domestic models never pass through this rate.
|
|
23
|
+
*/
|
|
24
|
+
const USD_TO_CNY = 6.79;
|
|
25
|
+
/** Default share of traffic assumed to fall in the peak band (0..1). */
|
|
26
|
+
const DEFAULT_PEAK_SHARE = .5;
|
|
27
|
+
/**
|
|
28
|
+
* Model keys served through a subscription plan (e.g. a coding plan or topic
|
|
29
|
+
* plan) instead of metered per-token API billing. Usage through these routes
|
|
30
|
+
* costs no tokens: the estimator treats them as ¥0 and the billing table
|
|
31
|
+
* labels them 订阅包含. Add any model key your deployment serves through a
|
|
32
|
+
* plan here; leave empty when every route is pay-as-you-go.
|
|
33
|
+
*/
|
|
34
|
+
const SUBSCRIPTION_PLAN_KEYS = [];
|
|
35
|
+
/** Whether one stats model key is billed through a subscription plan. */
|
|
36
|
+
function isSubscriptionPlan(key) {
|
|
37
|
+
return SUBSCRIPTION_PLAN_KEYS.includes(key);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Built-in catalog of current mainstream models as of 2026-08-16, priced from
|
|
41
|
+
* each provider's official price page. Domestic providers are OpenAI-API
|
|
42
|
+
* compatible and publish RMB prices directly; overseas providers publish USD
|
|
43
|
+
* and convert through the exchange rate at estimate time. Retired models
|
|
44
|
+
* (GPT-4o family, Gemini 2.x, GLM-4.x-lite, older Qwen) are deliberately
|
|
45
|
+
* absent, as are Anthropic Claude models (their native API is not
|
|
46
|
+
* OpenAI-compatible, so the harness cannot drive them directly). DeepSeek
|
|
47
|
+
* keys match the harness stats file so real usage prices from the catalog;
|
|
48
|
+
* unknown keys fall back to `other`.
|
|
49
|
+
*
|
|
50
|
+
* Time-of-day billing (peak/off-peak) is now real: DeepSeek V4 officially
|
|
51
|
+
* splits peak (09:00-12:00 / 14:00-18:00 Beijing) at 2x the off-peak rate
|
|
52
|
+
* from 2026-08-17, and Gemini's Flex tier discounts spare-capacity traffic.
|
|
53
|
+
*/
|
|
54
|
+
const MODEL_CATALOG = [
|
|
55
|
+
{
|
|
56
|
+
key: "flash",
|
|
57
|
+
name: "DeepSeek V4 Flash",
|
|
58
|
+
provider: "DeepSeek",
|
|
59
|
+
colorVar: "dsw-static-blue-500",
|
|
60
|
+
price: {
|
|
61
|
+
currency: "CNY",
|
|
62
|
+
input: 3,
|
|
63
|
+
cacheHit: .1,
|
|
64
|
+
output: 9,
|
|
65
|
+
offPeak: {
|
|
66
|
+
input: 1.5,
|
|
67
|
+
cacheHit: .05,
|
|
68
|
+
output: 4.5
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
peakHours: "09:00-12:00 / 14:00-18:00"
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
key: "pro",
|
|
75
|
+
name: "DeepSeek V4 Pro",
|
|
76
|
+
provider: "DeepSeek",
|
|
77
|
+
colorVar: "dsw-static-deepseek-500",
|
|
78
|
+
price: {
|
|
79
|
+
currency: "CNY",
|
|
80
|
+
input: 9,
|
|
81
|
+
cacheHit: .3,
|
|
82
|
+
output: 27,
|
|
83
|
+
offPeak: {
|
|
84
|
+
input: 4.5,
|
|
85
|
+
cacheHit: .15,
|
|
86
|
+
output: 13.5
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
peakHours: "09:00-12:00 / 14:00-18:00"
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
key: "glm",
|
|
93
|
+
name: "GLM-5.2",
|
|
94
|
+
provider: "智谱 AI",
|
|
95
|
+
colorVar: "dsw-static-blue-600",
|
|
96
|
+
price: {
|
|
97
|
+
currency: "CNY",
|
|
98
|
+
input: 8,
|
|
99
|
+
cacheHit: 2,
|
|
100
|
+
output: 28
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
{
|
|
104
|
+
key: "glm-5.3",
|
|
105
|
+
name: "GLM-5.3",
|
|
106
|
+
provider: "智谱 AI",
|
|
107
|
+
colorVar: "dsw-static-blue-500",
|
|
108
|
+
price: {
|
|
109
|
+
currency: "CNY",
|
|
110
|
+
input: 8,
|
|
111
|
+
cacheHit: 2,
|
|
112
|
+
output: 28
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
key: "glm-4.6",
|
|
117
|
+
name: "GLM-4.6",
|
|
118
|
+
provider: "智谱 AI",
|
|
119
|
+
colorVar: "dsw-static-blue-400",
|
|
120
|
+
price: {
|
|
121
|
+
currency: "CNY",
|
|
122
|
+
input: 4,
|
|
123
|
+
cacheHit: .8,
|
|
124
|
+
output: 16
|
|
125
|
+
}
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
key: "qwen-3.8-max",
|
|
129
|
+
name: "Qwen3.8 Max",
|
|
130
|
+
provider: "阿里通义",
|
|
131
|
+
colorVar: "dsw-static-blue-600",
|
|
132
|
+
price: {
|
|
133
|
+
currency: "CNY",
|
|
134
|
+
input: 13.58,
|
|
135
|
+
cacheHit: 1.36,
|
|
136
|
+
output: 40.74
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
{
|
|
140
|
+
key: "qwen-max",
|
|
141
|
+
name: "Qwen3.7-Max",
|
|
142
|
+
provider: "阿里通义",
|
|
143
|
+
colorVar: "dsw-static-blue-300",
|
|
144
|
+
price: {
|
|
145
|
+
currency: "CNY",
|
|
146
|
+
input: 6,
|
|
147
|
+
cacheHit: .6,
|
|
148
|
+
output: 18
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
key: "qwen-plus",
|
|
153
|
+
name: "Qwen3.5-Plus",
|
|
154
|
+
provider: "阿里通义",
|
|
155
|
+
colorVar: "dsw-static-blue-500",
|
|
156
|
+
price: {
|
|
157
|
+
currency: "CNY",
|
|
158
|
+
input: .8,
|
|
159
|
+
cacheHit: .08,
|
|
160
|
+
output: 4.8
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
key: "qwen-flash",
|
|
165
|
+
name: "Qwen3.5-Flash",
|
|
166
|
+
provider: "阿里通义",
|
|
167
|
+
colorVar: "dsw-static-blue-400",
|
|
168
|
+
price: {
|
|
169
|
+
currency: "CNY",
|
|
170
|
+
input: .2,
|
|
171
|
+
cacheHit: .02,
|
|
172
|
+
output: 2
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
key: "doubao",
|
|
177
|
+
name: "Doubao Seed-2.0 Pro",
|
|
178
|
+
provider: "字节豆包",
|
|
179
|
+
colorVar: "dsw-static-red-500",
|
|
180
|
+
price: {
|
|
181
|
+
currency: "CNY",
|
|
182
|
+
input: 3.2,
|
|
183
|
+
cacheHit: .64,
|
|
184
|
+
output: 16
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
key: "doubao-mini",
|
|
189
|
+
name: "Doubao Seed-2.0 Mini",
|
|
190
|
+
provider: "字节豆包",
|
|
191
|
+
colorVar: "dsw-static-red-300",
|
|
192
|
+
price: {
|
|
193
|
+
currency: "CNY",
|
|
194
|
+
input: .2,
|
|
195
|
+
cacheHit: .02,
|
|
196
|
+
output: 2
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
key: "doubao-1.6",
|
|
201
|
+
name: "Doubao Seed-1.6",
|
|
202
|
+
provider: "字节豆包",
|
|
203
|
+
colorVar: "dsw-static-red-400",
|
|
204
|
+
price: {
|
|
205
|
+
currency: "CNY",
|
|
206
|
+
input: .8,
|
|
207
|
+
cacheHit: 0,
|
|
208
|
+
output: 8
|
|
209
|
+
}
|
|
210
|
+
},
|
|
211
|
+
{
|
|
212
|
+
key: "kimi",
|
|
213
|
+
name: "Kimi K2.7 Code",
|
|
214
|
+
provider: "月之暗面",
|
|
215
|
+
colorVar: "dsw-static-neutral-bluish-700",
|
|
216
|
+
price: {
|
|
217
|
+
currency: "CNY",
|
|
218
|
+
input: 6.5,
|
|
219
|
+
cacheHit: 1.3,
|
|
220
|
+
output: 27
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
key: "kimi-k2.7-hs",
|
|
225
|
+
name: "Kimi K2.7 Code HighSpeed",
|
|
226
|
+
provider: "月之暗面",
|
|
227
|
+
colorVar: "dsw-static-neutral-bluish-600",
|
|
228
|
+
price: {
|
|
229
|
+
currency: "CNY",
|
|
230
|
+
input: 13,
|
|
231
|
+
cacheHit: 2.6,
|
|
232
|
+
output: 54
|
|
233
|
+
}
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
key: "kimi-k2.6",
|
|
237
|
+
name: "Kimi K2.6",
|
|
238
|
+
provider: "月之暗面",
|
|
239
|
+
colorVar: "dsw-static-neutral-bluish-500",
|
|
240
|
+
price: {
|
|
241
|
+
currency: "CNY",
|
|
242
|
+
input: 6.5,
|
|
243
|
+
cacheHit: 1.1,
|
|
244
|
+
output: 27
|
|
245
|
+
}
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
key: "kimi-k3",
|
|
249
|
+
name: "Kimi K3",
|
|
250
|
+
provider: "月之暗面",
|
|
251
|
+
colorVar: "dsw-static-neutral-bluish-500",
|
|
252
|
+
price: {
|
|
253
|
+
currency: "CNY",
|
|
254
|
+
input: 20,
|
|
255
|
+
cacheHit: 2,
|
|
256
|
+
output: 100
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
{
|
|
260
|
+
key: "minimax",
|
|
261
|
+
name: "MiniMax-M3",
|
|
262
|
+
provider: "MiniMax",
|
|
263
|
+
colorVar: "dsw-static-amber-500",
|
|
264
|
+
price: {
|
|
265
|
+
currency: "CNY",
|
|
266
|
+
input: 2.1,
|
|
267
|
+
cacheHit: .42,
|
|
268
|
+
output: 8.4
|
|
269
|
+
}
|
|
270
|
+
},
|
|
271
|
+
{
|
|
272
|
+
key: "ernie",
|
|
273
|
+
name: "ERNIE-5.1",
|
|
274
|
+
provider: "百度文心",
|
|
275
|
+
colorVar: "dsw-static-blue-300",
|
|
276
|
+
price: {
|
|
277
|
+
currency: "CNY",
|
|
278
|
+
input: 4,
|
|
279
|
+
cacheHit: .4,
|
|
280
|
+
output: 18
|
|
281
|
+
}
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
key: "hunyuan",
|
|
285
|
+
name: "混元 Hy3",
|
|
286
|
+
provider: "腾讯混元",
|
|
287
|
+
colorVar: "dsw-static-amber-400",
|
|
288
|
+
price: {
|
|
289
|
+
currency: "CNY",
|
|
290
|
+
input: 1,
|
|
291
|
+
cacheHit: .25,
|
|
292
|
+
output: 4
|
|
293
|
+
}
|
|
294
|
+
},
|
|
295
|
+
{
|
|
296
|
+
key: "hunyuan-t1",
|
|
297
|
+
name: "混元 T1",
|
|
298
|
+
provider: "腾讯混元",
|
|
299
|
+
colorVar: "dsw-static-amber-300",
|
|
300
|
+
price: {
|
|
301
|
+
currency: "CNY",
|
|
302
|
+
input: 1,
|
|
303
|
+
cacheHit: .1,
|
|
304
|
+
output: 4
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
key: "yi",
|
|
309
|
+
name: "Yi-Lightning",
|
|
310
|
+
provider: "零一万物",
|
|
311
|
+
colorVar: "dsw-static-green-500",
|
|
312
|
+
price: {
|
|
313
|
+
currency: "CNY",
|
|
314
|
+
input: .99,
|
|
315
|
+
cacheHit: .1,
|
|
316
|
+
output: .99
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
key: "step",
|
|
321
|
+
name: "Step 3.7 Flash",
|
|
322
|
+
provider: "阶跃星辰",
|
|
323
|
+
colorVar: "dsw-static-neutral-bluish-400",
|
|
324
|
+
price: {
|
|
325
|
+
currency: "CNY",
|
|
326
|
+
input: 1.35,
|
|
327
|
+
cacheHit: .27,
|
|
328
|
+
output: 8.1
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
{
|
|
332
|
+
key: "spark",
|
|
333
|
+
name: "Spark 4.0 Ultra",
|
|
334
|
+
provider: "科大讯飞",
|
|
335
|
+
colorVar: "dsw-static-green-400",
|
|
336
|
+
price: {
|
|
337
|
+
currency: "CNY",
|
|
338
|
+
input: 5,
|
|
339
|
+
cacheHit: .5,
|
|
340
|
+
output: 10
|
|
341
|
+
}
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
key: "sensenova",
|
|
345
|
+
name: "SenseNova 6.5",
|
|
346
|
+
provider: "商汤",
|
|
347
|
+
colorVar: "dsw-static-red-400",
|
|
348
|
+
price: {
|
|
349
|
+
currency: "CNY",
|
|
350
|
+
input: 4.5,
|
|
351
|
+
cacheHit: .45,
|
|
352
|
+
output: 9
|
|
353
|
+
}
|
|
354
|
+
},
|
|
355
|
+
{
|
|
356
|
+
key: "baichuan",
|
|
357
|
+
name: "Baichuan M3-Plus",
|
|
358
|
+
provider: "百川智能",
|
|
359
|
+
colorVar: "dsw-static-neutral-bluish-500",
|
|
360
|
+
price: {
|
|
361
|
+
currency: "CNY",
|
|
362
|
+
input: 5,
|
|
363
|
+
cacheHit: .5,
|
|
364
|
+
output: 9
|
|
365
|
+
}
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
key: "gpt-5.6-sol",
|
|
369
|
+
name: "GPT-5.6 Sol",
|
|
370
|
+
provider: "OpenAI",
|
|
371
|
+
colorVar: "dsw-static-green-500",
|
|
372
|
+
price: {
|
|
373
|
+
currency: "USD",
|
|
374
|
+
input: 5,
|
|
375
|
+
cacheHit: .5,
|
|
376
|
+
output: 30
|
|
377
|
+
}
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
key: "gpt-5.6-terra",
|
|
381
|
+
name: "GPT-5.6 Terra",
|
|
382
|
+
provider: "OpenAI",
|
|
383
|
+
colorVar: "dsw-static-green-400",
|
|
384
|
+
price: {
|
|
385
|
+
currency: "USD",
|
|
386
|
+
input: 2,
|
|
387
|
+
cacheHit: .2,
|
|
388
|
+
output: 12
|
|
389
|
+
}
|
|
390
|
+
},
|
|
391
|
+
{
|
|
392
|
+
key: "gpt-5.6-luna",
|
|
393
|
+
name: "GPT-5.6 Luna",
|
|
394
|
+
provider: "OpenAI",
|
|
395
|
+
colorVar: "dsw-static-green-500",
|
|
396
|
+
price: {
|
|
397
|
+
currency: "USD",
|
|
398
|
+
input: .2,
|
|
399
|
+
cacheHit: .02,
|
|
400
|
+
output: 1.2
|
|
401
|
+
}
|
|
402
|
+
},
|
|
403
|
+
{
|
|
404
|
+
key: "gemini-pro",
|
|
405
|
+
name: "Gemini 3.1 Pro",
|
|
406
|
+
provider: "Google",
|
|
407
|
+
colorVar: "dsw-static-blue-600",
|
|
408
|
+
price: {
|
|
409
|
+
currency: "USD",
|
|
410
|
+
input: 2,
|
|
411
|
+
cacheHit: .2,
|
|
412
|
+
output: 12,
|
|
413
|
+
offPeak: {
|
|
414
|
+
input: 1,
|
|
415
|
+
cacheHit: .1,
|
|
416
|
+
output: 6
|
|
417
|
+
}
|
|
418
|
+
},
|
|
419
|
+
peakHours: "Standard / Flex"
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
key: "gemini-flash",
|
|
423
|
+
name: "Gemini 3.6 Flash",
|
|
424
|
+
provider: "Google",
|
|
425
|
+
colorVar: "dsw-static-blue-400",
|
|
426
|
+
price: {
|
|
427
|
+
currency: "USD",
|
|
428
|
+
input: 1.5,
|
|
429
|
+
cacheHit: .15,
|
|
430
|
+
output: 7.5,
|
|
431
|
+
offPeak: {
|
|
432
|
+
input: .75,
|
|
433
|
+
cacheHit: .075,
|
|
434
|
+
output: 3.75
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
peakHours: "Standard / Flex"
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
key: "grok",
|
|
441
|
+
name: "Grok 4.6",
|
|
442
|
+
provider: "xAI",
|
|
443
|
+
colorVar: "dsw-static-neutral-bluish-700",
|
|
444
|
+
price: {
|
|
445
|
+
currency: "USD",
|
|
446
|
+
input: 2,
|
|
447
|
+
cacheHit: .5,
|
|
448
|
+
output: 6
|
|
449
|
+
}
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
key: "grok-4.3",
|
|
453
|
+
name: "Grok 4.3",
|
|
454
|
+
provider: "xAI",
|
|
455
|
+
colorVar: "dsw-static-neutral-bluish-500",
|
|
456
|
+
price: {
|
|
457
|
+
currency: "USD",
|
|
458
|
+
input: 1.25,
|
|
459
|
+
cacheHit: .2,
|
|
460
|
+
output: 2.5
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
key: "llama",
|
|
465
|
+
name: "Llama 4 Maverick",
|
|
466
|
+
provider: "Meta",
|
|
467
|
+
colorVar: "dsw-static-red-500",
|
|
468
|
+
price: {
|
|
469
|
+
currency: "USD",
|
|
470
|
+
input: .2,
|
|
471
|
+
cacheHit: .05,
|
|
472
|
+
output: .6
|
|
473
|
+
}
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
key: "llama-scout",
|
|
477
|
+
name: "Llama 4 Scout",
|
|
478
|
+
provider: "Meta",
|
|
479
|
+
colorVar: "dsw-static-red-400",
|
|
480
|
+
price: {
|
|
481
|
+
currency: "USD",
|
|
482
|
+
input: .1,
|
|
483
|
+
cacheHit: .025,
|
|
484
|
+
output: .3
|
|
485
|
+
}
|
|
486
|
+
},
|
|
487
|
+
{
|
|
488
|
+
key: "other",
|
|
489
|
+
name: "其他模型",
|
|
490
|
+
provider: "Custom",
|
|
491
|
+
colorVar: "dsw-static-neutral-bluish-500",
|
|
492
|
+
price: {
|
|
493
|
+
currency: "CNY",
|
|
494
|
+
input: .5,
|
|
495
|
+
cacheHit: .25,
|
|
496
|
+
cacheMiss: .5,
|
|
497
|
+
output: 1.5
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
];
|
|
501
|
+
/** Lookup a model by its stats key; falls back to the generic `other` entry. */
|
|
502
|
+
function modelOf(key) {
|
|
503
|
+
return MODEL_CATALOG.find((entry) => entry.key === key) ?? MODEL_CATALOG[MODEL_CATALOG.length - 1];
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Price one band's token usage in CNY. The stats `input` field is the TOTAL
|
|
507
|
+
* prompt tokens (cacheHit + cacheMiss), so billing splits it: the cache-hit
|
|
508
|
+
* share prices at the hit rate and the remaining share at the miss rate.
|
|
509
|
+
* Providers that report only disjoint buckets carry `cacheMiss` explicitly;
|
|
510
|
+
* otherwise the miss share is derived as `input - cacheHit`. Only USD-priced
|
|
511
|
+
* bands go through the exchange rate.
|
|
512
|
+
*/
|
|
513
|
+
function priceBandCost(band, buckets, currency) {
|
|
514
|
+
const miss = buckets.cacheMiss > 0 ? buckets.cacheMiss : Math.max(0, buckets.input - buckets.cacheHit);
|
|
515
|
+
const hit = Math.min(buckets.cacheHit, buckets.input);
|
|
516
|
+
const raw = (miss * (band.cacheMiss ?? band.input) + hit * band.cacheHit + buckets.output * band.output) / 1e6;
|
|
517
|
+
return currency === "USD" ? raw * USD_TO_CNY : raw;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Estimate the CNY cost of one model's token usage, mixing the peak and
|
|
521
|
+
* off-peak bands by the given peak share (flat-priced models cost the same in
|
|
522
|
+
* both bands).
|
|
523
|
+
* @param entry - the catalog entry whose prices apply.
|
|
524
|
+
* @param buckets - token usage counts.
|
|
525
|
+
* @param peakShare - share of traffic in the peak band (0..1); defaults to {@link DEFAULT_PEAK_SHARE}.
|
|
526
|
+
* @returns the estimated cost in CNY.
|
|
527
|
+
*/
|
|
528
|
+
function computeCost(entry, buckets, peakShare = DEFAULT_PEAK_SHARE) {
|
|
529
|
+
if (isSubscriptionPlan(entry.key)) return 0;
|
|
530
|
+
const peak = priceBandCost(entry.price, buckets, entry.price.currency);
|
|
531
|
+
const off = entry.price.offPeak === void 0 ? peak : priceBandCost(entry.price.offPeak, buckets, entry.price.currency);
|
|
532
|
+
return peak * peakShare + off * (1 - peakShare);
|
|
533
|
+
}
|
|
534
|
+
//#endregion
|
|
535
|
+
//#region lib/types/aggregate.js
|
|
536
|
+
/**
|
|
537
|
+
* Real-usage aggregation: folds every persisted session log into the
|
|
538
|
+
* usage-stats document the dashboard renders.
|
|
539
|
+
*
|
|
540
|
+
* Each LLM call is attributed to the model of the `request/header` event that
|
|
541
|
+
* precedes its `assistant/message` usage event. Costs are estimated with the
|
|
542
|
+
* shared billing catalog (`pricing.ts`, in CNY), so only models the catalog
|
|
543
|
+
* prices incur a cost — subscription-plan routes and unknown models price
|
|
544
|
+
* zero while their tokens still count. Pure functions only: the persistence
|
|
545
|
+
* handle is injected, so the fold is unit-testable without a host.
|
|
546
|
+
*/
|
|
547
|
+
/**
|
|
548
|
+
* Real provider model ids map to their billing-catalog keys. Unknown ids stay
|
|
549
|
+
* as-is and price zero (they are not in the catalog; subscription-plan routes
|
|
550
|
+
* like kimi-coding / token plans fall here and therefore cost nothing).
|
|
551
|
+
*/
|
|
552
|
+
const MODEL_KEY_ALIASES = {
|
|
553
|
+
"deepseek-v4-flash": "flash",
|
|
554
|
+
"deepseek-v4-pro": "pro",
|
|
555
|
+
"glm-5.2": "glm",
|
|
556
|
+
"qwen3.8-max": "qwen-3.8-max",
|
|
557
|
+
"qwen3.7-max": "qwen-max",
|
|
558
|
+
"qwen-max": "qwen-max",
|
|
559
|
+
"hunyuan-t1": "hunyuan-t1",
|
|
560
|
+
"step-3.7-flash": "step",
|
|
561
|
+
"seed-2.0-mini": "doubao-mini"
|
|
562
|
+
};
|
|
563
|
+
/**
|
|
564
|
+
* 走订阅套餐(coding / token / agent plan)的 provider id:这些通道的调用
|
|
565
|
+
* 按套餐计费,不再按 token 计费,因此即使模型 id 与计费表撞名也一律豁免。
|
|
566
|
+
* 部署可在 plugin config 的 `subscriptionProviders` 中覆盖。
|
|
567
|
+
*/
|
|
568
|
+
const DEFAULT_SUBSCRIPTION_PROVIDERS = ["kimi-coding", "xiaomi-token-plan-cn"];
|
|
569
|
+
/** Zeroed usage accumulator. */
|
|
570
|
+
function emptyUsage() {
|
|
571
|
+
return {
|
|
572
|
+
calls: 0,
|
|
573
|
+
input: 0,
|
|
574
|
+
output: 0,
|
|
575
|
+
cacheHit: 0,
|
|
576
|
+
cacheMiss: 0,
|
|
577
|
+
cost: 0
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
/**
|
|
581
|
+
* Fold one token usage event into an accumulator and re-price its cost.
|
|
582
|
+
* The stats `input` is the TOTAL prompt tokens (cacheHit + cacheMiss), so the
|
|
583
|
+
* miss bucket is uncached input plus cache writes.
|
|
584
|
+
* @param acc - the accumulator to mutate.
|
|
585
|
+
* @param usage - the provider-reported usage of one call.
|
|
586
|
+
* @param key - the billing-catalog key this call belongs to.
|
|
587
|
+
* @param subscription - whether the call went through a subscription plan; such calls never cost money.
|
|
588
|
+
*/
|
|
589
|
+
function foldUsage(acc, usage, key, subscription) {
|
|
590
|
+
const cacheHit = usage.cacheReadTokens ?? 0;
|
|
591
|
+
const cacheMiss = usage.inputTokens + (usage.cacheWriteTokens ?? 0);
|
|
592
|
+
acc.calls += 1;
|
|
593
|
+
acc.input += usage.inputTokens + cacheHit + (usage.cacheWriteTokens ?? 0);
|
|
594
|
+
acc.output += usage.outputTokens;
|
|
595
|
+
acc.cacheHit += cacheHit;
|
|
596
|
+
acc.cacheMiss += cacheMiss;
|
|
597
|
+
acc.cost = !subscription && MODEL_CATALOG.some((entry) => entry.key === key) ? computeCost(modelOf(key), {
|
|
598
|
+
input: acc.input,
|
|
599
|
+
cacheHit: acc.cacheHit,
|
|
600
|
+
cacheMiss: acc.cacheMiss,
|
|
601
|
+
output: acc.output
|
|
602
|
+
}) : 0;
|
|
603
|
+
}
|
|
604
|
+
/** Local-time date stamp (the host runs in the user's timezone). */
|
|
605
|
+
function dayStamp(time) {
|
|
606
|
+
const date = new Date(time);
|
|
607
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
608
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
|
609
|
+
}
|
|
610
|
+
/**
|
|
611
|
+
* Aggregate real usage from every persisted session log.
|
|
612
|
+
* @param persistence - the session persistence service.
|
|
613
|
+
* @param options - aggregation tuning (e.g. subscription-plan providers).
|
|
614
|
+
* @returns the usage-stats document (same shape the dashboard expects).
|
|
615
|
+
*/
|
|
616
|
+
async function aggregateUsage(persistence, options = {}) {
|
|
617
|
+
const subscriptionProviders = new Set(options.subscriptionProviders ?? DEFAULT_SUBSCRIPTION_PROVIDERS);
|
|
618
|
+
const total = emptyUsage();
|
|
619
|
+
const byModel = /* @__PURE__ */ new Map();
|
|
620
|
+
const byDay = /* @__PURE__ */ new Map();
|
|
621
|
+
for (const meta of await persistence.list()) {
|
|
622
|
+
const { events } = await persistence.readFrom(meta.id, 0);
|
|
623
|
+
let key = "other";
|
|
624
|
+
let subscription = false;
|
|
625
|
+
for (const event of events) {
|
|
626
|
+
if (event.type === "request/header") {
|
|
627
|
+
const { model, provider } = event.data.header.config;
|
|
628
|
+
key = MODEL_KEY_ALIASES[model] ?? model;
|
|
629
|
+
subscription = subscriptionProviders.has(provider);
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
if (event.type !== "assistant/message" || event.data.usage === void 0) continue;
|
|
633
|
+
const modelKey = key;
|
|
634
|
+
const day = dayStamp(event.time);
|
|
635
|
+
foldUsage(total, event.data.usage, modelKey, subscription);
|
|
636
|
+
foldUsage(byModel.get(modelKey) ?? byModel.set(modelKey, emptyUsage()).get(modelKey), event.data.usage, modelKey, subscription);
|
|
637
|
+
foldUsage(byDay.get(day) ?? byDay.set(day, emptyUsage()).get(day), event.data.usage, modelKey, subscription);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
const toRecord = (map) => Object.fromEntries(map);
|
|
641
|
+
return {
|
|
642
|
+
version: 1,
|
|
643
|
+
updatedAt: Date.now(),
|
|
644
|
+
source: "session-logs",
|
|
645
|
+
total,
|
|
646
|
+
byModel: toRecord(byModel),
|
|
647
|
+
byDay: toRecord(byDay)
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
//#endregion
|
|
651
|
+
//#region lib/types/index.js
|
|
652
|
+
/**
|
|
653
|
+
* Usage billing surface plugin, node half.
|
|
654
|
+
*
|
|
655
|
+
* Serves `/api/billing/usage-stats`: real usage aggregated from every
|
|
656
|
+
* persisted session log (see `aggregate.ts`) — the browser dashboard reads it
|
|
657
|
+
* instead of showing an empty snapshot. When `sessionPersistence` is
|
|
658
|
+
* unavailable (or aggregation fails), the configured `statsPath` /
|
|
659
|
+
* `DSH_USAGE_STATS` / conventional JSON file is served as a fallback, and a
|
|
660
|
+
* missing file answers `{ error }` so the dashboard shows zeros, never
|
|
661
|
+
* fabricated samples.
|
|
662
|
+
*/
|
|
663
|
+
/** Required services: the web server and the persisted session log store. */
|
|
664
|
+
const inject = ["webServer", "sessionPersistence"];
|
|
665
|
+
/**
|
|
666
|
+
* Host plugin body: serve real aggregated usage to the browser dashboard.
|
|
667
|
+
* @param ctx - host context carrying webServer and sessionPersistence.
|
|
668
|
+
* @param config - optional statsPath override.
|
|
669
|
+
*/
|
|
670
|
+
function apply(ctx, config = {}) {
|
|
671
|
+
const cwd = process.cwd();
|
|
672
|
+
const candidates = [
|
|
673
|
+
config.statsPath,
|
|
674
|
+
process.env.DSH_USAGE_STATS,
|
|
675
|
+
join(cwd, ".dsh-usage-stats.json"),
|
|
676
|
+
join(homedir(), ".dsh/.dsh-usage-stats.json")
|
|
677
|
+
].filter((path) => typeof path === "string" && path.length > 0);
|
|
678
|
+
ctx.effect(() => ctx.webServer.register({
|
|
679
|
+
kind: "exact",
|
|
680
|
+
path: "/api/billing/usage-stats",
|
|
681
|
+
handler: async (_req, res) => {
|
|
682
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8" });
|
|
683
|
+
try {
|
|
684
|
+
res.end(JSON.stringify(await aggregateUsage(ctx.sessionPersistence, { ...config.subscriptionProviders === void 0 ? {} : { subscriptionProviders: config.subscriptionProviders } })));
|
|
685
|
+
return;
|
|
686
|
+
} catch {}
|
|
687
|
+
for (const candidate of candidates) try {
|
|
688
|
+
const text = await readFile(candidate, "utf8");
|
|
689
|
+
JSON.parse(text);
|
|
690
|
+
res.end(text);
|
|
691
|
+
return;
|
|
692
|
+
} catch {}
|
|
693
|
+
res.end(JSON.stringify({ error: "usage stats unavailable" }));
|
|
694
|
+
}
|
|
695
|
+
}), "usage-billing: usage-stats route");
|
|
696
|
+
}
|
|
697
|
+
//#endregion
|
|
698
|
+
export { apply, inject };
|
package/lib/invariant.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
//#region lib/types/invariant.js
|
|
2
|
+
/** Package invariant companion for `@kenz1117/dsh-ui-usage-billing`. */
|
|
3
|
+
const PACKAGE_NAME = "@kenz1117/dsh-ui-usage-billing";
|
|
4
|
+
const name = "usage-billing-invariant";
|
|
5
|
+
const inject = ["invariants"];
|
|
6
|
+
/** No runtime invariant: pure UI surface plugin with no lifecycle dependencies. */
|
|
7
|
+
const install = () => {};
|
|
8
|
+
/**
|
|
9
|
+
* Register this package's invariant companion.
|
|
10
|
+
* @param ctx - Host context carrying the invariant registry.
|
|
11
|
+
* @returns the registration disposer after setup succeeds.
|
|
12
|
+
*/
|
|
13
|
+
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
14
|
+
//#endregion
|
|
15
|
+
export { apply, inject, name };
|