dsh-cost-meter 1.7.33 → 1.7.34
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 +6 -6
- package/README.zh-CN.md +6 -6
- package/lib/index.js +1 -5
- package/lib/ledger-persistence.js +223 -0
- package/lib/store.js +69 -97
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
Per-conversation cost · daily totals · OpenCode Go subscription quota display · budget with usage percentage · official account balance · custom provider balance · balance progress bar · history · peak/off-peak pricing hours display (peak hours UTC 01:00–04:00, 06:00–10:00; from Aug 23, 2026 weekends are billed at off-peak prices all day, shown as “Weekend — all off-peak”) · pre-switch popup & system-notification alerts for peak/off-peak changes (position / lead time / alert type configurable) · one-click price sync from the official docs · Codex-style token usage heat grid · multi-vendor model pricing (built-in 90+ model price catalog with auto-matching) · mainstream Coding Plan quota queries & display (Anthropic / Z.ai / MiniMax / Kimi / OpenRouter / SiliconFlow / CommandCode / SCNet / Volcano Ark / Qwen / Xiaomi MiMo) plan/API dual-track billing (subscription quota vs pay-as-you-go money separated, per-1% & full-window token/equivalent-cost estimates with daily/weekly/monthly curves) · · quota strip above the input box (budget / Go / coding-plan usage in one row, toggleable)
|
|
10
10
|
|
|
11
|
-
[](https://github.com/Han-1413141/dsh-cost-meter)
|
|
12
12
|
|
|
13
|
-
**v1.7.
|
|
13
|
+
**v1.7.34** fixes concurrent ledger writes from web/headless processes, preserving session details, daily totals and plan usage. Update the plugin in every profile that uses it and restart all DSH processes. See the [release notes](docs/release-notes/v1.7.34.md).
|
|
14
14
|
|
|
15
15
|
[](https://www.npmjs.com/package/dsh-cost-meter)
|
|
16
16
|
[](LICENSE)
|
|
@@ -316,22 +316,22 @@ On Node.js 20, use `npm install -g pnpm@10` instead. See [pnpm installation and
|
|
|
316
316
|
dsh plugin --profile web add dsh-cost-meter
|
|
317
317
|
```
|
|
318
318
|
|
|
319
|
-
**PowerShell one-click script** (copy the whole line, paste, press Enter; pnpm is provisioned automatically, git is auto-detected — no clone needed; the install chain is **pinned to the release tag `v1.7.
|
|
319
|
+
**PowerShell one-click script** (copy the whole line, paste, press Enter; pnpm is provisioned automatically, git is auto-detected — no clone needed; the install chain is **pinned to the release tag `v1.7.34`** — review the script before running):
|
|
320
320
|
|
|
321
321
|
```powershell
|
|
322
|
-
irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.
|
|
322
|
+
irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.34/install.ps1 | iex
|
|
323
323
|
```
|
|
324
324
|
|
|
325
325
|
**Or a plain command line** (the machine must already have pnpm and git; also pinned to the tag):
|
|
326
326
|
|
|
327
327
|
```sh
|
|
328
|
-
dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.
|
|
328
|
+
dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.34
|
|
329
329
|
```
|
|
330
330
|
|
|
331
331
|
Without git, use the GitHub tag archive:
|
|
332
332
|
|
|
333
333
|
```sh
|
|
334
|
-
dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.
|
|
334
|
+
dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.34.tar.gz
|
|
335
335
|
```
|
|
336
336
|
|
|
337
337
|
After installing, **restart** `dsh web` (plugin rows, the Typert manifest and the client bundle are all scanned at startup):
|
package/README.zh-CN.md
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
本会话费用 · 当日费用 · OpenCode Go 订阅额度显示 · 预算与已用百分比 · 官方账户余额 · 自定义 Provider 余额查询(可配任意 HTTP 端点) · 余额三段进度条 · 历史记录 · 峰谷计价时段显示(UTC 01:00–04:00、06:00–10:00 为峰时段;2026-08-23 起周末全天按谷价,显示「周末时段——全谷价」) · 峰/谷切换前弹窗与系统通知提醒(位置/提前量/提醒类型可配) · 官方价格一键同步 · 类 Codex Token 用量热图 · 多厂商多模型价格计费(内置 90+ 模型价格目录与自动匹配) · 主流 Coding Plan 额度查询与显示(Anthropic / Z.ai / MiniMax / Kimi / OpenRouter / SiliconFlow / CommandCode / SCNet / 火山方舟 / 千问 / 小米 MiMo 十一家,含 Volcano Ark AK/SK 签名与 MiMo 控制台 Cookie 查询) · Plan/API 双轨计费(订阅额度与按量金额分离统计,每 1% 额度与满窗的 token/等值金额估算及日/周/月曲线) · 输入框上方额度横条(预算/Go/Coding Plan 用量一条横排显示,可开关)
|
|
10
10
|
|
|
11
|
-
[](https://github.com/Han-1413141/dsh-cost-meter)
|
|
12
12
|
|
|
13
|
-
**v1.7.
|
|
13
|
+
**v1.7.34**:修复 web/headless 等多进程写账本互相覆盖,保留会话明细、每日合计和套餐用量。请更新所有使用此插件的 profile,并重启全部 DSH 进程。详见[更新说明](docs/release-notes/v1.7.34.md)。
|
|
14
14
|
|
|
15
15
|
[](https://www.npmjs.com/package/dsh-cost-meter)
|
|
16
16
|
[](LICENSE)
|
|
@@ -316,22 +316,22 @@ Node.js 20 请改用 `npm install -g pnpm@10`。版本要求见 [pnpm 官方安
|
|
|
316
316
|
dsh plugin --profile web add dsh-cost-meter
|
|
317
317
|
```
|
|
318
318
|
|
|
319
|
-
**PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.7.
|
|
319
|
+
**PowerShell 一键脚本**(复制整行粘贴回车;自动补齐 pnpm、自动探测 git,无需克隆仓库;安装链**固定到发布 tag `v1.7.34`**,建议先下载审阅再运行):
|
|
320
320
|
|
|
321
321
|
```powershell
|
|
322
|
-
irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.
|
|
322
|
+
irm https://raw.githubusercontent.com/Han-1413141/dsh-cost-meter/v1.7.34/install.ps1 | iex
|
|
323
323
|
```
|
|
324
324
|
|
|
325
325
|
**或直接命令行**(机器上需已有 pnpm 与 git;同样固定到 tag):
|
|
326
326
|
|
|
327
327
|
```sh
|
|
328
|
-
dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.
|
|
328
|
+
dsh plugin --profile web add github:Han-1413141/dsh-cost-meter#v1.7.34
|
|
329
329
|
```
|
|
330
330
|
|
|
331
331
|
没有 git 时可用 GitHub tag 打包直链:
|
|
332
332
|
|
|
333
333
|
```sh
|
|
334
|
-
dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.
|
|
334
|
+
dsh plugin --profile web add https://github.com/Han-1413141/dsh-cost-meter/archive/refs/tags/v1.7.34.tar.gz
|
|
335
335
|
```
|
|
336
336
|
|
|
337
337
|
安装后**重启** `dsh web`(插件行、Typert 清单与客户端 bundle 均在启动时扫描):
|
package/lib/index.js
CHANGED
|
@@ -2231,11 +2231,7 @@ function createService(ctx, ledger, openRouterPrices) {
|
|
|
2231
2231
|
// 清空全部历史:除 days 外,Plan 百分比采样/小时桶与余额对账基准一并
|
|
2232
2232
|
// 重置,否则残留样本仍会推算出 planStats、对账基准继续引用旧日合计,
|
|
2233
2233
|
// 与「清空全部历史」语义不一致。
|
|
2234
|
-
ledger.
|
|
2235
|
-
ledger.planSamples = {}
|
|
2236
|
-
ledger.planHourBuckets = {}
|
|
2237
|
-
ledger.balanceRef = null
|
|
2238
|
-
ledger.scheduleWrite()
|
|
2234
|
+
ledger.resetHistory()
|
|
2239
2235
|
return build(false)
|
|
2240
2236
|
},
|
|
2241
2237
|
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/** Cross-process ledger transactions. Runtime secrets never enter these snapshots. */
|
|
2
|
+
import { mkdirSync, readdirSync, writeFileSync, readFileSync, renameSync, unlinkSync, rmdirSync, statSync } from 'node:fs'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { randomUUID } from 'node:crypto'
|
|
5
|
+
import { isDeepStrictEqual as equal } from 'node:util'
|
|
6
|
+
import { appendHourBucket, pruneHourBuckets, PLAN_SAMPLE_CAP, PLAN_SAMPLE_MAX_AGE_MS } from './plan-billing.js'
|
|
7
|
+
|
|
8
|
+
const fields = ['input', 'output', 'cacheRead', 'cacheWrite', 'reasoning', 'calls', 'cost', 'apiCost']
|
|
9
|
+
const safe = key => !['__proto__', 'constructor', 'prototype'].includes(key)
|
|
10
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
11
|
+
const clone = value => value === undefined ? undefined : structuredClone(value)
|
|
12
|
+
const pause = new Int32Array(new SharedArrayBuffer(4))
|
|
13
|
+
|
|
14
|
+
/** mkdir is exclusive on Windows and POSIX. A live owner is never evicted by age. */
|
|
15
|
+
export function withLedgerLock(path, action, timeoutMs = 5000) {
|
|
16
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
17
|
+
const lock = `${path}.lock`
|
|
18
|
+
const owner = `${process.pid}-${randomUUID()}`
|
|
19
|
+
const deadline = Date.now() + timeoutMs
|
|
20
|
+
for (;;) {
|
|
21
|
+
try {
|
|
22
|
+
mkdirSync(lock)
|
|
23
|
+
try { writeFileSync(join(lock, owner), '') } catch (error) { rmdirSync(lock); throw error }
|
|
24
|
+
break
|
|
25
|
+
} catch (error) {
|
|
26
|
+
// Windows can briefly report EPERM while another process finishes removing
|
|
27
|
+
// a directory whose last read handle has only just closed.
|
|
28
|
+
if (!['EEXIST', 'EPERM'].includes(error.code)) throw error
|
|
29
|
+
try {
|
|
30
|
+
const names = readdirSync(lock)
|
|
31
|
+
const previous = names.length === 1 && names[0].match(/^(\d+)-[a-f0-9-]+$/)
|
|
32
|
+
if (previous) {
|
|
33
|
+
let dead = false
|
|
34
|
+
try { process.kill(Number(previous[1]), 0) } catch (probe) { dead = probe.code === 'ESRCH' }
|
|
35
|
+
// Only the contender that removes this unique owner file may remove the
|
|
36
|
+
// directory. Other contenders cannot accidentally unlink a new lock.
|
|
37
|
+
if (dead) { unlinkSync(join(lock, names[0])); rmdirSync(lock); continue }
|
|
38
|
+
} else if (names.length === 0 && Date.now() - statSync(lock).mtimeMs > 30_000) {
|
|
39
|
+
rmdirSync(lock) // Also recovers a process killed between mkdir and owner creation.
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
} catch (probe) {
|
|
43
|
+
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST', 'EPERM'].includes(probe.code)) throw probe
|
|
44
|
+
}
|
|
45
|
+
if (Date.now() >= deadline) throw new Error('Ledger is busy; preserving pending changes for retry')
|
|
46
|
+
Atomics.wait(pause, 0, 0, 10)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
try { return action() } finally {
|
|
50
|
+
unlinkSync(join(lock, owner))
|
|
51
|
+
rmdirSync(lock)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function readLedger(path) {
|
|
56
|
+
let text
|
|
57
|
+
try { text = readFileSync(path, 'utf8') } catch (error) {
|
|
58
|
+
if (error.code === 'ENOENT') return null
|
|
59
|
+
throw error
|
|
60
|
+
}
|
|
61
|
+
const value = JSON.parse(text)
|
|
62
|
+
if (!object(value) || value.version !== 1) throw new Error('Unsupported ledger format')
|
|
63
|
+
return value
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function writeLedger(path, state) {
|
|
67
|
+
const temp = `${path}.${process.pid}-${randomUUID()}.tmp`
|
|
68
|
+
try {
|
|
69
|
+
writeFileSync(temp, JSON.stringify(state), { encoding: 'utf8', flag: 'wx' })
|
|
70
|
+
renameSync(temp, path)
|
|
71
|
+
} finally {
|
|
72
|
+
try { unlinkSync(temp) } catch (error) { if (error.code !== 'ENOENT') throw error }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const empty = () => Object.fromEntries(fields.map(key => [key, 0]))
|
|
77
|
+
function add(target, values) {
|
|
78
|
+
for (const key of fields) target[key] = (Number(target[key]) || 0) + values[key]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Replay a priced call, not a snapshot: two equal calls are still two calls. */
|
|
82
|
+
export function applyAccount(state, entry) {
|
|
83
|
+
const { date, sessionId, atMs, providerKey, values, planId } = entry
|
|
84
|
+
if (!object(state.days[date])) state.days[date] = { date, ...empty(), byProviderModel: {}, sessions: [] }
|
|
85
|
+
const day = state.days[date]
|
|
86
|
+
if (!Array.isArray(day.sessions)) day.sessions = []
|
|
87
|
+
const containers = [day]
|
|
88
|
+
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
|
89
|
+
let session = day.sessions.find(s => s.id === sessionId)
|
|
90
|
+
if (!session) {
|
|
91
|
+
session = { id: sessionId, at: atMs, ...empty(), byProviderModel: {} }
|
|
92
|
+
day.sessions.push(session)
|
|
93
|
+
if (day.sessions.length > 200) day.sessions = day.sessions.slice(-200)
|
|
94
|
+
}
|
|
95
|
+
containers.push(session)
|
|
96
|
+
}
|
|
97
|
+
for (const container of containers) {
|
|
98
|
+
add(container, values)
|
|
99
|
+
container.byProviderModel ??= {}
|
|
100
|
+
const bucket = container.byProviderModel[providerKey] ??= empty()
|
|
101
|
+
add(bucket, values)
|
|
102
|
+
}
|
|
103
|
+
if (planId !== null) state.planHourBuckets = appendHourBucket(state.planHourBuckets, planId, atMs,
|
|
104
|
+
values.input + values.output + values.cacheRead + values.cacheWrite + values.reasoning, values.cost)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Three-way merge for settings and maintenance; unchanged local fields retain disk values. */
|
|
108
|
+
function merge(base, local, disk, numeric = false) {
|
|
109
|
+
if (equal(local, base)) return clone(disk)
|
|
110
|
+
if (equal(disk, base) || equal(local, disk)) return clone(local)
|
|
111
|
+
if (object(local) && object(disk)) {
|
|
112
|
+
const out = {}
|
|
113
|
+
for (const key of new Set([...Object.keys(base ?? {}), ...Object.keys(local), ...Object.keys(disk)])) {
|
|
114
|
+
if (!safe(key)) continue
|
|
115
|
+
const value = merge(base?.[key], local[key], disk[key], numeric && (object(local[key]) || fields.includes(key) || key === 'tokens'))
|
|
116
|
+
if (value !== undefined) out[key] = value
|
|
117
|
+
}
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
120
|
+
if (numeric && typeof local === 'number' && typeof disk === 'number') {
|
|
121
|
+
return Math.max(0, disk + local - (Number(base) || 0))
|
|
122
|
+
}
|
|
123
|
+
return clone(local)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const sessionMap = day => Object.fromEntries((day?.sessions ?? []).filter(s => safe(s.id)).map(s => [s.id, s]))
|
|
127
|
+
function mergeDays(base, local, disk) {
|
|
128
|
+
const out = {}
|
|
129
|
+
for (const date of new Set([...Object.keys(local), ...Object.keys(disk)])) {
|
|
130
|
+
if (!safe(date)) continue
|
|
131
|
+
const b = base[date], l = local[date], d = disk[date]
|
|
132
|
+
if (equal(b, l)) { if (d !== undefined) out[date] = clone(d); continue }
|
|
133
|
+
if (!l || !d) { if (l) out[date] = clone(l); continue }
|
|
134
|
+
if (equal(l, d)) { out[date] = clone(d); continue }
|
|
135
|
+
const merged = merge(b, l, d, true)
|
|
136
|
+
const bs = sessionMap(b), ls = sessionMap(l), ds = sessionMap(d)
|
|
137
|
+
const sessions = merge(bs, ls, ds, true)
|
|
138
|
+
// Rebase aggregate deltas instead of rebuilding totals from the retained
|
|
139
|
+
// sessions. Identical numbers can describe different imported sessions.
|
|
140
|
+
const totals = (base, local, disk) => Object.fromEntries(fields.map(key => [key,
|
|
141
|
+
(disk?.[key] ?? 0) + (local?.[key] ?? 0) - (base?.[key] ?? 0)]))
|
|
142
|
+
Object.assign(merged, totals(b, l, d))
|
|
143
|
+
merged.byProviderModel = {}
|
|
144
|
+
for (const key of new Set([...Object.keys(b?.byProviderModel ?? {}), ...Object.keys(l.byProviderModel ?? {}), ...Object.keys(d.byProviderModel ?? {})])) {
|
|
145
|
+
if (safe(key)) merged.byProviderModel[key] = totals(b?.byProviderModel?.[key], l.byProviderModel?.[key], d.byProviderModel?.[key])
|
|
146
|
+
}
|
|
147
|
+
// Subtract only changes already represented in the merged session. This
|
|
148
|
+
// deduplicates overlapping imports/repairs while retaining anonymous usage
|
|
149
|
+
// and totals belonging to details pruned by the 200-session limit.
|
|
150
|
+
for (const id of Object.keys(ls)) {
|
|
151
|
+
if (!ds[id] || equal(bs[id], ls[id]) || equal(bs[id], ds[id])) continue
|
|
152
|
+
for (const key of fields) merged[key] -= (ls[id][key] ?? 0) - (bs[id]?.[key] ?? 0) - ((sessions[id]?.[key] ?? 0) - (ds[id][key] ?? 0))
|
|
153
|
+
for (const [key, bucket] of Object.entries(ls[id].byProviderModel ?? {})) {
|
|
154
|
+
if (!safe(key) || !merged.byProviderModel[key]) continue
|
|
155
|
+
for (const field of fields) merged.byProviderModel[key][field] -= (bucket[field] ?? 0) - (bs[id]?.byProviderModel?.[key]?.[field] ?? 0)
|
|
156
|
+
- ((sessions[id]?.byProviderModel?.[key]?.[field] ?? 0) - (ds[id].byProviderModel?.[key]?.[field] ?? 0))
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
for (const container of [merged, ...Object.values(merged.byProviderModel)]) {
|
|
160
|
+
for (const key of fields) container[key] = Math.max(0, container[key])
|
|
161
|
+
}
|
|
162
|
+
merged.sessions = Object.values(sessions).sort((a, b) => (a.at ?? 0) - (b.at ?? 0)).slice(-200)
|
|
163
|
+
out[date] = merged
|
|
164
|
+
}
|
|
165
|
+
return out
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function mergeSamples(base, local, disk, now) {
|
|
169
|
+
const out = {}
|
|
170
|
+
for (const provider of new Set([...Object.keys(local), ...Object.keys(disk)])) {
|
|
171
|
+
if (!safe(provider)) continue
|
|
172
|
+
out[provider] = {}
|
|
173
|
+
for (const window of new Set([...Object.keys(local[provider] ?? {}), ...Object.keys(disk[provider] ?? {})])) {
|
|
174
|
+
if (!safe(window)) continue
|
|
175
|
+
const byTime = list => Object.fromEntries((Array.isArray(list) ? list : []).filter(s => s && Number.isFinite(s.t)).map(s => [s.t, s]))
|
|
176
|
+
const samples = merge(byTime(base[provider]?.[window]), byTime(local[provider]?.[window]), byTime(disk[provider]?.[window]))
|
|
177
|
+
out[provider][window] = Object.values(samples).filter(s => s.t >= now - PLAN_SAMPLE_MAX_AGE_MS)
|
|
178
|
+
.sort((a, b) => a.t - b.t).slice(-PLAN_SAMPLE_CAP)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return out
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Replay pending calls into both the baseline and current disk snapshot. The
|
|
185
|
+
// remaining local-versus-baseline diff contains only edits/imports/repairs, so
|
|
186
|
+
// repeated flushes cannot add an already committed call or hourly bucket twice.
|
|
187
|
+
export function mergeLedger(base, local, disk, pending, now = Date.now()) {
|
|
188
|
+
const b = clone(base), l = clone(local), d = clone(disk ?? base)
|
|
189
|
+
const localReset = !equal(l.historyReset, b.historyReset)
|
|
190
|
+
const remoteReset = !equal(d.historyReset, b.historyReset)
|
|
191
|
+
const reset = localReset ? l.historyReset : d.historyReset
|
|
192
|
+
if (localReset || remoteReset) {
|
|
193
|
+
for (const state of [b, ...(localReset ? [d] : [l])]) {
|
|
194
|
+
state.days = {}; state.planSamples = {}; state.planHourBuckets = {}; state.balanceRef = null
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
for (const entry of pending) {
|
|
198
|
+
if (remoteReset && !localReset && entry.recordedAt <= (reset?.at ?? 0)) continue
|
|
199
|
+
applyAccount(b, entry)
|
|
200
|
+
applyAccount(d, entry)
|
|
201
|
+
if (remoteReset && !localReset) applyAccount(l, entry)
|
|
202
|
+
}
|
|
203
|
+
const out = {
|
|
204
|
+
version: 1,
|
|
205
|
+
config: merge(b.config, l.config, d.config),
|
|
206
|
+
days: mergeDays(b.days, l.days, d.days),
|
|
207
|
+
balanceRef: equal(b.balanceRef, l.balanceRef) ? d.balanceRef
|
|
208
|
+
: (d.balanceRef?.at ?? 0) > (l.balanceRef?.at ?? 0) ? d.balanceRef : l.balanceRef,
|
|
209
|
+
migrations: [...new Set([...(d.migrations ?? []), ...(l.migrations ?? [])])],
|
|
210
|
+
planSamples: mergeSamples(b.planSamples, l.planSamples, d.planSamples, now),
|
|
211
|
+
planHourBuckets: pruneHourBuckets(merge(b.planHourBuckets, l.planHourBuckets, d.planHourBuckets, true), now),
|
|
212
|
+
openrouterPriceHashes: merge(b.openrouterPriceHashes, l.openrouterPriceHashes, d.openrouterPriceHashes),
|
|
213
|
+
historyReset: reset ?? null,
|
|
214
|
+
}
|
|
215
|
+
const dates = Object.keys(out.days).sort()
|
|
216
|
+
const keep = Math.max(7, Math.min(3650, Number(out.config.historyDays) || 180))
|
|
217
|
+
while (dates.length > keep) delete out.days[dates.shift()]
|
|
218
|
+
return out
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function mergeRuntimeConfig(base, local, persisted) {
|
|
222
|
+
return merge(base, local, persisted)
|
|
223
|
+
}
|
package/lib/store.js
CHANGED
|
@@ -5,8 +5,10 @@
|
|
|
5
5
|
* 原子重命名,并做防抖;账本按 config.historyDays 保留最近 N 天。
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { readFileSync, renameSync } from 'node:fs'
|
|
9
|
+
import { randomUUID } from 'node:crypto'
|
|
10
|
+
import { withLedgerLock, readLedger, writeLedger, applyAccount, mergeLedger, mergeRuntimeConfig } from './ledger-persistence.js'
|
|
11
|
+
import { join } from 'node:path'
|
|
10
12
|
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
|
11
13
|
import {
|
|
12
14
|
DEFAULT_PEAK_EFFECTIVE_AT,
|
|
@@ -24,10 +26,9 @@ import {
|
|
|
24
26
|
isLocalOriginProviderOrModel,
|
|
25
27
|
} from './pricing.js'
|
|
26
28
|
import { CODING_PLAN_PROVIDER_IDS, normalizeMiniMaxBaseUrl } from './coding-plans.js'
|
|
27
|
-
import { DEFAULT_PLAN_PROVIDER_CLASS, PLAN_PROVIDER_IDS, billingClassOf, enabledPlanSetOf, planProviderIdOf,
|
|
29
|
+
import { DEFAULT_PLAN_PROVIDER_CLASS, PLAN_PROVIDER_IDS, billingClassOf, enabledPlanSetOf, planProviderIdOf, convertRecentCallsToBuckets, isRoutedThirdPartyCall } from './plan-billing.js'
|
|
28
30
|
|
|
29
31
|
const LEDGER_VERSION = 1
|
|
30
|
-
const MAX_SESSIONS_PER_DAY = 200
|
|
31
32
|
const DEFAULT_HISTORY_DAYS = 180
|
|
32
33
|
const GATEWAY_PROVIDER_IDS = ['antigravity', 'claude', 'codex', 'kimi', 'xai', 'workbuddy']
|
|
33
34
|
const GATEWAY_DISPLAY_VALUES = ['sidebar', 'settings', 'both', 'off']
|
|
@@ -634,10 +635,6 @@ export function dedupeWrapperProviderDays(days) {
|
|
|
634
635
|
return result
|
|
635
636
|
}
|
|
636
637
|
|
|
637
|
-
function zeroSession(id) {
|
|
638
|
-
return { id, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, calls: 0, cost: 0, apiCost: 0, byProviderModel: {} }
|
|
639
|
-
}
|
|
640
|
-
|
|
641
638
|
/** 账本数值清洗:非有限/负数(含历史版本写入的 null)一律归 0,防止污染聚合并击穿 Typert strict codec。 */
|
|
642
639
|
function sanitizeNum(value) {
|
|
643
640
|
const n = Number(value)
|
|
@@ -1934,7 +1931,9 @@ export class Ledger {
|
|
|
1934
1931
|
this.closed = false
|
|
1935
1932
|
this.pendingWrite = false
|
|
1936
1933
|
// 余额差对账参考点({ date, total, granted, topped, at }),由 open() 载入、flush() 落盘。
|
|
1937
|
-
this.balanceRef = null
|
|
1934
|
+
this.balanceRef = extras.balanceRef ?? null
|
|
1935
|
+
this.historyReset = extras.historyReset ?? null
|
|
1936
|
+
this.pendingAccounts = []
|
|
1938
1937
|
this.migrations = Array.isArray(migrations) ? migrations.filter(m => typeof m === 'string') : []
|
|
1939
1938
|
// 存量密钥迁移状态(v1.6.8,**不落盘**):{ ran, pending }。由 runSecretMigration 写入、
|
|
1940
1939
|
// buildState 读取,用于在 UI 提示未能自动导入凭据库的密钥。
|
|
@@ -1945,12 +1944,17 @@ export class Ledger {
|
|
|
1945
1944
|
// 自动目录上次写入的价格指纹:重启后仍能识别用户手改价格,不下发到客户端。
|
|
1946
1945
|
this.openrouterPriceHashes = Object.fromEntries(Object.entries(extras.openrouterPriceHashes ?? {})
|
|
1947
1946
|
.filter(([key, value]) => !['__proto__', 'constructor', 'prototype'].includes(key) && typeof value === 'string' && /^[a-f0-9]{64}$/.test(value)))
|
|
1947
|
+
this.writeBase = this.snapshot()
|
|
1948
1948
|
}
|
|
1949
1949
|
|
|
1950
1950
|
/** 在 $DSH_HOME 下创建/加载账本。 */
|
|
1951
1951
|
static open() {
|
|
1952
1952
|
const root = join(resolveDshHome(), 'storages', 'cost-meter')
|
|
1953
1953
|
const path = join(root, 'ledger.json')
|
|
1954
|
+
return withLedgerLock(path, () => Ledger.load(path))
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
static load(path) {
|
|
1954
1958
|
let config = defaultConfig()
|
|
1955
1959
|
let days = {}
|
|
1956
1960
|
let balanceRef = null
|
|
@@ -1958,6 +1962,7 @@ export class Ledger {
|
|
|
1958
1962
|
let planSamples = {}
|
|
1959
1963
|
let planHourBuckets = {}
|
|
1960
1964
|
let openrouterPriceHashes = {}
|
|
1965
|
+
let historyReset = null
|
|
1961
1966
|
try {
|
|
1962
1967
|
const parsed = JSON.parse(readFileSync(path, 'utf8'))
|
|
1963
1968
|
if (parsed !== null && typeof parsed === 'object') {
|
|
@@ -1966,6 +1971,7 @@ export class Ledger {
|
|
|
1966
1971
|
try { renameSync(path, `${path}.corrupt-${Date.now()}`) } catch {}
|
|
1967
1972
|
console.warn(`[dsh-cost-meter] 账本版本 ${String(parsed.version)} 不受支持,按空账本启动`)
|
|
1968
1973
|
} else {
|
|
1974
|
+
historyReset = parsed.historyReset ?? null
|
|
1969
1975
|
const cfg = typeof parsed.config === 'object' && parsed.config !== null ? parsed.config : {}
|
|
1970
1976
|
// 一次性迁移:先于清洗对原始配置执行,已应用过的(账本 migrations 标记)跳过。
|
|
1971
1977
|
migrations = Array.isArray(parsed.migrations) ? parsed.migrations.filter(m => typeof m === 'string') : []
|
|
@@ -2009,8 +2015,7 @@ export class Ledger {
|
|
|
2009
2015
|
console.warn(`[dsh-cost-meter] 账本读取失败,按空账本启动: ${String(error?.message ?? error)}`)
|
|
2010
2016
|
}
|
|
2011
2017
|
}
|
|
2012
|
-
const ledger = new Ledger(config, days, path, migrations, { planSamples, planHourBuckets, openrouterPriceHashes })
|
|
2013
|
-
ledger.balanceRef = balanceRef
|
|
2018
|
+
const ledger = new Ledger(config, days, path, migrations, { planSamples, planHourBuckets, openrouterPriceHashes, balanceRef, historyReset })
|
|
2014
2019
|
return ledger
|
|
2015
2020
|
}
|
|
2016
2021
|
|
|
@@ -2058,74 +2063,15 @@ export class Ledger {
|
|
|
2058
2063
|
cacheWrite: num(tokens?.cacheWrite),
|
|
2059
2064
|
reasoning: num(tokens?.reasoning),
|
|
2060
2065
|
}
|
|
2061
|
-
const
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
day.input += buckets.input
|
|
2068
|
-
day.output += buckets.output
|
|
2069
|
-
day.cacheRead += buckets.cacheRead
|
|
2070
|
-
day.cacheWrite += buckets.cacheWrite
|
|
2071
|
-
day.reasoning += buckets.reasoning
|
|
2072
|
-
day.calls += 1
|
|
2073
|
-
day.cost += cost
|
|
2074
|
-
day.apiCost = (day.apiCost ?? 0) + apiCost
|
|
2075
|
-
const providerKey = `${typeof provider === 'string' && provider.length > 0 ? provider : 'deepseek'}:${String(modelId ?? 'default')}`
|
|
2076
|
-
day.byProviderModel = day.byProviderModel ?? {}
|
|
2077
|
-
const dayProvider = day.byProviderModel[providerKey] ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, calls: 0, cost: 0, apiCost: 0 }
|
|
2078
|
-
day.byProviderModel[providerKey] = {
|
|
2079
|
-
input: dayProvider.input + buckets.input,
|
|
2080
|
-
output: dayProvider.output + buckets.output,
|
|
2081
|
-
cacheRead: dayProvider.cacheRead + buckets.cacheRead,
|
|
2082
|
-
cacheWrite: dayProvider.cacheWrite + buckets.cacheWrite,
|
|
2083
|
-
reasoning: dayProvider.reasoning + buckets.reasoning,
|
|
2084
|
-
calls: dayProvider.calls + 1,
|
|
2085
|
-
cost: dayProvider.cost + cost,
|
|
2086
|
-
apiCost: (dayProvider.apiCost ?? 0) + apiCost,
|
|
2087
|
-
}
|
|
2088
|
-
if (typeof sessionId === 'string' && sessionId.length > 0) {
|
|
2089
|
-
let sessions = Array.isArray(day.sessions) ? day.sessions : []
|
|
2090
|
-
let session = sessions.find(s => s.id === sessionId)
|
|
2091
|
-
if (session === undefined) {
|
|
2092
|
-
session = zeroSession(sessionId)
|
|
2093
|
-
session.at = atMs // 会话首次入账时刻(按会话排行「按时间排序」用)。
|
|
2094
|
-
sessions.push(session)
|
|
2095
|
-
if (sessions.length > MAX_SESSIONS_PER_DAY) sessions = sessions.slice(-MAX_SESSIONS_PER_DAY)
|
|
2096
|
-
day.sessions = sessions
|
|
2097
|
-
}
|
|
2098
|
-
session.input += buckets.input
|
|
2099
|
-
session.output += buckets.output
|
|
2100
|
-
session.cacheRead += buckets.cacheRead
|
|
2101
|
-
session.cacheWrite += buckets.cacheWrite
|
|
2102
|
-
session.reasoning += buckets.reasoning
|
|
2103
|
-
session.calls += 1
|
|
2104
|
-
session.cost += cost
|
|
2105
|
-
session.apiCost = (session.apiCost ?? 0) + apiCost
|
|
2106
|
-
session.byProviderModel = session.byProviderModel ?? {}
|
|
2107
|
-
const sessionProvider = session.byProviderModel[providerKey] ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0, calls: 0, cost: 0, apiCost: 0 }
|
|
2108
|
-
session.byProviderModel[providerKey] = {
|
|
2109
|
-
input: sessionProvider.input + buckets.input,
|
|
2110
|
-
output: sessionProvider.output + buckets.output,
|
|
2111
|
-
cacheRead: sessionProvider.cacheRead + buckets.cacheRead,
|
|
2112
|
-
cacheWrite: sessionProvider.cacheWrite + buckets.cacheWrite,
|
|
2113
|
-
reasoning: sessionProvider.reasoning + buckets.reasoning,
|
|
2114
|
-
calls: sessionProvider.calls + 1,
|
|
2115
|
-
cost: sessionProvider.cost + cost,
|
|
2116
|
-
apiCost: (sessionProvider.apiCost ?? 0) + apiCost,
|
|
2117
|
-
}
|
|
2118
|
-
}
|
|
2119
|
-
if (cls === 'plan') {
|
|
2120
|
-
// 路由第三方调用(空/'deepseek' 前缀命中第三方目录)与直连 Plan 同样入小时桶,
|
|
2121
|
-
// 与 aggregateUsageSince 整天段口径一致,否则今日段本地量漏算。
|
|
2122
|
-
const planId = planProviderIdOf(provider)
|
|
2123
|
-
?? (isRoutedThirdPartyCall(provider, modelId, this.config.prices) ? 'go' : null)
|
|
2124
|
-
if (planId !== null) {
|
|
2125
|
-
this.planHourBuckets = appendHourBucket(this.planHourBuckets, planId, atMs,
|
|
2126
|
-
buckets.input + buckets.output + buckets.cacheRead + buckets.cacheWrite + buckets.reasoning, cost)
|
|
2127
|
-
}
|
|
2066
|
+
const planId = cls === 'plan' ? (planProviderIdOf(provider)
|
|
2067
|
+
?? (isRoutedThirdPartyCall(provider, modelId, this.config.prices) ? 'go' : null)) : null
|
|
2068
|
+
const call = {
|
|
2069
|
+
date: localDayKey(atMs), sessionId, atMs, recordedAt: Date.now(), planId,
|
|
2070
|
+
providerKey: `${typeof provider === 'string' && provider.length > 0 ? provider : 'deepseek'}:${String(modelId ?? 'default')}`,
|
|
2071
|
+
values: { ...buckets, calls: 1, cost, apiCost },
|
|
2128
2072
|
}
|
|
2073
|
+
applyAccount(this, call)
|
|
2074
|
+
this.pendingAccounts.push(call)
|
|
2129
2075
|
this.prune()
|
|
2130
2076
|
this.scheduleWrite()
|
|
2131
2077
|
}
|
|
@@ -2146,28 +2092,54 @@ export class Ledger {
|
|
|
2146
2092
|
}, 2000)
|
|
2147
2093
|
}
|
|
2148
2094
|
|
|
2149
|
-
/**
|
|
2095
|
+
/** A detached, secret-free baseline for merging changes from other processes. */
|
|
2096
|
+
snapshot() {
|
|
2097
|
+
return structuredClone({
|
|
2098
|
+
version: LEDGER_VERSION, config: stripSecrets(this.config), days: this.days,
|
|
2099
|
+
balanceRef: this.balanceRef ?? null, migrations: this.migrations,
|
|
2100
|
+
planSamples: this.planSamples ?? {}, planHourBuckets: this.planHourBuckets ?? {},
|
|
2101
|
+
openrouterPriceHashes: this.openrouterPriceHashes ?? {}, historyReset: this.historyReset ?? null,
|
|
2102
|
+
})
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
resetHistory() {
|
|
2106
|
+
this.days = {}
|
|
2107
|
+
this.planSamples = {}
|
|
2108
|
+
this.planHourBuckets = {}
|
|
2109
|
+
this.balanceRef = null
|
|
2110
|
+
this.pendingAccounts = []
|
|
2111
|
+
this.historyReset = { id: randomUUID(), at: Date.now() }
|
|
2112
|
+
this.scheduleWrite()
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
/** Read, reconcile and atomically replace under one cross-process lock. */
|
|
2150
2116
|
flush() {
|
|
2151
2117
|
if (!this.pendingWrite || this.closed) return
|
|
2152
2118
|
try {
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2119
|
+
withLedgerLock(this.path, () => {
|
|
2120
|
+
const raw = readLedger(this.path)
|
|
2121
|
+
if (raw !== null) {
|
|
2122
|
+
for (const migration of CONFIG_MIGRATIONS) {
|
|
2123
|
+
if (!(raw.migrations ?? []).includes(migration.id)) migration.apply(raw.config ?? {})
|
|
2124
|
+
}
|
|
2125
|
+
}
|
|
2126
|
+
const disk = raw === null ? null : {
|
|
2127
|
+
...raw, config: stripSecrets(sanitizeConfig(raw.config)),
|
|
2128
|
+
days: sanitizeDays(raw.days ?? {}), balanceRef: raw.balanceRef ?? null,
|
|
2129
|
+
migrations: raw.migrations ?? [], planSamples: raw.planSamples ?? {},
|
|
2130
|
+
planHourBuckets: raw.planHourBuckets ?? convertRecentCallsToBuckets(raw.planRecentCalls ?? []),
|
|
2131
|
+
openrouterPriceHashes: raw.openrouterPriceHashes ?? {}, historyReset: raw.historyReset ?? null,
|
|
2132
|
+
}
|
|
2133
|
+
const merged = mergeLedger(this.writeBase, this.snapshot(), disk, this.pendingAccounts)
|
|
2134
|
+
// Keep runtime-only secret fallbacks in memory, never in the file or baseline.
|
|
2135
|
+
const config = sanitizeConfig(mergeRuntimeConfig(this.writeBase.config, this.config, merged.config))
|
|
2136
|
+
writeLedger(this.path, merged)
|
|
2137
|
+
this.config = config
|
|
2138
|
+
for (const key of ['days', 'balanceRef', 'migrations', 'planSamples', 'planHourBuckets', 'openrouterPriceHashes', 'historyReset']) this[key] = merged[key]
|
|
2139
|
+
this.writeBase = this.snapshot()
|
|
2140
|
+
this.pendingAccounts = []
|
|
2141
|
+
this.pendingWrite = false
|
|
2142
|
+
})
|
|
2171
2143
|
} catch (error) {
|
|
2172
2144
|
console.warn(`[dsh-cost-meter] 账本写入失败,稍后重试: ${String(error?.message ?? error)}`)
|
|
2173
2145
|
if (!this.closed) this.scheduleWrite()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-cost-meter",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.34",
|
|
4
4
|
"description": "DeepSeek Harness 会话费用统计插件:本会话成本、当日费用、历史记录与官方价格同步,支持多厂商多模型价格计费(内置 90+ 模型价格目录与自动匹配)、主流 Coding Plan 订阅额度查询与显示(11 家,含小米 MiMo Token Plan 控制台 Cookie 查询、SCNet Token Plan 本地 Credits 计量与火山方舟 Volcano Ark Coding Plan AK/SK 签名查询)、自定义 Provider 余额查询(可配任意 HTTP 端点)与余额进度条、峰谷计价时段显示与切换前弹窗/系统通知提醒(位置/提前量/类型可配),界面中英双语。Session cost tracking plugin for DeepSeek Harness: per-conversation cost, daily totals, history and official price sync, with multi-vendor model pricing (built-in 90+ model catalog with auto-matching), Coding Plan quota queries & display for 11 vendors (incl. Xiaomi MiMo Token Plan via console cookie, local credits metering for the SCNet Token Plan and Volcano Ark Coding Plan via AK/SK signature), plan/API dual-track billing with per-1% & full-window Token Plan usage estimates, custom provider balance lookup (configurable HTTP endpoint) and balance progress bar, plus peak/off-peak pricing display with pre-switch popup & system-notification alerts (position / lead time / type configurable), in a bilingual (Chinese/English) UI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|