@angelyeye/dsh-cost-tracker 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +142 -0
- package/LICENSE +21 -0
- package/README.en.md +319 -0
- package/README.md +335 -0
- package/client.js +1255 -0
- package/config.js +81 -0
- package/cordis.patch.yml +5 -0
- package/index.js +962 -0
- package/package.json +52 -0
- package/pricing.js +268 -0
- package/store.js +248 -0
package/index.js
ADDED
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// DSH 花费统计插件 —— Host 半端(静态版)
|
|
3
|
+
// 由动态版 cost-tracker.host.js 改造而来:
|
|
4
|
+
// harness.handle → webServer HTTP 路由(/api/cost-tracker/*)
|
|
5
|
+
// harness.*Tool → ctx.tools.register
|
|
6
|
+
// subprocess node → 原生 fetch + node:fs
|
|
7
|
+
// 新增数据持久化 → ~/.dsh/storages/cost-tracker-records.json
|
|
8
|
+
// ============================================================
|
|
9
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync, existsSync, realpathSync } from 'node:fs'
|
|
10
|
+
import { join, dirname } from 'node:path'
|
|
11
|
+
import { homedir } from 'node:os'
|
|
12
|
+
import { createStore, collectTotals, DETAIL_DAYS, MAX_AXIS_DAYS } from './store.js'
|
|
13
|
+
import { PRICE_ERAS, V41_EFFECTIVE_AT, exactModelsAt, eraAt, SUBSCRIPTION_RATES, PROVIDER_RATES, GENERIC_RATES, PEAK_WINDOWS, PEAK_HOUR_WINDOWS, isPeak, peakPhaseAt, priceFor, computeCost, normalizeTokens } from './pricing.js'
|
|
14
|
+
import { normalizePeakConfig, defaultPeakConfig, peakEffective } from './config.js'
|
|
15
|
+
|
|
16
|
+
// ============================================================
|
|
17
|
+
// 启动信息日志开关(默认静默)
|
|
18
|
+
// 设置 DSH_COST_TRACKER_LOG=1(或 true/yes/on)后,dsh web 启动时会打印:
|
|
19
|
+
// nav-icon 自检结果、数据恢复报告、就绪标记。
|
|
20
|
+
// 错误日志(持久化失败、文件损坏等 console.error)始终打印,不受此开关影响。
|
|
21
|
+
// ============================================================
|
|
22
|
+
const STARTUP_LOG = /^(1|true|yes|on)$/i.test(String(process.env.DSH_COST_TRACKER_LOG || ''))
|
|
23
|
+
function startupLog(msg) {
|
|
24
|
+
if (STARTUP_LOG) console.log(msg)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ============================================================
|
|
28
|
+
// 设置侧边栏图标补丁 · 启动自愈
|
|
29
|
+
// DSH 设置外壳的 navIcon() 按 id 硬编码图标,未知 id 回退齿轮;
|
|
30
|
+
// slot 注册不支持自带图标,只能给外壳产物打补丁。
|
|
31
|
+
// DSH 升级/重装会覆盖外壳文件 —— 因此每次启动自检,缺失即重打。
|
|
32
|
+
// 任何一步失败都静默跳过(侧边栏回退齿轮,面板内图标不受影响)。
|
|
33
|
+
// ============================================================
|
|
34
|
+
const NAV_ICON_BRANCH = 'if (id === "cost-dashboard") return (0, react_jsx_runtime.jsxs)("svg", { className: SettingsRoot_module_css_default.navIcon, width: 16, height: 16, viewBox: "0 0 16 16", fill: "none", xmlns: "http://www.w3.org/2000/svg", children: [(0, react_jsx_runtime.jsx)("rect", { x: 1.5, y: 8.6, width: 3.1, height: 5.9, rx: 0.9, fill: "currentColor" }), (0, react_jsx_runtime.jsx)("rect", { x: 5.9, y: 5.2, width: 3.1, height: 9.3, rx: 0.9, fill: "currentColor" }), (0, react_jsx_runtime.jsxs)("g", { fill: "none", stroke: "currentColor", strokeWidth: 1.3, strokeLinecap: "round", strokeLinejoin: "round", children: [(0, react_jsx_runtime.jsx)("path", { d: "M10.7 4.9 L12.3 7.2 L13.9 4.9" }), (0, react_jsx_runtime.jsx)("path", { d: "M12.3 7.2 L12.3 10.9" }), (0, react_jsx_runtime.jsx)("path", { d: "M10.9 7.8 L13.7 7.8" }), (0, react_jsx_runtime.jsx)("path", { d: "M10.9 9.5 L13.7 9.5" })] })] }); // cost-tracker-icon-patch\n\t\t\t'
|
|
35
|
+
|
|
36
|
+
export function ensureNavIconPatch(opts) {
|
|
37
|
+
const log = (opts && opts.log) || (() => {})
|
|
38
|
+
try {
|
|
39
|
+
const entry = (opts && opts.entry) || (process.argv && process.argv[1]) || ''
|
|
40
|
+
// 全局安装通常通过符号链接启动(如 /opt/homebrew/bin/dsh),需同时尝试 realpath
|
|
41
|
+
const entries = [entry]
|
|
42
|
+
try { const real = realpathSync(entry); if (real && real !== entry) entries.push(real) } catch (e) {}
|
|
43
|
+
let shellFile = ''
|
|
44
|
+
for (const e0 of entries) {
|
|
45
|
+
let dir = dirname(e0)
|
|
46
|
+
for (let i = 0; i < 8 && dir && dir !== dirname(dir); i++) {
|
|
47
|
+
const candidate = join(dir, 'node_modules', '@deepseek-ai', 'dsh-client-ui-settings-general', 'lib', 'client.js')
|
|
48
|
+
if (existsSync(candidate)) { shellFile = candidate; break }
|
|
49
|
+
dir = dirname(dir)
|
|
50
|
+
}
|
|
51
|
+
if (shellFile) break
|
|
52
|
+
}
|
|
53
|
+
if (!shellFile) { log('skip: settings shell not found'); return false }
|
|
54
|
+
const src = readFileSync(shellFile, 'utf8')
|
|
55
|
+
if (src.includes('id === "cost-dashboard"')) { log('ok: already patched'); return true }
|
|
56
|
+
const anchor = /function navIcon\(id\)\s*\{\s*/.exec(src)
|
|
57
|
+
if (!anchor) { log('skip: navIcon() not found (shell layout changed?)'); return false }
|
|
58
|
+
if (!src.includes('react_jsx_runtime') || !src.includes('SettingsRoot_module_css_default')) {
|
|
59
|
+
log('skip: expected identifiers missing (shell layout changed?)'); return false
|
|
60
|
+
}
|
|
61
|
+
if (!existsSync(shellFile + '.cost-tracker-bak')) writeFileSync(shellFile + '.cost-tracker-bak', src)
|
|
62
|
+
const at = anchor.index + anchor[0].length
|
|
63
|
+
const tmp = shellFile + '.cost-tracker-tmp'
|
|
64
|
+
writeFileSync(tmp, src.slice(0, at) + NAV_ICON_BRANCH + src.slice(at))
|
|
65
|
+
renameSync(tmp, shellFile)
|
|
66
|
+
log('ok: patch applied -> ' + shellFile)
|
|
67
|
+
return true
|
|
68
|
+
} catch (e) {
|
|
69
|
+
log('skip: ' + String(e && e.message ? e.message : e))
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export default {
|
|
75
|
+
name: 'cost-tracker',
|
|
76
|
+
inject: ['tools', 'webServer'],
|
|
77
|
+
apply(ctx) {
|
|
78
|
+
// 侧边栏图标补丁自愈(DSH 升级覆盖外壳后自动重打;失败静默跳过)
|
|
79
|
+
ensureNavIconPatch({ log: (m) => startupLog('[cost-tracker] nav-icon ' + m) })
|
|
80
|
+
|
|
81
|
+
// ---------- price tables (CNY per 1M tokens) ----------
|
|
82
|
+
// 单价表 / 峰谷 / 费用计算集中在 pricing.js(纯模块,可独立测试);
|
|
83
|
+
// 单价按「计费时代」分版(PRICE_ERAS):按记录时间戳选版,故历史记录口径不变。
|
|
84
|
+
// V4.1 Flash 价(北京时间 2026-09-10 12:00 起)生效后,V4-Pro 与旧 V4-Flash 系
|
|
85
|
+
// 的请求按官方规则路由到 V4.1 Flash 计费,记录亦以 V4.1 Flash 模型名入账。
|
|
86
|
+
// 视觉模型 deepseek-v4-flash-vision-exp 的图片 token 由接口 usage 计入 inputTokens
|
|
87
|
+
|
|
88
|
+
// ---------- state ----------
|
|
89
|
+
// 注意:records/rollups 在 persistence 段由 store 初始化(details/rollups 引用)
|
|
90
|
+
let kimiCache = null
|
|
91
|
+
|
|
92
|
+
// ---------- small helpers ----------
|
|
93
|
+
function pad2(n) { return n < 10 ? '0' + n : '' + n }
|
|
94
|
+
function toInt(x) { const n = parseInt(x, 10); return isNaN(n) ? 0 : n }
|
|
95
|
+
function toStr(x) { return x === undefined || x === null ? '' : String(x) }
|
|
96
|
+
function r2(x) { return Math.round(x * 100) / 100 }
|
|
97
|
+
function r4(x) { return Math.round(x * 10000) / 10000 }
|
|
98
|
+
function normProvider(p) { return toStr(p).toLowerCase().replace(/-official$/, '') }
|
|
99
|
+
function dayKey(ts) {
|
|
100
|
+
const d = new Date(ts + 28800000)
|
|
101
|
+
return d.getUTCFullYear() + '-' + pad2(d.getUTCMonth() + 1) + '-' + pad2(d.getUTCDate())
|
|
102
|
+
}
|
|
103
|
+
function timeLabel(ts) {
|
|
104
|
+
const d = new Date(ts + 28800000)
|
|
105
|
+
return (d.getUTCMonth() + 1) + '/' + d.getUTCDate() + ' ' + pad2(d.getUTCHours()) + ':' + pad2(d.getUTCMinutes())
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------- persistence ----------
|
|
109
|
+
// 数据文件路径:可用环境变量 DSH_COST_TRACKER_STORE 覆盖(测试/自定义用);
|
|
110
|
+
// 默认 $DSH_HOME/storages/cost-tracker-records.json(未设 DSH_HOME 时为 ~/.dsh)。
|
|
111
|
+
// 明细保留最近 DETAIL_DAYS 天,更早自动压缩为永久日汇总(见 store.js)。
|
|
112
|
+
const STORE_FILE = process.env.DSH_COST_TRACKER_STORE || join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'storages', 'cost-tracker-records.json')
|
|
113
|
+
const store = createStore(STORE_FILE)
|
|
114
|
+
const records = store.details
|
|
115
|
+
const rollups = store.rollups
|
|
116
|
+
|
|
117
|
+
function writeRecords() { store.persist() }
|
|
118
|
+
|
|
119
|
+
// ---------- plugin config (peak pricing notice) ----------
|
|
120
|
+
// 与记录分开存储:$DSH_HOME/storages/cost-tracker-config.json。
|
|
121
|
+
// 提供读写与校验(默认值见 config.js),写失败不阻断(下次改设置重试)。
|
|
122
|
+
const CONFIG_FILE = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'storages', 'cost-tracker-config.json')
|
|
123
|
+
let peakConfig = defaultPeakConfig()
|
|
124
|
+
let configLoadWarned = false
|
|
125
|
+
|
|
126
|
+
function loadConfig() {
|
|
127
|
+
try {
|
|
128
|
+
if (!existsSync(CONFIG_FILE)) return
|
|
129
|
+
const parsed = JSON.parse(readFileSync(CONFIG_FILE, 'utf8'))
|
|
130
|
+
peakConfig = normalizePeakConfig(parsed)
|
|
131
|
+
} catch (e) {
|
|
132
|
+
if (!configLoadWarned) { console.error('cost tracker config load failed, using defaults', e); configLoadWarned = true }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function saveConfig() {
|
|
137
|
+
try {
|
|
138
|
+
mkdirSync(dirname(CONFIG_FILE), { recursive: true })
|
|
139
|
+
const tmp = CONFIG_FILE + '.tmp'
|
|
140
|
+
writeFileSync(tmp, JSON.stringify(peakConfig), 'utf8')
|
|
141
|
+
renameSync(tmp, CONFIG_FILE)
|
|
142
|
+
return true
|
|
143
|
+
} catch (e) {
|
|
144
|
+
console.error('cost tracker config persist failed', e)
|
|
145
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function setPeakConfig(raw) {
|
|
150
|
+
peakConfig = normalizePeakConfig(raw)
|
|
151
|
+
saveConfig()
|
|
152
|
+
return peakConfig
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// 峰谷相位快照(供客户端时段条 / 弹窗 / 倒计时)
|
|
156
|
+
function peakSnapshot() {
|
|
157
|
+
const now = Date.now()
|
|
158
|
+
const phase = peakPhaseAt(now)
|
|
159
|
+
return {
|
|
160
|
+
ok: true,
|
|
161
|
+
config: peakConfig,
|
|
162
|
+
enabled: peakConfig.peakEnabled,
|
|
163
|
+
effective: peakEffective(peakConfig, now),
|
|
164
|
+
notice: peakConfig.peakNotice,
|
|
165
|
+
style: peakConfig.peakStyle,
|
|
166
|
+
alert: {
|
|
167
|
+
enabled: peakConfig.peakAlertEnabled,
|
|
168
|
+
ahead: peakConfig.peakAlertAhead,
|
|
169
|
+
target: peakConfig.peakAlertTarget,
|
|
170
|
+
position: peakConfig.peakAlertPosition,
|
|
171
|
+
webNotify: peakConfig.peakAlertWebNotify,
|
|
172
|
+
},
|
|
173
|
+
phase,
|
|
174
|
+
peakWindows: PEAK_WINDOWS,
|
|
175
|
+
peakHours: PEAK_HOUR_WINDOWS,
|
|
176
|
+
effectiveAt: peakConfig.peakEffectiveAt,
|
|
177
|
+
now,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function loadRecords() {
|
|
182
|
+
const n = store.load()
|
|
183
|
+
const ru = Object.keys(rollups).length
|
|
184
|
+
if (n > 0 || ru > 0) startupLog('cost tracker restored ' + n + ' detail records, ' + ru + ' rollup days from ' + STORE_FILE)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let persistPending = false
|
|
188
|
+
let persistTimer = null
|
|
189
|
+
function schedulePersist() {
|
|
190
|
+
if (persistPending) return
|
|
191
|
+
persistPending = true
|
|
192
|
+
const run = () => {
|
|
193
|
+
persistPending = false
|
|
194
|
+
persistTimer = null
|
|
195
|
+
writeRecords()
|
|
196
|
+
}
|
|
197
|
+
const timer = ctx.get('timer')
|
|
198
|
+
persistTimer = timer ? timer.timeout(run, 1500) : setTimeout(run, 1500)
|
|
199
|
+
}
|
|
200
|
+
function persistNow() {
|
|
201
|
+
persistPending = false
|
|
202
|
+
if (persistTimer) { try { persistTimer() } catch (e) {} persistTimer = null }
|
|
203
|
+
writeRecords()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------- recording ----------
|
|
207
|
+
function recordUsage(options, usage, ts) {
|
|
208
|
+
const provider = toStr(options && options.provider)
|
|
209
|
+
const model = toStr(options && options.model)
|
|
210
|
+
if (!provider && !model) return
|
|
211
|
+
const np = normProvider(provider)
|
|
212
|
+
// 按「调用发生的时刻」选单价版本(跨 2026-09-10 12:00 自动切换,无需重启)。
|
|
213
|
+
const price = priceFor(np, model, ts)
|
|
214
|
+
// 峰谷计费开关:随配置峰谷启用 + 生效时间门控;未启用时按非峰谷档(平价)计费。
|
|
215
|
+
const peak = peakEffective(peakConfig, ts) ? isPeak(ts) : false
|
|
216
|
+
// 视觉模型(deepseek-v4-flash-vision-exp)的图片 token 已含在接口
|
|
217
|
+
// prompt_tokens 中(每张≤384 tokens),由 normalizeTokens 归入 input
|
|
218
|
+
const tokens = normalizeTokens(usage)
|
|
219
|
+
store.add({
|
|
220
|
+
// 被路由的请求以「实际计费模型名」入账(如 V4-Pro → deepseek-v4.1-flash),
|
|
221
|
+
// 使按模型聚合看到的就是真实计费口径。
|
|
222
|
+
ts, provider, model: price.model || model,
|
|
223
|
+
sessionId: toStr(options && options.sessionId),
|
|
224
|
+
purpose: toStr(options && options.purpose),
|
|
225
|
+
cost: computeCost(price.rates, price.tiered, peak, tokens),
|
|
226
|
+
estimated: price.estimated,
|
|
227
|
+
period: price.tiered ? (peak ? 'peak' : 'off-peak') : 'flat',
|
|
228
|
+
tokens,
|
|
229
|
+
subscription: price.subscription,
|
|
230
|
+
})
|
|
231
|
+
schedulePersist()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function* wrapStream(source, options) {
|
|
235
|
+
let usage = null
|
|
236
|
+
try {
|
|
237
|
+
for await (const chunk of source) {
|
|
238
|
+
if (chunk && chunk.type === 'usage' && chunk.usage) usage = chunk.usage
|
|
239
|
+
yield chunk
|
|
240
|
+
}
|
|
241
|
+
} finally {
|
|
242
|
+
if (usage) {
|
|
243
|
+
try { recordUsage(options, usage, Date.now()) } catch (e) { console.error('cost record failed', e) }
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
ctx.on('llm/stream', (options, next) => wrapStream(next(), options))
|
|
249
|
+
|
|
250
|
+
// ---------- network (native fetch) ----------
|
|
251
|
+
async function httpJson(url, headers, timeoutMs) {
|
|
252
|
+
const ac = new AbortController()
|
|
253
|
+
const t = setTimeout(() => ac.abort(), timeoutMs || 15000)
|
|
254
|
+
try {
|
|
255
|
+
const r = await fetch(url, { headers: headers || {}, signal: ac.signal })
|
|
256
|
+
const body = await r.text()
|
|
257
|
+
return { status: r.status, body: body.slice(0, 12000) }
|
|
258
|
+
} catch (e) {
|
|
259
|
+
return { status: 0, error: String(e && e.message ? e.message : e) }
|
|
260
|
+
} finally {
|
|
261
|
+
clearTimeout(t)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ---------- key resolution ----------
|
|
266
|
+
function readCredFile(envName) {
|
|
267
|
+
try {
|
|
268
|
+
const p = join(homedir(), '.dsh', '.credentials.yaml')
|
|
269
|
+
const t = readFileSync(p, 'utf8')
|
|
270
|
+
const lines = t.split(/\r?\n/)
|
|
271
|
+
for (const line of lines) {
|
|
272
|
+
const m = line.match(/^([A-Za-z0-9_]+):\s*(.+)\s*$/)
|
|
273
|
+
if (m && m[1] === envName) return m[2]
|
|
274
|
+
}
|
|
275
|
+
} catch (e) {}
|
|
276
|
+
return ''
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function kimiKeyEnv() {
|
|
280
|
+
const settings = ctx.get('settings')
|
|
281
|
+
if (settings) {
|
|
282
|
+
try {
|
|
283
|
+
const v = settings.get('llm-pi-ai')
|
|
284
|
+
const providers = v && v.providers
|
|
285
|
+
if (providers) {
|
|
286
|
+
const kc = providers['kimi-coding']
|
|
287
|
+
if (kc && kc.apiKeyEnv) return String(kc.apiKeyEnv)
|
|
288
|
+
const k = providers['kimi']
|
|
289
|
+
if (k && k.apiKeyEnv) return String(k.apiKeyEnv)
|
|
290
|
+
}
|
|
291
|
+
} catch (e) {}
|
|
292
|
+
}
|
|
293
|
+
return 'KIMI_CODING_API_KEY'
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function deepseekKeyEnv() {
|
|
297
|
+
const settings = ctx.get('settings')
|
|
298
|
+
if (settings) {
|
|
299
|
+
try {
|
|
300
|
+
const v = settings.get('llm-deepseek')
|
|
301
|
+
if (v && v.apiKeyEnv) return String(v.apiKeyEnv)
|
|
302
|
+
} catch (e) {}
|
|
303
|
+
try {
|
|
304
|
+
const v = settings.get('llm-pi-ai')
|
|
305
|
+
const p = v && v.providers && v.providers['deepseek']
|
|
306
|
+
if (p && p.apiKeyEnv) return String(p.apiKeyEnv)
|
|
307
|
+
} catch (e) {}
|
|
308
|
+
}
|
|
309
|
+
return 'DEEPSEEK_API_KEY'
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function resolveApiKey(envName) {
|
|
313
|
+
const cred = ctx.get('credentials')
|
|
314
|
+
if (cred) {
|
|
315
|
+
try {
|
|
316
|
+
const r = await cred.resolve(envName)
|
|
317
|
+
if (r && r.value) return { value: String(r.value), source: 'credentials:' + toStr(r.source) }
|
|
318
|
+
} catch (e) {}
|
|
319
|
+
}
|
|
320
|
+
const v = readCredFile(envName)
|
|
321
|
+
if (v) return { value: v, source: 'file' }
|
|
322
|
+
return { value: '', source: 'none' }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// ---------- kimi subscription quota ----------
|
|
326
|
+
function emptyKimi(error, keySource, keyEnv) {
|
|
327
|
+
return { ok: false, error, weekly: { used: 0, limit: 0, remaining: 0, resetTime: '' }, windows: [], parallel: 0, membership: '', region: '', fetchedAt: Date.now(), keySource, keyEnv }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function parseKimi(j, key, keyEnv) {
|
|
331
|
+
const u = j && j.usage ? j.usage : {}
|
|
332
|
+
const windows = []
|
|
333
|
+
const limits = j && j.limits
|
|
334
|
+
if (Array.isArray(limits)) {
|
|
335
|
+
for (const l of limits) {
|
|
336
|
+
const w = l && l.window ? l.window : {}
|
|
337
|
+
const d = l && l.detail ? l.detail : {}
|
|
338
|
+
windows.push({ duration: toInt(w.duration), timeUnit: toStr(w.timeUnit), used: toInt(d.used), limit: toInt(d.limit), remaining: toInt(d.remaining), resetTime: toStr(d.resetTime) })
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const user = j && j.user ? j.user : {}
|
|
342
|
+
const membership = user.membership ? user.membership.level : ''
|
|
343
|
+
return {
|
|
344
|
+
ok: true, error: '',
|
|
345
|
+
weekly: { used: toInt(u.used), limit: toInt(u.limit), remaining: toInt(u.remaining), resetTime: toStr(u.resetTime) },
|
|
346
|
+
windows,
|
|
347
|
+
parallel: toInt(j && j.parallel ? j.parallel.limit : 0),
|
|
348
|
+
membership: toStr(membership),
|
|
349
|
+
region: toStr(user.region),
|
|
350
|
+
fetchedAt: Date.now(),
|
|
351
|
+
keySource: key.source, keyEnv,
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function kimiUsage(force) {
|
|
356
|
+
const now = Date.now()
|
|
357
|
+
if (!force && kimiCache && now - kimiCache.fetchedAt < 120000) return kimiCache.data
|
|
358
|
+
const envName = kimiKeyEnv()
|
|
359
|
+
let data
|
|
360
|
+
try {
|
|
361
|
+
const key = await resolveApiKey(envName)
|
|
362
|
+
if (!key.value) {
|
|
363
|
+
data = emptyKimi('未找到 API Key(' + envName + ')', key.source, envName)
|
|
364
|
+
} else {
|
|
365
|
+
const headers = { Authorization: 'Bearer ' + key.value, 'User-Agent': 'KimiCLI/1.6' }
|
|
366
|
+
let r = await httpJson('https://api.kimi.com/coding/v1/usages', headers)
|
|
367
|
+
if (r.status === 404) r = await httpJson('https://api.kimi.com/coding/v1/usage', headers)
|
|
368
|
+
if (r.status === 200 && r.body) {
|
|
369
|
+
try {
|
|
370
|
+
data = parseKimi(JSON.parse(r.body), key, envName)
|
|
371
|
+
} catch (e) {
|
|
372
|
+
data = emptyKimi('响应解析失败', key.source, envName)
|
|
373
|
+
}
|
|
374
|
+
} else {
|
|
375
|
+
data = emptyKimi('HTTP ' + (r.status || 0) + (r.error ? ' · ' + r.error : ''), key.source, envName)
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
} catch (e) {
|
|
379
|
+
data = emptyKimi(toStr(e && e.message ? e.message : e), 'none', envName)
|
|
380
|
+
}
|
|
381
|
+
kimiCache = { fetchedAt: now, data }
|
|
382
|
+
return data
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ---------- deepseek balance ----------
|
|
386
|
+
async function balance(args) {
|
|
387
|
+
const manual = args && typeof args.apiKey === 'string' ? args.apiKey.trim() : ''
|
|
388
|
+
const key = manual ? { value: manual, source: 'manual' } : await resolveApiKey(deepseekKeyEnv())
|
|
389
|
+
if (!key.value) return { ok: false, error: '未找到 DeepSeek API Key', available: false, total: '', granted: '', toppedUp: '', currency: 'CNY', keySource: 'none' }
|
|
390
|
+
try {
|
|
391
|
+
const r = await httpJson('https://api.deepseek.com/user/balance', { Authorization: 'Bearer ' + key.value })
|
|
392
|
+
if (r.status !== 200 || !r.body) return { ok: false, error: 'HTTP ' + (r.status || 0) + (r.error ? ' · ' + r.error : ''), available: false, total: '', granted: '', toppedUp: '', currency: 'CNY', keySource: key.source }
|
|
393
|
+
const j = JSON.parse(r.body)
|
|
394
|
+
const infos = Array.isArray(j.balance_infos) ? j.balance_infos : []
|
|
395
|
+
let info = null
|
|
396
|
+
for (const b of infos) { if (b && b.currency === 'CNY') { info = b; break } }
|
|
397
|
+
if (!info) info = infos[0] || {}
|
|
398
|
+
return { ok: true, error: '', available: !!j.is_available, total: toStr(info.total_balance), granted: toStr(info.granted_balance), toppedUp: toStr(info.topped_up_balance), currency: toStr(info.currency) || 'CNY', keySource: key.source }
|
|
399
|
+
} catch (e) {
|
|
400
|
+
return { ok: false, error: toStr(e && e.message ? e.message : e), available: false, total: '', granted: '', toppedUp: '', currency: 'CNY', keySource: key.source }
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// ---------- csv export ----------
|
|
405
|
+
function csvCell(s) {
|
|
406
|
+
s = String(s)
|
|
407
|
+
return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function workspaceRoot() {
|
|
411
|
+
const policy = ctx.get('sandboxPolicy')
|
|
412
|
+
if (policy && policy.workspaceRoot) return policy.workspaceRoot
|
|
413
|
+
return process.cwd()
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function exportCsv() {
|
|
417
|
+
const rows = records.slice(-50000)
|
|
418
|
+
const lines = ['time,provider,model,sessionId,purpose,period,subscription,estimated,inputTokens,outputTokens,cacheReadTokens,cacheWriteTokens,totalTokens,costCNY']
|
|
419
|
+
for (const r of rows) {
|
|
420
|
+
const t = r.tokens
|
|
421
|
+
const d = new Date(r.ts + 28800000)
|
|
422
|
+
const ts = d.getUTCFullYear() + '-' + pad2(d.getUTCMonth() + 1) + '-' + pad2(d.getUTCDate()) + ' ' + pad2(d.getUTCHours()) + ':' + pad2(d.getUTCMinutes()) + ':' + pad2(d.getUTCSeconds())
|
|
423
|
+
lines.push([csvCell(ts), csvCell(r.provider), csvCell(r.model), csvCell(r.sessionId), csvCell(r.purpose), r.period, r.subscription ? '1' : '0', r.estimated ? '1' : '0', t.input, t.output, t.cacheRead, t.cacheWrite, t.input + t.output + t.cacheRead + t.cacheWrite, r4(r.cost)].join(','))
|
|
424
|
+
}
|
|
425
|
+
// 日汇总行(purpose=rollup):早于保留窗口的记录按天+模型聚合后永久保留
|
|
426
|
+
let rollupRows = 0
|
|
427
|
+
for (const dk of Object.keys(rollups).sort()) {
|
|
428
|
+
for (const mk of Object.keys(rollups[dk]).sort()) {
|
|
429
|
+
const e = rollups[dk][mk]
|
|
430
|
+
const total = e.input + e.output + e.cacheRead + e.cacheWrite
|
|
431
|
+
lines.push([csvCell(dk + ' 12:00:00'), csvCell(e.provider), csvCell(e.model), '', 'rollup', '', e.subscription ? '1' : '0', e.estimated ? '1' : '0', e.input, e.output, e.cacheRead, e.cacheWrite, total, r4(e.cost)].join(','))
|
|
432
|
+
rollupRows += 1
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const csv = '\uFEFF' + lines.join('\n') + '\n'
|
|
436
|
+
const name = 'cost-export-' + dayKey(Date.now()) + '.csv'
|
|
437
|
+
try {
|
|
438
|
+
const path = join(workspaceRoot(), name)
|
|
439
|
+
writeFileSync(path, csv, 'utf8')
|
|
440
|
+
return { ok: true, path, count: rows.length + rollupRows, error: '' }
|
|
441
|
+
} catch (e) {
|
|
442
|
+
return { ok: false, path: '', count: 0, error: toStr(e && e.message ? e.message : e).slice(0, 300) }
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
// ---------- prices ----------
|
|
447
|
+
function prices() {
|
|
448
|
+
const now = Date.now()
|
|
449
|
+
const era = eraAt(now)
|
|
450
|
+
return {
|
|
451
|
+
peakWindows: PEAK_WINDOWS,
|
|
452
|
+
offPeakFactor: 0.5,
|
|
453
|
+
unit: 'CNY / 1M tokens',
|
|
454
|
+
// exact = 当前生效时代的精确单价表(随价格时代自动切换)
|
|
455
|
+
exact: exactModelsAt(now),
|
|
456
|
+
era: era.id,
|
|
457
|
+
eraLabel: era.label,
|
|
458
|
+
// 全部价格时代(含生效时刻与路由规则),供工具/接口展示
|
|
459
|
+
eras: PRICE_ERAS.map((e) => ({ id: e.id, label: e.label, since: e.since, models: e.models, routes: e.routes || {} })),
|
|
460
|
+
v41EffectiveAt: V41_EFFECTIVE_AT,
|
|
461
|
+
subscription: SUBSCRIPTION_RATES,
|
|
462
|
+
providers: PROVIDER_RATES,
|
|
463
|
+
generic: GENERIC_RATES,
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ---------- aggregation ----------
|
|
468
|
+
function enumerateDays(startKey, endKey) {
|
|
469
|
+
const out = []
|
|
470
|
+
let t = Date.UTC(toInt(startKey.slice(0, 4)), toInt(startKey.slice(5, 7)) - 1, toInt(startKey.slice(8, 10)))
|
|
471
|
+
const end = Date.UTC(toInt(endKey.slice(0, 4)), toInt(endKey.slice(5, 7)) - 1, toInt(endKey.slice(8, 10)))
|
|
472
|
+
if (end - t > (MAX_AXIS_DAYS - 1) * 86400000) t = end - (MAX_AXIS_DAYS - 1) * 86400000
|
|
473
|
+
while (t <= end) {
|
|
474
|
+
const d = new Date(t)
|
|
475
|
+
out.push({ key: d.getUTCFullYear() + '-' + pad2(d.getUTCMonth() + 1) + '-' + pad2(d.getUTCDate()), label: (d.getUTCMonth() + 1) + '/' + d.getUTCDate() })
|
|
476
|
+
t += 86400000
|
|
477
|
+
}
|
|
478
|
+
return out
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function buildDashboard(args) {
|
|
482
|
+
const days = args && typeof args.days === 'number' && isFinite(args.days) ? Math.max(0, Math.floor(args.days)) : 7
|
|
483
|
+
const now = Date.now()
|
|
484
|
+
const todayKey = dayKey(now)
|
|
485
|
+
const cutoff = days > 0 ? now - days * 86400000 : 0
|
|
486
|
+
const filt = []
|
|
487
|
+
for (const r of records) if (r.ts >= cutoff) filt.push(r)
|
|
488
|
+
// 保留窗口外的日汇总(按天+模型),仅取落在查询范围内的天
|
|
489
|
+
const ru = {}
|
|
490
|
+
{
|
|
491
|
+
const startDk = dayKey(cutoff)
|
|
492
|
+
for (const dk of Object.keys(rollups)) if (dk >= startDk) ru[dk] = rollups[dk]
|
|
493
|
+
}
|
|
494
|
+
let realCost = 0, realTokens = 0, peakCost = 0, offCost = 0, flatCost = 0, realCalls = 0
|
|
495
|
+
let subEquivalent = 0, subTokens = 0, subCalls = 0
|
|
496
|
+
// 今日(北京日历日):消费 / 调用 / tokens,按量与订阅分开
|
|
497
|
+
let todayReal = 0, todayCalls = 0, todayTokens = 0, todaySub = 0, todaySubCalls = 0, todaySubTokens = 0
|
|
498
|
+
// 本月(北京日历月):同样按量/订阅分开
|
|
499
|
+
const monthPrefix = todayKey.slice(0, 7) // YYYY-MM
|
|
500
|
+
let monthReal = 0, monthCalls = 0, monthTokens = 0, monthSub = 0, monthSubCalls = 0, monthSubTokens = 0
|
|
501
|
+
const modelMap = {}
|
|
502
|
+
for (const r of filt) {
|
|
503
|
+
const t = r.tokens
|
|
504
|
+
const total = t.input + t.output + t.cacheRead + t.cacheWrite
|
|
505
|
+
const key = r.provider + '/' + r.model
|
|
506
|
+
let m = modelMap[key]
|
|
507
|
+
if (!m) m = modelMap[key] = { model: key, subscription: r.subscription, estimated: r.estimated, calls: 0, tokens: 0, cost: 0, dayMap: {} }
|
|
508
|
+
m.calls += 1; m.tokens += total; m.cost += r.cost
|
|
509
|
+
const dk = dayKey(r.ts)
|
|
510
|
+
let dm = m.dayMap[dk]
|
|
511
|
+
if (!dm) dm = m.dayMap[dk] = { calls: 0, tokens: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }
|
|
512
|
+
dm.calls += 1; dm.tokens += total; dm.input += t.input; dm.output += t.output; dm.cacheRead += t.cacheRead; dm.cacheWrite += t.cacheWrite; dm.cost += r.cost
|
|
513
|
+
if (dk === todayKey) {
|
|
514
|
+
if (r.subscription) { todaySub += r.cost; todaySubCalls += 1; todaySubTokens += total }
|
|
515
|
+
else { todayReal += r.cost; todayCalls += 1; todayTokens += total }
|
|
516
|
+
}
|
|
517
|
+
if (r.subscription) { subCalls += 1; subEquivalent += r.cost; subTokens += total }
|
|
518
|
+
else {
|
|
519
|
+
realCalls += 1; realCost += r.cost; realTokens += total
|
|
520
|
+
if (r.period === 'peak') peakCost += r.cost
|
|
521
|
+
else if (r.period === 'off-peak') offCost += r.cost
|
|
522
|
+
else flatCost += r.cost
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
// 本月(北京日历月):与查询窗口无关,扫全部明细记录(本月必在 180 天明细窗口内)
|
|
526
|
+
for (const r of records) {
|
|
527
|
+
if (dayKey(r.ts).slice(0, 7) !== monthPrefix) continue
|
|
528
|
+
const total = r.tokens.input + r.tokens.output + r.tokens.cacheRead + r.tokens.cacheWrite
|
|
529
|
+
if (r.subscription) { monthSub += r.cost; monthSubCalls += 1; monthSubTokens += total }
|
|
530
|
+
else { monthReal += r.cost; monthCalls += 1; monthTokens += total }
|
|
531
|
+
}
|
|
532
|
+
// 全时段累计(明细 + 永久日汇总),永远精确
|
|
533
|
+
const full = collectTotals(records, rollups)
|
|
534
|
+
// 合并日汇总到总量 / 模型 / 按天明细
|
|
535
|
+
for (const dk of Object.keys(ru)) {
|
|
536
|
+
for (const mk of Object.keys(ru[dk])) {
|
|
537
|
+
const e = ru[dk][mk]
|
|
538
|
+
const total = e.input + e.output + e.cacheRead + e.cacheWrite
|
|
539
|
+
let m = modelMap[mk]
|
|
540
|
+
if (!m) m = modelMap[mk] = { model: mk, subscription: e.subscription, estimated: e.estimated, calls: 0, tokens: 0, cost: 0, dayMap: {} }
|
|
541
|
+
m.calls += e.calls; m.tokens += total; m.cost += e.cost
|
|
542
|
+
let dm = m.dayMap[dk]
|
|
543
|
+
if (!dm) dm = m.dayMap[dk] = { calls: 0, tokens: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 }
|
|
544
|
+
dm.calls += e.calls; dm.tokens += total; dm.input += e.input; dm.output += e.output; dm.cacheRead += e.cacheRead; dm.cacheWrite += e.cacheWrite; dm.cost += e.cost
|
|
545
|
+
if (e.subscription) { subCalls += e.calls; subEquivalent += e.cost; subTokens += total }
|
|
546
|
+
else {
|
|
547
|
+
realCalls += e.calls; realCost += e.cost; realTokens += total
|
|
548
|
+
peakCost += e.peak; offCost += e.off; flatCost += e.flat
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
const endKey = dayKey(now)
|
|
553
|
+
let startKey = endKey
|
|
554
|
+
if (days > 0) startKey = dayKey(cutoff)
|
|
555
|
+
else if (filt.length > 0) startKey = dayKey(filt[0].ts)
|
|
556
|
+
const ruKeys = Object.keys(ru)
|
|
557
|
+
if (ruKeys.length) {
|
|
558
|
+
const earliest = ruKeys.sort()[0]
|
|
559
|
+
if (earliest < startKey) startKey = earliest
|
|
560
|
+
}
|
|
561
|
+
const dates = enumerateDays(startKey, endKey)
|
|
562
|
+
const dayAgg = {}
|
|
563
|
+
for (const d of dates) dayAgg[d.key] = { peak: 0, off: 0, flat: 0 }
|
|
564
|
+
for (const r of filt) {
|
|
565
|
+
if (r.subscription) continue
|
|
566
|
+
const m = dayAgg[dayKey(r.ts)]
|
|
567
|
+
if (!m) continue
|
|
568
|
+
if (r.period === 'peak') m.peak += r.cost
|
|
569
|
+
else if (r.period === 'off-peak') m.off += r.cost
|
|
570
|
+
else m.flat += r.cost
|
|
571
|
+
}
|
|
572
|
+
for (const dk of Object.keys(ru)) {
|
|
573
|
+
const m = dayAgg[dk]
|
|
574
|
+
if (!m) continue
|
|
575
|
+
for (const mk of Object.keys(ru[dk])) {
|
|
576
|
+
const e = ru[dk][mk]
|
|
577
|
+
if (e.subscription) continue
|
|
578
|
+
m.peak += e.peak; m.off += e.off; m.flat += e.flat
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
const byDay = dates.map(d => ({ date: d.key, label: d.label, peak: r4(dayAgg[d.key].peak), off: r4(dayAgg[d.key].off), flat: r4(dayAgg[d.key].flat) }))
|
|
582
|
+
// 模型展示顺序:按总费用降序排名(与 DeepSeek 开放平台一致,排名决定取色/堆叠顺序)
|
|
583
|
+
const keys = Object.keys(modelMap).sort((a, b) => modelMap[b].cost - modelMap[a].cost)
|
|
584
|
+
const byModel = keys.map(k => {
|
|
585
|
+
const m = modelMap[k]
|
|
586
|
+
return { model: m.model, subscription: m.subscription, estimated: m.estimated, calls: m.calls, tokens: m.tokens, cost: r4(m.cost) }
|
|
587
|
+
})
|
|
588
|
+
const byModelDay = keys.map(k => {
|
|
589
|
+
const m = modelMap[k]
|
|
590
|
+
return {
|
|
591
|
+
model: m.model, subscription: m.subscription, estimated: m.estimated,
|
|
592
|
+
days: dates.map(d => {
|
|
593
|
+
const dm = m.dayMap[d.key]
|
|
594
|
+
return { date: d.key, label: d.label, calls: dm ? dm.calls : 0, tokens: dm ? dm.tokens : 0, input: dm ? dm.input : 0, output: dm ? dm.output : 0, cacheRead: dm ? dm.cacheRead : 0, cacheWrite: dm ? dm.cacheWrite : 0, cost: dm ? r4(dm.cost) : 0 }
|
|
595
|
+
}),
|
|
596
|
+
}
|
|
597
|
+
})
|
|
598
|
+
const recent = []
|
|
599
|
+
const start = Math.max(0, records.length - 20)
|
|
600
|
+
for (let i = records.length - 1; i >= start; i--) {
|
|
601
|
+
const r = records[i]
|
|
602
|
+
const t = r.tokens
|
|
603
|
+
recent.push({ ts: r.ts, time: timeLabel(r.ts), provider: r.provider, model: r.model, period: r.period, subscription: r.subscription, estimated: r.estimated, input: t.input, output: t.output, cacheRead: t.cacheRead, cacheWrite: t.cacheWrite, tokens: t.input + t.output + t.cacheRead + t.cacheWrite, cost: r4(r.cost) })
|
|
604
|
+
}
|
|
605
|
+
return {
|
|
606
|
+
ok: true, days,
|
|
607
|
+
realCost: r4(realCost), realCalls, realTokens,
|
|
608
|
+
subEquivalent: r4(subEquivalent), subCalls, subTokens,
|
|
609
|
+
peakCost: r4(peakCost), offCost: r4(offCost), flatCost: r4(flatCost),
|
|
610
|
+
today: { real: r4(todayReal), calls: todayCalls, tokens: todayTokens, sub: r4(todaySub), subCalls: todaySubCalls, subTokens: todaySubTokens },
|
|
611
|
+
month: { real: r4(monthReal), calls: monthCalls, tokens: monthTokens, sub: r4(monthSub), subCalls: monthSubCalls, subTokens: monthSubTokens },
|
|
612
|
+
all: { real: r4(full.realCost), calls: full.realCalls, tokens: full.realTokens, sub: r4(full.subEquivalent), subCalls: full.subCalls, subTokens: full.subTokens },
|
|
613
|
+
byDay, byModel, byModelDay, recent,
|
|
614
|
+
peakWindows: PEAK_WINDOWS,
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function buildSummary(args) {
|
|
619
|
+
const sid = args && typeof args.sessionId === 'string' ? args.sessionId : ''
|
|
620
|
+
const now = Date.now()
|
|
621
|
+
const todayKey = dayKey(now)
|
|
622
|
+
let sessionCost = 0, sessionCalls = 0, sessionSub = 0, sessionSubCalls = 0, todayCost = 0
|
|
623
|
+
// 本会话按模型拆分(明细;订阅与按量分开,便于状态栏按会话实际内容展示)
|
|
624
|
+
const realMap = {}, subMap = {}
|
|
625
|
+
// 会话/当日只可能出现在明细里(日汇总早于保留窗口)
|
|
626
|
+
for (const r of records) {
|
|
627
|
+
if (r.subscription) {
|
|
628
|
+
if (r.sessionId === sid) { sessionSub += r.cost; sessionSubCalls += 1 }
|
|
629
|
+
} else {
|
|
630
|
+
if (dayKey(r.ts) === todayKey) todayCost += r.cost
|
|
631
|
+
if (r.sessionId === sid) { sessionCost += r.cost; sessionCalls += 1 }
|
|
632
|
+
}
|
|
633
|
+
if (r.sessionId === sid) {
|
|
634
|
+
const key = r.provider + '/' + r.model
|
|
635
|
+
const map = r.subscription ? subMap : realMap
|
|
636
|
+
let m = map[key]
|
|
637
|
+
if (!m) m = map[key] = { provider: r.provider, model: r.model, subscription: !!r.subscription, calls: 0, tokens: 0, cost: 0 }
|
|
638
|
+
m.calls += 1
|
|
639
|
+
m.tokens += (r.tokens.input + r.tokens.output + r.tokens.cacheRead + r.tokens.cacheWrite)
|
|
640
|
+
m.cost += r.cost
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
const fmtModels = (map) => Object.keys(map).map(k => map[k]).sort((a, b) => b.cost - a.cost)
|
|
644
|
+
.map(m => ({ provider: m.provider, model: m.model, subscription: m.subscription, calls: m.calls, tokens: m.tokens, cost: r4(m.cost) }))
|
|
645
|
+
const realModels = fmtModels(realMap)
|
|
646
|
+
const subModels = fmtModels(subMap)
|
|
647
|
+
const sessionModels = realModels.concat(subModels).sort((a, b) => b.cost - a.cost)
|
|
648
|
+
// 全时段总量 = 明细 + 永久日汇总,永远精确
|
|
649
|
+
const full = collectTotals(records, rollups)
|
|
650
|
+
let provider = '', model = ''
|
|
651
|
+
const adm = ctx.get('agentDefaultModel')
|
|
652
|
+
if (adm) {
|
|
653
|
+
try {
|
|
654
|
+
const sel = adm.currentSelection()
|
|
655
|
+
if (sel) { provider = toStr(sel.provider); model = toStr(sel.model) }
|
|
656
|
+
} catch (e) {}
|
|
657
|
+
}
|
|
658
|
+
const np = normProvider(provider)
|
|
659
|
+
const subscription = !!SUBSCRIPTION_RATES[np]
|
|
660
|
+
let kimiWeeklyRemaining = null
|
|
661
|
+
// 只要当前选择是订阅,或本会话实际用了订阅,就刷新 kimi 周配额
|
|
662
|
+
if (subscription || sessionSub > 0) {
|
|
663
|
+
if (!kimiCache || now - kimiCache.fetchedAt >= 120000) kimiUsage(false).catch(() => {})
|
|
664
|
+
if (kimiCache && kimiCache.data && kimiCache.data.ok) kimiWeeklyRemaining = kimiCache.data.weekly.remaining
|
|
665
|
+
}
|
|
666
|
+
return {
|
|
667
|
+
sessionCost: r4(sessionCost), sessionCalls,
|
|
668
|
+
sessionSub: r4(sessionSub), sessionSubCalls,
|
|
669
|
+
sessionRealModels: realModels,
|
|
670
|
+
sessionSubModels: subModels,
|
|
671
|
+
sessionModels,
|
|
672
|
+
todayCost: r4(todayCost), totalCost: r4(full.realCost), totalCalls: full.realCalls,
|
|
673
|
+
subEquivalent: r4(full.subEquivalent), subCalls: full.subCalls, subTokens: full.subTokens,
|
|
674
|
+
provider, model,
|
|
675
|
+
isDeepSeek: np === 'deepseek',
|
|
676
|
+
peak: peakEffective(peakConfig, now) ? isPeak(now) : false,
|
|
677
|
+
subscription,
|
|
678
|
+
kimiWeeklyRemaining,
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function resetData() {
|
|
683
|
+
const n = store.counts().calls
|
|
684
|
+
store.clear()
|
|
685
|
+
kimiCache = null
|
|
686
|
+
persistNow()
|
|
687
|
+
return { ok: true, cleared: n }
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// ---------- 一次性补账:按「计费时代」重算已入库记录 ----------
|
|
691
|
+
// 场景:价格时代切换时刻已过,但宿主仍加载着旧代码(插件未重启),
|
|
692
|
+
// 这段时间入库的记录用的是旧价。重启后调用一次即可按记录**自身的时间戳**
|
|
693
|
+
// 重新选版计费,无需重新采集。
|
|
694
|
+
// 只重算明细:明细保留最近 DETAIL_DAYS 天,更早的记录早已折叠进日汇总,
|
|
695
|
+
// 而日汇总覆盖的时间段远早于任何价格切换窗口,故不涉及。
|
|
696
|
+
// 默认只试算(不落盘),传 apply: true 才写回。
|
|
697
|
+
function recomputeCosts(args) {
|
|
698
|
+
const a = args || {}
|
|
699
|
+
const fallback = PRICE_ERAS[PRICE_ERAS.length - 1].since
|
|
700
|
+
let since = fallback
|
|
701
|
+
if (typeof a.since === 'string' && Number.isFinite(Date.parse(a.since))) since = Date.parse(a.since)
|
|
702
|
+
else if (Number.isFinite(a.since) && a.since > 0) since = a.since
|
|
703
|
+
const apply = a.apply === true
|
|
704
|
+
const byModel = {}
|
|
705
|
+
let scanned = 0, changed = 0, oldCost = 0, newCost = 0
|
|
706
|
+
for (const r of records) {
|
|
707
|
+
if (!(r.ts >= since)) continue
|
|
708
|
+
scanned += 1
|
|
709
|
+
const np = normProvider(r.provider)
|
|
710
|
+
const price = priceFor(np, r.model, r.ts)
|
|
711
|
+
const peak = peakEffective(peakConfig, r.ts) ? isPeak(r.ts) : false
|
|
712
|
+
const cost = computeCost(price.rates, price.tiered, peak, r.tokens)
|
|
713
|
+
const model = price.model || r.model
|
|
714
|
+
const period = price.tiered ? (peak ? 'peak' : 'off-peak') : 'flat'
|
|
715
|
+
oldCost += r.cost
|
|
716
|
+
newCost += cost
|
|
717
|
+
if (!(Math.abs(cost - r.cost) > 1e-9 || model !== r.model || period !== r.period)) continue
|
|
718
|
+
changed += 1
|
|
719
|
+
const key = r.provider + '|' + r.model + '|' + model
|
|
720
|
+
const m = byModel[key] || (byModel[key] = { provider: r.provider, from: r.model, to: model, calls: 0, oldCost: 0, newCost: 0 })
|
|
721
|
+
m.calls += 1
|
|
722
|
+
m.oldCost += r.cost
|
|
723
|
+
m.newCost += cost
|
|
724
|
+
if (apply) {
|
|
725
|
+
r.cost = cost
|
|
726
|
+
r.model = model
|
|
727
|
+
r.period = period
|
|
728
|
+
r.estimated = price.estimated
|
|
729
|
+
r.subscription = price.subscription
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
if (apply && changed > 0) persistNow()
|
|
733
|
+
const rows = Object.keys(byModel).map(k => {
|
|
734
|
+
const m = byModel[k]
|
|
735
|
+
return { provider: m.provider, from: m.from, to: m.to, calls: m.calls, oldCost: r4(m.oldCost), newCost: r4(m.newCost), delta: r4(m.newCost - m.oldCost) }
|
|
736
|
+
}).sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta))
|
|
737
|
+
return {
|
|
738
|
+
ok: true,
|
|
739
|
+
applied: apply && changed > 0,
|
|
740
|
+
since,
|
|
741
|
+
era: eraAt(since).id,
|
|
742
|
+
scanned,
|
|
743
|
+
changed,
|
|
744
|
+
oldCost: r4(oldCost),
|
|
745
|
+
newCost: r4(newCost),
|
|
746
|
+
delta: r4(newCost - oldCost),
|
|
747
|
+
byModel: rows,
|
|
748
|
+
note: changed === 0 ? '没有需要重算的记录' : (apply ? '已重算并落盘' : '试算结果,未落盘(传 apply: true 生效)'),
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// ---------- usage heatmap (Codex 风格 26 周每日用量方格热图) ----------
|
|
753
|
+
// 返回全时段累计 token + 按天聚合(明细 + 永久日汇总),供客户端渲染热力图。
|
|
754
|
+
// 汇总含按量与订阅(订阅为等效参考口径一致),days 覆盖最近约 27 周(含 26 周窗口余量)。
|
|
755
|
+
function buildUsageHeat() {
|
|
756
|
+
const now = Date.now()
|
|
757
|
+
const byDay = {}
|
|
758
|
+
const ensure = (dk) => {
|
|
759
|
+
let d = byDay[dk]
|
|
760
|
+
if (!d) d = byDay[dk] = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, calls: 0, cost: 0 }
|
|
761
|
+
return d
|
|
762
|
+
}
|
|
763
|
+
let totInput = 0, totOutput = 0, totCacheRead = 0, totCacheWrite = 0, totCalls = 0, totCost = 0
|
|
764
|
+
for (const r of records) {
|
|
765
|
+
const t = r.tokens
|
|
766
|
+
const d = ensure(dayKey(r.ts))
|
|
767
|
+
d.input += t.input; d.output += t.output; d.cacheRead += t.cacheRead; d.cacheWrite += t.cacheWrite; d.calls += 1; d.cost += r.cost
|
|
768
|
+
totInput += t.input; totOutput += t.output; totCacheRead += t.cacheRead; totCacheWrite += t.cacheWrite; totCalls += 1; totCost += r.cost
|
|
769
|
+
}
|
|
770
|
+
for (const dk of Object.keys(rollups)) {
|
|
771
|
+
for (const mk of Object.keys(rollups[dk])) {
|
|
772
|
+
const e = rollups[dk][mk]
|
|
773
|
+
const d = ensure(dk)
|
|
774
|
+
d.input += e.input; d.output += e.output; d.cacheRead += e.cacheRead; d.cacheWrite += e.cacheWrite; d.calls += e.calls; d.cost += e.cost
|
|
775
|
+
totInput += e.input; totOutput += e.output; totCacheRead += e.cacheRead; totCacheWrite += e.cacheWrite; totCalls += e.calls; totCost += e.cost
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
// 只保留最近约 27 周(客户端网格按周对齐,多留一周做余量,避免边缘缺格)
|
|
779
|
+
const startDk = dayKey(now - 27 * 7 * 86400000)
|
|
780
|
+
const days = []
|
|
781
|
+
for (const dk of Object.keys(byDay).sort()) {
|
|
782
|
+
if (dk < startDk) continue
|
|
783
|
+
const d = byDay[dk]
|
|
784
|
+
days.push({ date: dk, input: d.input, output: d.output, cacheRead: d.cacheRead, cacheWrite: d.cacheWrite, calls: d.calls, cost: r4(d.cost), tokens: d.input + d.output + d.cacheRead + d.cacheWrite })
|
|
785
|
+
}
|
|
786
|
+
return {
|
|
787
|
+
ok: true,
|
|
788
|
+
total: {
|
|
789
|
+
tokens: totInput + totOutput + totCacheRead + totCacheWrite,
|
|
790
|
+
input: totInput, cache: totCacheRead + totCacheWrite, output: totOutput,
|
|
791
|
+
calls: totCalls, cost: r4(totCost),
|
|
792
|
+
},
|
|
793
|
+
days,
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// ---------- HTTP routes (client → host) ----------
|
|
798
|
+
const ROUTES = {
|
|
799
|
+
summary: (args) => buildSummary(args),
|
|
800
|
+
dashboard: (args) => buildDashboard(args),
|
|
801
|
+
'kimi-usage': (args) => kimiUsage(!!(args && args.force)),
|
|
802
|
+
balance: (args) => balance(args),
|
|
803
|
+
export: () => exportCsv(),
|
|
804
|
+
prices: () => prices(),
|
|
805
|
+
recompute: (args) => recomputeCosts(args),
|
|
806
|
+
usage: () => buildUsageHeat(),
|
|
807
|
+
peak: () => peakSnapshot(),
|
|
808
|
+
'peak-config': (args) => setPeakConfig(args),
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
async function handleRoute(req, res) {
|
|
812
|
+
const pathname = decodeURIComponent(new URL(req.url || '/', 'http://localhost').pathname)
|
|
813
|
+
const name = pathname.replace(/^\/api\/cost-tracker\//, '').replace(/\/+$/, '')
|
|
814
|
+
if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return }
|
|
815
|
+
if (req.method !== 'POST') { res.writeHead(405, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ ok: false, error: 'method not allowed' })); return }
|
|
816
|
+
const fn = ROUTES[name]
|
|
817
|
+
if (!fn) { res.writeHead(404, { 'content-type': 'application/json; charset=utf-8' }); res.end(JSON.stringify({ ok: false, error: 'unknown route' })); return }
|
|
818
|
+
let args = {}
|
|
819
|
+
try {
|
|
820
|
+
let raw = ''
|
|
821
|
+
for await (const chunk of req) {
|
|
822
|
+
raw += chunk
|
|
823
|
+
if (raw.length > 1048576) break
|
|
824
|
+
}
|
|
825
|
+
if (raw) args = JSON.parse(raw)
|
|
826
|
+
} catch (e) {}
|
|
827
|
+
try {
|
|
828
|
+
const out = await fn(args)
|
|
829
|
+
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-cache' })
|
|
830
|
+
res.end(JSON.stringify(out))
|
|
831
|
+
} catch (e) {
|
|
832
|
+
res.writeHead(500, { 'content-type': 'application/json; charset=utf-8' })
|
|
833
|
+
res.end(JSON.stringify({ ok: false, error: toStr(e && e.message ? e.message : e) }))
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'prefix', path: '/api/cost-tracker', handler: handleRoute }), 'cost-tracker: api routes')
|
|
838
|
+
|
|
839
|
+
// ---------- model tools ----------
|
|
840
|
+
ctx.tools.register({
|
|
841
|
+
name: 'cost_stats',
|
|
842
|
+
description: '查询本进程内记录的模型调用花费与用量统计(人民币 CNY 计价)。按量计费模型与订阅制套餐(等效费用,仅供参考)分开统计;数据持久化于磁盘,进程重启后自动恢复。',
|
|
843
|
+
parameters: {
|
|
844
|
+
type: 'object',
|
|
845
|
+
properties: { days: { type: 'integer', description: '统计最近 N 天;0 表示全部。默认 7。' } },
|
|
846
|
+
additionalProperties: true,
|
|
847
|
+
},
|
|
848
|
+
output: {
|
|
849
|
+
schema: { type: 'object', additionalProperties: true },
|
|
850
|
+
render: (args, v) => [{ type: 'text', text: '花费统计(' + (v.days === 0 ? '全部' : '近 ' + v.days + ' 天') + ')\n按量消费:¥' + v.realTotal + '(高峰 ¥' + v.peakCost + ' · 闲时 ¥' + v.offCost + (v.flatCost > 0 ? ' · 平峰 ¥' + v.flatCost : '') + ')· 请求 ' + v.realCalls + ' 次 · Tokens ' + v.realTokens + '\n订阅套餐:请求 ' + v.subCalls + ' 次 · Tokens ' + v.subTokens + ' · 等效 ¥' + v.subEquivalent + '(订阅已覆盖,仅供参考)' }],
|
|
851
|
+
},
|
|
852
|
+
execute: async (args) => {
|
|
853
|
+
const d = buildDashboard(args && typeof args.days === 'number' ? { days: args.days } : { days: 7 })
|
|
854
|
+
return { ok: true, days: d.days, realTotal: d.realCost, realCalls: d.realCalls, realTokens: d.realTokens, peakCost: d.peakCost, offCost: d.offCost, flatCost: d.flatCost, subEquivalent: d.subEquivalent, subCalls: d.subCalls, subTokens: d.subTokens }
|
|
855
|
+
},
|
|
856
|
+
})
|
|
857
|
+
|
|
858
|
+
ctx.tools.register({
|
|
859
|
+
name: 'cost_prices',
|
|
860
|
+
description: '查看当前内置的模型单价表(CNY / 百万 tokens)与峰谷时段规则。',
|
|
861
|
+
parameters: {
|
|
862
|
+
type: 'object',
|
|
863
|
+
properties: {},
|
|
864
|
+
additionalProperties: true,
|
|
865
|
+
},
|
|
866
|
+
output: {
|
|
867
|
+
schema: { type: 'object', additionalProperties: true },
|
|
868
|
+
render: (args, v) => {
|
|
869
|
+
const when = (s) => (s === 0 ? '初始价(长期有效)' : new Date(s + 28800000).toISOString().replace('T', ' ').slice(0, 16).replace(/-/g, '/') + '(北京时间)')
|
|
870
|
+
const lines = ['单价表(CNY / 百万 tokens)', '峰谷时段(北京时间):' + v.peakWindows + ',闲时 = 高峰价 × ' + v.offPeakFactor]
|
|
871
|
+
for (const e of v.eras) {
|
|
872
|
+
lines.push('', '【' + e.label + '】生效:' + when(e.since))
|
|
873
|
+
for (const name of Object.keys(e.models)) {
|
|
874
|
+
const r = e.models[name]
|
|
875
|
+
lines.push(' ' + name + ':高峰 输入(未命中)' + r.input + ' / 输入(命中)' + r.cacheRead + ' / 输出 ' + r.output + '(闲时半价)')
|
|
876
|
+
}
|
|
877
|
+
for (const from of Object.keys(e.routes)) {
|
|
878
|
+
lines.push(' ↳ 路由:' + from + ' → 按 ' + e.routes[from] + ' 单价计费')
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
lines.push('', '当前生效:' + v.eraLabel + '(era=' + v.era + ')')
|
|
882
|
+
lines.push('视觉模型 deepseek-v4-flash-vision-exp:图片按官方规则换算 token(每张上限 384),以接口用量计费(已含在 inputTokens 内)。')
|
|
883
|
+
lines.push('kimi-coding(订阅等效,估算):输入 6.5 / 缓存命中(含缓存写入)1.1 / 输出 27.0')
|
|
884
|
+
lines.push('缓存写入(cache write)按缓存命中价计费,与官方规则一致。其他 provider 兜底为估算平价(openai 10/30/5,anthropic 15/75/1.5,gemini 2.5/10/0.625,未知 2/8/0.5);ollama/local 为 0。')
|
|
885
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
886
|
+
},
|
|
887
|
+
},
|
|
888
|
+
execute: async () => prices(),
|
|
889
|
+
})
|
|
890
|
+
|
|
891
|
+
ctx.tools.register({
|
|
892
|
+
name: 'cost_reset',
|
|
893
|
+
description: '清空本进程内记录的全部模型调用花费与用量数据(同时清空磁盘持久化,不可恢复)。',
|
|
894
|
+
parameters: {
|
|
895
|
+
type: 'object',
|
|
896
|
+
properties: {},
|
|
897
|
+
additionalProperties: true,
|
|
898
|
+
},
|
|
899
|
+
output: {
|
|
900
|
+
schema: { type: 'object', additionalProperties: true },
|
|
901
|
+
render: (args, v) => [{ type: 'text', text: '已清空 ' + v.cleared + ' 条花费记录。' }],
|
|
902
|
+
},
|
|
903
|
+
execute: async () => resetData(),
|
|
904
|
+
})
|
|
905
|
+
|
|
906
|
+
ctx.tools.register({
|
|
907
|
+
name: 'cost_recompute',
|
|
908
|
+
description: '按「计费时代」重算已入库记录的费用(一次性补账)。用于价格调整后宿主未及时重启、导致记录按旧价入库的情况;默认只试算不落盘,传 apply: true 才写回。',
|
|
909
|
+
parameters: {
|
|
910
|
+
type: 'object',
|
|
911
|
+
properties: {
|
|
912
|
+
apply: { type: 'boolean', description: '是否把重算结果写回(默认 false,仅试算)' },
|
|
913
|
+
since: { type: 'string', description: '重算起始时刻(ISO 字符串或 epoch ms);默认取最近一次价格时代的生效时刻' },
|
|
914
|
+
},
|
|
915
|
+
additionalProperties: true,
|
|
916
|
+
},
|
|
917
|
+
output: {
|
|
918
|
+
schema: { type: 'object', additionalProperties: true },
|
|
919
|
+
render: (args, v) => {
|
|
920
|
+
const when = new Date(v.since + 28800000).toISOString().replace('T', ' ').slice(0, 16).replace(/-/g, '/')
|
|
921
|
+
const lines = [
|
|
922
|
+
'费用重算(自 ' + when + ' 北京起 · era=' + v.era + ')',
|
|
923
|
+
'扫描 ' + v.scanned + ' 条,需修正 ' + v.changed + ' 条',
|
|
924
|
+
'合计:¥' + v.oldCost + ' → ¥' + v.newCost + '(' + (v.delta >= 0 ? '+' : '') + v.delta + ')',
|
|
925
|
+
]
|
|
926
|
+
for (const m of (v.byModel || []).slice(0, 8)) {
|
|
927
|
+
lines.push(' ' + m.from + (m.to !== m.from ? ' → ' + m.to : '') + ':' + m.calls + ' 次 · ¥' + m.oldCost + ' → ¥' + m.newCost)
|
|
928
|
+
}
|
|
929
|
+
lines.push(v.note)
|
|
930
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
931
|
+
},
|
|
932
|
+
},
|
|
933
|
+
execute: async (args) => recomputeCosts(args),
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
ctx.tools.register({
|
|
937
|
+
name: 'cost_peak',
|
|
938
|
+
description: '查询当前 DeepSeek 峰谷计价档位与下次切换倒计时(北京时间:高峰时段为周一至周五 9:00-12:00、14:00-18:00,其余为闲时,周末全天闲时)。',
|
|
939
|
+
parameters: {
|
|
940
|
+
type: 'object',
|
|
941
|
+
properties: {},
|
|
942
|
+
additionalProperties: true,
|
|
943
|
+
},
|
|
944
|
+
output: {
|
|
945
|
+
schema: { type: 'object', additionalProperties: true },
|
|
946
|
+
render: (args, v) => {
|
|
947
|
+
const p = v.phase
|
|
948
|
+
const phaseText = !p ? '未知' : p.weekend ? '周末全天闲时(全谷价)' : p.inPeak ? '高峰时段(按峰时价)' : '闲时时段(按谷时价)'
|
|
949
|
+
const nextText = p ? new Date(p.nextAtMs + 28800000).toISOString().replace('T', ' ').slice(0, 16).replace(/-/g, '/') + ' 转' + (p.nextIntoPeak ? '峰' : '谷') : '未知'
|
|
950
|
+
return [{ type: 'text', text: '峰谷计价:' + (v.enabled ? '已启用' : '已停用') + '(' + v.peakWindows + ')\n当前档位:' + phaseText + '\n下次切换:' + nextText + (v.effective ? '' : '(峰谷未生效,按平价计费)') }]
|
|
951
|
+
},
|
|
952
|
+
},
|
|
953
|
+
execute: async () => peakSnapshot(),
|
|
954
|
+
})
|
|
955
|
+
|
|
956
|
+
// ---------- lifecycle ----------
|
|
957
|
+
loadRecords()
|
|
958
|
+
loadConfig()
|
|
959
|
+
ctx.effect(() => () => { try { writeRecords() } catch (e) {} }, 'cost-tracker: final flush')
|
|
960
|
+
startupLog('cost tracker ready (static)')
|
|
961
|
+
},
|
|
962
|
+
}
|