@feiyang666/dsh-usage-plugin 1.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js ADDED
@@ -0,0 +1,911 @@
1
+ /**
2
+ * dsh-usage-plugin — HOST half.
3
+ *
4
+ * Permanent Cordis plugin for a DeepSeek Harness web/desktop profile:
5
+ * - listens to `llm/stream`, records every model call's token usage,
6
+ * cache-hit/miss counts and finish reason;
7
+ * - persists records to `<session workspace>/dsh-usage/usage-records.json`;
8
+ * - serves a JSON API at `POST /usage/api` for the client half.
9
+ *
10
+ * The apply body is instrumented: every step is appended to a diagnostics
11
+ * buffer and flushed to `dsh-usage-boot.log` (resolved relative to the fs
12
+ * provider cwd) so activation failures are visible without app logs.
13
+ *
14
+ * Cross-platform note: path handling uses node:path (join / dirname) with the
15
+ * host platform's separator, so the plugin works on Windows, macOS and Linux.
16
+ */
17
+ import path from 'node:path'
18
+ export default {
19
+ inject: ['fs', 'webServer', 'subprocess', 'credentials', 'sandboxPolicy', 'agents'],
20
+ apply(ctx) {
21
+ const diag = { ok: true, steps: [], error: null }
22
+ const push = (s) => { try { diag.steps.push(String(s)) } catch (e) {} }
23
+ const flushDiag = () => {
24
+ try {
25
+ const fs = ctx.get('fs')
26
+ if (fs && typeof fs.resolve === 'function' && typeof fs.writeText === 'function') {
27
+ fs.resolve('dsh-usage-boot.log')
28
+ .then((target) => fs.writeText(target, JSON.stringify({ time: Date.now(), ...diag }, null, 2)))
29
+ .catch(() => {})
30
+ }
31
+ } catch (e) {}
32
+ }
33
+ try {
34
+ push('apply-start')
35
+
36
+ const records = []
37
+ const MAX_RECORDS = 100000
38
+
39
+ const PRICING = {
40
+ base: {
41
+ 'deepseek-v4-flash': { cacheHit: 0.02, cacheMiss: 1.0, output: 2.0 },
42
+ 'deepseek-v4-pro': { cacheHit: 0.025, cacheMiss: 3.0, output: 6.0 }
43
+ },
44
+ peakValley: {
45
+ 'deepseek-v4-flash': {
46
+ offPeak: { cacheHit: 0.05, cacheMiss: 1.5, output: 4.5 },
47
+ peak: { cacheHit: 0.1, cacheMiss: 3.0, output: 9.0 }
48
+ },
49
+ 'deepseek-v4-pro': {
50
+ offPeak: { cacheHit: 0.15, cacheMiss: 4.5, output: 13.5 },
51
+ peak: { cacheHit: 0.3, cacheMiss: 9.0, output: 27.0 }
52
+ }
53
+ }
54
+ }
55
+ const DEFAULT_PRICING = JSON.parse(JSON.stringify(PRICING))
56
+ const PRICE_MODELS = ['deepseek-v4-flash', 'deepseek-v4-pro']
57
+ // 新价格表(峰谷价)生效时间:北京时间 2026-08-17 00:00。
58
+ // 在此之前的调用按旧价格表(基础价 base)计费;之后按新价格表(峰谷价)计费。
59
+ const EFFECTIVE_AT = Date.parse('2026-08-17T00:00:00+08:00')
60
+
61
+ function modelKey(model) {
62
+ const m = String(model || '').toLowerCase()
63
+ if (m.indexOf('flash') >= 0) return 'deepseek-v4-flash'
64
+ if (m.indexOf('pro') >= 0) return 'deepseek-v4-pro'
65
+ return 'unknown'
66
+ }
67
+
68
+ function isPeak(ts) {
69
+ const d = new Date(ts + 8 * 3600 * 1000)
70
+ const t = d.getUTCHours() * 60 + d.getUTCMinutes()
71
+ return (t >= 9 * 60 && t < 12 * 60) || (t >= 14 * 60 && t < 18 * 60)
72
+ }
73
+
74
+ // regime: 'base' = 旧价格表(基础价) | 'peakValley' = 新价格表(峰谷价) | 'auto' = 按生效日期自动切换
75
+ function costFor(rec, regime) {
76
+ const mk = modelKey(rec.model)
77
+ const hit = rec.cacheReadTokens || 0
78
+ const miss = rec.inputTokens || 0
79
+ const out = rec.outputTokens || 0
80
+ if (regime === 'base') {
81
+ const p = PRICING.base[mk]
82
+ if (!p) return 0
83
+ return (hit * p.cacheHit + miss * p.cacheMiss + out * p.output) / 1e6
84
+ }
85
+ if (regime === 'auto') {
86
+ // 生效前用旧价格表(基础价);生效后按峰谷时段用新价格表
87
+ if (rec.time < EFFECTIVE_AT) {
88
+ const p = PRICING.base[mk]
89
+ if (!p) return 0
90
+ return (hit * p.cacheHit + miss * p.cacheMiss + out * p.output) / 1e6
91
+ }
92
+ const pv = PRICING.peakValley[mk]
93
+ if (!pv) return 0
94
+ const p = isPeak(rec.time) ? pv.peak : pv.offPeak
95
+ return (hit * p.cacheHit + miss * p.cacheMiss + out * p.output) / 1e6
96
+ }
97
+ const pv = PRICING.peakValley[mk]
98
+ if (!pv) return 0
99
+ const p = isPeak(rec.time) ? pv.peak : pv.offPeak
100
+ return (hit * p.cacheHit + miss * p.cacheMiss + out * p.output) / 1e6
101
+ }
102
+
103
+ const msg = (e) => String((e && e.message) || e)
104
+ const fail = (message) => ({ ok: false, error: message })
105
+ const pad2 = (n) => (n < 10 ? '0' : '') + n
106
+ const fmtInt = (n) => String(Math.round(n || 0)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
107
+ const fmtTime = (ts) => {
108
+ const d = new Date(ts + 8 * 3600 * 1000)
109
+ return `${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`
110
+ }
111
+ const fmtMoney = (n) => {
112
+ if (!n) return '0.0000'
113
+ if (n < 0.0001) return n.toExponential(2)
114
+ if (n < 1) return n.toFixed(4)
115
+ return n.toFixed(2)
116
+ }
117
+ const IS_WIN = typeof process !== 'undefined' && process.platform === 'win32'
118
+ const IS_MAC = typeof process !== 'undefined' && process.platform === 'darwin'
119
+ // Windows 保留原有行为:把 / 统一成 \;POSIX 上保持原样(不做 / → \ 转换)。
120
+ const normPath = (p) => {
121
+ const s = String(p == null ? '' : p)
122
+ return IS_WIN ? s.replace(/\//g, '\\') : s
123
+ }
124
+ // 平台化拼接:Windows 用反斜杠,POSIX 用正斜杠。
125
+ const joinPath = (...parts) => path.join(...parts.map((p) => String(p == null ? '' : p)))
126
+ const stamp = () => {
127
+ const d = new Date()
128
+ return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`
129
+ }
130
+
131
+ // ── daily aggregates (Beijing-time calendar) ───────────────────────────
132
+ function bjKey(ts) {
133
+ const d = new Date(Number(ts) + 8 * 3600 * 1000)
134
+ return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`
135
+ }
136
+
137
+ function buildDays() {
138
+ const map = {}
139
+ for (const r of records) {
140
+ const key = bjKey(r.time)
141
+ let d = map[key]
142
+ if (!d) {
143
+ d = {
144
+ day: key, calls: 0, miss: 0, hit: 0, write: 0, out: 0, reason: 0,
145
+ peakCalls: 0, offPeakCalls: 0, baseCost: 0, peakValleyCost: 0, autoCost: 0,
146
+ // 高峰/空闲时段消耗拆分(每个计费档位各一桶),供日历/概览区分展示
147
+ basePeakCost: 0, baseOffPeakCost: 0,
148
+ pvPeakCost: 0, pvOffPeakCost: 0,
149
+ autoPeakCost: 0, autoOffPeakCost: 0
150
+ }
151
+ map[key] = d
152
+ }
153
+ d.calls++
154
+ d.miss += r.inputTokens || 0
155
+ d.hit += r.cacheReadTokens || 0
156
+ d.write += r.cacheWriteTokens || 0
157
+ d.out += r.outputTokens || 0
158
+ d.reason += r.reasoningTokens || 0
159
+ const cBase = costFor(r, 'base')
160
+ const cPv = costFor(r, 'peakValley')
161
+ const cAuto = costFor(r, 'auto')
162
+ if (isPeak(r.time)) {
163
+ d.peakCalls++
164
+ d.basePeakCost += cBase
165
+ d.pvPeakCost += cPv
166
+ d.autoPeakCost += cAuto
167
+ } else {
168
+ d.offPeakCalls++
169
+ d.baseOffPeakCost += cBase
170
+ d.pvOffPeakCost += cPv
171
+ d.autoOffPeakCost += cAuto
172
+ }
173
+ d.baseCost += cBase
174
+ d.peakValleyCost += cPv
175
+ d.autoCost += cAuto
176
+ }
177
+ const days = []
178
+ for (const k in map) days.push(map[k])
179
+ days.sort((a, b) => (a.day < b.day ? 1 : a.day > b.day ? -1 : 0))
180
+ return days
181
+ }
182
+
183
+ const fs = ctx.get('fs')
184
+ push('fs=' + (fs ? 'present' : 'undefined'))
185
+ let root = ''
186
+ let dataPath = ''
187
+ let pricingPath = ''
188
+ let persistOk = false
189
+ let persistError = ''
190
+ let initPromise = null
191
+ let writeChain = Promise.resolve()
192
+ let cachedPolicy = null
193
+
194
+ const dirs = () => ({
195
+ data: joinPath(root, 'dsh-usage'),
196
+ csv: joinPath(root, 'dsh-usage', 'csv'),
197
+ json: joinPath(root, 'dsh-usage', 'json'),
198
+ images: joinPath(root, 'dsh-usage', 'images')
199
+ })
200
+
201
+ function currentAgent() {
202
+ try {
203
+ const agents = ctx.get('agents')
204
+ if (agents && typeof agents.currentInitiator === 'function') return agents.currentInitiator()
205
+ } catch (e) {}
206
+ return undefined
207
+ }
208
+
209
+ function sessionPolicy() {
210
+ if (cachedPolicy) return cachedPolicy
211
+ try {
212
+ const agent = currentAgent()
213
+ const sp = ctx.get('sandboxPolicy')
214
+ if (sp && typeof sp.resolve === 'function' && agent && agent.session) {
215
+ const policy = sp.resolve({ session: agent.session })
216
+ if (policy && policy.workspaceRoot) {
217
+ cachedPolicy = policy
218
+ return policy
219
+ }
220
+ }
221
+ } catch (e) {}
222
+ return undefined
223
+ }
224
+
225
+ function persistNow() {
226
+ if (!fs || !dataPath || !persistOk) return Promise.resolve()
227
+ const text = JSON.stringify(records)
228
+ const policy = sessionPolicy()
229
+ writeChain = writeChain.then(() =>
230
+ fs.resolve(dataPath).then((target) =>
231
+ fs.writeText(target, text, undefined, undefined, policy || undefined)
232
+ )
233
+ ).catch(() => {})
234
+ return writeChain
235
+ }
236
+
237
+ function persistPricing() {
238
+ if (!fs || !pricingPath || !persistOk) return Promise.resolve()
239
+ const text = JSON.stringify(PRICING)
240
+ const policy = sessionPolicy()
241
+ return fs.resolve(pricingPath)
242
+ .then((target) => fs.writeText(target, text, undefined, undefined, policy || undefined))
243
+ .catch(() => {})
244
+ }
245
+
246
+ async function loadPricing(policy) {
247
+ if (!fs || !pricingPath) return
248
+ try {
249
+ const target = await fs.resolve(pricingPath)
250
+ const data = JSON.parse(await fs.readText(target))
251
+ if (!data || typeof data !== 'object') return
252
+ for (const regime of ['base', 'peakValley']) {
253
+ const src = data[regime]
254
+ const dst = PRICING[regime]
255
+ if (!src || typeof src !== 'object' || !dst) continue
256
+ for (const mk of PRICE_MODELS) {
257
+ const row = src[mk]
258
+ if (!row || typeof row !== 'object' || !dst[mk]) continue
259
+ for (const k of ['cacheHit', 'cacheMiss', 'output']) {
260
+ const v = Number(row[k])
261
+ if (Number.isFinite(v) && v >= 0) dst[mk][k] = v
262
+ }
263
+ }
264
+ }
265
+ } catch (e) {}
266
+ }
267
+
268
+ function normalizeRecord(raw) {
269
+ if (!raw || typeof raw !== 'object') return null
270
+ const time = Number(raw.time)
271
+ if (!Number.isFinite(time) || time <= 0) return null
272
+ const toNum = (v, d) => { const n = Number(v); return Number.isFinite(n) ? n : (d === undefined ? 0 : d) }
273
+ return {
274
+ time,
275
+ model: String(raw.model || ''),
276
+ provider: String(raw.provider || ''),
277
+ purpose: String(raw.purpose || ''),
278
+ inputTokens: toNum(raw.inputTokens),
279
+ outputTokens: toNum(raw.outputTokens),
280
+ cacheReadTokens: toNum(raw.cacheReadTokens),
281
+ cacheWriteTokens: toNum(raw.cacheWriteTokens),
282
+ reasoningTokens: toNum(raw.reasoningTokens),
283
+ finishReason: String(raw.finishReason || '')
284
+ }
285
+ }
286
+
287
+ async function tryInitWithRoot(candidate, policy) {
288
+ const tryPath = joinPath(normPath(candidate), 'dsh-usage', 'usage-records.json')
289
+ try {
290
+ const target = await fs.resolve(tryPath)
291
+ const arr = JSON.parse(await fs.readText(target))
292
+ if (Array.isArray(arr) && arr.length > 0) {
293
+ const existing = {}
294
+ for (let i = 0; i < records.length; i++) existing[records[i].time] = true
295
+ for (let i = 0; i < arr.length; i++) {
296
+ const rec = normalizeRecord(arr[i])
297
+ if (!rec || existing[rec.time]) continue
298
+ existing[rec.time] = true
299
+ records.push(rec)
300
+ }
301
+ if (records.length > MAX_RECORDS) records.splice(0, records.length - MAX_RECORDS)
302
+ records.sort((a, b) => a.time - b.time)
303
+ }
304
+ } catch (e) {}
305
+ try {
306
+ const target = await fs.resolve(tryPath)
307
+ await fs.writeText(target, JSON.stringify(records), undefined, undefined, policy || undefined)
308
+ root = normPath(candidate)
309
+ dataPath = tryPath
310
+ pricingPath = joinPath(path.dirname(dataPath), 'pricing.json')
311
+ await loadPricing(policy)
312
+ persistOk = true
313
+ persistError = ''
314
+ return { ok: true }
315
+ } catch (e) {
316
+ return { ok: false, error: msg(e) }
317
+ }
318
+ }
319
+
320
+ async function migrateLegacy(candidates) {
321
+ if (!fs) return
322
+ const paths = []
323
+ for (const c of candidates) {
324
+ paths.push(joinPath(normPath(c), '.dsh-usage-records.json'))
325
+ paths.push(joinPath(normPath(c), 'dsh-usage', 'usage-records.json'))
326
+ }
327
+ for (const p of paths) {
328
+ try {
329
+ const arr = JSON.parse(await fs.readText(await fs.resolve(p)))
330
+ if (Array.isArray(arr)) {
331
+ const existing = {}
332
+ for (let j = 0; j < records.length; j++) existing[records[j].time] = true
333
+ for (const raw of arr) {
334
+ const rec = normalizeRecord(raw)
335
+ if (!rec || existing[rec.time]) continue
336
+ existing[rec.time] = true
337
+ records.push(rec)
338
+ }
339
+ if (records.length > MAX_RECORDS) records.splice(0, records.length - MAX_RECORDS)
340
+ records.sort((a, b) => a.time - b.time)
341
+ }
342
+ } catch (e) {}
343
+ }
344
+ }
345
+
346
+ async function ensureSessionRoot() {
347
+ if (!fs) return
348
+ const policy = sessionPolicy()
349
+ if (!policy || !policy.workspaceRoot) return
350
+ const cwd = normPath(String(policy.workspaceRoot))
351
+ if (cwd === root && persistOk) return
352
+ const r = await tryInitWithRoot(cwd, policy)
353
+ if (r.ok) {
354
+ const sp = ctx.get('sandboxPolicy')
355
+ await migrateLegacy([cwd, normPath(String((sp && sp.workspaceRoot) || ''))])
356
+ persistNow()
357
+ }
358
+ }
359
+
360
+ async function initPersistence() {
361
+ if (!fs) { persistError = '文件服务不可用'; return }
362
+ const candidates = []
363
+ const agent = currentAgent()
364
+ if (agent && agent.session && agent.session.header && agent.session.header.cwd) candidates.push(normPath(String(agent.session.header.cwd)))
365
+ try {
366
+ const sp = ctx.get('sandboxPolicy')
367
+ if (sp && sp.workspaceRoot) candidates.push(normPath(String(sp.workspaceRoot)))
368
+ } catch (e) {}
369
+ try {
370
+ const t = await fs.resolve('dsh-usage-probe')
371
+ const p = String(t.displayPath || '')
372
+ const i = p.lastIndexOf('dsh-usage-probe')
373
+ if (i > 0) candidates.push(p.slice(0, i))
374
+ } catch (e) {}
375
+ const seen = {}
376
+ let lastError = ''
377
+ for (const c of candidates) {
378
+ if (!c || seen[c]) continue
379
+ seen[c] = true
380
+ const r = await tryInitWithRoot(c, sessionPolicy())
381
+ if (r.ok) {
382
+ await migrateLegacy(candidates)
383
+ persistNow()
384
+ return
385
+ }
386
+ lastError = r.error || '写入失败'
387
+ }
388
+ persistError = lastError || '未找到可写的持久化目录'
389
+ persistOk = false
390
+ root = ''
391
+ dataPath = ''
392
+ }
393
+
394
+ const ensureInit = () => (initPromise ||= initPersistence())
395
+
396
+ try { ensureInit() } catch (e) { push('ensureInit-threw: ' + msg(e)) }
397
+
398
+ // ── capture ────────────────────────────────────────────────────────────
399
+ try {
400
+ ctx.on('llm/stream', function (options, next) {
401
+ const source = next()
402
+ const model = (options && options.model) || ''
403
+ const provider = (options && options.provider) || ''
404
+ const purpose = options && options.purpose ? String(options.purpose) : ''
405
+ const startedAt = Date.now()
406
+ let usage = null
407
+ let finishReason = ''
408
+
409
+ async function* observe() {
410
+ try {
411
+ for await (const chunk of source) {
412
+ if (chunk && chunk.type === 'usage' && chunk.usage) {
413
+ usage = chunk.usage
414
+ } else if (chunk && chunk.type === 'finish') {
415
+ const r = chunk.reason
416
+ finishReason = r ? String(r.kind || '') : ''
417
+ }
418
+ yield chunk
419
+ }
420
+ } finally {
421
+ if (usage) {
422
+ records.push({
423
+ time: startedAt,
424
+ model,
425
+ provider,
426
+ purpose,
427
+ inputTokens: usage.inputTokens || 0,
428
+ outputTokens: usage.outputTokens || 0,
429
+ cacheReadTokens: usage.cacheReadTokens || 0,
430
+ cacheWriteTokens: usage.cacheWriteTokens || 0,
431
+ reasoningTokens: usage.reasoningTokens || 0,
432
+ finishReason
433
+ })
434
+ if (records.length > MAX_RECORDS) records.splice(0, records.length - MAX_RECORDS)
435
+ try {
436
+ const agent = currentAgent()
437
+ const sp = ctx.get('sandboxPolicy')
438
+ if (sp && typeof sp.resolve === 'function' && agent && agent.session) {
439
+ const policy = sp.resolve({ session: agent.session })
440
+ if (policy && policy.workspaceRoot) cachedPolicy = policy
441
+ }
442
+ } catch (e) {}
443
+ ensureSessionRoot().then(persistNow).catch(() => {})
444
+ }
445
+ }
446
+ }
447
+
448
+ return observe()
449
+ })
450
+ push('llm-stream-listener-ok')
451
+ } catch (e) {
452
+ push('llm-stream-listener-threw: ' + (e && e.stack ? e.stack : msg(e)))
453
+ }
454
+
455
+ // ── balance ────────────────────────────────────────────────────────────
456
+ function parseBalance(text) {
457
+ let data
458
+ try { data = JSON.parse(text) } catch (e) { return fail('无法解析余额响应') }
459
+ const infos = []
460
+ const rawInfos = data && Array.isArray(data.balance_infos) ? data.balance_infos : []
461
+ for (const b of rawInfos) {
462
+ infos.push({
463
+ currency: String(b.currency || 'CNY'),
464
+ totalBalance: String(b.total_balance == null ? '0' : b.total_balance),
465
+ grantedBalance: String(b.granted_balance == null ? '0' : b.granted_balance),
466
+ toppedUpBalance: String(b.topped_up_balance == null ? '0' : b.topped_up_balance)
467
+ })
468
+ }
469
+ return { ok: true, queriedAt: Date.now(), isAvailable: data.is_available === true, infos }
470
+ }
471
+
472
+ // spawn 的 cwd:校验工作区目录真实存在,无效时回退到宿主进程的 cwd
473
+ // (POSIX 上根目录是伪路径/不存在时,直接传 cwd 会导致 spawn ENOENT)。
474
+ async function safeCwd() {
475
+ if (root && fs) {
476
+ try {
477
+ const t = await fs.resolve(root)
478
+ const info = await fs.stat(t)
479
+ if (info) return root
480
+ } catch (e) {}
481
+ }
482
+ return (typeof process !== 'undefined' && typeof process.cwd === 'function' && process.cwd()) || '.'
483
+ }
484
+
485
+ // 通用子进程执行:收集 stdout/stderr,统一处理 cwd 与错误分类。
486
+ async function runCollect(argv, opts) {
487
+ const subprocess = ctx.get('subprocess')
488
+ if (!subprocess) return { ok: false, error: '命令执行服务不可用' }
489
+ let handle
490
+ try {
491
+ handle = subprocess.spawn({
492
+ argv,
493
+ cwd: await safeCwd(),
494
+ stdio: opts && opts.stdinData != null
495
+ ? { stdin: { data: opts.stdinData }, stdout: { maxBytes: 65536 }, stderr: { maxBytes: 65536 } }
496
+ : { stdin: 'ignore', stdout: { maxBytes: 65536 }, stderr: { maxBytes: 65536 } },
497
+ graceMs: (opts && opts.graceMs) || 15000,
498
+ ...(opts && opts.env ? { env: opts.env } : {})
499
+ })
500
+ } catch (e) { return { ok: false, error: '启动失败:' + msg(e) } }
501
+ let outcome
502
+ try { outcome = await handle.done } catch (e) { return { ok: false, error: '执行失败:' + msg(e) } }
503
+ const outText = handle.collected && handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ''
504
+ const errText = handle.collected && handle.collected.stderr ? handle.collected.stderr.readFrom(0).text : ''
505
+ return { ok: outcome.exitCode === 0, exitCode: outcome.exitCode, out: outText, err: errText }
506
+ }
507
+
508
+ // node 可执行文件候选:Windows 保留原有路径;POSIX 走 PATH;
509
+ // Electron 宿主(桌面端)下用 execPath + ELECTRON_RUN_AS_NODE=1 兜底。
510
+ const isElectron = typeof process !== 'undefined' && !!(process.versions && process.versions.electron)
511
+ function nodeCandidates() {
512
+ const list = IS_WIN
513
+ ? ['node.exe', 'node', 'C:\\Program Files\\nodejs\\node.exe']
514
+ : ['node']
515
+ if (typeof process !== 'undefined' && process.execPath && !list.includes(process.execPath)) list.push(process.execPath)
516
+ return list
517
+ }
518
+
519
+ async function spawnNode(script, stdinData, env) {
520
+ const subprocess = ctx.get('subprocess')
521
+ if (!subprocess) return { ok: false, error: '命令执行服务不可用' }
522
+ let exe = null
523
+ for (const c of nodeCandidates()) {
524
+ try { exe = await subprocess.resolveExecutable(c); if (exe) break } catch (e) {}
525
+ }
526
+ if (!exe) return { ok: false, error: '未找到 node 可执行文件' }
527
+ const finalEnv = env || {}
528
+ if (isElectron && exe === process.execPath && !('ELECTRON_RUN_AS_NODE' in finalEnv)) {
529
+ finalEnv.ELECTRON_RUN_AS_NODE = '1'
530
+ }
531
+ const r = await runCollect([exe, '-e', script], { stdinData, env: finalEnv })
532
+ if (!r.ok) {
533
+ if (r.exitCode != null) return { ok: false, error: 'node 退出码 ' + r.exitCode + (r.err ? ':' + r.err.trim() : '') }
534
+ return { ok: false, error: r.error || '执行失败' }
535
+ }
536
+ return { ok: true, out: r.out }
537
+ }
538
+
539
+ async function queryBalance() {
540
+ const credentials = ctx.get('credentials')
541
+ if (!credentials) return fail('凭据服务不可用')
542
+ let hit
543
+ try { hit = await credentials.resolve('DEEPSEEK_API_KEY') } catch (e) { return fail('读取凭据失败:' + msg(e)) }
544
+ if (!hit || !hit.value) return fail('未配置 DEEPSEEK_API_KEY,请在「设置 → 模型」中配置后重试')
545
+ const key = hit.value
546
+ const script = [
547
+ 'const https=require("https");',
548
+ 'const key=process.env.BALANCE_API_KEY||"";',
549
+ 'const req=https.get("https://api.deepseek.com/user/balance",{headers:{Authorization:"Bearer "+key}},function(res){',
550
+ 'var body="";',
551
+ 'res.on("data",function(c){body+=c});',
552
+ 'res.on("end",function(){process.stdout.write(JSON.stringify({statusCode:res.statusCode,body:body}))});',
553
+ '});',
554
+ 'req.on("error",function(e){process.stdout.write(JSON.stringify({error:String(e&&e.message||e)}))});',
555
+ 'req.setTimeout(20000,function(){req.destroy(new Error("timeout"))});'
556
+ ].join('\n')
557
+ const r = await spawnNode(script, null, { BALANCE_API_KEY: key })
558
+ if (!r.ok) return fail(r.error)
559
+ let parsed
560
+ try { parsed = JSON.parse(r.out) } catch (e) { return fail('无法解析 node 输出') }
561
+ if (parsed.error) return fail(parsed.error)
562
+ if (parsed.statusCode !== 200) return fail('接口返回 HTTP ' + parsed.statusCode + ':' + String(parsed.body || '').slice(0, 300))
563
+ return parseBalance(parsed.body)
564
+ }
565
+
566
+ // ── export helpers ─────────────────────────────────────────────────────
567
+ function csvCell(s) {
568
+ s = String(s == null ? '' : s)
569
+ if (/[",\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"'
570
+ return s
571
+ }
572
+
573
+ function buildCsv() {
574
+ const header = ['time', 'model', 'provider', 'inputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'outputTokens', 'reasoningTokens', 'finishReason', 'period', 'baseCost', 'peakValleyCost', 'autoCost']
575
+ const lines = [header.join(',')]
576
+ for (const r of records) {
577
+ lines.push([
578
+ r.time, r.model, r.provider, r.inputTokens, r.cacheReadTokens, r.cacheWriteTokens,
579
+ r.outputTokens, r.reasoningTokens, r.finishReason,
580
+ isPeak(r.time) ? 'peak' : 'offPeak', costFor(r, 'base'), costFor(r, 'peakValley'), costFor(r, 'auto')
581
+ ].map(csvCell).join(','))
582
+ }
583
+ return lines.join('\r\n')
584
+ }
585
+
586
+ async function writePngFile(base64, outPath) {
587
+ const script = [
588
+ 'const fs=require("fs");',
589
+ 'let d="";',
590
+ 'process.stdin.on("data",function(c){d+=c});',
591
+ 'process.stdin.on("end",function(){',
592
+ ' const buf=Buffer.from(d,"base64");',
593
+ ' fs.mkdirSync(require("path").dirname(process.env.PNG_PATH),{recursive:true});',
594
+ ' fs.writeFileSync(process.env.PNG_PATH,buf);',
595
+ ' process.stdout.write(JSON.stringify({ok:true,bytes:buf.length}));',
596
+ '});'
597
+ ].join('\n')
598
+ return spawnNode(script, base64, { PNG_PATH: outPath })
599
+ }
600
+
601
+ async function writeTextFileViaNode(content, outPath) {
602
+ const script = [
603
+ 'const fs=require("fs");',
604
+ 'let d="";',
605
+ 'process.stdin.on("data",function(c){d+=c});',
606
+ 'process.stdin.on("end",function(){',
607
+ ' fs.mkdirSync(require("path").dirname(process.env.OUT_PATH),{recursive:true});',
608
+ ' fs.writeFileSync(process.env.OUT_PATH, Buffer.from(d,"utf8"));',
609
+ ' process.stdout.write(JSON.stringify({ok:true}));',
610
+ '});'
611
+ ].join('\n')
612
+ return spawnNode(script, content, { OUT_PATH: outPath })
613
+ }
614
+
615
+ async function mkdirViaNode(dir) {
616
+ const script = [
617
+ 'const fs=require("fs");',
618
+ 'fs.mkdirSync(process.env.MKDIR_PATH,{recursive:true});',
619
+ 'process.stdout.write(JSON.stringify({ok:true}));'
620
+ ].join('\n')
621
+ return spawnNode(script, null, { MKDIR_PATH: dir })
622
+ }
623
+
624
+ async function pickDirectory() {
625
+ const subprocess = ctx.get('subprocess')
626
+ if (!subprocess) return fail('命令执行服务不可用')
627
+ // macOS:osascript 原生目录选择(POSIX path)。
628
+ if (IS_MAC) {
629
+ let exe = null
630
+ try { exe = await subprocess.resolveExecutable('osascript') } catch (e) {}
631
+ if (!exe) return fail('未找到 osascript(macOS 需安装命令行工具 Command Line Tools)')
632
+ const r = await runCollect([exe, '-e', 'POSIX path of (choose folder)'], { graceMs: 120000 })
633
+ if (!r.ok && r.error) return fail(r.error)
634
+ const path = normPath(r.out.trim())
635
+ if (!path) return { ok: false, cancelled: true }
636
+ return { ok: true, path }
637
+ }
638
+ // Linux:优先 zenity,其次 kdialog。
639
+ if (!IS_WIN) {
640
+ for (const c of ['zenity', 'kdialog']) {
641
+ let exe = null
642
+ try { exe = await subprocess.resolveExecutable(c) } catch (e) {}
643
+ if (!exe) continue
644
+ const argv = c === 'zenity'
645
+ ? [exe, '--file-selection', '--directory', '--title=选择导出目录']
646
+ : [exe, '--getexistingdirectory', '选择导出目录']
647
+ const r = await runCollect(argv, { graceMs: 120000 })
648
+ if (!r.ok && r.error) return fail(r.error)
649
+ const path = normPath(r.out.trim())
650
+ if (!path) return { ok: false, cancelled: true }
651
+ return { ok: true, path }
652
+ }
653
+ return fail('未找到目录选择工具(请安装 zenity 或 kdialog)')
654
+ }
655
+ // Windows:PowerShell 原生目录选择。
656
+ let exe = null
657
+ for (const c of ['powershell.exe', 'pwsh.exe', 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe']) {
658
+ try { exe = await subprocess.resolveExecutable(c); if (exe) break } catch (e) {}
659
+ }
660
+ if (!exe) return fail('未找到 PowerShell')
661
+ const script = 'Add-Type -AssemblyName System.Windows.Forms; $f = New-Object System.Windows.Forms.FolderBrowserDialog; $f.Description = "选择导出目录"; if ($f.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) { [Console]::Out.Write($f.SelectedPath) }'
662
+ const r = await runCollect([exe, '-NoProfile', '-STA', '-NonInteractive', '-Command', script], { graceMs: 120000 })
663
+ if (!r.ok && r.error) return fail(r.error)
664
+ const path = normPath(r.out.trim())
665
+ if (!path) return { ok: false, cancelled: true }
666
+ return { ok: true, path }
667
+ }
668
+
669
+ async function revealDir(dirArg) {
670
+ const subprocess = ctx.get('subprocess')
671
+ if (!subprocess) return fail('命令执行服务不可用')
672
+ let target = ''
673
+ const isKey = dirArg === 'csv' || dirArg === 'json' || dirArg === 'images' || dirArg === 'data'
674
+ if (isKey) {
675
+ const d = dirs()
676
+ target = dirArg === 'csv' ? d.csv : dirArg === 'json' ? d.json : dirArg === 'images' ? d.images : d.data
677
+ target = normPath(target)
678
+ const policy = sessionPolicy()
679
+ try {
680
+ const t = await fs.resolve(joinPath(target, '.keep'))
681
+ await fs.writeText(t, '', undefined, undefined, policy || undefined)
682
+ } catch (e) {}
683
+ } else {
684
+ target = normPath(dirArg)
685
+ await mkdirViaNode(target)
686
+ }
687
+ // 平台化「在文件管理器中显示」:Windows explorer.exe / macOS open / Linux xdg-open。
688
+ const revealCmd = IS_WIN ? 'explorer.exe' : (IS_MAC ? 'open' : 'xdg-open')
689
+ let exe = null
690
+ try { exe = await subprocess.resolveExecutable(revealCmd) } catch (e) {}
691
+ if (!exe) return fail('未找到 ' + revealCmd)
692
+ try {
693
+ subprocess.spawn({ argv: [exe, target], cwd: await safeCwd(), stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } }, graceMs: 5000 })
694
+ return { ok: true }
695
+ } catch (e) { return fail(msg(e)) }
696
+ }
697
+
698
+ // ── API ────────────────────────────────────────────────────────────────
699
+ async function routeApi(body) {
700
+ const action = body && body.action ? String(body.action) : ''
701
+ try { await ensureInit() } catch (e) {}
702
+ switch (action) {
703
+ case 'list': {
704
+ const items = records.map((r) => ({
705
+ time: r.time, model: r.model, provider: r.provider, purpose: r.purpose,
706
+ inputTokens: r.inputTokens, outputTokens: r.outputTokens,
707
+ cacheReadTokens: r.cacheReadTokens, cacheWriteTokens: r.cacheWriteTokens,
708
+ reasoningTokens: r.reasoningTokens, finishReason: r.finishReason,
709
+ modelKey: modelKey(r.model),
710
+ baseCost: costFor(r, 'base'), peakValleyCost: costFor(r, 'peakValley'), autoCost: costFor(r, 'auto'),
711
+ peak: isPeak(r.time)
712
+ }))
713
+ return { ok: true, records: items, count: items.length, dataPath, persistOk, persistError, pricing: PRICING, effectiveAt: EFFECTIVE_AT, days: buildDays() }
714
+ }
715
+ case 'clear': {
716
+ const n = records.length
717
+ records.length = 0
718
+ persistNow()
719
+ return { ok: true, cleared: n }
720
+ }
721
+ case 'setPrices': {
722
+ const prices = body && body.prices
723
+ if (!prices || typeof prices !== 'object') return fail('缺少价格数据')
724
+ let changed = false
725
+ for (const regime of ['base', 'peakValley']) {
726
+ const src = prices[regime]
727
+ const dst = PRICING[regime]
728
+ if (!src || typeof src !== 'object' || !dst) continue
729
+ for (const mk of PRICE_MODELS) {
730
+ const row = src[mk]
731
+ if (!row || typeof row !== 'object' || !dst[mk]) continue
732
+ for (const k of ['cacheHit', 'cacheMiss', 'output']) {
733
+ const v = Number(row[k])
734
+ if (Number.isFinite(v) && v >= 0) { dst[mk][k] = v; changed = true }
735
+ }
736
+ }
737
+ }
738
+ if (!changed) return fail('没有可用的价格更新(价格必须是非负数字)')
739
+ persistPricing()
740
+ return { ok: true }
741
+ }
742
+ case 'resetPrices': {
743
+ for (const regime of ['base', 'peakValley']) {
744
+ const src = DEFAULT_PRICING[regime]
745
+ const dst = PRICING[regime]
746
+ if (!src || !dst) continue
747
+ for (const mk of PRICE_MODELS) {
748
+ if (!src[mk] || !dst[mk]) continue
749
+ dst[mk].cacheHit = src[mk].cacheHit
750
+ dst[mk].cacheMiss = src[mk].cacheMiss
751
+ dst[mk].output = src[mk].output
752
+ }
753
+ }
754
+ persistPricing()
755
+ return { ok: true }
756
+ }
757
+ case 'balance':
758
+ return queryBalance()
759
+ case 'pickDir':
760
+ return pickDirectory()
761
+ case 'export': {
762
+ if (!root) return fail('未找到工作区路径')
763
+ const kind = (body && body.kind) === 'json' ? 'json' : 'csv'
764
+ const name = 'dsh-usage-' + stamp() + (kind === 'json' ? '.json' : '.csv')
765
+ const content = kind === 'json'
766
+ ? JSON.stringify({ exportedAt: Date.now(), pricing: PRICING, records }, null, 2)
767
+ : buildCsv()
768
+ const dirArg = body && body.dir ? normPath(String(body.dir)) : ''
769
+ if (dirArg) {
770
+ const outPath = joinPath(dirArg, name)
771
+ const r = await writeTextFileViaNode(content, outPath)
772
+ if (!r.ok) return fail(r.error)
773
+ return { ok: true, path: outPath, name, dir: dirArg }
774
+ }
775
+ const outPath = joinPath(kind === 'json' ? dirs().json : dirs().csv, name)
776
+ try {
777
+ const target = await fs.resolve(outPath)
778
+ await fs.writeText(target, content, undefined, undefined, sessionPolicy() || undefined)
779
+ return { ok: true, path: normPath(fs.processPath ? fs.processPath(target) : outPath), name, dir: kind === 'json' ? 'json' : 'csv' }
780
+ } catch (e) { return fail(msg(e)) }
781
+ }
782
+ case 'exportPng': {
783
+ const dataUrl = body && body.dataUrl ? String(body.dataUrl) : ''
784
+ if (!dataUrl) return fail('缺少图片数据')
785
+ const idx = dataUrl.indexOf('base64,')
786
+ const b64 = idx >= 0 ? dataUrl.slice(idx + 7) : dataUrl
787
+ if (!root) return fail('未找到工作区路径')
788
+ const name = 'dsh-usage-report-' + stamp() + '.png'
789
+ const dirArg = body && body.dir ? normPath(String(body.dir)) : ''
790
+ const outPath = normPath(joinPath(dirArg || dirs().images, name))
791
+ const r = await writePngFile(b64, outPath)
792
+ if (!r.ok) return fail(r.error)
793
+ return { ok: true, path: outPath, name, dir: dirArg || 'images' }
794
+ }
795
+ case 'import': {
796
+ const content = body && body.content != null ? String(body.content) : ''
797
+ const filename = body && body.filename ? String(body.filename) : ''
798
+ if (!content) return fail('请选择要导入的文件')
799
+ let parsed
800
+ if (String(filename || '').toLowerCase().indexOf('.csv') >= 0) {
801
+ const lines = String(content).split(/\r?\n/).filter((l) => l.trim().length > 0)
802
+ const header = lines[0] ? parseCsvLine(lines[0]) : []
803
+ const idx = {}
804
+ header.forEach((h, i) => { idx[String(h).trim()] = i })
805
+ parsed = lines.slice(1).map((line) => {
806
+ const cells = parseCsvLine(line)
807
+ const get = (name) => (idx[name] === undefined ? '' : (cells[idx[name]] === undefined ? '' : cells[idx[name]]))
808
+ return {
809
+ time: get('time'), model: get('model'), provider: get('provider'),
810
+ inputTokens: get('inputTokens'), outputTokens: get('outputTokens'),
811
+ cacheReadTokens: get('cacheReadTokens'), cacheWriteTokens: get('cacheWriteTokens'),
812
+ reasoningTokens: get('reasoningTokens'), finishReason: get('finishReason')
813
+ }
814
+ })
815
+ } else {
816
+ try {
817
+ const data = JSON.parse(content)
818
+ parsed = Array.isArray(data) ? data : (data && Array.isArray(data.records) ? data.records : null)
819
+ } catch (e) { parsed = null }
820
+ }
821
+ if (!parsed || !Array.isArray(parsed)) return fail('文件内容不是可识别的用量数据(支持 JSON 或 CSV)')
822
+ let imported = 0, skipped = 0, invalid = 0
823
+ const existing = {}
824
+ for (const r of records) existing[r.time] = true
825
+ for (const raw of parsed) {
826
+ const rec = normalizeRecord(raw)
827
+ if (!rec) { invalid++; continue }
828
+ if (existing[rec.time]) { skipped++; continue }
829
+ existing[rec.time] = true
830
+ records.push(rec)
831
+ imported++
832
+ }
833
+ if (records.length > MAX_RECORDS) records.splice(0, records.length - MAX_RECORDS)
834
+ records.sort((a, b) => a.time - b.time)
835
+ persistNow()
836
+ return { ok: true, imported, skipped, invalid, total: records.length }
837
+ }
838
+ case 'reveal': {
839
+ const dirArg = body && body.dir ? String(body.dir) : 'data'
840
+ return revealDir(dirArg)
841
+ }
842
+ default:
843
+ return fail('未知操作:' + action)
844
+ }
845
+ }
846
+
847
+ function parseCsvLine(line) {
848
+ const cells = []
849
+ let cur = ''
850
+ let inQ = false
851
+ for (let i = 0; i < line.length; i++) {
852
+ const ch = line[i]
853
+ if (inQ) {
854
+ if (ch === '"') {
855
+ if (line[i + 1] === '"') { cur += '"'; i++ } else inQ = false
856
+ } else cur += ch
857
+ } else if (ch === '"') inQ = true
858
+ else if (ch === ',') { cells.push(cur); cur = '' }
859
+ else cur += ch
860
+ }
861
+ cells.push(cur)
862
+ return cells
863
+ }
864
+
865
+ function readBody(req) {
866
+ return new Promise((resolve) => {
867
+ let d = ''
868
+ req.on('data', (c) => { d += c })
869
+ req.on('end', () => { try { resolve(JSON.parse(d)) } catch (e) { resolve({}) } })
870
+ req.on('error', () => resolve({}))
871
+ })
872
+ }
873
+
874
+ function sendJson(res, obj) {
875
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
876
+ res.end(JSON.stringify(obj))
877
+ }
878
+
879
+ const webServer = ctx.get('webServer')
880
+ push('webServer=' + (webServer ? 'present' : 'undefined'))
881
+ if (webServer && typeof webServer.register === 'function') {
882
+ try {
883
+ webServer.register({
884
+ kind: 'exact',
885
+ path: '/usage/api',
886
+ handler: async (req, res) => {
887
+ try {
888
+ const body = await readBody(req)
889
+ sendJson(res, await routeApi(body))
890
+ } catch (e) {
891
+ sendJson(res, { ok: false, error: msg(e) })
892
+ }
893
+ }
894
+ })
895
+ push('route-registered')
896
+ } catch (e) {
897
+ push('route-register-threw: ' + (e && e.stack ? e.stack : msg(e)))
898
+ }
899
+ } else {
900
+ push('route-not-registered (no webServer)')
901
+ }
902
+
903
+ push('apply-end')
904
+ diag.ok = true
905
+ } catch (e) {
906
+ diag.ok = false
907
+ diag.error = (e && e.stack) ? e.stack : String(e)
908
+ }
909
+ flushDiag()
910
+ }
911
+ }