Merge pull request #838 from getagentseal/phase8/judgment-mistral-vibe

refactor(core): mistral-vibe decode into core with host-side cost resolution (phase 8, judgment tier)
This commit is contained in:
Resham Joshi 2026-07-26 23:47:39 -07:00 committed by GitHub
commit 87b72d7260
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1532 additions and 258 deletions

View file

@ -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<string, string> = {
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
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<string, unknown> | 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<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 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<VibeMessage[]> {
const messages: VibeMessage[] = []
async function readMessages(path: string): Promise<unknown[]> {
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<VibeMessage[]> {
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<string>): 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<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 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<MistralVibeDecodedCall>({
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<SessionSource[]> {
@ -439,10 +229,30 @@ export function createMistralVibeProvider(sessionsDir?: string): Provider {
return sources
},
createSessionParser(source: SessionSource, seenKeys: Set<string>): 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<unknown[] | null> {
const metadata = await readJsonFile<VibeMetadata>(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()

View file

@ -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<string, unknown>,
messages: Record<string, unknown>[],
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<void> {
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<ParsedProviderCall[]> {
const sources = await provider.discoverSessions()
sources.sort((a, b) => a.path.localeCompare(b.path))
const seen = new Set<string>()
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<string>()
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<string, unknown>, messages: Record<string, unknown>[]) {
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<string, unknown>): Record<string, unknown> {
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<void> {
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)
})
})

View file

@ -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"

View file

@ -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<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',
}
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<string>
}
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<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 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:<sessionId>:<messageId>` 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<string>()
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 }
}

View file

@ -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'

View file

@ -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: [] }
}

View file

@ -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<string, unknown> | 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
}

View file

@ -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',

View file

@ -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)
})
})

View file

@ -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, unknown> | 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<string>()
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<string>()
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'])
})
})

View file

@ -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',