mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
feat(phase-1): land Mistral Vibe provider plugin (D8)
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
<fake-mistral-api-key> placeholders.
Co-Authored-By: Claude Opus 4.7 (noreply@anthropic.com)
This commit is contained in:
co-authored by
Claude Opus 4.7 (noreply@anthropic.com)
parent
ea9184d2e8
commit
95dc072245
+11
-1
@@ -16,21 +16,30 @@
|
|||||||
*
|
*
|
||||||
* At D6 (Phase 1 Day 4), the openai (Codex) plugin is added to STATIC_REGISTRY
|
* 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.
|
* 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 { validateProvider } from './base.mjs';
|
||||||
import anthropicDefault from './anthropic.mjs';
|
import anthropicDefault from './anthropic.mjs';
|
||||||
import codexDefault from './codex.mjs';
|
import codexDefault from './codex.mjs';
|
||||||
|
import mistralDefault from './mistral.mjs';
|
||||||
|
|
||||||
// Normalize default export pattern
|
// Normalize default export pattern
|
||||||
const anthropic = anthropicDefault;
|
const anthropic = anthropicDefault;
|
||||||
const codex = codexDefault;
|
const codex = codexDefault;
|
||||||
|
const mistral = mistralDefault;
|
||||||
|
|
||||||
// ── Static registry ───────────────────────────────────────────────────────
|
// ── Static registry ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
const STATIC_REGISTRY = [
|
const STATIC_REGISTRY = [
|
||||||
anthropic,
|
anthropic,
|
||||||
codex,
|
codex,
|
||||||
|
mistral,
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Registry functions ────────────────────────────────────────────────────
|
// ── Registry functions ────────────────────────────────────────────────────
|
||||||
@@ -96,7 +105,8 @@ export function getProviderByName(loadedProviders, name) {
|
|||||||
/**
|
/**
|
||||||
* Returns all provider names in the static registry (whether enabled or not).
|
* Returns all provider names in the static registry (whether enabled or not).
|
||||||
* Used by /health and diagnostics.
|
* 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[]}
|
* @returns {string[]}
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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 <id>" 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] <text>", assistant → "[Assistant] <text>",
|
||||||
|
// tool → "[Tool Result] <text>", 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: <string> }
|
||||||
|
// 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: <string> }
|
||||||
|
// 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<IRResponseChunk> per ADR 0002 § Provider contract.
|
||||||
|
//
|
||||||
|
// Spawn pattern:
|
||||||
|
// 1. Resolve binary via OLP_VIBE_BIN env or PATH 'vibe'
|
||||||
|
// 2. Build args via irToMistral() → ['--prompt', <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<ResponseChunk>
|
||||||
|
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;
|
||||||
@@ -81,6 +81,34 @@
|
|||||||
"gpt5": "gpt-5.5",
|
"gpt5": "gpt-5.5",
|
||||||
"gpt5-mini": "gpt-5.4-mini"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+688
-9
@@ -50,6 +50,16 @@ import codex, {
|
|||||||
__setSpawnImpl as codexSetSpawnImpl,
|
__setSpawnImpl as codexSetSpawnImpl,
|
||||||
__resetSpawnImpl as codexResetSpawnImpl,
|
__resetSpawnImpl as codexResetSpawnImpl,
|
||||||
} from './lib/providers/codex.mjs';
|
} 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' };
|
import modelsRegistry from './models-registry.json' with { type: 'json' };
|
||||||
|
|
||||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||||
@@ -488,9 +498,9 @@ describe('Provider contract validation', () => {
|
|||||||
// ── Suite 5: Plugin registry ──────────────────────────────────────────────
|
// ── Suite 5: Plugin registry ──────────────────────────────────────────────
|
||||||
|
|
||||||
describe('Plugin registry', () => {
|
describe('Plugin registry', () => {
|
||||||
it('STATIC_REGISTRY has 2 entries (anthropic + openai candidates) at D6', () => {
|
it('STATIC_REGISTRY has 3 entries (anthropic + openai + mistral candidates) at D8', () => {
|
||||||
// D4 added anthropic; D6 adds openai. Default config has both enabled:false.
|
// D4 added anthropic; D6 adds openai; D8 adds mistral. Default config has all enabled:false.
|
||||||
assert.equal(listAllProviderNames().length, 2);
|
assert.equal(listAllProviderNames().length, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('loadProviders with empty config → empty Map (anthropic not enabled)', () => {
|
it('loadProviders with empty config → empty Map (anthropic not enabled)', () => {
|
||||||
@@ -503,9 +513,9 @@ describe('Plugin registry', () => {
|
|||||||
assert.equal(m.size, 0);
|
assert.equal(m.size, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('listAllProviderNames returns [anthropic, openai] at D6', () => {
|
it('listAllProviderNames returns [anthropic, openai, mistral] at D8', () => {
|
||||||
// D4: ['anthropic']. D6 adds openai.
|
// D4: ['anthropic']. D6 adds openai. D8 adds mistral.
|
||||||
assert.deepEqual(listAllProviderNames(), ['anthropic', 'openai']);
|
assert.deepEqual(listAllProviderNames(), ['anthropic', 'openai', 'mistral']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('getProviderForModel returns null when no providers loaded', () => {
|
it('getProviderForModel returns null when no providers loaded', () => {
|
||||||
@@ -2124,9 +2134,10 @@ describe('Codex plugin (D6)', () => {
|
|||||||
assert.equal(codex.hints.concurrentSpawnSafe, true);
|
assert.equal(codex.hints.concurrentSpawnSafe, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Test 15: STATIC_REGISTRY length after D6 ─────────────────────────
|
// ── Test 15: STATIC_REGISTRY length after D8 still includes openai ────
|
||||||
it('STATIC_REGISTRY.length === 2 after D6 (anthropic + openai)', () => {
|
it('STATIC_REGISTRY includes openai (D6) after D8 (length >= 2)', () => {
|
||||||
assert.equal(listAllProviderNames().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('anthropic'));
|
||||||
assert.ok(listAllProviderNames().includes('openai'));
|
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: '<fake-mistral-api-key>' };
|
||||||
|
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: '<fake-mistral-api-key>' };
|
||||||
|
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: '<fake-mistral-api-key>' };
|
||||||
|
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: '<fake-mistral-api-key>' };
|
||||||
|
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: '<fake-mistral-api-key>' };
|
||||||
|
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: '<fake-mistral-api-key>' };
|
||||||
|
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: '<fake-key>' }),
|
||||||
|
});
|
||||||
|
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: '<fake-key>' }),
|
||||||
|
});
|
||||||
|
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 = '<fake-mistral-api-key>';
|
||||||
|
try {
|
||||||
|
const result = mistralReadAuthArtifact();
|
||||||
|
assert.ok(result !== null, 'Expected auth result');
|
||||||
|
assert.equal(result.apiKey, '<fake-mistral-api-key>');
|
||||||
|
} 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']);
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user