opencode-tokenwatch 0.1.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.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ export type SupportedLanguage = "zh" | "en";
2
+ export declare function detectLanguage(): SupportedLanguage;
3
+ export declare function setLanguage(lang: SupportedLanguage | "auto"): void;
4
+ export declare function getCurrentLanguage(): SupportedLanguage;
5
+ export declare function t(key: string): string;
package/dist/i18n.js ADDED
@@ -0,0 +1,171 @@
1
+ const zh = {
2
+ panelTitle: "TokenWatch",
3
+ collapse: "折叠",
4
+ expand: "展开",
5
+ sessionSummary: "会话累计",
6
+ input: "输入",
7
+ output: "输出",
8
+ cacheRead: "缓存",
9
+ cacheWrite: "缓存写",
10
+ cacheMiss: "未命中",
11
+ hitRate: "命中率",
12
+ requests: "请求",
13
+ cost: "成本",
14
+ trendUp: "↑",
15
+ trendDown: "↓",
16
+ cache: "缓存",
17
+ performance: "性能",
18
+ pricing: "Pricing",
19
+ tokenDistribution: "Token分布",
20
+ modelLabel: "模型",
21
+ provider: "提供商",
22
+ ttft: "TTFT",
23
+ tps: "TPS",
24
+ latency: "延迟",
25
+ avg: "平均",
26
+ max: "最大",
27
+ min: "最小",
28
+ read: "读",
29
+ write: "写",
30
+ sessionAccumulated: "Session累计",
31
+ saving: "节省",
32
+ priceInput: "输入",
33
+ priceCacheRead: "缓存读",
34
+ priceCacheWrite: "缓存写",
35
+ priceOutput: "输出",
36
+ total: "总计",
37
+ system: "系统提示",
38
+ user: "用户",
39
+ agent: "Agent指令",
40
+ toolCall: "Tool调用",
41
+ toolResult: "Tool结果",
42
+ outputTokens: "输出",
43
+ showPerformance: "显示性能指标",
44
+ showPricing: "显示模型定价",
45
+ showTokenDistribution: "显示Token分布",
46
+ showTrend: "显示趋势指示器",
47
+ language: "语言",
48
+ auto: "自动",
49
+ cmdTitleHtml: "HTML报告",
50
+ cmdDescHtml: "生成交互式HTML仪表盘,展示Token用量、缓存和性能图表",
51
+ cmdTitleJson: "JSON导出",
52
+ cmdDescJson: "导出原始用量数据为JSON文件",
53
+ cmdTitleText: "文本报告",
54
+ cmdDescText: "生成纯文本报告文件",
55
+ cmdTitleSettings: "设置",
56
+ cmdDescSettings: "配置侧边栏显示选项",
57
+ descShowPerformance: "在侧边栏显示TPS、TTFT、延迟等指标",
58
+ descShowPricing: "在侧边栏显示成本估算",
59
+ descShowTokenDistribution: "在侧边栏显示输入/输出/推理Token细分",
60
+ descShowTrend: "在侧边栏显示Token用量趋势",
61
+ settingsLanguage: "语言",
62
+ descSettingsLanguage: "切换显示语言",
63
+ settingsTitle: "TokenWatch设置",
64
+ settingsPlaceholder: "切换设置项...",
65
+ langAuto: "自动",
66
+ done: "完成",
67
+ closeSettings: "关闭设置",
68
+ menuToday: "今天",
69
+ menu7d: "最近 7 天",
70
+ menu30d: "最近 30 天",
71
+ menuAll: "全部时间",
72
+ };
73
+ const en = {
74
+ panelTitle: "TokenWatch",
75
+ collapse: "Collapse",
76
+ expand: "Expand",
77
+ sessionSummary: "Session",
78
+ input: "Input",
79
+ output: "Output",
80
+ cacheRead: "Cache",
81
+ cacheWrite: "C.Write",
82
+ cacheMiss: "Cache Miss",
83
+ hitRate: "Hit Rate",
84
+ requests: "Req",
85
+ cost: "Cost",
86
+ trendUp: "↑",
87
+ trendDown: "↓",
88
+ cache: "Cache",
89
+ performance: "Performance",
90
+ pricing: "Pricing",
91
+ tokenDistribution: "Token Distribution",
92
+ modelLabel: "Model",
93
+ provider: "Provider",
94
+ ttft: "TTFT",
95
+ tps: "TPS",
96
+ latency: "Latency",
97
+ avg: "Avg",
98
+ max: "Max",
99
+ min: "Min",
100
+ read: "Read",
101
+ write: "Write",
102
+ sessionAccumulated: "Session Accumulated",
103
+ saving: "Saving",
104
+ priceInput: "Input",
105
+ priceCacheRead: "Cache Read",
106
+ priceCacheWrite: "Cache Write",
107
+ priceOutput: "Output",
108
+ total: "Total",
109
+ system: "System",
110
+ user: "User",
111
+ agent: "Agent",
112
+ toolCall: "Tool Call",
113
+ toolResult: "Tool Result",
114
+ outputTokens: "Output",
115
+ showPerformance: "Show Performance",
116
+ showPricing: "Show Pricing",
117
+ showTokenDistribution: "Show Token Distribution",
118
+ showTrend: "Show Trend",
119
+ language: "Language",
120
+ auto: "Auto",
121
+ cmdTitleHtml: "HTML Report",
122
+ cmdDescHtml: "Generate interactive HTML dashboard with token usage, cache, and performance charts",
123
+ cmdTitleJson: "JSON Export",
124
+ cmdDescJson: "Export raw usage data as JSON file",
125
+ cmdTitleText: "Text Report",
126
+ cmdDescText: "Generate plain text report file",
127
+ cmdTitleSettings: "Settings",
128
+ cmdDescSettings: "Configure sidebar display options",
129
+ descShowPerformance: "Display TPS, TTFT, latency metrics in sidebar",
130
+ descShowPricing: "Display cost estimates in sidebar",
131
+ descShowTokenDistribution: "Display input/output/reasoning token breakdown in sidebar",
132
+ descShowTrend: "Display token usage trend in sidebar",
133
+ settingsLanguage: "Language",
134
+ descSettingsLanguage: "Switch display language",
135
+ settingsTitle: "TokenWatch Settings",
136
+ settingsPlaceholder: "Toggle settings...",
137
+ langAuto: "Auto",
138
+ done: "Done",
139
+ closeSettings: "Close settings",
140
+ menuToday: "Today",
141
+ menu7d: "Last 7 Days",
142
+ menu30d: "Last 30 Days",
143
+ menuAll: "All Time",
144
+ };
145
+ let currentLang = detectLanguage();
146
+ export function detectLanguage() {
147
+ try {
148
+ const locale = Intl.DateTimeFormat().resolvedOptions().locale;
149
+ if (locale.startsWith("zh"))
150
+ return "zh";
151
+ }
152
+ catch {
153
+ // Intl may not be available in restricted environments
154
+ }
155
+ return "en";
156
+ }
157
+ export function setLanguage(lang) {
158
+ if (lang === "auto") {
159
+ currentLang = detectLanguage();
160
+ }
161
+ else {
162
+ currentLang = lang;
163
+ }
164
+ }
165
+ export function getCurrentLanguage() {
166
+ return currentLang;
167
+ }
168
+ export function t(key) {
169
+ const table = currentLang === "zh" ? zh : en;
170
+ return table[key] ?? key;
171
+ }
@@ -0,0 +1,60 @@
1
+ import type { LogEntry, SessionPerfStats } from "./formatter.js";
2
+ interface PartEvent {
3
+ message_id?: string;
4
+ type?: string;
5
+ text?: string;
6
+ time?: {
7
+ start?: number;
8
+ };
9
+ }
10
+ interface MessageUpdateEvent {
11
+ properties: {
12
+ info: {
13
+ id?: string;
14
+ sessionID?: string;
15
+ role?: string;
16
+ providerID?: string;
17
+ modelID?: string;
18
+ tokens?: {
19
+ input?: number;
20
+ output?: number;
21
+ reasoning?: number;
22
+ cache?: {
23
+ read?: number;
24
+ write?: number;
25
+ };
26
+ };
27
+ cost?: number;
28
+ time?: {
29
+ created?: number;
30
+ completed?: number;
31
+ };
32
+ };
33
+ };
34
+ }
35
+ interface MessageRemoveEvent {
36
+ properties: {
37
+ sessionID?: string;
38
+ messageID?: string;
39
+ };
40
+ }
41
+ declare class PerfTracker {
42
+ private firstPartTimes;
43
+ private statsMap;
44
+ /** 原始样本串,用于分位数计算,不持久化 */
45
+ private ttftSamples;
46
+ private latencySamples;
47
+ handlePartUpdated(event: PartEvent): void;
48
+ handleMessageUpdated(event: MessageUpdateEvent): void;
49
+ private appendLog;
50
+ handleMessageRemoved(event: MessageRemoveEvent): void;
51
+ private updateStats;
52
+ /** 计算有序数组的指定百分位数(线性插值法) */
53
+ private percentile;
54
+ getSessionStats(): SessionPerfStats;
55
+ readLogs(last?: number): LogEntry[];
56
+ reset(): void;
57
+ }
58
+ export declare function createPerfTracker(): PerfTracker;
59
+ export type { PartEvent, PerfTracker };
60
+ export declare function readLogs(last?: number): LogEntry[];
@@ -0,0 +1,257 @@
1
+ import { appendFileSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { homedir } from "node:os";
4
+ import { existsSync, statSync } from "node:fs";
5
+ const LOG_PATH = join(homedir(), ".opencode", "tokenwatch.jsonl");
6
+ class PerfTracker {
7
+ firstPartTimes = new Map();
8
+ statsMap = new Map();
9
+ /** 原始样本串,用于分位数计算,不持久化 */
10
+ ttftSamples = new Map();
11
+ latencySamples = new Map();
12
+ handlePartUpdated(event) {
13
+ if (!event.time?.start || !event.message_id)
14
+ return;
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
+ }
20
+ }
21
+ handleMessageUpdated(event) {
22
+ const info = event.properties?.info;
23
+ if (!info || info.role !== "assistant")
24
+ return;
25
+ if (!info.time?.completed)
26
+ return;
27
+ const messageID = info.id ?? "";
28
+ const created = info.time.created;
29
+ const completed = info.time.completed;
30
+ if (!created || !completed) {
31
+ this.firstPartTimes.delete(messageID);
32
+ return;
33
+ }
34
+ const sessionID = info.sessionID ?? "";
35
+ const providerID = info.providerID ?? "unknown";
36
+ const modelID = info.modelID ?? "unknown";
37
+ const model = `${providerID}/${modelID}`;
38
+ const tokens = info.tokens;
39
+ const inputTokens = tokens?.input ?? 0;
40
+ const outputTokens = tokens?.output ?? 0;
41
+ const reasoningTokens = tokens?.reasoning ?? 0;
42
+ const cacheRead = tokens?.cache?.read ?? 0;
43
+ const cacheWrite = tokens?.cache?.write ?? 0;
44
+ const cost = info.cost ?? 0;
45
+ const firstPart = this.firstPartTimes.get(messageID) ?? null;
46
+ const latencyMs = completed - created;
47
+ const ttftMs = firstPart !== null ? firstPart - created : null;
48
+ const genMs = firstPart !== null ? completed - firstPart : null;
49
+ const tps = (genMs !== null && genMs > 0 && outputTokens > 0)
50
+ ? (outputTokens / genMs) * 1000
51
+ : null;
52
+ // Bug fix: 移除 TPS fallback。
53
+ // 原 fallback 用 latencyMs(completed-created,含排队+TTFT)计算 TPS,
54
+ // 会使结果严重低估(约 40%+)。null 表示"无可靠数据"比虚假数字更好。
55
+ this.firstPartTimes.delete(messageID);
56
+ const entry = {
57
+ ts: new Date().toISOString(),
58
+ model,
59
+ providerID,
60
+ modelID,
61
+ sessionID,
62
+ ttft_ms: ttftMs,
63
+ tps: tps, // 只在有可靠 genMs 时才有值
64
+ latency_ms: latencyMs,
65
+ inputTokens,
66
+ outputTokens,
67
+ reasoningTokens,
68
+ cacheReadTokens: cacheRead,
69
+ cacheWriteTokens: cacheWrite,
70
+ cost,
71
+ };
72
+ this.appendLog(entry);
73
+ this.updateStats(model, entry);
74
+ }
75
+ appendLog(entry) {
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
+ }
85
+ appendFileSync(LOG_PATH, JSON.stringify(entry) + "\n");
86
+ }
87
+ catch {
88
+ // Silently fail — logging is non-critical
89
+ }
90
+ }
91
+ handleMessageRemoved(event) {
92
+ const mid = event.properties?.messageID ?? "";
93
+ if (mid) {
94
+ this.firstPartTimes.delete(mid);
95
+ }
96
+ }
97
+ updateStats(model, entry) {
98
+ let stats = this.statsMap.get(model);
99
+ if (!stats) {
100
+ stats = {
101
+ model,
102
+ providerID: entry.providerID,
103
+ requestCount: 0,
104
+ ttftCount: 0, // Bug fix: 独立维护有效样本计数
105
+ tpsCount: 0,
106
+ latencyCount: 0,
107
+ totalInput: 0,
108
+ totalOutput: 0,
109
+ totalCacheRead: 0,
110
+ totalCacheWrite: 0,
111
+ totalCost: 0,
112
+ avgTTFT: null,
113
+ maxTTFT: null,
114
+ minTTFT: null,
115
+ p50TTFT: null,
116
+ p95TTFT: null,
117
+ p99TTFT: null,
118
+ avgTPS: null,
119
+ maxTPS: null,
120
+ minTPS: null,
121
+ avgLatency: null,
122
+ maxLatency: null,
123
+ minLatency: null,
124
+ p50Latency: null,
125
+ p95Latency: null,
126
+ p99Latency: null,
127
+ cacheHitRate: null,
128
+ };
129
+ this.statsMap.set(model, stats);
130
+ }
131
+ stats.requestCount++;
132
+ stats.totalInput += entry.inputTokens;
133
+ stats.totalOutput += entry.outputTokens;
134
+ stats.totalCacheRead += entry.cacheReadTokens;
135
+ stats.totalCacheWrite += entry.cacheWriteTokens;
136
+ stats.totalCost += entry.cost;
137
+ if (entry.ttft_ms !== null) {
138
+ // Bug fix: 分母使用 ttftCount(有效样本数),而非 requestCount(总请求数)
139
+ stats.ttftCount++;
140
+ const c = stats.ttftCount;
141
+ const prev = stats.avgTTFT;
142
+ stats.avgTTFT = prev !== null ? prev + (entry.ttft_ms - prev) / c : entry.ttft_ms;
143
+ stats.maxTTFT = stats.maxTTFT !== null ? Math.max(stats.maxTTFT, entry.ttft_ms) : entry.ttft_ms;
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);
149
+ }
150
+ if (entry.tps !== null) {
151
+ // Bug fix: 分母使用 tpsCount(有效样本数)
152
+ stats.tpsCount++;
153
+ const c = stats.tpsCount;
154
+ const prev = stats.avgTPS;
155
+ stats.avgTPS = prev !== null ? prev + (entry.tps - prev) / c : entry.tps;
156
+ stats.maxTPS = stats.maxTPS !== null ? Math.max(stats.maxTPS, entry.tps) : entry.tps;
157
+ stats.minTPS = stats.minTPS !== null ? Math.min(stats.minTPS, entry.tps) : entry.tps;
158
+ }
159
+ if (entry.latency_ms !== null) {
160
+ // latency 每条消息都有,但保持一致使用专用计数
161
+ stats.latencyCount++;
162
+ const c = stats.latencyCount;
163
+ const prev = stats.avgLatency;
164
+ stats.avgLatency = prev !== null ? prev + (entry.latency_ms - prev) / c : entry.latency_ms;
165
+ stats.maxLatency = stats.maxLatency !== null ? Math.max(stats.maxLatency, entry.latency_ms) : entry.latency_ms;
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);
171
+ }
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
+ }
186
+ getSessionStats() {
187
+ let totalInput = 0, totalOutput = 0, totalCacheRead = 0, totalCacheWrite = 0;
188
+ let totalRequests = 0, totalCost = 0;
189
+ let weightedHitSum = 0, totalReqForHit = 0;
190
+ for (const [model, s] of this.statsMap) {
191
+ totalInput += s.totalInput;
192
+ totalOutput += s.totalOutput;
193
+ totalCacheRead += s.totalCacheRead;
194
+ totalCacheWrite += s.totalCacheWrite;
195
+ totalRequests += s.requestCount;
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
+ }
214
+ }
215
+ const weightedCacheHitRate = totalReqForHit > 0 ? weightedHitSum / totalReqForHit : null;
216
+ return {
217
+ models: Object.fromEntries(this.statsMap),
218
+ totals: { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalRequests, totalCost, weightedCacheHitRate },
219
+ };
220
+ }
221
+ readLogs(last = 50) {
222
+ try {
223
+ if (!existsSync(LOG_PATH))
224
+ return [];
225
+ const content = readFileSync(LOG_PATH, "utf-8").trim();
226
+ if (!content)
227
+ return [];
228
+ const lines = content.split("\n");
229
+ const entries = [];
230
+ for (let i = Math.max(0, lines.length - last); i < lines.length; i++) {
231
+ try {
232
+ entries.push(JSON.parse(lines[i]));
233
+ }
234
+ catch {
235
+ // Skip malformed lines
236
+ }
237
+ }
238
+ return entries;
239
+ }
240
+ catch {
241
+ return [];
242
+ }
243
+ }
244
+ reset() {
245
+ this.firstPartTimes.clear();
246
+ this.statsMap.clear();
247
+ this.ttftSamples.clear();
248
+ this.latencySamples.clear();
249
+ }
250
+ }
251
+ export function createPerfTracker() {
252
+ return new PerfTracker();
253
+ }
254
+ export function readLogs(last = 50) {
255
+ const tracker = new PerfTracker();
256
+ return tracker.readLogs(last);
257
+ }
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;