mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-05-16 19:44:14 +00:00
Merge pull request #301 from ozymandiashh/feat/mistral-vibe-provider
Support Mistral Vibe sessions
This commit is contained in:
commit
7777bf80bf
9 changed files with 729 additions and 2 deletions
|
|
@ -3,6 +3,12 @@
|
|||
## 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.
|
||||
- **Kimi Code CLI provider.** CodeBurn now reads Kimi session usage from
|
||||
`$KIMI_SHARE_DIR/sessions/` or `~/.kimi/sessions/`, including subagent
|
||||
`wire.jsonl` files. The parser consumes Kimi's official `StatusUpdate`
|
||||
|
|
|
|||
|
|
@ -104,6 +104,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/ibm-bob.svg" width="28" /> | IBM Bob | Yes | [ibm-bob.md](docs/providers/ibm-bob.md) |
|
||||
| <img src="assets/providers/kiro.png" width="28" /> | Kiro | Yes | [kiro.md](docs/providers/kiro.md) |
|
||||
|
|
@ -134,6 +135,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.
|
||||
|
|
@ -379,6 +382,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.
|
||||
|
||||
**Cline / Roo Code / KiloCode** are Cline-family coding agents. CodeBurn reads `ui_messages.json` from each task directory, filtering `type: "say"` entries with `say: "api_req_started"` to extract token counts. Cline scans both VS Code's `globalStorage/saoudrizwan.claude-dev` and `~/.cline/data`.
|
||||
|
|
@ -400,6 +405,7 @@ CodeBurn deduplicates messages (by API message ID for Claude, by cumulative toke
|
|||
| `KIMI_SHARE_DIR` | Override Kimi Code CLI share directory (default: `~/.kimi`) |
|
||||
| `KIMI_MODEL_NAME` | Override Kimi model name when Kimi sessions do not record the model |
|
||||
| `QWEN_DATA_DIR` | Override Qwen data directory (default: `~/.qwen/projects`) |
|
||||
| `VIBE_HOME` | Override Mistral Vibe home directory (default: `~/.vibe`) |
|
||||
|
||||
## Sponsoring CodeBurn
|
||||
|
||||
|
|
|
|||
12
assets/providers/mistral-vibe.svg
Normal file
12
assets/providers/mistral-vibe.svg
Normal 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 |
|
|
@ -20,6 +20,7 @@ For the architectural picture, see `../architecture.md`.
|
|||
| [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` |
|
||||
| [Kimi](kimi.md) | JSONL | `src/providers/kimi.ts` | `tests/providers/kimi.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` |
|
||||
|
|
|
|||
41
docs/providers/mistral-vibe.md
Normal file
41
docs/providers/mistral-vibe.md
Normal 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.
|
||||
|
|
@ -8,6 +8,7 @@ import { ibmBob } from './ibm-bob.js'
|
|||
import { kiloCode } from './kilo-code.js'
|
||||
import { kiro } from './kiro.js'
|
||||
import { kimi } from './kimi.js'
|
||||
import { mistralVibe } from './mistral-vibe.js'
|
||||
import { openclaw } from './openclaw.js'
|
||||
import { pi, omp } from './pi.js'
|
||||
import { qwen } from './qwen.js'
|
||||
|
|
@ -104,7 +105,7 @@ async function loadCrush(): Promise<Provider | null> {
|
|||
}
|
||||
}
|
||||
|
||||
const coreProviders: Provider[] = [claude, cline, codex, copilot, droid, gemini, ibmBob, kiloCode, kiro, kimi, openclaw, pi, omp, qwen, rooCode]
|
||||
const coreProviders: Provider[] = [claude, cline, codex, copilot, droid, gemini, ibmBob, kiloCode, kiro, kimi, 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()])
|
||||
|
|
|
|||
355
src/providers/mistral-vibe.ts
Normal file
355
src/providers/mistral-vibe.ts
Normal 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()
|
||||
|
|
@ -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', 'cline', 'codex', 'copilot', 'droid', 'gemini', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code'])
|
||||
expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'codex', 'copilot', 'droid', 'gemini', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'mistral-vibe', 'openclaw', 'pi', 'omp', 'qwen', 'roo-code'])
|
||||
})
|
||||
|
||||
it('includes sqlite providers after async load', async () => {
|
||||
|
|
|
|||
305
tests/providers/mistral-vibe.test.ts
Normal file
305
tests/providers/mistral-vibe.test.ts
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
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('skips sessions with malformed meta.json', async () => {
|
||||
const sessionDir = join(tmpDir, 'session_20260511_100000_bad')
|
||||
await mkdir(sessionDir, { recursive: true })
|
||||
await writeFile(join(sessionDir, 'meta.json'), '{{not json')
|
||||
await writeFile(join(sessionDir, 'messages.jsonl'), JSON.stringify(userMessage()) + '\n')
|
||||
|
||||
const provider = createMistralVibeProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
expect(sessions).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('returns empty calls when messages.jsonl is malformed', async () => {
|
||||
const sessionDir = await writeSession('session_20260511_100000_badjsonl', metadata())
|
||||
await writeFile(join(sessionDir, 'messages.jsonl'), '{{not json\n{{also bad\n')
|
||||
|
||||
const calls = await collect(sessionDir, createMistralVibeProvider(tmpDir))
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.tools).toEqual([])
|
||||
expect(calls[0]!.bashCommands).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')
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue