opencode-token-tracker 1.7.1 → 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 +15 -2
- package/README.zh-CN.md +15 -2
- package/dist/bin/opencode-tokens.js +166 -22
- package/dist/index.js +93 -39
- package/dist/lib/shared.d.ts +102 -3
- package/dist/lib/shared.js +229 -34
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,6 +30,9 @@ If you are building AI-assisted engineering workflows, we strongly recommend ado
|
|
|
30
30
|
- **Budgets are warnings, not enforcement.** This plugin does not block API calls, throttle requests, or interrupt active sessions. It is designed purely as an observability and tracking tool.
|
|
31
31
|
- **Subscription or bundled providers** (such as GitHub Copilot, Cursor, etc.) or free local models should be configured with zero-cost overrides in your configuration file (see [Configuration](#configuration)).
|
|
32
32
|
- **Pricing freshness**: The built-in pricing table is manually maintained. Please run `opencode-tokens models` to inspect which of your used models currently fall back to the default pricing, and configure overrides if necessary.
|
|
33
|
+
- **Per-model audits**: `pricing` and `models` show each built-in entry's review date. `doctor` flags used entries that are stale (more than 90 days since review) or expired (past a known validity date). A recent audit is not a guarantee that prices remain unchanged. User overrides are not assigned the unused built-in entry's audit status.
|
|
34
|
+
- **DeepSeek estimates**: Flash/Pro use peak-hour USD rates; off-peak rates are 50% lower. Time-of-day and holiday selection are not automatic. Retired `deepseek-chat` / `deepseek-reasoner` retain legacy estimates marked expired. The October 2026 refresh covers DeepSeek Flash/Pro and Kimi K2.7 Code; other models retain their earlier review dates.
|
|
35
|
+
- **Matching and history**: Newly audited DeepSeek/Kimi entries accept exact names and slash-prefixed names. Unverified variants such as `kimi-k2.7-code-highspeed` remain explicit fallbacks unless configured by the user. Updated prices apply to future records; stored historical costs are not recalculated.
|
|
33
36
|
|
|
34
37
|
## Installation
|
|
35
38
|
|
|
@@ -86,11 +89,20 @@ Once installed, you'll see Toast notifications after each AI response:
|
|
|
86
89
|
|
|
87
90
|
```
|
|
88
91
|
12.5K tokens
|
|
89
|
-
$0.023 | Session: $0.156
|
|
92
|
+
$0.023 | Session: 45.2K · $0.156
|
|
90
93
|
```
|
|
91
94
|
|
|
95
|
+
> `Session:` rolls usage received by the current plugin process up to the top-level task, including its sub-agents. Parent links are restored from `sessions.jsonl` on startup and updated by session events; links learned later take effect on the next toast. Historical token totals are not replayed after a restart. Use `opencode-tokens --by session` for totals from persisted logs, or `--by raw-session` to inspect individual sessions.
|
|
96
|
+
|
|
92
97
|
When budget limits are configured, you'll see warnings:
|
|
93
98
|
|
|
99
|
+
```
|
|
100
|
+
12.5K tokens
|
|
101
|
+
$0.023 | Session: 45.2K · Daily: $4.20/$5.00 (84%)
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
When a budget is exceeded, the toast switches to an alert:
|
|
105
|
+
|
|
94
106
|
```
|
|
95
107
|
⚠️ Budget exceeded!
|
|
96
108
|
Daily: $5.50/$5.00 (110%)
|
|
@@ -194,7 +206,8 @@ Breakdown options (`--by`):
|
|
|
194
206
|
- `agent` - Group by agent (e.g., sisyphus, coder)
|
|
195
207
|
- `provider` - Group by provider (e.g., anthropic, openai)
|
|
196
208
|
- `daily` - Show day-by-day breakdown
|
|
197
|
-
- `session` - Group by session
|
|
209
|
+
- `session` - Group by top-level session, rolling sub-agent sessions up into their parent (labelled by the parent's title)
|
|
210
|
+
- `raw-session` - Group by each session id without rollup, so sub-agent sessions stay as separate rows labelled by their own title
|
|
198
211
|
- `all` - Show all breakdowns
|
|
199
212
|
|
|
200
213
|
### Trend Chart
|
package/README.zh-CN.md
CHANGED
|
@@ -30,6 +30,9 @@
|
|
|
30
30
|
- **预算提醒仅为警告,非强制阻断**。本插件不会拦截 API 调用、节流请求或中断您的会话。其设计初衷纯粹是为了可观测性与用量追踪。
|
|
31
31
|
- **订阅制或打包服务商**(如 GitHub Copilot、Cursor 等)或免费的本地模型,应在您的配置文件中将其价格覆写为 0(参见 [配置说明](#配置说明))。
|
|
32
32
|
- **定价数据时效性**:内置的定价表为手动维护。请运行 `opencode-tokens models` 来检查您当前使用的哪些模型退化(fallback)到了默认定价,并在需要时手动配置覆写。
|
|
33
|
+
- **逐型号核验**:`pricing` 和 `models` 显示各内置条目的核验日期;`doctor` 提示已使用条目中的 stale(距核验超过 90 天)与 expired(超过已知有效期)。近期核验不保证供应商未再次调价,用户自定义价格不沿用未使用的内置条目的时效标签。
|
|
34
|
+
- **DeepSeek 估算口径**:Flash/Pro 使用 USD 峰时价格,谷时价格低 50%;不自动判断时段或节假日。已停用的 `deepseek-chat` / `deepseek-reasoner` 保留历史估算值并标记 expired。本次 2026-10 局部更新仅覆盖 DeepSeek Flash/Pro 与 Kimi K2.7 Code,其他型号保留原核验日期。
|
|
35
|
+
- **匹配与历史**:本次核验的 DeepSeek/Kimi 条目接受精确名及以 `/` 分隔的 provider 前缀名;`kimi-k2.7-code-highspeed` 等未核价变体继续明确回退,用户可自行覆盖。更新价格只影响后续记录,不重算已保存的历史 cost。
|
|
33
36
|
|
|
34
37
|
## 安装
|
|
35
38
|
|
|
@@ -85,11 +88,20 @@ opencode-tokens today
|
|
|
85
88
|
|
|
86
89
|
```
|
|
87
90
|
12.5K tokens
|
|
88
|
-
$0.023 | Session: $0.156
|
|
91
|
+
$0.023 | Session: 45.2K · $0.156
|
|
89
92
|
```
|
|
90
93
|
|
|
94
|
+
> `Session:` 将当前插件进程收到的主、子 agent 消耗归并到顶层任务。启动时从 `sessions.jsonl` 恢复父子关系,并通过会话事件更新;晚到的关系在下一次提示中生效。重启后不会回放历史 token 累计;完整日志统计请用 `opencode-tokens --by session`,逐个会话明细请用 `--by raw-session`。
|
|
95
|
+
|
|
91
96
|
配置预算后,超阈值会显示预警:
|
|
92
97
|
|
|
98
|
+
```
|
|
99
|
+
12.5K tokens
|
|
100
|
+
$0.023 | Session: 45.2K · Daily: $4.20/$5.00 (84%)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
预算超限时,toast 会切换为告警:
|
|
104
|
+
|
|
93
105
|
```
|
|
94
106
|
⚠️ Budget exceeded!
|
|
95
107
|
Daily: $5.50/$5.00 (110%)
|
|
@@ -198,7 +210,8 @@ opencode-tokens --by daily
|
|
|
198
210
|
- `agent`:按 agent 分组
|
|
199
211
|
- `provider`:按 provider 分组
|
|
200
212
|
- `daily`:按天分组
|
|
201
|
-
- `session
|
|
213
|
+
- `session`:按顶层会话分组,子 agent 会话归并到其父会话(用父会话标题标注)
|
|
214
|
+
- `raw-session`:按每个 session id 分组、不做归并,子 agent 会话保留为独立行并用各自标题标注
|
|
202
215
|
- `all`:显示全部分组
|
|
203
216
|
|
|
204
217
|
### 趋势图
|
|
@@ -3,10 +3,11 @@ import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync,
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
|
-
import { BUILTIN_PRICING, BUILTIN_PRICING_META, DEFAULT_CONFIG, formatCost, formatLocalDateKey, formatTokens, getStartOfDay, getStartOfMonth, getStartOfWeek, hasBillableTokenUsage, resolvePricingStatus, round2, validateConfig, } from "../lib/shared.js";
|
|
6
|
+
import { BUILTIN_PRICING, BUILTIN_PRICING_META, DEFAULT_CONFIG, PRICING_STALE_AFTER_DAYS, formatCost, formatLocalDateKey, formatTokens, getBuiltinPricingAudit, getEffectivePricingAudit, getPricingFreshness, getStartOfDay, getStartOfMonth, getStartOfWeek, hasBillableTokenUsage, mergeSessionMeta, resolvePricingStatus, resolveRootSession, round2, sessionDisplayLabel, validateConfig, } from "../lib/shared.js";
|
|
7
7
|
const CONFIG_DIR = join(homedir(), ".config", "opencode");
|
|
8
8
|
const CONFIG_FILE = join(CONFIG_DIR, "token-tracker.json");
|
|
9
9
|
const LOG_FILE = join(CONFIG_DIR, "logs", "token-tracker", "tokens.jsonl");
|
|
10
|
+
const SESSIONS_LOG_FILE = join(CONFIG_DIR, "logs", "token-tracker", "sessions.jsonl");
|
|
10
11
|
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
11
12
|
const OPENCODE_CONFIG_FILES = [
|
|
12
13
|
join(CONFIG_DIR, "opencode.json"),
|
|
@@ -21,11 +22,6 @@ function padRight(str, len) {
|
|
|
21
22
|
function padLeft(str, len) {
|
|
22
23
|
return str.length >= len ? str : `${" ".repeat(len - str.length)}${str}`;
|
|
23
24
|
}
|
|
24
|
-
function truncateSessionId(sessionId) {
|
|
25
|
-
if (!sessionId)
|
|
26
|
-
return "unknown";
|
|
27
|
-
return sessionId.length > 16 ? `${sessionId.slice(0, 14)}…` : sessionId;
|
|
28
|
-
}
|
|
29
25
|
function formatAge(ts) {
|
|
30
26
|
const diffMs = Date.now() - ts;
|
|
31
27
|
if (diffMs < 60_000)
|
|
@@ -38,6 +34,12 @@ function formatAge(ts) {
|
|
|
38
34
|
return `${hours}h ago`;
|
|
39
35
|
return `${Math.floor(hours / 24)}d ago`;
|
|
40
36
|
}
|
|
37
|
+
// Compact relative time for table columns: "just now" / "5m ago" / "2h ago" / "3d ago".
|
|
38
|
+
function formatLastActive(ts) {
|
|
39
|
+
if (Date.now() - ts < 60_000)
|
|
40
|
+
return "just now";
|
|
41
|
+
return formatAge(ts);
|
|
42
|
+
}
|
|
41
43
|
const ZERO_COST_PROVIDER_HINTS = [
|
|
42
44
|
{ match: "copilot", reason: "subscription/bundled provider" },
|
|
43
45
|
{ match: "cursor", reason: "subscription/bundled provider" },
|
|
@@ -271,6 +273,89 @@ function printTable(title, groups, labelHeader) {
|
|
|
271
273
|
}
|
|
272
274
|
console.log();
|
|
273
275
|
}
|
|
276
|
+
function loadSessionMeta() {
|
|
277
|
+
if (!existsSync(SESSIONS_LOG_FILE))
|
|
278
|
+
return new Map();
|
|
279
|
+
const records = [];
|
|
280
|
+
try {
|
|
281
|
+
const content = readFileSync(SESSIONS_LOG_FILE, "utf-8");
|
|
282
|
+
for (const line of content.split("\n")) {
|
|
283
|
+
const trimmed = line.trim();
|
|
284
|
+
if (!trimmed)
|
|
285
|
+
continue;
|
|
286
|
+
try {
|
|
287
|
+
const rec = JSON.parse(trimmed);
|
|
288
|
+
if (rec.type !== "session")
|
|
289
|
+
continue;
|
|
290
|
+
records.push(rec);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
// Skip malformed lines
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
return new Map();
|
|
299
|
+
}
|
|
300
|
+
return mergeSessionMeta(records);
|
|
301
|
+
}
|
|
302
|
+
function printSessionBreakdown(entries, metaMap, options = {}) {
|
|
303
|
+
const { rollUp = true, heading = "By Session" } = options;
|
|
304
|
+
const parentOf = new Map();
|
|
305
|
+
for (const meta of metaMap.values()) {
|
|
306
|
+
parentOf.set(meta.sessionId, meta.parentID);
|
|
307
|
+
}
|
|
308
|
+
// Rolled-up view groups every child/subagent session under its top-level
|
|
309
|
+
// session; the raw view keeps each session's own id so sub-agents stay
|
|
310
|
+
// visible as separate rows labelled by their own title.
|
|
311
|
+
const groupKeyOf = (sessionId) => rollUp ? resolveRootSession(sessionId, parentOf) : sessionId;
|
|
312
|
+
const rows = new Map();
|
|
313
|
+
for (const e of entries) {
|
|
314
|
+
const key = groupKeyOf(e.sessionId ?? "unknown");
|
|
315
|
+
let row = rows.get(key);
|
|
316
|
+
if (!row) {
|
|
317
|
+
row = { stats: createEmptyStats(), lastActive: 0 };
|
|
318
|
+
rows.set(key, row);
|
|
319
|
+
}
|
|
320
|
+
row.stats.input += e.input ?? 0;
|
|
321
|
+
row.stats.output += e.output ?? 0;
|
|
322
|
+
row.stats.reasoning += e.reasoning ?? 0;
|
|
323
|
+
row.stats.cacheRead += e.cacheRead ?? 0;
|
|
324
|
+
row.stats.cacheWrite += e.cacheWrite ?? 0;
|
|
325
|
+
row.stats.cost += e.cost ?? 0;
|
|
326
|
+
row.stats.count += 1;
|
|
327
|
+
if (e._ts > row.lastActive)
|
|
328
|
+
row.lastActive = e._ts;
|
|
329
|
+
}
|
|
330
|
+
const sorted = Array.from(rows.entries()).sort((a, b) => b[1].stats.cost - a[1].stats.cost);
|
|
331
|
+
if (sorted.length === 0) {
|
|
332
|
+
console.log(`\n No data for ${heading}\n`);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const LABEL_MAX = 40;
|
|
336
|
+
const labeled = sorted.map(([key, row]) => ({
|
|
337
|
+
label: sessionDisplayLabel(key, metaMap.get(key), LABEL_MAX),
|
|
338
|
+
lastActive: row.lastActive ? formatLastActive(row.lastActive) : "-",
|
|
339
|
+
stats: row.stats,
|
|
340
|
+
}));
|
|
341
|
+
const labelHeader = "Session";
|
|
342
|
+
const activeHeader = "Last Active";
|
|
343
|
+
const labelWidth = Math.max(labelHeader.length, ...labeled.map((r) => r.label.length));
|
|
344
|
+
const activeWidth = Math.max(activeHeader.length, ...labeled.map((r) => r.lastActive.length));
|
|
345
|
+
const tokensWidth = 10;
|
|
346
|
+
const costWidth = 10;
|
|
347
|
+
const countWidth = 6;
|
|
348
|
+
console.log();
|
|
349
|
+
console.log(` ${heading}`);
|
|
350
|
+
console.log(` ${"─".repeat(labelWidth + activeWidth + tokensWidth + costWidth + countWidth + 8)}`);
|
|
351
|
+
console.log(` ${padRight(labelHeader, labelWidth)} ${padRight(activeHeader, activeWidth)} ${padLeft("Tokens", tokensWidth)} ${padLeft("Cost", costWidth)} ${padLeft("Msgs", countWidth)}`);
|
|
352
|
+
console.log(` ${"-".repeat(labelWidth)} ${"-".repeat(activeWidth)} ${"-".repeat(tokensWidth)} ${"-".repeat(costWidth)} ${"-".repeat(countWidth)}`);
|
|
353
|
+
for (const r of labeled) {
|
|
354
|
+
const totalTokens = r.stats.input + r.stats.output;
|
|
355
|
+
console.log(` ${padRight(r.label, labelWidth)} ${padRight(r.lastActive, activeWidth)} ${padLeft(formatTokens(totalTokens, 2), tokensWidth)} ${padLeft(formatCost(r.stats.cost), costWidth)} ${padLeft(r.stats.count.toString(), countWidth)}`);
|
|
356
|
+
}
|
|
357
|
+
console.log();
|
|
358
|
+
}
|
|
274
359
|
function printDailyBreakdown(entries) {
|
|
275
360
|
const byDay = groupBy(entries, (e) => {
|
|
276
361
|
const date = new Date(e._ts);
|
|
@@ -299,7 +384,7 @@ function printDailyBreakdown(entries) {
|
|
|
299
384
|
// ============================================================================
|
|
300
385
|
// Commands
|
|
301
386
|
// ============================================================================
|
|
302
|
-
const STATS_BREAKDOWNS = ["model", "agent", "provider", "day", "daily", "session", "all"];
|
|
387
|
+
const STATS_BREAKDOWNS = ["model", "agent", "provider", "day", "daily", "session", "raw-session", "all"];
|
|
303
388
|
const STATS_PERIODS = ["today", "week", "month", "all"];
|
|
304
389
|
const TOP_LEVEL_COMMANDS = ["budget", "doctor", "pricing", "models", "config", "export", "trend"];
|
|
305
390
|
function isStatsBreakdown(value) {
|
|
@@ -315,18 +400,18 @@ function getStatsBreakdown(flags) {
|
|
|
315
400
|
const longValue = flagValue(flags, "by");
|
|
316
401
|
const shortValue = flagValue(flags, "b");
|
|
317
402
|
if (flags.has("by") && !longValue) {
|
|
318
|
-
failCli("Missing value for --by", "Usage: opencode-tokens [today|week|month|all] --by model|agent|provider|daily|session|all");
|
|
403
|
+
failCli("Missing value for --by", "Usage: opencode-tokens [today|week|month|all] --by model|agent|provider|daily|session|raw-session|all");
|
|
319
404
|
return undefined;
|
|
320
405
|
}
|
|
321
406
|
if (flags.has("b") && !shortValue) {
|
|
322
|
-
failCli("Missing value for -b", "Usage: opencode-tokens [today|week|month|all] -b model|agent|provider|daily|session|all");
|
|
407
|
+
failCli("Missing value for -b", "Usage: opencode-tokens [today|week|month|all] -b model|agent|provider|daily|session|raw-session|all");
|
|
323
408
|
return undefined;
|
|
324
409
|
}
|
|
325
410
|
const breakdown = longValue ?? shortValue;
|
|
326
411
|
if (!breakdown)
|
|
327
412
|
return undefined;
|
|
328
413
|
if (!isStatsBreakdown(breakdown)) {
|
|
329
|
-
failCli(`Unsupported stats breakdown: ${breakdown}`, "Allowed breakdowns: model, agent, provider, daily, day, session, all");
|
|
414
|
+
failCli(`Unsupported stats breakdown: ${breakdown}`, "Allowed breakdowns: model, agent, provider, daily, day, session, raw-session, all");
|
|
330
415
|
return undefined;
|
|
331
416
|
}
|
|
332
417
|
return breakdown;
|
|
@@ -375,7 +460,10 @@ function cmdStats(period, breakdown) {
|
|
|
375
460
|
printDailyBreakdown(entries);
|
|
376
461
|
break;
|
|
377
462
|
case "session":
|
|
378
|
-
|
|
463
|
+
printSessionBreakdown(entries, loadSessionMeta());
|
|
464
|
+
break;
|
|
465
|
+
case "raw-session":
|
|
466
|
+
printSessionBreakdown(entries, loadSessionMeta(), { rollUp: false, heading: "By Raw Session" });
|
|
379
467
|
break;
|
|
380
468
|
case "all":
|
|
381
469
|
printTable("By Model", groupBy(entries, (e) => e.model ?? "unknown"), "Model");
|
|
@@ -384,6 +472,40 @@ function cmdStats(period, breakdown) {
|
|
|
384
472
|
break;
|
|
385
473
|
}
|
|
386
474
|
}
|
|
475
|
+
function formatPricingAudit(audit) {
|
|
476
|
+
if (!audit)
|
|
477
|
+
return "-";
|
|
478
|
+
const expiry = audit.expiresAt ? `; valid until ${audit.expiresAt}` : "";
|
|
479
|
+
const basis = audit.basis === "peak" ? "; peak estimate" : "";
|
|
480
|
+
return `${getPricingFreshness(audit)}; reviewed ${audit.reviewedAt}${basis}${expiry}`;
|
|
481
|
+
}
|
|
482
|
+
function printPricingAuditNotes() {
|
|
483
|
+
console.log(` Audit status: recent = reviewed within ${PRICING_STALE_AFTER_DAYS} days; stale = older; expired = past known validity; unknown = check date/clock.`);
|
|
484
|
+
console.log(` A recent audit does not guarantee that provider prices are unchanged.`);
|
|
485
|
+
console.log(` DeepSeek Flash/Pro: peak-rate estimate; off-peak rates are 50% lower. No automatic time/holiday selection.`);
|
|
486
|
+
console.log(` Retired DeepSeek aliases keep legacy estimates and are marked expired.`);
|
|
487
|
+
console.log(` Current pricing/configuration affects new records only; stored historical costs are not recalculated.`);
|
|
488
|
+
console.log();
|
|
489
|
+
}
|
|
490
|
+
function getPricingAuditWarnings(entries, config) {
|
|
491
|
+
const warnings = new Map();
|
|
492
|
+
const seen = new Set();
|
|
493
|
+
for (const entry of entries) {
|
|
494
|
+
const model = entry.model ?? "unknown";
|
|
495
|
+
const provider = entry.provider ?? "unknown";
|
|
496
|
+
const key = `${model}|${provider}`;
|
|
497
|
+
if (seen.has(key))
|
|
498
|
+
continue;
|
|
499
|
+
seen.add(key);
|
|
500
|
+
const audit = getEffectivePricingAudit(config, model, provider);
|
|
501
|
+
if (!audit)
|
|
502
|
+
continue;
|
|
503
|
+
const freshness = getPricingFreshness(audit);
|
|
504
|
+
if (freshness !== "recent")
|
|
505
|
+
warnings.set(key, { model, provider, audit, freshness });
|
|
506
|
+
}
|
|
507
|
+
return Array.from(warnings.values()).sort((a, b) => Number(b.freshness === "expired") - Number(a.freshness === "expired"));
|
|
508
|
+
}
|
|
387
509
|
function cmdPricing() {
|
|
388
510
|
const config = loadConfig();
|
|
389
511
|
console.log(`
|
|
@@ -391,27 +513,30 @@ function cmdPricing() {
|
|
|
391
513
|
══════════════════════════════════════════════════════════════════
|
|
392
514
|
Pricing last updated: ${BUILTIN_PRICING_META.pricingLastUpdated}
|
|
393
515
|
Metadata last updated: ${BUILTIN_PRICING_META.metadataLastUpdated}
|
|
516
|
+
Baseline audit: ${BUILTIN_PRICING_META.baselineLastUpdated} (unless a model has its own date)
|
|
394
517
|
Source: ${BUILTIN_PRICING_META.source}
|
|
518
|
+
Scope: ${BUILTIN_PRICING_META.notes}
|
|
395
519
|
`);
|
|
396
520
|
// Group by provider
|
|
397
521
|
const groups = {
|
|
398
522
|
"Anthropic Claude": ["claude-opus-4.8", "claude-opus-4.7", "claude-opus-4.6", "claude-opus-4.5", "claude-sonnet-4.6", "claude-sonnet-4.5", "claude-sonnet-4", "claude-haiku-4.5", "claude-haiku-4", "claude-opus-4.1", "claude-opus-4", "claude-haiku-3"],
|
|
399
523
|
"OpenAI": ["gpt-5.5", "gpt-5.5-pro", "gpt-5.4", "gpt-5.4-mini", "gpt-5.4-nano", "gpt-5.4-pro", "gpt-5.3-codex", "gpt-5.3-chat-latest", "gpt-5.2", "gpt-5.2-pro", "gpt-5-mini", "gpt-5-nano", "gpt-5.1", "gpt-5.1-chat-latest", "gpt-5.1-codex-max", "gpt-5.1-codex", "gpt-5.1-codex-mini", "gpt-5", "gpt-5-chat-latest", "gpt-5-codex", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano", "gpt-4o", "gpt-4o-mini", "o3", "o3-mini", "o4-mini", "o1", "o1-mini"],
|
|
400
|
-
"DeepSeek": ["deepseek-
|
|
524
|
+
"DeepSeek": ["deepseek-flash", "deepseek-v4-flash", "deepseek-v4-flash-vision-exp", "deepseek-v4-pro", "deepseek-chat", "deepseek-reasoner"],
|
|
525
|
+
"Moonshot Kimi": ["kimi-k2.7-code"],
|
|
401
526
|
"Google Gemini": ["gemini-3.1-pro-preview", "gemini-3-pro", "gemini-3-pro-preview", "gemini-3.5-flash", "gemini-3-flash", "gemini-3-flash-preview", "gemini-3.1-flash-lite", "gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-lite", "gemini-2.0-flash", "gemini-2.0-flash-lite"],
|
|
402
527
|
};
|
|
403
|
-
const modelWidth = 24;
|
|
528
|
+
const modelWidth = Math.max(24, ...Object.values(groups).flat().map(model => model.length + (config.models[model] ? 2 : 0)));
|
|
404
529
|
const priceWidth = 10;
|
|
405
530
|
for (const [group, models] of Object.entries(groups)) {
|
|
406
531
|
console.log(` ${group}`);
|
|
407
532
|
console.log(` ${"-".repeat(modelWidth + priceWidth * 4 + 12)}`);
|
|
408
|
-
console.log(` ${padRight("Model", modelWidth)} ${padLeft("Input", priceWidth)} ${padLeft("Output", priceWidth)} ${padLeft("CacheRd", priceWidth)} ${padLeft("CacheWr", priceWidth)}`);
|
|
533
|
+
console.log(` ${padRight("Model", modelWidth)} ${padLeft("Input", priceWidth)} ${padLeft("Output", priceWidth)} ${padLeft("CacheRd", priceWidth)} ${padLeft("CacheWr", priceWidth)} Audit`);
|
|
409
534
|
for (const model of models) {
|
|
410
535
|
const p = BUILTIN_PRICING[model];
|
|
411
536
|
if (!p)
|
|
412
537
|
continue;
|
|
413
538
|
const overridden = config.models[model] ? " *" : "";
|
|
414
|
-
console.log(` ${padRight(`${model}${overridden}`, modelWidth)} ${padLeft(`$${p.input.toString()}`, priceWidth)} ${padLeft(`$${p.output.toString()}`, priceWidth)} ${padLeft(p.cacheRead ? `$${p.cacheRead.toString()}` : "-", priceWidth)} ${padLeft(p.cacheWrite ? `$${p.cacheWrite.toString()}` : "-", priceWidth)}`);
|
|
539
|
+
console.log(` ${padRight(`${model}${overridden}`, modelWidth)} ${padLeft(`$${p.input.toString()}`, priceWidth)} ${padLeft(`$${p.output.toString()}`, priceWidth)} ${padLeft(p.cacheRead !== undefined ? `$${p.cacheRead.toString()}` : "-", priceWidth)} ${padLeft(p.cacheWrite !== undefined ? `$${p.cacheWrite.toString()}` : "-", priceWidth)} ${formatPricingAudit(getBuiltinPricingAudit(model))}`);
|
|
415
540
|
}
|
|
416
541
|
console.log();
|
|
417
542
|
}
|
|
@@ -427,8 +552,8 @@ function cmdPricing() {
|
|
|
427
552
|
console.log(` When a model is not matched in the built-in pricing table or user configuration,`);
|
|
428
553
|
console.log(` it falls back to the default rate ($1.0 / $4.0 per 1M tokens).`);
|
|
429
554
|
console.log(` You can easily override it in your configuration.`);
|
|
430
|
-
console.log(` ${BUILTIN_PRICING_META.notes}`);
|
|
431
555
|
console.log();
|
|
556
|
+
printPricingAuditNotes();
|
|
432
557
|
}
|
|
433
558
|
function cmdModels() {
|
|
434
559
|
const entries = loadEntries();
|
|
@@ -463,7 +588,7 @@ function cmdModels() {
|
|
|
463
588
|
const providerWidth = 16;
|
|
464
589
|
const countWidth = 8;
|
|
465
590
|
const statusWidth = 12;
|
|
466
|
-
console.log(` ${padRight("Model", modelWidth)} ${padRight("Provider", providerWidth)} ${padLeft("Msgs", countWidth)} ${padRight("Pricing", statusWidth)}`);
|
|
591
|
+
console.log(` ${padRight("Model", modelWidth)} ${padRight("Provider", providerWidth)} ${padLeft("Msgs", countWidth)} ${padRight("Pricing", statusWidth)} Audit`);
|
|
467
592
|
console.log(` ${"-".repeat(modelWidth)} ${"-".repeat(providerWidth)} ${"-".repeat(countWidth)} ${"-".repeat(statusWidth)}`);
|
|
468
593
|
const defaultModels = [];
|
|
469
594
|
const zeroCostProviderModels = [];
|
|
@@ -478,7 +603,7 @@ function cmdModels() {
|
|
|
478
603
|
defaultModels.push({ model, provider, count });
|
|
479
604
|
}
|
|
480
605
|
}
|
|
481
|
-
console.log(` ${padRight(model, modelWidth)} ${padRight(provider, providerWidth)} ${padLeft(count.toString(), countWidth)} ${padRight(status, statusWidth)}`);
|
|
606
|
+
console.log(` ${padRight(model, modelWidth)} ${padRight(provider, providerWidth)} ${padLeft(count.toString(), countWidth)} ${padRight(status, statusWidth)} ${formatPricingAudit(getEffectivePricingAudit(config, model, provider))}`);
|
|
482
607
|
}
|
|
483
608
|
console.log();
|
|
484
609
|
console.log(` Pricing status:`);
|
|
@@ -487,6 +612,11 @@ function cmdModels() {
|
|
|
487
612
|
console.log(` model cfg = overridden by models config`);
|
|
488
613
|
console.log(` default = unknown model, using $1/$4 per 1M tokens`);
|
|
489
614
|
console.log();
|
|
615
|
+
printPricingAuditNotes();
|
|
616
|
+
if (getPricingAuditWarnings(entries, config).length > 0) {
|
|
617
|
+
console.log(` Review stale/expired built-in prices with: opencode-tokens pricing`);
|
|
618
|
+
console.log();
|
|
619
|
+
}
|
|
490
620
|
if (zeroCostProviderModels.length > 0 || defaultModels.length > 0) {
|
|
491
621
|
console.log(` Next steps for default pricing:`);
|
|
492
622
|
const total = zeroCostProviderModels.length + defaultModels.length;
|
|
@@ -564,12 +694,21 @@ function cmdDoctor() {
|
|
|
564
694
|
}
|
|
565
695
|
console.log();
|
|
566
696
|
const defaultModels = getDefaultModelProviders(entries, configDiagnostics.config);
|
|
697
|
+
const auditWarnings = getPricingAuditWarnings(entries, configDiagnostics.config);
|
|
567
698
|
const zeroCostProviders = dedupeZeroCostProviderMatches(defaultModels.flatMap(({ model, provider, count }) => {
|
|
568
699
|
const match = getZeroCostProviderMatch(provider);
|
|
569
700
|
return match ? [{ model, provider, count, reason: match.reason }] : [];
|
|
570
701
|
}));
|
|
571
702
|
console.log(` Pricing`);
|
|
572
703
|
console.log(` Built-in pricing updated: ${BUILTIN_PRICING_META.pricingLastUpdated}`);
|
|
704
|
+
console.log(` ${BUILTIN_PRICING_META.notes}`);
|
|
705
|
+
console.log(` Built-in audit warnings: ${auditWarnings.length}`);
|
|
706
|
+
for (const { model, provider, audit } of auditWarnings.slice(0, 5)) {
|
|
707
|
+
console.log(` - ${model} (${provider}): ${formatPricingAudit(audit)}`);
|
|
708
|
+
}
|
|
709
|
+
if (auditWarnings.length > 5)
|
|
710
|
+
console.log(` - ...and ${auditWarnings.length - 5} more; see opencode-tokens models`);
|
|
711
|
+
console.log(` DeepSeek Flash/Pro use peak-rate estimates; off-peak rates are 50% lower.`);
|
|
573
712
|
console.log(` Default-priced model/provider pairs: ${defaultModels.length}`);
|
|
574
713
|
for (const { model, provider, count } of defaultModels.slice(0, 5)) {
|
|
575
714
|
console.log(` - ${model} (${provider}, ${count} msgs)`);
|
|
@@ -598,6 +737,9 @@ function cmdDoctor() {
|
|
|
598
737
|
nextSteps.push(`Review default pricing with: opencode-tokens models`);
|
|
599
738
|
nextSteps.push(`Generate suggested overrides with: opencode-tokens config init`);
|
|
600
739
|
}
|
|
740
|
+
if (auditWarnings.length > 0) {
|
|
741
|
+
nextSteps.push(`Review stale/expired built-in prices with: opencode-tokens pricing`);
|
|
742
|
+
}
|
|
601
743
|
if (!hasBudget) {
|
|
602
744
|
nextSteps.push(`Configure optional budgets with: opencode-tokens config init`);
|
|
603
745
|
}
|
|
@@ -1234,7 +1376,8 @@ function cmdHelp() {
|
|
|
1234
1376
|
month Show this month's usage
|
|
1235
1377
|
all Show all-time usage (default)
|
|
1236
1378
|
|
|
1237
|
-
--by <type> Group by: model, agent, provider, session, daily, all
|
|
1379
|
+
--by <type> Group by: model, agent, provider, session, raw-session, daily, all
|
|
1380
|
+
(session = rolled up to top-level; raw-session = per session, sub-agents shown separately)
|
|
1238
1381
|
|
|
1239
1382
|
Export Options:
|
|
1240
1383
|
--format csv (default) or json
|
|
@@ -1262,7 +1405,8 @@ function cmdHelp() {
|
|
|
1262
1405
|
opencode-tokens # All-time summary
|
|
1263
1406
|
opencode-tokens doctor # Diagnose setup and logs
|
|
1264
1407
|
opencode-tokens today --by model # Today by model
|
|
1265
|
-
opencode-tokens week --by session # This week by session
|
|
1408
|
+
opencode-tokens week --by session # This week by top-level session
|
|
1409
|
+
opencode-tokens --by raw-session # Per session, sub-agents shown separately
|
|
1266
1410
|
opencode-tokens trend --days 7 # 7-day cost trend
|
|
1267
1411
|
opencode-tokens export --format csv # Export all data as CSV
|
|
1268
1412
|
opencode-tokens config set budget.daily 10 # Set daily budget to $10
|
|
@@ -1566,13 +1710,13 @@ function main() {
|
|
|
1566
1710
|
return;
|
|
1567
1711
|
if (command) {
|
|
1568
1712
|
if (!isStatsPeriod(command)) {
|
|
1569
|
-
failCli(`Unknown command or stats period: ${command}`, "Usage: opencode-tokens [today|week|month|all] [--by model|agent|provider|daily|session|all]");
|
|
1713
|
+
failCli(`Unknown command or stats period: ${command}`, "Usage: opencode-tokens [today|week|month|all] [--by model|agent|provider|daily|session|raw-session|all]");
|
|
1570
1714
|
return;
|
|
1571
1715
|
}
|
|
1572
1716
|
period = command;
|
|
1573
1717
|
const extraArgs = parsed.positional.slice(1);
|
|
1574
1718
|
if (extraArgs.length > 0) {
|
|
1575
|
-
failCli(`Unexpected argument for stats command: ${extraArgs[0]}`, "Usage: opencode-tokens [today|week|month|all] [--by model|agent|provider|daily|session|all]");
|
|
1719
|
+
failCli(`Unexpected argument for stats command: ${extraArgs[0]}`, "Usage: opencode-tokens [today|week|month|all] [--by model|agent|provider|daily|session|raw-session|all]");
|
|
1576
1720
|
return;
|
|
1577
1721
|
}
|
|
1578
1722
|
}
|
package/dist/index.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
import { DEFAULT_CONFIG, calculateCost, evaluateBudgetStatus, formatCost, formatTokens, getStartOfDay, getStartOfMonth, getStartOfWeek, hasBillableTokenUsage, validateConfig, } from "./lib/shared.js";
|
|
2
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, openSync, readSync, closeSync } from "fs";
|
|
1
|
+
import { DEFAULT_CONFIG, aggregateRootSession, buildMessageToast, buildSessionRecord, calculateCost, evaluateBudgetStatus, formatCost, formatTokens, getStartOfDay, getStartOfMonth, getStartOfWeek, hasBillableTokenUsage, validateConfig, } from "./lib/shared.js";
|
|
2
|
+
import { appendFileSync, createReadStream, existsSync, mkdirSync, readFileSync, statSync, openSync, readSync, closeSync } from "fs";
|
|
3
3
|
import { open } from "fs/promises";
|
|
4
4
|
import { join } from "path";
|
|
5
5
|
import { homedir } from "os";
|
|
6
|
+
import { createInterface } from "node:readline";
|
|
6
7
|
const CONFIG_DIR = join(homedir(), ".config", "opencode");
|
|
7
8
|
const CONFIG_FILE = join(CONFIG_DIR, "token-tracker.json");
|
|
8
9
|
const LOG_DIR = join(CONFIG_DIR, "logs", "token-tracker");
|
|
9
10
|
const LOG_FILE = join(LOG_DIR, "tokens.jsonl");
|
|
11
|
+
const SESSIONS_LOG_FILE = join(LOG_DIR, "sessions.jsonl");
|
|
10
12
|
// ============================================================================
|
|
11
13
|
// Configuration
|
|
12
14
|
// ============================================================================
|
|
@@ -51,6 +53,8 @@ function ensureLatestConfig() {
|
|
|
51
53
|
}
|
|
52
54
|
}
|
|
53
55
|
const sessionStats = new Map();
|
|
56
|
+
// 从侧车日志和实时会话事件学习父子关系,展示时归并至顶层任务。
|
|
57
|
+
const parentOf = new Map();
|
|
54
58
|
function getOrCreateSessionStats(sessionId) {
|
|
55
59
|
if (!sessionStats.has(sessionId)) {
|
|
56
60
|
sessionStats.set(sessionId, {
|
|
@@ -66,6 +70,31 @@ function getOrCreateSessionStats(sessionId) {
|
|
|
66
70
|
}
|
|
67
71
|
return sessionStats.get(sessionId);
|
|
68
72
|
}
|
|
73
|
+
function rememberSessionParent(info) {
|
|
74
|
+
// 仅标题更新不应抹去已有关系,与 CLI 的元数据合并口径一致。
|
|
75
|
+
if (!info.id || !info.parentID)
|
|
76
|
+
return;
|
|
77
|
+
parentOf.set(info.id, info.parentID);
|
|
78
|
+
}
|
|
79
|
+
async function restoreSessionParents() {
|
|
80
|
+
try {
|
|
81
|
+
const lines = createInterface({ input: createReadStream(SESSIONS_LOG_FILE), crlfDelay: Infinity });
|
|
82
|
+
for await (const line of lines) {
|
|
83
|
+
try {
|
|
84
|
+
const record = JSON.parse(line);
|
|
85
|
+
if (record?.type !== "session" || typeof record.sessionId !== "string" || typeof record.parentID !== "string")
|
|
86
|
+
continue;
|
|
87
|
+
rememberSessionParent({ id: record.sessionId, parentID: record.parentID });
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// 跳过损坏行,后续有效记录仍可恢复关系。
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// 无历史元数据或读取失败时,继续通过实时会话事件学习关系。
|
|
96
|
+
}
|
|
97
|
+
}
|
|
69
98
|
// ============================================================================
|
|
70
99
|
// Deduplication
|
|
71
100
|
// ============================================================================
|
|
@@ -89,11 +118,32 @@ function ensureLogDir() {
|
|
|
89
118
|
mkdirSync(LOG_DIR, { recursive: true });
|
|
90
119
|
}
|
|
91
120
|
}
|
|
92
|
-
function logJson(data) {
|
|
121
|
+
function logJson(data, recordedAt) {
|
|
93
122
|
ensureLogDir();
|
|
94
|
-
const entry = JSON.stringify({ ...data, _ts:
|
|
123
|
+
const entry = JSON.stringify({ ...data, _ts: recordedAt }) + "\n";
|
|
95
124
|
appendFileSync(LOG_FILE, entry);
|
|
96
125
|
}
|
|
126
|
+
// Append-only sidecar of session metadata (id/title/parentID/directory) used
|
|
127
|
+
// by the CLI to label `--by session` rows and roll child sessions up to their
|
|
128
|
+
// parent. Per-process dedup keeps writes to actual title/parent changes only.
|
|
129
|
+
const seenSessions = new Map();
|
|
130
|
+
function logSessionMeta(info) {
|
|
131
|
+
const record = buildSessionRecord(info);
|
|
132
|
+
if (!record)
|
|
133
|
+
return;
|
|
134
|
+
const signature = `${record.title ?? ""}|${record.parentID ?? ""}|${record.directory ?? ""}`;
|
|
135
|
+
if (seenSessions.get(record.sessionId) === signature)
|
|
136
|
+
return;
|
|
137
|
+
seenSessions.set(record.sessionId, signature);
|
|
138
|
+
// Bound memory the same way the message dedup set does.
|
|
139
|
+
if (seenSessions.size > 10000) {
|
|
140
|
+
const stale = Array.from(seenSessions.keys()).slice(0, 5000);
|
|
141
|
+
for (const key of stale)
|
|
142
|
+
seenSessions.delete(key);
|
|
143
|
+
}
|
|
144
|
+
ensureLogDir();
|
|
145
|
+
appendFileSync(SESSIONS_LOG_FILE, JSON.stringify({ type: "session", ...record, _ts: Date.now() }) + "\n");
|
|
146
|
+
}
|
|
97
147
|
const budgetTracker = {
|
|
98
148
|
dailySpent: 0,
|
|
99
149
|
weeklySpent: 0,
|
|
@@ -272,17 +322,14 @@ async function initBudgetTracker() {
|
|
|
272
322
|
}
|
|
273
323
|
budgetTracker.initialized = true;
|
|
274
324
|
}
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
*/
|
|
278
|
-
function accumulateBudget(cost) {
|
|
325
|
+
// 在写入当前消息之前切换周期,避免重载结果已经包含当前消耗。
|
|
326
|
+
function refreshBudgetPeriods(recordedAt) {
|
|
279
327
|
if (!budgetTracker.initialized)
|
|
280
328
|
return;
|
|
281
|
-
const now = new Date();
|
|
329
|
+
const now = new Date(recordedAt);
|
|
282
330
|
const currentDayStart = getStartOfDay(now);
|
|
283
331
|
const currentWeekStart = getStartOfWeek(now);
|
|
284
332
|
const currentMonthStart = getStartOfMonth(now);
|
|
285
|
-
// Period rollover detection — reset and reload from file for accuracy
|
|
286
333
|
if (currentDayStart !== budgetTracker.dayStart) {
|
|
287
334
|
budgetTracker.dayStart = currentDayStart;
|
|
288
335
|
budgetTracker.dailySpent = loadCostsSince(currentDayStart);
|
|
@@ -295,6 +342,11 @@ function accumulateBudget(cost) {
|
|
|
295
342
|
budgetTracker.monthStart = currentMonthStart;
|
|
296
343
|
budgetTracker.monthlySpent = loadCostsSince(currentMonthStart);
|
|
297
344
|
}
|
|
345
|
+
}
|
|
346
|
+
// 只在日志成功写入后累计;周期判断和日志使用同一个 recordedAt。
|
|
347
|
+
function accumulateBudget(cost) {
|
|
348
|
+
if (!budgetTracker.initialized)
|
|
349
|
+
return;
|
|
298
350
|
budgetTracker.dailySpent += cost;
|
|
299
351
|
budgetTracker.weeklySpent += cost;
|
|
300
352
|
budgetTracker.monthlySpent += cost;
|
|
@@ -307,11 +359,6 @@ function checkBudgetStatus() {
|
|
|
307
359
|
};
|
|
308
360
|
return evaluateBudgetStatus(config.budget, snapshot, budgetTracker.initialized);
|
|
309
361
|
}
|
|
310
|
-
function formatBudgetMessage(status) {
|
|
311
|
-
const pct = Math.round(status.percentage * 100);
|
|
312
|
-
const periodLabel = status.period.charAt(0).toUpperCase() + status.period.slice(1);
|
|
313
|
-
return `${periodLabel}: ${formatCost(status.spent)}/${formatCost(status.limit)} (${pct}%)`;
|
|
314
|
-
}
|
|
315
362
|
export const TokenTrackerPlugin = async ({ directory, client }) => {
|
|
316
363
|
try {
|
|
317
364
|
// Load config on plugin init (with validation)
|
|
@@ -322,6 +369,7 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
|
|
|
322
369
|
}
|
|
323
370
|
// Initialize in-memory budget tracker (reads JSONL once)
|
|
324
371
|
await initBudgetTracker();
|
|
372
|
+
await restoreSessionParents();
|
|
325
373
|
// 不再写 type:"init" 标记:OpenCode 会在多个子进程(LSP、工具 runner 等)独立加载
|
|
326
374
|
// plugin,每次启动会向 JSONL 写多份重复的 init 行,污染日志且无计费价值。
|
|
327
375
|
// 跨进程去重的根因修复留待后续版本。
|
|
@@ -383,6 +431,8 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
|
|
|
383
431
|
stats.totalCacheWrite += cacheWrite;
|
|
384
432
|
stats.totalCost += cost;
|
|
385
433
|
stats.messageCount += 1;
|
|
434
|
+
const recordedAt = Date.now();
|
|
435
|
+
refreshBudgetPeriods(recordedAt);
|
|
386
436
|
// Log to file
|
|
387
437
|
logJson({
|
|
388
438
|
type: "tokens",
|
|
@@ -398,35 +448,28 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
|
|
|
398
448
|
cacheRead,
|
|
399
449
|
cacheWrite,
|
|
400
450
|
cost,
|
|
401
|
-
});
|
|
451
|
+
}, recordedAt);
|
|
402
452
|
// Accumulate cost into in-memory budget tracker
|
|
403
453
|
accumulateBudget(cost);
|
|
404
454
|
// Show toast for this message
|
|
405
455
|
if (config.toast.enabled) {
|
|
406
456
|
const totalTokens = input + output;
|
|
457
|
+
// Roll sub-agent sessions up to their top-level session so
|
|
458
|
+
// `Session:` reflects the whole task, not just this agent's slice.
|
|
459
|
+
const rootStats = aggregateRootSession(sessionStats, parentOf, sessionId);
|
|
407
460
|
// Check budget status
|
|
408
461
|
const budgetStatus = checkBudgetStatus();
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
message = formatBudgetMessage(budgetStatus);
|
|
417
|
-
variant = "error";
|
|
418
|
-
}
|
|
419
|
-
else if (budgetStatus.warning) {
|
|
420
|
-
message = `${formatCost(cost)} | ${formatBudgetMessage(budgetStatus)}`;
|
|
421
|
-
variant = "warning";
|
|
422
|
-
}
|
|
423
|
-
}
|
|
462
|
+
const toast = buildMessageToast({
|
|
463
|
+
messageTokens: totalTokens,
|
|
464
|
+
messageCost: cost,
|
|
465
|
+
sessionTokens: rootStats.totalInput + rootStats.totalOutput,
|
|
466
|
+
sessionCost: rootStats.totalCost,
|
|
467
|
+
budget: budgetStatus,
|
|
468
|
+
});
|
|
424
469
|
try {
|
|
425
470
|
await client.tui.showToast({
|
|
426
471
|
body: {
|
|
427
|
-
|
|
428
|
-
message,
|
|
429
|
-
variant,
|
|
472
|
+
...toast,
|
|
430
473
|
duration: budgetStatus?.exceeded ? 5000 : config.toast.duration,
|
|
431
474
|
},
|
|
432
475
|
});
|
|
@@ -443,16 +486,17 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
|
|
|
443
486
|
const sessionId = props?.sessionID;
|
|
444
487
|
if (!sessionId)
|
|
445
488
|
return;
|
|
446
|
-
|
|
447
|
-
|
|
489
|
+
// 主会话可能尚未产生消息,是否展示摘要应由整组消耗决定。
|
|
490
|
+
const rootStats = aggregateRootSession(sessionStats, parentOf, sessionId);
|
|
491
|
+
if (rootStats.messageCount === 0)
|
|
448
492
|
return;
|
|
449
|
-
const duration = Math.round((Date.now() -
|
|
450
|
-
const totalTokens =
|
|
493
|
+
const duration = Math.round((Date.now() - rootStats.startTime) / 1000 / 60);
|
|
494
|
+
const totalTokens = rootStats.totalInput + rootStats.totalOutput;
|
|
451
495
|
try {
|
|
452
496
|
await client.tui.showToast({
|
|
453
497
|
body: {
|
|
454
498
|
title: `Session: ${formatTokens(totalTokens)} tokens`,
|
|
455
|
-
message: `${formatCost(
|
|
499
|
+
message: `${formatCost(rootStats.totalCost)} | ${rootStats.messageCount} msgs | ${duration}min`,
|
|
456
500
|
variant: "info",
|
|
457
501
|
duration: 5000,
|
|
458
502
|
},
|
|
@@ -460,6 +504,16 @@ export const TokenTrackerPlugin = async ({ directory, client }) => {
|
|
|
460
504
|
}
|
|
461
505
|
catch { }
|
|
462
506
|
}
|
|
507
|
+
// Capture session metadata (title/parentID) for the CLI session view
|
|
508
|
+
// and the in-memory parent map used to roll sub-agent usage up to the
|
|
509
|
+
// top-level session in toasts.
|
|
510
|
+
if (event.type === "session.created" || event.type === "session.updated") {
|
|
511
|
+
const props = event.properties;
|
|
512
|
+
if (props?.info) {
|
|
513
|
+
rememberSessionParent(props.info);
|
|
514
|
+
logSessionMeta(props.info);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
463
517
|
}
|
|
464
518
|
catch { }
|
|
465
519
|
},
|
package/dist/lib/shared.d.ts
CHANGED
|
@@ -14,11 +14,22 @@ export interface ProviderModelPricingMap {
|
|
|
14
14
|
[provider: string]: ModelPricing;
|
|
15
15
|
}
|
|
16
16
|
export declare const BUILTIN_PRICING_META: {
|
|
17
|
-
readonly pricingLastUpdated: "2026-05
|
|
18
|
-
readonly metadataLastUpdated: "2026-05
|
|
17
|
+
readonly pricingLastUpdated: "2026-10-05";
|
|
18
|
+
readonly metadataLastUpdated: "2026-10-05";
|
|
19
|
+
readonly baselineLastUpdated: "2026-05-29";
|
|
19
20
|
readonly source: "Provider official pricing pages";
|
|
20
|
-
readonly notes: "Manually maintained
|
|
21
|
+
readonly 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.";
|
|
21
22
|
};
|
|
23
|
+
export interface BuiltinPricingAudit {
|
|
24
|
+
reviewedAt: string;
|
|
25
|
+
expiresAt?: string;
|
|
26
|
+
basis?: "peak";
|
|
27
|
+
exactOrPrefixed?: boolean;
|
|
28
|
+
sourceUrl?: string;
|
|
29
|
+
}
|
|
30
|
+
export type PricingFreshness = "recent" | "stale" | "expired" | "unknown";
|
|
31
|
+
export declare const PRICING_STALE_AFTER_DAYS = 90;
|
|
32
|
+
export declare const BUILTIN_PRICING_AUDITS: Record<string, BuiltinPricingAudit>;
|
|
22
33
|
export declare const BUILTIN_PRICING: Record<string, ModelPricing>;
|
|
23
34
|
export declare function formatCost(cost: number): string;
|
|
24
35
|
/**
|
|
@@ -68,14 +79,102 @@ export interface BudgetStatus {
|
|
|
68
79
|
exceeded: boolean;
|
|
69
80
|
warning: boolean;
|
|
70
81
|
}
|
|
82
|
+
export interface MessageToastInput {
|
|
83
|
+
messageTokens: number;
|
|
84
|
+
messageCost: number;
|
|
85
|
+
sessionTokens: number;
|
|
86
|
+
sessionCost: number;
|
|
87
|
+
budget: BudgetStatus | null;
|
|
88
|
+
}
|
|
89
|
+
export interface MessageToastBody {
|
|
90
|
+
title: string;
|
|
91
|
+
message: string;
|
|
92
|
+
variant: "info" | "warning" | "error";
|
|
93
|
+
}
|
|
94
|
+
export declare function formatBudgetMessage(status: BudgetStatus): string;
|
|
95
|
+
export declare function buildMessageToast(input: MessageToastInput): MessageToastBody;
|
|
71
96
|
export interface BudgetSpentSnapshot {
|
|
72
97
|
dailySpent: number;
|
|
73
98
|
weeklySpent: number;
|
|
74
99
|
monthlySpent: number;
|
|
75
100
|
}
|
|
76
101
|
export declare function evaluateBudgetStatus(budget: BudgetConfig, spent: BudgetSpentSnapshot, initialized: boolean): BudgetStatus | null;
|
|
102
|
+
export declare function getBuiltinPricingAudit(model: string): BuiltinPricingAudit | undefined;
|
|
103
|
+
export declare function getPricingFreshness(audit: BuiltinPricingAudit, now?: number): PricingFreshness;
|
|
104
|
+
export declare function getEffectivePricingAudit(config: TrackerConfig, model: string, provider: string): BuiltinPricingAudit | undefined;
|
|
77
105
|
export declare function resolvePricingStatus(config: TrackerConfig, model: string, provider: string): "provider cfg" | "model cfg" | "built-in" | "default";
|
|
78
106
|
export declare function round2(val: number): number;
|
|
79
107
|
export type ProviderFamily = "anthropic" | "openai" | "deepseek" | "google" | "other";
|
|
80
108
|
export declare function getProviderFamily(model: string, provider: string): ProviderFamily;
|
|
81
109
|
export declare function calculateCost(model: string, provider: string, input: number, output: number, cacheRead?: number, cacheWrite?: number, config?: TrackerConfig): number;
|
|
110
|
+
export interface SessionMeta {
|
|
111
|
+
sessionId: string;
|
|
112
|
+
title?: string;
|
|
113
|
+
parentID?: string;
|
|
114
|
+
directory?: string;
|
|
115
|
+
_ts: number;
|
|
116
|
+
}
|
|
117
|
+
/** Shape of a raw line in sessions.jsonl (all fields optional/untrusted). */
|
|
118
|
+
export interface SessionMetaRecord {
|
|
119
|
+
sessionId?: string;
|
|
120
|
+
title?: string;
|
|
121
|
+
parentID?: string;
|
|
122
|
+
directory?: string;
|
|
123
|
+
_ts?: number;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Resolve a session id to its root by following parentID links.
|
|
127
|
+
* Stops when no parent is known or a cycle is detected, so an unknown
|
|
128
|
+
* parent still yields a stable root id to group descendants under.
|
|
129
|
+
*/
|
|
130
|
+
export declare function resolveRootSession(sessionId: string, parentOf: Map<string, string | undefined>): string;
|
|
131
|
+
/** Per-session usage counters, summed across a root session's whole subtree. */
|
|
132
|
+
export interface SessionAggregate {
|
|
133
|
+
totalInput: number;
|
|
134
|
+
totalOutput: number;
|
|
135
|
+
totalReasoning: number;
|
|
136
|
+
totalCacheRead: number;
|
|
137
|
+
totalCacheWrite: number;
|
|
138
|
+
totalCost: number;
|
|
139
|
+
messageCount: number;
|
|
140
|
+
startTime: number;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Sum per-session stats across every session that resolves to the same root as
|
|
144
|
+
* `currentSessionId`, rolling a sub-agent session's usage up into its top-level
|
|
145
|
+
* (parent) session for toast / idle display.
|
|
146
|
+
*
|
|
147
|
+
* Aggregation happens at read time: a `parentID` learned only after a session's
|
|
148
|
+
* first message still merges on the next call (eventual consistency), so stored
|
|
149
|
+
* per-session stats never need re-bucketing. `startTime` is the earliest among
|
|
150
|
+
* the grouped sessions so duration reflects the whole task.
|
|
151
|
+
*/
|
|
152
|
+
export declare function aggregateRootSession(sessionStats: ReadonlyMap<string, SessionAggregate>, parentOf: Map<string, string | undefined>, currentSessionId: string): SessionAggregate;
|
|
153
|
+
/**
|
|
154
|
+
* Merge raw sessions.jsonl records into one SessionMeta per session id.
|
|
155
|
+
* Later non-empty fields win; empty/whitespace titles never clobber a real
|
|
156
|
+
* one (session.created may arrive before a title is generated).
|
|
157
|
+
*/
|
|
158
|
+
export declare function mergeSessionMeta(records: SessionMetaRecord[]): Map<string, SessionMeta>;
|
|
159
|
+
/** Truncate a label to maxWidth, appending an ellipsis when it overflows. */
|
|
160
|
+
export declare function truncateLabel(str: string, maxWidth: number): string;
|
|
161
|
+
/** Distinctive short code for a session id (its tail), for fallback display. */
|
|
162
|
+
export declare function shortSessionCode(sessionId?: string): string;
|
|
163
|
+
/** Pick a human label for a (root) session: title when known, else short code. */
|
|
164
|
+
export declare function sessionDisplayLabel(rootId: string, meta: SessionMeta | undefined, maxWidth: number): string;
|
|
165
|
+
/** Subset of an OpenCode Session event payload the tracker cares about. */
|
|
166
|
+
export interface SessionInfoInput {
|
|
167
|
+
id?: string;
|
|
168
|
+
title?: string;
|
|
169
|
+
parentID?: string;
|
|
170
|
+
directory?: string;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Build a session metadata record to persist, or null when there is nothing
|
|
174
|
+
* worth recording yet (no title and no parent link). Title is trimmed; a
|
|
175
|
+
* parentID is recorded even before a title exists so child sessions can be
|
|
176
|
+
* rolled up into their parent.
|
|
177
|
+
*/
|
|
178
|
+
export declare function buildSessionRecord(info: SessionInfoInput): (SessionMetaRecord & {
|
|
179
|
+
sessionId: string;
|
|
180
|
+
}) | null;
|
package/dist/lib/shared.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
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
7
|
// - Anthropic: https://platform.claude.com/docs/en/about-claude/pricing
|
|
8
8
|
// - OpenAI: https://developers.openai.com/api/docs/pricing
|
|
@@ -10,10 +10,36 @@
|
|
|
10
10
|
// - Google: https://cloud.google.com/vertex-ai/generative-ai/pricing
|
|
11
11
|
// ============================================================================
|
|
12
12
|
export const BUILTIN_PRICING_META = {
|
|
13
|
-
pricingLastUpdated: "2026-05
|
|
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
45
|
// Anthropic Claude (https://platform.claude.com/docs/en/about-claude/pricing)
|
|
@@ -71,13 +97,17 @@ export const BUILTIN_PRICING = {
|
|
|
71
97
|
"o1": { input: 15, output: 60, cacheRead: 7.5 },
|
|
72
98
|
"o1-mini": { input: 1.1, output: 4.4, cacheRead: 0.55 },
|
|
73
99
|
// DeepSeek (https://api-docs.deepseek.com/quick_start/pricing)
|
|
74
|
-
//
|
|
75
|
-
// $0.14 input (cache miss), $0.0028 input (cache hit), $0.28 output
|
|
100
|
+
// 已停用的旧别名保留历史估算值,由核验元数据提示到期。
|
|
76
101
|
"deepseek-chat": { input: 0.14, output: 0.28, cacheRead: 0.0028 },
|
|
77
102
|
"deepseek-reasoner": { input: 0.14, output: 0.28, cacheRead: 0.0028 },
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
"deepseek-
|
|
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 },
|
|
81
111
|
// Google Gemini (https://cloud.google.com/vertex-ai/generative-ai/pricing)
|
|
82
112
|
// Standard global text pricing at <=200K input tokens where tiered pricing applies.
|
|
83
113
|
// Gemini 3.1 Pro Preview: $2 input, $12 output, cache read $0.20
|
|
@@ -406,6 +436,34 @@ function validateBudget(raw, warnings) {
|
|
|
406
436
|
}
|
|
407
437
|
return result;
|
|
408
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
|
+
}
|
|
409
467
|
export function evaluateBudgetStatus(budget, spent, initialized) {
|
|
410
468
|
if (!initialized)
|
|
411
469
|
return null;
|
|
@@ -434,6 +492,38 @@ export function evaluateBudgetStatus(budget, spent, initialized) {
|
|
|
434
492
|
candidates.sort((a, b) => severity(b) - severity(a) || b.percentage - a.percentage);
|
|
435
493
|
return candidates[0] ?? null;
|
|
436
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
|
+
}
|
|
437
527
|
export function resolvePricingStatus(config, model, provider) {
|
|
438
528
|
// Step 1: config.providers[provider] -> "provider cfg"
|
|
439
529
|
if (config.providers && config.providers[provider]) {
|
|
@@ -443,20 +533,9 @@ export function resolvePricingStatus(config, model, provider) {
|
|
|
443
533
|
if (findModelConfigPricing(config.models, model, provider, false)) {
|
|
444
534
|
return "model cfg";
|
|
445
535
|
}
|
|
446
|
-
// Step 3:
|
|
447
|
-
if (
|
|
536
|
+
// Step 3/4: 内置精确匹配,其次按型号规则匹配。
|
|
537
|
+
if (findBuiltinPricingKey(model))
|
|
448
538
|
return "built-in";
|
|
449
|
-
}
|
|
450
|
-
// Step 4: Partial-match in BUILTIN_PRICING (sorted by key length desc) -> "built-in"
|
|
451
|
-
const modelLower = model.toLowerCase();
|
|
452
|
-
const sortedBuiltin = Object.keys(BUILTIN_PRICING)
|
|
453
|
-
.filter(k => k !== "_default")
|
|
454
|
-
.sort((a, b) => b.length - a.length);
|
|
455
|
-
for (const key of sortedBuiltin) {
|
|
456
|
-
if (modelLower.includes(key.toLowerCase())) {
|
|
457
|
-
return "built-in";
|
|
458
|
-
}
|
|
459
|
-
}
|
|
460
539
|
// Step 5: Partial-match in config.models (sorted by key length desc) -> "model cfg"
|
|
461
540
|
if (findModelConfigPricing(config.models, model, provider, true)) {
|
|
462
541
|
return "model cfg";
|
|
@@ -501,18 +580,9 @@ function getModelPricing(model, provider, config) {
|
|
|
501
580
|
return configuredPricing;
|
|
502
581
|
}
|
|
503
582
|
}
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
const modelLower = model.toLowerCase();
|
|
508
|
-
const sortedKeys = Object.keys(BUILTIN_PRICING)
|
|
509
|
-
.filter(k => k !== "_default")
|
|
510
|
-
.sort((a, b) => b.length - a.length);
|
|
511
|
-
for (const key of sortedKeys) {
|
|
512
|
-
if (modelLower.includes(key.toLowerCase())) {
|
|
513
|
-
return BUILTIN_PRICING[key];
|
|
514
|
-
}
|
|
515
|
-
}
|
|
583
|
+
const builtinKey = findBuiltinPricingKey(model);
|
|
584
|
+
if (builtinKey)
|
|
585
|
+
return BUILTIN_PRICING[builtinKey];
|
|
516
586
|
if (config && config.models) {
|
|
517
587
|
const partialUserPricing = findModelConfigPricing(config.models, model, provider, true);
|
|
518
588
|
if (partialUserPricing) {
|
|
@@ -552,3 +622,128 @@ export function calculateCost(model, provider, input, output, cacheRead = 0, cac
|
|
|
552
622
|
const cacheWriteCost = (cacheWrite / 1_000_000) * finalCacheWritePrice;
|
|
553
623
|
return inputCost + outputCost + cacheReadCost + cacheWriteCost;
|
|
554
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