diff --git a/packages/cli/src/providers/mistral-vibe.ts b/packages/cli/src/providers/mistral-vibe.ts index 225323a7..a6999a3f 100644 --- a/packages/cli/src/providers/mistral-vibe.ts +++ b/packages/cli/src/providers/mistral-vibe.ts @@ -2,11 +2,15 @@ import { readdir, stat } from 'fs/promises' import { basename, join } from 'path' import { homedir } from 'os' +import { decodeMistralVibe, mistralVibeToolNameMap } from '@codeburn/core/providers/mistral-vibe' +import type { MistralVibeDecodedCall, VibeMetadata } from '@codeburn/core/providers/mistral-vibe' + 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' import { safeNumber } from '../parser.js' +import { createBridgedProvider } from './bridge.js' +import type { Provider, SessionSource, ParsedProviderCall } from './types.js' const METADATA_FILENAME = 'meta.json' const MESSAGES_FILENAME = 'messages.jsonl' @@ -21,30 +25,6 @@ const modelDisplayNames: Record = { local: 'Local', } -const toolNameMap: Record = { - 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 - session_cost?: number - input_price_per_million?: number - output_price_per_million?: number - tokens_per_second?: number -} - type VibeModelConfig = { name?: string alias?: string @@ -52,36 +32,6 @@ type VibeModelConfig = { 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 | null - } -} - -type VibeMessage = { - role?: string - content?: unknown - message_id?: string - timestamp?: string - tool_calls?: VibeToolCall[] | null -} - function getMistralVibeSessionsDir(override?: string): string { if (override) return override const configuredHome = process.env['VIBE_HOME'] @@ -177,16 +127,17 @@ function resolveModel(metadata: VibeMetadata): string { return configured?.alias ?? configured?.name ?? DEFAULT_MODEL } -// Residual pricing-table dependency (Phase 0 misfit; revisit in Phase 8): this -// decoder computes ONE session cost — a provider-reported `session_cost`, else -// Vibe's own per-million prices, else the generic price table — and ALLOCATES it -// evenly across the session's assistant messages (allocateCost). Per-call token -// buckets therefore do not each reproduce their per-call dollar figure, so the -// generic 'estimated' pass cannot recreate the number. The decoder's allocated -// dollar figure is authoritative, so emitted calls are marked costBasis -// 'measured' and the pass passes costUSD through untouched. Only the last of the -// three branches consults the price table; lifting it out would require moving -// allocation host-side, which is a Core-extraction concern beyond Phase 0. +// Session-cost resolution, host-side by construction (Phase 8 resolution of the +// Phase 0 misfit): this helper computes ONE session cost — a provider-reported +// `session_cost`, else Vibe's own per-million prices, else the generic price +// table — which the core decoder then ALLOCATES evenly across the session's +// assistant messages (allocateCost). Per-call token buckets therefore do not +// each reproduce their per-call dollar figure, so the generic 'estimated' pass +// cannot recreate the number. The allocated dollar figure is authoritative, so +// emitted calls are marked costBasis 'measured' and the pass passes costUSD +// through untouched. Only the last of the three branches consults the price +// table — which is why the whole resolution stays here: pricing tables may not +// cross into @codeburn/core. function calculateSessionCost(metadata: VibeMetadata, model: string, inputTokens: number, outputTokens: number): number { const stats = metadata.stats ?? {} const sessionCost = safeNumber(stats.session_cost) @@ -203,82 +154,13 @@ function calculateSessionCost(metadata: VibeMetadata, model: string, inputTokens 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 | null | undefined): Record { - 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 : {} - } catch { - return {} - } -} - -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) { - const extracted = extractMessageTools(message) - tools.push(...extracted.tools) - bashCommands.push(...extracted.bashCommands) - } - - return { - tools: [...new Set(tools)], - bashCommands: [...new Set(bashCommands)], - } -} - -async function readMessages(path: string): Promise { - const messages: VibeMessage[] = [] +async function readMessages(path: string): Promise { + const messages: unknown[] = [] 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) + if (parsed && typeof parsed === 'object') messages.push(parsed) } catch { continue } @@ -286,130 +168,38 @@ async function readMessages(path: string): Promise { 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 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): SessionParser { +// Map one rich, measured decoder call into the host's ParsedProviderCall. The +// core decoder already allocated the session-level dollar figure across +// assistant messages, so this adapter passes it through untouched with +// `costBasis: 'measured'`. +function toProviderCall(rich: MistralVibeDecodedCall): ParsedProviderCall { return { - async *parse(): AsyncGenerator { - const metadataPath = join(source.path, METADATA_FILENAME) - const messagesPath = join(source.path, MESSAGES_FILENAME) - const metadata = await readJsonFile(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 messages = await readMessages(messagesPath) - const model = resolveModel(metadata) - const costUSD = calculateSessionCost(metadata, model, inputTokens, outputTokens) - const assistantMessages = messages.filter(m => m.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, - costBasis: 'measured', - 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), - costBasis: 'measured', - tools, - bashCommands, - timestamp: message.timestamp ?? fallbackTimestamp, - speed: 'standard', - deduplicationKey, - turnId: currentTurnId, - userMessage: currentUserMessage, - sessionId, - } - } - }, + provider: 'mistral-vibe', + model: rich.model, + inputTokens: rich.inputTokens, + outputTokens: rich.outputTokens, + cacheCreationInputTokens: rich.cacheCreationInputTokens, + cacheReadInputTokens: rich.cacheReadInputTokens, + cachedInputTokens: rich.cachedInputTokens, + reasoningTokens: rich.reasoningTokens, + webSearchRequests: rich.webSearchRequests, + costUSD: rich.measuredCostUSD, + costBasis: 'measured', + tools: rich.tools, + bashCommands: [...new Set(rich.rawBashCommands.flatMap(c => extractBashCommands(c)))], + timestamp: rich.timestamp, + speed: rich.speed, + deduplicationKey: rich.deduplicationKey, + ...(rich.turnId === undefined ? {} : { turnId: rich.turnId }), + userMessage: rich.userMessage, + sessionId: rich.sessionId, } } export function createMistralVibeProvider(sessionsDir?: string): Provider { const dir = getMistralVibeSessionsDir(sessionsDir) - return { + return createBridgedProvider({ name: 'mistral-vibe', displayName: 'Mistral Vibe', @@ -418,7 +208,7 @@ export function createMistralVibeProvider(sessionsDir?: string): Provider { }, toolDisplayName(rawTool: string): string { - return toolNameMap[rawTool] ?? rawTool + return mistralVibeToolNameMap[rawTool] ?? rawTool }, async discoverSessions(): Promise { @@ -439,10 +229,30 @@ export function createMistralVibeProvider(sessionsDir?: string): Provider { return sources }, - createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { - return createParser(source, seenKeys) + // I/O adapter: read `meta.json` and `messages.jsonl`, resolve the session- + // level cost host-side (including the generic price-table fallback), and hand + // the core decoder `[{ metadata, sessionCost, sessionIdFallback }, ...messages]`. + // The fallback id is the session directory's basename, which the pre-migration + // decode used whenever `meta.json` omitted `session_id`. + async readRecords(source: SessionSource): Promise { + const metadata = await readJsonFile(join(source.path, METADATA_FILENAME)) + if (!metadata) return null + + 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 messages = await readMessages(join(source.path, MESSAGES_FILENAME)) + const model = resolveModel(metadata) + const sessionCost = calculateSessionCost(metadata, model, inputTokens, outputTokens) + + return [{ metadata, sessionCost, sessionIdFallback: basename(source.path) }, ...messages] }, - } + + decode: decodeMistralVibe, + toProviderCall, + }) } export const mistralVibe = createMistralVibeProvider() diff --git a/packages/cli/tests/providers/mistral-vibe-bridge.test.ts b/packages/cli/tests/providers/mistral-vibe-bridge.test.ts new file mode 100644 index 00000000..bdb2c06c --- /dev/null +++ b/packages/cli/tests/providers/mistral-vibe-bridge.test.ts @@ -0,0 +1,506 @@ +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 { priceProviderCall } from '../../src/pricing-pass.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'mistral-vibe-bridge-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +function metadata(opts: { + sessionId?: string + cwd?: string + input?: number + output?: number + sessionCost?: 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, + session_cost: opts.sessionCost, + 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', messageId = 'msg-user-1') { + return { + role: 'user', + content, + message_id: messageId, + } +} + +function assistantMessage(content = 'Done', messageId = 'msg-assistant-1') { + return { + role: 'assistant', + content, + message_id: messageId, + tool_calls: [], + } +} + +async function writeSession( + name: string, + meta: Record, + messages: Record[], + 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 buildFixtures(): Promise { + await writeSession( + 'session_20260511_100000_remainder', + metadata({ + sessionId: 'session-remainder', + input: 100, + output: 100, + sessionCost: 0.1, + title: 'remainder session', + }), + [ + userMessage('first turn', 'msg-user-1'), + assistantMessage('a1', 'msg-assistant-1'), + userMessage('second turn', 'msg-user-2'), + assistantMessage('a2', 'msg-assistant-2'), + userMessage('third turn', 'msg-user-3'), + assistantMessage('a3', 'msg-assistant-3'), + ], + ) + + await writeSession( + 'session_20260511_100001_zero', + metadata({ + sessionId: 'session-zero-cost', + input: 1000, + output: 1000, + sessionCost: 0, + title: 'zero cost session', + }), + [userMessage('zero cost turn', 'msg-user-1'), assistantMessage('z1', 'msg-assistant-1')], + ) + + await writeSession( + 'session_20260511_100002_single', + metadata({ + sessionId: 'session-single', + input: 2000, + output: 3000, + sessionCost: 0.0255, + title: 'single assistant session', + }), + [userMessage('single turn', 'msg-user-1'), assistantMessage('s1', 'msg-assistant-1')], + ) +} + +async function collect(provider = createMistralVibeProvider(tmpDir)): Promise { + const sources = await provider.discoverSessions() + sources.sort((a, b) => a.path.localeCompare(b.path)) + const seen = new Set() + const calls: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seen).parse()) { + calls.push(call) + } + } + return calls +} + +// Byte-identical parity gate for the mistral-vibe bridge migration (phase 8, +// Category A / JSONL). The GOLDEN below was captured from the unmodified legacy +// in-CLI decode. Allocation remainders, the session_cost > 0 gate, and the +// single-assistant path are all pinned exactly as the original emits them. +const GOLDEN: ParsedProviderCall[] = [ + { + provider: 'mistral-vibe', + model: 'mistral-medium-3.5', + inputTokens: 34, + outputTokens: 34, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: 0.03333333333333333, + costBasis: 'measured', + tools: [], + bashCommands: [], + timestamp: '2026-05-11T10:05:00+00:00', + speed: 'standard', + deduplicationKey: 'mistral-vibe:session-remainder:msg-assistant-1', + turnId: 'session-remainder:turn-0', + userMessage: 'first turn', + sessionId: 'session-remainder', + }, + { + provider: 'mistral-vibe', + model: 'mistral-medium-3.5', + inputTokens: 33, + outputTokens: 33, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: 0.03333333333333333, + costBasis: 'measured', + tools: [], + bashCommands: [], + timestamp: '2026-05-11T10:05:00+00:00', + speed: 'standard', + deduplicationKey: 'mistral-vibe:session-remainder:msg-assistant-2', + turnId: 'session-remainder:turn-1', + userMessage: 'second turn', + sessionId: 'session-remainder', + }, + { + provider: 'mistral-vibe', + model: 'mistral-medium-3.5', + inputTokens: 33, + outputTokens: 33, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: 0.03333333333333333, + costBasis: 'measured', + tools: [], + bashCommands: [], + timestamp: '2026-05-11T10:05:00+00:00', + speed: 'standard', + deduplicationKey: 'mistral-vibe:session-remainder:msg-assistant-3', + turnId: 'session-remainder:turn-2', + userMessage: 'third turn', + sessionId: 'session-remainder', + }, + { + provider: 'mistral-vibe', + model: 'mistral-medium-3.5', + inputTokens: 1000, + outputTokens: 1000, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: 0.009, + costBasis: 'measured', + tools: [], + bashCommands: [], + timestamp: '2026-05-11T10:05:00+00:00', + speed: 'standard', + deduplicationKey: 'mistral-vibe:session-zero-cost:msg-assistant-1', + turnId: 'session-zero-cost:turn-0', + userMessage: 'zero cost turn', + sessionId: 'session-zero-cost', + }, + { + provider: 'mistral-vibe', + model: 'mistral-medium-3.5', + inputTokens: 2000, + outputTokens: 3000, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: 0.0255, + costBasis: 'measured', + tools: [], + bashCommands: [], + timestamp: '2026-05-11T10:05:00+00:00', + speed: 'standard', + deduplicationKey: 'mistral-vibe:session-single:msg-assistant-1', + turnId: 'session-single:turn-0', + userMessage: 'single turn', + sessionId: 'session-single', + }, +] + +describe('mistral-vibe bridge — fixture parity', () => { + it('the unmodified provider reproduces the golden decode byte-for-byte', async () => { + await buildFixtures() + expect(await collect()).toEqual(GOLDEN) + }) + + it('cost keys are present exactly as the original emits them', async () => { + await buildFixtures() + const calls = await collect() + expect(calls.length).toBe(5) + calls.forEach(call => { + expect(call.costBasis).toBe('measured') + expect(typeof call.costUSD).toBe('number') + expect(Number.isFinite(call.costUSD)).toBe(true) + expect(Object.hasOwn(call, 'costUSD')).toBe(true) + }) + }) + + it('the pricing pass leaves measured costUSD untouched', async () => { + await buildFixtures() + const raw = await collect() + const priced = raw.map(priceProviderCall) + priced.forEach((call, i) => { + expect(call.costBasis).toBe('measured') + expect(call.costUSD).toBe(raw[i]!.costUSD) + expect(call).toEqual(raw[i]) + }) + }) + + it('discovery, I/O, and dedup stay CLI-side; the shared seenKeys set dedups', async () => { + await buildFixtures() + const provider = createMistralVibeProvider(tmpDir) + const sources = await provider.discoverSessions() + sources.sort((a, b) => a.path.localeCompare(b.path)) + const seen = new Set() + const first: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seen).parse()) first.push(call) + } + const second: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser(source, seen).parse()) second.push(call) + } + expect(first).toEqual(GOLDEN) + expect(second).toEqual([]) + }) +}) + +// ── Adversarial allocation arms ───────────────────────────────────────────── +// +// Every value below was captured by running the UNMODIFIED f4f4dcca decode +// (checked out in place) over these exact fixtures. They pin the arms the first +// golden does not reach: an odd integer split with a remainder smaller than the +// message count, a total smaller than the count, the terminal zero-cost arm +// (no session_cost, no Vibe prices, model absent from the price table), the +// `session_id`-absent directory-basename fallback, the no-assistant session-level +// arm (which emits NO `turnId` key at all), and `idx-N` dedup keys. + +async function writeRaw(name: string, meta: Record, messages: Record[]) { + const sessionDir = join(tmpDir, 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') +} + +function advMeta(over: Record): Record { + return { + session_id: 'sess', + start_time: '2026-05-11T10:00:00+00:00', + end_time: '2026-05-11T10:05:00+00:00', + environment: { working_directory: '/Users/test/mistral-project' }, + config: { + active_model: 'mistral-medium-3.5', + models: [{ alias: 'mistral-medium-3.5', name: 'mistral-vibe-cli-latest', input_price: 1.5, output_price: 7.5 }], + }, + title: 'a title', + ...over, + } +} + +const advUser = (text: string, id: string) => ({ role: 'user', content: text, message_id: id }) +const advAssistant = (id: string | null) => ({ + role: 'assistant', + content: 'ok', + ...(id ? { message_id: id } : {}), + tool_calls: [], +}) + +async function buildAdversarialFixtures(): Promise { + await writeRaw('b1_seven_over_three', advMeta({ + session_id: 'alloc-7-over-3', + stats: { session_prompt_tokens: 7, session_completion_tokens: 2, session_cost: 1 }, + }), [advUser('u', 'u1'), advAssistant('a1'), advAssistant('a2'), advAssistant('a3')]) + + await writeRaw('b2_one_over_three', advMeta({ + session_id: 'alloc-1-over-3', + stats: { session_prompt_tokens: 1, session_completion_tokens: 0, session_cost: 0.1 }, + }), [advUser('u', 'u1'), advAssistant('a1'), advAssistant('a2'), advAssistant('a3')]) + + await writeRaw('b3_zero_terminal', advMeta({ + session_id: 'alloc-zero-cost', + stats: { session_prompt_tokens: 1000, session_completion_tokens: 1000 }, + config: { + active_model: 'totally-unknown-model-xyz', + models: [{ alias: 'totally-unknown-model-xyz', name: 'totally-unknown-model-xyz' }], + }, + }), [advUser('zero', 'u1'), advAssistant('a1'), advAssistant('a2')]) + + await writeRaw('b4_no_session_id', advMeta({ + session_id: undefined, + stats: { session_prompt_tokens: 10, session_completion_tokens: 10, session_cost: 0.02 }, + }), [advUser('no id', 'u1'), advAssistant('a1'), advAssistant('a2')]) + + await writeRaw('b5_no_assistant', advMeta({ + session_id: 'alloc-no-assistant', + stats: { session_prompt_tokens: 55, session_completion_tokens: 55, session_cost: 0.004 }, + }), [advUser('only user text', 'u1')]) + + await writeRaw('b6_no_message_ids', advMeta({ + session_id: 'alloc-idx-keys', + stats: { session_prompt_tokens: 5, session_completion_tokens: 5, session_cost: 0.3 }, + }), [advUser('x', 'u1'), advAssistant(null), advAssistant(null)]) + + // Float-op-order discriminator for the ALLOCATION: 0.005 / 3 and + // 0.005 * (1 / 3) differ in the last bit, so this pins the division order. + await writeRaw('b7_float_alloc', advMeta({ + session_id: 'alloc-float-order', + stats: { session_prompt_tokens: 30, session_completion_tokens: 30, session_cost: 0.005 }, + }), [advUser('f', 'u1'), advAssistant('a1'), advAssistant('a2'), advAssistant('a3')]) + + // Float-op-order discriminator for the HOST price branch: + // (1000/1e6)*0.3 + (1000/1e6)*0.9 !== (1000*0.3 + 1000*0.9)/1e6. + await writeRaw('b8_float_price', advMeta({ + session_id: 'price-float-order', + stats: { + session_prompt_tokens: 1000, session_completion_tokens: 1000, session_cost: 0, + input_price_per_million: 0.3, output_price_per_million: 0.9, + }, + }), [advUser('p', 'u1'), advAssistant('a1')]) +} + +const ADVERSARIAL_TOKENS: Array<[string, number, number]> = [ + // 7 over 3 -> 3,2,2 (remainder to the FIRST messages); 2 over 3 -> 1,1,0. + ['mistral-vibe:alloc-7-over-3:a1', 3, 1], + ['mistral-vibe:alloc-7-over-3:a2', 2, 1], + ['mistral-vibe:alloc-7-over-3:a3', 2, 0], + // 1 over 3 -> 1,0,0; 0 over 3 -> 0,0,0. + ['mistral-vibe:alloc-1-over-3:a1', 1, 0], + ['mistral-vibe:alloc-1-over-3:a2', 0, 0], + ['mistral-vibe:alloc-1-over-3:a3', 0, 0], + ['mistral-vibe:alloc-zero-cost:a1', 500, 500], + ['mistral-vibe:alloc-zero-cost:a2', 500, 500], + ['mistral-vibe:b4_no_session_id:a1', 5, 5], + ['mistral-vibe:b4_no_session_id:a2', 5, 5], + ['mistral-vibe:alloc-no-assistant', 55, 55], + ['mistral-vibe:alloc-idx-keys:idx-0', 3, 3], + ['mistral-vibe:alloc-idx-keys:idx-1', 2, 2], + ['mistral-vibe:alloc-float-order:a1', 10, 10], + ['mistral-vibe:alloc-float-order:a2', 10, 10], + ['mistral-vibe:alloc-float-order:a3', 10, 10], + ['mistral-vibe:price-float-order:a1', 1000, 1000], +] + +const ADVERSARIAL_COSTS: Array<[string, number]> = [ + ['mistral-vibe:alloc-7-over-3:a1', 0.3333333333333333], + ['mistral-vibe:alloc-7-over-3:a2', 0.3333333333333333], + ['mistral-vibe:alloc-7-over-3:a3', 0.3333333333333333], + ['mistral-vibe:alloc-1-over-3:a1', 0.03333333333333333], + ['mistral-vibe:alloc-1-over-3:a2', 0.03333333333333333], + ['mistral-vibe:alloc-1-over-3:a3', 0.03333333333333333], + // Terminal arm: nothing resolves a price, so the session cost is 0 — and the + // call still carries costUSD: 0 with costBasis 'measured'. + ['mistral-vibe:alloc-zero-cost:a1', 0], + ['mistral-vibe:alloc-zero-cost:a2', 0], + ['mistral-vibe:b4_no_session_id:a1', 0.01], + ['mistral-vibe:b4_no_session_id:a2', 0.01], + ['mistral-vibe:alloc-no-assistant', 0.004], + ['mistral-vibe:alloc-idx-keys:idx-0', 0.15], + ['mistral-vibe:alloc-idx-keys:idx-1', 0.15], + // Last-bit exact: division, not multiplication by the reciprocal. + ['mistral-vibe:alloc-float-order:a1', 0.0016666666666666668], + ['mistral-vibe:alloc-float-order:a2', 0.0016666666666666668], + ['mistral-vibe:alloc-float-order:a3', 0.0016666666666666668], + // Last-bit exact: the host price branch divides each bucket by 1e6 BEFORE + // multiplying by its price, then sums. + ['mistral-vibe:price-float-order:a1', 0.0012000000000000001], +] + +describe('mistral-vibe bridge — adversarial allocation parity', () => { + it('splits integer totals exactly as the original did (remainder to the FIRST messages)', async () => { + await buildAdversarialFixtures() + const byKey = new Map((await collect()).map(c => [c.deduplicationKey, c])) + expect(byKey.size).toBe(ADVERSARIAL_TOKENS.length) + for (const [key, input, output] of ADVERSARIAL_TOKENS) { + const call = byKey.get(key) + expect(call, key).toBeDefined() + expect([key, call!.inputTokens, call!.outputTokens]).toEqual([key, input, output]) + } + }) + + it('splits the session dollar figure exactly as the original did', async () => { + await buildAdversarialFixtures() + const byKey = new Map((await collect()).map(c => [c.deduplicationKey, c])) + for (const [key, cost] of ADVERSARIAL_COSTS) { + expect([key, byKey.get(key)!.costUSD]).toEqual([key, cost]) + } + }) + + it('emits costUSD + costBasis on every arm, including the terminal zero-cost arm', async () => { + await buildAdversarialFixtures() + for (const call of await collect()) { + expect(Object.hasOwn(call, 'costUSD')).toBe(true) + expect(Object.hasOwn(call, 'costBasis')).toBe(true) + expect(call.costBasis).toBe('measured') + expect(typeof call.costUSD).toBe('number') + } + }) + + it('falls back to the session directory basename when meta.json omits session_id', async () => { + await buildAdversarialFixtures() + const calls = (await collect()).filter(c => c.sessionId === 'b4_no_session_id') + expect(calls).toHaveLength(2) + expect(calls.map(c => c.turnId)).toEqual(['b4_no_session_id:turn-0', 'b4_no_session_id:turn-0']) + }) + + it('the no-assistant arm omits the turnId key entirely', async () => { + await buildAdversarialFixtures() + const call = (await collect()).find(c => c.deduplicationKey === 'mistral-vibe:alloc-no-assistant')! + expect(Object.hasOwn(call, 'turnId')).toBe(false) + expect(Object.keys(call).sort()).toEqual([ + 'bashCommands', 'cacheCreationInputTokens', 'cacheReadInputTokens', 'cachedInputTokens', + 'costBasis', 'costUSD', 'deduplicationKey', 'inputTokens', 'model', 'outputTokens', + 'provider', 'reasoningTokens', 'sessionId', 'speed', 'timestamp', 'tools', + 'userMessage', 'webSearchRequests', + ]) + const withTurn = (await collect()).find(c => c.deduplicationKey === 'mistral-vibe:alloc-idx-keys:idx-0')! + expect(Object.hasOwn(withTurn, 'turnId')).toBe(true) + }) +}) diff --git a/packages/core/package.json b/packages/core/package.json index ec414908..f7f73808 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -95,6 +95,10 @@ "types": "./dist/providers/kimicode/index.d.ts", "import": "./dist/providers/kimicode/index.js" }, + "./providers/mistral-vibe": { + "types": "./dist/providers/mistral-vibe/index.d.ts", + "import": "./dist/providers/mistral-vibe/index.js" + }, "./providers/pi": { "types": "./dist/providers/pi/index.d.ts", "import": "./dist/providers/pi/index.js" diff --git a/packages/core/src/providers/mistral-vibe/decode.ts b/packages/core/src/providers/mistral-vibe/decode.ts new file mode 100644 index 00000000..c2348779 --- /dev/null +++ b/packages/core/src/providers/mistral-vibe/decode.ts @@ -0,0 +1,269 @@ +// @codeburn/core Mistral Vibe decoder: pure decode over host-supplied records. +// +// The host reads `meta.json` and `messages.jsonl` and hands this decoder a record +// array whose first element is `{ metadata, sessionCost }` and whose remaining +// elements are the parsed messages. No fs/env/clock/pricing here: the host +// resolves the session-level dollar figure (including any generic price-table +// fallback), and this decoder only performs the pure arithmetic of allocating +// that figure and the session token totals evenly across the session's assistant +// messages. + +import type { DecodeContext } from '../../contracts.js' +import type { RecordDiagnostic } from '../../diagnostics.js' +import type { + MistralVibeDecodedCall, + MistralVibeSessionRecord, + VibeMessage, + VibeMetadata, + VibeModelConfig, +} from './types.js' + +const DEFAULT_MODEL = 'mistral-medium-3.5' + +// Mistral Vibe tool ids mapped to the canonical vocabulary. Unknown ids pass +// through unchanged so provider-native tools still show up. +export const mistralVibeToolNameMap: Record = { + 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', +} + +export type MistralVibeDecodeInput = { + records: unknown[] + context: DecodeContext + // Optional live dedup set the host mutates in place. Threaded exactly like + // qwen/codex; simple JSONL providers never persist resume state. + seenKeys?: Set +} + +export type MistralVibeDecodeResult = { + calls: MistralVibeDecodedCall[] + diagnostics: RecordDiagnostic[] +} + +function safeNumber(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0 +} + +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 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 | null | undefined): Record { + 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 : {} + } catch { + return {} + } +} + +function extractMessageTools(message: VibeMessage): { tools: string[]; rawBashCommands: string[] } { + const tools: string[] = [] + const rawBashCommands: string[] = [] + + if (message.role !== 'assistant') return { tools, rawBashCommands } + + for (const toolCall of message.tool_calls ?? []) { + const rawName = toolCall.function?.name + if (!rawName) continue + + const mappedName = mistralVibeToolNameMap[rawName] ?? rawName + tools.push(mappedName) + + if (mappedName !== 'Bash') continue + const args = parseToolArguments(toolCall.function?.arguments) + const command = args['command'] + if (typeof command === 'string') { + rawBashCommands.push(command) + } + } + + return { + tools: [...new Set(tools)], + rawBashCommands: [...new Set(rawBashCommands)], + } +} + +function extractTools(messages: VibeMessage[]): { tools: string[]; rawBashCommands: string[] } { + const tools: string[] = [] + const rawBashCommands: string[] = [] + + for (const message of messages) { + const extracted = extractMessageTools(message) + tools.push(...extracted.tools) + rawBashCommands.push(...extracted.rawBashCommands) + } + + return { + tools: [...new Set(tools)], + rawBashCommands: [...new Set(rawBashCommands)], + } +} + +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 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 +} + +/** + * Decode Mistral Vibe session records into rich, measured calls. The host + * resolves the session-level cost and this decoder allocates it (and the session + * token totals) evenly across assistant messages. + * + * Dedup is keyed on `mistral-vibe::` against the live + * `seenKeys` set (host-owned). The allocation arithmetic is intentionally kept + * byte-identical to the pre-migration in-CLI decode. + */ +export function decodeMistralVibe({ records, seenKeys: liveSeen }: MistralVibeDecodeInput): MistralVibeDecodeResult { + const seen = liveSeen ?? new Set() + const calls: MistralVibeDecodedCall[] = [] + const diagnostics: RecordDiagnostic[] = [] + + if (records.length === 0) return { calls, diagnostics } + + const sessionRecord = records[0] as MistralVibeSessionRecord + const metadata = sessionRecord.metadata ?? {} + const sessionCost = typeof sessionRecord.sessionCost === 'number' && Number.isFinite(sessionRecord.sessionCost) + ? sessionRecord.sessionCost + : 0 + + const stats = metadata.stats ?? {} + const inputTokens = safeNumber(stats.session_prompt_tokens) + const outputTokens = safeNumber(stats.session_completion_tokens) + if (inputTokens === 0 && outputTokens === 0) return { calls, diagnostics } + + const sessionId = metadata.session_id || sessionRecord.sessionIdFallback || '' + const messages = records.slice(1) as VibeMessage[] + const model = resolveModel(metadata) + const assistantMessages = messages.filter(m => m.role === 'assistant') + const fallbackTimestamp = metadata.end_time ?? metadata.start_time ?? '' + + if (assistantMessages.length === 0) { + const deduplicationKey = `mistral-vibe:${sessionId}` + if (seen.has(deduplicationKey)) return { calls, diagnostics } + seen.add(deduplicationKey) + const { tools, rawBashCommands } = extractTools(messages) + + calls.push({ + provider: 'mistral-vibe', + model, + inputTokens, + outputTokens, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + measuredCostUSD: sessionCost, + tools, + rawBashCommands, + timestamp: fallbackTimestamp, + speed: 'standard', + deduplicationKey, + userMessage: firstUserMessage(messages, metadata.title), + sessionId, + }) + return { calls, diagnostics } + } + + 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 (seen.has(deduplicationKey)) continue + seen.add(deduplicationKey) + + const { tools, rawBashCommands } = extractMessageTools(message) + + calls.push({ + 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, + measuredCostUSD: allocateCost(sessionCost, assistantMessages.length), + tools, + rawBashCommands, + timestamp: message.timestamp ?? fallbackTimestamp, + speed: 'standard', + deduplicationKey, + turnId: currentTurnId, + userMessage: currentUserMessage, + sessionId, + }) + } + + return { calls, diagnostics } +} diff --git a/packages/core/src/providers/mistral-vibe/index.ts b/packages/core/src/providers/mistral-vibe/index.ts new file mode 100644 index 00000000..94f510ca --- /dev/null +++ b/packages/core/src/providers/mistral-vibe/index.ts @@ -0,0 +1,32 @@ +// @codeburn/core Mistral Vibe provider. +// +// Two layers: +// - Rich pure decode (`decodeMistralVibe`): host-facing, NOT part of the stable +// minimized surface. Pure over host-supplied records; the host resolves the +// session-level dollar figure (including any price-table fallback) and this +// decoder only allocates it across assistant messages. +// - Minimizing transform (`toObservations`): maps the rich decode into the +// strict observation envelope; the content-smuggling guarantees bind here. + +export { + decodeMistralVibe, + mistralVibeToolNameMap, + type MistralVibeDecodeInput, + type MistralVibeDecodeResult, +} from './decode.js' + +export { + toObservations, + type RichMistralVibeSessionDecode, + type MistralVibeToObservationsContext, +} from './observations.js' + +export type { + MistralVibeDecodedCall, + MistralVibeSessionRecord, + VibeMessage, + VibeMetadata, + VibeStats, + VibeModelConfig, + VibeToolCall, +} from './types.js' diff --git a/packages/core/src/providers/mistral-vibe/observations.ts b/packages/core/src/providers/mistral-vibe/observations.ts new file mode 100644 index 00000000..f5e51ae9 --- /dev/null +++ b/packages/core/src/providers/mistral-vibe/observations.ts @@ -0,0 +1,89 @@ +// Minimizing transform: rich Mistral Vibe decode -> the strict observation +// envelope. Only opaque ids, fingerprints, enums, numbers, timestamps, dedup +// keys, and canonical tool names cross into the output. Mistral Vibe captures a +// user message and raw bash command strings, but those stay in the rich decode; +// they are never copied into the minimized envelope. + +import { projectRef, sessionRef } from '../../fingerprint.js' +import type { RecordDiagnostic } from '../../diagnostics.js' +import type { CallObservation, SessionObservation } from '../../observations.js' +import type { MistralVibeDecodedCall } from './types.js' + +/** One Mistral Vibe session's rich decode, as the host holds it before minimization. */ +export interface RichMistralVibeSessionDecode { + sessionId: string + /** Absolute project path; fingerprinted, never emitted raw. */ + projectPath: string + /** Rich calls in decode order (as decodeMistralVibe emits them). */ + calls: MistralVibeDecodedCall[] +} + +export interface MistralVibeToObservationsContext { + /** HMAC key that scopes every fingerprint. */ + privacyKey: string + /** Provider id stamped onto sessions/calls and folded into sessionRef. */ + provider?: string +} + +// Canonical tool-name charset, mirroring core's CanonicalToolName schema. A name +// that does not match (a provider-native id with a slash, an argument blob) is +// dropped rather than emitted. +const CANONICAL_TOOL_NAME = /^[A-Za-z0-9_.-]{1,64}$/ + +function toCallObservation(call: MistralVibeDecodedCall, turnIndex: number): CallObservation { + return { + provider: call.provider, + model: call.model, + tokens: { + input: call.inputTokens, + output: call.outputTokens, + reasoning: call.reasoningTokens, + cacheRead: call.cacheReadInputTokens, + cacheCreate: call.cacheCreationInputTokens, + }, + webSearchRequests: call.webSearchRequests, + speed: call.speed, + costBasis: 'measured', + measuredCostUSD: call.measuredCostUSD, + timestamp: call.timestamp, + dedupKey: call.deduplicationKey, + toolNames: call.tools.filter(t => CANONICAL_TOOL_NAME.test(t)), + turnIndex, + } +} + +function toSessionObservation( + decode: RichMistralVibeSessionDecode, + ctx: MistralVibeToObservationsContext, +): SessionObservation { + const provider = ctx.provider ?? 'mistral-vibe' + const calls: CallObservation[] = decode.calls.map((call, i) => toCallObservation(call, i)) + + const timestamps = calls.map(c => c.timestamp).filter(t => t.length > 0).sort() + const startedAt = timestamps[0] ?? '' + const endedAt = timestamps.length > 0 ? timestamps[timestamps.length - 1]! : '' + + return { + sessionRef: sessionRef(ctx.privacyKey, provider, decode.sessionId), + projectRef: projectRef(ctx.privacyKey, decode.projectPath), + providerId: provider, + startedAt, + ...(endedAt ? { endedAt } : {}), + calls, + turnCount: calls.length, + } +} + +/** + * Map a rich Mistral Vibe decode (one or many sessions) into the minimized + * observation layer. Returns the `sessions` array plus any per-record + * `diagnostics`. + */ +export function toObservations( + decode: RichMistralVibeSessionDecode | RichMistralVibeSessionDecode[], + ctx: MistralVibeToObservationsContext, +): { sessions: SessionObservation[]; diagnostics: RecordDiagnostic[] } { + const decodes = Array.isArray(decode) ? decode : [decode] + const sessions = decodes.map(d => toSessionObservation(d, ctx)) + return { sessions, diagnostics: [] } +} diff --git a/packages/core/src/providers/mistral-vibe/types.ts b/packages/core/src/providers/mistral-vibe/types.ts new file mode 100644 index 00000000..775a1747 --- /dev/null +++ b/packages/core/src/providers/mistral-vibe/types.ts @@ -0,0 +1,93 @@ +// Raw record + rich-decode types for the Mistral Vibe provider. +// +// Mistral Vibe stores one session per directory: `meta.json` (session metadata + +// cumulative stats) plus `messages.jsonl` (one JSON object per message). The host +// reads both files and hands the decoder a single record array whose first entry +// is `{ metadata, sessionCost }` and whose remaining entries are the parsed +// messages. This decoder is pure over those records: no fs, env, clock, or +// pricing-table lookups. + +export 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 +} + +export type VibeModelConfig = { + name?: string + alias?: string + input_price?: number + output_price?: number +} + +export 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 +} + +export type VibeToolCall = { + function?: { + name?: string + arguments?: string | Record | null + } +} + +export type VibeMessage = { + role?: string + content?: unknown + message_id?: string + timestamp?: string + tool_calls?: VibeToolCall[] | null +} + +/** + * The first record the host hands to the core decoder: the parsed `meta.json` + * plus the session-level cost the host resolved from the metadata (provider- + * reported `session_cost`, Vibe's per-million prices, or the generic price table + * fallback). The decoder allocates this cost evenly across assistant messages. + * + * `sessionIdFallback` is the host's path-derived id used when `meta.json` omits + * `session_id` (the pre-migration decode used the session directory's basename); + * deriving it needs the source path, which core never sees. + */ +export type MistralVibeSessionRecord = { + metadata: VibeMetadata + sessionCost: number + sessionIdFallback?: string +} + +/** The rich decode of one Mistral Vibe call (one assistant message), pre-pricing. */ +export type MistralVibeDecodedCall = { + provider: 'mistral-vibe' + model: string + inputTokens: number + outputTokens: number + cacheCreationInputTokens: number + cacheReadInputTokens: number + cachedInputTokens: number + reasoningTokens: number + webSearchRequests: number + /** Allocated from the host-resolved session cost; always present for Vibe. */ + measuredCostUSD: number + tools: string[] + rawBashCommands: string[] + timestamp: string + speed: 'standard' + deduplicationKey: string + turnId?: string + userMessage: string + sessionId: string +} diff --git a/packages/core/tests/architecture-gate.test.ts b/packages/core/tests/architecture-gate.test.ts index 8cfde696..e7a0ca9b 100644 --- a/packages/core/tests/architecture-gate.test.ts +++ b/packages/core/tests/architecture-gate.test.ts @@ -149,6 +149,8 @@ const USER_MESSAGE_ALLOWLIST = new Set([ 'src/providers/gemini/types.ts', 'src/providers/kimicode/decode.ts', 'src/providers/kimicode/types.ts', + 'src/providers/mistral-vibe/decode.ts', + 'src/providers/mistral-vibe/types.ts', 'src/providers/pi/decode.ts', 'src/providers/pi/types.ts', 'src/providers/zed/decode.ts', diff --git a/packages/core/tests/content-smuggling.test.ts b/packages/core/tests/content-smuggling.test.ts index 9f7a2966..05e15835 100644 --- a/packages/core/tests/content-smuggling.test.ts +++ b/packages/core/tests/content-smuggling.test.ts @@ -37,6 +37,7 @@ import { decodeDevin, toObservations as toDevinObservations } from '../src/provi import { decodeCopilot, toObservations as toCopilotObservations } from '../src/providers/copilot/index.js' import { decodeVscodeCline, toObservations as toVscodeClineObservations } from '../src/providers/vscode-cline/index.js' import { decodeOpenCodeSession, toObservations as toOpenCodeSessionObservations } from '../src/providers/opencode-session/index.js' +import { decodeMistralVibe, toObservations as toMistralVibeObservations } from '../src/providers/mistral-vibe/index.js' import type { DecodeContext } from '../src/contracts.js' import type { ZedThreadRow } from '../src/providers/zed/index.js' @@ -1497,3 +1498,76 @@ describe('content-smuggling guardrail: real opencode-session decode -> toObserva expect(allToolNames).not.toContain(SECRETS.commandLine) }) }) +describe('content-smuggling guardrail: real mistral-vibe decode -> toObservations is secret-free', () => { + // A hostile Mistral Vibe session planting every secret in the free-text fields + // the decode captures: the user prompt, a bash command string, and a tool NAME + // carrying a command line. The observation envelope MUST surface none of them. + const mistralVibeContext: DecodeContext = { + privacyKey: 'test-privacy-key', + providerId: 'mistral-vibe', + sourceRef: 'ref', + } + + function decodeAndMinimize() { + const records: unknown[] = [ + { + metadata: { + session_id: 'sess-hostile', + start_time: '2026-07-17T10:00:00+00:00', + end_time: '2026-07-17T10:05:00+00:00', + stats: { + session_prompt_tokens: 500, + session_completion_tokens: 200, + session_cost: 0.05, + }, + config: { active_model: 'mistral-medium-3.5', models: [] }, + title: SECRETS.prompt, + }, + sessionCost: 0.05, + }, + { + role: 'user', + content: `${SECRETS.prompt} ${SECRETS.apiKey} ${SECRETS.fileContent}`, + message_id: 'msg-user-1', + }, + { + role: 'assistant', + content: 'Done', + message_id: 'msg-assistant-1', + tool_calls: [ + { function: { name: 'bash', arguments: JSON.stringify({ command: SECRETS.commandLine }) } }, + // A hostile tool NAME carrying a command line: fails canonical charset. + { function: { name: SECRETS.commandLine, arguments: '{}' } }, + ], + }, + ] + const { calls } = decodeMistralVibe({ records, context: mistralVibeContext }) + const { sessions } = toMistralVibeObservations( + { sessionId: 'sess-hostile', projectPath: SECRETS.absPath, calls }, + { privacyKey: 'test-privacy-key', provider: 'mistral-vibe' }, + ) + return { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + } + + it('produces a schema-valid envelope from the hostile session', () => { + expect(ObservationEnvelope.safeParse(decodeAndMinimize()).success).toBe(true) + }) + + it('the serialized envelope contains none of the planted secrets', () => { + const serialized = JSON.stringify(decodeAndMinimize()) + for (const secret of ALL_SECRETS) { + expect(serialized).not.toContain(secret) + } + }) + + it('keeps canonical tool names (Bash) and drops the argument-carrying name', () => { + const env = decodeAndMinimize() + const allToolNames = env.sessions.flatMap(s => s.calls.flatMap(c => c.toolNames)) + expect(allToolNames).toContain('Bash') + expect(allToolNames).not.toContain(SECRETS.commandLine) + }) +}) diff --git a/packages/core/tests/providers/mistral-vibe-decode.test.ts b/packages/core/tests/providers/mistral-vibe-decode.test.ts new file mode 100644 index 00000000..2a29bec3 --- /dev/null +++ b/packages/core/tests/providers/mistral-vibe-decode.test.ts @@ -0,0 +1,394 @@ +import { describe, expect, it } from 'vitest' + +import { + decodeMistralVibe, + toObservations, + type MistralVibeSessionRecord, + type VibeMessage, +} from '../../src/providers/mistral-vibe/index.js' +import { ObservationEnvelope } from '../../src/observations.js' +import { OBSERVATION_SCHEMA_VERSION } from '../../src/schema.js' +import type { DecodeContext } from '../../src/contracts.js' + +const context: DecodeContext = { privacyKey: 'k', providerId: 'mistral-vibe', sourceRef: 'ref' } + +function metadata(opts: { + sessionId?: string + input?: number + output?: number + sessionCost?: number + inputPrice?: number + outputPrice?: number + activeModel?: string + modelName?: string + configInputPrice?: number + configOutputPrice?: number + endTime?: string | null + title?: string +} = {}): MistralVibeSessionRecord { + const activeModel = opts.activeModel ?? 'mistral-medium-3.5' + return { + metadata: { + 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', + stats: { + session_prompt_tokens: opts.input ?? 2000, + session_completion_tokens: opts.output ?? 3000, + session_cost: opts.sessionCost, + input_price_per_million: opts.inputPrice ?? 1.5, + output_price_per_million: opts.outputPrice ?? 7.5, + }, + config: { + active_model: activeModel, + models: [ + { + alias: activeModel, + name: opts.modelName ?? 'mistral-vibe-cli-latest', + input_price: opts.configInputPrice ?? 1.5, + output_price: opts.configOutputPrice ?? 7.5, + }, + ], + }, + title: opts.title ?? 'implement mistral support', + }, + sessionCost: opts.sessionCost ?? 0, + } +} + +function userMessage(content: unknown = 'implement mistral support', messageId = 'msg-user-1'): VibeMessage { + return { + role: 'user', + content, + message_id: messageId, + } +} + +function assistantMessage( + content = 'Done', + messageId = 'msg-assistant-1', + toolCalls: Array<{ name: string; args?: Record | string }> = [], +): VibeMessage { + return { + role: 'assistant', + content, + message_id: messageId, + 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 ?? {}), + }, + })), + } +} + +describe('mistral-vibe rich decode (moved to @codeburn/core)', () => { + it('allocates integer token totals with remainder distributed to early messages', () => { + const records: unknown[] = [ + metadata({ sessionId: 'session-remainder', input: 100, output: 100, sessionCost: 0.1 }), + userMessage('first turn', 'msg-user-1'), + assistantMessage('a1', 'msg-assistant-1'), + userMessage('second turn', 'msg-user-2'), + assistantMessage('a2', 'msg-assistant-2'), + userMessage('third turn', 'msg-user-3'), + assistantMessage('a3', 'msg-assistant-3'), + ] + + const { calls } = decodeMistralVibe({ records, context }) + + expect(calls).toHaveLength(3) + // 100 / 3 = 33 remainder 1 -> first message gets 34, rest get 33. + expect(calls[0]!.inputTokens).toBe(34) + expect(calls[0]!.outputTokens).toBe(34) + expect(calls[1]!.inputTokens).toBe(33) + expect(calls[1]!.outputTokens).toBe(33) + expect(calls[2]!.inputTokens).toBe(33) + expect(calls[2]!.outputTokens).toBe(33) + + // 0.10 / 3 = 0.03333333333333333 for every message. + expect(calls[0]!.measuredCostUSD).toBe(0.03333333333333333) + expect(calls[1]!.measuredCostUSD).toBe(0.03333333333333333) + expect(calls[2]!.measuredCostUSD).toBe(0.03333333333333333) + expect(calls[0]!.measuredCostUSD + calls[1]!.measuredCostUSD + calls[2]!.measuredCostUSD).toBeCloseTo(0.1, 10) + }) + + it('handles the session_cost = 0 arm by using the host-resolved session cost', () => { + // Host resolves the cost from Vibe's per-million prices: (1000/1e6)*1.5 + (1000/1e6)*7.5 = 0.009. + const records: unknown[] = [ + metadata({ sessionId: 'session-zero', input: 1000, output: 1000, sessionCost: 0.009 }), + userMessage('zero cost turn'), + assistantMessage('z1'), + ] + + const { calls } = decodeMistralVibe({ records, context }) + + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(1000) + expect(calls[0]!.outputTokens).toBe(1000) + expect(calls[0]!.measuredCostUSD).toBe(0.009) + }) + + it('passes a single-assistant session through unchanged (count <= 1 path)', () => { + const records: unknown[] = [ + metadata({ sessionId: 'session-single', input: 2000, output: 3000, sessionCost: 0.0255 }), + userMessage('single turn'), + assistantMessage('s1'), + ] + + const { calls } = decodeMistralVibe({ records, context }) + + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(2000) + expect(calls[0]!.outputTokens).toBe(3000) + expect(calls[0]!.measuredCostUSD).toBe(0.0255) + expect(calls[0]!.deduplicationKey).toBe('mistral-vibe:session-single:msg-assistant-1') + expect(calls[0]!.turnId).toBe('session-single:turn-0') + expect(calls[0]!.userMessage).toBe('single turn') + }) + + it('threads user messages across turns and stamps turnId on each assistant call', () => { + const records: unknown[] = [ + metadata({ sessionId: 'session-turns', input: 300, output: 300, sessionCost: 0 }), + userMessage('turn one', 'msg-user-1'), + assistantMessage('a1', 'msg-assistant-1'), + userMessage('turn two', 'msg-user-2'), + assistantMessage('a2', 'msg-assistant-2'), + ] + + const { calls } = decodeMistralVibe({ records, context }) + + expect(calls).toHaveLength(2) + expect(calls[0]!.userMessage).toBe('turn one') + expect(calls[0]!.turnId).toBe('session-turns:turn-0') + expect(calls[1]!.userMessage).toBe('turn two') + expect(calls[1]!.turnId).toBe('session-turns:turn-1') + }) + + it('deduplicates by session id when no assistant messages are present', () => { + const records: unknown[] = [ + metadata({ sessionId: 'session-no-assistant', input: 100, output: 100, sessionCost: 0.001 }), + userMessage('lonely user'), + ] + + const seen = new Set() + const first = decodeMistralVibe({ records, context, seenKeys: seen }).calls + expect(first).toHaveLength(1) + expect(first[0]!.deduplicationKey).toBe('mistral-vibe:session-no-assistant') + + const again = decodeMistralVibe({ records, context, seenKeys: seen }).calls + expect(again).toEqual([]) + }) + + it('threads a live seenKeys set so repeated assistant messages drop', () => { + const records: unknown[] = [ + metadata({ sessionId: 'session-dedup', input: 100, output: 100, sessionCost: 0.001 }), + userMessage('dedup turn'), + assistantMessage('a1', 'msg-assistant-1'), + ] + + const seen = new Set() + const first = decodeMistralVibe({ records, context, seenKeys: seen }).calls + expect(first).toHaveLength(1) + + const again = decodeMistralVibe({ records, context, seenKeys: seen }).calls + expect(again).toEqual([]) + }) + + it('skips sessions without cumulative token usage', () => { + const records: unknown[] = [ + metadata({ input: 0, output: 0 }), + userMessage(), + assistantMessage(), + ] + + const { calls } = decodeMistralVibe({ records, context }) + expect(calls).toEqual([]) + }) + + it('maps tool names and extracts raw bash commands', () => { + const records: unknown[] = [ + metadata({ input: 100, output: 100, sessionCost: 0.001 }), + userMessage(), + assistantMessage('Done', 'msg-assistant-1', [ + { 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 } = decodeMistralVibe({ records, context }) + + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual(['Read', 'Edit', 'Bash']) + expect(calls[0]!.rawBashCommands).toEqual(['npm test && git status']) + }) + + it('preserves unknown tool names in the rich decode; observations filter them', () => { + const records: unknown[] = [ + metadata({ input: 100, output: 100, sessionCost: 0.001 }), + userMessage(), + assistantMessage('Done', 'msg-assistant-1', [ + { name: 'bash', args: { command: 'echo ok' } }, + { name: 'not a valid tool name!', args: {} }, + ]), + ] + + const { calls } = decodeMistralVibe({ records, context }) + + // Rich decode mirrors the original provider behavior: unknown names pass through. + expect(calls[0]!.tools).toEqual(['Bash', 'not a valid tool name!']) + + const { sessions } = toObservations( + { sessionId: 'session-tools', projectPath: '/Users/test/mistral-project', calls }, + { privacyKey: 'test-privacy-key', provider: 'mistral-vibe' }, + ) + expect(sessions[0]!.calls[0]!.toolNames).toEqual(['Bash']) + }) + + // Every expectation below was captured by running the pre-migration in-CLI + // decode over the equivalent fixtures. + it('distributes an integer remainder to the FIRST messages (7 over 3, 2 over 3)', () => { + const records: unknown[] = [ + metadata({ sessionId: 'alloc-7-over-3', input: 7, output: 2, sessionCost: 1 }), + userMessage('u', 'msg-user-1'), + assistantMessage('a1', 'a1'), + assistantMessage('a2', 'a2'), + assistantMessage('a3', 'a3'), + ] + + const { calls } = decodeMistralVibe({ records, context }) + + expect(calls.map(c => [c.inputTokens, c.outputTokens])).toEqual([[3, 1], [2, 1], [2, 0]]) + expect(calls.map(c => c.measuredCostUSD)).toEqual([ + 0.3333333333333333, 0.3333333333333333, 0.3333333333333333, + ]) + }) + + it('handles a total smaller than the message count (1 over 3, 0 over 3)', () => { + const records: unknown[] = [ + metadata({ sessionId: 'alloc-1-over-3', input: 1, output: 0, sessionCost: 0.1 }), + userMessage('u', 'msg-user-1'), + assistantMessage('a1', 'a1'), + assistantMessage('a2', 'a2'), + assistantMessage('a3', 'a3'), + ] + + const { calls } = decodeMistralVibe({ records, context }) + + expect(calls.map(c => [c.inputTokens, c.outputTokens])).toEqual([[1, 0], [0, 0], [0, 0]]) + expect(calls.map(c => c.measuredCostUSD)).toEqual([ + 0.03333333333333333, 0.03333333333333333, 0.03333333333333333, + ]) + }) + + it('divides the session cost rather than multiplying by the reciprocal', () => { + // 0.005 / 3 and 0.005 * (1 / 3) differ in the last bit; the original divided. + const records: unknown[] = [ + metadata({ sessionId: 'alloc-float-order', input: 30, output: 30, sessionCost: 0.005 }), + userMessage('f', 'msg-user-1'), + assistantMessage('a1', 'a1'), + assistantMessage('a2', 'a2'), + assistantMessage('a3', 'a3'), + ] + + const { calls } = decodeMistralVibe({ records, context }) + + expect(calls.map(c => c.measuredCostUSD)).toEqual([ + 0.0016666666666666668, 0.0016666666666666668, 0.0016666666666666668, + ]) + }) + + it('carries measuredCostUSD 0 on the terminal arm where nothing resolved a cost', () => { + const records: unknown[] = [ + metadata({ sessionId: 'alloc-zero-cost', input: 1000, output: 1000, sessionCost: 0 }), + userMessage('zero', 'msg-user-1'), + assistantMessage('a1', 'a1'), + assistantMessage('a2', 'a2'), + ] + + const { calls } = decodeMistralVibe({ records, context }) + + for (const call of calls) { + expect(Object.hasOwn(call, 'measuredCostUSD')).toBe(true) + expect(call.measuredCostUSD).toBe(0) + } + }) + + it('uses the host sessionIdFallback only when meta.json omits session_id', () => { + const withFallback = metadata({ input: 10, output: 10, sessionCost: 0.02 }) + delete withFallback.metadata.session_id + withFallback.sessionIdFallback = 'b4_no_session_id' + + const { calls } = decodeMistralVibe({ + records: [withFallback, userMessage('no id', 'msg-user-1'), assistantMessage('a1', 'a1')], + context, + }) + expect(calls[0]!.sessionId).toBe('b4_no_session_id') + expect(calls[0]!.deduplicationKey).toBe('mistral-vibe:b4_no_session_id:a1') + expect(calls[0]!.turnId).toBe('b4_no_session_id:turn-0') + + const both: MistralVibeSessionRecord = { ...metadata({ sessionId: 'from-meta', input: 10, output: 10, sessionCost: 0.02 }), sessionIdFallback: 'from-path' } + const second = decodeMistralVibe({ records: [both, assistantMessage('a1', 'a1')], context }) + expect(second.calls[0]!.sessionId).toBe('from-meta') + }) + + it('keys assistant messages without a message_id by ordinal (idx-N)', () => { + const anonymous = (): VibeMessage => ({ role: 'assistant', content: 'ok', tool_calls: [] }) + const records: unknown[] = [ + metadata({ sessionId: 'alloc-idx-keys', input: 5, output: 5, sessionCost: 0.3 }), + userMessage('x', 'msg-user-1'), + anonymous(), + anonymous(), + ] + + const { calls } = decodeMistralVibe({ records, context }) + + expect(calls.map(c => c.deduplicationKey)).toEqual([ + 'mistral-vibe:alloc-idx-keys:idx-0', + 'mistral-vibe:alloc-idx-keys:idx-1', + ]) + expect(calls.map(c => [c.inputTokens, c.outputTokens])).toEqual([[3, 3], [2, 2]]) + }) + + it('omits turnId entirely on the no-assistant session-level arm', () => { + const records: unknown[] = [ + metadata({ sessionId: 'alloc-no-assistant', input: 55, output: 55, sessionCost: 0.004 }), + userMessage('only user text', 'msg-user-1'), + ] + + const { calls } = decodeMistralVibe({ records, context }) + + expect(Object.hasOwn(calls[0]!, 'turnId')).toBe(false) + expect(calls[0]!.inputTokens).toBe(55) + expect(calls[0]!.measuredCostUSD).toBe(0.004) + }) + + it('toObservations produces a schema-valid, secret-free envelope', () => { + const records: unknown[] = [ + metadata({ sessionId: 'session-obs', input: 100, output: 100, sessionCost: 0.001 }), + userMessage('plain user prompt'), + assistantMessage('Done', 'msg-assistant-1', [ + { name: 'bash', args: { command: 'npm test' } }, + ]), + ] + + const { calls } = decodeMistralVibe({ records, context }) + const { sessions } = toObservations( + { sessionId: 'session-obs', projectPath: '/Users/test/mistral-project', calls }, + { privacyKey: 'test-privacy-key', provider: 'mistral-vibe' }, + ) + + const envelope = { + schemaVersion: OBSERVATION_SCHEMA_VERSION, + generator: { name: '@codeburn/core', version: '0.0.0-test' }, + sessions, + } + + expect(ObservationEnvelope.safeParse(envelope).success).toBe(true) + expect(sessions[0]!.calls[0]!.costBasis).toBe('measured') + expect(sessions[0]!.calls[0]!.measuredCostUSD).toBe(0.001) + expect(sessions[0]!.calls[0]!.toolNames).toEqual(['Bash']) + }) +}) diff --git a/packages/core/tsup.config.ts b/packages/core/tsup.config.ts index 93a5f19c..68775e22 100644 --- a/packages/core/tsup.config.ts +++ b/packages/core/tsup.config.ts @@ -26,6 +26,7 @@ export default defineConfig({ 'src/providers/lingtai-tui/index.ts', 'src/providers/gemini/index.ts', 'src/providers/kimicode/index.ts', + 'src/providers/mistral-vibe/index.ts', 'src/providers/pi/index.ts', 'src/providers/crush/index.ts', 'src/providers/zcode/index.ts',