From aa64d739be2da55930f30ee94eaa45cce6e68b2e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 7 Jul 2026 02:25:45 +0000 Subject: [PATCH] feat: model-aware pricing with per-model cost breakdown Cost was previously computed with a single flat rate (Sonnet-based claudeStatus.pricing.* defaults) regardless of which model produced each entry, overestimating Haiku usage ~3x and underestimating Opus usage up to 5x. Each JSONL entry carries message.model, so price entries by it. - jsonlReader: built-in per-model rate table (Fable/Mythos 5, legacy Opus <=4.1, Opus 4.5+, Haiku, Sonnet) with resolveModelPricing() and entryCost(); UsageEntry gains a model field - New setting claudeStatus.pricing.useModelPricing (default true); unknown models and disabled mode fall back to manual pricing.* rates - All cost consumers (readAllUsage, projectCost, prediction, heatmap) now take CostOptions from config; prediction and heatmap previously ignored user pricing settings entirely and always used defaults - AggregatedUsage gains costByModel5h; dashboard token breakdown lists per-model 5h cost - l10n: EN/JA/ZH for the setting description and dashboard label - Docs: DATA.md pricing tables, SETTINGS.md, README (EN/JA/ZH), CHANGELOG - Tests: rate resolution, fallback, entryCost toggle, model parsing Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012ZLCLQeudf1LQBnBSyyr4f --- CHANGELOG.md | 13 +++++++ README.ja.md | 1 + README.md | 1 + README.zh.md | 1 + docs/DATA.md | 25 ++++++++++++- docs/SETTINGS.md | 8 +++- l10n/bundle.l10n.ja.json | 1 + l10n/bundle.l10n.zh-cn.json | 1 + package.json | 5 +++ package.nls.ja.json | 1 + package.nls.json | 1 + package.nls.zh-cn.json | 1 + src/config.ts | 13 ++++++- src/data/dataManager.ts | 8 ++-- src/data/jsonlReader.ts | 57 +++++++++++++++++++++++++++- src/data/prediction.ts | 9 +++-- src/data/projectCost.ts | 10 ++--- src/test/suite/jsonlReader.test.ts | 60 +++++++++++++++++++++++++++++- src/test/suite/statusBar.test.ts | 1 + src/webview/heatmap.ts | 9 +++-- src/webview/panel.ts | 15 ++++++++ 21 files changed, 219 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e748bc0..6b8ddca 100755 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **Model-aware pricing** (`claudeStatus.pricing.useModelPricing`, default `true`) — + cost is now computed per JSONL entry using built-in rates for the entry's + `message.model` (Opus 4.5+ $5/$25, legacy Opus ≤4.1 $15/$75, Sonnet $3/$15, + Haiku $1/$5, Fable/Mythos 5 $10/$50 per 1M tokens, with matching cache rates). + Previously every entry was priced at the flat Sonnet-based `claudeStatus.pricing.*` + rates, overestimating Haiku usage 3× and underestimating Opus usage up to 5×. + Unrecognized models — and all entries when the setting is disabled — fall back + to the manual `claudeStatus.pricing.*` rates. +- **Per-model cost breakdown in dashboard** — the token breakdown section now lists + the 5h-window cost per model (e.g. `sonnet-4-5: $0.42`, `haiku-4-5: $0.03`). + ### Performance - **Skip stale JSONL files by mtime** — the 60-second status bar poll and the diff --git a/README.ja.md b/README.ja.md index 715a308..a2ba651 100644 --- a/README.ja.md +++ b/README.ja.md @@ -224,6 +224,7 @@ npm run package # → vscode-claude-status-*.vsix | `claudeStatus.heatmap.days` | `30 \| 60 \| 90` | `90` | 使用ヒートマップに表示する日数 | | `claudeStatus.credentials.path` | `string \| null` | `null` | カスタム認証情報ファイルパス | | `claudeStatus.claudeProvider` | `"auto"` \| `"claude-ai"` \| `"aws-bedrock"` \| `"api-key"` | `"auto"` | プロバイダータイプ(自動検出または明示的指定) | +| `claudeStatus.pricing.useModelPricing` | `boolean` | `true` | モデル別の組み込み料金(Opus/Sonnet/Haiku)で正確なコストを計算。下記の手動料金はフォールバック | | `claudeStatus.pricing.inputPerMillion` | `number` | `3.00` | 入力トークン 1M あたりの USD 単価 | | `claudeStatus.pricing.outputPerMillion` | `number` | `15.00` | 出力トークン 1M あたりの USD 単価 | | `claudeStatus.pricing.cacheReadPerMillion` | `number` | `0.30` | キャッシュ読み取りトークン 1M あたりの USD 単価 | diff --git a/README.md b/README.md index b1fe3af..547a4b7 100644 --- a/README.md +++ b/README.md @@ -244,6 +244,7 @@ All settings are under the `claudeStatus` namespace in VS Code Settings. | `claudeStatus.heatmap.days` | `30 \| 60 \| 90` | `90` | Days shown in usage heatmap | | `claudeStatus.credentials.path` | `string \| null` | `null` | Custom credentials file path | | `claudeStatus.claudeProvider` | `"auto"` \| `"claude-ai"` \| `"aws-bedrock"` \| `"api-key"` | `"auto"` | Provider type (auto-detect or explicit) | +| `claudeStatus.pricing.useModelPricing` | `boolean` | `true` | Use built-in per-model rates (Opus/Sonnet/Haiku) for accurate costs; manual rates below are the fallback | | `claudeStatus.pricing.inputPerMillion` | `number` | `3.00` | USD per 1M input tokens | | `claudeStatus.pricing.outputPerMillion` | `number` | `15.00` | USD per 1M output tokens | | `claudeStatus.pricing.cacheReadPerMillion` | `number` | `0.30` | USD per 1M cache-read tokens | diff --git a/README.zh.md b/README.zh.md index acc580b..8e21333 100644 --- a/README.zh.md +++ b/README.zh.md @@ -219,6 +219,7 @@ npm run package # → vscode-claude-status-*.vsix | `claudeStatus.heatmap.days` | `30 \| 60 \| 90` | `90` | 使用热力图显示的天数 | | `claudeStatus.credentials.path` | `string \| null` | `null` | 自定义凭证文件路径 | | `claudeStatus.claudeProvider` | `"auto"` \| `"claude-ai"` \| `"aws-bedrock"` \| `"api-key"` | `"auto"` | 提供商类型(自动检测或显式指定) | +| `claudeStatus.pricing.useModelPricing` | `boolean` | `true` | 使用内置按模型费率(Opus/Sonnet/Haiku)以获得准确成本;下方手动费率为回退值 | | `claudeStatus.pricing.inputPerMillion` | `number` | `3.00` | 每百万输入令牌的 USD 单价 | | `claudeStatus.pricing.outputPerMillion` | `number` | `15.00` | 每百万输出令牌的 USD 单价 | | `claudeStatus.pricing.cacheReadPerMillion` | `number` | `0.30` | 每百万缓存读取令牌的 USD 单价 | diff --git a/docs/DATA.md b/docs/DATA.md index 66c3993..be2fb9a 100644 --- a/docs/DATA.md +++ b/docs/DATA.md @@ -76,8 +76,31 @@ Always keep a margin on the cutoff — never filter with the exact window start. ### Cost Calculation +#### Model-aware pricing (default) + +Each JSONL entry carries `message.model`. When `claudeStatus.pricing.useModelPricing` +is `true` (default), the cost of each entry is computed with the built-in rate for +its model family (`resolveModelPricing()` in `jsonlReader.ts`). Rules are matched +in order — first match wins: + +| Match (case-insensitive) | Model family | Input | Output | Cache read | Cache write | +|--------------------------|--------------|-------|--------|------------|-------------| +| `fable`, `mythos` | Fable 5 / Mythos 5 | $10.00 | $50.00 | $1.00 | $12.50 | +| `claude-3-opus`, `opus-4-0`, `opus-4-1`, `opus-4-2025` | Legacy Opus (≤ 4.1) | $15.00 | $75.00 | $1.50 | $18.75 | +| `opus` | Opus 4.5+ | $5.00 | $25.00 | $0.50 | $6.25 | +| `haiku` | Haiku | $1.00 | $5.00 | $0.10 | $1.25 | +| `sonnet` | Sonnet | $3.00 | $15.00 | $0.30 | $3.75 | + +Unrecognized models (e.g. ``) fall back to the manual rates below. +Cost aggregation also produces `costByModel5h` (model id → USD in the 5h window), +shown in the dashboard token breakdown. + +#### Manual rates (fallback / opt-out) + Default rates are based on Claude Sonnet 4.x pricing. -**All four rates are user-configurable** via `claudeStatus.pricing.*` settings. +**All four rates are user-configurable** via `claudeStatus.pricing.*` settings and +apply to **every** entry when `useModelPricing` is `false`, or to entries whose +model is not recognized. | Token type | Setting key | Default (USD / 1M) | |------------|-------------|-------------------| diff --git a/docs/SETTINGS.md b/docs/SETTINGS.md index c7cd670..626d389 100644 --- a/docs/SETTINGS.md +++ b/docs/SETTINGS.md @@ -163,7 +163,13 @@ What the status bar and dashboard show, depending on your Claude plan and key se "description": "Claude provider type. Controls whether rate limit percentages or cost is shown in the status bar." }, - // --- Token pricing (user-adjustable when Anthropic changes rates) --- + // --- Token pricing --- + "claudeStatus.pricing.useModelPricing": { + "type": "boolean", + "default": true, + "description": "Use built-in per-model rates (Opus/Sonnet/Haiku) based on each entry's model field for more accurate cost estimates. When disabled, or when a model is not recognized, the manual claudeStatus.pricing.* rates are used for every entry." + }, + // --- Manual token pricing (fallback for unknown models; all entries when useModelPricing is false) --- "claudeStatus.pricing.inputPerMillion": { "type": "number", "default": 3.00, diff --git a/l10n/bundle.l10n.ja.json b/l10n/bundle.l10n.ja.json index 9828276..fb7e72d 100644 --- a/l10n/bundle.l10n.ja.json +++ b/l10n/bundle.l10n.ja.json @@ -68,6 +68,7 @@ "Output": "出力", "Cache read": "キャッシュ読み取り", "Cache create": "キャッシュ作成", + "By model (5h)": "モデル別(5h)", "Good! Cache is saving cost.": "良好!キャッシュがコストを節約しています。", "Low cache reuse.": "キャッシュの再利用が少ないです。", "No cache reads in this window.": "このウィンドウ内にキャッシュ読み取りはありません。", diff --git a/l10n/bundle.l10n.zh-cn.json b/l10n/bundle.l10n.zh-cn.json index 6e7f944..82b4727 100644 --- a/l10n/bundle.l10n.zh-cn.json +++ b/l10n/bundle.l10n.zh-cn.json @@ -68,6 +68,7 @@ "Output": "输出", "Cache read": "缓存读取", "Cache create": "缓存创建", + "By model (5h)": "按模型(5h)", "Good! Cache is saving cost.": "很好!缓存正在节省费用。", "Low cache reuse.": "缓存复用率低。", "No cache reads in this window.": "此窗口内无缓存读取。", diff --git a/package.json b/package.json index 8a4de64..09946cd 100644 --- a/package.json +++ b/package.json @@ -195,6 +195,11 @@ "default": "auto", "description": "%config.claudeProvider.desc%" }, + "claudeStatus.pricing.useModelPricing": { + "type": "boolean", + "default": true, + "description": "%config.pricing.useModelPricing.desc%" + }, "claudeStatus.pricing.inputPerMillion": { "type": "number", "default": 3.0, diff --git a/package.nls.ja.json b/package.nls.ja.json index 56f4a41..4c4feae 100644 --- a/package.nls.ja.json +++ b/package.nls.ja.json @@ -26,6 +26,7 @@ "config.claudeProvider.claudeAi": "Claude.ai サブスクリプション — レート制限の利用率を表示します(5h ウィンドウ、利用可能な場合は 7d ウィンドウ)。", "config.claudeProvider.awsBedrock": "AWS Bedrock — レート制限なし、トークンコストのみ表示。", "config.claudeProvider.apiKey": "Anthropic API キー — レート制限なし、トークンコストのみ表示。", + "config.pricing.useModelPricing.desc": "各エントリの model フィールドに基づき、モデル別の組み込み料金(Opus/Sonnet/Haiku)を使用してより正確なコストを推定します。無効の場合、またはモデルを認識できない場合は、手動設定の claudeStatus.pricing.* 料金がすべてのエントリに使用されます。", "config.pricing.inputPerMillion.desc": "入力トークン 1M あたりのコスト(USD)。デフォルト: $3.00(Claude Sonnet 4.x)。Anthropic が料金を変更した場合は更新してください。", "config.pricing.outputPerMillion.desc": "出力トークン 1M あたりのコスト(USD)。デフォルト: $15.00(Claude Sonnet 4.x)。Anthropic が料金を変更した場合は更新してください。", "config.pricing.cacheReadPerMillion.desc": "キャッシュ読み取りトークン 1M あたりのコスト(USD)。デフォルト: $0.30(Claude Sonnet 4.x)。Anthropic が料金を変更した場合は更新してください。", diff --git a/package.nls.json b/package.nls.json index 9c882f9..263e506 100644 --- a/package.nls.json +++ b/package.nls.json @@ -26,6 +26,7 @@ "config.claudeProvider.claudeAi": "Claude.ai subscription — shows rate limit utilization (5h window, 7d window if available).", "config.claudeProvider.awsBedrock": "AWS Bedrock — no rate limits, shows token cost only.", "config.claudeProvider.apiKey": "Anthropic API key — no rate limits, shows token cost only.", + "config.pricing.useModelPricing.desc": "Use built-in per-model rates (Opus/Sonnet/Haiku) based on each entry's model field for more accurate cost estimates. When disabled, or when a model is not recognized, the manual claudeStatus.pricing.* rates are used for every entry.", "config.pricing.inputPerMillion.desc": "Cost per 1M input tokens in USD. Default: $3.00 (Claude Sonnet 4.x). Update this if Anthropic changes pricing.", "config.pricing.outputPerMillion.desc": "Cost per 1M output tokens in USD. Default: $15.00 (Claude Sonnet 4.x). Update this if Anthropic changes pricing.", "config.pricing.cacheReadPerMillion.desc": "Cost per 1M cache-read tokens in USD. Default: $0.30 (Claude Sonnet 4.x). Update this if Anthropic changes pricing.", diff --git a/package.nls.zh-cn.json b/package.nls.zh-cn.json index f77b2fc..278fc8f 100644 --- a/package.nls.zh-cn.json +++ b/package.nls.zh-cn.json @@ -26,6 +26,7 @@ "config.claudeProvider.claudeAi": "Claude.ai 订阅 — 显示速率限制利用率(5h 窗口,如可用则显示 7d 窗口)。", "config.claudeProvider.awsBedrock": "AWS Bedrock — 无速率限制,仅显示令牌费用。", "config.claudeProvider.apiKey": "Anthropic API 密钥 — 无速率限制,仅显示令牌费用。", + "config.pricing.useModelPricing.desc": "根据每条记录的 model 字段使用内置的按模型费率(Opus/Sonnet/Haiku),以获得更准确的成本估算。禁用时或无法识别模型时,所有记录都将使用手动设置的 claudeStatus.pricing.* 费率。", "config.pricing.inputPerMillion.desc": "每百万输入令牌的费用(USD)。默认:$3.00(Claude Sonnet 4.x)。Anthropic 调整定价后请更新此值。", "config.pricing.outputPerMillion.desc": "每百万输出令牌的费用(USD)。默认:$15.00(Claude Sonnet 4.x)。Anthropic 调整定价后请更新此值。", "config.pricing.cacheReadPerMillion.desc": "每百万缓存读取令牌的费用(USD)。默认:$0.30(Claude Sonnet 4.x)。Anthropic 调整定价后请更新此值。", diff --git a/src/config.ts b/src/config.ts index f3aaa9b..477892e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,6 @@ import * as vscode from 'vscode'; import type { ClaudeProvider } from './data/apiClient'; -import type { TokenPricing } from './data/jsonlReader'; +import type { CostOptions, TokenPricing } from './data/jsonlReader'; export class ExtensionConfig { private get cfg() { @@ -81,6 +81,17 @@ export class ExtensionConfig { }; } + get useModelPricing(): boolean { + return this.cfg.get('pricing.useModelPricing', true); + } + + get costOptions(): CostOptions { + return { + pricing: this.tokenPricing, + useModelPricing: this.useModelPricing, + }; + } + async setDisplayMode(mode: 'percent' | 'cost'): Promise { await this.cfg.update('displayMode', mode, vscode.ConfigurationTarget.Global); } diff --git a/src/data/dataManager.ts b/src/data/dataManager.ts index 997eb1f..8f8107e 100644 --- a/src/data/dataManager.ts +++ b/src/data/dataManager.ts @@ -27,6 +27,7 @@ export interface ClaudeUsageData { tokensOut5h: number tokensCacheRead5h: number tokensCacheCreate5h: number + costByModel5h: Record // Rate limit metadata has7dLimit: boolean // false for plans without a 7d window or non-Claude.ai providers @@ -64,7 +65,7 @@ export class DataManager { } async getUsageData(forceRefresh = false): Promise { - const [localUsage, cache] = await Promise.all([readAllUsage(config.tokenPricing), readCache()]); + const [localUsage, cache] = await Promise.all([readAllUsage(config.costOptions), readCache()]); // Determine provider type (user config or auto-detection) const configuredProvider = config.claudeProvider; @@ -154,7 +155,7 @@ export class DataManager { async refreshProjectCosts(): Promise { try { - this.lastProjectCosts = await getAllProjectCosts(config.tokenPricing); + this.lastProjectCosts = await getAllProjectCosts(config.costOptions); } catch { this.lastProjectCosts = []; } @@ -226,6 +227,7 @@ export class DataManager { this.lastData.cost5h, this.lastData.costDay, config.dailyBudget, + config.costOptions, ); this.lastPrediction = prediction; return prediction; @@ -244,7 +246,7 @@ export class DataManager { return this.lastHeatmapData; } try { - const data = await computeHeatmapData(config.heatmapDays); + const data = await computeHeatmapData(config.heatmapDays, config.costOptions); this.lastHeatmapData = data; this.heatmapComputedAt = now; return data; diff --git a/src/data/jsonlReader.ts b/src/data/jsonlReader.ts index ef43a1b..6991f18 100644 --- a/src/data/jsonlReader.ts +++ b/src/data/jsonlReader.ts @@ -21,6 +21,7 @@ interface JsonlEntry { requestId?: string message?: { id?: string + model?: string usage?: TokenUsage } } @@ -33,6 +34,7 @@ export interface AggregatedUsage { tokensOut5h: number tokensCacheRead5h: number tokensCacheCreate5h: number + costByModel5h: Record // model id → cost in the 5h window } export interface TokenPricing { @@ -49,6 +51,51 @@ export const DEFAULT_PRICING: TokenPricing = { cacheCreatePerMillion: 3.75, }; +// Built-in per-model rates (USD per 1M tokens), matched against the JSONL +// entry's message.model. Anthropic's published pricing: cache read = 0.1x +// input, 5-minute cache write = 1.25x input. Order matters — first match wins, +// so legacy patterns must precede their generic family pattern. +const MODEL_PRICING_RULES: ReadonlyArray<{ pattern: RegExp; pricing: TokenPricing }> = [ + // Fable 5 / Mythos 5: $10 / $50 + { pattern: /fable|mythos/i, pricing: { inputPerMillion: 10.00, outputPerMillion: 50.00, cacheReadPerMillion: 1.00, cacheCreatePerMillion: 12.50 } }, + // Legacy Opus (3.x, 4.0, 4.1): $15 / $75 + { pattern: /claude-3-opus|opus-4-0|opus-4-1|opus-4-2025/i, pricing: { inputPerMillion: 15.00, outputPerMillion: 75.00, cacheReadPerMillion: 1.50, cacheCreatePerMillion: 18.75 } }, + // Opus 4.5 and later: $5 / $25 + { pattern: /opus/i, pricing: { inputPerMillion: 5.00, outputPerMillion: 25.00, cacheReadPerMillion: 0.50, cacheCreatePerMillion: 6.25 } }, + // Haiku (4.5): $1 / $5 + { pattern: /haiku/i, pricing: { inputPerMillion: 1.00, outputPerMillion: 5.00, cacheReadPerMillion: 0.10, cacheCreatePerMillion: 1.25 } }, + // Sonnet (3.x–5): $3 / $15 + { pattern: /sonnet/i, pricing: { inputPerMillion: 3.00, outputPerMillion: 15.00, cacheReadPerMillion: 0.30, cacheCreatePerMillion: 3.75 } }, +]; + +// Returns the built-in rate for a recognized model, or the fallback (the +// user-configured claudeStatus.pricing.* rates) when the model is unknown +export function resolveModelPricing(model: string | undefined, fallback: TokenPricing): TokenPricing { + if (model) { + for (const rule of MODEL_PRICING_RULES) { + if (rule.pattern.test(model)) { return rule.pricing; } + } + } + return fallback; +} + +export interface CostOptions { + pricing: TokenPricing // manual rates; also the fallback for unknown models + useModelPricing: boolean // when true, recognized models use built-in rates +} + +export const DEFAULT_COST_OPTIONS: CostOptions = { + pricing: DEFAULT_PRICING, + useModelPricing: true, +}; + +export function entryCost(entry: UsageEntry, options: CostOptions = DEFAULT_COST_OPTIONS): number { + const pricing = options.useModelPricing + ? resolveModelPricing(entry.model, options.pricing) + : options.pricing; + return calculateCost(entry.usage, pricing); +} + export function calculateCost(usage: TokenUsage, pricing: TokenPricing = DEFAULT_PRICING): number { return ( ((usage.input_tokens || 0) / 1_000_000) * pricing.inputPerMillion + @@ -148,6 +195,7 @@ export interface UsageEntry { timestamp: number // ms since epoch, guaranteed valid usage: TokenUsage // all four fields normalized to numbers cwd?: string + model?: string // e.g. "claude-sonnet-4-5-20250929" } export async function readUsageEntries(filePath: string): Promise { @@ -167,12 +215,13 @@ export async function readUsageEntries(filePath: string): Promise cache_creation_input_tokens: rawUsage.cache_creation_input_tokens || 0, }, cwd: typeof entry.cwd === 'string' ? entry.cwd : undefined, + model: typeof entry.message?.model === 'string' ? entry.message.model : undefined, }); } return result; } -export async function readAllUsage(pricing: TokenPricing = DEFAULT_PRICING): Promise { +export async function readAllUsage(options: CostOptions = DEFAULT_COST_OPTIONS): Promise { const now = Date.now(); const window5h = 5 * 3600 * 1000; const window7d = 7 * 24 * 3600 * 1000; @@ -187,6 +236,7 @@ export async function readAllUsage(pricing: TokenPricing = DEFAULT_PRICING): Pro tokensOut5h: 0, tokensCacheRead5h: 0, tokensCacheCreate5h: 0, + costByModel5h: {}, }; // Skip files untouched for over 7 days (+1h margin) — they cannot contain @@ -197,7 +247,7 @@ export async function readAllUsage(pricing: TokenPricing = DEFAULT_PRICING): Pro const entries = await readUsageEntries(file); for (const entry of entries) { const { timestamp: ts, usage } = entry; - const cost = calculateCost(usage, pricing); + const cost = entryCost(entry, options); const age = now - ts; if (age <= window7d) { @@ -212,6 +262,9 @@ export async function readAllUsage(pricing: TokenPricing = DEFAULT_PRICING): Pro result.tokensOut5h += usage.output_tokens || 0; result.tokensCacheRead5h += usage.cache_read_input_tokens || 0; result.tokensCacheCreate5h += usage.cache_creation_input_tokens || 0; + if (entry.model) { + result.costByModel5h[entry.model] = (result.costByModel5h[entry.model] || 0) + cost; + } } } } diff --git a/src/data/prediction.ts b/src/data/prediction.ts index 455e0c0..d0aa10b 100644 --- a/src/data/prediction.ts +++ b/src/data/prediction.ts @@ -1,4 +1,4 @@ -import { findAllJsonlFiles, calculateCost, readUsageEntries } from './jsonlReader'; +import { findAllJsonlFiles, entryCost, readUsageEntries, CostOptions, DEFAULT_COST_OPTIONS } from './jsonlReader'; export type RecommendationKey = 'safe' | 'caution' | 'warning' | 'critical' | 'rate-limit-reached'; @@ -18,7 +18,7 @@ interface TimestampedCost { cost: number // USD } -async function readRecentCosts(windowMs: number): Promise { +async function readRecentCosts(windowMs: number, options: CostOptions): Promise { const now = Date.now(); const cutoff = now - windowMs; const result: TimestampedCost[] = []; @@ -31,7 +31,7 @@ async function readRecentCosts(windowMs: number): Promise { const entries = await readUsageEntries(file); for (const entry of entries) { if (entry.timestamp < cutoff) { continue; } - const cost = calculateCost(entry.usage); + const cost = entryCost(entry, options); if (cost > 0) { result.push({ timestamp: entry.timestamp, cost }); } } } @@ -67,8 +67,9 @@ export async function computePrediction( cost5h: number, costToday: number, dailyBudget: number | null, + costOptions: CostOptions = DEFAULT_COST_OPTIONS, ): Promise { - const entries = await readRecentCosts(30 * 60 * 1000); + const entries = await readRecentCosts(30 * 60 * 1000, costOptions); const burnRateUsdPerHour = calculateBurnRate(entries); // --- Rate limit exhaustion --- diff --git a/src/data/projectCost.ts b/src/data/projectCost.ts index 4ee269e..a3803f7 100644 --- a/src/data/projectCost.ts +++ b/src/data/projectCost.ts @@ -2,7 +2,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; import * as vscode from 'vscode'; -import { calculateCost, readUsageEntries, TokenPricing, DEFAULT_PRICING } from './jsonlReader'; +import { entryCost, readUsageEntries, CostOptions, DEFAULT_COST_OPTIONS } from './jsonlReader'; export interface ProjectCostData { projectName: string @@ -76,7 +76,7 @@ export async function workspacePathToProjectDir(workspacePath: string): Promise< return null; } -async function getProjectCostForDir(projectDir: string, projectName: string, pricing: TokenPricing = DEFAULT_PRICING): Promise { +async function getProjectCostForDir(projectDir: string, projectName: string, options: CostOptions = DEFAULT_COST_OPTIONS): Promise { const now = Date.now(); const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0); @@ -100,7 +100,7 @@ async function getProjectCostForDir(projectDir: string, projectName: string, pri const entries = await readUsageEntries(path.join(projectDir, file)); for (const entry of entries) { const tsMs = entry.timestamp; - const cost = calculateCost(entry.usage, pricing); + const cost = entryCost(entry, options); const ageMs = now - tsMs; if (ageMs < w30d) { cost30d += cost; } @@ -124,7 +124,7 @@ async function getProjectCostForDir(projectDir: string, projectName: string, pri }; } -export async function getAllProjectCosts(pricing: TokenPricing = DEFAULT_PRICING): Promise { +export async function getAllProjectCosts(options: CostOptions = DEFAULT_COST_OPTIONS): Promise { const folders = vscode.workspace.workspaceFolders ?? []; if (folders.length === 0) { return []; } @@ -134,7 +134,7 @@ export async function getAllProjectCosts(pricing: TokenPricing = DEFAULT_PRICING const projectDir = await workspacePathToProjectDir(workspacePath); if (!projectDir) { return null; } const projectName = path.basename(workspacePath); - return getProjectCostForDir(projectDir, projectName, pricing); + return getProjectCostForDir(projectDir, projectName, options); }) ); diff --git a/src/test/suite/jsonlReader.test.ts b/src/test/suite/jsonlReader.test.ts index d845d55..e5db9bf 100644 --- a/src/test/suite/jsonlReader.test.ts +++ b/src/test/suite/jsonlReader.test.ts @@ -2,7 +2,10 @@ import * as assert from 'assert'; import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; -import { calculateCost, findAllJsonlFiles, readJsonlFile, readUsageEntries, wasJsonlUpdatedRecently } from '../../data/jsonlReader'; +import { + calculateCost, entryCost, findAllJsonlFiles, readJsonlFile, readUsageEntries, + resolveModelPricing, wasJsonlUpdatedRecently, DEFAULT_PRICING, UsageEntry, +} from '../../data/jsonlReader'; suite('JsonlReader', () => { test('calculateCost returns 0 for zero tokens', () => { @@ -191,6 +194,61 @@ suite('JsonlReader', () => { }); }); + suite('model-aware pricing', () => { + const usage = { input_tokens: 1_000_000, output_tokens: 0, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }; + const entryFor = (model?: string): UsageEntry => ({ timestamp: Date.now(), usage, model }); + + test('resolves Opus 4.5+ rates', () => { + const p = resolveModelPricing('claude-opus-4-5-20251101', DEFAULT_PRICING); + assert.strictEqual(p.inputPerMillion, 5.00); + assert.strictEqual(p.outputPerMillion, 25.00); + }); + + test('resolves legacy Opus (4.1 and older) rates', () => { + for (const m of ['claude-opus-4-1-20250805', 'claude-opus-4-20250514', 'claude-3-opus-20240229']) { + const p = resolveModelPricing(m, DEFAULT_PRICING); + assert.strictEqual(p.inputPerMillion, 15.00, m); + assert.strictEqual(p.outputPerMillion, 75.00, m); + } + }); + + test('resolves Sonnet and Haiku rates', () => { + assert.strictEqual(resolveModelPricing('claude-sonnet-4-5-20250929', DEFAULT_PRICING).inputPerMillion, 3.00); + assert.strictEqual(resolveModelPricing('claude-haiku-4-5-20251001', DEFAULT_PRICING).inputPerMillion, 1.00); + assert.strictEqual(resolveModelPricing('claude-haiku-4-5-20251001', DEFAULT_PRICING).cacheReadPerMillion, 0.10); + }); + + test('falls back to provided pricing for unknown or missing model', () => { + const custom = { inputPerMillion: 9, outputPerMillion: 9, cacheReadPerMillion: 9, cacheCreatePerMillion: 9 }; + assert.deepStrictEqual(resolveModelPricing('', custom), custom); + assert.deepStrictEqual(resolveModelPricing(undefined, custom), custom); + }); + + test('entryCost uses model rates when enabled and manual rates when disabled', () => { + const opts = { pricing: DEFAULT_PRICING, useModelPricing: true }; + assert.strictEqual(entryCost(entryFor('claude-haiku-4-5-20251001'), opts), 1.00); + assert.strictEqual(entryCost(entryFor('claude-opus-4-6'), opts), 5.00); + const manual = { pricing: DEFAULT_PRICING, useModelPricing: false }; + assert.strictEqual(entryCost(entryFor('claude-haiku-4-5-20251001'), manual), 3.00); + }); + + test('readUsageEntries surfaces the model field', async () => { + const tmpFile = path.join(os.tmpdir(), `model-test-${Date.now()}.jsonl`); + try { + await fs.writeFile(tmpFile, JSON.stringify({ + type: 'assistant', + timestamp: new Date().toISOString(), + requestId: 'r1', + message: { model: 'claude-sonnet-4-5-20250929', usage: { input_tokens: 1, output_tokens: 1 } }, + })); + const entries = await readUsageEntries(tmpFile); + assert.strictEqual(entries[0].model, 'claude-sonnet-4-5-20250929'); + } finally { + await fs.unlink(tmpFile).catch(() => {}); + } + }); + }); + suite('findAllJsonlFiles mtime filter', () => { let tmpHome: string; let savedHome: string | undefined; diff --git a/src/test/suite/statusBar.test.ts b/src/test/suite/statusBar.test.ts index 4762ca1..6e7385a 100644 --- a/src/test/suite/statusBar.test.ts +++ b/src/test/suite/statusBar.test.ts @@ -18,6 +18,7 @@ function makeData(overrides: Partial = {}): ClaudeUsageData { tokensOut5h: 10_000, tokensCacheRead5h: 5_000, tokensCacheCreate5h: 1_000, + costByModel5h: {}, lastUpdated: new Date(), cacheAge: 30, dataSource: 'cache', diff --git a/src/webview/heatmap.ts b/src/webview/heatmap.ts index 80d6f51..ae3d060 100644 --- a/src/webview/heatmap.ts +++ b/src/webview/heatmap.ts @@ -1,7 +1,7 @@ import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; -import { calculateCost, readUsageEntries } from '../data/jsonlReader'; +import { entryCost, readUsageEntries, CostOptions, DEFAULT_COST_OPTIONS } from '../data/jsonlReader'; export interface DailyUsage { date: string // "YYYY-MM-DD" local time @@ -42,6 +42,7 @@ function toLocalDateKey(ts: number): string { async function readJsonlForHeatmap( filePath: string, cutoff: number, + options: CostOptions, ): Promise { // Deduplicated read — streaming duplicates would inflate daily/hourly costs const entries = await readUsageEntries(filePath); @@ -50,7 +51,7 @@ async function readJsonlForHeatmap( if (e.timestamp < cutoff) { continue; } result.push({ timestamp: e.timestamp, - cost: calculateCost(e.usage), + cost: entryCost(e, options), tokens: e.usage.input_tokens + e.usage.output_tokens, hour: new Date(e.timestamp).getHours(), }); @@ -100,7 +101,7 @@ export function aggregateByHour(entries: EntryForHeatmap[], days: number): Hourl // ---- main entry point ------------------------------------------------------- -export async function getHeatmapData(days = 90): Promise { +export async function getHeatmapData(days = 90, options: CostOptions = DEFAULT_COST_OPTIONS): Promise { const claudeProjectsDir = path.join(os.homedir(), '.claude', 'projects'); const cutoff = Date.now() - days * 24 * 3600 * 1000; const allEntries: EntryForHeatmap[] = []; @@ -128,7 +129,7 @@ export async function getHeatmapData(days = 90): Promise { } // Read all qualifying files in parallel - const chunks = await Promise.all(filePaths.map(fp => readJsonlForHeatmap(fp, cutoff))); + const chunks = await Promise.all(filePaths.map(fp => readJsonlForHeatmap(fp, cutoff, options))); for (const chunk of chunks) { allEntries.push(...chunk); } } catch { /* ~/.claude/projects doesn't exist — return empty data */ } diff --git a/src/webview/panel.ts b/src/webview/panel.ts index 3e275cd..c3c547c 100644 --- a/src/webview/panel.ts +++ b/src/webview/panel.ts @@ -70,6 +70,7 @@ function buildI18n(): Record { output: t('Output'), cacheRead: t('Cache read'), cacheCreate: t('Cache create'), + byModel: t('By model (5h)'), cacheGood: t('Good! Cache is saving cost.'), cacheLow: t('Low cache reuse.'), noCacheReads: t('No cache reads in this window.'), @@ -886,6 +887,20 @@ function getWebviewContent(nonce: string, i18n: Record): string '' + fmtK(usage.tokensCacheCreate5h) + ' tok = $' + crCost.toFixed(4) + ''; } + // Per-model cost breakdown (5h window) + const byModel = usage.costByModel5h || {}; + const models = Object.keys(byModel).sort((a, b) => byModel[b] - byModel[a]); + if (models.length > 0) { + html += '
' + + i18n.byModel + '
'; + for (const m of models) { + // "claude-sonnet-4-5-20250929" → "sonnet-4-5" + const shortName = m.replace(/^claude-/, '').replace(/-\d{8}$/, ''); + html += '
' + esc(shortName) + '' + + '$' + byModel[m].toFixed(4) + '
'; + } + } + html += '
'; if (usage.tokensCacheRead5h > 0) { html += i18n.cacheHitRatio + ' ' + cacheHitRatio + '% — ';