Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@
model aliases to priced Kimi K2 entries.

### Fixed (CLI)
- **Gemini and Mistral Vibe one-shot rates use provider turn grouping.**
Provider calls that belong to the same user turn are cached together, so
retry detection can see multi-message `Edit -> Bash -> Edit` flows instead
of counting each assistant message as an independent one-shot turn. Mistral
Vibe now splits assistant-message tool calls into turn-grouped calls while
preserving cumulative session token totals, and it prefers
`meta.json.stats.session_cost` over price-derived estimates when available
because current Vibe logs do not expose cache token fields. Session cache is
bumped to v2 so existing Gemini/Vibe cache entries are re-derived. Closes
#351.
- **OpenCode child sessions are attributed to their root session.** The
OpenCode parser now walks the unarchived `session.parent_id` subtree so
child and grandchild agent sessions contribute token and tool usage under
Expand Down
10 changes: 7 additions & 3 deletions docs/providers/mistral-vibe.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,20 @@ Subagent traces are stored under a parent session's `agents/` folder with the sa

## Caching

None.
Current Vibe local logs do not expose cache-read/cache-write token fields, so
CodeBurn reports cache token counts as `0`. When `meta.json.stats.session_cost`
is present, CodeBurn uses that session total instead of re-estimating from
prompt/completion token prices because it is the best cache-aware cost signal
available in the local log shape.

## Deduplication

Per `mistral-vibe:<session_id>`.

## Quirks

- **Usage is cumulative per session.** Vibe does not write per-assistant-message token usage into `messages.jsonl`; token counts come from `meta.json.stats.session_prompt_tokens` and `session_completion_tokens`. CodeBurn emits one usage record per Vibe session.
- **Cost prefers Vibe's own model prices.** `meta.json.stats.input_price_per_million` and `output_price_per_million` are used first, with the active model config as a fallback. LiteLLM pricing is only used when Vibe provides no price data.
- **Usage is cumulative per session.** Vibe does not write per-assistant-message token usage into `messages.jsonl`; token counts come from `meta.json.stats.session_prompt_tokens` and `session_completion_tokens`. CodeBurn splits assistant-message tools into their user turns for classification and distributes the cumulative token/cost totals across those assistant calls so session totals remain unchanged.
- **Cost prefers Vibe's own session total.** `meta.json.stats.session_cost` is used first. If it is missing, `meta.json.stats.input_price_per_million` and `output_price_per_million` are used with the active model config as a fallback. LiteLLM pricing is only used when Vibe provides no price data.
- **Project names come from metadata.** Discovery uses `meta.json.environment.working_directory` and falls back to the session directory name if that field is missing.
- **Tool calls come from messages.** Assistant `tool_calls[*].function.name` is normalized to the standard CodeBurn names (`bash` to `Bash`, `search_replace` to `Edit`, etc.). Bash commands are extracted from `function.arguments.command`.

