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/index.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-key-usage 服务端入口:挂载只读 HTTP 路由,向客户端提供
|
|
3
|
+
* 用量台账、通道配置、余额快照与会话用量的聚合数据。
|
|
4
|
+
*
|
|
5
|
+
* 数据来源:
|
|
6
|
+
* - 台账 / 快照 / 通道配置:本地文件只读;
|
|
7
|
+
* - 项目、会话、按小时:宿主 sessionPersistence 的只读 handle;
|
|
8
|
+
* - 余额与订阅窗口:优先查各通道官方接口(凭据经宿主 credentials seam 解析,
|
|
9
|
+
* 不落盘、不进日志、不返回给客户端),失败时回落到本地快照。
|
|
10
|
+
*/
|
|
11
|
+
import { mountKeyUsageRoutes } from './routes.js';
|
|
12
|
+
import { createLiveQuotaService } from './live-quota.js';
|
|
13
|
+
import { createSessionSource } from './sessions.js';
|
|
14
|
+
export const name = 'dsh-key-usage';
|
|
15
|
+
export function apply(ctx) {
|
|
16
|
+
// 会话服务是可选依赖:用 get 惰性取,缺了也只是会话相关页面显示空态
|
|
17
|
+
const sessionSource = createSessionSource({
|
|
18
|
+
get: (serviceName) => (typeof ctx.get === 'function' ? ctx.get(serviceName) : undefined),
|
|
19
|
+
});
|
|
20
|
+
// 凭据同样只在真正查询余额时经宿主 seam 解析;拿不到就回落快照
|
|
21
|
+
const liveQuota = createLiveQuotaService({
|
|
22
|
+
resolveCredential: async (reference) => {
|
|
23
|
+
const credentials = (typeof ctx.get === 'function' ? ctx.get('credentials') : undefined);
|
|
24
|
+
if (credentials !== undefined && typeof credentials.resolve === 'function') {
|
|
25
|
+
try {
|
|
26
|
+
const hit = await credentials.resolve(reference);
|
|
27
|
+
if (hit !== null && typeof hit.value === 'string' && hit.value.trim() !== '')
|
|
28
|
+
return hit.value.trim();
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// 继续尝试进程环境变量
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// 兜底:宿主把凭据放在进程环境里时直接取(只读环境变量,不碰任何凭据文件)
|
|
35
|
+
const fromEnv = process.env[reference];
|
|
36
|
+
return typeof fromEnv === 'string' ? fromEnv.trim() : '';
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
ctx.inject(['webServer'], (host) => {
|
|
40
|
+
host.effect(() => mountKeyUsageRoutes(host.webServer, sessionSource, liveQuota), 'dsh-key-usage: http routes');
|
|
41
|
+
});
|
|
42
|
+
}
|
package/lib/ledger.js
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 用量台账读取与聚合:解析 ~/.dsh/dsh-usage/usage-ledger.json,
|
|
3
|
+
* 提供范围聚合、按模型/通道拆分、年度热力图序列。
|
|
4
|
+
* 纯函数实现,便于离线测试(不触达真实家目录)。
|
|
5
|
+
*/
|
|
6
|
+
/** 安全读取并解析台账文件;不存在或损坏时返回 null,由调用方决定空态。 */
|
|
7
|
+
export function readLedger(ledgerPath, readFile) {
|
|
8
|
+
const raw = readFile(ledgerPath);
|
|
9
|
+
if (raw === null)
|
|
10
|
+
return null;
|
|
11
|
+
try {
|
|
12
|
+
const parsed = JSON.parse(raw);
|
|
13
|
+
if (typeof parsed !== 'object' || parsed === null || typeof parsed.days !== 'object')
|
|
14
|
+
return null;
|
|
15
|
+
return parsed;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/** 台账按天遍历(含端点),dateKey 形如 2026-10-13。 */
|
|
22
|
+
export function forEachDay(ledger, fromDate, toDate, callback) {
|
|
23
|
+
const start = new Date(fromDate + 'T00:00:00Z');
|
|
24
|
+
const end = new Date(toDate + 'T00:00:00Z');
|
|
25
|
+
for (let time = start.getTime(); time <= end.getTime(); time += 86400000) {
|
|
26
|
+
const dateKey = new Date(time).toISOString().slice(0, 10);
|
|
27
|
+
const channels = ledger.days[dateKey];
|
|
28
|
+
if (channels)
|
|
29
|
+
callback(dateKey, channels);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function addToAccumulator(target, entry) {
|
|
33
|
+
target.calls += entry.calls;
|
|
34
|
+
target.inputTokens += entry.inputTokens;
|
|
35
|
+
target.outputTokens += entry.outputTokens;
|
|
36
|
+
target.cacheReadTokens += entry.cacheReadTokens;
|
|
37
|
+
target.cacheWriteTokens += entry.cacheWriteTokens;
|
|
38
|
+
target.reasoningTokens += entry.reasoningTokens;
|
|
39
|
+
target.cost += entry.cost;
|
|
40
|
+
}
|
|
41
|
+
function emptyAccumulator() {
|
|
42
|
+
return { calls: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, reasoningTokens: 0, cost: 0 };
|
|
43
|
+
}
|
|
44
|
+
/** 范围聚合:可选限定通道(provider id 列表),返回总计与逐日序列。 */
|
|
45
|
+
export function aggregateRange(ledger, fromDate, toDate, providerFilter) {
|
|
46
|
+
const totals = emptyAccumulator();
|
|
47
|
+
const days = [];
|
|
48
|
+
forEachDay(ledger, fromDate, toDate, (dateKey, channels) => {
|
|
49
|
+
const dayAccumulator = emptyAccumulator();
|
|
50
|
+
const dayModels = [];
|
|
51
|
+
for (const [provider, models] of Object.entries(channels)) {
|
|
52
|
+
if (providerFilter !== null && !providerFilter.includes(provider))
|
|
53
|
+
continue;
|
|
54
|
+
for (const [model, entry] of Object.entries(models)) {
|
|
55
|
+
addToAccumulator(dayAccumulator, entry);
|
|
56
|
+
dayModels.push({ provider, model, date: dateKey, ...structuredClone(entry) });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (dayModels.length === 0)
|
|
60
|
+
return;
|
|
61
|
+
addToAccumulator(totals, dayAccumulator);
|
|
62
|
+
days.push({
|
|
63
|
+
date: dateKey,
|
|
64
|
+
provider: '',
|
|
65
|
+
...dayAccumulator,
|
|
66
|
+
models: dayModels.sort((first, second) => (second.inputTokens + second.outputTokens + second.cacheReadTokens) - (first.inputTokens + first.outputTokens + first.cacheReadTokens)),
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
return { totals: { date: '', ...totals }, days };
|
|
70
|
+
}
|
|
71
|
+
/** 年度热力图数据:起止范围内每天的总 tokens 与按模型拆分。 */
|
|
72
|
+
export function buildHeatmap(ledger, fromDate, toDate) {
|
|
73
|
+
const result = [];
|
|
74
|
+
forEachDay(ledger, fromDate, toDate, (dateKey, channels) => {
|
|
75
|
+
const modelMap = new Map();
|
|
76
|
+
let dayTotal = 0;
|
|
77
|
+
for (const [provider, models] of Object.entries(channels)) {
|
|
78
|
+
for (const [model, entry] of Object.entries(models)) {
|
|
79
|
+
const totalTokens = entry.inputTokens + entry.outputTokens + entry.cacheReadTokens;
|
|
80
|
+
if (totalTokens <= 0)
|
|
81
|
+
continue;
|
|
82
|
+
dayTotal += totalTokens;
|
|
83
|
+
const key = provider + '|' + model;
|
|
84
|
+
const bucket = modelMap.get(key);
|
|
85
|
+
if (bucket)
|
|
86
|
+
bucket.totalTokens += totalTokens;
|
|
87
|
+
else
|
|
88
|
+
modelMap.set(key, { provider, model, totalTokens });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (dayTotal <= 0)
|
|
92
|
+
return;
|
|
93
|
+
result.push({
|
|
94
|
+
date: dateKey,
|
|
95
|
+
totalTokens: dayTotal,
|
|
96
|
+
models: [...modelMap.values()].sort((first, second) => second.totalTokens - first.totalTokens),
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
/** 按通道汇总指定范围(展示口径 tokens 不含 cacheWrite)。 */
|
|
102
|
+
export function summarizeByProvider(ledger, fromDate, toDate) {
|
|
103
|
+
const byProvider = new Map();
|
|
104
|
+
forEachDay(ledger, fromDate, toDate, (_dateKey, channels) => {
|
|
105
|
+
for (const [provider, models] of Object.entries(channels)) {
|
|
106
|
+
let summary = byProvider.get(provider);
|
|
107
|
+
if (summary === undefined) {
|
|
108
|
+
summary = { provider, calls: 0, tokens: 0, cost: 0 };
|
|
109
|
+
byProvider.set(provider, summary);
|
|
110
|
+
}
|
|
111
|
+
for (const entry of Object.values(models)) {
|
|
112
|
+
summary.calls += entry.calls;
|
|
113
|
+
summary.tokens += entry.inputTokens + entry.outputTokens + entry.cacheReadTokens;
|
|
114
|
+
summary.cost += entry.cost;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
return [...byProvider.values()];
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* 从台账反推模型均价表(`provider|model` 与退化到 `model` 两种键)。
|
|
122
|
+
* 台账只有「当天该模型的成本合计」,没有单价表,所以会话/项目成本只能估算,
|
|
123
|
+
* 展示时必须标注口径(见页脚口径说明)。
|
|
124
|
+
*/
|
|
125
|
+
export function buildPriceTable(ledger) {
|
|
126
|
+
const table = new Map();
|
|
127
|
+
const add = (key, tokens, cost) => {
|
|
128
|
+
const entry = table.get(key);
|
|
129
|
+
if (entry === undefined)
|
|
130
|
+
table.set(key, { tokens, cost });
|
|
131
|
+
else {
|
|
132
|
+
entry.tokens += tokens;
|
|
133
|
+
entry.cost += cost;
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
for (const channels of Object.values(ledger.days)) {
|
|
137
|
+
for (const [provider, models] of Object.entries(channels)) {
|
|
138
|
+
for (const [model, entry] of Object.entries(models)) {
|
|
139
|
+
const tokens = entry.inputTokens + entry.outputTokens + entry.cacheReadTokens;
|
|
140
|
+
if (tokens <= 0 || entry.cost <= 0)
|
|
141
|
+
continue;
|
|
142
|
+
add(provider + '|' + model, tokens, entry.cost);
|
|
143
|
+
add(model, tokens, entry.cost);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return table;
|
|
148
|
+
}
|
|
149
|
+
/** 用均价表估算一组模型行的成本;没有任何可比价时返回 null(界面显示 —)。 */
|
|
150
|
+
export function estimateCost(models, priceTable) {
|
|
151
|
+
let total = 0;
|
|
152
|
+
let matched = false;
|
|
153
|
+
for (const row of models) {
|
|
154
|
+
if (row.tokens <= 0)
|
|
155
|
+
continue;
|
|
156
|
+
const price = priceTable.get(row.provider + '|' + row.model) ?? priceTable.get(row.model);
|
|
157
|
+
if (price === undefined || price.tokens <= 0)
|
|
158
|
+
continue;
|
|
159
|
+
total += row.tokens * (price.cost / price.tokens);
|
|
160
|
+
matched = true;
|
|
161
|
+
}
|
|
162
|
+
return matched ? Math.round(total * 100) / 100 : null;
|
|
163
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
const REQUEST_TIMEOUT_MS = 6000;
|
|
2
|
+
const MAX_RESPONSE_BYTES = 65536;
|
|
3
|
+
const DEFAULT_TTL_MS = 60000;
|
|
4
|
+
function isObject(value) {
|
|
5
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
6
|
+
}
|
|
7
|
+
function textOf(value) {
|
|
8
|
+
if (typeof value === 'string' && value !== '')
|
|
9
|
+
return value;
|
|
10
|
+
if (typeof value === 'number' && Number.isFinite(value))
|
|
11
|
+
return String(value);
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
/** OpenCode Go:GET https://opencode.ai/zen/go/v1/usage → usage.rolling / weekly / monthly。 */
|
|
15
|
+
const OPENCODE_GO_SCHEME = {
|
|
16
|
+
url: () => 'https://opencode.ai/zen/go/v1/usage',
|
|
17
|
+
parse: (body) => {
|
|
18
|
+
const usage = isObject(body) && isObject(body.usage) ? body.usage : body;
|
|
19
|
+
if (!isObject(usage))
|
|
20
|
+
return { balance: null, windows: null };
|
|
21
|
+
const mapping = [['rolling', '5h'], ['weekly', 'week'], ['monthly', 'month']];
|
|
22
|
+
const windows = [];
|
|
23
|
+
for (const [sourceKey, windowKey] of mapping) {
|
|
24
|
+
const raw = usage[sourceKey];
|
|
25
|
+
if (!isObject(raw))
|
|
26
|
+
continue;
|
|
27
|
+
const percent = typeof raw.percent === 'number' ? raw.percent : Number(raw.percent);
|
|
28
|
+
if (!Number.isFinite(percent))
|
|
29
|
+
continue;
|
|
30
|
+
windows.push({
|
|
31
|
+
key: windowKey,
|
|
32
|
+
percent,
|
|
33
|
+
resetsAt: textOf(raw.resetsAt),
|
|
34
|
+
status: typeof raw.status === 'string' ? raw.status : null,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return { balance: null, windows: windows.length > 0 ? windows : null };
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
/** 官方余额类接口:GET {origin}<path>,凭据同为 Bearer。 */
|
|
41
|
+
function balanceScheme(path, parse, fallbackOrigin) {
|
|
42
|
+
return {
|
|
43
|
+
url: (baseURL) => new URL(path, baseURL ?? fallbackOrigin).href,
|
|
44
|
+
parse: (body) => ({ balance: isObject(body) ? parse(body) : null, windows: null }),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const SCHEMES = {
|
|
48
|
+
'opencode-go': OPENCODE_GO_SCHEME,
|
|
49
|
+
// DeepSeek: GET {origin}/user/balance → balance_infos[{currency,total_balance,granted_balance,topped_up_balance}]
|
|
50
|
+
'deepseek-official': balanceScheme('/user/balance', (body) => {
|
|
51
|
+
const infos = Array.isArray(body.balance_infos) ? body.balance_infos : [];
|
|
52
|
+
const info = infos.find((entry) => isObject(entry) && entry.currency === 'CNY') ?? infos[0];
|
|
53
|
+
if (!isObject(info))
|
|
54
|
+
return null;
|
|
55
|
+
const balance = {};
|
|
56
|
+
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
|
+
if (currency !== null)
|
|
61
|
+
balance.currency = currency;
|
|
62
|
+
if (total !== null)
|
|
63
|
+
balance.totalBalance = total;
|
|
64
|
+
if (granted !== null)
|
|
65
|
+
balance.giftBalance = granted;
|
|
66
|
+
if (toppedUp !== null)
|
|
67
|
+
balance.topUpBalance = toppedUp;
|
|
68
|
+
return Object.keys(balance).length > 0 ? balance : null;
|
|
69
|
+
}, 'https://api.deepseek.com'),
|
|
70
|
+
// OpenRouter: GET {origin}/api/v1/credits(需要 Management Key)
|
|
71
|
+
openrouter: balanceScheme('/api/v1/credits', (body) => {
|
|
72
|
+
const data = isObject(body.data) ? body.data : null;
|
|
73
|
+
if (data === null)
|
|
74
|
+
return null;
|
|
75
|
+
const totalCredits = typeof data.total_credits === 'number' ? data.total_credits : null;
|
|
76
|
+
const totalUsage = typeof data.total_usage === 'number' ? data.total_usage : null;
|
|
77
|
+
if (totalCredits === null || totalUsage === null)
|
|
78
|
+
return null;
|
|
79
|
+
return { currency: 'USD', totalBalance: (totalCredits - totalUsage).toFixed(2) };
|
|
80
|
+
}, 'https://openrouter.ai'),
|
|
81
|
+
// Moonshot / Kimi: GET {origin}/v1/users/me/balance
|
|
82
|
+
moonshotai: balanceScheme('/v1/users/me/balance', (body) => {
|
|
83
|
+
const data = isObject(body.data) ? body.data : null;
|
|
84
|
+
if (data === null)
|
|
85
|
+
return null;
|
|
86
|
+
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);
|
|
91
|
+
if (currency !== null)
|
|
92
|
+
balance.currency = currency;
|
|
93
|
+
if (available !== null)
|
|
94
|
+
balance.totalBalance = available;
|
|
95
|
+
if (voucher !== null)
|
|
96
|
+
balance.giftBalance = voucher;
|
|
97
|
+
if (cash !== null)
|
|
98
|
+
balance.topUpBalance = cash;
|
|
99
|
+
return Object.keys(balance).length > 0 ? balance : null;
|
|
100
|
+
}, 'https://api.moonshot.cn'),
|
|
101
|
+
// Z.AI / GLM: GET {origin}/api/paas/v4/balance
|
|
102
|
+
zai: balanceScheme('/api/paas/v4/balance', (body) => {
|
|
103
|
+
const data = isObject(body.data) ? body.data : null;
|
|
104
|
+
if (data === null)
|
|
105
|
+
return null;
|
|
106
|
+
const balance = { currency: 'CNY' };
|
|
107
|
+
const total = textOf(data.total_balance);
|
|
108
|
+
const available = textOf(data.available_balance);
|
|
109
|
+
if (total !== null)
|
|
110
|
+
balance.totalBalance = total;
|
|
111
|
+
if (available !== null)
|
|
112
|
+
balance.availableBalance = available;
|
|
113
|
+
return Object.keys(balance).length > 1 ? balance : null;
|
|
114
|
+
}, 'https://api.z.ai'),
|
|
115
|
+
};
|
|
116
|
+
/** provider id → 方案 id(事件里的 provider 可能是路由名,先取末段)。 */
|
|
117
|
+
export function schemeIdOf(provider) {
|
|
118
|
+
const tail = provider.includes('/') ? provider.slice(provider.lastIndexOf('/') + 1) : provider;
|
|
119
|
+
if (tail === 'opencode-go' || tail === 'opencode')
|
|
120
|
+
return 'opencode-go';
|
|
121
|
+
if (tail === 'deepseek-official' || tail === 'deepseek')
|
|
122
|
+
return 'deepseek-official';
|
|
123
|
+
if (tail === 'openrouter')
|
|
124
|
+
return 'openrouter';
|
|
125
|
+
if (tail === 'moonshotai' || tail === 'moonshotai-cn' || tail === 'kimi' || tail === 'kimi-coding')
|
|
126
|
+
return 'moonshotai';
|
|
127
|
+
if (tail === 'zai' || tail === 'zai-coding-cn')
|
|
128
|
+
return 'zai';
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
async function queryOne(schemeId, apiKey, baseURL, fetchImpl) {
|
|
132
|
+
const scheme = SCHEMES[schemeId];
|
|
133
|
+
const controller = new AbortController();
|
|
134
|
+
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
135
|
+
try {
|
|
136
|
+
const response = await fetchImpl(scheme.url(baseURL), {
|
|
137
|
+
headers: { Authorization: 'Bearer ' + apiKey, Accept: 'application/json' },
|
|
138
|
+
signal: controller.signal,
|
|
139
|
+
});
|
|
140
|
+
if (response.status === 401 || response.status === 403)
|
|
141
|
+
return { balance: null, windows: null, error: 'unauthorized' };
|
|
142
|
+
if (!response.ok)
|
|
143
|
+
return { balance: null, windows: null, error: 'http-' + String(response.status) };
|
|
144
|
+
const raw = await response.text();
|
|
145
|
+
if (raw.length > MAX_RESPONSE_BYTES)
|
|
146
|
+
return { balance: null, windows: null, error: 'too-large' };
|
|
147
|
+
let body;
|
|
148
|
+
try {
|
|
149
|
+
body = JSON.parse(raw);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return { balance: null, windows: null, error: 'bad-json' };
|
|
153
|
+
}
|
|
154
|
+
const parsed = scheme.parse(body);
|
|
155
|
+
if (parsed.balance === null && parsed.windows === null)
|
|
156
|
+
return { balance: null, windows: null, error: 'bad-json' };
|
|
157
|
+
return { ...parsed, error: null };
|
|
158
|
+
}
|
|
159
|
+
catch (error) {
|
|
160
|
+
const aborted = error instanceof Error && error.name === 'AbortError';
|
|
161
|
+
return { balance: null, windows: null, error: aborted ? 'timeout' : 'network' };
|
|
162
|
+
}
|
|
163
|
+
finally {
|
|
164
|
+
clearTimeout(timer);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* 建立实时查询服务。
|
|
169
|
+
* @param resolver - 凭据解析(宿主 credentials seam)
|
|
170
|
+
* @param fetchImpl - 可注入的 fetch(测试用)
|
|
171
|
+
* @param ttlMs - 缓存有效期
|
|
172
|
+
*/
|
|
173
|
+
export function createLiveQuotaService(resolver, fetchImpl = fetch, ttlMs = DEFAULT_TTL_MS) {
|
|
174
|
+
let cache = new Map();
|
|
175
|
+
let inFlight = null;
|
|
176
|
+
const isStale = () => {
|
|
177
|
+
const now = Date.now();
|
|
178
|
+
for (const entry of cache.values())
|
|
179
|
+
if (now - entry.fetchedAt > ttlMs)
|
|
180
|
+
return true;
|
|
181
|
+
return cache.size === 0;
|
|
182
|
+
};
|
|
183
|
+
async function refresh(channels) {
|
|
184
|
+
const targets = channels
|
|
185
|
+
.map((channel) => ({ channel, schemeId: schemeIdOf(channel.provider) }))
|
|
186
|
+
.filter((entry) => entry.schemeId !== null);
|
|
187
|
+
const results = await Promise.all(targets.map(async ({ channel, schemeId }) => {
|
|
188
|
+
const apiKey = channel.apiKeyEnv === null ? '' : await resolver.resolveCredential(channel.apiKeyEnv);
|
|
189
|
+
const fetchedAt = Date.now();
|
|
190
|
+
if (apiKey === '') {
|
|
191
|
+
return [channel.provider, { provider: channel.provider, ok: false, error: 'no-key', fetchedAt, balance: null, windows: null }];
|
|
192
|
+
}
|
|
193
|
+
const outcome = await queryOne(schemeId, apiKey, channel.baseURL, fetchImpl);
|
|
194
|
+
return [channel.provider, {
|
|
195
|
+
provider: channel.provider,
|
|
196
|
+
ok: outcome.error === null,
|
|
197
|
+
error: outcome.error,
|
|
198
|
+
fetchedAt,
|
|
199
|
+
balance: outcome.balance,
|
|
200
|
+
windows: outcome.windows,
|
|
201
|
+
}];
|
|
202
|
+
}));
|
|
203
|
+
// 只保留本次仍然配置了的通道,并保留没有方案可查的旧条目以免频繁抖动
|
|
204
|
+
const next = new Map();
|
|
205
|
+
for (const [provider, result] of results)
|
|
206
|
+
next.set(provider, result);
|
|
207
|
+
for (const [provider, entry] of cache)
|
|
208
|
+
if (!next.has(provider))
|
|
209
|
+
next.set(provider, entry);
|
|
210
|
+
cache = next;
|
|
211
|
+
return cache;
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
cached: () => cache,
|
|
215
|
+
refreshIfStale: async (channels, force = false) => {
|
|
216
|
+
if (isStale() || force) {
|
|
217
|
+
if (inFlight === null) {
|
|
218
|
+
inFlight = refresh(channels).finally(() => { inFlight = null; });
|
|
219
|
+
}
|
|
220
|
+
return inFlight;
|
|
221
|
+
}
|
|
222
|
+
return cache;
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
}
|
package/lib/merge.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { attributeModelKey } from './session-usage.js';
|
|
2
|
+
/** 把会话记录摊平成按天行(provider 用已知通道名归一)。 */
|
|
3
|
+
export function sessionDayRows(records, knownProviders) {
|
|
4
|
+
const rows = [];
|
|
5
|
+
for (const record of records) {
|
|
6
|
+
for (const day of record.summary.days) {
|
|
7
|
+
for (const model of day.models) {
|
|
8
|
+
const attribution = attributeModelKey(model.key, knownProviders);
|
|
9
|
+
rows.push({
|
|
10
|
+
date: day.date,
|
|
11
|
+
provider: attribution.provider,
|
|
12
|
+
model: attribution.model,
|
|
13
|
+
calls: model.calls,
|
|
14
|
+
inputTokens: model.buckets.inputTokens,
|
|
15
|
+
outputTokens: model.buckets.outputTokens,
|
|
16
|
+
cacheReadTokens: model.buckets.cacheReadTokens,
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return rows;
|
|
22
|
+
}
|
|
23
|
+
const DAY_MS = 86400000;
|
|
24
|
+
const MAX_RANGE_DAYS = 400;
|
|
25
|
+
function dateKeysOf(from, to) {
|
|
26
|
+
if (from === '' || to === '')
|
|
27
|
+
return [];
|
|
28
|
+
const start = new Date(from + 'T00:00:00Z').getTime();
|
|
29
|
+
const end = new Date(to + 'T00:00:00Z').getTime();
|
|
30
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start)
|
|
31
|
+
return [];
|
|
32
|
+
const total = Math.min(Math.floor((end - start) / DAY_MS) + 1, MAX_RANGE_DAYS);
|
|
33
|
+
const keys = [];
|
|
34
|
+
for (let offset = 0; offset < total; offset += 1) {
|
|
35
|
+
keys.push(new Date(start + offset * DAY_MS).toISOString().slice(0, 10));
|
|
36
|
+
}
|
|
37
|
+
return keys;
|
|
38
|
+
}
|
|
39
|
+
function synthesizeDay(date, rows) {
|
|
40
|
+
// 同一天同一模型可能来自多个会话,必须先按 `provider|model` 合并,
|
|
41
|
+
// 否则热力图与每日细目会出现多条同名重复行。
|
|
42
|
+
const byModel = new Map();
|
|
43
|
+
let calls = 0;
|
|
44
|
+
let inputTokens = 0;
|
|
45
|
+
let outputTokens = 0;
|
|
46
|
+
let cacheReadTokens = 0;
|
|
47
|
+
for (const row of rows) {
|
|
48
|
+
calls += row.calls;
|
|
49
|
+
inputTokens += row.inputTokens;
|
|
50
|
+
outputTokens += row.outputTokens;
|
|
51
|
+
cacheReadTokens += row.cacheReadTokens;
|
|
52
|
+
const key = row.provider + '|' + row.model;
|
|
53
|
+
const entry = byModel.get(key);
|
|
54
|
+
if (entry === undefined) {
|
|
55
|
+
byModel.set(key, {
|
|
56
|
+
date,
|
|
57
|
+
provider: row.provider,
|
|
58
|
+
model: row.model,
|
|
59
|
+
calls: row.calls,
|
|
60
|
+
inputTokens: row.inputTokens,
|
|
61
|
+
outputTokens: row.outputTokens,
|
|
62
|
+
cacheReadTokens: row.cacheReadTokens,
|
|
63
|
+
reasoningTokens: 0,
|
|
64
|
+
// 会话事件没有官方成本,成本列对这些天留 0,由界面按计费模式展示
|
|
65
|
+
cost: 0,
|
|
66
|
+
});
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
entry.calls += row.calls;
|
|
70
|
+
entry.inputTokens += row.inputTokens;
|
|
71
|
+
entry.outputTokens += row.outputTokens;
|
|
72
|
+
entry.cacheReadTokens += row.cacheReadTokens;
|
|
73
|
+
}
|
|
74
|
+
const models = [...byModel.values()];
|
|
75
|
+
return {
|
|
76
|
+
date,
|
|
77
|
+
provider: '',
|
|
78
|
+
calls,
|
|
79
|
+
inputTokens,
|
|
80
|
+
outputTokens,
|
|
81
|
+
cacheReadTokens,
|
|
82
|
+
reasoningTokens: 0,
|
|
83
|
+
cost: 0,
|
|
84
|
+
models: models.sort((first, second) => (second.inputTokens + second.outputTokens + second.cacheReadTokens)
|
|
85
|
+
- (first.inputTokens + first.outputTokens + first.cacheReadTokens)),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* 合并台账与会话序列(按天取舍)。
|
|
90
|
+
* @param ledgerDays - 台账范围聚合结果
|
|
91
|
+
* @param sessionRows - 会话事件摊平行(可先按通道过滤)
|
|
92
|
+
* @param from - 范围起始(含)
|
|
93
|
+
* @param to - 范围结束(含)
|
|
94
|
+
*/
|
|
95
|
+
export function mergeDailySeries(ledgerDays, sessionRows, from, to) {
|
|
96
|
+
const ledgerByDate = new Map(ledgerDays.map((day) => [day.date, day]));
|
|
97
|
+
const sessionByDate = new Map();
|
|
98
|
+
for (const row of sessionRows) {
|
|
99
|
+
const bucket = sessionByDate.get(row.date);
|
|
100
|
+
if (bucket === undefined)
|
|
101
|
+
sessionByDate.set(row.date, [row]);
|
|
102
|
+
else
|
|
103
|
+
bucket.push(row);
|
|
104
|
+
}
|
|
105
|
+
const days = [];
|
|
106
|
+
const sessionDates = [];
|
|
107
|
+
for (const date of dateKeysOf(from, to)) {
|
|
108
|
+
const ledgerDay = ledgerByDate.get(date);
|
|
109
|
+
if (ledgerDay !== undefined) {
|
|
110
|
+
days.push(ledgerDay);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const rows = sessionByDate.get(date);
|
|
114
|
+
if (rows === undefined || rows.length === 0)
|
|
115
|
+
continue;
|
|
116
|
+
sessionDates.push(date);
|
|
117
|
+
days.push(synthesizeDay(date, rows));
|
|
118
|
+
}
|
|
119
|
+
return { days, sessionDates };
|
|
120
|
+
}
|
|
121
|
+
/** 合并后的范围总计(展示口径 tokens 不含 cacheWrite)。 */
|
|
122
|
+
export function totalsOfMerged(days) {
|
|
123
|
+
const totals = { date: '', calls: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, reasoningTokens: 0, cost: 0 };
|
|
124
|
+
for (const day of days) {
|
|
125
|
+
totals.calls += day.calls;
|
|
126
|
+
totals.inputTokens += day.inputTokens;
|
|
127
|
+
totals.outputTokens += day.outputTokens;
|
|
128
|
+
totals.cacheReadTokens += day.cacheReadTokens;
|
|
129
|
+
totals.reasoningTokens += day.reasoningTokens;
|
|
130
|
+
totals.cost += day.cost;
|
|
131
|
+
}
|
|
132
|
+
return totals;
|
|
133
|
+
}
|
|
134
|
+
/** 按通道汇总合并序列(总览「今日 / 近 30 天」用)。 */
|
|
135
|
+
export function summarizeMergedByProvider(days, from, to) {
|
|
136
|
+
const byProvider = new Map();
|
|
137
|
+
for (const day of days) {
|
|
138
|
+
if (day.date < from || day.date > to)
|
|
139
|
+
continue;
|
|
140
|
+
for (const model of day.models) {
|
|
141
|
+
let summary = byProvider.get(model.provider);
|
|
142
|
+
if (summary === undefined) {
|
|
143
|
+
summary = { provider: model.provider, calls: 0, tokens: 0, cost: 0 };
|
|
144
|
+
byProvider.set(model.provider, summary);
|
|
145
|
+
}
|
|
146
|
+
summary.calls += model.calls;
|
|
147
|
+
summary.tokens += model.inputTokens + model.outputTokens + model.cacheReadTokens;
|
|
148
|
+
summary.cost += model.cost;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return [...byProvider.values()];
|
|
152
|
+
}
|
|
153
|
+
/** 会话记录里是否有指定日期的数据(总览标注来源用)。 */
|
|
154
|
+
export function hasSessionDate(records, date) {
|
|
155
|
+
return records.some((record) => record.summary.days.some((day) => day.date === date && day.tokens > 0));
|
|
156
|
+
}
|