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