dsh-key-usage 0.1.2 → 0.1.4

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 CHANGED
@@ -7,10 +7,15 @@
7
7
  * - /dsh-key-usage/summary 总览表用的「今日 / 近 30 天」按通道汇总
8
8
  * - /dsh-key-usage/sessions 会话用量(来自宿主会话事件折叠)
9
9
  * - /dsh-key-usage/projects 项目(工作目录)用量
10
+ * - /dsh-key-usage/models 模型(provider + model)用量与估算成本
10
11
  *
11
12
  * 台账已停更时(本机停在 2026-09-30),按天序列会用会话事件补齐缺的日期(见 merge.ts),
12
13
  * 并在响应里用 `sessionDates` 标出来源。非本机回环请求一律 403。
13
14
  *
15
+ * 成本口径(四处一致):台账当天的官方成本 > 用户计费规则 > 台账反推均价 > 无依据(界面显示 —)。
16
+ * 每个成本单元格都带 `costSource`(official / rule / estimate / none),界面据此打来源角标。
17
+ * 规则与成本算法见 pricing.ts,规则本体存在宿主设置服务里(本插件不写文件)。
18
+ *
14
19
  * 网络:只有「余额与订阅窗口」会访问各通道**官方**接口(见 live-quota.ts),
15
20
  * 失败即回落本地快照;其余端点零外网。
16
21
  */
@@ -19,9 +24,13 @@ import { homedir } from 'node:os';
19
24
  import { join } from 'node:path';
20
25
  import { aggregateRange, buildPriceTable, estimateCost, readLedger, } from './ledger.js';
21
26
  import { classifyChannels, costModeLabel, extractChannels, quotaWindowLabel, readProviderSnapshots, } from './collect.js';
22
- import { hoursOfDate, projectRowsOf, sessionRowsOf, } from './sessions.js';
27
+ import { schemeIdOf } from './live-quota.js';
28
+ import { hoursOfDate, modelRowsOf, projectRowsOf, sessionRowsOf, } from './sessions.js';
23
29
  import { createArchiveSource, createTitleSource } from './session-meta.js';
30
+ import { readQuotaSnapshots, writeQuotaSnapshots } from './quota-snapshots.js';
24
31
  import { mergeDailySeries, sessionDayRows, summarizeMergedByProvider, totalsOfMerged, } from './merge.js';
32
+ import { EMPTY_PRICING_VIEW, PRICING_TEMPLATES, displayTokensOf, isOffPeakNow, priceRowsByRules, rowCostsOf, summarizeCoverage, } from './pricing.js';
33
+ import { catalogEntryCount, catalogSources } from './pricing-catalog.js';
25
34
  /** 无会话服务时的兜底数据源,保证台账相关端点照常工作。 */
