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/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",
@@ -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;
@@ -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
- this.firstPartTimes.set(event.message_id, event.time.start);
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
- const tpsFallback = (tps === null && latencyMs > 0 && outputTokens > 0)
47
- ? (outputTokens / latencyMs) * 1000
48
- : null;
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 ?? tpsFallback,
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
- const c = stats.requestCount;
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
- const c = stats.requestCount;
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
- const c = stats.requestCount;
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
- for (const s of this.statsMap.values()) {
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
- if (filters.startDate)
35
- where.push(`date(m.time_created / 1000, 'unixepoch', 'localtime') >= '${escapeSql(filters.startDate)}'`);
36
- if (filters.endDate)
37
- where.push(`date(m.time_created / 1000, 'unixepoch', 'localtime') <= '${escapeSql(filters.endDate)}'`);
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);