mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-27 09:55:13 +00:00
kiro: price v1 executions from credits; unify fallback across parsers
v1 modern execution files carry the same metered credits as v2 (usageSummary[].usage, unit "credit" — the predecessor of v2's promptTurnSummaries), but the parser only harvested usedTools from that array and priced executions from estimated tokens. Since v2 only ships in brand-new IDE builds, v1 is the format nearly all Kiro IDE users are on today. Sum usage across usageSummary entries and price at the public overage rate. Align all three credit parsers (CLI, v1, v2) on one fallback contract — gate on summed credits > 0 and fall back to token-estimated pricing with costIsEstimated: true: - CLI turns without metering_usage were priced at a frozen $0 - CLI turns with an EMPTY metering_usage array (75 of 10,105 real turn metadatas — meta written before metering lands) passed the truthy presence check and froze $0 marked as real cost - legacy .chat calls now carry costIsEstimated: true, which was always the reality but never stated Validated against a real machine: 221 of 277 v1/legacy calls now carry metered cost ($20.11) instead of token estimates that had overstated them ~5x; verified usageSummary values are per-turn amounts, not cumulative counters (sequences fluctuate). Call counts unchanged before and after — nothing gained, lost, or double-counted. Also document the companion-file fingerprint blind spot at fingerprintFile: kiro CLI credits and v2 modelId live in companion files the single-file fingerprint never sees, so a parse racing the companion write can cache fallback values that only self-heal while the transcript keeps changing. AI-Origin: ai-generated AI-Tool: kiro
This commit is contained in:
parent
7dcd618898
commit
bdf4f3fe24
4 changed files with 139 additions and 11 deletions
|
|
@ -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 `<tool_use><name>...</name>` 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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -296,6 +296,15 @@ export async function saveCache(cache: SessionCache): Promise<void> {
|
|||
}
|
||||
|
||||
// ── File Fingerprinting ────────────────────────────────────────────────
|
||||
//
|
||||
// Fingerprints cover the source's transcript file only. Providers that keep
|
||||
// metadata in a companion file (kiro CLI: credits in `<id>.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<FileFingerprint | null> {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue