@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.
@@ -0,0 +1,145 @@
1
+ // 仪表盘聚合:概览 / 趋势 / 热力图 / 明细的纯计算;输入为账本与当日键,输出点位数据。
2
+
3
+ import { DEFAULT_USD_CNY } from './rates.mjs'
4
+
5
+ const TREND_DAYS_SHORT = 7
6
+ const TREND_DAYS_LONG = 30
7
+ const WEEK_DAYS = 7
8
+
9
+ // 热力图费用分档上限(USD,CNY 展示前先按 USD 账本口径分档),空档为 0。
10
+ const HEAT_TIER_USD = [0.1, 1, 10]
11
+
12
+ function emptyTotals() {
13
+ return { inputTokens: 0, cacheReadTokens: 0, outputTokens: 0, calls: 0, cost: 0 }
14
+ }
15
+
16
+ function addTotals(target, source) {
17
+ target.inputTokens += source.inputTokens
18
+ target.cacheReadTokens += source.cacheReadTokens
19
+ target.outputTokens += source.outputTokens
20
+ target.calls += source.calls
21
+ target.cost += source.cost
22
+ }
23
+
24
+ function parseKey(key) {
25
+ const parts = key.split('-').map(Number)
26
+ return { year: parts[0], month: parts[1] - 1, day: parts[2] }
27
+ }
28
+
29
+ function keyOf(date) {
30
+ const pad = (n) => (n < 10 ? '0' + n : String(n))
31
+ return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate())
32
+ }
33
+
34
+ function shiftKey(key, offsetDays) {
35
+ const { year, month, day } = parseKey(key)
36
+ return keyOf(new Date(year, month, day + offsetDays))
37
+ }
38
+
39
+ function tierOf(cost) {
40
+ let tier = 0
41
+ for (const bound of HEAT_TIER_USD) {
42
+ if (cost > bound) tier += 1
43
+ }
44
+ return tier
45
+ }
46
+
47
+ // 概览:本月 Hero、按日均外推预计、今日与本周环比、token/调用 KPI。costCny 按当前汇率折算;rate 缺省用兜底汇率。
48
+ export function monthOverview(ledger, todayKey, rate) {
49
+ const rateValue = rate && Number.isFinite(rate.rate) ? rate.rate : DEFAULT_USD_CNY
50
+ const { year, month, day } = parseKey(todayKey)
51
+ const monthPrefix = todayKey.slice(0, 7)
52
+ const daysInMonth = new Date(year, month + 1, 0).getDate()
53
+ const monthTotals = emptyTotals()
54
+ const prevMonthTotals = emptyTotals()
55
+ const prevPrefix = keyOf(new Date(year, month - 1, 1)).slice(0, 7)
56
+ for (const key of Object.keys(ledger.days)) {
57
+ if (key.indexOf(monthPrefix) === 0) addTotals(monthTotals, ledger.days[key])
58
+ if (key.indexOf(prevPrefix) === 0) addTotals(prevMonthTotals, ledger.days[key])
59
+ }
60
+ const today = ledger.days[todayKey] || emptyTotals()
61
+ const yesterday = ledger.days[shiftKey(todayKey, -1)] || emptyTotals()
62
+ const dayRatio = (current, base) => (base.cost > 0 ? current.cost / base.cost : null)
63
+ let week = emptyTotals()
64
+ let prevWeek = emptyTotals()
65
+ for (let i = 0; i < WEEK_DAYS; i += 1) {
66
+ const key = shiftKey(todayKey, -i)
67
+ if (ledger.days[key]) addTotals(week, ledger.days[key])
68
+ const prevKey = shiftKey(todayKey, -i - WEEK_DAYS)
69
+ if (ledger.days[prevKey]) addTotals(prevWeek, ledger.days[prevKey])
70
+ }
71
+ return {
72
+ month: monthTotals,
73
+ monthCostCny: monthTotals.cost * rateValue,
74
+ projection: daysInMonth > 0 ? (monthTotals.cost / day) * daysInMonth : 0,
75
+ todayRatio: dayRatio(today, yesterday),
76
+ weekRatio: dayRatio(week, prevWeek),
77
+ prevMonthCostCny: prevMonthTotals.cost * rateValue,
78
+ }
79
+ }
80
+
81
+ // 趋势点位:固定日历窗口,无数据日期 cost 为 null 不伪造,tokens/calls 为 0。
82
+ export function dayTrend(ledger, todayKey, count) {
83
+ const points = []
84
+ for (let i = count - 1; i >= 0; i -= 1) {
85
+ const key = shiftKey(todayKey, -i)
86
+ const day = ledger.days[key]
87
+ points.push({
88
+ date: key,
89
+ cost: day ? day.cost : null,
90
+ tokens: day ? day.inputTokens + day.cacheReadTokens + day.outputTokens : 0,
91
+ calls: day ? day.calls : 0,
92
+ })
93
+ }
94
+ return points
95
+ }
96
+
97
+ // 月历热力图:当月逐日费用与分档,悬停展示费用与调用数。
98
+ export function monthHeatmap(ledger, todayKey) {
99
+ const { year, month } = parseKey(todayKey)
100
+ const daysInMonth = new Date(year, month + 1, 0).getDate()
101
+ const days = []
102
+ for (let d = 1; d <= daysInMonth; d += 1) {
103
+ const key = keyOf(new Date(year, month, d))
104
+ const day = ledger.days[key]
105
+ days.push({
106
+ date: key,
107
+ cost: day ? day.cost : null,
108
+ calls: day ? day.calls : 0,
109
+ tier: day ? tierOf(day.cost) : 0,
110
+ })
111
+ }
112
+ return { month: todayKey.slice(0, 7), days }
113
+ }
114
+
115
+ // 明细:顶层会话索引已跨日合并,按费用倒序;标题由调用方经 sessionQuery 动态补齐。
116
+ export function sessionsView(ledger, limit) {
117
+ const rows = Object.keys(ledger.sessions || {}).map((sessionId) => {
118
+ const row = ledger.sessions[sessionId]
119
+ return {
120
+ sessionId,
121
+ firstAt: row.firstAt,
122
+ lastAt: row.lastAt,
123
+ calls: row.calls,
124
+ inputTokens: row.inputTokens,
125
+ cacheReadTokens: row.cacheReadTokens,
126
+ outputTokens: row.outputTokens,
127
+ cost: row.cost,
128
+ }
129
+ })
130
+ rows.sort((a, b) => b.cost - a.cost)
131
+ return limit > 0 ? rows.slice(0, limit) : rows
132
+ }
133
+
134
+ // 一次取全:CNY 折算、双档趋势、当月热力图与明细。
135
+ export function buildDashboard(ledger, todayKey, rate) {
136
+ return {
137
+ todayKey,
138
+ rate,
139
+ overview: monthOverview(ledger, todayKey, rate),
140
+ trend7: dayTrend(ledger, todayKey, TREND_DAYS_SHORT),
141
+ trend30: dayTrend(ledger, todayKey, TREND_DAYS_LONG),
142
+ heatmap: monthHeatmap(ledger, todayKey),
143
+ sessions: sessionsView(ledger, 0),
144
+ }
145
+ }
package/src/feebar.mjs ADDED
@@ -0,0 +1,41 @@
1
+ // 自定义 JS 费用条:最小权限求值。函数源码经 new Function 编译(非 eval 全局),
2
+ // 仅传入结构化费用快照,要求返回字符串;异常或非字符串返回一律回退原生渲染。
3
+
4
+ // 试运行样例:设置区即时回显错误用。
5
+ export const FEE_BAR_SAMPLE = {
6
+ turnCost: 0.01,
7
+ sessionCost: 0.5,
8
+ sessionTokens: 12345,
9
+ recentDailyCosts: [0.1, 0.2, 0, 0.3, 0.15, 0.05, 0.5],
10
+ }
11
+
12
+ // 编译失败(语法错误或非函数表达式)返回 null。
13
+ export function compileFeeBar(source) {
14
+ if (typeof source !== 'string' || source.trim().length === 0) return null
15
+ try {
16
+ const factory = new Function('"use strict"; return (' + source + ');')
17
+ const fn = factory()
18
+ return typeof fn === 'function' ? fn : null
19
+ } catch {
20
+ return null
21
+ }
22
+ }
23
+
24
+ // 本轮费用差分:首样本无前值,turnCost 置 0(首跳无差分);负差分(清零等)钳为 0。
25
+ export function turnCostOf(currentCost, previousCost) {
26
+ if (previousCost === null) return 0
27
+ return Math.max(0, currentCost - previousCost)
28
+ }
29
+
30
+ // 渲染结果:{ fallback: false, text } 或 { fallback: true, error }。
31
+ export function renderFeeBar(source, data) {
32
+ const fn = compileFeeBar(source)
33
+ if (!fn) return { fallback: true, error: '费用条函数编译失败' }
34
+ try {
35
+ const text = fn(data)
36
+ if (typeof text !== 'string') return { fallback: true, error: '费用条函数返回非字符串' }
37
+ return { fallback: false, text }
38
+ } catch (error) {
39
+ return { fallback: true, error: error && error.message ? error.message : String(error) }
40
+ }
41
+ }
package/src/index.js ADDED
@@ -0,0 +1,506 @@
1
+ // 用量统计 Host 半区:监听 llm/stream 累计 token 与估算费用,账本持久化在
2
+ // ~/.dsh/dsh-usage-stats/ledger.json,webServer 路由供浏览器半区读取。
3
+
4
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
5
+ import { homedir } from 'node:os'
6
+ import { dirname, join } from 'node:path'
7
+ import z from '@deepseek-ai/schemastery'
8
+ import { settingsNamespace } from '@deepseek-ai/dsh-settings'
9
+ import {
10
+ createLedger,
11
+ dayKeyOf,
12
+ recordCall,
13
+ summarize,
14
+ pruneLedger,
15
+ ensureSessionsIndex,
16
+ sessionTotals,
17
+ FALLBACK_PRICING,
18
+ } from './ledger.mjs'
19
+ import { normalizeCustomPrices, matchPrice, nativeCostOfCall, toUsd, catalogOrFallback } from './pricing.mjs'
20
+ import { isRateStale, parseTencentRate, parseErApiRate, resolveRate, RATE_TTL_MS } from './rates.mjs'
21
+ import { buildDashboard, sessionsView } from './dashboard.mjs'
22
+ import { toCsv } from './csv.mjs'
23
+
24
+ const DATA_DIR = join(homedir(), '.dsh', 'dsh-usage-stats')
25
+ const DATA_FILE = join(DATA_DIR, 'ledger.json')
26
+ const RATES_FILE = join(DATA_DIR, 'rates.json')
27
+ const MODELS_DEV_URL = 'https://models.dev/api.json'
28
+ const TENCENT_RATE_URL = 'https://qt.gtimg.cn/q=whUSDCNY'
29
+ const ERAPI_RATE_URL = 'https://open.er-api.com/v6/latest/USD'
30
+ const PRICING_TTL_MS = 24 * 60 * 60 * 1000
31
+ const PRICING_TIMEOUT_MS = 30 * 1000
32
+ const RATE_TIMEOUT_MS = 5 * 1000
33
+ const PRICING_MAX_BYTES = 8 * 1024 * 1024
34
+ const LEDGER_KEEP_DAYS = 180
35
+ const RECENT_DAY_COUNT = 14
36
+ const MAX_SESSION_ROWS = 50
37
+ const BODY_MAX_BYTES = 64 * 1024
38
+ const CSV_CONTENT_TYPE = 'text/csv; charset=utf-8'
39
+ const EXPORT_KIND_DAYS = 'days'
40
+ const EXPORT_KIND_SESSIONS = 'sessions'
41
+ const EXPORT_KIND_JSON = 'json'
42
+
43
+ // models.dev 拉取失败时的兜底单价,USD / 百万 token。
44
+ const PRICING_SOURCE_LIVE = 'live'
45
+ const PRICING_SOURCE_FALLBACK = 'fallback'
46
+
47
+ // 设置命名空间:自定义单价表(配置属 settings 域,与账本文件分离)。
48
+ const SETTINGS_NAMESPACE = settingsNamespace('usage-stats')
49
+
50
+ // 自定义单价条目:model 支持精确与前缀匹配,currency 为 CNY/USD 原生币种。
51
+ const SETTINGS_SCHEMA = z.object({
52
+ customPrices: z.array(z.object({
53
+ model: z.string().description('模型 id,支持 provider/model 精确或模型名前缀'),
54
+ input: z.number().description('输入单价 / 百万 token'),
55
+ output: z.number().description('输出单价 / 百万 token'),
56
+ cacheRead: z.number().optional().description('缓存命中单价 / 百万 token'),
57
+ currency: z.union([z.literal('CNY'), z.literal('USD')]).default('USD').description('单价原生币种'),
58
+ })).default([]).description('自定义模型单价表,命中优先于目录价'),
59
+ })
60
+
61
+ // ---- HTTP 工具 ----
62
+ // 访问控制交给 DSH web 鉴权层(非本机 Host 的请求必须携带凭据);
63
+ // 本插件响应只含本地用量聚合,不做二次拦截。
64
+
65
+ function readBody(req) {
66
+ return new Promise((resolve, reject) => {
67
+ let size = 0
68
+ const chunks = []
69
+ req.on('data', (chunk) => {
70
+ size += chunk.length
71
+ if (size > BODY_MAX_BYTES) {
72
+ reject(new Error('请求体超过上限'))
73
+ req.destroy()
74
+ return
75
+ }
76
+ chunks.push(chunk)
77
+ })
78
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
79
+ req.on('error', reject)
80
+ })
81
+ }
82
+
83
+ function sendJson(res, status, payload) {
84
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
85
+ res.end(JSON.stringify(payload))
86
+ }
87
+
88
+ async function fetchPricingCatalog() {
89
+ const response = await fetch(MODELS_DEV_URL, { signal: AbortSignal.timeout(PRICING_TIMEOUT_MS) })
90
+ if (!response.ok) throw new Error('HTTP ' + response.status)
91
+ const text = await response.text()
92
+ if (text.length > PRICING_MAX_BYTES) throw new Error('价格目录超过上限')
93
+ return flattenPricing(JSON.parse(text))
94
+ }
95
+
96
+ /** @param {import('@deepseek-ai/cordis').Context} ctx */
97
+ export const inject = ['webServer']
98
+
99
+ export function apply(ctx) {
100
+ let ledger = null
101
+ let loadPromise = null
102
+ let writeChain = Promise.resolve()
103
+ let pricingTable = null
104
+ let pricingSource = PRICING_SOURCE_FALLBACK
105
+ let pricingPromise = null
106
+ let customTable = []
107
+ let rate = { rate: null, fetchedAt: 0, stale: true }
108
+ let ratePromise = null
109
+
110
+ customTable = readCustomSettings()
111
+ // 启动预热:汇率缓存先落定再触发刷新,避免未读缓存即发起网络请求产生覆盖竞态
112
+ loadRateCache().then(() => {
113
+ ensureRate()
114
+ })
115
+ ensurePricing()
116
+
117
+ function readCustomSettings() {
118
+ try {
119
+ const settings = ctx.get('settings')
120
+ if (settings === undefined) return []
121
+ const value = settings.get(SETTINGS_NAMESPACE)
122
+ const normalized = SETTINGS_SCHEMA(value || {})
123
+ return normalizeCustomPrices(normalized.customPrices)
124
+ } catch (error) {
125
+ console.error('usage-stats: 自定义单价配置无效,忽略', error)
126
+ return []
127
+ }
128
+ }
129
+
130
+ function ensureLedger() {
131
+ if (ledger !== null) return Promise.resolve(ledger)
132
+ if (!loadPromise) {
133
+ loadPromise = readFile(DATA_FILE, 'utf8')
134
+ .then((text) => {
135
+ const parsed = JSON.parse(text)
136
+ ledger = parsed && parsed.days && typeof parsed.days === 'object' ? parsed : createLedger()
137
+ // v1 旧账本无顶层会话索引:视为空,增量写入,不回填
138
+ ensureSessionsIndex(ledger)
139
+ return ledger
140
+ })
141
+ .catch(() => {
142
+ ledger = createLedger()
143
+ return ledger
144
+ })
145
+ }
146
+ return loadPromise
147
+ }
148
+
149
+ function persistLedger() {
150
+ writeChain = writeChain.then(async () => {
151
+ await mkdir(dirname(DATA_FILE), { recursive: true })
152
+ await writeFile(DATA_FILE, JSON.stringify(ledger, null, 2), 'utf8')
153
+ })
154
+ return writeChain
155
+ }
156
+
157
+ // 计价表懒加载:models.dev 24h 缓存,失败回落内置兜底表。
158
+ function ensurePricing() {
159
+ if (pricingTable !== null && pricingSource === PRICING_SOURCE_LIVE) return Promise.resolve(pricingTable)
160
+ if (!pricingPromise) {
161
+ pricingPromise = fetchPricingCatalog()
162
+ .then((table) => {
163
+ if (table.length > 0) {
164
+ pricingTable = table
165
+ pricingSource = PRICING_SOURCE_LIVE
166
+ return table
167
+ }
168
+ throw new Error('价格目录为空')
169
+ })
170
+ .catch((error) => {
171
+ console.error('usage-stats: models.dev 拉取失败,使用内置兜底价', error)
172
+ pricingTable = FALLBACK_PRICING
173
+ pricingSource = PRICING_SOURCE_FALLBACK
174
+ return pricingTable
175
+ })
176
+ }
177
+ return pricingPromise
178
+ }
179
+
180
+ // 汇率:腾讯财经 -> open.er-api -> 兜底;缓存落盘,重启沿用上次汇率。
181
+ function loadRateCache() {
182
+ return readFile(RATES_FILE, 'utf8')
183
+ .then((text) => {
184
+ const parsed = JSON.parse(text)
185
+ if (parsed && Number.isFinite(parsed.rate) && parsed.rate > 0) rate = parsed
186
+ })
187
+ .catch(() => {
188
+ // 无缓存或损坏:保持兜底口径,等待下次刷新
189
+ })
190
+ }
191
+
192
+ function persistRate() {
193
+ return mkdir(dirname(RATES_FILE), { recursive: true })
194
+ .then(() => writeFile(RATES_FILE, JSON.stringify(rate), 'utf8'))
195
+ .catch((error) => console.error('usage-stats: 汇率缓存写入失败', error))
196
+ }
197
+
198
+ async function fetchLiveRate() {
199
+ try {
200
+ const tencent = await fetch(TENCENT_RATE_URL, { signal: AbortSignal.timeout(RATE_TIMEOUT_MS) })
201
+ const tencentRate = parseTencentRate(await tencent.text())
202
+ if (tencentRate !== null) return { ok: true, rate: tencentRate }
203
+ } catch {
204
+ // 降级到 er-api
205
+ }
206
+ try {
207
+ const erapi = await fetch(ERAPI_RATE_URL, { signal: AbortSignal.timeout(RATE_TIMEOUT_MS) })
208
+ const erapiRate = parseErApiRate(await erapi.json())
209
+ if (erapiRate !== null) return { ok: true, rate: erapiRate }
210
+ } catch {
211
+ // 全源失败
212
+ }
213
+ return { ok: false }
214
+ }
215
+
216
+ function ensureRate() {
217
+ const now = Date.now()
218
+ if (rate.rate !== null && !isRateStale(rate.fetchedAt, now)) return Promise.resolve(rate)
219
+ if (!ratePromise) {
220
+ ratePromise = fetchLiveRate()
221
+ .then((outcome) => {
222
+ rate = resolveRate(rate.rate !== null ? rate : null, outcome, Date.now())
223
+ ratePromise = null
224
+ return persistRate().then(() => rate)
225
+ })
226
+ .catch((error) => {
227
+ console.error('usage-stats: 汇率刷新失败,沿用上次汇率', error)
228
+ ratePromise = null
229
+ return rate
230
+ })
231
+ }
232
+ return ratePromise
233
+ }
234
+
235
+ function recordAsync(options, usage) {
236
+ const call = {
237
+ at: Date.now(),
238
+ sessionId: options && options.sessionId ? options.sessionId : 'unknown',
239
+ model: options && options.model ? options.model : 'unknown',
240
+ provider: options && options.provider ? options.provider : '',
241
+ inputTokens: usage && Number.isFinite(usage.inputTokens) ? usage.inputTokens : null,
242
+ cacheReadTokens: usage && Number.isFinite(usage.cacheReadTokens) ? usage.cacheReadTokens : null,
243
+ outputTokens: usage && Number.isFinite(usage.outputTokens) ? usage.outputTokens : null,
244
+ }
245
+ return (async () => {
246
+ try {
247
+ await ensureLedger()
248
+ // 计费前等待计价表就绪;启动窗口内未就绪时同步回落兜底价,不静默丢费用
249
+ await ensurePricing()
250
+ const matched = matchPrice(customTable, catalogOrFallback(pricingTable), call.provider, call.model)
251
+ const native = nativeCostOfCall(call, matched ? matched.entry : null)
252
+ let cost = null
253
+ if (native !== null) {
254
+ const currentRate = await ensureRate()
255
+ cost = toUsd(native.amount, native.currency, currentRate.rate)
256
+ }
257
+ recordCall(ledger, call, cost)
258
+ pruneLedger(ledger, dayKeyOf(Date.now()), LEDGER_KEEP_DAYS)
259
+ await persistLedger()
260
+ } catch (error) {
261
+ console.error('usage-stats: 记录调用失败', error)
262
+ }
263
+ })()
264
+ }
265
+
266
+ ctx.on('llm/stream', (options, next) => {
267
+ const upstream = next()
268
+ let usage = null
269
+ const pump = (async function* () {
270
+ try {
271
+ for await (const chunk of upstream) {
272
+ if (chunk && chunk.type === 'usage' && chunk.usage) usage = chunk.usage
273
+ yield chunk
274
+ }
275
+ } finally {
276
+ recordAsync(options, usage)
277
+ }
278
+ })()
279
+ return pump
280
+ })
281
+
282
+ async function sessionTitle(sessionId) {
283
+ try {
284
+ const sessionQuery = ctx.get('sessionQuery')
285
+ if (sessionQuery === undefined) return null
286
+ const snapshot = await sessionQuery.readTitle(sessionId)
287
+ return snapshot && typeof snapshot.title === 'string' && snapshot.title.length > 0 ? snapshot.title : null
288
+ } catch {
289
+ return null
290
+ }
291
+ }
292
+
293
+ ctx.effect(
294
+ () =>
295
+ ctx.webServer.register({
296
+ kind: 'exact',
297
+ path: '/api/usage-stats/summary',
298
+ handler: async (req, res) => {
299
+ try {
300
+ if (req.method !== 'GET') {
301
+ sendJson(res, 405, { error: 'method not allowed' })
302
+ return
303
+ }
304
+ const current = await ensureLedger()
305
+ const todayKey = dayKeyOf(Date.now())
306
+ const summary = summarize(current, todayKey, RECENT_DAY_COUNT)
307
+ summary.recentDays = summary.recentDays.map((day) => ({
308
+ date: day.date,
309
+ calls: day.calls,
310
+ inputTokens: day.inputTokens,
311
+ cacheReadTokens: day.cacheReadTokens,
312
+ outputTokens: day.outputTokens,
313
+ cost: day.cost,
314
+ }))
315
+ const rows = summary.todaySessions.slice(0, MAX_SESSION_ROWS)
316
+ summary.todaySessions = await Promise.all(
317
+ rows.map(async (row) => ({ ...row, title: await sessionTitle(row.sessionId) })),
318
+ )
319
+ const currentRate = await ensureRate()
320
+ sendJson(res, 200, { summary, pricingSource, todayKey, rate: currentRate })
321
+ } catch (error) {
322
+ sendJson(res, 500, { error: error && error.message ? error.message : String(error) })
323
+ }
324
+ },
325
+ }),
326
+ 'usage-stats summary route',
327
+ )
328
+
329
+ ctx.effect(
330
+ () =>
331
+ ctx.webServer.register({
332
+ kind: 'exact',
333
+ path: '/api/usage-stats/session',
334
+ handler: async (req, res) => {
335
+ try {
336
+ if (req.method !== 'POST') {
337
+ sendJson(res, 405, { error: 'method not allowed' })
338
+ return
339
+ }
340
+ const body = JSON.parse(await readBody(req))
341
+ const sessionId = body && typeof body.sessionId === 'string' ? body.sessionId : ''
342
+ const current = await ensureLedger()
343
+ // v2:读顶层会话索引,会话累计跨日合并
344
+ const session = sessionTotals(current, sessionId)
345
+ sendJson(res, 200, {
346
+ calls: session ? session.calls : 0,
347
+ inputTokens: session ? session.inputTokens : 0,
348
+ cacheReadTokens: session ? session.cacheReadTokens : 0,
349
+ outputTokens: session ? session.outputTokens : 0,
350
+ cost: session ? session.cost : 0,
351
+ })
352
+ } catch (error) {
353
+ sendJson(res, 400, { error: error && error.message ? error.message : String(error) })
354
+ }
355
+ },
356
+ }),
357
+ 'usage-stats session route',
358
+ )
359
+
360
+ ctx.effect(
361
+ () =>
362
+ ctx.webServer.register({
363
+ kind: 'exact',
364
+ path: '/api/usage-stats/reset',
365
+ handler: async (req, res) => {
366
+ try {
367
+ if (req.method !== 'POST') {
368
+ sendJson(res, 405, { error: 'method not allowed' })
369
+ return
370
+ }
371
+ // 清零语义:days 与顶层会话索引一起清空,不可恢复;汇率缓存与导出不受影响
372
+ ledger = createLedger()
373
+ await persistLedger()
374
+ sendJson(res, 200, { ok: true })
375
+ } catch (error) {
376
+ sendJson(res, 500, { error: error && error.message ? error.message : String(error) })
377
+ }
378
+ },
379
+ }),
380
+ 'usage-stats reset route',
381
+ )
382
+
383
+ ctx.effect(
384
+ () =>
385
+ ctx.webServer.register({
386
+ kind: 'exact',
387
+ path: '/api/usage-stats/dashboard',
388
+ handler: async (req, res) => {
389
+ try {
390
+ if (req.method !== 'GET') {
391
+ sendJson(res, 405, { error: 'method not allowed' })
392
+ return
393
+ }
394
+ const current = await ensureLedger()
395
+ const todayKey = dayKeyOf(Date.now())
396
+ const currentRate = await ensureRate()
397
+ const dash = buildDashboard(current, todayKey, currentRate)
398
+ // 标题动态读取不入账本,缺失由前端以 ID 前缀兜底
399
+ const rows = dash.sessions.slice(0, MAX_SESSION_ROWS)
400
+ dash.sessions = await Promise.all(
401
+ rows.map(async (row) => ({ ...row, title: await sessionTitle(row.sessionId) })),
402
+ )
403
+ sendJson(res, 200, {
404
+ dashboard: dash,
405
+ pricingSource,
406
+ customTiers: customTable.length > 0 ? customTable.map((entry) => entry.keys[0]) : [],
407
+ totalSessions: sessionsView(current, 0).length,
408
+ })
409
+ } catch (error) {
410
+ sendJson(res, 500, { error: error && error.message ? error.message : String(error) })
411
+ }
412
+ },
413
+ }),
414
+ 'usage-stats dashboard route',
415
+ )
416
+
417
+ ctx.effect(
418
+ () =>
419
+ ctx.webServer.register({
420
+ kind: 'exact',
421
+ path: '/api/usage-stats/export',
422
+ handler: async (req, res) => {
423
+ try {
424
+ if (req.method !== 'GET') {
425
+ sendJson(res, 405, { error: 'method not allowed' })
426
+ return
427
+ }
428
+ const url = new URL(req.url, 'http://localhost')
429
+ const kind = url.searchParams.get('kind') || EXPORT_KIND_DAYS
430
+ const current = await ensureLedger()
431
+ if (kind === EXPORT_KIND_JSON) {
432
+ sendJson(res, 200, current)
433
+ return
434
+ }
435
+ const todayKey = dayKeyOf(Date.now())
436
+ let rows
437
+ let filename
438
+ if (kind === EXPORT_KIND_SESSIONS) {
439
+ filename = 'usage-stats-sessions.csv'
440
+ rows = [['sessionId', 'title', 'firstAt', 'lastAt', 'calls', 'inputTokens', 'cacheReadTokens', 'outputTokens', 'costUsd']]
441
+ for (const row of sessionsView(current, 0)) {
442
+ rows.push([
443
+ row.sessionId,
444
+ await sessionTitle(row.sessionId),
445
+ new Date(row.firstAt).toISOString(),
446
+ new Date(row.lastAt).toISOString(),
447
+ row.calls,
448
+ row.inputTokens,
449
+ row.cacheReadTokens,
450
+ row.outputTokens,
451
+ row.cost,
452
+ ])
453
+ }
454
+ } else {
455
+ filename = 'usage-stats-days.csv'
456
+ rows = [['date', 'calls', 'inputTokens', 'cacheReadTokens', 'outputTokens', 'costUsd']]
457
+ for (const key of Object.keys(current.days).sort()) {
458
+ const day = current.days[key]
459
+ rows.push([key, day.calls, day.inputTokens, day.cacheReadTokens, day.outputTokens, day.cost])
460
+ }
461
+ }
462
+ res.writeHead(200, {
463
+ 'content-type': CSV_CONTENT_TYPE,
464
+ 'content-disposition': 'attachment; filename="' + filename + '"',
465
+ })
466
+ res.end(toCsv(rows))
467
+ } catch (error) {
468
+ sendJson(res, 500, { error: error && error.message ? error.message : String(error) })
469
+ }
470
+ },
471
+ }),
472
+ 'usage-stats export route',
473
+ )
474
+
475
+ ctx.effect(
476
+ () =>
477
+ ctx.webServer.register({
478
+ kind: 'exact',
479
+ path: '/api/usage-stats/prices',
480
+ handler: async (req, res) => {
481
+ try {
482
+ if (req.method === 'GET') {
483
+ sendJson(res, 200, { customPrices: customTable })
484
+ return
485
+ }
486
+ if (req.method !== 'POST') {
487
+ sendJson(res, 405, { error: 'method not allowed' })
488
+ return
489
+ }
490
+ const body = JSON.parse(await readBody(req))
491
+ const normalized = normalizeCustomPrices(body && body.customPrices)
492
+ customTable = normalized
493
+ const settings = ctx.get('settings')
494
+ if (settings !== undefined) {
495
+ // 回写原始条目,保留字段语义;归一化表仅用于运行时匹配
496
+ await settings.update(SETTINGS_NAMESPACE, { customPrices: body && Array.isArray(body.customPrices) ? body.customPrices : [] })
497
+ }
498
+ sendJson(res, 200, { ok: true, customPrices: customTable })
499
+ } catch (error) {
500
+ sendJson(res, 400, { error: error && error.message ? error.message : String(error) })
501
+ }
502
+ },
503
+ }),
504
+ 'usage-stats prices route',
505
+ )
506
+ }