31 KiB
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 as part of this work
- Neither
sendAnthropicMessage()norsendOpenAIMessage()currently setstool_choice sendOpenAIMessage()does not convertview_imageresults toimage_urlformat — they are JSON-stringifiedgenerateConversationTitle()is hardcoded toclaude-haiku-4-5viaZEN_ANTHROPIC_URLanalyzeMediaImage()is hardcoded toclaude-sonnet-4-5viaZEN_ANTHROPIC_URLcheckReady()only checks the OpenCode key — blockssendMessage()for keyless users
Target Models
| Model ID | Display Name | Vision | Tools | Context Window | Context Budget |
|---|---|---|---|---|---|
mistral-large-2512 |
Mistral Large 3 | yes | yes | 40k | 35,000 |
mistral-medium-2508 |
Mistral Medium 3.1 | yes | yes | 40k | 35,000 |
mistral-small-2506 |
Mistral Small 3.2 | yes | yes | 128k | 120,000 |
devstral-small-2505 |
Devstral Small | no | yes | 128k | 120,000 |
devstral-large-2506 |
Devstral 2 | 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-2512': 'Mistral Large 3'
'mistral-medium-2508': 'Mistral Medium 3.1'
'mistral-small-2506': 'Mistral Small 3.2'
'devstral-small-2505': 'Devstral Small'
'devstral-large-2506': 'Devstral 2'
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 checksMODEL_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
- New field:
private mistralApiKey: string = '' - New methods:
setMistralApiKey(),getMistralApiKey(),validateMistralApiKey() - Load on init from settings key
'mistral_api_key'
E. Update checkReady()
- Return
ready: trueif either OpenCode key or Mistral key is set - Extend
ChatReadyStatusto 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. Add Mistral request path in sendMessage()
- Route
provider === 'mistral'to newsendMistralRequest()method - Similar to OpenAI path but:
- URL:
MISTRAL_API_URL(direct, not through OpenCode gateway) - Auth:
Authorization: Bearer ${this.mistralApiKey} - Context budget: per-model (see Target Models table above)
tool_choice: "auto"— Mistral's default; do not set"any"(which forces a tool call every turn even when the model should respond with text). Omittool_choiceentirely, since"auto"is the default, matching existing OpenCode behaviorparallel_tool_calls: false— set explicitly; our tool executor runs tools sequentially, so parallel tool calls would break the execution loop
- URL:
F2. Add MODEL_CONTEXT_BUDGETS map
- New constant map
MODEL_CONTEXT_BUDGETS: Record<string, number>with per-model token budgets truncateToTokenBudget()(L1654) currently defaults tomaxContextTokens = 150000- In
sendAnthropicMessage()andsendOpenAIMessage(): pass the model's context budget from the map (defaulting to 150,000 for OpenCode models) - In
sendMistralRequest(): look upMODEL_CONTEXT_BUDGETS[modelId]and pass to truncation - Values from Target Models table (35k, 120k, 240k)
G. Fix tool-call message history in OpenAI-compatible path
sendOpenAIMessage()currently only keepsuser/assistantmessages in history, discardingtoolrole messages- Tool results must be persisted in conversation history so follow-up rounds have context
- This affects all OpenAI-compatible providers (OpenCode OpenAI path + Mistral)
H. Fix vision in OpenAI-compatible path (affects Mistral too)
sendOpenAIMessage()currently JSON-stringifiesview_imageresults — noimage_urlconversion- Add
image_urlformat conversion for__isImageResultobjects 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()
- When Mistral key is set, include Mistral models in returned list
- Add
providerfield to model entries so UI can group them - Invalidate
cachedModels/cachedModelsAtwhen Mistral key is added or removed
J. Update generateConversationTitle() — make configurable in Preferences
- Currently hardcoded to
claude-haiku-4-5viaZEN_ANTHROPIC_URLwith 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) ormistral-small-2506(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-5viaZEN_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
visioncapability 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_urlformat with base64 data URI - When routed to OpenCode/Anthropic: keep current Anthropic-native
imageblock format
L. Update analyzeTaxonomy()
- Currently uses
this.apiKey(OpenCode) for both Anthropic and OpenAI paths - When a Mistral model is selected: use Mistral API key +
MISTRAL_API_URL - Must branch on provider to select correct key and URL
M. Convert chat HTTP calls to SSE streaming
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 readsresas a readable stream - Returns an async iterable of parsed SSE events (or accepts an
onEventcallback) - SSE line protocol: lines separated by
\n\n, each line prefixed withevent:ordata: - Must handle:
- Buffering partial lines across
datachunks (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)
- Buffering partial lines across
- Supports
AbortSignal— callsreq.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 viaonDelta(content)immediately - Tool call start:
delta.tool_calls[i]withid+function.name— begin accumulating arguments for tool call at indexi - Tool call argument fragments:
delta.tool_calls[i].function.arguments— append to argument accumulator string for indexi - 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 ifstream_options: { include_usage: true }is set in the request body — parseusage.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: extractusage.input_tokens(prompt tokens) +usage.cache_read_input_tokens+usage.cache_creation_input_tokenscontent_block_startwithtype: 'text': no-op (empty initial text)content_block_startwithtype: 'tool_use': record tool callidandnameat block indexcontent_block_deltawithtype: 'text_delta': emit viaonDelta(delta.text)immediatelycontent_block_deltawithtype: 'input_json_delta': appenddelta.partial_jsonto argument accumulatorcontent_block_stop: if tool block, JSON.parse the accumulated arguments for that blockmessage_delta: extractusage.output_tokens(completion tokens),delta.stop_reasonmessage_stop: stream completeping: ignore (keep-alive)error: throw withdata.error.message— handles mid-stream server errors (e.g. overloaded)
M4. Request body changes
sendAnthropicMessage(): add"stream": trueto request bodysendOpenAIMessage()/sendMistralRequest(): add"stream": trueand"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<number, { id: string, name: string, arguments: string }>keyed by tool call index - Append each
argumentsfragment to the accumulator string - On stream completion (finish_reason
tool_calls/tool_use, orcontent_block_stopfor 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: errorwith error details; OpenAI/Mistral return an error JSON in adata:line — detect and throw with parsed error message - Abort during streaming:
req.destroy()triggersres.on('error')orres.on('close')— handle gracefully without surfacing as an error to the user (it's intentional cancellation)
What does NOT change:
- The renderer pipeline —
onDelta→ IPCchat-stream-delta→appendStreamDelta→ React state → live Markdown rendering already works token-by-token; it just receives one big chunk today AbortControllerabort 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 fineanalyzeMediaImage()— one-shot, no UI streaming neededanalyzeTaxonomy()— one-shot, no UI streaming neededvalidateApiKey()/validateMistralApiKey()— small validation requests- Note:
validateMistralApiKey()must callGET https://api.mistral.ai/v1/modelswithAuthorization: Bearer ${key}. Mistral returns{ data: [{ id, object, created, owned_by }] }— check for HTTP 200 + non-emptydataarray. On 401, return invalid. On success, optionally cross-reference returned model IDs withMODEL_DISPLAY_NAMESto verify expected models are available
Estimated scope: ~300 lines of new code in OpenCodeManager.ts (streaming adds ~100 lines vs original estimate)
2. src/main/engine/ChatEngine.ts - Settings persistence
A. Add Mistral key helpers
getMistralApiKey()- read from settings tablesetMistralApiKey(key)- persist to settings table- Settings key:
'mistral_api_key'
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 returnsready: trueif 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
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 cachechat:getMistralApiKey- return masked keychat: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'from settings 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 preferencechat: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: booleanfield — 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
- Group models by provider in dropdown (optgroup: "OpenCode Zen", "Mistral AI")
- Show provider badge next to selected model
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
- Group by provider in dropdown
- Only show models for configured providers
7. src/renderer/components/AssistantSidebar/ - Assistant UI
A. No model selector changes needed
- AssistantSidebar has no model selector and no
checkReady()call of its own - It uses whatever default model is set in Preferences (via
getSelectedModel()) - No code changes needed here — provider-awareness is handled at the Preferences and engine level
8. src/renderer/components/Sidebar.tsx - Navigation
A. Update readiness check
- Calls
chat.checkReady()to show/hide chat features - Must handle multi-provider readiness (show chat if any provider is ready)
- Note: Zustand store (
src/renderer/store/appStore.ts) currently only trackschatTokenUsage— no provider/readiness state is stored there. Provider readiness is ephemeral (fetched on mount viacheckReady()), so no Zustand changes needed. If future features need reactive provider state, consider adding it then
9. src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx - Taxonomy analysis UI
A. Update model selector
- Has its own model selector (
ChatModel[]state +getAvailableModels()call) for taxonomy analysis - Currently renders a flat model list with no provider grouping
- Apply same provider grouping as ChatPanel (optgroup by provider)
- Default to whatever is set in Preferences as default model (via
getSelectedModel()) - Use the shared
ModelSelectorcomponent (see section 9b)
9b. src/renderer/components/shared/ModelSelector.tsx - Shared model selector component (NEW)
Extract a reusable ModelSelector component used by SettingsView, ChatPanel, and ImportAnalysisView:
- Props:
models: ChatModel[],selectedModelId: string,onChange: (modelId: string) => void,filterVisionOnly?: boolean,allowDefault?: boolean,disabled?: boolean - Groups models into
<optgroup>bymodel.provider(labels: "OpenCode Zen", "Mistral AI") - When
filterVisionOnlyis true, only shows models withvision: true(for image analysis model selector) - When
allowDefaultis true, adds a "Default" option at the top (for per-purpose model preferences) - All three surfaces currently duplicate this dropdown logic — extracting prevents drift
10. Preload/IPC registration
A. src/main/preload.ts
- Register new Mistral IPC channels in preload bridge
- All chat IPC channels are bridged 1:1; new methods need entries here
11. i18n - All locale files
A. Add Mistral-specific i18n keys in all 5 locale files:
src/renderer/i18n/locales/en.jsonsrc/renderer/i18n/locales/de.jsonsrc/renderer/i18n/locales/fr.jsonsrc/renderer/i18n/locales/es.jsonsrc/renderer/i18n/locales/it.json
Keys needed:
settings.ai.mistralApiKeyLabel— "Mistral API Key"settings.ai.mistralApiKeyDescription— description textsettings.ai.mistralApiKeyPlaceholder— placeholder textsettings.ai.titleModelLabel— "Title generation model"settings.ai.titleModelDescription— description textsettings.ai.imageAnalysisModelLabel— "Image analysis model"settings.ai.imageAnalysisModelDescription— description textsettings.ai.defaultOption— "Default" (for per-purpose model selectors)settings.ai.providerGroupOpenCode— "OpenCode Zen" (optgroup label)settings.ai.providerGroupMistral— "Mistral AI" (optgroup label)chat.providerKeyMissing— "The model '{model}' requires a {provider} API key. Go to Settings to configure it."chat.apiKeyRequiredTitle— make generic or multi-provider (currently hardcoded to "OpenCode Zen API Key Required")chat.apiKeyRequiredDescription— make generic or multi-provider (currently hardcoded to OpenCode-specific text)
12. MCP Server - src/main/engine/MCPServer.ts
- No changes needed — MCP server exposes tools for external AI agents to call; no bDS-side AI runs during MCP requests
13. Python API - src/main/shared/pythonApiContractV1.ts
- No changes needed — AI/chat features are explicitly not exposed via Python API
Tests to Update
New tests
- OpenCodeManager: Mistral key storage,
detectProvider('mistral-*')+detectProvider('devstral-*')+detectProvider('codestral-*')+detectProvider('pixtral-*'),sendMistralRequest(), vision image conversion in OpenAI path, tool-call message persistence in OpenAI path,generateConversationTitle()Mistral routing, model cache invalidation,MODEL_CONTEXT_BUDGETScorrectness, SSE line parsing (both OpenAI/Mistral and Anthropic formats),[DONE]sentinel handling, tool-call argument accumulation during streaming, mid-stream error handling,stream_optionsin request bodies - ChatEngine:
getMistralApiKey()/setMistralApiKey(),getTitleModel()/setTitleModel(),getImageAnalysisModel()/setImageAnalysisModel(), default model fallback - chatHandlers: new Mistral IPC handlers, per-purpose model preference handlers
Existing tests to update
tests/engine/OpenCodeManagerTools.test.ts— if mocked manager gains new required fieldstests/engine/ChatEngine.test.ts— default model fallback logictests/ipc/chatHandlers.test.ts— new handler registration, init flowelectronApiContract.test.ts—ElectronAPI.chatshape now includes Mistral methods- 10 renderer test files that mock
window.electronAPI.chat(12 mock blocks total) — add Mistral method stubs to mocks:tests/renderer/components/SidebarChat.test.tsxtests/renderer/components/SettingsView.test.tsx(2 mock blocks)tests/renderer/components/SettingsView.i18n.test.tsxtests/renderer/components/TabBar.test.tsxtests/renderer/components/EditorDashboardTimeline.test.tsxtests/renderer/components/AssistantSidebar.wiring.test.tsxtests/renderer/navigation/chatSurfaceUsageGuards.test.tstests/renderer/navigation/chatSurfaceModeUsageGuards.test.tstests/renderer/navigation/assistantSidebarGuards.test.tstests/renderer/a2ui/surfaceActionWiring.test.tsx
Implementation Order
- Tests first (per AGENTS.md)
- Types (
electronApi.ts—ChatModel,ChatReadyStatus,ElectronAPI.chat) - Engine (
OpenCodeManager.ts— constants,MODEL_CONTEXT_BUDGETS, detection, key storage,checkReady(), request path, vision fix, title generation fallback) - SSE streaming (
OpenCodeManager.ts—httpRequestStream(), SSE parsers for Anthropic + OpenAI/Mistral formats,stream: true+stream_optionsin request bodies) - Persistence (
ChatEngine.ts— settings helpers, per-purpose model preferences, default model fallback) - IPC (
chatHandlers.ts— new handlers, init flow update) - Preload (
preload.ts— bridge new channels) - i18n (all 5 locale files)
- Shared components (
ModelSelector.tsx— extract reusable model selector with provider grouping) - UI (
SettingsView/SettingsView.tsx,ChatPanel.tsx,ImportAnalysisView.tsx,Sidebar.tsx— use sharedModelSelector) - Update existing test mocks
- Build verification (
npm run build)
Key Differences to Handle
| Aspect | OpenCode/Anthropic | OpenCode/OpenAI-compat | Mistral |
|---|---|---|---|
| Base URL | opencode.ai/zen/v1/messages |
opencode.ai/zen/v1/chat/completions |
api.mistral.ai/v1/chat/completions |
| Auth header | Bearer ${openCodeKey} |
Bearer ${openCodeKey} |
Bearer ${mistralKey} |
| Tool choice | not set | not set | not set (default "auto") |
| Parallel tools | not set | not set | parallel_tool_calls: false |
| Context budget | 150k tokens | 150k tokens | per-model (see Target Models table) |
| Stream options | "stream": true |
"stream": true, "stream_options": {"include_usage": true} |
"stream": true, "stream_options": {"include_usage": true} |
| Vision in tool results | Anthropic image block (native) |
BUG: JSON-stringified | image_url block (fix needed) |
| HTTP mode | SSE streaming (stream: true) |
SSE streaming (stream: true) |
SSE streaming (stream: true) |
| Title generation | claude-haiku-4-5 default |
N/A | mistral-small-2506 default |
| Image analysis | claude-sonnet-4-5 default |
N/A | user-selected vision model |
Verification
- Run
npm test— all existing + new tests pass - Run
npm run build— clean build - Manual: set Mistral API key in Settings, verify validation
- Manual: select Mistral Large 3, send chat message, verify response completes
- Manual: use
view_imagetool in chat with Mistral model, verify vision works - Manual: verify tool calling works (search_posts, list_posts, etc.)
- Manual: verify OpenCode models still work unchanged
- Manual: verify Mistral-only mode (no OpenCode key) — chat works, title generates, readiness shows correctly
- Manual: verify
analyzeMediaImage()andanalyzeTaxonomy()with Mistral model - Manual: configure title generation model in Settings, verify titles use selected model
- Manual: configure image analysis model in Settings, verify media analysis uses selected model (independent of chat model)
- Manual: verify SSE streaming — text appears token-by-token (not as a single block after long wait)
- Manual: verify abort during streaming — text stops immediately, no wasted response
Resolved Decisions
analyzeMediaImage()— Configurable via Settings preference; user selects a dedicated vision model independent of chat model; dropdown only shows vision-capable modelsgenerateConversationTitle()— Configurable via Settings preference; user selects cheapest/fastest model for auto-titlingcheckReady()— Returns true if any provider key is set; reports per-provider availability- Default model — User-driven; set explicitly in Preferences when configuring provider + model; all surfaces (ChatPanel, AssistantSidebar, ImportAnalysisView) use this preference
- Vision in OpenAI path — Fix
image_urlconversion for all OpenAI-compatible providers (not just Mistral) - MCP server — N/A; only exposes tools, no bDS-side AI runs
- Python API — N/A; AI/chat not exposed via Python API
- Model dropdown grouping — Use
<optgroup>labels ("OpenCode Zen", "Mistral AI"); extract sharedModelSelectorcomponent to avoid duplication across 3 surfaces - SSE streaming — Convert all chat HTTP calls to
stream: true+ SSE parsing; keep one-shot requests (title, image analysis, taxonomy, validation) non-streaming. Renderer needs zero changes — existingonDeltapipeline already supports incremental tokens. Token usage requiresstream_options: { include_usage: true }for OpenAI/Mistral format; Anthropic provides usage inmessage_start+message_deltaevents - OpenAI tool-call history — Fix
sendOpenAIMessage()to persisttoolrole messages in conversation history (not justuser/assistant), so follow-up tool rounds have context - ImportAnalysisView — Has its own model selector; apply same provider grouping; default to Preferences model
- AssistantSidebar — No model selector of its own; uses Preferences default model; no code changes needed
tool_choice— Do NOT settool_choice: "any"for Mistral (this forces tool use every turn). Omit it entirely; Mistral defaults to"auto", same as OpenCode. Setparallel_tool_calls: falseexplicitly since our tool executor is sequentialdetectProvider()prefixes — Cover all Mistral model families:mistral,ministral,devstral,codestral,pixtralformatModelName()/UPPERCASE_PREFIXES— No changes needed; all 5 Mistral models are inMODEL_DISPLAY_NAMES; auto-format fallback handles future unknown models correctly- Context budgets — Stored in
MODEL_CONTEXT_BUDGETSmap; passed explicitly totruncateToTokenBudget()per provider path; OpenCode defaults to 150k, Mistral per-model (see Target Models table) - Error UX for removed provider key — Inline error banner in ChatPanel (not a toast) with link to Settings;
sendMessage()returns descriptive error string;checkReady()stays true if any provider available - Zustand store — No changes needed; provider readiness is ephemeral (fetched on mount), token usage tracking is already in store and is provider-agnostic
validateMistralApiKey()— CallsGET https://api.mistral.ai/v1/modelswith Bearer token; checks for HTTP 200 + non-emptydataarray; Mistral returns{ data: [{ id, object, created, owned_by }] }format