dsh-key-usage 0.1.2 → 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/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,8 +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';
23
25
  import { createArchiveSource, createTitleSource } from './session-meta.js';
26
+ import { readQuotaSnapshots, writeQuotaSnapshots } from './quota-snapshots.js';
24
27
  import { mergeDailySeries, sessionDayRows, summarizeMergedByProvider, totalsOfMerged, } from './merge.js';
25
28
  /** 无会话服务时的兜底数据源,保证台账相关端点照常工作。 */
26
29
  export const unavailableSessionSource = {
@@ -68,6 +71,7 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
68
71
  const snapshotPath = join(dshHomePath(), 'dsh-usage', 'provider-snapshots.json');
69
72
  const titleSource = createTitleSource(join(dshHomePath(), 'storages', 'session_projcache.json'));
70
73
  const archiveSource = createArchiveSource(hostServices, join(dshHomePath(), 'storages', 'workspace.json'));
74
+ const quotaSnapshotPath = join(dshHomePath(), 'dsh-key-usage', 'quota-snapshots.json');
71
75
  /** 会话标题与归档状态:宿主服务/缓存文件都拿不到时返回空集合,界面自行回落。 */
72
76
  const sessionMeta = () => ({ titles: titleSource.titles(), archived: archiveSource.archived() });
73
77
  const loadLedger = () => readLedger(ledgerPath, (path) => {
@@ -186,15 +190,31 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
186
190
  const live = url.searchParams.get('fresh') === '1'
187
191
  ? await liveQuota.refreshIfStale(channels, true)
188
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);
189
208
  const snapshots = readSnapshots();
190
209
  const providers = channels.map((channel) => {
191
210
  const liveEntry = live.get(channel.provider);
192
- const snapshot = snapshots.find((entry) => entry.provider === channel.provider) ?? null;
211
+ const supported = schemeIdOf(channel.provider) !== null;
193
212
  if (liveEntry !== undefined && liveEntry.ok) {
194
213
  return {
195
214
  provider: channel.provider,
196
215
  displayName: channel.displayName,
197
216
  credential: channel.apiKeyEnv,
217
+ supported,
198
218
  source: 'live',
199
219
  fetchedAt: liveEntry.fetchedAt,
200
220
  updatedAt: liveEntry.fetchedAt,
@@ -203,17 +223,27 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
203
223
  plan: liveEntry.windows === null ? null : { windows: liveEntry.windows },
204
224
  };
205
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;
206
235
  return {
207
236
  provider: channel.provider,
208
- displayName: snapshot?.displayName ?? channel.displayName,
209
- credential: snapshot?.credential ?? channel.apiKeyEnv,
237
+ displayName: host?.displayName ?? channel.displayName,
238
+ credential: host?.credential ?? channel.apiKeyEnv,
239
+ supported,
210
240
  source: 'snapshot',
211
241
  fetchedAt: null,
212
- updatedAt: snapshot?.updatedAt ?? null,
242
+ updatedAt,
213
243
  // 实时失败的原因要透出(未配置凭据 / 网络 / 鉴权),界面据此提示
214
- error: liveEntry?.error ?? (snapshot === null ? 'no-snapshot' : null),
215
- balance: snapshot?.balance ?? null,
216
- plan: snapshot?.plan ?? null,
244
+ error: liveEntry?.error ?? (updatedAt === null ? 'no-snapshot' : null),
245
+ balance: fallbackBalance,
246
+ plan: fallbackPlan,
217
247
  };
218
248
  });
219
249
  sendJson(response, 200, { providers });
@@ -295,6 +325,29 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
295
325
  sendJson(response, 200, { rows, coverage, priced: priceTable.size > 0 });
296
326
  },
297
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
+ });
298
351
  }
299
352
  /**
300
353
  * 把实时余额 / 窗口合并进快照数组,供通道状态判定(额度百分比、余额文案)使用。
@@ -126,18 +126,23 @@ export function foldSessionEvents(events) {
126
126
  }
127
127
  return { samples, firstTime, lastTime, firstPrompt };
128
128
  }
129
- /** 标题兜底:取 user/message 里第一个非空文本块,压平空白并截断。 */
129
+ /** 标题兜底:取用户消息里第一个非空文本块,压平空白并截断。 */
130
130
  function promptTextOf(event) {
131
- const message = event.data?.message;
132
- if (typeof message !== 'object' || message === null)
131
+ const data = event.data;
132
+ if (typeof data !== 'object' || data === null)
133
133
  return null;
134
- const content = message.content;
135
- if (!Array.isArray(content))
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)
136
140
  return null;
137
141
  for (const block of content) {
138
- if (typeof block !== 'object' || block === null)
139
- continue;
140
- const text = block.text;
142
+ // 文本块既可能是对象,也可能是裸字符串
143
+ const text = typeof block === 'string'
144
+ ? block
145
+ : typeof block === 'object' && block !== null ? block.text : null;
141
146
  if (typeof text !== 'string')
142
147
  continue;
143
148
  const flat = text.replace(/\s+/g, ' ').trim();
package/lib/sessions.js CHANGED
@@ -214,6 +214,51 @@ export function sessionRowsOf(records, knownProviders, meta = {}) {
214
214
  }))
