dsh-superinput 0.0.0-stage → 0.7.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.
@@ -0,0 +1,1346 @@
1
+ /* 由 scripts/build-suite.mjs 从 dsh-cost/index.js 机械生成;勿手改,重跑脚本再生成。 */
2
+ /**
3
+ * dsh-cost — DeepSeek Harness (dsh) 会话费用统计插件
4
+ *
5
+ * 借鉴 Han-1413141/dsh-cost-meter(v1.9.39)的核心数据模式,MVP 落地:
6
+ * - 主通道 ctx.on('llm/stream', listener, { global: true }):llm 服务官方
7
+ * 瀑布事件,链尾旁路观察逐 chunk 透传,捕获 usage 块(五桶
8
+ * input/output/cacheRead/cacheWrite/reasoning,宿主的 input 与
9
+ * cacheRead/cacheWrite 是互斥桶),流结束后按 DeepSeek 官方价格入账。
10
+ * - 计费数学与峰谷规则照抄 cost-meter lib/pricing.js:现行价(2026-09-10
11
+ * 北京时间中午后)flash 谷 $0.003/$0.15/$0.60、pro 谷 $0.022/$0.66/$1.98,
12
+ * 峰时 ×2;峰窗 UTC [01,04)∪[06,10)(北京 09-12 / 14-18 点),北京周六
13
+ * 日与法定节假日全天谷价;历史流量按当时档位(rateHistory / legacyBase)。
14
+ * - 账本 ~/.dsh/storages/dsh-cost/ledger.json:按日聚合(总/按模型/按会话),
15
+ * 金额恒为美元存储(与展示币种解耦,cost-meter 同款),临时文件原子
16
+ * 重命名 + 防抖写盘。
17
+ * - HTTP 端点 GET /api/dsh-cost/state|prices 供客户端渲染。
18
+ * - 异步局部存储(ALS)深度标记防包装路由插件嵌套重复入账(cost-meter
19
+ * 同款;本插件自己从不发起 llm 调用)。
20
+ *
21
+ * 已核对的 dsh API(对照 dsh-reference 与 cost-meter 源码):
22
+ * - ctx.on('llm/stream', (options, next) => AsyncIterable, { global: true })
23
+ * 监听器契约:必须调用 next() 推进瀑布并返回(包装的)流;嵌套时原样
24
+ * 返回 next() 的下游
25
+ * - GenerateOptions:{ sessionId?, model, provider, messages, ... }
26
+ * - StreamChunk:{ type:'usage', usage:{inputTokens,outputTokens,
27
+ * cacheReadTokens,cacheWriteTokens,reasoningTokens} }(usage 位于 finish 前)
28
+ * - webServer.register({ kind:'prefix', path, handler }, label)
29
+ */
30
+
31
+ import Schema from '@deepseek-ai/schemastery'
32
+ import { appendFile, mkdir, readFile, readdir, rename, stat, unlink, writeFile } from 'node:fs/promises'
33
+ import { closeSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, utimesSync, writeSync, writeFileSync } from 'node:fs'
34
+ import { homedir } from 'node:os'
35
+ import { dirname, join } from 'node:path'
36
+ import { AsyncLocalStorage } from 'node:async_hooks'
37
+
38
+ const name = 'cost'
39
+
40
+ const DIAG_LOG = join(homedir(), '.dsh', 'cost-debug.log')
41
+ const STORE_DIR = join(homedir(), '.dsh', 'storages', 'dsh-cost')
42
+ const LEDGER_PATH = join(STORE_DIR, 'ledger.json')
43
+ const JOURNAL_PATH = join(STORE_DIR, 'journal.jsonl')
44
+
45
+ const report = async (line) => {
46
+ try {
47
+ await mkdir(dirname(DIAG_LOG), { recursive: true })
48
+ await appendFile(DIAG_LOG, `${new Date().toISOString()} ${line}\n`, 'utf8')
49
+ } catch { /* ignore */ }
50
+ }
51
+
52
+ /* ------------------------------------------------------------------ */
53
+ /* 价格表(USD / 1M tokens;照抄 cost-meter lib/pricing.js 现行档) */
54
+ /* ------------------------------------------------------------------ */
55
+
56
+ /** 峰谷时代分界(2026-08-16 16:00 UTC):此前按历史基础价,无峰谷概念。 */
57
+ const LEGACY_BASE_BOUNDARY = Date.parse('2026-08-16T16:00:00Z')
58
+ /** Flash 调价边界(2026-09-10 北京时间中午 12:00 = UTC 04:00);Pro 未调价。 */
59
+ const FLASH_PRICE_EFFECTIVE_AT = Date.parse('2026-09-10T04:00:00Z')
60
+ /** 周末全天谷价生效时刻(2026-08-23 北京时间零点)。 */
61
+ const WEEKEND_OFFPEAK_EFFECTIVE_AT = Date.parse('2026-08-22T16:00:00Z')
62
+
63
+ /** 峰时段窗口(UTC 小时,半开区间 [start,end))= 北京 09-12 / 14-18 点。 */
64
+ const PEAK_WINDOWS = [{ start: 1, end: 4 }, { start: 6, end: 10 }]
65
+ /** 国务院办公厅 2026 年安排中峰谷规则生效后的剩余公众假期(北京日历日)。 */
66
+ const PEAK_HOLIDAYS = new Set([
67
+ '2026-09-25', '2026-09-26', '2026-09-27',
68
+ '2026-10-01', '2026-10-02', '2026-10-03', '2026-10-04',
69
+ '2026-10-05', '2026-10-06', '2026-10-07',
70
+ ])
71
+
72
+ const double = (tier) => Object.fromEntries(Object.entries(tier).map(([key, value]) => [key, value * 2]))
73
+
74
+ /** 档位形状:{ legacyBase, august, offPeak, peak }(后三者带峰谷两档)。 */
75
+ const deepseekFlash = {
76
+ legacyBase: { cacheHit: 0.0028, cacheMiss: 0.14, output: 0.28 },
77
+ august: { cacheHit: 0.007, cacheMiss: 0.22, output: 0.66, peak: double({ cacheHit: 0.007, cacheMiss: 0.22, output: 0.66 }) },
78
+ offPeak: { cacheHit: 0.003, cacheMiss: 0.15, output: 0.6 },
79
+ peak: double({ cacheHit: 0.003, cacheMiss: 0.15, output: 0.6 }),
80
+ }
81
+ const deepseekPro = {
82
+ legacyBase: { cacheHit: 0.003625, cacheMiss: 0.435, output: 0.87 },
83
+ august: { cacheHit: 0.022, cacheMiss: 0.66, output: 1.98, peak: double({ cacheHit: 0.022, cacheMiss: 0.66, output: 1.98 }) },
84
+ offPeak: { cacheHit: 0.022, cacheMiss: 0.66, output: 1.98 },
85
+ peak: double({ cacheHit: 0.022, cacheMiss: 0.66, output: 1.98 }),
86
+ }
87
+ /** 已下架旧模型的唯一历史价(只可能出现在峰谷时代之前)。 */
88
+ const legacyOnlyModels = {
89
+ deepseekchat: { legacyBase: { cacheHit: 0.07, cacheMiss: 0.27, output: 1.1 } },
90
+ deepseekreasoner: { legacyBase: { cacheHit: 0.14, cacheMiss: 0.55, output: 2.19 } },
91
+ }
92
+
93
+ const normalizeModelId = (value) => String(value ?? '').toLowerCase().replace(/[._\-\s]/g, '')
94
+
95
+ function isPeakAt(atMs) {
96
+ // 北京日界 = UTC+8;1970-01-01 为周四(weekday 4)。
97
+ const day = Math.floor((atMs + 8 * 3600000) / 86400000)
98
+ const weekday = (day + 4) % 7
99
+ if (atMs >= WEEKEND_OFFPEAK_EFFECTIVE_AT && (weekday === 6 || weekday === 0)) return false
100
+ const key = new Date(day * 86400000).toISOString().slice(0, 10)
101
+ if (atMs >= WEEKEND_OFFPEAK_EFFECTIVE_AT && PEAK_HOLIDAYS.has(key)) return false
102
+ const hour = new Date(atMs).getUTCHours()
103
+ return PEAK_WINDOWS.some(({ start, end }) => hour >= start && hour < end)
104
+ }
105
+
106
+ /** 选档:历史时刻回落历史价(rateHistory 语义的简化实现)。 */
107
+ function tierFor(entry, atMs, peak) {
108
+ if (entry.legacyBase && atMs < LEGACY_BASE_BOUNDARY) return entry.legacyBase
109
+ if (entry.august && atMs < FLASH_PRICE_EFFECTIVE_AT) return entry.august
110
+ return (peak && entry.peak) || entry.offPeak || entry.peak || entry.august || entry.legacyBase || { cacheHit: 0, cacheMiss: 0, output: 0 }
111
+ }
112
+
113
+ /**
114
+ * 单次调用成本(USD)。数学照抄 cost-meter costOf:五桶互斥,
115
+ * cacheWrite 缺价按 cacheHit,reasoning 缺价按 0(DeepSeek 推理并入输出)。
116
+ */
117
+ function costOfBuckets(buckets, tier) {
118
+ const amount = (value) => { const n = Number(value); return Number.isFinite(n) && n > 0 ? n : 0 }
119
+ const cost = (amount(buckets.input) * tier.cacheMiss
120
+ + amount(buckets.output) * tier.output
121
+ + amount(buckets.cacheRead) * tier.cacheHit
122
+ + amount(buckets.cacheWrite) * (tier.cacheWrite ?? tier.cacheHit)
123
+ + amount(buckets.reasoning) * (typeof tier.reasoning === 'number' ? tier.reasoning : 0)) / 1_000_000
124
+ return Number.isFinite(cost) && cost > 0 ? cost : 0
125
+ }
126
+
127
+ /* ------------------------------------------------------------------ */
128
+ /* 用量指纹去重(cost-meter issue #70/#76 同款兜底) */
129
+ /* ------------------------------------------------------------------ */
130
+
131
+ /** 桶名 → 价格键(costOfBuckets 同款映射;cacheWrite 缺价按 cacheHit)。 */
132
+ const BUCKET_PRICE_KEYS = { input: 'cacheMiss', output: 'output', cacheRead: 'cacheHit', cacheWrite: 'cacheWrite', reasoning: 'reasoning' }
133
+
134
+ /** 一次调用的分桶计价行(cost-meter costFromSamples 同形状),供回合明细。 */
135
+ function bucketRows(buckets, tier) {
136
+ const rows = []
137
+ for (const [bucket, priceKey] of Object.entries(BUCKET_PRICE_KEYS)) {
138
+ const tokens = Number(buckets[bucket]) > 0 ? Number(buckets[bucket]) : 0
139
+ if (!tokens) continue
140
+ const rate = Number(tier[priceKey] ?? (priceKey === 'cacheWrite' ? tier.cacheHit : 0))
141
+ rows.push({ bucket, tokens, rate, cost: tokens * rate / 1_000_000 })
142
+ }
143
+ return rows
144
+ }
145
+
146
+ /**
147
+ * (model, 五桶指纹) 10s 窗互斥:包装路由插件在 ALS 标记逃逸的异步上下文里
148
+ * 发起上游调用时,同一请求的两份 usage 都会到达这里——五桶逐位相同即判重,
149
+ * 无论到达顺序只记第一次。正常独立调用五桶逐位相同的概率可忽略。
150
+ */
151
+ function createUsageDeduper(windowMs = 10_000) {
152
+ const seen = new Map()
153
+ return (model, buckets, atMs) => {
154
+ const fingerprint = `${normalizeModelId(model)}|${buckets.input}|${buckets.output}|${buckets.cacheRead}|${buckets.cacheWrite}|${buckets.reasoning}`
155
+ const last = seen.get(fingerprint)
156
+ if (last !== undefined && atMs - last < windowMs) return false
157
+ seen.set(fingerprint, atMs)
158
+ if (seen.size > 512) {
159
+ const cutoff = atMs - windowMs
160
+ for (const [key, seenAt] of seen) if (seenAt < cutoff) seen.delete(key)
161
+ }
162
+ return true
163
+ }
164
+ }
165
+
166
+ /* ------------------------------------------------------------------ */
167
+ /* 调用明细 journal(JSONL 追加;rotate 保留最近 3 卷) */
168
+ /* ------------------------------------------------------------------ */
169
+
170
+ const JOURNAL_MAX_BYTES = 8 * 1024 * 1024
171
+ const JOURNAL_KEEP = 3
172
+
173
+ function createJournal() {
174
+ let lines = 0
175
+ return {
176
+ /** 明细纯记录,写失败绝不影响聚合账本。 */
177
+ async write(record) {
178
+ try {
179
+ lines += 1
180
+ if (lines % 500 === 0) {
181
+ const info = await stat(JOURNAL_PATH).catch(() => null)
182
+ if (info && info.size > JOURNAL_MAX_BYTES) {
183
+ await rename(JOURNAL_PATH, join(STORE_DIR, `journal-${Date.now()}.jsonl`)).catch(() => {})
184
+ try {
185
+ const entries = await readdir(STORE_DIR)
186
+ const archives = entries.filter((name) => /^journal-\d+\.jsonl$/.test(name)).sort()
187
+ while (archives.length > JOURNAL_KEEP) {
188
+ await unlink(join(STORE_DIR, archives.shift())).catch(() => {})
189
+ }
190
+ } catch { /* ignore */ }
191
+ }
192
+ }
193
+ await appendFile(JOURNAL_PATH, `${JSON.stringify(record)}\n`, 'utf8')
194
+ } catch { /* ignore */ }
195
+ },
196
+ /** 读当前卷的倒序明细(归档卷不参与,最近 UI 只看当下)。 */
197
+ async recent({ sessionId = '', day = '', limit = 20 } = {}) {
198
+ let text = ''
199
+ try { text = await readFile(JOURNAL_PATH, 'utf8') } catch { return [] }
200
+ const rows = []
201
+ for (const line of text.split('\n')) {
202
+ if (!line) continue
203
+ try {
204
+ const row = JSON.parse(line)
205
+ if (sessionId && row.session !== sessionId) continue
206
+ if (day && row.day !== day) continue
207
+ rows.push(row)
208
+ } catch { /* 半行跳过 */ }
209
+ }
210
+ rows.reverse()
211
+ return rows.slice(0, Math.min(200, Math.max(1, limit)))
212
+ },
213
+ /**
214
+ * 增量读取:从字节偏移起新增的完整行(字节级,避免多字节字符错位;
215
+ * 轮转后新文件比偏移小 → 从头读新卷,天然不会重放旧卷)。
216
+ * 供锁持有者把 journal 折叠进 ledger(跨进程单写者模型)。
217
+ */
218
+ async recordsAfter(offset = 0) {
219
+ try {
220
+ const info = await stat(JOURNAL_PATH)
221
+ if (info.size === offset) return { rows: [], offset }
222
+ if (info.size < offset) offset = 0
223
+ const buf = await readFile(JOURNAL_PATH)
224
+ let start = offset
225
+ if (offset > 0) {
226
+ // 偏移已在行边界(前一字节是换行)则直接用;落在行中间才跳到下一行。
227
+ if (buf[offset - 1] !== 10) {
228
+ const nl = buf.indexOf(10, offset)
229
+ if (nl < 0) return { rows: [], offset: buf.length }
230
+ start = nl + 1
231
+ }
232
+ }
233
+ const rows = []
234
+ for (const line of buf.slice(start).toString('utf8').split('\n')) {
235
+ if (!line) continue
236
+ try { rows.push(JSON.parse(line)) } catch { /* 半行跳过 */ }
237
+ }
238
+ return { rows, offset: buf.length }
239
+ } catch { return { rows: [], offset } }
240
+ },
241
+
242
+ /**
243
+ * 回合费用:会话在 [from,to] 时间窗内的调用聚合(客户端 turnTail 槽位
244
+ * 传宿主 turn/start~turn/end 事件时间)。journal 只保当前卷——轮转掉的
245
+ * 旧回合查不到,金额对 UI 恒标「≈」估算口径。
246
+ */ async range({ sessionId = '', from = 0, to = Number.MAX_SAFE_INTEGER } = {}) {
247
+ let text = ''
248
+ try { text = await readFile(JOURNAL_PATH, 'utf8') } catch { return { found: false, cost: 0, rows: [], calls: [] } }
249
+ const calls = []
250
+ const parts = new Map()
251
+ for (const line of text.split('\n')) {
252
+ if (!line) continue
253
+ let row
254
+ try { row = JSON.parse(line) } catch { continue }
255
+ if (row.session !== sessionId || !(row.at >= from && row.at <= to)) continue
256
+ calls.push({ model: row.model, provider: row.provider, at: row.at, cost: row.cost })
257
+ for (const item of Array.isArray(row.rows) ? row.rows : []) {
258
+ const key = JSON.stringify([row.provider, row.model, item.bucket])
259
+ const part = parts.get(key) ?? { provider: row.provider, model: row.model, bucket: item.bucket, tokens: 0, rate: item.rate, cost: 0 }
260
+ part.tokens += item.tokens
261
+ part.cost += item.cost
262
+ parts.set(key, part)
263
+ }
264
+ }
265
+ calls.sort((a, b) => a.at - b.at)
266
+ const rows = [...parts.values()].sort((a, b) => b.cost - a.cost)
267
+ return { found: calls.length > 0, cost: calls.reduce((sum, call) => sum + call.cost, 0), rows, calls }
268
+ },
269
+ }
270
+ }
271
+
272
+ /* ------------------------------------------------------------------ */
273
+ /* 账本 */
274
+ /* ------------------------------------------------------------------ */
275
+
276
+ const zeroBuckets = () => ({ input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 })
277
+ const localDayKey = (atMs) => {
278
+ const d = new Date(atMs)
279
+ const pad = (n) => String(n).padStart(2, '0')
280
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
281
+ }
282
+
283
+ function createLedger(retentionDays) {
284
+ let state = { version: 1, days: {}, imported: {} }
285
+ let dirty = false
286
+ let flushTimer = null
287
+ /** 最近调用明细(内存,重启清空):费用弹卡按时间倒序展示。 */
288
+ const RECENT_CALLS_MAX = 200
289
+ const recentCalls = []
290
+
291
+ const load = () => {
292
+ try {
293
+ const raw = JSON.parse(readFileSync(LEDGER_PATH, 'utf8'))
294
+ if (raw && typeof raw === 'object' && raw.days && typeof raw.days === 'object') state = raw
295
+ } catch { /* 首次运行或损坏:从空账本开始 */ }
296
+ }
297
+
298
+ const flushSync = () => {
299
+ if (!dirty) return
300
+ try {
301
+ // 保留天数裁剪:超龄日直接移除(cost-meter 同口径)。
302
+ if (Number.isFinite(retentionDays) && retentionDays > 0) {
303
+ const cutoff = localDayKey(Date.now() - retentionDays * 86400000)
304
+ for (const key of Object.keys(state.days)) if (key < cutoff) delete state.days[key]
305
+ }
306
+ mkdirSync(STORE_DIR, { recursive: true })
307
+ const tmp = `${LEDGER_PATH}.${process.pid}.tmp`
308
+ writeFileSync(tmp, JSON.stringify(state), 'utf8')
309
+ renameSync(tmp, LEDGER_PATH)
310
+ dirty = false
311
+ } catch (error) {
312
+ void report(`ledger flush failed: ${String(error?.message ?? error).slice(0, 200)}`)
313
+ }
314
+ }
315
+
316
+ const scheduleFlush = () => {
317
+ dirty = true
318
+ if (flushTimer) return
319
+ flushTimer = setTimeout(() => { flushTimer = null; flushSync() }, 800)
320
+ flushTimer.unref?.()
321
+ }
322
+
323
+ const dayOf = (atMs) => {
324
+ const key = localDayKey(atMs)
325
+ let day = state.days[key]
326
+ if (!day) {
327
+ day = { ...zeroBuckets(), calls: 0, cost: 0, byModel: {}, sessions: {} }
328
+ state.days[key] = day
329
+ }
330
+ return day
331
+ }
332
+
333
+ return {
334
+ load,
335
+ flushSync,
336
+ dispose() { if (flushTimer) { clearTimeout(flushTimer); flushTimer = null } flushSync() },
337
+ /** 回填游标:该会话日志已覆盖到的事件时刻(实时记账同样推进,幂等切割)。 */
338
+ importedAt(sessionKey) {
339
+ return state.imported?.[sessionKey] ?? 0
340
+ },
341
+ markImported(sessionKey, atMs) {
342
+ if (!state.imported) state.imported = {}
343
+ const current = state.imported[sessionKey] ?? 0
344
+ if (atMs <= current) return false
345
+ state.imported[sessionKey] = atMs
346
+ // 裁剪:只保留最近 500 个会话的游标。
347
+ const keys = Object.keys(state.imported)
348
+ if (keys.length > 500) {
349
+ for (const key of keys.slice(0, keys.length - 500)) delete state.imported[key]
350
+ }
351
+ scheduleFlush()
352
+ return true
353
+ },
354
+ /** journal 折叠进度(字节偏移,随 ledger 一起落盘 → 崩溃安全,不重不漏)。 */
355
+ journalOffset() { return state.journalOffset ?? 0 },
356
+ setJournalOffset(offset) {
357
+ if (!Number.isFinite(offset) || offset <= (state.journalOffset ?? 0)) return
358
+ state.journalOffset = offset
359
+ scheduleFlush()
360
+ },
361
+ /** 官方同步的现行价格(flash/pro 峰谷档)与汇率——覆盖内置表,历史档不动。 */
362
+ livePrices() { return state.live?.prices ?? null },
363
+ setLivePrices(tiers) {
364
+ state.live = { ...(state.live ?? {}), prices: tiers }
365
+ scheduleFlush()
366
+ },
367
+ liveExchangeRate() { return state.live?.exchangeRate ?? null },
368
+ setExchangeRate(rate, fetchedAt) {
369
+ state.live = { ...(state.live ?? {}), exchangeRate: { rate, fetchedAt } }
370
+ scheduleFlush()
371
+ },
372
+ /** 最近调用明细,时间倒序(费用弹卡用)。 */
373
+ recentCalls(limit = 50) {
374
+ return recentCalls.slice(-Math.max(1, limit)).reverse()
375
+ },
376
+ /** 累加一次模型调用的用量与费用。source 区分对话 / 增强 / 建议生成。 */
377
+ account({ sessionId, model, buckets, cost, atMs, source }) {
378
+ const day = dayOf(atMs)
379
+ const src = typeof source === 'string' && source ? source : 'chat'
380
+ const addTo = (target) => {
381
+ for (const key of Object.keys(zeroBuckets())) target[key] += buckets[key] ?? 0
382
+ target.calls += 1
383
+ target.cost += cost
384
+ }
385
+ addTo(day)
386
+ // 按来源分账,费用弹卡才能把「增强」和「对话」分开显示。
387
+ const bySource = day.bySource ?? (day.bySource = {})
388
+ const srcEntry = bySource[src] ?? (bySource[src] = { ...zeroBuckets(), calls: 0, cost: 0 })
389
+ addTo(srcEntry)
390
+ // 最近调用明细(内存环形缓冲,重启清空;历史总额仍走 days)。
391
+ recentCalls.push({
392
+ at: atMs, session: sessionId || '', model: model || '', source: src,
393
+ tokens: (buckets.input ?? 0) + (buckets.output ?? 0) + (buckets.cacheRead ?? 0) + (buckets.cacheWrite ?? 0),
394
+ input: buckets.input ?? 0, output: buckets.output ?? 0,
395
+ cacheRead: buckets.cacheRead ?? 0, cost,
396
+ })
397
+ if (recentCalls.length > RECENT_CALLS_MAX) recentCalls.splice(0, recentCalls.length - RECENT_CALLS_MAX)
398
+ if (model) {
399
+ const entry = day.byModel[model] ?? (day.byModel[model] = { ...zeroBuckets(), calls: 0, cost: 0 })
400
+ addTo(entry)
401
+ }
402
+ if (sessionId) {
403
+ const session = day.sessions[sessionId] ?? (day.sessions[sessionId] = { ...zeroBuckets(), calls: 0, cost: 0, at: 0 })
404
+ addTo(session)
405
+ const sessionBySource = session.bySource ?? (session.bySource = {})
406
+ const sessionSrc = sessionBySource[src] ?? (sessionBySource[src] = { ...zeroBuckets(), calls: 0, cost: 0 })
407
+ addTo(sessionSrc)
408
+ session.at = Math.max(session.at, atMs)
409
+ // 回填切割游标同步推进:回填只补「游标之后」的日志事件,幂等不重复。
410
+ if (!state.imported) state.imported = {}
411
+ if (atMs > (state.imported[sessionId] ?? 0)) state.imported[sessionId] = atMs
412
+ }
413
+ scheduleFlush()
414
+ },
415
+ snapshot() { return state },
416
+ }
417
+ }
418
+
419
+ /* ------------------------------------------------------------------ */
420
+ /* 历史回填(cost-meter backfill 同思路:重放会话日志的 usage 事件) */
421
+ /* ------------------------------------------------------------------ */
422
+
423
+ const SESSIONS_ROOT = join(homedir(), '.dsh', 'sessions')
424
+ /** 单日志解压上限(大会话整串解压占内存,超限跳过并留痕)。 */
425
+ const BACKFILL_MAX_LOG_BYTES = 64 * 1024 * 1024
426
+ const num = (value) => { const n = Number(value); return Number.isFinite(n) && n > 0 ? n : 0 }
427
+
428
+ /** 多帧 zstd(追加写每帧独立):按魔数切段逐帧解压,坏帧跳过。 */
429
+ async function decompressSessionLog(file) {
430
+ const info = await stat(file).catch(() => null)
431
+ if (!info || info.size > BACKFILL_MAX_LOG_BYTES) return null
432
+ if (file.endsWith('.zstd')) {
433
+ const zlib = await import('node:zlib')
434
+ if (typeof zlib.zstdDecompressSync !== 'function') return null
435
+ const buf = await readFile(file)
436
+ const idxs = [0]
437
+ for (let i = 4; i <= buf.length - 4; i += 1) {
438
+ if (buf[i] === 0x28 && buf[i + 1] === 0xB5 && buf[i + 2] === 0x2F && buf[i + 3] === 0xFD) idxs.push(i)
439
+ }
440
+ let out = ''
441
+ for (let k = 0; k < idxs.length; k += 1) {
442
+ try { out += zlib.zstdDecompressSync(buf.subarray(idxs[k], idxs[k + 1] ?? buf.length)).toString('utf8') } catch { /* 截断/坏帧跳过 */ }
443
+ }
444
+ return out
445
+ }
446
+ return readFile(file, 'utf8')
447
+ }
448
+
449
+ /** 目录内最新的会话日志(版本号最大、zstd 优先)。 */
450
+ async function pickSessionLog(dir) {
451
+ const entries = await readdir(dir).catch(() => [])
452
+ const logs = entries.filter((name) => /^session(\.v\d+)?\.jsonl(\.zstd)?$/.test(name))
453
+ if (logs.length === 0) return null
454
+ logs.sort((a, b) => {
455
+ const versionOf = (name) => Number((name.match(/\.v(\d+)\./) ?? [])[1] ?? 0)
456
+ const zstdOf = (name) => (name.endsWith('.zstd') ? 1 : 0)
457
+ return (versionOf(a) - versionOf(b)) || (zstdOf(a) - zstdOf(b))
458
+ })
459
+ return join(dir, logs[logs.length - 1])
460
+ }
461
+
462
+ /**
463
+ * 重放会话事件流 → 用量样本(cost-meter replaySessionRecords 核心子集):
464
+ * request/header 切换当前路由;usage 取自 assistant/chunk(usage 块) /
465
+ * assistant/message(data.usage) / compaction/summary,按「回合位置 + 五桶
466
+ * 指纹」去重(同一步的 chunk 与 message 双份只记一次,retry 用量不同照记);
467
+ * 只取游标之后的事件(与实时计量幂等切割)。
468
+ */
469
+ function samplesFromEvents(events, cursorAtMs) {
470
+ let provider = 'deepseek'
471
+ let model = 'default'
472
+ let sessionId = ''
473
+ const seen = new Set()
474
+ const samples = []
475
+ for (const event of events) {
476
+ if (event.type === 'request/header') {
477
+ const header = event.data?.header?.config
478
+ if (typeof header?.model === 'string' && header.model) model = header.model
479
+ if (typeof header?.provider === 'string' && header.provider) provider = header.provider
480
+ continue
481
+ }
482
+ if (event.type === 'session' && typeof event.id === 'string' && !sessionId) sessionId = event.id
483
+ let usage = null
484
+ let positionKey = ''
485
+ if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'usage' && event.data.chunk.usage != null) {
486
+ usage = event.data.chunk.usage
487
+ positionKey = `s:${num(event.data?.turn)}:${num(event.data?.step)}`
488
+ } else if (event.type === 'assistant/message' && event.data?.usage != null) {
489
+ usage = event.data.usage
490
+ positionKey = `s:${num(event.data?.turn)}:${num(event.data?.step)}`
491
+ const source = event.data?.message?.source
492
+ if (typeof source?.model === 'string' && source.model) model = source.model
493
+ if (typeof source?.provider === 'string' && source.provider) provider = source.provider
494
+ } else if (event.type === 'compaction/summary' && event.data?.usage != null) {
495
+ usage = event.data.usage
496
+ positionKey = `c:${event.seq ?? ''}`
497
+ } else {
498
+ continue
499
+ }
500
+ const atMs = Number(event.time)
501
+ if (!Number.isFinite(atMs) || atMs <= 0 || atMs <= cursorAtMs) continue
502
+ const buckets = {
503
+ input: num(usage.inputTokens),
504
+ output: num(usage.outputTokens),
505
+ cacheRead: num(usage.cacheReadTokens),
506
+ cacheWrite: num(usage.cacheWriteTokens),
507
+ reasoning: num(usage.reasoningTokens),
508
+ }
509
+ const fingerprint = `${positionKey}|${buckets.input}|${buckets.output}|${buckets.cacheRead}|${buckets.cacheWrite}|${buckets.reasoning}`
510
+ if (seen.has(fingerprint)) continue
511
+ seen.add(fingerprint)
512
+ samples.push({ provider, model, buckets, atMs })
513
+ }
514
+ return { sessionId, samples }
515
+ }
516
+
517
+ /**
518
+ * 扫描全部会话日志回填历史(仅锁持有者,boot 后延迟触发;游标幂等)。
519
+ * 0.7.0 起回填样本写 journal(与实时计量同源),由 ingest 统一折叠——
520
+ * 回填与实时对同一调用的重叠由 ingest 的指纹去重互斥,永不双计。
521
+ */
522
+ async function runBackfill(journal, ledger, priceEntryFor) {
523
+ const workspaces = await readdir(SESSIONS_ROOT, { withFileTypes: true }).catch(() => [])
524
+ let touchedSessions = 0
525
+ let importedCalls = 0
526
+ for (const workspace of workspaces) {
527
+ if (!workspace.isDirectory()) continue
528
+ const workspaceDir = join(SESSIONS_ROOT, workspace.name)
529
+ const sessionDirs = await readdir(workspaceDir, { withFileTypes: true }).catch(() => [])
530
+ for (const sessionDir of sessionDirs) {
531
+ if (!sessionDir.isDirectory()) continue
532
+ const logFile = await pickSessionLog(join(workspaceDir, sessionDir.name))
533
+ if (!logFile) continue
534
+ try {
535
+ const text = await decompressSessionLog(logFile)
536
+ if (text === null) continue
537
+ const events = []
538
+ for (const line of text.split('\n')) {
539
+ if (!line) continue
540
+ try { events.push(JSON.parse(line)) } catch { /* 半行跳过 */ }
541
+ }
542
+ const probe = samplesFromEvents(events, 0)
543
+ const sessionId = probe.sessionId
544
+ const { samples } = samplesFromEvents(events, sessionId ? ledger.importedAt(sessionId) : 0)
545
+ let maxAt = 0
546
+ for (const sample of samples) {
547
+ const entry = priceEntryFor(sample.model)
548
+ if (!entry) continue
549
+ const peak = isPeakAt(sample.atMs)
550
+ const tier = tierFor(entry, sample.atMs, peak)
551
+ const cost = costOfBuckets(sample.buckets, tier)
552
+ await journal.write({
553
+ at: sample.atMs,
554
+ day: localDayKey(sample.atMs),
555
+ session: sessionId || '',
556
+ model: sample.model,
557
+ provider: sample.provider,
558
+ ...sample.buckets,
559
+ rows: bucketRows(sample.buckets, tier),
560
+ cost,
561
+ peak,
562
+ })
563
+ maxAt = Math.max(maxAt, sample.atMs)
564
+ importedCalls += 1
565
+ }
566
+ if (sessionId && maxAt > 0 && ledger.markImported(sessionId, maxAt)) touchedSessions += 1
567
+ // 让出事件循环:几百个会话的解压不能卡住宿主启动。
568
+ await new Promise((resolve) => setImmediate(resolve))
569
+ } catch (error) {
570
+ void report(`backfill ${sessionDir.name} failed: ${String(error?.message ?? error).slice(0, 160)}`)
571
+ }
572
+ }
573
+ }
574
+ if (importedCalls > 0) void report(`backfill appended ${importedCalls} journal rows across ${touchedSessions} sessions`)
575
+ else void report('backfill done: nothing new')
576
+ }
577
+
578
+ /** 下一次峰谷状态翻转时刻(48h 内逐分钟扫描;周末/节假日边界自然覆盖)。 */
579
+ function nextPeakSwitch(now) {
580
+ const current = isPeakAt(now)
581
+ const start = Math.floor(now / 60_000) * 60_000 + 60_000
582
+ for (let ms = start; ms <= now + 48 * 3_600_000; ms += 60_000) {
583
+ if (isPeakAt(ms) !== current) return { atMs: ms, isPeak: isPeakAt(ms) }
584
+ }
585
+ return null
586
+ }
587
+
588
+ /** 官方定价页(预渲染 SSR)解析 → flash/pro 峰谷八字段(USD/1M)。 */
589
+ const OFFICIAL_PRICING_URL = 'https://api-docs.deepseek.com/quick_start/pricing/'
590
+
591
+ function parseOfficialPricing(html) {
592
+ const text = html.replace(/<script[\s\S]*?<\/script>/g, ' ').replace(/<[^>]*>/g, ' ')
593
+ .replace(/&amp;/g, '&').replace(/\s+/g, ' ')
594
+ const startIdx = text.indexOf('PRICING')
595
+ const endIdx = text.indexOf('Concurrency Limit', startIdx)
596
+ if (startIdx < 0 || endIdx < 0) throw new Error('pricing section not found')
597
+ const section = text.slice(startIdx, endIdx)
598
+ const nums = [...section.matchAll(/\$([0-9.]+)/g)].map((m) => Number(m[1]))
599
+ if (nums.length < 12) throw new Error(`pricing layout changed: expected 12 rates, got ${nums.length}`)
600
+ // 页面顺序(deepseek-flash | deepseek-v4-pro 两列):
601
+ // CACHE HIT off(a,b) peak(c,d) · CACHE MISS off(e,f) peak(g,h) · OUTPUT off(i,j) peak(k,l)
602
+ const [fhOff, phOff, fhPk, phPk, fmOff, pmOff, fmPk, pmPk, foOff, poOff, foPk, poPk] = nums
603
+ const pair = (flash, pro, label) => {
604
+ if (!(flash > 0 && pro > 0)) throw new Error(`pricing layout changed: invalid ${label}`)
605
+ return { flash, pro }
606
+ }
607
+ return {
608
+ fetchedAt: Date.now(),
609
+ currency: 'USD',
610
+ cacheHit: { offPeak: pair(fhOff, phOff, 'cacheHit.offPeak'), peak: pair(fhPk, phPk, 'cacheHit.peak') },
611
+ cacheMiss: { offPeak: pair(fmOff, pmOff, 'cacheMiss.offPeak'), peak: pair(fmPk, pmPk, 'cacheMiss.peak') },
612
+ output: { offPeak: pair(foOff, poOff, 'output.offPeak'), peak: pair(foPk, poPk, 'output.peak') },
613
+ }
614
+ }
615
+
616
+ async function fetchOfficialPricing() {
617
+ const res = await fetch(OFFICIAL_PRICING_URL, { headers: { 'user-agent': 'dsh-cost' }, signal: AbortSignal.timeout(15_000) })
618
+ if (!res.ok) throw new Error(`official pricing page HTTP ${res.status}`)
619
+ return parseOfficialPricing(await res.text())
620
+ }
621
+
622
+ /** 汇率(USD→CNY),frankfurter.dev 免钥公开端点。 */
623
+ async function fetchExchangeRate() {
624
+ const res = await fetch('https://api.frankfurter.dev/v1/latest?base=USD&symbols=CNY', { signal: AbortSignal.timeout(10_000) })
625
+ if (!res.ok) throw new Error(`frankfurter HTTP ${res.status}`)
626
+ const data = await res.json()
627
+ const rate = Number(data?.rates?.CNY)
628
+ if (!(rate > 0)) throw new Error('frankfurter returned no CNY rate')
629
+ return rate
630
+ }
631
+
632
+ /** 把官方解析结果折成 tier 形状(flash/pro 各一),并校验峰=谷×2(±1%)。 */
633
+ function tiersFromOfficial(parsed) {
634
+ const build = (family) => {
635
+ const offPeak = { cacheHit: parsed.cacheHit.offPeak[family], cacheMiss: parsed.cacheMiss.offPeak[family], output: parsed.output.offPeak[family] }
636
+ const peak = { cacheHit: parsed.cacheHit.peak[family], cacheMiss: parsed.cacheMiss.peak[family], output: parsed.output.peak[family] }
637
+ for (const key of Object.keys(offPeak)) {
638
+ if (Math.abs(peak[key] - offPeak[key] * 2) > offPeak[key] * 0.011) throw new Error(`peak != 2×offPeak for ${family}.${key} (official layout changed)`)
639
+ }
640
+ return { offPeak, peak: double(offPeak) }
641
+ }
642
+ return { fetchedAt: parsed.fetchedAt, currency: parsed.currency, flash: build('flash'), pro: build('pro') }
643
+ }
644
+
645
+ /* ------------------------------------------------------------------ */
646
+ /* 跨进程单写者锁(双进程同 boot 实锤;非持有者只 append journal) */
647
+ /* ------------------------------------------------------------------ */
648
+
649
+ const LOCK_STALE_MS = 30_000
650
+
651
+ function acquireLedgerLock(path) {
652
+ const tryWrite = () => {
653
+ try {
654
+ const fd = openSync(path, 'wx')
655
+ writeSync(fd, String(process.pid))
656
+ closeSync(fd)
657
+ return true
658
+ } catch (error) {
659
+ if (error?.code === 'EEXIST') return false
660
+ throw error
661
+ }
662
+ }
663
+ if (tryWrite()) return { owner: true }
664
+ try {
665
+ const info = statSync(path)
666
+ const holder = Number(readFileSync(path, 'utf8'))
667
+ let alive = false
668
+ if (Number.isSafeInteger(holder) && holder > 0) {
669
+ try { process.kill(holder, 0); alive = true } catch (error) { alive = error?.code === 'EPERM' }
670
+ }
671
+ if (!alive || Date.now() - info.mtimeMs > LOCK_STALE_MS) {
672
+ // 持有者已死或心跳超时:抢占(万一并发抢占,后写者败走非持有者,无害)。
673
+ try { unlinkSync(path) } catch { /* ignore */ }
674
+ return { owner: tryWrite() }
675
+ }
676
+ } catch { return { owner: tryWrite() } }
677
+ return { owner: false }
678
+ }
679
+
680
+ function touchLedgerLock(path) {
681
+ try { utimesSync(path, new Date(), new Date()) } catch { /* ignore */ }
682
+ }
683
+
684
+ function releaseLedgerLock(path) {
685
+ try {
686
+ if (Number(readFileSync(path, 'utf8')) === process.pid) unlinkSync(path)
687
+ } catch { /* ignore */ }
688
+ }
689
+
690
+ /* ------------------------------------------------------------------ */
691
+ /* 插件入口 */
692
+ /* ------------------------------------------------------------------ */
693
+ export const inject = []
694
+
695
+ /** 官方余额查询:host 白名单(安全约束——只允许官方域名,内网/环回不可能出现)。 */
696
+ const BALANCE_HOST = 'api.deepseek.com'
697
+
698
+ export const Config = Schema.object({
699
+ enabled: Schema.boolean().default(true).description('是否启用费用统计'),
700
+ /** 展示汇率(USD → CNY);金额账本恒存美元,展示时换算。 */
701
+ exchangeRate: Schema.number().default(7.2).description('展示汇率(USD→CNY)'),
702
+ /** 自定义单价覆盖:{ "模型id": { cacheHit, cacheMiss, output } }(USD/1M)。 */
703
+ customPrices: Schema.object({}).default({}).description('自定义模型单价(USD/1M tokens)'),
704
+ /** 账本保留天数。 */
705
+ retentionDays: Schema.number().default(180).description('历史保留天数'),
706
+ budgetEnabled: Schema.boolean().default(false).description('启用预算'),
707
+ budgetAmount: Schema.number().default(50).description('预算金额(CNY)'),
708
+ budgetPeriod: Schema.union(['day', 'month', 'all']).default('month').description('预算周期'),
709
+ backfillEnabled: Schema.boolean().default(true).description('启动时回填历史会话费用'),
710
+ peakAlertEnabled: Schema.boolean().default(false).description('峰谷切换前提醒'),
711
+ peakAlertLeadMinutes: Schema.number().default(2).description('峰谷切换提前量(分钟,1-30)'),
712
+ peakAlertNotify: Schema.boolean().default(false).description('同时发送系统通知'),
713
+ /** DeepSeek 开放平台 API Key(选填):配置后费用弹卡显示官方账户余额。 */
714
+ apiKey: Schema.string().default('').description('DeepSeek 开放平台 API Key(查官方余额用,可选)'),
715
+ })
716
+
717
+ export function apply(ctx, config) {
718
+ // 运行时可变副本:POST /settings 改这里,立即生效;显式改过的键持久化
719
+ // 到 storages、启动时回放(没改过的键继续跟随宿主配置)。
720
+ const live = { ...config }
721
+ // 官方余额缓存:10 分钟内直接回缓存;换 key 即失效。
722
+ let balanceCache = null
723
+ if (!live.enabled) return
724
+
725
+ // 设置持久化:只落与 config 不同的键,防抖 400ms 原子写。
726
+ const SETTINGS_KEYS = ['budgetEnabled', 'budgetAmount', 'budgetPeriod', 'peakAlertEnabled', 'peakAlertNotify', 'peakAlertLeadMinutes', 'exchangeRate', 'apiKey']
727
+ const SETTINGS_PATH = join(homedir(), '.dsh', 'storages', 'dsh-cost', 'settings.json')
728
+ try {
729
+ const saved = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))
730
+ for (const key of SETTINGS_KEYS) if (saved?.[key] !== undefined) live[key] = saved[key]
731
+ } catch { /* 无存档:全部跟随配置 */ }
732
+ let settingsTimer = null
733
+ const persistSettings = () => {
734
+ if (settingsTimer) return
735
+ settingsTimer = setTimeout(() => {
736
+ settingsTimer = null
737
+ try {
738
+ mkdirSync(dirname(SETTINGS_PATH), { recursive: true })
739
+ const out = {}
740
+ for (const key of SETTINGS_KEYS) if (live[key] !== config[key]) out[key] = live[key]
741
+ const tmp = `${SETTINGS_PATH}.tmp`
742
+ writeFileSync(tmp, JSON.stringify(out, null, 2))
743
+ renameSync(tmp, SETTINGS_PATH)
744
+ } catch { /* ignore */ }
745
+ }, 400)
746
+ }
747
+
748
+ const lockPath = join(STORE_DIR, 'ledger.lock')
749
+ const lock = acquireLedgerLock(lockPath)
750
+ void report(`server boot v0.8.2 pid=${process.pid} ledgerOwner=${lock.owner} retention=${live.retentionDays} rate=${live.exchangeRate} budget=${live.budgetEnabled ? `${live.budgetAmount}/${live.budgetPeriod}` : 'off'} backfill=${live.backfillEnabled && lock.owner ? 'on' : 'off'}`)
751
+
752
+ const ledger = createLedger(live.retentionDays)
753
+ if (lock.owner) ledger.load()
754
+ ctx.effect(() => { ledger.dispose(); if (lock.owner) releaseLedgerLock(lockPath) }, 'dsh-cost: ledger flush + lock release')
755
+
756
+ const admitUsage = createUsageDeduper()
757
+ const journal = createJournal()
758
+
759
+ /**
760
+ * 锁持有者把 journal 增量折叠进 ledger(0.7.0 单写者模型):
761
+ * 两个进程都会 boot 并各听各的 llm/stream——都只 append journal(O_APPEND
762
+ * 单行跨进程安全);只有锁持有者写 ledger.json,彻底消除整文件互踩。
763
+ * 指纹去重兜底:同一事件被两进程捕获 / 回填与实时重叠,10s 窗内互斥。
764
+ */
765
+ const ingestDedup = createUsageDeduper()
766
+ let ingestedCount = 0
767
+ let ingestBusy = false
768
+ async function ingestJournal() {
769
+ if (!lock.owner || ingestBusy) return
770
+ ingestBusy = true
771
+ try {
772
+ const { rows, offset } = await journal.recordsAfter(ledger.journalOffset())
773
+ for (const row of rows) {
774
+ if (!row || !Number.isFinite(row.at) || !Number.isFinite(row.cost) || !row.model) continue
775
+ const buckets = { input: row.input ?? 0, output: row.output ?? 0, cacheRead: row.cacheRead ?? 0, cacheWrite: row.cacheWrite ?? 0, reasoning: row.reasoning ?? 0 }
776
+ if (!ingestDedup(`${row.session ?? ''}|${row.model}`, buckets, row.at)) continue
777
+ ledger.account({ sessionId: row.session, model: row.model, buckets, cost: row.cost, atMs: row.at, source: row.source })
778
+ // +60s 余量:日志事件记的是消息完成时刻,晚于实时流发起时刻;
779
+ // 游标盖住整个调用时长,防回填重放同一条。
780
+ if (row.session) ledger.markImported(row.session, row.at + 60_000)
781
+ ingestedCount += 1
782
+ }
783
+ ledger.setJournalOffset(offset)
784
+ if (rows.length > 0) touchLedgerLock(lockPath)
785
+ } catch (error) {
786
+ void report(`ingest failed: ${String(error?.message ?? error).slice(0, 160)}`)
787
+ } finally {
788
+ ingestBusy = false
789
+ }
790
+ }
791
+
792
+ if (lock.owner) {
793
+ const ingestTimer = setInterval(() => { void ingestJournal() }, 5000)
794
+ ingestTimer.unref?.()
795
+ ctx.effect(() => () => clearInterval(ingestTimer), 'dsh-cost: journal ingest timer')
796
+ }
797
+
798
+ // 历史回填:仅锁持有者执行(boot 后 3s,避开宿主启动高峰);0.7.0 起
799
+ // 改写 journal(与实时同源),由 ingest 统一折叠 + 指纹去重,天然不重。
800
+ if (live.backfillEnabled && lock.owner) {
801
+ const backfillTimer = setTimeout(() => { void runBackfill(journal, ledger, priceEntryFor).catch((error) => { void report(`backfill crashed: ${String(error?.message ?? error).slice(0, 200)}`) }) }, 3000)
802
+ backfillTimer.unref?.()
803
+ ctx.effect(() => () => clearTimeout(backfillTimer), 'dsh-cost: backfill timer')
804
+ }
805
+
806
+ // 汇率自动刷新:仅锁持有者,启动 5s 后首刷,之后每小时。失败静默(用旧值)。
807
+ if (lock.owner) {
808
+ const refreshRate = async () => {
809
+ try {
810
+ const rate = await fetchExchangeRate()
811
+ ledger.setExchangeRate(rate, Date.now())
812
+ void report(`exchange rate auto: ${rate}`)
813
+ } catch (error) {
814
+ void report(`exchange rate auto failed: ${String(error?.message ?? error).slice(0, 120)}`)
815
+ }
816
+ }
817
+ const rateStart = setTimeout(() => { void refreshRate() }, 5000)
818
+ rateStart.unref?.()
819
+ const rateTimer = setInterval(() => { void refreshRate() }, 3_600_000)
820
+ rateTimer.unref?.()
821
+ ctx.effect(() => () => { clearTimeout(rateStart); clearInterval(rateTimer) }, 'dsh-cost: exchange rate timers')
822
+ }
823
+
824
+ /** 深度标记:存在即「已知会、不需要再入账」的 llm 流(嵌套排除)。 */
825
+ const llmDepth = new AsyncLocalStorage()
826
+
827
+ /**
828
+ * 跨插件的调用来源标记。prompt-enhancer / followup 在发起自己的
829
+ * llm.stream 前写 globalThis.__dshPluginCall,这里按 5 秒窗口读回:
830
+ * 落在窗口内算它们的调用,否则一律算对话。读一次就清,避免串到下一次。
831
+ */
832
+ const CALL_SOURCE_TTL_MS = 5000
833
+
834
+ /** 各插件自己的 prompt 特征:跨进程时 globalThis 不共享,只能看内容。 */
835
+ const SOURCE_SIGNATURES = [
836
+ { source: 'followup', needle: 'You generate follow-up suggestions' },
837
+ { source: 'enhance', needle: "You are a Prompt Engineering Expert" },
838
+ ]
839
+
840
+ function callSource(options) {
841
+ // 1) 同进程时的显式标记(prompt-enhancer / followup 调用前写入)
842
+ const marker = globalThis.__dshPluginCall
843
+ if (marker && typeof marker.at === 'number' && Date.now() - marker.at < CALL_SOURCE_TTL_MS) {
844
+ const source = typeof marker.source === 'string' && marker.source ? marker.source : 'chat'
845
+ globalThis.__dshPluginCall = undefined
846
+ return source
847
+ }
848
+ // 2) 兜底:按 prompt 特征识别,插件跑在别的进程时也能分开记
849
+ try {
850
+ const messages = options?.messages
851
+ if (messages) {
852
+ const text = JSON.stringify(messages)
853
+ for (const entry of SOURCE_SIGNATURES) {
854
+ if (text.includes(entry.needle)) return entry.source
855
+ }
856
+ }
857
+ } catch { /* 序列化失败就当普通对话 */ }
858
+ return 'chat'
859
+ }
860
+
861
+ const unmatchedModels = new Set()
862
+ let streamArmed = false
863
+ let accountCount = 0
864
+
865
+ function priceEntryFor(model) {
866
+ if (model && live.customPrices && live.customPrices[model]) return { custom: true, ...live.customPrices[model] }
867
+ const norm = normalizeModelId(model)
868
+ if (legacyOnlyModels[norm]) return legacyOnlyModels[norm]
869
+ if (norm.includes('deepseek')) {
870
+ // 官方同步的现行价优先(fetch-prices 成功后覆盖内置表;历史档不变)。
871
+ const live = ledger.livePrices()
872
+ if (live && live.currency === 'USD') {
873
+ const family = norm.includes('pro') ? 'pro' : 'flash'
874
+ const t = live[family]
875
+ return { legacyBase: (norm.includes('pro') ? deepseekPro : deepseekFlash).legacyBase, august: undefined, offPeak: t.offPeak, peak: t.peak, live: true }
876
+ }
877
+ return norm.includes('pro') ? deepseekPro : deepseekFlash
878
+ }
879
+ return null
880
+ }
881
+
882
+ ctx.on('llm/stream', (options, next) => {
883
+ // 包装路由插件在嵌套上下文里再发起的上游调用:原样透传,不重复入账。
884
+ if (llmDepth.getStore() !== undefined) return next()
885
+ const downstream = next()
886
+ if (!streamArmed) {
887
+ streamArmed = true
888
+ void report(`llm/stream first fire pid=${process.pid} — capture channel ALIVE in this process`)
889
+ }
890
+ const startedAtMs = Date.now()
891
+ const model = typeof options?.model === 'string' ? options.model : ''
892
+ const provider = typeof options?.provider === 'string' ? options.provider : ''
893
+ const sessionId = typeof options?.sessionId === 'string' ? options.sessionId : ''
894
+ let usage = null
895
+ let downstreamIter = null
896
+ let completed = false
897
+ return (async function* costObserve() {
898
+ try {
899
+ downstreamIter = downstream[Symbol.asyncIterator]()
900
+ for (;;) {
901
+ // 深度标记内拉取:下游适配器(包装路由)在消费上下文里再发起的
902
+ // ctx.llm.stream() 继承标记,内层监听器判定为嵌套、透传。
903
+ const result = await llmDepth.run(1, () => downstreamIter.next())
904
+ if (result.done) break
905
+ const chunk = result.value
906
+ // usage 块位于 finish 之前;多块时最后一块生效(cost-meter 同款)。
907
+ if (chunk?.type === 'usage' && chunk.usage != null) usage = chunk.usage
908
+ yield chunk
909
+ }
910
+ completed = true
911
+ } finally {
912
+ // 消费方提前中断也向下游传播 return(),防上游 HTTP 流悬挂。
913
+ if (!completed) {
914
+ try { await downstreamIter?.return?.() } catch { /* ignore */ }
915
+ }
916
+ try {
917
+ if (usage) {
918
+ const buckets = {
919
+ input: usage.inputTokens ?? 0,
920
+ output: usage.outputTokens ?? 0,
921
+ cacheRead: usage.cacheReadTokens ?? 0,
922
+ cacheWrite: usage.cacheWriteTokens ?? 0,
923
+ reasoning: usage.reasoningTokens ?? 0,
924
+ }
925
+ const entry = priceEntryFor(model)
926
+ if (entry) {
927
+ // 指纹去重兜底:本进程内 ALS 逃逸/同流双 usage 互斥(10s 窗)。
928
+ if (admitUsage(model, buckets, startedAtMs)) {
929
+ const peak = isPeakAt(startedAtMs)
930
+ const tier = tierFor(entry, startedAtMs, peak)
931
+ const cost = costOfBuckets(buckets, tier)
932
+ // 0.7.0 单写者模型:所有进程只 append journal(跨进程安全),
933
+ // ledger 由锁持有者的 ingest 统一折叠。
934
+ void journal.write({
935
+ at: startedAtMs,
936
+ day: localDayKey(startedAtMs),
937
+ session: sessionId || '',
938
+ model,
939
+ provider,
940
+ ...buckets,
941
+ rows: bucketRows(buckets, tier),
942
+ cost,
943
+ peak,
944
+ source: callSource(options),
945
+ })
946
+ accountCount += 1
947
+ // 采样留痕(前 10 条全记,之后 1/20),足以判定通路与发现异常。
948
+ if (accountCount <= 10 || accountCount % 20 === 0) {
949
+ void report(`journaled #${accountCount} pid=${process.pid} owner=${lock.owner ? 1 : 0} model=${model} provider=${provider} session=${sessionId ? sessionId.slice(0, 40) : '(none)'} peak=${peak ? 1 : 0} buckets=${JSON.stringify(buckets)} cost=$${cost.toFixed(6)}`)
950
+ }
951
+ } else {
952
+ void report(`dedup skip model=${model} buckets=${JSON.stringify(buckets)}`)
953
+ }
954
+ } else if (model && !unmatchedModels.has(model)) {
955
+ // 未内置价格的第三方模型按 default 档(flash 现行价)计费,
956
+ // 首见留痕提醒用户在 customPrices 里补充精确单价。
957
+ unmatchedModels.add(model)
958
+ void report(`unmatched model "${model}" billed at default tier; add customPrices entry for accuracy`)
959
+ }
960
+ }
961
+ } catch (error) {
962
+ void report(`account failed: ${String(error?.message ?? error).slice(0, 200)}`)
963
+ }
964
+ }
965
+ })()
966
+ }, { global: true })
967
+
968
+ /* -- HTTP 端点 ------------------------------------------------------ */
969
+
970
+ ctx.inject(['webServer'], (webCtx) => {
971
+ /** /state、/export 共用:锁持有者先折叠+落盘再读文件;非持有者直读
972
+ (持有者 5s ingest + 800ms 防抖,两个进程的 UI 数据都足够新鲜)。 */
973
+ async function ledgerSnapshotFresh() {
974
+ if (lock.owner) {
975
+ await ingestJournal()
976
+ ledger.flushSync()
977
+ }
978
+ try { return JSON.parse(await readFile(LEDGER_PATH, 'utf8')) } catch { return { days: {} } }
979
+ }
980
+ webCtx.effect(() => webCtx.webServer.register({
981
+ kind: 'prefix',
982
+ path: '/api/dsh-cost',
983
+ handler: async (req, res) => {
984
+ const send = (status, body) => {
985
+ res.statusCode = status
986
+ res.setHeader('content-type', 'application/json')
987
+ res.end(JSON.stringify(body))
988
+ }
989
+ const path = (req.url ?? '').split('?')[0].replace(/^\/api\/dsh-cost/, '')
990
+ if (path === '/settings' && req.method === 'GET') {
991
+ send(200, {
992
+ enabled: live.enabled,
993
+ exchangeRate: live.exchangeRate,
994
+ budgetEnabled: live.budgetEnabled,
995
+ budgetAmount: live.budgetAmount,
996
+ budgetPeriod: live.budgetPeriod,
997
+ peakAlertEnabled: live.peakAlertEnabled,
998
+ peakAlertLeadMinutes: live.peakAlertLeadMinutes,
999
+ peakAlertNotify: live.peakAlertNotify,
1000
+ retentionDays: live.retentionDays,
1001
+ })
1002
+ return
1003
+ }
1004
+ // 运行时改参数:retentionDays 与 customPrices 不在白名单——前者启动时
1005
+ // 已传给 createLedger,后者是对象结构,运行时改要么无效要么要重建缓存。
1006
+ if (path === '/settings' && req.method === 'POST') {
1007
+ let raw = ''
1008
+ for await (const chunk of req) raw += chunk
1009
+ let body
1010
+ try { body = JSON.parse(raw) } catch { send(400, { error: 'invalid-json' }); return }
1011
+ if (body.budgetEnabled !== undefined) live.budgetEnabled = body.budgetEnabled === true
1012
+ if (body.budgetPeriod === 'day' || body.budgetPeriod === 'month') live.budgetPeriod = body.budgetPeriod
1013
+ if (body.peakAlertEnabled !== undefined) live.peakAlertEnabled = body.peakAlertEnabled === true
1014
+ if (body.peakAlertNotify !== undefined) live.peakAlertNotify = body.peakAlertNotify === true
1015
+ if (body.budgetAmount !== undefined) {
1016
+ const value = Number(body.budgetAmount)
1017
+ if (Number.isFinite(value)) live.budgetAmount = Math.min(1_000_000, Math.max(0, value))
1018
+ }
1019
+ if (body.exchangeRate !== undefined) {
1020
+ const value = Number(body.exchangeRate)
1021
+ if (Number.isFinite(value) && value > 0) live.exchangeRate = Math.min(20, Math.max(1, value))
1022
+ }
1023
+ if (body.peakAlertLeadMinutes !== undefined) {
1024
+ const value = Number(body.peakAlertLeadMinutes)
1025
+ if (Number.isFinite(value)) live.peakAlertLeadMinutes = Math.min(30, Math.max(1, Math.round(value)))
1026
+ }
1027
+ if (body.apiKey !== undefined) {
1028
+ live.apiKey = String(body.apiKey ?? '').trim()
1029
+ balanceCache = null // 换 key 立即失效缓存
1030
+ }
1031
+ persistSettings()
1032
+ send(200, { ok: true })
1033
+ return
1034
+ }
1035
+
1036
+
1037
+ if (path === '/state' && req.method === 'GET') {
1038
+ const snap = await ledgerSnapshotFresh()
1039
+ const now = Date.now()
1040
+ const todayKey = localDayKey(now)
1041
+ const monthPrefix = todayKey.slice(0, 7)
1042
+ const weekStartKey = localDayKey(now - 6 * 86400000)
1043
+ let month = null
1044
+ const total = { ...zeroBuckets(), calls: 0, cost: 0 }
1045
+ const recent = []
1046
+ const sessions = []
1047
+ for (const key of Object.keys(snap.days).sort()) {
1048
+ const day = snap.days[key]
1049
+ for (const bucketKey of Object.keys(zeroBuckets())) total[bucketKey] += day[bucketKey] ?? 0
1050
+ total.calls += day.calls ?? 0
1051
+ total.cost += day.cost ?? 0
1052
+ if (key.startsWith(monthPrefix)) month = month ? mergeDay(month, day) : summarize(day)
1053
+ if (key >= weekStartKey) {
1054
+ recent.push({ date: key, calls: day.calls ?? 0, cost: day.cost ?? 0, tokens: (day.input ?? 0) + (day.output ?? 0) + (day.cacheRead ?? 0) + (day.cacheWrite ?? 0) })
1055
+ for (const [id, session] of Object.entries(day.sessions ?? {})) {
1056
+ sessions.push({ id, date: key, calls: session.calls ?? 0, cost: session.cost ?? 0, input: session.input ?? 0, output: session.output ?? 0, at: session.at ?? 0 })
1057
+ }
1058
+ }
1059
+ }
1060
+ sessions.sort((a, b) => b.at - a.at)
1061
+ // 预算:以展示币种(CNY)计,used 由对应周期的账本金额换算。
1062
+ const budgetReady = live.budgetEnabled && Number(live.budgetAmount) > 0
1063
+ const budgetUsd = budgetReady
1064
+ ? (live.budgetPeriod === 'day'
1065
+ ? (snap.days[todayKey]?.cost ?? 0)
1066
+ : live.budgetPeriod === 'month'
1067
+ ? (month?.cost ?? 0)
1068
+ : total.cost)
1069
+ : 0
1070
+ send(200, {
1071
+ serverNow: now,
1072
+ display: { currency: 'CNY', rate: ledger.liveExchangeRate()?.rate ?? live.exchangeRate, rateSource: ledger.liveExchangeRate() ? 'auto' : 'config', rateFetchedAt: ledger.liveExchangeRate()?.fetchedAt ?? null },
1073
+ peak: { isPeak: isPeakAt(now), windows: PEAK_WINDOWS, nextSwitchAtMs: nextPeakSwitch(now)?.atMs ?? null, nextIsPeak: nextPeakSwitch(now)?.isPeak ?? null },
1074
+ today: todayKey in snap.days ? summarize(snap.days[todayKey]) : null,
1075
+ month,
1076
+ total: summarize(total),
1077
+ budget: budgetReady
1078
+ ? { amount: live.budgetAmount, period: live.budgetPeriod, used: budgetUsd * live.exchangeRate, usedPct: budgetUsd * live.exchangeRate / live.budgetAmount }
1079
+ : null,
1080
+ recent,
1081
+ sessions: sessions.slice(0, 20),
1082
+ unmatched: [...unmatchedModels],
1083
+ streamAlive: streamArmed,
1084
+ alerts: {
1085
+ peakEnabled: live.peakAlertEnabled === true,
1086
+ leadMinutes: Math.min(30, Math.max(1, Math.round(live.peakAlertLeadMinutes ?? 2))),
1087
+ notify: live.peakAlertNotify === true,
1088
+ },
1089
+ })
1090
+ return
1091
+ }
1092
+
1093
+ /**
1094
+ * GET /forecast —— 月底花费预测。
1095
+ *
1096
+ * 用最近 7 个**完整**历史日的日均推算,不用今天(半天数据会把均值拉低)。
1097
+ * 样本少于 3 天时标 basis=insufficient,前端据此提示「数据不足」而不是
1098
+ * 显示一个不可信的数字。
1099
+ */
1100
+ if (path === '/forecast' && req.method === 'GET') {
1101
+ const snap = await ledgerSnapshotFresh()
1102
+ const now = Date.now()
1103
+ const todayKey = localDayKey(now)
1104
+ const monthPrefix = todayKey.slice(0, 7)
1105
+
1106
+ let monthCost = 0
1107
+ let monthCalls = 0
1108
+ const recent = []
1109
+
1110
+ for (const key of Object.keys(snap.days).sort()) {
1111
+ const day = snap.days[key]
1112
+ if (key.startsWith(monthPrefix)) {
1113
+ monthCost += day.cost ?? 0
1114
+ monthCalls += day.calls ?? 0
1115
+ }
1116
+ // 只取完整历史日,今天的数据还在增长
1117
+ if (key < todayKey && key >= localDayKey(now - 7 * 86400000)) {
1118
+ recent.push({ date: key, cost: day.cost ?? 0, calls: day.calls ?? 0 })
1119
+ }
1120
+ }
1121
+
1122
+ const n = recent.length
1123
+ const sumCost = recent.reduce((acc, d) => acc + d.cost, 0)
1124
+ const sumCalls = recent.reduce((acc, d) => acc + d.calls, 0)
1125
+ const avgDaily = n > 0 ? sumCost / n : 0
1126
+ const avgCalls = n > 0 ? sumCalls / n : 0
1127
+
1128
+ const nowDate = new Date(now)
1129
+ const daysInMonth = new Date(nowDate.getFullYear(), nowDate.getMonth() + 1, 0).getDate()
1130
+ const daysLeft = daysInMonth - nowDate.getDate()
1131
+ const projected = monthCost + avgDaily * daysLeft
1132
+
1133
+ return json({
1134
+ monthCost,
1135
+ monthCalls,
1136
+ avgDaily,
1137
+ avgCalls,
1138
+ daysLeft,
1139
+ projected,
1140
+ avgPerCall: monthCalls > 0 ? monthCost / monthCalls : 0,
1141
+ sampleDays: n,
1142
+ basis: n >= 3 ? 'recent' : 'insufficient',
1143
+ recent,
1144
+ })
1145
+ }
1146
+
1147
+ if (path === '/calls' && req.method === 'GET') {
1148
+ const url = new URL(req.url ?? '/api/dsh-cost/calls', 'http://localhost')
1149
+ const sessionId = url.searchParams.get('session') ?? ''
1150
+ const day = url.searchParams.get('day') ?? ''
1151
+ const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit') ?? 20)))
1152
+ // 全量按来源汇总(对话 / 增强 / 建议生成),供状态行费用弹卡分组显示。
1153
+ const snap = await ledgerSnapshotFresh()
1154
+ const bySource = {}
1155
+ let totalCost = 0
1156
+ let totalCalls = 0
1157
+ for (const key of Object.keys(snap.days)) {
1158
+ const d = snap.days[key]
1159
+ totalCost += d.cost ?? 0
1160
+ totalCalls += d.calls ?? 0
1161
+ for (const [src, entry] of Object.entries(d.bySource ?? {})) {
1162
+ const t = bySource[src] ?? (bySource[src] = { cost: 0, calls: 0 })
1163
+ t.cost += entry.cost ?? 0
1164
+ t.calls += entry.calls ?? 0
1165
+ }
1166
+ }
1167
+ send(200, { total: { cost: totalCost, calls: totalCalls, bySource }, calls: await journal.recent({ sessionId, day, limit }) })
1168
+ return
1169
+ }
1170
+
1171
+ // 本对话累计费用:跨所有日期聚合 day.sessions[sessionId],含来源分组。
1172
+ if (path === '/session' && req.method === 'GET') {
1173
+ const url = new URL(req.url ?? '/api/dsh-cost/session', 'http://localhost')
1174
+ const sessionId = url.searchParams.get('session') ?? ''
1175
+ if (!sessionId) { send(400, { error: 'missing session' }); return }
1176
+ const snap = await ledgerSnapshotFresh()
1177
+ const total = { ...zeroBuckets(), calls: 0, cost: 0, bySource: {} }
1178
+ for (const key of Object.keys(snap.days)) {
1179
+ const entry = snap.days[key].sessions?.[sessionId]
1180
+ if (!entry) continue
1181
+ for (const bucketKey of Object.keys(zeroBuckets())) total[bucketKey] += entry[bucketKey] ?? 0
1182
+ total.calls += entry.calls ?? 0
1183
+ total.cost += entry.cost ?? 0
1184
+ for (const [src, sub] of Object.entries(entry.bySource ?? {})) {
1185
+ const target = total.bySource[src] ?? (total.bySource[src] = { cost: 0, calls: 0 })
1186
+ target.cost += sub.cost ?? 0
1187
+ target.calls += sub.calls ?? 0
1188
+ }
1189
+ }
1190
+ send(200, {
1191
+ session: sessionId,
1192
+ total,
1193
+ calls: ledger.recentCalls(100).filter((call) => call.session === sessionId),
1194
+ })
1195
+ return
1196
+ }
1197
+
1198
+ // 回合费用:turnTail 槽位传宿主 turn/start~turn/end 事件时间窗。
1199
+ if (path === '/turn' && req.method === 'GET') {
1200
+ const url = new URL(req.url ?? '/api/dsh-cost/turn', 'http://localhost')
1201
+ const sessionId = url.searchParams.get('session') ?? ''
1202
+ const from = Number(url.searchParams.get('from') ?? 0)
1203
+ const to = Number(url.searchParams.get('to') ?? Number.MAX_SAFE_INTEGER)
1204
+ if (!sessionId || !Number.isFinite(from) || !Number.isFinite(to)) { send(400, { error: 'missing session/from/to' }); return }
1205
+ send(200, await journal.range({ sessionId, from, to }))
1206
+ return
1207
+ }
1208
+
1209
+ // 客户端调试上报:锚定/装配事件落 debug log(真机取证)。
1210
+ if (path === '/client-debug' && req.method === 'POST') {
1211
+ let body = ''
1212
+ for await (const chunk of req) body += chunk
1213
+ let message = ''
1214
+ try { message = String(JSON.parse(body).message ?? '').slice(0, 300) } catch { message = '' }
1215
+ if (message) void report(`client: ${message}`)
1216
+ send(200, { ok: true })
1217
+ return
1218
+ }
1219
+
1220
+ // 导出:全量 JSON(含日/模型/会话聚合)或日汇总 CSV。
1221
+ if (path === '/export' && req.method === 'GET') {
1222
+ const url = new URL(req.url ?? '/api/dsh-cost/export', 'http://localhost')
1223
+ const snap = await ledgerSnapshotFresh()
1224
+ if ((url.searchParams.get('format') ?? 'json') === 'csv') {
1225
+ const lines = ['date,input,output,cache_read,cache_write,reasoning,calls,cost_usd']
1226
+ for (const key of Object.keys(snap.days).sort()) {
1227
+ const day = snap.days[key]
1228
+ lines.push([key, day.input ?? 0, day.output ?? 0, day.cacheRead ?? 0, day.cacheWrite ?? 0, day.reasoning ?? 0, day.calls ?? 0, (day.cost ?? 0).toFixed(6)].join(','))
1229
+ }
1230
+ res.statusCode = 200
1231
+ res.setHeader('content-type', 'text/csv; charset=utf-8')
1232
+ res.setHeader('content-disposition', 'attachment; filename="dsh-cost-days.csv"')
1233
+ res.end(lines.join('\n'))
1234
+ return
1235
+ }
1236
+ res.statusCode = 200
1237
+ res.setHeader('content-type', 'application/json')
1238
+ res.setHeader('content-disposition', 'attachment; filename="dsh-cost-ledger.json"')
1239
+ res.end(JSON.stringify({ exportedAt: Date.now(), display: { currency: 'CNY', rate: ledger.liveExchangeRate()?.rate ?? live.exchangeRate }, days: snap.days }))
1240
+ return
1241
+ }
1242
+
1243
+ if (path === '/prices' && req.method === 'GET') {
1244
+ const now = Date.now()
1245
+ const peak = isPeakAt(now)
1246
+ const live = ledger.livePrices()
1247
+ send(200, {
1248
+ currency: 'USD',
1249
+ peak,
1250
+ peakWindowsUtc: PEAK_WINDOWS,
1251
+ holidays: [...PEAK_HOLIDAYS],
1252
+ source: live ? 'official-sync' : 'built-in',
1253
+ fetchedAt: live?.fetchedAt ?? null,
1254
+ models: {
1255
+ 'deepseek-v4.1-flash / deepseek-flash': live ? tierFor({ offPeak: live.flash.offPeak, peak: live.flash.peak }, now, peak) : currentTier(deepseekFlash, now, peak),
1256
+ 'deepseek-v4-pro': live ? tierFor({ offPeak: live.pro.offPeak, peak: live.pro.peak }, now, peak) : currentTier(deepseekPro, now, peak),
1257
+ },
1258
+ default: live ? tierFor({ offPeak: live.flash.offPeak, peak: live.flash.peak }, now, peak) : currentTier(deepseekFlash, now, peak),
1259
+ })
1260
+ return
1261
+ }
1262
+
1263
+ // 官方价格同步:抓官方定价页解析,校验峰=谷×2 后覆盖现行档。
1264
+ if (path === '/fetch-prices' && req.method === 'POST') {
1265
+ try {
1266
+ const parsed = await fetchOfficialPricing()
1267
+ const tiers = tiersFromOfficial(parsed)
1268
+ ledger.setLivePrices(tiers)
1269
+ void report(`price sync ok: flash off ${JSON.stringify(tiers.flash.offPeak)} pro off ${JSON.stringify(tiers.pro.offPeak)}`)
1270
+ send(200, { ok: true, fetchedAt: tiers.fetchedAt, flash: tiers.flash, pro: tiers.pro })
1271
+ } catch (error) {
1272
+ void report(`price sync failed: ${String(error?.message ?? error).slice(0, 200)}`)
1273
+ send(502, { ok: false, error: String(error?.message ?? error).slice(0, 200) })
1274
+ }
1275
+ return
1276
+ }
1277
+
1278
+ // 官方余额(选配 apiKey):host 白名单写死官方域名,https 证书校验兜底。
1279
+ if (path === '/balance' && req.method === 'GET') {
1280
+ const key = String(live.apiKey ?? '').trim()
1281
+ if (!key) { send(200, { ok: false, reason: 'no-key' }); return }
1282
+ if (balanceCache && Date.now() - balanceCache.at < 600_000) { send(200, balanceCache.data); return }
1283
+ if (BALANCE_HOST !== 'api.deepseek.com') { send(200, { ok: false, reason: 'bad-host' }); return }
1284
+ try {
1285
+ const res = await fetch(`https://${BALANCE_HOST}/user/balance`, {
1286
+ headers: { accept: 'application/json', authorization: `Bearer ${key}`, 'user-agent': 'dsh-cost' },
1287
+ signal: AbortSignal.timeout(8000),
1288
+ })
1289
+ if (!res.ok) throw new Error(`balance ${res.status}`)
1290
+ const data = await res.json()
1291
+ const info = Array.isArray(data.balance_infos) ? data.balance_infos[0] : null
1292
+ const out = info
1293
+ ? { ok: true, currency: info.currency ?? 'CNY', total: String(info.total_balance ?? ''), granted: String(info.granted_balance ?? ''), toppedUp: String(info.topped_up_balance ?? ''), at: Date.now() }
1294
+ : { ok: false, reason: 'empty' }
1295
+ balanceCache = { at: Date.now(), data: out }
1296
+ send(200, out)
1297
+ } catch (error) {
1298
+ const out = { ok: false, reason: String(error?.message ?? error).slice(0, 120) }
1299
+ balanceCache = { at: Date.now(), data: out }
1300
+ send(200, out)
1301
+ }
1302
+ return
1303
+ }
1304
+
1305
+ // 汇率手动刷新(自动刷新由每小时定时器执行)。
1306
+ if (path === '/fetch-rate' && req.method === 'POST') {
1307
+ try {
1308
+ const rate = await fetchExchangeRate()
1309
+ ledger.setExchangeRate(rate, Date.now())
1310
+ void report(`exchange rate ok: ${rate}`)
1311
+ send(200, { ok: true, rate })
1312
+ } catch (error) {
1313
+ void report(`exchange rate failed: ${String(error?.message ?? error).slice(0, 200)}`)
1314
+ send(502, { ok: false, error: String(error?.message ?? error).slice(0, 200) })
1315
+ }
1316
+ return
1317
+ }
1318
+
1319
+ send(404, { error: 'not-found' })
1320
+ },
1321
+ }), 'dsh-cost: /api/dsh-cost/*')
1322
+ })
1323
+
1324
+ function summarize(row) {
1325
+ return {
1326
+ input: row.input ?? 0, output: row.output ?? 0, cacheRead: row.cacheRead ?? 0,
1327
+ cacheWrite: row.cacheWrite ?? 0, reasoning: row.reasoning ?? 0,
1328
+ calls: row.calls ?? 0, cost: row.cost ?? 0,
1329
+ }
1330
+ }
1331
+
1332
+ function mergeDay(a, b) {
1333
+ const merged = { ...zeroBuckets(), calls: 0, cost: 0 }
1334
+ for (const part of [a, b]) {
1335
+ for (const key of Object.keys(zeroBuckets())) merged[key] += part[key] ?? 0
1336
+ merged.calls += part.calls ?? 0
1337
+ merged.cost += part.cost ?? 0
1338
+ }
1339
+ return merged
1340
+ }
1341
+
1342
+ function currentTier(entry, now, peak) {
1343
+ return tierFor(entry, now, peak)
1344
+ }
1345
+ }
1346
+