dsh-superinput 0.0.0-stage → 0.12.1

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,1914 @@
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
+ /* ------------------------------------------------------------------ */
699
+ /* Coding Plan 订阅配额(对标 dsh-cost-meter lib/coding-plans.js 的 */
700
+ /* Bearer 子集:端点/解析/降级链照抄上游,全部官方域名硬编码白名单) */
701
+ /* ------------------------------------------------------------------ */
702
+
703
+ /** 0-100 已用百分比(保留 1 位)。 */
704
+ const clampPct = (p) => Math.max(0, Math.min(100, Math.round(p * 10) / 10))
705
+
706
+ /** 归一化百分比:0-1 视为小数,>=1 视为已是百分数;非法 → null。 */
707
+ function normalizePercent(value) {
708
+ const n = Number(value)
709
+ if (!Number.isFinite(n) || n < 0) return null
710
+ return clampPct(n <= 1 ? n * 100 : n)
711
+ }
712
+
713
+ /** 归一化重置时刻:unix 秒 / 毫秒 / ISO 字符串 → ISO 字符串;非法 → ''。 */
714
+ function normalizeResetAt(value) {
715
+ if (typeof value === 'string' && value.length > 0) {
716
+ const ms = Date.parse(value)
717
+ if (Number.isFinite(ms)) return new Date(ms).toISOString()
718
+ const asNum = Number(value)
719
+ if (Number.isFinite(asNum) && asNum > 0) return new Date(asNum > 1e12 ? asNum : asNum * 1000).toISOString()
720
+ return ''
721
+ }
722
+ const n = Number(value)
723
+ if (!Number.isFinite(n) || n <= 0) return ''
724
+ return new Date(n > 1e12 ? n : n * 1000).toISOString()
725
+ }
726
+
727
+ /** 组装文本窗口(余额等无百分比的量):text 空 → null。 */
728
+ const textWindowOf = (text) => {
729
+ const s = String(text ?? '').trim()
730
+ return s.length > 0 ? { resetsAt: '', text: s } : null
731
+ }
732
+
733
+ /** 单窗口:utilization(0-100 或 0-1)+ resets(unix 秒/毫秒/ISO)。 */
734
+ function windowOf(utilization, resetsAt) {
735
+ const percent = normalizePercent(utilization)
736
+ return percent === null ? null : { percent, resetsAt: normalizeResetAt(resetsAt) }
737
+ }
738
+
739
+ /** Anthropic:{ five_hour: { utilization, resets_at }, seven_day: {...}, ... };子配额窗口丢弃。 */
740
+ function parseAnthropicUsage(data) {
741
+ if (data === null || typeof data !== 'object') return null
742
+ const windows = {}
743
+ for (const [name, raw] of Object.entries(data)) {
744
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue
745
+ if (/^(five[_ -]?hour|seven[_ -]?day)[_ -]/i.test(name)) continue
746
+ if (name === 'iguana_necktie') continue
747
+ const win = windowOf(raw.utilization ?? raw.used_percentage, raw.resets_at ?? raw.reset_at)
748
+ if (win !== null) windows[name] = win
749
+ }
750
+ return Object.keys(windows).length > 0 ? windows : null
751
+ }
752
+
753
+ /** Z.ai / 智谱 GLM:monitor 端点 limits 数组(unit 3=小时档/6=周档)→ plans 数组 → 扁平窗口。 */
754
+ function parseZaiUsage(data) {
755
+ if (data === null || typeof data !== 'object') return null
756
+ const windows = {}
757
+ const limits = data.data !== null && typeof data.data === 'object' && Array.isArray(data.data.limits)
758
+ ? data.data.limits : null
759
+ if (limits !== null) {
760
+ const monWindows = {}
761
+ const rest = []
762
+ for (const limit of limits) {
763
+ if (limit === null || typeof limit !== 'object' || (limit.type !== 'TOKENS_LIMIT' && limit.type !== 'CREDIT_LIMIT')) continue
764
+ const pct = limit.percentage !== undefined ? clampPct(Number(limit.percentage))
765
+ : Number.isFinite(Number(limit.usage)) && Number(limit.usage) > 0 && Number.isFinite(Number(limit.currentValue))
766
+ ? clampPct((Number(limit.currentValue) / Number(limit.usage)) * 100)
767
+ : null
768
+ if (pct === null) continue
769
+ const resetsAt = normalizeResetAt(limit.nextResetTime)
770
+ const unit = Number(limit.unit)
771
+ if (unit === 3 && monWindows.fiveHour === undefined) monWindows.fiveHour = { percent: pct, resetsAt }
772
+ else if (unit === 6 && monWindows.weekly === undefined) monWindows.weekly = { percent: pct, resetsAt }
773
+ else if (!Number.isFinite(unit)) rest.push({ pct, resetsAt, resetMs: Number(limit.nextResetTime) })
774
+ }
775
+ if (rest.length > 0) {
776
+ rest.sort((a, b) => {
777
+ const av = Number.isFinite(a.resetMs) && a.resetMs > 0 ? a.resetMs : 0
778
+ const bv = Number.isFinite(b.resetMs) && b.resetMs > 0 ? b.resetMs : 0
779
+ return av - bv
780
+ })
781
+ for (const item of rest) {
782
+ if (monWindows.fiveHour === undefined) monWindows.fiveHour = { percent: item.pct, resetsAt: item.resetsAt }
783
+ else if (monWindows.weekly === undefined) monWindows.weekly = { percent: item.pct, resetsAt: item.resetsAt }
784
+ }
785
+ }
786
+ if (Object.keys(monWindows).length > 0) return monWindows
787
+ }
788
+ if (Array.isArray(data.plans)) {
789
+ for (const plan of data.plans) {
790
+ if (plan === null || typeof plan !== 'object') continue
791
+ const total = Number(plan.total_units)
792
+ const used = Number(plan.used_units)
793
+ const pct = Number.isFinite(total) && total > 0 && Number.isFinite(used)
794
+ ? clampPct((used / total) * 100)
795
+ : normalizePercent(plan.utilization ?? plan.percent ?? plan.used_percentage)
796
+ if (pct === null) continue
797
+ const spanMs = Number(plan.period_end) * 1000 - Date.now()
798
+ const key = Number.isFinite(spanMs) && spanMs > 24 * 3600_000 ? 'weekly' : 'fiveHour'
799
+ windows[key] = { percent: pct, resetsAt: normalizeResetAt(plan.period_end) }
800
+ }
801
+ }
802
+ for (const [name, raw] of Object.entries(data)) {
803
+ if (name === 'plans' || raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue
804
+ const win = windowOf(raw.utilization ?? raw.percent ?? raw.used_percentage, raw.resets_at ?? raw.reset_at ?? raw.resetsAt)
805
+ if (win !== null) windows[name] = win
806
+ }
807
+ return Object.keys(windows).length > 0 ? windows : null
808
+ }
809
+
810
+ /** MiniMax 单条记录的剩余百分比(优先 *_remaining_percent,total 常为 0)。 */
811
+ function remainingPercentOf(row, remainPctKey, totalKey, usedKey, remainKey) {
812
+ if (row === null || typeof row !== 'object') return null
813
+ const numeric = (key) => (row[key] == null || row[key] === '' ? NaN : Number(row[key]))
814
+ const rp = numeric(remainPctKey)
815
+ if (Number.isFinite(rp)) return Math.min(100, Math.max(0, rp <= 1 ? rp * 100 : rp))
816
+ const total = numeric(totalKey)
817
+ const used = numeric(usedKey)
818
+ const remain = numeric(remainKey)
819
+ if (Number.isFinite(total) && total > 0) {
820
+ if (Number.isFinite(remain)) return (remain / total) * 100
821
+ if (Number.isFinite(used)) return ((total - used) / total) * 100
822
+ }
823
+ return null
824
+ }
825
+
826
+ /** MiniMax 单条记录 → 5h/7d 窗口;status=3 为不限量,保留显式 ∞。 */
827
+ function windowsFromMiniMaxRecord(row) {
828
+ if (row === null || typeof row !== 'object') return {}
829
+ const windows = {}
830
+ if (Number(row.current_interval_status) === 3) {
831
+ windows['5h'] = { unlimited: true, text: '∞', resetsAt: '' }
832
+ } else {
833
+ const remain = remainingPercentOf(row, 'current_interval_remaining_percent', 'current_interval_total_count', 'current_interval_usage_count', 'current_interval_remain_count')
834
+ if (remain !== null) {
835
+ windows['5h'] = { percent: clampPct(100 - remain), resetsAt: normalizeResetAt(row.end_time ?? row.reset_time ?? row.next_reset_time) }
836
+ }
837
+ }
838
+ if (Number(row.current_weekly_status) === 3) {
839
+ windows['7d'] = { unlimited: true, text: '∞', resetsAt: '' }
840
+ } else {
841
+ const remain = remainingPercentOf(row, 'current_weekly_remaining_percent', 'current_weekly_total_count', 'current_weekly_usage_count', 'current_weekly_remain_count')
842
+ if (remain !== null) {
843
+ windows['7d'] = { percent: clampPct(100 - remain), resetsAt: normalizeResetAt(row.weekly_end_time) }
844
+ }
845
+ }
846
+ return windows
847
+ }
848
+
849
+ /** 选 MiniMax 通用额度行:general → MiniMax-M* → 第一条可解析行。 */
850
+ function pickMiniMaxModelRow(rows) {
851
+ const list = rows.filter((row) => row !== null && typeof row === 'object' && !/^(video|speech|audio|image)(?:$|[-_])/i.test(String(row.model_name ?? '')))
852
+ const byName = (name) => list.find((row) => String(row.model_name ?? '').toLowerCase() === name)
853
+ return byName('general')
854
+ ?? list.find((row) => /^minimax-m/i.test(String(row.model_name ?? '')))
855
+ ?? list.find((row) => Object.keys(windowsFromMiniMaxRecord(row)).length > 0)
856
+ ?? list[0]
857
+ ?? null
858
+ }
859
+
860
+ /** MiniMax:token_plan/remains(现行 model_remains)→ 平铺 → 旧数组形态。 */
861
+ function parseMiniMaxRemains(data) {
862
+ if (data === null || typeof data !== 'object') return null
863
+ const payload = data.data !== null && typeof data.data === 'object' && !Array.isArray(data.data) ? data.data : data
864
+ const pickArray = (...keys) => {
865
+ for (const key of keys) {
866
+ if (Array.isArray(data?.[key])) return data[key]
867
+ if (Array.isArray(data?.data?.[key])) return data.data[key]
868
+ }
869
+ return null
870
+ }
871
+ const modelRows = pickArray('model_remains')
872
+ if (modelRows !== null) {
873
+ const fromRow = windowsFromMiniMaxRecord(pickMiniMaxModelRow(modelRows))
874
+ if (Object.keys(fromRow).length > 0) return fromRow
875
+ // 旧计数制:无 remaining_percent、靠 total>0 汇总。
876
+ let total = 0
877
+ let used = 0
878
+ let found = false
879
+ for (const item of modelRows) {
880
+ if (item === null || typeof item !== 'object') continue
881
+ const t = Number(item.current_interval_total_count ?? item.total)
882
+ const u = Number(item.current_interval_usage_count ?? item.used)
883
+ if (!Number.isFinite(t) || t <= 0) continue
884
+ found = true
885
+ total += t
886
+ used += Number.isFinite(u) ? u : 0
887
+ }
888
+ if (found && total > 0) return { current: { percent: clampPct((used / total) * 100), resetsAt: '' } }
889
+ }
890
+ const flat = windowsFromMiniMaxRecord(payload)
891
+ if (Object.keys(flat).length > 0) return flat
892
+ const windows = {}
893
+ const planRows = pickArray('token_plan_remains', 'plan_remains', 'remains', 'windows')
894
+ if (planRows !== null) {
895
+ planRows.forEach((row, index) => {
896
+ if (row === null || typeof row !== 'object') return
897
+ const total = Number(row.current_interval_total_count ?? row.total_count ?? row.total ?? row.limit)
898
+ const used = Number(row.current_interval_usage_count ?? row.used_count ?? row.usage_count ?? row.used)
899
+ const remain = Number(row.current_interval_remain_count ?? row.remain_count ?? row.remain ?? row.remaining)
900
+ let pct = null
901
+ if (Number.isFinite(total) && total > 0 && Number.isFinite(used)) pct = (used / total) * 100
902
+ else if (Number.isFinite(total) && total > 0 && Number.isFinite(remain)) pct = ((total - remain) / total) * 100
903
+ else pct = normalizePercent(row.utilization ?? row.percent ?? row.used_percentage)
904
+ if (pct === null) return
905
+ const labelRaw = row.interval ?? row.interval_type ?? row.window_type ?? row.type ?? row.name
906
+ const label = typeof labelRaw === 'string' && labelRaw.length > 0 ? labelRaw : `window${index + 1}`
907
+ windows[label] = { percent: clampPct(pct), resetsAt: normalizeResetAt(row.reset_time ?? row.resets_at ?? row.next_reset_time ?? row.reset_at) }
908
+ })
909
+ }
910
+ return Object.keys(windows).length > 0 ? windows : null
911
+ }
912
+
913
+ /** Kimi Code 订阅配额(api.kimi.com/coding/v1/usages,Kimi CLI 同款接口)。 */
914
+ function parseKimiCodingUsage(data) {
915
+ if (data === null || typeof data !== 'object') return null
916
+ const payload = data.data && typeof data.data === 'object' ? data.data : data
917
+ const quota = payload.quota && typeof payload.quota === 'object' ? payload.quota : payload
918
+ const windows = {}
919
+ const numeric = (value) => (value == null || typeof value === 'boolean' || String(value).trim() === '' ? NaN : Number(value))
920
+ const counter = (row) => {
921
+ if (!row || typeof row !== 'object' || Array.isArray(row)) return null
922
+ const total = numeric(row.limit)
923
+ const used = numeric(row.used)
924
+ const remaining = numeric(row.remaining)
925
+ const pct = total > 0 && Number.isFinite(total)
926
+ ? Number.isFinite(used) && used >= 0 ? (used / total) * 100
927
+ : Number.isFinite(remaining) && remaining >= 0 ? ((total - remaining) / total) * 100 : NaN
928
+ : NaN
929
+ return Number.isFinite(pct) ? { percent: clampPct(pct), resetsAt: normalizeResetAt(row.resetTime ?? row.reset_time ?? row.resetAt ?? row.resetsAt) } : null
930
+ }
931
+ const usages = quota.usages ?? {}
932
+ const shapes = [['5h', ['limit_5h', 'limit5h']], ['weekly', ['limit_7d', 'limit7d']], ['monthly', ['limit_month_total', 'limitMonthTotal']]]
933
+ for (const [name, aliases] of shapes) {
934
+ const raw = aliases.map((key) => usages[key]).find((row) => row && typeof row === 'object' && !Array.isArray(row))
935
+ if (!raw) continue
936
+ const counted = counter(raw)
937
+ const ratio = numeric(raw.usedRatio ?? raw.used_ratio)
938
+ if (counted) windows[name] = counted
939
+ else if (Number.isFinite(ratio) && ratio >= 0 && ratio <= 1) {
940
+ windows[name] = { percent: clampPct(ratio * 100), resetsAt: normalizeResetAt(raw.resetTime ?? raw.reset_time ?? raw.resetAt) }
941
+ }
942
+ }
943
+ const usage = counter(quota.usage)
944
+ if (usage && (!Object.keys(usages).length || usages.limit_7d || usages.limit7d)) windows.weekly = usage
945
+ const limits = Array.isArray(quota.limits) ? quota.limits : []
946
+ limits.forEach((row, index) => {
947
+ const parsed = counter(row?.detail)
948
+ if (!parsed) return
949
+ const duration = numeric(row.window?.duration)
950
+ const unitRaw = String(row.window?.timeUnit ?? '').toLowerCase().replace(/^time_unit_/, '')
951
+ const unit = unitRaw.startsWith('hour') ? 'h' : unitRaw.startsWith('day') ? 'd'
952
+ : unitRaw.startsWith('week') ? 'w' : unitRaw.startsWith('minute') ? 'm'
953
+ : unitRaw.startsWith('month') ? 'mo' : unitRaw.slice(0, 4)
954
+ const name = unit === 'm' && duration === 300 ? '5h'
955
+ : Number.isFinite(duration) && duration > 0 && unit.length > 0 ? `${duration}${unit}` : `window${index + 1}`
956
+ windows[name] = parsed
957
+ })
958
+ return Object.keys(windows).length > 0 ? windows : null
959
+ }
960
+
961
+ /** Kimi / Moonshot PAYG 余额(人民币分;<100 视为已是元)。 */
962
+ function parseKimiBalance(data) {
963
+ if (data === null || typeof data !== 'object') return null
964
+ const raw = data.available_balance ?? data.balance ?? data.cash_balance ?? data.data?.available_balance
965
+ const n = Number(raw)
966
+ if (!Number.isFinite(n) || n < 0) return null
967
+ const cny = n >= 100 ? n / 100 : n
968
+ const win = textWindowOf(`¥${(Math.round(cny * 100) / 100).toFixed(2)}`)
969
+ return win === null ? null : { balance: win }
970
+ }
971
+
972
+ /** OpenRouter:{ data: { total_credits, total_usage } }(美元)→ 已用%。 */
973
+ function parseOpenRouterCredits(data) {
974
+ if (data === null || typeof data !== 'object') return null
975
+ const d = data.data !== null && typeof data.data === 'object' ? data.data : data
976
+ const total = Number(d.total_credits ?? d.credits)
977
+ const used = Number(d.total_usage ?? d.usage)
978
+ if (!Number.isFinite(total) || total <= 0 || !Number.isFinite(used)) return null
979
+ return { credits: { percent: clampPct((used / total) * 100), resetsAt: normalizeResetAt(d.resets_at ?? d.next_reset_time) } }
980
+ }
981
+
982
+ /** SiliconFlow:balance/amount/remain(人民币)→ 文本窗口。 */
983
+ function parseSiliconFlowInfo(data) {
984
+ if (data === null || typeof data !== 'object') return null
985
+ const d = data.data !== null && typeof data.data === 'object' ? data.data : data
986
+ const n = Number(d.balance ?? d.amount ?? d.remain ?? d.remaining)
987
+ if (!Number.isFinite(n) || n < 0) return null
988
+ const win = textWindowOf(`¥${(Math.round(n * 100) / 100).toFixed(2)}`)
989
+ return win === null ? null : { balance: win }
990
+ }
991
+
992
+ /** CommandCode:windowLimits(used/cap)+ 月度 Credits 余额文本。 */
993
+ function parseCommandCodeCredits(data) {
994
+ if (data === null || typeof data !== 'object') return null
995
+ const windows = {}
996
+ const limits = data.windowLimits
997
+ if (limits !== null && typeof limits === 'object' && !Array.isArray(limits)) {
998
+ for (const [name, raw] of Object.entries(limits)) {
999
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) continue
1000
+ const used = Number(raw.used)
1001
+ const cap = Number(raw.cap)
1002
+ if (!Number.isFinite(used) || !Number.isFinite(cap) || cap <= 0 || used < 0) continue
1003
+ windows[name] = { percent: clampPct((used / cap) * 100), resetsAt: normalizeResetAt(raw.resetAt) }
1004
+ }
1005
+ }
1006
+ const monthly = Number(data.credits !== null && typeof data.credits === 'object' ? data.credits.monthlyCredits : undefined)
1007
+ if (Number.isFinite(monthly) && monthly >= 0) {
1008
+ const win = textWindowOf(`$${(Math.round(monthly * 100) / 100).toFixed(2)}`)
1009
+ if (win !== null) windows.monthly = win
1010
+ }
1011
+ return Object.keys(windows).length > 0 ? windows : null
1012
+ }
1013
+
1014
+ /** Cline Pass:limits[].percentUsed 已是 0-100 已用百分比,不走 0-1 猜测。 */
1015
+ function parseClinePassUsage(data) {
1016
+ if (data === null || typeof data !== 'object') return null
1017
+ const limits = data.data !== null && typeof data.data === 'object' ? data.data.limits : undefined
1018
+ if (!Array.isArray(limits)) return null
1019
+ const names = new Map([['five_hour', 'fiveHour'], ['weekly', 'weekly'], ['monthly', 'monthly']])
1020
+ const windows = {}
1021
+ for (const entry of limits) {
1022
+ if (entry === null || typeof entry !== 'object' || Array.isArray(entry)) continue
1023
+ const name = names.get(entry.type)
1024
+ const raw = entry.percentUsed
1025
+ const percent = typeof raw === 'number'
1026
+ ? raw
1027
+ : typeof raw === 'string' && /^[+-]?(\d+(\.\d+)?|\.\d+)([eE][+-]?\d+)?$/.test(raw.trim()) ? Number(raw) : Number.NaN
1028
+ if (name === undefined || !Number.isFinite(percent) || percent < 0) continue
1029
+ windows[name] = { percent: clampPct(percent), resetsAt: normalizeResetAt(entry.resetsAt) }
1030
+ }
1031
+ return Object.keys(windows).length > 0 ? windows : null
1032
+ }
1033
+
1034
+ const CODING_PLAN_PARSERS = {
1035
+ anthropic: parseAnthropicUsage,
1036
+ zai: parseZaiUsage,
1037
+ minimax: parseMiniMaxRemains,
1038
+ openrouter: parseOpenRouterCredits,
1039
+ siliconflow: parseSiliconFlowInfo,
1040
+ commandcode: parseCommandCodeCredits,
1041
+ clinepass: parseClinePassUsage,
1042
+ }
1043
+
1044
+ /**
1045
+ * 官方端点白名单(硬编码,配置不可改;MiniMax 是唯一例外——允许显式 HTTPS
1046
+ * origin 覆盖,且经 normalizeMiniMaxOrigin 校验为公网域名)。顺序即降级链:
1047
+ * 认证失败(401/403)与解析成功立即返回,其余错误换下一端点。
1048
+ */
1049
+ const CODING_PLAN_ENDPOINTS = {
1050
+ anthropic: ['https://api.anthropic.com/api/oauth/usage'],
1051
+ zai: [
1052
+ 'https://open.bigmodel.cn/api/monitor/usage/quota/limit',
1053
+ 'https://api.z.ai/api/monitor/usage/quota/limit',
1054
+ 'https://api.z.ai/api/coding/paas/v3/dashboard/billing/coding_plan/usage',
1055
+ 'https://open.bigmodel.cn/api/coding/paas/v3/dashboard/billing/coding_plan/usage',
1056
+ ],
1057
+ minimax: [
1058
+ 'https://www.minimax.cn/v1/token_plan/remains',
1059
+ 'https://www.minimax.io/v1/token_plan/remains',
1060
+ 'https://www.minimaxi.com/v1/token_plan/remains',
1061
+ ],
1062
+ kimi: [
1063
+ 'https://api.kimi.com/coding/v1/usages',
1064
+ 'https://api.kimi.com/coding/v1/usage',
1065
+ 'https://api.moonshot.cn/v1/users/me/balance',
1066
+ ],
1067
+ openrouter: ['https://openrouter.ai/api/v1/credits'],
1068
+ siliconflow: ['https://api.siliconflow.cn/v1/user/info'],
1069
+ commandcode: ['https://api.commandcode.ai/alpha/billing/credits'],
1070
+ clinepass: ['https://api.cline.bot/api/v1/users/me/plan/usage-limits'],
1071
+ }
1072
+
1073
+ const CODING_PLAN_IDS = Object.keys(CODING_PLAN_ENDPOINTS)
1074
+
1075
+ /**
1076
+ * MiniMax 自定源归一化:只接受 HTTPS origin(无路径/凭据/空白),并拒绝
1077
+ * 环回/内网/链路本地/裸 IP —— SSRF 硬化比上游更严(上游只查 HTTPS origin)。
1078
+ */
1079
+ function normalizeMiniMaxOrigin(value) {
1080
+ if (value === undefined || value === null || value === '') return ''
1081
+ if (typeof value !== 'string' || value.length > 2048) return null
1082
+ const text = value.trim()
1083
+ if (!text) return ''
1084
+ if (!/^https:\/\/[^/?#]+\/?$/i.test(text) || /[\s\\@?#]/.test(text)) return null
1085
+ let url
1086
+ try { url = new URL(text) } catch { return null }
1087
+ const host = url.hostname.toLowerCase()
1088
+ if (!host || url.username || url.password || url.pathname !== '/') return null
1089
+ if (host === '::1' || host.endsWith('.local') || host.endsWith('.internal')) return null
1090
+ if (/^(localhost|0\.|10\.|127\.|169\.254\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(host)) return null
1091
+ if (/^\[?[0-9a-f:]+\]?$/i.test(host) && host.includes(':')) return null
1092
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return null
1093
+ return url.origin
1094
+ }
1095
+
1096
+ /** 单端点抓取:Bearer 鉴权;Kimi 订阅端点须带官方 CLI 的 UA(上游实测约束)。 */
1097
+ async function fetchPlanJson(url, key, { kimiCoding = false, redirect } = {}) {
1098
+ const headers = {
1099
+ authorization: `Bearer ${key.trim()}`,
1100
+ accept: 'application/json',
1101
+ 'user-agent': 'dsh-cost (DeepSeek Harness plugin)',
1102
+ }
1103
+ if (kimiCoding) headers['user-agent'] = 'KimiCLI/1.6'
1104
+ return fetch(url, { headers, redirect, signal: AbortSignal.timeout(15_000) })
1105
+ }
1106
+
1107
+ /** 响应体有界 JSON(256KiB 上限,防异常端点拖内存)。 */
1108
+ async function readJsonBounded(res, cap = 262_144) {
1109
+ const text = await res.text()
1110
+ if (text.length > cap) throw new Error('response too large')
1111
+ return JSON.parse(text)
1112
+ }
1113
+
1114
+ /**
1115
+ * 查询单家 Coding Plan 额度:按白名单端点顺序尝试。401/403 与解析成功立即
1116
+ * 定论,其余错误换下一端点;zai/minimax 双域 Key 不互通、kimi 订阅端点 401
1117
+ * 视为「非订阅 Key」继续降级到 PAYG 余额端点(上游 issue #42/#53 同语义)。
1118
+ * 全部错误均为 soft(未订阅/Key 无效属预期场景,面板中性提示)。
1119
+ */
1120
+ async function queryCodingPlan(provider, key, origin) {
1121
+ const urls = provider === 'minimax'
1122
+ ? (() => {
1123
+ const base = normalizeMiniMaxOrigin(origin)
1124
+ if (base === null) throw Object.assign(new Error('minimax: invalid HTTPS origin'), { soft: true })
1125
+ return base ? [`${base}/v1/token_plan/remains`, `${base}/v1/api/openplatform/coding_plan/remains`] : CODING_PLAN_ENDPOINTS.minimax
1126
+ })()
1127
+ : CODING_PLAN_ENDPOINTS[provider]
1128
+ let lastError = 'HTTP error'
1129
+ for (const url of urls) {
1130
+ const kimiCoding = provider === 'kimi' && new URL(url).hostname === 'api.kimi.com'
1131
+ let res
1132
+ try {
1133
+ res = await fetchPlanJson(url, key, {
1134
+ kimiCoding,
1135
+ redirect: provider === 'minimax' ? 'error' : provider === 'zai' ? 'manual' : undefined,
1136
+ })
1137
+ } catch (error) {
1138
+ lastError = String(error?.message ?? error).slice(0, 120)
1139
+ continue
1140
+ }
1141
+ if (res.status === 401 || res.status === 403) {
1142
+ lastError = `HTTP ${res.status}`
1143
+ if (provider === 'zai' || provider === 'minimax' || kimiCoding) continue
1144
+ throw Object.assign(new Error(lastError), { soft: true })
1145
+ }
1146
+ if (!res.ok) { lastError = `HTTP ${res.status}`; continue }
1147
+ let data = null
1148
+ try { data = await readJsonBounded(res) } catch { lastError = 'invalid JSON response'; continue }
1149
+ const windows = (kimiCoding ? parseKimiCodingUsage(data) : CODING_PLAN_PARSERS[provider](data))
1150
+ if (windows) return { windows, endpoint: url }
1151
+ lastError = 'no usage windows in response'
1152
+ }
1153
+ throw Object.assign(new Error(lastError), { soft: true })
1154
+ }
1155
+
1156
+
1157
+ export const Config = Schema.object({
1158
+ enabled: Schema.boolean().default(true).description('是否启用费用统计'),
1159
+ /** 展示汇率(USD → CNY);金额账本恒存美元,展示时换算。 */
1160
+ exchangeRate: Schema.number().default(7.2).description('展示汇率(USD→CNY)'),
1161
+ /** 自定义单价覆盖:{ "模型id": { cacheHit, cacheMiss, output } }(USD/1M)。 */
1162
+ customPrices: Schema.object({}).default({}).description('自定义模型单价(USD/1M tokens)'),
1163
+ /** 账本保留天数。 */
1164
+ retentionDays: Schema.number().default(180).description('历史保留天数'),
1165
+ budgetEnabled: Schema.boolean().default(false).description('启用预算'),
1166
+ budgetAmount: Schema.number().default(50).description('预算金额(CNY)'),
1167
+ budgetPeriod: Schema.union(['day', 'month', 'all']).default('month').description('预算周期'),
1168
+ backfillEnabled: Schema.boolean().default(true).description('启动时回填历史会话费用'),
1169
+ peakAlertEnabled: Schema.boolean().default(false).description('峰谷切换前提醒'),
1170
+ peakAlertLeadMinutes: Schema.number().default(2).description('峰谷切换提前量(分钟,1-30)'),
1171
+ peakAlertNotify: Schema.boolean().default(false).description('同时发送系统通知'),
1172
+ /** DeepSeek 开放平台 API Key(选填):配置后费用弹卡显示官方账户余额。 */
1173
+ apiKey: Schema.string().default('').description('DeepSeek 开放平台 API Key(查官方余额用,可选)'),
1174
+ /** Coding Plan 订阅配额:{ 厂商: { key, origin? } },Key 在费用设置页配置、不回显。 */
1175
+ codingPlans: Schema.object({}).default({}).description('Coding Plan 订阅配额(在费用设置页配置)'),
1176
+ })
1177
+
1178
+ export function apply(ctx, config) {
1179
+ // 运行时可变副本:POST /settings 改这里,立即生效;显式改过的键持久化
1180
+ // 到 storages、启动时回放(没改过的键继续跟随宿主配置)。
1181
+ const live = { ...config }
1182
+ // 官方余额缓存:10 分钟内直接回缓存;换 key 即失效。
1183
+ let balanceCache = null
1184
+ if (!live.enabled) return
1185
+
1186
+ // 设置持久化:只落与 config 不同的键,防抖 400ms 原子写。
1187
+ const SETTINGS_KEYS = ['budgetEnabled', 'budgetAmount', 'budgetPeriod', 'peakAlertEnabled', 'peakAlertNotify', 'peakAlertLeadMinutes', 'exchangeRate', 'apiKey', 'codingPlans']
1188
+ const SETTINGS_PATH = join(homedir(), '.dsh', 'storages', 'dsh-cost', 'settings.json')
1189
+ try {
1190
+ const saved = JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))
1191
+ for (const key of SETTINGS_KEYS) if (saved?.[key] !== undefined) live[key] = saved[key]
1192
+ } catch { /* 无存档:全部跟随配置 */ }
1193
+ let settingsTimer = null
1194
+ const persistSettings = () => {
1195
+ if (settingsTimer) return
1196
+ settingsTimer = setTimeout(() => {
1197
+ settingsTimer = null
1198
+ try {
1199
+ mkdirSync(dirname(SETTINGS_PATH), { recursive: true })
1200
+ const out = {}
1201
+ for (const key of SETTINGS_KEYS) if (live[key] !== config[key]) out[key] = live[key]
1202
+ const tmp = `${SETTINGS_PATH}.tmp`
1203
+ writeFileSync(tmp, JSON.stringify(out, null, 2))
1204
+ renameSync(tmp, SETTINGS_PATH)
1205
+ } catch { /* ignore */ }
1206
+ }, 400)
1207
+ }
1208
+
1209
+ /* -- Coding Plan 配额缓存与查询编排 -------------------------------- */
1210
+
1211
+ const PLAN_TTL_MS = 600_000
1212
+ const planCache = new Map() // provider → { at, result }
1213
+ const planKeyOf = (id) => {
1214
+ const entry = live.codingPlans?.[id]
1215
+ const key = typeof entry?.key === 'string' ? entry.key.trim() : ''
1216
+ return key.length > 0 && key.length <= 4096 ? key : ''
1217
+ }
1218
+ async function refreshCodingPlan(id, force = false) {
1219
+ const key = planKeyOf(id)
1220
+ if (!key) return { hasKey: false }
1221
+ const cached = planCache.get(id)
1222
+ if (!force && cached && Date.now() - cached.at < PLAN_TTL_MS) return cached.result
1223
+ let result
1224
+ try {
1225
+ const { windows } = await queryCodingPlan(id, key, live.codingPlans?.[id]?.origin)
1226
+ result = { hasKey: true, windows, at: Date.now() }
1227
+ void report(`coding plan ${id} refreshed: ${Object.keys(windows).join(',')}`)
1228
+ } catch (error) {
1229
+ result = { hasKey: true, error: String(error?.message ?? error).slice(0, 160), at: Date.now() }
1230
+ void report(`coding plan ${id} refresh failed: ${result.error}`)
1231
+ }
1232
+ planCache.set(id, { at: Date.now(), result })
1233
+ return result
1234
+ }
1235
+
1236
+ const lockPath = join(STORE_DIR, 'ledger.lock')
1237
+ // 存储目录可能不存在(全新安装):锁文件 openSync(wx) 要求父目录已建。
1238
+ try { mkdirSync(STORE_DIR, { recursive: true }) } catch { /* ignore */ }
1239
+ const lock = acquireLedgerLock(lockPath)
1240
+ void report(`server boot v0.10.0 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'} plans=${CODING_PLAN_IDS.filter((id) => planKeyOf(id)).length}`)
1241
+
1242
+ const ledger = createLedger(live.retentionDays)
1243
+ if (lock.owner) ledger.load()
1244
+ ctx.effect(() => { ledger.dispose(); if (lock.owner) releaseLedgerLock(lockPath) }, 'dsh-cost: ledger flush + lock release')
1245
+
1246
+ const admitUsage = createUsageDeduper()
1247
+ const journal = createJournal()
1248
+
1249
+ /**
1250
+ * 锁持有者把 journal 增量折叠进 ledger(0.7.0 单写者模型):
1251
+ * 两个进程都会 boot 并各听各的 llm/stream——都只 append journal(O_APPEND
1252
+ * 单行跨进程安全);只有锁持有者写 ledger.json,彻底消除整文件互踩。
1253
+ * 指纹去重兜底:同一事件被两进程捕获 / 回填与实时重叠,10s 窗内互斥。
1254
+ */
1255
+ const ingestDedup = createUsageDeduper()
1256
+ let ingestedCount = 0
1257
+ let ingestBusy = false
1258
+ async function ingestJournal() {
1259
+ if (!lock.owner || ingestBusy) return
1260
+ ingestBusy = true
1261
+ try {
1262
+ const { rows, offset } = await journal.recordsAfter(ledger.journalOffset())
1263
+ for (const row of rows) {
1264
+ if (!row || !Number.isFinite(row.at) || !Number.isFinite(row.cost) || !row.model) continue
1265
+ const buckets = { input: row.input ?? 0, output: row.output ?? 0, cacheRead: row.cacheRead ?? 0, cacheWrite: row.cacheWrite ?? 0, reasoning: row.reasoning ?? 0 }
1266
+ if (!ingestDedup(`${row.session ?? ''}|${row.model}`, buckets, row.at)) continue
1267
+ ledger.account({ sessionId: row.session, model: row.model, buckets, cost: row.cost, atMs: row.at, source: row.source })
1268
+ // +60s 余量:日志事件记的是消息完成时刻,晚于实时流发起时刻;
1269
+ // 游标盖住整个调用时长,防回填重放同一条。
1270
+ if (row.session) ledger.markImported(row.session, row.at + 60_000)
1271
+ ingestedCount += 1
1272
+ }
1273
+ ledger.setJournalOffset(offset)
1274
+ if (rows.length > 0) touchLedgerLock(lockPath)
1275
+ } catch (error) {
1276
+ void report(`ingest failed: ${String(error?.message ?? error).slice(0, 160)}`)
1277
+ } finally {
1278
+ ingestBusy = false
1279
+ }
1280
+ }
1281
+
1282
+ if (lock.owner) {
1283
+ const ingestTimer = setInterval(() => { void ingestJournal() }, 5000)
1284
+ ingestTimer.unref?.()
1285
+ ctx.effect(() => () => clearInterval(ingestTimer), 'dsh-cost: journal ingest timer')
1286
+ }
1287
+
1288
+ // 历史回填:仅锁持有者执行(boot 后 3s,避开宿主启动高峰);0.7.0 起
1289
+ // 改写 journal(与实时同源),由 ingest 统一折叠 + 指纹去重,天然不重。
1290
+ if (live.backfillEnabled && lock.owner) {
1291
+ const backfillTimer = setTimeout(() => { void runBackfill(journal, ledger, priceEntryFor).catch((error) => { void report(`backfill crashed: ${String(error?.message ?? error).slice(0, 200)}`) }) }, 3000)
1292
+ backfillTimer.unref?.()
1293
+ ctx.effect(() => () => clearTimeout(backfillTimer), 'dsh-cost: backfill timer')
1294
+ }
1295
+
1296
+ // 汇率自动刷新:仅锁持有者,启动 5s 后首刷,之后每小时。失败静默(用旧值)。
1297
+ if (lock.owner) {
1298
+ const refreshRate = async () => {
1299
+ try {
1300
+ const rate = await fetchExchangeRate()
1301
+ ledger.setExchangeRate(rate, Date.now())
1302
+ void report(`exchange rate auto: ${rate}`)
1303
+ } catch (error) {
1304
+ void report(`exchange rate auto failed: ${String(error?.message ?? error).slice(0, 120)}`)
1305
+ }
1306
+ }
1307
+ const rateStart = setTimeout(() => { void refreshRate() }, 5000)
1308
+ rateStart.unref?.()
1309
+ const rateTimer = setInterval(() => { void refreshRate() }, 3_600_000)
1310
+ rateTimer.unref?.()
1311
+ ctx.effect(() => () => { clearTimeout(rateStart); clearInterval(rateTimer) }, 'dsh-cost: exchange rate timers')
1312
+ }
1313
+
1314
+ /** 深度标记:存在即「已知会、不需要再入账」的 llm 流(嵌套排除)。 */
1315
+ const llmDepth = new AsyncLocalStorage()
1316
+
1317
+ /**
1318
+ * 跨插件的调用来源标记。prompt-enhancer / followup 在发起自己的
1319
+ * llm.stream 前写 globalThis.__dshPluginCall,这里按 5 秒窗口读回:
1320
+ * 落在窗口内算它们的调用,否则一律算对话。读一次就清,避免串到下一次。
1321
+ */
1322
+ const CALL_SOURCE_TTL_MS = 5000
1323
+
1324
+ /** 各插件自己的 prompt 特征:跨进程时 globalThis 不共享,只能看内容。 */
1325
+ const SOURCE_SIGNATURES = [
1326
+ { source: 'followup', needle: 'You generate follow-up suggestions' },
1327
+ { source: 'enhance', needle: "You are a Prompt Engineering Expert" },
1328
+ ]
1329
+
1330
+ function callSource(options) {
1331
+ // 1) 同进程时的显式标记(prompt-enhancer / followup 调用前写入)
1332
+ const marker = globalThis.__dshPluginCall
1333
+ if (marker && typeof marker.at === 'number' && Date.now() - marker.at < CALL_SOURCE_TTL_MS) {
1334
+ const source = typeof marker.source === 'string' && marker.source ? marker.source : 'chat'
1335
+ globalThis.__dshPluginCall = undefined
1336
+ return source
1337
+ }
1338
+ // 2) 兜底:按 prompt 特征识别,插件跑在别的进程时也能分开记
1339
+ try {
1340
+ const messages = options?.messages
1341
+ if (messages) {
1342
+ const text = JSON.stringify(messages)
1343
+ for (const entry of SOURCE_SIGNATURES) {
1344
+ if (text.includes(entry.needle)) return entry.source
1345
+ }
1346
+ }
1347
+ } catch { /* 序列化失败就当普通对话 */ }
1348
+ return 'chat'
1349
+ }
1350
+
1351
+ const unmatchedModels = new Set()
1352
+ let streamArmed = false
1353
+ let accountCount = 0
1354
+
1355
+ function priceEntryFor(model) {
1356
+ if (model && live.customPrices && live.customPrices[model]) return { custom: true, ...live.customPrices[model] }
1357
+ const norm = normalizeModelId(model)
1358
+ if (legacyOnlyModels[norm]) return legacyOnlyModels[norm]
1359
+ if (norm.includes('deepseek')) {
1360
+ // 官方同步的现行价优先(fetch-prices 成功后覆盖内置表;历史档不变)。
1361
+ const live = ledger.livePrices()
1362
+ if (live && live.currency === 'USD') {
1363
+ const family = norm.includes('pro') ? 'pro' : 'flash'
1364
+ const t = live[family]
1365
+ return { legacyBase: (norm.includes('pro') ? deepseekPro : deepseekFlash).legacyBase, august: undefined, offPeak: t.offPeak, peak: t.peak, live: true }
1366
+ }
1367
+ return norm.includes('pro') ? deepseekPro : deepseekFlash
1368
+ }
1369
+ return null
1370
+ }
1371
+
1372
+ ctx.on('llm/stream', (options, next) => {
1373
+ // 包装路由插件在嵌套上下文里再发起的上游调用:原样透传,不重复入账。
1374
+ if (llmDepth.getStore() !== undefined) return next()
1375
+ const downstream = next()
1376
+ if (!streamArmed) {
1377
+ streamArmed = true
1378
+ void report(`llm/stream first fire pid=${process.pid} — capture channel ALIVE in this process`)
1379
+ }
1380
+ const startedAtMs = Date.now()
1381
+ const model = typeof options?.model === 'string' ? options.model : ''
1382
+ const provider = typeof options?.provider === 'string' ? options.provider : ''
1383
+ const sessionId = typeof options?.sessionId === 'string' ? options.sessionId : ''
1384
+ let usage = null
1385
+ let downstreamIter = null
1386
+ let completed = false
1387
+ return (async function* costObserve() {
1388
+ try {
1389
+ downstreamIter = downstream[Symbol.asyncIterator]()
1390
+ for (;;) {
1391
+ // 深度标记内拉取:下游适配器(包装路由)在消费上下文里再发起的
1392
+ // ctx.llm.stream() 继承标记,内层监听器判定为嵌套、透传。
1393
+ const result = await llmDepth.run(1, () => downstreamIter.next())
1394
+ if (result.done) break
1395
+ const chunk = result.value
1396
+ // usage 块位于 finish 之前;多块时最后一块生效(cost-meter 同款)。
1397
+ if (chunk?.type === 'usage' && chunk.usage != null) usage = chunk.usage
1398
+ yield chunk
1399
+ }
1400
+ completed = true
1401
+ } finally {
1402
+ // 消费方提前中断也向下游传播 return(),防上游 HTTP 流悬挂。
1403
+ if (!completed) {
1404
+ try { await downstreamIter?.return?.() } catch { /* ignore */ }
1405
+ }
1406
+ try {
1407
+ if (usage) {
1408
+ const buckets = {
1409
+ input: usage.inputTokens ?? 0,
1410
+ output: usage.outputTokens ?? 0,
1411
+ cacheRead: usage.cacheReadTokens ?? 0,
1412
+ cacheWrite: usage.cacheWriteTokens ?? 0,
1413
+ reasoning: usage.reasoningTokens ?? 0,
1414
+ }
1415
+ const entry = priceEntryFor(model)
1416
+ if (entry) {
1417
+ // 指纹去重兜底:本进程内 ALS 逃逸/同流双 usage 互斥(10s 窗)。
1418
+ if (admitUsage(model, buckets, startedAtMs)) {
1419
+ const peak = isPeakAt(startedAtMs)
1420
+ const tier = tierFor(entry, startedAtMs, peak)
1421
+ const cost = costOfBuckets(buckets, tier)
1422
+ // 0.7.0 单写者模型:所有进程只 append journal(跨进程安全),
1423
+ // ledger 由锁持有者的 ingest 统一折叠。
1424
+ void journal.write({
1425
+ at: startedAtMs,
1426
+ day: localDayKey(startedAtMs),
1427
+ session: sessionId || '',
1428
+ model,
1429
+ provider,
1430
+ ...buckets,
1431
+ rows: bucketRows(buckets, tier),
1432
+ cost,
1433
+ peak,
1434
+ source: callSource(options),
1435
+ })
1436
+ accountCount += 1
1437
+ // 采样留痕(前 10 条全记,之后 1/20),足以判定通路与发现异常。
1438
+ if (accountCount <= 10 || accountCount % 20 === 0) {
1439
+ 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)}`)
1440
+ }
1441
+ } else {
1442
+ void report(`dedup skip model=${model} buckets=${JSON.stringify(buckets)}`)
1443
+ }
1444
+ } else if (model && !unmatchedModels.has(model)) {
1445
+ // 未内置价格的第三方模型按 default 档(flash 现行价)计费,
1446
+ // 首见留痕提醒用户在 customPrices 里补充精确单价。
1447
+ unmatchedModels.add(model)
1448
+ void report(`unmatched model "${model}" billed at default tier; add customPrices entry for accuracy`)
1449
+ }
1450
+ }
1451
+ } catch (error) {
1452
+ void report(`account failed: ${String(error?.message ?? error).slice(0, 200)}`)
1453
+ }
1454
+ }
1455
+ })()
1456
+ }, { global: true })
1457
+
1458
+ /* -- HTTP 端点 ------------------------------------------------------ */
1459
+
1460
+ ctx.inject(['webServer'], (webCtx) => {
1461
+ /** /state、/export 共用:锁持有者先折叠+落盘再读文件;非持有者直读
1462
+ (持有者 5s ingest + 800ms 防抖,两个进程的 UI 数据都足够新鲜)。 */
1463
+ async function ledgerSnapshotFresh() {
1464
+ if (lock.owner) {
1465
+ await ingestJournal()
1466
+ ledger.flushSync()
1467
+ }
1468
+ try { return JSON.parse(await readFile(LEDGER_PATH, 'utf8')) } catch { return { days: {} } }
1469
+ }
1470
+ webCtx.effect(() => webCtx.webServer.register({
1471
+ kind: 'prefix',
1472
+ path: '/api/dsh-cost',
1473
+ handler: async (req, res) => {
1474
+ const send = (status, body) => {
1475
+ res.statusCode = status
1476
+ res.setHeader('content-type', 'application/json')
1477
+ res.end(JSON.stringify(body))
1478
+ }
1479
+ const path = (req.url ?? '').split('?')[0].replace(/^\/api\/dsh-cost/, '')
1480
+ if (path === '/settings' && req.method === 'GET') {
1481
+ send(200, {
1482
+ enabled: live.enabled,
1483
+ exchangeRate: live.exchangeRate,
1484
+ budgetEnabled: live.budgetEnabled,
1485
+ budgetAmount: live.budgetAmount,
1486
+ budgetPeriod: live.budgetPeriod,
1487
+ peakAlertEnabled: live.peakAlertEnabled,
1488
+ peakAlertLeadMinutes: live.peakAlertLeadMinutes,
1489
+ peakAlertNotify: live.peakAlertNotify,
1490
+ retentionDays: live.retentionDays,
1491
+ })
1492
+ return
1493
+ }
1494
+ // 运行时改参数:retentionDays 与 customPrices 不在白名单——前者启动时
1495
+ // 已传给 createLedger,后者是对象结构,运行时改要么无效要么要重建缓存。
1496
+ if (path === '/settings' && req.method === 'POST') {
1497
+ let raw = ''
1498
+ for await (const chunk of req) raw += chunk
1499
+ let body
1500
+ try { body = JSON.parse(raw) } catch { send(400, { error: 'invalid-json' }); return }
1501
+ if (body.budgetEnabled !== undefined) live.budgetEnabled = body.budgetEnabled === true
1502
+ if (body.budgetPeriod === 'day' || body.budgetPeriod === 'month') live.budgetPeriod = body.budgetPeriod
1503
+ if (body.peakAlertEnabled !== undefined) live.peakAlertEnabled = body.peakAlertEnabled === true
1504
+ if (body.peakAlertNotify !== undefined) live.peakAlertNotify = body.peakAlertNotify === true
1505
+ if (body.budgetAmount !== undefined) {
1506
+ const value = Number(body.budgetAmount)
1507
+ if (Number.isFinite(value)) live.budgetAmount = Math.min(1_000_000, Math.max(0, value))
1508
+ }
1509
+ if (body.exchangeRate !== undefined) {
1510
+ const value = Number(body.exchangeRate)
1511
+ if (Number.isFinite(value) && value > 0) live.exchangeRate = Math.min(20, Math.max(1, value))
1512
+ }
1513
+ if (body.peakAlertLeadMinutes !== undefined) {
1514
+ const value = Number(body.peakAlertLeadMinutes)
1515
+ if (Number.isFinite(value)) live.peakAlertLeadMinutes = Math.min(30, Math.max(1, Math.round(value)))
1516
+ }
1517
+ if (body.apiKey !== undefined) {
1518
+ live.apiKey = String(body.apiKey ?? '').trim()
1519
+ balanceCache = null // 换 key 立即失效缓存
1520
+ }
1521
+ persistSettings()
1522
+ send(200, { ok: true })
1523
+ return
1524
+ }
1525
+
1526
+
1527
+ if (path === '/state' && req.method === 'GET') {
1528
+ const snap = await ledgerSnapshotFresh()
1529
+ const now = Date.now()
1530
+ const todayKey = localDayKey(now)
1531
+ const monthPrefix = todayKey.slice(0, 7)
1532
+ const weekStartKey = localDayKey(now - 6 * 86400000)
1533
+ let month = null
1534
+ const total = { ...zeroBuckets(), calls: 0, cost: 0 }
1535
+ const recent = []
1536
+ const sessions = []
1537
+ for (const key of Object.keys(snap.days).sort()) {
1538
+ const day = snap.days[key]
1539
+ for (const bucketKey of Object.keys(zeroBuckets())) total[bucketKey] += day[bucketKey] ?? 0
1540
+ total.calls += day.calls ?? 0
1541
+ total.cost += day.cost ?? 0
1542
+ if (key.startsWith(monthPrefix)) month = month ? mergeDay(month, day) : summarize(day)
1543
+ if (key >= weekStartKey) {
1544
+ 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) })
1545
+ for (const [id, session] of Object.entries(day.sessions ?? {})) {
1546
+ 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 })
1547
+ }
1548
+ }
1549
+ }
1550
+ sessions.sort((a, b) => b.at - a.at)
1551
+ // 预算:以展示币种(CNY)计,used 由对应周期的账本金额换算。
1552
+ const budgetReady = live.budgetEnabled && Number(live.budgetAmount) > 0
1553
+ const budgetUsd = budgetReady
1554
+ ? (live.budgetPeriod === 'day'
1555
+ ? (snap.days[todayKey]?.cost ?? 0)
1556
+ : live.budgetPeriod === 'month'
1557
+ ? (month?.cost ?? 0)
1558
+ : total.cost)
1559
+ : 0
1560
+ send(200, {
1561
+ serverNow: now,
1562
+ display: { currency: 'CNY', rate: ledger.liveExchangeRate()?.rate ?? live.exchangeRate, rateSource: ledger.liveExchangeRate() ? 'auto' : 'config', rateFetchedAt: ledger.liveExchangeRate()?.fetchedAt ?? null },
1563
+ peak: { isPeak: isPeakAt(now), windows: PEAK_WINDOWS, nextSwitchAtMs: nextPeakSwitch(now)?.atMs ?? null, nextIsPeak: nextPeakSwitch(now)?.isPeak ?? null },
1564
+ today: todayKey in snap.days ? summarize(snap.days[todayKey]) : null,
1565
+ month,
1566
+ total: summarize(total),
1567
+ budget: budgetReady
1568
+ ? { amount: live.budgetAmount, period: live.budgetPeriod, used: budgetUsd * live.exchangeRate, usedPct: budgetUsd * live.exchangeRate / live.budgetAmount }
1569
+ : null,
1570
+ recent,
1571
+ sessions: sessions.slice(0, 20),
1572
+ unmatched: [...unmatchedModels],
1573
+ streamAlive: streamArmed,
1574
+ alerts: {
1575
+ peakEnabled: live.peakAlertEnabled === true,
1576
+ leadMinutes: Math.min(30, Math.max(1, Math.round(live.peakAlertLeadMinutes ?? 2))),
1577
+ notify: live.peakAlertNotify === true,
1578
+ },
1579
+ })
1580
+ return
1581
+ }
1582
+
1583
+ /**
1584
+ * GET /forecast —— 月底花费预测。
1585
+ *
1586
+ * 用最近 7 个**完整**历史日的日均推算,不用今天(半天数据会把均值拉低)。
1587
+ * 样本少于 3 天时标 basis=insufficient,前端据此提示「数据不足」而不是
1588
+ * 显示一个不可信的数字。
1589
+ */
1590
+ if (path === '/forecast' && req.method === 'GET') {
1591
+ const snap = await ledgerSnapshotFresh()
1592
+ const now = Date.now()
1593
+ const todayKey = localDayKey(now)
1594
+ const monthPrefix = todayKey.slice(0, 7)
1595
+
1596
+ let monthCost = 0
1597
+ let monthCalls = 0
1598
+ const recent = []
1599
+
1600
+ for (const key of Object.keys(snap.days).sort()) {
1601
+ const day = snap.days[key]
1602
+ if (key.startsWith(monthPrefix)) {
1603
+ monthCost += day.cost ?? 0
1604
+ monthCalls += day.calls ?? 0
1605
+ }
1606
+ // 只取完整历史日,今天的数据还在增长
1607
+ if (key < todayKey && key >= localDayKey(now - 7 * 86400000)) {
1608
+ recent.push({ date: key, cost: day.cost ?? 0, calls: day.calls ?? 0 })
1609
+ }
1610
+ }
1611
+
1612
+ const n = recent.length
1613
+ const sumCost = recent.reduce((acc, d) => acc + d.cost, 0)
1614
+ const sumCalls = recent.reduce((acc, d) => acc + d.calls, 0)
1615
+ const avgDaily = n > 0 ? sumCost / n : 0
1616
+ const avgCalls = n > 0 ? sumCalls / n : 0
1617
+
1618
+ const nowDate = new Date(now)
1619
+ const daysInMonth = new Date(nowDate.getFullYear(), nowDate.getMonth() + 1, 0).getDate()
1620
+ const daysLeft = daysInMonth - nowDate.getDate()
1621
+ const projected = monthCost + avgDaily * daysLeft
1622
+
1623
+ send(200, {
1624
+ monthCost,
1625
+ monthCalls,
1626
+ avgDaily,
1627
+ avgCalls,
1628
+ daysLeft,
1629
+ projected,
1630
+ avgPerCall: monthCalls > 0 ? monthCost / monthCalls : 0,
1631
+ sampleDays: n,
1632
+ basis: n >= 3 ? 'recent' : 'insufficient',
1633
+ recent,
1634
+ })
1635
+ return
1636
+ }
1637
+
1638
+ if (path === '/calls' && req.method === 'GET') {
1639
+ const url = new URL(req.url ?? '/api/dsh-cost/calls', 'http://localhost')
1640
+ const sessionId = url.searchParams.get('session') ?? ''
1641
+ const day = url.searchParams.get('day') ?? ''
1642
+ const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit') ?? 20)))
1643
+ // 全量按来源汇总(对话 / 增强 / 建议生成),供状态行费用弹卡分组显示。
1644
+ const snap = await ledgerSnapshotFresh()
1645
+ const bySource = {}
1646
+ let totalCost = 0
1647
+ let totalCalls = 0
1648
+ for (const key of Object.keys(snap.days)) {
1649
+ const d = snap.days[key]
1650
+ totalCost += d.cost ?? 0
1651
+ totalCalls += d.calls ?? 0
1652
+ for (const [src, entry] of Object.entries(d.bySource ?? {})) {
1653
+ const t = bySource[src] ?? (bySource[src] = { cost: 0, calls: 0 })
1654
+ t.cost += entry.cost ?? 0
1655
+ t.calls += entry.calls ?? 0
1656
+ }
1657
+ }
1658
+ send(200, { total: { cost: totalCost, calls: totalCalls, bySource }, calls: await journal.recent({ sessionId, day, limit }) })
1659
+ return
1660
+ }
1661
+
1662
+ // 本对话累计费用:跨所有日期聚合 day.sessions[sessionId],含来源分组。
1663
+ if (path === '/session' && req.method === 'GET') {
1664
+ const url = new URL(req.url ?? '/api/dsh-cost/session', 'http://localhost')
1665
+ const sessionId = url.searchParams.get('session') ?? ''
1666
+ if (!sessionId) { send(400, { error: 'missing session' }); return }
1667
+ const snap = await ledgerSnapshotFresh()
1668
+ const total = { ...zeroBuckets(), calls: 0, cost: 0, bySource: {} }
1669
+ for (const key of Object.keys(snap.days)) {
1670
+ const entry = snap.days[key].sessions?.[sessionId]
1671
+ if (!entry) continue
1672
+ for (const bucketKey of Object.keys(zeroBuckets())) total[bucketKey] += entry[bucketKey] ?? 0
1673
+ total.calls += entry.calls ?? 0
1674
+ total.cost += entry.cost ?? 0
1675
+ for (const [src, sub] of Object.entries(entry.bySource ?? {})) {
1676
+ const target = total.bySource[src] ?? (total.bySource[src] = { cost: 0, calls: 0 })
1677
+ target.cost += sub.cost ?? 0
1678
+ target.calls += sub.calls ?? 0
1679
+ }
1680
+ }
1681
+ send(200, {
1682
+ session: sessionId,
1683
+ total,
1684
+ calls: ledger.recentCalls(100).filter((call) => call.session === sessionId),
1685
+ })
1686
+ return
1687
+ }
1688
+
1689
+ // 回合费用:turnTail 槽位传宿主 turn/start~turn/end 事件时间窗。
1690
+ if (path === '/turn' && req.method === 'GET') {
1691
+ const url = new URL(req.url ?? '/api/dsh-cost/turn', 'http://localhost')
1692
+ const sessionId = url.searchParams.get('session') ?? ''
1693
+ const from = Number(url.searchParams.get('from') ?? 0)
1694
+ const to = Number(url.searchParams.get('to') ?? Number.MAX_SAFE_INTEGER)
1695
+ if (!sessionId || !Number.isFinite(from) || !Number.isFinite(to)) { send(400, { error: 'missing session/from/to' }); return }
1696
+ send(200, await journal.range({ sessionId, from, to }))
1697
+ return
1698
+ }
1699
+
1700
+ // 客户端调试上报:锚定/装配事件落 debug log(真机取证)。
1701
+ if (path === '/client-debug' && req.method === 'POST') {
1702
+ let body = ''
1703
+ for await (const chunk of req) body += chunk
1704
+ let message = ''
1705
+ try { message = String(JSON.parse(body).message ?? '').slice(0, 300) } catch { message = '' }
1706
+ if (message) void report(`client: ${message}`)
1707
+ send(200, { ok: true })
1708
+ return
1709
+ }
1710
+
1711
+ // 导出:全量 JSON(含日/模型/会话聚合)或日汇总 CSV。
1712
+ if (path === '/export' && req.method === 'GET') {
1713
+ const url = new URL(req.url ?? '/api/dsh-cost/export', 'http://localhost')
1714
+ const snap = await ledgerSnapshotFresh()
1715
+ if ((url.searchParams.get('format') ?? 'json') === 'csv') {
1716
+ const lines = ['date,input,output,cache_read,cache_write,reasoning,calls,cost_usd']
1717
+ for (const key of Object.keys(snap.days).sort()) {
1718
+ const day = snap.days[key]
1719
+ 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(','))
1720
+ }
1721
+ res.statusCode = 200
1722
+ res.setHeader('content-type', 'text/csv; charset=utf-8')
1723
+ res.setHeader('content-disposition', 'attachment; filename="dsh-cost-days.csv"')
1724
+ res.end(lines.join('\n'))
1725
+ return
1726
+ }
1727
+ res.statusCode = 200
1728
+ res.setHeader('content-type', 'application/json')
1729
+ res.setHeader('content-disposition', 'attachment; filename="dsh-cost-ledger.json"')
1730
+ res.end(JSON.stringify({ exportedAt: Date.now(), display: { currency: 'CNY', rate: ledger.liveExchangeRate()?.rate ?? live.exchangeRate }, days: snap.days }))
1731
+ return
1732
+ }
1733
+
1734
+ if (path === '/prices' && req.method === 'GET') {
1735
+ const now = Date.now()
1736
+ const peak = isPeakAt(now)
1737
+ const live = ledger.livePrices()
1738
+ send(200, {
1739
+ currency: 'USD',
1740
+ peak,
1741
+ peakWindowsUtc: PEAK_WINDOWS,
1742
+ holidays: [...PEAK_HOLIDAYS],
1743
+ source: live ? 'official-sync' : 'built-in',
1744
+ fetchedAt: live?.fetchedAt ?? null,
1745
+ models: {
1746
+ 'deepseek-v4.1-flash / deepseek-flash': live ? tierFor({ offPeak: live.flash.offPeak, peak: live.flash.peak }, now, peak) : currentTier(deepseekFlash, now, peak),
1747
+ 'deepseek-v4-pro': live ? tierFor({ offPeak: live.pro.offPeak, peak: live.pro.peak }, now, peak) : currentTier(deepseekPro, now, peak),
1748
+ },
1749
+ default: live ? tierFor({ offPeak: live.flash.offPeak, peak: live.flash.peak }, now, peak) : currentTier(deepseekFlash, now, peak),
1750
+ })
1751
+ return
1752
+ }
1753
+
1754
+ // 官方价格同步:抓官方定价页解析,校验峰=谷×2 后覆盖现行档。
1755
+ if (path === '/fetch-prices' && req.method === 'POST') {
1756
+ try {
1757
+ const parsed = await fetchOfficialPricing()
1758
+ const tiers = tiersFromOfficial(parsed)
1759
+ ledger.setLivePrices(tiers)
1760
+ void report(`price sync ok: flash off ${JSON.stringify(tiers.flash.offPeak)} pro off ${JSON.stringify(tiers.pro.offPeak)}`)
1761
+ send(200, { ok: true, fetchedAt: tiers.fetchedAt, flash: tiers.flash, pro: tiers.pro })
1762
+ } catch (error) {
1763
+ void report(`price sync failed: ${String(error?.message ?? error).slice(0, 200)}`)
1764
+ send(502, { ok: false, error: String(error?.message ?? error).slice(0, 200) })
1765
+ }
1766
+ return
1767
+ }
1768
+
1769
+ // 官方余额(选配 apiKey):host 白名单写死官方域名,https 证书校验兜底。
1770
+ if (path === '/balance' && req.method === 'GET') {
1771
+ const key = String(live.apiKey ?? '').trim()
1772
+ if (!key) { send(200, { ok: false, reason: 'no-key' }); return }
1773
+ if (balanceCache && Date.now() - balanceCache.at < 600_000) { send(200, balanceCache.data); return }
1774
+ if (BALANCE_HOST !== 'api.deepseek.com') { send(200, { ok: false, reason: 'bad-host' }); return }
1775
+ try {
1776
+ const res = await fetch(`https://${BALANCE_HOST}/user/balance`, {
1777
+ headers: { accept: 'application/json', authorization: `Bearer ${key}`, 'user-agent': 'dsh-cost' },
1778
+ signal: AbortSignal.timeout(8000),
1779
+ })
1780
+ if (!res.ok) throw new Error(`balance ${res.status}`)
1781
+ const data = await res.json()
1782
+ const info = Array.isArray(data.balance_infos) ? data.balance_infos[0] : null
1783
+ const out = info
1784
+ ? { 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() }
1785
+ : { ok: false, reason: 'empty' }
1786
+ balanceCache = { at: Date.now(), data: out }
1787
+ send(200, out)
1788
+ } catch (error) {
1789
+ const out = { ok: false, reason: String(error?.message ?? error).slice(0, 120) }
1790
+ balanceCache = { at: Date.now(), data: out }
1791
+ send(200, out)
1792
+ }
1793
+ return
1794
+ }
1795
+
1796
+ // 汇率手动刷新(自动刷新由每小时定时器执行)。
1797
+ if (path === '/fetch-rate' && req.method === 'POST') {
1798
+ try {
1799
+ const rate = await fetchExchangeRate()
1800
+ ledger.setExchangeRate(rate, Date.now())
1801
+ void report(`exchange rate ok: ${rate}`)
1802
+ send(200, { ok: true, rate })
1803
+ } catch (error) {
1804
+ void report(`exchange rate failed: ${String(error?.message ?? error).slice(0, 200)}`)
1805
+ send(502, { ok: false, error: String(error?.message ?? error).slice(0, 200) })
1806
+ }
1807
+ return
1808
+ }
1809
+
1810
+ // 热力图:近 N 天逐日 { cost, calls, tokens }(GitHub 贡献图样式)。
1811
+ if (path === '/heatmap' && req.method === 'GET') {
1812
+ const url = new URL(req.url ?? '/api/dsh-cost/heatmap', 'http://localhost')
1813
+ const days = Math.min(365, Math.max(28, Number(url.searchParams.get('days') ?? 182)))
1814
+ const snap = await ledgerSnapshotFresh()
1815
+ const since = localDayKey(Date.now() - days * 86400000)
1816
+ const rows = []
1817
+ for (const key of Object.keys(snap.days).sort()) {
1818
+ if (key < since) continue
1819
+ const day = snap.days[key]
1820
+ rows.push({
1821
+ date: key,
1822
+ cost: day.cost ?? 0,
1823
+ calls: day.calls ?? 0,
1824
+ tokens: (day.input ?? 0) + (day.output ?? 0) + (day.cacheRead ?? 0) + (day.cacheWrite ?? 0) + (day.reasoning ?? 0),
1825
+ })
1826
+ }
1827
+ send(200, { days: rows })
1828
+ return
1829
+ }
1830
+
1831
+ // Coding Plan 配额:只下发窗口/错误/时间戳,Key 永不出服务端。
1832
+ const planState = () => {
1833
+ const providers = {}
1834
+ for (const id of CODING_PLAN_IDS) providers[id] = { hasKey: planKeyOf(id).length > 0 }
1835
+ for (const id of CODING_PLAN_IDS) {
1836
+ if (!providers[id].hasKey) continue
1837
+ const cached = planCache.get(id)
1838
+ if (cached) providers[id] = { ...providers[id], ...cached.result, stale: Date.now() - cached.at >= PLAN_TTL_MS }
1839
+ }
1840
+ return providers
1841
+ }
1842
+ if (path === '/coding-plans' && req.method === 'GET') {
1843
+ // 已配置且缓存过期的后台补刷:GET 不阻塞,下一轮轮询取到新值。
1844
+ for (const id of CODING_PLAN_IDS) {
1845
+ if (!planKeyOf(id)) continue
1846
+ const cached = planCache.get(id)
1847
+ if (!cached || Date.now() - cached.at >= PLAN_TTL_MS) void refreshCodingPlan(id)
1848
+ }
1849
+ send(200, { providers: planState() })
1850
+ return
1851
+ }
1852
+ if (path === '/coding-plans/refresh' && req.method === 'POST') {
1853
+ let raw = ''
1854
+ for await (const chunk of req) raw += chunk
1855
+ let body = {}
1856
+ try { body = raw ? JSON.parse(raw) : {} } catch { send(400, { error: 'invalid-json' }); return }
1857
+ const only = typeof body.provider === 'string' ? body.provider : ''
1858
+ const ids = only ? [only] : CODING_PLAN_IDS.filter((id) => planKeyOf(id))
1859
+ for (const id of ids) await refreshCodingPlan(id, true)
1860
+ send(200, { ok: true, providers: planState() })
1861
+ return
1862
+ }
1863
+ if (path === '/coding-plans/config' && req.method === 'POST') {
1864
+ let raw = ''
1865
+ for await (const chunk of req) raw += chunk
1866
+ let body
1867
+ try { body = JSON.parse(raw) } catch { send(400, { error: 'invalid-json' }); return }
1868
+ const id = typeof body.provider === 'string' ? body.provider : ''
1869
+ if (!CODING_PLAN_IDS.includes(id)) { send(400, { error: 'unknown-provider' }); return }
1870
+ const key = typeof body.key === 'string' ? body.key.trim().slice(0, 4096) : ''
1871
+ const merged = { ...(live.codingPlans ?? {}) }
1872
+ if (key) {
1873
+ const origin = normalizeMiniMaxOrigin(typeof body.origin === 'string' ? body.origin : undefined)
1874
+ if (origin === null) { send(400, { error: 'invalid-origin' }); return }
1875
+ merged[id] = origin ? { key, origin } : { key }
1876
+ } else {
1877
+ delete merged[id] // 空 key = 清除配置
1878
+ }
1879
+ live.codingPlans = merged
1880
+ planCache.delete(id)
1881
+ persistSettings()
1882
+ void report(`coding plan config updated: ${id} ${key ? 'set' : 'cleared'}`)
1883
+ send(200, { ok: true })
1884
+ return
1885
+ }
1886
+
1887
+ send(404, { error: 'not-found' })
1888
+ },
1889
+ }), 'dsh-cost: /api/dsh-cost/*')
1890
+ })
1891
+
1892
+ function summarize(row) {
1893
+ return {
1894
+ input: row.input ?? 0, output: row.output ?? 0, cacheRead: row.cacheRead ?? 0,
1895
+ cacheWrite: row.cacheWrite ?? 0, reasoning: row.reasoning ?? 0,
1896
+ calls: row.calls ?? 0, cost: row.cost ?? 0,
1897
+ }
1898
+ }
1899
+
1900
+ function mergeDay(a, b) {
1901
+ const merged = { ...zeroBuckets(), calls: 0, cost: 0 }
1902
+ for (const part of [a, b]) {
1903
+ for (const key of Object.keys(zeroBuckets())) merged[key] += part[key] ?? 0
1904
+ merged.calls += part.calls ?? 0
1905
+ merged.cost += part.cost ?? 0
1906
+ }
1907
+ return merged
1908
+ }
1909
+
1910
+ function currentTier(entry, now, peak) {
1911
+ return tierFor(entry, now, peak)
1912
+ }
1913
+ }
1914
+