Merge pull request #28 from rfc1437/feat/mistral-integration

feat: Add Mistral AI as first-class alternative provider (PR 3)
This commit is contained in:
Georg Bauer
2026-03-01 17:14:13 +01:00
committed by GitHub
31 changed files with 3713 additions and 1159 deletions

View File

@@ -9,6 +9,7 @@ This document provides context and best practices for GitHub Copilot when workin
## Commits
- our default branch is origin/master
- commit messages are short - one sentence. do not write long articles.
- pull requests are more verbose and especially give reasoning for changes

View File

@@ -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<string, number>` 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<string, { vision: boolean }>` 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 (M1M6) 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 530s 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<number, { id: string, name: string, arguments: string }>` 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 `<select>` element — group models by provider using `<optgroup>` labels ("OpenCode Zen", "Mistral AI")
- When no API key is configured for any provider, disable the `<select>` 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 `<button>` elements, not a native `<select>`) — add provider group headers (non-clickable divider labels) within the dropdown to visually separate providers
- Only show models for configured providers; when no keys configured, hide the model selector entirely
- When both providers configured, merge models from both with visual grouping
### 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 tracks `chatTokenUsage` — no provider/readiness state is stored there. Provider readiness is ephemeral (fetched on mount via `checkReady()`), 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 provider grouping matching the component's existing dropdown pattern
- Default to whatever is set in Preferences as default model (via `getSelectedModel()`)
### 9b. Model selector UI approach
**Two different dropdown patterns exist** — keep each surface consistent with its current UX:
- **SettingsView** uses native `<select>` elements → use `<optgroup>` for provider grouping (standard HTML pattern)
- **ChatPanel** uses a custom CSS dropdown (`model-dropdown` with `<button>` elements) → add non-clickable provider group headers as dividers
- **ImportAnalysisView** uses a custom CSS dropdown (`taxonomy-model-dropdown` with `<button>` elements, same pattern as ChatPanel) → add non-clickable provider group headers as dividers
- Shared logic (filtering by vision, adding "Default" option, provider grouping) can be extracted into a utility function or hook rather than a full component, since the rendering pattern differs per surface
- Props for the shared utility: `models: ChatModel[]`, `filterVisionOnly?: boolean`, `includeDefault?: boolean` → returns grouped/filtered model list
### 9c. `src/renderer/navigation/useChatMessageSender.ts` - Shared chat hook
**A. Verify no provider assumptions**
- Used by both ChatPanel and AssistantSidebar to send messages
- Currently delegates to `sendConversationMessage()` from `chatSession.ts` — verify neither has hardcoded provider/model assumptions
- No code changes expected, but must be verified during implementation
### 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.json`
- `src/renderer/i18n/locales/de.json`
- `src/renderer/i18n/locales/fr.json`
- `src/renderer/i18n/locales/es.json`
- `src/renderer/i18n/locales/it.json`
Keys needed:
- `settings.ai.mistralApiKeyLabel` — "Mistral API Key"
- `settings.ai.mistralApiKeyDescription` — description text
- `settings.ai.mistralApiKeyPlaceholder` — placeholder text
- `settings.ai.titleModelLabel` — "Title generation model"
- `settings.ai.titleModelDescription` — description text
- `settings.ai.imageAnalysisModelLabel` — "Image analysis model"
- `settings.ai.imageAnalysisModelDescription` — description text
- `settings.ai.defaultOption` — "Default" (for per-purpose model selectors)
- `settings.ai.providerGroupOpenCode` — "OpenCode Zen" (provider group label)
- `settings.ai.providerGroupMistral` — "Mistral AI" (provider group 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
### 14. Main-process i18n locales - `src/main/shared/i18n/locales/`
- No changes expected — chat-related strings are renderer-only
- Verify no main-process strings reference "OpenCode" in a way that needs updating for multi-provider support
## Tests to Update
### New tests
**PR 1 (SSE Streaming):**
- SSE line parsing (both OpenAI/Mistral and Anthropic formats)
- `[DONE]` sentinel handling
- Tool-call argument accumulation during streaming
- Mid-stream error handling
- `stream_options` in request bodies
- Partial line buffering across TCP chunks
- Abort during streaming (graceful cancellation)
- Retry with exponential backoff: 429/502/503 retries, `Retry-After` header parsing, no retry on 4xx/abort
**PR 2 (Keychain Migration):**
- `SecureKeyStore` encrypt/decrypt round-trip
- Error when `safeStorage` unavailable (no plain-text fallback)
- Cleanup of old plain-text keys on startup
- `chatHandlers` integration with `SecureKeyStore`
**PR 3 (Mistral Integration):**
- OpenCodeManager: Mistral key storage, `detectProvider('mistral-*')` + `detectProvider('devstral-*')` + `detectProvider('codestral-*')` + `detectProvider('pixtral-*')`, parameterized `sendOpenAIMessage()` with Mistral URL/key, vision image conversion in OpenAI path, tool-call message persistence in OpenAI path, `generateConversationTitle()` Mistral routing, model cache merge (both providers), `MODEL_CONTEXT_BUDGETS` correctness, `MODEL_CAPABILITIES` correctness, `isProviderKeySet()` helper, `getProviderConfig()` helper, fallback model list filtering by available keys, provider-aware API key guards in `analyzeTaxonomy()`/`analyzeMediaImage()`
- ChatEngine: `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 fields
- `tests/engine/ChatEngine.test.ts` — default model fallback logic
- `tests/ipc/chatHandlers.test.ts` — new handler registration, init flow
- `electronApiContract.test.ts``ElectronAPI.chat` shape 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.tsx`
- `tests/renderer/components/SettingsView.test.tsx`
- `tests/renderer/components/SettingsView.i18n.test.tsx`
- `tests/renderer/components/TabBar.test.tsx`
- `tests/renderer/components/EditorDashboardTimeline.test.tsx`
- `tests/renderer/components/AssistantSidebar.wiring.test.tsx`
- `tests/renderer/navigation/chatSurfaceUsageGuards.test.ts`
- `tests/renderer/navigation/chatSurfaceModeUsageGuards.test.ts`
- `tests/renderer/navigation/assistantSidebarGuards.test.ts`
- `tests/renderer/a2ui/surfaceActionWiring.test.tsx`
## Implementation Order
### PR 1 — SSE Streaming (prerequisite)
1. Tests first (per AGENTS.md) — SSE parsing, streaming error handling, tool accumulation
2. Core streaming infrastructure (`httpRequestStream()`, SSE line parser)
3. Anthropic SSE parser (`sendAnthropicMessage()` → streaming)
4. OpenAI/Mistral SSE parser (`sendOpenAIMessage()` → streaming)
5. `stream: true` + `stream_options` in request bodies
6. Update existing test mocks if needed
7. Build verification (`npm run build`)
### PR 2 — Keychain Migration (prerequisite)
1. Tests first — `SecureKeyStore` unit tests
2. `SecureKeyStore` utility class
3. Delete old plain-text `opencode_api_key` in `getOpenCodeManager()` init
4. Update `chatHandlers` `setApiKey()` / init to use `SecureKeyStore`
5. Add `deleteSetting()` to `ChatEngine` for cleanup
6. Build verification (`npm run build`)
### PR 3 — Mistral Integration (builds on PR 1 + PR 2)
1. Tests first (per AGENTS.md)
2. Types (`electronApi.ts``ChatModel` with `vision`, `ChatReadyStatus`, `ElectronAPI.chat`; unify `ModelInfo`/`ChatModel`)
3. Engine (`OpenCodeManager.ts` — constants, `MODEL_DISPLAY_NAMES`, `MODEL_CONTEXT_BUDGETS`, `MODEL_CAPABILITIES`, `detectProvider()`, key storage via `SecureKeyStore`, `checkReady()`, `getProviderConfig()`, `isProviderKeySet()`, parameterized `sendOpenAIMessage()`, vision fix, provider-aware guards, title generation fallback, model cache merge + fallback filtering)
4. Persistence (`ChatEngine.ts` — per-purpose model preferences, default model fallback)
5. IPC (`chatHandlers.ts` — new handlers, init flow update)
6. Preload (`preload.ts` — bridge new channels)
7. i18n (all 5 locale files)
8. Shared utilities (model grouping/filtering utility for provider-aware dropdowns)
9. UI (`SettingsView/SettingsView.tsx`, `ChatPanel.tsx`, `ImportAnalysisView.tsx`, `Sidebar.tsx`)
10. Update existing test mocks (10 renderer test files + engine/IPC tests)
11. 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}` |
| Request method | `sendAnthropicMessage()` | `sendOpenAIMessage(url, key)` | `sendOpenAIMessage(url, key, opts)` (same method, parameterized) |
| 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-latest` default |
| Image analysis | `claude-sonnet-4-5` default | N/A | user-selected vision model |
| Model source | fetched from OpenCode API | fetched from OpenCode API | fetched from `api.mistral.ai/v1/models` |
| Key storage | `safeStorage` (keychain) | `safeStorage` (keychain) | `safeStorage` (keychain) |
## Verification
1. Run `npm test` — all existing + new tests pass
2. Run `npm run build` — clean build
3. Manual: set Mistral API key in Settings, verify validation
4. Manual: select Mistral Large, send chat message, verify response completes
5. Manual: use `view_image` tool in chat with Mistral model, verify vision works
6. Manual: verify tool calling works (search_posts, list_posts, etc.)
7. Manual: verify OpenCode models still work unchanged
8. Manual: verify Mistral-only mode (no OpenCode key) — chat works, title generates, readiness shows correctly
9. Manual: verify `analyzeMediaImage()` and `analyzeTaxonomy()` with Mistral model
10. Manual: configure title generation model in Settings, verify titles use selected model
11. Manual: configure image analysis model in Settings, verify media analysis uses selected model (independent of chat model)
12. Manual: verify SSE streaming — text appears token-by-token (not as a single block after long wait)
13. Manual: verify abort during streaming — text stops immediately, no wasted response
14. Manual: verify keychain storage — API keys are encrypted, not stored as plain text in SQLite
15. Manual: verify old plain-text key is deleted on first launch after update (user re-enters key)
## Resolved Decisions
1. **`analyzeMediaImage()`** — Configurable via Settings preference; user selects a dedicated vision model independent of chat model; dropdown only shows vision-capable models
2. **`generateConversationTitle()`** — Configurable via Settings preference; user selects cheapest/fastest model for auto-titling
3. **`checkReady()`** — Returns true if any provider key is set; reports per-provider availability
4. **Default model** — User-driven; set explicitly in Preferences when configuring provider + model; all surfaces (ChatPanel, AssistantSidebar, ImportAnalysisView) use this preference
5. **Vision in OpenAI path** — Fix `image_url` conversion for all OpenAI-compatible providers (not just Mistral)
6. **MCP server** — N/A; only exposes tools, no bDS-side AI runs
7. **Python API** — N/A; AI/chat not exposed via Python API
8. **Model dropdown grouping** — SettingsView uses native `<select>` with `<optgroup>`; ChatPanel uses custom CSS dropdown with provider header dividers; shared utility extracts grouping/filtering logic while each surface keeps its own rendering pattern
9. **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 — existing `onDelta` pipeline already supports incremental tokens. Token usage requires `stream_options: { include_usage: true }` for OpenAI/Mistral format; Anthropic provides usage in `message_start` + `message_delta` events
10. **OpenAI tool-call history** — Within a single `sendMessage()` call, tool results are tracked correctly across rounds. The fix is about persisting `tool` role messages to DB-backed conversation history so cross-session resume works
11. **ImportAnalysisView** — Has its own model selector; apply same provider grouping; default to Preferences model
12. **AssistantSidebar** — No model selector of its own; uses Preferences default model; no code changes needed
13. **`tool_choice`** — Do NOT set `tool_choice: "any"` for Mistral (this forces tool use every turn). Omit it entirely; Mistral defaults to `"auto"`, same as OpenCode. Set `parallel_tool_calls: false` explicitly since our tool executor is sequential
14. **No separate `sendMistralRequest()`** — Parameterize `sendOpenAIMessage()` with URL/key/options instead of creating a near-duplicate method; Mistral uses the identical OpenAI-compatible format
15. **`detectProvider()` prefixes** — Cover all Mistral model families: `mistral`, `ministral`, `devstral`, `codestral`, `pixtral`
16. **`formatModelName()` / `UPPERCASE_PREFIXES`** — No changes needed; all 5 Mistral models are in `MODEL_DISPLAY_NAMES`; auto-format fallback handles future unknown models correctly
17. **Context budgets** — Stored in `MODEL_CONTEXT_BUDGETS` map; passed explicitly to `truncateToTokenBudget()` per provider path; OpenCode defaults to 150k, Mistral per-model (see Target Models table)
18. **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
19. **Zustand store** — No changes needed; provider readiness is ephemeral (fetched on mount), token usage tracking is already in store and is provider-agnostic
20. **`validateMistralApiKey()`** — Calls `GET https://api.mistral.ai/v1/models` with Bearer token; checks for HTTP 200 + non-empty `data` array; Mistral returns `{ data: [{ id, object, created, owned_by }] }` format
21. **Model cache merge**`getAvailableModels()` fetches from both provider endpoints when both keys are set, merges into a single list with `provider` field on each model; when only one key is set, only that provider's models are returned; when no keys are set, returns empty list and UI disables the model dropdown
22. **Provider-aware API key guards**`analyzeTaxonomy()` and `analyzeMediaImage()` have `if (!this.apiKey)` early-return guards that must become provider-aware (check the relevant provider's key based on the selected model)
23. **`useChatMessageSender` hook** — Shared by ChatPanel and AssistantSidebar; verify no provider assumptions exist (expected: no changes needed)
24. **ChatEngine generic settings** — Use existing `getSetting()`/`setSetting()` for non-sensitive preferences; API keys use `SecureKeyStore` (keychain)
25. **SettingsView model state type** — Currently `{id: string; name: string}[]`; must be updated to `ChatModel[]` to include `provider` and `vision` fields for grouping and filtering
26. **PR structure** — Split into 3 PRs: PR 1 (SSE streaming), PR 2 (keychain migration), PR 3 (Mistral integration). Reduces risk and allows independent review/testing
27. **Model IDs** — Use "latest" aliases (`mistral-large-latest`, etc.) not dated IDs. Models auto-update when Mistral releases new versions; `getAvailableModels()` fetches actual model list from API
28. **API key storage** — All API keys (OpenCode + Mistral) stored via Electron `safeStorage` (OS keychain). Cross-platform: macOS Keychain, Windows DPAPI, Linux libsecret. No plain-text fallback — old plain-text keys are deleted on startup; users re-enter keys after upgrade
29. **Model fallback filtering**`getAvailableModels()` fallback list (from `MODEL_DISPLAY_NAMES`) filtered by available provider keys. Only shows models whose provider has a configured key, even in fallback mode
30. **`requestProvider` routing** — The `requestProvider` lambda in `sendMessage()` must pass provider-specific URL/key/options to `sendOpenAIMessage()` via `getProviderConfig()` helper
31. **Vision capability map**`MODEL_CAPABILITIES` static map provides `vision: boolean` per model ID, since neither Mistral nor OpenCode APIs expose this field. OpenCode models also need vision flags for the image analysis dropdown filter
32. **`ModelInfo` / `ChatModel` unification** — Remove internal `ModelInfo` type; use `ChatModel` (with `vision` field) everywhere: engine, IPC, renderer
33. **Retry logic** — All providers get retry-with-exponential-backoff for transient HTTP errors (429, 502, 503). Max 3 retries, ~1s/2s/4s with jitter. Respects `Retry-After` header for 429. Implemented in PR 1 as part of the HTTP infrastructure
34. **`validateApiKey()` model filtering** — Filter returned models by `isProviderKeySet()` to avoid showing Mistral models on OpenCode key validation (and vice versa). Same pattern as `getAvailableModels()` fallback filtering
35. **ImportAnalysisView dropdown** — Uses custom CSS dropdown with `<button>` elements (same pattern as ChatPanel, not native `<select>`); apply provider group headers as dividers
36. **Removed-key error for existing conversations** — Existing open conversations whose model belongs to a removed provider show the same inline error banner on next `sendMessage()`, not a silent failure

View File

@@ -0,0 +1,53 @@
CREATE TABLE `ai_models` (
`provider` text NOT NULL,
`model_id` text NOT NULL,
`name` text NOT NULL,
`family` text,
`attachment` integer DEFAULT false,
`reasoning` integer DEFAULT false,
`tool_call` integer DEFAULT false,
`structured_output` integer DEFAULT false,
`temperature` integer DEFAULT false,
`knowledge` text,
`release_date` text,
`last_updated_date` text,
`open_weights` integer DEFAULT false,
`input_price` real,
`output_price` real,
`cache_read_price` real,
`cache_write_price` real,
`context_window` integer,
`max_input_tokens` integer,
`max_output_tokens` integer,
`interleaved` text,
`status` text,
`provider_npm` text,
`updated_at` integer NOT NULL,
PRIMARY KEY(`provider`, `model_id`)
);
--> statement-breakpoint
CREATE TABLE `ai_catalog_meta` (
`key` text PRIMARY KEY NOT NULL,
`value` text NOT NULL
);
--> statement-breakpoint
CREATE TABLE `ai_model_modalities` (
`provider` text NOT NULL,
`model_id` text NOT NULL,
`direction` text NOT NULL,
`modality` text NOT NULL,
PRIMARY KEY(`provider`, `model_id`, `direction`, `modality`)
);
--> statement-breakpoint
CREATE TABLE `ai_providers` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`env` text,
`npm` text,
`api` text,
`doc` text,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
DROP TABLE `model_catalog`;--> statement-breakpoint
DROP TABLE `model_catalog_meta`;

File diff suppressed because it is too large Load Diff

View File

@@ -64,6 +64,13 @@
"when": 1772369331600,
"tag": "0008_third_cable",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1772380619098,
"tag": "0009_model_catalog_v2",
"breakpoints": true
}
]
}

View File

