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/README.md +34 -12
- package/client/client.js +905 -260
- package/client/client.js.map +1 -1
- package/lib/index.js +4 -1
- package/lib/live-quota.js +96 -30
- package/lib/quota-snapshots.js +68 -0
- package/lib/routes.js +70 -11
- package/lib/session-meta.js +101 -0
- package/lib/session-usage.js +33 -1
- package/lib/sessions.js +58 -4
- package/package.json +9 -3
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
|
|
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
|
-
/**
|
|
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,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 {
|
|
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
|
|
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:
|
|
204
|
-
credential:
|
|
237
|
+
displayName: host?.displayName ?? channel.displayName,
|
|
238
|
+
credential: host?.credential ?? channel.apiKeyEnv,
|
|
239
|
+
supported,
|
|
205
240
|
source: 'snapshot',
|
|
206
241
|
fetchedAt: null,
|
|
207
|
-
updatedAt
|
|
242
|
+
updatedAt,
|
|
208
243
|
// 实时失败的原因要透出(未配置凭据 / 网络 / 鉴权),界面据此提示
|
|
209
|
-
error: liveEntry?.error ?? (
|
|
210
|
-
balance:
|
|
211
|
-
plan:
|
|
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
|
|
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
|
+
}
|
package/lib/session-usage.js
CHANGED
|
@@ -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,
|