diff --git a/src/providers/codewhale.ts b/src/providers/codewhale.ts index 5135409..882c94a 100644 --- a/src/providers/codewhale.ts +++ b/src/providers/codewhale.ts @@ -4,7 +4,7 @@ import { join } from 'path' import { extractBashCommands } from '../bash-utils.js' import { readSessionFile } from '../fs-utils.js' -import { calculateCost, getShortModelName } from '../models.js' +import { getShortModelName } from '../models.js' import type { ToolCall } from '../types.js' import type { ParsedProviderCall, Provider, SessionParser, SessionSource } from './types.js' @@ -390,10 +390,9 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars const totalTokens = safeTokenCount(metadata.total_tokens) const model = metadata.model ?? metadata.model_provider ?? 'unknown' const localCost = reportedCost(metadata.cost) - const costUSD = localCost.exact - ? localCost.value - : calculateCost(model, totalTokens, 0, 0, 0, 0) - if (totalTokens === 0 && costUSD === 0) return + // Match the pre-lift guard exactly: it skipped zero-token sessions whose + // COMPUTED cost was 0 — which included an exact recorded cost of 0. + if (totalTokens === 0 && (!localCost.exact || localCost.value === 0)) return const deduplicationKey = `codewhale:${metadata.id}` if (seenKeys.has(deduplicationKey)) return @@ -420,7 +419,9 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests, - costUSD, + ...(localCost.exact + ? { costUSD: localCost.value, costBasis: 'measured' as const } + : { costBasis: 'estimated' as const }), costIsEstimated: !localCost.exact, tools, bashCommands, diff --git a/src/providers/hermes.ts b/src/providers/hermes.ts index 309b29e..4ce303d 100644 --- a/src/providers/hermes.ts +++ b/src/providers/hermes.ts @@ -2,7 +2,7 @@ import { readdir, stat } from 'fs/promises' import { basename, dirname, join } from 'path' import { homedir } from 'os' -import { calculateCost, getShortModelName } from '../models.js' +import { getShortModelName } from '../models.js' import { isSqliteAvailable, getSqliteLoadError, openDatabase, isSqliteBusyError, type SqliteDatabase } from '../sqlite.js' import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' import type { ToolCall } from '../types.js' @@ -390,23 +390,12 @@ function createParser(source: SessionSource, seenKeys: Set, hermesHome: seenKeys.add(dedupKey) // Hermes bills reasoning tokens at the output rate (same as Gemini). - // The LiteLLM model table is used as a fallback when Hermes has not - // stored an actual or estimated cost for the session. - const calculatedCost = calculateCost( - model, - inputTokens, - outputTokens + reasoningTokens, - cacheWriteTokens, - cacheReadTokens, - 0, - ) + // When Hermes stored an actual or estimated cost, pass it as measured; + // otherwise the pricing pass will estimate from token buckets. const recordedCost = (row.actual_cost_usd ?? 0) > 0 ? row.actual_cost_usd! : (row.estimated_cost_usd ?? 0) > 0 ? row.estimated_cost_usd! : null - // When Hermes stored no cost (e.g. subscription-billed sessions), the - // figure is our LiteLLM-priced estimate from the session token totals. - const costUSD = recordedCost ?? calculatedCost const costIsEstimated = recordedCost === null result = { @@ -419,7 +408,9 @@ function createParser(source: SessionSource, seenKeys: Set, hermesHome: cachedInputTokens: cacheReadTokens, reasoningTokens, webSearchRequests: 0, - costUSD, + ...(recordedCost !== null + ? { costUSD: recordedCost, costBasis: 'measured' as const } + : { costBasis: 'estimated' as const }), costIsEstimated, tools, bashCommands, diff --git a/src/providers/kiro.ts b/src/providers/kiro.ts index 57c5137..75037d7 100644 --- a/src/providers/kiro.ts +++ b/src/providers/kiro.ts @@ -5,7 +5,6 @@ import { basename, dirname, extname, join } from 'path' import { homedir } from 'os' import { readSessionFile } from '../fs-utils.js' -import { calculateCost } from '../models.js' import { estimateTokensFromChars } from '../token-estimate.js' import type { ToolCall } from '../types.js' import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' @@ -227,7 +226,6 @@ function parseChatFile(data: KiroChatFile, sessionId: string, project: string, s const outputTokens = estimateTokensFromChars(totalOutputChars) const inputTokens = estimateTokensFromChars(pendingUserMessage.length) - const costUSD = calculateCost(modelId, inputTokens, outputTokens, 0, 0, 0) const tsDate = parseKiroTimestamp(metadata.startTime) if (!tsDate) return results const timestamp = tsDate.toISOString() @@ -243,7 +241,7 @@ function parseChatFile(data: KiroChatFile, sessionId: string, project: string, s cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, - costUSD, + costBasis: 'estimated', costIsEstimated: true, tools: [...new Set(allTools)], bashCommands: [], @@ -377,9 +375,6 @@ function parseModernExecution(data: KiroModernExecution, sourcePath: string, see // Prefer real metered credits at the public overage rate; fall back to // token-estimated pricing when the execution has no usage data — same // contract as the CLI and v2 parsers. - const costUSD = executionCredits > 0 - ? executionCredits * USD_PER_KIRO_CREDIT - : calculateCost(modelId, inputTokens, outputTokens, 0, 0, 0) seenKeys.add(dedupKey) results.push({ @@ -392,7 +387,9 @@ function parseModernExecution(data: KiroModernExecution, sourcePath: string, see cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, - costUSD, + ...(executionCredits > 0 + ? { costUSD: executionCredits * USD_PER_KIRO_CREDIT, costBasis: 'measured' as const } + : { costBasis: 'estimated' as const }), costIsEstimated: executionCredits === 0, tools: [...new Set(allTools)], bashCommands: [], @@ -473,9 +470,6 @@ function parseCliSession(meta: KiroCliSessionMeta, entries: KiroCliEntry[], seen const turnCredits = turnMeta?.metering_usage ? turnMeta.metering_usage.reduce((sum, m) => sum + m.value, 0) : 0 - const costUSD = turnCredits > 0 - ? turnCredits * USD_PER_KIRO_CREDIT - : calculateCost(modelId, inputTokens, outputTokens, 0, 0, 0) seenKeys.add(dedupKey) results.push({ @@ -488,7 +482,9 @@ function parseCliSession(meta: KiroCliSessionMeta, entries: KiroCliEntry[], seen cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, - costUSD, + ...(turnCredits > 0 + ? { costUSD: turnCredits * USD_PER_KIRO_CREDIT, costBasis: 'measured' as const } + : { costBasis: 'estimated' as const }), costIsEstimated: turnCredits === 0, tools: [...new Set(allTools)], bashCommands: [], @@ -633,7 +629,6 @@ async function parseWorkspaceSession(record: Record, source: Se const inputTokens = estimateTokensFromChars(inputChars) const outputTokens = estimateTokensFromChars(outputChars) - const costUSD = calculateCost(modelId, inputTokens, outputTokens, 0, 0, 0) results.push({ provider: 'kiro', @@ -645,7 +640,7 @@ async function parseWorkspaceSession(record: Record, source: Se cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, - costUSD, + costBasis: 'estimated', costIsEstimated: true, tools: [...new Set(allTools)], bashCommands: [], @@ -743,9 +738,6 @@ async function parseV2Session(source: SessionSource, seenKeys: Set): Pro // no usage_summary (e.g. still in progress or null usage). Reasoning // text is billed as output, so combine it for pricing only (same as // the codex provider). - const costUSD = turnCredits > 0 - ? turnCredits * USD_PER_KIRO_CREDIT - : calculateCost(modelId, inputTokens, outputTokens + reasoningTokens, 0, 0, 0) results.push({ provider: 'kiro', model: modelId, @@ -756,7 +748,9 @@ async function parseV2Session(source: SessionSource, seenKeys: Set): Pro cachedInputTokens: 0, reasoningTokens, webSearchRequests: 0, - costUSD, + ...(turnCredits > 0 + ? { costUSD: turnCredits * USD_PER_KIRO_CREDIT, costBasis: 'measured' as const } + : { costBasis: 'estimated' as const }), costIsEstimated: turnCredits === 0, tools: [...new Set(tools)], bashCommands: [], diff --git a/tests/providers/codewhale.test.ts b/tests/providers/codewhale.test.ts index bdf9fd3..eda5609 100644 --- a/tests/providers/codewhale.test.ts +++ b/tests/providers/codewhale.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'os' import { join } from 'path' import { clearSessionCache, parseAllSessions } from '../../src/parser.js' +import { priceProviderCall } from '../../src/pricing-pass.js' import { sessionCachePath } from '../../src/session-cache.js' import { MAX_SESSION_FILE_BYTES } from '../../src/fs-utils.js' import { codewhale, createCodeWhaleProvider } from '../../src/providers/codewhale.js' @@ -64,7 +65,7 @@ async function parseOne(path: string, seenKeys = new Set()): Promise { const source = { path: chatPath, project: 'myproject', provider: 'kiro' } const calls: ParsedProviderCall[] = [] - for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(priceProviderCall(call)) expect(calls).toHaveLength(1) const call = calls[0]! @@ -123,7 +124,7 @@ describe('kiro provider - chat file parsing', () => { const source = { path: chatPath, project: 'test', provider: 'kiro' } const calls: ParsedProviderCall[] = [] - for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(priceProviderCall(call)) expect(calls).toHaveLength(1) expect(calls[0]!.model).toBe('kiro-auto') @@ -158,7 +159,7 @@ describe('kiro provider - chat file parsing', () => { const source = { path: chatPath, project: 'test', provider: 'kiro' } const calls: ParsedProviderCall[] = [] - for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(priceProviderCall(call)) expect(calls).toHaveLength(0) }) @@ -174,10 +175,10 @@ describe('kiro provider - chat file parsing', () => { const seenKeys = new Set() const calls1: ParsedProviderCall[] = [] - for await (const call of kiro.createSessionParser(source, seenKeys).parse()) calls1.push(call) + for await (const call of kiro.createSessionParser(source, seenKeys).parse()) calls1.push(priceProviderCall(call)) const calls2: ParsedProviderCall[] = [] - for await (const call of kiro.createSessionParser(source, seenKeys).parse()) calls2.push(call) + for await (const call of kiro.createSessionParser(source, seenKeys).parse()) calls2.push(priceProviderCall(call)) expect(calls1).toHaveLength(1) expect(calls2).toHaveLength(0) @@ -186,7 +187,7 @@ describe('kiro provider - chat file parsing', () => { it('returns empty for missing file', async () => { const source = { path: '/nonexistent/test.chat', project: 'test', provider: 'kiro' } const calls: ParsedProviderCall[] = [] - for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(priceProviderCall(call)) expect(calls).toHaveLength(0) }) @@ -199,7 +200,7 @@ describe('kiro provider - chat file parsing', () => { const source = { path: chatPath, project: 'test', provider: 'kiro' } const calls: ParsedProviderCall[] = [] - for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(priceProviderCall(call)) expect(calls).toHaveLength(0) }) @@ -213,7 +214,7 @@ describe('kiro provider - chat file parsing', () => { const source = { path: chatPath, project: 'test', provider: 'kiro' } const calls: ParsedProviderCall[] = [] - for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(priceProviderCall(call)) expect(calls).toHaveLength(1) expect(calls[0]!.outputTokens).toBe(109) @@ -231,7 +232,7 @@ describe('kiro provider - chat file parsing', () => { const source = { path: chatPath, project: 'test', provider: 'kiro' } const calls: ParsedProviderCall[] = [] - for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(priceProviderCall(call)) expect(calls).toHaveLength(1) expect(calls[0]!.model).toBe('claude-haiku-4-5') @@ -250,7 +251,7 @@ describe('kiro provider - chat file parsing', () => { const source = { path: chatPath, project: 'test', provider: 'kiro' } const calls: ParsedProviderCall[] = [] - for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(priceProviderCall(call)) expect(calls).toHaveLength(1) expect(calls[0]!.sessionId).toBe('my-workflow-id') @@ -272,7 +273,7 @@ describe('kiro provider - chat file parsing', () => { const source = { path: executionPath, project: 'test', provider: 'kiro' } const calls: ParsedProviderCall[] = [] - for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(priceProviderCall(call)) expect(calls).toHaveLength(1) const call = calls[0]! @@ -303,7 +304,7 @@ describe('kiro provider - chat file parsing', () => { const source = { path: indexPath, project: 'test', provider: 'kiro' } const calls: ParsedProviderCall[] = [] - for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(priceProviderCall(call)) expect(calls).toHaveLength(0) }) @@ -325,7 +326,7 @@ describe('kiro provider - chat file parsing', () => { const source = { path: executionPath, project: 'test', provider: 'kiro' } const calls: ParsedProviderCall[] = [] - for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(priceProviderCall(call)) expect(calls).toHaveLength(1) expect(calls[0]!.model).toBe('kiro-auto') @@ -345,7 +346,7 @@ describe('kiro provider - chat file parsing', () => { const source = { path: executionPath, project: 'test', provider: 'kiro' } const calls: ParsedProviderCall[] = [] - for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(priceProviderCall(call)) expect(calls).toHaveLength(1) expect(calls[0]!.timestamp).toBe('2026-04-27T23:36:40.000Z') @@ -363,7 +364,7 @@ describe('kiro provider - chat file parsing', () => { const source = { path: executionPath, project: 'test', provider: 'kiro' } const calls: ParsedProviderCall[] = [] - for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(priceProviderCall(call)) expect(calls).toHaveLength(1) expect(calls[0]!.timestamp).toBe('2026-04-27T23:36:40.000Z') @@ -421,7 +422,7 @@ describe('kiro provider - chat file parsing', () => { const source = { path: executionPath, project: 'test', provider: 'kiro' } const calls: ParsedProviderCall[] = [] - for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(priceProviderCall(call)) expect(calls).toHaveLength(1) expect(calls[0]!.userMessage).toBe(`request from ${key}`) @@ -448,7 +449,7 @@ describe('kiro provider - chat file parsing', () => { const source = { path: executionPath, project: 'test', provider: 'kiro' } const calls: ParsedProviderCall[] = [] - for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(priceProviderCall(call)) expect(calls).toHaveLength(1) expect(calls[0]!.tools).toEqual(['Bash']) @@ -474,7 +475,7 @@ describe('kiro provider - chat file parsing', () => { const source = { path: executionPath, project: 'test', provider: 'kiro' } const calls: ParsedProviderCall[] = [] - for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(priceProviderCall(call)) expect(calls).toHaveLength(1) expect(calls[0]!.tools).toEqual(['Edit']) @@ -677,7 +678,7 @@ describe('kiro provider - CLI session discovery', () => { const source = { path: join(cliDir, `${sessionId}.jsonl`), project: 'test-project', provider: 'kiro' } const calls: ParsedProviderCall[] = [] - for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(priceProviderCall(call)) expect(calls).toHaveLength(1) const call = calls[0]! @@ -719,7 +720,7 @@ describe('kiro provider - CLI session discovery', () => { const source = { path: join(cliDir, `${sessionId}.jsonl`), project: 'multi', provider: 'kiro' } const calls: ParsedProviderCall[] = [] - for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(priceProviderCall(call)) expect(calls).toHaveLength(2) expect(calls[0]!.userMessage).toBe('first question') @@ -749,7 +750,7 @@ describe('kiro provider - CLI session discovery', () => { const source = { path: join(cliDir, `${sessionId}.jsonl`), project: 'test-project', provider: 'kiro' } const calls: ParsedProviderCall[] = [] - for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(priceProviderCall(call)) expect(calls).toHaveLength(1) // Token-priced at the session's real model, not $0 — same fallback @@ -783,7 +784,7 @@ describe('kiro provider - CLI session discovery', () => { const source = { path: join(cliDir, `${sessionId}.jsonl`), project: 'test-project', provider: 'kiro' } const calls: ParsedProviderCall[] = [] - for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(call) + for await (const call of kiro.createSessionParser(source, new Set()).parse()) calls.push(priceProviderCall(call)) expect(calls).toHaveLength(1) expect(calls[0]!.costIsEstimated).toBe(true) @@ -839,7 +840,7 @@ describe('kiro provider - context.messages with entries', () => { const calls: ParsedProviderCall[] = [] for (const source of sessions) { for await (const call of provider.createSessionParser(source, new Set()).parse()) { - calls.push(call) + calls.push(priceProviderCall(call)) } } @@ -879,7 +880,7 @@ describe('kiro provider - context.messages with entries', () => { const calls: ParsedProviderCall[] = [] for (const source of sessions) { for await (const call of provider.createSessionParser(source, new Set()).parse()) { - calls.push(call) + calls.push(priceProviderCall(call)) } } @@ -919,7 +920,7 @@ describe('kiro provider - context.messages with entries', () => { const calls: ParsedProviderCall[] = [] for (const source of sessions) { for await (const call of provider.createSessionParser(source, new Set()).parse()) { - calls.push(call) + calls.push(priceProviderCall(call)) } } @@ -947,7 +948,7 @@ describe('kiro provider - context.messages with entries', () => { const calls: ParsedProviderCall[] = [] for (const source of sessions) { for await (const call of provider.createSessionParser(source, new Set()).parse()) { - calls.push(call) + calls.push(priceProviderCall(call)) } } @@ -996,7 +997,7 @@ describe('kiro provider - workspace-sessions format', () => { const calls: ParsedProviderCall[] = [] for (const source of wsSessions) { for await (const call of provider.createSessionParser(source, new Set()).parse()) { - calls.push(call) + calls.push(priceProviderCall(call)) } } @@ -1032,7 +1033,7 @@ describe('kiro provider - workspace-sessions format', () => { const calls: ParsedProviderCall[] = [] for (const source of sessions) { for await (const call of provider.createSessionParser(source, new Set()).parse()) { - calls.push(call) + calls.push(priceProviderCall(call)) } } @@ -1139,7 +1140,7 @@ describe('kiro provider - v2 sess_ format', () => { const calls: ParsedProviderCall[] = [] for (const source of v2) { - for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(priceProviderCall(call)) } expect(calls).toHaveLength(2) @@ -1173,7 +1174,7 @@ describe('kiro provider - v2 sess_ format', () => { const sources = await provider.discoverSessions() const calls: ParsedProviderCall[] = [] for (const source of sources) { - for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(priceProviderCall(call)) } expect(calls).toHaveLength(2) @@ -1199,7 +1200,7 @@ describe('kiro provider - v2 sess_ format', () => { const sources = await provider.discoverSessions() const calls: ParsedProviderCall[] = [] for (const source of sources) { - for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(priceProviderCall(call)) } expect(calls).toHaveLength(2) @@ -1218,7 +1219,7 @@ describe('kiro provider - v2 sess_ format', () => { const sources = await provider.discoverSessions() const calls: ParsedProviderCall[] = [] for (const source of sources) { - for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(priceProviderCall(call)) } expect(calls).toHaveLength(1) @@ -1325,7 +1326,7 @@ describe('kiro provider - mixed-format coexistence (legacy + v1 + workspace-sess const seenKeys = new Set() const calls: ParsedProviderCall[] = [] for (const source of sources) { - for await (const call of provider.createSessionParser(source, seenKeys).parse()) calls.push(call) + for await (const call of provider.createSessionParser(source, seenKeys).parse()) calls.push(priceProviderCall(call)) } return calls }