From c85beeaeaeaeec92671ddde6a0d1a385d5ff1d32 Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Mon, 11 May 2026 21:23:04 -0700 Subject: [PATCH] Fix Claude 1-hour cache write pricing (#317) Co-authored-by: ozymandiashh <234437643+ozymandiashh@users.noreply.github.com> Co-authored-by: iamtoruk --- CHANGELOG.md | 15 ++++---- docs/providers/claude.md | 11 ++++++ src/daily-cache.ts | 25 ++++++------- src/models.ts | 8 ++++- src/parser.ts | 26 +++++++++++++- src/types.ts | 4 +++ tests/daily-cache.test.ts | 30 ++++++++++++++++ tests/models.test.ts | 12 +++++++ tests/parser-claude-cwd.test.ts | 64 +++++++++++++++++++++++++++++---- 9 files changed, 165 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6d3191..d8c1163 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,13 +3,14 @@ ## Unreleased ### Added (CLI) -- **IBM Bob provider.** CodeBurn now discovers IBM Bob IDE task history from - `User/globalStorage/ibm.bob-code/tasks//` under both the GA - `IBM Bob` application data folder and preview-era `Bob-IDE` folder. The - provider reuses the Cline-family `ui_messages.json` parser for token/cost - records, reads `api_conversation_history.json` for model tags when present, - falls back to `ibm-bob-auto` pricing otherwise, and appears in CLI, - dashboard, JSON, docs, and the macOS provider tabs. Closes #248. +- **IBM Bob provider.** Discovers IBM Bob IDE task history, reuses the + Cline-family parser for token/cost records, extracts model tags and + workspace-based project names from session data. Closes #248. + +### Fixed (CLI) +- **Claude 1-hour cache write pricing.** 1-hour cache writes are now priced + at 2x base input (previously used the 5-minute 1.25x rate for all writes). + Daily cache bumped to v6 so stale totals are recomputed. Closes #276. ## 0.9.8 - 2026-05-10 diff --git a/docs/providers/claude.md b/docs/providers/claude.md index b0b7b8c..b5954c1 100644 --- a/docs/providers/claude.md +++ b/docs/providers/claude.md @@ -25,6 +25,17 @@ JSONL, one event per line, per session file. Sessions live under `/ = { 'claude-opus-4-7': 6, @@ -311,6 +312,7 @@ export function calculateCost( cacheReadTokens: number, webSearchRequests: number, speed: 'standard' | 'fast' = 'standard', + oneHourCacheCreationTokens = 0, ): number { const costs = getModelCosts(model) if (!costs) { @@ -336,11 +338,15 @@ export function calculateCost( // from real spend in aggregate totals. NaN is also handled here; the // arithmetic below short-circuits to 0 when any operand is non-finite. const safe = (n: number) => (Number.isFinite(n) && n > 0 ? n : 0) + const safeOneHourCacheCreation = safe(oneHourCacheCreationTokens) + const safeCacheCreation = Math.max(safe(cacheCreationTokens), safeOneHourCacheCreation) + const safeFiveMinuteCacheCreation = Math.max(0, safeCacheCreation - safeOneHourCacheCreation) return multiplier * ( safe(inputTokens) * costs.inputCostPerToken + safe(outputTokens) * costs.outputCostPerToken + - safe(cacheCreationTokens) * costs.cacheWriteCostPerToken + + safeFiveMinuteCacheCreation * costs.cacheWriteCostPerToken + + safeOneHourCacheCreation * costs.cacheWriteCostPerToken * ONE_HOUR_CACHE_WRITE_MULTIPLIER_FROM_FIVE_MINUTE_RATE + safe(cacheReadTokens) * costs.cacheReadCostPerToken + safe(webSearchRequests) * costs.webSearchCostPerRequest ) diff --git a/src/parser.ts b/src/parser.ts index d49697b..3bb602e 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -92,16 +92,39 @@ function getMessageId(entry: JournalEntry): string | null { return msg?.id ?? null } +function positiveNumber(n: number | undefined): number { + return n !== undefined && Number.isFinite(n) && n > 0 ? n : 0 +} + +function extractClaudeCacheCreation(usage: AssistantMessageContent['usage']): { totalTokens: number; oneHourTokens: number } { + const legacyTotal = positiveNumber(usage.cache_creation_input_tokens) + const cacheCreation = usage.cache_creation + const fiveMinuteTokens = positiveNumber(cacheCreation?.ephemeral_5m_input_tokens) + const oneHourTokens = positiveNumber(cacheCreation?.ephemeral_1h_input_tokens) + const splitTotal = fiveMinuteTokens + oneHourTokens + + if (splitTotal === 0) return { totalTokens: legacyTotal, oneHourTokens: 0 } + + // Valid Claude usage reports the legacy total and split total as equal. + // Keep the larger value so malformed partial splits do not drop tokens. + const totalTokens = Math.max(legacyTotal, splitTotal) + return { + totalTokens, + oneHourTokens: Math.min(oneHourTokens, totalTokens), + } +} + function parseApiCall(entry: JournalEntry): ParsedApiCall | null { if (entry.type !== 'assistant') return null const msg = entry.message as AssistantMessageContent | undefined if (!msg?.usage || !msg?.model) return null const usage = msg.usage + const cacheCreation = extractClaudeCacheCreation(usage) const tokens: TokenUsage = { inputTokens: usage.input_tokens ?? 0, outputTokens: usage.output_tokens ?? 0, - cacheCreationInputTokens: usage.cache_creation_input_tokens ?? 0, + cacheCreationInputTokens: cacheCreation.totalTokens, cacheReadInputTokens: usage.cache_read_input_tokens ?? 0, cachedInputTokens: 0, reasoningTokens: 0, @@ -118,6 +141,7 @@ function parseApiCall(entry: JournalEntry): ParsedApiCall | null { tokens.cacheReadInputTokens, tokens.webSearchRequests, usage.speed ?? 'standard', + cacheCreation.oneHourTokens, ) const bashCmds = extractBashCommandsFromContent(msg.content ?? []) diff --git a/src/types.ts b/src/types.ts index e5562e8..eecee5c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -25,6 +25,10 @@ export type ApiUsage = { input_tokens: number output_tokens: number cache_creation_input_tokens?: number + cache_creation?: { + ephemeral_5m_input_tokens?: number + ephemeral_1h_input_tokens?: number + } cache_read_input_tokens?: number server_tool_use?: { web_search_requests?: number diff --git a/tests/daily-cache.test.ts b/tests/daily-cache.test.ts index 5ec2661..2f384cc 100644 --- a/tests/daily-cache.test.ts +++ b/tests/daily-cache.test.ts @@ -104,6 +104,36 @@ describe('loadDailyCache', () => { expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v2.bak'))).toBe(true) }) + it('discards a v5 cache because cached Claude costs predate 1-hour cache pricing', async () => { + const saved = { + version: 5, + lastComputedDate: '2026-05-01', + days: [{ + date: '2026-05-01', + cost: 0.37575, + calls: 1, + sessions: 1, + inputTokens: 0, + outputTokens: 0, + cacheReadTokens: 0, + cacheWriteTokens: 60_120, + editTurns: 0, + oneShotTurns: 0, + models: { 'Opus 4.7': { calls: 1, cost: 0.37575, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 60_120 } }, + categories: {}, + providers: { claude: { calls: 1, cost: 0.37575 } }, + }], + } + const { writeFile, mkdir } = await import('fs/promises') + await mkdir(TMP_CACHE_ROOT, { recursive: true }) + await writeFile(join(TMP_CACHE_ROOT, 'daily-cache.json'), JSON.stringify(saved), 'utf-8') + const cache = await loadDailyCache() + expect(cache.version).toBe(DAILY_CACHE_VERSION) + expect(cache.days).toEqual([]) + expect(cache.lastComputedDate).toBeNull() + expect(existsSync(join(TMP_CACHE_ROOT, 'daily-cache.json.v5.bak'))).toBe(true) + }) + it('round-trips a valid cache through save and load', async () => { const saved: DailyCache = { version: DAILY_CACHE_VERSION, diff --git a/tests/models.test.ts b/tests/models.test.ts index 9fdf87b..41ccb5e 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -158,6 +158,18 @@ describe('calculateCost - OMP names produce non-zero cost', () => { }) }) +describe('calculateCost - Claude cache write durations', () => { + it('prices 1-hour cache writes at 1.6x the 5-minute cache write rate', () => { + const fiveMinute = calculateCost('claude-opus-4-7', 0, 0, 1_000_000, 0, 0) + const oneHour = calculateCost('claude-opus-4-7', 0, 0, 1_000_000, 0, 0, 'standard', 1_000_000) + const mixed = calculateCost('claude-opus-4-7', 0, 0, 100_000, 0, 0, 'standard', 60_000) + + expect(fiveMinute).toBeCloseTo(6.25, 6) + expect(oneHour).toBeCloseTo(10, 6) + expect(mixed).toBeCloseTo(0.85, 6) + }) +}) + describe('existing model names still resolve', () => { it('canonical claude-opus-4-6', () => { expect(getModelCosts('claude-opus-4-6')).not.toBeNull() diff --git a/tests/parser-claude-cwd.test.ts b/tests/parser-claude-cwd.test.ts index 65c96db..179ad7c 100644 --- a/tests/parser-claude-cwd.test.ts +++ b/tests/parser-claude-cwd.test.ts @@ -31,7 +31,14 @@ function dayRange(day: string): DateRange { } } -async function writeClaudeSession(projectSlug: string, sessionId: string, cwd: string, timestamp: string): Promise { +async function writeClaudeSession( + projectSlug: string, + sessionId: string, + cwd: string, + timestamp: string, + usage: Record = { input_tokens: 100, output_tokens: 50 }, + model = 'claude-sonnet-4-5', +): Promise { const projectDir = join(tmpDir, 'projects', projectSlug) await mkdir(projectDir, { recursive: true }) const filePath = join(projectDir, `${sessionId}.jsonl`) @@ -44,12 +51,9 @@ async function writeClaudeSession(projectSlug: string, sessionId: string, cwd: s id: `msg-${sessionId}`, type: 'message', role: 'assistant', - model: 'claude-sonnet-4-5', + model, content: [], - usage: { - input_tokens: 100, - output_tokens: 50, - }, + usage, }, }) + '\n') @@ -158,3 +162,51 @@ describe('Claude cwd project paths', () => { expect(projects[0]!.projectPath).toBe('fallback/slug') }) }) + +describe('Claude cache creation pricing', () => { + it('prices 1-hour cache writes from usage.cache_creation at the 2x input rate', async () => { + await writeClaudeSession( + 'cache-pricing', + 'one-hour-cache', + '/tmp/cache-pricing', + '2099-05-05T10:00:00.000Z', + { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 60_120, + cache_creation: { + ephemeral_5m_input_tokens: 0, + ephemeral_1h_input_tokens: 60_120, + }, + }, + 'claude-opus-4-7', + ) + + const projects = await parseAllSessions(dayRange('2099-05-05'), 'claude') + + expect(projects).toHaveLength(1) + expect(projects[0]!.sessions[0]!.totalCacheWriteTokens).toBe(60_120) + expect(projects[0]!.totalCostUSD).toBeCloseTo(0.6012, 6) + }) + + it('falls back to the legacy 5-minute cache write rate when split fields are absent', async () => { + await writeClaudeSession( + 'legacy-cache-pricing', + 'legacy-cache', + '/tmp/legacy-cache-pricing', + '2099-05-06T10:00:00.000Z', + { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 60_120, + }, + 'claude-opus-4-7', + ) + + const projects = await parseAllSessions(dayRange('2099-05-06'), 'claude') + + expect(projects).toHaveLength(1) + expect(projects[0]!.sessions[0]!.totalCacheWriteTokens).toBe(60_120) + expect(projects[0]!.totalCostUSD).toBeCloseTo(0.37575, 6) + }) +})