From 84a5e0b64a034d6892abd847c74df5ab9327ea10 Mon Sep 17 00:00:00 2001 From: hugo Date: Sun, 1 Mar 2026 17:11:58 +0100 Subject: [PATCH] chore: closed plan that is implemented --- MISTRAL_PLAN.md | 641 ------------------------------------------------ 1 file changed, 641 deletions(-) delete mode 100644 MISTRAL_PLAN.md diff --git a/MISTRAL_PLAN.md b/MISTRAL_PLAN.md deleted file mode 100644 index a98a754..0000000 --- a/MISTRAL_PLAN.md +++ /dev/null @@ -1,641 +0,0 @@ -# Plan: Add Mistral AI as Alternative Chat Provider - -## Context -bDS currently routes all AI chat through the OpenCode Zen gateway (`opencode.ai/zen/v1/...`) with two code paths: Anthropic Messages API and OpenAI-compatible. The user wants Mistral AI added as a direct alternative provider with frontier models that support chat completion, tool use, and vision. Mistral's API is OpenAI-compatible (`api.mistral.ai/v1/chat/completions`), making integration straightforward. - -**Important architecture facts:** -- HTTP requests are currently **non-streaming** (full response body collected, text emitted after each complete call) — to be converted to SSE streaming in a **separate prerequisite PR** (PR 1) -- API keys are stored in **plain-text SQLite** — to be migrated to Electron `safeStorage` (OS keychain) for all providers in a **separate prerequisite PR** (PR 2) -- Neither `sendAnthropicMessage()` nor `sendOpenAIMessage()` currently sets `tool_choice` -- `sendOpenAIMessage()` does **not** convert `view_image` results to `image_url` format — they are JSON-stringified -- `generateConversationTitle()` is hardcoded to `claude-haiku-4-5` via `ZEN_ANTHROPIC_URL` -- `analyzeMediaImage()` is hardcoded to `claude-sonnet-4-5` via `ZEN_ANTHROPIC_URL` -- `checkReady()` only checks the OpenCode key — blocks `sendMessage()` for keyless users -- Internal `ModelInfo` type (returned by `getAvailableModels()`) is `{ id, name, provider }` — same shape as `ChatModel` in `electronApi.ts`; ensure they stay aligned when adding `vision` field - -## PR Structure - -This work is split into **3 sequential PRs** to reduce risk: - -| PR | Scope | Key Changes | -|----|-------|-------------| -| **PR 1 — SSE Streaming** ✅ | Standalone feature, no Mistral dependency | `httpRequestStream()`, SSE parsers (Anthropic + OpenAI formats), `stream: true` in request bodies, tool-call accumulation during streaming | -| **PR 2 — Keychain Migration** ✅ | Standalone security improvement | Migrate OpenCode API key from plain-text SQLite to `safeStorage`; add encryption/decryption wrappers; delete old plain-text keys (no migration); cross-platform (macOS Keychain, Windows DPAPI, Linux libsecret) | -| **PR 3 — Mistral Integration** | Builds on PR 1 + PR 2 | Mistral constants, model detection, key storage (using keychain from PR 2), parameterized `sendOpenAIMessage()`, vision fix, provider-aware routing, UI changes, i18n | - -## Target Models - -Use **latest aliases** (not dated IDs) so models auto-update when Mistral releases new versions. `getAvailableModels()` fetches the actual model list from the API; `MODEL_DISPLAY_NAMES` provides human-readable names for known models. - -| Model ID (latest alias) | Display Name | Vision | Tools | Context Window | Context Budget | -|------------------------|-------------|--------|-------|----------------|----------------| -| `mistral-large-latest` | Mistral Large | yes | yes | 40k | 35,000 | -| `mistral-medium-latest` | Mistral Medium | yes | yes | 40k | 35,000 | -| `mistral-small-latest` | Mistral Small | yes | yes | 128k | 120,000 | -| `devstral-small-latest` | Devstral Small | no | yes | 128k | 120,000 | -| `devstral-large-latest` | Devstral Large | no | yes | 256k | 240,000 | - -## Files to Modify - -### 1. `src/main/engine/OpenCodeManager.ts` - Core provider logic - -**A. Add Mistral constants** (near lines 23-25) -- `MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions'` -- `MISTRAL_MODELS_URL = 'https://api.mistral.ai/v1/models'` - -**B. Add Mistral models to `MODEL_DISPLAY_NAMES`** (lines 28-69) -``` -'mistral-large-latest': 'Mistral Large' -'mistral-medium-latest': 'Mistral Medium' -'mistral-small-latest': 'Mistral Small' -'devstral-small-latest': 'Devstral Small' -'devstral-large-latest': 'Devstral Large' -``` - -**C. Update `detectProvider()`** (lines 1839-1845) -- Add: `if (id.startsWith('mistral') || id.startsWith('ministral') || id.startsWith('devstral') || id.startsWith('codestral') || id.startsWith('pixtral')) return 'mistral';` -- This covers all current and foreseeable Mistral model prefixes (Mistral, Ministral, Devstral, Codestral, Pixtral) - -**C2. Update `formatModelName()` and `UPPERCASE_PREFIXES`** -- `formatModelName()` (L1869) first checks `MODEL_DISPLAY_NAMES`, then auto-formats via hyphen splitting + capitalization -- All 5 Mistral models are in `MODEL_DISPLAY_NAMES`, so auto-format is a fallback for future unknown models — no changes needed -- `UPPERCASE_PREFIXES` (L72) contains `['gpt', 'glm']` — no Mistral prefixes need uppercasing, so no changes needed - -**D. Add Mistral API key storage (using keychain from PR 2)** -- New field: `private mistralApiKey: string = ''` -- New methods: `setMistralApiKey()`, `getMistralApiKey()`, `validateMistralApiKey()` -- Load on init via `SecureKeyStore.retrieve()` (keychain infrastructure from PR 2) -- Store/retrieve using the same `SecureKeyStore` wrapper that PR 2 introduces for the OpenCode key -- No plain-text fallback — `safeStorage` is required - -**E. Update `checkReady()`** -- Return `ready: true` if **either** OpenCode key or Mistral key is set -- Extend `ChatReadyStatus` to report per-provider availability, e.g. `providers: { opencode: boolean, mistral: boolean }` -- Callers (`Sidebar.tsx`, `sendMessage()`) must gate on the relevant provider, not a single boolean - -**F. Parameterize `sendOpenAIMessage()` for Mistral (no separate method)** -- Mistral uses the identical OpenAI-compatible chat/completions format — creating a separate `sendMistralRequest()` would be a near-duplicate -- Instead, parameterize `sendOpenAIMessage()` to accept URL, API key, and provider-specific options: - - Add params: `apiUrl: string`, `apiKey: string`, `providerOptions?: { parallelToolCalls?: boolean }` - - `sendMessage()` determines provider via `detectProvider()` and calls `sendOpenAIMessage()` with the correct URL/key/options - - For OpenCode OpenAI path: URL = `ZEN_OPENAI_URL`, key = `this.apiKey` - - For Mistral: URL = `MISTRAL_API_URL`, key = `this.mistralApiKey`, `parallelToolCalls: false` -- `tool_choice`: omit entirely for all OpenAI-compatible providers (default `"auto"` is correct) -- `parallel_tool_calls: false` — set explicitly for Mistral only; our tool executor runs tools sequentially, so parallel tool calls would break the execution loop - -**F1b. Update `requestProvider` closure in `sendMessage()`** -- The `requestProvider` lambda (~line 362) dispatches to `sendAnthropicMessage()` or `sendOpenAIMessage()` based on `detectProvider()` -- The else branch must pass provider-specific URL/key/options when calling `sendOpenAIMessage()`: - - `provider === 'mistral'`: URL = `MISTRAL_API_URL`, key = `this.mistralApiKey`, options = `{ parallelToolCalls: false }` - - All other non-Anthropic providers: URL = `ZEN_OPENAI_URL`, key = `this.apiKey` (existing behavior) -- Helper method `getProviderConfig(provider)` could return `{ apiUrl, apiKey, options }` to keep `requestProvider` clean - -**F2. Add `MODEL_CONTEXT_BUDGETS` map** -- New constant map `MODEL_CONTEXT_BUDGETS: Record` with per-model token budgets -- `truncateToTokenBudget()` (L1654) currently defaults to `maxContextTokens = 150000` -- In `sendAnthropicMessage()` and `sendOpenAIMessage()`: pass the model's context budget from the map (defaulting to 150,000 for OpenCode models) -- The parameterized `sendOpenAIMessage()` looks up `MODEL_CONTEXT_BUDGETS[modelId]` for Mistral models and passes to truncation -- Values (keyed by latest aliases): - - `'mistral-large-latest': 35_000` - - `'mistral-medium-latest': 35_000` - - `'mistral-small-latest': 120_000` - - `'devstral-small-latest': 120_000` - - `'devstral-large-latest': 240_000` - -**G. Fix tool-call message history in OpenAI-compatible path** -- Within a single `sendMessage()` call, the tool loop correctly tracks tool results across rounds -- However, `tool` role messages are not persisted to DB-backed conversation history — on conversation resume, the model loses context about prior tool results -- Ensure `tool` role messages are included when persisting conversation history so cross-session continuity works -- This affects all OpenAI-compatible providers (OpenCode OpenAI path + Mistral) - -**H. Fix vision in OpenAI-compatible path (affects Mistral too)** -- `sendOpenAIMessage()` currently JSON-stringifies `view_image` results — no `image_url` conversion -- Add `image_url` format conversion for `__isImageResult` objects in the OpenAI path: - `{ type: 'image_url', image_url: { url: 'data:image/webp;base64,...' } }` -- This fixes vision for all OpenAI-compatible providers, not just Mistral - -**I. Update `getAvailableModels()` — merge from both providers** -- **Model list merge strategy**: fetch models from each configured provider's API endpoint and merge into a single list. When both keys are configured, return models from both; when only one key is set, return only that provider's models; when no key is set, return an empty list (UI disables the dropdown) -- OpenCode models: fetched from existing OpenCode API (as today) -- Mistral models: fetched from `GET https://api.mistral.ai/v1/models` when Mistral key is set; cross-reference returned IDs with `MODEL_DISPLAY_NAMES` to use display names + static `vision`/`contextBudget` metadata -- Every model entry carries `provider: 'opencode' | 'mistral'` so the UI and engine can resolve the correct API URL + key -- Invalidate `cachedModels`/`cachedModelsAt` when any provider key is added or removed - -**I2. Filter fallback model list by available keys** -- `getAvailableModels()` currently falls back to the full `MODEL_DISPLAY_NAMES` map when the API call fails -- With Mistral models added to `MODEL_DISPLAY_NAMES`, the fallback would show Mistral models even without a Mistral key -- Filter fallback: `fallback.filter(m => this.isProviderKeySet(m.provider))` — only include models whose provider has a configured key -- Add helper `isProviderKeySet(provider: string): boolean` that checks the relevant key field -- **Same issue in `validateApiKey()`**: currently returns models from the full `MODEL_DISPLAY_NAMES` map regardless of which provider key was validated. Once Mistral models are added, a successful OpenCode validation would incorrectly include Mistral models. Apply the same `isProviderKeySet()` filter to `validateApiKey()` results - -**I3. Add `MODEL_CAPABILITIES` map for vision flags** -- The Mistral API's `/v1/models` endpoint does NOT include a `vision` field — vision capability must come from a local static map -- New constant: `MODEL_CAPABILITIES: Record` keyed by model ID -- Entries for Mistral models: - - `'mistral-large-latest': { vision: true }` - - `'mistral-medium-latest': { vision: true }` - - `'mistral-small-latest': { vision: true }` - - `'devstral-small-latest': { vision: false }` - - `'devstral-large-latest': { vision: false }` -- OpenCode models also need vision flags (e.g., `'claude-sonnet-4-5': { vision: true }`, `'o3': { vision: false }`) for the image analysis model dropdown filter -- `getAvailableModels()` attaches `vision` from this map to each returned model -- Falls back to `vision: false` for unknown models (safe default; prevents non-vision models from appearing in the image analysis dropdown) - -**I4. Reconcile `ModelInfo` and `ChatModel` types** -- Internal `ModelInfo` (returned by `getAvailableModels()`) is `{ id, name, provider }` — same shape as `ChatModel` in `electronApi.ts` -- When adding `vision: boolean` to `ChatModel`, also update `ModelInfo` (or alias/merge them) so the engine and renderer use the same type -- Simplest approach: remove `ModelInfo`, use `ChatModel` everywhere (engine + IPC + renderer) - -**J. Update `generateConversationTitle()` — make configurable in Preferences** -- Currently hardcoded to `claude-haiku-4-5` via `ZEN_ANTHROPIC_URL` with OpenCode key -- Add a **"Title generation model"** preference in Settings so users can pick the cheapest model for this task -- Default: `claude-haiku-4-5` (OpenCode) or `mistral-small-latest` (Mistral) based on available keys -- Route to the correct provider URL + API key based on the selected model's provider -- Must work with any configured provider, not just OpenCode - -**K. Update `analyzeMediaImage()` — make configurable in Preferences** (lines 2066-2192) -- Currently hardcoded to `claude-sonnet-4-5` via `ZEN_ANTHROPIC_URL` -- Add an **"Image analysis model"** preference in Settings so users can select a dedicated vision model independent of their chat model (e.g. use Devstral for chat but Mistral Large 3 for images) -- **Only vision-capable models may be offered** — filter model list by a `vision` capability flag (e.g. Devstral models have no vision and must be excluded) -- Default: `claude-sonnet-4-5` (OpenCode) or first vision-capable Mistral model based on available keys -- Route to the correct provider URL + API key based on the selected model's provider -- When routed to Mistral: use `image_url` format with base64 data URI -- When routed to OpenCode/Anthropic: keep current Anthropic-native `image` block format - -**L. Update `analyzeTaxonomy()`** -- Currently uses `this.apiKey` (OpenCode) for both Anthropic and OpenAI paths -- Has an early-return guard `if (!this.apiKey)` that must become provider-aware — check Mistral key when provider is Mistral -- When a Mistral model is selected: use Mistral API key + `MISTRAL_API_URL` -- Must branch on provider to select correct key and URL -- **Note**: the OpenAI branch inside `analyzeTaxonomy()` also hardcodes `ZEN_OPENAI_URL` and `this.apiKey` — both must become provider-aware (use `getProviderConfig(provider)` helper from F1b) - -**L2. Update `analyzeMediaImage()` API key guard** -- Same issue: has `if (!this.apiKey)` early-return guard -- Must become provider-aware — check the relevant provider's key based on the selected image analysis model -- When routed to Mistral: check `this.mistralApiKey` instead of `this.apiKey` - -**M. Convert chat HTTP calls to SSE streaming (PR 1 — separate prerequisite PR)** - -> **This entire section (M1–M6) is implemented in PR 1, before the Mistral PR.** The Mistral PR (PR 3) inherits streaming support and only needs to pass the correct URL/key/options to the already-streaming `sendOpenAIMessage()`. - -Currently `httpRequest()` buffers the entire response body before any text reaches the UI. Users wait 5–30s per API round with only a bouncing-dots indicator. All three providers (Anthropic, OpenAI, Mistral) support `stream: true` with SSE. - -**M1. Core streaming infrastructure — `httpRequestStream()`** -- New method (~100 lines) — uses Node.js `https.request()` but reads `res` as a readable stream -- Returns an async iterable of parsed SSE events (or accepts an `onEvent` callback) -- SSE line protocol: lines separated by `\n\n`, each line prefixed with `event: ` or `data: ` -- Must handle: - - Buffering partial lines across `data` chunks (TCP may split mid-line) - - Empty `data:` lines (keep-alive pings) - - `data: [DONE]` sentinel — terminates the stream for OpenAI/Mistral (do NOT try to JSON.parse this) - - Multiple `data:` lines between double-newlines (concatenate per SSE spec) -- Supports `AbortSignal` — calls `req.destroy()` to terminate immediately -- 120-second timeout matching existing `httpRequest()` -- On non-2xx status: collect the error body (not streamed) and throw with parsed error message - -**M2. SSE parser for OpenAI/Mistral format** (~50 lines) -OpenAI and Mistral use identical SSE event structure: -``` -data: {"id":"...","choices":[{"delta":{"content":"Hello"}}]} -data: {"id":"...","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_...","function":{"name":"search_posts","arguments":""}}]}}]} -data: {"id":"...","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"query\""}}]}}]} -... -data: {"id":"...","usage":{"prompt_tokens":150,"completion_tokens":42,"total_tokens":192}} -data: [DONE] -``` -- **Text deltas**: `choices[0].delta.content` — emit via `onDelta(content)` immediately -- **Tool call start**: `delta.tool_calls[i]` with `id` + `function.name` — begin accumulating arguments for tool call at index `i` -- **Tool call argument fragments**: `delta.tool_calls[i].function.arguments` — append to argument accumulator string for index `i` -- **Finish reason**: `choices[0].finish_reason === 'tool_calls'` or `'stop'` — signals end of this chunk -- **Token usage**: arrives in the **final chunk before `[DONE]`** only if `stream_options: { include_usage: true }` is set in the request body — parse `usage.prompt_tokens`, `usage.completion_tokens`, `usage.total_tokens` -- **`[DONE]` sentinel**: stop iteration, do NOT JSON.parse -- After stream ends: if tool calls were accumulated, JSON.parse each tool's assembled arguments string and execute - -**M3. SSE parser for Anthropic format** (~60 lines) -Anthropic uses named event types: -``` -event: message_start -data: {"type":"message_start","message":{"id":"...","model":"...","usage":{"input_tokens":150}}} - -event: content_block_start -data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}} - -event: content_block_start -data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_...","name":"search_posts"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"query\""}} - -event: content_block_stop -data: {"type":"content_block_stop","index":1} - -event: message_delta -data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":42}} - -event: message_stop -data: {"type":"message_stop"} -``` -- **`message_start`**: extract `usage.input_tokens` (prompt tokens) + `usage.cache_read_input_tokens` + `usage.cache_creation_input_tokens` -- **`content_block_start`** with `type: 'text'`: no-op (empty initial text) -- **`content_block_start`** with `type: 'tool_use'`: record tool call `id` and `name` at block index -- **`content_block_delta`** with `type: 'text_delta'`: emit via `onDelta(delta.text)` immediately -- **`content_block_delta`** with `type: 'input_json_delta'`: append `delta.partial_json` to argument accumulator -- **`content_block_stop`**: if tool block, JSON.parse the accumulated arguments for that block -- **`message_delta`**: extract `usage.output_tokens` (completion tokens), `delta.stop_reason` -- **`message_stop`**: stream complete -- **`ping`**: ignore (keep-alive) -- **`error`**: throw with `data.error.message` — handles mid-stream server errors (e.g. overloaded) - -**M4. Request body changes** -- `sendAnthropicMessage()`: add `"stream": true` to request body -- `sendOpenAIMessage()` (used for both OpenCode OpenAI and Mistral): add `"stream": true` and `"stream_options": { "include_usage": true }` to request body — this is **required** to receive token usage in streaming mode (without it, usage is omitted from streamed responses) - -**M5. Tool call accumulation during streaming** -- Tool call arguments arrive as partial JSON fragments across many SSE events -- Maintain a per-stream accumulator: `Map` keyed by tool call index -- Append each `arguments` fragment to the accumulator string -- On stream completion (finish_reason `tool_calls`/`tool_use`, or `content_block_stop` for Anthropic): JSON.parse the full accumulated arguments string and execute the tool -- If JSON.parse fails on accumulated arguments, report a tool error to the model and continue - -**M6. Error handling during streaming** -- **Non-2xx status on connection**: do NOT stream; collect the full error body and throw (same as current `httpRequest()` behavior) -- **Mid-stream TCP disconnect / network error**: `res.on('error')` handler — emit whatever text was accumulated so far, then throw so the tool-call loop can surface the error to the user -- **Mid-stream API error event**: Anthropic sends `event: error` with error details; OpenAI/Mistral return an error JSON in a `data:` line — detect and throw with parsed error message -- **Abort during streaming**: `req.destroy()` triggers `res.on('error')` or `res.on('close')` — handle gracefully without surfacing as an error to the user (it's intentional cancellation) - -**M7. Retry with exponential backoff for transient errors** -- Applies to **all providers** (Anthropic, OpenAI, Mistral) for both streaming and non-streaming calls -- Retry on HTTP status codes: `429` (rate limit), `503` (service unavailable), `502` (bad gateway) -- Max 3 retries with exponential backoff: ~1s, ~2s, ~4s (with jitter) -- For `429`: respect `Retry-After` header if present (use as minimum delay) -- For streaming: retry the entire request (cannot resume a partial SSE stream) -- Do NOT retry on `4xx` errors other than 429 (client errors like 400, 401, 403 are not transient) -- Do NOT retry on abort (intentional cancellation) -- Emit a brief status via `onDelta` or logging so the user knows a retry is in progress (e.g., "[Retrying...]") — or silently retry if preferred -- Wrap in a helper: `withRetry(fn, { maxRetries: 3, retryableStatuses: [429, 502, 503] })` - -**What does NOT change:** -- The renderer pipeline — `onDelta` → IPC `chat-stream-delta` → `appendStreamDelta` → React state → live Markdown rendering already works token-by-token; it just receives one big chunk today -- `AbortController` abort support — `req.destroy()` stops the stream immediately instead of wasting a buffered response -- The tool-call loop structure — still max 10 rounds, still sequential - -**What to keep non-streaming:** -- `generateConversationTitle()` — small one-shot request, buffering is fine -- `analyzeMediaImage()` — one-shot, no UI streaming needed -- `analyzeTaxonomy()` — one-shot, no UI streaming needed -- `validateApiKey()` / `validateMistralApiKey()` — small validation requests -- Note: `validateMistralApiKey()` must call `GET https://api.mistral.ai/v1/models` with `Authorization: Bearer ${key}`. Mistral returns `{ data: [{ id, object, created, owned_by }] }` — check for HTTP 200 + non-empty `data` array. On 401, return invalid. On success, optionally cross-reference returned model IDs with `MODEL_DISPLAY_NAMES` to verify expected models are available - -**Estimated scope:** ~350 lines of new code in `OpenCodeManager.ts` (streaming ~200 lines + retry ~50 lines + parsers ~100 lines) - -### 1b. Keychain Migration (PR 2 — separate prerequisite PR) - -> **This section is implemented in PR 2, before the Mistral PR.** PR 3 (Mistral) uses the keychain infrastructure introduced here. - -**Scope:** Migrate all API keys from plain-text SQLite to Electron `safeStorage` (OS keychain). Cross-platform: macOS Keychain, Windows DPAPI, Linux libsecret. No legacy fallback — old plain-text keys are deleted on startup; users re-enter keys. - -**1b-A. `SecureKeyStore` utility class** (~60 lines) -- New module: `src/main/engine/SecureKeyStore.ts` -- `store(key: string, value: string)` — encrypts with `safeStorage.encryptString()`, stores encrypted Buffer in SQLite settings table (as base64 string under a `__encrypted_` prefixed key) -- `retrieve(key: string): string | null` — reads encrypted base64 from SQLite, decrypts with `safeStorage.decryptString()` -- `remove(key: string)` — deletes the encrypted entry -- `isAvailable(): boolean` — wraps `safeStorage.isEncryptionAvailable()` -- No plain-text fallback — `store()` throws if `safeStorage` is unavailable - -**1b-B. Cleanup of old plain-text keys** (~10 lines) -- On app startup (in `getOpenCodeManager()` init): delete plain-text `opencode_api_key` from settings if it exists -- No migration — users re-enter their API key after the update -- Simple and secure: no window where both plain-text and encrypted keys coexist - -**1b-C. Update `setApiKey()` / `getApiKey()` in chatHandlers** -- Use `SecureKeyStore.store()` / `SecureKeyStore.retrieve()` instead of direct `getSetting()`/`setSetting()` -- `getApiKey()` returns masked key as before (for UI display) -- `validateApiKey()` unchanged — works with the decrypted key in memory - -**1b-D. Tests** -- `SecureKeyStore` unit tests: encrypt/decrypt round-trip, error when `safeStorage` unavailable, cleanup of old plain-text keys -- Mock `safeStorage` in tests (it's an Electron API, not available in Node) - -**Estimated scope:** ~120 lines of new code + ~80 lines of tests - -### 2. `src/main/engine/ChatEngine.ts` - Settings persistence - -**A. Add Mistral key helpers** -- Use existing generic `getSetting()`/`setSetting()` with key `'mistral_api_key'` — no dedicated methods needed, avoids unnecessary boilerplate -- ChatEngine already exposes generic helpers for reading/writing the settings table -- Note: the actual encrypted key storage goes through `SecureKeyStore` (PR 2) — `getSetting()`/`setSetting()` is used only for non-sensitive preferences - -**B. Default model is user-driven** -- `getSelectedModel()` defaults to `'claude-sonnet-4-5'` -- When user configures providers in Preferences, they explicitly select their default model — no automatic fallback logic needed -- All surfaces (ChatPanel, AssistantSidebar, ImportAnalysisView) use this preference as default -- If selected model's provider key is later removed: - - `sendMessage()` returns a clear error string: "The selected model requires a {provider} API key. Configure it in Settings." - - `checkReady()` still returns `ready: true` if any other provider is available - - ChatPanel shows an inline error banner (not a toast) with a link/button to open Settings - - i18n key: `chat.providerKeyMissing` — "The model '{model}' requires a {provider} API key. Go to Settings to configure it." - - Add this key to all 5 locale files - - This applies equally to **existing open conversations** whose model belongs to the removed provider — the next `sendMessage()` in those conversations shows the same inline error, not a silent failure - -**C. Add per-purpose model preferences** -- `getTitleModel()` / `setTitleModel(modelId)` — settings key `'chat_title_model'` -- `getImageAnalysisModel()` / `setImageAnalysisModel(modelId)` — settings key `'chat_image_analysis_model'` -- Both default to `null` (= use hardcoded defaults per provider) - -### 3. `src/main/ipc/chatHandlers.ts` - IPC bridge - -**A. Add Mistral-specific handlers** -- `chat:setMistralApiKey` - validate + persist Mistral key, invalidate model cache -- `chat:getMistralApiKey` - return masked key -- `chat:validateMistralApiKey` - test key against Mistral API - -**B. Update `chat:getAvailableModels`** -- Include Mistral models when Mistral key is configured -- Return provider info per model - -**C. Update `chat:checkReady`** -- Report readiness for both providers independently - -**D. Update `getOpenCodeManager()` init** -- Load Mistral API key via `SecureKeyStore.retrieve('mistral_api_key')` on first call (alongside OpenCode key) -- Call `manager.setMistralApiKey()` during init - -**E. Add per-purpose model preference handlers** -- `chat:setTitleModel` / `chat:getTitleModel` — persist + load title generation model preference -- `chat:setImageAnalysisModel` / `chat:getImageAnalysisModel` — persist + load image analysis model preference - -### 4. `src/main/shared/electronApi.ts` - Type definitions - -**A. Extend `ChatModel` interface** -- Add `provider: 'opencode' | 'mistral'` field (already optional, ensure populated) -- Add `vision: boolean` field — indicates whether the model supports image inputs (used to filter the image analysis model dropdown) - -**B. Extend `ChatReadyStatus` interface** -- Add `providers?: { opencode: boolean; mistral: boolean }` for per-provider status - -**C. Add Mistral IPC methods to `ElectronAPI.chat`** -- `setMistralApiKey(key: string)` -- `getMistralApiKey()` -- `validateMistralApiKey(key: string)` - -**D. Add per-purpose model preference methods to `ElectronAPI.chat`** -- `setTitleModel(modelId: string | null)` / `getTitleModel()` -- `setImageAnalysisModel(modelId: string | null)` / `getImageAnalysisModel()` - -### 5. `src/renderer/components/SettingsView/SettingsView.tsx` - UI settings - -**A. Add Mistral API key section** -- Separate input field for Mistral API key (below OpenCode key) -- Same pattern: masked display, change button, validation on save - -**B. Update model selector** -- SettingsView uses a native `` dropdown -- When both keys configured, show merged list from both providers; when only one key set, show only that provider's models -- **Note**: `availableModels` state is currently typed as `{id: string; name: string}[]` — must be updated to `ChatModel[]` (which includes `provider` and `vision` fields) so provider grouping and vision filtering work - -**C. Add per-purpose model preferences** -- "Title generation model" dropdown — select cheapest/fastest model for auto-titling conversations -- "Image analysis model" dropdown — select a dedicated vision model for media metadata (independent of chat model, e.g. use Devstral for chat but Mistral Large 3 for images); **only show vision-capable models** (filter out models without vision support like Devstral) -- Both show available models from all configured providers, grouped by provider -- Both allow a "Default" option that auto-selects per provider defaults - -### 6. `src/renderer/components/ChatPanel/ChatPanel.tsx` - Chat UI - -**A. Update model selector in chat** -- ChatPanel uses a custom dropdown (CSS `model-dropdown` with `