@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/README.md +59 -0
- package/cordis.patch.yml +8 -0
- package/package.json +43 -0
- package/src/client.js +583 -0
- package/src/csv.mjs +24 -0
- package/src/dashboard.mjs +145 -0
- package/src/feebar.mjs +41 -0
- package/src/index.js +506 -0
- package/src/ledger.mjs +217 -0
- package/src/pricing.mjs +95 -0
- package/src/rates.mjs +37 -0
- package/test/ledger.test.mjs +118 -0
- package/test/v2.test.mjs +278 -0
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
|
+
}
|
package/src/pricing.mjs
ADDED
|
@@ -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
|
+
})
|