Expand Down
84 changes: 59 additions & 25 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1517,6 +1517,32 @@ function providerCallToTurn(call: ParsedProviderCall): ParsedTurn {

// ── Cache Conversion ───────────────────────────────────────────────────

function providerCallToCachedCall(call: ParsedProviderCall): CachedCall {
return {
provider: call.provider,
model: call.model,
usage: {
inputTokens: call.inputTokens,
outputTokens: call.outputTokens,
cacheCreationInputTokens: call.cacheCreationInputTokens,
cacheReadInputTokens: call.cacheReadInputTokens,
cachedInputTokens: call.cachedInputTokens,
reasoningTokens: call.reasoningTokens,
webSearchRequests: call.webSearchRequests,
cacheCreationOneHourTokens: 0,
},
costUSD: call.provider === 'mistral-vibe' ? call.costUSD : undefined,
speed: call.speed,
timestamp: call.timestamp,
tools: call.tools,
bashCommands: call.bashCommands,
skills: [],
deduplicationKey: call.deduplicationKey,
project: call.project,
projectPath: call.projectPath,
}
}

function apiCallToCachedCall(call: ParsedApiCall): CachedCall {
return {
provider: call.provider,
Expand Down Expand Up @@ -1545,31 +1571,38 @@ function providerCallToCachedTurn(call: ParsedProviderCall): CachedTurn {
timestamp: call.timestamp,
sessionId: call.sessionId,
userMessage: call.userMessage.slice(0, 2000),
calls: [{
provider: call.provider,
model: call.model,
usage: {
inputTokens: call.inputTokens,
outputTokens: call.outputTokens,
cacheCreationInputTokens: call.cacheCreationInputTokens,
cacheReadInputTokens: call.cacheReadInputTokens,
cachedInputTokens: call.cachedInputTokens,
reasoningTokens: call.reasoningTokens,
webSearchRequests: call.webSearchRequests,
cacheCreationOneHourTokens: 0,
},
speed: call.speed,
timestamp: call.timestamp,
tools: call.tools,
bashCommands: call.bashCommands,
skills: [],
deduplicationKey: call.deduplicationKey,
project: call.project,
projectPath: call.projectPath,
}],
calls: [providerCallToCachedCall(call)],
}
}

function providerCallsToCachedTurns(calls: ParsedProviderCall[]): CachedTurn[] {
const turns: CachedTurn[] = []
const grouped = new Map<string, CachedTurn>()

for (const call of calls) {
if (!call.turnId) {
turns.push(providerCallToCachedTurn(call))
continue
}

const key = `${call.sessionId}\0${call.turnId}`
let turn = grouped.get(key)
if (!turn) {
turn = {
timestamp: call.timestamp,
sessionId: call.sessionId,
userMessage: call.userMessage.slice(0, 2000),
calls: [],
}
grouped.set(key, turn)
turns.push(turn)
}
turn.calls.push(providerCallToCachedCall(call))
}

return turns
}

function cachedCallToApiCall(call: CachedCall): ParsedApiCall {
const u = call.usage
const outputForCost = call.provider === 'claude'
Expand All @@ -1592,7 +1625,7 @@ function cachedCallToApiCall(call: CachedCall): ParsedApiCall {
reasoningTokens: u.reasoningTokens,
webSearchRequests: u.webSearchRequests,
},
costUSD,
costUSD: call.costUSD ?? costUSD,
tools: call.tools,
mcpTools: extractMcpTools(call.tools),
skills: call.skills,
Expand Down Expand Up @@ -1725,10 +1758,11 @@ async function parseProviderSources(
)

try {
const turns: CachedTurn[] = []
const calls: ParsedProviderCall[] = []
for await (const call of parser.parse()) {
turns.push(providerCallToCachedTurn(call))
calls.push(call)
}
const turns = providerCallsToCachedTurns(calls)
section.files[source.path] = { fingerprint: fp, mcpInventory: [], turns }
didParse = true
;(diskCache as { _dirty?: boolean })._dirty = true
Expand Down
4 changes: 4 additions & 0 deletions src/providers/gemini.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,8 @@ function parseSession(data: GeminiSession, seenKeys: Set<string>): ParsedProvide
const results: ParsedProviderCall[] = []

let lastUserMessage = ''
let turnOrdinal = 0
let currentTurnId = `${data.sessionId}:prelude`
let geminiOrdinal = 0

for (const msg of data.messages) {
Expand All @@ -76,6 +78,7 @@ function parseSession(data: GeminiSession, seenKeys: Set<string>): ParsedProvide
} else if (typeof msg.content === 'string') {
lastUserMessage = msg.content.slice(0, 500)
}
currentTurnId = `${data.sessionId}:turn-${turnOrdinal++}`
continue
}

Expand Down Expand Up @@ -136,6 +139,7 @@ function parseSession(data: GeminiSession, seenKeys: Set<string>): ParsedProvide
timestamp: tsDate.toISOString(),
speed: 'standard',
deduplicationKey: dedupKey,
turnId: currentTurnId,
userMessage: lastUserMessage,
sessionId: data.sessionId,
})
Expand Down
160 changes: 122 additions & 38 deletions src/providers/mistral-vibe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const toolNameMap: Record<string, string> = {
type VibeStats = {
session_prompt_tokens?: number
session_completion_tokens?: number
session_cost?: number
input_price_per_million?: number
output_price_per_million?: number
tokens_per_second?: number
Expand Down Expand Up @@ -75,6 +76,8 @@ type VibeToolCall = {
type VibeMessage = {
role?: string
content?: unknown
message_id?: string
timestamp?: string
tool_calls?: VibeToolCall[] | null
}

Expand Down Expand Up @@ -179,6 +182,9 @@ function safeNumber(value: unknown): number {

function calculateSessionCost(metadata: VibeMetadata, model: string, inputTokens: number, outputTokens: number): number {
const stats = metadata.stats ?? {}
const sessionCost = safeNumber(stats.session_cost)
if (sessionCost > 0) return sessionCost

const configured = activeModelConfig(metadata)
const inputPrice = safeNumber(stats.input_price_per_million) || safeNumber(configured?.input_price)
const outputPrice = safeNumber(stats.output_price_per_million) || safeNumber(configured?.output_price)
Expand Down Expand Up @@ -216,26 +222,41 @@ function parseToolArguments(raw: string | Record<string, unknown> | null | undef
}
}

function extractMessageTools(message: VibeMessage): { tools: string[]; bashCommands: string[] } {
const tools: string[] = []
const bashCommands: string[] = []

if (message.role !== 'assistant') return { tools, bashCommands }

for (const toolCall of message.tool_calls ?? []) {
const rawName = toolCall.function?.name
if (!rawName) continue

const mappedName = toolNameMap[rawName] ?? rawName
tools.push(mappedName)

if (mappedName !== 'Bash') continue
const args = parseToolArguments(toolCall.function?.arguments)
const command = args['command']
if (typeof command === 'string') {
bashCommands.push(...extractBashCommands(command))
}
}

return {
tools: [...new Set(tools)],
bashCommands: [...new Set(bashCommands)],
}
}

function extractTools(messages: VibeMessage[]): { tools: string[]; bashCommands: string[] } {
const tools: string[] = []
const bashCommands: string[] = []

for (const message of messages) {
if (message.role !== 'assistant') continue
for (const toolCall of message.tool_calls ?? []) {
const rawName = toolCall.function?.name
if (!rawName) continue

const mappedName = toolNameMap[rawName] ?? rawName
tools.push(mappedName)

if (mappedName !== 'Bash') continue
const args = parseToolArguments(toolCall.function?.arguments)
const command = args['command']
if (typeof command === 'string') {
bashCommands.push(...extractBashCommands(command))
}
}
const extracted = extractMessageTools(message)
tools.push(...extracted.tools)
bashCommands.push(...extracted.bashCommands)
}

return {
Expand Down Expand Up @@ -267,6 +288,17 @@ function firstUserMessage(messages: VibeMessage[], fallback?: string | null): st
return (fallback ?? '').slice(0, 500)
}

function allocateInteger(total: number, index: number, count: number): number {
if (count <= 1) return total
const base = Math.floor(total / count)
const remainder = total % count
return base + (index < remainder ? 1 : 0)
}

function allocateCost(total: number, count: number): number {
return count <= 1 ? total : total / count
}

function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
Expand All @@ -281,33 +313,85 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
if (inputTokens === 0 && outputTokens === 0) return

const sessionId = metadata.session_id || basename(source.path)
const deduplicationKey = `mistral-vibe:${sessionId}`
if (seenKeys.has(deduplicationKey)) return
seenKeys.add(deduplicationKey)

const messages = await readMessages(messagesPath)
const model = resolveModel(metadata)
const { tools, bashCommands } = extractTools(messages)
const costUSD = calculateSessionCost(metadata, model, inputTokens, outputTokens)
const assistantMessages = messages.filter(message => message.role === 'assistant')
const fallbackTimestamp = metadata.end_time ?? metadata.start_time ?? ''

if (assistantMessages.length === 0) {
const deduplicationKey = `mistral-vibe:${sessionId}`
if (seenKeys.has(deduplicationKey)) return
seenKeys.add(deduplicationKey)
const { tools, bashCommands } = extractTools(messages)

yield {
provider: 'mistral-vibe',
model,
inputTokens,
outputTokens,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
costUSD,
tools,
bashCommands,
timestamp: metadata.end_time ?? metadata.start_time ?? '',
speed: 'standard',
deduplicationKey,
userMessage: firstUserMessage(messages, metadata.title),
sessionId,
yield {
provider: 'mistral-vibe',
model,
inputTokens,
outputTokens,
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
costUSD,
tools,
bashCommands,
timestamp: fallbackTimestamp,
speed: 'standard',
deduplicationKey,
userMessage: firstUserMessage(messages, metadata.title),
sessionId,
}
return
}

let currentUserMessage = (metadata.title ?? '').slice(0, 500)
let turnOrdinal = 0
let currentTurnId = `${sessionId}:prelude`
let assistantOrdinal = 0

for (const message of messages) {
if (message.role === 'user') {
const text = normalizeContent(message.content).trim()
if (text) currentUserMessage = text.slice(0, 500)
currentTurnId = `${sessionId}:turn-${turnOrdinal++}`
continue
}

if (message.role !== 'assistant') continue

const messageKey = message.message_id || `idx-${assistantOrdinal}`
const deduplicationKey = `mistral-vibe:${sessionId}:${messageKey}`
const allocationIndex = assistantOrdinal
assistantOrdinal++

if (seenKeys.has(deduplicationKey)) continue
seenKeys.add(deduplicationKey)

const { tools, bashCommands } = extractMessageTools(message)

yield {
provider: 'mistral-vibe',
model,
inputTokens: allocateInteger(inputTokens, allocationIndex, assistantMessages.length),
outputTokens: allocateInteger(outputTokens, allocationIndex, assistantMessages.length),
cacheCreationInputTokens: 0,
cacheReadInputTokens: 0,
cachedInputTokens: 0,
reasoningTokens: 0,
webSearchRequests: 0,
costUSD: allocateCost(costUSD, assistantMessages.length),
tools,
bashCommands,
timestamp: message.timestamp ?? fallbackTimestamp,
speed: 'standard',
deduplicationKey,
turnId: currentTurnId,
userMessage: currentUserMessage,
sessionId,
}
}
},
}
Expand Down
Loading
Loading