dsh-key-usage 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +187 -2
- package/client/client.js +2280 -0
- package/client/client.js.map +1 -0
- package/cordis.patch.yml +4 -0
- package/docs/images/usage-daily.png +0 -0
- package/docs/images/usage-overview.png +0 -0
- package/docs/images/usage-quota.png +0 -0
- package/lib/collect.js +237 -0
- package/lib/index.js +42 -0
- package/lib/ledger.js +163 -0
- package/lib/live-quota.js +225 -0
- package/lib/merge.js +156 -0
- package/lib/routes.js +351 -0
- package/lib/session-usage.js +235 -0
- package/lib/sessions.js +289 -0
- package/package.json +78 -4
package/lib/sessions.js
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话数据源:把宿主 `sessionPersistence` 服务包装成「按需折叠 + 按 revision 缓存」的
|
|
3
|
+
* 只读数据源,供项目用量、会话用量与单日按小时视图使用。
|
|
4
|
+
*
|
|
5
|
+
* 只走官方 read handle(open("read") → read(0) → close),不自行解压、不改写历史日志。
|
|
6
|
+
* 服务缺失或读取失败时降级为空结果 + coverage 说明,绝不影响台账相关的其它端点。
|
|
7
|
+
*/
|
|
8
|
+
import { attributeModelKey, foldSessionEvents, summarizeSession, } from './session-usage.js';
|
|
9
|
+
/**
|
|
10
|
+
* 一次最多折叠的会话数:按更新时间倒序取,避免首次请求扫全量历史。
|
|
11
|
+
* 本机会话约 120 个 / 143 MB,取 200 覆盖全量;折叠结果按 revision 缓存,
|
|
12
|
+
* 只有首次请求(或会话变化)才重新读取事件。
|
|
13
|
+
*/
|
|
14
|
+
const MAX_SESSIONS = 200;
|
|
15
|
+
function firstString(...values) {
|
|
16
|
+
for (const value of values) {
|
|
17
|
+
if (typeof value === 'string' && value.trim() !== '')
|
|
18
|
+
return value;
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
function metadataOf(header) {
|
|
23
|
+
return {
|
|
24
|
+
title: firstString(header.title, header.name, header.summary, header.label),
|
|
25
|
+
workspace: firstString(header.cwd, header.workspace, header.workspacePath, header.projectPath, header.project),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function sortKeyOf(header, fallback) {
|
|
29
|
+
const updatedAt = header.updatedAt;
|
|
30
|
+
if (typeof updatedAt === 'number')
|
|
31
|
+
return updatedAt;
|
|
32
|
+
const createdAt = header.createdAt;
|
|
33
|
+
if (typeof createdAt === 'number')
|
|
34
|
+
return createdAt;
|
|
35
|
+
return fallback;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* 建立会话数据源。`host` 只需提供 cordis 风格的 `get(name)`;
|
|
39
|
+
* `sessionPersistence` 缺失时返回 unavailable,调用方据此显示空态。
|
|
40
|
+
*/
|
|
41
|
+
export function createSessionSource(host) {
|
|
42
|
+
const cache = new Map();
|
|
43
|
+
let inFlight = null;
|
|
44
|
+
async function loadModern(persistence) {
|
|
45
|
+
const snapshots = await persistence.list();
|
|
46
|
+
const ordered = snapshots
|
|
47
|
+
.map((snapshot, index) => ({ snapshot, order: sortKeyOf(snapshot.header, index) }))
|
|
48
|
+
.sort((first, second) => second.order - first.order)
|
|
49
|
+
.slice(0, MAX_SESSIONS);
|
|
50
|
+
const present = new Set();
|
|
51
|
+
let skipped = 0;
|
|
52
|
+
for (const { snapshot } of ordered) {
|
|
53
|
+
const id = firstString(snapshot.header.id);
|
|
54
|
+
if (id === null)
|
|
55
|
+
continue;
|
|
56
|
+
present.add(id);
|
|
57
|
+
const revision = snapshot.revision;
|
|
58
|
+
const eventCount = typeof snapshot.eventCount === 'number' ? snapshot.eventCount : -1;
|
|
59
|
+
const cached = cache.get(id);
|
|
60
|
+
if (cached !== undefined && cached.revision === revision && cached.eventCount === eventCount)
|
|
61
|
+
continue;
|
|
62
|
+
let handle;
|
|
63
|
+
try {
|
|
64
|
+
handle = await persistence.open?.(id, 'read');
|
|
65
|
+
if (handle === undefined) {
|
|
66
|
+
skipped += 1;
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
const { events } = await handle.read(0);
|
|
70
|
+
const inherited = typeof handle.inheritedEventCount === 'number' ? handle.inheritedEventCount : 0;
|
|
71
|
+
if (!Number.isSafeInteger(inherited) || inherited < 0 || inherited > events.length) {
|
|
72
|
+
skipped += 1;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
// 继承前缀只用于建立模型归属,不计入本次用量
|
|
76
|
+
const inheritedHeaders = events.slice(0, inherited).filter((event) => event.type === 'request/header');
|
|
77
|
+
const fold = foldSessionEvents([...inheritedHeaders, ...events.slice(inherited)]);
|
|
78
|
+
const metadata = metadataOf(snapshot.header);
|
|
79
|
+
cache.set(id, {
|
|
80
|
+
revision,
|
|
81
|
+
eventCount,
|
|
82
|
+
record: { id, title: metadata.title, workspace: metadata.workspace, summary: summarizeSession(fold) },
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
cache.delete(id);
|
|
87
|
+
skipped += 1;
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
if (handle !== undefined) {
|
|
91
|
+
try {
|
|
92
|
+
await handle.close();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
skipped += 1;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
for (const id of [...cache.keys()])
|
|
101
|
+
if (!present.has(id))
|
|
102
|
+
cache.delete(id);
|
|
103
|
+
const records = ordered
|
|
104
|
+
.map(({ snapshot }) => firstString(snapshot.header.id))
|
|
105
|
+
.filter((id) => id !== null)
|
|
106
|
+
.map((id) => cache.get(id)?.record)
|
|
107
|
+
.filter((record) => record !== undefined);
|
|
108
|
+
return { records, coverage: coverageOf(ordered.length, records.length, skipped) };
|
|
109
|
+
}
|
|
110
|
+
async function loadLegacy(persistence, live) {
|
|
111
|
+
const merged = new Map();
|
|
112
|
+
let total = 0;
|
|
113
|
+
let skipped = 0;
|
|
114
|
+
if (live !== null) {
|
|
115
|
+
for (const session of live.list()) {
|
|
116
|
+
total += 1;
|
|
117
|
+
try {
|
|
118
|
+
const fold = foldSessionEvents(session.events ?? []);
|
|
119
|
+
const metadata = { title: firstString(session.title), workspace: firstString(session.cwd) };
|
|
120
|
+
merged.set(session.id, { id: session.id, title: metadata.title, workspace: metadata.workspace, summary: summarizeSession(fold) });
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
skipped += 1;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (typeof persistence.list === 'function' && typeof persistence.readFrom === 'function') {
|
|
128
|
+
for (const snapshot of await persistence.list()) {
|
|
129
|
+
const id = firstString(snapshot.header.id);
|
|
130
|
+
if (id === null || merged.has(id))
|
|
131
|
+
continue;
|
|
132
|
+
total += 1;
|
|
133
|
+
try {
|
|
134
|
+
const { events } = await persistence.readFrom(id, 0);
|
|
135
|
+
const fold = foldSessionEvents(events);
|
|
136
|
+
const metadata = metadataOf(snapshot.header);
|
|
137
|
+
merged.set(id, { id, title: metadata.title, workspace: metadata.workspace, summary: summarizeSession(fold) });
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
skipped += 1;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const records = [...merged.values()];
|
|
145
|
+
return { records, coverage: coverageOf(total, records.length, skipped) };
|
|
146
|
+
}
|
|
147
|
+
async function collect() {
|
|
148
|
+
if (inFlight !== null)
|
|
149
|
+
return inFlight;
|
|
150
|
+
inFlight = (async () => {
|
|
151
|
+
try {
|
|
152
|
+
const persistence = host.get('sessionPersistence');
|
|
153
|
+
const live = host.get('sessions') ?? null;
|
|
154
|
+
if (persistence === undefined || typeof persistence.list !== 'function') {
|
|
155
|
+
return { records: [], coverage: { status: 'unavailable', totalSessions: 0, countedSessions: 0, skippedSessions: 0 } };
|
|
156
|
+
}
|
|
157
|
+
if (typeof persistence.open === 'function')
|
|
158
|
+
return await loadModern(persistence);
|
|
159
|
+
return await loadLegacy(persistence, live);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return { records: [], coverage: { status: 'unavailable', totalSessions: 0, countedSessions: 0, skippedSessions: 0 } };
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
inFlight = null;
|
|
166
|
+
}
|
|
167
|
+
})();
|
|
168
|
+
return inFlight;
|
|
169
|
+
}
|
|
170
|
+
return { collect };
|
|
171
|
+
}
|
|
172
|
+
function coverageOf(totalSessions, countedSessions, skippedSessions) {
|
|
173
|
+
if (countedSessions === 0 && totalSessions > 0) {
|
|
174
|
+
return { status: 'unavailable', totalSessions, countedSessions, skippedSessions };
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
status: skippedSessions > 0 ? 'partial' : 'complete',
|
|
178
|
+
totalSessions,
|
|
179
|
+
countedSessions,
|
|
180
|
+
skippedSessions,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function attributedModels(models, knownProviders) {
|
|
184
|
+
return models.map((model) => {
|
|
185
|
+
const attribution = attributeModelKey(model.key, knownProviders);
|
|
186
|
+
return {
|
|
187
|
+
provider: attribution.provider,
|
|
188
|
+
model: attribution.model,
|
|
189
|
+
tokens: model.tokens,
|
|
190
|
+
calls: model.calls,
|
|
191
|
+
buckets: { ...model.buckets },
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
export function sessionRowsOf(records, knownProviders) {
|
|
196
|
+
return records
|
|
197
|
+
.map((record) => ({
|
|
198
|
+
id: record.id,
|
|
199
|
+
title: record.title,
|
|
200
|
+
workspace: record.workspace,
|
|
201
|
+
provider: record.summary.primaryModelKey === null ? null : attributeModelKey(record.summary.primaryModelKey, knownProviders).provider,
|
|
202
|
+
firstTime: record.summary.firstTime,
|
|
203
|
+
lastTime: record.summary.lastTime,
|
|
204
|
+
calls: record.summary.calls,
|
|
205
|
+
tokens: record.summary.tokens,
|
|
206
|
+
cacheHitRate: record.summary.cacheHitRate,
|
|
207
|
+
days: record.summary.days.length,
|
|
208
|
+
models: attributedModels(record.summary.models, knownProviders),
|
|
209
|
+
}))
|
|
210
|
+
.sort((first, second) => (second.lastTime ?? 0) - (first.lastTime ?? 0));
|
|
211
|
+
}
|
|
212
|
+
export function projectRowsOf(records, knownProviders) {
|
|
213
|
+
const byWorkspace = new Map();
|
|
214
|
+
for (const record of records) {
|
|
215
|
+
const workspace = record.workspace ?? '未归属工作区';
|
|
216
|
+
let accumulator = byWorkspace.get(workspace);
|
|
217
|
+
if (accumulator === undefined) {
|
|
218
|
+
accumulator = {
|
|
219
|
+
workspace, calls: 0, tokens: 0, cacheReadTokens: 0, inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0,
|
|
220
|
+
sessionCount: 0, lastTime: null, providers: new Map(), models: new Map(),
|
|
221
|
+
};
|
|
222
|
+
byWorkspace.set(workspace, accumulator);
|
|
223
|
+
}
|
|
224
|
+
accumulator.calls += record.summary.calls;
|
|
225
|
+
accumulator.tokens += record.summary.tokens;
|
|
226
|
+
accumulator.inputTokens += record.summary.buckets.inputTokens;
|
|
227
|
+
accumulator.outputTokens += record.summary.buckets.outputTokens;
|
|
228
|
+
accumulator.cacheReadTokens += record.summary.buckets.cacheReadTokens;
|
|
229
|
+
accumulator.cacheWriteTokens += record.summary.buckets.cacheWriteTokens;
|
|
230
|
+
accumulator.sessionCount += 1;
|
|
231
|
+
const lastTime = record.summary.lastTime;
|
|
232
|
+
if (lastTime !== null && (accumulator.lastTime === null || lastTime > accumulator.lastTime))
|
|
233
|
+
accumulator.lastTime = lastTime;
|
|
234
|
+
if (record.summary.primaryModelKey !== null) {
|
|
235
|
+
const provider = attributeModelKey(record.summary.primaryModelKey, knownProviders).provider;
|
|
236
|
+
accumulator.providers.set(provider, (accumulator.providers.get(provider) ?? 0) + record.summary.tokens);
|
|
237
|
+
}
|
|
238
|
+
for (const model of attributedModels(record.summary.models, knownProviders)) {
|
|
239
|
+
const modelKey = model.provider + '|' + model.model;
|
|
240
|
+
let row = accumulator.models.get(modelKey);
|
|
241
|
+
if (row === undefined) {
|
|
242
|
+
row = { key: modelKey, provider: model.provider, model: model.model, tokens: 0, calls: 0, buckets: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } };
|
|
243
|
+
accumulator.models.set(modelKey, row);
|
|
244
|
+
}
|
|
245
|
+
row.tokens += model.tokens;
|
|
246
|
+
row.calls += model.calls;
|
|
247
|
+
row.buckets.inputTokens += model.buckets.inputTokens;
|
|
248
|
+
row.buckets.outputTokens += model.buckets.outputTokens;
|
|
249
|
+
row.buckets.cacheReadTokens += model.buckets.cacheReadTokens;
|
|
250
|
+
row.buckets.cacheWriteTokens += model.buckets.cacheWriteTokens;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return [...byWorkspace.values()]
|
|
254
|
+
.map((accumulator) => {
|
|
255
|
+
const displayTokens = accumulator.inputTokens + accumulator.outputTokens + accumulator.cacheReadTokens;
|
|
256
|
+
return {
|
|
257
|
+
workspace: accumulator.workspace,
|
|
258
|
+
calls: accumulator.calls,
|
|
259
|
+
tokens: accumulator.tokens,
|
|
260
|
+
cacheHitRate: displayTokens > 0 ? Math.round(accumulator.cacheReadTokens / displayTokens * 1000) / 10 : null,
|
|
261
|
+
sessionCount: accumulator.sessionCount,
|
|
262
|
+
lastTime: accumulator.lastTime,
|
|
263
|
+
providers: Object.fromEntries(accumulator.providers),
|
|
264
|
+
models: [...accumulator.models.values()].sort((first, second) => second.tokens - first.tokens),
|
|
265
|
+
};
|
|
266
|
+
})
|
|
267
|
+
.sort((first, second) => (second.lastTime ?? 0) - (first.lastTime ?? 0));
|
|
268
|
+
}
|
|
269
|
+
/** 指定日期的 24 小时分桶(跨会话汇总),供「使用明细」单日按小时视图使用。 */
|
|
270
|
+
export function hoursOfDate(records, date) {
|
|
271
|
+
const hours = Array.from({ length: 24 }, () => ({ calls: 0, buckets: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 } }));
|
|
272
|
+
for (const record of records) {
|
|
273
|
+
for (const day of record.summary.days) {
|
|
274
|
+
if (day.date !== date)
|
|
275
|
+
continue;
|
|
276
|
+
for (let hour = 0; hour < 24; hour += 1) {
|
|
277
|
+
const source = day.hours[hour];
|
|
278
|
+
if (source === undefined)
|
|
279
|
+
continue;
|
|
280
|
+
hours[hour].calls += source.calls;
|
|
281
|
+
hours[hour].buckets.inputTokens += source.buckets.inputTokens;
|
|
282
|
+
hours[hour].buckets.outputTokens += source.buckets.outputTokens;
|
|
283
|
+
hours[hour].buckets.cacheReadTokens += source.buckets.cacheReadTokens;
|
|
284
|
+
hours[hour].buckets.cacheWriteTokens += source.buckets.cacheWriteTokens;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return hours;
|
|
289
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,80 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-key-usage",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"description": "DeepSeek Harness 侧边栏「使用情况」:按通道展示用量明细、活跃热力图、项目/会话用量与官方余额/配额(实时优先,失败回落快照)",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"deepseek",
|
|
9
|
+
"deepseek-harness",
|
|
10
|
+
"dsh",
|
|
11
|
+
"dsh-plugin",
|
|
12
|
+
"usage",
|
|
13
|
+
"api-key"
|
|
14
|
+
],
|
|
15
|
+
"main": "lib/index.js",
|
|
16
|
+
"exports": {
|
|
17
|
+
".": "./lib/index.js",
|
|
18
|
+
"./client": "./client/client.js",
|
|
19
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"lib/",
|
|
24
|
+
"client/",
|
|
25
|
+
"cordis.patch.yml",
|
|
26
|
+
"README.md",
|
|
27
|
+
"LICENSE",
|
|
28
|
+
"docs/images/usage-overview.png",
|
|
29
|
+
"docs/images/usage-daily.png",
|
|
30
|
+
"docs/images/usage-quota.png"
|
|
31
|
+
],
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/xyl886/dsh-key-usage.git"
|
|
35
|
+
},
|
|
36
|
+
"homepage": "https://github.com/xyl886/dsh-key-usage",
|
|
37
|
+
"bugs": {
|
|
38
|
+
"url": "https://github.com/xyl886/dsh-key-usage/issues"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=22"
|
|
42
|
+
},
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public",
|
|
45
|
+
"registry": "https://registry.npmjs.org/"
|
|
46
|
+
},
|
|
47
|
+
"scripts": {
|
|
48
|
+
"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/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/sessions.test.mjs && node test/merge.test.mjs && node test/live-quota.test.mjs && node test/routes.test.mjs"
|
|
51
|
+
},
|
|
52
|
+
"peerDependencies": {
|
|
53
|
+
"@deepseek-ai/cordis": "^4.0.1"
|
|
54
|
+
},
|
|
55
|
+
"peerDependenciesMeta": {
|
|
56
|
+
"@deepseek-ai/cordis": {
|
|
57
|
+
"optional": true
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
"dsh": {
|
|
61
|
+
"bundle": {
|
|
62
|
+
"patch": "./cordis.patch.yml"
|
|
63
|
+
},
|
|
64
|
+
"client": {
|
|
65
|
+
"platform": "web",
|
|
66
|
+
"inject": [
|
|
67
|
+
"@deepseek-ai/dsh-client-locale",
|
|
68
|
+
"@deepseek-ai/dsh-client-ui-slots"
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
"devDependencies": {
|
|
73
|
+
"@types/node": "24.13.3",
|
|
74
|
+
"@types/react": "18.3.31",
|
|
75
|
+
"lightningcss": "1.33.0",
|
|
76
|
+
"react": "18.3.1",
|
|
77
|
+
"tsdown": "0.22.14",
|
|
78
|
+
"typescript": "7.0.2"
|
|
79
|
+
}
|
|
80
|
+
}
|