@mzzsfy/dsh-usage-stats 0.2.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/src/ledger.mjs ADDED
@@ -0,0 +1,217 @@
1
+ // 用量统计纯账本模块:调用记录聚合到日/会话,汇总与修剪;模型计价表。
2
+ // 只做数据变换,无 IO;host 半区直接 import 本模块。
3
+
4
+ const LEDGER_VERSION = 2
5
+ const TOKENS_PER_UNIT = 1000000
6
+
7
+ function dayKeyOf(ts) {
8
+ const d = new Date(ts)
9
+ const pad = (n) => (n < 10 ? '0' + n : String(n))
10
+ return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
11
+ }
12
+
13
+ function createLedger() {
14
+ return { version: LEDGER_VERSION, days: {}, sessions: {} }
15
+ }
16
+
17
+ // v1 旧账本无顶层 sessions 索引:载入时视为空并按新结构增量写入,不回填历史;结构升级后版本号置位。
18
+ function ensureSessionsIndex(ledger) {
19
+ if (!ledger.sessions || typeof ledger.sessions !== 'object') ledger.sessions = {}
20
+ ledger.version = LEDGER_VERSION
21
+ return ledger
22
+ }
23
+
24
+ function ensureBucket(days, key) {
25
+ if (!days[key]) {
26
+ days[key] = { inputTokens: 0, cacheReadTokens: 0, outputTokens: 0, calls: 0, cost: 0, sessions: {} }
27
+ }
28
+ return days[key]
29
+ }
30
+
31
+ function ensureSession(day, sessionId) {
32
+ const id = sessionId || 'unknown'
33
+ if (!day.sessions[id]) {
34
+ day.sessions[id] = { inputTokens: 0, cacheReadTokens: 0, outputTokens: 0, calls: 0, cost: 0, models: {} }
35
+ }
36
+ return day.sessions[id]
37
+ }
38
+
39
+ function addTokens(target, call) {
40
+ if (Number.isFinite(call.inputTokens)) target.inputTokens += call.inputTokens
41
+ if (Number.isFinite(call.cacheReadTokens)) target.cacheReadTokens += call.cacheReadTokens
42
+ if (Number.isFinite(call.outputTokens)) target.outputTokens += call.outputTokens
43
+ }
44
+
45
+ // 记录一次模型调用;costUsd 为 null 时只计 token 不计金额。
46
+ function recordCall(ledger, call, costUsd) {
47
+ ensureSessionsIndex(ledger)
48
+ const day = ensureBucket(ledger.days, dayKeyOf(call.at))
49
+ const session = ensureSession(day, call.sessionId)
50
+ day.calls += 1
51
+ addTokens(day, call)
52
+ if (costUsd !== null && Number.isFinite(costUsd)) day.cost += costUsd
53
+ session.calls += 1
54
+ addTokens(session, call)
55
+ if (costUsd !== null && Number.isFinite(costUsd)) session.cost += costUsd
56
+ const modelName = call.model || 'unknown'
57
+ if (!session.models[modelName]) session.models[modelName] = { calls: 0, inputTokens: 0, cacheReadTokens: 0, outputTokens: 0 }
58
+ session.models[modelName].calls += 1
59
+ addTokens(session.models[modelName], call)
60
+ const id = call.sessionId || 'unknown'
61
+ // 顶层会话索引:跨日合并,与按日聚合并存
62
+ const at = Number.isFinite(call.at) ? call.at : 0
63
+ if (!ledger.sessions[id]) {
64
+ ledger.sessions[id] = { firstAt: at, lastAt: at, calls: 0, inputTokens: 0, cacheReadTokens: 0, outputTokens: 0, cost: 0 }
65
+ }
66
+ const indexed = ledger.sessions[id]
67
+ indexed.firstAt = Math.min(indexed.firstAt, at)
68
+ indexed.lastAt = Math.max(indexed.lastAt, at)
69
+ indexed.calls += 1
70
+ addTokens(indexed, call)
71
+ if (costUsd !== null && Number.isFinite(costUsd)) indexed.cost += costUsd
72
+ }
73
+
74
+ // 顶层索引读数;索引缺失(v1 旧数据)返回 null。
75
+ function sessionTotals(ledger, sessionId) {
76
+ const row = ledger.sessions ? ledger.sessions[sessionId] : undefined
77
+ return row || null
78
+ }
79
+
80
+ function addTotals(target, source) {
81
+ target.inputTokens += source.inputTokens
82
+ target.cacheReadTokens += source.cacheReadTokens
83
+ target.outputTokens += source.outputTokens
84
+ target.calls += source.calls
85
+ target.cost += source.cost
86
+ }
87
+
88
+ // todayKey 由调用方传入(宿主本地时区),保持纯函数可测。
89
+ function summarize(ledger, todayKey, recentCount) {
90
+ const empty = { inputTokens: 0, cacheReadTokens: 0, outputTokens: 0, calls: 0, cost: 0 }
91
+ const summary = {
92
+ today: { ...empty },
93
+ month: { ...empty },
94
+ total: { ...empty },
95
+ recentDays: [],
96
+ todaySessions: [],
97
+ }
98
+ const monthPrefix = todayKey.slice(0, 7)
99
+ const dayKeys = Object.keys(ledger.days).sort()
100
+ for (const key of dayKeys) {
101
+ const day = ledger.days[key]
102
+ addTotals(summary.total, day)
103
+ if (key === todayKey) {
104
+ addTotals(summary.today, day)
105
+ summary.todaySessions = Object.keys(day.sessions).map((sessionId) => ({
106
+ sessionId,
107
+ ...day.sessions[sessionId],
108
+ }))
109
+ }
110
+ if (key.indexOf(monthPrefix) === 0) addTotals(summary.month, day)
111
+ }
112
+ summary.recentDays = dayKeys.slice(-recentCount).reverse().map((key) => ({
113
+ date: key,
114
+ ...ledger.days[key],
115
+ }))
116
+ return summary
117
+ }
118
+
119
+ function pruneLedger(ledger, todayKey, keepDays) {
120
+ const keys = Object.keys(ledger.days).sort()
121
+ const cutoff = new Date(todayKey + 'T00:00:00')
122
+ cutoff.setDate(cutoff.getDate() - (keepDays - 1))
123
+ const cutoffKey = dayKeyOf(cutoff.getTime())
124
+ for (const key of keys) {
125
+ if (key < cutoffKey) delete ledger.days[key]
126
+ }
127
+ // 顶层会话索引按 lastAt 同步修剪,不无限增长
128
+ ensureSessionsIndex(ledger)
129
+ for (const id of Object.keys(ledger.sessions)) {
130
+ if (ledger.sessions[id].lastAt < cutoff.getTime()) delete ledger.sessions[id]
131
+ }
132
+ }
133
+
134
+ // 单价:USD / 百万 token;价格表缺失或全空返回 null(不估金额)。
135
+ function costOfCall(call, price) {
136
+ if (!price) return null
137
+ const hasAny = Number.isFinite(call.inputTokens) || Number.isFinite(call.outputTokens) || Number.isFinite(call.cacheReadTokens)
138
+ if (!hasAny) return null
139
+ let cost = 0
140
+ if (Number.isFinite(call.inputTokens) && Number.isFinite(price.input)) {
141
+ cost += (call.inputTokens / TOKENS_PER_UNIT) * price.input
142
+ }
143
+ if (Number.isFinite(call.outputTokens) && Number.isFinite(price.output)) {
144
+ cost += (call.outputTokens / TOKENS_PER_UNIT) * price.output
145
+ }
146
+ if (Number.isFinite(call.cacheReadTokens) && Number.isFinite(price.cacheRead)) {
147
+ cost += (call.cacheReadTokens / TOKENS_PER_UNIT) * price.cacheRead
148
+ }
149
+ return cost
150
+ }
151
+
152
+ // models.dev api.json -> [{key, input, output, cacheRead}];key 同时含 provider/model 与裸模型名。
153
+ function flattenPricing(raw) {
154
+ const table = []
155
+ if (!raw || typeof raw !== 'object') return table
156
+ for (const providerId of Object.keys(raw)) {
157
+ const provider = raw[providerId]
158
+ if (!provider || typeof provider !== 'object' || !provider.models) continue
159
+ for (const modelId of Object.keys(provider.models)) {
160
+ const model = provider.models[modelId]
161
+ const cost = model && typeof model === 'object' ? model.cost : null
162
+ if (!cost || typeof cost !== 'object') continue
163
+ if (!Number.isFinite(Number(cost.input)) && !Number.isFinite(Number(cost.output))) continue
164
+ const entry = {
165
+ keys: [providerId + '/' + modelId.toLowerCase(), modelId.toLowerCase()],
166
+ input: Number(cost.input),
167
+ output: Number(cost.output),
168
+ cacheRead: Number.isFinite(Number(cost.cache_read)) ? Number(cost.cache_read) : null,
169
+ }
170
+ table.push(entry)
171
+ }
172
+ }
173
+ return table
174
+ }
175
+
176
+ // 匹配顺序:provider/model 精确 -> 任意条目 key 精确 -> 模型名前缀(最长优先)。
177
+ function priceFor(table, provider, model) {
178
+ if (!model) return null
179
+ const modelLower = String(model).toLowerCase()
180
+ const full = (provider || '') + '/' + modelLower
181
+ for (const entry of table) {
182
+ if (entry.keys[0] === full) return entry
183
+ }
184
+ for (const entry of table) {
185
+ if (entry.keys.indexOf(modelLower) >= 0) return entry
186
+ }
187
+ let best = null
188
+ for (const entry of table) {
189
+ for (const key of entry.keys) {
190
+ const suffix = key.indexOf('/') >= 0 ? key.slice(key.indexOf('/') + 1) : key
191
+ if (modelLower.indexOf(suffix) === 0 && (best === null || suffix.length > best.suffixLen)) {
192
+ best = { entry, suffixLen: suffix.length }
193
+ }
194
+ }
195
+ }
196
+ return best ? best.entry : null
197
+ }
198
+
199
+ // models.dev 拉取失败时的兜底单价,USD / 百万 token(对齐 dsh-balance 内置表)。
200
+ const FALLBACK_PRICING = [
201
+ { keys: ['deepseek/deepseek-chat', 'deepseek-chat'], input: 0.14, output: 0.28, cacheRead: 0.0028 },
202
+ { keys: ['deepseek/deepseek-reasoner', 'deepseek-reasoner'], input: 0.14, output: 0.28, cacheRead: 0.0028 },
203
+ ]
204
+
205
+ export {
206
+ createLedger,
207
+ dayKeyOf,
208
+ recordCall,
209
+ summarize,
210
+ pruneLedger,
211
+ ensureSessionsIndex,
212
+ sessionTotals,
213
+ costOfCall,
214
+ flattenPricing,
215
+ priceFor,
216
+ FALLBACK_PRICING,
217
+ }
@@ -0,0 +1,95 @@
1
+ // 自定义单价与币种折算:纯数据变换,无 IO。
2
+ // 币种口径:原生币种计价后统一折 USD 入账本;展示层按当前汇率折 CNY。
3
+
4
+ const TOKENS_PER_UNIT = 1000000
5
+ const CURRENCY_CNY = 'CNY'
6
+ const CURRENCY_USD = 'USD'
7
+
8
+ import { FALLBACK_PRICING } from './ledger.mjs'
9
+
10
+ // 目录价未就绪(启动窗口内为 null/空)时同步回落内置兜底价,计费不静默丢失。
11
+ export function catalogOrFallback(table) {
12
+ return Array.isArray(table) && table.length > 0 ? table : FALLBACK_PRICING
13
+ }
14
+
15
+ const TIERS = {
16
+ custom: { full: 'custom-full', name: 'custom-name', prefix: 'custom-prefix' },
17
+ catalog: { full: 'catalog', name: 'catalog', prefix: 'catalog' },
18
+ }
19
+
20
+ // 设置条目 -> 匹配表条目;模型 id 含 / 视为 provider/model 精确键,否则同时给裸名键。
21
+ function normalizeCustomPrices(list) {
22
+ const table = []
23
+ if (!Array.isArray(list)) return table
24
+ for (const item of list) {
25
+ if (!item || typeof item.model !== 'string' || item.model.trim().length === 0) continue
26
+ const input = Number(item.input)
27
+ const output = Number(item.output)
28
+ if (!Number.isFinite(input) && !Number.isFinite(output)) continue
29
+ const model = item.model.trim().toLowerCase()
30
+ const cacheRead = Number.isFinite(Number(item.cacheRead)) ? Number(item.cacheRead) : null
31
+ const slashAt = model.indexOf('/')
32
+ table.push({
33
+ keys: slashAt >= 0 ? [model, model.slice(slashAt + 1)] : [model],
34
+ input: Number.isFinite(input) ? input : null,
35
+ output: Number.isFinite(output) ? output : null,
36
+ cacheRead,
37
+ currency: item.currency === CURRENCY_CNY ? CURRENCY_CNY : CURRENCY_USD,
38
+ })
39
+ }
40
+ return table
41
+ }
42
+
43
+ // provider/model 精确 -> 模型名精确 -> 最长前缀,按层返回 {entry, tier}。
44
+ function matchTable(table, full, modelLower, tiers) {
45
+ for (const entry of table) {
46
+ if (entry.keys[0] === full) return { entry, tier: tiers.full }
47
+ }
48
+ for (const entry of table) {
49
+ if (entry.keys.indexOf(modelLower) >= 0) return { entry, tier: tiers.name }
50
+ }
51
+ let best = null
52
+ for (const entry of table) {
53
+ for (const key of entry.keys) {
54
+ if (modelLower.indexOf(key) === 0 && (best === null || key.length > best.keyLen)) {
55
+ best = { entry, keyLen: key.length }
56
+ }
57
+ }
58
+ }
59
+ return best ? { entry: best.entry, tier: tiers.prefix } : null
60
+ }
61
+
62
+ // 匹配链:自定义 provider/model 精确 -> 自定义模型名精确 -> 自定义最长前缀 -> 目录价(同序)。
63
+ function matchPrice(customTable, catalogTable, provider, model) {
64
+ if (!model) return null
65
+ const modelLower = String(model).toLowerCase()
66
+ const full = (provider || '') + '/' + modelLower
67
+ return matchTable(customTable, full, modelLower, TIERS.custom) ||
68
+ matchTable(catalogTable, full, modelLower, TIERS.catalog)
69
+ }
70
+
71
+ // 原生币种金额;价格缺失或全空 token 返回 null(不估金额)。
72
+ function nativeCostOfCall(call, price) {
73
+ if (!price) return null
74
+ const hasAny = Number.isFinite(call.inputTokens) || Number.isFinite(call.outputTokens) || Number.isFinite(call.cacheReadTokens)
75
+ if (!hasAny) return null
76
+ let amount = 0
77
+ if (Number.isFinite(call.inputTokens) && Number.isFinite(price.input)) {
78
+ amount += (call.inputTokens / TOKENS_PER_UNIT) * price.input
79
+ }
80
+ if (Number.isFinite(call.outputTokens) && Number.isFinite(price.output)) {
81
+ amount += (call.outputTokens / TOKENS_PER_UNIT) * price.output
82
+ }
83
+ if (Number.isFinite(call.cacheReadTokens) && Number.isFinite(price.cacheRead)) {
84
+ amount += (call.cacheReadTokens / TOKENS_PER_UNIT) * price.cacheRead
85
+ }
86
+ return { amount, currency: price.currency || CURRENCY_USD }
87
+ }
88
+
89
+ // 折 USD 入账本:CNY 金额除以汇率(每 USD 兑 CNY)。
90
+ function toUsd(amount, currency, rateUsdToCny) {
91
+ if (currency === CURRENCY_CNY) return amount / rateUsdToCny
92
+ return amount
93
+ }
94
+
95
+ export { normalizeCustomPrices, matchPrice, nativeCostOfCall, toUsd, TIERS }
package/src/rates.mjs ADDED
@@ -0,0 +1,37 @@
1
+ // 汇率解析与新鲜度判定:纯函数,网络请求由 host 半区执行。
2
+
3
+ // 全源失败且无历史缓存时的兜底汇率(每 USD 兑 CNY)。
4
+ export const DEFAULT_USD_CNY = 7.2
5
+
6
+ // 刷新节奏:每 6 小时。
7
+ export const RATE_TTL_MS = 6 * 60 * 60 * 1000
8
+
9
+ // 超过刷新周期即视为非实时,沿用上次值并标注。
10
+ export function isRateStale(fetchedAt, now) {
11
+ return !(now - fetchedAt < RATE_TTL_MS)
12
+ }
13
+
14
+ // 腾讯财经行情文本:v_whUSDCNY="100~USDCNY~7.2531~..."。
15
+ export function parseTencentRate(text) {
16
+ if (typeof text !== 'string') return null
17
+ const match = text.match(/USDCNY[^0-9]+([0-9]+\.[0-9]+)/)
18
+ const rate = match ? Number(match[1]) : NaN
19
+ return Number.isFinite(rate) && rate > 0 ? rate : null
20
+ }
21
+
22
+ // open.er-api 响应:{ result: "success", rates: { CNY } }。
23
+ export function parseErApiRate(payload) {
24
+ const rate = payload && payload.rates ? Number(payload.rates.CNY) : NaN
25
+ return Number.isFinite(rate) && rate > 0 ? rate : null
26
+ }
27
+
28
+ // 拉取成功刷新并清除标注;失败沿用上次汇率并标注非实时;无任何历史时用兜底值。
29
+ export function resolveRate(previous, outcome, now) {
30
+ if (outcome && outcome.ok && Number.isFinite(outcome.rate) && outcome.rate > 0) {
31
+ return { rate: outcome.rate, fetchedAt: now, stale: false }
32
+ }
33
+ if (previous && Number.isFinite(previous.rate) && previous.rate > 0) {
34
+ return { rate: previous.rate, fetchedAt: previous.fetchedAt, stale: true }
35
+ }
36
+ return { rate: DEFAULT_USD_CNY, fetchedAt: 0, stale: true }
37
+ }
@@ -0,0 +1,118 @@
1
+ // 用量统计账本纯模块测试:计量聚合、汇总、修剪、计价(见 dynamic-plugins/usage-stats/README.md)
2
+ import { test } from 'node:test'
3
+ import assert from 'node:assert/strict'
4
+
5
+ import {
6
+ createLedger,
7
+ dayKeyOf,
8
+ recordCall,
9
+ pruneLedger,
10
+ summarize,
11
+ costOfCall,
12
+ flattenPricing,
13
+ priceFor,
14
+ FALLBACK_PRICING,
15
+ } from '../src/ledger.mjs'
16
+
17
+ const DAY_MS = 24 * 60 * 60 * 1000
18
+ const PER_MILLION = 1000000
19
+
20
+ function callAt(ts, overrides) {
21
+ return Object.assign({
22
+ at: ts,
23
+ sessionId: 'sess-1',
24
+ model: 'deepseek-chat',
25
+ provider: 'deepseek',
26
+ inputTokens: 1000,
27
+ cacheReadTokens: 500,
28
+ outputTokens: 200,
29
+ }, overrides)
30
+ }
31
+
32
+ test('dayKeyOf:本地时区 YYYY-MM-DD', () => {
33
+ const ts = new Date(2026, 1, 26, 15, 30).getTime()
34
+ assert.equal(dayKeyOf(ts), '2026-02-26')
35
+ })
36
+
37
+ test('recordCall:同会话同日累计,跨日/跨会话分桶', () => {
38
+ const day0 = new Date(2026, 1, 26, 10).getTime()
39
+ const ledger = createLedger()
40
+ recordCall(ledger, callAt(day0), 0.001)
41
+ recordCall(ledger, callAt(day0, { outputTokens: 300 }), 0.002)
42
+ recordCall(ledger, callAt(day0 + DAY_MS, { sessionId: 'sess-2' }), 0.003)
43
+
44
+ assert.equal(Object.keys(ledger.days).length, 2)
45
+ const day1 = ledger.days[dayKeyOf(day0)]
46
+ assert.equal(day1.calls, 2)
47
+ assert.equal(day1.inputTokens, 2000)
48
+ assert.equal(day1.outputTokens, 500)
49
+ assert.ok(Math.abs(day1.cost - 0.003) < 1e-9)
50
+ assert.equal(day1.sessions['sess-1'].calls, 2)
51
+ const day2 = ledger.days[dayKeyOf(day0 + DAY_MS)]
52
+ assert.equal(day2.sessions['sess-2'].calls, 1)
53
+ })
54
+
55
+ test('recordCall:无 usage 数据不计 token 但计调用', () => {
56
+ const ts = new Date(2026, 1, 26, 10).getTime()
57
+ const ledger = createLedger()
58
+ recordCall(ledger, callAt(ts, { inputTokens: null, cacheReadTokens: null, outputTokens: null }), null)
59
+ const day = ledger.days[dayKeyOf(ts)]
60
+ assert.equal(day.calls, 1)
61
+ assert.equal(day.inputTokens, 0)
62
+ assert.equal(day.cost, 0)
63
+ })
64
+
65
+ test('costOfCall:按百万 token 单价计价,缺字段容错', () => {
66
+ const price = { input: 0.28, output: 0.56, cacheRead: 0.028 }
67
+ const cost = costOfCall({ inputTokens: PER_MILLION, cacheReadTokens: 0, outputTokens: PER_MILLION }, price)
68
+ assert.ok(Math.abs(cost - (0.28 + 0.56)) < 1e-9)
69
+ assert.equal(costOfCall({ inputTokens: 100 }, null), null)
70
+ assert.equal(costOfCall({ inputTokens: null, outputTokens: null, cacheReadTokens: null }, price), null)
71
+ })
72
+
73
+ test('flattenPricing + priceFor:provider/model 精确 -> 裸名精确 -> 前缀回退', () => {
74
+ const raw = {
75
+ deepseek: {
76
+ models: {
77
+ 'deepseek-chat': { cost: { input: 0.14, output: 0.28, cache_read: 0.014 } },
78
+ 'deepseek/deepseek-v4-pro': { cost: { input: 1.74, output: 3.48, cache_read: 0.145 } },
79
+ },
80
+ },
81
+ }
82
+ const table = flattenPricing(raw)
83
+ assert.equal(table.length, 2)
84
+ assert.ok(priceFor(table, 'deepseek', 'deepseek-chat'))
85
+ assert.ok(priceFor(table, 'other', 'deepseek/deepseek-v4-pro'))
86
+ assert.ok(priceFor(table, 'openai', 'deepseek-chat'))
87
+ assert.equal(priceFor(table, 'openai', 'unknown-model'), null)
88
+ assert.equal(priceFor(FALLBACK_PRICING, 'deepseek', 'deepseek-chat') !== null, true)
89
+ })
90
+
91
+ test('summarize:今日/本月/累计与近 N 天列表', () => {
92
+ const base = new Date(2026, 1, 26, 10).getTime()
93
+ const ledger = createLedger()
94
+ recordCall(ledger, callAt(base), 0.01)
95
+ recordCall(ledger, callAt(base - DAY_MS, { sessionId: 's0' }), 0.02)
96
+ recordCall(ledger, callAt(base - 40 * DAY_MS), 0.04)
97
+
98
+ const summary = summarize(ledger, dayKeyOf(base), 14)
99
+ assert.ok(Math.abs(summary.today.cost - 0.01) < 1e-9)
100
+ assert.equal(summary.today.calls, 1)
101
+ assert.ok(Math.abs(summary.month.cost - 0.03) < 1e-9)
102
+ assert.ok(Math.abs(summary.total.cost - 0.07) < 1e-9)
103
+ assert.equal(summary.recentDays.length, 3)
104
+ assert.equal(summary.recentDays[0].date, dayKeyOf(base))
105
+ assert.equal(summary.recentDays[0].calls, 1)
106
+ assert.ok(Math.abs(summary.recentDays[0].cost - 0.01) < 1e-9)
107
+ assert.equal(summary.todaySessions.length, 1)
108
+ })
109
+
110
+ test('pruneLedger:仅保留最近 N 天', () => {
111
+ const base = new Date(2026, 1, 26, 10).getTime()
112
+ const ledger = createLedger()
113
+ for (let i = 0; i < 5; i += 1) {
114
+ recordCall(ledger, callAt(base - i * DAY_MS), 0.01)
115
+ }
116
+ pruneLedger(ledger, dayKeyOf(base), 3)
117
+ assert.equal(Object.keys(ledger.days).length, 3)
118
+ })