215
215
  .sort((first, second) => (second.lastTime ?? 0) - (first.lastTime ?? 0));
216
216
  }
217
+ export function modelRowsOf(records, knownProviders) {
218
+ const byModel = new Map();
219
+ for (const record of records) {
220
+ const sessionLastTime = record.summary.lastTime;
221
+ for (const model of attributedModels(record.summary.models, knownProviders)) {
222
+ const key = model.provider + '|' + model.model;
223
+ let accumulator = byModel.get(key);
224
+ if (accumulator === undefined) {
225
+ accumulator = {
226
+ provider: model.provider, model: model.model, calls: 0,
227
+ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, sessionCount: 0, lastTime: null,
228
+ };
229
+ byModel.set(key, accumulator);
230
+ }
231
+ accumulator.calls += model.calls;
232
+ accumulator.inputTokens += model.buckets.inputTokens;
233
+ accumulator.outputTokens += model.buckets.outputTokens;
234
+ accumulator.cacheReadTokens += model.buckets.cacheReadTokens;
235
+ accumulator.sessionCount += 1;
236
+ // 会话事件只有会话级时间戳,「最近活跃」只能取用到该模型的最晚会话时间,是近似值
237
+ if (sessionLastTime !== null && (accumulator.lastTime === null || sessionLastTime > accumulator.lastTime)) {
238
+ accumulator.lastTime = sessionLastTime;
239
+ }
240
+ }
241
+ }
242
+ return [...byModel.values()]
243
+ .map((accumulator) => {
244
+ const displayTokens = accumulator.inputTokens + accumulator.outputTokens + accumulator.cacheReadTokens;
245
+ return {
246
+ provider: accumulator.provider,
247
+ model: accumulator.model,
248
+ calls: accumulator.calls,
249
+ tokens: displayTokens,
250
+ cacheHitRate: displayTokens > 0 ? Math.round(accumulator.cacheReadTokens / displayTokens * 1000) / 10 : null,
251
+ sessionCount: accumulator.sessionCount,
252
+ lastTime: accumulator.lastTime,
253
+ buckets: {
254
+ inputTokens: accumulator.inputTokens,
255
+ outputTokens: accumulator.outputTokens,
256
+ cacheReadTokens: accumulator.cacheReadTokens,
257
+ },
258
+ };
259
+ })
260
+ .sort((first, second) => second.tokens - first.tokens);
261
+ }
217
262
  export function projectRowsOf(records, knownProviders, meta = {}) {
218
263
  const archived = meta.archived ?? new Set();
219
264
  const byWorkspace = new Map();
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "dsh-key-usage",
3
3
  "description": "DeepSeek Harness 侧边栏「使用情况」:按通道展示用量明细、活跃热力图、项目/会话用量与官方余额/配额(实时优先,失败回落快照)",
4
- "version": "0.1.2",
4
+ "version": "0.1.3",
5
5
  "license": "MIT",
6
+ "packageManager": "pnpm@10.32.1",
6
7
  "type": "module",
7
8
  "keywords": [
8
9
  "deepseek",
@@ -45,9 +46,14 @@
45
46
  "registry": "https://registry.npmjs.org/"
46
47
  },
47
48
  "scripts": {
49
+ "build": "tsc -p tsconfig.server.json && tsdown && node scripts/normalize-client-banner.mjs",
48
50
  "build:client": "tsdown && node scripts/normalize-client-banner.mjs",
49
- "check": "node --check lib/index.js && node --check lib/ledger.js && node --check lib/collect.js && node --check lib/routes.js && node --check lib/sessions.js && node --check lib/session-usage.js && node --check lib/session-meta.js && node --check lib/merge.js && node --check lib/live-quota.js",
50
- "test": "node test/ledger.test.mjs && node test/collect.test.mjs && node test/channels.test.mjs && node test/session-usage.test.mjs && node test/session-meta.test.mjs && node test/sessions.test.mjs && node test/merge.test.mjs && node test/live-quota.test.mjs && node test/routes.test.mjs"
51
+ "build:server": "tsc -p tsconfig.server.json",
52
+ "typecheck": "tsc -p tsconfig.json --noEmit",
53
+ "check": "node --check lib/index.js && node --check lib/ledger.js && node --check lib/collect.js && node --check lib/routes.js && node --check lib/sessions.js && node --check lib/session-usage.js && node --check lib/session-meta.js && node --check lib/quota-snapshots.js && node --check lib/merge.js && node --check lib/live-quota.js",
54
+ "test": "node test/ledger.test.mjs && node test/collect.test.mjs && node test/channels.test.mjs && node test/session-usage.test.mjs && node test/session-meta.test.mjs && node test/sessions.test.mjs && node test/merge.test.mjs && node test/live-quota.test.mjs && node test/routes.test.mjs",
55
+ "verify": "npm run typecheck && npm run build && npm run check && npm run test",
56
+ "pack:dry": "npm pack --dry-run"
51
57
  },
52
58
  "peerDependencies": {
53
59
  "@deepseek-ai/cordis": "^4.0.1"