@@ -1,4 +1,4 @@
import { sqliteTable, text, integer, real, uniqueIndex } from 'drizzle-orm/sqlite-core';
import { sqliteTable, text, integer, real, uniqueIndex, primaryKey } from 'drizzle-orm/sqlite-core';
// Projects table - stores blog projects/websites
export const projects = sqliteTable('projects', {
@@ -206,27 +206,64 @@ export const dbNotifications = sqliteTable('db_notifications', {
createdAt: integer('created_at').notNull(),
});
// Model catalog table - cached model metadata from models.dev API
// Stores per-model data (limits, pricing, capabilities) for the OpenCode provider.
// ── Model Catalog ──
// Normalised tables from models.dev API.
// Refreshed on user action via conditional GET (ETag). Survives offline use.
export const modelCatalog = sqliteTable('model_catalog', {
id: text('id').primaryKey(), // model ID (e.g. 'claude-sonnet-4-5')
name: text('name').notNull(), // display name
family: text('family'), // model family (e.g. 'claude-sonnet')
contextWindow: integer('context_window'), // max context tokens
maxInputTokens: integer('max_input_tokens'), // max input tokens (null = same as context)
maxOutputTokens: integer('max_output_tokens'), // max output tokens
inputPrice: real('input_price'), // cost per 1M input tokens (USD)
outputPrice: real('output_price'), // cost per 1M output tokens (USD)
cacheReadPrice: real('cache_read_price'), // cost per 1M cached input tokens (USD)
supportsAttachments: integer('supports_attachments', { mode: 'boolean' }).default(false),
supportsReasoning: integer('supports_reasoning', { mode: 'boolean' }).default(false),
supportsToolCall: integer('supports_tool_call', { mode: 'boolean' }).default(false),
// Provider table — one row per models.dev top-level provider
export const modelCatalogProviders = sqliteTable('ai_providers', {
id: text('id').primaryKey(), // provider key (e.g. 'opencode', 'mistral')
name: text('name').notNull(), // display name (e.g. 'OpenCode Zen')
env: text('env'), // JSON array of env var names
npm: text('npm'), // primary npm package
api: text('api'), // API base URL
doc: text('doc'), // documentation URL
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});
// Model catalog HTTP cache metadata (ETag for conditional GET)
export const modelCatalogMeta = sqliteTable('model_catalog_meta', {
// Model table — one row per (provider, modelId) pair
export const modelCatalog = sqliteTable('ai_models', {
provider: text('provider').notNull(), // FK → ai_providers.id
modelId: text('model_id').notNull(),
name: text('name').notNull(), // display name (e.g. 'Claude Sonnet 4.5')
family: text('family'), // model family (e.g. 'claude-sonnet')
attachment: integer('attachment', { mode: 'boolean' }).default(false),
reasoning: integer('reasoning', { mode: 'boolean' }).default(false),
toolCall: integer('tool_call', { mode: 'boolean' }).default(false),
structuredOutput: integer('structured_output', { mode: 'boolean' }).default(false),
temperature: integer('temperature', { mode: 'boolean' }).default(false),
knowledge: text('knowledge'), // knowledge cutoff (e.g. '2025-03-31')
releaseDate: text('release_date'),
lastUpdatedDate: text('last_updated_date'),
openWeights: integer('open_weights', { mode: 'boolean' }).default(false),
inputPrice: real('input_price'), // USD per 1M input tokens
outputPrice: real('output_price'), // USD per 1M output tokens
cacheReadPrice: real('cache_read_price'),
cacheWritePrice: real('cache_write_price'),
contextWindow: integer('context_window'), // max context tokens
maxInputTokens: integer('max_input_tokens'),
maxOutputTokens: integer('max_output_tokens'),
interleaved: text('interleaved'), // JSON object (e.g. '{"field":"reasoning_content"}')
status: text('status'), // e.g. 'deprecated'
providerNpm: text('provider_npm'), // per-model npm override
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
}, (table) => ({
pk: primaryKey({ columns: [table.provider, table.modelId] }),
}));
// Modality junction table — each row is one (direction, modality) tag for a model
// e.g. ('opencode', 'claude-sonnet-4', 'input', 'image')
export const modelCatalogModalities = sqliteTable('ai_model_modalities', {
provider: text('provider').notNull(),
modelId: text('model_id').notNull(),
direction: text('direction').notNull(), // 'input' | 'output'
modality: text('modality').notNull(), // 'text' | 'image' | 'pdf' | 'audio' | 'video'
}, (table) => ({
pk: primaryKey({ columns: [table.provider, table.modelId, table.direction, table.modality] }),
}));
// HTTP cache metadata (ETag for conditional GET)
export const modelCatalogMeta = sqliteTable('ai_catalog_meta', {
key: text('key').primaryKey(), // 'etag' | 'lastFetchedAt'
value: text('value').notNull(),
});
@@ -260,7 +297,11 @@ export type Template = typeof templates.$inferSelect;
export type NewTemplate = typeof templates.$inferInsert;
export type DbNotification = typeof dbNotifications.$inferSelect;
export type NewDbNotification = typeof dbNotifications.$inferInsert;
export type ModelCatalogProviderEntry = typeof modelCatalogProviders.$inferSelect;
export type NewModelCatalogProviderEntry = typeof modelCatalogProviders.$inferInsert;
export type ModelCatalogEntry = typeof modelCatalog.$inferSelect;
export type NewModelCatalogEntry = typeof modelCatalog.$inferInsert;
export type ModelCatalogModalityEntry = typeof modelCatalogModalities.$inferSelect;
export type NewModelCatalogModalityEntry = typeof modelCatalogModalities.$inferInsert;
export type ModelCatalogMetaEntry = typeof modelCatalogMeta.$inferSelect;
export type NewModelCatalogMetaEntry = typeof modelCatalogMeta.$inferInsert;

View File

@@ -1,41 +1,65 @@
/**
* ModelCatalogEngine — Fetches and caches model metadata from models.dev
*
* Provides model output token limits, pricing info, and capabilities
* for all models available through the OpenCode Zen gateway.
* The full catalog is stored in three normalised SQLite tables:
* model_catalog_providers — one row per provider (opencode, mistral, …)
* model_catalog — one row per (provider, modelId) pair
* model_catalog_modalities — junction table with (provider, modelId, direction, modality) tags
*
* Data is persisted in SQLite (model_catalog + model_catalog_meta tables)
* and refreshed on user action via conditional GET (ETag).
* Data is refreshed on user action via conditional GET (ETag).
* Works fully offline after first successful fetch.
*/
import https from 'https';
import http from 'http';
import { URL } from 'url';
import { eq } from 'drizzle-orm';
import { eq, and } from 'drizzle-orm';
import { getDatabase } from '../database';
import { modelCatalog, modelCatalogMeta } from '../database/schema';
import { modelCatalog, modelCatalogMeta, modelCatalogProviders, modelCatalogModalities } from '../database/schema';
import type { ModelCatalogEntry } from '../database/schema';
const MODELS_DEV_URL = 'https://models.dev/api.json';
const PROVIDER_KEY = 'opencode';
// Default max output tokens when no catalog data is available
export const DEFAULT_MAX_OUTPUT_TOKENS = 16384;
/** Provider-level metadata from models.dev. */
export interface ProviderInfo {
id: string;
name: string;
env: string[];
npm: string | null;
api: string | null;
doc: string | null;
}
/** Flattened model info returned by query methods. */
export interface ModelCatalogInfo {
provider: string;
id: string;
name: string;
family: string | null;
contextWindow: number | null;
maxInputTokens: number | null;
maxOutputTokens: number | null;
attachment: boolean;
reasoning: boolean;
toolCall: boolean;
structuredOutput: boolean;
temperature: boolean;
knowledge: string | null;
releaseDate: string | null;
lastUpdatedDate: string | null;
openWeights: boolean;
inputPrice: number | null;
outputPrice: number | null;
cacheReadPrice: number | null;
supportsAttachments: boolean | null;
supportsReasoning: boolean | null;
supportsToolCall: boolean | null;
cacheWritePrice: number | null;
contextWindow: number | null;
maxInputTokens: number | null;
maxOutputTokens: number | null;
interleaved: string | null;
status: string | null;
providerNpm: string | null;
inputModalities: string[];
outputModalities: string[];
}
export interface RefreshResult {
@@ -58,30 +82,87 @@ export class ModelCatalogEngine {
async getAll(): Promise<ModelCatalogInfo[]> {
const db = getDatabase().getLocal();
const rows = await db.select().from(modelCatalog);
return rows.map(toInfo);
const modalities = await db.select().from(modelCatalogModalities);
return rows.map(r => toInfo(r, modalities));
}
/**
* Get a single model's catalog entry by ID.
* Get all models for a specific provider.
*/
async getModel(modelId: string): Promise<ModelCatalogInfo | null> {
async getByProvider(provider: string): Promise<ModelCatalogInfo[]> {
const db = getDatabase().getLocal();
const rows = await db.select().from(modelCatalog).where(eq(modelCatalog.id, modelId));
return rows.length > 0 ? toInfo(rows[0]) : null;
const rows = await db.select().from(modelCatalog).where(eq(modelCatalog.provider, provider));
const modalities = await db.select().from(modelCatalogModalities).where(eq(modelCatalogModalities.provider, provider));
return rows.map(r => toInfo(r, modalities));
}
/**
* Get a single model by provider and model ID.
*/
async getModel(modelId: string, provider?: string): Promise<ModelCatalogInfo | null> {
const db = getDatabase().getLocal();
let rows: ModelCatalogEntry[];
if (provider) {
rows = await db.select().from(modelCatalog).where(
and(eq(modelCatalog.provider, provider), eq(modelCatalog.modelId, modelId)),
);
} else {
// Search across all providers, return first match
rows = await db.select().from(modelCatalog).where(eq(modelCatalog.modelId, modelId));
}
if (rows.length === 0) return null;
const row = rows[0];
const modalities = await db.select().from(modelCatalogModalities).where(
and(eq(modelCatalogModalities.provider, row.provider), eq(modelCatalogModalities.modelId, row.modelId)),
);
return toInfo(row, modalities);
}
/**
* Get all providers from the catalog.
*/
async getProviders(): Promise<ProviderInfo[]> {
const db = getDatabase().getLocal();
const rows = await db.select().from(modelCatalogProviders);
return rows.map(r => ({
id: r.id,
name: r.name,
env: r.env ? JSON.parse(r.env) as string[] : [],
npm: r.npm,
api: r.api,
doc: r.doc,
}));
}
/**
* Get the max output tokens for a model (used by OpenCodeManager for max_tokens).
* Returns DEFAULT_MAX_OUTPUT_TOKENS if the model is not in the catalog.
*/
async getMaxOutputTokens(modelId: string): Promise<number> {
const model = await this.getModel(modelId);
async getMaxOutputTokens(modelId: string, provider?: string): Promise<number> {
const model = await this.getModel(modelId, provider);
return model?.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
}
/**
* Get the context window size for a model.
* Returns null if the model is not in the catalog.
*/
async getContextWindow(modelId: string, provider?: string): Promise<number | null> {
const model = await this.getModel(modelId, provider);
return model?.contextWindow ?? null;
}
/**
* Check whether a model supports a specific input modality (e.g. 'image').
*/
async hasInputModality(modelId: string, modality: string, provider?: string): Promise<boolean> {
const model = await this.getModel(modelId, provider);
return model?.inputModalities.includes(modality) ?? false;
}
/**
* Refresh the model catalog from models.dev using conditional GET (ETag).
* Returns the number of models updated, or notModified if the data hasn't changed.
* Stores ALL providers and ALL models from the API.
*/
async refresh(): Promise<RefreshResult> {
try {
@@ -109,9 +190,16 @@ export class ModelCatalogEngine {
// Parse response
const data = JSON.parse(response.body);
const models = data?.[PROVIDER_KEY]?.models;
if (!models || typeof models !== 'object') {
return { success: false, modelsUpdated: 0, error: 'Invalid response: no opencode models found' };
if (!data || typeof data !== 'object') {
return { success: false, modelsUpdated: 0, error: 'Invalid response: not an object' };
}
// Count providers with models
const providerEntries = Object.entries(data).filter(
([, v]) => v && typeof v === 'object' && 'models' in (v as Record<string, unknown>),
);
if (providerEntries.length === 0) {
return { success: false, modelsUpdated: 0, error: 'Invalid response: no providers found' };
}
// Store new ETag
@@ -121,10 +209,18 @@ export class ModelCatalogEngine {
}
await this.setMeta('lastFetchedAt', new Date().toISOString());
// Upsert all models
const count = await this.upsertModels(models);
// Upsert all providers and their models
let totalModels = 0;
for (const [providerId, providerData] of providerEntries) {
const prov = providerData as Record<string, unknown>;
await this.upsertProvider(providerId, prov);
const models = prov.models as Record<string, unknown> | undefined;
if (models && typeof models === 'object') {
totalModels += await this.upsertModels(providerId, models);
}
}
return { success: true, modelsUpdated: count };
return { success: true, modelsUpdated: totalModels };
} catch (error) {
return { success: false, modelsUpdated: 0, error: (error as Error).message };
}
@@ -140,10 +236,42 @@ export class ModelCatalogEngine {
// ── Internal ──
/**
* Parse models.dev model entries and upsert into database.
* Upsert a provider row.
*/
private async upsertProvider(id: string, data: Record<string, unknown>): Promise<void> {
const db = getDatabase().getLocal();
const now = new Date();
const env = Array.isArray(data.env) ? JSON.stringify(data.env) : null;
await db.insert(modelCatalogProviders)
.values({
id,
name: (data.name as string) || id,
env,
npm: (data.npm as string) || null,
api: (data.api as string) || null,
doc: (data.doc as string) || null,
updatedAt: now,
})
.onConflictDoUpdate({
target: modelCatalogProviders.id,
set: {
name: (data.name as string) || id,
env,
npm: (data.npm as string) || null,
api: (data.api as string) || null,
doc: (data.doc as string) || null,
updatedAt: now,
},
});
}
/**
* Parse and upsert model entries for a given provider.
* Also writes modality rows to the junction table.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private async upsertModels(models: Record<string, any>): Promise<number> {
private async upsertModels(providerId: string, models: Record<string, any>): Promise<number> {
const db = getDatabase().getLocal();
const now = new Date();
let count = 0;
@@ -152,40 +280,87 @@ export class ModelCatalogEngine {
if (!info || typeof info !== 'object') continue;
const entry = {
id,
provider: providerId,
modelId: id,
name: info.name || id,
family: info.family || null,
contextWindow: info.limit?.context ?? null,
maxInputTokens: info.limit?.input ?? null,
maxOutputTokens: info.limit?.output ?? null,
attachment: info.attachment ?? false,
reasoning: info.reasoning ?? false,
toolCall: info.tool_call ?? false,
structuredOutput: info.structured_output ?? false,
temperature: info.temperature ?? false,
knowledge: info.knowledge || null,
releaseDate: info.release_date || null,
lastUpdatedDate: info.last_updated || null,
openWeights: info.open_weights ?? false,
inputPrice: info.cost?.input ?? null,
outputPrice: info.cost?.output ?? null,
cacheReadPrice: info.cost?.cache_read ?? null,
supportsAttachments: info.attachment ?? false,
supportsReasoning: info.reasoning ?? false,
supportsToolCall: info.tool_call ?? false,
cacheWritePrice: info.cost?.cache_write ?? null,
contextWindow: info.limit?.context ?? null,
maxInputTokens: info.limit?.input ?? null,
maxOutputTokens: info.limit?.output ?? null,
interleaved: info.interleaved ? JSON.stringify(info.interleaved) : null,
status: info.status || null,
providerNpm: info.provider?.npm || null,
updatedAt: now,
};
await db.insert(modelCatalog)
.values(entry)
.onConflictDoUpdate({
target: modelCatalog.id,
target: [modelCatalog.provider, modelCatalog.modelId],
set: {
name: entry.name,
family: entry.family,
contextWindow: entry.contextWindow,
maxInputTokens: entry.maxInputTokens,
maxOutputTokens: entry.maxOutputTokens,
attachment: entry.attachment,
reasoning: entry.reasoning,
toolCall: entry.toolCall,
structuredOutput: entry.structuredOutput,
temperature: entry.temperature,
knowledge: entry.knowledge,
releaseDate: entry.releaseDate,
lastUpdatedDate: entry.lastUpdatedDate,
openWeights: entry.openWeights,
inputPrice: entry.inputPrice,
outputPrice: entry.outputPrice,
cacheReadPrice: entry.cacheReadPrice,
supportsAttachments: entry.supportsAttachments,
supportsReasoning: entry.supportsReasoning,
supportsToolCall: entry.supportsToolCall,
cacheWritePrice: entry.cacheWritePrice,
contextWindow: entry.contextWindow,
maxInputTokens: entry.maxInputTokens,
maxOutputTokens: entry.maxOutputTokens,
interleaved: entry.interleaved,
status: entry.status,
providerNpm: entry.providerNpm,
updatedAt: now,
},
});
// Upsert modality tags
const mods = info.modalities;
if (mods && typeof mods === 'object') {
for (const direction of ['input', 'output'] as const) {
const tags = mods[direction];
if (Array.isArray(tags)) {
for (const modality of tags) {
if (typeof modality === 'string') {
await db.insert(modelCatalogModalities)
.values({ provider: providerId, modelId: id, direction, modality })
.onConflictDoUpdate({
target: [
modelCatalogModalities.provider,
modelCatalogModalities.modelId,
modelCatalogModalities.direction,
modelCatalogModalities.modality,
],
set: { modality }, // no-op update to satisfy ON CONFLICT
});
}
}
}
}
}
count++;
}
@@ -240,19 +415,38 @@ export class ModelCatalogEngine {
}
}
function toInfo(row: ModelCatalogEntry): ModelCatalogInfo {
// ── Helpers ──
/** Map of (provider, modelId) → { input: string[], output: string[] } for modalities */
type ModalityEntry = { provider: string; modelId: string; direction: string; modality: string };
function toInfo(row: ModelCatalogEntry, allModalities: ModalityEntry[]): ModelCatalogInfo {
const rowModalities = allModalities.filter(m => m.provider === row.provider && m.modelId === row.modelId);
return {
id: row.id,
provider: row.provider,
id: row.modelId,
name: row.name,
family: row.family,
contextWindow: row.contextWindow,
maxInputTokens: row.maxInputTokens,
maxOutputTokens: row.maxOutputTokens,
attachment: row.attachment ?? false,
reasoning: row.reasoning ?? false,
toolCall: row.toolCall ?? false,
structuredOutput: row.structuredOutput ?? false,
temperature: row.temperature ?? false,
knowledge: row.knowledge,
releaseDate: row.releaseDate,
lastUpdatedDate: row.lastUpdatedDate,
openWeights: row.openWeights ?? false,
inputPrice: row.inputPrice,
outputPrice: row.outputPrice,
cacheReadPrice: row.cacheReadPrice,
supportsAttachments: row.supportsAttachments,
supportsReasoning: row.supportsReasoning,
supportsToolCall: row.supportsToolCall,
cacheWritePrice: row.cacheWritePrice ?? null,
contextWindow: row.contextWindow,
maxInputTokens: row.maxInputTokens,
maxOutputTokens: row.maxOutputTokens,
interleaved: row.interleaved,
status: row.status,
providerNpm: row.providerNpm,
inputModalities: rowModalities.filter(m => m.direction === 'input').map(m => m.modality),
outputModalities: rowModalities.filter(m => m.direction === 'output').map(m => m.modality),
};
}

View File

@@ -27,67 +27,19 @@ import type { PostMediaEngine } from './PostMediaEngine';
import { ModelCatalogEngine, DEFAULT_MAX_OUTPUT_TOKENS } from './ModelCatalogEngine';
import { isRenderTool, generateFromToolCall } from '../a2ui/generator';
import type { A2UIServerMessage } from '../a2ui/types';
import type { ChatModel } from '../shared/electronApi';
// OpenCode Zen API endpoints
const ZEN_ANTHROPIC_URL = 'https://opencode.ai/zen/v1/messages';
const ZEN_OPENAI_URL = 'https://opencode.ai/zen/v1/chat/completions';
const ZEN_MODELS_URL = 'https://opencode.ai/zen/v1/models';
// Known model display names: maps model IDs to polished names and serves as offline fallback
const MODEL_DISPLAY_NAMES: Record<string, string> = {
// Anthropic Claude
'claude-opus-4-6': 'Claude Opus 4.6',
'claude-opus-4-5': 'Claude Opus 4.5',
'claude-opus-4-1': 'Claude Opus 4.1',
'claude-sonnet-4-6': 'Claude Sonnet 4.6',
'claude-sonnet-4-5': 'Claude Sonnet 4.5',
'claude-sonnet-4': 'Claude Sonnet 4',
'claude-haiku-4-5': 'Claude Haiku 4.5',
'claude-3-5-haiku': 'Claude 3.5 Haiku',
// OpenAI GPT
'gpt-5.3-codex': 'GPT 5.3 Codex',
'gpt-5.2': 'GPT 5.2',
'gpt-5.2-codex': 'GPT 5.2 Codex',
'gpt-5.1': 'GPT 5.1',
'gpt-5.1-codex': 'GPT 5.1 Codex',
'gpt-5.1-codex-max': 'GPT 5.1 Codex Max',
'gpt-5.1-codex-mini': 'GPT 5.1 Codex Mini',
'gpt-5': 'GPT 5',
'gpt-5-codex': 'GPT 5 Codex',
'gpt-5-nano': 'GPT 5 Nano',
// Google Gemini
'gemini-3.1-pro': 'Gemini 3.1 Pro',
'gemini-3-pro': 'Gemini 3 Pro',
'gemini-3-flash': 'Gemini 3 Flash',
// Other providers
'glm-5': 'GLM 5',
'glm-5-free': 'GLM 5 Free',
'glm-4.7': 'GLM 4.7',
'glm-4.6': 'GLM 4.6',
'qwen3-coder': 'Qwen3 Coder',
'minimax-m2.5': 'MiniMax M2.5',
'minimax-m2.5-free': 'MiniMax M2.5 Free',
'minimax-m2.1': 'MiniMax M2.1',
'minimax-m2.1-free': 'MiniMax M2.1 Free',
'kimi-k2.5': 'Kimi K2.5',
'kimi-k2.5-free': 'Kimi K2.5 Free',
'kimi-k2': 'Kimi K2',
'kimi-k2-thinking': 'Kimi K2 Thinking',
'big-pickle': 'Big Pickle',
'trinity-large-preview-free': 'Trinity Large Preview Free',
};
// Mistral API endpoints
const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions';
const MISTRAL_MODELS_URL = 'https://api.mistral.ai/v1/models';
// Uppercase prefixes that should not be title-cased
const UPPERCASE_PREFIXES = ['gpt', 'glm'];
export interface ModelInfo {
id: string;
name: string;
provider: string;
}
export interface SendMessageOptions {
metadata?: {
surface?: 'tab' | 'sidebar';
@@ -171,8 +123,9 @@ export class OpenCodeManager {
private postMediaEngine: PostMediaEngine;
private getMainWindow: () => BrowserWindow | null;
private apiKey: string = '';
private mistralApiKey: string = '';
private abortControllers: Map<string, AbortController> = new Map();
private cachedModels: ModelInfo[] | null = null;
private cachedModels: ChatModel[] | null = null;
private cachedModelsAt: number = 0;
private static MODEL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
private modelCatalogEngine = new ModelCatalogEngine();
@@ -202,6 +155,9 @@ export class OpenCodeManager {
*/
setApiKey(key: string): void {
this.apiKey = key;
// Invalidate model cache so merged list is re-fetched
this.cachedModels = null;
this.cachedModelsAt = 0;
}
/**
@@ -212,19 +168,40 @@ export class OpenCodeManager {
}
/**
* Check if the service is configured and ready
* Set API key for Mistral AI
*/
async checkReady(): Promise<{ ready: boolean; error?: string }> {
if (!this.apiKey) {
return { ready: false, error: 'API key not configured' };
}
return { ready: true };
setMistralApiKey(key: string): void {
this.mistralApiKey = key;
// Invalidate model cache so merged list is re-fetched
this.cachedModels = null;
this.cachedModelsAt = 0;
}
/**
* Validate an API key by calling the models endpoint
* Get current Mistral API key
*/
async validateApiKey(apiKey: string): Promise<{ isValid: boolean; models: ModelInfo[] }> {
getMistralApiKey(): string {
return this.mistralApiKey;
}
/**
* Check if the service is configured and ready
*/
async checkReady(): Promise<{ ready: boolean; error?: string; providers?: { opencode: boolean; mistral: boolean } }> {
const providers = {
opencode: !!this.apiKey,
mistral: !!this.mistralApiKey,
};
if (!this.apiKey && !this.mistralApiKey) {
return { ready: false, error: 'API key not configured', providers };
}
return { ready: true, providers };
}
/**
* Validate an OpenCode API key by calling the models endpoint
*/
async validateApiKey(apiKey: string): Promise<{ isValid: boolean; models: ChatModel[] }> {
if (!apiKey || apiKey.length < 3) {
return { isValid: false, models: [] };
}
@@ -235,6 +212,8 @@ export class OpenCodeManager {
{ 'x-api-key': apiKey },
];
const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups();
for (const headers of attempts) {
try {
const response = await this.httpRequest(ZEN_MODELS_URL, {
@@ -242,7 +221,16 @@ export class OpenCodeManager {
headers,
});
if (response.statusCode >= 200 && response.statusCode < 300) {
return { isValid: true, models: Object.entries(MODEL_DISPLAY_NAMES).map(([id, name]) => ({ id, name, provider: this.detectProvider(id) })) };
const data = JSON.parse(response.body);
const models = (data.data && Array.isArray(data.data))
? (data.data as Array<{ id: string }>).map(m => ({
id: m.id,
name: this.resolveName(m.id, catalogNames),
provider: this.detectProvider(m.id),
vision: this.resolveVision(m.id, catalogVision),
}))
: [];
return { isValid: true, models };
}
} catch {
// Try next auth method
@@ -253,15 +241,61 @@ export class OpenCodeManager {
}
/**
* Get available models (cached with 5-minute TTL)
* Validate a Mistral API key by calling the Mistral models endpoint
*/
async getAvailableModels(): Promise<ModelInfo[]> {
async validateMistralApiKey(apiKey: string): Promise<{ isValid: boolean; models: ChatModel[] }> {
if (!apiKey || apiKey.length < 3) {
return { isValid: false, models: [] };
}
const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups();
try {
const response = await this.httpRequest(MISTRAL_MODELS_URL, {
method: 'GET',
headers: {
'Authorization': `Bearer ${apiKey}`,
},
});
if (response.statusCode >= 200 && response.statusCode < 300) {
const data = JSON.parse(response.body);
if (data.data && Array.isArray(data.data) && data.data.length > 0) {
const models = (data.data as Array<{ id: string }>)
.filter(m => this.detectProvider(m.id) === 'mistral')
.map(m => ({
id: m.id,
name: this.resolveName(m.id, catalogNames),
provider: 'mistral',
vision: this.resolveVision(m.id, catalogVision),
}));
return { isValid: true, models };
}
}
} catch {
// Fall through
}
return { isValid: false, models: [] };
}
/**
* Get available models (cached with 5-minute TTL)
* Merges models from all configured providers.
*/
async getAvailableModels(): Promise<ChatModel[]> {
// Return cached models if within TTL
if (this.cachedModels && Date.now() - this.cachedModelsAt < OpenCodeManager.MODEL_CACHE_TTL) {
return this.cachedModels;
}
// Try fetching from API
const allModels: ChatModel[] = [];
let fetched = false;
// Load catalog for vision + name cross-referencing
const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups();
// Fetch OpenCode models
if (this.apiKey) {
try {
const response = await this.httpRequest(ZEN_MODELS_URL, {
@@ -274,14 +308,15 @@ export class OpenCodeManager {
if (response.statusCode === 200) {
const data = JSON.parse(response.body);
if (data.data && Array.isArray(data.data)) {
const models = data.data.map((m: { id: string }) => ({
for (const m of data.data as Array<{ id: string }>) {
allModels.push({
id: m.id,
name: this.formatModelName(m.id),
name: this.resolveName(m.id, catalogNames),
provider: this.detectProvider(m.id),
}));
this.cachedModels = models;
this.cachedModelsAt = Date.now();
return models;
vision: this.resolveVision(m.id, catalogVision),
});
}
fetched = true;
}
}
} catch {
@@ -289,13 +324,59 @@ export class OpenCodeManager {
}
}
// Build fallback from display name map
const fallback = Object.entries(MODEL_DISPLAY_NAMES).map(([id, name]) => ({
id,
name,
provider: this.detectProvider(id),
}));
return fallback;
// Fetch Mistral models
if (this.mistralApiKey) {
try {
const response = await this.httpRequest(MISTRAL_MODELS_URL, {
method: 'GET',
headers: {
'Authorization': `Bearer ${this.mistralApiKey}`,
},
});
if (response.statusCode === 200) {
const data = JSON.parse(response.body);
if (data.data && Array.isArray(data.data)) {
for (const m of data.data as Array<{ id: string }>) {
if (this.detectProvider(m.id) === 'mistral') {
allModels.push({
id: m.id,
name: this.resolveName(m.id, catalogNames),
provider: 'mistral',
vision: this.resolveVision(m.id, catalogVision),
});
}
}
fetched = true;
}
}
} catch {
// Fall through to fallback
}
}
if (fetched && allModels.length > 0) {
this.cachedModels = allModels;
this.cachedModelsAt = Date.now();
return allModels;
}
// Fallback: build from model catalog database (models.dev), filtered by available provider keys
try {
const catalog = await this.modelCatalogEngine.getAll();
if (catalog.length > 0) {
return catalog
.map(m => ({
id: m.id,
name: m.name,
provider: this.detectProvider(m.id),
vision: m.inputModalities.includes('image'),
}))
.filter(m => this.isProviderKeySet(m.provider));
}
} catch {
// Fall through to empty
}
return [];
}
/**
@@ -335,6 +416,12 @@ export class OpenCodeManager {
const modelId = conversation.model || 'claude-sonnet-4';
const provider = this.detectProvider(modelId);
// Check that the provider's API key is available
if (!this.isProviderKeySet(provider)) {
const providerLabel = provider === 'mistral' ? 'Mistral' : 'OpenCode';
return { success: false, error: `The model '${modelId}' requires a ${providerLabel} API key. Configure it in Settings.` };
}
// Get system prompt
const systemMessage = conversation.messages.find(m => m.role === 'system');
const basePrompt = systemMessage?.content || await this.chatEngine.getDefaultSystemPrompt();
@@ -387,6 +474,8 @@ export class OpenCodeManager {
);
}
// Get provider-specific config (URL, key, options)
const config = this.getProviderConfig(provider);
return this.sendOpenAIMessage(
modelId,
prompt,
@@ -395,6 +484,9 @@ export class OpenCodeManager {
{ onDelta, onToolCall, onToolResult, onTokenUsage },
conversationId,
emitA2UIMessages,
config.apiUrl,
config.apiKey,
config.options,
);
};
@@ -735,12 +827,13 @@ export class OpenCodeManager {
}
/**
* Send via OpenAI-compatible API (non-Claude models)
* Send via OpenAI-compatible API (non-Claude models, including Mistral)
* Parameterized to support multiple providers with identical API format.
*/
private async sendOpenAIMessage(
modelId: string,
systemPrompt: string,
dbMessages: Array<{ role: string; content?: string }>,
dbMessages: Array<{ role: string; content?: string; toolCalls?: string; toolCallId?: string }>,
signal: AbortSignal,
callbacks: {
onDelta?: (delta: string) => void;
@@ -750,16 +843,22 @@ export class OpenCodeManager {
},
conversationId: string,
emitA2UIMessages: (messages: A2UIServerMessage[]) => void,
apiUrl: string = ZEN_OPENAI_URL,
apiKey: string = this.apiKey,
providerOptions?: { parallelToolCalls?: boolean },
): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> {
// Build OpenAI-format messages
// Build OpenAI-format messages (with tool-call summaries for context parity with Anthropic path)
const allMessages: Array<Record<string, unknown>> = [
{ role: 'system', content: systemPrompt },
...dbMessages
.filter(m => m.role === 'user' || m.role === 'assistant')
.map(m => ({
role: m.role,
content: m.content || '',
})),
.map(m => {
let content = m.content || '';
if (m.role === 'assistant') {
content += this.buildToolCallSummary(m.toolCalls);
}
return { role: m.role, content };
}),
];
// Build OpenAI tools format
@@ -775,12 +874,13 @@ export class OpenCodeManager {
// Truncate conversation history to fit within context window
// Keep system message (index 0), truncate from oldest conversation messages
const contextBudget = (await this.modelCatalogEngine.getContextWindow(modelId)) ?? 150000;
const conversationMessages = allMessages.slice(1);
const anthropicFmt = conversationMessages.map(m => ({
role: m.role as 'user' | 'assistant',
content: (m.content as string) || '',
}));
const truncated = this.truncateToTokenBudget(anthropicFmt, systemPrompt, anthropicTools);
const truncated = this.truncateToTokenBudget(anthropicFmt, systemPrompt, anthropicTools, contextBudget);
const messages: Array<Record<string, unknown>> = [
allMessages[0],
...truncated.map(m => ({ role: m.role, content: m.content })),
@@ -804,14 +904,19 @@ export class OpenCodeManager {
stream_options: { include_usage: true },
};
// Set parallel_tool_calls based on provider options (Mistral needs false)
if (providerOptions?.parallelToolCalls === false) {
body.parallel_tool_calls = false;
}
// Retry only the HTTP connection (429/502/503 are caught before any events are emitted).
// Event processing is outside retry scope to prevent double-emission of onDelta on retry.
const { events } = await withRetry(async () => {
return httpRequestStream(ZEN_OPENAI_URL, {
return httpRequestStream(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
'Authorization': `Bearer ${apiKey}`,
},
body: JSON.stringify(body),
signal,
@@ -970,12 +1075,40 @@ export class OpenCodeManager {
callbacks.onToolResult({ name: toolName, result });
}
// Check for image result that needs multimodal formatting (OpenAI image_url format)
if (result && typeof result === 'object' && (result as Record<string, unknown>).__isImageResult) {
const imageResult = result as {
__isImageResult: boolean;
success: boolean;
mediaType: string;
base64: string;
metadata: Record<string, unknown>;
};
messages.push({
role: 'tool',
content: [
{
type: 'image_url',
image_url: {
url: `data:${imageResult.mediaType};base64,${imageResult.base64}`,
},
},
{
type: 'text',
text: JSON.stringify({ success: true, metadata: imageResult.metadata }),
},
],
tool_call_id: toolCall.id,
});
} else {
messages.push({
role: 'tool',
content: JSON.stringify(result),
tool_call_id: toolCall.id,
});
}
}
if (signal.aborted) break;
}
@@ -1811,6 +1944,25 @@ export class OpenCodeManager {
return truncated;
}
/**
* Build a human-readable summary of tool calls from a serialized JSON string.
* Used by both Anthropic and OpenAI message builders to annotate assistant
* messages with tool-use context when resuming a conversation from DB history.
*/
private buildToolCallSummary(toolCallsJson?: string): string {
if (!toolCallsJson) return '';
try {
const toolCalls = JSON.parse(toolCallsJson) as Array<{ name: string; args: unknown }>;
if (toolCalls.length === 0) return '';
const summary = toolCalls
.map(tc => `- ${tc.name}(${JSON.stringify(tc.args)})`)
.join('\n');
return `\n\n[Tools used in this turn:\n${summary}\n]`;
} catch {
return '';
}
}
/**
* Build Anthropic-format messages from DB message history.
* For assistant messages that had tool calls, appends a summary annotation
@@ -1825,23 +1977,7 @@ export class OpenCodeManager {
if (msg.role === 'user') {
messages.push({ role: 'user', content: msg.content || '' });
} else if (msg.role === 'assistant') {
let content = msg.content || '';
// If this message had tool calls, append a summary for context on resume
if (msg.toolCalls) {
try {
const toolCalls = JSON.parse(msg.toolCalls) as Array<{ name: string; args: unknown }>;
if (toolCalls.length > 0) {
const summary = toolCalls
.map(tc => `- ${tc.name}(${JSON.stringify(tc.args)})`)
.join('\n');
content += `\n\n[Tools used in this turn:\n${summary}\n]`;
}
} catch {
// Ignore malformed toolCalls JSON
}
}
const content = (msg.content || '') + this.buildToolCallSummary(msg.toolCalls);
messages.push({ role: 'assistant', content });
}
}
@@ -1850,7 +1986,9 @@ export class OpenCodeManager {
}
/**
* Generate a title for a conversation
* Generate a title for a conversation.
* Uses the configured title model (fallback: claude-haiku-4-5) and routes
* the request to the correct provider API.
*/
private async generateConversationTitle(
conversationId: string,
@@ -1858,16 +1996,25 @@ export class OpenCodeManager {
_assistantResponse: string
): Promise<void> {
try {
// Read configured title model, with smart fallback based on available keys
let titleModel = await this.chatEngine.getSetting('chat_title_model');
if (!titleModel || !this.isProviderKeySet(this.detectProvider(titleModel))) {
titleModel = this.apiKey ? 'claude-haiku-4-5' : this.mistralApiKey ? 'mistral-small-latest' : null;
}
if (!titleModel) return;
const provider = this.detectProvider(titleModel);
const promptText = `Topic: ${userMessage.substring(0, 100)}`;
const systemText = 'Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Output ONLY the title text.';
let title = '';
if (provider === 'anthropic') {
const body = {
model: 'claude-haiku-4-5',
model: titleModel,
max_tokens: 20,
system: 'Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Output ONLY the title text.',
messages: [
{
role: 'user',
content: `Topic: ${userMessage.substring(0, 100)}`,
},
],
system: systemText,
messages: [{ role: 'user', content: promptText }],
};
const response = await this.httpRequest(ZEN_ANTHROPIC_URL, {
@@ -1881,9 +2028,9 @@ export class OpenCodeManager {
body: JSON.stringify(body),
});
if (response.statusCode === 200) {
if (response.statusCode !== 200) return;
const data = JSON.parse(response.body);
let title = '';
if (Array.isArray(data.content)) {
title = data.content
.filter((b: AnthropicContentBlock) => b.type === 'text')
@@ -1892,11 +2039,36 @@ export class OpenCodeManager {
} else {
title = data.content || '';
}
} else {
// OpenAI-compatible (includes Mistral)
const config = this.getProviderConfig(provider);
const body = {
model: titleModel,
max_tokens: 20,
messages: [
{ role: 'system', content: systemText },
{ role: 'user', content: promptText },
],
};
const response = await this.httpRequest(config.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`,
},
body: JSON.stringify(body),
});
if (response.statusCode !== 200) return;
const data = JSON.parse(response.body);
title = data.choices?.[0]?.message?.content || '';
}
// Clean up and truncate title
title = title.trim().replace(/^["']|["']$/g, '').replace(/[.!?]+$/, '');
// Hard limit on title length
const MAX_TITLE_LENGTH = 30;
if (title.length > MAX_TITLE_LENGTH) {
title = title.substring(0, MAX_TITLE_LENGTH - 1) + '…';
@@ -1910,7 +2082,6 @@ export class OpenCodeManager {
mainWindow.webContents.send('chat-title-updated', { conversationId, title });
}
}
}
} catch (error) {
console.error('[OpenCodeManager] Error generating title:', error);
}
@@ -1996,32 +2167,79 @@ NOTE: Use pagination (offset/limit) in list_posts and search_posts to access all
return this.modelCatalogEngine;
}
/**
* Invalidate the in-memory model cache so the next getAvailableModels()
* re-fetches and re-cross-references with the catalog.
*/
invalidateModelCache(): void {
this.cachedModels = null;
this.cachedModelsAt = 0;
}
/**
* Check whether the given provider's API key is configured.
* All non-mistral providers are routed through OpenCode Zen and share apiKey.
*/
private isProviderKeySet(provider: string): boolean {
if (provider === 'mistral') return !!this.mistralApiKey;
return !!this.apiKey;
}
/**
* Load model catalog into maps for quick vision and name lookups.
* Vision = model has 'image' in its input modalities.
*/
private async getCatalogLookups(): Promise<{ vision: Map<string, boolean>; names: Map<string, string> }> {
const vision = new Map<string, boolean>();
const names = new Map<string, string>();
try {
const catalog = await this.modelCatalogEngine.getAll();
for (const m of catalog) {
vision.set(m.id, m.inputModalities.includes('image'));
names.set(m.id, m.name);
}
} catch {
// Catalog unavailable — maps stay empty
}
return { vision, names };
}
/**
* Resolve vision capability for a model ID.
* Vision = 'image' is in the model's input modalities from the catalog.
*/
private resolveVision(modelId: string, catalogVision: Map<string, boolean>): boolean {
return catalogVision.get(modelId) ?? false;
}
/**
* Resolve display name for a model ID. Falls back to raw model ID.
*/
private resolveName(modelId: string, catalogNames: Map<string, string>): string {
return catalogNames.get(modelId) ?? modelId;
}
/**
* Return API URL, key and provider-specific options for a given provider.
* Used to parameterise sendOpenAIMessage() for non-Anthropic providers.
*/
private getProviderConfig(provider: string): { apiUrl: string; apiKey: string; options?: { parallelToolCalls?: boolean } } {
if (provider === 'mistral') {
return { apiUrl: MISTRAL_API_URL, apiKey: this.mistralApiKey, options: { parallelToolCalls: false } };
}
return { apiUrl: ZEN_OPENAI_URL, apiKey: this.apiKey };
}
private detectProvider(modelId: string): string {
const id = modelId.toLowerCase();
if (id.startsWith('claude')) return 'anthropic';
if (id.startsWith('gpt') || id.startsWith('o3') || id.startsWith('o4')) return 'openai';
if (id.startsWith('gemini')) return 'google';
if (id.startsWith('mistral') || id.startsWith('ministral') || id.startsWith('devstral') || id.startsWith('codestral') || id.startsWith('pixtral')) return 'mistral';
return 'other';
}
private formatModelName(modelId: string): string {
// Check display name map first
if (MODEL_DISPLAY_NAMES[modelId]) {
return MODEL_DISPLAY_NAMES[modelId];
}
// Auto-format: split on hyphens, handle uppercase prefixes and version dots
const words = modelId.split('-');
return words
.map((word, index) => {
// First word: check for uppercase prefixes
if (index === 0 && UPPERCASE_PREFIXES.includes(word.toLowerCase())) {
return word.toUpperCase();
}
// Capitalize first letter
return word.charAt(0).toUpperCase() + word.slice(1);
})
.join(' ');
}
private parseErrorResponse(response: HttpResponse): string {
let errorMsg = `API error: ${response.statusCode}`;
@@ -2053,11 +2271,11 @@ NOTE: Use pagination (offset/limit) in list_posts and search_posts to access all
tagMappings?: Record<string, string>;
error?: string;
}> {
if (!this.apiKey) {
return { success: false, error: 'API key not set' };
}
const provider = this.detectProvider(modelId);
if (!this.isProviderKeySet(provider)) {
const providerLabel = provider === 'mistral' ? 'Mistral' : 'OpenCode';
return { success: false, error: `${providerLabel} API key not set` };
}
// Build the prompt for taxonomy analysis
const existingCategories = categories.filter(c => c.existsInProject).map(c => c.name);
@@ -2148,7 +2366,8 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu
}
}
} else {
// OpenAI-compatible
// OpenAI-compatible (includes Mistral)
const config = this.getProviderConfig(provider);
const body = {
model: modelId,
max_tokens: 4096,
@@ -2158,11 +2377,11 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu
],
};
const response = await this.httpRequest(ZEN_OPENAI_URL, {
const response = await this.httpRequest(config.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`,
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify(body),
});
@@ -2224,7 +2443,8 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu
/**
* Analyze a media image and generate title, alt text, and caption using AI
* This is a one-shot request that looks at the image and suggests metadata
* This is a one-shot request that looks at the image and suggests metadata.
* Uses the configured image analysis model and routes to the correct provider.
*/
async analyzeMediaImage(mediaId: string, language: string = 'en'): Promise<{
success: boolean;
@@ -2233,9 +2453,15 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu
caption?: string;
error?: string;
}> {
if (!this.apiKey) {
return { success: false, error: 'API key not configured. Please set your OpenCode API key in Settings.' };
// Read configured image analysis model, with smart fallback based on available keys
let modelId = await this.chatEngine.getSetting('chat_image_analysis_model');
if (!modelId || !this.isProviderKeySet(this.detectProvider(modelId))) {
modelId = this.apiKey ? 'claude-sonnet-4-5' : this.mistralApiKey ? 'mistral-large-latest' : null;
}
if (!modelId) {
return { success: false, error: 'API key not configured. Please set an API key in Settings.' };
}
const provider = this.detectProvider(modelId);
// Get media metadata
const mediaItem = await this.mediaEngine.getMedia(mediaId);
@@ -2278,9 +2504,9 @@ CAPTION: Short, engaging blog caption (5-20 words).
Respond with JSON only: {"title": "...", "alt": "...", "caption": "..."}`;
try {
// Using Claude Sonnet 4.5 for best image analysis
const modelId = 'claude-sonnet-4-5';
let responseText = '';
if (provider === 'anthropic') {
const body = {
model: modelId,
max_tokens: 200,
@@ -2324,14 +2550,55 @@ Respond with JSON only: {"title": "...", "alt": "...", "caption": "..."}`;
}
const data = JSON.parse(response.body);
// Extract text from Anthropic response
let responseText = '';
for (const block of data.content || []) {
if (block.type === 'text') {
responseText += block.text;
}
}
} else {
// OpenAI-compatible (includes Mistral)
const config = this.getProviderConfig(provider);
const body = {
model: modelId,
max_tokens: 200,
messages: [
{ role: 'system', content: systemPrompt },
{
role: 'user',
content: [
{
type: 'image_url',
image_url: {
url: `data:image/webp;base64,${base64Data}`,
},
},
{
type: 'text',
text: 'Analyze and respond with JSON.',
},
],
},
],
};
const response = await this.httpRequest(config.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`,
},
body: JSON.stringify(body),
});
if (response.statusCode !== 200) {
console.error('[OpenCodeManager] Image analysis failed:', response.body);
const errorMsg = this.parseErrorResponse(response);
return { success: false, error: errorMsg };
}
const data = JSON.parse(response.body);
responseText = data.choices?.[0]?.message?.content || '';
}
// Parse the JSON response
const jsonMatch = responseText.match(/\{[\s\S]*\}/);

View File

@@ -33,7 +33,6 @@ export {
OpenCodeManager,
type SendMessageOptions,
type SendMessageResult,
type ModelInfo,
} from './OpenCodeManager';
export {
WxrParser,

View File

@@ -78,6 +78,16 @@ async function getOpenCodeManager(): Promise<OpenCodeManager> {
} catch {
// Silently ignore errors loading the key
}
// Load Mistral API key from encrypted storage
try {
const mistralKey = await keyStore.retrieve('mistral_api_key');
if (mistralKey) {
openCodeManager!.setMistralApiKey(mistralKey);
}
} catch {
// Silently ignore errors loading the Mistral key
}
})();
}
@@ -104,6 +114,7 @@ export function registerChatHandlers(): void {
ready: result.ready,
error: result.error,
backend: 'opencode',
providers: result.providers,
};
} catch (error) {
console.error('[Chat IPC] Error checking ready:', error);
@@ -160,6 +171,106 @@ export function registerChatHandlers(): void {
}
});
// ============ Mistral API Key ============
// Validate Mistral API key
ipcMain.handle('chat:validateMistralApiKey', async (_, apiKey: string) => {
try {
const manager = await getOpenCodeManager();
const result = await manager.validateMistralApiKey(apiKey);
return result;
} catch (error) {
console.error('[Chat IPC] Error validating Mistral API key:', error);
return { isValid: false, models: [] };
}
});
// Set Mistral API key
ipcMain.handle('chat:setMistralApiKey', async (_, apiKey: string) => {
try {
const manager = await getOpenCodeManager();
const previousKey = manager.getMistralApiKey();
manager.setMistralApiKey(apiKey);
// Persist to encrypted storage — roll back in-memory key on failure
try {
await getSecureKeyStore().store('mistral_api_key', apiKey);
} catch (storeError) {
manager.setMistralApiKey(previousKey);
throw storeError;
}
return { success: true };
} catch (error) {
console.error('[Chat IPC] Error setting Mistral API key:', error);
return { success: false, error: (error as Error).message };
}
});
// Get Mistral API key (masked)
ipcMain.handle('chat:getMistralApiKey', async () => {
try {
const manager = await getOpenCodeManager();
const key = manager.getMistralApiKey();
if (!key) return { hasKey: false, maskedKey: '' };
const masked = '•'.repeat(Math.max(0, key.length - 4)) + key.slice(-4);
return { hasKey: true, maskedKey: masked };
} catch (error) {
console.error('[Chat IPC] Error getting Mistral API key:', error);
return { hasKey: false, maskedKey: '' };
}
});
// ============ Per-Purpose Model Preferences ============
// Get title generation model
ipcMain.handle('chat:getTitleModel', async () => {
try {
const engine = getChatEngine();
const model = await engine.getSetting('chat_title_model');
return { success: true, modelId: model || null };
} catch (error) {
console.error('[Chat IPC] Error getting title model:', error);
return { success: false, modelId: null };
}
});
// Set title generation model
ipcMain.handle('chat:setTitleModel', async (_, modelId: string) => {
try {
const engine = getChatEngine();
await engine.setSetting('chat_title_model', modelId);
return { success: true };
} catch (error) {
console.error('[Chat IPC] Error setting title model:', error);
return { success: false, error: (error as Error).message };
}
});
// Get image analysis model
ipcMain.handle('chat:getImageAnalysisModel', async () => {
try {
const engine = getChatEngine();
const model = await engine.getSetting('chat_image_analysis_model');
return { success: true, modelId: model || null };
} catch (error) {
console.error('[Chat IPC] Error getting image analysis model:', error);
return { success: false, modelId: null };
}
});
// Set image analysis model
ipcMain.handle('chat:setImageAnalysisModel', async (_, modelId: string) => {
try {
const engine = getChatEngine();
await engine.setSetting('chat_image_analysis_model', modelId);
return { success: true };
} catch (error) {
console.error('[Chat IPC] Error setting image analysis model:', error);
return { success: false, error: (error as Error).message };
}
});
// ============ Chat Settings ============
// Get available models
@@ -219,6 +330,9 @@ export function registerChatHandlers(): void {
try {
const manager = await getOpenCodeManager();
const result = await manager.getModelCatalogEngine().refresh();
// Invalidate the in-memory model cache so vision/name data
// from the freshly populated catalog is picked up immediately.
manager.invalidateModelCache();
return result;
} catch (error) {
console.error('[Chat IPC] Error refreshing model catalog:', error);

View File

@@ -309,6 +309,17 @@ export const electronAPI: ElectronAPI = {
setApiKey: (apiKey: string) => ipcRenderer.invoke('chat:setApiKey', apiKey),
getApiKey: () => ipcRenderer.invoke('chat:getApiKey'),
// Mistral API Key Management
validateMistralApiKey: (apiKey: string) => ipcRenderer.invoke('chat:validateMistralApiKey', apiKey),
setMistralApiKey: (apiKey: string) => ipcRenderer.invoke('chat:setMistralApiKey', apiKey),
getMistralApiKey: () => ipcRenderer.invoke('chat:getMistralApiKey'),
// Per-Purpose Model Preferences
getTitleModel: () => ipcRenderer.invoke('chat:getTitleModel'),
setTitleModel: (modelId: string | null) => ipcRenderer.invoke('chat:setTitleModel', modelId),
getImageAnalysisModel: () => ipcRenderer.invoke('chat:getImageAnalysisModel'),
setImageAnalysisModel: (modelId: string | null) => ipcRenderer.invoke('chat:setImageAnalysisModel', modelId),
// Settings
getAvailableModels: () => ipcRenderer.invoke('chat:getAvailableModels'),
setDefaultModel: (modelId: string) => ipcRenderer.invoke('chat:setDefaultModel', modelId),

View File

@@ -421,7 +421,8 @@ export interface ChatMessage {
export interface ChatModel {
id: string;
name: string;
provider?: string;
provider: string;
vision?: boolean;
}
export interface ModelCatalogEntry {
@@ -450,6 +451,7 @@ export interface ChatReadyStatus {
ready: boolean;
error?: string;
backend?: string;
providers?: { opencode: boolean; mistral: boolean };
}
export interface ChatApiKeyStatus {
@@ -825,12 +827,23 @@ export interface ElectronAPI {
setApiKey: (apiKey: string) => Promise<{ success: boolean; error?: string }>;
getApiKey: () => Promise<ChatApiKeyStatus>;
// Mistral API Key
validateMistralApiKey: (apiKey: string) => Promise<{ isValid: boolean; models: ChatModel[] }>;
setMistralApiKey: (apiKey: string) => Promise<{ success: boolean; error?: string }>;
getMistralApiKey: () => Promise<ChatApiKeyStatus>;
// Settings
getAvailableModels: () => Promise<{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }>;
setDefaultModel: (modelId: string) => Promise<{ success: boolean; error?: string }>;
getSystemPrompt: () => Promise<{ success: boolean; prompt?: string; error?: string }>;
setSystemPrompt: (prompt: string) => Promise<{ success: boolean; error?: string }>;
// Per-purpose model preferences
getTitleModel: () => Promise<{ success: boolean; modelId?: string | null; error?: string }>;
setTitleModel: (modelId: string | null) => Promise<{ success: boolean; error?: string }>;
getImageAnalysisModel: () => Promise<{ success: boolean; modelId?: string | null; error?: string }>;
setImageAnalysisModel: (modelId: string | null) => Promise<{ success: boolean; error?: string }>;
// Model Catalog
refreshModelCatalog: () => Promise<ModelCatalogRefreshResult>;
getModelCatalog: () => Promise<{ success: boolean; entries: ModelCatalogEntry[]; error?: string }>;

View File

@@ -85,6 +85,21 @@
color: var(--vscode-list-activeSelectionForeground);
}
.model-group-header {
padding: 6px 12px 4px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--vscode-descriptionForeground);
pointer-events: none;
border-top: 1px solid var(--vscode-dropdown-border);
}
.model-group-header:first-child {
border-top: none;
}
.chat-messages {
flex: 1;
overflow-y: auto;

View File

@@ -23,9 +23,6 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
const [availableModels, setAvailableModels] = useState<ChatModel[]>([]);
const [showModelSelector, setShowModelSelector] = useState(false);
const [needsApiKey, setNeedsApiKey] = useState(false);
const [apiKeyInput, setApiKeyInput] = useState('');
const [apiKeyError, setApiKeyError] = useState('');
const [isValidating, setIsValidating] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -174,29 +171,6 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
scrollToBottom();
}, [messages, streamingContent, scrollToBottom]);
const handleApiKeySubmit = async () => {
if (!apiKeyInput.trim()) return;
setIsValidating(true);
setApiKeyError('');
try {
const result = await window.electronAPI?.chat.validateApiKey(apiKeyInput.trim());
if (result?.isValid) {
await window.electronAPI?.chat.setApiKey(apiKeyInput.trim());
setNeedsApiKey(false);
setApiKeyInput('');
loadData();
} else {
setApiKeyError(tr('chat.apiKeyInvalid'));
}
} catch {
setApiKeyError(tr('chat.apiKeyValidationFailed'));
} finally {
setIsValidating(false);
}
};
const handleSend = async () => {
const message = inputValue.trim();
if (!message || isStreaming) return;
@@ -303,6 +277,11 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
// API key setup screen
if (needsApiKey) {
const handleOpenSettings = () => {
useAppStore.getState().setActiveView('settings');
useAppStore.getState().openTab({ type: 'settings', id: 'settings', isTransient: false });
};
return (
<div className="chat-panel chat-surface">
<div className="chat-panel-header">
@@ -314,23 +293,12 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
<h2>{tr('chat.apiKeyRequiredTitle')}</h2>
<p>{tr('chat.apiKeyRequiredDescription')}</p>
<div className="api-key-form">
<input
type="password"
className="api-key-input"
value={apiKeyInput}
onChange={(e) => setApiKeyInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleApiKeySubmit()}
placeholder={tr('chat.apiKeyPlaceholder')}
disabled={isValidating}
/>
<button
className="api-key-submit"
onClick={handleApiKeySubmit}
disabled={!apiKeyInput.trim() || isValidating}
onClick={handleOpenSettings}
>
{isValidating ? tr('chat.apiKeyValidating') : tr('chat.apiKeySave')}
{tr('chat.openSettings')}
</button>
{apiKeyError && <div className="api-key-error">{apiKeyError}</div>}
</div>
</div>
</div>
@@ -355,7 +323,20 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
</button>
{showModelSelector && (
<div className="model-dropdown">
{availableModels.map(model => (
{(() => {
// Group models by provider for visual separation
const groups: Record<string, ChatModel[]> = {};
for (const model of availableModels) {
const p = model.provider || 'other';
if (!groups[p]) groups[p] = [];
groups[p].push(model);
}
return Object.entries(groups).map(([provider, models]) => (
<React.Fragment key={provider}>
{Object.keys(groups).length > 1 && (
<div className="model-group-header">{provider === 'mistral' ? tr('settings.ai.providerMistral') : tr('settings.ai.providerOpenCode')}</div>
)}
{models.map(model => (
<button
key={model.id}
className={`model-option ${conversation?.model === model.id ? 'active' : ''}`}
@@ -364,6 +345,9 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
{model.name}
</button>
))}
</React.Fragment>
));
})()}
</div>
)}
</div>

View File

@@ -1371,7 +1371,19 @@ const TaxonomySection: React.FC<{
</button>
{showModelSelector && (
<div className="taxonomy-model-dropdown">
{availableModels.map(model => (
{(() => {
const groups: Record<string, ChatModel[]> = {};
for (const model of availableModels) {
const p = model.provider || 'other';
if (!groups[p]) groups[p] = [];
groups[p].push(model);
}
return Object.entries(groups).map(([provider, models]) => (
<React.Fragment key={provider}>
{Object.keys(groups).length > 1 && (
<div className="model-group-header">{provider === 'mistral' ? t('settings.ai.providerMistral') : t('settings.ai.providerOpenCode')}</div>
)}
{models.map(model => (
<button
key={model.id}
className="taxonomy-model-option"
@@ -1380,6 +1392,9 @@ const TaxonomySection: React.FC<{
{model.name}
</button>
))}
</React.Fragment>
));
})()}
</div>
)}
</div>

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { useAppStore } from '../../store';
import { showToast } from '../Toast';
import { useI18n } from '../../i18n';
@@ -242,7 +242,12 @@ export const SettingsView: React.FC = () => {
const [aiApiKeyMasked, setAiApiKeyMasked] = useState('');
const [aiHasApiKey, setAiHasApiKey] = useState(false);
const [newApiKey, setNewApiKey] = useState('');
const [availableModels, setAvailableModels] = useState<{id: string; name: string}[]>([]);
const [aiHasMistralKey, setAiHasMistralKey] = useState(false);
const [aiMistralKeyMasked, setAiMistralKeyMasked] = useState('');
const [newMistralKey, setNewMistralKey] = useState('');
const [titleModel, setTitleModel] = useState('claude-haiku-4-5');
const [imageAnalysisModel, setImageAnalysisModel] = useState('claude-sonnet-4-5');
const [availableModels, setAvailableModels] = useState<{id: string; name: string; provider?: string; vision?: boolean}[]>([]);
const [selectedModel, setSelectedModel] = useState('');
const [modelCatalog, setModelCatalog] = useState<Map<string, {
maxOutputTokens: number | null;
@@ -403,6 +408,23 @@ export const SettingsView: React.FC = () => {
setSelectedModel(modelsResult.selectedModel || '');
}
// Load Mistral API key status
const mistralKeyResult = await window.electronAPI?.chat.getMistralApiKey();
if (mistralKeyResult) {
setAiHasMistralKey(mistralKeyResult.hasKey);
setAiMistralKeyMasked(mistralKeyResult.maskedKey || '');
}
// Load per-purpose model preferences
const titleModelResult = await window.electronAPI?.chat.getTitleModel();
if (titleModelResult?.success && titleModelResult.modelId) {
setTitleModel(titleModelResult.modelId);
}
const imageModelResult = await window.electronAPI?.chat.getImageAnalysisModel();
if (imageModelResult?.success && imageModelResult.modelId) {
setImageAnalysisModel(imageModelResult.modelId);
}
// Load model catalog metadata
const catalogResult = await window.electronAPI?.chat.getModelCatalog();
if (catalogResult?.success && catalogResult.entries) {
@@ -1080,6 +1102,13 @@ export const SettingsView: React.FC = () => {
setAiApiKeyMasked('•'.repeat(Math.max(0, newApiKey.length - 4)) + newApiKey.slice(-4));
setNewApiKey('');
showToast.success(t('settings.toast.apiKeySaved'));
// Refresh models after key change
const modelsResult = await window.electronAPI?.chat.getAvailableModels();
if (modelsResult?.success && modelsResult.models) {
setAvailableModels(modelsResult.models);
setSelectedModel(modelsResult.selectedModel || '');
}
} else {
showToast.error(t('settings.toast.apiKeyInvalid'));
}
@@ -1089,6 +1118,54 @@ export const SettingsView: React.FC = () => {
}
};
const handleSaveMistralApiKey = async () => {
if (!newMistralKey.trim()) return;
try {
const validateResult = await window.electronAPI?.chat.validateMistralApiKey(newMistralKey.trim());
if (validateResult?.isValid) {
await window.electronAPI?.chat.setMistralApiKey(newMistralKey.trim());
setAiHasMistralKey(true);
setAiMistralKeyMasked('•'.repeat(Math.max(0, newMistralKey.length - 4)) + newMistralKey.slice(-4));
setNewMistralKey('');
showToast.success(t('settings.toast.apiKeySaved'));
// Refresh models after key change
const modelsResult = await window.electronAPI?.chat.getAvailableModels();
if (modelsResult?.success && modelsResult.models) {
setAvailableModels(modelsResult.models);
setSelectedModel(modelsResult.selectedModel || '');
}
} else {
showToast.error(t('settings.toast.apiKeyInvalid'));
}
} catch (error) {
console.error('Failed to save Mistral API key:', error);
showToast.error(t('settings.toast.apiKeySaveFailed'));
}
};
const handleTitleModelChange = async (modelId: string) => {
try {
const result = await window.electronAPI?.chat.setTitleModel(modelId);
if (result?.success) {
setTitleModel(modelId);
}
} catch (error) {
console.error('Failed to set title model:', error);
}
};
const handleImageAnalysisModelChange = async (modelId: string) => {
try {
const result = await window.electronAPI?.chat.setImageAnalysisModel(modelId);
if (result?.success) {
setImageAnalysisModel(modelId);
}
} catch (error) {
console.error('Failed to set image analysis model:', error);
}
};
const handleModelChange = async (modelId: string) => {
try {
const result = await window.electronAPI?.chat.setDefaultModel(modelId);
@@ -1137,6 +1214,49 @@ export const SettingsView: React.FC = () => {
}
};
// Group models by provider for optgroup display
const groupModelsByProvider = useCallback((models: typeof availableModels) => {
const groups: Record<string, typeof availableModels> = {};
for (const model of models) {
const provider = model.provider || 'other';
if (!groups[provider]) groups[provider] = [];
groups[provider].push(model);
}
return groups;
}, []);
const groupedModels = useMemo(() => groupModelsByProvider(availableModels), [availableModels, groupModelsByProvider]);
// Vision-capable models only (for image analysis model selector)
const groupedVisionModels = useMemo(
() => groupModelsByProvider(availableModels.filter(m => m.vision)),
[availableModels, groupModelsByProvider]
);
const providerLabel = (provider: string) => {
if (provider === 'anthropic' || provider === 'openai' || provider === 'google' || provider === 'other') return t('settings.ai.providerOpenCode');
if (provider === 'mistral') return t('settings.ai.providerMistral');
return provider;
};
// Render a model <select> with optgroup by provider
const renderModelSelect = (id: string, value: string, onChange: (v: string) => void, disabled?: boolean, groups?: Record<string, typeof availableModels>) => {
const modelGroups = groups || groupedModels;
const modelList = Object.values(modelGroups).flat();
return (
<select id={id} value={value} onChange={(e) => onChange(e.target.value)} disabled={disabled}>
{modelList.length === 0 && <option value="">{t('settings.ai.noModels')}</option>}
{Object.entries(modelGroups).map(([provider, models]) => (
<optgroup key={provider} label={providerLabel(provider)}>
{models.map(model => (
<option key={model.id} value={model.id}>{model.name}</option>
))}
</optgroup>
))}
</select>
);
};
const renderAISettings = () => (
<SettingSection
id="settings-section-ai"
@@ -1185,27 +1305,58 @@ export const SettingsView: React.FC = () => {
)}
</SettingRow>
<SettingRow
id="ai-mistral-key"
label={t('settings.ai.mistralApiKeyLabel')}
description={t('settings.ai.mistralApiKeyDescription')}
>
<div className="setting-input-group">
{aiHasMistralKey ? (
<>
<input
id="ai-mistral-key"
type="text"
value={aiMistralKeyMasked}
disabled
placeholder={t('settings.ai.mistralApiKeyConfigured')}
/>
<span className="setting-status-badge success">{t('settings.ai.configured')}</span>
</>
) : (
<>
<input
id="ai-mistral-key"
type="password"
value={newMistralKey}
onChange={(e) => setNewMistralKey(e.target.value)}
placeholder={t('chat.apiKeyPlaceholder')}
/>
<button className="primary" onClick={handleSaveMistralApiKey} disabled={!newMistralKey.trim()}>
{t('chat.apiKeySave')}
</button>
</>
)}
</div>
{aiHasMistralKey && (
<div className="setting-inline-action">
<button className="text-button" onClick={() => { setAiHasMistralKey(false); setAiMistralKeyMasked(''); }}>
{t('settings.ai.changeMistralApiKey')}
</button>
</div>
)}
</SettingRow>
<SettingRow
id="ai-model"
label={t('settings.ai.defaultModelLabel')}
description={t('settings.ai.defaultModelDescription')}
>
<div className="setting-input-group">
<select
id="ai-model"
value={selectedModel}
onChange={(e) => handleModelChange(e.target.value)}
disabled={!aiHasApiKey}
>
{availableModels.length === 0 && <option value="">{t('settings.ai.noModels')}</option>}
{availableModels.map(model => (
<option key={model.id} value={model.id}>{model.name}</option>
))}
</select>
{renderModelSelect('ai-model', selectedModel, handleModelChange, !aiHasApiKey && !aiHasMistralKey)}
<button
className="secondary"
onClick={handleRefreshModelCatalog}
disabled={refreshingCatalog || !aiHasApiKey}
disabled={refreshingCatalog || (!aiHasApiKey && !aiHasMistralKey)}
title={t('settings.ai.refreshModelCatalog')}
>
{refreshingCatalog ? t('settings.ai.refreshing') : t('settings.ai.refreshModelCatalog')}
@@ -1232,6 +1383,22 @@ export const SettingsView: React.FC = () => {
})()}
</SettingRow>
<SettingRow
id="ai-title-model"
label={t('settings.ai.titleModelLabel')}
description={t('settings.ai.titleModelDescription')}
>
{renderModelSelect('ai-title-model', titleModel, handleTitleModelChange, !aiHasApiKey && !aiHasMistralKey)}
</SettingRow>
<SettingRow
id="ai-image-analysis-model"
label={t('settings.ai.imageAnalysisModelLabel')}
description={t('settings.ai.imageAnalysisModelDescription')}
>
{renderModelSelect('ai-image-analysis-model', imageAnalysisModel, handleImageAnalysisModelChange, !aiHasApiKey && !aiHasMistralKey, groupedVisionModels)}
</SettingRow>
<SettingRow
id="ai-system-prompt"
label={t('settings.ai.systemPromptLabel')}

View File

@@ -192,13 +192,11 @@
"settings.toast.thumbnailsComplete": "Generierung der Vorschaubilder abgeschlossen",
"settings.toast.thumbnailsFailed": "Vorschaubilder konnten nicht erzeugt werden",
"chat.setupTitle": "KI-Chat-Einrichtung",
"chat.apiKeyRequiredTitle": "OpenCode Zen API-Schlüssel erforderlich",
"chat.apiKeyRequiredDescription": "Gib deinen OpenCode API-Schlüssel ein, um den KI-Chat zu aktivieren.",
"chat.apiKeyRequiredTitle": "API-Schlüssel erforderlich",
"chat.apiKeyRequiredDescription": "Konfiguriere einen API-Schlüssel in den Einstellungen, um den KI-Chat zu aktivieren.",
"chat.openSettings": "Einstellungen öffnen",
"chat.apiKeyPlaceholder": "API-Schlüssel eingeben...",
"chat.apiKeySave": "Schlüssel speichern",
"chat.apiKeyValidating": "Wird validiert...",
"chat.apiKeyInvalid": "Ungültiger API-Schlüssel. Bitte prüfen und erneut versuchen.",
"chat.apiKeyValidationFailed": "API-Schlüssel konnte nicht validiert werden.",
"chat.newChat": "Neuer Chat",
"chat.welcomeTitle": "Willkommen beim KI-Assistenten",
"chat.welcomeDescription": "Ich kann dir helfen, deinen Blog mit anschaulichen Darstellungen zu verwalten. Frag mich zum Beispiel:",
@@ -733,6 +731,18 @@
"settings.ai.modelInfoOutputPrice": "Ausgabe",
"settings.ai.modelInfoTokens": "Token",
"settings.ai.modelInfoPerMTok": "/MTok",
"settings.ai.mistralApiKeyLabel": "Mistral API-Schlüssel",
"settings.ai.mistralApiKeyDescription": "Dein API-Schlüssel von Mistral AI. Ermöglicht Mistral-Modelle als Alternative zu OpenCode.",
"settings.ai.mistralApiKeyConfigured": "Mistral API-Schlüssel konfiguriert",
"settings.ai.changeMistralApiKey": "Mistral API-Schlüssel ändern",
"settings.ai.titleModelLabel": "Titelgenerierungsmodell",
"settings.ai.titleModelDescription": "Modell zur automatischen Generierung von Konversationstiteln.",
"settings.ai.imageAnalysisModelLabel": "Bildanalyse-Modell",
"settings.ai.imageAnalysisModelDescription": "Modell für die automatische Bildanalyse (Titel, Alt-Text, Bildunterschrift).",
"settings.ai.providerOpenCode": "OpenCode",
"settings.ai.providerMistral": "Mistral",
"settings.ai.providerOther": "Andere",
"chat.providerKeyMissing": "Das Modell '{{model}}' benötigt einen {{provider}} API-Schlüssel. Konfiguriere ihn in den Einstellungen.",
"settings.toast.modelCatalogRefreshed": "Modellkatalog aktualisiert ({{count}} Modelle)",
"settings.toast.modelCatalogUpToDate": "Modellkatalog ist bereits aktuell",
"settings.toast.modelCatalogRefreshFailed": "Modellkatalog konnte nicht aktualisiert werden",

View File

@@ -192,13 +192,11 @@
"settings.toast.thumbnailsComplete": "Thumbnail generation complete",
"settings.toast.thumbnailsFailed": "Failed to generate thumbnails",
"chat.setupTitle": "AI Chat Setup",
"chat.apiKeyRequiredTitle": "OpenCode Zen API Key Required",
"chat.apiKeyRequiredDescription": "Enter your OpenCode API key to enable AI chat.",
"chat.apiKeyRequiredTitle": "API Key Required",
"chat.apiKeyRequiredDescription": "Configure an API key in Settings to enable AI chat.",
"chat.openSettings": "Open Settings",
"chat.apiKeyPlaceholder": "Enter your API key...",
"chat.apiKeySave": "Save Key",
"chat.apiKeyValidating": "Validating...",
"chat.apiKeyInvalid": "Invalid API key. Please check and try again.",
"chat.apiKeyValidationFailed": "Failed to validate API key.",
"chat.newChat": "New Chat",
"chat.welcomeTitle": "Welcome to the AI Assistant",
"chat.welcomeDescription": "I can help you manage your blog with rich visualizations. Try asking me to:",
@@ -733,6 +731,18 @@
"settings.ai.modelInfoOutputPrice": "Output",
"settings.ai.modelInfoTokens": "tokens",
"settings.ai.modelInfoPerMTok": "/MTok",
"settings.ai.mistralApiKeyLabel": "Mistral API Key",
"settings.ai.mistralApiKeyDescription": "Your API key from Mistral AI. Enables Mistral models as an alternative to OpenCode.",
"settings.ai.mistralApiKeyConfigured": "Mistral API key configured",
"settings.ai.changeMistralApiKey": "Change Mistral API Key",
"settings.ai.titleModelLabel": "Title Generation Model",
"settings.ai.titleModelDescription": "Model used to generate conversation titles automatically.",
"settings.ai.imageAnalysisModelLabel": "Image Analysis Model",
"settings.ai.imageAnalysisModelDescription": "Model used for automatic image analysis (title, alt text, caption).",
"settings.ai.providerOpenCode": "OpenCode",
"settings.ai.providerMistral": "Mistral",
"settings.ai.providerOther": "Other",
"chat.providerKeyMissing": "The model '{{model}}' requires a {{provider}} API key. Configure it in Settings.",
"settings.toast.modelCatalogRefreshed": "Model catalog updated ({{count}} models)",
"settings.toast.modelCatalogUpToDate": "Model catalog already up to date",
"settings.toast.modelCatalogRefreshFailed": "Failed to refresh model catalog",

View File

@@ -192,13 +192,11 @@
"settings.toast.thumbnailsComplete": "Generación de miniaturas completa",
"settings.toast.thumbnailsFailed": "No se pudieron generar miniaturas",
"chat.setupTitle": "Configuración de chat IA",
"chat.apiKeyRequiredTitle": "Se requiere clave API de OpenCode Zen",
"chat.apiKeyRequiredDescription": "Introduce tu clave API de OpenCode para habilitar el chat de IA.",
"chat.apiKeyRequiredTitle": "Clave API requerida",
"chat.apiKeyRequiredDescription": "Configura una clave API en Ajustes para habilitar el chat de IA.",
"chat.openSettings": "Abrir Ajustes",
"chat.apiKeyPlaceholder": "Introduce tu clave API...",
"chat.apiKeySave": "Guardar clave",
"chat.apiKeyValidating": "Validando...",
"chat.apiKeyInvalid": "Clave API no válida. Compruébala e inténtalo de nuevo.",
"chat.apiKeyValidationFailed": "No se pudo validar la clave API.",
"chat.newChat": "Nuevo chat",
"chat.welcomeTitle": "Bienvenido al asistente de IA",
"chat.welcomeDescription": "Puedo ayudarte a gestionar tu blog con visualizaciones interactivas. Prueba a pedirme que:",
@@ -733,6 +731,18 @@
"settings.ai.modelInfoOutputPrice": "Salida",
"settings.ai.modelInfoTokens": "tokens",
"settings.ai.modelInfoPerMTok": "/MTok",
"settings.ai.mistralApiKeyLabel": "Clave API de Mistral",
"settings.ai.mistralApiKeyDescription": "Su clave API de Mistral AI. Habilita los modelos Mistral como alternativa a OpenCode.",
"settings.ai.mistralApiKeyConfigured": "Clave API de Mistral configurada",
"settings.ai.changeMistralApiKey": "Cambiar clave API de Mistral",
"settings.ai.titleModelLabel": "Modelo de generación de títulos",
"settings.ai.titleModelDescription": "Modelo utilizado para generar títulos de conversación automáticamente.",
"settings.ai.imageAnalysisModelLabel": "Modelo de análisis de imágenes",
"settings.ai.imageAnalysisModelDescription": "Modelo utilizado para el análisis automático de imágenes (título, texto alternativo, leyenda).",
"settings.ai.providerOpenCode": "OpenCode",
"settings.ai.providerMistral": "Mistral",
"settings.ai.providerOther": "Otro",
"chat.providerKeyMissing": "El modelo '{{model}}' requiere una clave API de {{provider}}. Configúrela en Ajustes.",
"settings.toast.modelCatalogRefreshed": "Catálogo actualizado ({{count}} modelos)",
"settings.toast.modelCatalogUpToDate": "El catálogo ya está actualizado",
"settings.toast.modelCatalogRefreshFailed": "No se pudo actualizar el catálogo",

View File

@@ -190,13 +190,11 @@
"settings.toast.thumbnailsComplete": "Génération des miniatures terminée",
"settings.toast.thumbnailsFailed": "Impossible de générer les miniatures",
"chat.setupTitle": "Configuration du chat IA",
"chat.apiKeyRequiredTitle": "Clé API OpenCode Zen requise",
"chat.apiKeyRequiredDescription": "Saisissez votre clé API OpenCode pour activer le chat IA.",
"chat.apiKeyRequiredTitle": "Clé API requise",
"chat.apiKeyRequiredDescription": "Configurez une clé API dans les Réglages pour activer le chat IA.",
"chat.openSettings": "Ouvrir les Réglages",
"chat.apiKeyPlaceholder": "Saisissez votre clé API...",
"chat.apiKeySave": "Enregistrer la clé",
"chat.apiKeyValidating": "Validation...",
"chat.apiKeyInvalid": "Clé API invalide. Veuillez vérifier et réessayer.",
"chat.apiKeyValidationFailed": "Impossible de valider la clé API.",
"chat.newChat": "Nouveau chat",
"chat.welcomeTitle": "Bienvenue dans lassistant IA",
"chat.welcomeDescription": "Je peux vous aider à gérer votre blog avec des visualisations riches. Essayez par exemple :",
@@ -731,6 +729,18 @@
"settings.ai.modelInfoOutputPrice": "Sortie",
"settings.ai.modelInfoTokens": "tokens",
"settings.ai.modelInfoPerMTok": "/MTok",
"settings.ai.mistralApiKeyLabel": "Clé API Mistral",
"settings.ai.mistralApiKeyDescription": "Votre clé API Mistral AI. Permet d'utiliser les modèles Mistral comme alternative à OpenCode.",
"settings.ai.mistralApiKeyConfigured": "Clé API Mistral configurée",
"settings.ai.changeMistralApiKey": "Modifier la clé API Mistral",
"settings.ai.titleModelLabel": "Modèle de génération de titres",
"settings.ai.titleModelDescription": "Modèle utilisé pour générer automatiquement les titres de conversation.",
"settings.ai.imageAnalysisModelLabel": "Modèle d'analyse d'images",
"settings.ai.imageAnalysisModelDescription": "Modèle utilisé pour l'analyse automatique d'images (titre, texte alternatif, légende).",
"settings.ai.providerOpenCode": "OpenCode",
"settings.ai.providerMistral": "Mistral",
"settings.ai.providerOther": "Autre",
"chat.providerKeyMissing": "Le modèle '{{model}}' nécessite une clé API {{provider}}. Configurez-la dans les paramètres.",
"settings.toast.modelCatalogRefreshed": "Catalogue mis à jour ({{count}} modèles)",
"settings.toast.modelCatalogUpToDate": "Le catalogue est déjà à jour",
"settings.toast.modelCatalogRefreshFailed": "Échec de l'actualisation du catalogue",

View File

@@ -190,13 +190,11 @@
"settings.toast.thumbnailsComplete": "Generazione miniature completata",
"settings.toast.thumbnailsFailed": "Impossibile generare le miniature",
"chat.setupTitle": "Configurazione chat IA",
"chat.apiKeyRequiredTitle": "Chiave API OpenCode Zen richiesta",
"chat.apiKeyRequiredDescription": "Inserisci la tua chiave API OpenCode per abilitare la chat IA.",
"chat.apiKeyRequiredTitle": "Chiave API richiesta",
"chat.apiKeyRequiredDescription": "Configura una chiave API nelle Impostazioni per abilitare la chat IA.",
"chat.openSettings": "Apri Impostazioni",
"chat.apiKeyPlaceholder": "Inserisci la tua chiave API...",
"chat.apiKeySave": "Salva chiave",
"chat.apiKeyValidating": "Convalida in corso...",
"chat.apiKeyInvalid": "Chiave API non valida. Controlla e riprova.",
"chat.apiKeyValidationFailed": "Impossibile convalidare la chiave API.",
"chat.newChat": "Nuova chat",
"chat.welcomeTitle": "Benvenuto nellassistente IA",
"chat.welcomeDescription": "Posso aiutarti a gestire il tuo blog con visualizzazioni interattive. Prova a chiedermi di:",
@@ -731,6 +729,18 @@
"settings.ai.modelInfoOutputPrice": "Output",
"settings.ai.modelInfoTokens": "token",
"settings.ai.modelInfoPerMTok": "/MTok",
"settings.ai.mistralApiKeyLabel": "Chiave API Mistral",
"settings.ai.mistralApiKeyDescription": "La tua chiave API Mistral AI. Abilita i modelli Mistral come alternativa a OpenCode.",
"settings.ai.mistralApiKeyConfigured": "Chiave API Mistral configurata",
"settings.ai.changeMistralApiKey": "Cambia chiave API Mistral",
"settings.ai.titleModelLabel": "Modello di generazione titoli",
"settings.ai.titleModelDescription": "Modello utilizzato per generare automaticamente i titoli delle conversazioni.",
"settings.ai.imageAnalysisModelLabel": "Modello di analisi immagini",
"settings.ai.imageAnalysisModelDescription": "Modello utilizzato per l'analisi automatica delle immagini (titolo, testo alternativo, didascalia).",
"settings.ai.providerOpenCode": "OpenCode",
"settings.ai.providerMistral": "Mistral",
"settings.ai.providerOther": "Altro",
"chat.providerKeyMissing": "Il modello '{{model}}' richiede una chiave API {{provider}}. Configurala nelle Impostazioni.",
"settings.toast.modelCatalogRefreshed": "Catalogo aggiornato ({{count}} modelli)",
"settings.toast.modelCatalogUpToDate": "Il catalogo è già aggiornato",
"settings.toast.modelCatalogRefreshFailed": "Aggiornamento del catalogo non riuscito",

View File

@@ -2,7 +2,8 @@
* ModelCatalogEngine Tests
*
* Tests the model catalog engine that fetches and caches
* model metadata from models.dev for the OpenCode provider.
* model metadata from models.dev for ALL providers.
* Three normalised tables: providers → models → modalities.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
@@ -19,12 +20,37 @@ function createSelectChain(mockData: unknown[] = []) {
return chain;
}
let selectMockData: unknown[] = [];
// Per-table mock data keyed by table name reference
let modelMockData: unknown[] = [];
let modalityMockData: unknown[] = [];
let providerMockData: unknown[] = [];
let metaMockData: unknown[] = [];
const insertedValues: unknown[] = [];
function createDrizzleMock() {
return {
select: vi.fn(() => createSelectChain(selectMockData)),
select: vi.fn(() => {
// Returns a chain whose `.from()` picks the right dataset by table reference
const chain: Record<string, unknown> = {
from: vi.fn().mockImplementation((table: unknown) => {
let data: unknown[];
if (table === modelCatalogModalities) {
data = modalityMockData;
} else if (table === modelCatalogProviders) {
data = providerMockData;
} else if (table === modelCatalogMeta) {
data = metaMockData;
} else {
data = modelMockData;
}
const inner = createSelectChain(data);
return inner;
}),
where: vi.fn().mockImplementation(() => chain),
then: (resolve: (v: unknown) => void) => Promise.resolve(modelMockData).then(resolve),
};
return chain;
}),
insert: vi.fn(() => ({
values: vi.fn((data: unknown) => {
insertedValues.push(data);
@@ -49,13 +75,19 @@ vi.mock('../../src/main/database', () => ({
}));
import { ModelCatalogEngine, DEFAULT_MAX_OUTPUT_TOKENS } from '../../src/main/engine/ModelCatalogEngine';
import { modelCatalog, modelCatalogModalities, modelCatalogProviders, modelCatalogMeta } from '../../src/main/database/schema';
// ── Sample models.dev response ──
// ── Sample models.dev response (multi-provider) ──
function sampleModelsDevResponse() {
return {
opencode: {
id: 'opencode',
name: 'OpenCode Zen',
env: ['OPENCODE_API_KEY'],
npm: '@ai-sdk/openai-compatible',
api: 'https://opencode.ai/zen/v1',
doc: 'https://opencode.ai/docs/zen',
models: {
'claude-sonnet-4-5': {
id: 'claude-sonnet-4-5',
@@ -64,6 +96,7 @@ function sampleModelsDevResponse() {
attachment: true,
reasoning: false,
tool_call: true,
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
cost: { input: 3, output: 15, cache_read: 0.3 },
limit: { context: 200000, output: 64000 },
},
@@ -74,6 +107,7 @@ function sampleModelsDevResponse() {
attachment: true,
reasoning: true,
tool_call: true,
modalities: { input: ['text', 'image'], output: ['text'] },
cost: { input: 1.07, output: 8.5, cache_read: 0.107 },
limit: { context: 400000, input: 272000, output: 128000 },
},
@@ -81,10 +115,32 @@ function sampleModelsDevResponse() {
id: 'model-no-cost',
name: 'Free Model',
family: 'free',
modalities: { input: ['text'], output: ['text'] },
limit: { context: 32000, output: 4096 },
},
},
},
mistral: {
id: 'mistral',
name: 'Mistral AI',
env: ['MISTRAL_API_KEY'],
npm: '@mistralai/mistralai',
api: 'https://api.mistral.ai/v1',
doc: 'https://docs.mistral.ai',
models: {
'mistral-large-latest': {
id: 'mistral-large-latest',
name: 'Mistral Large',
family: 'mistral',
attachment: true,
reasoning: false,
tool_call: true,
modalities: { input: ['text', 'image'], output: ['text'] },
cost: { input: 2, output: 6 },
limit: { context: 128000, output: 8192 },
},
},
},
};
}
@@ -93,53 +149,75 @@ describe('ModelCatalogEngine', () => {
beforeEach(() => {
vi.clearAllMocks();
selectMockData = [];
modelMockData = [];
modalityMockData = [];
providerMockData = [];
metaMockData = [];
insertedValues.length = 0;
engine = new ModelCatalogEngine();
});
describe('getAll', () => {
it('returns all cached model catalog entries', async () => {
selectMockData = [
it('returns all cached model catalog entries with modalities', async () => {
modelMockData = [
{
id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', family: 'claude-sonnet',
provider: 'opencode', modelId: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5',
family: 'claude-sonnet', attachment: true, reasoning: false, toolCall: true,
structuredOutput: false, temperature: false, knowledge: null,
releaseDate: null, lastUpdatedDate: null, openWeights: false,
contextWindow: 200000, maxInputTokens: null, maxOutputTokens: 64000,
inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3,
supportsAttachments: true, supportsReasoning: false, supportsToolCall: true,
inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3, cacheWritePrice: null,
interleaved: null, status: null, providerNpm: null,
},
];
modalityMockData = [
{ provider: 'opencode', modelId: 'claude-sonnet-4-5', direction: 'input', modality: 'text' },
{ provider: 'opencode', modelId: 'claude-sonnet-4-5', direction: 'input', modality: 'image' },
{ provider: 'opencode', modelId: 'claude-sonnet-4-5', direction: 'output', modality: 'text' },
];
const result = await engine.getAll();
expect(result).toHaveLength(1);
expect(result[0].id).toBe('claude-sonnet-4-5');
expect(result[0].provider).toBe('opencode');
expect(result[0].maxOutputTokens).toBe(64000);
expect(result[0].inputPrice).toBe(3);
expect(result[0].inputModalities).toEqual(['text', 'image']);
expect(result[0].outputModalities).toEqual(['text']);
});
it('returns empty array when no catalog entries exist', async () => {
selectMockData = [];
const result = await engine.getAll();
expect(result).toEqual([]);
});
});
describe('getModel', () => {
it('returns a specific model by ID', async () => {
selectMockData = [{
id: 'gpt-5', name: 'GPT 5', family: 'gpt',
it('returns a specific model by ID (cross-provider search)', async () => {
modelMockData = [{
provider: 'opencode', modelId: 'gpt-5', name: 'GPT 5', family: 'gpt',
attachment: true, reasoning: true, toolCall: true,
structuredOutput: false, temperature: false, knowledge: null,
releaseDate: null, lastUpdatedDate: null, openWeights: false,
contextWindow: 400000, maxInputTokens: 272000, maxOutputTokens: 128000,
inputPrice: 1.07, outputPrice: 8.5, cacheReadPrice: 0.107,
supportsAttachments: true, supportsReasoning: true, supportsToolCall: true,
inputPrice: 1.07, outputPrice: 8.5, cacheReadPrice: 0.107, cacheWritePrice: null,
interleaved: null, status: null, providerNpm: null,
}];
modalityMockData = [
{ provider: 'opencode', modelId: 'gpt-5', direction: 'input', modality: 'text' },
{ provider: 'opencode', modelId: 'gpt-5', direction: 'input', modality: 'image' },
];
const result = await engine.getModel('gpt-5');
expect(result).not.toBeNull();
expect(result!.name).toBe('GPT 5');
expect(result!.maxOutputTokens).toBe(128000);
expect(result!.inputModalities).toEqual(['text', 'image']);
});
it('returns null for unknown model', async () => {
selectMockData = [];
modelMockData = [];
modalityMockData = [];
const result = await engine.getModel('nonexistent');
expect(result).toBeNull();
});
@@ -147,38 +225,92 @@ describe('ModelCatalogEngine', () => {
describe('getMaxOutputTokens', () => {
it('returns output tokens from catalog when available', async () => {
selectMockData = [{
id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', family: 'claude-sonnet',
modelMockData = [{
provider: 'opencode', modelId: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5',
family: 'claude-sonnet', attachment: true, reasoning: false, toolCall: true,
structuredOutput: false, temperature: false, knowledge: null,
releaseDate: null, lastUpdatedDate: null, openWeights: false,
contextWindow: 200000, maxInputTokens: null, maxOutputTokens: 64000,
inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3,
supportsAttachments: true, supportsReasoning: false, supportsToolCall: true,
inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3, cacheWritePrice: null,
interleaved: null, status: null, providerNpm: null,
}];
modalityMockData = [];
const result = await engine.getMaxOutputTokens('claude-sonnet-4-5');
expect(result).toBe(64000);
});
it('returns DEFAULT_MAX_OUTPUT_TOKENS for uncatalogued model', async () => {
selectMockData = [];
modelMockData = [];
modalityMockData = [];
const result = await engine.getMaxOutputTokens('unknown-model');
expect(result).toBe(DEFAULT_MAX_OUTPUT_TOKENS);
});
it('returns DEFAULT_MAX_OUTPUT_TOKENS when model has null maxOutputTokens', async () => {
selectMockData = [{
id: 'weird-model', name: 'Weird', family: null,
modelMockData = [{
provider: 'opencode', modelId: 'weird-model', name: 'Weird', family: null,
attachment: false, reasoning: false, toolCall: false,
structuredOutput: false, temperature: false, knowledge: null,
releaseDate: null, lastUpdatedDate: null, openWeights: false,
contextWindow: null, maxInputTokens: null, maxOutputTokens: null,
inputPrice: null, outputPrice: null, cacheReadPrice: null,
supportsAttachments: false, supportsReasoning: false, supportsToolCall: false,
inputPrice: null, outputPrice: null, cacheReadPrice: null, cacheWritePrice: null,
interleaved: null, status: null, providerNpm: null,
}];
modalityMockData = [];
const result = await engine.getMaxOutputTokens('weird-model');
expect(result).toBe(DEFAULT_MAX_OUTPUT_TOKENS);
});
});
describe('hasInputModality', () => {
it('returns true when model has the modality', async () => {
modelMockData = [{
provider: 'opencode', modelId: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5',
family: 'claude-sonnet', attachment: true, reasoning: false, toolCall: true,
structuredOutput: false, temperature: false, knowledge: null,
releaseDate: null, lastUpdatedDate: null, openWeights: false,
contextWindow: 200000, maxInputTokens: null, maxOutputTokens: 64000,
inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3, cacheWritePrice: null,
interleaved: null, status: null, providerNpm: null,
}];
modalityMockData = [
{ provider: 'opencode', modelId: 'claude-sonnet-4-5', direction: 'input', modality: 'image' },
];
const result = await engine.hasInputModality('claude-sonnet-4-5', 'image');
expect(result).toBe(true);
});
it('returns false when model lacks the modality', async () => {
modelMockData = [{
provider: 'opencode', modelId: 'text-only', name: 'Text Only',
family: null, attachment: false, reasoning: false, toolCall: false,
structuredOutput: false, temperature: false, knowledge: null,
releaseDate: null, lastUpdatedDate: null, openWeights: false,
contextWindow: 32000, maxInputTokens: null, maxOutputTokens: 4096,
inputPrice: null, outputPrice: null, cacheReadPrice: null, cacheWritePrice: null,
interleaved: null, status: null, providerNpm: null,
}];
modalityMockData = [
{ provider: 'opencode', modelId: 'text-only', direction: 'input', modality: 'text' },
];
const result = await engine.hasInputModality('text-only', 'image');
expect(result).toBe(false);
});
it('returns false for unknown model', async () => {
modelMockData = [];
modalityMockData = [];
const result = await engine.hasInputModality('nonexistent', 'image');
expect(result).toBe(false);
});
});
describe('refresh', () => {
it('parses models.dev response and inserts models into DB', async () => {
it('parses multi-provider models.dev response and inserts all providers and models', async () => {
const mockResponse = sampleModelsDevResponse();
vi.spyOn(engine as any, 'httpGet').mockResolvedValue({
statusCode: 200,
@@ -186,13 +318,16 @@ describe('ModelCatalogEngine', () => {
headers: { etag: '"abc123"' },
});
// getMeta returns null (no existing etag)
selectMockData = [];
metaMockData = [];
const result = await engine.refresh();
expect(result.success).toBe(true);
expect(result.modelsUpdated).toBe(3);
// 3 opencode models + 1 mistral model = 4
expect(result.modelsUpdated).toBe(4);
expect(result.notModified).toBeUndefined();
// Should have inserted provider rows and model rows and modality rows
expect(insertedValues.length).toBeGreaterThan(0);
});
it('sends If-None-Match header when ETag is cached', async () => {
@@ -208,7 +343,9 @@ describe('ModelCatalogEngine', () => {
mockLocalDb.select = vi.fn(() => {
metaCallCount++;
if (metaCallCount === 1) {
return createSelectChain([{ key: 'etag', value: '"old-etag"' }]);
// getMeta('etag') → picks up model_catalog_meta table
const chain = createSelectChain([{ key: 'etag', value: '"old-etag"' }]);
return { from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue(chain), ...chain }), ...chain };
}
return createSelectChain([]);
}) as any;
@@ -231,7 +368,7 @@ describe('ModelCatalogEngine', () => {
body: 'Internal Server Error',
headers: {},
});
selectMockData = [];
metaMockData = [];
const result = await engine.refresh();
expect(result.success).toBe(false);
@@ -240,24 +377,24 @@ describe('ModelCatalogEngine', () => {
it('handles network errors gracefully', async () => {
vi.spyOn(engine as any, 'httpGet').mockRejectedValue(new Error('ECONNREFUSED'));
selectMockData = [];
metaMockData = [];
const result = await engine.refresh();
expect(result.success).toBe(false);
expect(result.error).toBe('ECONNREFUSED');
});
it('handles invalid response (missing opencode provider)', async () => {
it('handles invalid response (no providers)', async () => {
vi.spyOn(engine as any, 'httpGet').mockResolvedValue({
statusCode: 200,
body: JSON.stringify({ other_provider: { models: {} } }),
body: JSON.stringify({}),
headers: {},
});
selectMockData = [];
metaMockData = [];
const result = await engine.refresh();
expect(result.success).toBe(false);
expect(result.error).toContain('no opencode models');
expect(result.error).toContain('no providers');
});
it('handles malformed JSON gracefully', async () => {
@@ -266,7 +403,7 @@ describe('ModelCatalogEngine', () => {
body: 'not valid json {{{',
headers: {},
});
selectMockData = [];
metaMockData = [];
const result = await engine.refresh();
expect(result.success).toBe(false);

View File

@@ -0,0 +1,679 @@
/**
* OpenCodeManager Mistral Integration Tests
*
* Tests for Mistral AI as a first-class alternative provider:
* - detectProvider() for Mistral model prefixes
* - Mistral API key storage and retrieval
* - checkReady() multi-provider support
* - getAvailableModels() merge from both providers
* - getProviderConfig() helper
* - isProviderKeySet() helper
* - Vision from catalog modalities
* - validateMistralApiKey()
* - Provider-aware routing in sendOpenAIMessage()
* - generateConversationTitle() provider routing
* - analyzeMediaImage() provider-aware routing
* - analyzeTaxonomy() provider-aware guards
*/
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
// Mock dependencies before importing the class
vi.mock('../../src/main/engine/ChatEngine', () => ({
ChatEngine: class {
getSetting = vi.fn().mockResolvedValue(null);
setSetting = vi.fn().mockResolvedValue(undefined);
deleteSetting = vi.fn().mockResolvedValue(undefined);
getSelectedModel = vi.fn().mockResolvedValue('claude-sonnet-4-5');
getDefaultSystemPrompt = vi.fn().mockResolvedValue('You are a helpful assistant.');
getConversation = vi.fn();
addMessage = vi.fn();
updateConversation = vi.fn();
},
}));
vi.mock('../../src/main/engine/PostEngine', () => ({
getPostEngine: vi.fn(() => ({})),
}));
vi.mock('../../src/main/engine/MediaEngine', () => ({
getMediaEngine: vi.fn(() => ({})),
}));
vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({})),
}));
import { OpenCodeManager } from '../../src/main/engine/OpenCodeManager';
import type { ChatModel } from '../../src/main/shared/electronApi';
// Helper to create manager with mocked httpRequest
function createManager(): OpenCodeManager {
const manager = new OpenCodeManager(
{
getSetting: vi.fn().mockResolvedValue(null),
setSetting: vi.fn().mockResolvedValue(undefined),
deleteSetting: vi.fn().mockResolvedValue(undefined),
getSelectedModel: vi.fn().mockResolvedValue('claude-sonnet-4-5'),
getDefaultSystemPrompt: vi.fn().mockResolvedValue('You are a helpful assistant.'),
} as never,
{} as never,
{} as never,
{} as never,
() => null,
);
return manager;
}
// Mock Mistral models API response
function createMistralModelResponse(ids: string[]) {
return {
object: 'list',
data: ids.map(id => ({
id,
object: 'model',
created: 1772132920,
owned_by: 'mistralai',
})),
};
}
// Mock Zen models API response
function createZenModelResponse(ids: string[]) {
return {
object: 'list',
data: ids.map(id => ({
id,
object: 'model',
created: 1772132920,
owned_by: 'opencode',
})),
};
}
describe('OpenCodeManager Mistral integration', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
describe('detectProvider', () => {
it('detects mistral model prefixes', () => {
const manager = createManager();
const detect = (manager as any).detectProvider.bind(manager);
expect(detect('mistral-large-latest')).toBe('mistral');
expect(detect('mistral-medium-latest')).toBe('mistral');
expect(detect('mistral-small-latest')).toBe('mistral');
});
it('detects devstral model prefix', () => {
const manager = createManager();
const detect = (manager as any).detectProvider.bind(manager);
expect(detect('devstral-small-latest')).toBe('mistral');
expect(detect('devstral-large-latest')).toBe('mistral');
});
it('detects codestral model prefix', () => {
const manager = createManager();
const detect = (manager as any).detectProvider.bind(manager);
expect(detect('codestral-latest')).toBe('mistral');
});
it('detects pixtral model prefix', () => {
const manager = createManager();
const detect = (manager as any).detectProvider.bind(manager);
expect(detect('pixtral-large-latest')).toBe('mistral');
});
it('detects ministral model prefix', () => {
const manager = createManager();
const detect = (manager as any).detectProvider.bind(manager);
expect(detect('ministral-8b-latest')).toBe('mistral');
});
it('still detects anthropic, openai, google providers', () => {
const manager = createManager();
const detect = (manager as any).detectProvider.bind(manager);
expect(detect('claude-sonnet-4')).toBe('anthropic');
expect(detect('gpt-5')).toBe('openai');
expect(detect('gemini-3-pro')).toBe('google');
});
});
describe('Mistral API key management', () => {
it('stores and retrieves Mistral API key', () => {
const manager = createManager();
expect(manager.getMistralApiKey()).toBe('');
manager.setMistralApiKey('mist-test-key-123');
expect(manager.getMistralApiKey()).toBe('mist-test-key-123');
});
it('invalidates model cache when Mistral key changes', async () => {
const manager = createManager();
manager.setApiKey('opencode-key');
// Prime the cache
(manager as any).httpRequest = vi.fn().mockResolvedValue({
statusCode: 200,
body: JSON.stringify(createZenModelResponse(['claude-sonnet-4'])),
});
await manager.getAvailableModels();
// Set Mistral key — should clear cache
manager.setMistralApiKey('mist-key');
expect((manager as any).cachedModels).toBeNull();
});
});
describe('checkReady', () => {
it('returns ready when only OpenCode key is set', async () => {
const manager = createManager();
manager.setApiKey('opencode-key');
const result = await manager.checkReady();
expect(result.ready).toBe(true);
expect(result.providers?.opencode).toBe(true);
expect(result.providers?.mistral).toBe(false);
});
it('returns ready when only Mistral key is set', async () => {
const manager = createManager();
manager.setMistralApiKey('mistral-key');
const result = await manager.checkReady();
expect(result.ready).toBe(true);
expect(result.providers?.opencode).toBe(false);
expect(result.providers?.mistral).toBe(true);
});
it('returns ready when both keys are set', async () => {
const manager = createManager();
manager.setApiKey('opencode-key');
manager.setMistralApiKey('mistral-key');
const result = await manager.checkReady();
expect(result.ready).toBe(true);
expect(result.providers?.opencode).toBe(true);
expect(result.providers?.mistral).toBe(true);
});
it('returns not ready when no keys are set', async () => {
const manager = createManager();
const result = await manager.checkReady();
expect(result.ready).toBe(false);
expect(result.providers?.opencode).toBe(false);
expect(result.providers?.mistral).toBe(false);
});
});
describe('isProviderKeySet', () => {
it('checks OpenCode key availability', () => {
const manager = createManager();
const check = (manager as any).isProviderKeySet.bind(manager);
expect(check('opencode')).toBe(false);
expect(check('anthropic')).toBe(false);
expect(check('openai')).toBe(false);
manager.setApiKey('key');
expect(check('opencode')).toBe(true);
expect(check('anthropic')).toBe(true);
expect(check('openai')).toBe(true);
expect(check('google')).toBe(true);
expect(check('other')).toBe(true);
});
it('checks Mistral key availability', () => {
const manager = createManager();
const check = (manager as any).isProviderKeySet.bind(manager);
expect(check('mistral')).toBe(false);
manager.setMistralApiKey('key');
expect(check('mistral')).toBe(true);
});
});
describe('getProviderConfig', () => {
it('returns OpenCode config for anthropic provider', () => {
const manager = createManager();
manager.setApiKey('oc-key');
const config = (manager as any).getProviderConfig.call(manager, 'anthropic');
expect(config.apiKey).toBe('oc-key');
});
it('returns Mistral config for mistral provider', () => {
const manager = createManager();
manager.setMistralApiKey('mist-key');
const config = (manager as any).getProviderConfig.call(manager, 'mistral');
expect(config.apiKey).toBe('mist-key');
expect(config.apiUrl).toContain('mistral.ai');
expect(config.options?.parallelToolCalls).toBe(false);
});
});
describe('getAvailableModels', () => {
it('returns only OpenCode models when only OpenCode key is set', async () => {
const manager = createManager();
manager.setApiKey('oc-key');
(manager as any).httpRequest = vi.fn().mockResolvedValue({
statusCode: 200,
body: JSON.stringify(createZenModelResponse(['claude-sonnet-4', 'gpt-5'])),
});
const models = await manager.getAvailableModels();
const providers = new Set(models.map((m: ChatModel) => m.provider));
expect(providers.has('mistral')).toBe(false);
});
it('returns only Mistral models when only Mistral key is set', async () => {
const manager = createManager();
manager.setMistralApiKey('mist-key');
(manager as any).httpRequest = vi.fn().mockImplementation((url: string) => {
if (url.includes('mistral.ai')) {
return Promise.resolve({
statusCode: 200,
body: JSON.stringify(createMistralModelResponse([
'mistral-large-latest',
'mistral-small-latest',
])),
});
}
return Promise.reject(new Error('No key'));
});
const models = await manager.getAvailableModels();
expect(models.length).toBe(2);
expect(models.every((m: ChatModel) => m.provider === 'mistral')).toBe(true);
});
it('merges models from both providers when both keys are set', async () => {
const manager = createManager();
manager.setApiKey('oc-key');
manager.setMistralApiKey('mist-key');
let callCount = 0;
(manager as any).httpRequest = vi.fn().mockImplementation((url: string) => {
callCount++;
if (url.includes('mistral.ai')) {
return Promise.resolve({
statusCode: 200,
body: JSON.stringify(createMistralModelResponse([
'mistral-large-latest',
'mistral-small-latest',
])),
});
}
return Promise.resolve({
statusCode: 200,
body: JSON.stringify(createZenModelResponse(['claude-sonnet-4', 'gpt-5'])),
});
});
const models = await manager.getAvailableModels();
expect(models.length).toBe(4);
const providers = new Set(models.map((m: ChatModel) => m.provider));
expect(providers.has('anthropic')).toBe(true);
expect(providers.has('mistral')).toBe(true);
});
it('includes vision field from catalog modalities', async () => {
const manager = createManager();
manager.setMistralApiKey('mist-key');
// Mock catalog with modality data for vision resolution
(manager as any).modelCatalogEngine = {
getAll: vi.fn().mockResolvedValue([
{ id: 'mistral-large-latest', name: 'Mistral Large', inputModalities: ['text', 'image'], outputModalities: ['text'] },
{ id: 'devstral-small-latest', name: 'Devstral Small', inputModalities: ['text'], outputModalities: ['text'] },
]),
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
getContextWindow: vi.fn().mockResolvedValue(null),
};
(manager as any).httpRequest = vi.fn().mockImplementation((url: string) => {
if (url.includes('mistral.ai')) {
return Promise.resolve({
statusCode: 200,
body: JSON.stringify(createMistralModelResponse([
'mistral-large-latest',
'devstral-small-latest',
])),
});
}
return Promise.reject(new Error('No key'));
});
const models = await manager.getAvailableModels();
const large = models.find((m: ChatModel) => m.id === 'mistral-large-latest');
const devstral = models.find((m: ChatModel) => m.id === 'devstral-small-latest');
expect(large?.vision).toBe(true);
expect(devstral?.vision).toBe(false);
});
it('fallback model list filters by available provider keys', async () => {
const manager = createManager();
manager.setMistralApiKey('mist-key');
// No OpenCode key set
(manager as any).httpRequest = vi.fn().mockRejectedValue(new Error('Network error'));
(manager as any).modelCatalogEngine = {
getAll: vi.fn().mockResolvedValue([
{ id: 'mistral-large-latest', name: 'Mistral Large', inputModalities: ['text', 'image'], outputModalities: ['text'] },
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image'], outputModalities: ['text'] },
]),
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
getContextWindow: vi.fn().mockResolvedValue(null),
};
const models = await manager.getAvailableModels();
// Should only have Mistral models from fallback
const providers = new Set(models.map((m: ChatModel) => m.provider));
expect(providers.has('mistral')).toBe(true);
expect(providers.has('anthropic')).toBe(false);
expect(providers.has('openai')).toBe(false);
});
});
describe('validateMistralApiKey', () => {
it('validates a correct Mistral API key', async () => {
const manager = createManager();
(manager as any).httpRequest = vi.fn().mockResolvedValue({
statusCode: 200,
body: JSON.stringify(createMistralModelResponse(['mistral-large-latest'])),
});
const result = await manager.validateMistralApiKey('valid-key');
expect(result.isValid).toBe(true);
expect(result.models.length).toBeGreaterThan(0);
});
it('rejects an invalid Mistral API key', async () => {
const manager = createManager();
(manager as any).httpRequest = vi.fn().mockResolvedValue({
statusCode: 401,
body: '{"message":"Unauthorized"}',
});
const result = await manager.validateMistralApiKey('bad-key');
expect(result.isValid).toBe(false);
expect(result.models).toEqual([]);
});
it('rejects empty key', async () => {
const manager = createManager();
const result = await manager.validateMistralApiKey('');
expect(result.isValid).toBe(false);
});
});
describe('generateConversationTitle provider routing', () => {
it('uses Mistral API when conversation model is a Mistral model', async () => {
const manager = createManager();
manager.setMistralApiKey('mist-key');
const httpMock = vi.fn().mockResolvedValue({
statusCode: 200,
body: JSON.stringify({
choices: [{ message: { content: 'Travel Blog' } }],
}),
});
(manager as any).httpRequest = httpMock;
// Set the title model to mistral
(manager as any).chatEngine.getSetting = vi.fn().mockImplementation(async (key: string) => {
if (key === 'chat_title_model') return 'mistral-small-latest';
return null;
});
await (manager as any).generateConversationTitle('conv-1', 'Hello world', 'Response');
expect(httpMock).toHaveBeenCalled();
const callUrl = httpMock.mock.calls[0][0];
expect(callUrl).toContain('mistral.ai');
});
it('uses Anthropic API when title model is an Anthropic model', async () => {
const manager = createManager();
manager.setApiKey('oc-key');
const httpMock = vi.fn().mockResolvedValue({
statusCode: 200,
body: JSON.stringify({
content: [{ type: 'text', text: 'Travel Blog' }],
}),
});
(manager as any).httpRequest = httpMock;
// No title model set — defaults to claude-haiku-4-5
await (manager as any).generateConversationTitle('conv-1', 'Hello world', 'Response');
expect(httpMock).toHaveBeenCalled();
const callUrl = httpMock.mock.calls[0][0];
expect(callUrl).toContain('opencode.ai');
});
});
describe('analyzeTaxonomy provider-aware guards', () => {
it('returns error when model is Mistral but no Mistral key is set', async () => {
const manager = createManager();
manager.setApiKey('oc-key'); // only OpenCode key
const result = await manager.analyzeTaxonomy(
[{ name: 'Travel', slug: 'travel', existsInProject: true }],
[],
'mistral-large-latest'
);
expect(result.success).toBe(false);
expect(result.error).toContain('API key');
});
it('returns error when model is OpenCode but no OpenCode key is set', async () => {
const manager = createManager();
manager.setMistralApiKey('mist-key'); // only Mistral key
const result = await manager.analyzeTaxonomy(
[{ name: 'Travel', slug: 'travel', existsInProject: true }],
[],
'claude-sonnet-4'
);
expect(result.success).toBe(false);
expect(result.error).toContain('API key');
});
});
describe('analyzeMediaImage provider-aware routing', () => {
it('returns error when no API key is available for the configured model', async () => {
const manager = createManager();
// No keys set at all
const result = await manager.analyzeMediaImage('media-1', 'en');
expect(result.success).toBe(false);
expect(result.error).toContain('API key');
});
});
describe('setApiKey cache invalidation', () => {
it('invalidates model cache when OpenCode key changes', async () => {
const manager = createManager();
manager.setMistralApiKey('mist-key');
// Prime the cache
(manager as any).httpRequest = vi.fn().mockResolvedValue({
statusCode: 200,
body: JSON.stringify(createMistralModelResponse(['mistral-large-latest'])),
});
await manager.getAvailableModels();
expect((manager as any).cachedModels).not.toBeNull();
// Set OpenCode key — should clear cache
manager.setApiKey('oc-key');
expect((manager as any).cachedModels).toBeNull();
});
});
describe('vision from catalog modalities', () => {
it('vision flags are derived from catalog input modalities via getAvailableModels', async () => {
const manager = createManager();
manager.setMistralApiKey('mist-key');
// Mock catalog with modality data
(manager as any).modelCatalogEngine = {
getAll: vi.fn().mockResolvedValue([
{ id: 'mistral-large-latest', name: 'Mistral Large', inputModalities: ['text', 'image'], outputModalities: ['text'] },
{ id: 'mistral-medium-latest', name: 'Mistral Medium', inputModalities: ['text', 'image'], outputModalities: ['text'] },
{ id: 'mistral-small-latest', name: 'Mistral Small', inputModalities: ['text', 'image'], outputModalities: ['text'] },
{ id: 'devstral-small-latest', name: 'Devstral Small', inputModalities: ['text'], outputModalities: ['text'] },
{ id: 'devstral-large-latest', name: 'Devstral Large', inputModalities: ['text'], outputModalities: ['text'] },
]),
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
getContextWindow: vi.fn().mockResolvedValue(null),
};
(manager as any).httpRequest = vi.fn().mockImplementation((url: string) => {
if (url.includes('mistral.ai')) {
return Promise.resolve({
statusCode: 200,
body: JSON.stringify(createMistralModelResponse([
'mistral-large-latest',
'mistral-medium-latest',
'mistral-small-latest',
'devstral-small-latest',
'devstral-large-latest',
])),
});
}
return Promise.reject(new Error('No key'));
});
const models = await manager.getAvailableModels();
// Vision-capable models (image in input modalities)
expect(models.find((m: ChatModel) => m.id === 'mistral-large-latest')?.vision).toBe(true);
expect(models.find((m: ChatModel) => m.id === 'mistral-medium-latest')?.vision).toBe(true);
expect(models.find((m: ChatModel) => m.id === 'mistral-small-latest')?.vision).toBe(true);
// Non-vision models (no image in input modalities)
expect(models.find((m: ChatModel) => m.id === 'devstral-small-latest')?.vision).toBe(false);
expect(models.find((m: ChatModel) => m.id === 'devstral-large-latest')?.vision).toBe(false);
});
});
describe('generateConversationTitle smart defaults', () => {
it('falls back to mistral-small-latest when only Mistral key is set and no title model configured', async () => {
const manager = createManager();
manager.setMistralApiKey('mist-key');
// No OpenCode key set
const httpMock = vi.fn().mockResolvedValue({
statusCode: 200,
body: JSON.stringify({
choices: [{ message: { content: 'Blog Post' } }],
}),
});
(manager as any).httpRequest = httpMock;
// No title model configured (returns null)
(manager as any).chatEngine.getSetting = vi.fn().mockResolvedValue(null);
(manager as any).chatEngine.updateConversation = vi.fn();
await (manager as any).generateConversationTitle('conv-1', 'Hello world', 'Response');
expect(httpMock).toHaveBeenCalled();
const callUrl = httpMock.mock.calls[0][0];
expect(callUrl).toContain('mistral.ai');
// Verify it used mistral-small-latest
const body = JSON.parse(httpMock.mock.calls[0][1].body);
expect(body.model).toBe('mistral-small-latest');
});
it('does not generate title when no keys are set', async () => {
const manager = createManager();
// No keys at all
const httpMock = vi.fn();
(manager as any).httpRequest = httpMock;
(manager as any).chatEngine.getSetting = vi.fn().mockResolvedValue(null);
await (manager as any).generateConversationTitle('conv-1', 'Hello world', 'Response');
expect(httpMock).not.toHaveBeenCalled();
});
});
describe('analyzeMediaImage smart defaults', () => {
it('falls back to mistral-large-latest when only Mistral key is set and no image model configured', async () => {
const manager = createManager();
manager.setMistralApiKey('mist-key');
// No OpenCode key set
// Mock getSetting to return null (no configured model)
(manager as any).chatEngine.getSetting = vi.fn().mockResolvedValue(null);
// Mock mediaEngine — return a valid image
(manager as any).mediaEngine = {
getMedia: vi.fn().mockResolvedValue({ id: 'media-1', mimeType: 'image/jpeg' }),
getThumbnailDataUrl: vi.fn().mockResolvedValue('data:image/webp;base64,dGVzdA=='),
};
const httpMock = vi.fn().mockResolvedValue({
statusCode: 200,
body: JSON.stringify({
choices: [{
message: {
content: JSON.stringify({ title: 'Sunset', alt: 'A sunset', caption: 'Beautiful sunset' }),
},
}],
}),
});
(manager as any).httpRequest = httpMock;
await manager.analyzeMediaImage('media-1', 'en');
expect(httpMock).toHaveBeenCalled();
const callUrl = httpMock.mock.calls[0][0];
expect(callUrl).toContain('mistral.ai');
const body = JSON.parse(httpMock.mock.calls[0][1].body);
expect(body.model).toBe('mistral-large-latest');
});
});
describe('validateApiKey returns models from API response', () => {
it('returns models from the actual API response', async () => {
const manager = createManager();
manager.setApiKey('oc-key');
(manager as any).httpRequest = vi.fn().mockResolvedValue({
statusCode: 200,
body: JSON.stringify(createZenModelResponse(['claude-sonnet-4'])),
});
const result = await manager.validateApiKey('oc-key');
expect(result.isValid).toBe(true);
expect(result.models).toHaveLength(1);
expect(result.models[0].id).toBe('claude-sonnet-4');
expect(result.models[0].provider).toBe('anthropic');
});
});
});

View File

@@ -29,7 +29,8 @@ vi.mock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({})),
}));
import { OpenCodeManager, ModelInfo } from '../../src/main/engine/OpenCodeManager';
import { OpenCodeManager } from '../../src/main/engine/OpenCodeManager';
import type { ChatModel } from '../../src/main/shared/electronApi';
// Helper to create manager with mocked httpRequest
function createManager(): OpenCodeManager {
@@ -66,76 +67,21 @@ describe('OpenCodeManager model discovery', () => {
vi.useRealTimers();
});
describe('formatModelName', () => {
it('formats Claude model IDs with proper spacing', () => {
const manager = createManager();
const format = (manager as any).formatModelName.bind(manager);
expect(format('claude-opus-4-6')).toBe('Claude Opus 4.6');
expect(format('claude-sonnet-4-5')).toBe('Claude Sonnet 4.5');
expect(format('claude-sonnet-4')).toBe('Claude Sonnet 4');
expect(format('claude-haiku-4-5')).toBe('Claude Haiku 4.5');
expect(format('claude-3-5-haiku')).toBe('Claude 3.5 Haiku');
});
it('formats GPT model IDs with uppercase prefix', () => {
const manager = createManager();
const format = (manager as any).formatModelName.bind(manager);
expect(format('gpt-5')).toBe('GPT 5');
expect(format('gpt-5.1')).toBe('GPT 5.1');
expect(format('gpt-5.1-codex')).toBe('GPT 5.1 Codex');
expect(format('gpt-5.1-codex-max')).toBe('GPT 5.1 Codex Max');
expect(format('gpt-5.1-codex-mini')).toBe('GPT 5.1 Codex Mini');
expect(format('gpt-5-nano')).toBe('GPT 5 Nano');
expect(format('gpt-5.3-codex')).toBe('GPT 5.3 Codex');
});
it('formats GLM model IDs with uppercase prefix', () => {
const manager = createManager();
const format = (manager as any).formatModelName.bind(manager);
expect(format('glm-5')).toBe('GLM 5');
expect(format('glm-4.7')).toBe('GLM 4.7');
expect(format('glm-4.6')).toBe('GLM 4.6');
});
it('formats Gemini model IDs properly', () => {
const manager = createManager();
const format = (manager as any).formatModelName.bind(manager);
expect(format('gemini-3-pro')).toBe('Gemini 3 Pro');
expect(format('gemini-3-flash')).toBe('Gemini 3 Flash');
expect(format('gemini-3.1-pro')).toBe('Gemini 3.1 Pro');
});
it('formats free/preview suffixes', () => {
const manager = createManager();
const format = (manager as any).formatModelName.bind(manager);
expect(format('gpt-5-nano')).toBe('GPT 5 Nano');
expect(format('minimax-m2.5-free')).toBe('MiniMax M2.5 Free');
expect(format('kimi-k2.5-free')).toBe('Kimi K2.5 Free');
expect(format('trinity-large-preview-free')).toBe('Trinity Large Preview Free');
});
it('formats other provider model IDs', () => {
const manager = createManager();
const format = (manager as any).formatModelName.bind(manager);
expect(format('minimax-m2.5')).toBe('MiniMax M2.5');
expect(format('minimax-m2.1')).toBe('MiniMax M2.1');
expect(format('kimi-k2.5')).toBe('Kimi K2.5');
expect(format('kimi-k2')).toBe('Kimi K2');
expect(format('kimi-k2-thinking')).toBe('Kimi K2 Thinking');
expect(format('qwen3-coder')).toBe('Qwen3 Coder');
expect(format('big-pickle')).toBe('Big Pickle');
});
});
describe('getAvailableModels', () => {
it('returns models from API with proper names and providers', async () => {
it('returns models from API with catalog names and catalog-derived vision', async () => {
const manager = createManager();
// Mock catalog with modality data and display names
(manager as any).modelCatalogEngine = {
getAll: vi.fn().mockResolvedValue([
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image', 'pdf'], outputModalities: ['text'] },
{ id: 'gpt-5.1-codex', name: 'GPT 5.1 Codex', inputModalities: ['text'], outputModalities: ['text'] },
{ id: 'gemini-3-pro', name: 'Gemini 3 Pro', inputModalities: ['text', 'image', 'video'], outputModalities: ['text'] },
]),
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
getContextWindow: vi.fn().mockResolvedValue(null),
};
const zenResponse = createZenModelResponse([
'claude-sonnet-4',
'gpt-5.1-codex',
@@ -150,40 +96,55 @@ describe('OpenCodeManager model discovery', () => {
const models = await manager.getAvailableModels();
expect(models).toHaveLength(3);
expect(models[0]).toEqual({ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', provider: 'anthropic' });
expect(models[1]).toEqual({ id: 'gpt-5.1-codex', name: 'GPT 5.1 Codex', provider: 'openai' });
expect(models[2]).toEqual({ id: 'gemini-3-pro', name: 'Gemini 3 Pro', provider: 'google' });
expect(models[0]).toEqual({ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', provider: 'anthropic', vision: true });
expect(models[1]).toEqual({ id: 'gpt-5.1-codex', name: 'GPT 5.1 Codex', provider: 'openai', vision: false });
expect(models[2]).toEqual({ id: 'gemini-3-pro', name: 'Gemini 3 Pro', provider: 'google', vision: true });
});
it('falls back to known models when API fails', async () => {
it('falls back to model catalog when API fails', async () => {
const manager = createManager();
(manager as any).httpRequest = vi.fn().mockRejectedValue(new Error('Network error'));
(manager as any).modelCatalogEngine = {
getAll: vi.fn().mockResolvedValue([
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image'], outputModalities: ['text'] },
{ id: 'gpt-5', name: 'GPT 5', inputModalities: ['text', 'image'], outputModalities: ['text'] },
]),
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
getContextWindow: vi.fn().mockResolvedValue(null),
};
const models = await manager.getAvailableModels();
expect(models.length).toBeGreaterThan(0);
// Should include well-known models from the display name map
const ids = models.map((m: ModelInfo) => m.id);
const ids = models.map((m: ChatModel) => m.id);
expect(ids).toContain('claude-sonnet-4');
expect(ids).toContain('gpt-5');
// Every model should have proper provider detection
const claudeModel = models.find((m: ModelInfo) => m.id === 'claude-sonnet-4');
const claudeModel = models.find((m: ChatModel) => m.id === 'claude-sonnet-4');
expect(claudeModel?.provider).toBe('anthropic');
const gptModel = models.find((m: ModelInfo) => m.id === 'gpt-5');
expect(claudeModel?.name).toBe('Claude Sonnet 4');
const gptModel = models.find((m: ChatModel) => m.id === 'gpt-5');
expect(gptModel?.provider).toBe('openai');
expect(gptModel?.name).toBe('GPT 5');
});
it('falls back when API returns non-200 status', async () => {
it('falls back to model catalog when API returns non-200 status', async () => {
const manager = createManager();
(manager as any).httpRequest = vi.fn().mockResolvedValue({
statusCode: 401,
body: '{"error":"unauthorized"}',
});
(manager as any).modelCatalogEngine = {
getAll: vi.fn().mockResolvedValue([
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image'], outputModalities: ['text'] },
]),
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
getContextWindow: vi.fn().mockResolvedValue(null),
};
const models = await manager.getAvailableModels();
expect(models.length).toBeGreaterThan(0);
const ids = models.map((m: ModelInfo) => m.id);
const ids = models.map((m: ChatModel) => m.id);
expect(ids).toContain('claude-sonnet-4');
});
@@ -219,7 +180,7 @@ describe('OpenCodeManager model discovery', () => {
expect(httpRequest).toHaveBeenCalledTimes(2);
});
it('handles unknown model IDs from API with auto-formatting', async () => {
it('handles unknown model IDs from API with raw IDs as fallback names', async () => {
const manager = createManager();
const zenResponse = createZenModelResponse(['some-new-model-v3']);
@@ -231,19 +192,31 @@ describe('OpenCodeManager model discovery', () => {
const models = await manager.getAvailableModels();
expect(models).toHaveLength(1);
expect(models[0].name).toBe('Some New Model V3');
expect(models[0].name).toBe('some-new-model-v3');
expect(models[0].provider).toBe('other');
});
it('falls back to known models when no API key is set', async () => {
it('falls back to model catalog when no API key is set', async () => {
const manager = createManager();
(manager as any).apiKey = '';
manager.setMistralApiKey('test-key');
(manager as any).modelCatalogEngine = {
getAll: vi.fn().mockResolvedValue([
{ id: 'mistral-large-latest', name: 'Mistral Large', inputModalities: ['text', 'image'], outputModalities: ['text'] },
{ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image'], outputModalities: ['text'] },
]),
getMaxOutputTokens: vi.fn().mockResolvedValue(16384),
getContextWindow: vi.fn().mockResolvedValue(null),
};
const models = await manager.getAvailableModels();
// Only Mistral models will be in fallback since only Mistral key is set
expect(models.length).toBeGreaterThan(0);
const ids = models.map((m: ModelInfo) => m.id);
expect(ids).toContain('claude-sonnet-4');
const providers = new Set(models.map((m: ChatModel) => m.provider));
expect(providers.has('mistral')).toBe(true);
// OpenCode/Anthropic models should be filtered out (no OpenCode key)
expect(providers.has('anthropic')).toBe(false);
});
});
});

View File

@@ -83,6 +83,13 @@ function setupChatApi() {
onA2UIMessage: vi.fn(() => vi.fn()),
onTokenUsage: vi.fn(() => vi.fn()),
dispatchA2UIAction: vi.fn(),
validateMistralApiKey: vi.fn().mockResolvedValue({ isValid: false, models: [] }),
setMistralApiKey: vi.fn().mockResolvedValue({ success: true }),
getMistralApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-haiku-4-5' }),
setTitleModel: vi.fn().mockResolvedValue({ success: true }),
getImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-sonnet-4-5' }),
setImageAnalysisModel: vi.fn().mockResolvedValue({ success: true }),
} as never;
}

View File

@@ -39,6 +39,13 @@ describe('AssistantSidebar wiring', () => {
onA2UIMessage: vi.fn(() => vi.fn()),
onTokenUsage: vi.fn(() => vi.fn()),
dispatchA2UIAction: vi.fn(),
validateMistralApiKey: vi.fn().mockResolvedValue({ isValid: false, models: [] }),
setMistralApiKey: vi.fn().mockResolvedValue({ success: true }),
getMistralApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-haiku-4-5' }),
setTitleModel: vi.fn().mockResolvedValue({ success: true }),
getImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-sonnet-4-5' }),
setImageAnalysisModel: vi.fn().mockResolvedValue({ success: true }),
} as never;
});

View File

@@ -45,6 +45,10 @@ describe('SettingsView i18n', () => {
getSystemPrompt: vi.fn().mockResolvedValue({ success: true, prompt: '' }),
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }),
getMistralApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-haiku-4-5' }),
getImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-sonnet-4-5' }),
getModelCatalog: vi.fn().mockResolvedValue({ success: true, entries: [] }),
},
templates: {
...(window as Window & { electronAPI: any }).electronAPI?.templates,

View File

@@ -35,6 +35,10 @@ describe('MCPAgentButton uninstall', () => {
getSystemPrompt: vi.fn().mockResolvedValue({ success: true, prompt: '' }),
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }),
getMistralApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-haiku-4-5' }),
getImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-sonnet-4-5' }),
getModelCatalog: vi.fn().mockResolvedValue({ success: true, entries: [] }),
},
templates: { getEnabledByKind: vi.fn().mockResolvedValue([]) },
projects: { update: vi.fn().mockResolvedValue({}) },

View File

@@ -37,6 +37,13 @@ describe('assistant sidebar guard rails', () => {
onA2UIMessage: vi.fn(() => vi.fn()),
onTokenUsage: vi.fn(() => vi.fn()),
dispatchA2UIAction: vi.fn(),
validateMistralApiKey: vi.fn().mockResolvedValue({ isValid: false, models: [] }),
setMistralApiKey: vi.fn().mockResolvedValue({ success: true }),
getMistralApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-haiku-4-5' }),
setTitleModel: vi.fn().mockResolvedValue({ success: true }),
getImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-sonnet-4-5' }),
setImageAnalysisModel: vi.fn().mockResolvedValue({ success: true }),
} as never;
});

View File

@@ -48,6 +48,13 @@ describe('chat surface mode usage guards', () => {
onA2UIMessage: vi.fn(() => vi.fn()),
onTokenUsage: vi.fn(() => vi.fn()),
dispatchA2UIAction: vi.fn(),
validateMistralApiKey: vi.fn().mockResolvedValue({ isValid: false, models: [] }),
setMistralApiKey: vi.fn().mockResolvedValue({ success: true }),
getMistralApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-haiku-4-5' }),
setTitleModel: vi.fn().mockResolvedValue({ success: true }),
getImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-sonnet-4-5' }),
setImageAnalysisModel: vi.fn().mockResolvedValue({ success: true }),
} as never;
});

View File

@@ -51,6 +51,13 @@ describe('chat surface shared usage guards', () => {
onA2UIMessage: vi.fn(() => vi.fn()),
onTokenUsage: vi.fn(() => vi.fn()),
dispatchA2UIAction: vi.fn(),
validateMistralApiKey: vi.fn().mockResolvedValue({ isValid: false, models: [] }),
setMistralApiKey: vi.fn().mockResolvedValue({ success: true }),
getMistralApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
getTitleModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-haiku-4-5' }),
setTitleModel: vi.fn().mockResolvedValue({ success: true }),
getImageAnalysisModel: vi.fn().mockResolvedValue({ success: true, modelId: 'claude-sonnet-4-5' }),
setImageAnalysisModel: vi.fn().mockResolvedValue({ success: true }),
} as never;
});