@feiyang666/dsh-usage-plugin 1.9.0 → 1.9.2

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 CHANGED
@@ -1,911 +1,1196 @@
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
- }
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 a FIXED dedicated data directory (see resolveDataRoot),
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
+ import os from 'node:os'
19
+ import {
20
+ getBalanceProvider,
21
+ matchesModelProvider,
22
+ parseBalanceResponse,
23
+ providerList,
24
+ resolveBalanceEndpoint
25
+ } from './balance.js'
26
+
27
+ export default {
28
+ inject: ['fs', 'webServer', 'subprocess', 'credentials', 'settings', 'sandboxPolicy', 'agents'],
29
+ apply(ctx) {
30
+ const diag = { ok: true, steps: [], error: null }
31
+ const push = (s) => { try { diag.steps.push(String(s)) } catch (e) {} }
32
+ const flushDiag = () => {
33
+ try {
34
+ const fs = ctx.get('fs')
35
+ if (fs && typeof fs.resolve === 'function' && typeof fs.writeText === 'function') {
36
+ fs.resolve('dsh-usage-boot.log')
37
+ .then((target) => fs.writeText(target, JSON.stringify({ time: Date.now(), ...diag }, null, 2)))
38
+ .catch(() => {})
39
+ }
40
+ } catch (e) {}
41
+ }
42
+ try {
43
+ push('apply-start')
44
+
45
+ const records = []
46
+ const MAX_RECORDS = 100000
47
+
48
+ const PRICING = {
49
+ base: {
50
+ 'deepseek-v4-flash': { cacheHit: 0.02, cacheMiss: 1.0, output: 2.0 },
51
+ 'deepseek-v4-pro': { cacheHit: 0.025, cacheMiss: 3.0, output: 6.0 }
52
+ },
53
+ peakValley: {
54
+ 'deepseek-v4-flash': {
55
+ offPeak: { cacheHit: 0.05, cacheMiss: 1.5, output: 4.5 },
56
+ peak: { cacheHit: 0.1, cacheMiss: 3.0, output: 9.0 }
57
+ },
58
+ 'deepseek-v4-pro': {
59
+ offPeak: { cacheHit: 0.15, cacheMiss: 4.5, output: 13.5 },
60
+ peak: { cacheHit: 0.3, cacheMiss: 9.0, output: 27.0 }
61
+ }
62
+ }
63
+ }
64
+ const DEFAULT_PRICING = JSON.parse(JSON.stringify(PRICING))
65
+ const PRICE_MODELS = ['deepseek-v4-flash', 'deepseek-v4-pro']
66
+ const SILICONFLOW_PRICING = {
67
+ 'deepseek-ai/deepseek-v4-flash': { cacheHit: 0.02, cacheMiss: 1.0, output: 2.0 },
68
+ 'deepseek-ai/deepseek-v4-pro': { cacheHit: 1.0, cacheMiss: 12.0, output: 24.0 },
69
+ 'deepseek-ai/deepseek-v3.2': { cacheHit: 0.4, cacheMiss: 4.0, output: 6.0 },
70
+ 'pro/deepseek-ai/deepseek-v3.2': { cacheHit: 0.4, cacheMiss: 4.0, output: 6.0 },
71
+ 'qwen/qwen3.6-27b': { cacheHit: 3.0, cacheMiss: 3.0, output: 18.0 }
72
+ }
73
+ const DIGITALOCEAN_PRICING = {
74
+ flash: { cacheHit: 0.028, cacheMiss: 0.112, output: 0.224 },
75
+ pro: { cacheHit: 0.348, cacheMiss: 1.392, output: 2.784 },
76
+ v32: { cacheHit: 0.15, cacheMiss: 0.425, output: 1.36 }
77
+ }
78
+ let FX = { rate: 0, inverse: 0, date: '', queriedAt: 0, source: 'Frankfurter', stale: false, error: '' }
79
+ // 新价格表(峰谷价)生效时间:北京时间 2026-08-17 00:00。
80
+ // 在此之前的调用按旧价格表(基础价 base)计费;之后按新价格表(峰谷价)计费。
81
+ const EFFECTIVE_AT = Date.parse('2026-08-17T00:00:00+08:00')
82
+
83
+ function modelKey(model) {
84
+ const m = String(model || '').toLowerCase()
85
+ if (m.indexOf('flash') >= 0) return 'deepseek-v4-flash'
86
+ if (m.indexOf('pro') >= 0) return 'deepseek-v4-pro'
87
+ return 'unknown'
88
+ }
89
+
90
+ function isPeak(ts) {
91
+ const d = new Date(ts + 8 * 3600 * 1000)
92
+ const t = d.getUTCHours() * 60 + d.getUTCMinutes()
93
+ return (t >= 9 * 60 && t < 12 * 60) || (t >= 14 * 60 && t < 18 * 60)
94
+ }
95
+
96
+ // Third-party providers are priced only when a verified provider/model
97
+ // mapping exists. Unknown mappings deliberately remain zero rather than
98
+ // inheriting DeepSeek prices from a similar model name.
99
+ function costFor(rec, regime) {
100
+ const provider = String(rec.provider || '').trim().toLowerCase()
101
+ const model = String(rec.model || '').trim().toLowerCase()
102
+ const hit = rec.cacheReadTokens || 0
103
+ const miss = rec.inputTokens || 0
104
+ const out = rec.outputTokens || 0
105
+
106
+ if (provider === 'siliconflow') {
107
+ const p = SILICONFLOW_PRICING[model]
108
+ if (!p) return 0
109
+ return (hit * p.cacheHit + miss * p.cacheMiss + out * p.output) / 1e6
110
+ }
111
+
112
+ if (provider === 'digital-ocean' || provider === 'digitalocean') {
113
+ let p = null
114
+ if (model.indexOf('v3.2') >= 0 || model.indexOf('v3-2') >= 0) p = DIGITALOCEAN_PRICING.v32
115
+ else if (model.indexOf('pro') >= 0) p = DIGITALOCEAN_PRICING.pro
116
+ else if (model.indexOf('flash') >= 0) p = DIGITALOCEAN_PRICING.flash
117
+ if (!p) return 0
118
+ const rate = Number(rec.usdCnyRate || FX.rate || 0)
119
+ if (!(rate > 0)) return 0
120
+ return ((hit * p.cacheHit + miss * p.cacheMiss + out * p.output) / 1e6) * rate
121
+ }
122
+
123
+ if (provider === 'amd' || provider === 'amd-gpu-cloud' || provider === 'alibaba' || provider === 'aliyun' || provider === 'qwen') return 0
124
+ if (provider !== 'deepseek-official' && provider !== 'deepseek') return 0
125
+
126
+ const mk = modelKey(rec.model)
127
+ if (regime === 'base') {
128
+ const p = PRICING.base[mk]
129
+ if (!p) return 0
130
+ return (hit * p.cacheHit + miss * p.cacheMiss + out * p.output) / 1e6
131
+ }
132
+ if (regime === 'auto') {
133
+ if (rec.time < EFFECTIVE_AT) {
134
+ const p = PRICING.base[mk]
135
+ if (!p) return 0
136
+ return (hit * p.cacheHit + miss * p.cacheMiss + out * p.output) / 1e6
137
+ }
138
+ const pv = PRICING.peakValley[mk]
139
+ if (!pv) return 0
140
+ const p = isPeak(rec.time) ? pv.peak : pv.offPeak
141
+ return (hit * p.cacheHit + miss * p.cacheMiss + out * p.output) / 1e6
142
+ }
143
+ const pv = PRICING.peakValley[mk]
144
+ if (!pv) return 0
145
+ const p = isPeak(rec.time) ? pv.peak : pv.offPeak
146
+ return (hit * p.cacheHit + miss * p.cacheMiss + out * p.output) / 1e6
147
+ }
148
+
149
+ const msg = (e) => String((e && e.message) || e)
150
+ const fail = (message) => ({ ok: false, error: message })
151
+ const pad2 = (n) => (n < 10 ? '0' : '') + n
152
+ const fmtInt = (n) => String(Math.round(n || 0)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
153
+ const fmtTime = (ts) => {
154
+ const d = new Date(ts + 8 * 3600 * 1000)
155
+ return `${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`
156
+ }
157
+ const fmtMoney = (n) => {
158
+ if (!n) return '0.0000'
159
+ if (n < 0.0001) return n.toExponential(2)
160
+ if (n < 1) return n.toFixed(4)
161
+ return n.toFixed(2)
162
+ }
163
+ const IS_WIN = typeof process !== 'undefined' && process.platform === 'win32'
164
+ const IS_MAC = typeof process !== 'undefined' && process.platform === 'darwin'
165
+ const normPath = (p) => {
166
+ const s = String(p == null ? '' : p)
167
+ return IS_WIN ? s.replace(/\//g, '\\') : s
168
+ }
169
+ const joinPath = (...parts) => path.join(...parts.map((p) => String(p == null ? '' : p)))
170
+ const stamp = () => {
171
+ const d = new Date()
172
+ return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`
173
+ }
174
+
175
+ function bjKey(ts) {
176
+ const d = new Date(Number(ts) + 8 * 3600 * 1000)
177
+ return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`
178
+ }
179
+
180
+ function buildDays() {
181
+ const map = {}
182
+ for (const r of records) {
183
+ const key = bjKey(r.time)
184
+ let d = map[key]
185
+ if (!d) {
186
+ d = {
187
+ day: key, calls: 0, miss: 0, hit: 0, write: 0, out: 0, reason: 0,
188
+ peakCalls: 0, offPeakCalls: 0, baseCost: 0, peakValleyCost: 0, autoCost: 0,
189
+ basePeakCost: 0, baseOffPeakCost: 0,
190
+ pvPeakCost: 0, pvOffPeakCost: 0,
191
+ autoPeakCost: 0, autoOffPeakCost: 0
192
+ }
193
+ map[key] = d
194
+ }
195
+ d.calls++
196
+ d.miss += r.inputTokens || 0
197
+ d.hit += r.cacheReadTokens || 0
198
+ d.write += r.cacheWriteTokens || 0
199
+ d.out += r.outputTokens || 0
200
+ d.reason += r.reasoningTokens || 0
201
+ const cBase = costFor(r, 'base')
202
+ const cPv = costFor(r, 'peakValley')
203
+ const cAuto = costFor(r, 'auto')
204
+ if (isPeak(r.time)) {
205
+ d.peakCalls++
206
+ d.basePeakCost += cBase
207
+ d.pvPeakCost += cPv
208
+ d.autoPeakCost += cAuto
209
+ } else {
210
+ d.offPeakCalls++
211
+ d.baseOffPeakCost += cBase
212
+ d.pvOffPeakCost += cPv
213
+ d.autoOffPeakCost += cAuto
214
+ }
215
+ d.baseCost += cBase
216
+ d.peakValleyCost += cPv
217
+ d.autoCost += cAuto
218
+ }
219
+ const days = []
220
+ for (const k in map) days.push(map[k])
221
+ days.sort((a, b) => (a.day < b.day ? 1 : a.day > b.day ? -1 : 0))
222
+ return days
223
+ }
224
+
225
+ const fs = ctx.get('fs')
226
+ push('fs=' + (fs ? 'present' : 'undefined'))
227
+ let root = ''
228
+ let dataPath = ''
229
+ let pricingPath = ''
230
+ let persistOk = false
231
+ let persistError = ''
232
+ let initPromise = null
233
+ let writeChain = Promise.resolve()
234
+ let cachedPolicy = null
235
+
236
+ const dirs = () => ({
237
+ data: joinPath(root, 'dsh-usage'),
238
+ csv: joinPath(root, 'dsh-usage', 'csv'),
239
+ json: joinPath(root, 'dsh-usage', 'json'),
240
+ images: joinPath(root, 'dsh-usage', 'images')
241
+ })
242
+
243
+ function currentAgent() {
244
+ try {
245
+ const agents = ctx.get('agents')
246
+ if (agents && typeof agents.currentInitiator === 'function') return agents.currentInitiator()
247
+ } catch (e) {}
248
+ return undefined
249
+ }
250
+
251
+ function sessionPolicy() {
252
+ if (cachedPolicy) return cachedPolicy
253
+ try {
254
+ const agent = currentAgent()
255
+ const sp = ctx.get('sandboxPolicy')
256
+ if (sp && typeof sp.resolve === 'function' && agent && agent.session) {
257
+ const policy = sp.resolve({ session: agent.session })
258
+ if (policy && policy.workspaceRoot) {
259
+ cachedPolicy = policy
260
+ return policy
261
+ }
262
+ }
263
+ } catch (e) {}
264
+ return undefined
265
+ }
266
+
267
+ function persistNow() {
268
+ if (!fs || !dataPath || !persistOk) return Promise.resolve()
269
+ const text = JSON.stringify(records)
270
+ const policy = undefined
271
+ writeChain = writeChain.then(() =>
272
+ fs.resolve(dataPath).then((target) =>
273
+ fs.writeText(target, text, undefined, undefined, policy || undefined)
274
+ )
275
+ ).catch(() => {})
276
+ return writeChain
277
+ }
278
+
279
+ function persistPricing() {
280
+ if (!fs || !pricingPath || !persistOk) return Promise.resolve()
281
+ const text = JSON.stringify(PRICING)
282
+ const policy = undefined
283
+ return fs.resolve(pricingPath)
284
+ .then((target) => fs.writeText(target, text, undefined, undefined, policy || undefined))
285
+ .catch(() => {})
286
+ }
287
+
288
+ async function loadPricing(policy) {
289
+ if (!fs || !pricingPath) return
290
+ try {
291
+ const target = await fs.resolve(pricingPath)
292
+ const data = JSON.parse(await fs.readText(target))
293
+ if (!data || typeof data !== 'object') return
294
+ for (const regime of ['base', 'peakValley']) {
295
+ const src = data[regime]
296
+ const dst = PRICING[regime]
297
+ if (!src || typeof src !== 'object' || !dst) continue
298
+ for (const mk of PRICE_MODELS) {
299
+ const row = src[mk]
300
+ if (!row || typeof row !== 'object' || !dst[mk]) continue
301
+ for (const k of ['cacheHit', 'cacheMiss', 'output']) {
302
+ const v = Number(row[k])
303
+ if (Number.isFinite(v) && v >= 0) dst[mk][k] = v
304
+ }
305
+ }
306
+ }
307
+ } catch (e) {}
308
+ }
309
+
310
+ function normalizeRecord(raw) {
311
+ if (!raw || typeof raw !== 'object') return null
312
+ const time = Number(raw.time)
313
+ if (!Number.isFinite(time) || time <= 0) return null
314
+ const toNum = (v, d) => { const n = Number(v); return Number.isFinite(n) ? n : (d === undefined ? 0 : d) }
315
+ return {
316
+ time,
317
+ model: String(raw.model || ''),
318
+ provider: String(raw.provider || ''),
319
+ purpose: String(raw.purpose || ''),
320
+ inputTokens: toNum(raw.inputTokens),
321
+ outputTokens: toNum(raw.outputTokens),
322
+ cacheReadTokens: toNum(raw.cacheReadTokens),
323
+ cacheWriteTokens: toNum(raw.cacheWriteTokens),
324
+ reasoningTokens: toNum(raw.reasoningTokens),
325
+ finishReason: String(raw.finishReason || ''),
326
+ usdCnyRate: toNum(raw.usdCnyRate),
327
+ fxDate: String(raw.fxDate || '')
328
+ }
329
+ }
330
+
331
+ async function tryInitWithRoot(candidate, policy) {
332
+ const tryPath = joinPath(normPath(candidate), 'dsh-usage', 'usage-records.json')
333
+ try {
334
+ const target = await fs.resolve(tryPath)
335
+ const arr = JSON.parse(await fs.readText(target))
336
+ if (Array.isArray(arr) && arr.length > 0) {
337
+ const existing = {}
338
+ for (let i = 0; i < records.length; i++) existing[records[i].time] = true
339
+ for (let i = 0; i < arr.length; i++) {
340
+ const rec = normalizeRecord(arr[i])
341
+ if (!rec || existing[rec.time]) continue
342
+ existing[rec.time] = true
343
+ records.push(rec)
344
+ }
345
+ if (records.length > MAX_RECORDS) records.splice(0, records.length - MAX_RECORDS)
346
+ records.sort((a, b) => a.time - b.time)
347
+ }
348
+ } catch (e) {}
349
+ try {
350
+ const target = await fs.resolve(tryPath)
351
+ await fs.writeText(target, JSON.stringify(records), undefined, undefined, policy || undefined)
352
+ root = normPath(candidate)
353
+ dataPath = tryPath
354
+ pricingPath = joinPath(path.dirname(dataPath), 'pricing.json')
355
+ await loadPricing(policy)
356
+ persistOk = true
357
+ persistError = ''
358
+ return { ok: true }
359
+ } catch (e) {
360
+ return { ok: false, error: msg(e) }
361
+ }
362
+ }
363
+
364
+ async function migrateLegacy(candidates) {
365
+ if (!fs) return
366
+ const paths = []
367
+ for (const c of candidates) {
368
+ paths.push(joinPath(normPath(c), '.dsh-usage-records.json'))
369
+ paths.push(joinPath(normPath(c), 'dsh-usage', 'usage-records.json'))
370
+ }
371
+ for (const p of paths) {
372
+ try {
373
+ const arr = JSON.parse(await fs.readText(await fs.resolve(p)))
374
+ if (Array.isArray(arr)) {
375
+ const existing = {}
376
+ for (let j = 0; j < records.length; j++) existing[records[j].time] = true
377
+ for (const raw of arr) {
378
+ const rec = normalizeRecord(raw)
379
+ if (!rec || existing[rec.time]) continue
380
+ existing[rec.time] = true
381
+ records.push(rec)
382
+ }
383
+ if (records.length > MAX_RECORDS) records.splice(0, records.length - MAX_RECORDS)
384
+ records.sort((a, b) => a.time - b.time)
385
+ }
386
+ } catch (e) {}
387
+ }
388
+ }
389
+
390
+ // 固定、专用的数据目录:不随工作区漂移,也不放在 DSH 主目录(~/.dsh)或
391
+ // 桌面端安装目录。优先级:环境变量 DSH_USAGE_DATA_DIR >
392
+ // 系统应用数据目录(AppData/Local 或 ~/Library/Application Support) >
393
+ // 用户主目录下的专用文件夹 dsh-usage-data。
394
+ function resolveDataRoot() {
395
+ const out = []
396
+ const env = ((typeof process !== 'undefined' && process.env && process.env.DSH_USAGE_DATA_DIR) || '').trim()
397
+ if (env) out.push(normPath(env))
398
+ const appData = (typeof process !== 'undefined' && process.env) ? (process.env.LOCALAPPDATA || process.env.APPDATA) : ''
399
+ if (appData) out.push(joinPath(normPath(appData), 'dsh-usage-plugin'))
400
+ out.push(joinPath(normPath(os.homedir()), 'dsh-usage-data'))
401
+ return out
402
+ }
403
+
404
+ // 已知可能遗留旧数据的目录(用于一次性合并迁移),不再作为活动根。
405
+ function knownLegacyRoots() {
406
+ const home = normPath(os.homedir())
407
+ const roots = [home, joinPath(home, '.dsh')]
408
+ try {
409
+ const sp = ctx.get('sandboxPolicy')
410
+ if (sp && sp.workspaceRoot) roots.push(normPath(String(sp.workspaceRoot)))
411
+ } catch (e) {}
412
+ const agent = currentAgent()
413
+ if (agent && agent.session && agent.session.header && agent.session.header.cwd) {
414
+ roots.push(normPath(String(agent.session.header.cwd)))
415
+ }
416
+ return roots
417
+ }
418
+
419
+ // 会话激活后不再切换根目录(避免路径漂移/历史被拆散);仅把工作区里
420
+ // 可能遗留的旧记录并入固定的数据根。
421
+ async function ensureSessionRoot() {
422
+ if (!fs || !persistOk) return
423
+ await migrateLegacy(knownLegacyRoots())
424
+ persistNow()
425
+ }
426
+
427
+ async function initPersistence() {
428
+ if (!fs) { persistError = '文件服务不可用'; return }
429
+ const candidates = resolveDataRoot()
430
+ let lastError = ''
431
+ for (const c of candidates) {
432
+ const r = await tryInitWithRoot(c, undefined)
433
+ if (r.ok) {
434
+ // 首次初始化时把散落在 主目录/.dsh/各工作区 的历史记录合并进来
435
+ await migrateLegacy(knownLegacyRoots())
436
+ persistNow()
437
+ return
438
+ }
439
+ lastError = r.error || '写入失败'
440
+ }
441
+ persistError = lastError || '未找到可写的持久化目录'
442
+ persistOk = false
443
+ root = ''
444
+ dataPath = ''
445
+ }
446
+
447
+ const ensureInit = () => (initPromise ||= initPersistence())
448
+ try { ensureInit() } catch (e) { push('ensureInit-threw: ' + msg(e)) }
449
+
450
+ // ── capture ────────────────────────────────────────────────────────────
451
+ try {
452
+ ctx.on('llm/stream', function (options, next) {
453
+ const source = next()
454
+ const model = (options && options.model) || ''
455
+ const provider = (options && options.provider) || ''
456
+ const purpose = options && options.purpose ? String(options.purpose) : ''
457
+ const startedAt = Date.now()
458
+ let usage = null
459
+ let finishReason = ''
460
+
461
+ async function* observe() {
462
+ try {
463
+ for await (const chunk of source) {
464
+ if (chunk && chunk.type === 'usage' && chunk.usage) {
465
+ usage = chunk.usage
466
+ } else if (chunk && chunk.type === 'finish') {
467
+ const r = chunk.reason
468
+ finishReason = r ? String(r.kind || '') : ''
469
+ }
470
+ yield chunk
471
+ }
472
+ } finally {
473
+ if (usage) {
474
+ if (provider === 'digital-ocean' || provider === 'digitalocean') {
475
+ try { await refreshFxRate(false) } catch (e) {}
476
+ }
477
+ records.push({
478
+ time: startedAt,
479
+ model,
480
+ provider,
481
+ purpose,
482
+ inputTokens: usage.inputTokens || 0,
483
+ outputTokens: usage.outputTokens || 0,
484
+ cacheReadTokens: usage.cacheReadTokens || 0,
485
+ cacheWriteTokens: usage.cacheWriteTokens || 0,
486
+ reasoningTokens: usage.reasoningTokens || 0,
487
+ finishReason,
488
+ usdCnyRate: (provider === 'digital-ocean' || provider === 'digitalocean') ? (FX.rate || 0) : 0,
489
+ fxDate: (provider === 'digital-ocean' || provider === 'digitalocean') ? (FX.date || '') : ''
490
+ })
491
+ if (records.length > MAX_RECORDS) records.splice(0, records.length - MAX_RECORDS)
492
+ try {
493
+ const agent = currentAgent()
494
+ const sp = ctx.get('sandboxPolicy')
495
+ if (sp && typeof sp.resolve === 'function' && agent && agent.session) {
496
+ const policy = sp.resolve({ session: agent.session })
497
+ if (policy && policy.workspaceRoot) cachedPolicy = policy
498
+ }
499
+ } catch (e) {}
500
+ ensureSessionRoot().then(persistNow).catch(() => {})
501
+ }
502
+ }
503
+ }
504
+
505
+ return observe()
506
+ })
507
+ push('llm-stream-listener-ok')
508
+ } catch (e) {
509
+ push('llm-stream-listener-threw: ' + (e && e.stack ? e.stack : msg(e)))
510
+ }
511
+
512
+ // ── balance / network helpers ──────────────────────────────────────────
513
+ async function safeCwd() {
514
+ if (root && fs) {
515
+ try {
516
+ const t = await fs.resolve(root)
517
+ const info = await fs.stat(t)
518
+ if (info) return root
519
+ } catch (e) {}
520
+ }
521
+ return (typeof process !== 'undefined' && typeof process.cwd === 'function' && process.cwd()) || '.'
522
+ }
523
+
524
+ async function runCollect(argv, opts) {
525
+ const subprocess = ctx.get('subprocess')
526
+ if (!subprocess) return { ok: false, error: '命令执行服务不可用' }
527
+ let handle
528
+ try {
529
+ handle = subprocess.spawn({
530
+ argv,
531
+ cwd: await safeCwd(),
532
+ stdio: opts && opts.stdinData != null
533
+ ? { stdin: { data: opts.stdinData }, stdout: { maxBytes: 65536 }, stderr: { maxBytes: 65536 } }
534
+ : { stdin: 'ignore', stdout: { maxBytes: 65536 }, stderr: { maxBytes: 65536 } },
535
+ graceMs: (opts && opts.graceMs) || 15000,
536
+ ...(opts && opts.env ? { env: opts.env } : {})
537
+ })
538
+ } catch (e) { return { ok: false, error: '启动失败:' + msg(e) } }
539
+ let outcome
540
+ try { outcome = await handle.done } catch (e) { return { ok: false, error: '执行失败:' + msg(e) } }
541
+ const outText = handle.collected && handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ''
542
+ const errText = handle.collected && handle.collected.stderr ? handle.collected.stderr.readFrom(0).text : ''
543
+ return { ok: outcome.exitCode === 0, exitCode: outcome.exitCode, out: outText, err: errText }
544
+ }
545
+
546
+ const isElectron = typeof process !== 'undefined' && !!(process.versions && process.versions.electron)
547
+ function nodeCandidates() {
548
+ const list = IS_WIN
549
+ ? ['node.exe', 'node', 'C:\\Program Files\\nodejs\\node.exe']
550
+ : ['node']
551
+ if (typeof process !== 'undefined' && process.execPath && !list.includes(process.execPath)) list.push(process.execPath)
552
+ return list
553
+ }
554
+
555
+ async function spawnNode(script, stdinData, env) {
556
+ const subprocess = ctx.get('subprocess')
557
+ if (!subprocess) return { ok: false, error: '命令执行服务不可用' }
558
+ let exe = null
559
+ for (const c of nodeCandidates()) {
560
+ try { exe = await subprocess.resolveExecutable(c); if (exe) break } catch (e) {}
561
+ }
562
+ if (!exe) return { ok: false, error: '未找到 node 可执行文件' }
563
+ const finalEnv = env || {}
564
+ if (isElectron && exe === process.execPath && !('ELECTRON_RUN_AS_NODE' in finalEnv)) {
565
+ finalEnv.ELECTRON_RUN_AS_NODE = '1'
566
+ }
567
+ const r = await runCollect([exe, '-e', script], { stdinData, env: finalEnv })
568
+ if (!r.ok) {
569
+ if (r.exitCode != null) return { ok: false, error: 'node 退出码 ' + r.exitCode + (r.err ? '' + r.err.trim() : '') }
570
+ return { ok: false, error: r.error || '执行失败' }
571
+ }
572
+ return { ok: true, out: r.out }
573
+ }
574
+
575
+ function bjTodayKey() {
576
+ const d = new Date(Date.now() + 8 * 3600 * 1000)
577
+ return d.getUTCFullYear() + '-' + pad2(d.getUTCMonth() + 1) + '-' + pad2(d.getUTCDate())
578
+ }
579
+
580
+ async function refreshFxRate(force) {
581
+ if (!force && FX.rate > 0 && FX.queriedAt && bjKey(FX.queriedAt) === bjTodayKey()) return FX
582
+ const script = [
583
+ 'const https=require("https");',
584
+ 'const u="https://api.frankfurter.dev/v2/rates?base=USD&quotes=CNY";',
585
+ 'const req=https.get(u,{headers:{Accept:"application/json","User-Agent":"dsh-usage-plugin"}},function(res){',
586
+ 'let b="";res.on("data",c=>b+=c);res.on("end",()=>process.stdout.write(JSON.stringify({status:res.statusCode,body:b})));',
587
+ '});',
588
+ 'req.on("error",e=>process.stdout.write(JSON.stringify({error:String(e&&e.message||e)})));',
589
+ 'req.setTimeout(15000,()=>req.destroy(new Error("timeout")));'
590
+ ].join('\n')
591
+ const r = await spawnNode(script)
592
+ if (!r.ok) {
593
+ FX = { ...FX, stale: FX.rate > 0, error: r.error || '汇率请求失败', queriedAt: Date.now() }
594
+ return FX
595
+ }
596
+ try {
597
+ const wrapper = JSON.parse(r.out)
598
+ if (wrapper.error || wrapper.status !== 200) throw new Error(wrapper.error || ('HTTP ' + wrapper.status))
599
+ const arr = JSON.parse(wrapper.body)
600
+ const row = Array.isArray(arr) ? arr.find((x) => x && x.base === 'USD' && x.quote === 'CNY') : null
601
+ const rate = Number(row && row.rate)
602
+ if (!(rate > 0)) throw new Error('响应中缺少 USD/CNY 汇率')
603
+ FX = { rate, inverse: 1 / rate, date: String(row.date || ''), queriedAt: Date.now(), source: 'Frankfurter', stale: false, error: '' }
604
+ } catch (e) {
605
+ FX = { ...FX, stale: FX.rate > 0, error: msg(e), queriedAt: Date.now() }
606
+ }
607
+ return FX
608
+ }
609
+
610
+ async function resolveCredential(credentials, candidates) {
611
+ const seen = new Set()
612
+ for (const candidate of candidates) {
613
+ const name = typeof candidate === 'string' ? candidate : candidate.name
614
+ if (!name || seen.has(name)) continue
615
+ seen.add(name)
616
+ try {
617
+ const hit = await credentials.resolve(name)
618
+ if (hit && hit.value) {
619
+ return {
620
+ name,
621
+ value: hit.value,
622
+ source: String(hit.source || ''),
623
+ route: typeof candidate === 'string' ? '' : String(candidate.route || '')
624
+ }
625
+ }
626
+ } catch (e) {}
627
+ }
628
+ return null
629
+ }
630
+
631
+ async function configuredModelProvider(provider) {
632
+ if (!provider || provider.queryMode !== 'direct') return null
633
+ try {
634
+ const settings = ctx.get('settings')
635
+ if (!settings || typeof settings.get !== 'function') return null
636
+ const section = await settings.get('llm-pi-ai')
637
+ const profiles = section && section.providers
638
+ if (!profiles || typeof profiles !== 'object') return null
639
+ for (const route of Object.keys(profiles)) {
640
+ const profile = profiles[route]
641
+ if (!profile || typeof profile !== 'object') continue
642
+ if (!matchesModelProvider(provider.id, route, profile.displayName)) continue
643
+ return {
644
+ route,
645
+ apiKeyEnv: typeof profile.apiKeyEnv === 'string' ? profile.apiKeyEnv.trim() : '',
646
+ baseURL: typeof profile.baseURL === 'string' ? profile.baseURL.trim() : ''
647
+ }
648
+ }
649
+ } catch (e) {}
650
+ return null
651
+ }
652
+
653
+ function balanceFailure(provider, error, fields) {
654
+ return {
655
+ ok: false,
656
+ provider: provider.id,
657
+ providerName: provider.name,
658
+ error,
659
+ credentialHelpUrl: provider.credentialHelpUrl || '',
660
+ ...(fields || {})
661
+ }
662
+ }
663
+
664
+ const DIGITALOCEAN_CREDENTIAL = 'DIGITALOCEAN_TOKEN'
665
+
666
+ async function credentialDescription(credentials, name) {
667
+ if (credentials && typeof credentials.describe === 'function') {
668
+ try {
669
+ const info = await credentials.describe(name)
670
+ return {
671
+ configured: !!(info && info.configured),
672
+ source: String((info && info.source) || ''),
673
+ writable: !!(info && info.writable)
674
+ }
675
+ } catch (e) {}
676
+ }
677
+ const hit = await resolveCredential(credentials, [{ name, route: '' }])
678
+ return {
679
+ configured: !!hit,
680
+ source: hit ? hit.source : '',
681
+ writable: !!(credentials && typeof credentials.set === 'function')
682
+ }
683
+ }
684
+
685
+ async function balanceCredentialStatus(providerId) {
686
+ const provider = getBalanceProvider(providerId)
687
+ if (!provider) return fail('不支持的余额服务商:' + String(providerId || ''))
688
+ const credentials = ctx.get('credentials')
689
+ if (!credentials) return balanceFailure(provider, '凭据服务不可用', { errorCode: 'credentials-unavailable' })
690
+ if (provider.id === 'siliconflow') {
691
+ const profile = await configuredModelProvider(provider)
692
+ if (!profile) {
693
+ return balanceFailure(provider, '未在“设置 模型”中找到 Provider ID 或显示名为 siliconflow 的模型提供商。', { errorCode: 'model-provider-missing' })
694
+ }
695
+ if (!profile.apiKeyEnv) {
696
+ return balanceFailure(provider, '模型提供商 ' + profile.route + ' 没有配置 apiKeyEnv;请编辑该模型提供商并保存 API Key。', { errorCode: 'model-credential-ref-missing', modelProviderRoute: profile.route })
697
+ }
698
+ const info = await credentialDescription(credentials, profile.apiKeyEnv)
699
+ return {
700
+ ok: true,
701
+ provider: provider.id,
702
+ configured: info.configured,
703
+ source: info.source,
704
+ writable: info.writable,
705
+ masked: info.configured ? '••••••••••••' : '',
706
+ credentialName: profile.apiKeyEnv,
707
+ modelProviderRoute: profile.route
708
+ }
709
+ }
710
+ if (provider.id === 'digitalocean') {
711
+ let credentialName = DIGITALOCEAN_CREDENTIAL
712
+ let info = await credentialDescription(credentials, credentialName)
713
+ if (!info.configured) {
714
+ for (const candidate of provider.credentialNames) {
715
+ if (candidate === DIGITALOCEAN_CREDENTIAL) continue
716
+ const candidateInfo = await credentialDescription(credentials, candidate)
717
+ if (!candidateInfo.configured) continue
718
+ credentialName = candidate
719
+ info = candidateInfo
720
+ break
721
+ }
722
+ }
723
+ return {
724
+ ok: true,
725
+ provider: provider.id,
726
+ configured: info.configured,
727
+ source: info.source,
728
+ writable: info.writable,
729
+ masked: info.configured ? '••••••••••••' : '',
730
+ credentialName
731
+ }
732
+ }
733
+ return balanceFailure(provider, '该服务商不支持在余额页管理凭据', { errorCode: 'credential-management-unsupported' })
734
+ }
735
+
736
+ async function saveBalanceCredential(providerId, rawValue) {
737
+ const provider = getBalanceProvider(providerId)
738
+ if (!provider || provider.id !== 'digitalocean') return fail('仅支持在余额页保存 DigitalOcean 账户 Token')
739
+ const value = String(rawValue || '').trim()
740
+ if (!/^dop_v1_[A-Za-z0-9_-]{20,}$/.test(value)) {
741
+ return balanceFailure(provider, 'Token 格式不正确:请输入 DigitalOcean 控制台创建的 dop_v1_ Personal Access Token,不要使用 DO AI 推理 Key。', { errorCode: 'invalid-credential-format' })
742
+ }
743
+ const credentials = ctx.get('credentials')
744
+ if (!credentials || typeof credentials.set !== 'function') {
745
+ return balanceFailure(provider, '当前 Harness 凭据服务不支持安全保存 Token', { errorCode: 'credentials-read-only' })
746
+ }
747
+ const info = await credentialDescription(credentials, DIGITALOCEAN_CREDENTIAL)
748
+ if (info.configured && !info.writable) {
749
+ return balanceFailure(provider, 'DIGITALOCEAN_TOKEN 当前由只读来源 ' + (info.source || '环境变量') + ' 提供,不能在页面覆盖;请修改该来源后重启。', { errorCode: 'credential-read-only', credentialSource: info.source })
750
+ }
751
+ try {
752
+ await credentials.set(DIGITALOCEAN_CREDENTIAL, value)
753
+ } catch (e) {
754
+ return balanceFailure(provider, '保存 Token 失败:' + msg(e), { errorCode: 'credential-save-failed' })
755
+ }
756
+ const saved = await credentialDescription(credentials, DIGITALOCEAN_CREDENTIAL)
757
+ if (!saved.configured) return balanceFailure(provider, 'Token 保存后未能从凭据服务中重新读取', { errorCode: 'credential-save-unverified' })
758
+ return {
759
+ ok: true,
760
+ provider: provider.id,
761
+ configured: true,
762
+ source: saved.source,
763
+ writable: saved.writable,
764
+ masked: '••••••••••••',
765
+ credentialName: DIGITALOCEAN_CREDENTIAL
766
+ }
767
+ }
768
+
769
+ async function queryBalance(providerId) {
770
+ const provider = getBalanceProvider(providerId)
771
+ if (!provider) return fail('不支持的余额服务商:' + String(providerId || ''))
772
+ if (provider.queryMode === 'unsupported') {
773
+ return balanceFailure(provider, 'AMD GPU Cloud 当前未公开可由推理 API Key 调用的余额查询端点;请在 AMD Developer Cloud 控制台查看 credits。', { unsupported: true, errorCode: 'unsupported' })
774
+ }
775
+ const credentials = ctx.get('credentials')
776
+ if (!credentials) return balanceFailure(provider, '凭据服务不可用', { errorCode: 'credentials-unavailable' })
777
+ const modelProfile = await configuredModelProvider(provider)
778
+ const credentialCandidates = []
779
+ if (provider.id === 'siliconflow' && !modelProfile) {
780
+ return balanceFailure(provider, '未在“设置 → 模型”中找到 Provider ID 或显示名为 siliconflow 的模型提供商。请先添加该提供商、填写 API Key 并保存,然后返回此页查询。', { errorCode: 'model-provider-missing' })
781
+ }
782
+ if (provider.id === 'siliconflow' && modelProfile && !modelProfile.apiKeyEnv) {
783
+ return balanceFailure(provider, '模型提供商 ' + modelProfile.route + ' 没有配置 API Key。请在“设置 → 模型”中编辑该提供商并保存 API Key。', { errorCode: 'model-credential-ref-missing', modelProviderRoute: modelProfile.route })
784
+ }
785
+ if (modelProfile && modelProfile.apiKeyEnv) credentialCandidates.push({ name: modelProfile.apiKeyEnv, route: modelProfile.route })
786
+ if (provider.id !== 'siliconflow') {
787
+ for (const name of provider.credentialNames) credentialCandidates.push({ name, route: '' })
788
+ }
789
+ const hit = await resolveCredential(credentials, credentialCandidates)
790
+ if (!hit) {
791
+ const message = provider.id === 'digitalocean'
792
+ ? '尚未保存 DigitalOcean 账户 Personal Access Token。请在此页面输入 dop_v1_ Token,保存后查询。'
793
+ : provider.id === 'siliconflow'
794
+ ? '模型提供商 ' + modelProfile.route + ' 引用了 ' + modelProfile.apiKeyEnv + ',但该凭据未配置。请在“设置 → 模型”中重新填写 API Key 并保存。'
795
+ : '未找到 ' + provider.credentialHint + ',请配置后重试'
796
+ return balanceFailure(provider, message, { errorCode: 'missing-credential', modelProviderRoute: modelProfile ? modelProfile.route : '' })
797
+ }
798
+ const endpoint = resolveBalanceEndpoint(provider.id, modelProfile && modelProfile.baseURL)
799
+ const script = [
800
+ 'const https=require("https");',
801
+ 'const key=process.env.BALANCE_API_KEY||"";',
802
+ 'const url=process.env.BALANCE_API_URL||"";',
803
+ 'const req=https.get(url,{headers:{Authorization:"Bearer "+key,Accept:"application/json","User-Agent":"dsh-usage-plugin"}},function(res){',
804
+ 'var body="";',
805
+ 'res.on("data",function(c){body+=c});',
806
+ 'res.on("end",function(){process.stdout.write(JSON.stringify({statusCode:res.statusCode,contentType:String(res.headers["content-type"]||""),body:body}))});',
807
+ '});',
808
+ 'req.on("error",function(e){process.stdout.write(JSON.stringify({error:String(e&&e.message||e)}))});',
809
+ 'req.setTimeout(20000,function(){req.destroy(new Error("timeout"))});'
810
+ ].join('\n')
811
+ const r = await spawnNode(script, null, { BALANCE_API_KEY: hit.value, BALANCE_API_URL: endpoint })
812
+ if (!r.ok) return balanceFailure(provider, r.error, { errorCode: 'request-failed' })
813
+ let parsed
814
+ try { parsed = JSON.parse(r.out) } catch (e) { return balanceFailure(provider, '无法解析 node 输出', { errorCode: 'invalid-response' }) }
815
+ if (parsed.error) return balanceFailure(provider, parsed.error, { errorCode: 'request-failed' })
816
+ if (parsed.statusCode !== 200) {
817
+ const authHint = parsed.statusCode === 401 || parsed.statusCode === 403 ? ' 请检查凭据是否属于该账户、是否有效及是否具备余额/账单读取权限。' : ''
818
+ return balanceFailure(provider, '接口返回 HTTP ' + parsed.statusCode + ':' + String(parsed.body || '').slice(0, 300) + authHint, {
819
+ errorCode: parsed.statusCode === 401 || parsed.statusCode === 403 ? 'unauthorized' : 'http-error',
820
+ statusCode: parsed.statusCode,
821
+ credentialName: hit.name,
822
+ credentialSource: hit.source,
823
+ modelProviderRoute: hit.route || (modelProfile ? modelProfile.route : '')
824
+ })
825
+ }
826
+ if (!String(parsed.contentType || '').toLowerCase().includes('application/json')) {
827
+ return balanceFailure(provider, '接口返回了非 JSON 内容(Content-Type: ' + String(parsed.contentType || '未知') + '),请求可能被网络代理拦截。', { errorCode: 'invalid-content-type', statusCode: parsed.statusCode })
828
+ }
829
+ const normalized = parseBalanceResponse(provider.id, parsed.body)
830
+ if (normalized.ok) {
831
+ normalized.credentialName = hit.name
832
+ normalized.credentialSource = hit.source
833
+ normalized.modelProviderRoute = hit.route || (modelProfile ? modelProfile.route : '')
834
+ normalized.endpoint = endpoint
835
+ } else {
836
+ normalized.provider = provider.id
837
+ normalized.providerName = provider.name
838
+ normalized.credentialHelpUrl = provider.credentialHelpUrl || ''
839
+ }
840
+ return normalized
841
+ }
842
+
843
+ // ── export helpers ─────────────────────────────────────────────────────
844
+ function csvCell(s) {
845
+ s = String(s == null ? '' : s)
846
+ if (/[",\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"'
847
+ return s
848
+ }
849
+
850
+ function buildCsv() {
851
+ const header = ['time', 'model', 'provider', 'inputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'outputTokens', 'reasoningTokens', 'finishReason', 'period', 'baseCost', 'peakValleyCost', 'autoCost']
852
+ const lines = [header.join(',')]
853
+ for (const r of records) {
854
+ lines.push([
855
+ r.time, r.model, r.provider, r.inputTokens, r.cacheReadTokens, r.cacheWriteTokens,
856
+ r.outputTokens, r.reasoningTokens, r.finishReason,
857
+ isPeak(r.time) ? 'peak' : 'offPeak', costFor(r, 'base'), costFor(r, 'peakValley'), costFor(r, 'auto')
858
+ ].map(csvCell).join(','))
859
+ }
860
+ return lines.join('\r\n')
861
+ }
862
+
863
+ async function writePngFile(base64, outPath) {
864
+ const script = [
865
+ 'const fs=require("fs");',
866
+ 'let d="";',
867
+ 'process.stdin.on("data",function(c){d+=c});',
868
+ 'process.stdin.on("end",function(){',
869
+ ' const buf=Buffer.from(d,"base64");',
870
+ ' fs.mkdirSync(require("path").dirname(process.env.PNG_PATH),{recursive:true});',
871
+ ' fs.writeFileSync(process.env.PNG_PATH,buf);',
872
+ ' process.stdout.write(JSON.stringify({ok:true,bytes:buf.length}));',
873
+ '});'
874
+ ].join('\n')
875
+ return spawnNode(script, base64, { PNG_PATH: outPath })
876
+ }
877
+
878
+ async function writeTextFileViaNode(content, outPath) {
879
+ const script = [
880
+ 'const fs=require("fs");',
881
+ 'let d="";',
882
+ 'process.stdin.on("data",function(c){d+=c});',
883
+ 'process.stdin.on("end",function(){',
884
+ ' fs.mkdirSync(require("path").dirname(process.env.OUT_PATH),{recursive:true});',
885
+ ' fs.writeFileSync(process.env.OUT_PATH, Buffer.from(d,"utf8"));',
886
+ ' process.stdout.write(JSON.stringify({ok:true}));',
887
+ '});'
888
+ ].join('\n')
889
+ return spawnNode(script, content, { OUT_PATH: outPath })
890
+ }
891
+
892
+ async function mkdirViaNode(dir) {
893
+ const script = [
894
+ 'const fs=require("fs");',
895
+ 'fs.mkdirSync(process.env.MKDIR_PATH,{recursive:true});',
896
+ 'process.stdout.write(JSON.stringify({ok:true}));'
897
+ ].join('\n')
898
+ return spawnNode(script, null, { MKDIR_PATH: dir })
899
+ }
900
+
901
+ async function pickDirectory() {
902
+ const subprocess = ctx.get('subprocess')
903
+ if (!subprocess) return fail('命令执行服务不可用')
904
+ if (IS_MAC) {
905
+ let exe = null
906
+ try { exe = await subprocess.resolveExecutable('osascript') } catch (e) {}
907
+ if (!exe) return fail('未找到 osascript(macOS 需安装命令行工具 Command Line Tools)')
908
+ const r = await runCollect([exe, '-e', 'POSIX path of (choose folder)'], { graceMs: 120000 })
909
+ if (!r.ok && r.error) return fail(r.error)
910
+ const picked = normPath(r.out.trim())
911
+ if (!picked) return { ok: false, cancelled: true }
912
+ return { ok: true, path: picked }
913
+ }
914
+ if (!IS_WIN) {
915
+ for (const c of ['zenity', 'kdialog']) {
916
+ let exe = null
917
+ try { exe = await subprocess.resolveExecutable(c) } catch (e) {}
918
+ if (!exe) continue
919
+ const argv = c === 'zenity'
920
+ ? [exe, '--file-selection', '--directory', '--title=选择导出目录']
921
+ : [exe, '--getexistingdirectory', '选择导出目录']
922
+ const r = await runCollect(argv, { graceMs: 120000 })
923
+ if (!r.ok && r.error) return fail(r.error)
924
+ const picked = normPath(r.out.trim())
925
+ if (!picked) return { ok: false, cancelled: true }
926
+ return { ok: true, path: picked }
927
+ }
928
+ return fail('未找到目录选择工具(请安装 zenity 或 kdialog)')
929
+ }
930
+ let exe = null
931
+ for (const c of ['powershell.exe', 'pwsh.exe', 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe']) {
932
+ try { exe = await subprocess.resolveExecutable(c); if (exe) break } catch (e) {}
933
+ }
934
+ if (!exe) return fail('未找到 PowerShell')
935
+ 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) }'
936
+ const r = await runCollect([exe, '-NoProfile', '-STA', '-NonInteractive', '-Command', script], { graceMs: 120000 })
937
+ if (!r.ok && r.error) return fail(r.error)
938
+ const picked = normPath(r.out.trim())
939
+ if (!picked) return { ok: false, cancelled: true }
940
+ return { ok: true, path: picked }
941
+ }
942
+
943
+ async function revealDir(dirArg) {
944
+ const subprocess = ctx.get('subprocess')
945
+ if (!subprocess) return fail('命令执行服务不可用')
946
+ let target = ''
947
+ const isKey = dirArg === 'csv' || dirArg === 'json' || dirArg === 'images' || dirArg === 'data'
948
+ if (isKey) {
949
+ const d = dirs()
950
+ target = dirArg === 'csv' ? d.csv : dirArg === 'json' ? d.json : dirArg === 'images' ? d.images : d.data
951
+ target = normPath(target)
952
+ const policy = sessionPolicy()
953
+ try {
954
+ const t = await fs.resolve(joinPath(target, '.keep'))
955
+ await fs.writeText(t, '', undefined, undefined, policy || undefined)
956
+ } catch (e) {}
957
+ } else {
958
+ target = normPath(dirArg)
959
+ await mkdirViaNode(target)
960
+ }
961
+ const revealCmd = IS_WIN ? 'explorer.exe' : (IS_MAC ? 'open' : 'xdg-open')
962
+ let exe = null
963
+ try { exe = await subprocess.resolveExecutable(revealCmd) } catch (e) {}
964
+ if (!exe) return fail('未找到 ' + revealCmd)
965
+ try {
966
+ subprocess.spawn({ argv: [exe, target], cwd: await safeCwd(), stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } }, graceMs: 5000 })
967
+ return { ok: true }
968
+ } catch (e) { return fail(msg(e)) }
969
+ }
970
+
971
+ // ── API ────────────────────────────────────────────────────────────────
972
+ async function routeApi(body) {
973
+ const action = body && body.action ? String(body.action) : ''
974
+ try { await ensureInit() } catch (e) {}
975
+ switch (action) {
976
+ case 'list': {
977
+ try { await refreshFxRate(false) } catch (e) {}
978
+ const items = records.map((r) => ({
979
+ time: r.time, model: r.model, provider: r.provider, purpose: r.purpose,
980
+ inputTokens: r.inputTokens, outputTokens: r.outputTokens,
981
+ cacheReadTokens: r.cacheReadTokens, cacheWriteTokens: r.cacheWriteTokens,
982
+ reasoningTokens: r.reasoningTokens, finishReason: r.finishReason,
983
+ usdCnyRate: r.usdCnyRate || 0, fxDate: r.fxDate || '',
984
+ modelKey: modelKey(r.model),
985
+ baseCost: costFor(r, 'base'), peakValleyCost: costFor(r, 'peakValley'), autoCost: costFor(r, 'auto'),
986
+ peak: isPeak(r.time)
987
+ }))
988
+ return { ok: true, records: items, count: items.length, dataPath, persistOk, persistError, pricing: PRICING, effectiveAt: EFFECTIVE_AT, days: buildDays(), fx: FX }
989
+ }
990
+ case 'clear': {
991
+ const n = records.length
992
+ records.length = 0
993
+ persistNow()
994
+ return { ok: true, cleared: n }
995
+ }
996
+ case 'setPrices': {
997
+ const prices = body && body.prices
998
+ if (!prices || typeof prices !== 'object') return fail('缺少价格数据')
999
+ let changed = false
1000
+ for (const regime of ['base', 'peakValley']) {
1001
+ const src = prices[regime]
1002
+ const dst = PRICING[regime]
1003
+ if (!src || typeof src !== 'object' || !dst) continue
1004
+ for (const mk of PRICE_MODELS) {
1005
+ const row = src[mk]
1006
+ if (!row || typeof row !== 'object' || !dst[mk]) continue
1007
+ for (const k of ['cacheHit', 'cacheMiss', 'output']) {
1008
+ const v = Number(row[k])
1009
+ if (Number.isFinite(v) && v >= 0) { dst[mk][k] = v; changed = true }
1010
+ }
1011
+ }
1012
+ }
1013
+ if (!changed) return fail('没有可用的价格更新(价格必须是非负数字)')
1014
+ persistPricing()
1015
+ return { ok: true }
1016
+ }
1017
+ case 'resetPrices': {
1018
+ for (const regime of ['base', 'peakValley']) {
1019
+ const src = DEFAULT_PRICING[regime]
1020
+ const dst = PRICING[regime]
1021
+ if (!src || !dst) continue
1022
+ for (const mk of PRICE_MODELS) {
1023
+ if (!src[mk] || !dst[mk]) continue
1024
+ dst[mk].cacheHit = src[mk].cacheHit
1025
+ dst[mk].cacheMiss = src[mk].cacheMiss
1026
+ dst[mk].output = src[mk].output
1027
+ }
1028
+ }
1029
+ persistPricing()
1030
+ return { ok: true }
1031
+ }
1032
+ case 'fxRefresh': {
1033
+ const fx = await refreshFxRate(true)
1034
+ return { ok: fx.rate > 0, fx, error: fx.rate > 0 ? '' : (fx.error || '无法获取汇率') }
1035
+ }
1036
+ case 'balance':
1037
+ return queryBalance(body && body.provider)
1038
+ case 'balanceProviders':
1039
+ return { ok: true, providers: providerList() }
1040
+ case 'balanceCredentialStatus':
1041
+ return balanceCredentialStatus(body && body.provider)
1042
+ case 'saveBalanceCredential':
1043
+ return saveBalanceCredential(body && body.provider, body && body.value)
1044
+ case 'pickDir':
1045
+ return pickDirectory()
1046
+ case 'export': {
1047
+ if (!root) return fail('未找到工作区路径')
1048
+ const kind = (body && body.kind) === 'json' ? 'json' : 'csv'
1049
+ const name = 'dsh-usage-' + stamp() + (kind === 'json' ? '.json' : '.csv')
1050
+ const content = kind === 'json'
1051
+ ? JSON.stringify({ exportedAt: Date.now(), pricing: PRICING, records }, null, 2)
1052
+ : buildCsv()
1053
+ const dirArg = body && body.dir ? normPath(String(body.dir)) : ''
1054
+ if (dirArg) {
1055
+ const outPath = joinPath(dirArg, name)
1056
+ const r = await writeTextFileViaNode(content, outPath)
1057
+ if (!r.ok) return fail(r.error)
1058
+ return { ok: true, path: outPath, name, dir: dirArg }
1059
+ }
1060
+ const outPath = joinPath(kind === 'json' ? dirs().json : dirs().csv, name)
1061
+ try {
1062
+ const target = await fs.resolve(outPath)
1063
+ await fs.writeText(target, content, undefined, undefined, sessionPolicy() || undefined)
1064
+ return { ok: true, path: normPath(fs.processPath ? fs.processPath(target) : outPath), name, dir: kind === 'json' ? 'json' : 'csv' }
1065
+ } catch (e) { return fail(msg(e)) }
1066
+ }
1067
+ case 'exportPng': {
1068
+ const dataUrl = body && body.dataUrl ? String(body.dataUrl) : ''
1069
+ if (!dataUrl) return fail('缺少图片数据')
1070
+ const idx = dataUrl.indexOf('base64,')
1071
+ const b64 = idx >= 0 ? dataUrl.slice(idx + 7) : dataUrl
1072
+ if (!root) return fail('未找到工作区路径')
1073
+ const name = 'dsh-usage-report-' + stamp() + '.png'
1074
+ const dirArg = body && body.dir ? normPath(String(body.dir)) : ''
1075
+ const outPath = normPath(joinPath(dirArg || dirs().images, name))
1076
+ const r = await writePngFile(b64, outPath)
1077
+ if (!r.ok) return fail(r.error)
1078
+ return { ok: true, path: outPath, name, dir: dirArg || 'images' }
1079
+ }
1080
+ case 'import': {
1081
+ const content = body && body.content != null ? String(body.content) : ''
1082
+ const filename = body && body.filename ? String(body.filename) : ''
1083
+ if (!content) return fail('请选择要导入的文件')
1084
+ let parsed
1085
+ if (String(filename || '').toLowerCase().indexOf('.csv') >= 0) {
1086
+ const lines = String(content).split(/\r?\n/).filter((l) => l.trim().length > 0)
1087
+ const header = lines[0] ? parseCsvLine(lines[0]) : []
1088
+ const idx = {}
1089
+ header.forEach((h, i) => { idx[String(h).trim()] = i })
1090
+ parsed = lines.slice(1).map((line) => {
1091
+ const cells = parseCsvLine(line)
1092
+ const get = (name) => (idx[name] === undefined ? '' : (cells[idx[name]] === undefined ? '' : cells[idx[name]]))
1093
+ return {
1094
+ time: get('time'), model: get('model'), provider: get('provider'),
1095
+ inputTokens: get('inputTokens'), outputTokens: get('outputTokens'),
1096
+ cacheReadTokens: get('cacheReadTokens'), cacheWriteTokens: get('cacheWriteTokens'),
1097
+ reasoningTokens: get('reasoningTokens'), finishReason: get('finishReason')
1098
+ }
1099
+ })
1100
+ } else {
1101
+ try {
1102
+ const data = JSON.parse(content)
1103
+ parsed = Array.isArray(data) ? data : (data && Array.isArray(data.records) ? data.records : null)
1104
+ } catch (e) { parsed = null }
1105
+ }
1106
+ if (!parsed || !Array.isArray(parsed)) return fail('文件内容不是可识别的用量数据(支持 JSON 或 CSV)')
1107
+ let imported = 0, skipped = 0, invalid = 0
1108
+ const existing = {}
1109
+ for (const r of records) existing[r.time] = true
1110
+ for (const raw of parsed) {
1111
+ const rec = normalizeRecord(raw)
1112
+ if (!rec) { invalid++; continue }
1113
+ if (existing[rec.time]) { skipped++; continue }
1114
+ existing[rec.time] = true
1115
+ records.push(rec)
1116
+ imported++
1117
+ }
1118
+ if (records.length > MAX_RECORDS) records.splice(0, records.length - MAX_RECORDS)
1119
+ records.sort((a, b) => a.time - b.time)
1120
+ persistNow()
1121
+ return { ok: true, imported, skipped, invalid, total: records.length }
1122
+ }
1123
+ case 'reveal': {
1124
+ const dirArg = body && body.dir ? String(body.dir) : 'data'
1125
+ return revealDir(dirArg)
1126
+ }
1127
+ default:
1128
+ return fail('未知操作:' + action)
1129
+ }
1130
+ }
1131
+
1132
+ function parseCsvLine(line) {
1133
+ const cells = []
1134
+ let cur = ''
1135
+ let inQ = false
1136
+ for (let i = 0; i < line.length; i++) {
1137
+ const ch = line[i]
1138
+ if (inQ) {
1139
+ if (ch === '"') {
1140
+ if (line[i + 1] === '"') { cur += '"'; i++ } else inQ = false
1141
+ } else cur += ch
1142
+ } else if (ch === '"') inQ = true
1143
+ else if (ch === ',') { cells.push(cur); cur = '' }
1144
+ else cur += ch
1145
+ }
1146
+ cells.push(cur)
1147
+ return cells
1148
+ }
1149
+
1150
+ function readBody(req) {
1151
+ return new Promise((resolve) => {
1152
+ let d = ''
1153
+ req.on('data', (c) => { d += c })
1154
+ req.on('end', () => { try { resolve(JSON.parse(d)) } catch (e) { resolve({}) } })
1155
+ req.on('error', () => resolve({}))
1156
+ })
1157
+ }
1158
+
1159
+ function sendJson(res, obj) {
1160
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
1161
+ res.end(JSON.stringify(obj))
1162
+ }
1163
+
1164
+ const webServer = ctx.get('webServer')
1165
+ push('webServer=' + (webServer ? 'present' : 'undefined'))
1166
+ if (webServer && typeof webServer.register === 'function') {
1167
+ try {
1168
+ webServer.register({
1169
+ kind: 'exact',
1170
+ path: '/usage/api',
1171
+ handler: async (req, res) => {
1172
+ try {
1173
+ const body = await readBody(req)
1174
+ sendJson(res, await routeApi(body))
1175
+ } catch (e) {
1176
+ sendJson(res, { ok: false, error: msg(e) })
1177
+ }
1178
+ }
1179
+ })
1180
+ push('route-registered')
1181
+ } catch (e) {
1182
+ push('route-register-threw: ' + (e && e.stack ? e.stack : msg(e)))
1183
+ }
1184
+ } else {
1185
+ push('route-not-registered (no webServer)')
1186
+ }
1187
+
1188
+ push('apply-end')
1189
+ diag.ok = true
1190
+ } catch (e) {
1191
+ diag.ok = false
1192
+ diag.error = (e && e.stack) ? e.stack : String(e)
1193
+ }
1194
+ flushDiag()
1195
+ }
1196
+ }