dsh-key-usage 0.1.2 → 0.1.4
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 +115 -24
- package/client/client.js +2793 -419
- package/client/client.js.map +1 -1
- 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/docs/images//346/214/211/346/250/241/345/236/213.png +0 -0
- package/docs/images//351/241/271/347/233/256/346/200/273/351/207/217.png +0 -0
- package/lib/index.js +73 -4
- package/lib/live-quota.js +96 -30
- package/lib/merge.js +8 -1
- package/lib/pricing-catalog.js +349 -0
- package/lib/pricing.js +500 -0
- package/lib/quota-snapshots.js +68 -0
- package/lib/routes.js +275 -28
- package/lib/session-usage.js +67 -46
- package/lib/sessions.js +98 -8
- package/package.json +18 -9
package/lib/routes.js
CHANGED
|
@@ -7,10 +7,15 @@
|
|
|
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。
|
|
13
14
|
*
|
|
15
|
+
* 成本口径(四处一致):台账当天的官方成本 > 用户计费规则 > 台账反推均价 > 无依据(界面显示 —)。
|
|
16
|
+
* 每个成本单元格都带 `costSource`(official / rule / estimate / none),界面据此打来源角标。
|
|
17
|
+
* 规则与成本算法见 pricing.ts,规则本体存在宿主设置服务里(本插件不写文件)。
|
|
18
|
+
*
|
|
14
19
|
* 网络:只有「余额与订阅窗口」会访问各通道**官方**接口(见 live-quota.ts),
|
|
15
20
|
* 失败即回落本地快照;其余端点零外网。
|
|
16
21
|
*/
|
|
@@ -19,9 +24,13 @@ import { homedir } from 'node:os';
|
|
|
19
24
|
import { join } from 'node:path';
|
|
20
25
|
import { aggregateRange, buildPriceTable, estimateCost, readLedger, } from './ledger.js';
|
|
21
26
|
import { classifyChannels, costModeLabel, extractChannels, quotaWindowLabel, readProviderSnapshots, } from './collect.js';
|
|
22
|
-
import {
|
|
27
|
+
import { schemeIdOf } from './live-quota.js';
|
|
28
|
+
import { hoursOfDate, modelRowsOf, projectRowsOf, sessionRowsOf, } from './sessions.js';
|
|
23
29
|
import { createArchiveSource, createTitleSource } from './session-meta.js';
|
|
30
|
+
import { readQuotaSnapshots, writeQuotaSnapshots } from './quota-snapshots.js';
|
|
24
31
|
import { mergeDailySeries, sessionDayRows, summarizeMergedByProvider, totalsOfMerged, } from './merge.js';
|
|
32
|
+
import { EMPTY_PRICING_VIEW, PRICING_TEMPLATES, displayTokensOf, isOffPeakNow, priceRowsByRules, rowCostsOf, summarizeCoverage, } from './pricing.js';
|
|
33
|
+
import { catalogEntryCount, catalogSources } from './pricing-catalog.js';
|
|
25
34
|
/** 无会话服务时的兜底数据源,保证台账相关端点照常工作。 */
|
|
26
35
|
export const unavailableSessionSource = {
|
|
27
36
|
collect: async () => ({
|
|
@@ -35,6 +44,77 @@ export const disabledLiveQuota = {
|
|
|
35
44
|
refreshIfStale: async () => new Map(),
|
|
36
45
|
};
|
|
37
46
|
const EMPTY_LEDGER = { version: 0, days: {} };
|
|
47
|
+
const SOURCE_RANK = { official: 0, rule: 1, catalog: 2, estimate: 3, none: 4 };
|
|
48
|
+
/** 一天里最弱的那一格决定整天的角标:宁可标弱,也不把估算冒充成规则价。 */
|
|
49
|
+
export function weakestSource(sources) {
|
|
50
|
+
if (sources.length === 0)
|
|
51
|
+
return 'none';
|
|
52
|
+
return sources.reduce((weakest, current) => (SOURCE_RANK[current] > SOURCE_RANK[weakest] ? current : weakest), sources[0]);
|
|
53
|
+
}
|
|
54
|
+
/** 会话侧的模型行 → 计费行(三种视图的 `models[]` 形状一致,用同一份映射)。 */
|
|
55
|
+
export function pricingRowOf(model) {
|
|
56
|
+
return {
|
|
57
|
+
provider: model.provider,
|
|
58
|
+
model: model.model,
|
|
59
|
+
calls: model.calls,
|
|
60
|
+
tokens: model.tokens,
|
|
61
|
+
inputTokens: model.buckets.inputTokens ?? 0,
|
|
62
|
+
outputTokens: model.buckets.outputTokens ?? 0,
|
|
63
|
+
cacheReadTokens: model.buckets.cacheReadTokens ?? 0,
|
|
64
|
+
cacheWriteTokens: model.buckets.cacheWriteTokens ?? 0,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/** 台账序列里的模型行 → 计费行(分桶在顶层字段上,不是 `buckets`)。 */
|
|
68
|
+
export function dayModelPricingRow(model) {
|
|
69
|
+
return {
|
|
70
|
+
provider: model.provider,
|
|
71
|
+
model: model.model,
|
|
72
|
+
calls: model.calls,
|
|
73
|
+
tokens: model.inputTokens + model.outputTokens + model.cacheReadTokens,
|
|
74
|
+
inputTokens: model.inputTokens,
|
|
75
|
+
outputTokens: model.outputTokens,
|
|
76
|
+
cacheReadTokens: model.cacheReadTokens,
|
|
77
|
+
cacheWriteTokens: model.cacheWriteTokens ?? 0,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/** 24 个小时槽 → 计费行(错峰占比只用得上 token 分桶)。 */
|
|
81
|
+
export function hoursAsPricingRows(hours) {
|
|
82
|
+
if (hours === null)
|
|
83
|
+
return null;
|
|
84
|
+
return hours.map((slot) => ({
|
|
85
|
+
provider: '',
|
|
86
|
+
model: '',
|
|
87
|
+
calls: slot.calls,
|
|
88
|
+
inputTokens: slot.buckets.inputTokens ?? 0,
|
|
89
|
+
outputTokens: slot.buckets.outputTokens ?? 0,
|
|
90
|
+
cacheReadTokens: slot.buckets.cacheReadTokens ?? 0,
|
|
91
|
+
cacheWriteTokens: slot.buckets.cacheWriteTokens ?? 0,
|
|
92
|
+
}));
|
|
93
|
+
}
|
|
94
|
+
/** 逐行金额求和;全部为 null(无任何依据)时返回 null,界面据此显示 —。 */
|
|
95
|
+
export function sumOrNull(costs) {
|
|
96
|
+
let total = 0;
|
|
97
|
+
let matched = false;
|
|
98
|
+
for (const entry of costs) {
|
|
99
|
+
if (entry.cost === null)
|
|
100
|
+
continue;
|
|
101
|
+
total += entry.cost;
|
|
102
|
+
matched = true;
|
|
103
|
+
}
|
|
104
|
+
return matched ? Math.round(total * 100) / 100 : null;
|
|
105
|
+
}
|
|
106
|
+
/** 某个通道在指定范围内的成本来源:取该通道所有模型行里最弱的那一格。 */
|
|
107
|
+
export function sourceOfProvider(days, provider, from, to) {
|
|
108
|
+
const sources = [];
|
|
109
|
+
for (const day of days) {
|
|
110
|
+
if (day.date < from || day.date > to)
|
|
111
|
+
continue;
|
|
112
|
+
for (const model of day.models)
|
|
113
|
+
if (model.provider === provider)
|
|
114
|
+
sources.push(model.costSource);
|
|
115
|
+
}
|
|
116
|
+
return sources.length === 0 ? 'none' : weakestSource(sources);
|
|
117
|
+
}
|
|
38
118
|
function sendJson(response, statusCode, body) {
|
|
39
119
|
const payload = JSON.stringify(body);
|
|
40
120
|
response.writeHead(statusCode, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-cache' });
|
|
@@ -63,11 +143,13 @@ function channelPayload(channel) {
|
|
|
63
143
|
quotaWindowLabel: channel.quotaWindowKey === null ? null : quotaWindowLabel(channel.quotaWindowKey),
|
|
64
144
|
};
|
|
65
145
|
}
|
|
66
|
-
export
|
|
146
|
+
export const noPricingSource = { current: () => EMPTY_PRICING_VIEW };
|
|
147
|
+
export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessionSource, liveQuota = disabledLiveQuota, hostServices = { get: () => undefined }, pricingSource = noPricingSource) {
|
|
67
148
|
const ledgerPath = join(dshHomePath(), 'dsh-usage', 'usage-ledger.json');
|
|
68
149
|
const snapshotPath = join(dshHomePath(), 'dsh-usage', 'provider-snapshots.json');
|
|
69
150
|
const titleSource = createTitleSource(join(dshHomePath(), 'storages', 'session_projcache.json'));
|
|
70
151
|
const archiveSource = createArchiveSource(hostServices, join(dshHomePath(), 'storages', 'workspace.json'));
|
|
152
|
+
const quotaSnapshotPath = join(dshHomePath(), 'dsh-key-usage', 'quota-snapshots.json');
|
|
71
153
|
/** 会话标题与归档状态:宿主服务/缓存文件都拿不到时返回空集合,界面自行回落。 */
|
|
72
154
|
const sessionMeta = () => ({ titles: titleSource.titles(), archived: archiveSource.archived() });
|
|
73
155
|
const loadLedger = () => readLedger(ledgerPath, (path) => {
|
|
@@ -79,6 +161,49 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
|
|
|
79
161
|
}
|
|
80
162
|
}) ?? EMPTY_LEDGER;
|
|
81
163
|
const readSnapshots = () => readProviderSnapshots(snapshotPath).providers;
|
|
164
|
+
/** 规则每次请求现取:用户在界面上改完立即生效,不需要重启宿主。 */
|
|
165
|
+
const pricingView = () => {
|
|
166
|
+
try {
|
|
167
|
+
return pricingSource.current();
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return EMPTY_PRICING_VIEW;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
const averagePriceOf = (priceTable) => (row) => estimateCost([{ provider: row.provider, model: row.model, tokens: displayTokensOf(row) }], priceTable);
|
|
174
|
+
/**
|
|
175
|
+
* 给合并序列补成本与来源:台账天保留官方成本(official,不重算),
|
|
176
|
+
* 会话补位天逐模型按规则估算,规则没命中再回落台账均价。
|
|
177
|
+
* @param offPeakHours - 单日范围才有的小时槽;给了就按错峰占比加权
|
|
178
|
+
*/
|
|
179
|
+
const annotateCosts = (days, sessionDates, priceTable, offPeakHours) => {
|
|
180
|
+
const sessionSet = new Set(sessionDates);
|
|
181
|
+
const view = pricingView();
|
|
182
|
+
return days.map((day) => {
|
|
183
|
+
if (!sessionSet.has(day.date)) {
|
|
184
|
+
// 台账天带官方账单金额:原样保留,不按规则重算
|
|
185
|
+
return {
|
|
186
|
+
...day,
|
|
187
|
+
costSource: 'official',
|
|
188
|
+
models: day.models.map((model) => ({ ...model, costSource: 'official' })),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
const models = day.models.map((model) => {
|
|
192
|
+
const row = dayModelPricingRow(model);
|
|
193
|
+
const priced = priceRowsByRules([row], view.config, { hours: offPeakHours })[0];
|
|
194
|
+
const fallback = estimateCost([{ provider: model.provider, model: model.model, tokens: displayTokensOf(row) }], priceTable);
|
|
195
|
+
const cost = priced.cost ?? fallback ?? 0;
|
|
196
|
+
const source = priced.cost !== null ? priced.source : fallback !== null ? 'estimate' : 'none';
|
|
197
|
+
return { ...model, cost: Math.round(cost * 100) / 100, costSource: source };
|
|
198
|
+
});
|
|
199
|
+
return {
|
|
200
|
+
...day,
|
|
201
|
+
cost: Math.round(models.reduce((total, model) => total + model.cost, 0) * 100) / 100,
|
|
202
|
+
costSource: weakestSource(models.map((model) => model.costSource)),
|
|
203
|
+
models,
|
|
204
|
+
};
|
|
205
|
+
});
|
|
206
|
+
};
|
|
82
207
|
const loadChannels = (live = new Map()) => {
|
|
83
208
|
// cordis.yml 是应用按需重写的组合产物:插件集变化期间会短暂变成空模板,
|
|
84
209
|
// 因此先读它,取不到再回退到用户 patch 层 cordis.patch.yml。
|
|
@@ -128,9 +253,12 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
|
|
|
128
253
|
const merged = mergeDailySeries(ledgerResult.days, sessionRows, from, to);
|
|
129
254
|
// 单日范围才提供小时细分(来自会话事件,口径与台账不同,见页脚口径说明)
|
|
130
255
|
const hours = from !== '' && from === to ? hoursOfDate(collection.records, from) : null;
|
|
256
|
+
// 单日有小时槽时按错峰占比加权;跨天拿不到小时分布,只能按标准价(不猜折扣)
|
|
257
|
+
const days = annotateCosts(merged.days, merged.sessionDates, buildPriceTable(ledger), hoursAsPricingRows(hours));
|
|
258
|
+
const totals = days.length > 0 ? totalsOfMerged(days) : null;
|
|
131
259
|
sendJson(response, 200, {
|
|
132
|
-
days
|
|
133
|
-
totals:
|
|
260
|
+
days,
|
|
261
|
+
totals: totals === null ? null : { ...totals, costSource: weakestSource(days.map((day) => day.costSource)) },
|
|
134
262
|
sessionDates: merged.sessionDates,
|
|
135
263
|
availableFrom: availableFrom ?? null,
|
|
136
264
|
availableTo: availableTo ?? null,
|
|
@@ -155,8 +283,10 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
|
|
|
155
283
|
const from = earliest > yearAgo ? earliest : yearAgo;
|
|
156
284
|
const to = maxKey(dayKeys[dayKeys.length - 1], sessionDates[sessionDates.length - 1]) ?? from;
|
|
157
285
|
const merged = mergeDailySeries(aggregateRange(ledger, from, to, null).days, sessionRows, from, to);
|
|
286
|
+
// 与「使用明细」同一口径:会话补位天按规则/均价估算,台账天保留官方成本
|
|
287
|
+
const days = annotateCosts(merged.days, merged.sessionDates, buildPriceTable(ledger), null);
|
|
158
288
|
sendJson(response, 200, {
|
|
159
|
-
days:
|
|
289
|
+
days: days
|
|
160
290
|
.map((day) => ({
|
|
161
291
|
date: day.date,
|
|
162
292
|
totalTokens: day.inputTokens + day.outputTokens + day.cacheReadTokens,
|
|
@@ -186,15 +316,31 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
|
|
|
186
316
|
const live = url.searchParams.get('fresh') === '1'
|
|
187
317
|
? await liveQuota.refreshIfStale(channels, true)
|
|
188
318
|
: await liveQuota.refreshIfStale(channels);
|
|
319
|
+
// 实时成功立刻落一份自己的快照:下次失败时回落的是「最近一次成功」,而不是宿主那份十几天前的旧值
|
|
320
|
+
const succeeded = [];
|
|
321
|
+
for (const entry of live.values()) {
|
|
322
|
+
if (!entry.ok)
|
|
323
|
+
continue;
|
|
324
|
+
succeeded.push({
|
|
325
|
+
provider: entry.provider,
|
|
326
|
+
displayName: channels.find((channel) => channel.provider === entry.provider)?.displayName ?? null,
|
|
327
|
+
updatedAt: entry.fetchedAt,
|
|
328
|
+
balance: entry.balance,
|
|
329
|
+
windows: entry.windows,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
writeQuotaSnapshots(quotaSnapshotPath, succeeded);
|
|
333
|
+
const ownSnapshots = readQuotaSnapshots(quotaSnapshotPath);
|
|
189
334
|
const snapshots = readSnapshots();
|
|
190
335
|
const providers = channels.map((channel) => {
|
|
191
336
|
const liveEntry = live.get(channel.provider);
|
|
192
|
-
const
|
|
337
|
+
const supported = schemeIdOf(channel.provider) !== null;
|
|
193
338
|
if (liveEntry !== undefined && liveEntry.ok) {
|
|
194
339
|
return {
|
|
195
340
|
provider: channel.provider,
|
|
196
341
|
displayName: channel.displayName,
|
|
197
342
|
credential: channel.apiKeyEnv,
|
|
343
|
+
supported,
|
|
198
344
|
source: 'live',
|
|
199
345
|
fetchedAt: liveEntry.fetchedAt,
|
|
200
346
|
updatedAt: liveEntry.fetchedAt,
|
|
@@ -203,17 +349,27 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
|
|
|
203
349
|
plan: liveEntry.windows === null ? null : { windows: liveEntry.windows },
|
|
204
350
|
};
|
|
205
351
|
}
|
|
352
|
+
// 回落顺序:自己的快照与宿主的快照里,取 updatedAt 更新的那一份
|
|
353
|
+
const own = ownSnapshots.find((entry) => entry.provider === channel.provider) ?? null;
|
|
354
|
+
const host = snapshots.find((entry) => entry.provider === channel.provider) ?? null;
|
|
355
|
+
const useOwn = own !== null && (host === null || own.updatedAt >= (host.updatedAt ?? 0));
|
|
356
|
+
const updatedAt = useOwn ? own.updatedAt : host?.updatedAt ?? null;
|
|
357
|
+
const fallbackBalance = useOwn ? own.balance : host?.balance ?? null;
|
|
358
|
+
const fallbackPlan = useOwn
|
|
359
|
+
? (own.windows === null ? null : { windows: own.windows })
|
|
360
|
+
: host?.plan ?? null;
|
|
206
361
|
return {
|
|
207
362
|
provider: channel.provider,
|
|
208
|
-
displayName:
|
|
209
|
-
credential:
|
|
363
|
+
displayName: host?.displayName ?? channel.displayName,
|
|
364
|
+
credential: host?.credential ?? channel.apiKeyEnv,
|
|
365
|
+
supported,
|
|
210
366
|
source: 'snapshot',
|
|
211
367
|
fetchedAt: null,
|
|
212
|
-
updatedAt
|
|
368
|
+
updatedAt,
|
|
213
369
|
// 实时失败的原因要透出(未配置凭据 / 网络 / 鉴权),界面据此提示
|
|
214
|
-
error: liveEntry?.error ?? (
|
|
215
|
-
balance:
|
|
216
|
-
plan:
|
|
370
|
+
error: liveEntry?.error ?? (updatedAt === null ? 'no-snapshot' : null),
|
|
371
|
+
balance: fallbackBalance,
|
|
372
|
+
plan: fallbackPlan,
|
|
217
373
|
};
|
|
218
374
|
});
|
|
219
375
|
sendJson(response, 200, { providers });
|
|
@@ -233,15 +389,17 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
|
|
|
233
389
|
const knownProviders = channels.map((channel) => channel.provider);
|
|
234
390
|
const sessionRows = sessionDayRows(collection.records, knownProviders);
|
|
235
391
|
const merged = mergeDailySeries(aggregateRange(ledger, monthFrom, today, null).days, sessionRows, monthFrom, today);
|
|
236
|
-
|
|
237
|
-
const
|
|
392
|
+
// 总览的「今日 / 近 30 天」成本必须与使用明细同一口径,否则两页数字对不上
|
|
393
|
+
const days = annotateCosts(merged.days, merged.sessionDates, buildPriceTable(ledger), null);
|
|
394
|
+
const todaySummary = summarizeMergedByProvider(days, today, today);
|
|
395
|
+
const monthSummary = summarizeMergedByProvider(days, monthFrom, today);
|
|
238
396
|
const rows = channels.map((channel) => {
|
|
239
397
|
const todayRow = todaySummary.find((row) => row.provider === channel.provider);
|
|
240
398
|
const monthRow = monthSummary.find((row) => row.provider === channel.provider);
|
|
241
399
|
return {
|
|
242
400
|
provider: channel.provider,
|
|
243
|
-
today: todayRow ?? { provider: channel.provider, calls: 0, tokens: 0, cost: 0 },
|
|
244
|
-
month: monthRow ?? { provider: channel.provider, calls: 0, tokens: 0, cost: 0 },
|
|
401
|
+
today: { ...todayRow ?? { provider: channel.provider, calls: 0, tokens: 0, cost: 0 }, costSource: sourceOfProvider(days, channel.provider, today, today) },
|
|
402
|
+
month: { ...monthRow ?? { provider: channel.provider, calls: 0, tokens: 0, cost: 0 }, costSource: sourceOfProvider(days, channel.provider, monthFrom, today) },
|
|
245
403
|
};
|
|
246
404
|
});
|
|
247
405
|
sendJson(response, 200, {
|
|
@@ -264,14 +422,20 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
|
|
|
264
422
|
const knownProviders = channels.map((channel) => channel.provider);
|
|
265
423
|
const costModeOf = new Map(channels.map((channel) => [channel.provider, channel.costMode]));
|
|
266
424
|
const { records, coverage } = await sessionSource.collect();
|
|
267
|
-
const
|
|
425
|
+
const ledger = loadLedger();
|
|
426
|
+
const priceTable = buildPriceTable(ledger);
|
|
427
|
+
const view = pricingView();
|
|
268
428
|
const meta = sessionMeta();
|
|
269
|
-
const rows = sessionRowsOf(records, knownProviders, meta).slice(0, limit).map((row) =>
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
429
|
+
const rows = sessionRowsOf(records, knownProviders, meta).slice(0, limit).map((row) => {
|
|
430
|
+
const costs = rowCostsOf(row.models.map(pricingRowOf), view.config, averagePriceOf(priceTable));
|
|
431
|
+
return {
|
|
432
|
+
...row,
|
|
433
|
+
providerLabel: channels.find((channel) => channel.provider === row.provider)?.displayName ?? row.provider,
|
|
434
|
+
costMode: row.provider === null ? null : costModeOf.get(row.provider) ?? null,
|
|
435
|
+
cost: costs.length === 0 ? null : sumOrNull(costs),
|
|
436
|
+
costSource: costs.length === 0 ? 'none' : weakestSource(costs.map((entry) => entry.source)),
|
|
437
|
+
};
|
|
438
|
+
});
|
|
275
439
|
sendJson(response, 200, { rows, coverage, priced: priceTable.size > 0 });
|
|
276
440
|
},
|
|
277
441
|
});
|
|
@@ -287,14 +451,97 @@ export function mountKeyUsageRoutes(webServer, sessionSource = unavailableSessio
|
|
|
287
451
|
const knownProviders = channels.map((channel) => channel.provider);
|
|
288
452
|
const { records, coverage } = await sessionSource.collect();
|
|
289
453
|
const priceTable = buildPriceTable(loadLedger());
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
454
|
+
const view = pricingView();
|
|
455
|
+
const rows = projectRowsOf(records, knownProviders, sessionMeta()).slice(0, limit).map((row) => {
|
|
456
|
+
const costs = rowCostsOf(row.models.map(pricingRowOf), view.config, averagePriceOf(priceTable));
|
|
457
|
+
return {
|
|
458
|
+
...row,
|
|
459
|
+
dominantProvider: Object.entries(row.providers).sort((first, second) => second[1] - first[1])[0]?.[0] ?? null,
|
|
460
|
+
cost: costs.length === 0 ? null : sumOrNull(costs),
|
|
461
|
+
costSource: costs.length === 0 ? 'none' : weakestSource(costs.map((entry) => entry.source)),
|
|
462
|
+
};
|
|
463
|
+
});
|
|
295
464
|
sendJson(response, 200, { rows, coverage, priced: priceTable.size > 0 });
|
|
296
465
|
},
|
|
297
466
|
});
|
|
467
|
+
webServer.register({
|
|
468
|
+
kind: 'exact',
|
|
469
|
+
path: '/dsh-key-usage/models',
|
|
470
|
+
handler: async (request, response) => {
|
|
471
|
+
if (!requireLoopback(request, response))
|
|
472
|
+
return;
|
|
473
|
+
const url = new URL(request.url ?? '/', 'http://localhost');
|
|
474
|
+
const limit = parseLimit(url.searchParams.get('limit'), 200);
|
|
475
|
+
const channels = loadChannels();
|
|
476
|
+
const knownProviders = channels.map((channel) => channel.provider);
|
|
477
|
+
const costModeOf = new Map(channels.map((channel) => [channel.provider, channel.costMode]));
|
|
478
|
+
const { records, coverage } = await sessionSource.collect();
|
|
479
|
+
const priceTable = buildPriceTable(loadLedger());
|
|
480
|
+
const view = pricingView();
|
|
481
|
+
const rows = modelRowsOf(records, knownProviders).slice(0, limit).map((row) => {
|
|
482
|
+
const costs = rowCostsOf([pricingRowOf(row)], view.config, averagePriceOf(priceTable));
|
|
483
|
+
return {
|
|
484
|
+
...row,
|
|
485
|
+
providerLabel: channels.find((channel) => channel.provider === row.provider)?.displayName ?? row.provider,
|
|
486
|
+
costMode: costModeOf.get(row.provider) ?? null,
|
|
487
|
+
// 与 /sessions 同一口径:用户规则优先,没命中再回落台账反推均价
|
|
488
|
+
cost: sumOrNull(costs),
|
|
489
|
+
costSource: costs[0]?.source ?? 'none',
|
|
490
|
+
};
|
|
491
|
+
});
|
|
492
|
+
sendJson(response, 200, { rows, coverage, priced: priceTable.size > 0 });
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
/**
|
|
496
|
+
* 计费规则面板:服务端视角的规则、内置模板、设定来源,以及近 30 天的覆盖度与缓存省钱。
|
|
497
|
+
* 规则本体由客户端经宿主设置服务读写,这里只提供「算出来是什么样」的对照。
|
|
498
|
+
*/
|
|
499
|
+
webServer.register({
|
|
500
|
+
kind: 'exact',
|
|
501
|
+
path: '/dsh-key-usage/pricing',
|
|
502
|
+
handler: async (request, response) => {
|
|
503
|
+
if (!requireLoopback(request, response))
|
|
504
|
+
return;
|
|
505
|
+
const view = pricingView();
|
|
506
|
+
const ledger = loadLedger();
|
|
507
|
+
const channels = loadChannels();
|
|
508
|
+
const knownProviders = channels.map((channel) => channel.provider);
|
|
509
|
+
const collection = await sessionSource.collect();
|
|
510
|
+
const today = dayKeyOf(new Date());
|
|
511
|
+
const monthFrom = shiftedDayKey(-29);
|
|
512
|
+
const merged = mergeDailySeries(aggregateRange(ledger, monthFrom, today, null).days, sessionDayRows(collection.records, knownProviders), monthFrom, today);
|
|
513
|
+
// 覆盖度按「近 30 天出现过的 provider|model」统计,与使用明细的口径一致
|
|
514
|
+
const priceTable = buildPriceTable(ledger);
|
|
515
|
+
const usageRows = merged.days.flatMap((day) => day.models.map(dayModelPricingRow));
|
|
516
|
+
const coverage = summarizeCoverage(usageRows, view.config, averagePriceOf(priceTable));
|
|
517
|
+
const now = new Date();
|
|
518
|
+
sendJson(response, 200, {
|
|
519
|
+
rules: view.config.rules,
|
|
520
|
+
currency: view.config.currency,
|
|
521
|
+
writable: view.writable,
|
|
522
|
+
revision: view.revision,
|
|
523
|
+
templates: PRICING_TEMPLATES,
|
|
524
|
+
// 内置官方价目:自动生效、无需套用模板;这里只透出「覆盖了哪些通道 / 来源 / 抓取日期」
|
|
525
|
+
catalog: {
|
|
526
|
+
entries: catalogEntryCount(),
|
|
527
|
+
sources: catalogSources(),
|
|
528
|
+
},
|
|
529
|
+
offPeakNow: view.config.rules.map((rule) => isOffPeakNow(rule, now)),
|
|
530
|
+
coverage: {
|
|
531
|
+
from: monthFrom,
|
|
532
|
+
to: today,
|
|
533
|
+
ruleModels: coverage.ruleModels,
|
|
534
|
+
catalogModels: coverage.catalogModels,
|
|
535
|
+
averagedModels: coverage.averagedModels,
|
|
536
|
+
unpricedModels: coverage.unpricedModels,
|
|
537
|
+
// 只作参照:界面上的成本按「官方 > 规则 > 内置 > 均价」逐格取值,与这个总额不一定相等
|
|
538
|
+
ruleCostByCurrency: coverage.costByCurrency,
|
|
539
|
+
savingsByCurrency: coverage.savingsByCurrency,
|
|
540
|
+
savingsDetail: coverage.savingsDetail,
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
},
|
|
544
|
+
});
|
|
298
545
|
}
|
|
299
546
|
/**
|
|
300
547
|
* 把实时余额 / 窗口合并进快照数组,供通道状态判定(额度百分比、余额文案)使用。
|
package/lib/session-usage.js
CHANGED
|
@@ -57,23 +57,28 @@ function modelOfEvent(event) {
|
|
|
57
57
|
}
|
|
58
58
|
return null;
|
|
59
59
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
|
|
60
|
+
/**
|
|
61
|
+
* 取事件携带的用量样本;与「哪些事件算样本」的口径一一对应(见 docs/01 §3.10)。
|
|
62
|
+
* 注意 `assistant/attempt` 不认 `data.usage`,只认 `data.stream` 里的样本,与旧实现一致。
|
|
63
|
+
*/
|
|
64
|
+
function usagePayloadOf(event) {
|
|
65
|
+
const type = event.type;
|
|
66
|
+
const data = event.data;
|
|
67
|
+
if (type === 'assistant/chunk') {
|
|
68
|
+
const chunk = data?.chunk;
|
|
69
|
+
return chunk?.type === 'usage' && chunk.usage !== undefined ? { usage: chunk.usage } : null;
|
|
69
70
|
}
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
71
|
+
if (type !== 'assistant/message' && type !== 'assistant/attempt')
|
|
72
|
+
return null;
|
|
73
|
+
if (type === 'assistant/message' && data?.usage !== undefined)
|
|
74
|
+
return { usage: data.usage };
|
|
75
|
+
const stream = data?.stream;
|
|
76
|
+
if (!Array.isArray(stream))
|
|
77
|
+
return null;
|
|
78
|
+
for (let index = stream.length - 1; index >= 0; index -= 1) {
|
|
79
|
+
const record = stream[index];
|
|
80
|
+
if (record?.type === 'chunk' && record.chunk?.type === 'usage' && record.chunk.usage !== undefined) {
|
|
81
|
+
return { usage: record.chunk.usage };
|
|
77
82
|
}
|
|
78
83
|
}
|
|
79
84
|
return null;
|
|
@@ -96,48 +101,64 @@ export function foldSessionEvents(events) {
|
|
|
96
101
|
if (lastTime === null || time > lastTime)
|
|
97
102
|
lastTime = time;
|
|
98
103
|
}
|
|
99
|
-
|
|
104
|
+
// 先按类型分流:整段日志里绝大多数事件与用量无关,不必为它们拼 (turn, step) 键
|
|
105
|
+
const type = event.type;
|
|
106
|
+
if (type === 'assistant/chunk' || type === 'assistant/message' || type === 'assistant/attempt') {
|
|
107
|
+
const payload = usagePayloadOf(event);
|
|
108
|
+
if (payload === null || time === null)
|
|
109
|
+
continue;
|
|
110
|
+
const turn = event.data?.turn;
|
|
111
|
+
const step = event.data?.step;
|
|
112
|
+
const baseKey = Number.isInteger(turn) && Number.isInteger(step) ? `${turn}:${step}` : `seq:${event.seq}`;
|
|
113
|
+
const attempt = attemptCounts.get(baseKey) ?? 0;
|
|
114
|
+
const sampleKey = `${baseKey}#${attempt}`;
|
|
115
|
+
const model = modelOfEvent(event) ?? currentModel ?? 'unknown/unknown';
|
|
116
|
+
samples.set(sampleKey, {
|
|
117
|
+
key: sampleKey,
|
|
118
|
+
day: dayKeyOf(time),
|
|
119
|
+
hour: hourOfTime(time),
|
|
120
|
+
model,
|
|
121
|
+
buckets: bucketsOf(payload.usage),
|
|
122
|
+
});
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (type === 'request/header') {
|
|
100
126
|
const model = modelOfEvent(event);
|
|
101
127
|
if (model !== null)
|
|
102
128
|
currentModel = model;
|
|
129
|
+
continue;
|
|
103
130
|
}
|
|
104
|
-
if (
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
attemptCounts.set(baseKey, (attemptCounts.get(baseKey) ?? 0) + 1);
|
|
112
|
-
}
|
|
113
|
-
const sample = sampleOfEvent(event);
|
|
114
|
-
if (sample === null || time === null)
|
|
131
|
+
if (type === 'llm/retry-started') {
|
|
132
|
+
const turn = event.data?.turn;
|
|
133
|
+
const step = event.data?.step;
|
|
134
|
+
if (Number.isInteger(turn) && Number.isInteger(step)) {
|
|
135
|
+
const baseKey = `${turn}:${step}`;
|
|
136
|
+
attemptCounts.set(baseKey, (attemptCounts.get(baseKey) ?? 0) + 1);
|
|
137
|
+
}
|
|
115
138
|
continue;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
samples.set(sampleKey, {
|
|
120
|
-
key: sampleKey,
|
|
121
|
-
day: dayKeyOf(time),
|
|
122
|
-
hour: hourOfTime(time),
|
|
123
|
-
model,
|
|
124
|
-
buckets: bucketsOf(sample.usage),
|
|
125
|
-
});
|
|
139
|
+
}
|
|
140
|
+
if (firstPrompt === null && type === 'user/message')
|
|
141
|
+
firstPrompt = promptTextOf(event);
|
|
126
142
|
}
|
|
127
143
|
return { samples, firstTime, lastTime, firstPrompt };
|
|
128
144
|
}
|
|
129
|
-
/**
|
|
145
|
+
/** 标题兜底:取用户消息里第一个非空文本块,压平空白并截断。 */
|
|
130
146
|
function promptTextOf(event) {
|
|
131
|
-
const
|
|
132
|
-
if (typeof
|
|
147
|
+
const data = event.data;
|
|
148
|
+
if (typeof data !== 'object' || data === null)
|
|
133
149
|
return null;
|
|
134
|
-
|
|
135
|
-
|
|
150
|
+
// `user/message` 的正文在 `data.content`(宿主 dsh-context 就是这么读的),
|
|
151
|
+
// `assistant/message` 才用 `data.message.content` —— 两种都认,避免形状差异导致标题全空。
|
|
152
|
+
const direct = data.content;
|
|
153
|
+
const nested = data.message?.content;
|
|
154
|
+
const content = Array.isArray(direct) ? direct : Array.isArray(nested) ? nested : null;
|
|
155
|
+
if (content === null)
|
|
136
156
|
return null;
|
|
137
157
|
for (const block of content) {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
158
|
+
// 文本块既可能是对象,也可能是裸字符串
|
|
159
|
+
const text = typeof block === 'string'
|
|
160
|
+
? block
|
|
161
|
+
: typeof block === 'object' && block !== null ? block.text : null;
|
|
141
162
|
if (typeof text !== 'string')
|
|
142
163
|
continue;
|
|
143
164
|
const flat = text.replace(/\s+/g, ' ').trim();
|