dsh-model-billing 0.1.1 → 0.2.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/host.js CHANGED
@@ -1,898 +1,1289 @@
1
- import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
2
- import { dirname, join } from 'node:path'
3
- import { homedir } from 'node:os'
4
-
5
- /**
6
- * Model order, token prices and session cost estimation.
7
- *
8
- * The browser half is a build-free plain-JavaScript Client module, so it cannot
9
- * declare a generated `ctx.remote` namespace. It talks to this half over
10
- * same-origin routes on `ctx.webServer`, the same transport other community
11
- * plugins use. Prices and the user's order live in a small JSON file under the
12
- * Harness home; usage is always folded from the durable session log.
13
- */
14
-
15
- export const inject = ['webServer']
16
-
17
- const CATALOG_PATH = '/model-billing/catalog'
18
- const USAGE_PATH = '/model-billing/usage'
19
- const SAVE_PATH = '/model-billing/save'
20
- const SELECT_PATH = '/model-billing/select'
21
-
22
- const STATE_VERSION = 1
23
- const MAX_MODELS = 500
24
- const MAX_STORED_PRICES = 2000
25
- const MAX_BODY_BYTES = 256 * 1024
26
- const PRICE_FIELDS = ['cacheHit', 'cacheMiss', 'output']
27
- const KEY_SEPARATOR = '\u0000'
28
- const DEEPSEEK_PRICING_SCHEDULE = '北京时间周一至周五 9:00–12:00、14:00–18:00(法定节假日除外)'
29
- const DEEPSEEK_PRICES = {
30
- 'deepseek-flash': {
31
- peak: { cacheHit: 0.04, cacheMiss: 2, output: 8 },
32
- offPeak: { cacheHit: 0.02, cacheMiss: 1, output: 4 },
33
- },
34
- 'deepseek-v4-flash': {
35
- peak: { cacheHit: 0.04, cacheMiss: 2, output: 8 },
36
- offPeak: { cacheHit: 0.02, cacheMiss: 1, output: 4 },
37
- },
38
- 'deepseek-v4-flash-vision-exp': {
39
- peak: { cacheHit: 0.04, cacheMiss: 2, output: 8 },
40
- offPeak: { cacheHit: 0.02, cacheMiss: 1, output: 4 },
41
- },
42
- 'deepseek-v4-pro': {
43
- peak: { cacheHit: 0.3, cacheMiss: 9, output: 27 },
44
- offPeak: { cacheHit: 0.15, cacheMiss: 4.5, output: 13.5 },
45
- },
46
- }
47
-
48
- // Chinese statutory holidays (2026). Weekends are always off-peak; these dates
49
- // are the extra weekday closures that must not be billed at peak rates.
50
- const DEEPSEEK_HOLIDAYS = new Set([
51
- '2026-01-01', '2026-01-02', '2026-01-03',
52
- '2026-02-15', '2026-02-16', '2026-02-17', '2026-02-18', '2026-02-19',
53
- '2026-02-20', '2026-02-21', '2026-02-22', '2026-02-23',
54
- '2026-04-04', '2026-04-05', '2026-04-06',
55
- '2026-05-01', '2026-05-02', '2026-05-03', '2026-05-04', '2026-05-05',
56
- '2026-06-19', '2026-06-20', '2026-06-21',
57
- '2026-09-25', '2026-09-26', '2026-09-27',
58
- '2026-10-01', '2026-10-02', '2026-10-03', '2026-10-04',
59
- '2026-10-05', '2026-10-06', '2026-10-07',
60
- ])
61
- const JSON_HEADERS = {
62
- 'content-type': 'application/json; charset=utf-8',
63
- 'cache-control': 'no-store',
64
- }
65
-
66
- /** A refusal that carries the HTTP status and stable code the Client reports. */
67
- class BillingRefusal extends Error {
68
- constructor(status, code, message) {
69
- super(message)
70
- this.name = 'BillingRefusal'
71
- this.status = status
72
- this.code = code
73
- }
74
- }
75
-
76
- /** The durable state path; `DSH_HOME` wins over the conventional home. */
77
- function statePath() {
78
- const home = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.trim() !== ''
79
- ? process.env.DSH_HOME.trim()
80
- : join(homedir(), '.dsh')
81
- return join(home, 'model-billing.json')
82
- }
83
-
84
- /** Look up the model catalog and merge the saved order/prices into it. */
85
- async function buildCatalog(ctx) {
86
- const state = await loadState(ctx)
87
- const llm = ctx.get('llm')
88
- if (llm === undefined) {
89
- return {
90
- ok: true,
91
- models: [],
92
- failures: [{ provider: 'llm', message: 'the llm service is not mounted' }],
93
- ...stateEnvelope(state),
94
- }
95
- }
96
-
97
- const listed = typeof llm.listProviders === 'function' ? llm.listProviders() : []
98
- const providers = Array.isArray(listed) ? listed : []
99
- const settled = await Promise.allSettled(providers.map(async (provider) => {
100
- const models = await llm.listModels(provider.id)
101
- if (!Array.isArray(models)) throw new Error('the provider returned no model list')
102
- const entries = models.filter((model) => typeof model?.id === 'string' && model.id !== '')
103
- return Promise.all(entries.map(async (model) => {
104
- let reasoning
105
- if (typeof llm.resolveModelInfo === 'function') {
106
- try {
107
- const resolved = await llm.resolveModelInfo(provider.id, model.id)
108
- reasoning = publicReasoning(resolved?.reasoning)
109
- } catch {
110
- // Reasoning metadata is optional; an unavailable adapter must not hide the model.
111
- }
112
- }
113
- return {
114
- provider: provider.id,
115
- providerName: provider.name || provider.id,
116
- model: model.id,
117
- name: model.name || model.id,
118
- isDeepSeek: isDeepSeekRoute(provider.id, model.id),
119
- ...reasoning === undefined ? {} : { reasoning },
120
- }
121
- }))
122
- }))
123
-
124
- const rows = []
125
- const failures = []
126
- const seen = new Set()
127
- settled.forEach((result, index) => {
128
- const provider = providers[index]
129
- if (result.status === 'rejected') {
130
- failures.push({
131
- provider: provider?.id ?? 'unknown',
132
- providerName: provider?.name ?? provider?.id ?? 'unknown',
133
- message: messageOf(result.reason),
134
- })
135
- return
136
- }
137
- for (const row of result.value) {
138
- const key = modelKey(row.provider, row.model)
139
- if (seen.has(key)) continue
140
- seen.add(key)
141
- rows.push(row)
142
- }
143
- })
144
-
145
- const ranked = applySavedOrder(rows, state)
146
- return {
147
- ok: true,
148
- models: ranked.map((row, index) => ({
149
- ...row,
150
- order: index,
151
- pricing: catalogPricing(row.provider, row.model, state.pricing[modelKey(row.provider, row.model)]),
152
- })),
153
- failures,
154
- ...stateEnvelope(state),
155
- }
156
- }
157
-
158
- /** Put saved routes first, keeping new routes in provider/catalog order behind them. */
159
- function applySavedOrder(rows, state) {
160
- const rank = new Map()
161
- for (const entry of state.order) {
162
- const key = modelKey(entry.provider, entry.model)
163
- if (!rank.has(key)) rank.set(key, rank.size)
164
- }
165
- return rows
166
- .map((row, index) => ({ row, index, rank: rank.get(modelKey(row.provider, row.model)) ?? Number.MAX_SAFE_INTEGER }))
167
- .sort((left, right) => left.rank - right.rank || left.index - right.index)
168
- .map((entry) => entry.row)
169
- }
170
-
171
- /** The pricing/order metadata every catalog response carries. */
172
- function stateEnvelope(state) {
173
- return {
174
- stateVersion: STATE_VERSION,
175
- savedAt: state.savedAt,
176
- order: state.order.map((entry) => ({ provider: entry.provider, model: entry.model })),
177
- }
178
- }
179
-
180
- /** Whether a route is one of the official DeepSeek routes. */
181
- function isDeepSeekRoute(provider, model) {
182
- return String(provider).toLowerCase().includes('deepseek')
183
- || String(model).toLowerCase().startsWith('deepseek')
184
- }
185
-
186
- /** Whether one instant falls in DeepSeek's Beijing peak window. */
187
- function deepseekPeakAt(time) {
188
- const stamp = Number(time)
189
- if (!Number.isFinite(stamp)) return false
190
- const beijing = new Date(stamp + 8 * 60 * 60 * 1000)
191
- const day = beijing.getUTCDay()
192
- if (day === 0 || day === 6) return false
193
- if (DEEPSEEK_HOLIDAYS.has(beijing.toISOString().slice(0, 10))) return false
194
- const hour = beijing.getUTCHours()
195
- return (hour >= 9 && hour < 12) || (hour >= 14 && hour < 18)
196
- }
197
-
198
- /** The built-in price set for a DeepSeek model at one instant. */
199
- function deepseekPricingAt(model, time) {
200
- const id = String(model ?? '').toLowerCase()
201
- const rates = id.includes('pro')
202
- ? DEEPSEEK_PRICES['deepseek-v4-pro']
203
- : DEEPSEEK_PRICES['deepseek-flash']
204
- return deepseekPeakAt(time) ? rates.peak : rates.offPeak
205
- }
206
-
207
- /** Keep only the reasoning metadata the Client needs for its effort menu. */
208
- function publicReasoning(value) {
209
- if (value === null || typeof value !== 'object' || !Array.isArray(value.efforts)) return undefined
210
- const efforts = value.efforts
211
- .filter((effort) => typeof effort?.id === 'string' && effort.id !== '')
212
- .map((effort) => ({
213
- id: effort.id,
214
- name: typeof effort.name === 'string' && effort.name !== '' ? effort.name : effort.id,
215
- ...(typeof effort.description === 'string' && effort.description !== '' ? { description: effort.description } : {}),
216
- }))
217
- if (efforts.length === 0) return undefined
218
- return {
219
- efforts,
220
- ...(typeof value.defaultEffort === 'string' && value.defaultEffort !== '' ? { defaultEffort: value.defaultEffort } : {}),
221
- }
222
- }
223
-
224
- /** A stable map key that cannot collide across provider/model strings. */
225
- function modelKey(provider, model) {
226
- return `${provider}${KEY_SEPARATOR}${model}`
227
- }
228
-
229
- /** Default prices: unset values stay `null`, zero is a real free price. */
230
- function defaultPricing() {
231
- return { cacheHit: null, cacheMiss: null, output: null }
232
- }
233
-
234
- /** The public form of one model's three prices. */
235
- function publicPricing(value) {
236
- const source = value?.base ?? value
237
- const fallback = defaultPricing()
238
- if (source === null || typeof source !== 'object') return fallback
239
- const result = {}
240
- for (const field of PRICE_FIELDS) result[field] = priceOrNull(source[field])
241
- return result
242
- }
243
-
244
- /** Official DeepSeek peak/off-peak prices, built in rather than user-edited. */
245
- function deepseekBuiltinPricing(provider, model) {
246
- if (!String(provider).toLowerCase().includes('deepseek')) return null
247
- const prices = DEEPSEEK_PRICES[model]
248
- if (prices === undefined) return null
249
- return {
250
- builtin: true,
251
- peakEnabled: true,
252
- peak: { ...prices.peak },
253
- offPeak: { ...prices.offPeak },
254
- schedule: DEEPSEEK_PRICING_SCHEDULE,
255
- }
256
- }
257
-
258
- /** Pricing as presented to the Client: built-in official table or the saved flat prices. */
259
- function catalogPricing(provider, model, value) {
260
- return deepseekBuiltinPricing(provider, model) ?? publicPricing(value)
261
- }
262
-
263
- /** A finite non-negative number, or null for "not set". */
264
- function priceOrNull(value) {
265
- if (value === null || value === undefined || value === '') return null
266
- const number = typeof value === 'number' ? value : Number(value)
267
- if (!Number.isFinite(number) || number < 0 || number > 1e12) return null
268
- return Math.round(number * 1e8) / 1e8
269
- }
270
-
271
- /** Whether all three prices of a set are configured. */
272
- function priceSetComplete(value) {
273
- return PRICE_FIELDS.every((field) => typeof value?.[field] === 'number' && Number.isFinite(value[field]) && value[field] >= 0)
274
- }
275
-
276
- function isCount(value) {
277
- return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
278
- }
279
-
280
- function safeSum(values) {
281
- let total = 0
282
- for (const value of values) {
283
- total += value
284
- if (!Number.isSafeInteger(total)) return undefined
285
- }
286
- return total
287
- }
288
-
289
- /** The last usage chunk of a durable assistant stream. */
290
- function streamUsage(stream) {
291
- if (!Array.isArray(stream)) return undefined
292
- for (let index = stream.length - 1; index >= 0; index -= 1) {
293
- const record = stream[index]
294
- if (record?.type === 'chunk' && record.chunk?.type === 'usage') return record.chunk.usage
295
- }
296
- return undefined
297
- }
298
-
299
- /**
300
- * Normalize a provider usage sample. `inputTokens` is the uncached input bucket;
301
- * cache reads and writes sit beside it, matching token-meter's accounting.
302
- */
303
- function normalizeUsage(usage, stream) {
304
- const sample = usage ?? streamUsage(stream)
305
- if (sample === null || typeof sample !== 'object') return undefined
306
- const { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, reasoningTokens, totalTokens } = sample
307
- if (!isCount(inputTokens) || !isCount(outputTokens)) return undefined
308
- if (cacheReadTokens !== undefined && !isCount(cacheReadTokens)) return undefined
309
- if (cacheWriteTokens !== undefined && !isCount(cacheWriteTokens)) return undefined
310
- if (reasoningTokens !== undefined && (!isCount(reasoningTokens) || reasoningTokens > outputTokens)) return undefined
311
- const knownPrompt = safeSum([
312
- inputTokens,
313
- ...cacheReadTokens === undefined ? [] : [cacheReadTokens],
314
- ...cacheWriteTokens === undefined ? [] : [cacheWriteTokens],
315
- ])
316
- if (knownPrompt === undefined) return undefined
317
- if (totalTokens !== undefined) {
318
- if (!isCount(totalTokens)) return undefined
319
- const exactPrompt = totalTokens - outputTokens
320
- if (!isCount(exactPrompt) || exactPrompt < knownPrompt) return undefined
321
- if (cacheReadTokens !== undefined && cacheWriteTokens !== undefined && exactPrompt !== knownPrompt) return undefined
322
- }
323
- return {
324
- uncachedInputTokens: inputTokens,
325
- outputTokens,
326
- ...cacheReadTokens === undefined ? {} : { cacheReadTokens },
327
- ...cacheWriteTokens === undefined ? {} : { cacheWriteTokens },
328
- ...reasoningTokens === undefined ? {} : { reasoningTokens },
329
- }
330
- }
331
-
332
- function messageRoute(message) {
333
- const provider = message?.source?.provider
334
- const model = message?.source?.model
335
- return typeof provider === 'string' && typeof model === 'string' && provider !== '' && model !== ''
336
- ? { provider, model }
337
- : undefined
338
- }
339
-
340
- /** A short, whitespace-collapsed preview of the first user text in one turn. */
341
- function userTextPreview(message) {
342
- const content = Array.isArray(message?.content) ? message.content : []
343
- const text = content
344
- .filter((block) => block?.type === 'text' && typeof block.text === 'string')
345
- .map((block) => block.text)
346
- .join(' ')
347
- .replace(/\s+/g, ' ')
348
- .trim()
349
- if (text === '') return '(无文本)'
350
- const characters = Array.from(text)
351
- return characters.length > 36 ? `${characters.slice(0, 36).join('')}…` : text
352
- }
353
-
354
- /**
355
- * Fold one Session's durable attempts into per-turn and whole-session cost.
356
- *
357
- * Only `assistant/message` and `assistant/attempt` samples are billed. A usage
358
- * sample without a lifecycle route is retained with an unknown route so the UI
359
- * can report it as unpriced instead of silently dropping tokens.
360
- */
361
- function computeSessionUsage(events, pricingByKey = new Map(), nameByKey = new Map()) {
362
- const turns = new Map()
363
- let headerRoute
364
- let latestTime = 0
365
- let currentTurn = null
366
- let pendingPreview = null
367
-
368
- const ensureTurn = (turn, time) => {
369
- let entry = turns.get(turn)
370
- if (entry === undefined) {
371
- entry = {
372
- turn,
373
- startTime: Number(time) || 0,
374
- endTime: Number(time) || 0,
375
- preview: '',
376
- seenUser: false,
377
- attempts: [],
378
- models: new Map(),
379
- }
380
- turns.set(turn, entry)
381
- }
382
- return entry
383
- }
384
-
385
- const addAttempt = (event, route, usage) => {
386
- const turn = event?.data?.turn
387
- if (!Number.isSafeInteger(turn) || usage === undefined) return
388
- const time = Number(event.time) || latestTime
389
- latestTime = Math.max(latestTime, time)
390
- const entry = ensureTurn(turn, time)
391
- entry.endTime = Math.max(entry.endTime, time)
392
- const provider = route?.provider
393
- const model = route?.model
394
- const key = provider !== undefined && model !== undefined ? modelKey(provider, model) : undefined
395
- const deepseek = provider !== undefined && model !== undefined && isDeepSeekRoute(provider, model)
396
- const pricing = deepseek
397
- ? deepseekPricingAt(model, time)
398
- : key !== undefined ? pricingByKey.get(key) : undefined
399
- const effective = pricing ?? defaultPricing()
400
- const complete = priceSetComplete(effective)
401
- const cost = !complete ? 0 : estimateAttemptCost(usage, effective)
402
- const attempt = {
403
- seq: event.seq,
404
- time,
405
- provider: provider ?? null,
406
- model: model ?? null,
407
- name: key === undefined ? null : nameByKey.get(key) ?? null,
408
- deepseek,
409
- priced: complete,
410
- cost,
411
- usage,
412
- }
413
- entry.attempts.push(attempt)
414
- if (provider !== undefined && model !== undefined) {
415
- const modelName = nameByKey.get(key) ?? `${provider} / ${model}`
416
- const existing = entry.models.get(key)
417
- if (existing === undefined) {
418
- entry.models.set(key, { provider, model, name: modelName, cost: 0, priced: complete, isDeepSeek: deepseek })
419
- }
420
- const modelEntry = entry.models.get(key)
421
- modelEntry.cost += cost
422
- modelEntry.priced = modelEntry.priced && complete
423
- }
424
- }
425
-
426
- for (const event of events) {
427
- if (event?.type === 'turn/start') {
428
- const turn = event.data?.turn
429
- if (Number.isSafeInteger(turn)) {
430
- currentTurn = turn
431
- const entry = ensureTurn(turn, event.time)
432
- if (!entry.seenUser && pendingPreview !== null) {
433
- entry.preview = pendingPreview
434
- entry.seenUser = true
435
- pendingPreview = null
436
- }
437
- }
438
- continue
439
- }
440
- if (event?.type === 'user/message') {
441
- const preview = userTextPreview(event.data)
442
- if (currentTurn === null) {
443
- pendingPreview = preview
444
- } else {
445
- const entry = ensureTurn(currentTurn, event.time)
446
- if (!entry.seenUser) {
447
- entry.preview = preview
448
- entry.seenUser = true
449
- }
450
- }
451
- continue
452
- }
453
- if (event?.type === 'turn/end') {
454
- currentTurn = null
455
- continue
456
- }
457
- if (event?.type === 'request/header') {
458
- const config = event.data?.header?.config
459
- if (typeof config?.provider === 'string' && typeof config?.model === 'string') {
460
- headerRoute = { provider: config.provider, model: config.model }
461
- }
462
- continue
463
- }
464
- if (event?.type === 'assistant/message') {
465
- addAttempt(event, messageRoute(event.data?.message) ?? headerRoute, normalizeUsage(event.data?.usage, event.data?.stream))
466
- continue
467
- }
468
- if (event?.type === 'assistant/attempt') {
469
- addAttempt(event, headerRoute, normalizeUsage(undefined, event.data?.stream))
470
- }
471
- }
472
-
473
- const turnRows = []
474
- const byModel = new Map()
475
- const totals = {
476
- cost: 0,
477
- promptTokens: 0,
478
- uncachedInputTokens: 0,
479
- cacheReadTokens: 0,
480
- cacheWriteTokens: 0,
481
- outputTokens: 0,
482
- cacheHit: null,
483
- attempts: 0,
484
- chargeableAttempts: 0,
485
- pricedAttempts: 0,
486
- unpricedAttempts: 0,
487
- cacheReported: false,
488
- hasBillable: false,
489
- partial: false,
490
- costKnown: false,
491
- }
492
-
493
- for (const entry of [...turns.values()].sort((left, right) => left.turn - right.turn)) {
494
- if (entry.attempts.length === 0) continue
495
- const row = {
496
- turn: entry.turn,
497
- startTime: entry.startTime,
498
- endTime: entry.endTime,
499
- preview: entry.preview,
500
- cost: 0,
501
- promptTokens: 0,
502
- uncachedInputTokens: 0,
503
- cacheReadTokens: 0,
504
- cacheWriteTokens: 0,
505
- outputTokens: 0,
506
- cacheHit: null,
507
- attempts: entry.attempts.length,
508
- chargeableAttempts: 0,
509
- pricedAttempts: 0,
510
- unpricedAttempts: 0,
511
- cacheReported: false,
512
- hasBillable: false,
513
- partial: false,
514
- costKnown: false,
515
- models: [...entry.models.values()].sort((left, right) => left.name.localeCompare(right.name)),
516
- }
517
- for (const attempt of entry.attempts) {
518
- const usage = attempt.usage
519
- const prompt = usage.uncachedInputTokens
520
- + (usage.cacheReadTokens ?? 0)
521
- + (usage.cacheWriteTokens ?? 0)
522
- row.promptTokens += prompt
523
- row.uncachedInputTokens += usage.uncachedInputTokens
524
- row.cacheReadTokens += usage.cacheReadTokens ?? 0
525
- row.cacheWriteTokens += usage.cacheWriteTokens ?? 0
526
- row.outputTokens += usage.outputTokens
527
- row.cost += attempt.cost
528
- if (usage.cacheReadTokens !== undefined || usage.cacheWriteTokens !== undefined) row.cacheReported = true
529
- row.chargeableAttempts += 1
530
- row.hasBillable = true
531
- if (attempt.priced) row.pricedAttempts += 1
532
- else row.unpricedAttempts += 1
533
-
534
- const key = attempt.provider !== null && attempt.model !== null
535
- ? modelKey(attempt.provider, attempt.model)
536
- : undefined
537
- const aggregateKey = key ?? `unknown${KEY_SEPARATOR}${attempt.name ?? 'unknown'}`
538
- let aggregate = byModel.get(aggregateKey)
539
- if (aggregate === undefined) {
540
- aggregate = {
541
- provider: attempt.provider,
542
- model: attempt.model,
543
- name: attempt.name ?? '未知模型',
544
- cost: 0,
545
- promptTokens: 0,
546
- outputTokens: 0,
547
- cacheReadTokens: 0,
548
- cacheReported: false,
549
- attempts: 0,
550
- pricedAttempts: 0,
551
- unpricedAttempts: 0,
552
- isDeepSeek: attempt.deepseek === true,
553
- }
554
- byModel.set(aggregateKey, aggregate)
555
- }
556
- aggregate.cost += attempt.cost
557
- aggregate.promptTokens += prompt
558
- aggregate.outputTokens += usage.outputTokens
559
- aggregate.cacheReadTokens += usage.cacheReadTokens ?? 0
560
- aggregate.cacheReported = aggregate.cacheReported || usage.cacheReadTokens !== undefined || usage.cacheWriteTokens !== undefined
561
- aggregate.attempts += 1
562
- if (attempt.priced) aggregate.pricedAttempts += 1
563
- else aggregate.unpricedAttempts += 1
564
- }
565
- row.cacheHit = row.cacheReported && row.promptTokens > 0 ? row.cacheReadTokens / row.promptTokens : null
566
- row.partial = row.unpricedAttempts > 0
567
- row.costKnown = row.attempts > 0 && row.unpricedAttempts === 0
568
- turnRows.push(row)
569
-
570
- totals.cost += row.cost
571
- totals.promptTokens += row.promptTokens
572
- totals.uncachedInputTokens += row.uncachedInputTokens
573
- totals.cacheReadTokens += row.cacheReadTokens
574
- totals.cacheWriteTokens += row.cacheWriteTokens
575
- totals.outputTokens += row.outputTokens
576
- totals.attempts += row.attempts
577
- totals.chargeableAttempts += row.chargeableAttempts
578
- totals.pricedAttempts += row.pricedAttempts
579
- totals.unpricedAttempts += row.unpricedAttempts
580
- totals.cacheReported = totals.cacheReported || row.cacheReported
581
- totals.hasBillable = totals.hasBillable || row.hasBillable
582
- }
583
-
584
- totals.cacheHit = totals.cacheReported && totals.promptTokens > 0 ? totals.cacheReadTokens / totals.promptTokens : null
585
- totals.partial = totals.unpricedAttempts > 0
586
- totals.costKnown = totals.attempts > 0 && totals.unpricedAttempts === 0
587
-
588
- return {
589
- turns: turnRows,
590
- totals,
591
- byModel: [...byModel.values()].sort((left, right) => left.cost - right.cost || left.name.localeCompare(right.name)),
592
- lastTurn: turnRows.at(-1) ?? null,
593
- }
594
- }
595
-
596
- /** Price one normalized attempt using one price set. */
597
- function estimateAttemptCost(usage, prices) {
598
- const hit = (usage.cacheReadTokens ?? 0) / 1_000_000 * prices.cacheHit
599
- const miss = ((usage.uncachedInputTokens ?? 0) + (usage.cacheWriteTokens ?? 0)) / 1_000_000 * prices.cacheMiss
600
- const output = (usage.outputTokens ?? 0) / 1_000_000 * prices.output
601
- const total = hit + miss + output
602
- return Number.isFinite(total) ? Math.round(total * 1e10) / 1e10 : 0
603
- }
604
-
605
- /** Load and normalize the durable state. A corrupt file degrades to defaults. */
606
- async function loadState(ctx) {
607
- const path = statePath()
608
- try {
609
- const text = await readFile(path, 'utf8')
610
- return normalizeState(JSON.parse(text))
611
- } catch (error) {
612
- if (error?.code !== 'ENOENT') ctx.logger?.warn?.(`[model-billing] ignoring unreadable state: ${messageOf(error)}`)
613
- return normalizeState(undefined)
614
- }
615
- }
616
-
617
- /** Validate a persisted state document into the current shape. */
618
- function normalizeState(raw) {
619
- const state = {
620
- version: STATE_VERSION,
621
- order: [],
622
- pricing: {},
623
- savedAt: typeof raw?.savedAt === 'number' && Number.isFinite(raw.savedAt) ? raw.savedAt : 0,
624
- }
625
- const seen = new Set()
626
- if (Array.isArray(raw?.order)) {
627
- for (const entry of raw.order) {
628
- if (state.order.length >= MAX_MODELS) break
629
- if (!validRoute(entry?.provider, entry?.model)) continue
630
- const key = modelKey(entry.provider, entry.model)
631
- if (seen.has(key)) continue
632
- seen.add(key)
633
- state.order.push({ provider: entry.provider, model: entry.model })
634
- }
635
- }
636
- if (raw?.pricing !== null && typeof raw?.pricing === 'object' && !Array.isArray(raw.pricing)) {
637
- let count = 0
638
- for (const [key, value] of Object.entries(raw.pricing)) {
639
- if (count >= MAX_STORED_PRICES) break
640
- if (key.includes(KEY_SEPARATOR)) {
641
- state.pricing[key] = publicPricing(value)
642
- count += 1
643
- }
644
- }
645
- }
646
- return state
647
- }
648
-
649
- /** Persist one normalized state document atomically. */
650
- async function writeState(state) {
651
- const path = statePath()
652
- await mkdir(dirname(path), { recursive: true })
653
- const temporary = `${path}.tmp-${process.pid}-${Date.now()}`
654
- await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
655
- await rename(temporary, path)
656
- }
657
-
658
- /** Validate a route pair used as a map key. */
659
- function validRoute(provider, model) {
660
- return typeof provider === 'string'
661
- && typeof model === 'string'
662
- && provider.trim() !== ''
663
- && model.trim() !== ''
664
- && provider.length <= 200
665
- && model.length <= 200
666
- && !/[\u0000-\u001f\u007f]/.test(provider)
667
- && !/[\u0000-\u001f\u007f]/.test(model)
668
- }
669
-
670
- /** Merge one save request into the durable state and return the new envelope. */
671
- async function saveOrderAndPrices(ctx, body) {
672
- const models = Array.isArray(body?.models) ? body.models : null
673
- if (models === null || models.length > MAX_MODELS) {
674
- throw new BillingRefusal(400, 'bad-request', `models must be an array of at most ${MAX_MODELS} entries`)
675
- }
676
- const state = await loadState(ctx)
677
- const order = []
678
- const seen = new Set()
679
- const pricing = { ...state.pricing }
680
- for (const entry of models) {
681
- if (!validRoute(entry?.provider, entry?.model)) {
682
- throw new BillingRefusal(400, 'bad-request', 'every model needs a non-empty provider and model id')
683
- }
684
- const provider = entry.provider.trim()
685
- const model = entry.model.trim()
686
- const key = modelKey(provider, model)
687
- if (seen.has(key)) throw new BillingRefusal(400, 'bad-request', `duplicate model: ${provider}/${model}`)
688
- seen.add(key)
689
- order.push({ provider, model })
690
- if (!isDeepSeekRoute(provider, model)) pricing[key] = publicPricing(entry.pricing)
691
- }
692
- const next = {
693
- version: STATE_VERSION,
694
- order,
695
- pricing,
696
- savedAt: Date.now(),
697
- }
698
- await enqueueWrite(ctx, next)
699
- return stateEnvelope(next)
700
- }
701
-
702
- /** Serialize writes so two quick saves cannot interleave. */
703
- let writeChain = Promise.resolve()
704
- function enqueueWrite(ctx, state) {
705
- const operation = writeChain.then(() => writeState(state))
706
- writeChain = operation.catch(() => {})
707
- return operation
708
- }
709
-
710
- /**
711
- * Register the catalog, usage and save routes.
712
- * @param {object} ctx Host Cordis context.
713
- */
714
- export function apply(ctx) {
715
- const onCatalog = (request, response) => {
716
- void serveCatalog(ctx, request, response).catch((error) => fail(ctx, response, error))
717
- }
718
- const onUsage = (request, response) => {
719
- void serveUsage(ctx, request, response).catch((error) => fail(ctx, response, error))
720
- }
721
- const onSave = (request, response) => {
722
- void serveSave(ctx, request, response).catch((error) => fail(ctx, response, error))
723
- }
724
- const onSelect = (request, response) => {
725
- void serveSelect(ctx, request, response).catch((error) => fail(ctx, response, error))
726
- }
727
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: CATALOG_PATH, handler: onCatalog }), 'model-billing: catalog route')
728
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: USAGE_PATH, handler: onUsage }), 'model-billing: usage route')
729
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: SAVE_PATH, handler: onSave }), 'model-billing: save route')
730
- ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: SELECT_PATH, handler: onSelect }), 'model-billing: select route')
731
- ctx.logger?.info?.(`[model-billing] routes mounted at ${CATALOG_PATH}, ${USAGE_PATH}, ${SAVE_PATH}, ${SELECT_PATH}`)
732
- }
733
-
734
- async function serveCatalog(ctx, request, response) {
735
- if (request.method !== 'GET') return sendJson(response, 405, { ok: false, code: 'method-not-allowed', message: 'only GET is accepted' })
736
- if (!sameOrigin(request)) return sendJson(response, 403, { ok: false, code: 'untrusted-origin', message: 'the request did not come from this Harness page' })
737
- sendJson(response, 200, await buildCatalog(ctx))
738
- }
739
-
740
- async function serveUsage(ctx, request, response) {
741
- if (request.method !== 'GET') return sendJson(response, 405, { ok: false, code: 'method-not-allowed', message: 'only GET is accepted' })
742
- if (!sameOrigin(request)) return sendJson(response, 403, { ok: false, code: 'untrusted-origin', message: 'the request did not come from this Harness page' })
743
- let sessionId = ''
744
- try {
745
- sessionId = (new URL(request.url ?? '/', 'http://127.0.0.1').searchParams.get('sessionId') ?? '').trim()
746
- } catch {
747
- return sendJson(response, 400, { ok: false, code: 'bad-request', message: 'sessionId is missing or malformed' })
748
- }
749
- if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(sessionId)) {
750
- return sendJson(response, 400, { ok: false, code: 'bad-request', message: 'sessionId is missing or malformed' })
751
- }
752
- const sessions = ctx.get('sessions')
753
- const session = sessions?.get?.(sessionId)
754
- if (session === undefined) {
755
- return sendJson(response, 404, { ok: false, code: 'session-not-found', message: 'this Session is not loaded in the Host' })
756
- }
757
- const state = await loadState(ctx)
758
- const pricingByKey = new Map()
759
- for (const [key, value] of Object.entries(state.pricing)) pricingByKey.set(key, publicPricing(value))
760
- const usage = computeSessionUsage(session.ownEvents(), pricingByKey)
761
- sendJson(response, 200, {
762
- ok: true,
763
- sessionId,
764
- savedAt: state.savedAt,
765
- stateVersion: STATE_VERSION,
766
- ...usage,
767
- })
768
- }
769
-
770
- async function serveSave(ctx, request, response) {
771
- if (request.method !== 'POST') return sendJson(response, 405, { ok: false, code: 'method-not-allowed', message: 'only POST is accepted' })
772
- if (!sameOrigin(request)) return sendJson(response, 403, { ok: false, code: 'untrusted-origin', message: 'the request did not come from this Harness page' })
773
- let body
774
- try {
775
- body = JSON.parse(await readBody(request))
776
- } catch (error) {
777
- return sendJson(response, error instanceof BillingRefusal ? error.status : 400, {
778
- ok: false,
779
- code: error instanceof BillingRefusal ? error.code : 'bad-request',
780
- message: messageOf(error),
781
- })
782
- }
783
- const envelope = await saveOrderAndPrices(ctx, body)
784
- sendJson(response, 200, { ok: true, ...envelope })
785
- }
786
-
787
- /** Select a model for one Session through the official session controller. */
788
- async function serveSelect(ctx, request, response) {
789
- if (request.method !== 'POST') return sendJson(response, 405, { ok: false, code: 'method-not-allowed', message: 'only POST is accepted' })
790
- if (!sameOrigin(request)) return sendJson(response, 403, { ok: false, code: 'untrusted-origin', message: 'the request did not come from this Harness page' })
791
- let body
792
- try {
793
- body = JSON.parse(await readBody(request))
794
- } catch (error) {
795
- return sendJson(response, 400, { ok: false, code: 'bad-request', message: messageOf(error) })
796
- }
797
- const sessionId = typeof body?.sessionId === 'string' ? body.sessionId.trim() : ''
798
- if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(sessionId)) {
799
- return sendJson(response, 400, { ok: false, code: 'bad-request', message: 'sessionId is missing or malformed' })
800
- }
801
- if (!validRoute(body?.provider, body?.model)) {
802
- return sendJson(response, 400, { ok: false, code: 'bad-request', message: 'provider and model are required' })
803
- }
804
- const controller = ctx.get('sessionController')
805
- if (controller === undefined) {
806
- return sendJson(response, 503, { ok: false, code: 'unavailable', message: 'the session controller is not mounted' })
807
- }
808
- const reasoningEffort = typeof body.reasoningEffort === 'string' && body.reasoningEffort !== ''
809
- ? body.reasoningEffort
810
- : undefined
811
- try {
812
- const selection = await controller.selectModel({
813
- sessionId,
814
- provider: body.provider.trim(),
815
- model: body.model.trim(),
816
- ...reasoningEffort === undefined ? {} : { reasoningEffort },
817
- })
818
- sendJson(response, 200, { ok: true, selection })
819
- } catch (error) {
820
- sendJson(response, 400, { ok: false, code: 'select-failed', message: messageOf(error) })
821
- }
822
- }
823
-
824
- function fail(ctx, response, error) {
825
- const refusal = error instanceof BillingRefusal
826
- ? error
827
- : new BillingRefusal(500, 'internal', messageOf(error))
828
- ctx.logger?.warn?.(`[model-billing] ${refusal.code}: ${refusal.message}`)
829
- sendJson(response, refusal.status, { ok: false, code: refusal.code, message: refusal.message })
830
- }
831
-
832
- function sendJson(response, status, value) {
833
- if (response.headersSent) return
834
- response.writeHead(status, JSON_HEADERS)
835
- response.end(JSON.stringify(value))
836
- }
837
-
838
- /** Whether the request came from this Harness page rather than another site. */
839
- function sameOrigin(request) {
840
- const host = request.headers.host
841
- if (host !== undefined && !loopbackAuthority(host)) return false
842
- if (request.headers['sec-fetch-site'] === 'cross-site') return false
843
- const origin = request.headers.origin
844
- if (origin === undefined) return true
845
- try {
846
- return new URL(origin).host === host
847
- } catch {
848
- return false
849
- }
850
- }
851
-
852
- function loopbackAuthority(host) {
853
- const lower = host.toLowerCase()
854
- const name = lower.startsWith('[') ? lower.slice(0, lower.indexOf(']') + 1) : lower.split(':')[0]
855
- return name === '127.0.0.1' || name === 'localhost' || name === '[::1]'
856
- }
857
-
858
- /** Read a size-capped JSON request body. */
859
- async function readBody(request) {
860
- let size = 0
861
- const chunks = []
862
- for await (const chunk of request) {
863
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
864
- size += buffer.length
865
- if (size > MAX_BODY_BYTES) {
866
- throw new BillingRefusal(413, 'body-too-large', `the request body exceeds ${MAX_BODY_BYTES} bytes`)
867
- }
868
- chunks.push(buffer)
869
- }
870
- return Buffer.concat(chunks).toString('utf8')
871
- }
872
-
873
- function messageOf(error) {
874
- return error instanceof Error ? error.message : String(error)
875
- }
876
-
877
- /** Pure helpers exported for the Host test run. */
878
- export const __test = {
879
- applySavedOrder,
880
- buildCatalog,
881
- catalogPricing,
882
- computeSessionUsage,
883
- deepseekBuiltinPricing,
884
- deepseekPeakAt,
885
- deepseekPricingAt,
886
- defaultPricing,
887
- estimateAttemptCost,
888
- isDeepSeekRoute,
889
- loadState,
890
- modelKey,
891
- normalizeState,
892
- normalizeUsage,
893
- priceSetComplete,
894
- publicPricing,
895
- saveOrderAndPrices,
896
- statePath,
897
- validRoute,
898
- }
1
+ import { mkdir, readdir, readFile, rename, unlink, writeFile } from 'node:fs/promises'
2
+ import { basename, dirname, join } from 'node:path'
3
+ import { homedir } from 'node:os'
4
+
5
+ /**
6
+ * Model order, token prices and session cost estimation.
7
+ *
8
+ * The browser half is a build-free plain-JavaScript Client module, so it cannot
9
+ * declare a generated `ctx.remote` namespace. It talks to this half over
10
+ * same-origin routes on `ctx.webServer`, the same transport other community
11
+ * plugins use. Prices and the user's order live in a small JSON file under the
12
+ * Harness home; usage is always folded from the durable session log.
13
+ */
14
+
15
+ export const inject = ['webServer']
16
+
17
+ const CATALOG_PATH = '/model-billing/catalog'
18
+ const USAGE_PATH = '/model-billing/usage'
19
+ const SAVE_PATH = '/model-billing/save'
20
+ const SELECT_PATH = '/model-billing/select'
21
+
22
+ const STATE_VERSION = 1
23
+ const MAX_MODELS = 500
24
+ const MAX_STORED_PRICES = 2000
25
+ const MAX_BODY_BYTES = 256 * 1024
26
+ const PRICE_FIELDS = ['cacheHit', 'cacheMiss', 'output']
27
+ const KEY_SEPARATOR = '\u0000'
28
+ const DISPLAY_DEFAULTS = Object.freeze({
29
+ showTurnTail: true,
30
+ showDock: true,
31
+ showModels: true,
32
+ showProvider: false,
33
+ showCost: true,
34
+ showTokens: false,
35
+ showCacheHit: false,
36
+ showLabels: true,
37
+ showUnpricedNotice: true,
38
+ takeoverPicker: true,
39
+ fontSize: 'normal',
40
+ density: 'comfortable',
41
+ fontWeight: 'normal',
42
+ })
43
+ const DISPLAY_FLAGS = ['showTurnTail', 'showDock', 'showModels', 'showProvider', 'showCost', 'showTokens', 'showCacheHit', 'showLabels', 'showUnpricedNotice', 'takeoverPicker']
44
+ const DISPLAY_FONT_SIZES = new Set(['small', 'normal', 'large'])
45
+ const DISPLAY_DENSITIES = new Set(['compact', 'comfortable'])
46
+ const DISPLAY_FONT_WEIGHTS = new Set(['normal', 'bold'])
47
+ const DEEPSEEK_PRICING_SCHEDULE = '北京时间周一至周五 9:00–12:00、14:00–18:00(法定节假日除外)'
48
+ const DEEPSEEK_PRICES = {
49
+ 'deepseek-flash': {
50
+ peak: { cacheHit: 0.04, cacheMiss: 2, output: 8 },
51
+ offPeak: { cacheHit: 0.02, cacheMiss: 1, output: 4 },
52
+ },
53
+ 'deepseek-v4-flash': {
54
+ peak: { cacheHit: 0.04, cacheMiss: 2, output: 8 },
55
+ offPeak: { cacheHit: 0.02, cacheMiss: 1, output: 4 },
56
+ },
57
+ 'deepseek-v4-flash-vision-exp': {
58
+ peak: { cacheHit: 0.04, cacheMiss: 2, output: 8 },
59
+ offPeak: { cacheHit: 0.02, cacheMiss: 1, output: 4 },
60
+ },
61
+ 'deepseek-v4-pro': {
62
+ peak: { cacheHit: 0.3, cacheMiss: 9, output: 27 },
63
+ offPeak: { cacheHit: 0.15, cacheMiss: 4.5, output: 13.5 },
64
+ },
65
+ }
66
+
67
+ // Chinese statutory holidays (2026). Weekends are always off-peak; these dates
68
+ // are the extra weekday closures that must not be billed at peak rates.
69
+ const DEEPSEEK_HOLIDAYS = new Set([
70
+ '2026-01-01', '2026-01-02', '2026-01-03',
71
+ '2026-02-15', '2026-02-16', '2026-02-17', '2026-02-18', '2026-02-19',
72
+ '2026-02-20', '2026-02-21', '2026-02-22', '2026-02-23',
73
+ '2026-04-04', '2026-04-05', '2026-04-06',
74
+ '2026-05-01', '2026-05-02', '2026-05-03', '2026-05-04', '2026-05-05',
75
+ '2026-06-19', '2026-06-20', '2026-06-21',
76
+ '2026-09-25', '2026-09-26', '2026-09-27',
77
+ '2026-10-01', '2026-10-02', '2026-10-03', '2026-10-04',
78
+ '2026-10-05', '2026-10-06', '2026-10-07',
79
+ ])
80
+ const JSON_HEADERS = {
81
+ 'content-type': 'application/json; charset=utf-8',
82
+ 'cache-control': 'no-store',
83
+ }
84
+
85
+ /** A refusal that carries the HTTP status and stable code the Client reports. */
86
+ class BillingRefusal extends Error {
87
+ constructor(status, code, message) {
88
+ super(message)
89
+ this.name = 'BillingRefusal'
90
+ this.status = status
91
+ this.code = code
92
+ }
93
+ }
94
+
95
+ /** The durable state path; `DSH_HOME` wins over the conventional home. */
96
+ function statePath() {
97
+ const home = typeof process.env.DSH_HOME === 'string' && process.env.DSH_HOME.trim() !== ''
98
+ ? process.env.DSH_HOME.trim()
99
+ : join(homedir(), '.dsh')
100
+ return join(home, 'model-billing.json')
101
+ }
102
+
103
+ /** How long a provider's model list stays fresh. */
104
+ const CATALOG_TTL_MS = 60_000
105
+ let providerListCache = { at: 0, value: null }
106
+ const modelListCache = new Map()
107
+ const reasoningCache = new Map()
108
+
109
+ /** Drop every catalog cache so the next lookup hits the adapters again. */
110
+ function invalidateCatalogCache() {
111
+ providerListCache = { at: 0, value: null }
112
+ modelListCache.clear()
113
+ reasoningCache.clear()
114
+ }
115
+
116
+ /** Look up the model catalog and merge the saved order/prices into it. */
117
+ async function buildCatalog(ctx, options = {}) {
118
+ const state = await loadState(ctx)
119
+ const llm = ctx.get('llm')
120
+ if (llm === undefined) {
121
+ return {
122
+ ok: true,
123
+ models: [],
124
+ failures: [{ provider: 'llm', message: 'the llm service is not mounted' }],
125
+ pricingArchive: pricingArchive(state),
126
+ ...stateEnvelope(state),
127
+ }
128
+ }
129
+
130
+ const refresh = options?.refresh === true
131
+ const now = Date.now()
132
+
133
+ let providers
134
+ if (!refresh && providerListCache.value !== null && now - providerListCache.at < CATALOG_TTL_MS) {
135
+ providers = providerListCache.value
136
+ } else {
137
+ const listed = typeof llm.listProviders === 'function' ? llm.listProviders() : []
138
+ providers = Array.isArray(listed) ? listed : []
139
+ providerListCache = { at: now, value: providers }
140
+ }
141
+
142
+ const settled = await Promise.allSettled(providers.map(async (provider) => {
143
+ let cached = refresh ? undefined : modelListCache.get(provider.id)
144
+ if (cached === undefined || Date.now() - cached.at >= CATALOG_TTL_MS) {
145
+ const models = await llm.listModels(provider.id)
146
+ if (!Array.isArray(models)) throw new Error('the provider returned no model list')
147
+ cached = { at: Date.now(), models }
148
+ modelListCache.set(provider.id, cached)
149
+ }
150
+ const entries = cached.models.filter((model) => typeof model?.id === 'string' && model.id !== '')
151
+ return Promise.all(entries.map(async (model) => {
152
+ let reasoning
153
+ if (typeof llm.resolveModelInfo === 'function') {
154
+ const cacheKey = `${provider.id}\u0000${model.id}`
155
+ if (!refresh && reasoningCache.has(cacheKey)) {
156
+ reasoning = reasoningCache.get(cacheKey)
157
+ } else {
158
+ try {
159
+ const resolved = await llm.resolveModelInfo(provider.id, model.id)
160
+ reasoning = publicReasoning(resolved?.reasoning)
161
+ } catch {
162
+ // Reasoning metadata is optional; an unavailable adapter must not hide the model.
163
+ reasoning = undefined
164
+ }
165
+ // Memoized until the next save; a resolution failure is cached too, so a
166
+ // flapping adapter is not retried on every single catalog request.
167
+ reasoningCache.set(cacheKey, reasoning)
168
+ }
169
+ }
170
+ return {
171
+ provider: provider.id,
172
+ providerName: provider.name || provider.id,
173
+ model: model.id,
174
+ name: model.name || model.id,
175
+ isDeepSeek: isDeepSeekRoute(provider.id, model.id),
176
+ ...reasoning === undefined ? {} : { reasoning },
177
+ }
178
+ }))
179
+ }))
180
+
181
+ const rows = []
182
+ const failures = []
183
+ const seen = new Set()
184
+ settled.forEach((result, index) => {
185
+ const provider = providers[index]
186
+ if (result.status === 'rejected') {
187
+ failures.push({
188
+ provider: provider?.id ?? 'unknown',
189
+ providerName: provider?.name ?? provider?.id ?? 'unknown',
190
+ message: messageOf(result.reason),
191
+ })
192
+ return
193
+ }
194
+ for (const row of result.value) {
195
+ const key = modelKey(row.provider, row.model)
196
+ if (seen.has(key)) continue
197
+ seen.add(key)
198
+ rows.push(row)
199
+ }
200
+ })
201
+
202
+ const ranked = applySavedOrder(rows, state)
203
+ return {
204
+ ok: true,
205
+ models: ranked.map((row, index) => ({
206
+ ...row,
207
+ order: index,
208
+ pricing: catalogPricing(row.provider, row.model, state.pricing[modelKey(row.provider, row.model)]),
209
+ })),
210
+ failures,
211
+ pricingArchive: pricingArchive(state),
212
+ ...stateEnvelope(state),
213
+ }
214
+ }
215
+
216
+ /** Put saved routes first, keeping new routes in provider/catalog order behind them. */
217
+ function applySavedOrder(rows, state) {
218
+ const rank = new Map()
219
+ for (const entry of state.order) {
220
+ const key = modelKey(entry.provider, entry.model)
221
+ if (!rank.has(key)) rank.set(key, rank.size)
222
+ }
223
+ return rows
224
+ .map((row, index) => ({ row, index, rank: rank.get(modelKey(row.provider, row.model)) ?? Number.MAX_SAFE_INTEGER }))
225
+ .sort((left, right) => left.rank - right.rank || left.index - right.index)
226
+ .map((entry) => entry.row)
227
+ }
228
+
229
+ /** The pricing/order metadata every catalog response carries. */
230
+ function stateEnvelope(state) {
231
+ return {
232
+ stateVersion: STATE_VERSION,
233
+ savedAt: state.savedAt,
234
+ order: state.order.map((entry) => ({ provider: entry.provider, model: entry.model })),
235
+ display: { ...state.display },
236
+ }
237
+ }
238
+
239
+ /**
240
+ * Prices for routes that are no longer in the configured model list. They stay
241
+ * in the price book so historical calls remain priced; the settings page edits
242
+ * them here instead of forcing the user to re-add a retired model to the picker.
243
+ */
244
+ function pricingArchive(state) {
245
+ const current = new Set(state.order.map((entry) => modelKey(entry.provider, entry.model)))
246
+ const rows = []
247
+ for (const [key, value] of Object.entries(state.pricing)) {
248
+ if (current.has(key)) continue
249
+ const at = key.indexOf(KEY_SEPARATOR)
250
+ if (at <= 0) continue
251
+ const provider = key.slice(0, at)
252
+ const model = key.slice(at + KEY_SEPARATOR.length)
253
+ if (!validRoute(provider, model)) continue
254
+ rows.push({ provider, model, pricing: { ...publicPricing(value), source: 'user' } })
255
+ }
256
+ return rows.sort((left, right) =>
257
+ left.provider.localeCompare(right.provider) || left.model.localeCompare(right.model))
258
+ }
259
+
260
+ /**
261
+ * Whether a provider id belongs to DeepSeek itself. Anchored at the start so a
262
+ * relay that merely contains the word (`my-deepseek-relay`) is not captured.
263
+ */
264
+ const OFFICIAL_DEEPSEEK_PROVIDER = /^deepseek(?:$|[-_.])/
265
+ function isOfficialDeepSeekProvider(provider) {
266
+ return OFFICIAL_DEEPSEEK_PROVIDER.test(String(provider ?? '').trim().toLowerCase())
267
+ }
268
+
269
+ /** The built-in rate table entry for a model, matched case-insensitively. */
270
+ function builtinRatesFor(model) {
271
+ const id = String(model ?? '').trim().toLowerCase()
272
+ return Object.prototype.hasOwnProperty.call(DEEPSEEK_PRICES, id) ? DEEPSEEK_PRICES[id] : undefined
273
+ }
274
+
275
+ /** Whether a route is an official DeepSeek route with a built-in rate. */
276
+ function isDeepSeekRoute(provider, model) {
277
+ return isOfficialDeepSeekProvider(provider) && builtinRatesFor(model) !== undefined
278
+ }
279
+
280
+ /** Whether one instant falls in DeepSeek's Beijing peak window. */
281
+ function deepseekPeakAt(time) {
282
+ const stamp = Number(time)
283
+ if (!Number.isFinite(stamp)) return false
284
+ const beijing = new Date(stamp + 8 * 60 * 60 * 1000)
285
+ const day = beijing.getUTCDay()
286
+ if (day === 0 || day === 6) return false
287
+ if (DEEPSEEK_HOLIDAYS.has(beijing.toISOString().slice(0, 10))) return false
288
+ const hour = beijing.getUTCHours()
289
+ return (hour >= 9 && hour < 12) || (hour >= 14 && hour < 18)
290
+ }
291
+
292
+ /** The built-in price set for a DeepSeek model at one instant, or undefined. */
293
+ function deepseekPricingAt(model, time) {
294
+ const rates = builtinRatesFor(model)
295
+ if (rates === undefined) return undefined
296
+ return deepseekPeakAt(time) ? rates.peak : rates.offPeak
297
+ }
298
+
299
+ /** Keep only the reasoning metadata the Client needs for its effort menu. */
300
+ function publicReasoning(value) {
301
+ if (value === null || typeof value !== 'object' || !Array.isArray(value.efforts)) return undefined
302
+ const efforts = value.efforts
303
+ .filter((effort) => typeof effort?.id === 'string' && effort.id !== '')
304
+ .map((effort) => ({
305
+ id: effort.id,
306
+ name: typeof effort.name === 'string' && effort.name !== '' ? effort.name : effort.id,
307
+ ...(typeof effort.description === 'string' && effort.description !== '' ? { description: effort.description } : {}),
308
+ }))
309
+ if (efforts.length === 0) return undefined
310
+ return {
311
+ efforts,
312
+ ...(typeof value.defaultEffort === 'string' && value.defaultEffort !== '' ? { defaultEffort: value.defaultEffort } : {}),
313
+ }
314
+ }
315
+
316
+ /** A stable map key that cannot collide across provider/model strings. */
317
+ function modelKey(provider, model) {
318
+ return `${provider}${KEY_SEPARATOR}${model}`
319
+ }
320
+
321
+ /** Default prices: unset values stay `null`, zero is a real free price. */
322
+ function defaultPricing() {
323
+ return { cacheHit: null, cacheMiss: null, output: null }
324
+ }
325
+
326
+ /** The public form of one model's three prices. */
327
+ function publicPricing(value) {
328
+ const source = value?.base ?? value
329
+ const fallback = defaultPricing()
330
+ if (source === null || typeof source !== 'object') return fallback
331
+ const result = {}
332
+ for (const field of PRICE_FIELDS) result[field] = priceOrNull(source[field])
333
+ return result
334
+ }
335
+
336
+ /** Official DeepSeek peak/off-peak prices, built in rather than user-edited. */
337
+ function deepseekBuiltinPricing(provider, model) {
338
+ if (!isOfficialDeepSeekProvider(provider)) return null
339
+ const prices = builtinRatesFor(model)
340
+ if (prices === undefined) return null
341
+ return {
342
+ builtin: true,
343
+ peak: { ...prices.peak },
344
+ offPeak: { ...prices.offPeak },
345
+ schedule: DEEPSEEK_PRICING_SCHEDULE,
346
+ }
347
+ }
348
+
349
+ /**
350
+ * The single pricing resolver shared by catalog and usage. A user-supplied price
351
+ * set always wins; otherwise a DeepSeek route falls back to the built-in official
352
+ * table, and every other route stays on its saved flat prices.
353
+ * @returns {{ kind: 'builtin'|'custom'|'flat'|'none', prices: object, complete: boolean, source: 'official'|'user' }}
354
+ */
355
+ function resolveRoutePricing(provider, model, savedPricing, time) {
356
+ const saved = publicPricing(savedPricing)
357
+ if (priceSetComplete(saved)) return { kind: 'custom', prices: saved, complete: true, source: 'user' }
358
+ const rates = isOfficialDeepSeekProvider(provider) ? builtinRatesFor(model) : undefined
359
+ if (rates !== undefined) {
360
+ const prices = deepseekPeakAt(time) ? rates.peak : rates.offPeak
361
+ return { kind: 'builtin', prices: { ...prices }, complete: priceSetComplete(prices), source: 'official' }
362
+ }
363
+ const prices = publicPricing(savedPricing)
364
+ const complete = priceSetComplete(prices)
365
+ return { kind: complete ? 'flat' : 'none', prices, complete, source: 'user' }
366
+ }
367
+
368
+ /** Pricing as presented to the Client: the built-in official table or the saved flat prices. */
369
+ function catalogPricing(provider, model, value) {
370
+ const resolved = resolveRoutePricing(provider, model, value, Date.now())
371
+ if (resolved.kind === 'builtin') return { ...deepseekBuiltinPricing(provider, model), source: 'official' }
372
+ // Keep the official table alongside a custom DeepSeek price so the settings page
373
+ // can offer "restore official prices" without another round trip.
374
+ const official = isOfficialDeepSeekProvider(provider) ? deepseekBuiltinPricing(provider, model) : null
375
+ return {
376
+ ...resolved.prices,
377
+ source: 'user',
378
+ ...official === null ? {} : { official },
379
+ }
380
+ }
381
+
382
+ /** A finite non-negative number, or null for "not set". */
383
+ function priceOrNull(value) {
384
+ if (value === null || value === undefined || value === '') return null
385
+ const number = typeof value === 'number' ? value : Number(value)
386
+ if (!Number.isFinite(number) || number < 0 || number > 1e12) return null
387
+ return Math.round(number * 1e8) / 1e8
388
+ }
389
+
390
+ /** Whether all three prices of a set are configured. */
391
+ function priceSetComplete(value) {
392
+ return PRICE_FIELDS.every((field) => typeof value?.[field] === 'number' && Number.isFinite(value[field]) && value[field] >= 0)
393
+ }
394
+
395
+ function isCount(value) {
396
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
397
+ }
398
+
399
+ function safeSum(values) {
400
+ let total = 0
401
+ for (const value of values) {
402
+ total += value
403
+ if (!Number.isSafeInteger(total)) return undefined
404
+ }
405
+ return total
406
+ }
407
+
408
+ /** The last usage chunk of a durable assistant stream. */
409
+ function streamUsage(stream) {
410
+ if (!Array.isArray(stream)) return undefined
411
+ for (let index = stream.length - 1; index >= 0; index -= 1) {
412
+ const record = stream[index]
413
+ if (record?.type === 'chunk' && record.chunk?.type === 'usage') return record.chunk.usage
414
+ }
415
+ return undefined
416
+ }
417
+
418
+ /**
419
+ * Normalize a provider usage sample. `inputTokens` is the uncached input bucket;
420
+ * cache reads and writes sit beside it, matching token-meter's accounting.
421
+ */
422
+ function normalizeUsage(usage, stream) {
423
+ const sample = usage ?? streamUsage(stream)
424
+ if (sample === null || typeof sample !== 'object') return undefined
425
+ const { inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, reasoningTokens, totalTokens } = sample
426
+ if (!isCount(inputTokens) || !isCount(outputTokens)) return undefined
427
+ if (cacheReadTokens !== undefined && !isCount(cacheReadTokens)) return undefined
428
+ if (cacheWriteTokens !== undefined && !isCount(cacheWriteTokens)) return undefined
429
+ if (reasoningTokens !== undefined && (!isCount(reasoningTokens) || reasoningTokens > outputTokens)) return undefined
430
+ const knownPrompt = safeSum([
431
+ inputTokens,
432
+ ...cacheReadTokens === undefined ? [] : [cacheReadTokens],
433
+ ...cacheWriteTokens === undefined ? [] : [cacheWriteTokens],
434
+ ])
435
+ if (knownPrompt === undefined) return undefined
436
+ // The three buckets are the authoritative source; a totalTokens that disagrees
437
+ // with them only marks the row partial. Dropping the whole row would under-report
438
+ // the session far more than flagging it uncertain.
439
+ let partial = false
440
+ if (totalTokens !== undefined) {
441
+ if (isCount(totalTokens)) {
442
+ const exactPrompt = totalTokens - outputTokens
443
+ if (!isCount(exactPrompt) || exactPrompt < knownPrompt) partial = true
444
+ else if (cacheReadTokens !== undefined && cacheWriteTokens !== undefined && exactPrompt !== knownPrompt) partial = true
445
+ }
446
+ }
447
+ return {
448
+ uncachedInputTokens: inputTokens,
449
+ outputTokens,
450
+ ...cacheReadTokens === undefined ? {} : { cacheReadTokens },
451
+ ...cacheWriteTokens === undefined ? {} : { cacheWriteTokens },
452
+ ...reasoningTokens === undefined ? {} : { reasoningTokens },
453
+ ...partial ? { partial: true } : {},
454
+ }
455
+ }
456
+
457
+ function messageRoute(message) {
458
+ const provider = message?.source?.provider
459
+ const model = message?.source?.model
460
+ return typeof provider === 'string' && typeof model === 'string' && provider !== '' && model !== ''
461
+ ? { provider, model }
462
+ : undefined
463
+ }
464
+
465
+ /** A short, whitespace-collapsed preview of the first user text in one turn. */
466
+ function userTextPreview(message) {
467
+ const content = Array.isArray(message?.content) ? message.content : []
468
+ const text = content
469
+ .filter((block) => block?.type === 'text' && typeof block.text === 'string')
470
+ .map((block) => block.text)
471
+ .join(' ')
472
+ .replace(/\s+/g, ' ')
473
+ .trim()
474
+ if (text === '') return '(无文本)'
475
+ const characters = Array.from(text)
476
+ return characters.length > 36 ? `${characters.slice(0, 36).join('')}…` : text
477
+ }
478
+
479
+ /**
480
+ * Fold one Session's durable attempts into per-turn and whole-session cost.
481
+ *
482
+ * Only `assistant/message` and `assistant/attempt` samples are billed. A usage
483
+ * sample without a lifecycle route is retained with an unknown route so the UI
484
+ * can report it as unpriced instead of silently dropping tokens.
485
+ */
486
+ function computeSessionUsage(events, pricingByKey = new Map(), nameByKey = new Map(), orderKeys = null) {
487
+ const turns = new Map()
488
+ // When the caller knows which routes are still configured, an unpriced call can
489
+ // be split into "needs attention now" and "historical leftovers". Without that
490
+ // set every unpriced call is historical, which keeps older callers unchanged.
491
+ const orderSet = orderKeys instanceof Set ? orderKeys : null
492
+ const unpricedRoutes = new Map()
493
+ let headerRoute
494
+ let headerAt
495
+ let latestTime = 0
496
+ let currentTurn = null
497
+ let pendingPreview = null
498
+
499
+ const ensureTurn = (turn, time) => {
500
+ let entry = turns.get(turn)
501
+ if (entry === undefined) {
502
+ entry = {
503
+ turn,
504
+ startTime: Number(time) || 0,
505
+ endTime: Number(time) || 0,
506
+ preview: '',
507
+ seenUser: false,
508
+ attempts: [],
509
+ models: new Map(),
510
+ }
511
+ turns.set(turn, entry)
512
+ }
513
+ return entry
514
+ }
515
+
516
+ const addAttempt = (event, route, usage) => {
517
+ const turn = event?.data?.turn
518
+ if (!Number.isSafeInteger(turn) || usage === undefined) return
519
+ const time = Number(event.time) || latestTime
520
+ latestTime = Math.max(latestTime, time)
521
+ const entry = ensureTurn(turn, time)
522
+ entry.endTime = Math.max(entry.endTime, time)
523
+ const provider = route?.provider
524
+ const model = route?.model
525
+ const key = provider !== undefined && model !== undefined ? modelKey(provider, model) : undefined
526
+ // Peak/off-peak follows the moment the request reached the server, not when
527
+ // the stream finished — a long request straddling noon stays on its entry rate.
528
+ const pricedAt = headerAt ?? time
529
+ const resolved = provider !== undefined && model !== undefined
530
+ ? resolveRoutePricing(provider, model, key === undefined ? undefined : pricingByKey.get(key), pricedAt)
531
+ : { kind: 'none', prices: defaultPricing(), complete: false }
532
+ const effective = resolved.prices
533
+ const complete = resolved.complete
534
+ const cost = !complete ? 0 : estimateAttemptCost(usage, effective)
535
+ const deepseek = resolved.kind === 'builtin'
536
+ const attempt = {
537
+ seq: event.seq,
538
+ time,
539
+ provider: provider ?? null,
540
+ model: model ?? null,
541
+ name: key === undefined ? null : nameByKey.get(key) ?? null,
542
+ deepseek,
543
+ priced: complete,
544
+ currentOrder: orderSet === null || key === undefined ? null : orderSet.has(key),
545
+ cost,
546
+ usage,
547
+ }
548
+ entry.attempts.push(attempt)
549
+ if (provider !== undefined && model !== undefined) {
550
+ const modelName = nameByKey.get(key) ?? `${provider} / ${model}`
551
+ const existing = entry.models.get(key)
552
+ if (existing === undefined) {
553
+ entry.models.set(key, { provider, model, name: modelName, cost: 0, priced: complete, isDeepSeek: deepseek })
554
+ }
555
+ const modelEntry = entry.models.get(key)
556
+ modelEntry.cost += cost
557
+ modelEntry.priced = modelEntry.priced && complete
558
+ }
559
+ }
560
+
561
+ for (const event of events) {
562
+ if (event?.type === 'turn/start') {
563
+ const turn = event.data?.turn
564
+ if (Number.isSafeInteger(turn)) {
565
+ currentTurn = turn
566
+ headerRoute = undefined
567
+ headerAt = undefined
568
+ const entry = ensureTurn(turn, event.time)
569
+ if (!entry.seenUser && pendingPreview !== null) {
570
+ entry.preview = pendingPreview
571
+ entry.seenUser = true
572
+ pendingPreview = null
573
+ }
574
+ }
575
+ continue
576
+ }
577
+ if (event?.type === 'user/message') {
578
+ const preview = userTextPreview(event.data)
579
+ if (currentTurn === null) {
580
+ pendingPreview = preview
581
+ } else {
582
+ const entry = ensureTurn(currentTurn, event.time)
583
+ if (!entry.seenUser) {
584
+ entry.preview = preview
585
+ entry.seenUser = true
586
+ }
587
+ }
588
+ continue
589
+ }
590
+ if (event?.type === 'turn/end') {
591
+ currentTurn = null
592
+ continue
593
+ }
594
+ if (event?.type === 'request/header') {
595
+ const config = event.data?.header?.config
596
+ headerAt = Number(event.time) || headerAt
597
+ if (typeof config?.provider === 'string' && typeof config?.model === 'string') {
598
+ headerRoute = { provider: config.provider, model: config.model }
599
+ }
600
+ continue
601
+ }
602
+ if (event?.type === 'assistant/message') {
603
+ addAttempt(event, messageRoute(event.data?.message) ?? headerRoute, normalizeUsage(event.data?.usage, event.data?.stream))
604
+ continue
605
+ }
606
+ if (event?.type === 'assistant/attempt') {
607
+ addAttempt(event, headerRoute, normalizeUsage(undefined, event.data?.stream))
608
+ }
609
+ }
610
+
611
+ const turnRows = []
612
+ const byModel = new Map()
613
+ const totals = {
614
+ cost: 0,
615
+ promptTokens: 0,
616
+ uncachedInputTokens: 0,
617
+ cacheReadTokens: 0,
618
+ cacheWriteTokens: 0,
619
+ outputTokens: 0,
620
+ cacheHit: null,
621
+ attempts: 0,
622
+ chargeableAttempts: 0,
623
+ pricedAttempts: 0,
624
+ unpricedAttempts: 0,
625
+ unpricedCurrentAttempts: 0,
626
+ unpricedHistoricalAttempts: 0,
627
+ cacheReported: false,
628
+ hasBillable: false,
629
+ partial: false,
630
+ partialCurrent: false,
631
+ costKnown: false,
632
+ }
633
+
634
+ for (const entry of [...turns.values()].sort((left, right) => left.turn - right.turn)) {
635
+ if (entry.attempts.length === 0) continue
636
+ const row = {
637
+ turn: entry.turn,
638
+ startTime: entry.startTime,
639
+ endTime: entry.endTime,
640
+ preview: entry.preview,
641
+ cost: 0,
642
+ promptTokens: 0,
643
+ uncachedInputTokens: 0,
644
+ cacheReadTokens: 0,
645
+ cacheWriteTokens: 0,
646
+ outputTokens: 0,
647
+ cacheHit: null,
648
+ attempts: entry.attempts.length,
649
+ chargeableAttempts: 0,
650
+ pricedAttempts: 0,
651
+ unpricedAttempts: 0,
652
+ unpricedCurrentAttempts: 0,
653
+ unpricedHistoricalAttempts: 0,
654
+ cacheReported: false,
655
+ hasBillable: false,
656
+ partial: false,
657
+ partialCurrent: false,
658
+ costKnown: false,
659
+ models: [...entry.models.values()].sort((left, right) => left.name.localeCompare(right.name)),
660
+ }
661
+ let usagePartial = false
662
+ for (const attempt of entry.attempts) {
663
+ const usage = attempt.usage
664
+ const prompt = usage.uncachedInputTokens
665
+ + (usage.cacheReadTokens ?? 0)
666
+ + (usage.cacheWriteTokens ?? 0)
667
+ row.promptTokens += prompt
668
+ row.uncachedInputTokens += usage.uncachedInputTokens
669
+ row.cacheReadTokens += usage.cacheReadTokens ?? 0
670
+ row.cacheWriteTokens += usage.cacheWriteTokens ?? 0
671
+ row.outputTokens += usage.outputTokens
672
+ row.cost += attempt.cost
673
+ if (usage.partial === true) usagePartial = true
674
+ if (usage.cacheReadTokens !== undefined || usage.cacheWriteTokens !== undefined) row.cacheReported = true
675
+ row.chargeableAttempts += 1
676
+ row.hasBillable = true
677
+ if (attempt.priced) row.pricedAttempts += 1
678
+ else {
679
+ row.unpricedAttempts += 1
680
+ if (attempt.currentOrder === true) row.unpricedCurrentAttempts += 1
681
+ else row.unpricedHistoricalAttempts += 1
682
+ if (attempt.provider !== null && attempt.model !== null) {
683
+ const routeKey = modelKey(attempt.provider, attempt.model)
684
+ let leftover = unpricedRoutes.get(routeKey)
685
+ if (leftover === undefined) {
686
+ leftover = {
687
+ provider: attempt.provider,
688
+ model: attempt.model,
689
+ name: attempt.name ?? `${attempt.provider} / ${attempt.model}`,
690
+ attempts: 0,
691
+ promptTokens: 0,
692
+ outputTokens: 0,
693
+ cacheReadTokens: 0,
694
+ firstTime: attempt.time,
695
+ lastTime: attempt.time,
696
+ currentOrder: attempt.currentOrder === true,
697
+ }
698
+ unpricedRoutes.set(routeKey, leftover)
699
+ }
700
+ leftover.attempts += 1
701
+ leftover.promptTokens += prompt
702
+ leftover.outputTokens += usage.outputTokens
703
+ leftover.cacheReadTokens += usage.cacheReadTokens ?? 0
704
+ leftover.firstTime = Math.min(leftover.firstTime, attempt.time)
705
+ leftover.lastTime = Math.max(leftover.lastTime, attempt.time)
706
+ leftover.currentOrder = leftover.currentOrder || attempt.currentOrder === true
707
+ }
708
+ }
709
+
710
+ const key = attempt.provider !== null && attempt.model !== null
711
+ ? modelKey(attempt.provider, attempt.model)
712
+ : undefined
713
+ const aggregateKey = key ?? `unknown${KEY_SEPARATOR}${attempt.name ?? 'unknown'}`
714
+ let aggregate = byModel.get(aggregateKey)
715
+ if (aggregate === undefined) {
716
+ aggregate = {
717
+ provider: attempt.provider,
718
+ model: attempt.model,
719
+ name: attempt.name ?? '未知模型',
720
+ cost: 0,
721
+ promptTokens: 0,
722
+ outputTokens: 0,
723
+ cacheReadTokens: 0,
724
+ cacheReported: false,
725
+ attempts: 0,
726
+ pricedAttempts: 0,
727
+ unpricedAttempts: 0,
728
+ isDeepSeek: attempt.deepseek === true,
729
+ }
730
+ byModel.set(aggregateKey, aggregate)
731
+ }
732
+ aggregate.cost += attempt.cost
733
+ aggregate.promptTokens += prompt
734
+ aggregate.outputTokens += usage.outputTokens
735
+ aggregate.cacheReadTokens += usage.cacheReadTokens ?? 0
736
+ aggregate.cacheReported = aggregate.cacheReported || usage.cacheReadTokens !== undefined || usage.cacheWriteTokens !== undefined
737
+ aggregate.attempts += 1
738
+ if (attempt.priced) aggregate.pricedAttempts += 1
739
+ else aggregate.unpricedAttempts += 1
740
+ }
741
+ row.cacheHit = row.cacheReported && row.promptTokens > 0 ? row.cacheReadTokens / row.promptTokens : null
742
+ row.partial = row.unpricedAttempts > 0 || usagePartial
743
+ row.partialCurrent = row.unpricedCurrentAttempts > 0
744
+ row.costKnown = row.attempts > 0 && row.unpricedAttempts === 0
745
+ turnRows.push(row)
746
+
747
+ totals.cost += row.cost
748
+ totals.promptTokens += row.promptTokens
749
+ totals.uncachedInputTokens += row.uncachedInputTokens
750
+ totals.cacheReadTokens += row.cacheReadTokens
751
+ totals.cacheWriteTokens += row.cacheWriteTokens
752
+ totals.outputTokens += row.outputTokens
753
+ totals.attempts += row.attempts
754
+ totals.chargeableAttempts += row.chargeableAttempts
755
+ totals.pricedAttempts += row.pricedAttempts
756
+ totals.unpricedAttempts += row.unpricedAttempts
757
+ totals.unpricedCurrentAttempts += row.unpricedCurrentAttempts
758
+ totals.unpricedHistoricalAttempts += row.unpricedHistoricalAttempts
759
+ totals.cacheReported = totals.cacheReported || row.cacheReported
760
+ totals.hasBillable = totals.hasBillable || row.hasBillable
761
+ }
762
+
763
+ totals.cacheHit = totals.cacheReported && totals.promptTokens > 0 ? totals.cacheReadTokens / totals.promptTokens : null
764
+ totals.partial = totals.unpricedAttempts > 0 || turnRows.some((row) => row.partial === true)
765
+ totals.partialCurrent = totals.unpricedCurrentAttempts > 0
766
+ totals.costKnown = totals.attempts > 0 && totals.unpricedAttempts === 0
767
+
768
+ return {
769
+ turns: turnRows,
770
+ totals,
771
+ byModel: [...byModel.values()].sort((left, right) => left.cost - right.cost || left.name.localeCompare(right.name)),
772
+ unpricedRoutes: [...unpricedRoutes.values()].sort((left, right) => right.attempts - left.attempts || left.name.localeCompare(right.name)),
773
+ lastTurn: turnRows.at(-1) ?? null,
774
+ }
775
+ }
776
+
777
+ /** Price one normalized attempt using one price set. */
778
+ function estimateAttemptCost(usage, prices) {
779
+ const hit = (usage.cacheReadTokens ?? 0) / 1_000_000 * prices.cacheHit
780
+ const miss = ((usage.uncachedInputTokens ?? 0) + (usage.cacheWriteTokens ?? 0)) / 1_000_000 * prices.cacheMiss
781
+ const output = (usage.outputTokens ?? 0) / 1_000_000 * prices.output
782
+ const total = hit + miss + output
783
+ return Number.isFinite(total) ? Math.round(total * 1e10) / 1e10 : 0
784
+ }
785
+
786
+ /** Load and normalize the durable state. A corrupt file degrades to defaults. */
787
+ async function loadState(ctx) {
788
+ const path = statePath()
789
+ await removeStaleTemps(dirname(path), path)
790
+ try {
791
+ const text = await readFile(path, 'utf8')
792
+ const raw = JSON.parse(text)
793
+ const state = normalizeState(raw)
794
+ if (isStateTooNew(raw)) {
795
+ // Hand back the readable subset but refuse every write, so an older plugin
796
+ // never clobbers a file it does not understand.
797
+ return { ...state, readOnly: true }
798
+ }
799
+ return state
800
+ } catch (error) {
801
+ if (error?.code !== 'ENOENT') ctx.logger?.warn?.(`[model-billing] ignoring unreadable state: ${messageOf(error)}`)
802
+ return normalizeState(undefined)
803
+ }
804
+ }
805
+
806
+ /** True when the document was written by a newer plugin than this one. */
807
+ function isStateTooNew(raw) {
808
+ return typeof raw?.version === 'number' && Number.isFinite(raw.version) && raw.version > STATE_VERSION
809
+ }
810
+
811
+ /**
812
+ * Delete temp files left behind by an older plugin version. The current writer
813
+ * uses a fixed `${path}.tmp` name that is always replaced, so anything else in
814
+ * the directory is debris from the old pid+timestamp naming scheme.
815
+ */
816
+ async function removeStaleTemps(directory, path) {
817
+ let entries
818
+ try {
819
+ entries = await readdir(directory)
820
+ } catch {
821
+ return
822
+ }
823
+ for (const entry of entries) {
824
+ if (!entry.startsWith(`${basename(path)}.tmp-`)) continue
825
+ await unlink(join(directory, entry)).catch(() => {})
826
+ }
827
+ }
828
+
829
+ /** Validate persisted turn-tail display settings, keeping unknown keys out. */
830
+ function normalizeDisplay(raw) {
831
+ const source = raw !== null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}
832
+ const display = { ...DISPLAY_DEFAULTS }
833
+ for (const key of DISPLAY_FLAGS) {
834
+ if (typeof source[key] === 'boolean') display[key] = source[key]
835
+ }
836
+ if (DISPLAY_FONT_SIZES.has(source.fontSize)) display.fontSize = source.fontSize
837
+ if (DISPLAY_DENSITIES.has(source.density)) display.density = source.density
838
+ if (DISPLAY_FONT_WEIGHTS.has(source.fontWeight)) display.fontWeight = source.fontWeight
839
+ return display
840
+ }
841
+
842
+ /** Validate a persisted state document into the current shape. */
843
+ function normalizeState(raw) {
844
+ const state = {
845
+ version: STATE_VERSION,
846
+ order: [],
847
+ pricing: {},
848
+ display: normalizeDisplay(raw?.display),
849
+ savedAt: typeof raw?.savedAt === 'number' && Number.isFinite(raw.savedAt) ? raw.savedAt : 0,
850
+ }
851
+ const seen = new Set()
852
+ if (Array.isArray(raw?.order)) {
853
+ for (const entry of raw.order) {
854
+ if (state.order.length >= MAX_MODELS) break
855
+ if (!validRoute(entry?.provider, entry?.model)) continue
856
+ const key = modelKey(entry.provider, entry.model)
857
+ if (seen.has(key)) continue
858
+ seen.add(key)
859
+ state.order.push({ provider: entry.provider, model: entry.model })
860
+ }
861
+ }
862
+ if (raw?.pricing !== null && typeof raw?.pricing === 'object' && !Array.isArray(raw.pricing)) {
863
+ let count = 0
864
+ for (const [key, value] of Object.entries(raw.pricing)) {
865
+ if (count >= MAX_STORED_PRICES) break
866
+ if (key.includes(KEY_SEPARATOR)) {
867
+ state.pricing[key] = publicPricing(value)
868
+ count += 1
869
+ }
870
+ }
871
+ }
872
+ return state
873
+ }
874
+
875
+ /** Persist one normalized state document atomically. */
876
+ async function writeState(state) {
877
+ const path = statePath()
878
+ await mkdir(dirname(path), { recursive: true })
879
+ const temporary = `${path}.tmp`
880
+ await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
881
+ try {
882
+ await rename(temporary, path)
883
+ } catch (error) {
884
+ // A failed swap must not leave the fixed temp name behind to block the next write.
885
+ await unlink(temporary).catch(() => {})
886
+ throw error
887
+ }
888
+ }
889
+
890
+ /** Validate a route pair used as a map key. */
891
+ function validRoute(provider, model) {
892
+ return typeof provider === 'string'
893
+ && typeof model === 'string'
894
+ && provider.trim() !== ''
895
+ && model.trim() !== ''
896
+ && provider.length <= 200
897
+ && model.length <= 200
898
+ && !/[\u0000-\u001f\u007f]/.test(provider)
899
+ && !/[\u0000-\u001f\u007f]/.test(model)
900
+ }
901
+
902
+ /** Merge one save request into the durable state and return the new envelope. */
903
+ async function saveOrderAndPrices(ctx, body) {
904
+ const modelsSupplied = Array.isArray(body?.models)
905
+ const archiveSupplied = Array.isArray(body?.pricingArchive)
906
+ if (!modelsSupplied && !archiveSupplied) {
907
+ throw new BillingRefusal(400, 'bad-request', 'models or pricingArchive must be an array')
908
+ }
909
+ const models = modelsSupplied ? body.models : null
910
+ if (models !== null && models.length > MAX_MODELS) {
911
+ throw new BillingRefusal(400, 'bad-request', `models must be an array of at most ${MAX_MODELS} entries`)
912
+ }
913
+ if (archiveSupplied && body.pricingArchive.length > MAX_STORED_PRICES) {
914
+ throw new BillingRefusal(400, 'bad-request', `pricingArchive must be at most ${MAX_STORED_PRICES} entries`)
915
+ }
916
+ if (body?.display !== undefined && (body.display === null || typeof body.display !== 'object' || Array.isArray(body.display))) {
917
+ throw new BillingRefusal(400, 'bad-request', 'display must be an object')
918
+ }
919
+ // Validate the payload before queueing so a bad request never blocks good ones.
920
+ for (const entry of models ?? []) {
921
+ if (!validRoute(entry?.provider, entry?.model)) {
922
+ throw new BillingRefusal(400, 'bad-request', 'every model needs a non-empty provider and model id')
923
+ }
924
+ }
925
+ for (const entry of body?.pricingArchive ?? []) {
926
+ if (!validRoute(entry?.provider, entry?.model)) {
927
+ throw new BillingRefusal(400, 'bad-request', 'every archived price needs a non-empty provider and model id')
928
+ }
929
+ }
930
+ // Read, merge and write as one queued unit. Doing the read outside the queue
931
+ // lets two quick saves both start from the same stale snapshot, so the later
932
+ // one silently drops the earlier one's prices.
933
+ return enqueueWrite(async () => {
934
+ const state = await loadState(ctx)
935
+ if (state.readOnly === true) {
936
+ throw new BillingRefusal(409, 'state-too-new', 'this state file was written by a newer version of the plugin; upgrade before saving')
937
+ }
938
+ const display = body?.display === undefined ? state.display : normalizeDisplay(body.display)
939
+ const order = []
940
+ const seen = new Set()
941
+ const pricing = { ...state.pricing }
942
+ if (models === null) {
943
+ // An archive-only save must not disturb the configured order.
944
+ for (const entry of state.order) {
945
+ order.push({ provider: entry.provider, model: entry.model })
946
+ seen.add(modelKey(entry.provider, entry.model))
947
+ }
948
+ } else {
949
+ for (const entry of models) {
950
+ const provider = entry.provider.trim()
951
+ const model = entry.model.trim()
952
+ const key = modelKey(provider, model)
953
+ if (seen.has(key)) throw new BillingRefusal(400, 'bad-request', `duplicate model: ${provider}/${model}`)
954
+ seen.add(key)
955
+ order.push({ provider, model })
956
+ pricing[key] = publicPricing(entry.pricing)
957
+ }
958
+ }
959
+ if (archiveSupplied) {
960
+ for (const entry of body.pricingArchive) {
961
+ const provider = entry.provider.trim()
962
+ const model = entry.model.trim()
963
+ const key = modelKey(provider, model)
964
+ if (seen.has(key)) {
965
+ throw new BillingRefusal(400, 'bad-request', `archived price targets a configured model: ${provider}/${model}`)
966
+ }
967
+ const archived = publicPricing(entry.pricing)
968
+ // Three blank fields mean "remove this price-book entry".
969
+ if (PRICE_FIELDS.every((field) => archived[field] === null)) delete pricing[key]
970
+ else pricing[key] = archived
971
+ }
972
+ }
973
+ const capped = capPricing(pricing, order, ctx)
974
+ const next = {
975
+ version: STATE_VERSION,
976
+ order,
977
+ pricing: capped,
978
+ display,
979
+ savedAt: Date.now(),
980
+ }
981
+ await writeState(next)
982
+ // A save can accompany a newly discovered provider/model, so drop the
983
+ // catalog cache so the next catalog request reflects the fresh state.
984
+ invalidateCatalogCache()
985
+ return stateEnvelope(next)
986
+ })
987
+ }
988
+ /**
989
+ * Trim stored prices to the limit, keeping the routes the user still has in
990
+ * `order` first. Truncating by raw insertion order instead drops exactly the
991
+ * newest prices, which is the opposite of what a user expects.
992
+ */
993
+ function capPricing(pricing, order, ctx) {
994
+ const keys = Object.keys(pricing)
995
+ if (keys.length <= MAX_STORED_PRICES) return pricing
996
+ const orderedKeys = new Set(order.map((entry) => modelKey(entry.provider, entry.model)))
997
+ const kept = []
998
+ for (const key of keys) {
999
+ if (orderedKeys.has(key)) kept.push(key)
1000
+ }
1001
+ for (const key of keys) {
1002
+ if (kept.length >= MAX_STORED_PRICES) break
1003
+ if (!orderedKeys.has(key)) kept.push(key)
1004
+ }
1005
+ const trimmed = {}
1006
+ for (const key of kept.slice(0, MAX_STORED_PRICES)) trimmed[key] = pricing[key]
1007
+ const dropped = keys.length - Object.keys(trimmed).length
1008
+ if (dropped > 0) {
1009
+ ctx.logger?.warn?.(`[model-billing] dropped ${dropped} price entries to stay under the ${MAX_STORED_PRICES} entry limit`)
1010
+ }
1011
+ return trimmed
1012
+ }
1013
+
1014
+ /**
1015
+ * Serialize state work so two quick saves cannot interleave. The whole
1016
+ * read-merge-write runs inside the chain; the previous chain's failures are
1017
+ * swallowed with `.then(operation, operation)` so one bad save never wedges the
1018
+ * next one.
1019
+ */
1020
+ let writeChain = Promise.resolve()
1021
+ function enqueueWrite(operation) {
1022
+ const run = writeChain.then(operation, operation)
1023
+ writeChain = run.catch(() => {})
1024
+ return run
1025
+ }
1026
+
1027
+ /**
1028
+ * Register the catalog, usage and save routes.
1029
+ * @param {object} ctx Host Cordis context.
1030
+ */
1031
+ export function apply(ctx) {
1032
+ const onCatalog = (request, response) => {
1033
+ void serveCatalog(ctx, request, response).catch((error) => fail(ctx, response, error))
1034
+ }
1035
+ const onUsage = (request, response) => {
1036
+ void serveUsage(ctx, request, response).catch((error) => fail(ctx, response, error))
1037
+ }
1038
+ const onSave = (request, response) => {
1039
+ void serveSave(ctx, request, response).catch((error) => fail(ctx, response, error))
1040
+ }
1041
+ const onSelect = (request, response) => {
1042
+ void serveSelect(ctx, request, response).catch((error) => fail(ctx, response, error))
1043
+ }
1044
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: CATALOG_PATH, handler: onCatalog }), 'model-billing: catalog route')
1045
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: USAGE_PATH, handler: onUsage }), 'model-billing: usage route')
1046
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: SAVE_PATH, handler: onSave }), 'model-billing: save route')
1047
+ ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: SELECT_PATH, handler: onSelect }), 'model-billing: select route')
1048
+ ctx.logger?.info?.(`[model-billing] routes mounted at ${CATALOG_PATH}, ${USAGE_PATH}, ${SAVE_PATH}, ${SELECT_PATH}`)
1049
+ }
1050
+
1051
+ async function serveCatalog(ctx, request, response) {
1052
+ if (request.method !== 'GET') return sendJson(response, 405, { ok: false, code: 'method-not-allowed', message: 'only GET is accepted' })
1053
+ if (!sameOrigin(request)) return sendJson(response, 403, { ok: false, code: 'untrusted-origin', message: 'the request did not come from this Harness page' })
1054
+ sendJson(response, 200, await buildCatalog(ctx, { refresh: wantsRefresh(request) }))
1055
+ }
1056
+
1057
+ /** `?refresh=1` forces the catalog to hit the adapters again instead of the cache. */
1058
+ function wantsRefresh(request) {
1059
+ try {
1060
+ return (new URL(request.url ?? '/', 'http://127.0.0.1').searchParams.get('refresh') ?? '') === '1'
1061
+ } catch {
1062
+ return false
1063
+ }
1064
+ }
1065
+
1066
+ const USAGE_CACHE_LIMIT = 50
1067
+ const usageCache = new Map()
1068
+
1069
+ /** Cache one session's folded usage until its event tail or pricing changes. */
1070
+ function usageCacheKey(sessionId, lastSeq, savedAt) {
1071
+ return `${sessionId}\u0000${lastSeq ?? 'none'}\u0000${savedAt ?? 'none'}`
1072
+ }
1073
+
1074
+ function cachedUsage(sessionId, lastSeq, savedAt, fold) {
1075
+ const key = usageCacheKey(sessionId, lastSeq, savedAt)
1076
+ const hit = usageCache.get(key)
1077
+ if (hit !== undefined) {
1078
+ usageCache.delete(key)
1079
+ usageCache.set(key, hit)
1080
+ return hit
1081
+ }
1082
+ const value = fold()
1083
+ usageCache.set(key, value)
1084
+ while (usageCache.size > USAGE_CACHE_LIMIT) {
1085
+ usageCache.delete(usageCache.keys().next().value)
1086
+ }
1087
+ return value
1088
+ }
1089
+
1090
+ async function serveUsage(ctx, request, response) {
1091
+ if (request.method !== 'GET') return sendJson(response, 405, { ok: false, code: 'method-not-allowed', message: 'only GET is accepted' })
1092
+ if (!sameOrigin(request)) return sendJson(response, 403, { ok: false, code: 'untrusted-origin', message: 'the request did not come from this Harness page' })
1093
+ let sessionId = ''
1094
+ try {
1095
+ sessionId = (new URL(request.url ?? '/', 'http://127.0.0.1').searchParams.get('sessionId') ?? '').trim()
1096
+ } catch {
1097
+ return sendJson(response, 400, { ok: false, code: 'bad-request', message: 'sessionId is missing or malformed' })
1098
+ }
1099
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(sessionId)) {
1100
+ return sendJson(response, 400, { ok: false, code: 'bad-request', message: 'sessionId is missing or malformed' })
1101
+ }
1102
+ const sessions = ctx.get('sessions')
1103
+ const session = sessions?.get?.(sessionId)
1104
+ if (session === undefined) {
1105
+ return sendJson(response, 404, { ok: false, code: 'session-not-found', message: 'this Session is not loaded in the Host' })
1106
+ }
1107
+ const state = await loadState(ctx)
1108
+ const events = session.ownEvents()
1109
+ const lastSeq = Array.isArray(events) && events.length > 0 ? events[events.length - 1]?.seq : undefined
1110
+ const usage = cachedUsage(sessionId, lastSeq, state.savedAt, () => {
1111
+ const pricingByKey = new Map()
1112
+ for (const [key, value] of Object.entries(state.pricing)) pricingByKey.set(key, publicPricing(value))
1113
+ const orderKeys = new Set(state.order.map((entry) => modelKey(entry.provider, entry.model)))
1114
+ return computeSessionUsage(events, pricingByKey, new Map(), orderKeys)
1115
+ })
1116
+ sendJson(response, 200, {
1117
+ ok: true,
1118
+ sessionId,
1119
+ savedAt: state.savedAt,
1120
+ stateVersion: STATE_VERSION,
1121
+ ...usage,
1122
+ })
1123
+ }
1124
+
1125
+ async function serveSave(ctx, request, response) {
1126
+ if (request.method !== 'POST') return sendJson(response, 405, { ok: false, code: 'method-not-allowed', message: 'only POST is accepted' })
1127
+ if (!sameOrigin(request)) return sendJson(response, 403, { ok: false, code: 'untrusted-origin', message: 'the request did not come from this Harness page' })
1128
+ let body
1129
+ try {
1130
+ body = JSON.parse(await readBody(request))
1131
+ } catch (error) {
1132
+ return sendJson(response, error instanceof BillingRefusal ? error.status : 400, {
1133
+ ok: false,
1134
+ code: error instanceof BillingRefusal ? error.code : 'bad-request',
1135
+ message: messageOf(error),
1136
+ })
1137
+ }
1138
+ const envelope = await saveOrderAndPrices(ctx, body)
1139
+ sendJson(response, 200, { ok: true, ...envelope })
1140
+ }
1141
+
1142
+ /** Select a model for one Session through the official session controller. */
1143
+ async function serveSelect(ctx, request, response) {
1144
+ if (request.method !== 'POST') return sendJson(response, 405, { ok: false, code: 'method-not-allowed', message: 'only POST is accepted' })
1145
+ if (!sameOrigin(request)) return sendJson(response, 403, { ok: false, code: 'untrusted-origin', message: 'the request did not come from this Harness page' })
1146
+ let body
1147
+ try {
1148
+ body = JSON.parse(await readBody(request))
1149
+ } catch (error) {
1150
+ return sendJson(response, error instanceof BillingRefusal ? error.status : 400, {
1151
+ ok: false,
1152
+ code: error instanceof BillingRefusal ? error.code : 'bad-request',
1153
+ message: messageOf(error),
1154
+ })
1155
+ }
1156
+ const sessionId = typeof body?.sessionId === 'string' ? body.sessionId.trim() : ''
1157
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,199}$/.test(sessionId)) {
1158
+ return sendJson(response, 400, { ok: false, code: 'bad-request', message: 'sessionId is missing or malformed' })
1159
+ }
1160
+ if (!validRoute(body?.provider, body?.model)) {
1161
+ return sendJson(response, 400, { ok: false, code: 'bad-request', message: 'provider and model are required' })
1162
+ }
1163
+ const controller = ctx.get('sessionController')
1164
+ if (controller === undefined) {
1165
+ return sendJson(response, 503, { ok: false, code: 'unavailable', message: 'the session controller is not mounted' })
1166
+ }
1167
+ const reasoningEffort = typeof body.reasoningEffort === 'string' && body.reasoningEffort !== ''
1168
+ ? body.reasoningEffort
1169
+ : undefined
1170
+ try {
1171
+ const selection = await controller.selectModel({
1172
+ sessionId,
1173
+ provider: body.provider.trim(),
1174
+ model: body.model.trim(),
1175
+ ...reasoningEffort === undefined ? {} : { reasoningEffort },
1176
+ })
1177
+ sendJson(response, 200, { ok: true, selection })
1178
+ } catch (error) {
1179
+ sendJson(response, error instanceof BillingRefusal ? error.status : 400, {
1180
+ ok: false,
1181
+ code: error instanceof BillingRefusal ? error.code : 'select-failed',
1182
+ message: messageOf(error),
1183
+ })
1184
+ }
1185
+ }
1186
+
1187
+ function fail(ctx, response, error) {
1188
+ const refusal = error instanceof BillingRefusal
1189
+ ? error
1190
+ : new BillingRefusal(500, 'internal', messageOf(error))
1191
+ ctx.logger?.warn?.(`[model-billing] ${refusal.code}: ${refusal.message}`)
1192
+ sendJson(response, refusal.status, { ok: false, code: refusal.code, message: refusal.message })
1193
+ }
1194
+
1195
+ function sendJson(response, status, value) {
1196
+ if (response.headersSent) return
1197
+ response.writeHead(status, JSON_HEADERS)
1198
+ response.end(JSON.stringify(value))
1199
+ }
1200
+
1201
+ /** Whether the request came from this Harness page rather than another site. */
1202
+ function sameOrigin(request) {
1203
+ const host = request.headers.host
1204
+ if (host !== undefined && !trustedAuthority(request, host)) return false
1205
+ if (request.headers['sec-fetch-site'] === 'cross-site') return false
1206
+ const origin = request.headers.origin
1207
+ if (origin === undefined) return true
1208
+ try {
1209
+ return new URL(origin).host === host
1210
+ } catch {
1211
+ return false
1212
+ }
1213
+ }
1214
+
1215
+ function loopbackAuthority(host) {
1216
+ const lower = host.toLowerCase()
1217
+ const name = lower.startsWith('[') ? lower.slice(0, lower.indexOf(']') + 1) : lower.split(':')[0]
1218
+ return name === '127.0.0.1' || name === 'localhost' || name === '[::1]'
1219
+ }
1220
+
1221
+ /**
1222
+ * Loopback stays the default. A request whose Host header also matches the address
1223
+ * this socket is actually listening on is trusted, so a reverse proxy or a LAN
1224
+ * address works without letting a forged Host header through.
1225
+ */
1226
+ function trustedAuthority(request, host) {
1227
+ if (loopbackAuthority(host)) return true
1228
+ const socket = request.socket
1229
+ const address = socket?.localAddress
1230
+ const port = socket?.localPort
1231
+ if (typeof address !== 'string' || address.length === 0) return false
1232
+ const local = authorityOf(address, port)
1233
+ return local !== null && host.toLowerCase() === local
1234
+ }
1235
+
1236
+ /** Format a socket address the way an HTTP Host header spells it. */
1237
+ function authorityOf(address, port) {
1238
+ const name = address.includes(':') && !address.startsWith('[') ? `[${address}]` : address.toLowerCase()
1239
+ return typeof port === 'number' && port > 0 ? `${name}:${port}` : name
1240
+ }
1241
+
1242
+ /** Read a size-capped JSON request body. */
1243
+ async function readBody(request) {
1244
+ let size = 0
1245
+ const chunks = []
1246
+ for await (const chunk of request) {
1247
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
1248
+ size += buffer.length
1249
+ if (size > MAX_BODY_BYTES) {
1250
+ throw new BillingRefusal(413, 'body-too-large', `the request body exceeds ${MAX_BODY_BYTES} bytes`)
1251
+ }
1252
+ chunks.push(buffer)
1253
+ }
1254
+ return Buffer.concat(chunks).toString('utf8')
1255
+ }
1256
+
1257
+ function messageOf(error) {
1258
+ return error instanceof Error ? error.message : String(error)
1259
+ }
1260
+
1261
+ /** Pure helpers exported for the Host test run. */
1262
+ export const __test = {
1263
+ applySavedOrder,
1264
+ authorityOf,
1265
+ builtinRatesFor,
1266
+ buildCatalog,
1267
+ catalogPricing,
1268
+ computeSessionUsage,
1269
+ deepseekBuiltinPricing,
1270
+ deepseekPeakAt,
1271
+ deepseekPricingAt,
1272
+ defaultPricing,
1273
+ estimateAttemptCost,
1274
+ isDeepSeekRoute,
1275
+ isOfficialDeepSeekProvider,
1276
+ isStateTooNew,
1277
+ loadState,
1278
+ modelKey,
1279
+ normalizeDisplay,
1280
+ normalizeState,
1281
+ normalizeUsage,
1282
+ priceSetComplete,
1283
+ publicPricing,
1284
+ resolveRoutePricing,
1285
+ saveOrderAndPrices,
1286
+ statePath,
1287
+ trustedAuthority,
1288
+ validRoute,
1289
+ }