opencode-token-tracker 1.7.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +61 -7
- package/README.zh-CN.md +59 -7
- package/dist/bin/opencode-tokens.js +593 -75
- package/dist/index.js +94 -41
- package/dist/lib/shared.d.ts +110 -3
- package/dist/lib/shared.js +288 -54
- package/package.json +5 -2
|
@@ -1,11 +1,18 @@
|
|
|
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 {
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
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";
|
|
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 SESSIONS_LOG_FILE = join(CONFIG_DIR, "logs", "token-tracker", "sessions.jsonl");
|
|
11
|
+
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
12
|
+
const OPENCODE_CONFIG_FILES = [
|
|
13
|
+
join(CONFIG_DIR, "opencode.json"),
|
|
14
|
+
join(CONFIG_DIR, "opencode.jsonc"),
|
|
15
|
+
];
|
|
9
16
|
// ============================================================================
|
|
10
17
|
// Helpers
|
|
11
18
|
// ============================================================================
|
|
@@ -15,10 +22,46 @@ function padRight(str, len) {
|
|
|
15
22
|
function padLeft(str, len) {
|
|
16
23
|
return str.length >= len ? str : `${" ".repeat(len - str.length)}${str}`;
|
|
17
24
|
}
|
|
18
|
-
function
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
25
|
+
function formatAge(ts) {
|
|
26
|
+
const diffMs = Date.now() - ts;
|
|
27
|
+
if (diffMs < 60_000)
|
|
28
|
+
return "less than 1m ago";
|
|
29
|
+
const minutes = Math.floor(diffMs / 60_000);
|
|
30
|
+
if (minutes < 60)
|
|
31
|
+
return `${minutes}m ago`;
|
|
32
|
+
const hours = Math.floor(minutes / 60);
|
|
33
|
+
if (hours < 24)
|
|
34
|
+
return `${hours}h ago`;
|
|
35
|
+
return `${Math.floor(hours / 24)}d ago`;
|
|
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
|
+
}
|
|
43
|
+
const ZERO_COST_PROVIDER_HINTS = [
|
|
44
|
+
{ match: "copilot", reason: "subscription/bundled provider" },
|
|
45
|
+
{ match: "cursor", reason: "subscription/bundled provider" },
|
|
46
|
+
{ match: "free", reason: "free provider name" },
|
|
47
|
+
{ match: "ollama", reason: "local/self-hosted provider" },
|
|
48
|
+
{ match: "lmstudio", reason: "local/self-hosted provider" },
|
|
49
|
+
{ match: "lm-studio", reason: "local/self-hosted provider" },
|
|
50
|
+
{ match: "llamacpp", reason: "local/self-hosted provider" },
|
|
51
|
+
{ match: "llama.cpp", reason: "local/self-hosted provider" },
|
|
52
|
+
{ match: "local", reason: "local/self-hosted provider" },
|
|
53
|
+
{ match: "localhost", reason: "local/self-hosted provider" },
|
|
54
|
+
{ match: "self-hosted", reason: "local/self-hosted provider" },
|
|
55
|
+
{ match: "selfhosted", reason: "local/self-hosted provider" },
|
|
56
|
+
];
|
|
57
|
+
function getZeroCostProviderMatch(provider) {
|
|
58
|
+
const normalized = provider.toLowerCase().replace(/\s+/g, "");
|
|
59
|
+
for (const hint of ZERO_COST_PROVIDER_HINTS) {
|
|
60
|
+
if (normalized.includes(hint.match)) {
|
|
61
|
+
return { provider, reason: hint.reason };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
22
65
|
}
|
|
23
66
|
function parseArgs(args) {
|
|
24
67
|
const positional = [];
|
|
@@ -49,7 +92,7 @@ function parseArgs(args) {
|
|
|
49
92
|
i++;
|
|
50
93
|
continue;
|
|
51
94
|
}
|
|
52
|
-
if (
|
|
95
|
+
if (/^-[A-Za-z]$/.test(arg)) {
|
|
53
96
|
const next = args[i + 1];
|
|
54
97
|
if (next && !next.startsWith("-")) {
|
|
55
98
|
flags.set(arg.slice(1), next);
|
|
@@ -109,7 +152,7 @@ function loadEntries(since) {
|
|
|
109
152
|
shouldStop = true;
|
|
110
153
|
break;
|
|
111
154
|
}
|
|
112
|
-
if (!entry
|
|
155
|
+
if (!hasBillableTokenUsage(entry))
|
|
113
156
|
continue;
|
|
114
157
|
entries.push(entry);
|
|
115
158
|
}
|
|
@@ -122,7 +165,7 @@ function loadEntries(since) {
|
|
|
122
165
|
if (!shouldStop && leftover.trim()) {
|
|
123
166
|
try {
|
|
124
167
|
const entry = JSON.parse(leftover.trim());
|
|
125
|
-
if (entry.type === "tokens" && (!since || entry._ts >= since) && (entry
|
|
168
|
+
if (entry.type === "tokens" && (!since || entry._ts >= since) && hasBillableTokenUsage(entry)) {
|
|
126
169
|
entries.push(entry);
|
|
127
170
|
}
|
|
128
171
|
}
|
|
@@ -230,10 +273,93 @@ function printTable(title, groups, labelHeader) {
|
|
|
230
273
|
}
|
|
231
274
|
console.log();
|
|
232
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
|
+
}
|
|
233
359
|
function printDailyBreakdown(entries) {
|
|
234
360
|
const byDay = groupBy(entries, (e) => {
|
|
235
361
|
const date = new Date(e._ts);
|
|
236
|
-
return date
|
|
362
|
+
return formatLocalDateKey(date);
|
|
237
363
|
});
|
|
238
364
|
const sorted = Array.from(byDay.entries()).sort((a, b) => b[0].localeCompare(a[0]));
|
|
239
365
|
if (sorted.length === 0) {
|
|
@@ -258,6 +384,38 @@ function printDailyBreakdown(entries) {
|
|
|
258
384
|
// ============================================================================
|
|
259
385
|
// Commands
|
|
260
386
|
// ============================================================================
|
|
387
|
+
const STATS_BREAKDOWNS = ["model", "agent", "provider", "day", "daily", "session", "raw-session", "all"];
|
|
388
|
+
const STATS_PERIODS = ["today", "week", "month", "all"];
|
|
389
|
+
const TOP_LEVEL_COMMANDS = ["budget", "doctor", "pricing", "models", "config", "export", "trend"];
|
|
390
|
+
function isStatsBreakdown(value) {
|
|
391
|
+
return STATS_BREAKDOWNS.includes(value);
|
|
392
|
+
}
|
|
393
|
+
function isStatsPeriod(value) {
|
|
394
|
+
return STATS_PERIODS.includes(value);
|
|
395
|
+
}
|
|
396
|
+
function isTopLevelCommand(value) {
|
|
397
|
+
return TOP_LEVEL_COMMANDS.includes(value);
|
|
398
|
+
}
|
|
399
|
+
function getStatsBreakdown(flags) {
|
|
400
|
+
const longValue = flagValue(flags, "by");
|
|
401
|
+
const shortValue = flagValue(flags, "b");
|
|
402
|
+
if (flags.has("by") && !longValue) {
|
|
403
|
+
failCli("Missing value for --by", "Usage: opencode-tokens [today|week|month|all] --by model|agent|provider|daily|session|raw-session|all");
|
|
404
|
+
return undefined;
|
|
405
|
+
}
|
|
406
|
+
if (flags.has("b") && !shortValue) {
|
|
407
|
+
failCli("Missing value for -b", "Usage: opencode-tokens [today|week|month|all] -b model|agent|provider|daily|session|raw-session|all");
|
|
408
|
+
return undefined;
|
|
409
|
+
}
|
|
410
|
+
const breakdown = longValue ?? shortValue;
|
|
411
|
+
if (!breakdown)
|
|
412
|
+
return undefined;
|
|
413
|
+
if (!isStatsBreakdown(breakdown)) {
|
|
414
|
+
failCli(`Unsupported stats breakdown: ${breakdown}`, "Allowed breakdowns: model, agent, provider, daily, day, session, raw-session, all");
|
|
415
|
+
return undefined;
|
|
416
|
+
}
|
|
417
|
+
return breakdown;
|
|
418
|
+
}
|
|
261
419
|
function cmdStats(period, breakdown) {
|
|
262
420
|
const now = new Date();
|
|
263
421
|
let since;
|
|
@@ -302,7 +460,10 @@ function cmdStats(period, breakdown) {
|
|
|
302
460
|
printDailyBreakdown(entries);
|
|
303
461
|
break;
|
|
304
462
|
case "session":
|
|
305
|
-
|
|
463
|
+
printSessionBreakdown(entries, loadSessionMeta());
|
|
464
|
+
break;
|
|
465
|
+
case "raw-session":
|
|
466
|
+
printSessionBreakdown(entries, loadSessionMeta(), { rollUp: false, heading: "By Raw Session" });
|
|
306
467
|
break;
|
|
307
468
|
case "all":
|
|
308
469
|
printTable("By Model", groupBy(entries, (e) => e.model ?? "unknown"), "Model");
|
|
@@ -311,6 +472,40 @@ function cmdStats(period, breakdown) {
|
|
|
311
472
|
break;
|
|
312
473
|
}
|
|
313
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
|
+
}
|
|
314
509
|
function cmdPricing() {
|
|
315
510
|
const config = loadConfig();
|
|
316
511
|
console.log(`
|
|
@@ -318,27 +513,30 @@ function cmdPricing() {
|
|
|
318
513
|
══════════════════════════════════════════════════════════════════
|
|
319
514
|
Pricing last updated: ${BUILTIN_PRICING_META.pricingLastUpdated}
|
|
320
515
|
Metadata last updated: ${BUILTIN_PRICING_META.metadataLastUpdated}
|
|
516
|
+
Baseline audit: ${BUILTIN_PRICING_META.baselineLastUpdated} (unless a model has its own date)
|
|
321
517
|
Source: ${BUILTIN_PRICING_META.source}
|
|
518
|
+
Scope: ${BUILTIN_PRICING_META.notes}
|
|
322
519
|
`);
|
|
323
520
|
// Group by provider
|
|
324
521
|
const groups = {
|
|
325
|
-
"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"],
|
|
326
|
-
"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"],
|
|
327
|
-
"DeepSeek": ["deepseek-chat", "deepseek-reasoner"],
|
|
328
|
-
"
|
|
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"],
|
|
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"],
|
|
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"],
|
|
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"],
|
|
329
527
|
};
|
|
330
|
-
const modelWidth =
|
|
528
|
+
const modelWidth = Math.max(24, ...Object.values(groups).flat().map(model => model.length + (config.models[model] ? 2 : 0)));
|
|
331
529
|
const priceWidth = 10;
|
|
332
530
|
for (const [group, models] of Object.entries(groups)) {
|
|
333
531
|
console.log(` ${group}`);
|
|
334
532
|
console.log(` ${"-".repeat(modelWidth + priceWidth * 4 + 12)}`);
|
|
335
|
-
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`);
|
|
336
534
|
for (const model of models) {
|
|
337
535
|
const p = BUILTIN_PRICING[model];
|
|
338
536
|
if (!p)
|
|
339
537
|
continue;
|
|
340
538
|
const overridden = config.models[model] ? " *" : "";
|
|
341
|
-
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))}`);
|
|
342
540
|
}
|
|
343
541
|
console.log();
|
|
344
542
|
}
|
|
@@ -354,8 +552,8 @@ function cmdPricing() {
|
|
|
354
552
|
console.log(` When a model is not matched in the built-in pricing table or user configuration,`);
|
|
355
553
|
console.log(` it falls back to the default rate ($1.0 / $4.0 per 1M tokens).`);
|
|
356
554
|
console.log(` You can easily override it in your configuration.`);
|
|
357
|
-
console.log(` ${BUILTIN_PRICING_META.notes}`);
|
|
358
555
|
console.log();
|
|
556
|
+
printPricingAuditNotes();
|
|
359
557
|
}
|
|
360
558
|
function cmdModels() {
|
|
361
559
|
const entries = loadEntries();
|
|
@@ -390,11 +588,22 @@ function cmdModels() {
|
|
|
390
588
|
const providerWidth = 16;
|
|
391
589
|
const countWidth = 8;
|
|
392
590
|
const statusWidth = 12;
|
|
393
|
-
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`);
|
|
394
592
|
console.log(` ${"-".repeat(modelWidth)} ${"-".repeat(providerWidth)} ${"-".repeat(countWidth)} ${"-".repeat(statusWidth)}`);
|
|
593
|
+
const defaultModels = [];
|
|
594
|
+
const zeroCostProviderModels = [];
|
|
395
595
|
for (const { model, provider, count } of sorted) {
|
|
396
596
|
const status = resolvePricingStatus(config, model, provider);
|
|
397
|
-
|
|
597
|
+
if (status === "default") {
|
|
598
|
+
const zeroCostMatch = getZeroCostProviderMatch(provider);
|
|
599
|
+
if (zeroCostMatch) {
|
|
600
|
+
zeroCostProviderModels.push({ model, provider, count, reason: zeroCostMatch.reason });
|
|
601
|
+
}
|
|
602
|
+
else {
|
|
603
|
+
defaultModels.push({ model, provider, count });
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
console.log(` ${padRight(model, modelWidth)} ${padRight(provider, providerWidth)} ${padLeft(count.toString(), countWidth)} ${padRight(status, statusWidth)} ${formatPricingAudit(getEffectivePricingAudit(config, model, provider))}`);
|
|
398
607
|
}
|
|
399
608
|
console.log();
|
|
400
609
|
console.log(` Pricing status:`);
|
|
@@ -403,6 +612,211 @@ function cmdModels() {
|
|
|
403
612
|
console.log(` model cfg = overridden by models config`);
|
|
404
613
|
console.log(` default = unknown model, using $1/$4 per 1M tokens`);
|
|
405
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
|
+
}
|
|
620
|
+
if (zeroCostProviderModels.length > 0 || defaultModels.length > 0) {
|
|
621
|
+
console.log(` Next steps for default pricing:`);
|
|
622
|
+
const total = zeroCostProviderModels.length + defaultModels.length;
|
|
623
|
+
console.log(` ${total} model/provider ${total === 1 ? "pair uses" : "pairs use"} the default $1/$4 estimate.`);
|
|
624
|
+
console.log(` Run: opencode-tokens config init`);
|
|
625
|
+
console.log(` Or: opencode-tokens config generate`);
|
|
626
|
+
console.log();
|
|
627
|
+
if (zeroCostProviderModels.length > 0) {
|
|
628
|
+
console.log(` Likely zero-cost provider overrides to review:`);
|
|
629
|
+
for (const { provider, reason } of dedupeZeroCostProviderMatches(zeroCostProviderModels).slice(0, 5)) {
|
|
630
|
+
console.log(` - ${provider} (${reason}): { "input": 0, "output": 0 }`);
|
|
631
|
+
}
|
|
632
|
+
if (zeroCostProviderModels.length > 5) {
|
|
633
|
+
console.log(` - ...and ${zeroCostProviderModels.length - 5} more model/provider pairs`);
|
|
634
|
+
}
|
|
635
|
+
console.log();
|
|
636
|
+
}
|
|
637
|
+
if (defaultModels.length > 0) {
|
|
638
|
+
console.log(` Model overrides to review before saving:`);
|
|
639
|
+
for (const { model, provider, count } of defaultModels.slice(0, 5)) {
|
|
640
|
+
console.log(` - ${model} (${provider}, ${count} msgs): { "input": 1, "output": 4 }`);
|
|
641
|
+
}
|
|
642
|
+
if (defaultModels.length > 5) {
|
|
643
|
+
console.log(` - ...and ${defaultModels.length - 5} more`);
|
|
644
|
+
}
|
|
645
|
+
console.log();
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
function cmdDoctor() {
|
|
650
|
+
const entries = loadEntries();
|
|
651
|
+
const configDiagnostics = inspectTrackerConfig();
|
|
652
|
+
const pluginDiagnostics = inspectOpenCodePluginConfig();
|
|
653
|
+
console.log(`
|
|
654
|
+
OpenCode Token Tracker Doctor
|
|
655
|
+
══════════════════════════════════════════════════════════════════
|
|
656
|
+
`);
|
|
657
|
+
console.log(` CLI`);
|
|
658
|
+
console.log(` Entry: ${process.argv[1] ?? "unknown"}`);
|
|
659
|
+
console.log(` Package version: ${readPackageJsonVersion()}`);
|
|
660
|
+
console.log();
|
|
661
|
+
console.log(` OpenCode plugin config`);
|
|
662
|
+
if (pluginDiagnostics.found) {
|
|
663
|
+
console.log(` File: ${pluginDiagnostics.path}`);
|
|
664
|
+
console.log(` Plugin entry: ${pluginDiagnostics.hasPlugin ? "found" : "missing"}`);
|
|
665
|
+
}
|
|
666
|
+
else {
|
|
667
|
+
console.log(` File: not found (${OPENCODE_CONFIG_FILES.map((p) => p.replace(`${homedir()}/`, "~/")).join(" or ")})`);
|
|
668
|
+
console.log(` Plugin entry: unknown`);
|
|
669
|
+
}
|
|
670
|
+
console.log();
|
|
671
|
+
console.log(` Tracker config`);
|
|
672
|
+
console.log(` File: ${CONFIG_FILE}`);
|
|
673
|
+
console.log(` Status: ${configDiagnostics.exists ? "exists" : "using defaults"}`);
|
|
674
|
+
if (configDiagnostics.parseError) {
|
|
675
|
+
console.log(` Warning: ${configDiagnostics.parseError}`);
|
|
676
|
+
}
|
|
677
|
+
for (const warning of configDiagnostics.warnings) {
|
|
678
|
+
console.log(` Warning: ${warning}`);
|
|
679
|
+
}
|
|
680
|
+
const budget = configDiagnostics.config.budget;
|
|
681
|
+
const hasBudget = Boolean(budget.daily || budget.weekly || budget.monthly);
|
|
682
|
+
console.log(` Budget: ${hasBudget ? "configured" : "not configured"}`);
|
|
683
|
+
console.log();
|
|
684
|
+
console.log(` Token log`);
|
|
685
|
+
if (!existsSync(LOG_FILE)) {
|
|
686
|
+
console.log(` File: not found (${LOG_FILE})`);
|
|
687
|
+
console.log(` Entries: 0`);
|
|
688
|
+
}
|
|
689
|
+
else {
|
|
690
|
+
const latest = entries[entries.length - 1];
|
|
691
|
+
console.log(` File: ${LOG_FILE}`);
|
|
692
|
+
console.log(` Entries: ${entries.length}`);
|
|
693
|
+
console.log(` Latest: ${latest ? `${new Date(latest._ts).toISOString()} (${formatAge(latest._ts)})` : "none"}`);
|
|
694
|
+
}
|
|
695
|
+
console.log();
|
|
696
|
+
const defaultModels = getDefaultModelProviders(entries, configDiagnostics.config);
|
|
697
|
+
const auditWarnings = getPricingAuditWarnings(entries, configDiagnostics.config);
|
|
698
|
+
const zeroCostProviders = dedupeZeroCostProviderMatches(defaultModels.flatMap(({ model, provider, count }) => {
|
|
699
|
+
const match = getZeroCostProviderMatch(provider);
|
|
700
|
+
return match ? [{ model, provider, count, reason: match.reason }] : [];
|
|
701
|
+
}));
|
|
702
|
+
console.log(` Pricing`);
|
|
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.`);
|
|
712
|
+
console.log(` Default-priced model/provider pairs: ${defaultModels.length}`);
|
|
713
|
+
for (const { model, provider, count } of defaultModels.slice(0, 5)) {
|
|
714
|
+
console.log(` - ${model} (${provider}, ${count} msgs)`);
|
|
715
|
+
}
|
|
716
|
+
if (defaultModels.length > 5) {
|
|
717
|
+
console.log(` - ...and ${defaultModels.length - 5} more`);
|
|
718
|
+
}
|
|
719
|
+
if (zeroCostProviders.length > 0) {
|
|
720
|
+
console.log(` Likely zero-cost providers:`);
|
|
721
|
+
for (const { provider, reason } of zeroCostProviders.slice(0, 5)) {
|
|
722
|
+
console.log(` - ${provider} (${reason})`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
console.log();
|
|
726
|
+
const nextSteps = [];
|
|
727
|
+
if (!pluginDiagnostics.found || !pluginDiagnostics.hasPlugin) {
|
|
728
|
+
nextSteps.push(`Add "opencode-token-tracker" to your OpenCode plugin config.`);
|
|
729
|
+
}
|
|
730
|
+
if (!existsSync(LOG_FILE) || entries.length === 0) {
|
|
731
|
+
nextSteps.push(`Run an OpenCode request, then check ${LOG_FILE}.`);
|
|
732
|
+
}
|
|
733
|
+
if (configDiagnostics.parseError || configDiagnostics.warnings.length > 0) {
|
|
734
|
+
nextSteps.push(`Fix ${CONFIG_FILE} or regenerate it with: opencode-tokens config generate`);
|
|
735
|
+
}
|
|
736
|
+
if (defaultModels.length > 0) {
|
|
737
|
+
nextSteps.push(`Review default pricing with: opencode-tokens models`);
|
|
738
|
+
nextSteps.push(`Generate suggested overrides with: opencode-tokens config init`);
|
|
739
|
+
}
|
|
740
|
+
if (auditWarnings.length > 0) {
|
|
741
|
+
nextSteps.push(`Review stale/expired built-in prices with: opencode-tokens pricing`);
|
|
742
|
+
}
|
|
743
|
+
if (!hasBudget) {
|
|
744
|
+
nextSteps.push(`Configure optional budgets with: opencode-tokens config init`);
|
|
745
|
+
}
|
|
746
|
+
console.log(` Next steps`);
|
|
747
|
+
if (nextSteps.length === 0) {
|
|
748
|
+
console.log(` No obvious issues found.`);
|
|
749
|
+
}
|
|
750
|
+
else {
|
|
751
|
+
for (const step of nextSteps) {
|
|
752
|
+
console.log(` - ${step}`);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
console.log();
|
|
756
|
+
}
|
|
757
|
+
function readPackageJsonVersion() {
|
|
758
|
+
try {
|
|
759
|
+
const pkg = JSON.parse(readFileSync(join(PACKAGE_ROOT, "package.json"), "utf-8"));
|
|
760
|
+
return typeof pkg.version === "string" ? pkg.version : "unknown";
|
|
761
|
+
}
|
|
762
|
+
catch {
|
|
763
|
+
return "unknown";
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
function inspectOpenCodePluginConfig() {
|
|
767
|
+
for (const path of OPENCODE_CONFIG_FILES) {
|
|
768
|
+
if (!existsSync(path))
|
|
769
|
+
continue;
|
|
770
|
+
const raw = readFileSync(path, "utf-8");
|
|
771
|
+
return {
|
|
772
|
+
found: true,
|
|
773
|
+
path,
|
|
774
|
+
hasPlugin: raw.includes("opencode-token-tracker"),
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
return { found: false, hasPlugin: false };
|
|
778
|
+
}
|
|
779
|
+
function inspectTrackerConfig() {
|
|
780
|
+
if (!existsSync(CONFIG_FILE)) {
|
|
781
|
+
return { exists: false, config: DEFAULT_CONFIG, warnings: [] };
|
|
782
|
+
}
|
|
783
|
+
try {
|
|
784
|
+
const raw = JSON.parse(readFileSync(CONFIG_FILE, "utf-8"));
|
|
785
|
+
const result = validateConfig(raw);
|
|
786
|
+
return { exists: true, config: result.config, warnings: result.warnings };
|
|
787
|
+
}
|
|
788
|
+
catch {
|
|
789
|
+
return { exists: true, config: DEFAULT_CONFIG, warnings: [], parseError: "Config file is not valid JSON, using defaults" };
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
function getDefaultModelProviders(entries, config) {
|
|
793
|
+
const counts = new Map();
|
|
794
|
+
for (const e of entries) {
|
|
795
|
+
const model = e.model ?? "unknown";
|
|
796
|
+
const provider = e.provider ?? "unknown";
|
|
797
|
+
if (model === "unknown" || provider === "unknown")
|
|
798
|
+
continue;
|
|
799
|
+
const key = `${model}|${provider}`;
|
|
800
|
+
let item = counts.get(key);
|
|
801
|
+
if (!item) {
|
|
802
|
+
item = { model, provider, count: 0, lastUsed: 0 };
|
|
803
|
+
counts.set(key, item);
|
|
804
|
+
}
|
|
805
|
+
item.count++;
|
|
806
|
+
item.lastUsed = Math.max(item.lastUsed, e._ts);
|
|
807
|
+
}
|
|
808
|
+
return Array.from(counts.values())
|
|
809
|
+
.filter(({ model, provider }) => resolvePricingStatus(config, model, provider) === "default")
|
|
810
|
+
.sort((a, b) => b.lastUsed - a.lastUsed);
|
|
811
|
+
}
|
|
812
|
+
function dedupeZeroCostProviderMatches(items) {
|
|
813
|
+
const providers = new Map();
|
|
814
|
+
for (const item of items) {
|
|
815
|
+
if (!providers.has(item.provider)) {
|
|
816
|
+
providers.set(item.provider, { provider: item.provider, reason: item.reason });
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
return Array.from(providers.values());
|
|
406
820
|
}
|
|
407
821
|
function cmdConfig(positional) {
|
|
408
822
|
const action = positional[1];
|
|
@@ -444,12 +858,14 @@ function cmdConfig(positional) {
|
|
|
444
858
|
},
|
|
445
859
|
};
|
|
446
860
|
const suggestedProviders = [];
|
|
447
|
-
|
|
861
|
+
const suggestedProviderNames = new Set();
|
|
862
|
+
// Add likely zero-cost providers detected from logs.
|
|
448
863
|
for (const provider of providers) {
|
|
449
|
-
const
|
|
450
|
-
if (
|
|
864
|
+
const zeroCostMatch = getZeroCostProviderMatch(provider);
|
|
865
|
+
if (zeroCostMatch) {
|
|
451
866
|
exampleConfig.providers[provider] = { input: 0, output: 0 };
|
|
452
|
-
suggestedProviders.push(
|
|
867
|
+
suggestedProviders.push(zeroCostMatch);
|
|
868
|
+
suggestedProviderNames.add(provider);
|
|
453
869
|
}
|
|
454
870
|
}
|
|
455
871
|
const suggestedModels = [];
|
|
@@ -458,6 +874,8 @@ function cmdConfig(positional) {
|
|
|
458
874
|
const [model, provider] = mp.split("|");
|
|
459
875
|
if (model === "unknown" || provider === "unknown")
|
|
460
876
|
continue;
|
|
877
|
+
if (suggestedProviderNames.has(provider))
|
|
878
|
+
continue;
|
|
461
879
|
const status = resolvePricingStatus(config, model, provider);
|
|
462
880
|
if (status === "default") {
|
|
463
881
|
exampleConfig.models[model] = { input: 1, output: 4 };
|
|
@@ -488,10 +906,10 @@ function cmdConfig(positional) {
|
|
|
488
906
|
}
|
|
489
907
|
if (suggestedProviders.length > 0) {
|
|
490
908
|
suggestionsSummary += `
|
|
491
|
-
Detected zero-cost
|
|
909
|
+
Detected likely zero-cost providers:
|
|
492
910
|
`;
|
|
493
|
-
for (const
|
|
494
|
-
suggestionsSummary += ` • ${
|
|
911
|
+
for (const { provider, reason } of suggestedProviders) {
|
|
912
|
+
suggestionsSummary += ` • ${provider} (${reason}, automatically pre-configured to $0.00)\n`;
|
|
495
913
|
}
|
|
496
914
|
}
|
|
497
915
|
if (suggestedModels.length > 0) {
|
|
@@ -518,11 +936,11 @@ function cmdConfig(positional) {
|
|
|
518
936
|
|
|
519
937
|
Examples:
|
|
520
938
|
{ "input": 15, "output": 75 } = $15 per 1M input, $75 per 1M output
|
|
521
|
-
{ "input": 0, "output": 0 } = Free (subscription or local model)
|
|
939
|
+
{ "input": 0, "output": 0 } = Free (subscription or local provider/model)
|
|
522
940
|
|
|
523
941
|
Common scenarios:
|
|
524
942
|
- GitHub Copilot, Cursor, etc. → Set provider to { input: 0, output: 0 }
|
|
525
|
-
-
|
|
943
|
+
- Ollama, LM Studio, localhost → Set provider to { input: 0, output: 0 }
|
|
526
944
|
- Direct API usage → Look up provider's pricing page
|
|
527
945
|
|
|
528
946
|
Where to find pricing:
|
|
@@ -570,11 +988,11 @@ function cmdConfig(positional) {
|
|
|
570
988
|
if (action === "get") {
|
|
571
989
|
const key = positional[2];
|
|
572
990
|
if (!key) {
|
|
573
|
-
|
|
991
|
+
failCli("Missing config key", "Usage: opencode-tokens config get <key>");
|
|
574
992
|
return;
|
|
575
993
|
}
|
|
576
994
|
if (!SETTABLE_KEYS[key]) {
|
|
577
|
-
|
|
995
|
+
failCli(`Unknown config key: ${key}`, `Available keys: ${Object.keys(SETTABLE_KEYS).join(", ")}`);
|
|
578
996
|
return;
|
|
579
997
|
}
|
|
580
998
|
const value = resolveConfigKey(config, key);
|
|
@@ -584,27 +1002,31 @@ function cmdConfig(positional) {
|
|
|
584
1002
|
if (action === "set") {
|
|
585
1003
|
const key = positional[2];
|
|
586
1004
|
const rawValue = positional[3];
|
|
587
|
-
if (!key
|
|
588
|
-
|
|
1005
|
+
if (!key) {
|
|
1006
|
+
failCli("Missing config key", "Usage: opencode-tokens config set <key> <value>");
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
if (!rawValue) {
|
|
1010
|
+
failCli("Missing config value", "Usage: opencode-tokens config set <key> <value>");
|
|
589
1011
|
return;
|
|
590
1012
|
}
|
|
591
1013
|
const spec = SETTABLE_KEYS[key];
|
|
592
1014
|
if (!spec) {
|
|
593
|
-
|
|
1015
|
+
failCli(`Unknown config key: ${key}`, `Available keys: ${Object.keys(SETTABLE_KEYS).join(", ")}`);
|
|
594
1016
|
return;
|
|
595
1017
|
}
|
|
596
1018
|
const value = parseConfigValue(rawValue);
|
|
597
1019
|
if (typeof value !== spec.type) {
|
|
598
|
-
|
|
1020
|
+
failCli(`Invalid type for ${key}: expected ${spec.type}, got ${typeof value}`);
|
|
599
1021
|
return;
|
|
600
1022
|
}
|
|
601
1023
|
if (typeof value === "number") {
|
|
602
1024
|
if (value < 0) {
|
|
603
|
-
|
|
1025
|
+
failCli(`Invalid value for ${key}: must be >= 0`);
|
|
604
1026
|
return;
|
|
605
1027
|
}
|
|
606
1028
|
if (spec.max !== undefined && value > spec.max) {
|
|
607
|
-
|
|
1029
|
+
failCli(`Invalid value for ${key}: must be <= ${spec.max}`);
|
|
608
1030
|
return;
|
|
609
1031
|
}
|
|
610
1032
|
}
|
|
@@ -615,12 +1037,12 @@ function cmdConfig(positional) {
|
|
|
615
1037
|
if (action === "unset") {
|
|
616
1038
|
const key = positional[2];
|
|
617
1039
|
if (!key) {
|
|
618
|
-
|
|
1040
|
+
failCli("Missing config key", "Usage: opencode-tokens config unset <key>");
|
|
619
1041
|
return;
|
|
620
1042
|
}
|
|
621
1043
|
const spec = SETTABLE_KEYS[key];
|
|
622
1044
|
if (!spec) {
|
|
623
|
-
|
|
1045
|
+
failCli(`Unknown config key: ${key}`, `Available keys: ${Object.keys(SETTABLE_KEYS).join(", ")}`);
|
|
624
1046
|
return;
|
|
625
1047
|
}
|
|
626
1048
|
applyConfigUnset(key);
|
|
@@ -628,8 +1050,7 @@ function cmdConfig(positional) {
|
|
|
628
1050
|
return;
|
|
629
1051
|
}
|
|
630
1052
|
if (action && action !== "show") {
|
|
631
|
-
|
|
632
|
-
console.log(` Usage: opencode-tokens config [show|init|generate|get|set|unset]\n`);
|
|
1053
|
+
failCli(`Unknown config action: ${action}`, "Usage: opencode-tokens config [show|init|generate|get|set|unset]");
|
|
633
1054
|
return;
|
|
634
1055
|
}
|
|
635
1056
|
// Show current config
|
|
@@ -733,10 +1154,48 @@ function saveConfig(raw) {
|
|
|
733
1154
|
// ============================================================================
|
|
734
1155
|
// Export
|
|
735
1156
|
// ============================================================================
|
|
1157
|
+
const EXPORT_FORMATS = ["csv", "json"];
|
|
1158
|
+
const EXPORT_PERIODS = ["today", "week", "month", "all"];
|
|
1159
|
+
function failCli(message, usage) {
|
|
1160
|
+
process.stderr.write(`\n Error: ${message}\n`);
|
|
1161
|
+
if (usage) {
|
|
1162
|
+
process.stderr.write(` ${usage}\n`);
|
|
1163
|
+
}
|
|
1164
|
+
process.stderr.write("\n");
|
|
1165
|
+
process.exitCode = 1;
|
|
1166
|
+
}
|
|
1167
|
+
function isExportFormat(format) {
|
|
1168
|
+
return EXPORT_FORMATS.includes(format);
|
|
1169
|
+
}
|
|
1170
|
+
function isExportPeriod(period) {
|
|
1171
|
+
return EXPORT_PERIODS.includes(period);
|
|
1172
|
+
}
|
|
736
1173
|
function cmdExport(flags) {
|
|
737
|
-
const
|
|
738
|
-
const
|
|
1174
|
+
const formatValue = flagValue(flags, "format");
|
|
1175
|
+
const periodValue = flagValue(flags, "period");
|
|
739
1176
|
const outputFile = flagValue(flags, "output");
|
|
1177
|
+
if (flags.has("format") && !formatValue) {
|
|
1178
|
+
failCli("Missing value for --format", "Usage: opencode-tokens export --format csv|json");
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
if (flags.has("period") && !periodValue) {
|
|
1182
|
+
failCli("Missing value for --period", "Usage: opencode-tokens export --period today|week|month|all");
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
if (flags.has("output") && !outputFile) {
|
|
1186
|
+
failCli("Missing value for --output", "Usage: opencode-tokens export --output <file>");
|
|
1187
|
+
return;
|
|
1188
|
+
}
|
|
1189
|
+
const format = formatValue ?? "csv";
|
|
1190
|
+
const period = periodValue ?? "all";
|
|
1191
|
+
if (!isExportFormat(format)) {
|
|
1192
|
+
failCli(`Unsupported export format: ${format}`, "Allowed formats: csv, json");
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
if (!isExportPeriod(period)) {
|
|
1196
|
+
failCli(`Unsupported export period: ${period}`, "Allowed periods: today, week, month, all");
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
740
1199
|
const now = new Date();
|
|
741
1200
|
let since;
|
|
742
1201
|
switch (period) {
|
|
@@ -780,7 +1239,14 @@ function cmdExport(flags) {
|
|
|
780
1239
|
output = `${[headers.join(","), ...rows].join("\n")}\n`;
|
|
781
1240
|
}
|
|
782
1241
|
if (outputFile) {
|
|
783
|
-
|
|
1242
|
+
try {
|
|
1243
|
+
writeFileSync(outputFile, output);
|
|
1244
|
+
}
|
|
1245
|
+
catch (error) {
|
|
1246
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
1247
|
+
failCli(`Failed to write export file: ${detail}`);
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
784
1250
|
console.log(`\n Exported ${entries.length} entries to ${outputFile}\n`);
|
|
785
1251
|
}
|
|
786
1252
|
else {
|
|
@@ -897,6 +1363,7 @@ function cmdHelp() {
|
|
|
897
1363
|
Commands:
|
|
898
1364
|
(default) Show usage statistics
|
|
899
1365
|
budget Show budget status (daily/weekly/monthly)
|
|
1366
|
+
doctor Diagnose plugin config, logs, and pricing fallbacks
|
|
900
1367
|
pricing Show built-in pricing table
|
|
901
1368
|
models Show your used models and their pricing status
|
|
902
1369
|
config Show/generate/modify configuration
|
|
@@ -909,7 +1376,8 @@ function cmdHelp() {
|
|
|
909
1376
|
month Show this month's usage
|
|
910
1377
|
all Show all-time usage (default)
|
|
911
1378
|
|
|
912
|
-
--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)
|
|
913
1381
|
|
|
914
1382
|
Export Options:
|
|
915
1383
|
--format csv (default) or json
|
|
@@ -935,8 +1403,10 @@ function cmdHelp() {
|
|
|
935
1403
|
|
|
936
1404
|
Examples:
|
|
937
1405
|
opencode-tokens # All-time summary
|
|
1406
|
+
opencode-tokens doctor # Diagnose setup and logs
|
|
938
1407
|
opencode-tokens today --by model # Today by model
|
|
939
|
-
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
|
|
940
1410
|
opencode-tokens trend --days 7 # 7-day cost trend
|
|
941
1411
|
opencode-tokens export --format csv # Export all data as CSV
|
|
942
1412
|
opencode-tokens config set budget.daily 10 # Set daily budget to $10
|
|
@@ -945,6 +1415,10 @@ function cmdHelp() {
|
|
|
945
1415
|
Costs are estimates from local logs; budgets are warnings, not enforcement.
|
|
946
1416
|
`);
|
|
947
1417
|
}
|
|
1418
|
+
const TREND_METRICS = ["cost", "tokens", "messages"];
|
|
1419
|
+
function isTrendMetric(metric) {
|
|
1420
|
+
return TREND_METRICS.includes(metric);
|
|
1421
|
+
}
|
|
948
1422
|
function getTrendValue(point, metric) {
|
|
949
1423
|
return metric === "tokens" ? point.tokens : metric === "messages" ? point.messages : point.cost;
|
|
950
1424
|
}
|
|
@@ -958,6 +1432,25 @@ function formatSignedTrendValue(value, metric) {
|
|
|
958
1432
|
function metricLabel(metric) {
|
|
959
1433
|
return metric === "tokens" ? "Token Trend" : metric === "messages" ? "Message Trend" : "Cost Trend";
|
|
960
1434
|
}
|
|
1435
|
+
function parsePositiveIntegerOption(flags, name, defaultValue, usage) {
|
|
1436
|
+
const value = flagValue(flags, name);
|
|
1437
|
+
if (flags.has(name) && !value) {
|
|
1438
|
+
failCli(`Missing value for --${name}`, usage);
|
|
1439
|
+
return undefined;
|
|
1440
|
+
}
|
|
1441
|
+
if (!value)
|
|
1442
|
+
return defaultValue;
|
|
1443
|
+
if (!/^\d+$/.test(value)) {
|
|
1444
|
+
failCli(`Invalid value for --${name}: ${value}`, usage);
|
|
1445
|
+
return undefined;
|
|
1446
|
+
}
|
|
1447
|
+
const parsed = Number.parseInt(value, 10);
|
|
1448
|
+
if (parsed <= 0) {
|
|
1449
|
+
failCli(`Invalid value for --${name}: ${value}`, usage);
|
|
1450
|
+
return undefined;
|
|
1451
|
+
}
|
|
1452
|
+
return parsed;
|
|
1453
|
+
}
|
|
961
1454
|
function buildLineRows(points, width, height) {
|
|
962
1455
|
const grid = Array.from({ length: height }, () => Array.from({ length: width }, () => " "));
|
|
963
1456
|
// 1. Calculate the exact integer row r[x] for every column x from 0 to width - 1
|
|
@@ -1064,9 +1557,22 @@ function buildTrendXAxis(points, chartPoints, chartWidth) {
|
|
|
1064
1557
|
};
|
|
1065
1558
|
}
|
|
1066
1559
|
function cmdTrend(flags) {
|
|
1067
|
-
const days =
|
|
1068
|
-
|
|
1069
|
-
|
|
1560
|
+
const days = parsePositiveIntegerOption(flags, "days", 30, "Usage: opencode-tokens trend --days <positive-integer>");
|
|
1561
|
+
if (days === undefined)
|
|
1562
|
+
return;
|
|
1563
|
+
const metricValue = flagValue(flags, "metric");
|
|
1564
|
+
if (flags.has("metric") && !metricValue) {
|
|
1565
|
+
failCli("Missing value for --metric", "Usage: opencode-tokens trend --metric cost|tokens|messages");
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
const metric = metricValue ?? "cost";
|
|
1569
|
+
if (!isTrendMetric(metric)) {
|
|
1570
|
+
failCli(`Unsupported trend metric: ${metric}`, "Allowed metrics: cost, tokens, messages");
|
|
1571
|
+
return;
|
|
1572
|
+
}
|
|
1573
|
+
const width = parsePositiveIntegerOption(flags, "width", 60, "Usage: opencode-tokens trend --width <positive-integer>");
|
|
1574
|
+
if (width === undefined)
|
|
1575
|
+
return;
|
|
1070
1576
|
const since = getStartOfDay(new Date(Date.now() - days * 86400000));
|
|
1071
1577
|
const entries = loadEntries(since);
|
|
1072
1578
|
if (entries.length === 0) {
|
|
@@ -1173,33 +1679,45 @@ function main() {
|
|
|
1173
1679
|
return;
|
|
1174
1680
|
}
|
|
1175
1681
|
const { command } = parsed;
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1682
|
+
if (isTopLevelCommand(command))
|
|
1683
|
+
switch (command) {
|
|
1684
|
+
case "budget":
|
|
1685
|
+
cmdBudget();
|
|
1686
|
+
return;
|
|
1687
|
+
case "doctor":
|
|
1688
|
+
cmdDoctor();
|
|
1689
|
+
return;
|
|
1690
|
+
case "pricing":
|
|
1691
|
+
cmdPricing();
|
|
1692
|
+
return;
|
|
1693
|
+
case "models":
|
|
1694
|
+
cmdModels();
|
|
1695
|
+
return;
|
|
1696
|
+
case "config":
|
|
1697
|
+
cmdConfig(parsed.positional);
|
|
1698
|
+
return;
|
|
1699
|
+
case "export":
|
|
1700
|
+
cmdExport(parsed.flags);
|
|
1701
|
+
return;
|
|
1702
|
+
case "trend":
|
|
1703
|
+
cmdTrend(parsed.flags);
|
|
1704
|
+
return;
|
|
1705
|
+
}
|
|
1196
1706
|
// Default: stats
|
|
1197
1707
|
let period = "all";
|
|
1198
|
-
const breakdown =
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1708
|
+
const breakdown = getStatsBreakdown(parsed.flags);
|
|
1709
|
+
if (process.exitCode)
|
|
1710
|
+
return;
|
|
1711
|
+
if (command) {
|
|
1712
|
+
if (!isStatsPeriod(command)) {
|
|
1713
|
+
failCli(`Unknown command or stats period: ${command}`, "Usage: opencode-tokens [today|week|month|all] [--by model|agent|provider|daily|session|raw-session|all]");
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
period = command;
|
|
1717
|
+
const extraArgs = parsed.positional.slice(1);
|
|
1718
|
+
if (extraArgs.length > 0) {
|
|
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]");
|
|
1720
|
+
return;
|
|
1203
1721
|
}
|
|
1204
1722
|
}
|
|
1205
1723
|
cmdStats(period, breakdown);
|