opencode-token-tracker 1.6.5 → 1.7.1

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.
@@ -1,11 +1,17 @@
1
1
  #!/usr/bin/env node
2
2
  import { closeSync, copyFileSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
- import { join } from "node:path";
5
- import { BUILTIN_PRICING, DEFAULT_CONFIG, findModelConfigPricing, formatCost, formatTokens, getStartOfDay, getStartOfMonth, getStartOfWeek, validateConfig } from "../lib/shared.js";
4
+ import { dirname, join } from "node:path";
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
7
  const CONFIG_DIR = join(homedir(), ".config", "opencode");
7
8
  const CONFIG_FILE = join(CONFIG_DIR, "token-tracker.json");
8
9
  const LOG_FILE = join(CONFIG_DIR, "logs", "token-tracker", "tokens.jsonl");
10
+ const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
11
+ const OPENCODE_CONFIG_FILES = [
12
+ join(CONFIG_DIR, "opencode.json"),
13
+ join(CONFIG_DIR, "opencode.jsonc"),
14
+ ];
9
15
  // ============================================================================
10
16
  // Helpers
11
17
  // ============================================================================
@@ -20,6 +26,41 @@ function truncateSessionId(sessionId) {
20
26
  return "unknown";
21
27
  return sessionId.length > 16 ? `${sessionId.slice(0, 14)}…` : sessionId;
22
28
  }
29
+ function formatAge(ts) {
30
+ const diffMs = Date.now() - ts;
31
+ if (diffMs < 60_000)
32
+ return "less than 1m ago";
33
+ const minutes = Math.floor(diffMs / 60_000);
34
+ if (minutes < 60)
35
+ return `${minutes}m ago`;
36
+ const hours = Math.floor(minutes / 60);
37
+ if (hours < 24)
38
+ return `${hours}h ago`;
39
+ return `${Math.floor(hours / 24)}d ago`;
40
+ }
41
+ const ZERO_COST_PROVIDER_HINTS = [
42
+ { match: "copilot", reason: "subscription/bundled provider" },
43
+ { match: "cursor", reason: "subscription/bundled provider" },
44
+ { match: "free", reason: "free provider name" },
45
+ { match: "ollama", reason: "local/self-hosted provider" },
46
+ { match: "lmstudio", reason: "local/self-hosted provider" },
47
+ { match: "lm-studio", reason: "local/self-hosted provider" },
48
+ { match: "llamacpp", reason: "local/self-hosted provider" },
49
+ { match: "llama.cpp", reason: "local/self-hosted provider" },
50
+ { match: "local", reason: "local/self-hosted provider" },
51
+ { match: "localhost", reason: "local/self-hosted provider" },
52
+ { match: "self-hosted", reason: "local/self-hosted provider" },
53
+ { match: "selfhosted", reason: "local/self-hosted provider" },
54
+ ];
55
+ function getZeroCostProviderMatch(provider) {
56
+ const normalized = provider.toLowerCase().replace(/\s+/g, "");
57
+ for (const hint of ZERO_COST_PROVIDER_HINTS) {
58
+ if (normalized.includes(hint.match)) {
59
+ return { provider, reason: hint.reason };
60
+ }
61
+ }
62
+ return undefined;
63
+ }
23
64
  function parseArgs(args) {
24
65
  const positional = [];
25
66
  const flags = new Map();
@@ -49,7 +90,7 @@ function parseArgs(args) {
49
90
  i++;
50
91
  continue;
51
92
  }
52
- if (arg.startsWith("-") && arg.length === 2 && arg !== "--") {
93
+ if (/^-[A-Za-z]$/.test(arg)) {
53
94
  const next = args[i + 1];
54
95
  if (next && !next.startsWith("-")) {
55
96
  flags.set(arg.slice(1), next);
@@ -109,7 +150,7 @@ function loadEntries(since) {
109
150
  shouldStop = true;
110
151
  break;
111
152
  }
112
- if (!entry.input && !entry.output)
153
+ if (!hasBillableTokenUsage(entry))
113
154
  continue;
114
155
  entries.push(entry);
115
156
  }
@@ -122,7 +163,7 @@ function loadEntries(since) {
122
163
  if (!shouldStop && leftover.trim()) {
123
164
  try {
124
165
  const entry = JSON.parse(leftover.trim());
125
- if (entry.type === "tokens" && (!since || entry._ts >= since) && (entry.input || entry.output)) {
166
+ if (entry.type === "tokens" && (!since || entry._ts >= since) && hasBillableTokenUsage(entry)) {
126
167
  entries.push(entry);
127
168
  }
128
169
  }
@@ -233,7 +274,7 @@ function printTable(title, groups, labelHeader) {
233
274
  function printDailyBreakdown(entries) {
234
275
  const byDay = groupBy(entries, (e) => {
235
276
  const date = new Date(e._ts);
236
- return date.toISOString().slice(0, 10);
277
+ return formatLocalDateKey(date);
237
278
  });
238
279
  const sorted = Array.from(byDay.entries()).sort((a, b) => b[0].localeCompare(a[0]));
239
280
  if (sorted.length === 0) {
@@ -258,6 +299,38 @@ function printDailyBreakdown(entries) {
258
299
  // ============================================================================
259
300
  // Commands
260
301
  // ============================================================================
302
+ const STATS_BREAKDOWNS = ["model", "agent", "provider", "day", "daily", "session", "all"];
303
+ const STATS_PERIODS = ["today", "week", "month", "all"];
304
+ const TOP_LEVEL_COMMANDS = ["budget", "doctor", "pricing", "models", "config", "export", "trend"];
305
+ function isStatsBreakdown(value) {
306
+ return STATS_BREAKDOWNS.includes(value);
307
+ }
308
+ function isStatsPeriod(value) {
309
+ return STATS_PERIODS.includes(value);
310
+ }
311
+ function isTopLevelCommand(value) {
312
+ return TOP_LEVEL_COMMANDS.includes(value);
313
+ }
314
+ function getStatsBreakdown(flags) {
315
+ const longValue = flagValue(flags, "by");
316
+ const shortValue = flagValue(flags, "b");
317
+ 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");
319
+ return undefined;
320
+ }
321
+ 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");
323
+ return undefined;
324
+ }
325
+ const breakdown = longValue ?? shortValue;
326
+ if (!breakdown)
327
+ return undefined;
328
+ if (!isStatsBreakdown(breakdown)) {
329
+ failCli(`Unsupported stats breakdown: ${breakdown}`, "Allowed breakdowns: model, agent, provider, daily, day, session, all");
330
+ return undefined;
331
+ }
332
+ return breakdown;
333
+ }
261
334
  function cmdStats(period, breakdown) {
262
335
  const now = new Date();
263
336
  let since;
@@ -314,17 +387,20 @@ function cmdStats(period, breakdown) {
314
387
  function cmdPricing() {
315
388
  const config = loadConfig();
316
389
  console.log(`
317
- Built-in Pricing Table (USD per 1M tokens) - Updated 2026-02-11
390
+ Built-in Pricing Table (USD per 1M tokens)
318
391
  ══════════════════════════════════════════════════════════════════
392
+ Pricing last updated: ${BUILTIN_PRICING_META.pricingLastUpdated}
393
+ Metadata last updated: ${BUILTIN_PRICING_META.metadataLastUpdated}
394
+ Source: ${BUILTIN_PRICING_META.source}
319
395
  `);
320
396
  // Group by provider
321
397
  const groups = {
322
- "Anthropic Claude": ["claude-opus-4.6", "claude-opus-4.5", "claude-sonnet-4.5", "claude-sonnet-4", "claude-haiku-4.5", "claude-haiku-4", "claude-opus-4.1", "claude-opus-4", "claude-haiku-3"],
323
- "OpenAI": ["gpt-5.2", "gpt-5.2-pro", "gpt-5-mini", "gpt-5.1", "gpt-5", "gpt-4.1", "gpt-4.1-mini", "gpt-4.1-nano", "gpt-4o", "gpt-4o-mini", "o3", "o3-mini", "o4-mini", "o1", "o1-mini"],
324
- "DeepSeek": ["deepseek-chat", "deepseek-reasoner"],
325
- "Google Gemini": ["gemini-3-pro", "gemini-3-pro-preview", "gemini-3-flash", "gemini-3-flash-preview", "gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-lite", "gemini-2.0-flash", "gemini-2.0-flash-lite"],
398
+ "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
+ "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"],
401
+ "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"],
326
402
  };
327
- const modelWidth = 20;
403
+ const modelWidth = 24;
328
404
  const priceWidth = 10;
329
405
  for (const [group, models] of Object.entries(groups)) {
330
406
  console.log(` ${group}`);
@@ -347,6 +423,12 @@ function cmdPricing() {
347
423
  if (Object.keys(config.models || {}).length > 0) {
348
424
  console.log(` * = overridden in config`);
349
425
  }
426
+ console.log(` Fallback Pricing Notice:`);
427
+ console.log(` When a model is not matched in the built-in pricing table or user configuration,`);
428
+ console.log(` it falls back to the default rate ($1.0 / $4.0 per 1M tokens).`);
429
+ console.log(` You can easily override it in your configuration.`);
430
+ console.log(` ${BUILTIN_PRICING_META.notes}`);
431
+ console.log();
350
432
  }
351
433
  function cmdModels() {
352
434
  const entries = loadEntries();
@@ -383,29 +465,17 @@ function cmdModels() {
383
465
  const statusWidth = 12;
384
466
  console.log(` ${padRight("Model", modelWidth)} ${padRight("Provider", providerWidth)} ${padLeft("Msgs", countWidth)} ${padRight("Pricing", statusWidth)}`);
385
467
  console.log(` ${"-".repeat(modelWidth)} ${"-".repeat(providerWidth)} ${"-".repeat(countWidth)} ${"-".repeat(statusWidth)}`);
468
+ const defaultModels = [];
469
+ const zeroCostProviderModels = [];
386
470
  for (const { model, provider, count } of sorted) {
387
- let status;
388
- // Mirror the runtime pricing resolution order (getModelPricing in index.ts)
389
- if (config.providers?.[provider]) {
390
- status = "provider cfg";
391
- }
392
- else if (findModelConfigPricing(config.models, model, provider, false)) {
393
- status = "model cfg";
394
- }
395
- else if (BUILTIN_PRICING[model]) {
396
- status = "built-in";
397
- }
398
- else {
399
- const modelLower = model.toLowerCase();
400
- const hasBuiltinPartial = Object.keys(BUILTIN_PRICING).some(k => k !== "_default" && modelLower.includes(k.toLowerCase()));
401
- if (hasBuiltinPartial) {
402
- status = "built-in";
403
- }
404
- else if (findModelConfigPricing(config.models, model, provider, true)) {
405
- status = "model cfg";
471
+ const status = resolvePricingStatus(config, model, provider);
472
+ if (status === "default") {
473
+ const zeroCostMatch = getZeroCostProviderMatch(provider);
474
+ if (zeroCostMatch) {
475
+ zeroCostProviderModels.push({ model, provider, count, reason: zeroCostMatch.reason });
406
476
  }
407
477
  else {
408
- status = "default";
478
+ defaultModels.push({ model, provider, count });
409
479
  }
410
480
  }
411
481
  console.log(` ${padRight(model, modelWidth)} ${padRight(provider, providerWidth)} ${padLeft(count.toString(), countWidth)} ${padRight(status, statusWidth)}`);
@@ -417,28 +487,219 @@ function cmdModels() {
417
487
  console.log(` model cfg = overridden by models config`);
418
488
  console.log(` default = unknown model, using $1/$4 per 1M tokens`);
419
489
  console.log();
490
+ if (zeroCostProviderModels.length > 0 || defaultModels.length > 0) {
491
+ console.log(` Next steps for default pricing:`);
492
+ const total = zeroCostProviderModels.length + defaultModels.length;
493
+ console.log(` ${total} model/provider ${total === 1 ? "pair uses" : "pairs use"} the default $1/$4 estimate.`);
494
+ console.log(` Run: opencode-tokens config init`);
495
+ console.log(` Or: opencode-tokens config generate`);
496
+ console.log();
497
+ if (zeroCostProviderModels.length > 0) {
498
+ console.log(` Likely zero-cost provider overrides to review:`);
499
+ for (const { provider, reason } of dedupeZeroCostProviderMatches(zeroCostProviderModels).slice(0, 5)) {
500
+ console.log(` - ${provider} (${reason}): { "input": 0, "output": 0 }`);
501
+ }
502
+ if (zeroCostProviderModels.length > 5) {
503
+ console.log(` - ...and ${zeroCostProviderModels.length - 5} more model/provider pairs`);
504
+ }
505
+ console.log();
506
+ }
507
+ if (defaultModels.length > 0) {
508
+ console.log(` Model overrides to review before saving:`);
509
+ for (const { model, provider, count } of defaultModels.slice(0, 5)) {
510
+ console.log(` - ${model} (${provider}, ${count} msgs): { "input": 1, "output": 4 }`);
511
+ }
512
+ if (defaultModels.length > 5) {
513
+ console.log(` - ...and ${defaultModels.length - 5} more`);
514
+ }
515
+ console.log();
516
+ }
517
+ }
518
+ }
519
+ function cmdDoctor() {
520
+ const entries = loadEntries();
521
+ const configDiagnostics = inspectTrackerConfig();
522
+ const pluginDiagnostics = inspectOpenCodePluginConfig();
523
+ console.log(`
524
+ OpenCode Token Tracker Doctor
525
+ ══════════════════════════════════════════════════════════════════
526
+ `);
527
+ console.log(` CLI`);
528
+ console.log(` Entry: ${process.argv[1] ?? "unknown"}`);
529
+ console.log(` Package version: ${readPackageJsonVersion()}`);
530
+ console.log();
531
+ console.log(` OpenCode plugin config`);
532
+ if (pluginDiagnostics.found) {
533
+ console.log(` File: ${pluginDiagnostics.path}`);
534
+ console.log(` Plugin entry: ${pluginDiagnostics.hasPlugin ? "found" : "missing"}`);
535
+ }
536
+ else {
537
+ console.log(` File: not found (${OPENCODE_CONFIG_FILES.map((p) => p.replace(`${homedir()}/`, "~/")).join(" or ")})`);
538
+ console.log(` Plugin entry: unknown`);
539
+ }
540
+ console.log();
541
+ console.log(` Tracker config`);
542
+ console.log(` File: ${CONFIG_FILE}`);
543
+ console.log(` Status: ${configDiagnostics.exists ? "exists" : "using defaults"}`);
544
+ if (configDiagnostics.parseError) {
545
+ console.log(` Warning: ${configDiagnostics.parseError}`);
546
+ }
547
+ for (const warning of configDiagnostics.warnings) {
548
+ console.log(` Warning: ${warning}`);
549
+ }
550
+ const budget = configDiagnostics.config.budget;
551
+ const hasBudget = Boolean(budget.daily || budget.weekly || budget.monthly);
552
+ console.log(` Budget: ${hasBudget ? "configured" : "not configured"}`);
553
+ console.log();
554
+ console.log(` Token log`);
555
+ if (!existsSync(LOG_FILE)) {
556
+ console.log(` File: not found (${LOG_FILE})`);
557
+ console.log(` Entries: 0`);
558
+ }
559
+ else {
560
+ const latest = entries[entries.length - 1];
561
+ console.log(` File: ${LOG_FILE}`);
562
+ console.log(` Entries: ${entries.length}`);
563
+ console.log(` Latest: ${latest ? `${new Date(latest._ts).toISOString()} (${formatAge(latest._ts)})` : "none"}`);
564
+ }
565
+ console.log();
566
+ const defaultModels = getDefaultModelProviders(entries, configDiagnostics.config);
567
+ const zeroCostProviders = dedupeZeroCostProviderMatches(defaultModels.flatMap(({ model, provider, count }) => {
568
+ const match = getZeroCostProviderMatch(provider);
569
+ return match ? [{ model, provider, count, reason: match.reason }] : [];
570
+ }));
571
+ console.log(` Pricing`);
572
+ console.log(` Built-in pricing updated: ${BUILTIN_PRICING_META.pricingLastUpdated}`);
573
+ console.log(` Default-priced model/provider pairs: ${defaultModels.length}`);
574
+ for (const { model, provider, count } of defaultModels.slice(0, 5)) {
575
+ console.log(` - ${model} (${provider}, ${count} msgs)`);
576
+ }
577
+ if (defaultModels.length > 5) {
578
+ console.log(` - ...and ${defaultModels.length - 5} more`);
579
+ }
580
+ if (zeroCostProviders.length > 0) {
581
+ console.log(` Likely zero-cost providers:`);
582
+ for (const { provider, reason } of zeroCostProviders.slice(0, 5)) {
583
+ console.log(` - ${provider} (${reason})`);
584
+ }
585
+ }
586
+ console.log();
587
+ const nextSteps = [];
588
+ if (!pluginDiagnostics.found || !pluginDiagnostics.hasPlugin) {
589
+ nextSteps.push(`Add "opencode-token-tracker" to your OpenCode plugin config.`);
590
+ }
591
+ if (!existsSync(LOG_FILE) || entries.length === 0) {
592
+ nextSteps.push(`Run an OpenCode request, then check ${LOG_FILE}.`);
593
+ }
594
+ if (configDiagnostics.parseError || configDiagnostics.warnings.length > 0) {
595
+ nextSteps.push(`Fix ${CONFIG_FILE} or regenerate it with: opencode-tokens config generate`);
596
+ }
597
+ if (defaultModels.length > 0) {
598
+ nextSteps.push(`Review default pricing with: opencode-tokens models`);
599
+ nextSteps.push(`Generate suggested overrides with: opencode-tokens config init`);
600
+ }
601
+ if (!hasBudget) {
602
+ nextSteps.push(`Configure optional budgets with: opencode-tokens config init`);
603
+ }
604
+ console.log(` Next steps`);
605
+ if (nextSteps.length === 0) {
606
+ console.log(` No obvious issues found.`);
607
+ }
608
+ else {
609
+ for (const step of nextSteps) {
610
+ console.log(` - ${step}`);
611
+ }
612
+ }
613
+ console.log();
614
+ }
615
+ function readPackageJsonVersion() {
616
+ try {
617
+ const pkg = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf-8"));
618
+ return typeof pkg.version === "string" ? pkg.version : "unknown";
619
+ }
620
+ catch {
621
+ return "unknown";
622
+ }
623
+ }
624
+ function inspectOpenCodePluginConfig() {
625
+ for (const path of OPENCODE_CONFIG_FILES) {
626
+ if (!existsSync(path))
627
+ continue;
628
+ const raw = readFileSync(path, "utf-8");
629
+ return {
630
+ found: true,
631
+ path,
632
+ hasPlugin: raw.includes("opencode-token-tracker"),
633
+ };
634
+ }
635
+ return { found: false, hasPlugin: false };
636
+ }
637
+ function inspectTrackerConfig() {
638
+ if (!existsSync(CONFIG_FILE)) {
639
+ return { exists: false, config: DEFAULT_CONFIG, warnings: [] };
640
+ }
641
+ try {
642
+ const raw = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
643
+ const result = validateConfig(raw);
644
+ return { exists: true, config: result.config, warnings: result.warnings };
645
+ }
646
+ catch {
647
+ return { exists: true, config: DEFAULT_CONFIG, warnings: [], parseError: "Config file is not valid JSON, using defaults" };
648
+ }
649
+ }
650
+ function getDefaultModelProviders(entries, config) {
651
+ const counts = new Map();
652
+ for (const e of entries) {
653
+ const model = e.model ?? "unknown";
654
+ const provider = e.provider ?? "unknown";
655
+ if (model === "unknown" || provider === "unknown")
656
+ continue;
657
+ const key = `${model}|${provider}`;
658
+ let item = counts.get(key);
659
+ if (!item) {
660
+ item = { model, provider, count: 0, lastUsed: 0 };
661
+ counts.set(key, item);
662
+ }
663
+ item.count++;
664
+ item.lastUsed = Math.max(item.lastUsed, e._ts);
665
+ }
666
+ return Array.from(counts.values())
667
+ .filter(({ model, provider }) => resolvePricingStatus(config, model, provider) === "default")
668
+ .sort((a, b) => b.lastUsed - a.lastUsed);
669
+ }
670
+ function dedupeZeroCostProviderMatches(items) {
671
+ const providers = new Map();
672
+ for (const item of items) {
673
+ if (!providers.has(item.provider)) {
674
+ providers.set(item.provider, { provider: item.provider, reason: item.reason });
675
+ }
676
+ }
677
+ return Array.from(providers.values());
420
678
  }
421
679
  function cmdConfig(positional) {
422
680
  const action = positional[1];
423
681
  const config = loadConfig();
424
682
  const entries = loadEntries();
425
683
  if (action === "init" || action === "generate") {
426
- // Get unique providers from logs
684
+ // Get unique providers and model+provider combinations from logs
427
685
  const providers = new Set();
428
- const models = new Set();
686
+ const modelProviders = new Set();
429
687
  for (const e of entries) {
430
688
  if (e.provider)
431
689
  providers.add(e.provider);
432
- if (e.model)
433
- models.add(e.model);
690
+ const model = e.model ?? "unknown";
691
+ const provider = e.provider ?? "unknown";
692
+ modelProviders.add(`${model}|${provider}`);
434
693
  }
435
- // Find providers/models without built-in pricing
436
- const unknownModels = Array.from(models).filter(m => {
437
- if (BUILTIN_PRICING[m])
438
- return false;
439
- const hasMatch = Object.keys(BUILTIN_PRICING).some(k => k !== "_default" && m.toLowerCase().includes(k.toLowerCase()));
440
- return !hasMatch;
441
- });
694
+ // Estimate daily avg from last 7 days
695
+ const now = Date.now();
696
+ const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000;
697
+ const recentEntries = entries.filter(e => e._ts >= sevenDaysAgo);
698
+ const totalSpent = recentEntries.reduce((sum, e) => sum + (e.cost ?? 0), 0);
699
+ const dailyAvg = totalSpent / 7;
700
+ const dailyLimit = dailyAvg > 0 ? Math.max(0.5, round2(dailyAvg * 1.5)) : 5;
701
+ const weeklyLimit = dailyAvg > 0 ? Math.max(0.5, round2(dailyAvg * 7 * 1.3)) : 25;
702
+ const monthlyLimit = dailyAvg > 0 ? Math.max(0.5, round2(dailyAvg * 30 * 1.2)) : 100;
442
703
  const exampleConfig = {
443
704
  providers: {},
444
705
  models: {},
@@ -448,25 +709,78 @@ function cmdConfig(positional) {
448
709
  showOnIdle: true,
449
710
  },
450
711
  budget: {
451
- daily: 5,
452
- weekly: 25,
453
- monthly: 100,
712
+ daily: dailyLimit,
713
+ weekly: weeklyLimit,
714
+ monthly: monthlyLimit,
454
715
  warnAt: 0.8,
455
716
  },
456
717
  };
457
- // Add providers as comments/examples
718
+ const suggestedProviders = [];
719
+ const suggestedProviderNames = new Set();
720
+ // Add likely zero-cost providers detected from logs.
458
721
  for (const provider of providers) {
459
- // Common free providers
460
- if (provider.includes("copilot") || provider.includes("cursor") || provider.includes("free")) {
722
+ const zeroCostMatch = getZeroCostProviderMatch(provider);
723
+ if (zeroCostMatch) {
461
724
  exampleConfig.providers[provider] = { input: 0, output: 0 };
725
+ suggestedProviders.push(zeroCostMatch);
726
+ suggestedProviderNames.add(provider);
462
727
  }
463
728
  }
464
- // Add unknown models
465
- for (const model of unknownModels) {
466
- exampleConfig.models[model] = { input: 1, output: 4 };
729
+ const suggestedModels = [];
730
+ // Add unknown/fallback models where status is "default"
731
+ for (const mp of modelProviders) {
732
+ const [model, provider] = mp.split("|");
733
+ if (model === "unknown" || provider === "unknown")
734
+ continue;
735
+ if (suggestedProviderNames.has(provider))
736
+ continue;
737
+ const status = resolvePricingStatus(config, model, provider);
738
+ if (status === "default") {
739
+ exampleConfig.models[model] = { input: 1, output: 4 };
740
+ suggestedModels.push(`${model} (${provider})`);
741
+ }
467
742
  }
468
- // Print explanation first
469
- console.log(`
743
+ // Construct Dynamic Usage-Aware Suggestions Summary
744
+ let suggestionsSummary = `
745
+ 📢 Usage-Aware Suggestions Summary
746
+ ══════════════════════════════════════════════════════════════════
747
+ - Analyzed timeframe: Last 7 days
748
+ - Found ${entries.length} historical usage entries in total.
749
+ - Calculated 7-day average daily cost: $${round2(dailyAvg).toFixed(4)}
750
+ `;
751
+ if (dailyAvg > 0) {
752
+ suggestionsSummary += `
753
+ Recommended Budget Limits (derived from daily avg $${round2(dailyAvg).toFixed(4)}):
754
+ - Daily Limit : $${dailyLimit.toFixed(2)} (Avg * 1.5 buffer, clamped min $0.50)
755
+ - Weekly Limit : $${weeklyLimit.toFixed(2)} (Avg * 7 * 1.3 buffer, clamped min $0.50)
756
+ - Monthly Limit : $${monthlyLimit.toFixed(2)} (Avg * 30 * 1.2 buffer, clamped min $0.50)
757
+ `;
758
+ }
759
+ else {
760
+ suggestionsSummary += `
761
+ No active usage logs detected in the last 7 days.
762
+ - Applying fallback default budgets: Daily $5.00, Weekly $25.00, Monthly $100.00.
763
+ `;
764
+ }
765
+ if (suggestedProviders.length > 0) {
766
+ suggestionsSummary += `
767
+ Detected likely zero-cost providers:
768
+ `;
769
+ for (const { provider, reason } of suggestedProviders) {
770
+ suggestionsSummary += ` • ${provider} (${reason}, automatically pre-configured to $0.00)\n`;
771
+ }
772
+ }
773
+ if (suggestedModels.length > 0) {
774
+ suggestionsSummary += `
775
+ Detected unrecognized fallback models:
776
+ `;
777
+ for (const m of suggestedModels) {
778
+ suggestionsSummary += ` • ${m} (automatically pre-configured with fallback $1/$4 rate)\n`;
779
+ }
780
+ }
781
+ suggestionsSummary += ` ────────────────────────────────────────────────────────────────\n`;
782
+ // Print explanation first to stderr
783
+ const guideText = `
470
784
  Configuration Guide
471
785
  ══════════════════════════════════════════════════════════════════
472
786
 
@@ -480,11 +794,11 @@ function cmdConfig(positional) {
480
794
 
481
795
  Examples:
482
796
  { "input": 15, "output": 75 } = $15 per 1M input, $75 per 1M output
483
- { "input": 0, "output": 0 } = Free (subscription or local model)
797
+ { "input": 0, "output": 0 } = Free (subscription or local provider/model)
484
798
 
485
799
  Common scenarios:
486
800
  - GitHub Copilot, Cursor, etc. → Set provider to { input: 0, output: 0 }
487
- - Local/self-hosted models → Set to 0
801
+ - Ollama, LM Studio, localhost → Set provider to { input: 0, output: 0 }
488
802
  - Direct API usage → Look up provider's pricing page
489
803
 
490
804
  Where to find pricing:
@@ -507,33 +821,36 @@ function cmdConfig(positional) {
507
821
 
508
822
  ────────────────────────────────────────────────────────────────
509
823
  Example config based on your usage:
510
- `);
511
- console.log(JSON.stringify(exampleConfig, null, 2));
512
- if (action === "generate") {
513
- const json = JSON.stringify(exampleConfig, null, 2);
514
- writeFileSync(CONFIG_FILE, json);
515
- console.log(`
516
- Config file created: ${CONFIG_FILE}
517
- `);
518
- }
519
- else {
520
- console.log(`
824
+ `;
825
+ process.stderr.write(suggestionsSummary + "\n");
826
+ process.stderr.write(guideText + "\n");
827
+ if (action === "init") {
828
+ // Print clean config to stdout ONLY on init
829
+ process.stdout.write(JSON.stringify(exampleConfig, null, 2) + "\n");
830
+ process.stderr.write(`
521
831
  To create this config file, run:
522
832
  opencode-tokens config generate
523
833
 
524
834
  Or manually create: ${CONFIG_FILE}
525
- `);
835
+ \n`);
836
+ }
837
+ else if (action === "generate") {
838
+ // Generate has completely empty stdout! Writes to config file, alerts to stderr
839
+ saveConfig(exampleConfig);
840
+ process.stderr.write(`
841
+ Config file created: ${CONFIG_FILE}
842
+ \n`);
526
843
  }
527
844
  return;
528
845
  }
529
846
  if (action === "get") {
530
847
  const key = positional[2];
531
848
  if (!key) {
532
- console.log("\n Usage: opencode-tokens config get <key>\n");
849
+ failCli("Missing config key", "Usage: opencode-tokens config get <key>");
533
850
  return;
534
851
  }
535
852
  if (!SETTABLE_KEYS[key]) {
536
- console.log(`\n Unknown key: ${key}\n Available: ${Object.keys(SETTABLE_KEYS).join(", ")}\n`);
853
+ failCli(`Unknown config key: ${key}`, `Available keys: ${Object.keys(SETTABLE_KEYS).join(", ")}`);
537
854
  return;
538
855
  }
539
856
  const value = resolveConfigKey(config, key);
@@ -543,27 +860,31 @@ function cmdConfig(positional) {
543
860
  if (action === "set") {
544
861
  const key = positional[2];
545
862
  const rawValue = positional[3];
546
- if (!key || !rawValue) {
547
- console.log("\n Usage: opencode-tokens config set <key> <value>\n");
863
+ if (!key) {
864
+ failCli("Missing config key", "Usage: opencode-tokens config set <key> <value>");
865
+ return;
866
+ }
867
+ if (!rawValue) {
868
+ failCli("Missing config value", "Usage: opencode-tokens config set <key> <value>");
548
869
  return;
549
870
  }
550
871
  const spec = SETTABLE_KEYS[key];
551
872
  if (!spec) {
552
- console.log(`\n Unknown key: ${key}\n Available: ${Object.keys(SETTABLE_KEYS).join(", ")}\n`);
873
+ failCli(`Unknown config key: ${key}`, `Available keys: ${Object.keys(SETTABLE_KEYS).join(", ")}`);
553
874
  return;
554
875
  }
555
876
  const value = parseConfigValue(rawValue);
556
877
  if (typeof value !== spec.type) {
557
- console.log(`\n Invalid type: expected ${spec.type}, got ${typeof value}\n`);
878
+ failCli(`Invalid type for ${key}: expected ${spec.type}, got ${typeof value}`);
558
879
  return;
559
880
  }
560
881
  if (typeof value === "number") {
561
882
  if (value < 0) {
562
- console.log(`\n Value must be >= 0\n`);
883
+ failCli(`Invalid value for ${key}: must be >= 0`);
563
884
  return;
564
885
  }
565
886
  if (spec.max !== undefined && value > spec.max) {
566
- console.log(`\n Value must be <= ${spec.max}\n`);
887
+ failCli(`Invalid value for ${key}: must be <= ${spec.max}`);
567
888
  return;
568
889
  }
569
890
  }
@@ -574,12 +895,12 @@ function cmdConfig(positional) {
574
895
  if (action === "unset") {
575
896
  const key = positional[2];
576
897
  if (!key) {
577
- console.log("\n Usage: opencode-tokens config unset <key>\n");
898
+ failCli("Missing config key", "Usage: opencode-tokens config unset <key>");
578
899
  return;
579
900
  }
580
901
  const spec = SETTABLE_KEYS[key];
581
902
  if (!spec) {
582
- console.log(`\n Unknown key: ${key}\n Available: ${Object.keys(SETTABLE_KEYS).join(", ")}\n`);
903
+ failCli(`Unknown config key: ${key}`, `Available keys: ${Object.keys(SETTABLE_KEYS).join(", ")}`);
583
904
  return;
584
905
  }
585
906
  applyConfigUnset(key);
@@ -587,8 +908,7 @@ function cmdConfig(positional) {
587
908
  return;
588
909
  }
589
910
  if (action && action !== "show") {
590
- console.log(`\n Unknown config action: ${action}`);
591
- console.log(` Usage: opencode-tokens config [show|init|generate|get|set|unset]\n`);
911
+ failCli(`Unknown config action: ${action}`, "Usage: opencode-tokens config [show|init|generate|get|set|unset]");
592
912
  return;
593
913
  }
594
914
  // Show current config
@@ -692,10 +1012,48 @@ function saveConfig(raw) {
692
1012
  // ============================================================================
693
1013
  // Export
694
1014
  // ============================================================================
1015
+ const EXPORT_FORMATS = ["csv", "json"];
1016
+ const EXPORT_PERIODS = ["today", "week", "month", "all"];
1017
+ function failCli(message, usage) {
1018
+ process.stderr.write(`\n Error: ${message}\n`);
1019
+ if (usage) {
1020
+ process.stderr.write(` ${usage}\n`);
1021
+ }
1022
+ process.stderr.write("\n");
1023
+ process.exitCode = 1;
1024
+ }
1025
+ function isExportFormat(format) {
1026
+ return EXPORT_FORMATS.includes(format);
1027
+ }
1028
+ function isExportPeriod(period) {
1029
+ return EXPORT_PERIODS.includes(period);
1030
+ }
695
1031
  function cmdExport(flags) {
696
- const format = flagValue(flags, "format") || "csv";
697
- const period = flagValue(flags, "period") || "all";
1032
+ const formatValue = flagValue(flags, "format");
1033
+ const periodValue = flagValue(flags, "period");
698
1034
  const outputFile = flagValue(flags, "output");
1035
+ if (flags.has("format") && !formatValue) {
1036
+ failCli("Missing value for --format", "Usage: opencode-tokens export --format csv|json");
1037
+ return;
1038
+ }
1039
+ if (flags.has("period") && !periodValue) {
1040
+ failCli("Missing value for --period", "Usage: opencode-tokens export --period today|week|month|all");
1041
+ return;
1042
+ }
1043
+ if (flags.has("output") && !outputFile) {
1044
+ failCli("Missing value for --output", "Usage: opencode-tokens export --output <file>");
1045
+ return;
1046
+ }
1047
+ const format = formatValue ?? "csv";
1048
+ const period = periodValue ?? "all";
1049
+ if (!isExportFormat(format)) {
1050
+ failCli(`Unsupported export format: ${format}`, "Allowed formats: csv, json");
1051
+ return;
1052
+ }
1053
+ if (!isExportPeriod(period)) {
1054
+ failCli(`Unsupported export period: ${period}`, "Allowed periods: today, week, month, all");
1055
+ return;
1056
+ }
699
1057
  const now = new Date();
700
1058
  let since;
701
1059
  switch (period) {
@@ -739,7 +1097,14 @@ function cmdExport(flags) {
739
1097
  output = `${[headers.join(","), ...rows].join("\n")}\n`;
740
1098
  }
741
1099
  if (outputFile) {
742
- writeFileSync(outputFile, output);
1100
+ try {
1101
+ writeFileSync(outputFile, output);
1102
+ }
1103
+ catch (error) {
1104
+ const detail = error instanceof Error ? error.message : String(error);
1105
+ failCli(`Failed to write export file: ${detail}`);
1106
+ return;
1107
+ }
743
1108
  console.log(`\n Exported ${entries.length} entries to ${outputFile}\n`);
744
1109
  }
745
1110
  else {
@@ -777,6 +1142,8 @@ function cmdBudget() {
777
1142
  }
778
1143
 
779
1144
  Run: opencode-tokens config init for more details.
1145
+
1146
+ Costs are estimates from local logs; budgets are warnings, not enforcement.
780
1147
  `);
781
1148
  return;
782
1149
  }
@@ -841,6 +1208,7 @@ function cmdBudget() {
841
1208
  console.log();
842
1209
  }
843
1210
  console.log(` Legend: 🟢 OK 🟡 Warning (>${Math.round(warnAt * 100)}%) 🔴 Exceeded`);
1211
+ console.log(` Costs are estimates from local logs; budgets are warnings, not enforcement.`);
844
1212
  console.log();
845
1213
  }
846
1214
  function cmdHelp() {
@@ -853,6 +1221,7 @@ function cmdHelp() {
853
1221
  Commands:
854
1222
  (default) Show usage statistics
855
1223
  budget Show budget status (daily/weekly/monthly)
1224
+ doctor Diagnose plugin config, logs, and pricing fallbacks
856
1225
  pricing Show built-in pricing table
857
1226
  models Show your used models and their pricing status
858
1227
  config Show/generate/modify configuration
@@ -891,14 +1260,21 @@ function cmdHelp() {
891
1260
 
892
1261
  Examples:
893
1262
  opencode-tokens # All-time summary
1263
+ opencode-tokens doctor # Diagnose setup and logs
894
1264
  opencode-tokens today --by model # Today by model
895
1265
  opencode-tokens week --by session # This week by session
896
1266
  opencode-tokens trend --days 7 # 7-day cost trend
897
1267
  opencode-tokens export --format csv # Export all data as CSV
898
1268
  opencode-tokens config set budget.daily 10 # Set daily budget to $10
899
1269
  opencode-tokens config get toast.enabled # Check if toast is enabled
1270
+
1271
+ Costs are estimates from local logs; budgets are warnings, not enforcement.
900
1272
  `);
901
1273
  }
1274
+ const TREND_METRICS = ["cost", "tokens", "messages"];
1275
+ function isTrendMetric(metric) {
1276
+ return TREND_METRICS.includes(metric);
1277
+ }
902
1278
  function getTrendValue(point, metric) {
903
1279
  return metric === "tokens" ? point.tokens : metric === "messages" ? point.messages : point.cost;
904
1280
  }
@@ -912,6 +1288,25 @@ function formatSignedTrendValue(value, metric) {
912
1288
  function metricLabel(metric) {
913
1289
  return metric === "tokens" ? "Token Trend" : metric === "messages" ? "Message Trend" : "Cost Trend";
914
1290
  }
1291
+ function parsePositiveIntegerOption(flags, name, defaultValue, usage) {
1292
+ const value = flagValue(flags, name);
1293
+ if (flags.has(name) && !value) {
1294
+ failCli(`Missing value for --${name}`, usage);
1295
+ return undefined;
1296
+ }
1297
+ if (!value)
1298
+ return defaultValue;
1299
+ if (!/^\d+$/.test(value)) {
1300
+ failCli(`Invalid value for --${name}: ${value}`, usage);
1301
+ return undefined;
1302
+ }
1303
+ const parsed = Number.parseInt(value, 10);
1304
+ if (parsed <= 0) {
1305
+ failCli(`Invalid value for --${name}: ${value}`, usage);
1306
+ return undefined;
1307
+ }
1308
+ return parsed;
1309
+ }
915
1310
  function buildLineRows(points, width, height) {
916
1311
  const grid = Array.from({ length: height }, () => Array.from({ length: width }, () => " "));
917
1312
  // 1. Calculate the exact integer row r[x] for every column x from 0 to width - 1
@@ -1018,9 +1413,22 @@ function buildTrendXAxis(points, chartPoints, chartWidth) {
1018
1413
  };
1019
1414
  }
1020
1415
  function cmdTrend(flags) {
1021
- const days = parseInt(String(flagValue(flags, "days") ?? "30"), 10);
1022
- const metric = flagValue(flags, "metric") ?? "cost";
1023
- const width = parseInt(String(flagValue(flags, "width") ?? "60"), 10);
1416
+ const days = parsePositiveIntegerOption(flags, "days", 30, "Usage: opencode-tokens trend --days <positive-integer>");
1417
+ if (days === undefined)
1418
+ return;
1419
+ const metricValue = flagValue(flags, "metric");
1420
+ if (flags.has("metric") && !metricValue) {
1421
+ failCli("Missing value for --metric", "Usage: opencode-tokens trend --metric cost|tokens|messages");
1422
+ return;
1423
+ }
1424
+ const metric = metricValue ?? "cost";
1425
+ if (!isTrendMetric(metric)) {
1426
+ failCli(`Unsupported trend metric: ${metric}`, "Allowed metrics: cost, tokens, messages");
1427
+ return;
1428
+ }
1429
+ const width = parsePositiveIntegerOption(flags, "width", 60, "Usage: opencode-tokens trend --width <positive-integer>");
1430
+ if (width === undefined)
1431
+ return;
1024
1432
  const since = getStartOfDay(new Date(Date.now() - days * 86400000));
1025
1433
  const entries = loadEntries(since);
1026
1434
  if (entries.length === 0) {
@@ -1127,33 +1535,45 @@ function main() {
1127
1535
  return;
1128
1536
  }
1129
1537
  const { command } = parsed;
1130
- switch (command) {
1131
- case "budget":
1132
- cmdBudget();
1133
- return;
1134
- case "pricing":
1135
- cmdPricing();
1136
- return;
1137
- case "models":
1138
- cmdModels();
1139
- return;
1140
- case "config":
1141
- cmdConfig(parsed.positional);
1142
- return;
1143
- case "export":
1144
- cmdExport(parsed.flags);
1145
- return;
1146
- case "trend":
1147
- cmdTrend(parsed.flags);
1148
- return;
1149
- }
1538
+ if (isTopLevelCommand(command))
1539
+ switch (command) {
1540
+ case "budget":
1541
+ cmdBudget();
1542
+ return;
1543
+ case "doctor":
1544
+ cmdDoctor();
1545
+ return;
1546
+ case "pricing":
1547
+ cmdPricing();
1548
+ return;
1549
+ case "models":
1550
+ cmdModels();
1551
+ return;
1552
+ case "config":
1553
+ cmdConfig(parsed.positional);
1554
+ return;
1555
+ case "export":
1556
+ cmdExport(parsed.flags);
1557
+ return;
1558
+ case "trend":
1559
+ cmdTrend(parsed.flags);
1560
+ return;
1561
+ }
1150
1562
  // Default: stats
1151
1563
  let period = "all";
1152
- const breakdown = flagValue(parsed.flags, "by") || (parsed.flags.has("b") ? String(parsed.flags.get("b")) : undefined);
1153
- for (const p of ["today", "week", "month", "all"]) {
1154
- if (parsed.positional.includes(p)) {
1155
- period = p;
1156
- break;
1564
+ const breakdown = getStatsBreakdown(parsed.flags);
1565
+ if (process.exitCode)
1566
+ return;
1567
+ if (command) {
1568
+ 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]");
1570
+ return;
1571
+ }
1572
+ period = command;
1573
+ const extraArgs = parsed.positional.slice(1);
1574
+ 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]");
1576
+ return;
1157
1577
  }
1158
1578
  }
1159
1579
  cmdStats(period, breakdown);