opencode-tokenwatch 0.2.0 → 0.3.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 -21
- package/README.en.md +97 -96
- package/README.md +97 -96
- package/dist/commands.jsx +58 -2
- package/dist/formatter.d.ts +30 -0
- package/dist/generate-usage-html.js +749 -622
- package/dist/i18n.js +0 -4
- package/dist/perf-tracker.d.ts +5 -0
- package/dist/perf-tracker.js +85 -13
- package/dist/queries.d.ts +3 -1
- package/dist/queries.js +156 -97
- package/dist/sidebar.jsx +127 -94
- package/dist/tui.jsx +4 -1
- package/package.json +63 -63
- package/dist/commands.js +0 -208
package/dist/i18n.js
CHANGED
|
@@ -40,8 +40,6 @@ const zh = {
|
|
|
40
40
|
toolCall: "Tool调用",
|
|
41
41
|
toolResult: "Tool结果",
|
|
42
42
|
outputTokens: "输出",
|
|
43
|
-
settings: "Settings",
|
|
44
|
-
showCache: "显示缓存统计",
|
|
45
43
|
showPerformance: "显示性能指标",
|
|
46
44
|
showPricing: "显示模型定价",
|
|
47
45
|
showTokenDistribution: "显示Token分布",
|
|
@@ -114,8 +112,6 @@ const en = {
|
|
|
114
112
|
toolCall: "Tool Call",
|
|
115
113
|
toolResult: "Tool Result",
|
|
116
114
|
outputTokens: "Output",
|
|
117
|
-
settings: "Settings",
|
|
118
|
-
showCache: "Show Cache",
|
|
119
115
|
showPerformance: "Show Performance",
|
|
120
116
|
showPricing: "Show Pricing",
|
|
121
117
|
showTokenDistribution: "Show Token Distribution",
|
package/dist/perf-tracker.d.ts
CHANGED
|
@@ -41,11 +41,16 @@ interface MessageRemoveEvent {
|
|
|
41
41
|
declare class PerfTracker {
|
|
42
42
|
private firstPartTimes;
|
|
43
43
|
private statsMap;
|
|
44
|
+
/** 原始样本串,用于分位数计算,不持久化 */
|
|
45
|
+
private ttftSamples;
|
|
46
|
+
private latencySamples;
|
|
44
47
|
handlePartUpdated(event: PartEvent): void;
|
|
45
48
|
handleMessageUpdated(event: MessageUpdateEvent): void;
|
|
46
49
|
private appendLog;
|
|
47
50
|
handleMessageRemoved(event: MessageRemoveEvent): void;
|
|
48
51
|
private updateStats;
|
|
52
|
+
/** 计算有序数组的指定百分位数(线性插值法) */
|
|
53
|
+
private percentile;
|
|
49
54
|
getSessionStats(): SessionPerfStats;
|
|
50
55
|
readLogs(last?: number): LogEntry[];
|
|
51
56
|
reset(): void;
|
package/dist/perf-tracker.js
CHANGED
|
@@ -1,16 +1,22 @@
|
|
|
1
|
-
import { appendFileSync } from "node:fs";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
2
|
import { join } from "node:path";
|
|
4
3
|
import { homedir } from "node:os";
|
|
5
|
-
import { existsSync } from "node:fs";
|
|
4
|
+
import { existsSync, statSync } from "node:fs";
|
|
6
5
|
const LOG_PATH = join(homedir(), ".opencode", "tokenwatch.jsonl");
|
|
7
6
|
class PerfTracker {
|
|
8
7
|
firstPartTimes = new Map();
|
|
9
8
|
statsMap = new Map();
|
|
9
|
+
/** 原始样本串,用于分位数计算,不持久化 */
|
|
10
|
+
ttftSamples = new Map();
|
|
11
|
+
latencySamples = new Map();
|
|
10
12
|
handlePartUpdated(event) {
|
|
11
13
|
if (!event.time?.start || !event.message_id)
|
|
12
14
|
return;
|
|
13
|
-
|
|
15
|
+
// Bug fix: 取最早 part 时间而非最后一个,避免 TTFT 被高估
|
|
16
|
+
const cur = this.firstPartTimes.get(event.message_id) ?? Number.POSITIVE_INFINITY;
|
|
17
|
+
if (event.time.start < cur) {
|
|
18
|
+
this.firstPartTimes.set(event.message_id, event.time.start);
|
|
19
|
+
}
|
|
14
20
|
}
|
|
15
21
|
handleMessageUpdated(event) {
|
|
16
22
|
const info = event.properties?.info;
|
|
@@ -43,9 +49,9 @@ class PerfTracker {
|
|
|
43
49
|
const tps = (genMs !== null && genMs > 0 && outputTokens > 0)
|
|
44
50
|
? (outputTokens / genMs) * 1000
|
|
45
51
|
: null;
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
52
|
+
// Bug fix: 移除 TPS fallback。
|
|
53
|
+
// 原 fallback 用 latencyMs(completed-created,含排队+TTFT)计算 TPS,
|
|
54
|
+
// 会使结果严重低估(约 40%+)。null 表示"无可靠数据"比虚假数字更好。
|
|
49
55
|
this.firstPartTimes.delete(messageID);
|
|
50
56
|
const entry = {
|
|
51
57
|
ts: new Date().toISOString(),
|
|
@@ -54,7 +60,7 @@ class PerfTracker {
|
|
|
54
60
|
modelID,
|
|
55
61
|
sessionID,
|
|
56
62
|
ttft_ms: ttftMs,
|
|
57
|
-
tps: tps
|
|
63
|
+
tps: tps, // 只在有可靠 genMs 时才有值
|
|
58
64
|
latency_ms: latencyMs,
|
|
59
65
|
inputTokens,
|
|
60
66
|
outputTokens,
|
|
@@ -68,6 +74,14 @@ class PerfTracker {
|
|
|
68
74
|
}
|
|
69
75
|
appendLog(entry) {
|
|
70
76
|
try {
|
|
77
|
+
// Risk fix: JSONL 日志轮转保护,防止长期使用后文件无限增长
|
|
78
|
+
// 超过 5MB 时截断,保留最新 2000 行
|
|
79
|
+
const MAX_SIZE = 5 * 1024 * 1024; // 5 MB
|
|
80
|
+
const KEEP_LINES = 2000;
|
|
81
|
+
if (existsSync(LOG_PATH) && statSync(LOG_PATH).size > MAX_SIZE) {
|
|
82
|
+
const lines = readFileSync(LOG_PATH, "utf-8").trim().split("\n");
|
|
83
|
+
writeFileSync(LOG_PATH, lines.slice(-KEEP_LINES).join("\n") + "\n");
|
|
84
|
+
}
|
|
71
85
|
appendFileSync(LOG_PATH, JSON.stringify(entry) + "\n");
|
|
72
86
|
}
|
|
73
87
|
catch {
|
|
@@ -87,6 +101,9 @@ class PerfTracker {
|
|
|
87
101
|
model,
|
|
88
102
|
providerID: entry.providerID,
|
|
89
103
|
requestCount: 0,
|
|
104
|
+
ttftCount: 0, // Bug fix: 独立维护有效样本计数
|
|
105
|
+
tpsCount: 0,
|
|
106
|
+
latencyCount: 0,
|
|
90
107
|
totalInput: 0,
|
|
91
108
|
totalOutput: 0,
|
|
92
109
|
totalCacheRead: 0,
|
|
@@ -95,12 +112,19 @@ class PerfTracker {
|
|
|
95
112
|
avgTTFT: null,
|
|
96
113
|
maxTTFT: null,
|
|
97
114
|
minTTFT: null,
|
|
115
|
+
p50TTFT: null,
|
|
116
|
+
p95TTFT: null,
|
|
117
|
+
p99TTFT: null,
|
|
98
118
|
avgTPS: null,
|
|
99
119
|
maxTPS: null,
|
|
100
120
|
minTPS: null,
|
|
101
121
|
avgLatency: null,
|
|
102
122
|
maxLatency: null,
|
|
103
123
|
minLatency: null,
|
|
124
|
+
p50Latency: null,
|
|
125
|
+
p95Latency: null,
|
|
126
|
+
p99Latency: null,
|
|
127
|
+
cacheHitRate: null,
|
|
104
128
|
};
|
|
105
129
|
this.statsMap.set(model, stats);
|
|
106
130
|
}
|
|
@@ -111,41 +135,87 @@ class PerfTracker {
|
|
|
111
135
|
stats.totalCacheWrite += entry.cacheWriteTokens;
|
|
112
136
|
stats.totalCost += entry.cost;
|
|
113
137
|
if (entry.ttft_ms !== null) {
|
|
114
|
-
|
|
138
|
+
// Bug fix: 分母使用 ttftCount(有效样本数),而非 requestCount(总请求数)
|
|
139
|
+
stats.ttftCount++;
|
|
140
|
+
const c = stats.ttftCount;
|
|
115
141
|
const prev = stats.avgTTFT;
|
|
116
142
|
stats.avgTTFT = prev !== null ? prev + (entry.ttft_ms - prev) / c : entry.ttft_ms;
|
|
117
143
|
stats.maxTTFT = stats.maxTTFT !== null ? Math.max(stats.maxTTFT, entry.ttft_ms) : entry.ttft_ms;
|
|
118
144
|
stats.minTTFT = stats.minTTFT !== null ? Math.min(stats.minTTFT, entry.ttft_ms) : entry.ttft_ms;
|
|
145
|
+
// 收集原始样本用于分位数计算
|
|
146
|
+
const ttftArr = this.ttftSamples.get(model) ?? [];
|
|
147
|
+
ttftArr.push(entry.ttft_ms);
|
|
148
|
+
this.ttftSamples.set(model, ttftArr);
|
|
119
149
|
}
|
|
120
150
|
if (entry.tps !== null) {
|
|
121
|
-
|
|
151
|
+
// Bug fix: 分母使用 tpsCount(有效样本数)
|
|
152
|
+
stats.tpsCount++;
|
|
153
|
+
const c = stats.tpsCount;
|
|
122
154
|
const prev = stats.avgTPS;
|
|
123
155
|
stats.avgTPS = prev !== null ? prev + (entry.tps - prev) / c : entry.tps;
|
|
124
156
|
stats.maxTPS = stats.maxTPS !== null ? Math.max(stats.maxTPS, entry.tps) : entry.tps;
|
|
125
157
|
stats.minTPS = stats.minTPS !== null ? Math.min(stats.minTPS, entry.tps) : entry.tps;
|
|
126
158
|
}
|
|
127
159
|
if (entry.latency_ms !== null) {
|
|
128
|
-
|
|
160
|
+
// latency 每条消息都有,但保持一致使用专用计数
|
|
161
|
+
stats.latencyCount++;
|
|
162
|
+
const c = stats.latencyCount;
|
|
129
163
|
const prev = stats.avgLatency;
|
|
130
164
|
stats.avgLatency = prev !== null ? prev + (entry.latency_ms - prev) / c : entry.latency_ms;
|
|
131
165
|
stats.maxLatency = stats.maxLatency !== null ? Math.max(stats.maxLatency, entry.latency_ms) : entry.latency_ms;
|
|
132
166
|
stats.minLatency = stats.minLatency !== null ? Math.min(stats.minLatency, entry.latency_ms) : entry.latency_ms;
|
|
167
|
+
// 收集原始样本用于分位数计算
|
|
168
|
+
const latArr = this.latencySamples.get(model) ?? [];
|
|
169
|
+
latArr.push(entry.latency_ms);
|
|
170
|
+
this.latencySamples.set(model, latArr);
|
|
133
171
|
}
|
|
134
172
|
}
|
|
173
|
+
/** 计算有序数组的指定百分位数(线性插值法) */
|
|
174
|
+
percentile(sortedArr, p) {
|
|
175
|
+
if (sortedArr.length === 0)
|
|
176
|
+
return null;
|
|
177
|
+
if (sortedArr.length === 1)
|
|
178
|
+
return sortedArr[0];
|
|
179
|
+
const idx = (p / 100) * (sortedArr.length - 1);
|
|
180
|
+
const lo = Math.floor(idx);
|
|
181
|
+
const hi = Math.ceil(idx);
|
|
182
|
+
if (lo === hi)
|
|
183
|
+
return sortedArr[lo];
|
|
184
|
+
return sortedArr[lo] + (sortedArr[hi] - sortedArr[lo]) * (idx - lo);
|
|
185
|
+
}
|
|
135
186
|
getSessionStats() {
|
|
136
187
|
let totalInput = 0, totalOutput = 0, totalCacheRead = 0, totalCacheWrite = 0;
|
|
137
188
|
let totalRequests = 0, totalCost = 0;
|
|
138
|
-
|
|
189
|
+
let weightedHitSum = 0, totalReqForHit = 0;
|
|
190
|
+
for (const [model, s] of this.statsMap) {
|
|
139
191
|
totalInput += s.totalInput;
|
|
140
192
|
totalOutput += s.totalOutput;
|
|
141
193
|
totalCacheRead += s.totalCacheRead;
|
|
142
194
|
totalCacheWrite += s.totalCacheWrite;
|
|
143
195
|
totalRequests += s.requestCount;
|
|
144
196
|
totalCost += s.totalCost;
|
|
197
|
+
// 计算每个模型的分位数(需先排序)
|
|
198
|
+
const ttftArr = [...(this.ttftSamples.get(model) ?? [])].sort((a, b) => a - b);
|
|
199
|
+
s.p50TTFT = this.percentile(ttftArr, 50);
|
|
200
|
+
s.p95TTFT = this.percentile(ttftArr, 95);
|
|
201
|
+
s.p99TTFT = this.percentile(ttftArr, 99);
|
|
202
|
+
const latArr = [...(this.latencySamples.get(model) ?? [])].sort((a, b) => a - b);
|
|
203
|
+
s.p50Latency = this.percentile(latArr, 50);
|
|
204
|
+
s.p95Latency = this.percentile(latArr, 95);
|
|
205
|
+
s.p99Latency = this.percentile(latArr, 99);
|
|
206
|
+
// 模型级缓存命中率
|
|
207
|
+
const denom = s.totalInput + s.totalCacheRead;
|
|
208
|
+
s.cacheHitRate = denom > 0 ? (s.totalCacheRead / denom) * 100 : null;
|
|
209
|
+
// 累加加权命中率(按请求数加权)
|
|
210
|
+
if (s.cacheHitRate !== null) {
|
|
211
|
+
weightedHitSum += s.cacheHitRate * s.requestCount;
|
|
212
|
+
totalReqForHit += s.requestCount;
|
|
213
|
+
}
|
|
145
214
|
}
|
|
215
|
+
const weightedCacheHitRate = totalReqForHit > 0 ? weightedHitSum / totalReqForHit : null;
|
|
146
216
|
return {
|
|
147
217
|
models: Object.fromEntries(this.statsMap),
|
|
148
|
-
totals: { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalRequests, totalCost },
|
|
218
|
+
totals: { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalRequests, totalCost, weightedCacheHitRate },
|
|
149
219
|
};
|
|
150
220
|
}
|
|
151
221
|
readLogs(last = 50) {
|
|
@@ -174,6 +244,8 @@ class PerfTracker {
|
|
|
174
244
|
reset() {
|
|
175
245
|
this.firstPartTimes.clear();
|
|
176
246
|
this.statsMap.clear();
|
|
247
|
+
this.ttftSamples.clear();
|
|
248
|
+
this.latencySamples.clear();
|
|
177
249
|
}
|
|
178
250
|
}
|
|
179
251
|
export function createPerfTracker() {
|
package/dist/queries.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { DailyBreakdownItem, ModelBreakdownItem, ProviderBreakdownItem, SessionBreakdownItem, SessionTokenData, UsageFilters, UsageReport } from "./formatter.js";
|
|
1
|
+
import type { DailyBreakdownItem, ErrorStats, ModelBreakdownItem, ProviderBreakdownItem, SessionBreakdownItem, SessionTokenData, UsageFilters, UsageReport } from "./formatter.js";
|
|
2
2
|
export declare function getPresetRange(preset: "all" | "7d" | "30d" | "month"): Pick<UsageFilters, "startDate" | "endDate">;
|
|
3
3
|
export declare function getCurrentSessionStats(sessionId?: string): Promise<SessionTokenData>;
|
|
4
4
|
export declare function getSummary(filters?: UsageFilters): Promise<SessionTokenData>;
|
|
@@ -8,5 +8,7 @@ export declare function getDailyBreakdown(filters?: UsageFilters): Promise<Daily
|
|
|
8
8
|
export declare function getSessionBreakdown(filters?: UsageFilters): Promise<SessionBreakdownItem[]>;
|
|
9
9
|
export declare function getAvailableModels(): Promise<string[]>;
|
|
10
10
|
export declare function getAvailableProviders(): Promise<string[]>;
|
|
11
|
+
/** 失败请求计数 SQL。1次运行获取成功数+失败数+按模型细分 */
|
|
12
|
+
export declare function getErrorStats(filters?: UsageFilters): Promise<ErrorStats>;
|
|
11
13
|
export declare function getUsageReport(filters?: UsageFilters): Promise<UsageReport>;
|
|
12
14
|
export declare function exportReportAsCsv(report: UsageReport, section: "models" | "providers" | "daily" | "sessions"): string;
|
package/dist/queries.js
CHANGED
|
@@ -20,6 +20,10 @@ async function queryDb(sql) {
|
|
|
20
20
|
function escapeSql(value) {
|
|
21
21
|
return value.replace(/'/g, "''");
|
|
22
22
|
}
|
|
23
|
+
/** 校验日期格式必须为 YYYY-MM-DD,防止格式异常字符串进入 SQL */
|
|
24
|
+
function isValidDate(s) {
|
|
25
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(s);
|
|
26
|
+
}
|
|
23
27
|
function messageWhere(filters) {
|
|
24
28
|
const where = [
|
|
25
29
|
"json_extract(m.data, '$.role') = 'assistant'",
|
|
@@ -31,10 +35,13 @@ function messageWhere(filters) {
|
|
|
31
35
|
where.push(`coalesce(json_extract(m.data, '$.providerID'), '') = '${escapeSql(filters.provider)}'`);
|
|
32
36
|
if (filters.model)
|
|
33
37
|
where.push(`coalesce(json_extract(m.data, '$.modelID'), '') = '${escapeSql(filters.model)}'`);
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
// Risk fix: 日期参数先验证格式(YYYY-MM-DD),格式不符则忽略该过滤条件
|
|
39
|
+
if (filters.startDate && isValidDate(filters.startDate)) {
|
|
40
|
+
where.push(`date(m.time_created / 1000, 'unixepoch', 'localtime') >= '${filters.startDate}'`);
|
|
41
|
+
}
|
|
42
|
+
if (filters.endDate && isValidDate(filters.endDate)) {
|
|
43
|
+
where.push(`date(m.time_created / 1000, 'unixepoch', 'localtime') <= '${filters.endDate}'`);
|
|
44
|
+
}
|
|
38
45
|
return where.join(" AND ");
|
|
39
46
|
}
|
|
40
47
|
function parseList(value) {
|
|
@@ -83,41 +90,41 @@ export async function getCurrentSessionStats(sessionId) {
|
|
|
83
90
|
return getSummary(filters);
|
|
84
91
|
}
|
|
85
92
|
export async function getSummary(filters = {}) {
|
|
86
|
-
const sql = `
|
|
87
|
-
SELECT
|
|
88
|
-
group_concat(distinct coalesce(json_extract(m.data, '$.modelID'), 'unknown')) as models_used,
|
|
89
|
-
group_concat(distinct coalesce(json_extract(m.data, '$.providerID'), 'unknown')) as providers_used,
|
|
90
|
-
count(*) as request_count,
|
|
91
|
-
sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
|
|
92
|
-
sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
|
|
93
|
-
sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
|
|
94
|
-
sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
|
|
95
|
-
sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
|
|
96
|
-
sum(coalesce(json_extract(m.data, '$.tokens.cache.write'), 0)) as cache_write,
|
|
97
|
-
sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
|
|
98
|
-
FROM message m
|
|
99
|
-
WHERE ${messageWhere(filters)}
|
|
93
|
+
const sql = `
|
|
94
|
+
SELECT
|
|
95
|
+
group_concat(distinct coalesce(json_extract(m.data, '$.modelID'), 'unknown')) as models_used,
|
|
96
|
+
group_concat(distinct coalesce(json_extract(m.data, '$.providerID'), 'unknown')) as providers_used,
|
|
97
|
+
count(*) as request_count,
|
|
98
|
+
sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
|
|
99
|
+
sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
|
|
100
|
+
sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
|
|
101
|
+
sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
|
|
102
|
+
sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
|
|
103
|
+
sum(coalesce(json_extract(m.data, '$.tokens.cache.write'), 0)) as cache_write,
|
|
104
|
+
sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
|
|
105
|
+
FROM message m
|
|
106
|
+
WHERE ${messageWhere(filters)}
|
|
100
107
|
`.trim();
|
|
101
108
|
const rows = await queryDb(sql);
|
|
102
109
|
return toSessionTokenData(rows[0]);
|
|
103
110
|
}
|
|
104
111
|
export async function getModelBreakdown(filters = {}) {
|
|
105
|
-
const sql = `
|
|
106
|
-
SELECT
|
|
107
|
-
coalesce(json_extract(m.data, '$.providerID'), 'unknown') as provider,
|
|
108
|
-
coalesce(json_extract(m.data, '$.modelID'), 'unknown') as model,
|
|
109
|
-
count(*) as requests,
|
|
110
|
-
count(distinct m.session_id) as sessions,
|
|
111
|
-
sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
|
|
112
|
-
sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
|
|
113
|
-
sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
|
|
114
|
-
sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
|
|
115
|
-
sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
|
|
116
|
-
sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
|
|
117
|
-
FROM message m
|
|
118
|
-
WHERE ${messageWhere(filters)}
|
|
119
|
-
GROUP BY provider, model
|
|
120
|
-
ORDER BY total_tokens DESC
|
|
112
|
+
const sql = `
|
|
113
|
+
SELECT
|
|
114
|
+
coalesce(json_extract(m.data, '$.providerID'), 'unknown') as provider,
|
|
115
|
+
coalesce(json_extract(m.data, '$.modelID'), 'unknown') as model,
|
|
116
|
+
count(*) as requests,
|
|
117
|
+
count(distinct m.session_id) as sessions,
|
|
118
|
+
sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
|
|
119
|
+
sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
|
|
120
|
+
sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
|
|
121
|
+
sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
|
|
122
|
+
sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
|
|
123
|
+
sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
|
|
124
|
+
FROM message m
|
|
125
|
+
WHERE ${messageWhere(filters)}
|
|
126
|
+
GROUP BY provider, model
|
|
127
|
+
ORDER BY total_tokens DESC
|
|
121
128
|
`.trim();
|
|
122
129
|
const rows = await queryDb(sql);
|
|
123
130
|
return rows.map((row) => ({
|
|
@@ -134,21 +141,21 @@ ORDER BY total_tokens DESC
|
|
|
134
141
|
}));
|
|
135
142
|
}
|
|
136
143
|
export async function getProviderBreakdown(filters = {}) {
|
|
137
|
-
const sql = `
|
|
138
|
-
SELECT
|
|
139
|
-
coalesce(json_extract(m.data, '$.providerID'), 'unknown') as provider,
|
|
140
|
-
count(*) as requests,
|
|
141
|
-
count(distinct m.session_id) as sessions,
|
|
142
|
-
sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
|
|
143
|
-
sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
|
|
144
|
-
sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
|
|
145
|
-
sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
|
|
146
|
-
sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
|
|
147
|
-
sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
|
|
148
|
-
FROM message m
|
|
149
|
-
WHERE ${messageWhere(filters)}
|
|
150
|
-
GROUP BY provider
|
|
151
|
-
ORDER BY total_tokens DESC
|
|
144
|
+
const sql = `
|
|
145
|
+
SELECT
|
|
146
|
+
coalesce(json_extract(m.data, '$.providerID'), 'unknown') as provider,
|
|
147
|
+
count(*) as requests,
|
|
148
|
+
count(distinct m.session_id) as sessions,
|
|
149
|
+
sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
|
|
150
|
+
sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
|
|
151
|
+
sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
|
|
152
|
+
sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
|
|
153
|
+
sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
|
|
154
|
+
sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
|
|
155
|
+
FROM message m
|
|
156
|
+
WHERE ${messageWhere(filters)}
|
|
157
|
+
GROUP BY provider
|
|
158
|
+
ORDER BY total_tokens DESC
|
|
152
159
|
`.trim();
|
|
153
160
|
const rows = await queryDb(sql);
|
|
154
161
|
return rows.map((row) => ({
|
|
@@ -165,22 +172,22 @@ ORDER BY total_tokens DESC
|
|
|
165
172
|
}
|
|
166
173
|
export async function getDailyBreakdown(filters = {}) {
|
|
167
174
|
const limit = filters.limit ?? 30;
|
|
168
|
-
const sql = `
|
|
169
|
-
SELECT
|
|
170
|
-
date(m.time_created / 1000, 'unixepoch', 'localtime') as day,
|
|
171
|
-
count(*) as requests,
|
|
172
|
-
count(distinct m.session_id) as sessions,
|
|
173
|
-
sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
|
|
174
|
-
sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
|
|
175
|
-
sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
|
|
176
|
-
sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
|
|
177
|
-
sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
|
|
178
|
-
sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
|
|
179
|
-
FROM message m
|
|
180
|
-
WHERE ${messageWhere(filters)}
|
|
181
|
-
GROUP BY day
|
|
182
|
-
ORDER BY day DESC
|
|
183
|
-
LIMIT ${Math.max(1, limit)}
|
|
175
|
+
const sql = `
|
|
176
|
+
SELECT
|
|
177
|
+
date(m.time_created / 1000, 'unixepoch', 'localtime') as day,
|
|
178
|
+
count(*) as requests,
|
|
179
|
+
count(distinct m.session_id) as sessions,
|
|
180
|
+
sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
|
|
181
|
+
sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
|
|
182
|
+
sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
|
|
183
|
+
sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
|
|
184
|
+
sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
|
|
185
|
+
sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost
|
|
186
|
+
FROM message m
|
|
187
|
+
WHERE ${messageWhere(filters)}
|
|
188
|
+
GROUP BY day
|
|
189
|
+
ORDER BY day DESC
|
|
190
|
+
LIMIT ${Math.max(1, limit)}
|
|
184
191
|
`.trim();
|
|
185
192
|
const rows = await queryDb(sql);
|
|
186
193
|
return rows.map((row) => ({
|
|
@@ -197,26 +204,26 @@ LIMIT ${Math.max(1, limit)}
|
|
|
197
204
|
}
|
|
198
205
|
export async function getSessionBreakdown(filters = {}) {
|
|
199
206
|
const limit = filters.limit ?? 15;
|
|
200
|
-
const sql = `
|
|
201
|
-
SELECT
|
|
202
|
-
s.id as session_id,
|
|
203
|
-
s.title as title,
|
|
204
|
-
coalesce(json_extract(m.data, '$.providerID'), json_extract(s.model, '$.providerID'), 'unknown') as provider,
|
|
205
|
-
coalesce(json_extract(m.data, '$.modelID'), json_extract(s.model, '$.id'), 'unknown') as model,
|
|
206
|
-
count(*) as requests,
|
|
207
|
-
sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
|
|
208
|
-
sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
|
|
209
|
-
sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
|
|
210
|
-
sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
|
|
211
|
-
sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
|
|
212
|
-
sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost,
|
|
213
|
-
date(max(m.time_created) / 1000, 'unixepoch', 'localtime') as day
|
|
214
|
-
FROM message m
|
|
215
|
-
JOIN session s ON s.id = m.session_id
|
|
216
|
-
WHERE ${messageWhere(filters)}
|
|
217
|
-
GROUP BY s.id, s.title, provider, model
|
|
218
|
-
ORDER BY max(m.time_created) DESC
|
|
219
|
-
LIMIT ${Math.max(1, limit)}
|
|
207
|
+
const sql = `
|
|
208
|
+
SELECT
|
|
209
|
+
s.id as session_id,
|
|
210
|
+
s.title as title,
|
|
211
|
+
coalesce(json_extract(m.data, '$.providerID'), json_extract(s.model, '$.providerID'), 'unknown') as provider,
|
|
212
|
+
coalesce(json_extract(m.data, '$.modelID'), json_extract(s.model, '$.id'), 'unknown') as model,
|
|
213
|
+
count(*) as requests,
|
|
214
|
+
sum(coalesce(json_extract(m.data, '$.tokens.total'), 0)) as total_tokens,
|
|
215
|
+
sum(coalesce(json_extract(m.data, '$.tokens.input'), 0)) as input_tokens,
|
|
216
|
+
sum(coalesce(json_extract(m.data, '$.tokens.output'), 0)) as output_tokens,
|
|
217
|
+
sum(coalesce(json_extract(m.data, '$.tokens.reasoning'), 0)) as reasoning_tokens,
|
|
218
|
+
sum(coalesce(json_extract(m.data, '$.tokens.cache.read'), 0)) as cache_read,
|
|
219
|
+
sum(coalesce(json_extract(m.data, '$.cost'), 0)) as total_cost,
|
|
220
|
+
date(max(m.time_created) / 1000, 'unixepoch', 'localtime') as day
|
|
221
|
+
FROM message m
|
|
222
|
+
JOIN session s ON s.id = m.session_id
|
|
223
|
+
WHERE ${messageWhere(filters)}
|
|
224
|
+
GROUP BY s.id, s.title, provider, model
|
|
225
|
+
ORDER BY max(m.time_created) DESC
|
|
226
|
+
LIMIT ${Math.max(1, limit)}
|
|
220
227
|
`.trim();
|
|
221
228
|
const rows = await queryDb(sql);
|
|
222
229
|
return rows.map((row) => ({
|
|
@@ -235,34 +242,86 @@ LIMIT ${Math.max(1, limit)}
|
|
|
235
242
|
}));
|
|
236
243
|
}
|
|
237
244
|
export async function getAvailableModels() {
|
|
238
|
-
const sql = `
|
|
239
|
-
SELECT distinct coalesce(json_extract(m.data, '$.modelID'), 'unknown') as value
|
|
240
|
-
FROM message m
|
|
241
|
-
WHERE ${messageWhere({})}
|
|
242
|
-
ORDER BY value ASC
|
|
245
|
+
const sql = `
|
|
246
|
+
SELECT distinct coalesce(json_extract(m.data, '$.modelID'), 'unknown') as value
|
|
247
|
+
FROM message m
|
|
248
|
+
WHERE ${messageWhere({})}
|
|
249
|
+
ORDER BY value ASC
|
|
243
250
|
`.trim();
|
|
244
251
|
const rows = await queryDb(sql);
|
|
245
252
|
return rows.map((row) => row.value ?? "unknown");
|
|
246
253
|
}
|
|
247
254
|
export async function getAvailableProviders() {
|
|
248
|
-
const sql = `
|
|
249
|
-
SELECT distinct coalesce(json_extract(m.data, '$.providerID'), 'unknown') as value
|
|
250
|
-
FROM message m
|
|
251
|
-
WHERE ${messageWhere({})}
|
|
252
|
-
ORDER BY value ASC
|
|
255
|
+
const sql = `
|
|
256
|
+
SELECT distinct coalesce(json_extract(m.data, '$.providerID'), 'unknown') as value
|
|
257
|
+
FROM message m
|
|
258
|
+
WHERE ${messageWhere({})}
|
|
259
|
+
ORDER BY value ASC
|
|
253
260
|
`.trim();
|
|
254
261
|
const rows = await queryDb(sql);
|
|
255
262
|
return rows.map((row) => row.value ?? "unknown");
|
|
256
263
|
}
|
|
264
|
+
/** 失败请求计数 SQL。1次运行获取成功数+失败数+按模型细分 */
|
|
265
|
+
export async function getErrorStats(filters = {}) {
|
|
266
|
+
// 构建日期/Session/Provider/Model 过滤条件(不包含 tokens.total > 0 过滤)
|
|
267
|
+
const baseConds = [
|
|
268
|
+
"json_extract(m.data, '$.role') = 'assistant'",
|
|
269
|
+
];
|
|
270
|
+
if (filters.sessionId)
|
|
271
|
+
baseConds.push(`m.session_id = '${escapeSql(filters.sessionId)}'`);
|
|
272
|
+
if (filters.provider)
|
|
273
|
+
baseConds.push(`coalesce(json_extract(m.data, '$.providerID'), '') = '${escapeSql(filters.provider)}'`);
|
|
274
|
+
if (filters.model)
|
|
275
|
+
baseConds.push(`coalesce(json_extract(m.data, '$.modelID'), '') = '${escapeSql(filters.model)}'`);
|
|
276
|
+
if (filters.startDate && isValidDate(filters.startDate)) {
|
|
277
|
+
baseConds.push(`date(m.time_created / 1000, 'unixepoch', 'localtime') >= '${filters.startDate}'`);
|
|
278
|
+
}
|
|
279
|
+
if (filters.endDate && isValidDate(filters.endDate)) {
|
|
280
|
+
baseConds.push(`date(m.time_created / 1000, 'unixepoch', 'localtime') <= '${filters.endDate}'`);
|
|
281
|
+
}
|
|
282
|
+
const baseWhere = baseConds.join(" AND ");
|
|
283
|
+
// 按模型细化:同时统计成功和失败请求
|
|
284
|
+
const sql = `
|
|
285
|
+
SELECT
|
|
286
|
+
coalesce(json_extract(m.data, '$.providerID'), 'unknown') as provider,
|
|
287
|
+
coalesce(json_extract(m.data, '$.modelID'), 'unknown') as model,
|
|
288
|
+
count(*) as total,
|
|
289
|
+
sum(CASE WHEN coalesce(json_extract(m.data, '$.tokens.total'), 0) = 0 THEN 1 ELSE 0 END) as failed
|
|
290
|
+
FROM message m
|
|
291
|
+
WHERE ${baseWhere}
|
|
292
|
+
GROUP BY provider, model
|
|
293
|
+
ORDER BY failed DESC
|
|
294
|
+
`.trim();
|
|
295
|
+
try {
|
|
296
|
+
const rows = await queryDb(sql);
|
|
297
|
+
let successCount = 0, failedCount = 0;
|
|
298
|
+
const byModel = rows.map(r => {
|
|
299
|
+
const total = r.total ?? 0;
|
|
300
|
+
const failed = r.failed ?? 0;
|
|
301
|
+
const success = total - failed;
|
|
302
|
+
successCount += success;
|
|
303
|
+
failedCount += failed;
|
|
304
|
+
return { provider: r.provider ?? 'unknown', model: r.model ?? 'unknown', failed, total };
|
|
305
|
+
});
|
|
306
|
+
const errorRate = (successCount + failedCount) > 0
|
|
307
|
+
? failedCount / (successCount + failedCount)
|
|
308
|
+
: 0;
|
|
309
|
+
return { successCount, failedCount, errorRate, byModel };
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
return { successCount: 0, failedCount: 0, errorRate: 0, byModel: [] };
|
|
313
|
+
}
|
|
314
|
+
}
|
|
257
315
|
export async function getUsageReport(filters = {}) {
|
|
258
|
-
const [summary, models, providers, daily, sessions] = await Promise.all([
|
|
316
|
+
const [summary, models, providers, daily, sessions, errors] = await Promise.all([
|
|
259
317
|
getSummary(filters),
|
|
260
318
|
getModelBreakdown(filters),
|
|
261
319
|
getProviderBreakdown(filters),
|
|
262
320
|
getDailyBreakdown(filters),
|
|
263
321
|
getSessionBreakdown(filters),
|
|
322
|
+
getErrorStats(filters),
|
|
264
323
|
]);
|
|
265
|
-
return { filters, summary, models, providers, daily, sessions };
|
|
324
|
+
return { filters, summary, models, providers, daily, sessions, errors };
|
|
266
325
|
}
|
|
267
326
|
function csvEscape(value) {
|
|
268
327
|
const text = String(value);
|