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 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 ID
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`:按 session ID 分组
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
- printTable("By Session", groupBy(entries, (e) => truncateSessionId(e.sessionId)), "Session");
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-chat", "deepseek-reasoner", "deepseek-v4-pro"],
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: Date.now() }) + "\n";
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
- * Accumulate cost into budgetTracker after a new token entry is logged.
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
- let title = `${formatTokens(totalTokens)} tokens`;
410
- let message = `${formatCost(cost)} | Session: ${formatCost(stats.totalCost)}`;
411
- let variant = "info";
412
- // Add budget warning/alert if applicable
413
- if (budgetStatus) {
414
- if (budgetStatus.exceeded) {
415
- title = `⚠️ Budget exceeded!`;
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
- title,
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
- const stats = sessionStats.get(sessionId);
447
- if (!stats || stats.messageCount === 0)
489
+ // 主会话可能尚未产生消息,是否展示摘要应由整组消耗决定。
490
+ const rootStats = aggregateRootSession(sessionStats, parentOf, sessionId);
491
+ if (rootStats.messageCount === 0)
448
492
  return;
449
- const duration = Math.round((Date.now() - stats.startTime) / 1000 / 60);
450
- const totalTokens = stats.totalInput + stats.totalOutput;
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(stats.totalCost)} | ${stats.messageCount} msgs | ${duration}min`,
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
  },
@@ -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-29";
18
- readonly metadataLastUpdated: "2026-05-29";
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. Report stale prices: https://github.com/tongsh6/opencode-token-tracker/issues/new";
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;
@@ -2,7 +2,7 @@
2
2
  // Shared types, pricing data, and utilities for opencode-token-tracker
3
3
  // ============================================================================
4
4
  // ============================================================================
5
- // Built-in Pricing (USD per 1M tokens) - Updated 2026-05-29
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-29",
14
- metadataLastUpdated: "2026-05-29",
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. Report stale prices: https://github.com/tongsh6/opencode-token-tracker/issues/new",
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
- // DeepSeek-V4 Flash compatibility pricing for deepseek-chat / deepseek-reasoner.
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
- // DeepSeek-V4-Pro discounted pricing, current until 2026-05-31 15:59 UTC.
79
- // $0.435 input (cache miss), $0.003625 input (cache hit), $0.87 output
80
- "deepseek-v4-pro": { input: 0.435, output: 0.87, cacheRead: 0.003625 },
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: Exact-match in BUILTIN_PRICING -> "built-in"
447
- if (BUILTIN_PRICING[model]) {
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
- if (BUILTIN_PRICING[model]) {
505
- return BUILTIN_PRICING[model];
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-token-tracker",
3
- "version": "1.7.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",