dsh-key-usage 0.0.0-stage → 0.1.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/lib/routes.js ADDED
@@ -0,0 +1,351 @@
1
+ /**
2
+ * HTTP 路由:只读 GET 端点,向客户端提供聚合数据。
3
+ * - /dsh-key-usage/channels 通道清单(含状态与计费模式)
4
+ * - /dsh-key-usage/usage 范围聚合(from/to/provider 过滤;单日附带按小时)
5
+ * - /dsh-key-usage/heatmap 年度热力图序列
6
+ * - /dsh-key-usage/quota 余额与订阅窗口原样快照
7
+ * - /dsh-key-usage/summary 总览表用的「今日 / 近 30 天」按通道汇总
8
+ * - /dsh-key-usage/sessions 会话用量(来自宿主会话事件折叠)
9
+ * - /dsh-key-usage/projects 项目(工作目录)用量
10
+ *
11
+ * 台账已停更时(本机停在 2026-09-30),按天序列会用会话事件补齐缺的日期(见 merge.ts),
12
+ * 并在响应里用 `sessionDates` 标出来源。非本机回环请求一律 403。
13
+ *
14
+ * 网络:只有「余额与订阅窗口」会访问各通道**官方**接口(见 live-quota.ts),
15
+ * 失败即回落本地快照;其余端点零外网。
16
+ */
17
+ import { readFileSync } from 'node:fs';
18
+ import { homedir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import { aggregateRange, buildPriceTable, estimateCost, readLedger, } from './ledger.js';
21
+ import { classifyChannels, costModeLabel, extractChannels, quotaWindowLabel, readProviderSnapshots, } from './collect.js';
22
+ import { hoursOfDate, projectRowsOf, sessionRowsOf, } from './sessions.js';
23
+ import { mergeDailySeries, sessionDayRows, summarizeMergedByProvider, totalsOfMerged, } from './merge.js';
24
+ /** 无会话服务时的兜底数据源,保证台账相关端点照常工作。 */
25
+ export const unavailableSessionSource = {
26
+ collect: async () => ({
27
+ records: [],
28
+ coverage: { status: 'unavailable', totalSessions: 0, countedSessions: 0, skippedSessions: 0 },
29
+ }),
30
+ };
31
+ /** 关闭实时查询时的空实现(离线测试与降级用)。 */
32
+ export const disabledLiveQuota = {
33
+ cached: () => new Map(),
34
+ refreshIfStale: async () => new Map(),
35
+ };
36
+ const EMPTY_LEDGER = { version: 0, days: {} };
37
+ function sendJson(response, statusCode, body) {
38
+ const payload = JSON.stringify(body);
39
+ response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-cache' });
40
+ response.end(payload);
41
+ }
42
+ function isLoopback(request) {
43
+ const remoteAddress = request.socket.remoteAddress ?? '';
44
+ return remoteAddress === '127.0.0.1' || remoteAddress === '::1' || remoteAddress === '::ffff:127.0.0.1';
45
+ }
46
+ /** dsh 家目录:与宿主一致地优先 DSH_HOME。 */
47
+ export function dshHomePath() {
48
+ return process.env.DSH_HOME ?? join(homedir(), '.dsh');
49
+ }
50
+ function dayKeyOf(date) {
51
+ const month = String(date.getMonth() + 1).padStart(2, '0');
52
+ const day = String(date.getDate()).padStart(2, '0');
53
+ return `${date.getFullYear()}-${month}-${day}`;
54
+ }
55
+ function shiftedDayKey(offsetDays) {
56
+ return dayKeyOf(new Date(Date.now() + offsetDays * 86400000));
57
+ }
58
+ function channelPayload(channel) {
59
+ return {
60
+ ...channel,
61
+ costLabel: costModeLabel(channel.costMode),
62
+ quotaWindowLabel: channel.quotaWindowKey === null ? null : quotaWindowLabel(channel.quotaWindowKey),
63
+ };
64
+ }
65
+ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessionSource, liveQuota = disabledLiveQuota) {
66
+ const ledgerPath = join(dshHomePath(), 'dsh-usage', 'usage-ledger.json');
67
+ const snapshotPath = join(dshHomePath(), 'dsh-usage', 'provider-snapshots.json');
68
+ const loadLedger = () => readLedger(ledgerPath, (path) => {
69
+ try {
70
+ return readFileSync(path, 'utf8');
71
+ }
72
+ catch {
73
+ return null;
74
+ }
75
+ }) ?? EMPTY_LEDGER;
76
+ const readSnapshots = () => readProviderSnapshots(snapshotPath).providers;
77
+ const loadChannels = (live = new Map()) => {
78
+ // cordis.yml 是应用按需重写的组合产物:插件集变化期间会短暂变成空模板,
79
+ // 因此先读它,取不到再回退到用户 patch 层 cordis.patch.yml。
80
+ // 两个文件都含 !!js 表达式,只做文本提取,不执行任何表达式。
81
+ const profileDir = join(dshHomePath(), 'profiles', 'desktop');
82
+ const snapshots = mergeLiveSnapshots(readSnapshots(), live);
83
+ const composed = readTextFile(join(profileDir, 'cordis.yml'));
84
+ if (composed !== null) {
85
+ const channels = classifyChannels(extractChannels(composed), snapshots);
86
+ if (channels.length > 0)
87
+ return channels;
88
+ }
89
+ const patch = readTextFile(join(profileDir, 'cordis.patch.yml'));
90
+ return patch === null ? [] : classifyChannels(extractChannels(patch), snapshots);
91
+ };
92
+ webServer.register({
93
+ kind: 'exact',
94
+ path: '/dsh-key-usage/channels',
95
+ handler: async (request, response) => {
96
+ if (!requireLoopback(request, response))
97
+ return;
98
+ // 只用缓存:状态判定不该为了一次刷新去等网络,实时结果由 /quota 预热
99
+ sendJson(response, 200, { channels: loadChannels(liveQuota.cached()).map(channelPayload) });
100
+ },
101
+ });
102
+ webServer.register({
103
+ kind: 'exact',
104
+ path: '/dsh-key-usage/usage',
105
+ handler: async (request, response) => {
106
+ if (!requireLoopback(request, response))
107
+ return;
108
+ const url = new URL(request.url ?? '/', 'http://localhost');
109
+ const ledger = loadLedger();
110
+ const collection = await sessionSource.collect();
111
+ const knownProviders = loadChannels().map((channel) => channel.provider);
112
+ const dayKeys = Object.keys(ledger.days).sort();
113
+ const providerParam = url.searchParams.get('provider');
114
+ const providerFilter = providerParam !== null && providerParam !== '' ? providerParam.split(',').filter(Boolean) : null;
115
+ const sessionRows = sessionDayRows(collection.records, knownProviders)
116
+ .filter((row) => providerFilter === null || providerFilter.includes(row.provider));
117
+ const sessionDates = [...new Set(sessionRows.map((row) => row.date))].sort();
118
+ const availableFrom = minKey(dayKeys[0], sessionDates[0]);
119
+ const availableTo = maxKey(dayKeys[dayKeys.length - 1], sessionDates[sessionDates.length - 1]);
120
+ const from = url.searchParams.get('from') ?? availableFrom ?? '';
121
+ const to = url.searchParams.get('to') ?? availableTo ?? '';
122
+ const ledgerResult = aggregateRange(ledger, from, to, providerFilter);
123
+ const merged = mergeDailySeries(ledgerResult.days, sessionRows, from, to);
124
+ // 单日范围才提供小时细分(来自会话事件,口径与台账不同,见页脚口径说明)
125
+ const hours = from !== '' && from === to ? hoursOfDate(collection.records, from) : null;
126
+ sendJson(response, 200, {
127
+ days: merged.days,
128
+ totals: merged.days.length > 0 ? totalsOfMerged(merged.days) : null,
129
+ sessionDates: merged.sessionDates,
130
+ availableFrom: availableFrom ?? null,
131
+ availableTo: availableTo ?? null,
132
+ hours,
133
+ });
134
+ },
135
+ });
136
+ webServer.register({
137
+ kind: 'exact',
138
+ path: '/dsh-key-usage/heatmap',
139
+ handler: async (request, response) => {
140
+ if (!requireLoopback(request, response))
141
+ return;
142
+ const ledger = loadLedger();
143
+ const collection = await sessionSource.collect();
144
+ const knownProviders = loadChannels().map((channel) => channel.provider);
145
+ const sessionRows = sessionDayRows(collection.records, knownProviders);
146
+ const dayKeys = Object.keys(ledger.days).sort();
147
+ const sessionDates = [...new Set(sessionRows.map((row) => row.date))].sort();
148
+ const yearAgo = new Date(Date.now() - 364 * 86400000).toISOString().slice(0, 10);
149
+ const earliest = minKey(dayKeys[0], sessionDates[0]) ?? yearAgo;
150
+ const from = earliest > yearAgo ? earliest : yearAgo;
151
+ const to = maxKey(dayKeys[dayKeys.length - 1], sessionDates[sessionDates.length - 1]) ?? from;
152
+ const merged = mergeDailySeries(aggregateRange(ledger, from, to, null).days, sessionRows, from, to);
153
+ sendJson(response, 200, {
154
+ days: merged.days
155
+ .map((day) => ({
156
+ date: day.date,
157
+ totalTokens: day.inputTokens + day.outputTokens + day.cacheReadTokens,
158
+ models: day.models
159
+ .map((model) => ({
160
+ provider: model.provider,
161
+ model: model.model,
162
+ totalTokens: model.inputTokens + model.outputTokens + model.cacheReadTokens,
163
+ }))
164
+ .filter((model) => model.totalTokens > 0)
165
+ .sort((first, second) => second.totalTokens - first.totalTokens),
166
+ }))
167
+ .filter((day) => day.totalTokens > 0),
168
+ sessionDates: merged.sessionDates,
169
+ });
170
+ },
171
+ });
172
+ webServer.register({
173
+ kind: 'exact',
174
+ path: '/dsh-key-usage/quota',
175
+ handler: async (request, response) => {
176
+ if (!requireLoopback(request, response))
177
+ return;
178
+ const url = new URL(request.url ?? '/', 'http://localhost');
179
+ const channels = loadChannels();
180
+ // 打开「余额与配额」时才等网络:缓存过期就刷新一次,失败一律回落快照
181
+ const live = url.searchParams.get('fresh') === '1'
182
+ ? await liveQuota.refreshIfStale(channels, true)
183
+ : await liveQuota.refreshIfStale(channels);
184
+ const snapshots = readSnapshots();
185
+ const providers = channels.map((channel) => {
186
+ const liveEntry = live.get(channel.provider);
187
+ const snapshot = snapshots.find((entry) => entry.provider === channel.provider) ?? null;
188
+ if (liveEntry !== undefined && liveEntry.ok) {
189
+ return {
190
+ provider: channel.provider,
191
+ displayName: channel.displayName,
192
+ credential: channel.apiKeyEnv,
193
+ source: 'live',
194
+ fetchedAt: liveEntry.fetchedAt,
195
+ updatedAt: liveEntry.fetchedAt,
196
+ error: null,
197
+ balance: liveEntry.balance,
198
+ plan: liveEntry.windows === null ? null : { windows: liveEntry.windows },
199
+ };
200
+ }
201
+ return {
202
+ provider: channel.provider,
203
+ displayName: snapshot?.displayName ?? channel.displayName,
204
+ credential: snapshot?.credential ?? channel.apiKeyEnv,
205
+ source: 'snapshot',
206
+ fetchedAt: null,
207
+ updatedAt: snapshot?.updatedAt ?? null,
208
+ // 实时失败的原因要透出(未配置凭据 / 网络 / 鉴权),界面据此提示
209
+ error: liveEntry?.error ?? (snapshot === null ? 'no-snapshot' : null),
210
+ balance: snapshot?.balance ?? null,
211
+ plan: snapshot?.plan ?? null,
212
+ };
213
+ });
214
+ sendJson(response, 200, { providers });
215
+ },
216
+ });
217
+ webServer.register({
218
+ kind: 'exact',
219
+ path: '/dsh-key-usage/summary',
220
+ handler: async (request, response) => {
221
+ if (!requireLoopback(request, response))
222
+ return;
223
+ const today = dayKeyOf(new Date());
224
+ const monthFrom = shiftedDayKey(-29);
225
+ const ledger = loadLedger();
226
+ const collection = await sessionSource.collect();
227
+ const channels = loadChannels();
228
+ const knownProviders = channels.map((channel) => channel.provider);
229
+ const sessionRows = sessionDayRows(collection.records, knownProviders);
230
+ const merged = mergeDailySeries(aggregateRange(ledger, monthFrom, today, null).days, sessionRows, monthFrom, today);
231
+ const todaySummary = summarizeMergedByProvider(merged.days, today, today);
232
+ const monthSummary = summarizeMergedByProvider(merged.days, monthFrom, today);
233
+ const rows = channels.map((channel) => {
234
+ const todayRow = todaySummary.find((row) => row.provider === channel.provider);
235
+ const monthRow = monthSummary.find((row) => row.provider === channel.provider);
236
+ return {
237
+ provider: channel.provider,
238
+ today: todayRow ?? { provider: channel.provider, calls: 0, tokens: 0, cost: 0 },
239
+ month: monthRow ?? { provider: channel.provider, calls: 0, tokens: 0, cost: 0 },
240
+ };
241
+ });
242
+ sendJson(response, 200, {
243
+ channels: rows,
244
+ range: { today, monthFrom },
245
+ todaySource: merged.sessionDates.includes(today) ? 'session' : 'ledger',
246
+ sessionDates: merged.sessionDates,
247
+ });
248
+ },
249
+ });
250
+ webServer.register({
251
+ kind: 'exact',
252
+ path: '/dsh-key-usage/sessions',
253
+ handler: async (request, response) => {
254
+ if (!requireLoopback(request, response))
255
+ return;
256
+ const url = new URL(request.url ?? '/', 'http://localhost');
257
+ const limit = parseLimit(url.searchParams.get('limit'), 200);
258
+ const channels = loadChannels();
259
+ const knownProviders = channels.map((channel) => channel.provider);
260
+ const costModeOf = new Map(channels.map((channel) => [channel.provider, channel.costMode]));
261
+ const { records, coverage } = await sessionSource.collect();
262
+ const priceTable = buildPriceTable(loadLedger());
263
+ const rows = sessionRowsOf(records, knownProviders).slice(0, limit).map((row) => ({
264
+ ...row,
265
+ providerLabel: channels.find((channel) => channel.provider === row.provider)?.displayName ?? row.provider,
266
+ costMode: row.provider === null ? null : costModeOf.get(row.provider) ?? null,
267
+ cost: estimateCost(row.models, priceTable),
268
+ }));
269
+ sendJson(response, 200, { rows, coverage, priced: priceTable.size > 0 });
270
+ },
271
+ });
272
+ webServer.register({
273
+ kind: 'exact',
274
+ path: '/dsh-key-usage/projects',
275
+ handler: async (request, response) => {
276
+ if (!requireLoopback(request, response))
277
+ return;
278
+ const url = new URL(request.url ?? '/', 'http://localhost');
279
+ const limit = parseLimit(url.searchParams.get('limit'), 200);
280
+ const channels = loadChannels();
281
+ const knownProviders = channels.map((channel) => channel.provider);
282
+ const { records, coverage } = await sessionSource.collect();
283
+ const priceTable = buildPriceTable(loadLedger());
284
+ const rows = projectRowsOf(records, knownProviders).slice(0, limit).map((row) => ({
285
+ ...row,
286
+ dominantProvider: Object.entries(row.providers).sort((first, second) => second[1] - first[1])[0]?.[0] ?? null,
287
+ cost: estimateCost(row.models, priceTable),
288
+ }));
289
+ sendJson(response, 200, { rows, coverage, priced: priceTable.size > 0 });
290
+ },
291
+ });
292
+ }
293
+ /**
294
+ * 把实时余额 / 窗口合并进快照数组,供通道状态判定(额度百分比、余额文案)使用。
295
+ * 只覆盖查询成功的条目,失败条目保持快照原值。
296
+ */
297
+ function mergeLiveSnapshots(snapshots, live) {
298
+ if (live.size === 0)
299
+ return snapshots;
300
+ const byProvider = new Map(snapshots.map((entry) => [entry.provider, entry]));
301
+ for (const [provider, result] of live) {
302
+ if (!result.ok)
303
+ continue;
304
+ const existing = byProvider.get(provider);
305
+ byProvider.set(provider, {
306
+ provider,
307
+ displayName: existing?.displayName ?? null,
308
+ credential: existing?.credential ?? null,
309
+ updatedAt: result.fetchedAt,
310
+ balance: result.balance ?? existing?.balance ?? null,
311
+ plan: result.windows === null ? (existing?.plan ?? null) : { windows: result.windows },
312
+ });
313
+ }
314
+ return [...byProvider.values()];
315
+ }
316
+ function minKey(first, second) {
317
+ if (first === undefined)
318
+ return second;
319
+ if (second === undefined)
320
+ return first;
321
+ return first < second ? first : second;
322
+ }
323
+ function maxKey(first, second) {
324
+ if (first === undefined)
325
+ return second;
326
+ if (second === undefined)
327
+ return first;
328
+ return first > second ? first : second;
329
+ }
330
+ function parseLimit(raw, fallback) {
331
+ if (raw === null)
332
+ return fallback;
333
+ const parsed = Number.parseInt(raw, 10);
334
+ if (!Number.isFinite(parsed) || parsed <= 0)
335
+ return fallback;
336
+ return Math.min(parsed, 1000);
337
+ }
338
+ function requireLoopback(request, response) {
339
+ if (isLoopback(request))
340
+ return true;
341
+ sendJson(response, 403, { error: 'loopback only' });
342
+ return false;
343
+ }
344
+ function readTextFile(path) {
345
+ try {
346
+ return readFileSync(path, 'utf8');
347
+ }
348
+ catch {
349
+ return null;
350
+ }
351
+ }
@@ -0,0 +1,235 @@
1
+ /**
2
+ * 会话用量折叠:把宿主会话事件流折叠成「按天 / 按小时 / 按模型」的 token 桶,
3
+ * 供「项目用量」「会话用量」两个 Tab 与「使用明细」的单日按小时视图使用。
4
+ *
5
+ * 口径对齐宿主 token 投影(参考 dsh-token-meter / dsh-usage-stats,MIT):
6
+ * - 用量样本搭载在 `assistant/chunk`(`data.chunk.type === "usage"`)或
7
+ * `assistant/message`(`data.usage`);v2 结算还会把样本放进 `data.stream`;
8
+ * - 同一 (turn, step) 重复上报按「后到覆盖先到」处理,不重复计费;
9
+ * - 模型归属取 `data.message.source`,缺失时回落最近一次 `request/header` 的 config;
10
+ * - 判定不出模型的样本落到 `unknown/unknown`。
11
+ *
12
+ * 与参考实现的差异:这里不做跨批次增量状态,而是每次按整段事件重折(按 revision 缓存),
13
+ * 因此不需要「减掉上一次样本」的补偿逻辑,重复上报直接覆盖,天然一致。
14
+ */
15
+ export function zeroBuckets() {
16
+ return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
17
+ }
18
+ export function totalTokens(buckets) {
19
+ return buckets.inputTokens + buckets.outputTokens + buckets.cacheReadTokens + buckets.cacheWriteTokens;
20
+ }
21
+ export function addBuckets(target, source) {
22
+ target.inputTokens += source.inputTokens;
23
+ target.outputTokens += source.outputTokens;
24
+ target.cacheReadTokens += source.cacheReadTokens;
25
+ target.cacheWriteTokens += source.cacheWriteTokens;
26
+ return target;
27
+ }
28
+ /** 本地日历 `YYYY-MM-DD`。 */
29
+ export function dayKeyOf(timeMs) {
30
+ const date = new Date(timeMs);
31
+ const month = String(date.getMonth() + 1).padStart(2, '0');
32
+ const day = String(date.getDate()).padStart(2, '0');
33
+ return `${date.getFullYear()}-${month}-${day}`;
34
+ }
35
+ /** 本地小时(0–23)。 */
36
+ export function hourOfTime(timeMs) {
37
+ return new Date(timeMs).getHours();
38
+ }
39
+ function bucketsOf(usage) {
40
+ return {
41
+ inputTokens: usage?.inputTokens ?? 0,
42
+ outputTokens: usage?.outputTokens ?? 0,
43
+ cacheReadTokens: usage?.cacheReadTokens ?? 0,
44
+ cacheWriteTokens: usage?.cacheWriteTokens ?? 0,
45
+ };
46
+ }
47
+ function modelOfEvent(event) {
48
+ const source = event.data?.message?.source;
49
+ if (source !== undefined && typeof source.model === 'string' && source.model !== '') {
50
+ const provider = typeof source.provider === 'string' && source.provider !== '' ? source.provider : 'unknown';
51
+ return `${provider}/${source.model}`;
52
+ }
53
+ const config = event.data?.header?.config;
54
+ if (config !== undefined && typeof config.model === 'string' && config.model !== '') {
55
+ const provider = typeof config.provider === 'string' && config.provider !== '' ? config.provider : 'unknown';
56
+ return `${provider}/${config.model}`;
57
+ }
58
+ return null;
59
+ }
60
+ function sampleOfEvent(event) {
61
+ const turn = event.data?.turn;
62
+ const step = event.data?.step;
63
+ const key = Number.isInteger(turn) && Number.isInteger(step) ? `${turn}:${step}` : `seq:${event.seq}`;
64
+ if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'usage' && event.data.chunk.usage !== undefined) {
65
+ return { key, usage: event.data.chunk.usage };
66
+ }
67
+ if (event.type === 'assistant/message' && event.data?.usage !== undefined) {
68
+ return { key, usage: event.data.usage };
69
+ }
70
+ if ((event.type === 'assistant/message' || event.type === 'assistant/attempt') && Array.isArray(event.data?.stream)) {
71
+ const stream = event.data.stream;
72
+ for (let index = stream.length - 1; index >= 0; index -= 1) {
73
+ const record = stream[index];
74
+ if (record?.type === 'chunk' && record.chunk?.type === 'usage' && record.chunk.usage !== undefined) {
75
+ return { key, usage: record.chunk.usage };
76
+ }
77
+ }
78
+ }
79
+ return null;
80
+ }
81
+ /** 折叠一段事件(整段重折,重复上报覆盖旧值)。 */
82
+ export function foldSessionEvents(events) {
83
+ const samples = new Map();
84
+ // `llm/retry-started` 终结当前 (turn, step) 的覆盖槽:重试是新的上报,与原尝试累加而不是覆盖
85
+ const attemptCounts = new Map();
86
+ let currentModel = null;
87
+ let firstTime = null;
88
+ let lastTime = null;
89
+ for (const event of events) {
90
+ const time = typeof event.time === 'number' ? event.time : null;
91
+ if (time !== null) {
92
+ if (firstTime === null || time < firstTime)
93
+ firstTime = time;
94
+ if (lastTime === null || time > lastTime)
95
+ lastTime = time;
96
+ }
97
+ if (event.type === 'request/header') {
98
+ const model = modelOfEvent(event);
99
+ if (model !== null)
100
+ currentModel = model;
101
+ }
102
+ const turn = event.data?.turn;
103
+ const step = event.data?.step;
104
+ const baseKey = Number.isInteger(turn) && Number.isInteger(step) ? `${turn}:${step}` : `seq:${event.seq}`;
105
+ if (event.type === 'llm/retry-started' && Number.isInteger(turn) && Number.isInteger(step)) {
106
+ attemptCounts.set(baseKey, (attemptCounts.get(baseKey) ?? 0) + 1);
107
+ }
108
+ const sample = sampleOfEvent(event);
109
+ if (sample === null || time === null)
110
+ continue;
111
+ const attempt = attemptCounts.get(baseKey) ?? 0;
112
+ const sampleKey = `${baseKey}#${attempt}`;
113
+ const model = modelOfEvent(event) ?? currentModel ?? 'unknown/unknown';
114
+ samples.set(sampleKey, {
115
+ key: sampleKey,
116
+ day: dayKeyOf(time),
117
+ hour: hourOfTime(time),
118
+ model,
119
+ buckets: bucketsOf(sample.usage),
120
+ });
121
+ }
122
+ return { samples, firstTime, lastTime };
123
+ }
124
+ /**
125
+ * 缓存命中率(百分数,一位小数):`cacheRead / (input + output + cacheRead)`。
126
+ * 与台账表的既有口径保持一致(截图里 3.82B/3.96B = 96.2%),不含 cacheWrite。
127
+ */
128
+ export function cacheHitRateOf(buckets) {
129
+ const displayTokens = buckets.inputTokens + buckets.outputTokens + buckets.cacheReadTokens;
130
+ if (displayTokens <= 0)
131
+ return null;
132
+ return Math.round(buckets.cacheReadTokens / displayTokens * 1000) / 10;
133
+ }
134
+ function emptyHours() {
135
+ return Array.from({ length: 24 }, () => ({ calls: 0, buckets: zeroBuckets() }));
136
+ }
137
+ /** 把折叠结果汇总成可直接下发的会话用量(按天升序、天内按模型降序)。 */
138
+ export function summarizeSession(fold) {
139
+ const byDay = new Map();
140
+ const byModel = new Map();
141
+ const totals = zeroBuckets();
142
+ let calls = 0;
143
+ for (const sample of fold.samples.values()) {
144
+ let dayBucket = byDay.get(sample.day);
145
+ if (dayBucket === undefined) {
146
+ dayBucket = { calls: 0, buckets: zeroBuckets(), hours: emptyHours(), models: new Map() };
147
+ byDay.set(sample.day, dayBucket);
148
+ }
149
+ dayBucket.calls += 1;
150
+ addBuckets(dayBucket.buckets, sample.buckets);
151
+ dayBucket.hours[sample.hour].calls += 1;
152
+ addBuckets(dayBucket.hours[sample.hour].buckets, sample.buckets);
153
+ let dayModel = dayBucket.models.get(sample.model);
154
+ if (dayModel === undefined) {
155
+ dayModel = { key: sample.model, calls: 0, tokens: 0, buckets: zeroBuckets() };
156
+ dayBucket.models.set(sample.model, dayModel);
157
+ }
158
+ dayModel.calls += 1;
159
+ addBuckets(dayModel.buckets, sample.buckets);
160
+ dayModel.tokens = totalTokens(dayModel.buckets);
161
+ let modelRow = byModel.get(sample.model);
162
+ if (modelRow === undefined) {
163
+ modelRow = { key: sample.model, calls: 0, tokens: 0, buckets: zeroBuckets() };
164
+ byModel.set(sample.model, modelRow);
165
+ }
166
+ modelRow.calls += 1;
167
+ addBuckets(modelRow.buckets, sample.buckets);
168
+ modelRow.tokens = totalTokens(modelRow.buckets);
169
+ calls += 1;
170
+ addBuckets(totals, sample.buckets);
171
+ }
172
+ const days = [...byDay.entries()]
173
+ .map(([date, bucket]) => ({
174
+ date,
175
+ calls: bucket.calls,
176
+ tokens: totalTokens(bucket.buckets),
177
+ buckets: bucket.buckets,
178
+ hours: bucket.hours,
179
+ models: [...bucket.models.values()].sort((first, second) => second.tokens - first.tokens),
180
+ }))
181
+ .sort((first, second) => (first.date < second.date ? -1 : first.date > second.date ? 1 : 0));
182
+ const models = [...byModel.values()].sort((first, second) => second.tokens - first.tokens);
183
+ return {
184
+ calls,
185
+ tokens: totalTokens(totals),
186
+ buckets: totals,
187
+ cacheHitRate: cacheHitRateOf(totals),
188
+ firstTime: fold.firstTime,
189
+ lastTime: fold.lastTime,
190
+ primaryModelKey: models.length > 0 ? models[0].key : null,
191
+ days,
192
+ models,
193
+ };
194
+ }
195
+ /**
196
+ * 把事件里出现的 provider 归一到宿主配置里的通道 id。
197
+ * 事件里的 provider 可能是 pi-ai 路由(如 `pi-ai/opencode-go`)或适配器 id,
198
+ * 只要末段与已知通道同名就归到该通道,否则原样保留。
199
+ */
200
+ export function normalizeProvider(provider, knownProviders) {
201
+ if (knownProviders.includes(provider))
202
+ return provider;
203
+ const separator = provider.lastIndexOf('/');
204
+ const tail = separator >= 0 ? provider.slice(separator + 1) : provider;
205
+ const matched = knownProviders.find((candidate) => candidate === tail || candidate === provider);
206
+ return matched ?? provider;
207
+ }
208
+ /**
209
+ * 拆分 `provider/model` 归属键:优先按已知通道名在段边界上从右往左匹配,
210
+ * 匹配不到再退回第一个 `/`。事件里的 provider 可能是 `pi-ai/opencode-go` 这类路由。
211
+ */
212
+ export function attributeModelKey(key, knownProviders) {
213
+ let bestIndex = -1;
214
+ let bestProvider = '';
215
+ for (const provider of knownProviders) {
216
+ let searchFrom = 0;
217
+ for (;;) {
218
+ const index = key.indexOf(provider + '/', searchFrom);
219
+ if (index === -1)
220
+ break;
221
+ if (index > bestIndex) {
222
+ bestIndex = index;
223
+ bestProvider = provider;
224
+ }
225
+ searchFrom = index + 1;
226
+ }
227
+ }
228
+ if (bestIndex >= 0 && bestProvider !== '') {
229
+ return { provider: bestProvider, model: key.slice(bestIndex + bestProvider.length + 1) };
230
+ }
231
+ const separator = key.indexOf('/');
232
+ if (separator > 0)
233
+ return { provider: key.slice(0, separator), model: key.slice(separator + 1) };
234
+ return { provider: key, model: key };
235
+ }