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.
- package/CHANGELOG.md +405 -0
- package/README.md +49 -2
- package/client.js +7397 -0
- package/cordis.patch.yml +6 -0
- package/index.js +34 -0
- package/modules/compaction.js +413 -0
- package/modules/cost.js +1914 -0
- package/modules/enhancer.js +726 -0
- package/modules/followup.js +718 -0
- package/modules/hub.client.js +218 -0
- package/modules/hub.js +78 -0
- package/modules/picker.js +133 -0
- package/modules/prompts.client.js +1709 -0
- package/modules/prompts.js +370 -0
- package/modules/voice.js +17 -0
- package/package.json +60 -4
- package/scripts/build-suite.mjs +325 -0
- package/scripts/smoke.mjs +172 -0
- package/server.js +4482 -0
package/modules/cost.js
ADDED
|
@@ -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(/&/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
|
+
|