diff --git a/docs/providers/kiro.md b/docs/providers/kiro.md index 26cf543..0252e90 100644 --- a/docs/providers/kiro.md +++ b/docs/providers/kiro.md @@ -57,7 +57,7 @@ The stores are disjoint (v2 sessions use `sess_`-prefixed IDs in a separate dire - **Model ID normalization.** Kiro stores models like `claude-1.2`; the parser rewrites the dot to a hyphen so they match `claude-1-2` in the pricing snapshot. Add new versions here when Kiro ships them. - **Tool name extraction accepts text and structured calls.** Kiro can embed tool calls inside message text as `...` or expose structured `toolCalls` / `tool_calls` / `tools` entries. - Token counts are estimated via char count (`CHARS_PER_TOKEN = 4`). -- **Credits are the cost source; tokens stay estimated.** Kiro bills in credits ($20/mo for 1,000; overage $0.04/credit). CLI (`metering_usage`) and v2 (`usage_summary.promptTurnSummaries[].usage`) turns record real credits, converted to USD at `USD_PER_KIRO_CREDIT = 0.04` (the public overage rate — the same never-understate approach as Codebuff). Turns without credit data fall back to token-estimated cost (`costIsEstimated: true`). Note: an earlier CLI implementation summed credit values directly as dollars, overstating cost 25×. Token *counts* remain char-estimated everywhere (input undercounts: only visible transcript text is seen, not the full resent context; v2's `session_metadata.contextUsage.usagePercentage` × context window is a better input proxy if ever needed). v2 does keep the real `modelId`, so unlike the v1 execution-file path it is not mislabeled `kiro-auto`. +- **Credits are the cost source; tokens stay estimated.** Kiro bills in credits ($20/mo for 1,000; overage $0.04/credit). CLI (`metering_usage`), v1 executions (`usageSummary[].usage`), and v2 (`usage_summary.promptTurnSummaries[].usage`) turns record real credits, converted to USD at `USD_PER_KIRO_CREDIT = 0.04` (the public overage rate — the same never-understate approach as Codebuff). Turns without credit data fall back to token-estimated cost (`costIsEstimated: true`); legacy `.chat` and workspace-session records carry no usage data, so they are always token-estimated. Note: an earlier CLI implementation summed credit values directly as dollars, overstating cost 25×. Token *counts* remain char-estimated everywhere (input undercounts: only visible transcript text is seen, not the full resent context; v2's `session_metadata.contextUsage.usagePercentage` × context window is a better input proxy if ever needed). v2 does keep the real `modelId`, so unlike the v1 execution-file path it is not mislabeled `kiro-auto`. - **Cost is frozen at parse time.** Kiro is on the `costUSD` pass-through allowlist in `providerCallToCachedCall` (alongside mistral-vibe, devin, hermes, …), so its credit-based cost survives the session cache instead of being re-priced from estimated tokens — token re-pricing understated/overstated real kiro spend by up to 16× per model. The tradeoff, shared with all allowlisted providers: `codeburn price-override` and `model-alias` do not affect kiro dollar amounts (token *counts* are unaffected). Historical caches from before this change re-parse via the `CACHE_VERSION` bump to 5. ## When fixing a bug here diff --git a/src/providers/kiro.ts b/src/providers/kiro.ts index eaeba3b..bc91e1e 100644 --- a/src/providers/kiro.ts +++ b/src/providers/kiro.ts @@ -244,6 +244,7 @@ function parseChatFile(data: KiroChatFile, sessionId: string, project: string, s reasoningTokens: 0, webSearchRequests: 0, costUSD, + costIsEstimated: true, tools: [...new Set(allTools)], bashCommands: [], toolSequence: toolSequence.length > 1 ? toolSequence : undefined, @@ -335,13 +336,18 @@ function parseModernExecution(data: KiroModernExecution, sourcePath: string, see if (found) break } - // Extract tools from usageSummary (reliable structured tool list in current Kiro builds). - // usageSummary is an array of per-turn entries with optional usedTools field. + // Extract tools and metered credits from usageSummary (reliable structured + // data in current Kiro builds). usageSummary is an array of per-turn entries + // with optional usedTools and usage (credits, unit "credit") fields — the + // v1 predecessor of v2's usage_summary.promptTurnSummaries. + let executionCredits = 0 const usageSummary = data['usageSummary'] if (Array.isArray(usageSummary)) { for (const entry of usageSummary) { const rec = asRecord(entry) if (!rec) continue + const usage = rec['usage'] + if (typeof usage === 'number' && Number.isFinite(usage)) executionCredits += usage const usedTools = rec['usedTools'] if (Array.isArray(usedTools)) { for (const tool of usedTools) { @@ -368,7 +374,12 @@ function parseModernExecution(data: KiroModernExecution, sourcePath: string, see const inputTokens = Math.ceil(inputChars / CHARS_PER_TOKEN) const outputTokens = Math.ceil(outputChars / CHARS_PER_TOKEN) - const costUSD = calculateCost(modelId, inputTokens, outputTokens, 0, 0, 0) + // 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({ @@ -382,6 +393,7 @@ function parseModernExecution(data: KiroModernExecution, sourcePath: string, see reasoningTokens: 0, webSearchRequests: 0, costUSD, + costIsEstimated: executionCredits === 0, tools: [...new Set(allTools)], bashCommands: [], timestamp: tsDate.toISOString(), @@ -450,14 +462,20 @@ function parseCliSession(meta: KiroCliSessionMeta, entries: KiroCliEntry[], seen const tsDate = parseKiroTimestamp(timestamp) if (!tsDate) { turnIndex++; return } - // metering_usage values are credits (unit: "credit"), not dollars — - // convert at the public overage rate. - const costUSD = turnMeta?.metering_usage - ? turnMeta.metering_usage.reduce((sum, m) => sum + m.value, 0) * USD_PER_KIRO_CREDIT - : 0 - const inputTokens = Math.ceil(inputChars / CHARS_PER_TOKEN) const outputTokens = Math.ceil(outputChars / CHARS_PER_TOKEN) + // metering_usage values are credits (unit: "credit"), not dollars — + // convert at the public overage rate. Gate on credits > 0, not array + // presence: real sessions carry empty metering_usage arrays (turn still + // in flight when the meta was written), which must fall back to + // token-estimated pricing — same contract as the v1-execution and v2 + // parsers. + 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({ @@ -471,7 +489,7 @@ function parseCliSession(meta: KiroCliSessionMeta, entries: KiroCliEntry[], seen reasoningTokens: 0, webSearchRequests: 0, costUSD, - costIsEstimated: !turnMeta?.metering_usage, + costIsEstimated: turnCredits === 0, tools: [...new Set(allTools)], bashCommands: [], timestamp: tsDate.toISOString(), diff --git a/src/session-cache.ts b/src/session-cache.ts index ab6d5a2..7f7b79e 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -296,6 +296,15 @@ export async function saveCache(cache: SessionCache): Promise { } // ── File Fingerprinting ──────────────────────────────────────────────── +// +// Fingerprints cover the source's transcript file only. Providers that keep +// metadata in a companion file (kiro CLI: credits in `.json` next to the +// `.jsonl`; kiro v2: modelId in `session.json` next to `messages.jsonl`) have +// a blind spot: a parse that races the companion write caches the turn with +// fallback values, and if the transcript never changes again (a session's +// final turn) the entry never invalidates. Mid-session turns self-heal since +// append-only transcripts keep changing. Fixing this properly means +// multi-file fingerprints per source. export async function fingerprintFile(filePath: string): Promise { try { diff --git a/tests/providers/kiro.test.ts b/tests/providers/kiro.test.ts index b16ce83..a12ae4b 100644 --- a/tests/providers/kiro.test.ts +++ b/tests/providers/kiro.test.ts @@ -728,6 +728,68 @@ describe('kiro provider - CLI session discovery', () => { expect(calls[1]!.costUSD).toBeCloseTo(0.06 * 0.04, 4) // 0.06 credits × $0.04/credit }) + it('falls back to token-estimated cost for CLI turns without metering_usage', async () => { + const sessionId = '44444444-4444-4444-4444-444444444444' + const jsonl = [ + JSON.stringify({ version: '1', kind: 'Prompt', data: { message_id: 'm1', content: [{ kind: 'text', data: 'hi' }], meta: { timestamp: 1700000000 } } }), + JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm2', content: [{ kind: 'text', data: 'x'.repeat(4000) }] } }), + ].join('\n') + + await writeFile(join(cliDir, `${sessionId}.jsonl`), jsonl) + await writeFile(join(cliDir, `${sessionId}.json`), JSON.stringify({ + session_id: sessionId, + cwd: '/tmp/test-project', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:01:00Z', + session_state: { + rts_model_state: { model_info: { model_id: 'claude-sonnet-4.6' } }, + // no conversation_metadata / metering_usage (e.g. turn still in flight) + }, + })) + + 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) + + expect(calls).toHaveLength(1) + // Token-priced at the session's real model, not $0 — same fallback + // contract as the v1-execution and v2 parsers. + expect(calls[0]!.costIsEstimated).toBe(true) + expect(calls[0]!.costUSD).toBeGreaterThan(0) + }) + + it('treats an empty metering_usage array as no metering (token fallback, not frozen $0)', async () => { + const sessionId = '55555555-5555-5555-5555-555555555555' + const jsonl = [ + JSON.stringify({ version: '1', kind: 'Prompt', data: { message_id: 'm1', content: [{ kind: 'text', data: 'hi' }], meta: { timestamp: 1700000000 } } }), + JSON.stringify({ version: '1', kind: 'AssistantMessage', data: { message_id: 'm2', content: [{ kind: 'text', data: 'x'.repeat(4000) }] } }), + ].join('\n') + + await writeFile(join(cliDir, `${sessionId}.jsonl`), jsonl) + await writeFile(join(cliDir, `${sessionId}.json`), JSON.stringify({ + session_id: sessionId, + cwd: '/tmp/test-project', + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:01:00Z', + session_state: { + rts_model_state: { model_info: { model_id: 'claude-sonnet-4.6' } }, + conversation_metadata: { + // Observed in real sessions: the turn metadata exists but metering + // hasn't landed yet — an empty array, which is truthy. + user_turn_metadatas: [{ end_timestamp: '2026-01-01T00:00:30Z', metering_usage: [] }], + }, + }, + })) + + 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) + + expect(calls).toHaveLength(1) + expect(calls[0]!.costIsEstimated).toBe(true) + expect(calls[0]!.costUSD).toBeGreaterThan(0) + }) + it('skips non-jsonl files in CLI directory', async () => { await writeFile(join(cliDir, 'something.json'), '{}') await writeFile(join(cliDir, 'something.lock'), '') @@ -826,6 +888,45 @@ describe('kiro provider - context.messages with entries', () => { expect(call.tools).toContain('aws_sentral_mcp_search_accounts') expect(call.tools).toContain('Bash') expect(call.tools).toContain('Read') + // usageSummary credits (0.5 + 1.0) price the execution at $0.04/credit + expect(call.costUSD).toBeCloseTo(1.5 * 0.04, 8) + expect(call.costIsEstimated).toBe(false) + }) + + it('falls back to token-estimated cost when a modern execution has no usageSummary credits', async () => { + const file = JSON.stringify({ + executionId: 'exec-nocredits-001', + workflowType: 'chat-agent', + status: 'succeed', + startTime: 1777333000000, + chatSessionId: 'session-nocredits-001', + modelId: 'claude-sonnet-4.6', + context: { + messages: [ + { role: 'human', entries: ['hello'] }, + { role: 'bot', entries: ['x'.repeat(4000)] }, + ], + }, + }) + + const wsHash = 'c'.repeat(32) + const subDir = 'e'.repeat(32) + await mkdir(join(tmpDir, wsHash, subDir), { recursive: true }) + await writeFile(join(tmpDir, wsHash, subDir, 'exec-nocredits-001'), file) + + const provider = createKiroProvider(tmpDir, tmpDir, '/nonexistent/cli') + const sessions = await provider.discoverSessions() + const calls: ParsedProviderCall[] = [] + for (const source of sessions) { + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + } + + expect(calls).toHaveLength(1) + const call = calls[0]! + expect(call.costIsEstimated).toBe(true) + expect(call.costUSD).toBeGreaterThan(0) // token-estimated at the real model }) it('skips execution index files with executions array', async () => {