Add Mistral Vibe provider

This commit is contained in:
ozymandiashh 2026-05-11 16:54:28 +03:00
parent d9acd8c4cd
commit e2d4e565f8
9 changed files with 713 additions and 3 deletions

View file

@ -1,5 +1,15 @@
# Changelog
## Unreleased
### Added (CLI)
- **Mistral Vibe provider.** CodeBurn now reads Mistral Vibe session folders
from `$VIBE_HOME/logs/session/` or `~/.vibe/logs/session/`, using
`meta.json` for cumulative prompt/completion tokens, model pricing, and
timestamps, and `messages.jsonl` for user prompts and tool calls. Subagent
sessions under a parent session's `agents/` folder are tracked separately.
Closes #283.
## 0.9.8 - 2026-05-10
### Added (CLI)

View file

@ -13,7 +13,7 @@
<a href="https://github.com/sponsors/iamtoruk"><img src="https://img.shields.io/badge/sponsor-♥-ea4aaa?logo=github" alt="Sponsor" /></a>
</p>
CodeBurn tracks token usage, cost, and performance across **18 AI coding tools**. It breaks down spending by task type, model, tool, project, and provider so you can see exactly where your budget goes.
CodeBurn tracks token usage, cost, and performance across **19 AI coding tools**. It breaks down spending by task type, model, tool, project, and provider so you can see exactly where your budget goes.
Everything runs locally. No wrapper, no proxy, no API keys. CodeBurn reads session data directly from disk and prices every call using [LiteLLM](https://github.com/BerriAI/litellm).
@ -103,6 +103,7 @@ Arrow keys switch between Today, 7 Days, 30 Days, Month, and 6 Months (use `--fr
| <img src="assets/providers/cursor.jpg" width="28" /> | Cursor | Yes | [cursor.md](docs/providers/cursor.md) |
| <img src="assets/providers/cursor-agent.jpg" width="28" /> | cursor-agent | Yes | [cursor-agent.md](docs/providers/cursor-agent.md) |
| <img src="assets/providers/gemini.png" width="28" /> | Gemini CLI | Yes | [gemini.md](docs/providers/gemini.md) |
| <img src="assets/providers/mistral-vibe.svg" width="28" /> | Mistral Vibe | Yes | [mistral-vibe.md](docs/providers/mistral-vibe.md) |
| <img src="assets/providers/copilot.jpg" width="28" /> | GitHub Copilot | Yes | [copilot.md](docs/providers/copilot.md) |
| <img src="assets/providers/kiro.png" width="28" /> | Kiro | Yes | [kiro.md](docs/providers/kiro.md) |
| <img src="assets/providers/opencode.png" width="28" /> | OpenCode | Yes | [opencode.md](docs/providers/opencode.md) |
@ -131,6 +132,8 @@ The `--provider` flag filters any command to a single provider: `codeburn report
**Gemini CLI** stores sessions as single JSON files. Each session embeds real token counts (input, output, cached, thoughts) per message, so no estimation is needed. Gemini reports input tokens inclusive of cached; CodeBurn subtracts cached from input before pricing to avoid double charging.
**Mistral Vibe** stores sessions as folders under `~/.vibe/logs/session/` (or `$VIBE_HOME/logs/session/`). CodeBurn reads cumulative prompt/completion totals and model pricing from `meta.json`, then reads `messages.jsonl` for the first user prompt and assistant tool calls. Subagent sessions under `agents/` are counted as separate Vibe sessions.
**Kiro** stores conversations as `.chat` JSON files. Token counts are estimated from content length. The underlying model is not exposed, so sessions are labeled `kiro-auto` and costed at Sonnet rates.
**GitHub Copilot** reads from both `~/.copilot/session-state/` (legacy CLI) and VS Code's `workspaceStorage/*/GitHub.copilot-chat/transcripts/`. The VS Code format has no explicit token counts; tokens are estimated from content length and the model is inferred from tool call ID prefixes.
@ -376,6 +379,8 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta
**Gemini CLI** stores sessions as single JSON files at `~/.gemini/tmp/<project>/chats/session-*.json`. Each session embeds real token counts (input, output, cached, thoughts) per message. Gemini reports input tokens inclusive of cached; CodeBurn subtracts cached from input before pricing to avoid double charging.
**Mistral Vibe** stores session folders at `~/.vibe/logs/session/`. Each folder contains `meta.json` with cumulative prompt/completion token totals, model pricing, timestamps, and working directory, plus `messages.jsonl` with user prompts and assistant tool calls. CodeBurn emits one record per Vibe session because the source data is cumulative, not per assistant turn.
**OpenClaw** stores agent sessions as JSONL at `~/.openclaw/agents/*.jsonl`. Also checks legacy paths `.clawdbot`, `.moltbot`, `.moldbot`. Token usage comes from assistant message `usage` blocks; model from `modelId` or `message.model` fields.
**Roo Code / KiloCode** are Cline-family VS Code extensions. CodeBurn reads `ui_messages.json` from each task directory in VS Code's `globalStorage`, filtering `type: "say"` entries with `say: "api_req_started"` to extract token counts.
@ -391,6 +396,7 @@ CodeBurn deduplicates messages (by API message ID for Claude, by cumulative toke
| `CODEX_HOME` | Override Codex data directory (default: `~/.codex`) |
| `FACTORY_DIR` | Override Droid data directory (default: `~/.factory`) |
| `QWEN_DATA_DIR` | Override Qwen data directory (default: `~/.qwen/projects`) |
| `VIBE_HOME` | Override Mistral Vibe home directory (default: `~/.vibe`) |
## Sponsoring CodeBurn

View file

@ -0,0 +1,12 @@
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.8147 5.35803H5.35791V8.46914H8.8147V5.35803Z" fill="black"/>
<path d="M22.6419 5.35803H19.1851V8.46914H22.6419V5.35803Z" fill="black"/>
<path d="M15.7283 15.7284H12.2715V18.8395H15.7283V15.7284Z" fill="black"/>
<path d="M8.8147 15.7284H5.35791V18.8395H8.8147V15.7284Z" fill="black"/>
<path d="M22.6419 15.7284H19.1851V18.8395H22.6419V15.7284Z" fill="black"/>
<path d="M12.2715 8.81482H5.35791V11.9259H12.2715V8.81482Z" fill="black"/>
<path d="M12.2718 19.1852H1.90137V22.2963H12.2718V19.1852Z" fill="black"/>
<path d="M26.0989 19.1852H15.7285V22.2963H26.0989V19.1852Z" fill="black"/>
<path d="M22.6419 12.2716H5.35791V15.3827H22.6419V12.2716Z" fill="black"/>
<path d="M22.6421 8.81482H15.7285V11.9259H22.6421V8.81482Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 849 B

View file

@ -17,6 +17,7 @@ For the architectural picture, see `../architecture.md`.
| [Gemini](gemini.md) | JSON / JSONL | `src/providers/gemini.ts` | none |
| [KiloCode](kilo-code.md) | JSON | `src/providers/kilo-code.ts` | `tests/providers/kilo-code.test.ts` |
| [Kiro](kiro.md) | JSON | `src/providers/kiro.ts` | `tests/providers/kiro.test.ts` |
| [Mistral Vibe](mistral-vibe.md) | JSON / JSONL | `src/providers/mistral-vibe.ts` | `tests/providers/mistral-vibe.test.ts` |
| [OpenClaw](openclaw.md) | JSONL | `src/providers/openclaw.ts` | `tests/providers/openclaw.test.ts` |
| [Pi](pi.md) | JSONL | `src/providers/pi.ts` | `tests/providers/pi.test.ts` |
| [OMP](omp.md) | JSONL | `src/providers/pi.ts` | `tests/providers/omp.test.ts` |

View file

@ -0,0 +1,41 @@
# Mistral Vibe
Mistral Vibe CLI.
- **Source:** `src/providers/mistral-vibe.ts`
- **Loading:** eager (`src/providers/index.ts`)
- **Test:** `tests/providers/mistral-vibe.test.ts`
## Where it reads from
`$VIBE_HOME/logs/session/` when `VIBE_HOME` is set, otherwise `~/.vibe/logs/session/`.
## Storage format
Vibe 2.x stores each session as a directory:
- `meta.json` contains session metadata, cumulative token totals, active model config, model prices, timestamps, working directory, and available tools.
- `messages.jsonl` contains non-system messages and assistant `tool_calls`.
Subagent traces are stored under a parent session's `agents/` folder with the same `meta.json` / `messages.jsonl` shape, so CodeBurn scans those one level down as separate sessions.
## Caching
None.
## 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.
- **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`.
## When fixing a bug here
1. Reproduce with a fixture that has both `meta.json` and `messages.jsonl`; both files are required for current Vibe sessions.
2. If the bug is "wrong total", check `meta.json.stats` first. `messages.jsonl` is only for prompts and tool calls.
3. If a future Vibe release adds per-turn usage, add tests before changing the one-record-per-session behavior so historical sessions continue to parse correctly.

View file

@ -5,6 +5,7 @@ import { droid } from './droid.js'
import { gemini } from './gemini.js'
import { kiloCode } from './kilo-code.js'
import { kiro } from './kiro.js'
import { mistralVibe } from './mistral-vibe.js'
import { openclaw } from './openclaw.js'
import { pi, omp } from './pi.js'
import { qwen } from './qwen.js'
@ -101,7 +102,7 @@ async function loadCrush(): Promise<Provider | null> {
}
}
const coreProviders: Provider[] = [claude, codex, copilot, droid, gemini, kiloCode, kiro, openclaw, pi, omp, qwen, rooCode]
const coreProviders: Provider[] = [claude, codex, copilot, droid, gemini, kiloCode, kiro, mistralVibe, openclaw, pi, omp, qwen, rooCode]
export async function getAllProviders(): Promise<Provider[]> {
const [ag, gs, cursor, opencode, cursorAgent, crush] = await Promise.all([loadAntigravity(), loadGoose(), loadCursor(), loadOpenCode(), loadCursorAgent(), loadCrush()])

View file

@ -0,0 +1,355 @@
import { readdir, stat } from 'fs/promises'
import { basename, join } from 'path'
import { homedir } from 'os'
import { readSessionFile, readSessionLines } from '../fs-utils.js'
import { calculateCost } from '../models.js'
import { extractBashCommands } from '../bash-utils.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
const METADATA_FILENAME = 'meta.json'
const MESSAGES_FILENAME = 'messages.jsonl'
const DEFAULT_MODEL = 'mistral-medium-3.5'
const modelDisplayNames: Record<string, string> = {
'mistral-medium-3.5': 'Mistral Medium 3.5',
'mistral-vibe-cli-latest': 'Mistral Vibe CLI',
'devstral-small': 'Devstral Small',
'devstral-small-latest': 'Devstral Small',
devstral: 'Devstral',
local: 'Local',
}
const toolNameMap: Record<string, string> = {
bash: 'Bash',
read_file: 'Read',
write_file: 'Write',
search_replace: 'Edit',
grep: 'Grep',
task: 'Agent',
todo: 'TodoWrite',
skill: 'Skill',
web_fetch: 'WebFetch',
web_search: 'WebSearch',
ask_user_question: 'AskUser',
exit_plan_mode: 'ExitPlanMode',
}
type VibeStats = {
session_prompt_tokens?: number
session_completion_tokens?: number
input_price_per_million?: number
output_price_per_million?: number
tokens_per_second?: number
}
type VibeModelConfig = {
name?: string
alias?: string
input_price?: number
output_price?: number
}
type VibeMetadata = {
session_id?: string
start_time?: string
end_time?: string | null
environment?: {
working_directory?: string | null
}
stats?: VibeStats
config?: {
active_model?: string
models?: VibeModelConfig[]
}
title?: string | null
}
type VibeToolCall = {
function?: {
name?: string
arguments?: string | Record<string, unknown> | null
}
}
type VibeMessage = {
role?: string
content?: unknown
tool_calls?: VibeToolCall[] | null
}
function getMistralVibeSessionsDir(override?: string): string {
if (override) return override
const configuredHome = process.env['VIBE_HOME']
const vibeHome = configuredHome ? expandHome(configuredHome) : join(homedir(), '.vibe')
return join(vibeHome, 'logs', 'session')
}
function expandHome(path: string): string {
if (path === '~') return homedir()
if (path.startsWith('~/')) return join(homedir(), path.slice(2))
return path
}
async function isFile(path: string): Promise<boolean> {
const s = await stat(path).catch(() => null)
return Boolean(s?.isFile())
}
async function isDirectory(path: string): Promise<boolean> {
const s = await stat(path).catch(() => null)
return Boolean(s?.isDirectory())
}
async function hasSessionFiles(dir: string): Promise<boolean> {
const [hasMetadata, hasMessages] = await Promise.all([
isFile(join(dir, METADATA_FILENAME)),
isFile(join(dir, MESSAGES_FILENAME)),
])
return hasMetadata && hasMessages
}
async function readJsonFile<T>(path: string): Promise<T | null> {
const raw = await readSessionFile(path)
if (raw === null) return null
try {
const parsed = JSON.parse(raw) as unknown
return typeof parsed === 'object' && parsed !== null ? parsed as T : null
} catch {
return null
}
}
async function discoverSessionDirs(root: string): Promise<string[]> {
const sessionDirs: string[] = []
let entries: string[]
try {
entries = (await readdir(root)).sort()
} catch {
return sessionDirs
}
for (const entry of entries) {
const dir = join(root, entry)
if (!await isDirectory(dir)) continue
if (await hasSessionFiles(dir)) {
sessionDirs.push(dir)
}
const agentsDir = join(dir, 'agents')
if (!await isDirectory(agentsDir)) continue
let agentEntries: string[]
try {
agentEntries = (await readdir(agentsDir)).sort()
} catch {
continue
}
for (const agentEntry of agentEntries) {
const agentDir = join(agentsDir, agentEntry)
if (await isDirectory(agentDir) && await hasSessionFiles(agentDir)) {
sessionDirs.push(agentDir)
}
}
}
return sessionDirs
}
function activeModelConfig(metadata: VibeMetadata): VibeModelConfig | null {
const activeModel = metadata.config?.active_model
const models = metadata.config?.models
if (!activeModel || !Array.isArray(models)) return null
return models.find(m => m.alias === activeModel || m.name === activeModel) ?? null
}
function resolveModel(metadata: VibeMetadata): string {
const activeModel = metadata.config?.active_model
if (activeModel) return activeModel
const configured = activeModelConfig(metadata)
return configured?.alias ?? configured?.name ?? DEFAULT_MODEL
}
function safeNumber(value: unknown): number {
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0
}
function calculateSessionCost(metadata: VibeMetadata, model: string, inputTokens: number, outputTokens: number): number {
const stats = metadata.stats ?? {}
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)
if (inputPrice > 0 || outputPrice > 0) {
return (inputTokens / 1_000_000) * inputPrice + (outputTokens / 1_000_000) * outputPrice
}
return calculateCost(model, inputTokens, outputTokens, 0, 0, 0)
}
function normalizeContent(content: unknown): string {
if (typeof content === 'string') return content
if (Array.isArray(content)) {
return content
.map(part => {
if (typeof part === 'string') return part
if (part && typeof part === 'object' && 'text' in part && typeof part.text === 'string') return part.text
return ''
})
.filter(Boolean)
.join(' ')
}
return ''
}
function parseToolArguments(raw: string | Record<string, unknown> | null | undefined): Record<string, unknown> {
if (!raw) return {}
if (typeof raw === 'object') return raw
try {
const parsed = JSON.parse(raw) as unknown
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : {}
} catch {
return {}
}
}
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))
}
}
}
return {
tools: [...new Set(tools)],
bashCommands: [...new Set(bashCommands)],
}
}
async function readMessages(path: string): Promise<VibeMessage[]> {
const messages: VibeMessage[] = []
for await (const line of readSessionLines(path)) {
if (!line.trim()) continue
try {
const parsed = JSON.parse(line) as unknown
if (parsed && typeof parsed === 'object') messages.push(parsed as VibeMessage)
} catch {
continue
}
}
return messages
}
function firstUserMessage(messages: VibeMessage[], fallback?: string | null): string {
for (const message of messages) {
if (message.role !== 'user') continue
const text = normalizeContent(message.content).trim()
if (text) return text.slice(0, 500)
}
return (fallback ?? '').slice(0, 500)
}
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
const metadataPath = join(source.path, METADATA_FILENAME)
const messagesPath = join(source.path, MESSAGES_FILENAME)
const metadata = await readJsonFile<VibeMetadata>(metadataPath)
if (!metadata) return
const stats = metadata.stats ?? {}
const inputTokens = safeNumber(stats.session_prompt_tokens)
const outputTokens = safeNumber(stats.session_completion_tokens)
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)
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,
}
},
}
}
export function createMistralVibeProvider(sessionsDir?: string): Provider {
const dir = getMistralVibeSessionsDir(sessionsDir)
return {
name: 'mistral-vibe',
displayName: 'Mistral Vibe',
modelDisplayName(model: string): string {
return modelDisplayNames[model] ?? model
},
toolDisplayName(rawTool: string): string {
return toolNameMap[rawTool] ?? rawTool
},
async discoverSessions(): Promise<SessionSource[]> {
const dirs = await discoverSessionDirs(dir)
const sources: SessionSource[] = []
for (const sessionDir of dirs) {
const metadata = await readJsonFile<VibeMetadata>(join(sessionDir, METADATA_FILENAME))
if (!metadata) continue
const cwd = metadata.environment?.working_directory
sources.push({
path: sessionDir,
project: cwd ? basename(cwd) : basename(sessionDir),
provider: 'mistral-vibe',
})
}
return sources
},
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return createParser(source, seenKeys)
},
}
}
export const mistralVibe = createMistralVibeProvider()

View file

@ -3,7 +3,7 @@ import { providers, getAllProviders } from '../src/providers/index.js'
describe('provider registry', () => {
it('has core providers registered synchronously', () => {
expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'droid', 'gemini', 'kilo-code', 'kiro', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code'])
expect(providers.map(p => p.name)).toEqual(['claude', 'codex', 'copilot', 'droid', 'gemini', 'kilo-code', 'kiro', 'mistral-vibe', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code'])
})
it('includes sqlite providers after async load', async () => {

View file

@ -0,0 +1,284 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises'
import { join } from 'path'
import { tmpdir } from 'os'
import { createMistralVibeProvider } from '../../src/providers/mistral-vibe.js'
import type { ParsedProviderCall } from '../../src/providers/types.js'
let tmpDir: string
let originalVibeHome: string | undefined
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), 'mistral-vibe-test-'))
originalVibeHome = process.env['VIBE_HOME']
delete process.env['VIBE_HOME']
})
afterEach(async () => {
if (originalVibeHome === undefined) {
delete process.env['VIBE_HOME']
} else {
process.env['VIBE_HOME'] = originalVibeHome
}
await rm(tmpDir, { recursive: true, force: true })
})
function metadata(opts: {
sessionId?: string
cwd?: string
input?: number
output?: number
inputPrice?: number
outputPrice?: number
activeModel?: string
modelName?: string
configInputPrice?: number
configOutputPrice?: number
endTime?: string | null
title?: string
} = {}) {
const activeModel = opts.activeModel ?? 'mistral-medium-3.5'
return {
session_id: opts.sessionId ?? 'session-abc123',
start_time: '2026-05-11T10:00:00+00:00',
end_time: Object.hasOwn(opts, 'endTime') ? opts.endTime : '2026-05-11T10:05:00+00:00',
environment: {
working_directory: opts.cwd ?? '/Users/test/mistral-project',
},
stats: {
session_prompt_tokens: opts.input ?? 2000,
session_completion_tokens: opts.output ?? 3000,
input_price_per_million: opts.inputPrice ?? 1.5,
output_price_per_million: opts.outputPrice ?? 7.5,
tokens_per_second: 42,
},
config: {
active_model: activeModel,
models: [
{
alias: activeModel,
name: opts.modelName ?? 'mistral-vibe-cli-latest',
provider: 'mistral',
input_price: opts.configInputPrice ?? 1.5,
output_price: opts.configOutputPrice ?? 7.5,
},
],
},
title: opts.title ?? 'implement mistral support',
total_messages: 2,
}
}
function userMessage(content: unknown = 'implement mistral support') {
return {
role: 'user',
content,
message_id: 'msg-user-1',
}
}
function assistantMessage(toolCalls: Array<{ name: string; args?: Record<string, unknown> | string }> = []) {
return {
role: 'assistant',
content: 'Done',
message_id: 'msg-assistant-1',
tool_calls: toolCalls.map((call, idx) => ({
id: `tool-${idx}`,
type: 'function',
function: {
name: call.name,
arguments: typeof call.args === 'string' ? call.args : JSON.stringify(call.args ?? {}),
},
})),
}
}
async function writeSession(
name: string,
meta: Record<string, unknown>,
messages = [userMessage(), assistantMessage()],
root = tmpDir,
) {
const sessionDir = join(root, name)
await mkdir(sessionDir, { recursive: true })
await writeFile(join(sessionDir, 'meta.json'), JSON.stringify(meta, null, 2))
await writeFile(join(sessionDir, 'messages.jsonl'), messages.map(m => JSON.stringify(m)).join('\n') + '\n')
return sessionDir
}
async function collect(sourcePath: string, provider = createMistralVibeProvider(tmpDir)): Promise<ParsedProviderCall[]> {
const calls: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser({
path: sourcePath,
project: 'mistral-project',
provider: 'mistral-vibe',
}, new Set()).parse()) {
calls.push(call)
}
return calls
}
describe('mistral-vibe provider - session discovery', () => {
it('discovers Vibe session folders and derives project from metadata cwd', async () => {
const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata({
sessionId: 'session-a',
cwd: '/Users/test/project-a',
}))
await mkdir(join(tmpDir, 'not-a-session'), { recursive: true })
const provider = createMistralVibeProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
expect(sessions[0]).toEqual({
path: sessionDir,
project: 'project-a',
provider: 'mistral-vibe',
})
})
it('discovers subagent session folders nested under agents', async () => {
const parentDir = await writeSession('session_20260511_100000_parent', metadata({
sessionId: 'parent-session',
cwd: '/Users/test/parent-project',
}))
const childDir = await writeSession('session_20260511_100001_child', metadata({
sessionId: 'child-session',
cwd: '/Users/test/child-project',
}), [userMessage('child task'), assistantMessage()], join(parentDir, 'agents'))
const provider = createMistralVibeProvider(tmpDir)
const sessions = await provider.discoverSessions()
expect(sessions.map(s => s.path).sort()).toEqual([childDir, parentDir].sort())
expect(sessions.map(s => s.project).sort()).toEqual(['child-project', 'parent-project'])
})
it('returns empty for a missing Vibe sessions directory', async () => {
const provider = createMistralVibeProvider('/missing/vibe/logs/session')
await expect(provider.discoverSessions()).resolves.toEqual([])
})
it('uses VIBE_HOME when no override directory is provided', async () => {
const vibeHome = join(tmpDir, 'vibe-home')
process.env['VIBE_HOME'] = vibeHome
const sessionsDir = join(vibeHome, 'logs', 'session')
await writeSession('session_20260511_100000_sessiona', metadata({
sessionId: 'env-session',
cwd: '/Users/test/env-project',
}), [userMessage(), assistantMessage()], sessionsDir)
const provider = createMistralVibeProvider()
const sessions = await provider.discoverSessions()
expect(sessions).toHaveLength(1)
expect(sessions[0]!.project).toBe('env-project')
})
})
describe('mistral-vibe provider - parsing', () => {
it('parses cumulative session usage, tools, bash commands, and first user message', async () => {
const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata(), [
userMessage([{ type: 'text', text: 'track Mistral Vibe usage' }]),
assistantMessage([
{ name: 'read_file', args: { path: 'src/index.ts' } },
{ name: 'search_replace', args: { file_path: 'src/index.ts', content: 'patch' } },
{ name: 'bash', args: { command: 'npm test && git status' } },
]),
])
const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir))
expect(calls).toHaveLength(1)
const call = calls[0]!
expect(call.provider).toBe('mistral-vibe')
expect(call.model).toBe('mistral-medium-3.5')
expect(call.inputTokens).toBe(2000)
expect(call.outputTokens).toBe(3000)
expect(call.costUSD).toBeCloseTo(0.0255, 8)
expect(call.tools).toEqual(['Read', 'Edit', 'Bash'])
expect(call.bashCommands).toEqual(['npm', 'git'])
expect(call.timestamp).toBe('2026-05-11T10:05:00+00:00')
expect(call.userMessage).toBe('track Mistral Vibe usage')
expect(call.sessionId).toBe('session-abc123')
expect(call.deduplicationKey).toBe('mistral-vibe:session-abc123')
})
it('uses configured model prices when stats omit prices', async () => {
const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata({
inputPrice: 0,
outputPrice: 0,
input: 1000,
output: 1000,
}))
const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir))
expect(calls).toHaveLength(1)
expect(calls[0]!.costUSD).toBeCloseTo(0.009, 8)
})
it('falls back to LiteLLM pricing when Vibe does not provide prices', async () => {
const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata({
activeModel: 'claude-sonnet-4-6',
modelName: 'claude-sonnet-4-6',
input: 1000,
output: 1000,
inputPrice: 0,
outputPrice: 0,
configInputPrice: 0,
configOutputPrice: 0,
}))
const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir))
expect(calls).toHaveLength(1)
expect(calls[0]!.costUSD).toBeCloseTo(0.018, 8)
})
it('falls back to start_time when end_time is missing', async () => {
const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata({
endTime: null,
}))
const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir))
expect(calls[0]!.timestamp).toBe('2026-05-11T10:00:00+00:00')
})
it('deduplicates by session id', async () => {
const sessionDir = await writeSession('session_20260511_100000_sessiona', metadata())
const provider = createMistralVibeProvider(tmpDir)
const source = { path: sessionDir, project: 'mistral-project', provider: 'mistral-vibe' }
const seen = new Set<string>()
const first: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, seen).parse()) first.push(call)
const second: ParsedProviderCall[] = []
for await (const call of provider.createSessionParser(source, seen).parse()) second.push(call)
expect(first).toHaveLength(1)
expect(second).toHaveLength(0)
})
it('skips sessions without cumulative token usage', async () => {
const sessionDir = await writeSession('session_20260511_100000_empty', metadata({
input: 0,
output: 0,
}))
const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir))
expect(calls).toEqual([])
})
it('formats model and tool display names', () => {
const provider = createMistralVibeProvider(tmpDir)
expect(provider.modelDisplayName('mistral-medium-3.5')).toBe('Mistral Medium 3.5')
expect(provider.modelDisplayName('devstral-small-latest')).toBe('Devstral Small')
expect(provider.toolDisplayName('search_replace')).toBe('Edit')
expect(provider.toolDisplayName('unknown_tool')).toBe('unknown_tool')
})
})