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/README.md +34 -12
- package/client/client.js +847 -244
- package/client/client.js.map +1 -1
- package/lib/live-quota.js +96 -30
- package/lib/quota-snapshots.js +68 -0
- package/lib/routes.js +61 -8
- package/lib/session-usage.js +13 -8
- package/lib/sessions.js +45 -0
- package/package.json +9 -3
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
|
-
/**
|
|
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
|
-
|
|
28
|
-
|
|
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:
|
|
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
|
-
|
|
52
|
-
const
|
|
53
|
-
|
|
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
|
|
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 =
|
|
76
|
-
const totalUsage =
|
|
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
|
|
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
|
|
107
|
-
const
|
|
108
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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:
|
|
209
|
-
credential:
|
|
237
|
+
displayName: host?.displayName ?? channel.displayName,
|
|
238
|
+
credential: host?.credential ?? channel.apiKeyEnv,
|
|
239
|
+
supported,
|
|
210
240
|
source: 'snapshot',
|
|
211
241
|
fetchedAt: null,
|
|
212
|
-
updatedAt
|
|
242
|
+
updatedAt,
|
|
213
243
|
// 实时失败的原因要透出(未配置凭据 / 网络 / 鉴权),界面据此提示
|
|
214
|
-
error: liveEntry?.error ?? (
|
|
215
|
-
balance:
|
|
216
|
-
plan:
|
|
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
|
* 把实时余额 / 窗口合并进快照数组,供通道状态判定(额度百分比、余额文案)使用。
|
package/lib/session-usage.js
CHANGED
|
@@ -126,18 +126,23 @@ export function foldSessionEvents(events) {
|
|
|
126
126
|
}
|
|
127
127
|
return { samples, firstTime, lastTime, firstPrompt };
|
|
128
128
|
}
|
|
129
|
-
/**
|
|
129
|
+
/** 标题兜底:取用户消息里第一个非空文本块,压平空白并截断。 */
|
|
130
130
|
function promptTextOf(event) {
|
|
131
|
-
const
|
|
132
|
-
if (typeof
|
|
131
|
+
const data = event.data;
|
|
132
|
+
if (typeof data !== 'object' || data === null)
|
|
133
133
|
return null;
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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.
|
|
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
|
-
"
|
|
50
|
-
"
|
|
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"
|