diff --git a/CHANGELOG.md b/CHANGELOG.md index dce931ae..92e18381 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ upstream release: 1.0.8). Format follows [Keep a Changelog](https://keepachangel ## [2.3.0] — Unreleased ### Fixed +- **AWS Bedrock in-region Claude pricing** — the selectable Bedrock table now + covers Claude Opus 4.5, 4.6, 4.7, 4.8, Sonnet 4.5, Sonnet 4.6, Sonnet 5, + Opus 5, and Haiku 4.5, including separate 5-minute and 1-hour cache-write + rates. Changing the pricing backend invalidates the incremental cost + aggregate so existing logs are recalculated immediately. - **Codex Today is now the configured calendar day** — the first Codex tab now pairs its day total with exact hourly API-equivalent cost and a separate token-composition view. Its additive schema-3 current-day sidecar scans only diff --git a/package-lock.json b/package-lock.json index 10caceca..9690caac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "claude-code-usage", - "version": "2.1.1", + "version": "2.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "claude-code-usage", - "version": "2.1.1", + "version": "2.3.1", "license": "MIT", "devDependencies": { "@axe-core/playwright": "4.12.1", diff --git a/package.json b/package.json index 642769d1..23861493 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "claude-code-usage", "displayName": "Claude Code Usage", "description": "Local Claude Code and Codex usage insights in the VS Code status bar", - "version": "2.1.1", + "version": "2.3.1", "publisher": "GrowthJack", "author": "GrowthJack", "license": "MIT", diff --git a/src/extension.ts b/src/extension.ts index d878cbb8..b4ecc355 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -18,7 +18,7 @@ import { StatusBarManager } from './statusBar'; import { UsageWebviewProvider } from './webview'; import { I18n } from './i18n'; import { resolveTimeZone } from './dateKeys'; -import { fetchLatestPricing } from './pricing'; +import { fetchLatestPricing, setPricingBackend } from './pricing'; import { ClaudeApiClient } from './claudeApiClient'; import { buildOptimizerSystemPrompt, @@ -150,6 +150,7 @@ export class ClaudeCodeUsageExtension { private quotaColdRetryDone: boolean = false; private claudeProfileGeneration: number = 0; private claudeWeeklyQuotaHistory: WeeklyQuotaObservation[] = []; + private activePricingBackend: ExtensionConfig['pricingBackend'] = 'anthropic'; private codexProvider: CodexProvider; private readonly codexSalt: string; private codexView: CodexUsageView | null = null; @@ -304,6 +305,7 @@ export class ClaudeCodeUsageExtension { private async refreshPricing(): Promise { try { const result = await fetchLatestPricing(); + this.invalidateClaudeUsagePricingCache(); vscode.window.showInformationMessage(`${I18n.t.popup.pricingUpdated} (${result.updated})`); // Force a full recompute so the new prices take effect. void this.refreshData(true, 'pricing'); @@ -756,6 +758,8 @@ export class ClaudeCodeUsageExtension { private loadConfiguration(): void { const config = this.getConfiguration(); + this.activePricingBackend = config.pricingBackend; + setPricingBackend(config.pricingBackend); I18n.setLanguage(config.language as any); I18n.setDecimalPlaces(config.decimalPlaces); I18n.setTokenDecimalPlaces(config.tokenDecimalPlaces); @@ -791,6 +795,7 @@ export class ClaudeCodeUsageExtension { return { refreshInterval: s.get('refreshInterval'), dataDirectory: s.get('dataDirectory'), + pricingBackend: s.get<'anthropic' | 'aws-bedrock-in-region'>('pricingBackend'), codexEnabled: s.get('codex.enabled'), codexDataDirectory: s.get('codex.dataDirectory'), codexFileWatchSeconds: @@ -1109,6 +1114,12 @@ export class ClaudeCodeUsageExtension { private onConfigurationChanged(): void { const config = this.getConfiguration(); + const pricingBackendChanged = this.activePricingBackend !== config.pricingBackend; + this.activePricingBackend = config.pricingBackend; + setPricingBackend(config.pricingBackend); + if (pricingBackendChanged) { + this.invalidateClaudeUsagePricingCache(); + } I18n.setLanguage(config.language as any); I18n.setDecimalPlaces(config.decimalPlaces); I18n.setTokenDecimalPlaces(config.tokenDecimalPlaces); @@ -1136,6 +1147,21 @@ export class ClaudeCodeUsageExtension { }); } + /** + * Pricing is part of every cached UsageData aggregate. The incremental + * loader normally skips unchanged JSONL files, so changing the pricing + * backend would otherwise update only the displayed rate labels while + * leaving totalCost/modelBreakdown.cost at the old rates. + */ + private invalidateClaudeUsagePricingCache(): void { + this.cache.claudeIndex = createClaudeUsageIndex(); + this.cache.records = []; + this.cache.contentAnalysis = null; + this.cache.manifest = null; + this.cache.dataDirectory = null; + this.cache.lastUpdate = new Date(0); + } + /** Keep quota credentials on the same Claude profile as this window's logs. * A profile switch invalidates every in-memory quota/backoff value because it * belongs to a different account. */ diff --git a/src/i18n.ts b/src/i18n.ts index 483775c5..e82ecc8d 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -2919,6 +2919,7 @@ const SETTINGS_I18N: Partial { // Opus current tier: $5 / $25 / $6.25 / $0.50 per million in/out/write/read. @@ -83,3 +88,114 @@ test('getModelPricing falls back to the right family for an unknown snapshot', ( assert.ok(pricing, 'expected a fallback pricing object, got null'); assert.equal(pricing!.input_cost_per_token, 5 / 1_000_000); }); + +test('AWS Bedrock mode prices Claude 5 models with in-region rates', () => { + setPricingBackend('aws-bedrock-in-region'); + try { + for (const model of [ + 'claude-sonnet-5', + 'us.anthropic.claude-sonnet-5-v1:0', + 'claude-sonnet-5[1m]', + ]) { + const pricing = getModelPricing(model); + assert.ok(pricing, `expected Bedrock pricing for ${model}, got null`); + assert.equal(pricing!.input_cost_per_token, 2.2 / 1_000_000, model); + assert.equal(pricing!.output_cost_per_token, 11 / 1_000_000, model); + assert.equal(pricing!.cache_creation_input_token_cost, 2.75 / 1_000_000, model); + assert.equal(pricing!.cache_creation_1h_input_token_cost, 4.4 / 1_000_000, model); + assert.equal(pricing!.cache_read_input_token_cost, 0.22 / 1_000_000, model); + } + + const cost = calculateCostFromTokens( + { + input_tokens: 1_000_000, + output_tokens: 1_000_000, + cache_creation_input_tokens: 1_000_000, + cache_read_input_tokens: 1_000_000, + }, + 'claude-sonnet-5', + ); + assert.ok(Math.abs(cost - 16.17) < 1e-9, `expected ~16.17, got ${cost}`); + } finally { + setPricingBackend('anthropic'); + } +}); + +test('AWS Bedrock mode prices Claude Opus 4.5-4.8 with in-region rates', () => { + setPricingBackend('aws-bedrock-in-region'); + try { + for (const model of [ + 'claude-opus-4-8', + 'claude-opus-4-7', + 'claude-opus-4-6', + 'claude-opus-4-5-20251101', + 'us.anthropic.claude-opus-4-8-v1:0', + ]) { + const pricing = getModelPricing(model); + assert.ok(pricing, `expected Bedrock pricing for ${model}, got null`); + assert.equal(pricing!.input_cost_per_token, 5.5 / 1_000_000, model); + assert.equal(pricing!.output_cost_per_token, 27.5 / 1_000_000, model); + assert.equal(pricing!.cache_creation_input_token_cost, 6.875 / 1_000_000, model); + assert.equal(pricing!.cache_creation_1h_input_token_cost, 11 / 1_000_000, model); + assert.equal(pricing!.cache_read_input_token_cost, 0.55 / 1_000_000, model); + } + } finally { + setPricingBackend('anthropic'); + } +}); + +test('AWS Bedrock mode prices Claude Sonnet 4.5 and 4.6 with in-region rates', () => { + setPricingBackend('aws-bedrock-in-region'); + try { + for (const model of [ + 'claude-sonnet-4-6', + 'claude-sonnet-4-5-20250929', + 'claude-sonnet-4-5', + 'us.anthropic.claude-sonnet-4-6-v1:0', + 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + 'claude-sonnet-4.5', + 'claude_sonnet_4_5', + ]) { + const pricing = getModelPricing(model); + assert.ok(pricing, `expected Bedrock pricing for ${model}, got null`); + assert.equal(pricing!.input_cost_per_token, 3.3 / 1_000_000, model); + assert.equal(pricing!.output_cost_per_token, 16.5 / 1_000_000, model); + assert.equal(pricing!.cache_creation_input_token_cost, 4.125 / 1_000_000, model); + assert.equal(pricing!.cache_creation_1h_input_token_cost, 6.6 / 1_000_000, model); + assert.equal(pricing!.cache_read_input_token_cost, 0.33 / 1_000_000, model); + } + + const cost = calculateCostFromTokens( + { + input_tokens: 1_000_000, + output_tokens: 1_000_000, + cache_creation_input_tokens: 1_000_000, + cache_read_input_tokens: 1_000_000, + }, + 'claude-sonnet-4-5-20250929', + ); + assert.ok(Math.abs(cost - 24.255) < 1e-9, `expected ~24.255, got ${cost}`); + } finally { + setPricingBackend('anthropic'); + } +}); + +test('Claude Sonnet 4.5 switches between direct and Bedrock rates for the same model id', () => { + const model = 'claude-sonnet-4-5-20250929'; + + setPricingBackend('anthropic'); + const direct = getModelPricing(model); + assert.ok(direct); + assert.equal(direct.input_cost_per_token, 3 / 1_000_000); + assert.equal(direct.output_cost_per_token, 15 / 1_000_000); + + setPricingBackend('aws-bedrock-in-region'); + try { + const bedrock = getModelPricing(model); + assert.ok(bedrock); + assert.equal(bedrock.input_cost_per_token, 3.3 / 1_000_000); + assert.equal(bedrock.output_cost_per_token, 16.5 / 1_000_000); + } finally { + setPricingBackend('anthropic'); + } +}); diff --git a/src/types.ts b/src/types.ts index d3cc91d5..06e2d7ff 100644 --- a/src/types.ts +++ b/src/types.ts @@ -268,6 +268,7 @@ export interface UsageAttribution { export interface ExtensionConfig { refreshInterval: number; dataDirectory: string; + pricingBackend: 'anthropic' | 'aws-bedrock-in-region'; codexEnabled: boolean; codexDataDirectory: string; codexFileWatchSeconds: number; diff --git a/tests/ui/claude-settings.spec.mjs b/tests/ui/claude-settings.spec.mjs new file mode 100644 index 00000000..9c52c760 --- /dev/null +++ b/tests/ui/claude-settings.spec.mjs @@ -0,0 +1,32 @@ +import { test, expect, openClaude, openCodex } from './support/app.mjs'; + +test('Claude pricing backend is available only in Claude settings', async ({ page }) => { + await openClaude(page); + await page.locator('#tab-settings').click(); + + const backend = page.locator('#set_pricingBackend'); + await expect(backend).toBeVisible(); + await expect(page.locator('label[for="set_pricingBackend"]')).toHaveText('Claude pricing backend'); + await expect(backend.locator('option')).toHaveText([ + 'Anthropic direct API', + 'AWS Bedrock (in-region)', + ]); + await expect(backend).toHaveValue('anthropic'); + + await openCodex(page); + await page.locator('#tab-settings').click(); + await expect(page.locator('#set_pricingBackend')).toHaveCount(0); +}); + +test('Claude pricing backend dropdown posts the selected Bedrock backend', async ({ page }) => { + await openClaude(page); + await page.locator('#tab-settings').click(); + + await page.locator('#set_pricingBackend').selectOption('aws-bedrock-in-region'); + + await expect.poll(() => page.evaluate(() => window.__ccuPostedMessages.at(-1))).toEqual({ + command: 'updateSetting', + key: 'pricingBackend', + value: 'aws-bedrock-in-region', + }); +});