dsh-key-usage 0.1.0 → 0.1.2
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 +156 -133
- package/client/client.js +159 -117
- package/client/client.js.map +1 -1
- package/lib/index.js +4 -1
- package/lib/routes.js +9 -3
- package/lib/session-meta.js +101 -0
- package/lib/session-usage.js +28 -1
- package/lib/sessions.js +13 -4
- package/package.json +3 -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/routes.js
CHANGED
|
@@ -20,6 +20,7 @@ import { join } from 'node:path';
|
|
|
20
20
|
import { aggregateRange, buildPriceTable, estimateCost, readLedger, } from './ledger.js';
|
|
21
21
|
import { classifyChannels, costModeLabel, extractChannels, quotaWindowLabel, readProviderSnapshots, } from './collect.js';
|
|
22
22
|
import { hoursOfDate, projectRowsOf, sessionRowsOf, } from './sessions.js';
|
|
23
|
+
import { createArchiveSource, createTitleSource } from './session-meta.js';
|
|
23
24
|
import { mergeDailySeries, sessionDayRows, summarizeMergedByProvider, totalsOfMerged, } from './merge.js';
|
|
24
25
|
/** 无会话服务时的兜底数据源,保证台账相关端点照常工作。 */
|
|
25
26
|
export const unavailableSessionSource = {
|
|
@@ -62,9 +63,13 @@ function channelPayload(channel) {
|
|
|
62
63
|
quotaWindowLabel: channel.quotaWindowKey === null ? null : quotaWindowLabel(channel.quotaWindowKey),
|
|
63
64
|
};
|
|
64
65
|
}
|
|
65
|
-
export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessionSource, liveQuota = disabledLiveQuota) {
|
|
66
|
+
export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessionSource, liveQuota = disabledLiveQuota, hostServices = { get: () => undefined }) {
|
|
66
67
|
const ledgerPath = join(dshHomePath(), 'dsh-usage', 'usage-ledger.json');
|
|
67
68
|
const snapshotPath = join(dshHomePath(), 'dsh-usage', 'provider-snapshots.json');
|
|
69
|
+
const titleSource = createTitleSource(join(dshHomePath(), 'storages', 'session_projcache.json'));
|
|
70
|
+
const archiveSource = createArchiveSource(hostServices, join(dshHomePath(), 'storages', 'workspace.json'));
|
|
71
|
+
/** 会话标题与归档状态:宿主服务/缓存文件都拿不到时返回空集合,界面自行回落。 */
|
|
72
|
+
const sessionMeta = () => ({ titles: titleSource.titles(), archived: archiveSource.archived() });
|
|
68
73
|
const loadLedger = () => readLedger(ledgerPath, (path) => {
|
|
69
74
|
try {
|
|
70
75
|
return readFileSync(path, 'utf8');
|
|
@@ -260,7 +265,8 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
|
|
|
260
265
|
const costModeOf = new Map(channels.map((channel) => [channel.provider, channel.costMode]));
|
|
261
266
|
const { records, coverage } = await sessionSource.collect();
|
|
262
267
|
const priceTable = buildPriceTable(loadLedger());
|
|
263
|
-
const
|
|
268
|
+
const meta = sessionMeta();
|
|
269
|
+
const rows = sessionRowsOf(records, knownProviders, meta).slice(0, limit).map((row) => ({
|
|
264
270
|
...row,
|
|
265
271
|
providerLabel: channels.find((channel) => channel.provider === row.provider)?.displayName ?? row.provider,
|
|
266
272
|
costMode: row.provider === null ? null : costModeOf.get(row.provider) ?? null,
|
|
@@ -281,7 +287,7 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
|
|
|
281
287
|
const knownProviders = channels.map((channel) => channel.provider);
|
|
282
288
|
const { records, coverage } = await sessionSource.collect();
|
|
283
289
|
const priceTable = buildPriceTable(loadLedger());
|
|
284
|
-
const rows = projectRowsOf(records, knownProviders).slice(0, limit).map((row) => ({
|
|
290
|
+
const rows = projectRowsOf(records, knownProviders, sessionMeta()).slice(0, limit).map((row) => ({
|
|
285
291
|
...row,
|
|
286
292
|
dominantProvider: Object.entries(row.providers).sort((first, second) => second[1] - first[1])[0]?.[0] ?? null,
|
|
287
293
|
cost: estimateCost(row.models, priceTable),
|
|
@@ -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,28 @@ 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
|
+
/** 标题兜底:取 user/message 里第一个非空文本块,压平空白并截断。 */
|
|
130
|
+
function promptTextOf(event) {
|
|
131
|
+
const message = event.data?.message;
|
|
132
|
+
if (typeof message !== 'object' || message === null)
|
|
133
|
+
return null;
|
|
134
|
+
const content = message.content;
|
|
135
|
+
if (!Array.isArray(content))
|
|
136
|
+
return null;
|
|
137
|
+
for (const block of content) {
|
|
138
|
+
if (typeof block !== 'object' || block === null)
|
|
139
|
+
continue;
|
|
140
|
+
const text = block.text;
|
|
141
|
+
if (typeof text !== 'string')
|
|
142
|
+
continue;
|
|
143
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
144
|
+
if (flat === '')
|
|
145
|
+
continue;
|
|
146
|
+
return flat.length > 60 ? flat.slice(0, 60) + '…' : flat;
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
123
149
|
}
|
|
124
150
|
/**
|
|
125
151
|
* 缓存命中率(百分数,一位小数):`cacheRead / (input + output + cacheRead)`。
|
|
@@ -187,6 +213,7 @@ export function summarizeSession(fold) {
|
|
|
187
213
|
cacheHitRate: cacheHitRateOf(totals),
|
|
188
214
|
firstTime: fold.firstTime,
|
|
189
215
|
lastTime: fold.lastTime,
|
|
216
|
+
firstPrompt: fold.firstPrompt,
|
|
190
217
|
primaryModelKey: models.length > 0 ? models[0].key : null,
|
|
191
218
|
days,
|
|
192
219
|
models,
|
package/lib/sessions.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* 服务缺失或读取失败时降级为空结果 + coverage 说明,绝不影响台账相关的其它端点。
|
|
7
7
|
*/
|
|
8
8
|
import { attributeModelKey, foldSessionEvents, summarizeSession, } from './session-usage.js';
|
|
9
|
+
import { normalizeSessionId } from './session-meta.js';
|
|
9
10
|
/**
|
|
10
11
|
* 一次最多折叠的会话数:按更新时间倒序取,避免首次请求扫全量历史。
|
|
11
12
|
* 本机会话约 120 个 / 143 MB,取 200 覆盖全量;折叠结果按 revision 缓存,
|
|
@@ -192,11 +193,15 @@ function attributedModels(models, knownProviders) {
|
|
|
192
193
|
};
|
|
193
194
|
});
|
|
194
195
|
}
|
|
195
|
-
export function sessionRowsOf(records, knownProviders) {
|
|
196
|
+
export function sessionRowsOf(records, knownProviders, meta = {}) {
|
|
197
|
+
const titles = meta.titles ?? new Map();
|
|
198
|
+
const archived = meta.archived ?? new Set();
|
|
196
199
|
return records
|
|
197
200
|
.map((record) => ({
|
|
198
201
|
id: record.id,
|
|
199
|
-
|
|
202
|
+
// 标题优先级:宿主投影缓存的标题 → 会话头自带字段 → 首条用户提问 → 界面回落成短 id
|
|
203
|
+
title: titles.get(normalizeSessionId(record.id)) ?? record.title ?? record.summary.firstPrompt ?? null,
|
|
204
|
+
archived: archived.has(normalizeSessionId(record.id)),
|
|
200
205
|
workspace: record.workspace,
|
|
201
206
|
provider: record.summary.primaryModelKey === null ? null : attributeModelKey(record.summary.primaryModelKey, knownProviders).provider,
|
|
202
207
|
firstTime: record.summary.firstTime,
|
|
@@ -209,7 +214,8 @@ export function sessionRowsOf(records, knownProviders) {
|
|
|
209
214
|
}))
|
|
210
215
|
.sort((first, second) => (second.lastTime ?? 0) - (first.lastTime ?? 0));
|
|
211
216
|
}
|
|
212
|
-
export function projectRowsOf(records, knownProviders) {
|
|
217
|
+
export function projectRowsOf(records, knownProviders, meta = {}) {
|
|
218
|
+
const archived = meta.archived ?? new Set();
|
|
213
219
|
const byWorkspace = new Map();
|
|
214
220
|
for (const record of records) {
|
|
215
221
|
const workspace = record.workspace ?? '未归属工作区';
|
|
@@ -217,7 +223,7 @@ export function projectRowsOf(records, knownProviders) {
|
|
|
217
223
|
if (accumulator === undefined) {
|
|
218
224
|
accumulator = {
|
|
219
225
|
workspace, calls: 0, tokens: 0, cacheReadTokens: 0, inputTokens: 0, outputTokens: 0, cacheWriteTokens: 0,
|
|
220
|
-
sessionCount: 0, lastTime: null, providers: new Map(), models: new Map(),
|
|
226
|
+
sessionCount: 0, archivedSessionCount: 0, lastTime: null, providers: new Map(), models: new Map(),
|
|
221
227
|
};
|
|
222
228
|
byWorkspace.set(workspace, accumulator);
|
|
223
229
|
}
|
|
@@ -228,6 +234,8 @@ export function projectRowsOf(records, knownProviders) {
|
|
|
228
234
|
accumulator.cacheReadTokens += record.summary.buckets.cacheReadTokens;
|
|
229
235
|
accumulator.cacheWriteTokens += record.summary.buckets.cacheWriteTokens;
|
|
230
236
|
accumulator.sessionCount += 1;
|
|
237
|
+
if (archived.has(normalizeSessionId(record.id)))
|
|
238
|
+
accumulator.archivedSessionCount += 1;
|
|
231
239
|
const lastTime = record.summary.lastTime;
|
|
232
240
|
if (lastTime !== null && (accumulator.lastTime === null || lastTime > accumulator.lastTime))
|
|
233
241
|
accumulator.lastTime = lastTime;
|
|
@@ -259,6 +267,7 @@ export function projectRowsOf(records, knownProviders) {
|
|
|
259
267
|
tokens: accumulator.tokens,
|
|
260
268
|
cacheHitRate: displayTokens > 0 ? Math.round(accumulator.cacheReadTokens / displayTokens * 1000) / 10 : null,
|
|
261
269
|
sessionCount: accumulator.sessionCount,
|
|
270
|
+
archivedSessionCount: accumulator.archivedSessionCount,
|
|
262
271
|
lastTime: accumulator.lastTime,
|
|
263
272
|
providers: Object.fromEntries(accumulator.providers),
|
|
264
273
|
models: [...accumulator.models.values()].sort((first, second) => second.tokens - first.tokens),
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-key-usage",
|
|
3
3
|
"description": "DeepSeek Harness 侧边栏「使用情况」:按通道展示用量明细、活跃热力图、项目/会话用量与官方余额/配额(实时优先,失败回落快照)",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.2",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"keywords": [
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
},
|
|
47
47
|
"scripts": {
|
|
48
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"
|
|
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
51
|
},
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"@deepseek-ai/cordis": "^4.0.1"
|