dsh-dock 0.10.3 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,27 +2,28 @@
2
2
  //
3
3
  // 记录 DSH 会话日志中的每次 LLM 调用(assistant/message 的 usage),
4
4
  // 支持历史全量扫描 + 实时 session/event 增量;RPC 经 webServer HTTP 路由
5
- // POST /dsh-dock/tokenlog/query|export|scan|hello
6
- // 向浏览器半部提供查询/统计/导出。
5
+ // POST /dsh-dock/tokenlog/query|export|scan|hello|pricing|setpricing
6
+ // 向浏览器半部提供查询/统计/导出,以及单价的读取与保存。
7
7
  //
8
8
  // 数据模型(标量,无 Host 对象引用):
9
9
  // {
10
10
  // id, time, sessionId, provider, model, apiKey(掩码),
11
11
  // inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens, reasoningTokens,
12
- // billedInput, totalTokens, cacheHitPercent, cost, effort, status, llmMs, turn, step
12
+ // billedInput, totalTokens, cacheHitPercent, cost, pricingSource, effort, status, llmMs, turn, step
13
13
  // }
14
14
  //
15
- // 定价可经 settings.yaml 的 dsh-dock-tokenlog 命名空间覆盖(未注册 schema,读原始用户层):
16
- // dsh-dock-tokenlog:
17
- // usdCnyRate: 7.2
18
- // pricingUrl: 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing'
19
- // pricingFetchIntervalHours: 24
20
- // pricing:
21
- // deepseek-v4-flash: { input: 1.5, output: 4.5, cacheRead: 0.05, cacheWrite: 0 }
22
- import { sendJson, readBody } from '../../src/host-core.js'
23
-
24
- /** 本功能的 settings 覆盖命名空间(未注册 schema:读取原始用户层,字段全部可选)。 */
25
- const NS = 'dsh-dock-tokenlog'
15
+ // 单价配置保存在 settings 的 dsh-dock 命名空间 tokenlog 段(schema 见 src/host-core.js):
16
+ // dsh-dock:
17
+ // tokenlog:
18
+ // usdCnyRate: 7.2
19
+ // fetchOfficial: true
20
+ // pricing:
21
+ // - { match: deepseek-v4-flash, input: 1.5, output: 4.5, cacheRead: 0.05, cacheWrite: 0,
22
+ // peaks: [{ start: 9, end: 14, input: 3, output: 9, cacheRead: 0.1, cacheWrite: 0 }] }
23
+ // 定价优先级:用户自定义单价 > 官网抓取刊例价 > 内置默认表 > 兜底单价。
24
+ // 分时段价支持多段(start>end 跨零点、start=end 全天、小数小时表示半点);旧单段 peak 自动归一。
25
+ // 费用在查询/导出时按当前单价即时重算,因此改价无需重扫历史。
26
+ import { DOCK_NS, sendJson, readBody } from '../../src/host-core.js'
26
27
 
