opencode-token-tracker 1.7.0 → 1.8.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/README.md +61 -7
- package/README.zh-CN.md +59 -7
- package/dist/bin/opencode-tokens.js +593 -75
- package/dist/index.js +94 -41
- package/dist/lib/shared.d.ts +110 -3
- package/dist/lib/shared.js +288 -54
- package/package.json +5 -2
package/dist/lib/shared.js
CHANGED
|
@@ -2,28 +2,56 @@
|
|
|
2
2
|
// Shared types, pricing data, and utilities for opencode-token-tracker
|
|
3
3
|
// ============================================================================
|
|
4
4
|
// ============================================================================
|
|
5
|
-
//
|
|
5
|
+
// 内置价格(USD / 1M tokens);核验日期按型号维护,本轮仅更新 DeepSeek 和 Kimi。
|
|
6
6
|
// Sources:
|
|
7
|
-
// - Anthropic: https://
|
|
8
|
-
// - OpenAI: https://openai.com/api/pricing
|
|
7
|
+
// - Anthropic: https://platform.claude.com/docs/en/about-claude/pricing
|
|
8
|
+
// - OpenAI: https://developers.openai.com/api/docs/pricing
|
|
9
9
|
// - DeepSeek: https://api-docs.deepseek.com/quick_start/pricing
|
|
10
10
|
// - Google: https://cloud.google.com/vertex-ai/generative-ai/pricing
|
|
11
11
|
// ============================================================================
|
|
12
12
|
export const BUILTIN_PRICING_META = {
|
|
13
|
-
pricingLastUpdated: "2026-
|
|
14
|
-
metadataLastUpdated: "2026-05
|
|
13
|
+
pricingLastUpdated: "2026-10-05",
|
|
14
|
+
metadataLastUpdated: "2026-10-05",
|
|
15
|
+
baselineLastUpdated: "2026-05-29",
|
|
15
16
|
source: "Provider official pricing pages",
|
|
16
|
-
notes: "Manually maintained
|
|
17
|
+
notes: "Partial refresh: DeepSeek Flash/Pro and Kimi K2.7 Code only. Other entries retain the baseline audit date. Manually maintained; recent audits do not guarantee unchanged prices.",
|
|
18
|
+
};
|
|
19
|
+
export const PRICING_STALE_AFTER_DAYS = 90;
|
|
20
|
+
const DEEPSEEK_PEAK_AUDIT = {
|
|
21
|
+
reviewedAt: "2026-10-05",
|
|
22
|
+
basis: "peak",
|
|
23
|
+
exactOrPrefixed: true,
|
|
24
|
+
sourceUrl: "https://api-docs.deepseek.com/quick_start/pricing/",
|
|
25
|
+
};
|
|
26
|
+
const DEEPSEEK_LEGACY_AUDIT = {
|
|
27
|
+
reviewedAt: "2026-05-29",
|
|
28
|
+
expiresAt: "2026-07-24T16:00:00Z",
|
|
29
|
+
sourceUrl: "https://api-docs.deepseek.com/news/news260424/",
|
|
30
|
+
};
|
|
31
|
+
export const BUILTIN_PRICING_AUDITS = {
|
|
32
|
+
"deepseek-flash": DEEPSEEK_PEAK_AUDIT,
|
|
33
|
+
"deepseek-v4-flash": DEEPSEEK_PEAK_AUDIT,
|
|
34
|
+
"deepseek-v4-flash-vision-exp": DEEPSEEK_PEAK_AUDIT,
|
|
35
|
+
"deepseek-v4-pro": DEEPSEEK_PEAK_AUDIT,
|
|
36
|
+
"deepseek-chat": DEEPSEEK_LEGACY_AUDIT,
|
|
37
|
+
"deepseek-reasoner": DEEPSEEK_LEGACY_AUDIT,
|
|
38
|
+
"kimi-k2.7-code": {
|
|
39
|
+
reviewedAt: "2026-10-05",
|
|
40
|
+
exactOrPrefixed: true,
|
|
41
|
+
sourceUrl: "https://www.kimi.ai/resources/kimi-k2-7-code",
|
|
42
|
+
},
|
|
17
43
|
};
|
|
18
44
|
export const BUILTIN_PRICING = {
|
|
19
|
-
// Anthropic Claude (https://
|
|
20
|
-
// Opus 4.6: $5 input, $25 output
|
|
45
|
+
// Anthropic Claude (https://platform.claude.com/docs/en/about-claude/pricing)
|
|
46
|
+
// Opus 4.8/4.7/4.6/4.5: $5 input, $25 output, cache write $6.25, cache read $0.50
|
|
47
|
+
"claude-opus-4.8": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
|
48
|
+
"claude-opus-4.7": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
|
21
49
|
"claude-opus-4.6": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
|
22
|
-
// Opus 4.5 (legacy): same pricing as Opus 4.6
|
|
23
50
|
"claude-opus-4.5": { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
|
|
24
|
-
// Sonnet 4.5: $3 input, $15 output
|
|
51
|
+
// Sonnet 4.6/4.5: $3 input, $15 output, cache write $3.75, cache read $0.30
|
|
52
|
+
"claude-sonnet-4.6": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
|
25
53
|
"claude-sonnet-4.5": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
|
26
|
-
// Sonnet 4: $3 input, $15 output
|
|
54
|
+
// Sonnet 4 (deprecated): $3 input, $15 output
|
|
27
55
|
"claude-sonnet-4": { input: 3, output: 15, cacheRead: 0.3, cacheWrite: 3.75 },
|
|
28
56
|
// Haiku 4.5: $1 input, $5 output, cache write $1.25, cache read $0.10
|
|
29
57
|
"claude-haiku-4.5": { input: 1, output: 5, cacheRead: 0.1, cacheWrite: 1.25 },
|
|
@@ -32,38 +60,67 @@ export const BUILTIN_PRICING = {
|
|
|
32
60
|
"claude-opus-4.1": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
|
|
33
61
|
"claude-opus-4": { input: 15, output: 75, cacheRead: 1.5, cacheWrite: 18.75 },
|
|
34
62
|
"claude-haiku-3": { input: 0.25, output: 1.25, cacheRead: 0.03, cacheWrite: 0.3 },
|
|
35
|
-
// OpenAI GPT (https://openai.com/api/pricing
|
|
36
|
-
// GPT-5.
|
|
63
|
+
// OpenAI GPT (https://developers.openai.com/api/docs/pricing)
|
|
64
|
+
// GPT-5.5/5.4 standard short-context text pricing.
|
|
65
|
+
"gpt-5.5": { input: 5, output: 30, cacheRead: 0.5 },
|
|
66
|
+
"gpt-5.5-pro": { input: 30, output: 180 },
|
|
67
|
+
"gpt-5.4": { input: 2.5, output: 15, cacheRead: 0.25 },
|
|
68
|
+
"gpt-5.4-mini": { input: 0.75, output: 4.5, cacheRead: 0.075 },
|
|
69
|
+
"gpt-5.4-nano": { input: 0.2, output: 1.25, cacheRead: 0.02 },
|
|
70
|
+
"gpt-5.4-pro": { input: 30, output: 180 },
|
|
71
|
+
"gpt-5.3-codex": { input: 1.75, output: 14, cacheRead: 0.175 },
|
|
72
|
+
"gpt-5.3-chat-latest": { input: 1.75, output: 14, cacheRead: 0.175 },
|
|
73
|
+
// GPT-5.2 and earlier GPT-5 family.
|
|
37
74
|
"gpt-5.2": { input: 1.75, output: 14, cacheRead: 0.175 },
|
|
38
75
|
"gpt-5.2-pro": { input: 21, output: 168 },
|
|
39
76
|
"gpt-5-mini": { input: 0.25, output: 2, cacheRead: 0.025 },
|
|
40
|
-
"gpt-5
|
|
41
|
-
"gpt-5": { input:
|
|
42
|
-
|
|
43
|
-
"gpt-
|
|
44
|
-
"gpt-
|
|
45
|
-
"gpt-
|
|
77
|
+
"gpt-5-nano": { input: 0.05, output: 0.4, cacheRead: 0.005 },
|
|
78
|
+
"gpt-5.1": { input: 1.25, output: 10, cacheRead: 0.125 },
|
|
79
|
+
"gpt-5.1-chat-latest": { input: 1.25, output: 10, cacheRead: 0.125 },
|
|
80
|
+
"gpt-5.1-codex-max": { input: 1.25, output: 10, cacheRead: 0.125 },
|
|
81
|
+
"gpt-5.1-codex": { input: 1.25, output: 10, cacheRead: 0.125 },
|
|
82
|
+
"gpt-5.1-codex-mini": { input: 0.25, output: 2, cacheRead: 0.025 },
|
|
83
|
+
"gpt-5": { input: 1.25, output: 10, cacheRead: 0.125 },
|
|
84
|
+
"gpt-5-chat-latest": { input: 1.25, output: 10, cacheRead: 0.125 },
|
|
85
|
+
"gpt-5-codex": { input: 1.25, output: 10, cacheRead: 0.125 },
|
|
86
|
+
// GPT-4.1 series
|
|
87
|
+
"gpt-4.1": { input: 2, output: 8, cacheRead: 0.5 },
|
|
88
|
+
"gpt-4.1-mini": { input: 0.4, output: 1.6, cacheRead: 0.1 },
|
|
89
|
+
"gpt-4.1-nano": { input: 0.1, output: 0.4, cacheRead: 0.025 },
|
|
46
90
|
// GPT-4o series (may be deprecated)
|
|
47
|
-
"gpt-4o": { input: 2.5, output: 10 },
|
|
48
|
-
"gpt-4o-mini": { input: 0.15, output: 0.6 },
|
|
91
|
+
"gpt-4o": { input: 2.5, output: 10, cacheRead: 1.25 },
|
|
92
|
+
"gpt-4o-mini": { input: 0.15, output: 0.6, cacheRead: 0.075 },
|
|
49
93
|
// Reasoning models
|
|
50
|
-
"o3": { input:
|
|
51
|
-
"o3-mini": { input: 1.1, output: 4.4 },
|
|
52
|
-
"o4-mini": { input:
|
|
53
|
-
"o1": { input: 15, output: 60 },
|
|
54
|
-
"o1-mini": { input: 1.1, output: 4.4 },
|
|
94
|
+
"o3": { input: 2, output: 8, cacheRead: 0.5 },
|
|
95
|
+
"o3-mini": { input: 1.1, output: 4.4, cacheRead: 0.55 },
|
|
96
|
+
"o4-mini": { input: 1.1, output: 4.4, cacheRead: 0.275 },
|
|
97
|
+
"o1": { input: 15, output: 60, cacheRead: 7.5 },
|
|
98
|
+
"o1-mini": { input: 1.1, output: 4.4, cacheRead: 0.55 },
|
|
55
99
|
// DeepSeek (https://api-docs.deepseek.com/quick_start/pricing)
|
|
56
|
-
//
|
|
57
|
-
// $0.14 input (cache miss), $0.0028 input (cache hit), $0.28 output
|
|
100
|
+
// 已停用的旧别名保留历史估算值,由核验元数据提示到期。
|
|
58
101
|
"deepseek-chat": { input: 0.14, output: 0.28, cacheRead: 0.0028 },
|
|
59
102
|
"deepseek-reasoner": { input: 0.14, output: 0.28, cacheRead: 0.0028 },
|
|
103
|
+
// 采用峰时保守估算;谷时价格为一半,不在运行时自动判断时段或节假日。
|
|
104
|
+
// V4 Flash 旧名目前由 V4.1 Flash 服务,按当前 Flash 价格估算。
|
|
105
|
+
"deepseek-flash": { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 },
|
|
106
|
+
"deepseek-v4-flash": { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 },
|
|
107
|
+
"deepseek-v4-flash-vision-exp": { input: 0.3, output: 1.2, cacheRead: 0.006, cacheWrite: 0 },
|
|
108
|
+
"deepseek-v4-pro": { input: 1.32, output: 3.96, cacheRead: 0.044, cacheWrite: 0 },
|
|
109
|
+
// Kimi 官方 API 按量价格,不适用于 Kimi Code 订阅或未核价的高速变体。
|
|
110
|
+
"kimi-k2.7-code": { input: 0.95, output: 4, cacheRead: 0.19, cacheWrite: 0 },
|
|
60
111
|
// Google Gemini (https://cloud.google.com/vertex-ai/generative-ai/pricing)
|
|
61
|
-
//
|
|
112
|
+
// Standard global text pricing at <=200K input tokens where tiered pricing applies.
|
|
113
|
+
// Gemini 3.1 Pro Preview: $2 input, $12 output, cache read $0.20
|
|
114
|
+
"gemini-3.1-pro-preview": { input: 2, output: 12, cacheRead: 0.2 },
|
|
62
115
|
"gemini-3-pro": { input: 2, output: 12, cacheRead: 0.2 },
|
|
63
116
|
"gemini-3-pro-preview": { input: 2, output: 12, cacheRead: 0.2 },
|
|
117
|
+
// Gemini 3.5 Flash: $1.50 input, $9 output, cache read $0.15
|
|
118
|
+
"gemini-3.5-flash": { input: 1.5, output: 9, cacheRead: 0.15 },
|
|
64
119
|
// Gemini 3 Flash Preview: $0.5 input, $3 output
|
|
65
120
|
"gemini-3-flash": { input: 0.5, output: 3, cacheRead: 0.05 },
|
|
66
121
|
"gemini-3-flash-preview": { input: 0.5, output: 3, cacheRead: 0.05 },
|
|
122
|
+
// Gemini 3.1 Flash-Lite: $0.25 input, $1.50 output, cache read $0.025
|
|
123
|
+
"gemini-3.1-flash-lite": { input: 0.25, output: 1.5, cacheRead: 0.025 },
|
|
67
124
|
// Gemini 2.5 Pro: $1.25 input, $10 output (≤200K)
|
|
68
125
|
"gemini-2.5-pro": { input: 1.25, output: 10, cacheRead: 0.125 },
|
|
69
126
|
// Gemini 2.5 Flash: $0.3 input, $2.5 output
|
|
@@ -99,6 +156,12 @@ export function formatTokens(tokens, millionDecimals = 1) {
|
|
|
99
156
|
return `${(tokens / 1_000).toFixed(1)}K`;
|
|
100
157
|
return tokens.toString();
|
|
101
158
|
}
|
|
159
|
+
export function hasBillableTokenUsage(usage) {
|
|
160
|
+
return (usage.input ?? 0) > 0
|
|
161
|
+
|| (usage.output ?? 0) > 0
|
|
162
|
+
|| (usage.cacheRead ?? 0) > 0
|
|
163
|
+
|| (usage.cacheWrite ?? 0) > 0;
|
|
164
|
+
}
|
|
102
165
|
// ============================================================================
|
|
103
166
|
// Time Utilities
|
|
104
167
|
// ============================================================================
|
|
@@ -107,6 +170,12 @@ export function getStartOfDay(date = new Date()) {
|
|
|
107
170
|
d.setHours(0, 0, 0, 0);
|
|
108
171
|
return d.getTime();
|
|
109
172
|
}
|
|
173
|
+
export function formatLocalDateKey(date) {
|
|
174
|
+
const year = date.getFullYear();
|
|
175
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
176
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
177
|
+
return `${year}-${month}-${day}`;
|
|
178
|
+
}
|
|
110
179
|
export function getStartOfWeek(date = new Date()) {
|
|
111
180
|
const d = new Date(date);
|
|
112
181
|
const day = d.getDay();
|
|
@@ -367,6 +436,34 @@ function validateBudget(raw, warnings) {
|
|
|
367
436
|
}
|
|
368
437
|
return result;
|
|
369
438
|
}
|
|
439
|
+
export function formatBudgetMessage(status) {
|
|
440
|
+
const pct = Math.round(status.percentage * 100);
|
|
441
|
+
const periodLabel = status.period.charAt(0).toUpperCase() + status.period.slice(1);
|
|
442
|
+
return `${periodLabel}: ${formatCost(status.spent)}/${formatCost(status.limit)} (${pct}%)`;
|
|
443
|
+
}
|
|
444
|
+
export function buildMessageToast(input) {
|
|
445
|
+
if (input.budget?.exceeded) {
|
|
446
|
+
return {
|
|
447
|
+
title: "⚠️ Budget exceeded!",
|
|
448
|
+
message: formatBudgetMessage(input.budget),
|
|
449
|
+
variant: "error",
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
const title = `${formatTokens(input.messageTokens)} tokens`;
|
|
453
|
+
const messageCost = formatCost(input.messageCost);
|
|
454
|
+
if (input.budget?.warning) {
|
|
455
|
+
return {
|
|
456
|
+
title,
|
|
457
|
+
message: `${messageCost} | Session: ${formatTokens(input.sessionTokens)} · ${formatBudgetMessage(input.budget)}`,
|
|
458
|
+
variant: "warning",
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
title,
|
|
463
|
+
message: `${messageCost} | Session: ${formatTokens(input.sessionTokens)} · ${formatCost(input.sessionCost)}`,
|
|
464
|
+
variant: "info",
|
|
465
|
+
};
|
|
466
|
+
}
|
|
370
467
|
export function evaluateBudgetStatus(budget, spent, initialized) {
|
|
371
468
|
if (!initialized)
|
|
372
469
|
return null;
|
|
@@ -395,6 +492,38 @@ export function evaluateBudgetStatus(budget, spent, initialized) {
|
|
|
395
492
|
candidates.sort((a, b) => severity(b) - severity(a) || b.percentage - a.percentage);
|
|
396
493
|
return candidates[0] ?? null;
|
|
397
494
|
}
|
|
495
|
+
// 计价、来源标签和时效诊断复用同一内置匹配入口,防止三者口径分叉。
|
|
496
|
+
function findBuiltinPricingKey(model) {
|
|
497
|
+
if (BUILTIN_PRICING[model])
|
|
498
|
+
return model;
|
|
499
|
+
const modelLower = model.toLowerCase();
|
|
500
|
+
const keys = Object.keys(BUILTIN_PRICING)
|
|
501
|
+
.filter(key => key !== "_default")
|
|
502
|
+
.sort((a, b) => b.length - a.length);
|
|
503
|
+
return keys.find(key => BUILTIN_PRICING_AUDITS[key]?.exactOrPrefixed
|
|
504
|
+
? modelLower === key || modelLower.endsWith(`/${key}`)
|
|
505
|
+
: modelLower.includes(key.toLowerCase()));
|
|
506
|
+
}
|
|
507
|
+
export function getBuiltinPricingAudit(model) {
|
|
508
|
+
const key = findBuiltinPricingKey(model);
|
|
509
|
+
if (!key || key === "_default")
|
|
510
|
+
return undefined;
|
|
511
|
+
return BUILTIN_PRICING_AUDITS[key] ?? { reviewedAt: BUILTIN_PRICING_META.baselineLastUpdated };
|
|
512
|
+
}
|
|
513
|
+
export function getPricingFreshness(audit, now = Date.now()) {
|
|
514
|
+
const reviewedAt = Date.parse(`${audit.reviewedAt}T00:00:00Z`);
|
|
515
|
+
const expiresAt = audit.expiresAt ? Date.parse(audit.expiresAt) : undefined;
|
|
516
|
+
if (!Number.isFinite(now) || !Number.isFinite(reviewedAt) || reviewedAt > now
|
|
517
|
+
|| (expiresAt !== undefined && !Number.isFinite(expiresAt)))
|
|
518
|
+
return "unknown";
|
|
519
|
+
if (expiresAt !== undefined && now >= expiresAt)
|
|
520
|
+
return "expired";
|
|
521
|
+
return now - reviewedAt > PRICING_STALE_AFTER_DAYS * 86_400_000 ? "stale" : "recent";
|
|
522
|
+
}
|
|
523
|
+
export function getEffectivePricingAudit(config, model, provider) {
|
|
524
|
+
// 用户已覆盖价格时,不应拿未使用的内置日期给用户配置打上过期标签。
|
|
525
|
+
return resolvePricingStatus(config, model, provider) === "built-in" ? getBuiltinPricingAudit(model) : undefined;
|
|
526
|
+
}
|
|
398
527
|
export function resolvePricingStatus(config, model, provider) {
|
|
399
528
|
// Step 1: config.providers[provider] -> "provider cfg"
|
|
400
529
|
if (config.providers && config.providers[provider]) {
|
|
@@ -404,20 +533,9 @@ export function resolvePricingStatus(config, model, provider) {
|
|
|
404
533
|
if (findModelConfigPricing(config.models, model, provider, false)) {
|
|
405
534
|
return "model cfg";
|
|
406
535
|
}
|
|
407
|
-
// Step 3:
|
|
408
|
-
if (
|
|
536
|
+
// Step 3/4: 内置精确匹配,其次按型号规则匹配。
|
|
537
|
+
if (findBuiltinPricingKey(model))
|
|
409
538
|
return "built-in";
|
|
410
|
-
}
|
|
411
|
-
// Step 4: Partial-match in BUILTIN_PRICING (sorted by key length desc) -> "built-in"
|
|
412
|
-
const modelLower = model.toLowerCase();
|
|
413
|
-
const sortedBuiltin = Object.keys(BUILTIN_PRICING)
|
|
414
|
-
.filter(k => k !== "_default")
|
|
415
|
-
.sort((a, b) => b.length - a.length);
|
|
416
|
-
for (const key of sortedBuiltin) {
|
|
417
|
-
if (modelLower.includes(key.toLowerCase())) {
|
|
418
|
-
return "built-in";
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
539
|
// Step 5: Partial-match in config.models (sorted by key length desc) -> "model cfg"
|
|
422
540
|
if (findModelConfigPricing(config.models, model, provider, true)) {
|
|
423
541
|
return "model cfg";
|
|
@@ -462,18 +580,9 @@ function getModelPricing(model, provider, config) {
|
|
|
462
580
|
return configuredPricing;
|
|
463
581
|
}
|
|
464
582
|
}
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
const modelLower = model.toLowerCase();
|
|
469
|
-
const sortedKeys = Object.keys(BUILTIN_PRICING)
|
|
470
|
-
.filter(k => k !== "_default")
|
|
471
|
-
.sort((a, b) => b.length - a.length);
|
|
472
|
-
for (const key of sortedKeys) {
|
|
473
|
-
if (modelLower.includes(key.toLowerCase())) {
|
|
474
|
-
return BUILTIN_PRICING[key];
|
|
475
|
-
}
|
|
476
|
-
}
|
|
583
|
+
const builtinKey = findBuiltinPricingKey(model);
|
|
584
|
+
if (builtinKey)
|
|
585
|
+
return BUILTIN_PRICING[builtinKey];
|
|
477
586
|
if (config && config.models) {
|
|
478
587
|
const partialUserPricing = findModelConfigPricing(config.models, model, provider, true);
|
|
479
588
|
if (partialUserPricing) {
|
|
@@ -513,3 +622,128 @@ export function calculateCost(model, provider, input, output, cacheRead = 0, cac
|
|
|
513
622
|
const cacheWriteCost = (cacheWrite / 1_000_000) * finalCacheWritePrice;
|
|
514
623
|
return inputCost + outputCost + cacheReadCost + cacheWriteCost;
|
|
515
624
|
}
|
|
625
|
+
/**
|
|
626
|
+
* Resolve a session id to its root by following parentID links.
|
|
627
|
+
* Stops when no parent is known or a cycle is detected, so an unknown
|
|
628
|
+
* parent still yields a stable root id to group descendants under.
|
|
629
|
+
*/
|
|
630
|
+
export function resolveRootSession(sessionId, parentOf) {
|
|
631
|
+
let current = sessionId;
|
|
632
|
+
const seen = new Set();
|
|
633
|
+
while (true) {
|
|
634
|
+
const parent = parentOf.get(current);
|
|
635
|
+
if (!parent || parent === current || seen.has(parent)) {
|
|
636
|
+
return current;
|
|
637
|
+
}
|
|
638
|
+
seen.add(current);
|
|
639
|
+
current = parent;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Sum per-session stats across every session that resolves to the same root as
|
|
644
|
+
* `currentSessionId`, rolling a sub-agent session's usage up into its top-level
|
|
645
|
+
* (parent) session for toast / idle display.
|
|
646
|
+
*
|
|
647
|
+
* Aggregation happens at read time: a `parentID` learned only after a session's
|
|
648
|
+
* first message still merges on the next call (eventual consistency), so stored
|
|
649
|
+
* per-session stats never need re-bucketing. `startTime` is the earliest among
|
|
650
|
+
* the grouped sessions so duration reflects the whole task.
|
|
651
|
+
*/
|
|
652
|
+
export function aggregateRootSession(sessionStats, parentOf, currentSessionId) {
|
|
653
|
+
const root = resolveRootSession(currentSessionId, parentOf);
|
|
654
|
+
const agg = {
|
|
655
|
+
totalInput: 0,
|
|
656
|
+
totalOutput: 0,
|
|
657
|
+
totalReasoning: 0,
|
|
658
|
+
totalCacheRead: 0,
|
|
659
|
+
totalCacheWrite: 0,
|
|
660
|
+
totalCost: 0,
|
|
661
|
+
messageCount: 0,
|
|
662
|
+
startTime: 0,
|
|
663
|
+
};
|
|
664
|
+
let earliest = Infinity;
|
|
665
|
+
for (const [sessionId, stats] of sessionStats) {
|
|
666
|
+
if (resolveRootSession(sessionId, parentOf) !== root)
|
|
667
|
+
continue;
|
|
668
|
+
agg.totalInput += stats.totalInput;
|
|
669
|
+
agg.totalOutput += stats.totalOutput;
|
|
670
|
+
agg.totalReasoning += stats.totalReasoning;
|
|
671
|
+
agg.totalCacheRead += stats.totalCacheRead;
|
|
672
|
+
agg.totalCacheWrite += stats.totalCacheWrite;
|
|
673
|
+
agg.totalCost += stats.totalCost;
|
|
674
|
+
agg.messageCount += stats.messageCount;
|
|
675
|
+
if (stats.startTime < earliest)
|
|
676
|
+
earliest = stats.startTime;
|
|
677
|
+
}
|
|
678
|
+
agg.startTime = Number.isFinite(earliest) ? earliest : 0;
|
|
679
|
+
return agg;
|
|
680
|
+
}
|
|
681
|
+
/**
|
|
682
|
+
* Merge raw sessions.jsonl records into one SessionMeta per session id.
|
|
683
|
+
* Later non-empty fields win; empty/whitespace titles never clobber a real
|
|
684
|
+
* one (session.created may arrive before a title is generated).
|
|
685
|
+
*/
|
|
686
|
+
export function mergeSessionMeta(records) {
|
|
687
|
+
const map = new Map();
|
|
688
|
+
for (const r of records) {
|
|
689
|
+
const id = r.sessionId;
|
|
690
|
+
if (!id)
|
|
691
|
+
continue;
|
|
692
|
+
const existing = map.get(id) ?? { sessionId: id, _ts: 0 };
|
|
693
|
+
const title = typeof r.title === "string" ? r.title.trim() : "";
|
|
694
|
+
if (title)
|
|
695
|
+
existing.title = title;
|
|
696
|
+
if (r.parentID)
|
|
697
|
+
existing.parentID = r.parentID;
|
|
698
|
+
if (r.directory)
|
|
699
|
+
existing.directory = r.directory;
|
|
700
|
+
if (typeof r._ts === "number" && r._ts > existing._ts)
|
|
701
|
+
existing._ts = r._ts;
|
|
702
|
+
map.set(id, existing);
|
|
703
|
+
}
|
|
704
|
+
return map;
|
|
705
|
+
}
|
|
706
|
+
/** Truncate a label to maxWidth, appending an ellipsis when it overflows. */
|
|
707
|
+
export function truncateLabel(str, maxWidth) {
|
|
708
|
+
if (str.length <= maxWidth)
|
|
709
|
+
return str;
|
|
710
|
+
if (maxWidth <= 1)
|
|
711
|
+
return "…";
|
|
712
|
+
return `${str.slice(0, maxWidth - 1)}…`;
|
|
713
|
+
}
|
|
714
|
+
/** Distinctive short code for a session id (its tail), for fallback display. */
|
|
715
|
+
export function shortSessionCode(sessionId) {
|
|
716
|
+
if (!sessionId)
|
|
717
|
+
return "unknown";
|
|
718
|
+
return sessionId.length > 12 ? `…${sessionId.slice(-10)}` : sessionId;
|
|
719
|
+
}
|
|
720
|
+
/** Pick a human label for a (root) session: title when known, else short code. */
|
|
721
|
+
export function sessionDisplayLabel(rootId, meta, maxWidth) {
|
|
722
|
+
const title = meta?.title?.trim();
|
|
723
|
+
if (title)
|
|
724
|
+
return truncateLabel(title, maxWidth);
|
|
725
|
+
return shortSessionCode(rootId);
|
|
726
|
+
}
|
|
727
|
+
/**
|
|
728
|
+
* Build a session metadata record to persist, or null when there is nothing
|
|
729
|
+
* worth recording yet (no title and no parent link). Title is trimmed; a
|
|
730
|
+
* parentID is recorded even before a title exists so child sessions can be
|
|
731
|
+
* rolled up into their parent.
|
|
732
|
+
*/
|
|
733
|
+
export function buildSessionRecord(info) {
|
|
734
|
+
const sessionId = info.id;
|
|
735
|
+
if (!sessionId)
|
|
736
|
+
return null;
|
|
737
|
+
const title = typeof info.title === "string" ? info.title.trim() : "";
|
|
738
|
+
const parentID = info.parentID;
|
|
739
|
+
if (!title && !parentID)
|
|
740
|
+
return null;
|
|
741
|
+
const record = { sessionId };
|
|
742
|
+
if (title)
|
|
743
|
+
record.title = title;
|
|
744
|
+
if (parentID)
|
|
745
|
+
record.parentID = parentID;
|
|
746
|
+
if (info.directory)
|
|
747
|
+
record.directory = info.directory;
|
|
748
|
+
return record;
|
|
749
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-token-tracker",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.0",
|
|
4
4
|
"description": "Real-time token usage and cost tracking plugin for OpenCode with Toast notifications and CLI stats",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -11,7 +11,10 @@
|
|
|
11
11
|
"scripts": {
|
|
12
12
|
"build": "tsc",
|
|
13
13
|
"test": "tsc && node --test dist/test/*.test.js",
|
|
14
|
-
"prepublishOnly": "npm run build"
|
|
14
|
+
"prepublishOnly": "npm run build",
|
|
15
|
+
"release:check": "node scripts/release.js check",
|
|
16
|
+
"release:prepare": "node scripts/release.js prepare",
|
|
17
|
+
"release:tag": "node scripts/release.js tag"
|
|
15
18
|
},
|
|
16
19
|
"keywords": [
|
|
17
20
|
"opencode",
|