@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/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@angelyeye/dsh-cost-tracker",
3
+ "version": "1.7.0",
4
+ "description": "DeepSeek Harness (DSH) plugin: LLM token usage & cost tracking with a settings dashboard, agent tools, HTTP API and persistent storage.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "files": [
8
+ "index.js",
9
+ "client.js",
10
+ "store.js",
11
+ "pricing.js",
12
+ "config.js",
13
+ "cordis.patch.yml",
14
+ "CHANGELOG.md"
15
+ ],
16
+ "exports": {
17
+ ".": "./index.js",
18
+ "./client": "./client.js",
19
+ "./package.json": "./package.json"
20
+ },
21
+ "scripts": {
22
+ "test": "node test/storage.test.js && node test/pricing.test.js && node test/config.test.js && node test/recompute.test.js"
23
+ },
24
+ "keywords": [
25
+ "dsh-plugin",
26
+ "dsh",
27
+ "deepseek-harness",
28
+ "cordis",
29
+ "llm",
30
+ "token-usage",
31
+ "cost-tracking"
32
+ ],
33
+ "license": "MIT",
34
+ "engines": {
35
+ "dsh": ">=0.1.0-rc.1"
36
+ },
37
+ "repository": {
38
+ "type": "git",
39
+ "url": "https://github.com/Angelyeye/dsh-cost-tracker.git"
40
+ },
41
+ "dsh": {
42
+ "bundle": {
43
+ "patch": "./cordis.patch.yml"
44
+ },
45
+ "client": {
46
+ "platform": "web",
47
+ "inject": [
48
+ "@deepseek-ai/dsh-client-runtime"
49
+ ]
50
+ }
51
+ }
52
+ }
package/pricing.js ADDED
@@ -0,0 +1,268 @@
1
+ // ============================================================
2
+ // DSH 花费统计插件 —— 定价与 Token 层(纯逻辑,可独立测试)
3
+ //
4
+ // 单价来源:DeepSeek 官方定价页
5
+ // https://api-docs.deepseek.com/zh-cn/quick_start/pricing
6
+ // - 高峰时段(北京时间周一至周五 9:00-12:00、14:00-18:00)为表内价格;
7
+ // 空闲时段 = 高峰 × 0.5。周末(周六/周日)全天计入空闲时段(该窗口两版价通用)。
8
+ // - **单价随时间调整**:故单价表按生效时刻分版(见 PRICE_ERAS),按记录时间戳选版。
9
+ // · legacy :V4-Flash 3.0/9.0/0.10、V4-Pro 9.0/27.0/0.30
10
+ // · v41 :V4.1 Flash 2.0/8.0/0.04(北京时间 2026-09-10 12:00 起生效),
11
+ // V4-Pro 与旧 V4-Flash 系(含 vision-exp)的请求全部路由到
12
+ // V4.1 Flash 并按 V4.1 Flash 单价计费(V4.1 Pro 上线前)。
13
+ // - deepseek-v4-flash-vision-exp 与 deepseek-v4-flash 在 legacy 时代单价一致。
14
+ //
15
+ // 视觉模型 Token 规则:
16
+ // https://api-docs.deepseek.com/zh-cn/guides/vision#token-usage
17
+ // 图片进入模型前按尺寸自动缩放(<384×384 放大、更大缩小至约 800×800),
18
+ // 每张图片换算 token 数存在上限(384 个),与文本 token 一并计费——
19
+ // 即包含在接口返回的 prompt_tokens 中,由 DSH 映射为 inputTokens,
20
+ // 插件按接口用量记账(接口用量为准,估算可能有误差)。
21
+ // ============================================================
22
+
23
+ /**
24
+ * 计费时代(price era)——DeepSeek 单价是「随时间调整」的,因此单价表按
25
+ * 生效时刻(since,epoch ms)分版。计费与展示一律以「记录自身的时间戳」选版,
26
+ * 历史记录口径不会被新价改写。
27
+ *
28
+ * 字段:input=输入缓存未命中价 / output=输出价 / cacheRead=cacheWrite=缓存命中价
29
+ * (官方规则:缓存写入(cache write)与缓存命中(cache hit)同价)。
30
+ * 均为「高峰时段」价;空闲时段 = 高峰 × 0.5(两版价格的空闲档都恰好是半价)。
31
+ *
32
+ * routes:某个时代内把指定模型名的请求**改按另一档单价计费**,记录也以被路由
33
+ * 到的模型名入账,便于按模型聚合时看到真实计费口径。
34
+ */
35
+
36
+ /** V4.1 Flash 价格的生效时刻:北京时间 2026-09-10 12:00(UTC+8)= 2026-09-10T04:00:00Z */
37
+ export const V41_EFFECTIVE_AT = Date.UTC(2026, 8, 10, 4, 0, 0)
38
+
39
+ /** V4.1 Flash 的规范模型名(被路由的请求一律以此名入账) */
40
+ export const V41_FLASH_MODEL = 'deepseek-v4.1-flash'
41
+
42
+ export const PRICE_ERAS = [
43
+ {
44
+ id: 'legacy',
45
+ label: '2026-08 价(V4-Flash / V4-Pro 各自独立计价)',
46
+ since: 0,
47
+ models: {
48
+ 'deepseek-v4-flash': { input: 3.0, output: 9.0, cacheRead: 0.10, cacheWrite: 0.10 },
49
+ 'deepseek-v4-pro': { input: 9.0, output: 27.0, cacheRead: 0.30, cacheWrite: 0.30 },
50
+ 'deepseek-v4-flash-vision-exp': { input: 3.0, output: 9.0, cacheRead: 0.10, cacheWrite: 0.10 },
51
+ },
52
+ routes: {},
53
+ },
54
+ {
55
+ id: 'v41',
56
+ label: 'V4.1 Flash 价(V4-Pro 与旧 V4-Flash 系均路由至此)',
57
+ since: V41_EFFECTIVE_AT,
58
+ models: {
59
+ // 高峰价:输入(缓存命中)0.04 / 输入(缓存未命中)2 / 输出 8;空闲减半
60
+ [V41_FLASH_MODEL]: { input: 2.0, output: 8.0, cacheRead: 0.04, cacheWrite: 0.04 },
61
+ },
62
+ // V4.1 Pro 上线前,V4-Pro 的请求全部路由到 V4.1 Flash 并按 V4.1 Flash 单价计费;
63
+ // 旧 V4-Flash 系(含视觉版)已被 V4.1 Flash 取代,一并按新价计费。
64
+ routes: {
65
+ 'deepseek-v4-pro': V41_FLASH_MODEL,
66
+ 'deepseek-v4-flash': V41_FLASH_MODEL,
67
+ 'deepseek-v4-flash-vision-exp': V41_FLASH_MODEL,
68
+ },
69
+ },
70
+ ]
71
+
72
+ /** 旧价精确单价表(legacy 时代)。保留导出,兼容既有调用与历史口径。 */
73
+ export const EXACT_MODELS = PRICE_ERAS[0].models
74
+
75
+ /** 归一化模型名:小写并剔除分隔符,使 v4.1 / v4-1 / v41 等写法命中同一档价。 */
76
+ export function normalizeModelName(m) {
77
+ return String(m == null ? '' : m).toLowerCase().replace(/[^a-z0-9]/g, '')
78
+ }
79
+
80
+ const ERA_INDEX = new Map()
81
+ function eraIndex(era) {
82
+ let idx = ERA_INDEX.get(era)
83
+ if (!idx) {
84
+ idx = { models: new Map(), routes: new Map() }
85
+ for (const k of Object.keys(era.models)) idx.models.set(normalizeModelName(k), k)
86
+ for (const k of Object.keys(era.routes || {})) idx.routes.set(normalizeModelName(k), era.routes[k])
87
+ ERA_INDEX.set(era, idx)
88
+ }
89
+ return idx
90
+ }
91
+
92
+ /**
93
+ * 某模型在指定时代下命中的**计费模型规范名**:先查本时代单价表,
94
+ * 再查路由表(路由目标须在本时代单价表内);均未命中返回 null。
95
+ */
96
+ export function resolveModelInEra(era, model) {
97
+ if (!era) return null
98
+ const n = normalizeModelName(model)
99
+ if (!n) return null
100
+ const idx = eraIndex(era)
101
+ if (idx.models.has(n)) return idx.models.get(n)
102
+ const target = idx.routes.get(n)
103
+ if (target) {
104
+ const hit = idx.models.get(normalizeModelName(target))
105
+ if (hit) return hit
106
+ }
107
+ return null
108
+ }
109
+
110
+ /** 某一时刻生效的价格时代(缺省用当前时间)。 */
111
+ export function eraAt(ts) {
112
+ const t = Number.isFinite(ts) ? ts : Date.now()
113
+ let cur = PRICE_ERAS[0]
114
+ for (const e of PRICE_ERAS) if (t >= e.since) cur = e
115
+ return cur
116
+ }
117
+
118
+ /** 某一时刻生效的精确单价表(缺省用当前时间)。 */
119
+ export function exactModelsAt(ts) { return eraAt(ts).models }
120
+
121
+ /**
122
+ * 订阅套餐(等效费用估算,仅供参考)。
123
+ * DSH 的 kimi provider 上报名为 'kimi'(模型 kimi-k3 等),
124
+ * 'kimi-coding' 为别名形式(kimi-coding-official 归一化后形如 kimi-coding),
125
+ * 两个键都命中,避免订阅调用被误计为按量估算。
126
+ * 缓存写入按缓存命中价计(与官方规则一致)。
127
+ */
128
+ export const SUBSCRIPTION_RATES = {
129
+ 'kimi-coding': { input: 6.5, output: 27.0, cacheRead: 1.1, cacheWrite: 1.1 },
130
+ kimi: { input: 6.5, output: 27.0, cacheRead: 1.1, cacheWrite: 1.1 },
131
+ }
132
+
133
+ /** Provider 兜底单价(估算);缓存写入按缓存命中价计。
134
+ * deepseek 兜底已同步至 V4.1 Flash 档(2.0/8.0/0.04),未知模型不再按旧价高估。 */
135
+ export const PROVIDER_RATES = {
136
+ deepseek: { rates: { input: 2.0, output: 8.0, cacheRead: 0.04, cacheWrite: 0.04 }, tiered: true },
137
+ openai: { rates: { input: 10.0, output: 30.0, cacheRead: 5.0, cacheWrite: 5.0 }, tiered: false },
138
+ anthropic: { rates: { input: 15.0, output: 75.0, cacheRead: 1.5, cacheWrite: 1.5 }, tiered: false },
139
+ gemini: { rates: { input: 2.5, output: 10.0, cacheRead: 0.625, cacheWrite: 0.625 }, tiered: false },
140
+ ollama: { rates: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, tiered: false },
141
+ local: { rates: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, tiered: false },
142
+ }
143
+
144
+ /** 未知模型兜底单价(估算);缓存写入按缓存命中价计。 */
145
+ export const GENERIC_RATES = { input: 2.0, output: 8.0, cacheRead: 0.5, cacheWrite: 0.5 }
146
+
147
+ /** 高峰时段(北京时间,仅周一至周五),空闲时段 = 高峰 × 0.5;周末全天空闲 */
148
+ export const PEAK_WINDOWS = '周一至周五 9:00-12:00 · 14:00-18:00(周末全天闲时)'
149
+
150
+ /** 视觉模型名(DeepSeek DeepSeek-V4-Flash-Vision-Exp) */
151
+ export const VISION_MODEL = 'deepseek-v4-flash-vision-exp'
152
+
153
+ /** 每张图片换算 token 的上限(官方规则) */
154
+ export const VISION_IMAGE_MAX_TOKENS = 384
155
+
156
+ /** 是否高峰时段(北京时间 UTC+8,仅周一至周五;周末全天空闲) */
157
+ export function isPeak(ts) {
158
+ const d = new Date(ts + 28800000)
159
+ const day = d.getUTCDay() // 0=周日 … 6=周六
160
+ if (day === 0 || day === 6) return false // 周末不计高峰
161
+ const h = d.getUTCHours()
162
+ return (h >= 9 && h < 12) || (h >= 14 && h < 18)
163
+ }
164
+
165
+ /** 峰时段窗口(北京时间小时,半开区间 [start, end))。 */
166
+ export const PEAK_HOUR_WINDOWS = [{ start: 9, end: 12 }, { start: 14, end: 18 }]
167
+
168
+ /**
169
+ * 某一时刻所处的峰谷相位与相邻切换点(供时段条 / 倒计时 / 切换前弹窗)。
170
+ * 与 isPeak 同口径(北京时间 UTC+8),并处理「周末全天谷价」:
171
+ * - 工作日:按峰窗口判定 inPeak,扫描前后 N 天收集切换点;
172
+ * - 周末:inPeak=false 且 weekend=true,日内无切换点(价格恒为谷),
173
+ * 下一个价格切换点为下周一 09:00(首个峰窗口起点)。
174
+ * @param {number} ts - epoch ms
175
+ * @param {number} [spanDays=5] - 前后扫描天数(须覆盖最长周末间隔)
176
+ * @returns {{inPeak:boolean, weekend:boolean, prevAtMs:number, nextAtMs:number,
177
+ * nextIntoPeak:boolean}|null} prevAtMs/nextAtMs 为相邻切换点 epoch ms
178
+ */
179
+ export function peakPhaseAt(ts, spanDays) {
180
+ if (!Number.isFinite(ts)) return null
181
+ const SPAN = (Number.isFinite(spanDays) && spanDays >= 1) ? Math.floor(spanDays) : 5
182
+ const DAY_MS = 86400000
183
+ // 北京时间日 index 与星期(0=周日 … 6=周六;1970-01-01 为周四)。
184
+ const D0 = Math.floor((ts + 28800000) / DAY_MS)
185
+ const weekday = (D0 + 4) % 7
186
+ const isWeekendNow = weekday === 6 || weekday === 0
187
+ // 某北京时间日 D 的某时 h 对应的 epoch ms(北京 = UTC+8)。
188
+ const atBeijing = (D, h) => D * DAY_MS - 28800000 + h * 3600000
189
+ // 收集 ±SPAN 天内全部窗口边界切换点,剔除落在周末(无价格变化)的点。
190
+ const points = []
191
+ for (let off = -SPAN; off <= SPAN; off += 1) {
192
+ const D = D0 + off
193
+ const wd = (D + 4) % 7
194
+ if (wd === 6 || wd === 0) continue // 周末日内无切换
195
+ for (const w of PEAK_HOUR_WINDOWS) {
196
+ points.push({ at: atBeijing(D, w.start), intoPeak: true })
197
+ points.push({ at: atBeijing(D, w.end), intoPeak: false })
198
+ }
199
+ }
200
+ let prev = null
201
+ let next = null
202
+ for (const p of points) {
203
+ if (p.at <= ts && (prev === null || p.at > prev.at)) prev = p
204
+ if (p.at > ts && (next === null || p.at < next.at)) next = p
205
+ }
206
+ if (isWeekendNow) {
207
+ // 周末全谷价:当前谷,下一切换 = 下周一首个峰窗口起点;prev = 周六 00:00(北京)。
208
+ if (next === null) return null
209
+ return { inPeak: false, weekend: true, prevAtMs: (D0 - weekday) * DAY_MS - 57600000, nextAtMs: next.at, nextIntoPeak: next.intoPeak }
210
+ }
211
+ if (prev === null || next === null) return null
212
+ return { inPeak: isPeak(ts), weekend: false, prevAtMs: prev.at, nextAtMs: next.at, nextIntoPeak: next.intoPeak }
213
+ }
214
+
215
+ /**
216
+ * 解析一次调用的价格信息。
217
+ * @param {string} np - 归一化后的 provider 名(如 deepseek)
218
+ * @param {string} model - 模型名(如 deepseek-v4-flash-vision-exp)
219
+ * @param {number} [ts] - 调用发生时刻(epoch ms);决定用哪一版单价表。
220
+ * 缺省用当前时间——注意历史/测试场景应显式传入,否则跨价格时代会错。
221
+ * @returns {{rates:object, tiered:boolean, estimated:boolean, subscription:boolean,
222
+ * model:string, era:string|null}}
223
+ * model 为**计费模型规范名**:命中路由时是被路由到的模型(如 V4-Pro → V4.1 Flash),
224
+ * 记账应以它入账;未命中精确表时为原模型名。
225
+ */
226
+ export function priceFor(np, model, ts) {
227
+ if (SUBSCRIPTION_RATES[np]) return { rates: SUBSCRIPTION_RATES[np], tiered: false, estimated: true, subscription: true, model, era: null }
228
+ const era = eraAt(ts)
229
+ const hit = resolveModelInEra(era, model)
230
+ if (hit) return { rates: era.models[hit], tiered: true, estimated: false, subscription: false, model: hit, era: era.id }
231
+ const p = PROVIDER_RATES[np]
232
+ if (p) return { rates: p.rates, tiered: p.tiered, estimated: true, subscription: false, model, era: era.id }
233
+ return { rates: GENERIC_RATES, tiered: false, estimated: true, subscription: false, model, era: era.id }
234
+ }
235
+
236
+ /**
237
+ * 计算一次调用的费用(CNY)。
238
+ * @param {object} rates - {input, output, cacheRead, cacheWrite, reasoning?}(CNY / 1M tokens)
239
+ * cacheRead 与 cacheWrite 同价(均为缓存命中价),输入用未命中价,reasoning 缺省 0。
240
+ * @param {boolean} tiered - 是否峰谷计价(false 时按表内价)
241
+ * @param {boolean} peak - 是否高峰时段(tiered 且非高峰时 ×0.5)
242
+ * @param {{input:number, output:number, cacheRead:number, cacheWrite:number, reasoning?:number}} t - token 用量
243
+ */
244
+ export function computeCost(rates, tiered, peak, t) {
245
+ const f = tiered && !peak ? 0.5 : 1
246
+ const reasoning = toNum(t.reasoning) * (toNum(rates.reasoning))
247
+ return (t.input * rates.input + t.output * rates.output + (t.cacheRead + t.cacheWrite) * rates.cacheRead + reasoning) * f / 1000000
248
+ }
249
+
250
+ function toNum(x) { const n = Number(x); return isNaN(n) ? 0 : n }
251
+
252
+ /**
253
+ * 把 DSH usage 事件归一化为插件记账的 token 桶。
254
+ * - inputTokens:缓存未命中的输入(DSH 已从 prompt_tokens 中扣除缓存命中部分)
255
+ * - imageTokens:预留的视觉图片 token 字段(当前 DSH 未提供,图片 token 已在 inputTokens 内);
256
+ * 若未来出现独立字段则并入输入,避免漏计
257
+ * - reasoningTokens:推理 token(reasoning 模型按单独单价计费;缺省 0)
258
+ * @param {object} usage - DSH llm/stream usage 事件
259
+ */
260
+ export function normalizeTokens(usage) {
261
+ return {
262
+ input: toNum(usage.inputTokens) + toNum(usage.imageTokens),
263
+ output: toNum(usage.outputTokens),
264
+ cacheRead: toNum(usage.cacheReadTokens),
265
+ cacheWrite: toNum(usage.cacheWriteTokens),
266
+ reasoning: toNum(usage.reasoningTokens),
267
+ }
268
+ }
package/store.js ADDED
@@ -0,0 +1,248 @@
1
+ // ============================================================
2
+ // DSH 花费统计插件 —— 存储层(纯 Node,可独立测试)
3
+ //
4
+ // 数据保留策略(v1.1.2 起):
5
+ // 明细记录(details)保留最近 DETAIL_DAYS 天;更早的记录自动按
6
+ // 「天 + 模型」压缩为永久日汇总(rollups)。全时段统计 = 明细 + 汇总,
7
+ // 永远精确,且内存 / 磁盘 / 写入量有界。
8
+ //
9
+ // 文件格式:
10
+ // v2: { v: 2, details: [...], rollups: { dayKey: { modelKey: entry } } }
11
+ // v1(旧版裸数组)在 load() 时自动迁移;损坏文件自动备份为
12
+ // .corrupt-<时间戳> 并从零开始,不阻断启动。
13
+ // ============================================================
14
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
15
+ import { dirname } from 'node:path'
16
+
17
+ /** 明细保留天数(更早的记录折叠进日汇总) */
18
+ export const DETAIL_DAYS = 180
19
+ /** 图表按天轴最大跨度(天) */
20
+ export const MAX_AXIS_DAYS = 730
21
+ /** 明细安全上限(纯兜底;180 天内几乎不可达) */
22
+ export const MAX_DETAILS = 200000
23
+ const DAY_MS = 86400000
24
+
25
+ function pad2(n) { return n < 10 ? '0' + n : '' + n }
26
+
27
+ /** 北京时间(UTC+8)的日期键 YYYY-MM-DD */
28
+ export function dayKey(ts) {
29
+ const d = new Date(ts + 28800000)
30
+ return d.getUTCFullYear() + '-' + pad2(d.getUTCMonth() + 1) + '-' + pad2(d.getUTCDate())
31
+ }
32
+
33
+ export function modelKeyOf(r) {
34
+ return r.provider + '/' + r.model
35
+ }
36
+
37
+ export function emptyEntry(provider, model, subscription, estimated) {
38
+ return {
39
+ provider, model,
40
+ subscription: !!subscription,
41
+ estimated: !!estimated,
42
+ calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0,
43
+ cost: 0, peak: 0, off: 0, flat: 0,
44
+ }
45
+ }
46
+
47
+ export function mergeIntoEntry(entry, r) {
48
+ entry.calls += 1
49
+ entry.input += r.tokens.input
50
+ entry.output += r.tokens.output
51
+ entry.cacheRead += r.tokens.cacheRead
52
+ entry.cacheWrite += r.tokens.cacheWrite
53
+ entry.cost += r.cost
54
+ if (r.period === 'peak') entry.peak += r.cost
55
+ else if (r.period === 'off-peak') entry.off += r.cost
56
+ else entry.flat += r.cost
57
+ return entry
58
+ }
59
+
60
+ /** 把一条记录折叠进 rollups(按 dayKey + modelKey) */
61
+ export function rollupRecord(rollups, r) {
62
+ const dk = dayKey(r.ts)
63
+ const mk = modelKeyOf(r)
64
+ const day = rollups[dk] || (rollups[dk] = {})
65
+ const entry = day[mk] || (day[mk] = emptyEntry(r.provider, r.model, r.subscription, r.estimated))
66
+ return mergeIntoEntry(entry, r)
67
+ }
68
+
69
+ /** 把 details 中早于保留窗口的记录折叠进 rollups,返回折叠条数(details 为按时间升序) */
70
+ export function applyRetention(details, rollups, now, detailDays = DETAIL_DAYS) {
71
+ const cutoff = now - detailDays * DAY_MS
72
+ let i = 0
73
+ while (i < details.length && details[i].ts < cutoff) {
74
+ rollupRecord(rollups, details[i])
75
+ i++
76
+ }
77
+ if (i > 0) details.splice(0, i)
78
+ return i
79
+ }
80
+
81
+ /**
82
+ * 全时段汇总(明细 + 日汇总)。口径与 buildSummary 一致:
83
+ * 按量计费与订阅(等效费用)分开统计。
84
+ * @returns {{realCost:number, realCalls:number, realTokens:number,
85
+ * subEquivalent:number, subCalls:number, subTokens:number,
86
+ * byModel:Map<string,{model:string, subscription:boolean, estimated:boolean,
87
+ * calls:number, tokens:number, cost:number}>}}
88
+ */
89
+ export function collectTotals(details, rollups) {
90
+ const t = { realCost: 0, realCalls: 0, realTokens: 0, subEquivalent: 0, subCalls: 0, subTokens: 0, byModel: new Map() }
91
+ const add = (provider, model, subscription, estimated, calls, tokens, cost) => {
92
+ const key = modelKeyOf({ provider, model })
93
+ let m = t.byModel.get(key)
94
+ if (!m) {
95
+ m = { model: key, subscription: !!subscription, estimated: !!estimated, calls: 0, tokens: 0, cost: 0 }
96
+ t.byModel.set(key, m)
97
+ }
98
+ m.calls += calls
99
+ m.tokens += tokens
100
+ m.cost += cost
101
+ if (subscription) { t.subCalls += calls; t.subEquivalent += cost; t.subTokens += tokens }
102
+ else { t.realCalls += calls; t.realCost += cost; t.realTokens += tokens }
103
+ }
104
+ for (const r of details) {
105
+ add(r.provider, r.model, r.subscription, r.estimated, 1, r.tokens.input + r.tokens.output + r.tokens.cacheRead + r.tokens.cacheWrite, r.cost)
106
+ }
107
+ for (const dk of Object.keys(rollups)) {
108
+ const day = rollups[dk]
109
+ for (const mk of Object.keys(day)) {
110
+ const e = day[mk]
111
+ add(e.provider, e.model, e.subscription, e.estimated, e.calls, e.input + e.output + e.cacheRead + e.cacheWrite, e.cost)
112
+ }
113
+ }
114
+ return t
115
+ }
116
+
117
+ /**
118
+ * 按天聚合(明细 + 日汇总):dayKey -> 当日分段
119
+ * @returns {Map<string,{peak:number, off:number, flat:number, cost:number, calls:number, tokens:number, subCost:number, subCalls:number}>}
120
+ */
121
+ export function collectByDay(details, rollups) {
122
+ const days = new Map()
123
+ const ensure = (dk) => {
124
+ let d = days.get(dk)
125
+ if (!d) {
126
+ d = { peak: 0, off: 0, flat: 0, cost: 0, calls: 0, tokens: 0, subCost: 0, subCalls: 0 }
127
+ days.set(dk, d)
128
+ }
129
+ return d
130
+ }
131
+ for (const r of details) {
132
+ const d = ensure(dayKey(r.ts))
133
+ const total = r.tokens.input + r.tokens.output + r.tokens.cacheRead + r.tokens.cacheWrite
134
+ d.tokens += total
135
+ if (r.subscription) { d.subCost += r.cost; d.subCalls += 1 }
136
+ else {
137
+ d.calls += 1
138
+ d.cost += r.cost
139
+ if (r.period === 'peak') d.peak += r.cost
140
+ else if (r.period === 'off-peak') d.off += r.cost
141
+ else d.flat += r.cost
142
+ }
143
+ }
144
+ for (const dk of Object.keys(rollups)) {
145
+ const d = ensure(dk)
146
+ for (const mk of Object.keys(rollups[dk])) {
147
+ const e = rollups[dk][mk]
148
+ const total = e.input + e.output + e.cacheRead + e.cacheWrite
149
+ d.tokens += total
150
+ if (e.subscription) { d.subCost += e.cost; d.subCalls += e.calls }
151
+ else {
152
+ d.calls += e.calls
153
+ d.cost += e.cost
154
+ d.peak += e.peak
155
+ d.off += e.off
156
+ d.flat += e.flat
157
+ }
158
+ }
159
+ }
160
+ return days
161
+ }
162
+
163
+ /**
164
+ * 创建存储。details/rollups 为原地可变引用。
165
+ * @param {string} filePath - 数据文件路径
166
+ * @param {{detailDays?:number, maxDetails?:number}} [opts] - 测试可覆盖默认值
167
+ */
168
+ export function createStore(filePath, opts) {
169
+ const detailDays = (opts && opts.detailDays) || DETAIL_DAYS
170
+ const maxDetails = (opts && opts.maxDetails) || MAX_DETAILS
171
+ const details = []
172
+ const rollups = {}
173
+
174
+ function load() {
175
+ try {
176
+ if (!existsSync(filePath)) return
177
+ const parsed = JSON.parse(readFileSync(filePath, 'utf8'))
178
+ if (Array.isArray(parsed)) {
179
+ // v1:旧版裸数组,逐条迁移
180
+ for (const r of parsed) if (isRecord(r)) details.push(r)
181
+ } else if (parsed && typeof parsed === 'object') {
182
+ if (Array.isArray(parsed.details)) for (const r of parsed.details) if (isRecord(r)) details.push(r)
183
+ if (parsed.rollups && typeof parsed.rollups === 'object') {
184
+ for (const dk of Object.keys(parsed.rollups)) {
185
+ const day = parsed.rollups[dk]
186
+ if (!day || typeof day !== 'object') continue
187
+ for (const mk of Object.keys(day)) {
188
+ const e = day[mk]
189
+ if (!e || typeof e !== 'object' || typeof e.calls !== 'number') continue
190
+ rollups[dk] = rollups[dk] || {}
191
+ rollups[dk][mk] = e
192
+ }
193
+ }
194
+ }
195
+ }
196
+ applyRetention(details, rollups, Date.now(), detailDays)
197
+ trimDetails()
198
+ return details.length
199
+ } catch (e) {
200
+ // 损坏时改名备份,从零开始,不阻断启动
201
+ try { renameSync(filePath, filePath + '.corrupt-' + Date.now()) } catch (e2) {}
202
+ console.error('cost tracker load failed, starting empty', e)
203
+ return 0
204
+ }
205
+ }
206
+
207
+ function isRecord(r) {
208
+ return !!r && typeof r === 'object' && typeof r.ts === 'number' && r.tokens && typeof r.tokens.input === 'number'
209
+ }
210
+
211
+ function trimDetails() {
212
+ if (details.length > maxDetails) details.splice(0, details.length - maxDetails)
213
+ }
214
+
215
+ function add(r) {
216
+ details.push(r)
217
+ applyRetention(details, rollups, Date.now(), detailDays)
218
+ trimDetails()
219
+ }
220
+
221
+ function persist() {
222
+ try {
223
+ mkdirSync(dirname(filePath), { recursive: true })
224
+ const tmp = filePath + '.tmp'
225
+ writeFileSync(tmp, JSON.stringify({ v: 2, details, rollups }), 'utf8')
226
+ renameSync(tmp, filePath)
227
+ return true
228
+ } catch (e) {
229
+ console.error('cost tracker persist failed', e)
230
+ return false
231
+ }
232
+ }
233
+
234
+ function clear() {
235
+ details.length = 0
236
+ for (const dk of Object.keys(rollups)) delete rollups[dk]
237
+ }
238
+
239
+ function counts() {
240
+ let calls = details.length
241
+ for (const dk of Object.keys(rollups)) {
242
+ for (const mk of Object.keys(rollups[dk])) calls += rollups[dk][mk].calls
243
+ }
244
+ return { details: details.length, calls }
245
+ }
246
+
247
+ return { details, rollups, load, add, persist, clear, counts }
248
+ }