From 835619397b141e42191d7e838e62d0a3f6665a80 Mon Sep 17 00:00:00 2001 From: akshatshaw Date: Sat, 18 Apr 2026 01:26:22 +0530 Subject: [PATCH 1/2] feat: add detailed token tracking and cost estimation support for Claude models --- package-lock.json | 4 +- package.json | 4 +- src/adapters/claude.ts | 89 +++++++++++++++++-- src/aggregator.ts | 1 + src/cli.ts | 4 +- src/pricing.ts | 190 +++++++++++++++++++++++++++++++++++++++++ src/render/svg.ts | 75 +++++++++++++++- src/render/terminal.ts | 50 +++++++++++ src/types.ts | 6 ++ 9 files changed, 411 insertions(+), 12 deletions(-) create mode 100644 src/pricing.ts diff --git a/package-lock.json b/package-lock.json index 507ab0c..8d05543 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "tokenviz", - "version": "0.3.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "tokenviz", - "version": "0.3.0", + "version": "0.3.1", "license": "MIT", "dependencies": { "@resvg/resvg-js": "^2.6.2", diff --git a/package.json b/package.json index 484f5be..30e8f92 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tokenviz", - "version": "0.3.0", + "version": "0.3.1", "description": "GitHub-style contribution heatmap for your AI coding tool usage. Supports Claude Code, Codex, OpenCode & Cursor.", "type": "module", "bin": { @@ -46,7 +46,7 @@ "bugs": { "url": "https://github.com/harshkedia177/tokenviz/issues" }, - "author": "Harsh Kedia", + "author": "Harsh Kedia & Akshat Shaw", "dependencies": { "@resvg/resvg-js": "^2.6.2", "chalk": "^5.4.0", diff --git a/src/adapters/claude.ts b/src/adapters/claude.ts index 3c2a89a..a2ad2d9 100644 --- a/src/adapters/claude.ts +++ b/src/adapters/claude.ts @@ -4,6 +4,7 @@ import { join } from 'path'; import { claudePaths } from '../lib/paths.js'; import { poolMap } from '../lib/concurrency.js'; import type { DayData, AdapterResult } from '../types.js'; +import type { ModelTokenDetail } from '../pricing.js'; const FILE_CONCURRENCY = parseInt(process.env.BRAGGRID_CONCURRENCY ?? '', 10) || 32; @@ -40,6 +41,7 @@ interface DayAccum { inputTokens: number; outputTokens: number; cacheReadTokens: number; + cacheWriteTokens: number; models: Record; hours: Record; sessions: Set; @@ -68,6 +70,7 @@ interface ParsedRecord { inputTokens: number; outputTokens: number; cacheReadTokens: number; + cacheWriteTokens: number; model: string; hour: number; sessionId?: string; @@ -104,14 +107,15 @@ function parseLines(content: string, yearPrefix: string | null): Map, dayMap: Map = {}; const modelUsage: Record = {}; + const detailedModelUsage: Record = {}; let totalSessions = 0; let totalMessages = 0; let firstDate: string | null = null; @@ -228,6 +234,23 @@ async function loadFromJsonl(dirs: string[], yearFilter: number | null): Promise } } + // Build detailedModelUsage by proportional distribution of day-level in/out/cache + // across models based on their share of total tokens per day + for (const [, entry] of dayMap) { + const dayTotal = entry.inputTokens + entry.outputTokens + entry.cacheReadTokens; + if (dayTotal === 0) continue; + for (const [model, modelTotal] of Object.entries(entry.models)) { + const ratio = modelTotal / dayTotal; + if (!detailedModelUsage[model]) { + detailedModelUsage[model] = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }; + } + detailedModelUsage[model].inputTokens += Math.round((entry.inputTokens - entry.cacheWriteTokens) * ratio); + detailedModelUsage[model].outputTokens += Math.round(entry.outputTokens * ratio); + detailedModelUsage[model].cacheReadTokens += Math.round(entry.cacheReadTokens * ratio); + detailedModelUsage[model].cacheWriteTokens += Math.round(entry.cacheWriteTokens * ratio); + } + } + return { tool: 'claude', days, @@ -236,6 +259,7 @@ async function loadFromJsonl(dirs: string[], yearFilter: number | null): Promise totalMessages, firstSessionDate: firstDate, modelUsage, + detailedModelUsage, avgSessionSeconds: 0, }; } @@ -249,6 +273,7 @@ function loadFromCache(dirs: string[], yearFilter: number | null): AdapterResult const days: DayData[] = []; const modelUsage: Record = {}; + const detailedModelUsage: Record = {}; let firstDate: string | null = null; for (const [date, models] of Object.entries(costDays)) { @@ -277,6 +302,17 @@ function loadFromCache(dirs: string[], yearFilter: number | null): AdapterResult for (const [model, tokens] of Object.entries(dayModels)) { modelUsage[model] = (modelUsage[model] || 0) + tokens; } + + // Track detailed per-model token breakdown from cost cache + for (const [modelId, usage] of Object.entries(models)) { + if (!detailedModelUsage[modelId]) { + detailedModelUsage[modelId] = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }; + } + detailedModelUsage[modelId].inputTokens += usage.input || 0; + detailedModelUsage[modelId].outputTokens += usage.output || 0; + detailedModelUsage[modelId].cacheReadTokens += usage.cacheRead || 0; + detailedModelUsage[modelId].cacheWriteTokens += usage.cacheWrite || 0; + } } if (days.length === 0) return null; @@ -289,6 +325,7 @@ function loadFromCache(dirs: string[], yearFilter: number | null): AdapterResult totalMessages: 0, firstSessionDate: firstDate, modelUsage, + detailedModelUsage, avgSessionSeconds: 0, }; } @@ -302,6 +339,7 @@ function loadFromStatsCache(dirs: string[], yearFilter: number | null): AdapterR const days: DayData[] = []; const modelUsage: Record = {}; + const detailedModelUsage: Record = {}; let firstDate: string | null = null; for (const [date, entry] of Object.entries(statsCache)) { @@ -322,6 +360,15 @@ function loadFromStatsCache(dirs: string[], yearFilter: number | null): AdapterR cacheReadTokens += cacheRead; const modelTotal = input + cacheRead + output; dayModels[modelId] = (dayModels[modelId] || 0) + modelTotal; + + // Track detailed per-model token breakdown + if (!detailedModelUsage[modelId]) { + detailedModelUsage[modelId] = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }; + } + detailedModelUsage[modelId].inputTokens += usage.inputTokens || 0; + detailedModelUsage[modelId].outputTokens += output; + detailedModelUsage[modelId].cacheReadTokens += cacheRead; + detailedModelUsage[modelId].cacheWriteTokens += usage.cacheCreationTokens || 0; } if (inputTokens + outputTokens + cacheReadTokens === 0) continue; @@ -344,16 +391,48 @@ function loadFromStatsCache(dirs: string[], yearFilter: number | null): AdapterR totalMessages: 0, firstSessionDate: firstDate, modelUsage, + detailedModelUsage, avgSessionSeconds: 0, }; } +/** + * Enrich detailedModelUsage from stats-cache.json's top-level modelUsage, + * which has authoritative per-model token breakdowns including cacheCreationInputTokens. + */ +function enrichFromStatsCache(result: AdapterResult, dirs: string[]): void { + const raw = loadJson(dirs, 'stats-cache.json'); + if (!raw) return; + + const modelUsage = raw.modelUsage as Record> | undefined; + if (!modelUsage) return; + + // Only replace if we have authoritative data + const enriched: Record = {}; + for (const [model, usage] of Object.entries(modelUsage)) { + enriched[model] = { + inputTokens: usage.inputTokens || 0, + outputTokens: usage.outputTokens || 0, + cacheReadTokens: usage.cacheReadInputTokens || 0, + cacheWriteTokens: usage.cacheCreationInputTokens || 0, + }; + } + + if (Object.keys(enriched).length > 0) { + result.detailedModelUsage = enriched; + } +} + export async function load(yearFilter: number | null): Promise { const dirs = claudePaths(); if (!dirs.length) return null; const jsonlResult = await loadFromJsonl(dirs, yearFilter); - if (jsonlResult) return jsonlResult; + if (jsonlResult) { + // Enrich with stats-cache.json for accurate per-model token breakdowns + enrichFromStatsCache(jsonlResult, dirs); + return jsonlResult; + } // Try stats-cache.json (more detailed than readout-cost-cache) const statsCacheResult = loadFromStatsCache(dirs, yearFilter); diff --git a/src/aggregator.ts b/src/aggregator.ts index fed5de1..6752af1 100644 --- a/src/aggregator.ts +++ b/src/aggregator.ts @@ -63,6 +63,7 @@ function toAggregatedData(name: string, result: AdapterResult): AggregatedData { totalMessages: result.totalMessages || 0, firstSessionDate: result.firstSessionDate || null, modelUsage, + detailedModelUsage: result.detailedModelUsage || {}, avgSessionSeconds: result.avgSessionSeconds || 0, }; } diff --git a/src/cli.ts b/src/cli.ts index 84b011f..be9eaa5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -40,6 +40,7 @@ interface CLIOptions { json?: boolean; listThemes?: boolean; verbose?: boolean; + cost?: boolean; } program @@ -66,6 +67,7 @@ program .option('--json', 'Output raw stats as JSON') .option('--list-themes', 'Show all available themes') .option('--verbose', 'Show debug output for troubleshooting') + .option('--cost', 'Show estimated cost breakdown by model') .action(async (opts: CLIOptions) => { try { if (opts.listThemes) { @@ -114,7 +116,7 @@ program return; } - const renderOpts = { theme: opts.theme, user: opts.user, year: opts.year }; + const renderOpts = { theme: opts.theme, user: opts.user, year: opts.year, showCost: opts.cost }; renderTerminal(panels, renderOpts); diff --git a/src/pricing.ts b/src/pricing.ts new file mode 100644 index 0000000..613f86c --- /dev/null +++ b/src/pricing.ts @@ -0,0 +1,190 @@ +/** + * Model pricing table (per million tokens, USD). + * Prices are best-effort estimates based on public pricing pages. + * Unknown models fall back to a conservative default. + */ + +export interface ModelPricing { + inputPerM: number; + outputPerM: number; + cacheReadPerM: number; + cacheWritePerM: number; +} + +/** + * Per-model token breakdown for cost calculation. + */ +export interface ModelTokenDetail { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; +} + +export interface ModelCost { + model: string; + inputCost: number; + outputCost: number; + cacheReadCost: number; + cacheWriteCost: number; + totalCost: number; + tokens: ModelTokenDetail; +} + +export interface CostSummary { + totalCost: number; + modelCosts: ModelCost[]; +} + +// Pricing per 1M tokens (USD) +// Source: https://platform.claude.com/docs/en/about-claude/pricing +// https://openai.com/api/pricing/ +const PRICING: Record = { + // === Claude Models (official pricing from platform.claude.com) === + + // Opus 4.7 — $5 input, $25 output + 'claude-opus-4-7': { inputPerM: 5, outputPerM: 25, cacheReadPerM: 0.50, cacheWritePerM: 6.25 }, + + // Opus 4.6 — $5 input, $25 output + 'claude-opus-4-6': { inputPerM: 5, outputPerM: 25, cacheReadPerM: 0.50, cacheWritePerM: 6.25 }, + + // Opus 4.5 — $5 input, $25 output + 'claude-opus-4-5': { inputPerM: 5, outputPerM: 25, cacheReadPerM: 0.50, cacheWritePerM: 6.25 }, + + // Opus 4.1 — $15 input, $75 output + 'claude-opus-4-1': { inputPerM: 15, outputPerM: 75, cacheReadPerM: 1.50, cacheWritePerM: 18.75 }, + + // Opus 4 — $15 input, $75 output + 'claude-opus-4-20250514': { inputPerM: 15, outputPerM: 75, cacheReadPerM: 1.50, cacheWritePerM: 18.75 }, + 'claude-opus-4': { inputPerM: 15, outputPerM: 75, cacheReadPerM: 1.50, cacheWritePerM: 18.75 }, + + // Sonnet 4.6 — $3 input, $15 output + 'claude-sonnet-4-6': { inputPerM: 3, outputPerM: 15, cacheReadPerM: 0.30, cacheWritePerM: 3.75 }, + + // Sonnet 4.5 — $3 input, $15 output + 'claude-sonnet-4-5': { inputPerM: 3, outputPerM: 15, cacheReadPerM: 0.30, cacheWritePerM: 3.75 }, + + // Sonnet 4 — $3 input, $15 output + 'claude-sonnet-4-20250514': { inputPerM: 3, outputPerM: 15, cacheReadPerM: 0.30, cacheWritePerM: 3.75 }, + 'claude-sonnet-4': { inputPerM: 3, outputPerM: 15, cacheReadPerM: 0.30, cacheWritePerM: 3.75 }, + + // Sonnet 3.7 (deprecated) — $3 input, $15 output + 'claude-3-7-sonnet': { inputPerM: 3, outputPerM: 15, cacheReadPerM: 0.30, cacheWritePerM: 3.75 }, + + // Haiku 4.5 — $1 input, $5 output + 'claude-haiku-4-5': { inputPerM: 1, outputPerM: 5, cacheReadPerM: 0.10, cacheWritePerM: 1.25 }, + + // Haiku 3.5 — $0.80 input, $4 output + 'claude-haiku-4-5-20251001': { inputPerM: 0.80, outputPerM: 4, cacheReadPerM: 0.08, cacheWritePerM: 1.0 }, + 'claude-3-5-haiku-20241022': { inputPerM: 0.80, outputPerM: 4, cacheReadPerM: 0.08, cacheWritePerM: 1.0 }, + 'claude-3-5-haiku': { inputPerM: 0.80, outputPerM: 4, cacheReadPerM: 0.08, cacheWritePerM: 1.0 }, + + // Sonnet 3.5 (deprecated) — $3 input, $15 output + 'claude-3-5-sonnet-20241022': { inputPerM: 3, outputPerM: 15, cacheReadPerM: 0.30, cacheWritePerM: 3.75 }, + 'claude-3-5-sonnet-20240620': { inputPerM: 3, outputPerM: 15, cacheReadPerM: 0.30, cacheWritePerM: 3.75 }, + + // Opus 3 (deprecated) — $15 input, $75 output + 'claude-3-opus-20240229': { inputPerM: 15, outputPerM: 75, cacheReadPerM: 1.50, cacheWritePerM: 18.75 }, + + // Haiku 3 — $0.25 input, $1.25 output + 'claude-3-haiku-20240307': { inputPerM: 0.25, outputPerM: 1.25, cacheReadPerM: 0.03, cacheWritePerM: 0.30 }, + + // === OpenAI Models === + 'gpt-4o': { inputPerM: 2.5, outputPerM: 10, cacheReadPerM: 1.25, cacheWritePerM: 2.5 }, + 'gpt-4o-2024-08-06': { inputPerM: 2.5, outputPerM: 10, cacheReadPerM: 1.25, cacheWritePerM: 2.5 }, + 'gpt-4o-mini': { inputPerM: 0.15, outputPerM: 0.60, cacheReadPerM: 0.075, cacheWritePerM: 0.15 }, + 'gpt-4-turbo': { inputPerM: 10, outputPerM: 30, cacheReadPerM: 5, cacheWritePerM: 10 }, + 'o1': { inputPerM: 15, outputPerM: 60, cacheReadPerM: 7.5, cacheWritePerM: 15 }, + 'o1-mini': { inputPerM: 3, outputPerM: 12, cacheReadPerM: 1.5, cacheWritePerM: 3 }, + 'o3': { inputPerM: 10, outputPerM: 40, cacheReadPerM: 2.5, cacheWritePerM: 10 }, + 'o3-mini': { inputPerM: 1.10, outputPerM: 4.40, cacheReadPerM: 0.55, cacheWritePerM: 1.10 }, + 'o4-mini': { inputPerM: 1.10, outputPerM: 4.40, cacheReadPerM: 0.55, cacheWritePerM: 1.10 }, + 'codex-mini-latest': { inputPerM: 1.50, outputPerM: 6, cacheReadPerM: 0.75, cacheWritePerM: 1.50 }, +}; + +// Conservative default for unknown models +const DEFAULT_PRICING: ModelPricing = { + inputPerM: 3, + outputPerM: 15, + cacheReadPerM: 0.30, + cacheWritePerM: 3.75, +}; + +/** + * Look up pricing for a model. Uses prefix matching for versioned model names. + */ +function getPricing(model: string): ModelPricing { + // Exact match + if (PRICING[model]) return PRICING[model]; + + // Prefix match (e.g. "claude-opus-4-6-20260101" → "claude-opus-4-6") + for (const key of Object.keys(PRICING)) { + if (model.startsWith(key)) return PRICING[key]; + } + + // Substring match (e.g. "anthropic/claude-3-5-sonnet" → match "claude-3-5-sonnet-*") + for (const key of Object.keys(PRICING)) { + if (model.includes(key) || key.includes(model)) return PRICING[key]; + } + + return DEFAULT_PRICING; +} + +/** + * Calculate cost for specific token counts and a model. + */ +function calculateModelCost(model: string, tokens: ModelTokenDetail): ModelCost { + const pricing = getPricing(model); + + const inputCost = (tokens.inputTokens / 1_000_000) * pricing.inputPerM; + const outputCost = (tokens.outputTokens / 1_000_000) * pricing.outputPerM; + const cacheReadCost = (tokens.cacheReadTokens / 1_000_000) * pricing.cacheReadPerM; + const cacheWriteCost = (tokens.cacheWriteTokens / 1_000_000) * pricing.cacheWritePerM; + + return { + model, + inputCost, + outputCost, + cacheReadCost, + cacheWriteCost, + totalCost: inputCost + outputCost + cacheReadCost + cacheWriteCost, + tokens, + }; +} + +/** + * Compute cost summary from a detailed model usage map. + */ +export function computeCostSummary( + detailedModelUsage: Record, +): CostSummary { + const modelCosts: ModelCost[] = []; + + for (const [model, tokens] of Object.entries(detailedModelUsage)) { + const cost = calculateModelCost(model, tokens); + if (cost.totalCost > 0) { + modelCosts.push(cost); + } + } + + // Sort by cost descending + modelCosts.sort((a, b) => b.totalCost - a.totalCost); + + const totalCost = modelCosts.reduce((sum, mc) => sum + mc.totalCost, 0); + + return { totalCost, modelCosts }; +} + +/** + * Format a USD cost value for display. + */ +export function formatCost(cost: number): string { + if (cost >= 1000) return `$${(cost / 1000).toFixed(1)}K`; + if (cost >= 100) return `$${cost.toFixed(0)}`; + if (cost >= 10) return `$${cost.toFixed(1)}`; + if (cost >= 1) return `$${cost.toFixed(2)}`; + if (cost >= 0.01) return `$${cost.toFixed(2)}`; + if (cost >= 0.001) return `$${cost.toFixed(3)}`; + if (cost === 0) return '$0.00'; + return `$${cost.toFixed(4)}`; +} diff --git a/src/render/svg.ts b/src/render/svg.ts index 4c3e93f..40cb820 100644 --- a/src/render/svg.ts +++ b/src/render/svg.ts @@ -1,5 +1,6 @@ import { getTheme } from '../themes.js'; import { formatTokens } from '../stats.js'; +import { computeCostSummary, formatCost } from '../pricing.js'; import { MONTH_NAMES, DAY_LABELS, TOOL_COLORS, buildGrid, extractDisplayStats, computeGlobalTotals } from './shared.js'; import type { ToolPanel, Theme, GridResult, RenderOptions } from '../types.js'; @@ -52,8 +53,9 @@ function renderPanel( theme: Theme, isMultiTool: boolean, showMonthLabels: boolean = true, + showCost: boolean = false, ): number { - const { stats, capabilities, tool } = panel; + const { stats, capabilities, tool, data } = panel; const ds = extractDisplayStats(stats); const { grid, weekMonths, maxTokens } = gridResult; const gridHeight = 7 * step - CELL_GAP; @@ -151,6 +153,74 @@ function renderPanel( y += 30; } + // Cost breakdown section + if (showCost) { + const costSummary = computeCostSummary(data.detailedModelUsage); + + if (costSummary.modelCosts.length > 0) { + y += 10; + parts.push(``); + y += 18; + + // Use SVG circle + "$" instead of emoji (resvg-js can't render emojis) + const iconX = MARGIN.left; + const iconCY = y + 7; + parts.push(``); + parts.push(`$`); + parts.push(`Estimated Cost`); + y += 24; + + // Column layout for cost table + const costCols = [ + { label: 'MODEL', width: gridWidth * 0.30 }, + { label: 'INPUT', width: gridWidth * 0.14 }, + { label: 'OUTPUT', width: gridWidth * 0.14 }, + { label: 'CACHE READ', width: gridWidth * 0.14 }, + { label: 'CACHE WRITE', width: gridWidth * 0.14 }, + { label: 'TOTAL', width: gridWidth * 0.14 }, + ]; + + // Headers + let colX = MARGIN.left; + for (const col of costCols) { + parts.push(`${escapeXml(col.label)}`); + colX += col.width; + } + y += 16; + + // Rows + for (const mc of costSummary.modelCosts) { + colX = MARGIN.left; + parts.push(`${escapeXml(truncate(mc.model, 25))}`); + colX += costCols[0].width; + parts.push(`${escapeXml(formatCost(mc.inputCost))}`); + colX += costCols[1].width; + parts.push(`${escapeXml(formatCost(mc.outputCost))}`); + colX += costCols[2].width; + parts.push(`${escapeXml(formatCost(mc.cacheReadCost))}`); + colX += costCols[3].width; + parts.push(`${escapeXml(formatCost(mc.cacheWriteCost))}`); + colX += costCols[4].width; + parts.push(`${escapeXml(formatCost(mc.totalCost))}`); + y += 18; + } + + // Total row + y += 4; + parts.push(``); + y += 10; + colX = MARGIN.left; + parts.push(`TOTAL`); + colX = MARGIN.left + costCols[0].width + costCols[1].width + costCols[2].width + costCols[3].width + costCols[4].width; + parts.push(`${escapeXml(formatCost(costSummary.totalCost))}`); + y += 20; + + // Disclaimer + parts.push(`* Estimates based on public API pricing. Actual costs may vary.`); + y += 14; + } + } + return y - yOffset; } @@ -159,6 +229,7 @@ export function renderSVG(panels: ToolPanel[], opts: RenderOptions = {}): string const user = opts.user ? truncate(opts.user, 24) : null; const fontFamily = "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"; const isMultiTool = panels.length > 1; + const showCost = opts.showCost ?? false; const step = CELL_SIZE + CELL_GAP; const panelGrids = panels.map(p => buildGrid(p.data, opts.year)); @@ -233,7 +304,7 @@ export function renderSVG(panels: ToolPanel[], opts: RenderOptions = {}): string for (let i = 0; i < panels.length; i++) { if (i > 0) y += PANEL_GAP; const showMonths = i === 0; - const panelHeight = renderPanel(parts, panels[i], panelGrids[i], y, gridWidth, numWeeks, step, theme, isMultiTool, showMonths); + const panelHeight = renderPanel(parts, panels[i], panelGrids[i], y, gridWidth, numWeeks, step, theme, isMultiTool, showMonths, showCost); y += panelHeight; } diff --git a/src/render/terminal.ts b/src/render/terminal.ts index 1cb627c..bdee1fd 100644 --- a/src/render/terminal.ts +++ b/src/render/terminal.ts @@ -1,6 +1,7 @@ import chalk from 'chalk'; import { getTheme, isDark } from '../themes.js'; import { formatTokens } from '../stats.js'; +import { computeCostSummary, formatCost } from '../pricing.js'; import { MONTH_NAMES, DAY_LABELS, TOOL_COLORS, buildGrid, extractDisplayStats, computeGlobalTotals } from './shared.js'; import type { ToolPanel, Theme, RenderOptions } from '../types.js'; @@ -154,6 +155,55 @@ export function renderTerminal(panels: ToolPanel[], opts: RenderOptions = {}): v } lines.push(''); + // Cost breakdown (when --cost flag is used) + if (opts.showCost) { + const costSummary = computeCostSummary(data.detailedModelUsage); + + if (costSummary.modelCosts.length > 0) { + lines.push(lbl.dim(' ' + '\u2500'.repeat(Math.min(gridCharWidth, 100)))); + lines.push(''); + lines.push(txt.bold(' \uD83D\uDCB0 ESTIMATED COST')); + lines.push(''); + + const MODEL_COL = 30; + const COST_COL = 14; + + lines.push( + ' ' + + lbl('MODEL'.padEnd(MODEL_COL)) + + lbl('INPUT'.padEnd(COST_COL)) + + lbl('OUTPUT'.padEnd(COST_COL)) + + lbl('CACHE READ'.padEnd(COST_COL)) + + lbl('CACHE WRITE'.padEnd(COST_COL)) + + lbl('TOTAL'), + ); + + for (const mc of costSummary.modelCosts) { + const modelName = mc.model.length > MODEL_COL - 2 + ? mc.model.slice(0, MODEL_COL - 3) + '\u2026' + : mc.model; + lines.push( + ' ' + + txt(modelName.padEnd(MODEL_COL)) + + txt(formatCost(mc.inputCost).padEnd(COST_COL)) + + txt(formatCost(mc.outputCost).padEnd(COST_COL)) + + txt(formatCost(mc.cacheReadCost).padEnd(COST_COL)) + + txt(formatCost(mc.cacheWriteCost).padEnd(COST_COL)) + + txt.bold(formatCost(mc.totalCost)), + ); + } + + lines.push(''); + lines.push(' ' + lbl('TOTAL'.padEnd(MODEL_COL)) + + ''.padEnd(COST_COL * 4) + + chalk.greenBright.bold(formatCost(costSummary.totalCost))); + lines.push(''); + + lines.push(lbl.dim(' * Estimates based on public API pricing. Actual costs may vary.')); + lines.push(''); + } + } + if (p < panels.length - 1) { lines.push(''); } diff --git a/src/types.ts b/src/types.ts index bc6d0fc..1963e55 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,6 @@ +import type { ModelTokenDetail } from './pricing.js'; +export type { ModelTokenDetail }; + export interface DayData { date: string; inputTokens: number; @@ -17,6 +20,7 @@ export interface AdapterResult { totalMessages: number; firstSessionDate: string | null; modelUsage: Record; + detailedModelUsage?: Record; avgSessionSeconds: number; } @@ -33,6 +37,7 @@ export interface AggregatedData { totalMessages: number; firstSessionDate: string | null; modelUsage: Record; + detailedModelUsage: Record; avgSessionSeconds: number; } @@ -104,4 +109,5 @@ export interface RenderOptions { theme?: string; user?: string; year?: number; + showCost?: boolean; } From 5cda1c9bb4d4b0e80d31ff6a3fc8c7cd46ab48e1 Mon Sep 17 00:00:00 2001 From: akshatshaw Date: Sat, 18 Apr 2026 01:29:34 +0530 Subject: [PATCH 2/2] docs: add cost analysis documentation and update README tables for readability --- README.md | 79 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 51 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index ad3d413..3b3dad3 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,12 @@ That's it. It reads your local data, renders a heatmap in your terminal, and exp ## Supported Tools -| Tool | Data Source | What's Tracked | -|------|-----------|----------------| -| **Claude Code** | `~/.claude/stats-cache.json` | Tokens, models, sessions, costs | -| **Codex CLI** | `~/.codex/sessions/*.jsonl` | Tokens, models, session durations | -| **OpenCode** | `~/.local/share/opencode/` | Tokens, models, messages | -| **Cursor** | Cursor API + local `state.vscdb` | Tokens, models, usage events | +| Tool | Data Source | What's Tracked | +| --------------- | -------------------------------- | --------------------------------- | +| **Claude Code** | `~/.claude/stats-cache.json` | Tokens, models, sessions, costs | +| **Codex CLI** | `~/.codex/sessions/*.jsonl` | Tokens, models, session durations | +| **OpenCode** | `~/.local/share/opencode/` | Tokens, models, messages | +| **Cursor** | Cursor API + local `state.vscdb` | Tokens, models, usage events | tokenviz auto-detects which tools you have installed. No configuration needed. @@ -79,6 +79,22 @@ A full-color contribution grid right in your terminal, with: - Average session length - Per-tool usage panels +### Cost Analysis + +See exactly how much your AI usage costs with `--cost`: + +``` + 💰 ESTIMATED COST + + MODEL INPUT OUTPUT CACHE READ CACHE WRITE TOTAL + claude-opus-4-6 $0.37 $5.65 $33.9 $59.7 $99.5 + claude-sonnet-4-6 $0.03 $1.58 $4.06 $5.48 $11.2 + + TOTAL $116 +``` + +Breaks down cost per model with input, output, cache read, and cache write columns. Pricing is based on official published API rates. + ### Shareable PNG/SVG Automatically exports a high-quality image you can share on Twitter, LinkedIn, your blog, or anywhere. @@ -125,6 +141,11 @@ tokenviz --copy # Dump raw stats as JSON (for scripting) tokenviz --json +# Show estimated cost breakdown by model +tokenviz --cost +tokenviz --claude --cost +tokenviz --cost --no-export + # See all themes tokenviz --list-themes ``` @@ -133,13 +154,13 @@ tokenviz --list-themes 10 built-in themes — 5 light, 5 dark: -| Dark | Light | -|------|-------| -| `dark-ember` | `green` (default) | -| `dark-green` | `purple` | -| `dark-purple` | `blue` | -| `dark-blue` | `amber` | -| `dark-mono` | `mono` | +| Dark | Light | +| ------------- | ----------------- | +| `dark-ember` | `green` (default) | +| `dark-green` | `purple` | +| `dark-purple` | `blue` | +| `dark-blue` | `amber` | +| `dark-mono` | `mono` | ```bash tokenviz --theme dark-purple @@ -148,21 +169,22 @@ tokenviz --theme amber ## Options -| Flag | Description | Default | -|------|-------------|---------| -| `--user ` | Username shown on the heatmap | — | -| `--claude` | Include only Claude Code data | — | -| `--codex` | Include only Codex data | — | -| `--opencode` | Include only OpenCode data | — | -| `--cursor` | Include only Cursor data | — | -| `--theme ` | Color theme | `green` | -| `--export ` | Export format: `png` or `svg` | `png` | -| `--no-export` | Skip file export, terminal only | — | -| `--out ` | Custom output file path | `tokenviz.png` | -| `--copy` | Copy PNG to clipboard after export | — | -| `--year ` | Filter to a specific year | last 365 days | -| `--json` | Output raw stats as JSON | — | -| `--list-themes` | Show all available themes | — | +| Flag | Description | Default | +| ---------------- | -------------------------------------- | -------------- | +| `--user ` | Username shown on the heatmap | — | +| `--claude` | Include only Claude Code data | — | +| `--codex` | Include only Codex data | — | +| `--opencode` | Include only OpenCode data | — | +| `--cursor` | Include only Cursor data | — | +| `--theme ` | Color theme | `green` | +| `--export ` | Export format: `png` or `svg` | `png` | +| `--no-export` | Skip file export, terminal only | — | +| `--out ` | Custom output file path | `tokenviz.png` | +| `--copy` | Copy PNG to clipboard after export | — | +| `--year ` | Filter to a specific year | last 365 days | +| `--json` | Output raw stats as JSON | — | +| `--cost` | Show estimated cost breakdown by model | — | +| `--list-themes` | Show all available themes | — | ## How It Works @@ -183,6 +205,7 @@ tokenviz reads **locally stored data** from your AI coding tools. It never sends **Q: I don't see any data?** Make sure you've actually used one of the supported tools. tokenviz reads from the default data locations — if you've customized paths, set the environment variable: + - `CLAUDE_CONFIG_DIR` for Claude Code - `CODEX_HOME` for Codex CLI - `OPENCODE_DATA_DIR` for OpenCode