26
35
  export const unavailableSessionSource = {
27
36
  collect: async () => ({
@@ -35,6 +44,77 @@ export const disabledLiveQuota = {
35
44
  refreshIfStale: async () => new Map(),
36
45
  };
37
46
  const EMPTY_LEDGER = { version: 0, days: {} };
47
+ const SOURCE_RANK = { official: 0, rule: 1, catalog: 2, estimate: 3, none: 4 };
48
+ /** 一天里最弱的那一格决定整天的角标:宁可标弱,也不把估算冒充成规则价。 */
49
+ export function weakestSource(sources) {
50
+ if (sources.length === 0)
51
+ return 'none';
52
+ return sources.reduce((weakest, current) => (SOURCE_RANK[current] > SOURCE_RANK[weakest] ? current : weakest), sources[0]);
53
+ }
54
+ /** 会话侧的模型行 → 计费行(三种视图的 `models[]` 形状一致,用同一份映射)。 */
55
+ export function pricingRowOf(model) {
56
+ return {
57
+ provider: model.provider,
58
+ model: model.model,
59
+ calls: model.calls,
60
+ tokens: model.tokens,
61
+ inputTokens: model.buckets.inputTokens ?? 0,
62
+ outputTokens: model.buckets.outputTokens ?? 0,
63
+ cacheReadTokens: model.buckets.cacheReadTokens ?? 0,
64
+ cacheWriteTokens: model.buckets.cacheWriteTokens ?? 0,
65
+ };
66
+ }
67
+ /** 台账序列里的模型行 → 计费行(分桶在顶层字段上,不是 `buckets`)。 */
68
+ export function dayModelPricingRow(model) {
69
+ return {
70
+ provider: model.provider,
71
+ model: model.model,
72
+ calls: model.calls,
73
+ tokens: model.inputTokens + model.outputTokens + model.cacheReadTokens,
74
+ inputTokens: model.inputTokens,
75
+ outputTokens: model.outputTokens,
76
+ cacheReadTokens: model.cacheReadTokens,
77
+ cacheWriteTokens: model.cacheWriteTokens ?? 0,
78
+ };
79
+ }
80
+ /** 24 个小时槽 → 计费行(错峰占比只用得上 token 分桶)。 */
81
+ export function hoursAsPricingRows(hours) {
82
+ if (hours === null)
83
+ return null;
84
+ return hours.map((slot) => ({
85
+ provider: '',
86
+ model: '',
87
+ calls: slot.calls,
88
+ inputTokens: slot.buckets.inputTokens ?? 0,
89
+ outputTokens: slot.buckets.outputTokens ?? 0,
90
+ cacheReadTokens: slot.buckets.cacheReadTokens ?? 0,
91
+ cacheWriteTokens: slot.buckets.cacheWriteTokens ?? 0,
92
+ }));
93
+ }
94
+ /** 逐行金额求和;全部为 null(无任何依据)时返回 null,界面据此显示 —。 */
95
+ export function sumOrNull(costs) {
96
+ let total = 0;
97
+ let matched = false;
98
+ for (const entry of costs) {
99
+ if (entry.cost === null)
100
+ continue;
101
+ total += entry.cost;
102
+ matched = true;
103
+ }
104
+ return matched ? Math.round(total * 100) / 100 : null;
105
+ }
106
+ /** 某个通道在指定范围内的成本来源:取该通道所有模型行里最弱的那一格。 */
107
+ export function sourceOfProvider(days, provider, from, to) {
108
+ const sources = [];
109
+ for (const day of days) {
110
+ if (day.date < from || day.date > to)
111
+ continue;
112
+ for (const model of day.models)
113
+ if (model.provider === provider)
114
+ sources.push(model.costSource);
115
+ }
116
+ return sources.length === 0 ? 'none' : weakestSource(sources);
117
+ }
38
118
  function sendJson(response, statusCode, body) {
39
119
  const payload = JSON.stringify(body);
40
120
  response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-cache' });
@@ -63,11 +143,13 @@ function channelPayload(channel) {
63
143
  quotaWindowLabel: channel.quotaWindowKey === null ? null : quotaWindowLabel(channel.quotaWindowKey),
64
144
  };
65
145
  }
66
- export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessionSource, liveQuota = disabledLiveQuota, hostServices = { get: () => undefined }) {
146
+ export const noPricingSource = { current: () => EMPTY_PRICING_VIEW };
147
+ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessionSource, liveQuota = disabledLiveQuota, hostServices = { get: () => undefined }, pricingSource = noPricingSource) {
67
148
  const ledgerPath = join(dshHomePath(), 'dsh-usage', 'usage-ledger.json');
68
149
  const snapshotPath = join(dshHomePath(), 'dsh-usage', 'provider-snapshots.json');
69
150
  const titleSource = createTitleSource(join(dshHomePath(), 'storages', 'session_projcache.json'));
70
151
  const archiveSource = createArchiveSource(hostServices, join(dshHomePath(), 'storages', 'workspace.json'));
152
+ const quotaSnapshotPath = join(dshHomePath(), 'dsh-key-usage', 'quota-snapshots.json');
71
153
  /** 会话标题与归档状态:宿主服务/缓存文件都拿不到时返回空集合,界面自行回落。 */
72
154
  const sessionMeta = () => ({ titles: titleSource.titles(), archived: archiveSource.archived() });
73
155
  const loadLedger = () => readLedger(ledgerPath, (path) => {
@@ -79,6 +161,49 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
79
161
  }
80
162
  }) ?? EMPTY_LEDGER;
81
163
  const readSnapshots = () => readProviderSnapshots(snapshotPath).providers;
164
+ /** 规则每次请求现取:用户在界面上改完立即生效,不需要重启宿主。 */
165
+ const pricingView = () => {
166
+ try {
167
+ return pricingSource.current();
168
+ }
169
+ catch {
170
+ return EMPTY_PRICING_VIEW;
171
+ }
172
+ };
173
+ const averagePriceOf = (priceTable) => (row) => estimateCost([{ provider: row.provider, model: row.model, tokens: displayTokensOf(row) }], priceTable);
174
+ /**
175
+ * 给合并序列补成本与来源:台账天保留官方成本(official,不重算),
176
+ * 会话补位天逐模型按规则估算,规则没命中再回落台账均价。
177
+ * @param offPeakHours - 单日范围才有的小时槽;给了就按错峰占比加权
178
+ */
179
+ const annotateCosts = (days, sessionDates, priceTable, offPeakHours) => {
180
+ const sessionSet = new Set(sessionDates);
181
+ const view = pricingView();
182
+ return days.map((day) => {
183
+ if (!sessionSet.has(day.date)) {
184
+ // 台账天带官方账单金额:原样保留,不按规则重算
185
+ return {
186
+ ...day,
187
+ costSource: 'official',
188
+ models: day.models.map((model) => ({ ...model, costSource: 'official' })),
189
+ };
190
+ }
191
+ const models = day.models.map((model) => {
192
+ const row = dayModelPricingRow(model);
193
+ const priced = priceRowsByRules([row], view.config, { hours: offPeakHours })[0];
194
+ const fallback = estimateCost([{ provider: model.provider, model: model.model, tokens: displayTokensOf(row) }], priceTable);
195
+ const cost = priced.cost ?? fallback ?? 0;
196
+ const source = priced.cost !== null ? priced.source : fallback !== null ? 'estimate' : 'none';
197
+ return { ...model, cost: Math.round(cost * 100) / 100, costSource: source };
198
+ });
199
+ return {
200
+ ...day,
201
+ cost: Math.round(models.reduce((total, model) => total + model.cost, 0) * 100) / 100,
202
+ costSource: weakestSource(models.map((model) => model.costSource)),
203
+ models,
204
+ };
205
+ });
206
+ };
82
207
  const loadChannels = (live = new Map()) => {
83
208
  // cordis.yml 是应用按需重写的组合产物:插件集变化期间会短暂变成空模板,
84
209
  // 因此先读它,取不到再回退到用户 patch 层 cordis.patch.yml。
@@ -128,9 +253,12 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
128
253
  const merged = mergeDailySeries(ledgerResult.days, sessionRows, from, to);
129
254
  // 单日范围才提供小时细分(来自会话事件,口径与台账不同,见页脚口径说明)
130
255
  const hours = from !== '' && from === to ? hoursOfDate(collection.records, from) : null;
256
+ // 单日有小时槽时按错峰占比加权;跨天拿不到小时分布,只能按标准价(不猜折扣)
257
+ const days = annotateCosts(merged.days, merged.sessionDates, buildPriceTable(ledger), hoursAsPricingRows(hours));
258
+ const totals = days.length > 0 ? totalsOfMerged(days) : null;
131
259
  sendJson(response, 200, {
132
- days: merged.days,
133
- totals: merged.days.length > 0 ? totalsOfMerged(merged.days) : null,
260
+ days,
261
+ totals: totals === null ? null : { ...totals, costSource: weakestSource(days.map((day) => day.costSource)) },
134
262
  sessionDates: merged.sessionDates,
135
263
  availableFrom: availableFrom ?? null,
136
264
  availableTo: availableTo ?? null,
@@ -155,8 +283,10 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
155
283
  const from = earliest > yearAgo ? earliest : yearAgo;
156
284
  const to = maxKey(dayKeys[dayKeys.length - 1], sessionDates[sessionDates.length - 1]) ?? from;
157
285
  const merged = mergeDailySeries(aggregateRange(ledger, from, to, null).days, sessionRows, from, to);
286
+ // 与「使用明细」同一口径:会话补位天按规则/均价估算,台账天保留官方成本
287
+ const days = annotateCosts(merged.days, merged.sessionDates, buildPriceTable(ledger), null);
158
288
  sendJson(response, 200, {
159
- days: merged.days
289
+ days: days
160
290
  .map((day) => ({
161
291
  date: day.date,
162
292
  totalTokens: day.inputTokens + day.outputTokens + day.cacheReadTokens,
@@ -186,15 +316,31 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
186
316
  const live = url.searchParams.get('fresh') === '1'
187
317
  ? await liveQuota.refreshIfStale(channels, true)
188
318
  : await liveQuota.refreshIfStale(channels);
319
+ // 实时成功立刻落一份自己的快照:下次失败时回落的是「最近一次成功」,而不是宿主那份十几天前的旧值
320
+ const succeeded = [];
321
+ for (const entry of live.values()) {
322
+ if (!entry.ok)
323
+ continue;
324
+ succeeded.push({
325
+ provider: entry.provider,
326
+ displayName: channels.find((channel) => channel.provider === entry.provider)?.displayName ?? null,
327
+ updatedAt: entry.fetchedAt,
328
+ balance: entry.balance,
329
+ windows: entry.windows,
330
+ });
331
+ }
332
+ writeQuotaSnapshots(quotaSnapshotPath, succeeded);
333
+ const ownSnapshots = readQuotaSnapshots(quotaSnapshotPath);
189
334
  const snapshots = readSnapshots();
190
335
  const providers = channels.map((channel) => {
191
336
  const liveEntry = live.get(channel.provider);
192
- const snapshot = snapshots.find((entry) => entry.provider === channel.provider) ?? null;
337
+ const supported = schemeIdOf(channel.provider) !== null;
193
338
  if (liveEntry !== undefined && liveEntry.ok) {
194
339
  return {
195
340
  provider: channel.provider,
196
341
  displayName: channel.displayName,
197
342
  credential: channel.apiKeyEnv,
343
+ supported,
198
344
  source: 'live',
199
345
  fetchedAt: liveEntry.fetchedAt,
200
346
  updatedAt: liveEntry.fetchedAt,
@@ -203,17 +349,27 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
203
349
  plan: liveEntry.windows === null ? null : { windows: liveEntry.windows },
204
350
  };
205
351
  }
352
+ // 回落顺序:自己的快照与宿主的快照里,取 updatedAt 更新的那一份
353
+ const own = ownSnapshots.find((entry) => entry.provider === channel.provider) ?? null;
354
+ const host = snapshots.find((entry) => entry.provider === channel.provider) ?? null;
355
+ const useOwn = own !== null && (host === null || own.updatedAt >= (host.updatedAt ?? 0));
356
+ const updatedAt = useOwn ? own.updatedAt : host?.updatedAt ?? null;
357
+ const fallbackBalance = useOwn ? own.balance : host?.balance ?? null;
358
+ const fallbackPlan = useOwn
359
+ ? (own.windows === null ? null : { windows: own.windows })
360
+ : host?.plan ?? null;
206
361
  return {
207
362
  provider: channel.provider,
208
- displayName: snapshot?.displayName ?? channel.displayName,
209
- credential: snapshot?.credential ?? channel.apiKeyEnv,
363
+ displayName: host?.displayName ?? channel.displayName,
364
+ credential: host?.credential ?? channel.apiKeyEnv,
365
+ supported,
210
366
  source: 'snapshot',
211
367
  fetchedAt: null,
212
- updatedAt: snapshot?.updatedAt ?? null,
368
+ updatedAt,
213
369
  // 实时失败的原因要透出(未配置凭据 / 网络 / 鉴权),界面据此提示
214
- error: liveEntry?.error ?? (snapshot === null ? 'no-snapshot' : null),
215
- balance: snapshot?.balance ?? null,
216
- plan: snapshot?.plan ?? null,
370
+ error: liveEntry?.error ?? (updatedAt === null ? 'no-snapshot' : null),
371
+ balance: fallbackBalance,
372
+ plan: fallbackPlan,
217
373
  };
218
374
  });
219
375
  sendJson(response, 200, { providers });
@@ -233,15 +389,17 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
233
389
  const knownProviders = channels.map((channel) => channel.provider);
234
390
  const sessionRows = sessionDayRows(collection.records, knownProviders);
235
391
  const merged = mergeDailySeries(aggregateRange(ledger, monthFrom, today, null).days, sessionRows, monthFrom, today);
236
- const todaySummary = summarizeMergedByProvider(merged.days, today, today);
237
- const monthSummary = summarizeMergedByProvider(merged.days, monthFrom, today);
392
+ // 总览的「今日 / 近 30 天」成本必须与使用明细同一口径,否则两页数字对不上
393
+ const days = annotateCosts(merged.days, merged.sessionDates, buildPriceTable(ledger), null);
394
+ const todaySummary = summarizeMergedByProvider(days, today, today);
395
+ const monthSummary = summarizeMergedByProvider(days, monthFrom, today);
238
396
  const rows = channels.map((channel) => {
239
397
  const todayRow = todaySummary.find((row) => row.provider === channel.provider);
240
398
  const monthRow = monthSummary.find((row) => row.provider === channel.provider);
241
399
  return {
242
400
  provider: channel.provider,
243
- today: todayRow ?? { provider: channel.provider, calls: 0, tokens: 0, cost: 0 },
244
- month: monthRow ?? { provider: channel.provider, calls: 0, tokens: 0, cost: 0 },
401
+ today: { ...todayRow ?? { provider: channel.provider, calls: 0, tokens: 0, cost: 0 }, costSource: sourceOfProvider(days, channel.provider, today, today) },
402
+ month: { ...monthRow ?? { provider: channel.provider, calls: 0, tokens: 0, cost: 0 }, costSource: sourceOfProvider(days, channel.provider, monthFrom, today) },
245
403
  };
246
404
  });
247
405
  sendJson(response, 200, {
@@ -264,14 +422,20 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
264
422
  const knownProviders = channels.map((channel) => channel.provider);
265
423
  const costModeOf = new Map(channels.map((channel) => [channel.provider, channel.costMode]));
266
424
  const { records, coverage } = await sessionSource.collect();
267
- const priceTable = buildPriceTable(loadLedger());
425
+ const ledger = loadLedger();
426
+ const priceTable = buildPriceTable(ledger);
427
+ const view = pricingView();
268
428
  const meta = sessionMeta();
269
- const rows = sessionRowsOf(records, knownProviders, meta).slice(0, limit).map((row) => ({
270
- ...row,
271
- providerLabel: channels.find((channel) => channel.provider === row.provider)?.displayName ?? row.provider,
272
- costMode: row.provider === null ? null : costModeOf.get(row.provider) ?? null,
273
- cost: estimateCost(row.models, priceTable),
274
- }));
429
+ const rows = sessionRowsOf(records, knownProviders, meta).slice(0, limit).map((row) => {
430
+ const costs = rowCostsOf(row.models.map(pricingRowOf), view.config, averagePriceOf(priceTable));
431
+ return {
432
+ ...row,
433
+ providerLabel: channels.find((channel) => channel.provider === row.provider)?.displayName ?? row.provider,
434
+ costMode: row.provider === null ? null : costModeOf.get(row.provider) ?? null,
435
+ cost: costs.length === 0 ? null : sumOrNull(costs),
436
+ costSource: costs.length === 0 ? 'none' : weakestSource(costs.map((entry) => entry.source)),
437
+ };
438
+ });
275
439
  sendJson(response, 200, { rows, coverage, priced: priceTable.size > 0 });
276
440
  },
277
441
  });
@@ -287,14 +451,97 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
287
451
  const knownProviders = channels.map((channel) => channel.provider);
288
452
  const { records, coverage } = await sessionSource.collect();
289
453
  const priceTable = buildPriceTable(loadLedger());
290
- const rows = projectRowsOf(records, knownProviders, sessionMeta()).slice(0, limit).map((row) => ({
291
- ...row,
292
- dominantProvider: Object.entries(row.providers).sort((first, second) => second[1] - first[1])[0]?.[0] ?? null,
293
- cost: estimateCost(row.models, priceTable),
294
- }));
454
+ const view = pricingView();
455
+ const rows = projectRowsOf(records, knownProviders, sessionMeta()).slice(0, limit).map((row) => {
456
+ const costs = rowCostsOf(row.models.map(pricingRowOf), view.config, averagePriceOf(priceTable));
457
+ return {
458
+ ...row,
459
+ dominantProvider: Object.entries(row.providers).sort((first, second) => second[1] - first[1])[0]?.[0] ?? null,
460
+ cost: costs.length === 0 ? null : sumOrNull(costs),
461
+ costSource: costs.length === 0 ? 'none' : weakestSource(costs.map((entry) => entry.source)),
462
+ };
463
+ });
295
464
  sendJson(response, 200, { rows, coverage, priced: priceTable.size > 0 });
296
465
  },
297
466
  });
467
+ webServer.register({
468
+ kind: 'exact',
469
+ path: '/dsh-key-usage/models',
470
+ handler: async (request, response) => {
471
+ if (!requireLoopback(request, response))
472
+ return;
473
+ const url = new URL(request.url ?? '/', 'http://localhost');
474
+ const limit = parseLimit(url.searchParams.get('limit'), 200);
475
+ const channels = loadChannels();
476
+ const knownProviders = channels.map((channel) => channel.provider);
477
+ const costModeOf = new Map(channels.map((channel) => [channel.provider, channel.costMode]));
478
+ const { records, coverage } = await sessionSource.collect();
479
+ const priceTable = buildPriceTable(loadLedger());
480
+ const view = pricingView();
481
+ const rows = modelRowsOf(records, knownProviders).slice(0, limit).map((row) => {
482
+ const costs = rowCostsOf([pricingRowOf(row)], view.config, averagePriceOf(priceTable));
483
+ return {
484
+ ...row,
485
+ providerLabel: channels.find((channel) => channel.provider === row.provider)?.displayName ?? row.provider,
486
+ costMode: costModeOf.get(row.provider) ?? null,
487
+ // 与 /sessions 同一口径:用户规则优先,没命中再回落台账反推均价
488
+ cost: sumOrNull(costs),
489
+ costSource: costs[0]?.source ?? 'none',
490
+ };
491
+ });
492
+ sendJson(response, 200, { rows, coverage, priced: priceTable.size > 0 });
493
+ },
494
+ });
495
+ /**
496
+ * 计费规则面板:服务端视角的规则、内置模板、设定来源,以及近 30 天的覆盖度与缓存省钱。
497
+ * 规则本体由客户端经宿主设置服务读写,这里只提供「算出来是什么样」的对照。
498
+ */
499
+ webServer.register({
500
+ kind: 'exact',
501
+ path: '/dsh-key-usage/pricing',
502
+ handler: async (request, response) => {
503
+ if (!requireLoopback(request, response))
504
+ return;
505
+ const view = pricingView();
506
+ const ledger = loadLedger();
507
+ const channels = loadChannels();
508
+ const knownProviders = channels.map((channel) => channel.provider);
509
+ const collection = await sessionSource.collect();
510
+ const today = dayKeyOf(new Date());
511
+ const monthFrom = shiftedDayKey(-29);
512
+ const merged = mergeDailySeries(aggregateRange(ledger, monthFrom, today, null).days, sessionDayRows(collection.records, knownProviders), monthFrom, today);
513
+ // 覆盖度按「近 30 天出现过的 provider|model」统计,与使用明细的口径一致
514
+ const priceTable = buildPriceTable(ledger);
515
+ const usageRows = merged.days.flatMap((day) => day.models.map(dayModelPricingRow));
516
+ const coverage = summarizeCoverage(usageRows, view.config, averagePriceOf(priceTable));
517
+ const now = new Date();
518
+ sendJson(response, 200, {
519
+ rules: view.config.rules,
520
+ currency: view.config.currency,
521
+ writable: view.writable,
522
+ revision: view.revision,
523
+ templates: PRICING_TEMPLATES,
524
+ // 内置官方价目:自动生效、无需套用模板;这里只透出「覆盖了哪些通道 / 来源 / 抓取日期」
525
+ catalog: {
526
+ entries: catalogEntryCount(),
527
+ sources: catalogSources(),
528
+ },
529
+ offPeakNow: view.config.rules.map((rule) => isOffPeakNow(rule, now)),
530
+ coverage: {
531
+ from: monthFrom,
532
+ to: today,
533
+ ruleModels: coverage.ruleModels,
534
+ catalogModels: coverage.catalogModels,
535
+ averagedModels: coverage.averagedModels,
536
+ unpricedModels: coverage.unpricedModels,
537
+ // 只作参照:界面上的成本按「官方 > 规则 > 内置 > 均价」逐格取值,与这个总额不一定相等
538
+ ruleCostByCurrency: coverage.costByCurrency,
539
+ savingsByCurrency: coverage.savingsByCurrency,
540
+ savingsDetail: coverage.savingsDetail,
541
+ },
542
+ });
543
+ },
544
+ });
298
545
  }
299
546
  /**
300
547
  * 把实时余额 / 窗口合并进快照数组,供通道状态判定(额度百分比、余额文案)使用。
@@ -57,23 +57,28 @@ function modelOfEvent(event) {
57
57
  }
58
58
  return null;
59
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 };
60
+ /**
61
+ * 取事件携带的用量样本;与「哪些事件算样本」的口径一一对应(见 docs/01 §3.10)。
62
+ * 注意 `assistant/attempt` 不认 `data.usage`,只认 `data.stream` 里的样本,与旧实现一致。
63
+ */
64
+ function usagePayloadOf(event) {
65
+ const type = event.type;
66
+ const data = event.data;
67
+ if (type === 'assistant/chunk') {
68
+ const chunk = data?.chunk;
69
+ return chunk?.type === 'usage' && chunk.usage !== undefined ? { usage: chunk.usage } : null;
69
70
  }
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
- }
71
+ if (type !== 'assistant/message' && type !== 'assistant/attempt')
72
+ return null;
73
+ if (type === 'assistant/message' && data?.usage !== undefined)
74
+ return { usage: data.usage };
75
+ const stream = data?.stream;
76
+ if (!Array.isArray(stream))
77
+ return null;
78
+ for (let index = stream.length - 1; index >= 0; index -= 1) {
79
+ const record = stream[index];
80
+ if (record?.type === 'chunk' && record.chunk?.type === 'usage' && record.chunk.usage !== undefined) {
81
+ return { usage: record.chunk.usage };
77
82
  }
78
83
  }
79
84
  return null;
@@ -96,48 +101,64 @@ export function foldSessionEvents(events) {
96
101
  if (lastTime === null || time > lastTime)
97
102
  lastTime = time;
98
103
  }
99
- if (event.type === 'request/header') {
104
+ // 先按类型分流:整段日志里绝大多数事件与用量无关,不必为它们拼 (turn, step) 键
105
+ const type = event.type;
106
+ if (type === 'assistant/chunk' || type === 'assistant/message' || type === 'assistant/attempt') {
107
+ const payload = usagePayloadOf(event);
108
+ if (payload === null || time === null)
109
+ continue;
110
+ const turn = event.data?.turn;
111
+ const step = event.data?.step;
112
+ const baseKey = Number.isInteger(turn) && Number.isInteger(step) ? `${turn}:${step}` : `seq:${event.seq}`;
113
+ const attempt = attemptCounts.get(baseKey) ?? 0;
114
+ const sampleKey = `${baseKey}#${attempt}`;
115
+ const model = modelOfEvent(event) ?? currentModel ?? 'unknown/unknown';
116
+ samples.set(sampleKey, {
117
+ key: sampleKey,
118
+ day: dayKeyOf(time),
119
+ hour: hourOfTime(time),
120
+ model,
121
+ buckets: bucketsOf(payload.usage),
122
+ });
123
+ continue;
124
+ }
125
+ if (type === 'request/header') {
100
126
  const model = modelOfEvent(event);
101
127
  if (model !== null)
102
128
  currentModel = model;
129
+ continue;
103
130
  }
104
- if (firstPrompt === null && event.type === 'user/message') {
105
- firstPrompt = promptTextOf(event);
106
- }
107
- const turn = event.data?.turn;
108
- const step = event.data?.step;
109
- const baseKey = Number.isInteger(turn) && Number.isInteger(step) ? `${turn}:${step}` : `seq:${event.seq}`;
110
- if (event.type === 'llm/retry-started' && Number.isInteger(turn) && Number.isInteger(step)) {
111
- attemptCounts.set(baseKey, (attemptCounts.get(baseKey) ?? 0) + 1);
112
- }
113
- const sample = sampleOfEvent(event);
114
- if (sample === null || time === null)
131
+ if (type === 'llm/retry-started') {
132
+ const turn = event.data?.turn;
133
+ const step = event.data?.step;
134
+ if (Number.isInteger(turn) && Number.isInteger(step)) {
135
+ const baseKey = `${turn}:${step}`;
136
+ attemptCounts.set(baseKey, (attemptCounts.get(baseKey) ?? 0) + 1);
137
+ }
115
138
  continue;
116
- const attempt = attemptCounts.get(baseKey) ?? 0;
117
- const sampleKey = `${baseKey}#${attempt}`;
118
- const model = modelOfEvent(event) ?? currentModel ?? 'unknown/unknown';
119
- samples.set(sampleKey, {
120
- key: sampleKey,
121
- day: dayKeyOf(time),
122
- hour: hourOfTime(time),
123
- model,
124
- buckets: bucketsOf(sample.usage),
125
- });
139
+ }
140
+ if (firstPrompt === null && type === 'user/message')
141
+ firstPrompt = promptTextOf(event);
126
142
  }
127
143
  return { samples, firstTime, lastTime, firstPrompt };
128
144
  }
129
- /** 标题兜底:取 user/message 里第一个非空文本块,压平空白并截断。 */
145
+ /** 标题兜底:取用户消息里第一个非空文本块,压平空白并截断。 */
130
146
  function promptTextOf(event) {
131
- const message = event.data?.message;
132
- if (typeof message !== 'object' || message === null)
147
+ const data = event.data;
148
+ if (typeof data !== 'object' || data === null)
133
149
  return null;
134
- const content = message.content;
135
- if (!Array.isArray(content))
150
+ // `user/message` 的正文在 `data.content`(宿主 dsh-context 就是这么读的),
151
+ // `assistant/message` 才用 `data.message.content` —— 两种都认,避免形状差异导致标题全空。
152
+ const direct = data.content;
153
+ const nested = data.message?.content;
154
+ const content = Array.isArray(direct) ? direct : Array.isArray(nested) ? nested : null;
155
+ if (content === null)
136
156
  return null;
137
157
  for (const block of content) {
138
- if (typeof block !== 'object' || block === null)
139
- continue;
140
- const text = block.text;
158
+ // 文本块既可能是对象,也可能是裸字符串
159
+ const text = typeof block === 'string'
160
+ ? block
161
+ : typeof block === 'object' && block !== null ? block.text : null;
141
162
  if (typeof text !== 'string')
142
163
  continue;
143
164
  const flat = text.replace(/\s+/g, ' ').trim();