27
28
  export const feature = {
28
29
  id: 'tokenlog',
@@ -53,25 +54,28 @@ export const feature = {
53
54
  // ===== 定价与金额估算 =====
54
55
  // 定价表以「人民币元 / 百万 tokens」为基准(DeepSeek/智谱官方刊例即人民币), 金额先算 CNY,
55
56
  // 对外记录的 cost 保持 USD,供旧版客户端兼容;当前客户端和 CSV 统一展示人民币。
56
- // usdCnyRate 与 pricing 均可被 settings.yaml 覆盖:
57
- // dsh-dock-tokenlog:
58
- // usdCnyRate: 7.2 # USD↔CNY 汇率
59
- // pricing: # 可选: 覆盖/新增定价条目(modelPattern -> {...})
60
- // deepseek-v4-flash:
61
- // input: 1.5 output: 4.5 cacheRead: 0.05 cacheWrite: 0
62
- // peak: { start: 9, end: 14, input: 3.0, output: 9.0, cacheRead: 0.10, cacheWrite: 0 }
63
- // 官方价格随时会变动: 改价只改 settings.yaml 即可, 无需升级插件; 重启/重扫后历史记录按新价重算。
57
+ // 单价来源与优先级(resolvePricing):
58
+ // 1) 用户在面板「单价设置」保存的自定义单价(settings dsh-dock.tokenlog.pricing)——最高优先;
59
+ // 2) 官网抓取刊例价(fetchOfficial 关闭时不参与);
60
+ // 3) 内置默认表 DEFAULT_PRICING;
61
+ // 4) 兜底单价(settings dsh-dock.tokenlog.fallback)。
62
+ // 费用在查询/导出时即时重算(见 withCost), 所以改价立即生效、无需重扫历史。
63
+ const DEFAULT_PRICING_URL = 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing'
64
+ const DEFAULT_FETCH_INTERVAL_HOURS = 24
65
+ const DEFAULT_FALLBACK = { input: 2.16, output: 6.48, cacheRead: 0.43, cacheWrite: 4.32 }
66
+ /** 计价来源标签(随记录下发, 供界面标注)。 */
67
+ const PRICING_SOURCE_LABELS = { custom: '自定义', official: '官网', builtin: '内置', fallback: '兜底' }
64
68
  let usdCnyRate = 7.2
65
- let fallbackPricing = { match: '*', input: 2.16, output: 6.48, cacheRead: 0.43, cacheWrite: 4.32 }
69
+ let fallbackPricing = { match: '*', ...DEFAULT_FALLBACK }
66
70
  // 内置定价表(人民币元 / 百万 tokens), 按 model 子串匹配; 未匹配走 fallback。
67
- // 支持峰谷定价: 条目带 peak={start,end,input,output,cacheRead,cacheWrite},
68
- // 高峰时段(插件运行机器本地时间 [start,end))用峰价, 其余用基础价。
69
- // 官方来源: DeepSeek-V4 系列 2026-08-17 生效的峰谷刊例(高峰每日 9:00-14:00, 空闲为高峰一半)。
71
+ // 支持分时段定价: 条目可带 peaks=[{start,end,input,output,cacheRead,cacheWrite}, ...],
72
+ // 命中某段(插件运行机器本地时间)用该段价, 其余时段用基准价; 支持多段与跨零点(start>end)。
73
+ // 官方来源: DeepSeek-V4 系列 2026-08-17 生效的分时段刊例(高峰每日 9:00-14:00, 空闲为高峰一半)。
70
74
  const DEFAULT_PRICING = [
71
75
  { match: 'deepseek-v4-flash', input: 1.5, output: 4.5, cacheRead: 0.05, cacheWrite: 0,
72
- peak: { start: 9, end: 14, input: 3.0, output: 9.0, cacheRead: 0.10, cacheWrite: 0 } },
76
+ peaks: [{ start: 9, end: 14, input: 3.0, output: 9.0, cacheRead: 0.10, cacheWrite: 0 }] },
73
77
  { match: 'deepseek-v4-pro', input: 4.5, output: 13.5, cacheRead: 0.15, cacheWrite: 0,
74
- peak: { start: 9, end: 14, input: 9.0, output: 27.0, cacheRead: 0.30, cacheWrite: 0 } },
78
+ peaks: [{ start: 9, end: 14, input: 9.0, output: 27.0, cacheRead: 0.30, cacheWrite: 0 }] },
75
79
  { match: 'deepseek-v3', input: 3.6, output: 7.2, cacheRead: 0.72, cacheWrite: 7.2 },
76
80
  { match: 'deepseek-chat', input: 1.94, output: 7.92, cacheRead: 0.5, cacheWrite: 7.92 },
77
81
  { match: 'deepseek-reasoner', input: 3.96, output: 15.77, cacheRead: 1.01, cacheWrite: 7.92 },
@@ -79,84 +83,125 @@ export const feature = {
79
83
  { match: 'glm', input: 2.88, output: 8.64, cacheRead: 0.58, cacheWrite: 7.2 },
80
84
  ]
81
85
  let pricingTable = DEFAULT_PRICING
86
+ // 用户自定义单价(最高优先)与官网抓取开关/地址——均由 loadPricingConfig() 从 settings 刷新。
87
+ let userPricing = []
88
+ let fetchOfficialEnabled = true
89
+ let pricingUrl = DEFAULT_PRICING_URL
90
+ let fetchIntervalMs = DEFAULT_FETCH_INTERVAL_HOURS * 3600000
82
91
 
83
92
  function normalizePricing(match, e) {
84
93
  const n = (x) => (typeof x === 'number' && Number.isFinite(x) ? x : 0)
85
94
  const row = { match, input: n(e.input), output: n(e.output), cacheRead: n(e.cacheRead), cacheWrite: n(e.cacheWrite) }
86
- if (e.peak && typeof e.peak === 'object') {
87
- row.peak = {
88
- start: n(e.peak.start), end: n(e.peak.end),
89
- input: n(e.peak.input), output: n(e.peak.output),
90
- cacheRead: n(e.peak.cacheRead), cacheWrite: n(e.peak.cacheWrite),
91
- }
95
+ // 分时段价支持多段: peaks=[{start,end,input,output,cacheRead,cacheWrite}, ...]。
96
+ // 兼容旧的单段写法 peak={...}(以及旧版本存盘数据), 统一归一到 peaks。
97
+ const src = Array.isArray(e.peaks) && e.peaks.length ? e.peaks : (e.peak && typeof e.peak === 'object' ? [e.peak] : [])
98
+ const peaks = []
99
+ for (const s of src) {
100
+ if (!s || typeof s !== 'object') continue
101
+ peaks.push({
102
+ start: n(s.start), end: n(s.end),
103
+ input: n(s.input), output: n(s.output),
104
+ cacheRead: n(s.cacheRead), cacheWrite: n(s.cacheWrite),
105
+ })
92
106
  }
107
+ if (peaks.length) row.peaks = peaks
93
108
  return row
94
109
  }
95
110
 
96
- // 读取 settings.yaml 的 dsh-dock-tokenlog { usdCnyRate?, pricing? } 覆盖默认值。
97
- // pricing: map modelPattern -> {input,output,cacheRead,cacheWrite,peak?};
98
- // 设置条目排前面优先命中(同 match 覆盖内置); 键 '*' 覆盖 fallback。幂等, 可重复调用。
111
+ /** 浅拷贝分时段数组(RPC 下发与落盘都用它,避免调用方改到内存态)。 */
112
+ function clonePeaks(peaks) {
113
+ return Array.isArray(peaks) ? peaks.map((s) => Object.assign({}, s)) : []
114
+ }
115
+
116
+ // 读 settings 的 dsh-dock.tokenlog 段并刷新模块级定价状态。幂等, 可重复调用。
117
+ // 取值做防御性归一(schema 已给默认, 但 settings 未挂载/异常时仍需兜底)。
99
118
  function loadPricingConfig() {
100
119
  try {
101
120
  const settings = ctx.get('settings')
102
- if (!settings || typeof settings.get !== 'function') return
103
- const v = settings.get(NS)
104
- if (!v || typeof v !== 'object') return
105
- if (typeof v.usdCnyRate === 'number' && v.usdCnyRate > 0) usdCnyRate = v.usdCnyRate
106
- if (v.pricing && typeof v.pricing === 'object') {
107
- const overrides = []
108
- for (const [match, entry] of Object.entries(v.pricing)) {
109
- if (!entry || typeof entry !== 'object') continue
110
- const row = normalizePricing(String(match).toLowerCase(), entry)
111
- if (row.match === '*') fallbackPricing = row
112
- else overrides.push(row)
113
- }
114
- pricingTable = overrides.concat(DEFAULT_PRICING)
121
+ const root = settings && typeof settings.get === 'function' ? settings.get(DOCK_NS) : null
122
+ const t = root && typeof root === 'object' && root.tokenlog && typeof root.tokenlog === 'object' ? root.tokenlog : null
123
+ if (!t) return
124
+ if (typeof t.usdCnyRate === 'number' && t.usdCnyRate > 0) usdCnyRate = t.usdCnyRate
125
+ if (typeof t.fetchOfficial === 'boolean') fetchOfficialEnabled = t.fetchOfficial
126
+ if (typeof t.pricingUrl === 'string' && t.pricingUrl) pricingUrl = t.pricingUrl
127
+ if (typeof t.pricingFetchIntervalHours === 'number' && t.pricingFetchIntervalHours > 0) {
128
+ fetchIntervalMs = t.pricingFetchIntervalHours * 3600000
115
129
  }
130
+ userPricing = Array.isArray(t.pricing)
131
+ ? t.pricing
132
+ .filter((e) => e && typeof e === 'object' && typeof e.match === 'string' && e.match)
133
+ .map((e) => normalizePricing(String(e.match).toLowerCase(), e))
134
+ : []
135
+ if (t.fallback && typeof t.fallback === 'object') fallbackPricing = normalizePricing('*', t.fallback)
116
136
  } catch (e) {
117
137
  console.error('[dsh-dock] tokenlog read pricing config failed', e && e.message)
118
138
  }
119
139
  }
120
140
 
121
- // 命中定价行: 优先官网自动获取 → settings.yaml 覆盖+内置 → fallback; 再按调用时刻取峰/谷价。
122
- // 官网获取的数据若为 USD 则自动折成 CNY(内置表已是 CNY)。返回人民币单价(元/百万 tokens)。
141
+ // 分时段命中: start<end 为普通区间 [start,end); start>end 表示跨零点(如 23~7 点);
142
+ // start===end 视为全天。多段命中时取第一段(按配置顺序)。
143
+ function pickPeakSegment(peaks, hour) {
144
+ if (!Array.isArray(peaks) || hour < 0) return null
145
+ for (const s of peaks) {
146
+ const a = Number(s.start), b = Number(s.end)
147
+ if (!Number.isFinite(a) || !Number.isFinite(b)) continue
148
+ const hit = a === b ? true : (a < b ? (hour >= a && hour < b) : (hour >= a || hour < b))
149
+ if (hit) return s
150
+ }
151
+ return null
152
+ }
153
+
154
+ // 命中定价行: 用户自定义 → 官网抓取(可关闭) → 内置表 → 兜底; 再按调用时刻取分时价。
155
+ // 官网获取的数据若为 USD 则自动折成 CNY(内置表已是 CNY)。返回单价(元/百万 tokens)与来源标签。
123
156
  function resolvePricing(model, timeMs) {
124
157
  const m = String(model || '').toLowerCase()
125
158
  let row = null
126
- let fromFetch = false
127
- // 1. 优先: 官网自动获取的定价(48h 内有效, 超时回退内置)
128
- const fetched = fetchedPricing
129
- if (fetched && fetched.length > 0 && (Date.now() - lastFetchTime < FETCH_INTERVAL_MS * 2)) {
130
- for (const r of fetched) {
131
- if (m.includes(r.match)) { row = r; fromFetch = true; break }
159
+ let source = 'fallback'
160
+ // 1) 用户自定义单价: 最高优先(官网抓取价常与实际计费不符, 用户填了就以其为准)
161
+ for (const r of userPricing) {
162
+ if (m.includes(r.match)) { row = r; source = 'custom'; break }
163
+ }
164
+ // 2) 官网自动获取的定价(48h 内有效, 超时回退内置; 可在设置里关闭抓取)
165
+ if (!row && fetchOfficialEnabled) {
166
+ const fetched = fetchedPricing
167
+ if (fetched && fetched.length > 0 && (Date.now() - lastFetchTime < fetchIntervalMs * 2)) {
168
+ for (const r of fetched) {
169
+ if (m.includes(r.match)) { row = r; source = 'official'; break }
170
+ }
132
171
  }
133
172
  }
134
- // 2. 回退: settings.yaml 覆盖 + 内置表
173
+ const fromFetch = source === 'official'
174
+ // 3) 内置表
135
175
  if (!row) {
136
176
  for (const r of pricingTable) {
137
- if (m.includes(r.match)) { row = r; break }
177
+ if (m.includes(r.match)) { row = r; source = 'builtin'; break }
138
178
  }
139
179
  }
140
- if (!row) row = fallbackPricing
141
- // 3. 峰谷判断(本地时间 [start,end) 用峰价)
142
- const h = (typeof timeMs === 'number' && Number.isFinite(timeMs)) ? new Date(timeMs).getHours() : -1
143
- let prices
144
- if (row.peak && h >= row.peak.start && h < row.peak.end) {
145
- prices = { input: row.peak.input, output: row.peak.output, cacheRead: row.peak.cacheRead, cacheWrite: row.peak.cacheWrite }
146
- } else {
147
- prices = { input: row.input, output: row.output, cacheRead: row.cacheRead, cacheWrite: row.cacheWrite }
180
+ // 4) 兜底
181
+ if (!row) { row = fallbackPricing; source = 'fallback' }
182
+ // 分时段判断(本地时间, 支持多段与跨零点; 用小数小时以便 08:30 这类半点边界); 未命中用基准价
183
+ let h = -1
184
+ if (typeof timeMs === 'number' && Number.isFinite(timeMs)) {
185
+ const d = new Date(timeMs)
186
+ h = d.getHours() + d.getMinutes() / 60
148
187
  }
149
- // 4. 币种转换: 获取的数据若是 USD 则折成 CNY(内置表已是 CNY, 无需转换)
188
+ const seg = pickPeakSegment(row.peaks, h)
189
+ const prices = seg
190
+ ? { input: seg.input, output: seg.output, cacheRead: seg.cacheRead, cacheWrite: seg.cacheWrite }
191
+ : { input: row.input, output: row.output, cacheRead: row.cacheRead, cacheWrite: row.cacheWrite }
192
+ // 币种转换: 官网抓取的数据若是 USD 则折成 CNY(内置表已是 CNY, 无需转换)
150
193
  if (fromFetch && row.currency === 'usd') {
151
194
  prices.input *= usdCnyRate
152
195
  prices.output *= usdCnyRate
153
196
  prices.cacheRead *= usdCnyRate
154
197
  prices.cacheWrite *= usdCnyRate
155
198
  }
156
- return prices
199
+ return { ...prices, source }
157
200
  }
201
+
158
202
  // 金额估算: 表为人民币价 → 先算 CNY, 再折成 USD(= CNY / usdCnyRate) 供客户端/CSV 使用。
159
- function estimateCost(usage, model, timeMs) {
203
+ // 返回 { cost(USD), source(计价来源) }。
204
+ function costOf(usage, model, timeMs) {
160
205
  const p = resolvePricing(model, timeMs)
161
206
  const cny = (
162
207
  num(usage.inputTokens) * p.input +
@@ -164,29 +209,156 @@ export const feature = {
164
209
  num(usage.cacheWriteTokens) * p.cacheWrite +
165
210
  num(usage.outputTokens) * p.output
166
211
  ) / 1e6
167
- return cny / usdCnyRate
212
+ return { cost: cny / usdCnyRate, source: p.source }
213
+ }
214
+
215
+ // 按当前单价重算一条记录的费用(浅拷贝):面板改价后无需重扫历史即可即时生效。
216
+ function withCost(r) {
217
+ const c = costOf(r, r.model, r.time)
218
+ return Object.assign({}, r, { cost: c.cost, pricingSource: c.source })
219
+ }
220
+
221
+ // 面板「单价设置」读取:返回当前生效的单价表 + 兜底/汇率/抓取开关 + 已出现过的模型
222
+ // (含已配置 Provider 的模型, 供用户按模型逐条配置单价; 未配置的模型可手输匹配串)。
223
+ function readPricingConfig() {
224
+ const cfg = readConfiguredProviders()
225
+ const seen = new Set()
226
+ for (const r of records.values()) if (r.model) seen.add(r.model)
227
+ for (const ids of Object.values(cfg)) for (const id of ids) if (id) seen.add(id)
228
+ // 每个模型的当前计价来源(供面板提示哪些模型走了兜底价, 提示用户去配单价)
229
+ const sourcesByModel = {}
230
+ for (const m of seen) sourcesByModel[m] = PRICING_SOURCE_LABELS[resolvePricing(m, Date.now()).source] || ''
231
+ return {
232
+ usdCnyRate,
233
+ fetchOfficial: fetchOfficialEnabled,
234
+ pricingUrl,
235
+ pricingFetchIntervalHours: Math.round(fetchIntervalMs / 3600000),
236
+ pricing: userPricing.map((r) => Object.assign({}, r, { peaks: clonePeaks(r.peaks) })),
237
+ fallback: Object.assign({}, fallbackPricing),
238
+ builtin: pricingTable.map((r) => ({ match: r.match, input: r.input, output: r.output, cacheRead: r.cacheRead, cacheWrite: r.cacheWrite, peaks: clonePeaks(r.peaks) })),
239
+ models: [...seen].sort(),
240
+ sourcesByModel,
241
+ }
242
+ }
243
+
244
+ // 保存面板提交的单价配置: 校验 → 合并进 dsh-dock.tokenlog(保留 pricingUrl 等未在面板暴露
245
+ // 的字段) → 落盘。保存后由调用方 loadPricingConfig() 刷新内存, 使改价立即影响费用估算。
246
+ async function savePricingConfig(input) {
247
+ const settings = ctx.get('settings')
248
+ if (!settings || typeof settings.get !== 'function' || typeof settings.mutate !== 'function') {
249
+ throw new Error('settings 服务不可用,单价无法持久化')
250
+ }
251
+ const bad = (msg) => { const err = new Error(msg); err.statusCode = 400; return err }
252
+ const nonNeg = (v, label) => {
253
+ const n = Number(v)
254
+ if (!Number.isFinite(n) || n < 0) throw bad(`「${label}」需为不小于 0 的数字`)
255
+ return n
256
+ }
257
+ const root = settings.get(DOCK_NS)
258
+ const current = root && typeof root === 'object' && root.tokenlog && typeof root.tokenlog === 'object' ? root.tokenlog : {}
259
+ const next = Object.assign({}, current)
260
+ if (input.usdCnyRate !== undefined) {
261
+ const r = Number(input.usdCnyRate)
262
+ if (!Number.isFinite(r) || r <= 0 || r > 1000) throw bad('「USD→CNY 汇率」需为 0~1000 的正数')
263
+ next.usdCnyRate = r
264
+ }
265
+ if (input.fetchOfficial !== undefined) next.fetchOfficial = !!input.fetchOfficial
266
+ if (input.pricing !== undefined) {
267
+ if (!Array.isArray(input.pricing)) throw bad('「模型单价」需为数组')
268
+ const rows = []
269
+ for (const raw of input.pricing) {
270
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw bad('模型单价条目格式不符')
271
+ const match = String(raw.match || '').trim().toLowerCase()
272
+ if (!match) throw bad('模型单价条目的「模型匹配」不能为空')
273
+ if (match === '*') throw bad('模型匹配不能是 *;未匹配模型请填「兜底单价」')
274
+ const row = {
275
+ match,
276
+ input: nonNeg(raw.input, `${match} 输入单价`),
277
+ output: nonNeg(raw.output, `${match} 输出单价`),
278
+ cacheRead: nonNeg(raw.cacheRead, `${match} 缓存命中单价`),
279
+ cacheWrite: nonNeg(raw.cacheWrite, `${match} 缓存写入单价`),
280
+ }
281
+ // 分时段价(可多段): 每段 start/end 为 0~24 的本地小时(支持 8.5 表示 08:30);
282
+ // start>end 表示跨零点, start===end 表示全天。
283
+ const rawPeaks = Array.isArray(raw.peaks) ? raw.peaks : []
284
+ const peaks = []
285
+ for (const seg of rawPeaks) {
286
+ if (!seg || typeof seg !== 'object' || Array.isArray(seg)) throw bad(`「${match}」分时段条目格式不符`)
287
+ const start = Number(seg.start), end = Number(seg.end)
288
+ if (!Number.isFinite(start) || !Number.isFinite(end) || start < 0 || start > 24 || end < 0 || end > 24) {
289
+ throw bad(`「${match}」分时段时刻需为 0~24 的小时数`)
290
+ }
291
+ peaks.push({
292
+ start, end,
293
+ input: nonNeg(seg.input, `${match} 分时段输入单价`),
294
+ output: nonNeg(seg.output, `${match} 分时段输出单价`),
295
+ cacheRead: nonNeg(seg.cacheRead, `${match} 分时段缓存命中单价`),
296
+ cacheWrite: nonNeg(seg.cacheWrite, `${match} 分时段缓存写入单价`),
297
+ })
298
+ }
299
+ if (peaks.length) row.peaks = peaks
300
+ rows.push(row)
301
+ }
302
+ next.pricing = rows
303
+ }
304
+ if (input.fallback !== undefined) {
305
+ const f = input.fallback
306
+ if (!f || typeof f !== 'object' || Array.isArray(f)) throw bad('「兜底单价」格式不符')
307
+ next.fallback = {
308
+ input: nonNeg(f.input, '兜底输入单价'),
309
+ output: nonNeg(f.output, '兜底输出单价'),
310
+ cacheRead: nonNeg(f.cacheRead, '兜底缓存命中单价'),
311
+ cacheWrite: nonNeg(f.cacheWrite, '兜底缓存写入单价'),
312
+ }
313
+ }
314
+ try {
315
+ await settings.mutate(DOCK_NS, [{ op: 'set', path: ['tokenlog'], value: next }])
316
+ } catch (e) {
317
+ const err = new Error('保存单价被拒绝:' + ((e && e.message) || String(e)))
318
+ err.statusCode = 400
319
+ throw err
320
+ }
321
+ console.log('[dsh-dock] tokenlog pricing saved: rows=' + (Array.isArray(next.pricing) ? next.pricing.length : 0) +
322
+ ', fetchOfficial=' + next.fetchOfficial)
323
+ return next
168
324
  }
169
325
 
170
326
  // ===== 官网定价自动获取 =====
171
- // 每 24 小时从 DeepSeek 官网抓取最新定价并解析 HTML 表格; 抓取/解析失败时使用内置默认值。
172
- const FETCH_INTERVAL_MS = 24 * 60 * 60 * 1000
327
+ // 每 N 小时(默认 24)从官网抓取最新定价并解析 HTML 表格; 抓取/解析失败时使用内置默认值。
328
+ // 用户在面板配置的单价优先于抓取价, 因此抓取仅作兜底; 也可在面板关闭抓取。
173
329
  let fetchedPricing = null // Array<{ match, input, output, cacheRead, cacheWrite, currency }>
174
330
  let lastFetchTime = 0
175
331
 
176
332
  function getPricingFetchConfig() {
177
- let url = 'https://api-docs.deepseek.com/zh-cn/quick_start/pricing'
178
- let intervalMs = FETCH_INTERVAL_MS
179
- try {
180
- const settings = ctx.get('settings')
181
- if (settings && typeof settings.get === 'function') {
182
- const v = settings.get(NS)
183
- if (v && typeof v === 'object') {
184
- if (typeof v.pricingUrl === 'string' && v.pricingUrl) url = v.pricingUrl
185
- if (typeof v.pricingFetchIntervalHours === 'number' && v.pricingFetchIntervalHours > 0) intervalMs = v.pricingFetchIntervalHours * 3600000
186
- }
187
- }
188
- } catch (e) { /* 用默认 */ }
189
- return { url, intervalMs }
333
+ return { url: pricingUrl, intervalMs: fetchIntervalMs }
334
+ }
335
+
336
+ // 服务端出站请求前的 URL 校验(SSRF 防护): 仅 http/https, 且拒绝本机/环回/私有/保留地址。
337
+ function isPrivateOrReservedHost(host) {
338
+ const h = String(host || '').toLowerCase().replace(/^\[|\]$/g, '')
339
+ if (!h) return true
340
+ if (h === 'localhost' || h.endsWith('.localhost') || h.endsWith('.local') || h.endsWith('.internal')) return true
341
+ if (h.includes(':')) { // IPv6 字面量
342
+ return h === '::' || h === '::1' || h.startsWith('fe80') || h.startsWith('fc') || h.startsWith('fd')
343
+ }
344
+ const m = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/)
345
+ if (m) {
346
+ const a = Number(m[1]), b = Number(m[2])
347
+ return a === 0 || a === 10 || a === 127
348
+ || (a === 169 && b === 254) // link-local
349
+ || (a === 172 && b >= 16 && b <= 31) // 私网
350
+ || (a === 192 && b === 168) // 私网
351
+ || a >= 224 // 组播/保留
352
+ }
353
+ return false
354
+ }
355
+
356
+ function assertAllowedFetchUrl(raw) {
357
+ let u
358
+ try { u = new URL(String(raw)) } catch { throw new Error('价格页地址不是合法 URL:' + raw) }
359
+ if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('价格页地址仅支持 http/https')
360
+ if (isPrivateOrReservedHost(u.hostname)) throw new Error('价格页地址不允许指向本机/内网/保留地址')
361
+ return u.toString()
190
362
  }
191
363
 
192
364
  // 从 HTML 中提取所有 <table> 的行列数据
@@ -282,8 +454,9 @@ export const feature = {
282
454
  async function fetchOfficialPricing() {
283
455
  try {
284
456
  const { url } = getPricingFetchConfig()
285
- console.log('[dsh-dock] tokenlog fetching official pricing from:', url)
286
- const res = await fetch(url, {
457
+ const safeUrl = assertAllowedFetchUrl(url)
458
+ console.log('[dsh-dock] tokenlog fetching official pricing from:', safeUrl)
459
+ const res = await fetch(safeUrl, {
287
460
  headers: { 'User-Agent': 'dsh-dock-tokenlog/0.4', 'Accept': 'text/html,*/*' }
288
461
  })
289
462
  if (!res.ok) throw new Error('HTTP ' + res.status)
@@ -409,7 +582,7 @@ export const feature = {
409
582
  const cacheRead = num(usage.cacheReadTokens)
410
583
  const cacheWrite = num(usage.cacheWriteTokens)
411
584
  const reasoning = num(usage.reasoningTokens)
412
- const cost = estimateCost(usage, head.model, event.time)
585
+ const priced = costOf(usage, head.model, event.time)
413
586
  stepStart.delete(startK)
414
587
  return {
415
588
  id: sessionId + ':' + event.seq,
@@ -426,7 +599,8 @@ export const feature = {
426
599
  billedInput: input + cacheRead + cacheWrite,
427
600
  totalTokens: input + output + cacheRead + cacheWrite,
428
601
  cacheHitPercent: (input + cacheRead) > 0 ? Math.round((cacheRead / (input + cacheRead)) * 100) : 0,
429
- cost,
602
+ cost: priced.cost,
603
+ pricingSource: priced.source,
430
604
  effort: head.effort || '',
431
605
  status,
432
606
  statusCode: ei ? ei.statusCode : 0,
@@ -567,11 +741,11 @@ export const feature = {
567
741
  let sessionQuery = null
568
742
  disposers.push(ctx.inject(['sessionQuery'], (sqCtx) => {
569
743
  sessionQuery = sqCtx.sessionQuery
570
- // 服务就绪后加载 settings 定价配置(汇率 + pricing 覆盖), 供采集/重扫/导出使用
744
+ // 服务就绪后加载 settings 单价配置(汇率 + 自定义单价 + 抓取开关), 供采集/重扫/导出使用
571
745
  loadPricingConfig()
572
- // 从官网获取最新定价(启动时 + 每 24h 自动刷新)
573
- fetchOfficialPricing()
574
- const fetchTimer = setInterval(fetchOfficialPricing, getPricingFetchConfig().intervalMs)
746
+ // 从官网获取最新定价(启动时 + 每 N 小时自动刷新; 面板关闭抓取时不发起请求)
747
+ if (fetchOfficialEnabled) fetchOfficialPricing()
748
+ const fetchTimer = setInterval(() => { if (fetchOfficialEnabled) fetchOfficialPricing() }, getPricingFetchConfig().intervalMs)
575
749
  sqCtx.effect(() => () => clearInterval(fetchTimer))
576
750
  // 实时监听: session/event (root scope 可收到所有 session 事件, 与官方 persistence 同款订阅)
577
751
  disposers.push(sqCtx.on('session/event', (session, event) => {
@@ -616,7 +790,8 @@ export const feature = {
616
790
  const payload = await readBody(req)
617
791
  if (method === 'query') {
618
792
  loadPricingConfig()
619
- const list = buildQuery(payload || {})
793
+ // 费用按当前单价即时重算(withCost): 面板改价后立即反映到统计与明细, 无需重扫历史。
794
+ const list = buildQuery(payload || {}).map(withCost)
620
795
  const rows = [...list].sort((a, b) => b.time - a.time)
621
796
  const all = [...records.values()]
622
797
  const cfg = readConfiguredProviders()
@@ -653,14 +828,29 @@ export const feature = {
653
828
  efforts: [...new Set(all.map((r) => r.effort).filter(Boolean))],
654
829
  sessionIds: [...new Set(all.map((r) => r.sessionId).filter(Boolean))].sort(),
655
830
  rateUsdCny: usdCnyRate,
831
+ pricingInfo: {
832
+ hasCustom: userPricing.length > 0,
833
+ customCount: userPricing.length,
834
+ fetchOfficial: fetchOfficialEnabled,
835
+ sources: [...new Set(list.map((r) => r.pricingSource).filter(Boolean))].map((s) => PRICING_SOURCE_LABELS[s] || s),
836
+ },
656
837
  totals: totals(list),
657
838
  summary: payload && payload.dim ? summarize(list, payload.dim) : [],
658
839
  }
659
840
  })
660
841
  }
842
+ if (method === 'pricing') {
843
+ loadPricingConfig()
844
+ return sendJson(res, 200, { ok: true, data: readPricingConfig() })
845
+ }
846
+ if (method === 'setpricing') {
847
+ await savePricingConfig(payload || {})
848
+ loadPricingConfig()
849
+ return sendJson(res, 200, { ok: true, data: readPricingConfig() })
850
+ }
661
851
  if (method === 'export') {
662
852
  loadPricingConfig()
663
- const list = buildQuery(payload || {})
853
+ const list = buildQuery(payload || {}).map(withCost)
664
854
  const header = ['time', 'provider', 'model', 'apiKey', 'inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens', 'billedInput', 'cacheHitPercent', 'totalTokens', 'costCny', 'effort', 'status', 'statusCode', 'errorCode', 'errorMsg', 'llmMs', 'sessionId', 'turn', 'step']
665
855
  const esc = (v) => {
666
856
  const s = String(v === undefined || v === null ? '' : v)
@@ -679,7 +869,9 @@ export const feature = {
679
869
  }
680
870
  return sendJson(res, 404, { ok: false, error: { code: 'method-not-found', message: 'unknown method: ' + method, details: {} } })
681
871
  } catch (e) {
682
- return sendJson(res, 500, { ok: false, error: { code: 'internal', message: (e && e.message) || String(e), details: {} } })
872
+ // 参数校验类错误按 4xx 返回(如单价非法), 其余为 500
873
+ const status = e && e.statusCode ? e.statusCode : 500
874
+ return sendJson(res, status, { ok: false, error: { code: status === 500 ? 'internal' : 'bad-request', message: (e && e.message) || String(e), details: {} } })
683
875
  }
684
876
  }
685
877
  }), 'dsh-dock tokenlog: /dsh-dock/tokenlog HTTP route')