dsh-token-plugin 0.0.0-stage → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +671 -0
- package/LICENSE +21 -0
- package/README.md +202 -3
- package/README.zh.md +204 -0
- package/cordis.patch.yml +23 -0
- package/lib/client.js +2485 -0
- package/lib/index.js +1722 -0
- package/lib/records.js +125 -0
- package/lib/storage.js +66 -0
- package/package.json +101 -6
- package/scripts/check-package.js +84 -0
- package/scripts/release.mjs +97 -0
- package/scripts/upload-asset.mjs +50 -0
- package/scripts/wire.js +109 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,1722 @@
|
|
|
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 { randomUUID } from 'node:crypto'
|
|
20
|
+
import { createRequire } from 'node:module'
|
|
21
|
+
import { promises as fsp, existsSync } from 'node:fs'
|
|
22
|
+
import { normalizeRecord, recordKey } from './records.js'
|
|
23
|
+
import { atomicWriteText, readStoredRecords } from './storage.js'
|
|
24
|
+
|
|
25
|
+
// 插件自身版本号:直接读 package.json,不在代码里再写死一份——否则发版时
|
|
26
|
+
// 很容易出现"面板显示 1.18.0、实际跑的是 1.20.0"这种排查事故。
|
|
27
|
+
// 客户端顶部用它展示当前运行的版本。
|
|
28
|
+
const PKG_META = (() => {
|
|
29
|
+
try {
|
|
30
|
+
return createRequire(import.meta.url)('../package.json') || {}
|
|
31
|
+
} catch (e) {
|
|
32
|
+
return {}
|
|
33
|
+
}
|
|
34
|
+
})()
|
|
35
|
+
export const PLUGIN_VERSION = String(PKG_META.version || '')
|
|
36
|
+
export const PLUGIN_NAME = String(PKG_META.name || '')
|
|
37
|
+
|
|
38
|
+
// 周末统一按空闲价计费规则生效时间:北京时间 2026-08-23 00:00(周日)。
|
|
39
|
+
// 生效前(含周末)仍按原峰谷分段计费;生效后周末(周六、周日)全天统一按空闲价,工作日仍按峰谷分段。
|
|
40
|
+
export const WEEKEND_FLAT_AT = Date.parse('2026-08-23T00:00:00+08:00')
|
|
41
|
+
|
|
42
|
+
// 法定节假日规则生效时刻:北京时间 2026-11-01 00:00。
|
|
43
|
+
// 官方价目页(2026-11-01 核对)写明高峰时段是「周一至周五(不含中国法定节假日)」,
|
|
44
|
+
// 此前本插件只处理了周末、没处理节假日。为了**不改写历史账单**,这条规则只对
|
|
45
|
+
// 该时刻之后的调用生效;此前已产生的记录继续按当时实现的规则计费。
|
|
46
|
+
export const HOLIDAY_RULE_AT = Date.parse('2026-11-01T00:00:00+08:00')
|
|
47
|
+
|
|
48
|
+
// 国务院办公厅《关于 2026 年部分节假日安排的通知》公布的放假日期(含调休连休段)。
|
|
49
|
+
// 只列「放假」日;调休上班的周末(如 2/14、5/9)按官方口径仍属周末 → 空闲价。
|
|
50
|
+
export const HOLIDAYS_2026 = Object.freeze([
|
|
51
|
+
'2026-01-01', '2026-01-02', '2026-01-03',
|
|
52
|
+
'2026-02-15', '2026-02-16', '2026-02-17', '2026-02-18', '2026-02-19', '2026-02-20', '2026-02-21', '2026-02-22', '2026-02-23',
|
|
53
|
+
'2026-04-04', '2026-04-05', '2026-04-06',
|
|
54
|
+
'2026-05-01', '2026-05-02', '2026-05-03', '2026-05-04', '2026-05-05',
|
|
55
|
+
'2026-06-19', '2026-06-20', '2026-06-21',
|
|
56
|
+
'2026-09-25', '2026-09-26', '2026-09-27',
|
|
57
|
+
'2026-10-01', '2026-10-02', '2026-10-03', '2026-10-04', '2026-10-05', '2026-10-06', '2026-10-07'
|
|
58
|
+
])
|
|
59
|
+
|
|
60
|
+
/** 把 ts 换算成北京时间的 `YYYY-MM-DD`(节假日表用这个键)。 */
|
|
61
|
+
export function bjDayKeyOf(ts) {
|
|
62
|
+
const d = new Date(Number(ts) + 8 * 3600 * 1000)
|
|
63
|
+
const pad = (n) => (n < 10 ? '0' : '') + n
|
|
64
|
+
return d.getUTCFullYear() + '-' + pad(d.getUTCMonth() + 1) + '-' + pad(d.getUTCDate())
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 高峰时段判定。
|
|
69
|
+
*
|
|
70
|
+
* 规则(官方价目页):
|
|
71
|
+
* - 2026-08-23 起:周末(周六、周日)全天按空闲价;
|
|
72
|
+
* - 2026-11-01 起:法定节假日全天按空闲价(`holidays` 为北京日期键集合);
|
|
73
|
+
* - 其余时间按北京时间 9:00–12:00、14:00–18:00 为高峰。
|
|
74
|
+
*
|
|
75
|
+
* 星期取自平移后的北京时间(ts + 8h),避免 UTC 日历与北京日历在 16:00–24:00 UTC 段错位。
|
|
76
|
+
* 导出为独立函数,供 test/period.test.js 做回归测试(issue #9)。
|
|
77
|
+
*
|
|
78
|
+
* @param ts - 计费时刻(毫秒)。
|
|
79
|
+
* @param holidays - 可选,法定节假日日期键(Set 或数组),如 `new Set(['2027-01-01'])`。
|
|
80
|
+
*/
|
|
81
|
+
export function isPeakAt(ts, holidays) {
|
|
82
|
+
const d = new Date(ts + 8 * 3600 * 1000)
|
|
83
|
+
const wd = d.getUTCDay() // 0=周日 1=周一 … 6=周六
|
|
84
|
+
if (ts >= WEEKEND_FLAT_AT && (wd === 0 || wd === 6)) return false
|
|
85
|
+
if (ts >= HOLIDAY_RULE_AT && holidays) {
|
|
86
|
+
const key = bjDayKeyOf(ts)
|
|
87
|
+
const isHoliday = typeof holidays.has === 'function' ? holidays.has(key) : Array.isArray(holidays) && holidays.indexOf(key) >= 0
|
|
88
|
+
if (isHoliday) return false
|
|
89
|
+
}
|
|
90
|
+
const t = d.getUTCHours() * 60 + d.getUTCMinutes()
|
|
91
|
+
return (t >= 9 * 60 && t < 12 * 60) || (t >= 14 * 60 && t < 18 * 60)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 官方价格表分段生效时间(北京时间):
|
|
95
|
+
// - 2026-08-17 00:00:旧基础价(base)→ 第一版峰谷价(peakValley)。
|
|
96
|
+
// - 2026-09-10 12:00:DeepSeek V4.1 Flash 调价生效,峰谷价切换到新版
|
|
97
|
+
// (peakValleyV41:deepseek-flash 单价下调;deepseek-v4-pro 价格不变)。
|
|
98
|
+
// 导出为独立常量,供 test/pricing.test.js 做回归测试。
|
|
99
|
+
export const PEAK_VALLEY_AT = Date.parse('2026-08-17T00:00:00+08:00')
|
|
100
|
+
export const V41_EFFECTIVE_AT = Date.parse('2026-09-10T12:00:00+08:00')
|
|
101
|
+
|
|
102
|
+
// ── DeepSeek 官方价格表(单位:元 / 百万 tokens)──────────────────────
|
|
103
|
+
// 按生效时间分三段:
|
|
104
|
+
// base —— 旧基础价(2026-08-17 00:00 之前)
|
|
105
|
+
// peakValley —— 第一版峰谷价(2026-08-17 00:00 ~ 2026-09-10 12:00)
|
|
106
|
+
// peakValleyV41 —— DeepSeek V4.1 Flash 调价后的峰谷价(2026-09-10 12:00 起)
|
|
107
|
+
// 模型档位按官方新命名归并:
|
|
108
|
+
// deepseek-flash ← deepseek-flash / deepseek-v4-flash / deepseek-v4-flash-vision-exp
|
|
109
|
+
// deepseek-v4-pro ← deepseek-v4-pro(V4 Pro 计价方式不变)
|
|
110
|
+
// 导出为工厂函数:每次 apply() 取一份独立副本,价格可被 loadPricing / setPrices 单独改写。
|
|
111
|
+
export const PRICE_REGIMES = ['base', 'peakValley', 'peakValleyV41']
|
|
112
|
+
export const PRICE_MODELS = ['deepseek-flash', 'deepseek-v4-pro']
|
|
113
|
+
export function defaultPricing() {
|
|
114
|
+
return {
|
|
115
|
+
base: {
|
|
116
|
+
'deepseek-flash': { cacheHit: 0.02, cacheMiss: 1.0, output: 2.0 },
|
|
117
|
+
'deepseek-v4-pro': { cacheHit: 0.025, cacheMiss: 3.0, output: 6.0 }
|
|
118
|
+
},
|
|
119
|
+
peakValley: {
|
|
120
|
+
'deepseek-flash': {
|
|
121
|
+
offPeak: { cacheHit: 0.05, cacheMiss: 1.5, output: 4.5 },
|
|
122
|
+
peak: { cacheHit: 0.1, cacheMiss: 3.0, output: 9.0 }
|
|
123
|
+
},
|
|
124
|
+
'deepseek-v4-pro': {
|
|
125
|
+
offPeak: { cacheHit: 0.15, cacheMiss: 4.5, output: 13.5 },
|
|
126
|
+
peak: { cacheHit: 0.3, cacheMiss: 9.0, output: 27.0 }
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
peakValleyV41: {
|
|
130
|
+
'deepseek-flash': {
|
|
131
|
+
offPeak: { cacheHit: 0.02, cacheMiss: 1.0, output: 4.0 },
|
|
132
|
+
peak: { cacheHit: 0.04, cacheMiss: 2.0, output: 8.0 }
|
|
133
|
+
},
|
|
134
|
+
'deepseek-v4-pro': {
|
|
135
|
+
offPeak: { cacheHit: 0.15, cacheMiss: 4.5, output: 13.5 },
|
|
136
|
+
peak: { cacheHit: 0.3, cacheMiss: 9.0, output: 27.0 }
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 模型 → 计费档位。官方新命名 deepseek-flash 与两个旧名
|
|
143
|
+
// (deepseek-v4-flash / deepseek-v4-flash-vision-exp,仍可调用且已路由到
|
|
144
|
+
// V4.1 Flash 并按 Flash 单价计费)统一归并到 deepseek-flash 档位。
|
|
145
|
+
export function modelKeyOf(model) {
|
|
146
|
+
const m = String(model || '').toLowerCase()
|
|
147
|
+
if (m.indexOf('vision') >= 0) return 'deepseek-flash'
|
|
148
|
+
if (m.indexOf('flash') >= 0) return 'deepseek-flash'
|
|
149
|
+
if (m.indexOf('pro') >= 0) return 'deepseek-v4-pro'
|
|
150
|
+
return 'unknown'
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 该 provider id 是否走 DeepSeek 官方价目。
|
|
155
|
+
*
|
|
156
|
+
* 官方一直在往 provider id 里加后缀:`deepseek`、`deepseek-official`,桌面端
|
|
157
|
+
* 官方账号那一路叫 `deepseek-account`(issue #19)。此前这里用两个字面量做白名单,
|
|
158
|
+
* 于是 `deepseek-account` 的每一条记录都被判成"未知 provider":费用恒为 ¥0,
|
|
159
|
+
* 也从不冻结单价(概览页的"历史价格提示"条数等于记录总数)。
|
|
160
|
+
*
|
|
161
|
+
* 统一按前缀判定后,任何 `deepseek-*` 都按 DeepSeek 官方价目计费;第三方
|
|
162
|
+
* (siliconflow / digitalocean / amd / 千问)在各自分支里先行返回,不受影响。
|
|
163
|
+
*
|
|
164
|
+
* @param id - 记录或请求里的 provider id。
|
|
165
|
+
* @returns 是否按 DeepSeek 官方价目计费。
|
|
166
|
+
*/
|
|
167
|
+
export function isDeepseekProvider(id) {
|
|
168
|
+
const p = String(id || '').trim().toLowerCase()
|
|
169
|
+
return p === 'deepseek' || p.indexOf('deepseek-') === 0
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 某时刻生效的峰谷价表:V4.1 调价前用第一版,调价后用新版。
|
|
173
|
+
export function peakValleyTableOf(pricing, ts) {
|
|
174
|
+
return ts >= V41_EFFECTIVE_AT ? pricing.peakValleyV41 : pricing.peakValley
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* 用"记录自带的冻结单价"算一笔费用(元)。纯函数,导出以便直接单测。
|
|
179
|
+
*
|
|
180
|
+
* 这是"改价格表不改写历史"这条规则的核心:入参只有记录自己的快照和四桶用量,
|
|
181
|
+
* 完全读不到当前价格表,因此同一份记录在任何时刻、任何价格表下都得到同一个金额。
|
|
182
|
+
*
|
|
183
|
+
* @param unit - 冻结单价 `{ hit, miss, out }`(元 / 百万 tokens)。
|
|
184
|
+
* @param hit - 缓存命中 token。
|
|
185
|
+
* @param miss - 未命中输入 token。
|
|
186
|
+
* @param out - 输出 token。
|
|
187
|
+
* @returns 费用(元);没有可用快照时返回 null,由调用方回落到当前价格表。
|
|
188
|
+
*/
|
|
189
|
+
export function costFromUnit(unit, hit, miss, out) {
|
|
190
|
+
if (!unit || typeof unit !== 'object') return null
|
|
191
|
+
// 至少要有一个可用的单价才算"有快照";缺的按 0 处理(与 normalizeRecord 的
|
|
192
|
+
// 接受规则一致,避免同一份数据在两处得到不同的"是否有快照"判断)。
|
|
193
|
+
const hasAny = [unit.hit, unit.miss, unit.out].some((n) => Number.isFinite(n))
|
|
194
|
+
if (!hasAny) return null
|
|
195
|
+
return (Number(hit || 0) * (Number(unit.hit) || 0)
|
|
196
|
+
+ Number(miss || 0) * (Number(unit.miss) || 0)
|
|
197
|
+
+ Number(out || 0) * (Number(unit.out) || 0)) / 1e6
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export default {
|
|
201
|
+
inject: ['fs', 'webServer', 'subprocess', 'credentials', 'settings', 'sandboxPolicy', 'agents'],
|
|
202
|
+
apply(ctx) {
|
|
203
|
+
const diag = { ok: true, steps: [], error: null }
|
|
204
|
+
const push = (s) => { try { diag.steps.push(String(s)) } catch (e) {} }
|
|
205
|
+
const flushDiag = () => {
|
|
206
|
+
try {
|
|
207
|
+
const fs = ctx.get('fs')
|
|
208
|
+
if (fs && typeof fs.resolve === 'function' && typeof fs.writeText === 'function') {
|
|
209
|
+
fs.resolve('dsh-usage-boot.log')
|
|
210
|
+
.then((target) => fs.writeText(target, JSON.stringify({ time: Date.now(), ...diag }, null, 2)))
|
|
211
|
+
.catch(() => {})
|
|
212
|
+
}
|
|
213
|
+
} catch (e) {}
|
|
214
|
+
}
|
|
215
|
+
try {
|
|
216
|
+
push('apply-start')
|
|
217
|
+
|
|
218
|
+
const records = []
|
|
219
|
+
const MAX_RECORDS = 100000
|
|
220
|
+
|
|
221
|
+
// 本实例私有的价格表副本(模块级 defaultPricing() 定义见文件顶部)。
|
|
222
|
+
const PRICING = defaultPricing()
|
|
223
|
+
const DEFAULT_PRICING = JSON.parse(JSON.stringify(PRICING))
|
|
224
|
+
// 一条记录在其发生时刻应使用的峰谷价表:V4.1 调价前用第一版,调价后用新版。
|
|
225
|
+
function peakValleyTableAt(ts) {
|
|
226
|
+
return peakValleyTableOf(PRICING, ts)
|
|
227
|
+
}
|
|
228
|
+
const SILICONFLOW_PRICING = {
|
|
229
|
+
'deepseek-ai/deepseek-v4-flash': { cacheHit: 0.02, cacheMiss: 1.0, output: 2.0 },
|
|
230
|
+
'deepseek-ai/deepseek-v4-pro': { cacheHit: 1.0, cacheMiss: 12.0, output: 24.0 },
|
|
231
|
+
'deepseek-ai/deepseek-v3.2': { cacheHit: 0.4, cacheMiss: 4.0, output: 6.0 },
|
|
232
|
+
'pro/deepseek-ai/deepseek-v3.2': { cacheHit: 0.4, cacheMiss: 4.0, output: 6.0 },
|
|
233
|
+
'qwen/qwen3.6-27b': { cacheHit: 3.0, cacheMiss: 3.0, output: 18.0 }
|
|
234
|
+
}
|
|
235
|
+
const DIGITALOCEAN_PRICING = {
|
|
236
|
+
flash: { cacheHit: 0.028, cacheMiss: 0.112, output: 0.224 },
|
|
237
|
+
pro: { cacheHit: 0.348, cacheMiss: 1.392, output: 2.784 },
|
|
238
|
+
v32: { cacheHit: 0.15, cacheMiss: 0.425, output: 1.36 }
|
|
239
|
+
}
|
|
240
|
+
let FX = { rate: 0, inverse: 0, date: '', queriedAt: 0, source: 'Frankfurter', stale: false, error: '' }
|
|
241
|
+
// 第一版峰谷价生效时间:北京时间 2026-08-17 00:00(模块级常量,见文件顶部)。
|
|
242
|
+
// 在此之前的调用按旧价格表(基础价 base)计费;之后按峰谷价计费,
|
|
243
|
+
// 2026-09-10 12:00 起再切到 V4.1 调价后的峰谷价表(见 peakValleyTableAt)。
|
|
244
|
+
const EFFECTIVE_AT = PEAK_VALLEY_AT
|
|
245
|
+
|
|
246
|
+
// 法定节假日:默认用内置的 2026 年放假日期,可在 pricing.json 里用
|
|
247
|
+
// `holidays: ["2027-01-01", ...]` 覆盖——官方公布新一年安排后不必升级插件。
|
|
248
|
+
let HOLIDAYS = new Set(HOLIDAYS_2026)
|
|
249
|
+
// 高峰/空闲判定使用模块级 isPeakAt(含周末 / 节假日规则与生效时间保护;
|
|
250
|
+
// 实现见文件顶部导出,供回归测试)
|
|
251
|
+
const isPeak = (ts) => isPeakAt(ts, HOLIDAYS)
|
|
252
|
+
|
|
253
|
+
// 模型 → 计费档位(纯函数,实现见文件顶部 modelKeyOf 导出)。
|
|
254
|
+
const modelKey = modelKeyOf
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 取"此刻生效"的三个单价,供记录落库时冻结使用(元 / 百万 tokens)。
|
|
258
|
+
*
|
|
259
|
+
* 为什么必须在记录里冻结:价格表是可实时修改的(`setPrices` 接口或直接改
|
|
260
|
+
* `pricing.json`)。若历史费用始终用"当前价"重算,用户改一次价就会把所有
|
|
261
|
+
* 过去的消耗都改写成新价——而那些调用的真实账单早已按当时的价结清。
|
|
262
|
+
* 冻结快照后,改价只影响"之后产生的记录";要重算历史必须显式发起。
|
|
263
|
+
*
|
|
264
|
+
* @param model - 模型名(可含 provider 前缀)。
|
|
265
|
+
* @param provider - provider id。
|
|
266
|
+
* @param at - 计费时刻(毫秒)。
|
|
267
|
+
* @returns `{ hit, miss, out, table }`;该 provider/模型没有可信价目时返回 null。
|
|
268
|
+
*/
|
|
269
|
+
function unitSnapshotOf(model, provider, at) {
|
|
270
|
+
const prov = String(provider || '').trim().toLowerCase()
|
|
271
|
+
const mdl = String(model || '').trim().toLowerCase()
|
|
272
|
+
if (prov === 'siliconflow') {
|
|
273
|
+
const p = SILICONFLOW_PRICING[mdl]
|
|
274
|
+
return p ? { hit: p.cacheHit, miss: p.cacheMiss, out: p.output, table: 'siliconflow' } : null
|
|
275
|
+
}
|
|
276
|
+
if (prov === 'digital-ocean' || prov === 'digitalocean') {
|
|
277
|
+
let p = null
|
|
278
|
+
if (mdl.indexOf('v3.2') >= 0 || mdl.indexOf('v3-2') >= 0) p = DIGITALOCEAN_PRICING.v32
|
|
279
|
+
else if (mdl.indexOf('pro') >= 0) p = DIGITALOCEAN_PRICING.pro
|
|
280
|
+
else if (mdl.indexOf('flash') >= 0) p = DIGITALOCEAN_PRICING.flash
|
|
281
|
+
return p ? { hit: p.cacheHit, miss: p.cacheMiss, out: p.output, table: 'digitalocean' } : null
|
|
282
|
+
}
|
|
283
|
+
if (!isDeepseekProvider(prov)) return null
|
|
284
|
+
const mk = modelKey(mdl)
|
|
285
|
+
if (at < EFFECTIVE_AT) {
|
|
286
|
+
const p = PRICING.base[mk]
|
|
287
|
+
return p ? { hit: p.cacheHit, miss: p.cacheMiss, out: p.output, table: 'base' } : null
|
|
288
|
+
}
|
|
289
|
+
const table = peakValleyTableAt(at)[mk]
|
|
290
|
+
if (!table) return null
|
|
291
|
+
const p = isPeak(at) ? table.peak : table.offPeak
|
|
292
|
+
return {
|
|
293
|
+
hit: p.cacheHit,
|
|
294
|
+
miss: p.cacheMiss,
|
|
295
|
+
out: p.output,
|
|
296
|
+
table: at >= V41_EFFECTIVE_AT ? 'peakValleyV41' : 'peakValley'
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// Third-party providers are priced only when a verified provider/model
|
|
301
|
+
// mapping exists. Unknown mappings deliberately remain zero rather than
|
|
302
|
+
// inheriting DeepSeek prices from a similar model name.
|
|
303
|
+
function costFor(rec, regime) {
|
|
304
|
+
const provider = String(rec.provider || '').trim().toLowerCase()
|
|
305
|
+
const model = String(rec.model || '').trim().toLowerCase()
|
|
306
|
+
const hit = rec.cacheReadTokens || 0
|
|
307
|
+
const miss = rec.inputTokens || 0
|
|
308
|
+
const out = rec.outputTokens || 0
|
|
309
|
+
|
|
310
|
+
// auto 档 = 实际计费口径:记录自带冻结单价时一律用它,历史费用不随价格表
|
|
311
|
+
// 变化。旧记录(无快照)退回下面的"按当前价格表计算"分支,由 UI 提示。
|
|
312
|
+
if (regime === 'auto') {
|
|
313
|
+
const frozen = costFromUnit(rec.unit, hit, miss, out)
|
|
314
|
+
if (frozen !== null) {
|
|
315
|
+
if (provider === 'digital-ocean' || provider === 'digitalocean') {
|
|
316
|
+
const fxRate = Number((rec.unit && rec.unit.fx) || rec.usdCnyRate || FX.rate || 0)
|
|
317
|
+
return fxRate > 0 ? frozen * fxRate : 0
|
|
318
|
+
}
|
|
319
|
+
return frozen
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (provider === 'siliconflow') {
|
|
324
|
+
const p = SILICONFLOW_PRICING[model]
|
|
325
|
+
if (!p) return 0
|
|
326
|
+
return (hit * p.cacheHit + miss * p.cacheMiss + out * p.output) / 1e6
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (provider === 'digital-ocean' || provider === 'digitalocean') {
|
|
330
|
+
let p = null
|
|
331
|
+
if (model.indexOf('v3.2') >= 0 || model.indexOf('v3-2') >= 0) p = DIGITALOCEAN_PRICING.v32
|
|
332
|
+
else if (model.indexOf('pro') >= 0) p = DIGITALOCEAN_PRICING.pro
|
|
333
|
+
else if (model.indexOf('flash') >= 0) p = DIGITALOCEAN_PRICING.flash
|
|
334
|
+
if (!p) return 0
|
|
335
|
+
const rate = Number(rec.usdCnyRate || FX.rate || 0)
|
|
336
|
+
if (!(rate > 0)) return 0
|
|
337
|
+
return ((hit * p.cacheHit + miss * p.cacheMiss + out * p.output) / 1e6) * rate
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (provider === 'amd' || provider === 'amd-gpu-cloud' || provider === 'alibaba' || provider === 'aliyun' || provider === 'qwen') return 0
|
|
341
|
+
if (!isDeepseekProvider(provider)) return 0
|
|
342
|
+
|
|
343
|
+
const mk = modelKey(rec.model)
|
|
344
|
+
// 取价时刻:优先 pricingTime(记录产生时冻结的计费时刻)。旧记录没有该字段,
|
|
345
|
+
// 自动退回 time,行为不变。这样历史 / 回填 / 导入的记录不会被后续调价改写费用。
|
|
346
|
+
const pricedAt = rec.pricingTime || rec.time
|
|
347
|
+
// base:固定按旧基础价;auto:记录发生在 2026-08-17 之前的按旧基础价,
|
|
348
|
+
// 之后按该时刻对应的峰谷价表(peakValley / peakValleyV41)。
|
|
349
|
+
if (regime === 'base' || (regime === 'auto' && pricedAt < EFFECTIVE_AT)) {
|
|
350
|
+
const p = PRICING.base[mk]
|
|
351
|
+
if (!p) return 0
|
|
352
|
+
return (hit * p.cacheHit + miss * p.cacheMiss + out * p.output) / 1e6
|
|
353
|
+
}
|
|
354
|
+
const pv = peakValleyTableAt(pricedAt)[mk]
|
|
355
|
+
if (!pv) return 0
|
|
356
|
+
const p = isPeak(pricedAt) ? pv.peak : pv.offPeak
|
|
357
|
+
return (hit * p.cacheHit + miss * p.cacheMiss + out * p.output) / 1e6
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// 过滤内部/占位调用(如 harness 内部 dsh2shell-* + model "fake"):
|
|
361
|
+
// 这些不是用户调用的 API 服务商,不出现在用量明细 / 消耗明细 / 日历统计里。
|
|
362
|
+
function isRealCall(rec) {
|
|
363
|
+
const model = String(rec.model || '').trim().toLowerCase()
|
|
364
|
+
const provider = String(rec.provider || '').trim().toLowerCase()
|
|
365
|
+
if (!model || model === 'fake' || model === 'unknown') return false
|
|
366
|
+
if (!provider || provider === 'unknown') return false
|
|
367
|
+
// 内部 agent/工具路由(dsh2shell-* 等)不计入用户可见明细
|
|
368
|
+
if (provider.indexOf('dsh') === 0) return false
|
|
369
|
+
return true
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* 搜索后端调用的输入侧 token **下限**估算。
|
|
374
|
+
*
|
|
375
|
+
* 只从"插件确实看得到"的东西推:请求体本身(prompt 文本 + tools 定义)。
|
|
376
|
+
* 这是下限而不是估计值——搜索后端会在服务端把检索结果注入上下文,并可能
|
|
377
|
+
* 自行多轮生成,这些 token 在插件侧完全不可观测,所以真实输入一定不小于
|
|
378
|
+
* 这里的值。输出侧一律记 0,不编数字。
|
|
379
|
+
*
|
|
380
|
+
* @param body - `web/deepseek-search-llm-request` 事件里的请求体。
|
|
381
|
+
* @returns 估算的下限 token 数。
|
|
382
|
+
*/
|
|
383
|
+
function estimateSearchInputTokens(body) {
|
|
384
|
+
let text = ''
|
|
385
|
+
try { text = JSON.stringify(body || {}) } catch (e) { return 0 }
|
|
386
|
+
return Math.ceil(text.length / 4)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* 记录一次 DeepSeek 搜索后端(`web_search`)调用:只计次 + 输入下限。
|
|
391
|
+
*
|
|
392
|
+
* 为什么只能这样:`web-search-deepseek` 用原生 fetch 直连
|
|
393
|
+
* `api.deepseek.com/anthropic/v1/messages`,刻意绕开 `ctx.llm`,而且上游
|
|
394
|
+
* 只把请求写进 session(`web/deepseek-search-llm-request`),响应的 usage
|
|
395
|
+
* 被整个丢弃(其 `AnthropicResponse` 类型里根本没有 usage 字段)。这部分
|
|
396
|
+
* 真实计费没有任何数据源可以取到,因此记录一律带 `estimated: true`,
|
|
397
|
+
* 由 UI 与导出显式区分,绝不当成实测值。
|
|
398
|
+
*
|
|
399
|
+
* @param session - 触发该请求的会话。
|
|
400
|
+
* @param event - 已落盘的 session 事件。
|
|
401
|
+
*/
|
|
402
|
+
function recordSearchBackendCall(session, event) {
|
|
403
|
+
const data = (event && event.data) || {}
|
|
404
|
+
const endpoint = String(data.endpoint || '')
|
|
405
|
+
// 只认 DeepSeek 官方端点:其它 endpoint 的价目无从得知,不臆测。
|
|
406
|
+
if (endpoint.indexOf('api.deepseek.com') < 0) return
|
|
407
|
+
const body = (data && data.body) || {}
|
|
408
|
+
const model = String(body.model || '')
|
|
409
|
+
const tools = Array.isArray(body.tools) ? body.tools : []
|
|
410
|
+
const maxUses = Number(tools[0] && tools[0].max_uses)
|
|
411
|
+
const at = Number(event.time) || Date.now()
|
|
412
|
+
const sessionId = String((session && session.id) || '')
|
|
413
|
+
records.push({
|
|
414
|
+
recordId: randomUUID(),
|
|
415
|
+
time: at,
|
|
416
|
+
pricingTime: at,
|
|
417
|
+
model,
|
|
418
|
+
provider: 'deepseek-official',
|
|
419
|
+
purpose: 'web-search',
|
|
420
|
+
sessionId,
|
|
421
|
+
inputTokens: estimateSearchInputTokens(body),
|
|
422
|
+
outputTokens: 0,
|
|
423
|
+
cacheReadTokens: 0,
|
|
424
|
+
cacheWriteTokens: 0,
|
|
425
|
+
reasoningTokens: 0,
|
|
426
|
+
finishReason: '',
|
|
427
|
+
origin: 'search-backend',
|
|
428
|
+
estimated: true,
|
|
429
|
+
maxTokens: Number(body.max_tokens) || 0,
|
|
430
|
+
maxUses: Number.isFinite(maxUses) && maxUses > 0 ? maxUses : 0,
|
|
431
|
+
usdCnyRate: 0,
|
|
432
|
+
fxDate: ''
|
|
433
|
+
})
|
|
434
|
+
const snapshot = unitSnapshotOf(model, 'deepseek-official', at)
|
|
435
|
+
if (snapshot) records[records.length - 1].unit = snapshot
|
|
436
|
+
if (records.length > MAX_RECORDS) records.splice(0, records.length - MAX_RECORDS)
|
|
437
|
+
ensureSessionRoot().then(persistNow).catch(() => {})
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// 把一条记录投影为 API 响应形状(供 list / tokenForMessage 共用,含成本与峰谷判定)。
|
|
441
|
+
// origin:'probe'(实时探针,上游实测)/ 'search-backend'(DeepSeek 搜索后端,
|
|
442
|
+
// 上游未持久化 usage,仅计次,费用为下限估算)。estimated 为 true 时,
|
|
443
|
+
// token 与费用都不是上游实测值,UI 与导出必须显式区分。
|
|
444
|
+
// 投影缓存:projectRecord 每条记录要算 3 次费用(base / peakValley / auto),
|
|
445
|
+
// 100k 条就是 30 万次计算,而客户端每次刷新都会重新拉一次 list。价格表、
|
|
446
|
+
// 汇率、记录内容任一变化都会 bump projectionVersion,缓存整体失效——不做
|
|
447
|
+
// 逐条失效,避免漏掉某条路径导致金额显示成旧值。
|
|
448
|
+
let projectionVersion = 0
|
|
449
|
+
const projectionCache = new WeakMap()
|
|
450
|
+
const invalidateProjection = () => { projectionVersion++ }
|
|
451
|
+
|
|
452
|
+
function projectRecord(r) {
|
|
453
|
+
const cached = projectionCache.get(r)
|
|
454
|
+
if (cached && cached.version === projectionVersion) return cached.out
|
|
455
|
+
const out = projectRecordUncached(r)
|
|
456
|
+
projectionCache.set(r, { version: projectionVersion, out })
|
|
457
|
+
return out
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function projectRecordUncached(r) {
|
|
461
|
+
const pricedAt = r.pricingTime || r.time
|
|
462
|
+
return {
|
|
463
|
+
time: r.time, pricingTime: pricedAt,
|
|
464
|
+
model: r.model, provider: r.provider, purpose: r.purpose, sessionId: r.sessionId || '',
|
|
465
|
+
origin: r.origin || 'probe', estimated: r.estimated === true, recordId: r.recordId || '',
|
|
466
|
+
inputTokens: r.inputTokens, outputTokens: r.outputTokens,
|
|
467
|
+
cacheReadTokens: r.cacheReadTokens, cacheWriteTokens: r.cacheWriteTokens,
|
|
468
|
+
reasoningTokens: r.reasoningTokens, finishReason: r.finishReason,
|
|
469
|
+
interrupted: !!r.interrupted,
|
|
470
|
+
usdCnyRate: r.usdCnyRate || 0, fxDate: r.fxDate || '',
|
|
471
|
+
modelKey: modelKey(r.model),
|
|
472
|
+
baseCost: costFor(r, 'base'), peakValleyCost: costFor(r, 'peakValley'), autoCost: costFor(r, 'auto'),
|
|
473
|
+
peak: isPeak(pricedAt)
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* 清掉全部记录的冻结单价快照,返回受影响条数。
|
|
479
|
+
*
|
|
480
|
+
* 这是**唯一**会改写历史费用的入口,必须由用户显式触发("用当前价格表重算
|
|
481
|
+
* 全部历史")。日常改价格表只影响之后产生的记录,不会回头改写过去。
|
|
482
|
+
*/
|
|
483
|
+
function dropUnitSnapshots() {
|
|
484
|
+
let n = 0
|
|
485
|
+
for (const r of records) {
|
|
486
|
+
if (r.unit) { delete r.unit; n++ }
|
|
487
|
+
}
|
|
488
|
+
if (n > 0) invalidateProjection()
|
|
489
|
+
return n
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// 对已投影的记录做聚合(对话累计 / 本轮明细共用)。
|
|
493
|
+
function computeAgg(items) {
|
|
494
|
+
const agg = { calls: items.length, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, peakCost: 0, offCost: 0, baseCost: 0, totalCost: 0, hitRate: 0 }
|
|
495
|
+
let firstTime = 0, lastTime = 0
|
|
496
|
+
for (const it of items) {
|
|
497
|
+
agg.input += it.inputTokens; agg.output += it.outputTokens
|
|
498
|
+
agg.cacheRead += it.cacheReadTokens; agg.cacheWrite += it.cacheWriteTokens
|
|
499
|
+
agg.reasoning += it.reasoningTokens
|
|
500
|
+
if (it.peak) agg.peakCost += it.peakValleyCost; else agg.offCost += it.peakValleyCost
|
|
501
|
+
agg.baseCost += it.baseCost; agg.totalCost += it.autoCost
|
|
502
|
+
if (!firstTime || it.time < firstTime) firstTime = it.time
|
|
503
|
+
if (it.time > lastTime) lastTime = it.time
|
|
504
|
+
}
|
|
505
|
+
const denom = agg.cacheRead + agg.input
|
|
506
|
+
agg.hitRate = denom > 0 ? (agg.cacheRead / denom * 100) : 0
|
|
507
|
+
agg.time = { start: firstTime, end: lastTime }
|
|
508
|
+
return agg
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const msg = (e) => String((e && e.message) || e)
|
|
512
|
+
const fail = (message) => ({ ok: false, error: message })
|
|
513
|
+
const pad2 = (n) => (n < 10 ? '0' : '') + n
|
|
514
|
+
const fmtInt = (n) => String(Math.round(n || 0)).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
|
|
515
|
+
const fmtTime = (ts) => {
|
|
516
|
+
const d = new Date(ts + 8 * 3600 * 1000)
|
|
517
|
+
return `${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())} ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())}:${pad2(d.getUTCSeconds())}`
|
|
518
|
+
}
|
|
519
|
+
const fmtMoney = (n) => {
|
|
520
|
+
if (!n) return '0.0000'
|
|
521
|
+
if (n < 0.0001) return n.toExponential(2)
|
|
522
|
+
if (n < 1) return n.toFixed(4)
|
|
523
|
+
return n.toFixed(2)
|
|
524
|
+
}
|
|
525
|
+
const IS_WIN = typeof process !== 'undefined' && process.platform === 'win32'
|
|
526
|
+
const IS_MAC = typeof process !== 'undefined' && process.platform === 'darwin'
|
|
527
|
+
const normPath = (p) => {
|
|
528
|
+
const s = String(p == null ? '' : p)
|
|
529
|
+
return IS_WIN ? s.replace(/\//g, '\\') : s
|
|
530
|
+
}
|
|
531
|
+
const joinPath = (...parts) => path.join(...parts.map((p) => String(p == null ? '' : p)))
|
|
532
|
+
// 插件自身持久化统一走宿主进程的 node:fs(不经 ctx.fs 沙箱),
|
|
533
|
+
// 因此可稳定写入工作区之外的系统目录,切换工作区也不会丢失历史。
|
|
534
|
+
// 写入必须是原子的(临时文件 → fsync → rename):半截写入不会覆盖掉
|
|
535
|
+
// 真实历史,失败时原文件保持不动,下一次写入仍可重试。
|
|
536
|
+
const nfsWriteText = atomicWriteText
|
|
537
|
+
const nfsReadText = async (filePath) => fsp.readFile(filePath, 'utf8')
|
|
538
|
+
const nfsExists = (filePath) => { try { return existsSync(filePath) } catch (e) { return false } }
|
|
539
|
+
const stamp = () => {
|
|
540
|
+
const d = new Date()
|
|
541
|
+
return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function bjKey(ts) {
|
|
545
|
+
const d = new Date(Number(ts) + 8 * 3600 * 1000)
|
|
546
|
+
return `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* 按北京自然日聚合。
|
|
551
|
+
*
|
|
552
|
+
* 入参是**已投影**的记录(`projectRecord` 的结果),直接复用其中的
|
|
553
|
+
* `baseCost` / `peakValleyCost` / `autoCost` / `peak`。
|
|
554
|
+
*
|
|
555
|
+
* 以前这里再遍历一遍原始记录、每条又算三次费用,与 `projectRecord` 完全重复:
|
|
556
|
+
* 100k 条记录每次刷新要做 60 万次费用计算,这就是"用量查询偶尔很慢"的主因之一。
|
|
557
|
+
*/
|
|
558
|
+
function buildDays(items) {
|
|
559
|
+
const map = {}
|
|
560
|
+
for (const it of items) {
|
|
561
|
+
const key = bjKey(it.time)
|
|
562
|
+
let d = map[key]
|
|
563
|
+
if (!d) {
|
|
564
|
+
d = {
|
|
565
|
+
day: key, calls: 0, miss: 0, hit: 0, write: 0, out: 0, reason: 0,
|
|
566
|
+
peakCalls: 0, offPeakCalls: 0, baseCost: 0, peakValleyCost: 0, autoCost: 0,
|
|
567
|
+
basePeakCost: 0, baseOffPeakCost: 0,
|
|
568
|
+
pvPeakCost: 0, pvOffPeakCost: 0,
|
|
569
|
+
autoPeakCost: 0, autoOffPeakCost: 0
|
|
570
|
+
}
|
|
571
|
+
map[key] = d
|
|
572
|
+
}
|
|
573
|
+
d.calls++
|
|
574
|
+
d.miss += it.inputTokens || 0
|
|
575
|
+
d.hit += it.cacheReadTokens || 0
|
|
576
|
+
d.write += it.cacheWriteTokens || 0
|
|
577
|
+
d.out += it.outputTokens || 0
|
|
578
|
+
d.reason += it.reasoningTokens || 0
|
|
579
|
+
const cBase = it.baseCost || 0
|
|
580
|
+
const cPv = it.peakValleyCost || 0
|
|
581
|
+
const cAuto = it.autoCost || 0
|
|
582
|
+
if (it.peak) {
|
|
583
|
+
d.peakCalls++
|
|
584
|
+
d.basePeakCost += cBase
|
|
585
|
+
d.pvPeakCost += cPv
|
|
586
|
+
d.autoPeakCost += cAuto
|
|
587
|
+
} else {
|
|
588
|
+
d.offPeakCalls++
|
|
589
|
+
d.baseOffPeakCost += cBase
|
|
590
|
+
d.pvOffPeakCost += cPv
|
|
591
|
+
d.autoOffPeakCost += cAuto
|
|
592
|
+
}
|
|
593
|
+
d.baseCost += cBase
|
|
594
|
+
d.peakValleyCost += cPv
|
|
595
|
+
d.autoCost += cAuto
|
|
596
|
+
}
|
|
597
|
+
const days = []
|
|
598
|
+
for (const k in map) days.push(map[k])
|
|
599
|
+
days.sort((a, b) => (a.day < b.day ? 1 : a.day > b.day ? -1 : 0))
|
|
600
|
+
return days
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const fs = ctx.get('fs')
|
|
604
|
+
push('fs=' + (fs ? 'present' : 'undefined'))
|
|
605
|
+
let root = ''
|
|
606
|
+
let dataPath = ''
|
|
607
|
+
let pricingPath = ''
|
|
608
|
+
let pricingHistoryPath = ''
|
|
609
|
+
let budgetPath = ''
|
|
610
|
+
// 价格表历史:每次改价 / 重置都在这里留一份完整快照(只增不改)。
|
|
611
|
+
// `pricing.json` 只保存"当前表",没有它就无法回答"上周是按什么价算的"。
|
|
612
|
+
let pricingHistory = []
|
|
613
|
+
let budget = null // { monthly: number|null } — 月度预算(元),null 表示未设置
|
|
614
|
+
let persistOk = false
|
|
615
|
+
let persistError = ''
|
|
616
|
+
let initPromise = null
|
|
617
|
+
let writeChain = Promise.resolve()
|
|
618
|
+
let cachedPolicy = null
|
|
619
|
+
|
|
620
|
+
const dirs = () => ({
|
|
621
|
+
data: joinPath(root, 'dsh-usage'),
|
|
622
|
+
csv: joinPath(root, 'dsh-usage', 'csv'),
|
|
623
|
+
json: joinPath(root, 'dsh-usage', 'json'),
|
|
624
|
+
images: joinPath(root, 'dsh-usage', 'images')
|
|
625
|
+
})
|
|
626
|
+
|
|
627
|
+
function currentAgent() {
|
|
628
|
+
try {
|
|
629
|
+
const agents = ctx.get('agents')
|
|
630
|
+
if (agents && typeof agents.currentInitiator === 'function') return agents.currentInitiator()
|
|
631
|
+
} catch (e) {}
|
|
632
|
+
return undefined
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function sessionPolicy() {
|
|
636
|
+
if (cachedPolicy) return cachedPolicy
|
|
637
|
+
try {
|
|
638
|
+
const agent = currentAgent()
|
|
639
|
+
const sp = ctx.get('sandboxPolicy')
|
|
640
|
+
if (sp && typeof sp.resolve === 'function' && agent && agent.session) {
|
|
641
|
+
const policy = sp.resolve({ session: agent.session })
|
|
642
|
+
if (policy && policy.workspaceRoot) {
|
|
643
|
+
cachedPolicy = policy
|
|
644
|
+
return policy
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
} catch (e) {}
|
|
648
|
+
return undefined
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// SSE 实时推送:数据变化(新记录写入 / 导入 / 清除等)时通知订阅者。
|
|
652
|
+
// 订阅端点 /usage/api/events。无订阅者时是 no-op,插件独立使用不受影响。
|
|
653
|
+
const sseClients = new Set()
|
|
654
|
+
function notifyChanged() {
|
|
655
|
+
if (sseClients.size === 0) return
|
|
656
|
+
const payload = 'data: {"type":"changed","at":' + Date.now() + '}\n\n'
|
|
657
|
+
for (const res of sseClients) {
|
|
658
|
+
try { res.write(payload) } catch (e) { sseClients.delete(res) }
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function persistNow() {
|
|
663
|
+
if (!dataPath || !persistOk) return Promise.resolve()
|
|
664
|
+
const text = JSON.stringify(records)
|
|
665
|
+
writeChain = writeChain.then(() => nfsWriteText(dataPath, text)).then(() => { notifyChanged() }).catch(() => {})
|
|
666
|
+
return writeChain
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function persistPricing() {
|
|
670
|
+
if (!pricingPath || !persistOk) return Promise.resolve()
|
|
671
|
+
return nfsWriteText(pricingPath, JSON.stringify(PRICING)).catch(() => {})
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function persistPricingHistory() {
|
|
675
|
+
if (!pricingHistoryPath || !persistOk) return Promise.resolve()
|
|
676
|
+
return nfsWriteText(pricingHistoryPath, JSON.stringify(pricingHistory)).catch(() => {})
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* 追加一条价格表快照(只增不改)。
|
|
681
|
+
*
|
|
682
|
+
* 与旧的 `pricing.json`("当前表")互补:
|
|
683
|
+
* - `pricing.json` 决定**之后**产生的记录用哪套价;
|
|
684
|
+
* - `pricing-history.json` 记录**曾经**用过哪几套价,供用户在价格页回看。
|
|
685
|
+
* 记录本身的冻结单价的机制不变,因此历史费用依旧不会被改写。
|
|
686
|
+
*
|
|
687
|
+
* @param kind - 'baseline'(首次建表)/ 'edit'(手工改价)/ 'reset'(恢复默认)。
|
|
688
|
+
* @param label - 展示用说明。
|
|
689
|
+
*/
|
|
690
|
+
function appendPricingHistory(kind, label) {
|
|
691
|
+
const entry = {
|
|
692
|
+
at: Date.now(),
|
|
693
|
+
kind,
|
|
694
|
+
label: String(label || ''),
|
|
695
|
+
table: JSON.parse(JSON.stringify(PRICING)),
|
|
696
|
+
holidays: Array.from(HOLIDAYS)
|
|
697
|
+
}
|
|
698
|
+
const last = pricingHistory[pricingHistory.length - 1]
|
|
699
|
+
// 内容与上一条完全一致就不重复记录(例如点了保存但没改任何数字)。
|
|
700
|
+
if (last && JSON.stringify(last.table) === JSON.stringify(entry.table)) return last
|
|
701
|
+
pricingHistory.push(entry)
|
|
702
|
+
if (pricingHistory.length > 200) pricingHistory.splice(0, pricingHistory.length - 200)
|
|
703
|
+
persistPricingHistory()
|
|
704
|
+
return entry
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
async function loadPricingHistory() {
|
|
708
|
+
if (!pricingHistoryPath) return
|
|
709
|
+
try {
|
|
710
|
+
const data = JSON.parse(await nfsReadText(pricingHistoryPath))
|
|
711
|
+
if (Array.isArray(data)) {
|
|
712
|
+
pricingHistory = data.filter((e) => e && typeof e === 'object' && e.table && typeof e.table === 'object')
|
|
713
|
+
}
|
|
714
|
+
} catch (e) {}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function persistBudget() {
|
|
718
|
+
if (!budgetPath || !persistOk) return Promise.resolve()
|
|
719
|
+
return nfsWriteText(budgetPath, JSON.stringify(budget)).catch(() => {})
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
async function loadBudget() {
|
|
723
|
+
if (!budgetPath) return
|
|
724
|
+
try {
|
|
725
|
+
const data = JSON.parse(await nfsReadText(budgetPath))
|
|
726
|
+
if (!data || typeof data !== 'object') return
|
|
727
|
+
const m = Number(data.monthly)
|
|
728
|
+
budget = { monthly: Number.isFinite(m) && m >= 0 ? m : null }
|
|
729
|
+
} catch (e) {}
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
async function loadPricing() {
|
|
733
|
+
if (!pricingPath) return
|
|
734
|
+
try {
|
|
735
|
+
const data = JSON.parse(await nfsReadText(pricingPath))
|
|
736
|
+
if (!data || typeof data !== 'object') return
|
|
737
|
+
for (const regime of PRICE_REGIMES) {
|
|
738
|
+
const src = data[regime]
|
|
739
|
+
const dst = PRICING[regime]
|
|
740
|
+
if (!src || typeof src !== 'object' || !dst) continue
|
|
741
|
+
for (const mk of PRICE_MODELS) {
|
|
742
|
+
const row = src[mk]
|
|
743
|
+
if (!row || typeof row !== 'object' || !dst[mk]) continue
|
|
744
|
+
for (const k of ['cacheHit', 'cacheMiss', 'output']) {
|
|
745
|
+
const v = Number(row[k])
|
|
746
|
+
if (Number.isFinite(v) && v >= 0) dst[mk][k] = v
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
// 法定节假日可在 pricing.json 里覆盖(官方公布新一年安排后不必升级插件)。
|
|
751
|
+
// 只接受 `YYYY-MM-DD` 形状的字符串,其余一律忽略,避免脏数据影响计费。
|
|
752
|
+
if (Array.isArray(data.holidays)) {
|
|
753
|
+
const keys = data.holidays.map((v) => String(v || '').trim()).filter((v) => /^\d{4}-\d{2}-\d{2}$/.test(v))
|
|
754
|
+
if (keys.length > 0) HOLIDAYS = new Set(keys)
|
|
755
|
+
}
|
|
756
|
+
} catch (e) {}
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
// normalizeRecord / recordKey 来自 lib/records.js(纯逻辑层,可单测)。
|
|
760
|
+
// 去重按"记录身份"而不是"毫秒":同一毫秒的两条不同请求不能互相覆盖。
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* 把无法解析的用量文件改名保留,返回保留后的完整路径(失败返回空串)。
|
|
764
|
+
*
|
|
765
|
+
* 只在"文件存在但读不出来"时调用。目的不是丢弃,而是**保留证据 + 让插件继续可用**:
|
|
766
|
+
* 文件名带 ISO 时间戳,用户可以在数据目录里找到它并人工抢救。
|
|
767
|
+
*/
|
|
768
|
+
async function quarantineCorruptFile(file) {
|
|
769
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
770
|
+
const kept = file + '.corrupt-' + stamp + '.json'
|
|
771
|
+
await fsp.rename(file, kept)
|
|
772
|
+
return kept
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
async function tryInitWithRoot(candidate) {
|
|
776
|
+
const tryPath = joinPath(normPath(candidate), 'dsh-usage', 'usage-records.json')
|
|
777
|
+
try {
|
|
778
|
+
const arr = await readStoredRecords(tryPath)
|
|
779
|
+
if (arr.length > 0) {
|
|
780
|
+
const existing = {}
|
|
781
|
+
for (let i = 0; i < records.length; i++) existing[recordKey(records[i])] = true
|
|
782
|
+
for (let i = 0; i < arr.length; i++) {
|
|
783
|
+
const rec = normalizeRecord(arr[i])
|
|
784
|
+
if (!rec) continue
|
|
785
|
+
const key = recordKey(rec)
|
|
786
|
+
if (existing[key]) continue
|
|
787
|
+
existing[key] = true
|
|
788
|
+
records.push(rec)
|
|
789
|
+
}
|
|
790
|
+
if (records.length > MAX_RECORDS) records.splice(0, records.length - MAX_RECORDS)
|
|
791
|
+
records.sort((a, b) => a.time - b.time)
|
|
792
|
+
}
|
|
793
|
+
} catch (e) {
|
|
794
|
+
// 文件存在但读不出来(损坏 / 权限)。绝不能静默当成空数组再写回去——
|
|
795
|
+
// 那会把整份历史抹掉。做法:把原件改名保留,然后照常继续启动,让插件
|
|
796
|
+
// 仍然可用、数据仍可人工抢救,而不是让插件直接不可用。
|
|
797
|
+
const kept = await quarantineCorruptFile(tryPath).catch(() => '')
|
|
798
|
+
push('usage-file-unreadable: ' + msg(e) + (kept ? ' -> ' + kept : ''))
|
|
799
|
+
persistError = '用量文件无法读取(' + msg(e) + ')'
|
|
800
|
+
+ (kept ? ',原件已保留为 ' + path.basename(kept) : '')
|
|
801
|
+
}
|
|
802
|
+
try {
|
|
803
|
+
// 真实试写以确认该目录可写;用宿主进程的 node:fs,不受 workspace-write
|
|
804
|
+
// 沙箱约束,因此活动数据根会落在工作区之外的系统/用户目录。
|
|
805
|
+
await nfsWriteText(tryPath, JSON.stringify(records))
|
|
806
|
+
root = normPath(candidate)
|
|
807
|
+
dataPath = tryPath
|
|
808
|
+
pricingPath = joinPath(path.dirname(dataPath), 'pricing.json')
|
|
809
|
+
pricingHistoryPath = joinPath(path.dirname(dataPath), 'pricing-history.json')
|
|
810
|
+
budgetPath = joinPath(path.dirname(dataPath), 'budget.json')
|
|
811
|
+
await loadPricing()
|
|
812
|
+
await loadPricingHistory()
|
|
813
|
+
await loadBudget()
|
|
814
|
+
persistOk = true
|
|
815
|
+
persistError = ''
|
|
816
|
+
// 首次启动(或历史文件丢失)时补一条基线快照,之后每次改价都往后追加。
|
|
817
|
+
if (pricingHistory.length === 0) appendPricingHistory('baseline', '首次建立的基准价格表')
|
|
818
|
+
return { ok: true }
|
|
819
|
+
} catch (e) {
|
|
820
|
+
return { ok: false, error: msg(e) }
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
async function migrateLegacy(candidates) {
|
|
825
|
+
const paths = []
|
|
826
|
+
for (const c of candidates) {
|
|
827
|
+
paths.push(joinPath(normPath(c), '.dsh-usage-records.json'))
|
|
828
|
+
paths.push(joinPath(normPath(c), 'dsh-usage', 'usage-records.json'))
|
|
829
|
+
}
|
|
830
|
+
for (const p of paths) {
|
|
831
|
+
try {
|
|
832
|
+
const arr = await readStoredRecords(p)
|
|
833
|
+
if (arr.length > 0) {
|
|
834
|
+
const existing = {}
|
|
835
|
+
for (let j = 0; j < records.length; j++) existing[recordKey(records[j])] = true
|
|
836
|
+
for (const raw of arr) {
|
|
837
|
+
const rec = normalizeRecord(raw)
|
|
838
|
+
if (!rec) continue
|
|
839
|
+
const key = recordKey(rec)
|
|
840
|
+
if (existing[key]) continue
|
|
841
|
+
existing[key] = true
|
|
842
|
+
records.push(rec)
|
|
843
|
+
}
|
|
844
|
+
if (records.length > MAX_RECORDS) records.splice(0, records.length - MAX_RECORDS)
|
|
845
|
+
records.sort((a, b) => a.time - b.time)
|
|
846
|
+
}
|
|
847
|
+
} catch (e) {}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
// 固定、专用的数据目录:不随工作区漂移,也绝不放进工作目录。
|
|
852
|
+
// 优先级:环境变量 DSH_USAGE_DATA_DIR >
|
|
853
|
+
// 系统应用数据目录(LOCALAPPDATA 或 APPDATA 下的 dsh-usage-plugin) >
|
|
854
|
+
// 用户主目录下的专用文件夹 dsh-usage-data。
|
|
855
|
+
// 这些目录由宿主进程 node:fs 直接写入,不受 workspace-write 沙箱约束,
|
|
856
|
+
// 因此数据稳定地落在工作区之外——切换工作区也不会丢失历史。
|
|
857
|
+
// 仅在以上目录全部不可写时,才兜底写到工作区(极少见,且会提示用户)。
|
|
858
|
+
function resolveDataRoot() {
|
|
859
|
+
const out = []
|
|
860
|
+
const env = ((typeof process !== 'undefined' && process.env && process.env.DSH_USAGE_DATA_DIR) || '').trim()
|
|
861
|
+
if (env) out.push(normPath(env))
|
|
862
|
+
const localApp = (typeof process !== 'undefined' && process.env) ? (process.env.LOCALAPPDATA || '') : ''
|
|
863
|
+
if (localApp) out.push(joinPath(normPath(localApp), 'dsh-usage-plugin'))
|
|
864
|
+
const roamingApp = (typeof process !== 'undefined' && process.env) ? (process.env.APPDATA || '') : ''
|
|
865
|
+
if (roamingApp) out.push(joinPath(normPath(roamingApp), 'dsh-usage-plugin'))
|
|
866
|
+
out.push(joinPath(normPath(os.homedir()), 'dsh-usage-data'))
|
|
867
|
+
// 兜底(极少见):以上均不可写时落到工作区,保证不丢数据并提示用户。
|
|
868
|
+
try {
|
|
869
|
+
const sp = ctx.get('sandboxPolicy')
|
|
870
|
+
if (sp && sp.workspaceRoot) out.push(joinPath(normPath(String(sp.workspaceRoot)), 'dsh-usage'))
|
|
871
|
+
} catch (e) {}
|
|
872
|
+
return out
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// 已知可能遗留旧数据的目录(用于一次性合并迁移),不再作为活动根。
|
|
876
|
+
function knownLegacyRoots() {
|
|
877
|
+
const home = normPath(os.homedir())
|
|
878
|
+
const roots = [home, joinPath(home, '.dsh')]
|
|
879
|
+
try {
|
|
880
|
+
const sp = ctx.get('sandboxPolicy')
|
|
881
|
+
if (sp && sp.workspaceRoot) roots.push(normPath(String(sp.workspaceRoot)))
|
|
882
|
+
} catch (e) {}
|
|
883
|
+
const agent = currentAgent()
|
|
884
|
+
if (agent && agent.session && agent.session.header && agent.session.header.cwd) {
|
|
885
|
+
roots.push(normPath(String(agent.session.header.cwd)))
|
|
886
|
+
}
|
|
887
|
+
return roots
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
// 会话激活后不再切换根目录(避免路径漂移/历史被拆散);仅把工作区里
|
|
891
|
+
// 可能遗留的旧记录并入固定的数据根。
|
|
892
|
+
async function ensureSessionRoot() {
|
|
893
|
+
if (!persistOk) return
|
|
894
|
+
await migrateLegacy(knownLegacyRoots())
|
|
895
|
+
persistNow()
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
async function initPersistence() {
|
|
899
|
+
const candidates = resolveDataRoot()
|
|
900
|
+
let lastError = ''
|
|
901
|
+
for (const c of candidates) {
|
|
902
|
+
const r = await tryInitWithRoot(c)
|
|
903
|
+
if (r.ok) {
|
|
904
|
+
// 首次初始化时把散落在 主目录/.dsh/各工作区 的历史记录合并进来
|
|
905
|
+
await migrateLegacy(knownLegacyRoots())
|
|
906
|
+
persistNow()
|
|
907
|
+
return
|
|
908
|
+
}
|
|
909
|
+
lastError = r.error || '写入失败'
|
|
910
|
+
}
|
|
911
|
+
persistError = lastError || '未找到可写的持久化目录'
|
|
912
|
+
persistOk = false
|
|
913
|
+
root = ''
|
|
914
|
+
dataPath = ''
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
const ensureInit = () => (initPromise ||= initPersistence())
|
|
918
|
+
try { ensureInit() } catch (e) { push('ensureInit-threw: ' + msg(e)) }
|
|
919
|
+
|
|
920
|
+
// ── capture ────────────────────────────────────────────────────────────
|
|
921
|
+
try {
|
|
922
|
+
ctx.on('llm/stream', function (options, next) {
|
|
923
|
+
const source = next()
|
|
924
|
+
const model = (options && options.model) || ''
|
|
925
|
+
const provider = (options && options.provider) || ''
|
|
926
|
+
const purpose = options && options.purpose ? String(options.purpose) : ''
|
|
927
|
+
let sessionId = options && options.sessionId ? String(options.sessionId) : ''
|
|
928
|
+
const startedAt = Date.now()
|
|
929
|
+
let usage = null
|
|
930
|
+
let finishReason = ''
|
|
931
|
+
|
|
932
|
+
async function* observe() {
|
|
933
|
+
try {
|
|
934
|
+
for await (const chunk of source) {
|
|
935
|
+
if (chunk && chunk.type === 'usage' && chunk.usage) {
|
|
936
|
+
usage = chunk.usage
|
|
937
|
+
} else if (chunk && chunk.type === 'finish') {
|
|
938
|
+
const r = chunk.reason
|
|
939
|
+
finishReason = r ? String(r.kind || '') : ''
|
|
940
|
+
}
|
|
941
|
+
yield chunk
|
|
942
|
+
}
|
|
943
|
+
} finally {
|
|
944
|
+
if (!sessionId) {
|
|
945
|
+
try { const ag = currentAgent(); if (ag && ag.session && ag.session.id) sessionId = String(ag.session.id) } catch (e) {}
|
|
946
|
+
}
|
|
947
|
+
if (provider === 'digital-ocean' || provider === 'digitalocean') {
|
|
948
|
+
try { await refreshFxRate(false) } catch (e) {}
|
|
949
|
+
}
|
|
950
|
+
const isDO = provider === 'digital-ocean' || provider === 'digitalocean'
|
|
951
|
+
// 冻结这笔调用当时生效的三个单价(以及 DigitalOcean 的汇率),
|
|
952
|
+
// 写进记录的 unit 字段。之后任何改价都不会回头改写它的费用。
|
|
953
|
+
const unitSnapshot = unitSnapshotOf(model, provider, startedAt)
|
|
954
|
+
if (unitSnapshot && isDO && FX.rate > 0) unitSnapshot.fx = FX.rate
|
|
955
|
+
if (usage) {
|
|
956
|
+
// 只记录上游真实上报的值。DeepSeek 系 provider 不报 cacheWriteTokens
|
|
957
|
+
// (官方计费里"缓存写入"本就按未命中价计,不单列)。此前这里是
|
|
958
|
+
// `usage.cacheWriteTokens || usage.inputTokens`,等于伪造一笔与未命中
|
|
959
|
+
// 相等的缓存写入:四桶合计(输入 + 命中 + 写入 + 输出)因此系统性多算
|
|
960
|
+
// 一笔,插件"总 token"永远比宿主状态栏大一截。
|
|
961
|
+
// 该列的展示兜底改由 lib/client.js 负责,不再污染记录本身。
|
|
962
|
+
records.push({
|
|
963
|
+
recordId: randomUUID(),
|
|
964
|
+
time: startedAt,
|
|
965
|
+
// 计费时刻:取价格档位以 pricingTime 为准,历史回填 / 导入的记录
|
|
966
|
+
// 也能借此冻结"当时生效的价格",不随后续调价改变。
|
|
967
|
+
pricingTime: startedAt,
|
|
968
|
+
model,
|
|
969
|
+
provider,
|
|
970
|
+
purpose,
|
|
971
|
+
sessionId,
|
|
972
|
+
inputTokens: usage.inputTokens || 0,
|
|
973
|
+
outputTokens: usage.outputTokens || 0,
|
|
974
|
+
cacheReadTokens: usage.cacheReadTokens || 0,
|
|
975
|
+
cacheWriteTokens: usage.cacheWriteTokens || 0,
|
|
976
|
+
reasoningTokens: usage.reasoningTokens || 0,
|
|
977
|
+
finishReason,
|
|
978
|
+
usdCnyRate: isDO ? (FX.rate || 0) : 0,
|
|
979
|
+
fxDate: isDO ? (FX.date || '') : '',
|
|
980
|
+
...(unitSnapshot ? { unit: unitSnapshot } : {})
|
|
981
|
+
})
|
|
982
|
+
} else if (isRealCall({ model, provider, time: startedAt })) {
|
|
983
|
+
// 兜底:harness 在流被中断(aborted / error / timeout)时不会产出
|
|
984
|
+
// usage chunk(usage 只在收到 [DONE] 哨兵后才由 adapter yield),
|
|
985
|
+
// 但官方后台仍会按该次请求的实际 token 计费。这里补记一条 0-token
|
|
986
|
+
// 的「中断调用」,使调用次数与官方口径对齐;token 与费用均为 0,
|
|
987
|
+
// 不会虚增消耗。interrupted 标记供客户端 UI 区分展示。
|
|
988
|
+
records.push({
|
|
989
|
+
recordId: randomUUID(),
|
|
990
|
+
time: startedAt,
|
|
991
|
+
pricingTime: startedAt,
|
|
992
|
+
model,
|
|
993
|
+
provider,
|
|
994
|
+
purpose,
|
|
995
|
+
sessionId,
|
|
996
|
+
inputTokens: 0,
|
|
997
|
+
outputTokens: 0,
|
|
998
|
+
cacheReadTokens: 0,
|
|
999
|
+
cacheWriteTokens: 0,
|
|
1000
|
+
reasoningTokens: 0,
|
|
1001
|
+
finishReason: finishReason || 'aborted',
|
|
1002
|
+
interrupted: true,
|
|
1003
|
+
usdCnyRate: 0,
|
|
1004
|
+
fxDate: ''
|
|
1005
|
+
})
|
|
1006
|
+
}
|
|
1007
|
+
if (records.length > MAX_RECORDS) records.splice(0, records.length - MAX_RECORDS)
|
|
1008
|
+
try {
|
|
1009
|
+
const agent = currentAgent()
|
|
1010
|
+
const sp = ctx.get('sandboxPolicy')
|
|
1011
|
+
if (sp && typeof sp.resolve === 'function' && agent && agent.session) {
|
|
1012
|
+
const policy = sp.resolve({ session: agent.session })
|
|
1013
|
+
if (policy && policy.workspaceRoot) cachedPolicy = policy
|
|
1014
|
+
}
|
|
1015
|
+
} catch (e) {}
|
|
1016
|
+
ensureSessionRoot().then(persistNow).catch(() => {})
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
return observe()
|
|
1021
|
+
})
|
|
1022
|
+
push('llm-stream-listener-ok')
|
|
1023
|
+
} catch (e) {
|
|
1024
|
+
push('llm-stream-listener-threw: ' + (e && e.stack ? e.stack : msg(e)))
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
// ── DeepSeek 搜索后端(web_search)计次 ────────────────────────────────
|
|
1028
|
+
// 第二条独立数据通道:`session/event` 是宿主 append 后的广播(post-commit,
|
|
1029
|
+
// 失败不会影响 append 本身)。用它接住 `web/deepseek-search-llm-request`,
|
|
1030
|
+
// 把这部分此前完全不可见的调用显式计入"已知未覆盖项"。
|
|
1031
|
+
// 详见 recordSearchBackendCall 的说明。
|
|
1032
|
+
try {
|
|
1033
|
+
ctx.on('session/event', function (session, event) {
|
|
1034
|
+
if (!event || event.type !== 'web/deepseek-search-llm-request') return
|
|
1035
|
+
try { recordSearchBackendCall(session, event) } catch (e) { push('search-backend-threw: ' + msg(e)) }
|
|
1036
|
+
})
|
|
1037
|
+
push('session-event-listener-ok')
|
|
1038
|
+
} catch (e) {
|
|
1039
|
+
push('session-event-listener-threw: ' + (e && e.stack ? e.stack : msg(e)))
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// ── balance / network helpers ──────────────────────────────────────────
|
|
1043
|
+
async function safeCwd() {
|
|
1044
|
+
if (root) {
|
|
1045
|
+
try {
|
|
1046
|
+
await fsp.stat(root)
|
|
1047
|
+
return root
|
|
1048
|
+
} catch (e) {}
|
|
1049
|
+
}
|
|
1050
|
+
return (typeof process !== 'undefined' && typeof process.cwd === 'function' && process.cwd()) || '.'
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
async function runCollect(argv, opts) {
|
|
1054
|
+
const subprocess = ctx.get('subprocess')
|
|
1055
|
+
if (!subprocess) return { ok: false, error: '命令执行服务不可用' }
|
|
1056
|
+
let handle
|
|
1057
|
+
try {
|
|
1058
|
+
handle = subprocess.spawn({
|
|
1059
|
+
argv,
|
|
1060
|
+
cwd: await safeCwd(),
|
|
1061
|
+
stdio: opts && opts.stdinData != null
|
|
1062
|
+
? { stdin: { data: opts.stdinData }, stdout: { maxBytes: 65536 }, stderr: { maxBytes: 65536 } }
|
|
1063
|
+
: { stdin: 'ignore', stdout: { maxBytes: 65536 }, stderr: { maxBytes: 65536 } },
|
|
1064
|
+
graceMs: (opts && opts.graceMs) || 15000,
|
|
1065
|
+
...(opts && opts.env ? { env: opts.env } : {})
|
|
1066
|
+
})
|
|
1067
|
+
} catch (e) { return { ok: false, error: '启动失败:' + msg(e) } }
|
|
1068
|
+
let outcome
|
|
1069
|
+
try { outcome = await handle.done } catch (e) { return { ok: false, error: '执行失败:' + msg(e) } }
|
|
1070
|
+
const outText = handle.collected && handle.collected.stdout ? handle.collected.stdout.readFrom(0).text : ''
|
|
1071
|
+
const errText = handle.collected && handle.collected.stderr ? handle.collected.stderr.readFrom(0).text : ''
|
|
1072
|
+
return { ok: outcome.exitCode === 0, exitCode: outcome.exitCode, out: outText, err: errText }
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const isElectron = typeof process !== 'undefined' && !!(process.versions && process.versions.electron)
|
|
1076
|
+
function nodeCandidates() {
|
|
1077
|
+
const list = IS_WIN
|
|
1078
|
+
? ['node.exe', 'node', 'C:\\Program Files\\nodejs\\node.exe']
|
|
1079
|
+
: ['node']
|
|
1080
|
+
if (typeof process !== 'undefined' && process.execPath && !list.includes(process.execPath)) list.push(process.execPath)
|
|
1081
|
+
return list
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
async function spawnNode(script, stdinData, env) {
|
|
1085
|
+
const subprocess = ctx.get('subprocess')
|
|
1086
|
+
if (!subprocess) return { ok: false, error: '命令执行服务不可用' }
|
|
1087
|
+
let exe = null
|
|
1088
|
+
for (const c of nodeCandidates()) {
|
|
1089
|
+
try { exe = await subprocess.resolveExecutable(c); if (exe) break } catch (e) {}
|
|
1090
|
+
}
|
|
1091
|
+
if (!exe) return { ok: false, error: '未找到 node 可执行文件' }
|
|
1092
|
+
const finalEnv = env || {}
|
|
1093
|
+
if (isElectron && exe === process.execPath && !('ELECTRON_RUN_AS_NODE' in finalEnv)) {
|
|
1094
|
+
finalEnv.ELECTRON_RUN_AS_NODE = '1'
|
|
1095
|
+
}
|
|
1096
|
+
const r = await runCollect([exe, '-e', script], { stdinData, env: finalEnv })
|
|
1097
|
+
if (!r.ok) {
|
|
1098
|
+
if (r.exitCode != null) return { ok: false, error: 'node 退出码 ' + r.exitCode + (r.err ? ':' + r.err.trim() : '') }
|
|
1099
|
+
return { ok: false, error: r.error || '执行失败' }
|
|
1100
|
+
}
|
|
1101
|
+
return { ok: true, out: r.out }
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// ── HTTP:进程内直连优先,子进程兜底 ─────────────────────────────────
|
|
1105
|
+
// 此前所有网络请求(余额、汇率)都通过 `node -e` 子进程发起:Windows 上
|
|
1106
|
+
// 起一个 node 进程要 200~800ms,所以"查余额"经常要等一两秒。宿主本身就在
|
|
1107
|
+
// Node 里跑,而 undici 已经是本包的依赖,直接在进程内请求快一个数量级;
|
|
1108
|
+
// 只有 undici 不可用(被裁剪的运行时)时才回落到子进程,行为与旧版一致。
|
|
1109
|
+
let undiciRequest
|
|
1110
|
+
async function directRequest() {
|
|
1111
|
+
if (undiciRequest !== undefined) return undiciRequest
|
|
1112
|
+
try {
|
|
1113
|
+
const mod = await import('undici')
|
|
1114
|
+
undiciRequest = (mod && typeof mod.request === 'function') ? mod.request : null
|
|
1115
|
+
} catch (e) { undiciRequest = null }
|
|
1116
|
+
return undiciRequest
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// 进程内请求同样要尊重 HTTPS_PROXY:百炼 CLI 那条通道一直靠 EnvHttpProxyAgent
|
|
1120
|
+
// 走代理,余额/汇率两条不认代理。统一在这里建一个共享 dispatcher(只在本插件
|
|
1121
|
+
// 的请求里使用,不改全局 dispatcher,避免影响宿主和其它插件)。
|
|
1122
|
+
let proxyDispatcher
|
|
1123
|
+
let proxyDispatcherReady = false
|
|
1124
|
+
async function getDispatcher() {
|
|
1125
|
+
if (proxyDispatcherReady) return proxyDispatcher
|
|
1126
|
+
proxyDispatcherReady = true
|
|
1127
|
+
try {
|
|
1128
|
+
const mod = await import('undici')
|
|
1129
|
+
if (mod && typeof mod.EnvHttpProxyAgent === 'function') proxyDispatcher = new mod.EnvHttpProxyAgent()
|
|
1130
|
+
} catch (e) { proxyDispatcher = null }
|
|
1131
|
+
return proxyDispatcher
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
async function httpDirect(method, url, headers, body, timeoutMs) {
|
|
1135
|
+
const request = await directRequest()
|
|
1136
|
+
if (!request) return { ok: false, unsupported: true, error: 'undici 不可用' }
|
|
1137
|
+
const dispatcher = await getDispatcher()
|
|
1138
|
+
const controller = new AbortController()
|
|
1139
|
+
const timer = setTimeout(() => { try { controller.abort() } catch (e) {} }, timeoutMs || 20000)
|
|
1140
|
+
try {
|
|
1141
|
+
// 注意:undici v7 的 request() 不再接受 `maxRedirections`(改由 redirect
|
|
1142
|
+
// interceptor 处理),传了会直接抛
|
|
1143
|
+
// "maxRedirections is not supported, use the redirect interceptor"——
|
|
1144
|
+
// 余额查询因此整体失败过。这里不跟随重定向:账单/配额端点都是直连,
|
|
1145
|
+
// 3xx 一律按非 200 处理并报出状态码,比静默跟跳到未知地址更安全。
|
|
1146
|
+
const options = {
|
|
1147
|
+
method,
|
|
1148
|
+
headers: headers || {},
|
|
1149
|
+
signal: controller.signal
|
|
1150
|
+
}
|
|
1151
|
+
if (body != null) options.body = body
|
|
1152
|
+
if (dispatcher) options.dispatcher = dispatcher
|
|
1153
|
+
const res = await request(url, options)
|
|
1154
|
+
const text = await res.body.text()
|
|
1155
|
+
const rawType = res.headers && (res.headers['content-type'] || res.headers['Content-Type'])
|
|
1156
|
+
return { ok: true, statusCode: res.statusCode, contentType: String(rawType || ''), body: text }
|
|
1157
|
+
} catch (e) {
|
|
1158
|
+
const aborted = e && (e.name === 'AbortError' || String(e.code || '') === 'UND_ERR_ABORTED')
|
|
1159
|
+
return { ok: false, error: aborted ? '请求超时' : msg(e) }
|
|
1160
|
+
} finally {
|
|
1161
|
+
clearTimeout(timer)
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function httpGetDirect(url, headers, timeoutMs) {
|
|
1166
|
+
return httpDirect('GET', url, headers, null, timeoutMs)
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
async function httpGetViaSpawn(url, headers) {
|
|
1170
|
+
const script = [
|
|
1171
|
+
'const https=require("https");',
|
|
1172
|
+
'const url=process.env.REQ_URL||"";',
|
|
1173
|
+
'const headers=JSON.parse(process.env.REQ_HEADERS||"{}");',
|
|
1174
|
+
'const req=https.get(url,{headers:headers},function(res){',
|
|
1175
|
+
'let b="";res.on("data",c=>b+=c);',
|
|
1176
|
+
'res.on("end",function(){process.stdout.write(JSON.stringify({statusCode:res.statusCode,contentType:String(res.headers["content-type"]||""),body:b}))});',
|
|
1177
|
+
'});',
|
|
1178
|
+
'req.on("error",function(e){process.stdout.write(JSON.stringify({error:String(e&&e.message||e)}))});',
|
|
1179
|
+
'req.setTimeout(20000,function(){req.destroy(new Error("timeout"))});'
|
|
1180
|
+
].join('\n')
|
|
1181
|
+
const r = await spawnNode(script, null, { REQ_URL: url, REQ_HEADERS: JSON.stringify(headers || {}) })
|
|
1182
|
+
if (!r.ok) return { ok: false, error: r.error || '执行失败' }
|
|
1183
|
+
let parsed
|
|
1184
|
+
try { parsed = JSON.parse(r.out) } catch (e) { return { ok: false, error: '无法解析 node 输出' } }
|
|
1185
|
+
if (parsed.error) return { ok: false, error: parsed.error }
|
|
1186
|
+
return { ok: true, statusCode: parsed.statusCode, contentType: String(parsed.contentType || ''), body: parsed.body }
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* GET 一个 JSON/文本端点:优先进程内直连,undici 缺失时才起子进程。
|
|
1191
|
+
*
|
|
1192
|
+
* @returns `{ ok, statusCode, contentType, body }` 或 `{ ok:false, error }`。
|
|
1193
|
+
* 与旧实现返回形状一致,调用方的状态码 / Content-Type 判断不用改。
|
|
1194
|
+
*/
|
|
1195
|
+
async function httpGet(url, headers, timeoutMs) {
|
|
1196
|
+
const direct = await httpGetDirect(url, headers, timeoutMs)
|
|
1197
|
+
if (direct.ok) return direct
|
|
1198
|
+
if (!direct.unsupported) return direct
|
|
1199
|
+
return httpGetViaSpawn(url, headers)
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// POST 兜底:undici 不可用时用子进程 + undici 的 EnvHttpProxyAgent(与旧实现一致)。
|
|
1203
|
+
async function httpPostViaSpawn(url, headers, body) {
|
|
1204
|
+
let undiciPath = ''
|
|
1205
|
+
try { undiciPath = new URL(import.meta.resolve('undici')).pathname } catch (e) { undiciPath = '' }
|
|
1206
|
+
const script = [
|
|
1207
|
+
'const url=process.env.REQ_URL||"";',
|
|
1208
|
+
'const bodyText=process.env.REQ_BODY||"";',
|
|
1209
|
+
'const headers=JSON.parse(process.env.REQ_HEADERS||"{}");',
|
|
1210
|
+
'const undiciPath=process.env.UNDICI_PATH||"";',
|
|
1211
|
+
'if(!undiciPath){process.stdout.write(JSON.stringify({error:"undici 不可用,请确认插件依赖已安装"}));process.exit(0);}',
|
|
1212
|
+
'const m=require(undiciPath);',
|
|
1213
|
+
'try{m.setGlobalDispatcher(new m.EnvHttpProxyAgent())}catch(e){}',
|
|
1214
|
+
'const timeout=setTimeout(function(){process.stdout.write(JSON.stringify({error:"timeout"}));process.exit(0)},20000);',
|
|
1215
|
+
'(async function(){',
|
|
1216
|
+
' try{',
|
|
1217
|
+
' const res=await m.request(url,{method:"POST",headers:headers,body:bodyText});',
|
|
1218
|
+
' clearTimeout(timeout);',
|
|
1219
|
+
' const b=await res.body.text();',
|
|
1220
|
+
' process.stdout.write(JSON.stringify({statusCode:res.statusCode,contentType:String(res.headers["content-type"]||""),body:b}));',
|
|
1221
|
+
' }catch(e){clearTimeout(timeout);process.stdout.write(JSON.stringify({error:String(e&&e.message||e)}));}',
|
|
1222
|
+
'})();'
|
|
1223
|
+
].join('\n')
|
|
1224
|
+
const r = await spawnNode(script, null, {
|
|
1225
|
+
REQ_URL: url,
|
|
1226
|
+
REQ_HEADERS: JSON.stringify(headers || {}),
|
|
1227
|
+
REQ_BODY: String(body == null ? '' : body),
|
|
1228
|
+
UNDICI_PATH: undiciPath
|
|
1229
|
+
})
|
|
1230
|
+
if (!r.ok) return { ok: false, error: r.error || '执行失败' }
|
|
1231
|
+
let parsed
|
|
1232
|
+
try { parsed = JSON.parse(r.out) } catch (e) { return { ok: false, error: '无法解析 node 输出' } }
|
|
1233
|
+
if (parsed.error) return { ok: false, error: parsed.error }
|
|
1234
|
+
return { ok: true, statusCode: parsed.statusCode, contentType: String(parsed.contentType || ''), body: parsed.body }
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
async function httpPost(url, headers, body, timeoutMs) {
|
|
1238
|
+
const direct = await httpDirect('POST', url, headers, body, timeoutMs)
|
|
1239
|
+
if (direct.ok) return direct
|
|
1240
|
+
if (!direct.unsupported) return direct
|
|
1241
|
+
return httpPostViaSpawn(url, headers, body)
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
function bjTodayKey() {
|
|
1245
|
+
const d = new Date(Date.now() + 8 * 3600 * 1000)
|
|
1246
|
+
return d.getUTCFullYear() + '-' + pad2(d.getUTCMonth() + 1) + '-' + pad2(d.getUTCDate())
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// 是否存在 DigitalOcean 记录:只有这类记录的费用需要 USD/CNY 折算,
|
|
1250
|
+
// 其余记录的金额与汇率无关,不该为了汇率去等网络。
|
|
1251
|
+
function hasDigitalOceanRecords() {
|
|
1252
|
+
for (const r of records) {
|
|
1253
|
+
const p = String(r.provider || '').toLowerCase()
|
|
1254
|
+
if (p === 'digital-ocean' || p === 'digitalocean') return true
|
|
1255
|
+
}
|
|
1256
|
+
return false
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
// 同一天只拉一次,且并发调用合并成一次请求:多个面板同时刷新时不会
|
|
1260
|
+
// 各自起一个请求(旧实现是每次 refreshFxRate(false) 都可能起一次)。
|
|
1261
|
+
let fxInFlight = null
|
|
1262
|
+
async function refreshFxRate(force) {
|
|
1263
|
+
if (!force && FX.rate > 0 && FX.queriedAt && bjKey(FX.queriedAt) === bjTodayKey()) return FX
|
|
1264
|
+
if (fxInFlight) return fxInFlight
|
|
1265
|
+
fxInFlight = (async () => {
|
|
1266
|
+
const r = await httpGet('https://api.frankfurter.dev/v2/rates?base=USD"es=CNY', {
|
|
1267
|
+
Accept: 'application/json',
|
|
1268
|
+
'User-Agent': 'dsh-usage-plugin'
|
|
1269
|
+
}, 15000)
|
|
1270
|
+
if (!r.ok) {
|
|
1271
|
+
FX = { ...FX, stale: FX.rate > 0, error: r.error || '汇率请求失败', queriedAt: Date.now() }
|
|
1272
|
+
return FX
|
|
1273
|
+
}
|
|
1274
|
+
try {
|
|
1275
|
+
if (r.statusCode !== 200) throw new Error('HTTP ' + r.statusCode)
|
|
1276
|
+
const arr = JSON.parse(r.body)
|
|
1277
|
+
const row = Array.isArray(arr) ? arr.find((x) => x && x.base === 'USD' && x.quote === 'CNY') : null
|
|
1278
|
+
const rate = Number(row && row.rate)
|
|
1279
|
+
if (!(rate > 0)) throw new Error('响应中缺少 USD/CNY 汇率')
|
|
1280
|
+
FX = { rate, inverse: 1 / rate, date: String(row.date || ''), queriedAt: Date.now(), source: 'Frankfurter', stale: false, error: '' }
|
|
1281
|
+
// DigitalOcean 的费用按汇率折算,汇率一变投影结果就要重算。
|
|
1282
|
+
invalidateProjection()
|
|
1283
|
+
} catch (e) {
|
|
1284
|
+
FX = { ...FX, stale: FX.rate > 0, error: msg(e), queriedAt: Date.now() }
|
|
1285
|
+
}
|
|
1286
|
+
return FX
|
|
1287
|
+
})().finally(() => { fxInFlight = null })
|
|
1288
|
+
return fxInFlight
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
// ── export helpers ─────────────────────────────────────────────────────
|
|
1292
|
+
function csvCell(s) {
|
|
1293
|
+
s = String(s == null ? '' : s)
|
|
1294
|
+
if (/[",\n\r]/.test(s)) return '"' + s.replace(/"/g, '""') + '"'
|
|
1295
|
+
return s
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
function buildCsv() {
|
|
1299
|
+
const header = ['time', 'model', 'provider', 'origin', 'estimated', 'inputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'outputTokens', 'reasoningTokens', 'finishReason', 'period', 'baseCost', 'peakValleyCost', 'autoCost']
|
|
1300
|
+
const lines = [header.join(',')]
|
|
1301
|
+
for (const r of records) {
|
|
1302
|
+
lines.push([
|
|
1303
|
+
r.time, r.model, r.provider, r.origin || 'probe', r.estimated === true ? '1' : '0',
|
|
1304
|
+
r.inputTokens, r.cacheReadTokens, r.cacheWriteTokens,
|
|
1305
|
+
r.outputTokens, r.reasoningTokens, r.finishReason,
|
|
1306
|
+
isPeak(r.pricingTime || r.time) ? 'peak' : 'offPeak', costFor(r, 'base'), costFor(r, 'peakValley'), costFor(r, 'auto')
|
|
1307
|
+
].map(csvCell).join(','))
|
|
1308
|
+
}
|
|
1309
|
+
return lines.join('\r\n')
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
async function writePngFile(base64, outPath) {
|
|
1313
|
+
try {
|
|
1314
|
+
await fsp.mkdir(path.dirname(outPath), { recursive: true })
|
|
1315
|
+
await fsp.writeFile(outPath, Buffer.from(base64, 'base64'))
|
|
1316
|
+
return { ok: true }
|
|
1317
|
+
} catch (e) { return { ok: false, error: msg(e) } }
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
async function writeTextFileViaNode(content, outPath) {
|
|
1321
|
+
try {
|
|
1322
|
+
await fsp.mkdir(path.dirname(outPath), { recursive: true })
|
|
1323
|
+
await fsp.writeFile(outPath, Buffer.from(content, 'utf8'))
|
|
1324
|
+
return { ok: true }
|
|
1325
|
+
} catch (e) { return { ok: false, error: msg(e) } }
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
async function mkdirViaNode(dir) {
|
|
1329
|
+
try { await fsp.mkdir(dir, { recursive: true }); return { ok: true } } catch (e) { return { ok: false, error: msg(e) } }
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
async function pickDirectory() {
|
|
1333
|
+
const subprocess = ctx.get('subprocess')
|
|
1334
|
+
if (!subprocess) return fail('命令执行服务不可用')
|
|
1335
|
+
if (IS_MAC) {
|
|
1336
|
+
let exe = null
|
|
1337
|
+
try { exe = await subprocess.resolveExecutable('osascript') } catch (e) {}
|
|
1338
|
+
if (!exe) return fail('未找到 osascript(macOS 需安装命令行工具 Command Line Tools)')
|
|
1339
|
+
const r = await runCollect([exe, '-e', 'POSIX path of (choose folder)'], { graceMs: 120000 })
|
|
1340
|
+
if (!r.ok && r.error) return fail(r.error)
|
|
1341
|
+
const picked = normPath(r.out.trim())
|
|
1342
|
+
if (!picked) return { ok: false, cancelled: true }
|
|
1343
|
+
return { ok: true, path: picked }
|
|
1344
|
+
}
|
|
1345
|
+
if (!IS_WIN) {
|
|
1346
|
+
for (const c of ['zenity', 'kdialog']) {
|
|
1347
|
+
let exe = null
|
|
1348
|
+
try { exe = await subprocess.resolveExecutable(c) } catch (e) {}
|
|
1349
|
+
if (!exe) continue
|
|
1350
|
+
const argv = c === 'zenity'
|
|
1351
|
+
? [exe, '--file-selection', '--directory', '--title=选择导出目录']
|
|
1352
|
+
: [exe, '--getexistingdirectory', '选择导出目录']
|
|
1353
|
+
const r = await runCollect(argv, { graceMs: 120000 })
|
|
1354
|
+
if (!r.ok && r.error) return fail(r.error)
|
|
1355
|
+
const picked = normPath(r.out.trim())
|
|
1356
|
+
if (!picked) return { ok: false, cancelled: true }
|
|
1357
|
+
return { ok: true, path: picked }
|
|
1358
|
+
}
|
|
1359
|
+
return fail('未找到目录选择工具(请安装 zenity 或 kdialog)')
|
|
1360
|
+
}
|
|
1361
|
+
let exe = null
|
|
1362
|
+
for (const c of ['powershell.exe', 'pwsh.exe', 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe']) {
|
|
1363
|
+
try { exe = await subprocess.resolveExecutable(c); if (exe) break } catch (e) {}
|
|
1364
|
+
}
|
|
1365
|
+
if (!exe) return fail('未找到 PowerShell')
|
|
1366
|
+
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) }'
|
|
1367
|
+
const r = await runCollect([exe, '-NoProfile', '-STA', '-NonInteractive', '-Command', script], { graceMs: 120000 })
|
|
1368
|
+
if (!r.ok && r.error) return fail(r.error)
|
|
1369
|
+
const picked = normPath(r.out.trim())
|
|
1370
|
+
if (!picked) return { ok: false, cancelled: true }
|
|
1371
|
+
return { ok: true, path: picked }
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
async function revealDir(dirArg) {
|
|
1375
|
+
const subprocess = ctx.get('subprocess')
|
|
1376
|
+
if (!subprocess) return fail('命令执行服务不可用')
|
|
1377
|
+
let target = ''
|
|
1378
|
+
const isKey = dirArg === 'csv' || dirArg === 'json' || dirArg === 'images' || dirArg === 'data'
|
|
1379
|
+
if (isKey) {
|
|
1380
|
+
const d = dirs()
|
|
1381
|
+
target = dirArg === 'csv' ? d.csv : dirArg === 'json' ? d.json : dirArg === 'images' ? d.images : d.data
|
|
1382
|
+
target = normPath(target)
|
|
1383
|
+
try {
|
|
1384
|
+
await nfsWriteText(joinPath(target, '.keep'), '')
|
|
1385
|
+
} catch (e) {}
|
|
1386
|
+
} else {
|
|
1387
|
+
target = normPath(dirArg)
|
|
1388
|
+
await mkdirViaNode(target)
|
|
1389
|
+
}
|
|
1390
|
+
const revealCmd = IS_WIN ? 'explorer.exe' : (IS_MAC ? 'open' : 'xdg-open')
|
|
1391
|
+
let exe = null
|
|
1392
|
+
try { exe = await subprocess.resolveExecutable(revealCmd) } catch (e) {}
|
|
1393
|
+
if (!exe) return fail('未找到 ' + revealCmd)
|
|
1394
|
+
try {
|
|
1395
|
+
subprocess.spawn({ argv: [exe, target], cwd: await safeCwd(), stdio: { stdin: 'ignore', stdout: { maxBytes: 1024 }, stderr: { maxBytes: 1024 } }, graceMs: 5000 })
|
|
1396
|
+
return { ok: true }
|
|
1397
|
+
} catch (e) { return fail(msg(e)) }
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
// ── API ────────────────────────────────────────────────────────────────
|
|
1401
|
+
async function routeApi(body) {
|
|
1402
|
+
const action = body && body.action ? String(body.action) : ''
|
|
1403
|
+
try { await ensureInit() } catch (e) {}
|
|
1404
|
+
switch (action) {
|
|
1405
|
+
case 'list': {
|
|
1406
|
+
// 汇率只影响 DigitalOcean 记录的折算。此前这里是**无条件 await**:
|
|
1407
|
+
// 没有 DO 记录时(绝大多数用户)每次刷新都会起一个 node 子进程去拉汇率,
|
|
1408
|
+
// 于是"用量与消耗"面板每次打开都要多等几百毫秒。现在只在确有 DO 记录时
|
|
1409
|
+
// 等待;否则彻底不碰网络,让面板立即可用。
|
|
1410
|
+
if (hasDigitalOceanRecords()) {
|
|
1411
|
+
try { await refreshFxRate(false) } catch (e) {}
|
|
1412
|
+
}
|
|
1413
|
+
const items = []
|
|
1414
|
+
const toolItems = []
|
|
1415
|
+
let legacyPricingCount = 0
|
|
1416
|
+
let latest = 0
|
|
1417
|
+
for (const r of records) {
|
|
1418
|
+
const rt = r.time || 0
|
|
1419
|
+
if (rt > latest) latest = rt
|
|
1420
|
+
if (isRealCall(r)) {
|
|
1421
|
+
items.push(projectRecord(r))
|
|
1422
|
+
if (!r.unit) legacyPricingCount++
|
|
1423
|
+
} else {
|
|
1424
|
+
toolItems.push(projectRecord(r))
|
|
1425
|
+
}
|
|
1426
|
+
}
|
|
1427
|
+
// 没有冻结单价的记录(旧版本写入 / 手工导入):它们的费用是按"当前
|
|
1428
|
+
// 价格表"算的,改价后会跟着变。UI 必须把这件事显式告诉用户,
|
|
1429
|
+
// 否则用户会以为历史和现在用的同一套价。
|
|
1430
|
+
// 价格来源与核对信息:官方价目页 2026-11-01 复核后,
|
|
1431
|
+
// - 模型单价(flash / v4-pro)未变;
|
|
1432
|
+
// - 新增 Search / Fetch 按次计费(¥5 / 1000 次成功请求,2026-11-01 起);
|
|
1433
|
+
// - 高峰时段明确排除法定节假日(本插件自 2026-11-01 起按此计费)。
|
|
1434
|
+
// Search/Fetch 的次数插件拿不到(Harness 不持久化该计数),因此只作声明,
|
|
1435
|
+
// 不按估算次数计入费用——宁可少算,也不编一个看起来精确的数字。
|
|
1436
|
+
const pricingMeta = {
|
|
1437
|
+
sourceUrl: 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing/',
|
|
1438
|
+
checkedAt: '2026-11-01',
|
|
1439
|
+
searchFetch: { pricePer1000: 5, effectiveAt: HOLIDAY_RULE_AT, counted: false },
|
|
1440
|
+
holidayRuleAt: HOLIDAY_RULE_AT,
|
|
1441
|
+
holidays: Array.from(HOLIDAYS)
|
|
1442
|
+
}
|
|
1443
|
+
return { ok: true, version: PLUGIN_VERSION, total: records.length, latest, pricingVersion: projectionVersion, records: items, count: items.length, toolCalls: toolItems, dataPath, persistOk, persistError, pricing: PRICING, pricingMeta, pricingHistory: pricingHistory.slice(-12), effectiveAt: EFFECTIVE_AT, effectiveV41At: V41_EFFECTIVE_AT, days: buildDays(items), fx: FX, budget, legacyPricingCount }
|
|
1444
|
+
}
|
|
1445
|
+
case 'stat': {
|
|
1446
|
+
// 轻量心跳:只回答"数据变了没有",不返回任何记录。
|
|
1447
|
+
// 客户端此前每 10 秒调一次 `list` —— 那等于每 10 秒把全部记录
|
|
1448
|
+
// (上万条可达数十 MB)重新投影、序列化、JSON.parse、全量重渲染,
|
|
1449
|
+
// 面板因此长期占用主线程,表现为"打字/切换页签卡顿"。
|
|
1450
|
+
let latest = 0
|
|
1451
|
+
for (let i = 0; i < records.length; i++) {
|
|
1452
|
+
const t = records[i].time || 0
|
|
1453
|
+
if (t > latest) latest = t
|
|
1454
|
+
}
|
|
1455
|
+
return {
|
|
1456
|
+
ok: true,
|
|
1457
|
+
count: records.length,
|
|
1458
|
+
latest,
|
|
1459
|
+
pricingVersion: projectionVersion,
|
|
1460
|
+
persistOk,
|
|
1461
|
+
hasDigitalOcean: hasDigitalOceanRecords()
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
case 'tokenForMessage': {
|
|
1465
|
+
// 消息底部弹窗:分两层统计——整场对话累计(conversation)+ 本轮明细(aggregate/records)。
|
|
1466
|
+
const sid = body && body.sessionId ? String(body.sessionId) : ''
|
|
1467
|
+
const from = Number(body && body.from) || 0
|
|
1468
|
+
const to = Number(body && body.to) || 0
|
|
1469
|
+
let sessionRecs = records.filter(isRealCall)
|
|
1470
|
+
if (sid) sessionRecs = sessionRecs.filter((r) => String(r.sessionId || '') === sid)
|
|
1471
|
+
let turnRecs = sessionRecs
|
|
1472
|
+
if (from > 0) turnRecs = turnRecs.filter((r) => r.time >= from)
|
|
1473
|
+
if (to > 0) turnRecs = turnRecs.filter((r) => r.time <= to)
|
|
1474
|
+
const convoAgg = computeAgg(sessionRecs.map(projectRecord))
|
|
1475
|
+
const turnAgg = computeAgg(turnRecs.map(projectRecord))
|
|
1476
|
+
return { ok: true, records: turnRecs.map(projectRecord), aggregate: turnAgg, conversation: convoAgg, count: turnRecs.length, from, to }
|
|
1477
|
+
}
|
|
1478
|
+
case 'clear': {
|
|
1479
|
+
const n = records.length
|
|
1480
|
+
records.length = 0
|
|
1481
|
+
persistNow()
|
|
1482
|
+
return { ok: true, cleared: n }
|
|
1483
|
+
}
|
|
1484
|
+
case 'setPrices': {
|
|
1485
|
+
const prices = body && body.prices
|
|
1486
|
+
if (!prices || typeof prices !== 'object') return fail('缺少价格数据')
|
|
1487
|
+
let changed = false
|
|
1488
|
+
for (const regime of PRICE_REGIMES) {
|
|
1489
|
+
const src = prices[regime]
|
|
1490
|
+
const dst = PRICING[regime]
|
|
1491
|
+
if (!src || typeof src !== 'object' || !dst) continue
|
|
1492
|
+
for (const mk of PRICE_MODELS) {
|
|
1493
|
+
const row = src[mk]
|
|
1494
|
+
if (!row || typeof row !== 'object' || !dst[mk]) continue
|
|
1495
|
+
for (const k of ['cacheHit', 'cacheMiss', 'output']) {
|
|
1496
|
+
const v = Number(row[k])
|
|
1497
|
+
if (Number.isFinite(v) && v >= 0) { dst[mk][k] = v; changed = true }
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
if (!changed) return fail('没有可用的价格更新(价格必须是非负数字)')
|
|
1502
|
+
invalidateProjection()
|
|
1503
|
+
persistPricing()
|
|
1504
|
+
// 只影响之后产生的记录;同时把这次改价之后的价格表存成一条历史快照。
|
|
1505
|
+
appendPricingHistory('edit', body && body.label ? String(body.label) : '手工修改价格表')
|
|
1506
|
+
// 默认只影响"之后产生的记录":历史记录带自己的冻结单价,不会被重算。
|
|
1507
|
+
// 确实想用新价改写历史时,显式传 reprice: true(会清空全部快照)。
|
|
1508
|
+
let repriced = 0
|
|
1509
|
+
if (body && body.reprice === true) repriced = dropUnitSnapshots()
|
|
1510
|
+
if (repriced > 0) await persistNow()
|
|
1511
|
+
return { ok: true, repriced }
|
|
1512
|
+
}
|
|
1513
|
+
case 'repriceAll': {
|
|
1514
|
+
// 显式重算历史:清掉所有记录的冻结单价,让它们改用当前价格表计算。
|
|
1515
|
+
// 这是唯一会改写历史费用的入口,只能由用户主动触发。
|
|
1516
|
+
const repriced = dropUnitSnapshots()
|
|
1517
|
+
await persistNow()
|
|
1518
|
+
return { ok: true, repriced }
|
|
1519
|
+
}
|
|
1520
|
+
case 'resetPrices': {
|
|
1521
|
+
for (const regime of PRICE_REGIMES) {
|
|
1522
|
+
const src = DEFAULT_PRICING[regime]
|
|
1523
|
+
const dst = PRICING[regime]
|
|
1524
|
+
if (!src || !dst) continue
|
|
1525
|
+
for (const mk of PRICE_MODELS) {
|
|
1526
|
+
if (!src[mk] || !dst[mk]) continue
|
|
1527
|
+
dst[mk].cacheHit = src[mk].cacheHit
|
|
1528
|
+
dst[mk].cacheMiss = src[mk].cacheMiss
|
|
1529
|
+
dst[mk].output = src[mk].output
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
invalidateProjection()
|
|
1533
|
+
persistPricing()
|
|
1534
|
+
appendPricingHistory('reset', '恢复内置默认价格表')
|
|
1535
|
+
return { ok: true }
|
|
1536
|
+
}
|
|
1537
|
+
case 'setBudget': {
|
|
1538
|
+
// 设置月度预算(元);传入非负数字生效,null/NaN/负数视为清除预算。
|
|
1539
|
+
const raw = Number(body && body.monthly)
|
|
1540
|
+
if (Number.isFinite(raw) && raw >= 0) budget = { monthly: raw }
|
|
1541
|
+
else budget = null
|
|
1542
|
+
persistBudget()
|
|
1543
|
+
return { ok: true, budget }
|
|
1544
|
+
}
|
|
1545
|
+
case 'fxRefresh': {
|
|
1546
|
+
const fx = await refreshFxRate(true)
|
|
1547
|
+
return { ok: fx.rate > 0, fx, error: fx.rate > 0 ? '' : (fx.error || '无法获取汇率') }
|
|
1548
|
+
}
|
|
1549
|
+
case 'pickDir':
|
|
1550
|
+
return pickDirectory()
|
|
1551
|
+
case 'export': {
|
|
1552
|
+
if (!root) return fail('未找到数据根目录')
|
|
1553
|
+
const kind = (body && body.kind) === 'json' ? 'json' : 'csv'
|
|
1554
|
+
const name = 'dsh-usage-' + stamp() + (kind === 'json' ? '.json' : '.csv')
|
|
1555
|
+
const content = kind === 'json'
|
|
1556
|
+
? JSON.stringify({ exportedAt: Date.now(), pricing: PRICING, records }, null, 2)
|
|
1557
|
+
: buildCsv()
|
|
1558
|
+
const dirArg = body && body.dir ? normPath(String(body.dir)) : ''
|
|
1559
|
+
if (dirArg) {
|
|
1560
|
+
const outPath = joinPath(dirArg, name)
|
|
1561
|
+
const r = await writeTextFileViaNode(content, outPath)
|
|
1562
|
+
if (!r.ok) return fail(r.error)
|
|
1563
|
+
return { ok: true, path: outPath, name, dir: dirArg }
|
|
1564
|
+
}
|
|
1565
|
+
const outPath = joinPath(kind === 'json' ? dirs().json : dirs().csv, name)
|
|
1566
|
+
try {
|
|
1567
|
+
await nfsWriteText(outPath, content)
|
|
1568
|
+
return { ok: true, path: normPath(outPath), name, dir: kind === 'json' ? 'json' : 'csv' }
|
|
1569
|
+
} catch (e) { return fail(msg(e)) }
|
|
1570
|
+
}
|
|
1571
|
+
case 'exportPng': {
|
|
1572
|
+
const dataUrl = body && body.dataUrl ? String(body.dataUrl) : ''
|
|
1573
|
+
if (!dataUrl) return fail('缺少图片数据')
|
|
1574
|
+
const idx = dataUrl.indexOf('base64,')
|
|
1575
|
+
const b64 = idx >= 0 ? dataUrl.slice(idx + 7) : dataUrl
|
|
1576
|
+
if (!root) return fail('未找到数据根目录')
|
|
1577
|
+
const name = 'dsh-usage-report-' + stamp() + '.png'
|
|
1578
|
+
const dirArg = body && body.dir ? normPath(String(body.dir)) : ''
|
|
1579
|
+
const outPath = normPath(joinPath(dirArg || dirs().images, name))
|
|
1580
|
+
const r = await writePngFile(b64, outPath)
|
|
1581
|
+
if (!r.ok) return fail(r.error)
|
|
1582
|
+
return { ok: true, path: outPath, name, dir: dirArg || 'images' }
|
|
1583
|
+
}
|
|
1584
|
+
case 'import': {
|
|
1585
|
+
const content = body && body.content != null ? String(body.content) : ''
|
|
1586
|
+
const filename = body && body.filename ? String(body.filename) : ''
|
|
1587
|
+
if (!content) return fail('请选择要导入的文件')
|
|
1588
|
+
let parsed
|
|
1589
|
+
if (String(filename || '').toLowerCase().indexOf('.csv') >= 0) {
|
|
1590
|
+
const lines = String(content).split(/\r?\n/).filter((l) => l.trim().length > 0)
|
|
1591
|
+
const header = lines[0] ? parseCsvLine(lines[0]) : []
|
|
1592
|
+
const idx = {}
|
|
1593
|
+
header.forEach((h, i) => { idx[String(h).trim()] = i })
|
|
1594
|
+
parsed = lines.slice(1).map((line) => {
|
|
1595
|
+
const cells = parseCsvLine(line)
|
|
1596
|
+
const get = (name) => (idx[name] === undefined ? '' : (cells[idx[name]] === undefined ? '' : cells[idx[name]]))
|
|
1597
|
+
return {
|
|
1598
|
+
time: get('time'), model: get('model'), provider: get('provider'),
|
|
1599
|
+
inputTokens: get('inputTokens'), outputTokens: get('outputTokens'),
|
|
1600
|
+
cacheReadTokens: get('cacheReadTokens'), cacheWriteTokens: get('cacheWriteTokens'),
|
|
1601
|
+
reasoningTokens: get('reasoningTokens'), finishReason: get('finishReason')
|
|
1602
|
+
}
|
|
1603
|
+
})
|
|
1604
|
+
} else {
|
|
1605
|
+
try {
|
|
1606
|
+
const data = JSON.parse(content)
|
|
1607
|
+
parsed = Array.isArray(data) ? data : (data && Array.isArray(data.records) ? data.records : null)
|
|
1608
|
+
} catch (e) { parsed = null }
|
|
1609
|
+
}
|
|
1610
|
+
if (!parsed || !Array.isArray(parsed)) return fail('文件内容不是可识别的用量数据(支持 JSON 或 CSV)')
|
|
1611
|
+
let imported = 0, skipped = 0, invalid = 0
|
|
1612
|
+
const existing = {}
|
|
1613
|
+
for (const r of records) existing[recordKey(r)] = true
|
|
1614
|
+
for (const raw of parsed) {
|
|
1615
|
+
const rec = normalizeRecord(raw)
|
|
1616
|
+
if (!rec) { invalid++; continue }
|
|
1617
|
+
const key = recordKey(rec)
|
|
1618
|
+
if (existing[key]) { skipped++; continue }
|
|
1619
|
+
existing[key] = true
|
|
1620
|
+
records.push(rec)
|
|
1621
|
+
imported++
|
|
1622
|
+
}
|
|
1623
|
+
if (records.length > MAX_RECORDS) records.splice(0, records.length - MAX_RECORDS)
|
|
1624
|
+
records.sort((a, b) => a.time - b.time)
|
|
1625
|
+
persistNow()
|
|
1626
|
+
return { ok: true, imported, skipped, invalid, total: records.length }
|
|
1627
|
+
}
|
|
1628
|
+
case 'reveal': {
|
|
1629
|
+
const dirArg = body && body.dir ? String(body.dir) : 'data'
|
|
1630
|
+
return revealDir(dirArg)
|
|
1631
|
+
}
|
|
1632
|
+
default:
|
|
1633
|
+
return fail('未知操作:' + action)
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
function parseCsvLine(line) {
|
|
1638
|
+
const cells = []
|
|
1639
|
+
let cur = ''
|
|
1640
|
+
let inQ = false
|
|
1641
|
+
for (let i = 0; i < line.length; i++) {
|
|
1642
|
+
const ch = line[i]
|
|
1643
|
+
if (inQ) {
|
|
1644
|
+
if (ch === '"') {
|
|
1645
|
+
if (line[i + 1] === '"') { cur += '"'; i++ } else inQ = false
|
|
1646
|
+
} else cur += ch
|
|
1647
|
+
} else if (ch === '"') inQ = true
|
|
1648
|
+
else if (ch === ',') { cells.push(cur); cur = '' }
|
|
1649
|
+
else cur += ch
|
|
1650
|
+
}
|
|
1651
|
+
cells.push(cur)
|
|
1652
|
+
return cells
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
function readBody(req) {
|
|
1656
|
+
return new Promise((resolve) => {
|
|
1657
|
+
let d = ''
|
|
1658
|
+
req.on('data', (c) => { d += c })
|
|
1659
|
+
req.on('end', () => { try { resolve(JSON.parse(d)) } catch (e) { resolve({}) } })
|
|
1660
|
+
req.on('error', () => resolve({}))
|
|
1661
|
+
})
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
function sendJson(res, obj) {
|
|
1665
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' })
|
|
1666
|
+
res.end(JSON.stringify(obj))
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
const webServer = ctx.get('webServer')
|
|
1670
|
+
push('webServer=' + (webServer ? 'present' : 'undefined'))
|
|
1671
|
+
if (webServer && typeof webServer.register === 'function') {
|
|
1672
|
+
try {
|
|
1673
|
+
webServer.register({
|
|
1674
|
+
kind: 'exact',
|
|
1675
|
+
path: '/usage/api',
|
|
1676
|
+
handler: async (req, res) => {
|
|
1677
|
+
try {
|
|
1678
|
+
const body = await readBody(req)
|
|
1679
|
+
sendJson(res, await routeApi(body))
|
|
1680
|
+
} catch (e) {
|
|
1681
|
+
sendJson(res, { ok: false, error: msg(e) })
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
})
|
|
1685
|
+
push('route-registered')
|
|
1686
|
+
} catch (e) {
|
|
1687
|
+
push('route-register-threw: ' + (e && e.stack ? e.stack : msg(e)))
|
|
1688
|
+
}
|
|
1689
|
+
try {
|
|
1690
|
+
// SSE 实时推送端点:数据变化时向订阅者推送 {"type":"changed"}。
|
|
1691
|
+
// 桌面端数据中心订阅该流即可即时感知用量数据更新。
|
|
1692
|
+
webServer.register({
|
|
1693
|
+
kind: 'exact',
|
|
1694
|
+
path: '/usage/api/events',
|
|
1695
|
+
handler: (req, res) => {
|
|
1696
|
+
res.writeHead(200, {
|
|
1697
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
1698
|
+
'Cache-Control': 'no-cache',
|
|
1699
|
+
Connection: 'keep-alive',
|
|
1700
|
+
'X-Accel-Buffering': 'no',
|
|
1701
|
+
})
|
|
1702
|
+
res.write(':ok\n\n')
|
|
1703
|
+
sseClients.add(res)
|
|
1704
|
+
req.on('close', () => { sseClients.delete(res) })
|
|
1705
|
+
}
|
|
1706
|
+
})
|
|
1707
|
+
} catch (e) {
|
|
1708
|
+
push('events-route-register-threw: ' + msg(e))
|
|
1709
|
+
}
|
|
1710
|
+
} else {
|
|
1711
|
+
push('route-not-registered (no webServer)')
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
push('apply-end')
|
|
1715
|
+
diag.ok = true
|
|
1716
|
+
} catch (e) {
|
|
1717
|
+
diag.ok = false
|
|
1718
|
+
diag.error = (e && e.stack) ? e.stack : String(e)
|
|
1719
|
+
}
|
|
1720
|
+
flushDiag()
|
|
1721
|
+
}
|
|
1722
|
+
}
|