dsh-key-usage 0.1.1 → 0.1.3

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/index.js CHANGED
@@ -37,6 +37,9 @@ export function apply(ctx) {
37
37
  },
38
38
  });
39
39
  ctx.inject(['webServer'], (host) => {
40
- host.effect(() => mountKeyUsageRoutes(host.webServer, sessionSource, liveQuota), 'dsh-key-usage: http routes');
40
+ host.effect(() => mountKeyUsageRoutes(host.webServer, sessionSource, liveQuota, {
41
+ // workspaceRegistry 用来读官方归档集合(拿不到就回退本地 storages/workspace.json)
42
+ get: (serviceName) => (typeof ctx.get === 'function' ? ctx.get(serviceName) : undefined),
43
+ }), 'dsh-key-usage: http routes');
41
44
  });
42
45
  }
package/lib/live-quota.js CHANGED
@@ -11,7 +11,62 @@ function textOf(value) {
11
11
  return String(value);
12
12
  return null;
13
13
  }
14
- /** OpenCode Go:GET https://opencode.ai/zen/go/v1/usage → usage.rolling / weekly / monthly。 */
14
+ /** 金额字段:官方既有字符串(DeepSeek)也有数字(Moonshot),两种都接受,统一输出原样字符串。 */
15
+ function amountTextOf(value) {
16
+ if (typeof value === 'number')
17
+ return Number.isFinite(value) ? String(value) : null;
18
+ if (typeof value === 'string') {
19
+ const trimmed = value.trim();
20
+ return trimmed !== '' && Number.isFinite(Number(trimmed)) ? trimmed : null;
21
+ }
22
+ return null;
23
+ }
24
+ /** 金额 / 百分比的数值形态:接受 number 与数字字符串,其它一律 null。 */
25
+ function amountOf(value) {
26
+ const text = amountTextOf(value);
27
+ if (text === null)
28
+ return null;
29
+ const parsed = Number(text);
30
+ return Number.isFinite(parsed) ? parsed : null;
31
+ }
32
+ /** epoch → ISO:13 位按毫秒、10 位按秒;越界返回 null。 */
33
+ function isoFromEpoch(value) {
34
+ const milliseconds = value >= 1e11 || value < 1e9 ? value : value * 1000;
35
+ const date = new Date(milliseconds);
36
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
37
+ }
38
+ /** 重置时间:官方为 ISO 字符串,兼容 epoch 毫秒 / 秒(数字或纯数字字符串);识别不出时原样保留。 */
39
+ function resetAtOf(value) {
40
+ if (typeof value === 'number')
41
+ return Number.isFinite(value) ? isoFromEpoch(value) : null;
42
+ if (typeof value !== 'string' || value === '')
43
+ return null;
44
+ const trimmed = value.trim();
45
+ if (/^\d{9,14}$/.test(trimmed))
46
+ return isoFromEpoch(Number(trimmed));
47
+ return value;
48
+ }
49
+ /** 接口没返回币种时的兜底:按站点判定(Z.AI 海外站按美元计价,BigModel / Moonshot 国内站按人民币)。 */
50
+ function currencyFromHost(requestUrl, fallback) {
51
+ let host = '';
52
+ try {
53
+ host = new URL(requestUrl).hostname.toLowerCase();
54
+ }
55
+ catch {
56
+ return fallback;
57
+ }
58
+ if (host === 'z.ai' || host.endsWith('.z.ai'))
59
+ return 'USD';
60
+ if (host === 'bigmodel.cn' || host.endsWith('.bigmodel.cn'))
61
+ return 'CNY';
62
+ if (host === 'moonshot.cn' || host.endsWith('.moonshot.cn'))
63
+ return 'CNY';
64
+ return fallback;
65
+ }
66
+ /**
67
+ * OpenCode Go:GET https://opencode.ai/zen/go/v1/usage → usage.rolling / weekly / monthly。
68
+ * 该端点未收录于官方文档(opencode.ai/docs/go 只指向 console),字段结构取自社区实现,见 test/fixtures/providers/opencode-go。
69
+ */
15
70
  const OPENCODE_GO_SCHEME = {
16
71
  url: () => 'https://opencode.ai/zen/go/v1/usage',
17
72
  parse: (body) => {
@@ -24,13 +79,14 @@ const OPENCODE_GO_SCHEME = {
24
79
  const raw = usage[sourceKey];
25
80
  if (!isObject(raw))
26
81
  continue;
27
- const percent = typeof raw.percent === 'number' ? raw.percent : Number(raw.percent);
28
- if (!Number.isFinite(percent))
82
+ // 社区实现里 percent 可能是字符串、resetsAt 可能是 epoch,两者都按漂移兼容处理
83
+ const percent = amountOf(raw.percent);
84
+ if (percent === null)
29
85
  continue;
30
86
  windows.push({
31
87
  key: windowKey,
32
88
  percent,
33
- resetsAt: textOf(raw.resetsAt),
89
+ resetsAt: resetAtOf(raw.resetsAt),
34
90
  status: typeof raw.status === 'string' ? raw.status : null,
35
91
  });
36
92
  }
@@ -41,22 +97,26 @@ const OPENCODE_GO_SCHEME = {
41
97
  function balanceScheme(path, parse, fallbackOrigin) {
42
98
  return {
43
99
  url: (baseURL) => new URL(path, baseURL ?? fallbackOrigin).href,
44
- parse: (body) => ({ balance: isObject(body) ? parse(body) : null, windows: null }),
100
+ parse: (body, requestUrl) => ({ balance: isObject(body) ? parse(body, requestUrl) : null, windows: null }),
45
101
  };
46
102
  }
47
103
  const SCHEMES = {
48
104
  'opencode-go': OPENCODE_GO_SCHEME,
49
105
  // DeepSeek: GET {origin}/user/balance → balance_infos[{currency,total_balance,granted_balance,topped_up_balance}]
50
106
  'deepseek-official': balanceScheme('/user/balance', (body) => {
51
- const infos = Array.isArray(body.balance_infos) ? body.balance_infos : [];
52
- const info = infos.find((entry) => isObject(entry) && entry.currency === 'CNY') ?? infos[0];
53
- if (!isObject(info))
107
+ // 文档 balance_infos[].currency 只可能是 CNY / USD:没有 CNY 项时退回第一项,并如实保留它的币种
108
+ const infos = (Array.isArray(body.balance_infos) ? body.balance_infos : []).filter(isObject);
109
+ const info = infos.find((entry) => (textOf(entry.currency) ?? '').toUpperCase() === 'CNY') ?? infos[0];
110
+ if (info === undefined)
111
+ return null;
112
+ const total = amountTextOf(info.total_balance);
113
+ const granted = amountTextOf(info.granted_balance);
114
+ const toppedUp = amountTextOf(info.topped_up_balance);
115
+ // 三个金额全缺说明结构已经漂移,判 bad-json,不返回「只有币种」的空壳成功
116
+ if (total === null && granted === null && toppedUp === null)
54
117
  return null;
55
118
  const balance = {};
56
119
  const currency = textOf(info.currency);
57
- const total = textOf(info.total_balance);
58
- const granted = textOf(info.granted_balance);
59
- const toppedUp = textOf(info.topped_up_balance);
60
120
  if (currency !== null)
61
121
  balance.currency = currency;
62
122
  if (total !== null)
@@ -65,29 +125,31 @@ const SCHEMES = {
65
125
  balance.giftBalance = granted;
66
126
  if (toppedUp !== null)
67
127
  balance.topUpBalance = toppedUp;
68
- return Object.keys(balance).length > 0 ? balance : null;
128
+ return balance;
69
129
  }, 'https://api.deepseek.com'),
70
130
  // OpenRouter: GET {origin}/api/v1/credits(需要 Management Key)
71
131
  openrouter: balanceScheme('/api/v1/credits', (body) => {
72
132
  const data = isObject(body.data) ? body.data : null;
73
133
  if (data === null)
74
134
  return null;
75
- const totalCredits = typeof data.total_credits === 'number' ? data.total_credits : null;
76
- const totalUsage = typeof data.total_usage === 'number' ? data.total_usage : null;
135
+ const totalCredits = amountOf(data.total_credits);
136
+ const totalUsage = amountOf(data.total_usage);
77
137
  if (totalCredits === null || totalUsage === null)
78
138
  return null;
79
139
  return { currency: 'USD', totalBalance: (totalCredits - totalUsage).toFixed(2) };
80
140
  }, 'https://openrouter.ai'),
81
- // Moonshot / Kimi: GET {origin}/v1/users/me/balance
82
- moonshotai: balanceScheme('/v1/users/me/balance', (body) => {
141
+ // Moonshot / Kimi: GET {origin}/v1/users/me/balance(文档未定义 currency 字段,国内站按人民币计)
142
+ moonshotai: balanceScheme('/v1/users/me/balance', (body, requestUrl) => {
83
143
  const data = isObject(body.data) ? body.data : null;
84
144
  if (data === null)
85
145
  return null;
146
+ const available = amountTextOf(data.available_balance);
147
+ const cash = amountTextOf(data.cash_balance);
148
+ const voucher = amountTextOf(data.voucher_balance);
149
+ if (available === null && cash === null && voucher === null)
150
+ return null;
86
151
  const balance = {};
87
- const currency = textOf(data.currency);
88
- const available = textOf(data.available_balance);
89
- const cash = textOf(data.cash_balance);
90
- const voucher = textOf(data.voucher_balance);
152
+ const currency = textOf(data.currency) ?? currencyFromHost(requestUrl, null);
91
153
  if (currency !== null)
92
154
  balance.currency = currency;
93
155
  if (available !== null)
@@ -96,21 +158,23 @@ const SCHEMES = {
96
158
  balance.giftBalance = voucher;
97
159
  if (cash !== null)
98
160
  balance.topUpBalance = cash;
99
- return Object.keys(balance).length > 0 ? balance : null;
161
+ return balance;
100
162
  }, 'https://api.moonshot.cn'),
101
- // Z.AI / GLM: GET {origin}/api/paas/v4/balance
102
- zai: balanceScheme('/api/paas/v4/balance', (body) => {
163
+ // Z.AI / GLM: GET {origin}/api/paas/v4/balance(官方文档未收录,接口自带 currency 时才采信)
164
+ zai: balanceScheme('/api/paas/v4/balance', (body, requestUrl) => {
103
165
  const data = isObject(body.data) ? body.data : null;
104
166
  if (data === null)
105
167
  return null;
106
- const balance = { currency: 'CNY' };
107
- const total = textOf(data.total_balance);
108
- const available = textOf(data.available_balance);
168
+ const total = amountTextOf(data.total_balance);
169
+ const available = amountTextOf(data.available_balance);
170
+ if (total === null && available === null)
171
+ return null;
172
+ const balance = { currency: textOf(data.currency) ?? currencyFromHost(requestUrl, 'CNY') };
109
173
  if (total !== null)
110
174
  balance.totalBalance = total;
111
175
  if (available !== null)
112
176
  balance.availableBalance = available;
113
- return Object.keys(balance).length > 1 ? balance : null;
177
+ return balance;
114
178
  }, 'https://api.z.ai'),
115
179
  };
116
180
  /** provider id → 方案 id(事件里的 provider 可能是路由名,先取末段)。 */
@@ -133,7 +197,8 @@ async function queryOne(schemeId, apiKey, baseURL, fetchImpl) {
133
197
  const controller = new AbortController();
134
198
  const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
135
199
  try {
136
- const response = await fetchImpl(scheme.url(baseURL), {
200
+ const requestUrl = scheme.url(baseURL);
201
+ const response = await fetchImpl(requestUrl, {
137
202
  headers: { Authorization: 'Bearer ' + apiKey, Accept: 'application/json' },
138
203
  signal: controller.signal,
139
204
  });
@@ -142,7 +207,8 @@ async function queryOne(schemeId, apiKey, baseURL, fetchImpl) {
142
207
  if (!response.ok)
143
208
  return { balance: null, windows: null, error: 'http-' + String(response.status) };
144
209
  const raw = await response.text();
145
- if (raw.length > MAX_RESPONSE_BYTES)
210
+ // 上限按**字节**算:raw.length 是 UTF-16 码元数,中文响应会因此超限仍被接受
211
+ if (Buffer.byteLength(raw, 'utf8') > MAX_RESPONSE_BYTES)
146
212
  return { balance: null, windows: null, error: 'too-large' };
147
213
  let body;
148
214
  try {
@@ -151,7 +217,7 @@ async function queryOne(schemeId, apiKey, baseURL, fetchImpl) {
151
217
  catch {
152
218
  return { balance: null, windows: null, error: 'bad-json' };
153
219
  }
154
- const parsed = scheme.parse(body);
220
+ const parsed = scheme.parse(body, requestUrl);
155
221
  if (parsed.balance === null && parsed.windows === null)
156
222
  return { balance: null, windows: null, error: 'bad-json' };
157
223
  return { ...parsed, error: null };
@@ -0,0 +1,68 @@
1
+ /**
2
+ * 余额 / 窗口快照的本地持久化。
3
+ *
4
+ * 为什么需要:宿主那份 `dsh-usage/provider-snapshots.json` 由别的组件写,本机实测从 2026-09-30
5
+ * 起就停更了 —— 实时查询一旦失败,界面就只剩十几天前的旧值(截图里的「已 13 天未更新」)。
6
+ * 这里在**每次实时查询成功后**立刻落一份自己的快照,失败时优先回落到它,
7
+ * 把数据断档压缩到「最近一次成功查询」为止。
8
+ *
9
+ * 约定:只写插件自己的文件(`$DSH_HOME/dsh-key-usage/quota-snapshots.json`),不碰宿主的文件;
10
+ * 读写失败一律忽略(返回空 / 不写),绝不影响接口可用性。
11
+ */
12
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
13
+ import { dirname } from 'node:path';
14
+ function isObject(value) {
15
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
16
+ }
17
+ /** 读快照;文件缺失、损坏、结构不符一律返回空数组。 */
18
+ export function readQuotaSnapshots(path) {
19
+ let document;
20
+ try {
21
+ document = JSON.parse(readFileSync(path, 'utf8'));
22
+ }
23
+ catch {
24
+ return [];
25
+ }
26
+ const providers = document?.providers;
27
+ if (!isObject(providers))
28
+ return [];
29
+ const entries = [];
30
+ for (const [id, raw] of Object.entries(providers)) {
31
+ if (!isObject(raw))
32
+ continue;
33
+ if (typeof raw.updatedAt !== 'number' || !Number.isFinite(raw.updatedAt))
34
+ continue;
35
+ const windows = Array.isArray(raw.windows)
36
+ ? raw.windows.filter((window) => isObject(window) && typeof window.key === 'string' && typeof window.percent === 'number')
37
+ : null;
38
+ entries.push({
39
+ provider: typeof raw.provider === 'string' ? raw.provider : id,
40
+ displayName: typeof raw.displayName === 'string' ? raw.displayName : null,
41
+ updatedAt: raw.updatedAt,
42
+ balance: isObject(raw.balance) ? raw.balance : null,
43
+ windows: windows !== null && windows.length > 0 ? windows : null,
44
+ });
45
+ }
46
+ return entries;
47
+ }
48
+ /**
49
+ * 合并写入:本次成功的通道覆盖旧值,其余通道保留(避免一次只刷新部分通道时把别的通道抹掉)。
50
+ * 先写临时文件再改名,避免读到半截 JSON;任何异常都吞掉。
51
+ */
52
+ export function writeQuotaSnapshots(path, entries) {
53
+ if (entries.length === 0)
54
+ return;
55
+ try {
56
+ const merged = new Map(readQuotaSnapshots(path).map((entry) => [entry.provider, entry]));
57
+ for (const entry of entries)
58
+ merged.set(entry.provider, entry);
59
+ const document = { version: 1, providers: Object.fromEntries(merged) };
60
+ mkdirSync(dirname(path), { recursive: true });
61
+ const temporaryPath = path + '.tmp';
62
+ writeFileSync(temporaryPath, JSON.stringify(document, null, 2) + '\n', 'utf8');
63
+ renameSync(temporaryPath, path);
64
+ }
65
+ catch {
66
+ // 快照只是加速回落,写不了就不写
67
+ }
68
+ }
package/lib/routes.js CHANGED
@@ -7,6 +7,7 @@
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。
@@ -19,7 +20,10 @@ import { homedir } from 'node:os';
19
20
  import { join } from 'node:path';
20
21
  import { aggregateRange, buildPriceTable, estimateCost, readLedger, } from './ledger.js';
21
22
  import { classifyChannels, costModeLabel, extractChannels, quotaWindowLabel, readProviderSnapshots, } from './collect.js';
22
- import { hoursOfDate, projectRowsOf, sessionRowsOf, } from './sessions.js';
23
+ import { schemeIdOf } from './live-quota.js';
24
+ import { hoursOfDate, modelRowsOf, projectRowsOf, sessionRowsOf, } from './sessions.js';
25
+ import { createArchiveSource, createTitleSource } from './session-meta.js';
26
+ import { readQuotaSnapshots, writeQuotaSnapshots } from './quota-snapshots.js';
23
27
  import { mergeDailySeries, sessionDayRows, summarizeMergedByProvider, totalsOfMerged, } from './merge.js';
24
28
  /** 无会话服务时的兜底数据源,保证台账相关端点照常工作。 */
25
29
  export const unavailableSessionSource = {
@@ -62,9 +66,14 @@ function channelPayload(channel) {
62
66
  quotaWindowLabel: channel.quotaWindowKey === null ? null : quotaWindowLabel(channel.quotaWindowKey),
63
67
  };
64
68
  }
65
- export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessionSource, liveQuota = disabledLiveQuota) {
69
+ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessionSource, liveQuota = disabledLiveQuota, hostServices = { get: () => undefined }) {
66
70
  const ledgerPath = join(dshHomePath(), 'dsh-usage', 'usage-ledger.json');
67
71
  const snapshotPath = join(dshHomePath(), 'dsh-usage', 'provider-snapshots.json');
72
+ const titleSource = createTitleSource(join(dshHomePath(), 'storages', 'session_projcache.json'));
73
+ const archiveSource = createArchiveSource(hostServices, join(dshHomePath(), 'storages', 'workspace.json'));
74
+ const quotaSnapshotPath = join(dshHomePath(), 'dsh-key-usage', 'quota-snapshots.json');
75
+ /** 会话标题与归档状态:宿主服务/缓存文件都拿不到时返回空集合,界面自行回落。 */
76
+ const sessionMeta = () => ({ titles: titleSource.titles(), archived: archiveSource.archived() });
68
77
  const loadLedger = () => readLedger(ledgerPath, (path) => {
69
78
  try {
70
79
  return readFileSync(path, 'utf8');
@@ -181,15 +190,31 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
181
190
  const live = url.searchParams.get('fresh') === '1'
182
191
  ? await liveQuota.refreshIfStale(channels, true)
183
192
  : await liveQuota.refreshIfStale(channels);
193
+ // 实时成功立刻落一份自己的快照:下次失败时回落的是「最近一次成功」,而不是宿主那份十几天前的旧值
194
+ const succeeded = [];
195
+ for (const entry of live.values()) {
196
+ if (!entry.ok)
197
+ continue;
198
+ succeeded.push({
199
+ provider: entry.provider,
200
+ displayName: channels.find((channel) => channel.provider === entry.provider)?.displayName ?? null,
201
+ updatedAt: entry.fetchedAt,
202
+ balance: entry.balance,
203
+ windows: entry.windows,
204
+ });
205
+ }
206
+ writeQuotaSnapshots(quotaSnapshotPath, succeeded);
207
+ const ownSnapshots = readQuotaSnapshots(quotaSnapshotPath);
184
208
  const snapshots = readSnapshots();
185
209
  const providers = channels.map((channel) => {
186
210
  const liveEntry = live.get(channel.provider);
187
- const snapshot = snapshots.find((entry) => entry.provider === channel.provider) ?? null;
211
+ const supported = schemeIdOf(channel.provider) !== null;
188
212
  if (liveEntry !== undefined && liveEntry.ok) {
189
213
  return {
190
214
  provider: channel.provider,
191
215
  displayName: channel.displayName,
192
216
  credential: channel.apiKeyEnv,
217
+ supported,
193
218
  source: 'live',
194
219
  fetchedAt: liveEntry.fetchedAt,
195
220
  updatedAt: liveEntry.fetchedAt,
@@ -198,17 +223,27 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
198
223
  plan: liveEntry.windows === null ? null : { windows: liveEntry.windows },
199
224
  };
200
225
  }
226
+ // 回落顺序:自己的快照与宿主的快照里,取 updatedAt 更新的那一份
227
+ const own = ownSnapshots.find((entry) => entry.provider === channel.provider) ?? null;
228
+ const host = snapshots.find((entry) => entry.provider === channel.provider) ?? null;
229
+ const useOwn = own !== null && (host === null || own.updatedAt >= (host.updatedAt ?? 0));
230
+ const updatedAt = useOwn ? own.updatedAt : host?.updatedAt ?? null;
231
+ const fallbackBalance = useOwn ? own.balance : host?.balance ?? null;
232
+ const fallbackPlan = useOwn
233
+ ? (own.windows === null ? null : { windows: own.windows })
234
+ : host?.plan ?? null;
201
235
  return {
202
236
  provider: channel.provider,
203
- displayName: snapshot?.displayName ?? channel.displayName,
204
- credential: snapshot?.credential ?? channel.apiKeyEnv,
237
+ displayName: host?.displayName ?? channel.displayName,
238
+ credential: host?.credential ?? channel.apiKeyEnv,
239
+ supported,
205
240
  source: 'snapshot',
206
241
  fetchedAt: null,
207
- updatedAt: snapshot?.updatedAt ?? null,
242
+ updatedAt,
208
243
  // 实时失败的原因要透出(未配置凭据 / 网络 / 鉴权),界面据此提示
209
- error: liveEntry?.error ?? (snapshot === null ? 'no-snapshot' : null),
210
- balance: snapshot?.balance ?? null,
211
- plan: snapshot?.plan ?? null,
244
+ error: liveEntry?.error ?? (updatedAt === null ? 'no-snapshot' : null),
245
+ balance: fallbackBalance,
246
+ plan: fallbackPlan,
212
247
  };
213
248
  });
214
249
  sendJson(response, 200, { providers });
@@ -260,7 +295,8 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
260
295
  const costModeOf = new Map(channels.map((channel) => [channel.provider, channel.costMode]));
261
296
  const { records, coverage } = await sessionSource.collect();
262
297
  const priceTable = buildPriceTable(loadLedger());
263
- const rows = sessionRowsOf(records, knownProviders).slice(0, limit).map((row) => ({
298
+ const meta = sessionMeta();
299
+ const rows = sessionRowsOf(records, knownProviders, meta).slice(0, limit).map((row) => ({
264
300
  ...row,
265
301
  providerLabel: channels.find((channel) => channel.provider === row.provider)?.displayName ?? row.provider,
266
302
  costMode: row.provider === null ? null : costModeOf.get(row.provider) ?? null,
@@ -281,7 +317,7 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
281
317
  const knownProviders = channels.map((channel) => channel.provider);
282
318
  const { records, coverage } = await sessionSource.collect();
283
319
  const priceTable = buildPriceTable(loadLedger());
284
- const rows = projectRowsOf(records, knownProviders).slice(0, limit).map((row) => ({
320
+ const rows = projectRowsOf(records, knownProviders, sessionMeta()).slice(0, limit).map((row) => ({
285
321
  ...row,
286
322
  dominantProvider: Object.entries(row.providers).sort((first, second) => second[1] - first[1])[0]?.[0] ?? null,
287
323
  cost: estimateCost(row.models, priceTable),
@@ -289,6 +325,29 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
289
325
  sendJson(response, 200, { rows, coverage, priced: priceTable.size > 0 });
290
326
  },
291
327
  });
328
+ webServer.register({
329
+ kind: 'exact',
330
+ path: '/dsh-key-usage/models',
331
+ handler: async (request, response) => {
332
+ if (!requireLoopback(request, response))
333
+ return;
334
+ const url = new URL(request.url ?? '/', 'http://localhost');
335
+ const limit = parseLimit(url.searchParams.get('limit'), 200);
336
+ const channels = loadChannels();
337
+ const knownProviders = channels.map((channel) => channel.provider);
338
+ const costModeOf = new Map(channels.map((channel) => [channel.provider, channel.costMode]));
339
+ const { records, coverage } = await sessionSource.collect();
340
+ const priceTable = buildPriceTable(loadLedger());
341
+ const rows = modelRowsOf(records, knownProviders).slice(0, limit).map((row) => ({
342
+ ...row,
343
+ providerLabel: channels.find((channel) => channel.provider === row.provider)?.displayName ?? row.provider,
344
+ costMode: costModeOf.get(row.provider) ?? null,
345
+ // 与 /sessions 同一口径:按台账反推的均价估算,不是官方账单金额
346
+ cost: estimateCost([{ provider: row.provider, model: row.model, tokens: row.tokens }], priceTable),
347
+ }));
348
+ sendJson(response, 200, { rows, coverage, priced: priceTable.size > 0 });
349
+ },
350
+ });
292
351
  }
293
352
  /**
294
353
  * 把实时余额 / 窗口合并进快照数组,供通道状态判定(额度百分比、余额文案)使用。
@@ -0,0 +1,101 @@
1
+ /**
2
+ * 会话标题与归档状态的本地来源。
3
+ *
4
+ * - **标题**:宿主把「会话标题」存在投影缓存 `$DSH_HOME/storages/session_projcache.json`
5
+ * 的 `tables.sessions.<id>.rows.title.val`;只有被宿主投影过的会话才有,
6
+ * 其余由 `session-usage.ts` 折叠出的「首条用户提问」兜底。
7
+ * - **归档**:优先读宿主只读服务 `workspaceRegistry.archivedSessionIds`(官方接口);
8
+ * 服务不可用时回退 `$DSH_HOME/storages/workspace.json` 的 `global.archivedSessionIds`。
9
+ *
10
+ * 两个文件都是普通 JSON,只读、不写;解析失败一律退化为「没有数据」,不影响统计。
11
+ */
12
+ import { readFileSync, statSync } from 'node:fs';
13
+ /** 宿主会话 id 两种写法(`session-<uuid>` / 裸 `<uuid>`)统一成裸 uuid 便于比对。 */
14
+ export function normalizeSessionId(id) {
15
+ const lowered = id.toLowerCase();
16
+ return lowered.startsWith('session-') ? lowered.slice('session-'.length) : lowered;
17
+ }
18
+ function readJson(path, readText) {
19
+ const text = readText(path);
20
+ if (text === null)
21
+ return null;
22
+ try {
23
+ return JSON.parse(text);
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ /** 读取 `session_projcache.json` 里的会话标题表(按 mtime 缓存,避免每次请求都解析 2 MB JSON)。 */
30
+ export function createTitleSource(cachePath, readText = (path) => {
31
+ try {
32
+ return readFileSync(path, 'utf8');
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ }) {
38
+ let cache = null;
39
+ return {
40
+ titles: () => {
41
+ let mtimeMs = 0;
42
+ try {
43
+ mtimeMs = statSync(cachePath).mtimeMs;
44
+ }
45
+ catch {
46
+ return new Map();
47
+ }
48
+ if (cache !== null && cache.mtimeMs === mtimeMs)
49
+ return cache.value;
50
+ const value = parseTitles(readJson(cachePath, readText));
51
+ cache = { mtimeMs, value };
52
+ return value;
53
+ },
54
+ };
55
+ }
56
+ /** 从投影缓存文档里取出 id → 标题(结构不符时返回空表)。 */
57
+ export function parseTitles(document) {
58
+ const titles = new Map();
59
+ const sessions = document?.tables?.sessions;
60
+ if (typeof sessions !== 'object' || sessions === null)
61
+ return titles;
62
+ for (const [id, entry] of Object.entries(sessions)) {
63
+ const value = entry?.rows?.title?.val;
64
+ if (typeof value !== 'string')
65
+ continue;
66
+ const trimmed = value.trim();
67
+ if (trimmed !== '')
68
+ titles.set(normalizeSessionId(id), trimmed);
69
+ }
70
+ return titles;
71
+ }
72
+ /** 归档会话 id 集合(归一后用于比对)。 */
73
+ export function createArchiveSource(host, workspacePath, readText = (path) => {
74
+ try {
75
+ return readFileSync(path, 'utf8');
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ }) {
81
+ const fromFile = () => {
82
+ const document = readJson(workspacePath, readText);
83
+ const ids = document?.global?.archivedSessionIds;
84
+ return new Set(Array.isArray(ids) ? ids.filter((id) => typeof id === 'string').map(normalizeSessionId) : []);
85
+ };
86
+ return {
87
+ archived: () => {
88
+ // 先用宿主官方只读 getter;拿不到再读文件(两者都是权威数据,getter 优先)
89
+ try {
90
+ const registry = host.get('workspaceRegistry');
91
+ if (registry !== null && registry !== undefined && Array.isArray(registry.archivedSessionIds)) {
92
+ return new Set(registry.archivedSessionIds.filter((id) => typeof id === 'string').map(normalizeSessionId));
93
+ }
94
+ }
95
+ catch {
96
+ // 落到文件
97
+ }
98
+ return fromFile();
99
+ },
100
+ };
101
+ }
@@ -86,6 +86,8 @@ export function foldSessionEvents(events) {
86
86
  let currentModel = null;
87
87
  let firstTime = null;
88
88
  let lastTime = null;
89
+ // 会话标题在宿主侧只对有投影的会话可见,其余用「首条用户提问」兜底
90
+ let firstPrompt = null;
89
91
  for (const event of events) {
90
92
  const time = typeof event.time === 'number' ? event.time : null;
91
93
  if (time !== null) {
@@ -99,6 +101,9 @@ export function foldSessionEvents(events) {
99
101
  if (model !== null)
100
102
  currentModel = model;
101
103
  }
104
+ if (firstPrompt === null && event.type === 'user/message') {
105
+ firstPrompt = promptTextOf(event);
106
+ }
102
107
  const turn = event.data?.turn;
103
108
  const step = event.data?.step;
104
109
  const baseKey = Number.isInteger(turn) && Number.isInteger(step) ? `${turn}:${step}` : `seq:${event.seq}`;
@@ -119,7 +124,33 @@ export function foldSessionEvents(events) {
119
124
  buckets: bucketsOf(sample.usage),
120
125
  });
121
126
  }
122
- return { samples, firstTime, lastTime };
127
+ return { samples, firstTime, lastTime, firstPrompt };
128
+ }
129
+ /** 标题兜底:取用户消息里第一个非空文本块,压平空白并截断。 */
130
+ function promptTextOf(event) {
131
+ const data = event.data;
132
+ if (typeof data !== 'object' || data === null)
133
+ return null;
134
+ // `user/message` 的正文在 `data.content`(宿主 dsh-context 就是这么读的),
135
+ // `assistant/message` 才用 `data.message.content` —— 两种都认,避免形状差异导致标题全空。
136
+ const direct = data.content;
137
+ const nested = data.message?.content;
138
+ const content = Array.isArray(direct) ? direct : Array.isArray(nested) ? nested : null;
139
+ if (content === null)
140
+ return null;
141
+ for (const block of content) {
142
+ // 文本块既可能是对象,也可能是裸字符串
143
+ const text = typeof block === 'string'
144
+ ? block
145
+ : typeof block === 'object' && block !== null ? block.text : null;
146
+ if (typeof text !== 'string')
147
+ continue;
148
+ const flat = text.replace(/\s+/g, ' ').trim();
149
+ if (flat === '')
150
+ continue;
151
+ return flat.length > 60 ? flat.slice(0, 60) + '…' : flat;
152
+ }
153
+ return null;
123
154
  }
124
155
  /**
125
156
  * 缓存命中率(百分数,一位小数):`cacheRead / (input + output + cacheRead)`。
@@ -187,6 +218,7 @@ export function summarizeSession(fold) {
187
218
  cacheHitRate: cacheHitRateOf(totals),
188
219
  firstTime: fold.firstTime,
189
220
  lastTime: fold.lastTime,
221
+ firstPrompt: fold.firstPrompt,
190
222
  primaryModelKey: models.length > 0 ? models[0].key : null,
191
223
  days,
192
224
  models,