dsh-dock 0.10.3 → 0.11.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/README.md +4 -1
- package/client.js +394 -18
- package/features/tokenlog/host.js +292 -99
- package/features/tokenlog/view.jsx +374 -13
- package/package.json +3 -3
- package/src/client.jsx +5 -4
- package/src/host-core.js +38 -0
|
@@ -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
|
-
//
|
|
16
|
-
// dsh-dock
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
//
|
|
57
|
-
// dsh-dock
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
|
|
63
|
-
|
|
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: '*',
|
|
69
|
+
let fallbackPricing = { match: '*', ...DEFAULT_FALLBACK }
|
|
66
70
|
// 内置定价表(人民币元 / 百万 tokens), 按 model 子串匹配; 未匹配走 fallback。
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
// 官方来源: DeepSeek-V4 系列 2026-08-17
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
103
|
-
const
|
|
104
|
-
if (!
|
|
105
|
-
if (typeof
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
//
|
|
122
|
-
//
|
|
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
|
|
127
|
-
// 1
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
let
|
|
144
|
-
if (
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
// 每
|
|
172
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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
|
|
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
|
|
744
|
+
// 服务就绪后加载 settings 单价配置(汇率 + 自定义单价 + 抓取开关), 供采集/重扫/导出使用
|
|
571
745
|
loadPricingConfig()
|
|
572
|
-
// 从官网获取最新定价(启动时 + 每
|
|
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
|
-
|
|
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,15 +828,31 @@ 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 || {})
|
|
664
|
-
|
|
853
|
+
const list = buildQuery(payload || {}).map(withCost)
|
|
854
|
+
// 列序对齐界面习惯(缓存命中在前):… 命中 → 未命中 → 输出 → 写入
|
|
855
|
+
const header = ['time', 'provider', 'model', 'apiKey', 'cacheReadTokens', 'inputTokens', 'outputTokens', 'cacheWriteTokens', 'reasoningTokens', 'billedInput', 'cacheHitPercent', 'totalTokens', 'costCny', 'effort', 'status', 'statusCode', 'errorCode', 'errorMsg', 'llmMs', 'sessionId', 'turn', 'step']
|
|
665
856
|
const esc = (v) => {
|
|
666
857
|
const s = String(v === undefined || v === null ? '' : v)
|
|
667
858
|
return /[,"\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s
|
|
@@ -679,7 +870,9 @@ export const feature = {
|
|
|
679
870
|
}
|
|
680
871
|
return sendJson(res, 404, { ok: false, error: { code: 'method-not-found', message: 'unknown method: ' + method, details: {} } })
|
|
681
872
|
} catch (e) {
|
|
682
|
-
|
|
873
|
+
// 参数校验类错误按 4xx 返回(如单价非法), 其余为 500
|
|
874
|
+
const status = e && e.statusCode ? e.statusCode : 500
|
|
875
|
+
return sendJson(res, status, { ok: false, error: { code: status === 500 ? 'internal' : 'bad-request', message: (e && e.message) || String(e), details: {} } })
|
|
683
876
|
}
|
|
684
877
|
}
|
|
685
878
|
}), 'dsh-dock tokenlog: /dsh-dock/tokenlog HTTP route')
|