mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-07-30 11:25:08 +00:00
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:
commit
87b72d7260
11 changed files with 1532 additions and 258 deletions
|
|
@ -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()
|
||||
|
|
|
|||
506
packages/cli/tests/providers/mistral-vibe-bridge.test.ts
Normal file
506
packages/cli/tests/providers/mistral-vibe-bridge.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
269
packages/core/src/providers/mistral-vibe/decode.ts
Normal file
269
packages/core/src/providers/mistral-vibe/decode.ts
Normal 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 }
|
||||
}
|
||||
32
packages/core/src/providers/mistral-vibe/index.ts
Normal file
32
packages/core/src/providers/mistral-vibe/index.ts
Normal 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'
|
||||
89
packages/core/src/providers/mistral-vibe/observations.ts
Normal file
89
packages/core/src/providers/mistral-vibe/observations.ts
Normal 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: [] }
|
||||
}
|
||||
93
packages/core/src/providers/mistral-vibe/types.ts
Normal file
93
packages/core/src/providers/mistral-vibe/types.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
394
packages/core/tests/providers/mistral-vibe-decode.test.ts
Normal file
394
packages/core/tests/providers/mistral-vibe-decode.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue