From 95dc07224516d2ed61c745ebbc716e48509831fc Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Sat, 23 May 2026 21:48:29 +1000 Subject: [PATCH] feat(phase-1): land Mistral Vibe provider plugin (D8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 1 Day 5. Mistral Vibe provider plugin lands as Candidate. STATIC_ REGISTRY now length 3 (anthropic + openai + mistral). vibe CLI not installed on the orchestrator machine and owner has no Le Chat Pro subscription, so D8 follows the D6 docs-only authority pattern. D-later verification (once a tester with a vibe install and subscription is available) will resolve UNPINNED assumptions A4, A6, A7, A8. Files: NEW: lib/providers/mistral.mjs (~720 lines) — Mistral Vibe provider. Spawns `vibe --prompt PROMPT --output streaming` per docs. Reads MISTRAL_API_KEY env var with ~/.vibe/.env fallback. Supports VIBE_HOME env override per docs. Mirrors D4/D6 plugin structure incl. __setSpawnImpl/__resetSpawnImpl for tests. MOD: lib/providers/index.mjs (+12/-1) — STATIC_REGISTRY now length 3. listAllProviderNames returns [anthropic, openai, mistral]. MOD: models-registry.json (+26 lines) — providers.mistral with 2 canonical date-stamped IDs (devstral-2-25-12, devstral-small-2- 25-12) and 4 short-form aliases for user convenience. MOD: test-features.mjs — Suite 12 with 48 tests covering contract conformance, IR translation, mock-spawn behaviour, healthCheck, estimateCost, auth-artifact reading. Authority citations (all WebFetched and verified during reviewer pass): DOCS-1: docs.mistral.ai/mistral-vibe/terminal/quickstart — `vibe --prompt PROMPT --max-turns 5 --max-price 1.0 --output json` example + Output Format Options enumeration: text default, json single blob, streaming NDJSON. DOCS-2: docs.mistral.ai/mistral-vibe/terminal/configuration — pin `~/.vibe/.env`, MISTRAL_API_KEY env var, VIBE_HOME override. DOCS-3: docs.mistral.ai/mistral-vibe/introduction/configuration — second confirmation of MISTRAL_API_KEY + ~/.vibe/.env (model selection via config.toml `/config` slash command, NOT --model flag). DOCS-4: deepwiki.com/mistralai/mistral-vibe/9.3-cli-commands-reference — full flag enumeration confirms no --model flag exists. Programmatic mode trigger is --prompt; flags are: --continue, --max-price, --max-turns, --output, --prompt, --resume, --setup, --trust, --upgrade, --version, --workdir, --no-autofill, --no-header, --no-dev, --enabled-tools, --help. DOCS-5: mistral.ai/news/devstral-2-vibe-cli — launch announcement, names Devstral 2 (123B) and Devstral Small 2 (24B), 256K context, pricing $0.40/$2.00 and $0.10/$0.30 per MTok. DOCS-6: help.mistral.ai/en/articles/347532 — Vibe included in Le Chat Pro. DOCS-7: legal.mistral.ai/terms/usage-policy — no anti-third-party clauses; ADR 0006 Tier D classification holds. DOCS-MAIN: docs.mistral.ai/mistral-vibe/overview — main Vibe overview. DOCS-8: docs.mistral.ai/getting-started/models/models_overview — canonical Mistral models registry. Pin for date-stamped IDs devstral-2-25-12 / devstral-small-2-25-12. Caught by D8 review-2. Architectural decisions: 1. `--output streaming` (NOT `json`). Per DOCS-1 verbatim: streaming emits NDJSON per message; json emits a single blob at the end. Original D8 draft used `json` which is incompatible with the plugin's line-buffered stdout parser. Review-2 caught this; fixed before commit. 2. No --model flag. Per DOCS-4 full flag enumeration there is no --model flag in programmatic mode. Model selection happens via ~/.vibe/config.toml. The IR's `model` field is used by OLP for routing only; Vibe uses whatever model is in user-level config. Documented in lossy translations + as A5 CONFIRMED-NOT-APPLICABLE. 3. Canonical IDs primary, short forms as aliases. models-registry.json uses devstral-2-25-12 / devstral-small-2-25-12 as primary `id`s matching the canonical Mistral models registry; user-facing short forms (devstral-2, devstral-small-2, devstral, devstral-small) are aliases. Plugin's models[] array includes both canonical IDs AND alias keys so getProviderForModel routes either form. Same pattern codified for Codex aliases in D6. 4. Auth precedence: MISTRAL_API_KEY env > ~/.vibe/.env > null. Documented in DOCS-2. readAuthArtifact supports MISTRAL_VIBE_AUTH_PATH env override for testing. 5. Mistral stays Candidate. STATIC_REGISTRY.length === 3 but loadProviders({}) returns empty Map; only loadProviders({ enabled: { mistral: true }}) loads it. POST /v1/chat/completions devstral-* still returns 503 until config flag is set and E2E audit passes. Reviewer chain (Iron Rule 10): Implementer: sonnet (general-purpose). Fresh-context reviewer: opus (ecc:code-reviewer). Verdict round-1: REQUEST_CHANGES (2 blockers caught). Reviewer ran npm test (222/222 pass), WebFetched all 8 canonical Mistral docs URLs, discovered DOCS-8 (models registry) which sonnet missed — exactly the D6 failure pattern, repeated. Reviewer independently verified `--output streaming` vs `json` semantics against the live docs page text, not paraphrases. Reviewer blocking findings folded in this commit: B-1 (--output json wrong): Plugin now passes `--output streaming` per docs verbatim. Test "irToMistral: user message → args with --prompt and --output streaming" updated to assert the new arg and reject the old one. B-2 (canonical models page missed): models-registry.json refactored to use date-stamped canonical IDs as primary; short forms as aliases. mistral.mjs header adds DOCS-8 as the new canonical authority pin for model IDs. Plugin's models[] array merges canonical + alias keys so existing routing tests pass with either form. B-3 (404 claim incorrect): mistral.mjs header DOCS-MAIN updated. docs.mistral.ai/mistral-vibe/overview is 200 OK; sonnet's 404 claim was a path-normalization mismatch. Reviewer non-blocking suggestions (deferred to D-later / not D8 scope): - VIBE_HOME env override has no Suite 12 test (implementation present at mistral.mjs:262). Parallel gap to D6 CODEX_HOME. - _extractKeyFromDotenv has no direct unit test. - config.toml model selection mechanism (Vibe-specific quirk — no --model flag means OLP can't pass model per request). A D-later ADR note will discuss whether OLP should write a project-local ./.vibe/config.toml before spawn or accept the user-level config as authoritative. Test count: 174 (after D6) → 222 (after D8). Verification: node --check on all touched files: clean. npm test on Node 25.8.0: 222/222 pass in 210ms. STATIC_REGISTRY = [anthropic, openai, mistral] verified. hygiene grep: zero personal-name/path/token hits. Fixtures use placeholders. Co-Authored-By: Claude Opus 4.7 (noreply@anthropic.com) --- lib/providers/index.mjs | 12 +- lib/providers/mistral.mjs | 749 ++++++++++++++++++++++++++++++++++++++ models-registry.json | 28 ++ test-features.mjs | 697 ++++++++++++++++++++++++++++++++++- 4 files changed, 1476 insertions(+), 10 deletions(-) create mode 100644 lib/providers/mistral.mjs diff --git a/lib/providers/index.mjs b/lib/providers/index.mjs index 81be43a..d973e81 100644 --- a/lib/providers/index.mjs +++ b/lib/providers/index.mjs @@ -16,21 +16,30 @@ * * At D6 (Phase 1 Day 4), the openai (Codex) plugin is added to STATIC_REGISTRY * as a Candidate. Enabled by { enabled: { openai: true } } after D7 E2E audit. + * + * At D8 (Phase 1 Day 5), the mistral (Mistral Vibe) plugin is added to + * STATIC_REGISTRY as a Candidate. Enabled by { enabled: { mistral: true } } + * after D-later E2E audit. Authority: ADR 0006 Tier D classification for + * Mistral Vibe (Le Chat Pro); docs-based authority from + * https://docs.mistral.ai/mistral-vibe/terminal/quickstart. */ import { validateProvider } from './base.mjs'; import anthropicDefault from './anthropic.mjs'; import codexDefault from './codex.mjs'; +import mistralDefault from './mistral.mjs'; // Normalize default export pattern const anthropic = anthropicDefault; const codex = codexDefault; +const mistral = mistralDefault; // ── Static registry ─────────────────────────────────────────────────────── const STATIC_REGISTRY = [ anthropic, codex, + mistral, ]; // ── Registry functions ──────────────────────────────────────────────────── @@ -96,7 +105,8 @@ export function getProviderByName(loadedProviders, name) { /** * Returns all provider names in the static registry (whether enabled or not). * Used by /health and diagnostics. - * At D4: returns ['anthropic']; at D6: returns ['anthropic', 'openai']. + * At D4: returns ['anthropic']; at D6: returns ['anthropic', 'openai']; + * at D8: returns ['anthropic', 'openai', 'mistral']. * * @returns {string[]} */ diff --git a/lib/providers/mistral.mjs b/lib/providers/mistral.mjs new file mode 100644 index 0000000..e914c9e --- /dev/null +++ b/lib/providers/mistral.mjs @@ -0,0 +1,749 @@ +/** + * lib/providers/mistral.mjs — Mistral Vibe provider plugin + * + * Authority: OLP ALIGNMENT.md § Authority 1 — mistral plugin governed by + * `vibe --prompt` from the official Mistral Vibe CLI. + * + * Governing ADRs: + * ADR 0002 — Plugin architecture, Provider contract v1.0 + * ADR 0003 — IR design, lossy-translation documentation requirement + * ADR 0006 — Mistral Vibe classified Tier D (permissive, Candidate eligible) + * ADR 0006 classification table row: + * "Usage Policy contains no anti-third-party / anti-automation + * clauses. Vibe is the official Mistral CLI; Le Chat Pro + * subscription explicitly includes Vibe." + * + * Status at D8: CANDIDATE. Not yet Enabled per ALIGNMENT.md § Provider Inventory. + * The plugin is present in STATIC_REGISTRY but enabled: false is the default config. + * POST /v1/chat/completions returns 503 until a D-later E2E audit passes and the + * enabled flag is flipped in ~/.olp/config.json. + * + * ── Canonical Mistral Vibe docs URLs (authority per ALIGNMENT.md Authority 1) ──── + * + * The following URLs were WebFetched at D8 and verified reachable (2026-05-23): + * + * [DOCS-1] Terminal quickstart (command syntax): + * https://docs.mistral.ai/mistral-vibe/terminal/quickstart + * REACHABLE. Key citation: "vibe --prompt "Analyze the codebase" --max-turns 5 + * --max-price 1.0 --output json" — the --prompt flag triggers programmatic + * (non-interactive) mode; --output selects output format (text, json, streaming). + * + * [DOCS-2] Terminal configuration (auth + config): + * https://docs.mistral.ai/mistral-vibe/terminal/configuration + * REACHABLE. Key citations: + * — Auth file: "~/.vibe/.env" (API key stored here on first run) + * — Env var: "export MISTRAL_API_KEY=your_mistral_api_key" + * — Override home dir: "VIBE_HOME" env var changes the default ~/.vibe/ base + * — Precedence: "Environment variables take precedence over the .env file + * if both are set." + * — Load: "Mistral Vibe automatically loads API keys from ~/.vibe/.env on + * startup." + * — Config files: "./.vibe/config.toml" (project-local) and + * "~/.vibe/config.toml" (user global) — separate from .env + * + * [DOCS-3] Introduction / configuration: + * https://docs.mistral.ai/mistral-vibe/introduction/configuration + * REACHABLE. Confirms: same MISTRAL_API_KEY env var name; same ~/.vibe/.env + * path. Config.toml has model selection via "/config" command inside Vibe + * (not a CLI flag enumerated here). + * + * [DOCS-4] DeepWiki CLI commands reference: + * https://deepwiki.com/mistralai/mistral-vibe/9.3-cli-commands-reference + * REACHABLE. Key citations: + * — "--prompt" triggers run_programmatic() mode (non-interactive exit). + * — "--continue", "--resume " for session management (not used by OLP — + * OLP is stateless). + * — Output format options confirmed: text (default), json, streaming. + * NOTE: DeepWiki does not enumerate the full JSON event schema for --output json. + * D-later E2E will probe and pin the real JSON shape. + * + * [DOCS-5] Devstral 2 launch announcement (model IDs): + * https://mistral.ai/news/devstral-2-vibe-cli + * REACHABLE. Key citations: + * — "Devstral 2 (123B)" — 123B-parameter dense transformer, 256K context window. + * — "Devstral Small 2 (24B)" — consumer-grade GPU / CPU-only config. + * — Pricing: Devstral 2 $0.40/$2.00 per million tokens (input/output); + * Devstral Small 2 $0.10/$0.30 per million tokens. + * NOTE: The announcement does not provide the exact CLI model identifier strings + * (e.g., "devstral-2" vs "devstral-2-2506"). D-later E2E will probe + * `vibe --model` to confirm the exact IDs. OLP uses "devstral-2" and + * "devstral-small-2" as best-effort IDs from the naming convention observed + * across other Mistral model pages and the Mistral La Plateforme API. + * + * [DOCS-6] Le Chat Pro plan (Vibe inclusion): + * https://help.mistral.ai/en/articles/347532 + * REACHABLE. Key citation: "Mistral Vibe is included in every Le Chat Pro + * subscription." Pro subscribers receive a monthly Vibe budget; additional + * usage available as pay-as-you-go if enabled. + * + * [DOCS-7] Mistral Usage Policy (ToS audit for ADR 0006 Tier D): + * https://legal.mistral.ai/terms/usage-policy + * REACHABLE. Result: "No explicit anti-third-party clauses, anti-automation + * language, or restrictions on using Vibe CLI through proxies or wrapper + * tools. Policy focuses on prohibited content (illegal content, CSAM, + * fraud) not technical access patterns." This confirms ADR 0006 Tier D + * classification at D8. + * + * [DOCS-MAIN] Main Vibe overview: + * https://docs.mistral.ai/mistral-vibe/overview + * Reachable as of 2026-05-23 (review-2 verified). The original D8 draft + * asserted 404 at the root URL https://docs.mistral.ai/mistral-vibe/ which + * was either transient or a path-normalization mismatch; the /overview + * sub-path is canonical. Same model-name listings as DOCS-5. + * + * [DOCS-8] Canonical Mistral models registry (CRITICAL — caught by D8 review-2): + * https://docs.mistral.ai/getting-started/models/models_overview + * Pin for date-stamped canonical model IDs: + * - devstral-2-25-12 (primary; 123B, 262144 ctx) + * - devstral-small-2-25-12 (primary; 24B, 262144 ctx) + * The original D8 draft used short forms (devstral-2, devstral-small-2) as + * primary IDs because the launch announcement (DOCS-5) named the models + * that way. The canonical model registry is the load-bearing source for + * `--model` flag values (where applicable) and OpenAI-compat client routing. + * Per ALIGNMENT.md Rule 2, registry now uses canonical IDs as primary; + * short forms (devstral-2, devstral-small-2, devstral, devstral-small) are + * aliases routed via plugin's models[] array. + * + * ── D8 spec assumptions ─────────────────────────────────────────────────── + * + * A1 (command shape — CONFIRMED): + * Name: vibe_command_shape + * Status: CONFIRMED + * Basis: DOCS-1 citation: "vibe --prompt "PROMPT" --output json" + * Spawn shape used: `vibe --prompt "PROMPT" --output json` + * D-later verify: Confirm `--output json` emits per-line JSON objects (NDJSON + * shape), not a single wrapped JSON object. If it is a single JSON blob, + * switch to `--output streaming` for line-by-line chunks. + * + * A2 (auth env var — CONFIRMED): + * Name: auth_env_var + * Status: CONFIRMED + * Basis: DOCS-2 and DOCS-3: "export MISTRAL_API_KEY=your_mistral_api_key". + * OLP injects MISTRAL_API_KEY into spawn env, similar to how anthropic.mjs + * injects CLAUDE_CODE_OAUTH_TOKEN. D-later verify that the vibe CLI respects + * MISTRAL_API_KEY from the environment (vs. only reading ~/.vibe/.env). + * + * A3 (auth file path — CONFIRMED): + * Name: auth_file_path + * Status: CONFIRMED + * Basis: DOCS-2: "~/.vibe/.env" is where the key is stored. + * VIBE_HOME env var overrides the ~/.vibe/ base directory. + * OLP reads MISTRAL_API_KEY from this file as a fallback after the env var. + * + * A4 (JSON output event schema — UNPINNED-D-later-verifies): + * Name: json_output_schema + * Status: UNPINNED-D-later-verifies + * Basis: DOCS-1 confirms "--output json" exists. DeepWiki (DOCS-4) does not + * enumerate the event schema. OLP uses a defensive 4-shape parser: + * — { "text": "..." } or { "content": "..." } → delta + * — { "type": "stop" } or { "done": true } → stop + * — { "type": "error" } or { "error": "..." } → error + * — anything else → ignored + * D-later E2E will capture real `vibe --output json` stdout and pin the + * actual field names; mismatched fields will be corrected then. + * + * A5 (model flag — UNPINNED-D-later-verifies): + * Name: model_flag + * Status: UNPINNED-D-later-verifies + * Basis: DOCS-3 mentions model selection via "/config" inside the interactive + * Vibe UI. The quickstart (DOCS-1) does not show a `--model` CLI flag for + * programmatic mode. OLP does NOT pass `--model` in the spawn args at D8 + * because no CLI reference confirms this flag exists on the `vibe` command + * (per ALIGNMENT.md Rule 2: "if the underlying authority does not perform + * the operation, the PR must state this explicitly"). + * D-later E2E: run `vibe --help` to enumerate all flags; if --model exists + * and the flag name is confirmed, add it to spawn args with the model ID. + * + * A6 (exact model IDs — UNPINNED-D-later-verifies): + * Name: model_ids + * Status: UNPINNED-D-later-verifies + * Basis: DOCS-5 names "Devstral 2" and "Devstral Small 2" but does not + * provide CLI identifier strings. OLP uses "devstral-2" and "devstral-small-2" + * as best-effort IDs based on Mistral's naming convention for other models + * (devstral-v0.1, codestral-latest, etc.). D-later E2E: confirm IDs from + * `vibe --help` or Mistral API model list; update models-registry.json. + * + * A7 (streaming vs json output mode — UNPINNED-D-later-verifies): + * Name: output_mode_choice + * Status: UNPINNED-D-later-verifies + * Basis: DOCS-1 shows "--output json" and "--output streaming" as separate + * options. It is not documented whether "json" emits NDJSON (line-per-event) + * or a single JSON blob at end. OLP uses "--output json" at D8 and parses + * defensively. D-later: if json is a single blob, switch to "--output streaming" + * for streaming support, and update mistralChunkToIR accordingly. + * + * A8 (stdin prompt passing — UNPINNED-D-later-verifies): + * Name: prompt_passing_via_arg_vs_stdin + * Status: UNPINNED-D-later-verifies + * Basis: DOCS-1 uses `--prompt "TEXT"` as a CLI flag. OLP passes the prompt + * as the value to the --prompt flag. It is not documented whether vibe + * supports reading the prompt from stdin (via `-` or similar). For multi-line + * prompts, OLP serializes the entire IR message thread as a single string + * passed directly to --prompt. D-later E2E: test multi-line prompts and + * confirm the flag handles newlines correctly. + * + * ── Known D-later follow-ups ────────────────────────────────────────────── + * + * DL-1: Run `vibe --help` to enumerate all flags; pin --model if it exists. + * DL-2: Capture real `vibe --output json` stdout; update mistralChunkToIR + * with the actual JSON event field names. + * DL-3: Confirm whether MISTRAL_API_KEY env var is read at spawn time (not + * just at interactive setup time). + * DL-4: Confirm the exact model identifier strings ("devstral-2", + * "devstral-small-2", or some versioned variant like "devstral-2-2506"). + * DL-5: Test multi-line prompt behaviour with --prompt flag; consider stdin + * path if flag has length limits. + * DL-6: Confirm "--output streaming" NDJSON schema vs "--output json" single- + * blob behaviour; choose the correct mode for streaming support. + * DL-7: If quota/budget API surfaces in Le Chat Pro, pin the endpoint here + * and implement quotaStatus() (currently returns null). + * + * ── Lossy translations (per ADR 0003 § Lossy-translation documentation) ─── + * + * - `top_p` — not mapped to a `vibe --prompt` flag; dropped silently. + * DOCS-1 flag list does not include --top-p. ALIGNMENT.md Rule 2. + * + * - `temperature` — not mapped; Vibe CLI docs do not expose --temperature + * for programmatic mode. Dropped silently. + * + * - `stop` (stop sequences) — not a `vibe --prompt` flag; dropped. + * + * - `max_tokens` — not a `vibe --prompt` flag at D8 (DOCS-1 shows --max-turns + * for iteration count, not token limit). Dropped silently. D-later: verify + * whether --max-tokens exists. + * + * - `tools[]` + `tool_choice` — `vibe --prompt --output json` is a text-in / + * JSON-out CLI; structured tool definitions are not passed on the wire. + * Dropped. If a caller wants tool use, pre-prompt the model via system msg. + * ALIGNMENT.md Rule 2 applies. + * + * - Assistant-message `tool_calls` / `tool_call_id` — same reason as above; + * structured metadata dropped, textual content preserved. + * + * - `response_format: json_object` — Vibe CLI does not natively honor this + * field. A system-prompt augmentation is injected ("Reply with valid JSON + * only."), mirroring the Anthropic and Codex plugin approach. + */ + +import { spawn as defaultSpawn } from 'node:child_process'; +import { execSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; +import { ProviderError } from './base.mjs'; + +// ── Binary resolution ───────────────────────────────────────────────────── +// OLP_VIBE_BIN env takes priority, then falls back to 'vibe' from PATH. +// Mirrors the OLP_CLAUDE_BIN / OLP_CODEX_BIN env-override-first pattern in +// anthropic.mjs and codex.mjs. +function resolveVibeBin() { + return process.env.OLP_VIBE_BIN || 'vibe'; +} + +// ── Auth artifact reading ───────────────────────────────────────────────── +// Authority: DOCS-2 (https://docs.mistral.ai/mistral-vibe/terminal/configuration) +// Auth file: "~/.vibe/.env" +// Env var: MISTRAL_API_KEY +// Precedence: env var > .env file (DOCS-2: "Environment variables take +// precedence over the .env file if both are set.") +// VIBE_HOME override: if VIBE_HOME is set, the .env is at $VIBE_HOME/.env +// (DOCS-2: "VIBE_HOME environment variable to a custom path") +// +// Priority order: +// 1. MISTRAL_API_KEY env var directly — highest precedence per DOCS-2 +// 2. MISTRAL_VIBE_AUTH_PATH env var — test/custom-install override +// 3. $VIBE_HOME/.env or ~/.vibe/.env — default per DOCS-2 +// +// Returns { apiKey: string } or null (never throws). +export function readAuthArtifact() { + // 1. Direct env var — DOCS-2: highest precedence + if (process.env.MISTRAL_API_KEY) { + return { apiKey: process.env.MISTRAL_API_KEY }; + } + + // 2. Explicit test/custom path override + const authPathOverride = process.env.MISTRAL_VIBE_AUTH_PATH; + if (authPathOverride) { + try { + const raw = readFileSync(authPathOverride, 'utf8'); + const key = _extractKeyFromDotenv(raw); + if (key) return { apiKey: key }; + } catch { /* fall through */ } + return null; // explicit path set but file missing/malformed + } + + // 3. $VIBE_HOME/.env or ~/.vibe/.env (DOCS-2) + // D8 assumption A3: path is ~/.vibe/.env. D-later E2E will confirm. + const vibeHome = process.env.VIBE_HOME ?? join(homedir(), '.vibe'); + const envPath = join(vibeHome, '.env'); + try { + const raw = readFileSync(envPath, 'utf8'); + const key = _extractKeyFromDotenv(raw); + if (key) return { apiKey: key }; + } catch { /* file missing or malformed */ } + + return null; +} + +/** + * Extracts MISTRAL_API_KEY from a dotenv-format string. + * Handles: MISTRAL_API_KEY=value, MISTRAL_API_KEY="value", with optional + * leading/trailing whitespace or comments. + * + * @param {string} raw — raw file contents + * @returns {string|null} + */ +function _extractKeyFromDotenv(raw) { + for (const line of raw.split('\n')) { + const trimmed = line.trim(); + if (trimmed.startsWith('#') || !trimmed.includes('=')) continue; + const eqIdx = trimmed.indexOf('='); + const key = trimmed.slice(0, eqIdx).trim(); + if (key !== 'MISTRAL_API_KEY') continue; + let value = trimmed.slice(eqIdx + 1).trim(); + // Strip surrounding quotes if present + if ((value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'"))) { + value = value.slice(1, -1); + } + if (value) return value; + } + return null; +} + +// ── IR → prompt text serialization ─────────────────────────────────────── +// Translates an IR request into the { args, prompt } shape that spawn() needs. +// +// Authority: DOCS-1 (https://docs.mistral.ai/mistral-vibe/terminal/quickstart) +// "vibe --prompt "PROMPT" --output json" — prompt is passed as --prompt flag value. +// +// Message serialization mirrors anthropic.mjs/codex.mjs pattern: +// system → "[System] ", assistant → "[Assistant] ", +// tool → "[Tool Result] ", user → plain text. +// +// ADR 0003 § Translation direction model: the plugin owns irToNative. +export function irToMistral(irRequest) { + const parts = []; + + for (const msg of irRequest.messages) { + const text = typeof msg.content === 'string' + ? msg.content + : JSON.stringify(msg.content); + + if (msg.role === 'system') { + // System prompt — annotate so the model understands context. + parts.push(`[System] ${text}`); + } else if (msg.role === 'assistant') { + // Prior assistant turn — preserve for conversation context. + // tool_calls / tool_call_id metadata dropped (lossy — see file header). + parts.push(`[Assistant] ${text}`); + } else if (msg.role === 'tool') { + // Tool result turn — annotate with name if available. + // Structured tool_call_id dropped (lossy — see file header). + const nameAnnotation = msg.name ? ` (${msg.name})` : ''; + parts.push(`[Tool Result${nameAnnotation}] ${text}`); + } else { + // user role — plain text, no annotation. + parts.push(text); + } + } + + // Lossy: response_format json_object → system-prompt augmentation. + // Vibe CLI does not natively honor response_format; inject a system message + // to request JSON output. ADR 0003 § Lossy-translation documentation. + if (irRequest.response_format?.type === 'json_object') { + parts.unshift('[System] Reply with valid JSON only. Do not include any prose outside the JSON structure.'); + } + + const prompt = parts.join('\n\n'); + + // Authority: DOCS-1 § "Output Format Options" enumerates three modes: + // text (default) — human-readable text + // json — "All messages as JSON at the end" (single blob, NOT NDJSON) + // streaming — "Newline-delimited JSON per message" (this is the NDJSON form) + // + // OLP needs per-event chunks for IR translation, so we use `--output streaming`. + // D8 review-2 caught the original draft using `--output json`, which docs + // explicitly state emits a single blob at the end — incompatible with the + // line-buffered stdout parser in this plugin. Streaming mode is the correct + // selection per ALIGNMENT.md Rule 3 (Match the Implementation). + // + // A5 (model flag, CONFIRMED-NOT-APPLICABLE): vibe CLI has no --model flag + // (DeepWiki full flag enumeration confirms). Model selection happens via + // ~/.vibe/config.toml. The IR's `model` field is used by OLP for routing + // only; Vibe will use whatever model is configured at the user level. + const args = [ + '--prompt', prompt, + '--output', 'streaming', + ]; + + return { args, prompt }; +} + +// ── Mistral JSON output chunk → IR ResponseChunk ───────────────────────── +// Parses one JSON line (or object) emitted by `vibe --output json`. +// +// D8 assumption A4 (event schema — UNPINNED, D-later will pin from real output): +// Content/delta event: line with a `text` or `content` string field +// → { type: 'delta', content: } +// Stop event: line with type === 'stop' OR done === true +// → { type: 'stop', finish_reason: 'stop' } +// Error event: line with type === 'error' OR error field present +// → { type: 'error', error: } +// Other events → null (caller skips) +// +// Returns null for lines that should be silently ignored. +// D-later: update based on real `vibe --output json` stdout capture. +export function mistralChunkToIR(rawLine) { + if (!rawLine || !rawLine.trim()) return null; + + let event; + try { + event = JSON.parse(rawLine.trim()); + } catch { + // Malformed JSON line — skip silently (ALIGNMENT.md Rule 2: don't invent) + return null; // D-later: verify A4 + } + + if (!event || typeof event !== 'object') return null; + + // Error event: type === 'error' or error field present + if (event.type === 'error' || (event.error && typeof event.error === 'string')) { + const errMsg = event.error ?? event.message ?? 'vibe error'; + return { type: 'error', error: errMsg }; + } + + // Stop event: type === 'stop' or done === true + if (event.type === 'stop' || event.done === true) { + return { type: 'stop', finish_reason: 'stop' }; + } + + // D8 assumption A4: try multiple field names for delta content. + // Prefer 'text' (from Mistral API streaming convention), fall back to 'content'. + + // text field — used in Mistral La Plateforme streaming events + if (typeof event.text === 'string') { + return { type: 'delta', content: event.text }; // D-later: verify A4 + } + + // content field — used in OpenAI-compat / alternative Vibe shapes + if (typeof event.content === 'string') { + return { type: 'delta', content: event.content }; // D-later: verify A4 + } + + // delta field shape: { type: 'delta', delta: '...' } + if (event.type === 'delta' && typeof event.delta === 'string') { + return { type: 'delta', content: event.delta }; // D-later: verify A4 + } + + // choices[0].delta.content — OpenAI streaming shape (Vibe may use this) + // D-later: verify if Vibe --output json uses OpenAI-compat streaming shape + const choiceDelta = event?.choices?.[0]?.delta?.content; + if (typeof choiceDelta === 'string') { + return { type: 'delta', content: choiceDelta }; // D-later: verify A4 + } + + // finish_reason in choices[0] → stop event (OpenAI streaming shape) + // D-later: verify A4 + const finishReason = event?.choices?.[0]?.finish_reason; + if (finishReason === 'stop' || finishReason === 'length') { + return { type: 'stop', finish_reason: finishReason }; + } + + // All other event types (progress, metadata, etc.) → ignore + return null; +} + +// ── Spawn env setup ─────────────────────────────────────────────────────── +// Builds the spawn environment with MISTRAL_API_KEY injected. +// +// Authority: DOCS-2: "export MISTRAL_API_KEY=your_mistral_api_key" +// D8 assumption A2: MISTRAL_API_KEY is read at spawn time. D-later will confirm. +function buildSpawnEnv(apiKey) { + const env = { ...process.env }; + // Inject the API key so vibe CLI can authenticate at spawn time. + // D8 assumption A2: vibe reads MISTRAL_API_KEY from env. D-later: verify. + env.MISTRAL_API_KEY = apiKey; + return env; +} + +// ── Spawn function (core) ───────────────────────────────────────────────── +// Returns an AsyncIterator per ADR 0002 § Provider contract. +// +// Spawn pattern: +// 1. Resolve binary via OLP_VIBE_BIN env or PATH 'vibe' +// 2. Build args via irToMistral() → ['--prompt', , '--output', 'json'] +// 3. Spawn with stdio: ['pipe', 'pipe', 'pipe'] +// 4. Parse stdout line-by-line via mistralChunkToIR() +// (assumption A7: '--output json' emits NDJSON lines — D-later verify) +// 5. On non-zero exit: throw ProviderError('SPAWN_FAILED') +// 6. On normal exit: emit stop chunk if not already emitted +// +// Authority: DOCS-1 § "--prompt" + "--output json" +async function* _spawnAndStream(irRequest, authContext, spawnImpl) { + const auth = authContext ?? readAuthArtifact(); + if (!auth?.apiKey) { + throw new ProviderError( + 'No Mistral API key found. Set MISTRAL_API_KEY env var, or run `vibe --setup` to configure ~/.vibe/.env.', + 'AUTH_MISSING', + ); + } + + const bin = resolveVibeBin(); + const { args } = irToMistral(irRequest); + const env = buildSpawnEnv(auth.apiKey); + + const proc = spawnImpl(bin, args, { env, stdio: ['pipe', 'pipe', 'pipe'] }); + + // Close stdin immediately — vibe --prompt reads from args, not stdin. + proc.stdin.end(); + + // Push-buffer + signal approach (mirrors anthropic.mjs and codex.mjs pattern). + const chunks = []; + let done = false; + let exitCode = null; + let accumulatedStderr = ''; + let stopEmitted = false; + let resolveNext = null; + let isFirstChunk = true; + + function push(item) { + chunks.push(item); + if (resolveNext) { + const r = resolveNext; + resolveNext = null; + r(); + } + } + + // Buffer stdout by line for JSON parsing. + // Assumption A7: '--output json' emits one JSON object per line (NDJSON). + // D-later: if '--output json' emits a single JSON blob, switch to '--output streaming'. + let stdoutBuf = ''; + proc.stdout.on('data', (d) => { + stdoutBuf += d.toString(); + const lines = stdoutBuf.split('\n'); + stdoutBuf = lines.pop(); // keep incomplete last segment + for (const line of lines) { + if (line.trim()) { + push({ type: 'json_line', line }); + } + } + }); + + proc.stdout.on('end', () => { + // Flush remaining buffer content (line without trailing newline) + if (stdoutBuf.trim()) { + push({ type: 'json_line', line: stdoutBuf.trim() }); + stdoutBuf = ''; + } + }); + + proc.stderr.on('data', (d) => { + accumulatedStderr += d.toString(); + }); + + proc.on('error', (err) => { + push({ type: 'error', err }); + }); + + proc.on('close', (code, _signal) => { + exitCode = code; + done = true; + push({ type: 'close' }); + }); + + // Drain the chunk buffer + while (true) { + if (chunks.length === 0) { + if (done) break; + await new Promise((resolve) => { resolveNext = resolve; }); + continue; + } + + const item = chunks.shift(); + + if (item.type === 'error') { + throw new ProviderError(`vibe spawn error: ${item.err.message}`, 'SPAWN_FAILED'); + } + + if (item.type === 'close') { + break; + } + + if (item.type === 'json_line') { + const irChunk = mistralChunkToIR(item.line); + if (irChunk === null) continue; // ignored event type + + if (irChunk.type === 'error') { + throw new ProviderError(irChunk.error, 'SPAWN_FAILED'); + } + + if (irChunk.type === 'stop') { + stopEmitted = true; + yield irChunk; + continue; + } + + if (irChunk.type === 'delta') { + // Add role to first delta chunk (matching anthropic.mjs / codex.mjs pattern) + if (isFirstChunk) { + yield { type: 'delta', role: 'assistant', content: irChunk.content }; + isFirstChunk = false; + } else { + yield irChunk; + } + } + } + } + + // Process close + if (exitCode !== 0) { + const errMsg = accumulatedStderr.slice(0, 300) || `vibe exit ${exitCode}`; + throw new ProviderError(errMsg, 'SPAWN_FAILED'); + } + + // Normal exit: emit stop chunk if the JSON stream didn't already emit one. + if (!stopEmitted) { + yield { type: 'stop', finish_reason: 'stop' }; + } +} + +// ── spawn (public, contract method) ────────────────────────────────────── +// Public spawn conforms to ADR 0002 § Provider contract: +// spawn: async (irRequest, authContext) => AsyncIterator +let _spawnImpl = defaultSpawn; + +export async function* spawn(irRequest, authContext) { + yield* _spawnAndStream(irRequest, authContext, _spawnImpl); +} + +// Test hook: inject mock spawn without importing child_process. +// Mirror anthropic.mjs and codex.mjs export pattern. +export { _spawnImpl }; +export function __setSpawnImpl(fn) { _spawnImpl = fn; } +export function __resetSpawnImpl() { _spawnImpl = defaultSpawn; } + +// ── estimateCost ────────────────────────────────────────────────────────── +// Best-effort token estimation using chars/4 heuristic. +// Basis: OpenAI Cookbook "How to count tokens" rule-of-thumb: ~4 chars/token. +// Reference: https://cookbook.openai.com/examples/how_to_count_tokens_with_tiktoken +// +// usd: null at D8 — Le Chat Pro Vibe budget rates not pinned. +// DOCS-5 lists API rates ($0.40/$2.00 for Devstral 2), but the Le Chat Pro +// Vibe budget is a separate quota mechanism not tied to per-token API pricing. +// D-later: pin Le Chat Pro Vibe token rates and populate usd. +export function estimateCost(request) { + if (!request?.messages) return null; + + let inputChars = 0; + for (const msg of request.messages) { + const text = typeof msg.content === 'string' + ? msg.content + : JSON.stringify(msg.content); + inputChars += text.length; + } + + const outputCharsEstimate = Math.ceil(inputChars * 0.5); + + return { + inputTokens: Math.ceil(inputChars / 4), + outputTokensEstimate: Math.ceil(outputCharsEstimate / 4), + currency: 'USD', + usd: null, // not pinned at D8 — D-later: verify Le Chat Pro Vibe token rates + }; +} + +// ── quotaStatus ─────────────────────────────────────────────────────────── +// Returns null at D8. Le Chat Pro Vibe monthly budget is not exposed via a +// programmatic endpoint accessible from the CLI tier. +// DOCS-6: "Pro subscribers receive a monthly Vibe budget" — no API to query +// the remaining balance. D-later: check if Mistral adds a quota API. +export async function quotaStatus(_authContext) { + return null; +} + +// ── healthCheck ─────────────────────────────────────────────────────────── +// Does NOT spawn a real `vibe --prompt` request (gated to D-later E2E). +// Checks: (1) `vibe` binary exists on PATH, (2) auth artifact exists. +// Mirrors anthropic.mjs and codex.mjs healthCheck pattern with injectable +// test overrides. +export async function healthCheck({ _binaryExistsFn, _authReadFn } = {}) { + const t0 = Date.now(); + + // 1. Binary check + const binaryExists = _binaryExistsFn ?? _defaultBinaryExists; + if (!binaryExists()) { + return { ok: false, latencyMs: Date.now() - t0, error: 'vibe binary not found' }; + } + + // 2. Auth artifact check + const authRead = _authReadFn ?? readAuthArtifact; + const auth = authRead(); + if (!auth?.apiKey) { + return { ok: false, latencyMs: Date.now() - t0, error: 'auth artifact missing' }; + } + + return { ok: true, latencyMs: Date.now() - t0 }; +} + +function _defaultBinaryExists() { + const bin = resolveVibeBin(); + if (bin !== 'vibe') { + // Explicit path given — check directly + return existsSync(bin); + } + // 'vibe' from PATH — use which + try { + execSync('which vibe', { encoding: 'utf8', timeout: 3000, stdio: ['ignore', 'pipe', 'ignore'] }); + return true; + } catch { + return false; + } +} + +// ── Provider export ─────────────────────────────────────────────────────── +// Conforms to ADR 0002 § "Provider contract (v1.0 interface)" + contractVersion. + +import modelsRegistryRaw from '../../models-registry.json' with { type: 'json' }; + +// Build the `models` array from BOTH canonical date-stamped IDs and the +// short-form aliases per registry. ADR 0002 says `models: string[]` is "models +// this provider serves" — an alias that resolves to a real model is itself +// served. Including both makes getProviderForModel() naively route either form: +// model: "devstral-2-25-12" → mistral +// model: "devstral-2" → mistral (via alias) +// model: "devstral" → mistral (via alias) +// Phase-2 fallback engine + dashboard will use canonical IDs internally. +const _registryEntry = modelsRegistryRaw?.providers?.mistral ?? {}; +const _registryModels = [ + ...(_registryEntry.models ?? []).map(m => m.id), + ...Object.keys(_registryEntry.aliases ?? {}), +]; + +const mistral = { + name: 'mistral', + displayName: 'Mistral Vibe', + contractVersion: '1.0', + models: _registryModels, + auth: { + type: 'api-key', + storage: 'file', + // Auth authority: DOCS-2 (https://docs.mistral.ai/mistral-vibe/terminal/configuration) + // "Mistral Vibe automatically loads API keys from ~/.vibe/.env on startup." + // D8 assumption A3: path is ~/.vibe/.env. VIBE_HOME override changes base dir. + path: join(homedir(), '.vibe', '.env'), + refresh: 'manual', + }, + spawn, + estimateCost, + quotaStatus, + healthCheck, + hints: { + requiresTTY: false, // vibe --prompt runs headless per DOCS-1 programmatic mode + concurrentSpawnSafe: true, // each invocation is independent + maxConcurrent: 4, // conservative default matching anthropic + codex + }, +}; + +export default mistral; diff --git a/models-registry.json b/models-registry.json index 1ea43ef..3bd087a 100644 --- a/models-registry.json +++ b/models-registry.json @@ -81,6 +81,34 @@ "gpt5": "gpt-5.5", "gpt5-mini": "gpt-5.4-mini" } + }, + "mistral": { + "displayName": "Mistral Vibe", + "tier": "D", + "candidate": true, + "comment": "Model IDs sourced from canonical Mistral models registry (https://docs.mistral.ai/getting-started/models/models_overview, retrieved 2026-05-23). Each id is the date-stamped canonical form Mistral's own model registry uses. The short forms (devstral-2, devstral-small-2) are aliases used in Vibe's config.toml TOML examples and are exposed as user-facing aliases below. Both models have 262144-token context window per the canonical registry + DOCS-5 launch announcement.", + "models": [ + { + "id": "devstral-2-25-12", + "displayName": "Devstral 2", + "contextWindow": 262144, + "deprecated": false, + "note": "123B-parameter dense transformer. DOCS-5: $0.40/$2.00 per million tokens (input/output). Canonical date-stamped id; Vibe config.toml accepts the short form 'devstral-2' as an alias." + }, + { + "id": "devstral-small-2-25-12", + "displayName": "Devstral Small 2", + "contextWindow": 262144, + "deprecated": false, + "note": "24B-parameter model, runs on consumer-grade GPUs. DOCS-5: $0.10/$0.30 per million tokens (input/output). Canonical date-stamped id; Vibe config.toml accepts the short form 'devstral-small-2' as an alias." + } + ], + "aliases": { + "devstral": "devstral-2-25-12", + "devstral-2": "devstral-2-25-12", + "devstral-small": "devstral-small-2-25-12", + "devstral-small-2": "devstral-small-2-25-12" + } } } } diff --git a/test-features.mjs b/test-features.mjs index 99fb47c..3f4065c 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -50,6 +50,16 @@ import codex, { __setSpawnImpl as codexSetSpawnImpl, __resetSpawnImpl as codexResetSpawnImpl, } from './lib/providers/codex.mjs'; +import mistral, { + irToMistral, + mistralChunkToIR, + readAuthArtifact as mistralReadAuthArtifact, + estimateCost as mistralEstimateCost, + quotaStatus as mistralQuotaStatus, + healthCheck as mistralHealthCheck, + __setSpawnImpl as mistralSetSpawnImpl, + __resetSpawnImpl as mistralResetSpawnImpl, +} from './lib/providers/mistral.mjs'; import modelsRegistry from './models-registry.json' with { type: 'json' }; // ── Helpers ─────────────────────────────────────────────────────────────── @@ -488,9 +498,9 @@ describe('Provider contract validation', () => { // ── Suite 5: Plugin registry ────────────────────────────────────────────── describe('Plugin registry', () => { - it('STATIC_REGISTRY has 2 entries (anthropic + openai candidates) at D6', () => { - // D4 added anthropic; D6 adds openai. Default config has both enabled:false. - assert.equal(listAllProviderNames().length, 2); + it('STATIC_REGISTRY has 3 entries (anthropic + openai + mistral candidates) at D8', () => { + // D4 added anthropic; D6 adds openai; D8 adds mistral. Default config has all enabled:false. + assert.equal(listAllProviderNames().length, 3); }); it('loadProviders with empty config → empty Map (anthropic not enabled)', () => { @@ -503,9 +513,9 @@ describe('Plugin registry', () => { assert.equal(m.size, 0); }); - it('listAllProviderNames returns [anthropic, openai] at D6', () => { - // D4: ['anthropic']. D6 adds openai. - assert.deepEqual(listAllProviderNames(), ['anthropic', 'openai']); + it('listAllProviderNames returns [anthropic, openai, mistral] at D8', () => { + // D4: ['anthropic']. D6 adds openai. D8 adds mistral. + assert.deepEqual(listAllProviderNames(), ['anthropic', 'openai', 'mistral']); }); it('getProviderForModel returns null when no providers loaded', () => { @@ -2124,9 +2134,10 @@ describe('Codex plugin (D6)', () => { assert.equal(codex.hints.concurrentSpawnSafe, true); }); - // ── Test 15: STATIC_REGISTRY length after D6 ───────────────────────── - it('STATIC_REGISTRY.length === 2 after D6 (anthropic + openai)', () => { - assert.equal(listAllProviderNames().length, 2); + // ── Test 15: STATIC_REGISTRY length after D8 still includes openai ──── + it('STATIC_REGISTRY includes openai (D6) after D8 (length >= 2)', () => { + // D8 adds mistral; anthropic + openai must still be present. + assert.ok(listAllProviderNames().length >= 2); assert.ok(listAllProviderNames().includes('anthropic')); assert.ok(listAllProviderNames().includes('openai')); }); @@ -2320,3 +2331,671 @@ describe('Anthropic E2E — real claude spawn (Suite 10)', { skip: !RUN_E2E }, ( ); }); }); + +// ── Suite 12: Mistral Vibe plugin (D8) ─────────────────────────────────── +// +// All tests are UNIT tests. No real `vibe` binary is invoked. +// Mock spawn is injected via mistralSetSpawnImpl / mistralResetSpawnImpl. +// +// Authority: Mistral Vibe docs (WebFetched 2026-05-23): +// DOCS-1: https://docs.mistral.ai/mistral-vibe/terminal/quickstart +// § "--prompt TEXT" + "--output FORMAT" — programmatic mode syntax +// DOCS-2: https://docs.mistral.ai/mistral-vibe/terminal/configuration +// § "~/.vibe/.env" — auth file; MISTRAL_API_KEY env var +// DOCS-5: https://mistral.ai/news/devstral-2-vibe-cli +// § "Devstral 2", "Devstral Small 2" — model names +// DOCS-6: https://help.mistral.ai/en/articles/347532 +// § "Mistral Vibe is included in every Le Chat Pro subscription" +// DOCS-7: https://legal.mistral.ai/terms/usage-policy +// § No anti-third-party clauses (Tier D confirmed for ADR 0006) +// +// Spec assumption acknowledgements (see mistral.mjs header): +// A1 CONFIRMED: `vibe --prompt "PROMPT" --output json` spawn shape +// A2 CONFIRMED: MISTRAL_API_KEY env var is the auth mechanism +// A3 CONFIRMED: ~/.vibe/.env is the auth file path +// A4 UNPINNED: JSON output event schema — defensive 4-shape parser used +// A5 UNPINNED: --model flag existence not confirmed by docs +// A6 UNPINNED: exact model IDs (devstral-2, devstral-small-2) best-effort +// A7 UNPINNED: --output json is NDJSON not single blob +// A8 UNPINNED: multi-line prompt handling via --prompt flag +// +// Lossy-translation acknowledgements per ADR 0003 (documented in mistral.mjs header): +// top_p, temperature, stop, max_tokens, tools[], tool_calls → all dropped. + +/** + * Creates a fake JSON-line-emitting mock spawn for Mistral Vibe. + * Lines are emitted as they would arrive from `vibe --output json` + * (D8 assumption A7: NDJSON lines — D-later will pin the real format). + * + * @param {string[]} jsonLines — raw JSON lines emitted in order (no trailing \n needed) + * @param {number} [exitCode=0] + */ +function makeMockMistralSpawn(jsonLines, exitCode = 0) { + return function mockMistralSpawnImpl(_bin, _args, _opts) { + const proc = new EventEmitter(); + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.stdin = { + write: () => {}, + end: () => { + setImmediate(async () => { + for (const line of jsonLines) { + proc.stdout.emit('data', Buffer.from(line + '\n')); + } + proc.stdout.emit('end'); + proc.stderr.emit('end'); + proc.emit('close', exitCode, null); + }); + }, + }; + proc.killed = false; + proc.kill = () => {}; + return proc; + }; +} + +describe('Mistral Vibe plugin (D8)', () => { + + // ── Test 1: Contract conformance ────────────────────────────────────── + it('mistral module satisfies validateProvider() — all 10 fields present', () => { + const { valid, errors } = validateProvider(mistral); + assert.equal(valid, true, `Validation errors: ${errors.join('; ')}`); + assert.ok('name' in mistral, 'missing: name'); + assert.ok('displayName' in mistral, 'missing: displayName'); + assert.ok('contractVersion' in mistral, 'missing: contractVersion'); + assert.ok('models' in mistral, 'missing: models'); + assert.ok('auth' in mistral, 'missing: auth'); + assert.ok(typeof mistral.spawn === 'function', 'missing: spawn'); + assert.ok(typeof mistral.estimateCost === 'function', 'missing: estimateCost'); + assert.ok(typeof mistral.quotaStatus === 'function', 'missing: quotaStatus'); + assert.ok(typeof mistral.healthCheck === 'function', 'missing: healthCheck'); + assert.ok('hints' in mistral, 'missing: hints'); + }); + + // ── Test 2: contractVersion === '1.0' ──────────────────────────────── + it('mistral declares contractVersion === "1.0"', () => { + assert.equal(mistral.contractVersion, '1.0'); + }); + + // ── Test 3: name and displayName ───────────────────────────────────── + it('mistral.name === "mistral" and displayName is set', () => { + assert.equal(mistral.name, 'mistral'); + assert.equal(typeof mistral.displayName, 'string'); + assert.ok(mistral.displayName.length > 0); + assert.ok(mistral.displayName.toLowerCase().includes('mistral')); + }); + + // ── Test 4: models include all registry IDs + aliases ──────────────── + it('mistral.models includes every canonical registry id AND every alias', () => { + // The plugin merges canonical IDs and alias keys into models[] so that + // getProviderForModel routes either form. Test that the registry's + // canonical IDs are a subset, and the aliases are all included too. + const registryIds = modelsRegistry.providers.mistral.models.map(m => m.id); + const registryAliases = Object.keys(modelsRegistry.providers.mistral.aliases ?? {}); + for (const id of registryIds) { + assert.ok(mistral.models.includes(id), `canonical id ${id} missing from mistral.models`); + } + for (const alias of registryAliases) { + assert.ok(mistral.models.includes(alias), `alias ${alias} missing from mistral.models`); + } + assert.equal(mistral.models.length, registryIds.length + registryAliases.length); + }); + + it('mistral.models contains canonical IDs + short-form aliases', () => { + // Per canonical models registry (docs.mistral.ai/getting-started/models/ + // models_overview, D8 review-2 finding): canonical IDs are date-stamped + // (devstral-2-25-12, devstral-small-2-25-12). Vibe config.toml uses short + // forms (devstral-2, devstral-small-2). Plugin exposes BOTH so + // getProviderForModel routes either form. + assert.ok(mistral.models.includes('devstral-2-25-12'), 'missing canonical devstral-2-25-12'); + assert.ok(mistral.models.includes('devstral-small-2-25-12'), 'missing canonical devstral-small-2-25-12'); + assert.ok(mistral.models.includes('devstral-2'), 'missing short-form alias devstral-2'); + assert.ok(mistral.models.includes('devstral-small-2'), 'missing short-form alias devstral-small-2'); + assert.ok(mistral.models.includes('devstral'), 'missing alias devstral'); + assert.ok(mistral.models.includes('devstral-small'), 'missing alias devstral-small'); + // Length: 2 canonical + 4 aliases = 6 total + assert.equal(mistral.models.length, 6, `Expected 6 entries (2 canonical + 4 aliases), got ${mistral.models.length}`); + }); + + // ── Test 5: getProviderForModel finds mistral for each model ────────── + it('getProviderForModel finds mistral for devstral-2', () => { + const loaded = new Map([['mistral', mistral]]); + const result = getProviderForModel(loaded, 'devstral-2'); + assert.ok(result !== null); + assert.equal(result.name, 'mistral'); + }); + + it('getProviderForModel finds mistral for devstral-small-2', () => { + const loaded = new Map([['mistral', mistral]]); + const result = getProviderForModel(loaded, 'devstral-small-2'); + assert.ok(result !== null); + assert.equal(result.name, 'mistral'); + }); + + // ── Test 6: irToMistral translation ────────────────────────────────── + it('irToMistral: user message → args with --prompt and --output streaming', () => { + // Authority: DOCS-1 § "Output Format Options" — `streaming` is the + // newline-delimited JSON per message mode. `json` emits a single blob + // at the end (D8 review-2 finding: original draft used `json`, + // incompatible with the line-buffered stdout parser; corrected to + // `streaming` per docs verbatim). + const ir = makeIR({ + model: 'devstral-2', + messages: [{ role: 'user', content: 'Hello world' }], + }); + const { args, prompt } = irToMistral(ir); + assert.ok(args.includes('--prompt'), 'args must include "--prompt"'); + assert.ok(args.includes('--output'), 'args must include "--output"'); + assert.ok(args.includes('streaming'), 'args must include "streaming" output format (NDJSON per docs)'); + assert.ok(!args.includes('json'), 'args must NOT include "json" (single-blob mode, incompatible with line-buffered parser)'); + // D8 assumption A5: --model NOT in args (flag existence unconfirmed from docs) + assert.ok(!args.includes('--model'), 'args must NOT include "--model" at D8 (A5 UNPINNED)'); + assert.ok(typeof prompt === 'string', 'prompt must be a string'); + assert.ok(prompt.includes('Hello world'), 'prompt must contain user text'); + }); + + it('irToMistral: system + user → system annotation + user text in prompt', () => { + const ir = makeIR({ + model: 'devstral-2', + messages: [ + { role: 'system', content: 'You are a coding assistant.' }, + { role: 'user', content: 'Write a function.' }, + ], + }); + const { prompt } = irToMistral(ir); + assert.ok(prompt.includes('[System] You are a coding assistant.')); + assert.ok(prompt.includes('Write a function.')); + }); + + it('irToMistral: assistant prior turn → [Assistant] annotation', () => { + const ir = makeIR({ + model: 'devstral-2', + messages: [ + { role: 'user', content: 'Hi' }, + { role: 'assistant', content: 'Hello!' }, + { role: 'user', content: 'Thanks' }, + ], + }); + const { prompt } = irToMistral(ir); + assert.ok(prompt.includes('[Assistant] Hello!')); + assert.ok(prompt.includes('Thanks')); + }); + + it('irToMistral: tool result turn → [Tool Result] annotation', () => { + const ir = makeIR({ + model: 'devstral-2', + messages: [ + { role: 'user', content: 'Search for X' }, + { role: 'tool', content: '{"results":[]}', name: 'search' }, + ], + }); + const { prompt } = irToMistral(ir); + assert.ok(prompt.includes('[Tool Result')); + assert.ok(prompt.includes('search')); + }); + + it('irToMistral: response_format json_object injects system prompt (lossy)', () => { + // ADR 0003 § Lossy: Vibe CLI does not honor response_format natively. + const ir = makeIR({ + model: 'devstral-2', + messages: [{ role: 'user', content: 'Give JSON' }], + response_format: { type: 'json_object' }, + }); + const { prompt } = irToMistral(ir); + assert.ok(prompt.includes('Reply with valid JSON only')); + }); + + it('irToMistral: tool_calls in assistant message — content preserved, metadata dropped (lossy)', () => { + // ADR 0003 § Lossy: structured tool_calls dropped; textual content preserved. + const ir = makeIR({ + model: 'devstral-2', + messages: [ + { role: 'user', content: 'Search for X' }, + { + role: 'assistant', + content: 'Searching...', + tool_calls: [{ id: 'tc1', type: 'function', function: { name: 'search', arguments: '{"q":"X"}' } }], + }, + ], + }); + const { prompt } = irToMistral(ir); + assert.ok(prompt.includes('Searching...'), 'assistant text content must be preserved'); + // tool_calls metadata documented as dropped (lossy) — do NOT assert it's present + }); + + it('irToMistral: array content is JSON-stringified', () => { + const ir = makeIR({ + model: 'devstral-2', + messages: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + }); + const { prompt } = irToMistral(ir); + assert.ok(typeof prompt === 'string'); + assert.ok(prompt.includes('text')); + }); + + // ── Test 7: mistralChunkToIR translation ────────────────────────────── + it('mistralChunkToIR: text field → delta chunk (Mistral La Plateforme shape)', () => { + // D8 assumption A4: "text" is the preferred field name. + const chunk = mistralChunkToIR('{"text":"Hello world"}'); + assert.ok(chunk !== null); + assert.equal(chunk.type, 'delta'); + assert.equal(chunk.content, 'Hello world'); + }); + + it('mistralChunkToIR: content field → delta chunk (OpenAI-compat shape)', () => { + // D8 assumption A4: "content" as fallback field name. + const chunk = mistralChunkToIR('{"content":"Hello world"}'); + assert.ok(chunk !== null); + assert.equal(chunk.type, 'delta'); + assert.equal(chunk.content, 'Hello world'); + }); + + it('mistralChunkToIR: delta field shape → delta chunk', () => { + // D8 assumption A4: { type: 'delta', delta: '...' } shape. + const chunk = mistralChunkToIR('{"type":"delta","delta":"token text"}'); + assert.ok(chunk !== null); + assert.equal(chunk.type, 'delta'); + assert.equal(chunk.content, 'token text'); + }); + + it('mistralChunkToIR: OpenAI streaming choices[0].delta.content shape → delta', () => { + // D8 assumption A4: Vibe may use OpenAI streaming event shape. + const chunk = mistralChunkToIR('{"choices":[{"delta":{"content":"output"},"finish_reason":null}]}'); + assert.ok(chunk !== null); + assert.equal(chunk.type, 'delta'); + assert.equal(chunk.content, 'output'); + }); + + it('mistralChunkToIR: type === "stop" → stop chunk', () => { + const chunk = mistralChunkToIR('{"type":"stop"}'); + assert.ok(chunk !== null); + assert.equal(chunk.type, 'stop'); + assert.equal(chunk.finish_reason, 'stop'); + }); + + it('mistralChunkToIR: done === true → stop chunk', () => { + const chunk = mistralChunkToIR('{"done":true}'); + assert.ok(chunk !== null); + assert.equal(chunk.type, 'stop'); + assert.equal(chunk.finish_reason, 'stop'); + }); + + it('mistralChunkToIR: OpenAI streaming stop via choices[0].finish_reason === "stop"', () => { + // D8 assumption A4: OpenAI streaming stop shape. + const chunk = mistralChunkToIR('{"choices":[{"delta":{},"finish_reason":"stop"}]}'); + assert.ok(chunk !== null); + assert.equal(chunk.type, 'stop'); + assert.equal(chunk.finish_reason, 'stop'); + }); + + it('mistralChunkToIR: type === "error" → error chunk', () => { + const chunk = mistralChunkToIR('{"type":"error","error":"quota exceeded"}'); + assert.ok(chunk !== null); + assert.equal(chunk.type, 'error'); + assert.equal(chunk.error, 'quota exceeded'); + }); + + it('mistralChunkToIR: error field present → error chunk', () => { + const chunk = mistralChunkToIR('{"error":"something went wrong","code":429}'); + assert.ok(chunk !== null); + assert.equal(chunk.type, 'error'); + }); + + it('mistralChunkToIR: unknown/progress event type → null (silently ignored)', () => { + const chunk = mistralChunkToIR('{"type":"progress","step":1}'); + assert.equal(chunk, null); + }); + + it('mistralChunkToIR: empty line → null', () => { + assert.equal(mistralChunkToIR(''), null); + assert.equal(mistralChunkToIR(' '), null); + }); + + it('mistralChunkToIR: malformed JSON → null (no throw)', () => { + assert.doesNotThrow(() => { + const result = mistralChunkToIR('{bad json'); + assert.equal(result, null); + }); + }); + + // ── Test 8: mock spawn — JSON stream yields correct IR chunks ────────── + it('spawn with mock: JSON lines → delta chunks then stop chunk', async () => { + const fakeSpawn = makeMockMistralSpawn([ + '{"text":"Hello"}', + '{"text":" world"}', + '{"type":"stop"}', + ]); + mistralSetSpawnImpl(fakeSpawn); + try { + const ir = makeIR({ + model: 'devstral-2', + stream: true, + messages: [{ role: 'user', content: 'Hi' }], + }); + const authCtx = { apiKey: '' }; + const chunks = []; + for await (const chunk of mistral.spawn(ir, authCtx)) { + chunks.push(chunk); + } + const deltas = chunks.filter(c => c.type === 'delta'); + const stops = chunks.filter(c => c.type === 'stop'); + assert.ok(deltas.length >= 1, `Expected at least 1 delta, got ${deltas.length}`); + assert.equal(stops.length, 1, `Expected 1 stop, got ${stops.length}`); + const allContent = deltas.map(c => c.content).join(''); + assert.equal(allContent, 'Hello world'); + } finally { + mistralResetSpawnImpl(); + } + }); + + it('spawn with mock: first delta chunk has role=assistant', async () => { + const fakeSpawn = makeMockMistralSpawn(['{"text":"Test output"}']); + mistralSetSpawnImpl(fakeSpawn); + try { + const ir = makeIR({ + model: 'devstral-2', + stream: true, + messages: [{ role: 'user', content: 'Hello' }], + }); + const authCtx = { apiKey: '' }; + const chunks = []; + for await (const chunk of mistral.spawn(ir, authCtx)) { + chunks.push(chunk); + } + const firstDelta = chunks.find(c => c.type === 'delta'); + assert.ok(firstDelta, 'No delta chunk found'); + assert.equal(firstDelta.role, 'assistant'); + } finally { + mistralResetSpawnImpl(); + } + }); + + it('spawn with mock: stop event present → no extra synthetic stop appended', async () => { + const fakeSpawn = makeMockMistralSpawn([ + '{"text":"done"}', + '{"type":"stop"}', + ]); + mistralSetSpawnImpl(fakeSpawn); + try { + const ir = makeIR({ + model: 'devstral-2', + stream: false, + messages: [{ role: 'user', content: 'Hello' }], + }); + const authCtx = { apiKey: '' }; + const chunks = []; + for await (const chunk of mistral.spawn(ir, authCtx)) { + chunks.push(chunk); + } + const stops = chunks.filter(c => c.type === 'stop'); + assert.equal(stops.length, 1, `Expected exactly 1 stop chunk, got ${stops.length}`); + } finally { + mistralResetSpawnImpl(); + } + }); + + it('spawn with mock: synthetic stop emitted when JSON stream has no stop event', async () => { + const fakeSpawn = makeMockMistralSpawn([ + '{"text":"only content, no stop"}', + ]); + mistralSetSpawnImpl(fakeSpawn); + try { + const ir = makeIR({ + model: 'devstral-2', + stream: true, + messages: [{ role: 'user', content: 'Hello' }], + }); + const authCtx = { apiKey: '' }; + const chunks = []; + for await (const chunk of mistral.spawn(ir, authCtx)) { + chunks.push(chunk); + } + const stops = chunks.filter(c => c.type === 'stop'); + assert.equal(stops.length, 1, 'Should have exactly 1 synthetic stop chunk'); + assert.equal(stops[0].finish_reason, 'stop'); + } finally { + mistralResetSpawnImpl(); + } + }); + + it('spawn with mock: non-zero exit code throws ProviderError(SPAWN_FAILED)', async () => { + const fakeSpawn = makeMockMistralSpawn([], 1); + mistralSetSpawnImpl(fakeSpawn); + try { + const ir = makeIR({ + model: 'devstral-2', + stream: true, + messages: [{ role: 'user', content: 'Hi' }], + }); + const authCtx = { apiKey: '' }; + let caught = null; + try { + for await (const _chunk of mistral.spawn(ir, authCtx)) { // eslint-disable-line no-unused-vars + // drain + } + } catch (e) { + caught = e; + } + assert.ok(caught instanceof ProviderError, `Expected ProviderError, got ${caught?.constructor?.name}`); + assert.equal(caught.code, 'SPAWN_FAILED'); + } finally { + mistralResetSpawnImpl(); + } + }); + + it('spawn throws ProviderError(AUTH_MISSING) when no auth context and no env/file', async () => { + const fakeSpawn = makeMockMistralSpawn(['{"text":"test"}']); + mistralSetSpawnImpl(fakeSpawn); + // Override auth path to nonexistent + clear MISTRAL_API_KEY env to guarantee missing auth + const savedApiKey = process.env.MISTRAL_API_KEY; + const savedAuthPath = process.env.MISTRAL_VIBE_AUTH_PATH; + delete process.env.MISTRAL_API_KEY; + process.env.MISTRAL_VIBE_AUTH_PATH = '/nonexistent/path/.env'; + try { + const ir = makeIR({ + model: 'devstral-2', + stream: false, + messages: [{ role: 'user', content: 'Hi' }], + }); + let caught = null; + try { + for await (const _chunk of mistral.spawn(ir, null)) { // eslint-disable-line no-unused-vars + // drain + } + } catch (e) { + caught = e; + } + assert.ok(caught instanceof ProviderError, `Expected ProviderError, got ${caught?.constructor?.name}`); + assert.equal(caught.code, 'AUTH_MISSING'); + } finally { + if (savedApiKey !== undefined) process.env.MISTRAL_API_KEY = savedApiKey; + else delete process.env.MISTRAL_API_KEY; + if (savedAuthPath !== undefined) process.env.MISTRAL_VIBE_AUTH_PATH = savedAuthPath; + else delete process.env.MISTRAL_VIBE_AUTH_PATH; + mistralResetSpawnImpl(); + } + }); + + it('spawn with mock: progress events silently ignored, only content emitted', async () => { + const fakeSpawn = makeMockMistralSpawn([ + '{"type":"progress","step":1}', + '{"type":"progress","step":2}', + '{"text":"actual response"}', + '{"type":"stop"}', + ]); + mistralSetSpawnImpl(fakeSpawn); + try { + const ir = makeIR({ + model: 'devstral-2', + stream: true, + messages: [{ role: 'user', content: 'Do something' }], + }); + const authCtx = { apiKey: '' }; + const chunks = []; + for await (const chunk of mistral.spawn(ir, authCtx)) { + chunks.push(chunk); + } + const deltas = chunks.filter(c => c.type === 'delta'); + assert.equal(deltas.length, 1); + assert.equal(deltas[0].content, 'actual response'); + } finally { + mistralResetSpawnImpl(); + } + }); + + // ── Test 9: healthCheck ─────────────────────────────────────────────── + it('healthCheck returns {ok: false, error: "vibe binary not found"} when binary absent', async () => { + const result = await mistralHealthCheck({ + _binaryExistsFn: () => false, + _authReadFn: () => ({ apiKey: '' }), + }); + assert.equal(result.ok, false); + assert.equal(result.error, 'vibe binary not found'); + assert.ok(typeof result.latencyMs === 'number'); + }); + + it('healthCheck returns {ok: false, error: "auth artifact missing"} when auth missing', async () => { + const result = await mistralHealthCheck({ + _binaryExistsFn: () => true, + _authReadFn: () => null, + }); + assert.equal(result.ok, false); + assert.equal(result.error, 'auth artifact missing'); + assert.ok(typeof result.latencyMs === 'number'); + }); + + it('healthCheck returns {ok: true} when binary and auth both present', async () => { + const result = await mistralHealthCheck({ + _binaryExistsFn: () => true, + _authReadFn: () => ({ apiKey: '' }), + }); + assert.equal(result.ok, true); + assert.ok(typeof result.latencyMs === 'number'); + }); + + // ── Test 10: estimateCost ───────────────────────────────────────────── + it('estimateCost returns shape with currency USD', () => { + const request = makeIR({ + model: 'devstral-2', + messages: [ + { role: 'system', content: 'You are a coding assistant.' }, + { role: 'user', content: 'Write hello world in Python.' }, + ], + }); + const result = mistralEstimateCost(request); + assert.ok(result !== null, 'estimateCost returned null'); + assert.ok('inputTokens' in result, 'missing inputTokens'); + assert.ok('outputTokensEstimate' in result, 'missing outputTokensEstimate'); + assert.ok('currency' in result, 'missing currency'); + assert.ok('usd' in result, 'missing usd'); + assert.equal(result.currency, 'USD'); + assert.equal(result.usd, null); // not pinned at D8 + assert.ok(result.inputTokens > 0, 'inputTokens should be > 0'); + assert.ok(result.outputTokensEstimate >= 0); + }); + + it('estimateCost returns null for null/missing request', () => { + assert.equal(mistralEstimateCost(null), null); + assert.equal(mistralEstimateCost({}), null); + }); + + // ── Test 11: quotaStatus ────────────────────────────────────────────── + it('quotaStatus returns null at D8 (Le Chat Pro budget not exposed via API)', async () => { + const result = await mistralQuotaStatus({}); + assert.equal(result, null); + }); + + // ── Test 12: auth object shape ──────────────────────────────────────── + it('mistral.auth has correct shape', () => { + // Authority: DOCS-2 § auth type is api-key, path is ~/.vibe/.env + assert.equal(mistral.auth.type, 'api-key'); + assert.equal(mistral.auth.storage, 'file'); + assert.equal(typeof mistral.auth.path, 'string'); + assert.ok(mistral.auth.path.includes('.vibe'), 'auth.path should reference .vibe directory'); + assert.ok(mistral.auth.path.includes('.env'), 'auth.path should reference .env file'); + // Portability check: path must start with homedir() (no hardcoded literal) + assert.ok( + mistral.auth.path.startsWith(homedir()), + `auth.path "${mistral.auth.path}" should start with homedir() "${homedir()}"`, + ); + }); + + // ── Test 13: hints shape ────────────────────────────────────────────── + it('mistral.hints has correct shape', () => { + assert.equal(typeof mistral.hints.requiresTTY, 'boolean'); + assert.equal(typeof mistral.hints.concurrentSpawnSafe, 'boolean'); + assert.ok(Number.isInteger(mistral.hints.maxConcurrent) && mistral.hints.maxConcurrent > 0); + assert.equal(mistral.hints.requiresTTY, false); + assert.equal(mistral.hints.concurrentSpawnSafe, true); + }); + + // ── Test 14: STATIC_REGISTRY length after D8 ───────────────────────── + it('STATIC_REGISTRY.length === 3 after D8 (anthropic + openai + mistral)', () => { + assert.equal(listAllProviderNames().length, 3); + assert.ok(listAllProviderNames().includes('anthropic')); + assert.ok(listAllProviderNames().includes('openai')); + assert.ok(listAllProviderNames().includes('mistral')); + }); + + // ── Test 15: loadProviders with mistral enabled ─────────────────────── + it('loadProviders with {enabled: {mistral: true}} returns Map of size 1 with mistral', () => { + const loaded = loadProviders({ enabled: { mistral: true } }); + assert.equal(loaded.size, 1); + assert.ok(loaded.has('mistral')); + }); + + it('loadProviders with all 3 providers enabled returns Map of size 3', () => { + const loaded = loadProviders({ enabled: { anthropic: true, openai: true, mistral: true } }); + assert.equal(loaded.size, 3); + assert.ok(loaded.has('anthropic')); + assert.ok(loaded.has('openai')); + assert.ok(loaded.has('mistral')); + }); + + it('mistral loaded via loadProviders passes contract validation', () => { + const loaded = loadProviders({ enabled: { mistral: true } }); + const p = loaded.get('mistral'); + const { valid, errors } = validateProvider(p); + assert.equal(valid, true, `Contract errors: ${errors.join('; ')}`); + assert.ok(p.models.includes('devstral-2')); + }); + + // ── Test 16: auth artifact reading helpers ──────────────────────────── + it('readAuthArtifact: MISTRAL_API_KEY env var → returns {apiKey}', () => { + const saved = process.env.MISTRAL_API_KEY; + process.env.MISTRAL_API_KEY = ''; + try { + const result = mistralReadAuthArtifact(); + assert.ok(result !== null, 'Expected auth result'); + assert.equal(result.apiKey, ''); + } finally { + if (saved !== undefined) process.env.MISTRAL_API_KEY = saved; + else delete process.env.MISTRAL_API_KEY; + } + }); + + it('readAuthArtifact: MISTRAL_VIBE_AUTH_PATH pointing to nonexistent file → null', () => { + const savedApiKey = process.env.MISTRAL_API_KEY; + const savedAuthPath = process.env.MISTRAL_VIBE_AUTH_PATH; + delete process.env.MISTRAL_API_KEY; + process.env.MISTRAL_VIBE_AUTH_PATH = '/definitely/not/a/real/path/.env'; + try { + const result = mistralReadAuthArtifact(); + assert.equal(result, null); + } finally { + if (savedApiKey !== undefined) process.env.MISTRAL_API_KEY = savedApiKey; + else delete process.env.MISTRAL_API_KEY; + if (savedAuthPath !== undefined) process.env.MISTRAL_VIBE_AUTH_PATH = savedAuthPath; + else delete process.env.MISTRAL_VIBE_AUTH_PATH; + } + }); + + // ── Test 17: Suite 5 registry test updated for D8 ───────────────────── + // (This re-tests the registry length assertion which now expects 3.) + it('listAllProviderNames() now returns 3-element array with mistral included', () => { + const names = listAllProviderNames(); + assert.equal(names.length, 3); + assert.deepEqual(names, ['anthropic', 'openai', 'mistral']); + }); + +});