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:
@@ -9,6 +9,7 @@ This document provides context and best practices for GitHub Copilot when workin
|
|||||||
|
|
||||||
## Commits
|
## Commits
|
||||||
|
|
||||||
|
- our default branch is origin/master
|
||||||
- commit messages are short - one sentence. do not write long articles.
|
- commit messages are short - one sentence. do not write long articles.
|
||||||
- pull requests are more verbose and especially give reasoning for changes
|
- pull requests are more verbose and especially give reasoning for changes
|
||||||
|
|
||||||
|
|||||||
641
MISTRAL_PLAN.md
641
MISTRAL_PLAN.md
@@ -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 (M1–M6) is implemented in PR 1, before the Mistral PR.** The Mistral PR (PR 3) inherits streaming support and only needs to pass the correct URL/key/options to the already-streaming `sendOpenAIMessage()`.
|
|
||||||
|
|
||||||
Currently `httpRequest()` buffers the entire response body before any text reaches the UI. Users wait 5–30s per API round with only a bouncing-dots indicator. All three providers (Anthropic, OpenAI, Mistral) support `stream: true` with SSE.
|
|
||||||
|
|
||||||
**M1. Core streaming infrastructure — `httpRequestStream()`**
|
|
||||||
- New method (~100 lines) — uses Node.js `https.request()` but reads `res` as a readable stream
|
|
||||||
- Returns an async iterable of parsed SSE events (or accepts an `onEvent` callback)
|
|
||||||
- SSE line protocol: lines separated by `\n\n`, each line prefixed with `event: ` or `data: `
|
|
||||||
- Must handle:
|
|
||||||
- Buffering partial lines across `data` chunks (TCP may split mid-line)
|
|
||||||
- Empty `data:` lines (keep-alive pings)
|
|
||||||
- `data: [DONE]` sentinel — terminates the stream for OpenAI/Mistral (do NOT try to JSON.parse this)
|
|
||||||
- Multiple `data:` lines between double-newlines (concatenate per SSE spec)
|
|
||||||
- Supports `AbortSignal` — calls `req.destroy()` to terminate immediately
|
|
||||||
- 120-second timeout matching existing `httpRequest()`
|
|
||||||
- On non-2xx status: collect the error body (not streamed) and throw with parsed error message
|
|
||||||
|
|
||||||
**M2. SSE parser for OpenAI/Mistral format** (~50 lines)
|
|
||||||
OpenAI and Mistral use identical SSE event structure:
|
|
||||||
```
|
|
||||||
data: {"id":"...","choices":[{"delta":{"content":"Hello"}}]}
|
|
||||||
data: {"id":"...","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_...","function":{"name":"search_posts","arguments":""}}]}}]}
|
|
||||||
data: {"id":"...","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"query\""}}]}}]}
|
|
||||||
...
|
|
||||||
data: {"id":"...","usage":{"prompt_tokens":150,"completion_tokens":42,"total_tokens":192}}
|
|
||||||
data: [DONE]
|
|
||||||
```
|
|
||||||
- **Text deltas**: `choices[0].delta.content` — emit via `onDelta(content)` immediately
|
|
||||||
- **Tool call start**: `delta.tool_calls[i]` with `id` + `function.name` — begin accumulating arguments for tool call at index `i`
|
|
||||||
- **Tool call argument fragments**: `delta.tool_calls[i].function.arguments` — append to argument accumulator string for index `i`
|
|
||||||
- **Finish reason**: `choices[0].finish_reason === 'tool_calls'` or `'stop'` — signals end of this chunk
|
|
||||||
- **Token usage**: arrives in the **final chunk before `[DONE]`** only if `stream_options: { include_usage: true }` is set in the request body — parse `usage.prompt_tokens`, `usage.completion_tokens`, `usage.total_tokens`
|
|
||||||
- **`[DONE]` sentinel**: stop iteration, do NOT JSON.parse
|
|
||||||
- After stream ends: if tool calls were accumulated, JSON.parse each tool's assembled arguments string and execute
|
|
||||||
|
|
||||||
**M3. SSE parser for Anthropic format** (~60 lines)
|
|
||||||
Anthropic uses named event types:
|
|
||||||
```
|
|
||||||
event: message_start
|
|
||||||
data: {"type":"message_start","message":{"id":"...","model":"...","usage":{"input_tokens":150}}}
|
|
||||||
|
|
||||||
event: content_block_start
|
|
||||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}
|
|
||||||
|
|
||||||
event: content_block_delta
|
|
||||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}
|
|
||||||
|
|
||||||
event: content_block_start
|
|
||||||
data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_...","name":"search_posts"}}
|
|
||||||
|
|
||||||
event: content_block_delta
|
|
||||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"query\""}}
|
|
||||||
|
|
||||||
event: content_block_stop
|
|
||||||
data: {"type":"content_block_stop","index":1}
|
|
||||||
|
|
||||||
event: message_delta
|
|
||||||
data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":42}}
|
|
||||||
|
|
||||||
event: message_stop
|
|
||||||
data: {"type":"message_stop"}
|
|
||||||
```
|
|
||||||
- **`message_start`**: extract `usage.input_tokens` (prompt tokens) + `usage.cache_read_input_tokens` + `usage.cache_creation_input_tokens`
|
|
||||||
- **`content_block_start`** with `type: 'text'`: no-op (empty initial text)
|
|
||||||
- **`content_block_start`** with `type: 'tool_use'`: record tool call `id` and `name` at block index
|
|
||||||
- **`content_block_delta`** with `type: 'text_delta'`: emit via `onDelta(delta.text)` immediately
|
|
||||||
- **`content_block_delta`** with `type: 'input_json_delta'`: append `delta.partial_json` to argument accumulator
|
|
||||||
- **`content_block_stop`**: if tool block, JSON.parse the accumulated arguments for that block
|
|
||||||
- **`message_delta`**: extract `usage.output_tokens` (completion tokens), `delta.stop_reason`
|
|
||||||
- **`message_stop`**: stream complete
|
|
||||||
- **`ping`**: ignore (keep-alive)
|
|
||||||
- **`error`**: throw with `data.error.message` — handles mid-stream server errors (e.g. overloaded)
|
|
||||||
|
|
||||||
**M4. Request body changes**
|
|
||||||
- `sendAnthropicMessage()`: add `"stream": true` to request body
|
|
||||||
- `sendOpenAIMessage()` (used for both OpenCode OpenAI and Mistral): add `"stream": true` and `"stream_options": { "include_usage": true }` to request body — this is **required** to receive token usage in streaming mode (without it, usage is omitted from streamed responses)
|
|
||||||
|
|
||||||
**M5. Tool call accumulation during streaming**
|
|
||||||
- Tool call arguments arrive as partial JSON fragments across many SSE events
|
|
||||||
- Maintain a per-stream accumulator: `Map<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
|
|
||||||
53
drizzle/0009_model_catalog_v2.sql
Normal file
53
drizzle/0009_model_catalog_v2.sql
Normal 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`;
|
||||||
1432
drizzle/meta/0009_snapshot.json
Normal file
1432
drizzle/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,13 @@
|
|||||||
"when": 1772369331600,
|
"when": 1772369331600,
|
||||||
"tag": "0008_third_cable",
|
"tag": "0008_third_cable",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 9,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1772380619098,
|
||||||
|
"tag": "0009_model_catalog_v2",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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
|
// Projects table - stores blog projects/websites
|
||||||
export const projects = sqliteTable('projects', {
|
export const projects = sqliteTable('projects', {
|
||||||
@@ -206,27 +206,64 @@ export const dbNotifications = sqliteTable('db_notifications', {
|
|||||||
createdAt: integer('created_at').notNull(),
|
createdAt: integer('created_at').notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Model catalog table - cached model metadata from models.dev API
|
// ── Model Catalog ──
|
||||||
// Stores per-model data (limits, pricing, capabilities) for the OpenCode provider.
|
// Normalised tables from models.dev API.
|
||||||
// Refreshed on user action via conditional GET (ETag). Survives offline use.
|
// 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')
|
// Provider table — one row per models.dev top-level provider
|
||||||
name: text('name').notNull(), // display name
|
export const modelCatalogProviders = sqliteTable('ai_providers', {
|
||||||
family: text('family'), // model family (e.g. 'claude-sonnet')
|
id: text('id').primaryKey(), // provider key (e.g. 'opencode', 'mistral')
|
||||||
contextWindow: integer('context_window'), // max context tokens
|
name: text('name').notNull(), // display name (e.g. 'OpenCode Zen')
|
||||||
maxInputTokens: integer('max_input_tokens'), // max input tokens (null = same as context)
|
env: text('env'), // JSON array of env var names
|
||||||
maxOutputTokens: integer('max_output_tokens'), // max output tokens
|
npm: text('npm'), // primary npm package
|
||||||
inputPrice: real('input_price'), // cost per 1M input tokens (USD)
|
api: text('api'), // API base URL
|
||||||
outputPrice: real('output_price'), // cost per 1M output tokens (USD)
|
doc: text('doc'), // documentation URL
|
||||||
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),
|
|
||||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Model catalog HTTP cache metadata (ETag for conditional GET)
|
// Model table — one row per (provider, modelId) pair
|
||||||
export const modelCatalogMeta = sqliteTable('model_catalog_meta', {
|
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'
|
key: text('key').primaryKey(), // 'etag' | 'lastFetchedAt'
|
||||||
value: text('value').notNull(),
|
value: text('value').notNull(),
|
||||||
});
|
});
|
||||||
@@ -260,7 +297,11 @@ export type Template = typeof templates.$inferSelect;
|
|||||||
export type NewTemplate = typeof templates.$inferInsert;
|
export type NewTemplate = typeof templates.$inferInsert;
|
||||||
export type DbNotification = typeof dbNotifications.$inferSelect;
|
export type DbNotification = typeof dbNotifications.$inferSelect;
|
||||||
export type NewDbNotification = typeof dbNotifications.$inferInsert;
|
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 ModelCatalogEntry = typeof modelCatalog.$inferSelect;
|
||||||
export type NewModelCatalogEntry = typeof modelCatalog.$inferInsert;
|
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 ModelCatalogMetaEntry = typeof modelCatalogMeta.$inferSelect;
|
||||||
export type NewModelCatalogMetaEntry = typeof modelCatalogMeta.$inferInsert;
|
export type NewModelCatalogMetaEntry = typeof modelCatalogMeta.$inferInsert;
|
||||||
|
|||||||
@@ -1,41 +1,65 @@
|
|||||||
/**
|
/**
|
||||||
* ModelCatalogEngine — Fetches and caches model metadata from models.dev
|
* ModelCatalogEngine — Fetches and caches model metadata from models.dev
|
||||||
*
|
*
|
||||||
* Provides model output token limits, pricing info, and capabilities
|
* The full catalog is stored in three normalised SQLite tables:
|
||||||
* for all models available through the OpenCode Zen gateway.
|
* 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)
|
* Data is refreshed on user action via conditional GET (ETag).
|
||||||
* and refreshed on user action via conditional GET (ETag).
|
|
||||||
* Works fully offline after first successful fetch.
|
* Works fully offline after first successful fetch.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import https from 'https';
|
import https from 'https';
|
||||||
import http from 'http';
|
import http from 'http';
|
||||||
import { URL } from 'url';
|
import { URL } from 'url';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, and } from 'drizzle-orm';
|
||||||
import { getDatabase } from '../database';
|
import { getDatabase } from '../database';
|
||||||
import { modelCatalog, modelCatalogMeta } from '../database/schema';
|
import { modelCatalog, modelCatalogMeta, modelCatalogProviders, modelCatalogModalities } from '../database/schema';
|
||||||
import type { ModelCatalogEntry } from '../database/schema';
|
import type { ModelCatalogEntry } from '../database/schema';
|
||||||
|
|
||||||
const MODELS_DEV_URL = 'https://models.dev/api.json';
|
const MODELS_DEV_URL = 'https://models.dev/api.json';
|
||||||
const PROVIDER_KEY = 'opencode';
|
|
||||||
|
|
||||||
// Default max output tokens when no catalog data is available
|
// Default max output tokens when no catalog data is available
|
||||||
export const DEFAULT_MAX_OUTPUT_TOKENS = 16384;
|
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 {
|
export interface ModelCatalogInfo {
|
||||||
|
provider: string;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
family: string | null;
|
family: string | null;
|
||||||
contextWindow: number | null;
|
attachment: boolean;
|
||||||
maxInputTokens: number | null;
|
reasoning: boolean;
|
||||||
maxOutputTokens: number | null;
|
toolCall: boolean;
|
||||||
|
structuredOutput: boolean;
|
||||||
|
temperature: boolean;
|
||||||
|
knowledge: string | null;
|
||||||
|
releaseDate: string | null;
|
||||||
|
lastUpdatedDate: string | null;
|
||||||
|
openWeights: boolean;
|
||||||
inputPrice: number | null;
|
inputPrice: number | null;
|
||||||
outputPrice: number | null;
|
outputPrice: number | null;
|
||||||
cacheReadPrice: number | null;
|
cacheReadPrice: number | null;
|
||||||
supportsAttachments: boolean | null;
|
cacheWritePrice: number | null;
|
||||||
supportsReasoning: boolean | null;
|
contextWindow: number | null;
|
||||||
supportsToolCall: boolean | null;
|
maxInputTokens: number | null;
|
||||||
|
maxOutputTokens: number | null;
|
||||||
|
interleaved: string | null;
|
||||||
|
status: string | null;
|
||||||
|
providerNpm: string | null;
|
||||||
|
inputModalities: string[];
|
||||||
|
outputModalities: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RefreshResult {
|
export interface RefreshResult {
|
||||||
@@ -58,30 +82,87 @@ export class ModelCatalogEngine {
|
|||||||
async getAll(): Promise<ModelCatalogInfo[]> {
|
async getAll(): Promise<ModelCatalogInfo[]> {
|
||||||
const db = getDatabase().getLocal();
|
const db = getDatabase().getLocal();
|
||||||
const rows = await db.select().from(modelCatalog);
|
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 db = getDatabase().getLocal();
|
||||||
const rows = await db.select().from(modelCatalog).where(eq(modelCatalog.id, modelId));
|
const rows = await db.select().from(modelCatalog).where(eq(modelCatalog.provider, provider));
|
||||||
return rows.length > 0 ? toInfo(rows[0]) : null;
|
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).
|
* 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.
|
* Returns DEFAULT_MAX_OUTPUT_TOKENS if the model is not in the catalog.
|
||||||
*/
|
*/
|
||||||
async getMaxOutputTokens(modelId: string): Promise<number> {
|
async getMaxOutputTokens(modelId: string, provider?: string): Promise<number> {
|
||||||
const model = await this.getModel(modelId);
|
const model = await this.getModel(modelId, provider);
|
||||||
return model?.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS;
|
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).
|
* 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> {
|
async refresh(): Promise<RefreshResult> {
|
||||||
try {
|
try {
|
||||||
@@ -109,9 +190,16 @@ export class ModelCatalogEngine {
|
|||||||
|
|
||||||
// Parse response
|
// Parse response
|
||||||
const data = JSON.parse(response.body);
|
const data = JSON.parse(response.body);
|
||||||
const models = data?.[PROVIDER_KEY]?.models;
|
if (!data || typeof data !== 'object') {
|
||||||
if (!models || typeof models !== 'object') {
|
return { success: false, modelsUpdated: 0, error: 'Invalid response: not an object' };
|
||||||
return { success: false, modelsUpdated: 0, error: 'Invalid response: no opencode models found' };
|
}
|
||||||
|
|
||||||
|
// 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
|
// Store new ETag
|
||||||
@@ -121,10 +209,18 @@ export class ModelCatalogEngine {
|
|||||||
}
|
}
|
||||||
await this.setMeta('lastFetchedAt', new Date().toISOString());
|
await this.setMeta('lastFetchedAt', new Date().toISOString());
|
||||||
|
|
||||||
// Upsert all models
|
// Upsert all providers and their models
|
||||||
const count = await this.upsertModels(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) {
|
} catch (error) {
|
||||||
return { success: false, modelsUpdated: 0, error: (error as Error).message };
|
return { success: false, modelsUpdated: 0, error: (error as Error).message };
|
||||||
}
|
}
|
||||||
@@ -140,10 +236,42 @@ export class ModelCatalogEngine {
|
|||||||
// ── Internal ──
|
// ── 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
|
// 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 db = getDatabase().getLocal();
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
let count = 0;
|
let count = 0;
|
||||||
@@ -152,40 +280,87 @@ export class ModelCatalogEngine {
|
|||||||
if (!info || typeof info !== 'object') continue;
|
if (!info || typeof info !== 'object') continue;
|
||||||
|
|
||||||
const entry = {
|
const entry = {
|
||||||
id,
|
provider: providerId,
|
||||||
|
modelId: id,
|
||||||
name: info.name || id,
|
name: info.name || id,
|
||||||
family: info.family || null,
|
family: info.family || null,
|
||||||
contextWindow: info.limit?.context ?? null,
|
attachment: info.attachment ?? false,
|
||||||
maxInputTokens: info.limit?.input ?? null,
|
reasoning: info.reasoning ?? false,
|
||||||
maxOutputTokens: info.limit?.output ?? null,
|
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,
|
inputPrice: info.cost?.input ?? null,
|
||||||
outputPrice: info.cost?.output ?? null,
|
outputPrice: info.cost?.output ?? null,
|
||||||
cacheReadPrice: info.cost?.cache_read ?? null,
|
cacheReadPrice: info.cost?.cache_read ?? null,
|
||||||
supportsAttachments: info.attachment ?? false,
|
cacheWritePrice: info.cost?.cache_write ?? null,
|
||||||
supportsReasoning: info.reasoning ?? false,
|
contextWindow: info.limit?.context ?? null,
|
||||||
supportsToolCall: info.tool_call ?? false,
|
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,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
await db.insert(modelCatalog)
|
await db.insert(modelCatalog)
|
||||||
.values(entry)
|
.values(entry)
|
||||||
.onConflictDoUpdate({
|
.onConflictDoUpdate({
|
||||||
target: modelCatalog.id,
|
target: [modelCatalog.provider, modelCatalog.modelId],
|
||||||
set: {
|
set: {
|
||||||
name: entry.name,
|
name: entry.name,
|
||||||
family: entry.family,
|
family: entry.family,
|
||||||
contextWindow: entry.contextWindow,
|
attachment: entry.attachment,
|
||||||
maxInputTokens: entry.maxInputTokens,
|
reasoning: entry.reasoning,
|
||||||
maxOutputTokens: entry.maxOutputTokens,
|
toolCall: entry.toolCall,
|
||||||
|
structuredOutput: entry.structuredOutput,
|
||||||
|
temperature: entry.temperature,
|
||||||
|
knowledge: entry.knowledge,
|
||||||
|
releaseDate: entry.releaseDate,
|
||||||
|
lastUpdatedDate: entry.lastUpdatedDate,
|
||||||
|
openWeights: entry.openWeights,
|
||||||
inputPrice: entry.inputPrice,
|
inputPrice: entry.inputPrice,
|
||||||
outputPrice: entry.outputPrice,
|
outputPrice: entry.outputPrice,
|
||||||
cacheReadPrice: entry.cacheReadPrice,
|
cacheReadPrice: entry.cacheReadPrice,
|
||||||
supportsAttachments: entry.supportsAttachments,
|
cacheWritePrice: entry.cacheWritePrice,
|
||||||
supportsReasoning: entry.supportsReasoning,
|
contextWindow: entry.contextWindow,
|
||||||
supportsToolCall: entry.supportsToolCall,
|
maxInputTokens: entry.maxInputTokens,
|
||||||
|
maxOutputTokens: entry.maxOutputTokens,
|
||||||
|
interleaved: entry.interleaved,
|
||||||
|
status: entry.status,
|
||||||
|
providerNpm: entry.providerNpm,
|
||||||
updatedAt: now,
|
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++;
|
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 {
|
return {
|
||||||
id: row.id,
|
provider: row.provider,
|
||||||
|
id: row.modelId,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
family: row.family,
|
family: row.family,
|
||||||
contextWindow: row.contextWindow,
|
attachment: row.attachment ?? false,
|
||||||
maxInputTokens: row.maxInputTokens,
|
reasoning: row.reasoning ?? false,
|
||||||
maxOutputTokens: row.maxOutputTokens,
|
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,
|
inputPrice: row.inputPrice,
|
||||||
outputPrice: row.outputPrice,
|
outputPrice: row.outputPrice,
|
||||||
cacheReadPrice: row.cacheReadPrice,
|
cacheReadPrice: row.cacheReadPrice,
|
||||||
supportsAttachments: row.supportsAttachments,
|
cacheWritePrice: row.cacheWritePrice ?? null,
|
||||||
supportsReasoning: row.supportsReasoning,
|
contextWindow: row.contextWindow,
|
||||||
supportsToolCall: row.supportsToolCall,
|
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),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,67 +27,19 @@ import type { PostMediaEngine } from './PostMediaEngine';
|
|||||||
import { ModelCatalogEngine, DEFAULT_MAX_OUTPUT_TOKENS } from './ModelCatalogEngine';
|
import { ModelCatalogEngine, DEFAULT_MAX_OUTPUT_TOKENS } from './ModelCatalogEngine';
|
||||||
import { isRenderTool, generateFromToolCall } from '../a2ui/generator';
|
import { isRenderTool, generateFromToolCall } from '../a2ui/generator';
|
||||||
import type { A2UIServerMessage } from '../a2ui/types';
|
import type { A2UIServerMessage } from '../a2ui/types';
|
||||||
|
import type { ChatModel } from '../shared/electronApi';
|
||||||
|
|
||||||
// OpenCode Zen API endpoints
|
// OpenCode Zen API endpoints
|
||||||
const ZEN_ANTHROPIC_URL = 'https://opencode.ai/zen/v1/messages';
|
const ZEN_ANTHROPIC_URL = 'https://opencode.ai/zen/v1/messages';
|
||||||
const ZEN_OPENAI_URL = 'https://opencode.ai/zen/v1/chat/completions';
|
const ZEN_OPENAI_URL = 'https://opencode.ai/zen/v1/chat/completions';
|
||||||
const ZEN_MODELS_URL = 'https://opencode.ai/zen/v1/models';
|
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
|
// Mistral API endpoints
|
||||||
const MODEL_DISPLAY_NAMES: Record<string, string> = {
|
const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions';
|
||||||
// Anthropic Claude
|
const MISTRAL_MODELS_URL = 'https://api.mistral.ai/v1/models';
|
||||||
'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',
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// 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 {
|
export interface SendMessageOptions {
|
||||||
metadata?: {
|
metadata?: {
|
||||||
surface?: 'tab' | 'sidebar';
|
surface?: 'tab' | 'sidebar';
|
||||||
@@ -171,8 +123,9 @@ export class OpenCodeManager {
|
|||||||
private postMediaEngine: PostMediaEngine;
|
private postMediaEngine: PostMediaEngine;
|
||||||
private getMainWindow: () => BrowserWindow | null;
|
private getMainWindow: () => BrowserWindow | null;
|
||||||
private apiKey: string = '';
|
private apiKey: string = '';
|
||||||
|
private mistralApiKey: string = '';
|
||||||
private abortControllers: Map<string, AbortController> = new Map();
|
private abortControllers: Map<string, AbortController> = new Map();
|
||||||
private cachedModels: ModelInfo[] | null = null;
|
private cachedModels: ChatModel[] | null = null;
|
||||||
private cachedModelsAt: number = 0;
|
private cachedModelsAt: number = 0;
|
||||||
private static MODEL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
private static MODEL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
|
||||||
private modelCatalogEngine = new ModelCatalogEngine();
|
private modelCatalogEngine = new ModelCatalogEngine();
|
||||||
@@ -202,6 +155,9 @@ export class OpenCodeManager {
|
|||||||
*/
|
*/
|
||||||
setApiKey(key: string): void {
|
setApiKey(key: string): void {
|
||||||
this.apiKey = key;
|
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 }> {
|
setMistralApiKey(key: string): void {
|
||||||
if (!this.apiKey) {
|
this.mistralApiKey = key;
|
||||||
return { ready: false, error: 'API key not configured' };
|
// Invalidate model cache so merged list is re-fetched
|
||||||
}
|
this.cachedModels = null;
|
||||||
return { ready: true };
|
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) {
|
if (!apiKey || apiKey.length < 3) {
|
||||||
return { isValid: false, models: [] };
|
return { isValid: false, models: [] };
|
||||||
}
|
}
|
||||||
@@ -235,6 +212,8 @@ export class OpenCodeManager {
|
|||||||
{ 'x-api-key': apiKey },
|
{ 'x-api-key': apiKey },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups();
|
||||||
|
|
||||||
for (const headers of attempts) {
|
for (const headers of attempts) {
|
||||||
try {
|
try {
|
||||||
const response = await this.httpRequest(ZEN_MODELS_URL, {
|
const response = await this.httpRequest(ZEN_MODELS_URL, {
|
||||||
@@ -242,7 +221,16 @@ export class OpenCodeManager {
|
|||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
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 {
|
} catch {
|
||||||
// Try next auth method
|
// 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
|
// Return cached models if within TTL
|
||||||
if (this.cachedModels && Date.now() - this.cachedModelsAt < OpenCodeManager.MODEL_CACHE_TTL) {
|
if (this.cachedModels && Date.now() - this.cachedModelsAt < OpenCodeManager.MODEL_CACHE_TTL) {
|
||||||
return this.cachedModels;
|
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) {
|
if (this.apiKey) {
|
||||||
try {
|
try {
|
||||||
const response = await this.httpRequest(ZEN_MODELS_URL, {
|
const response = await this.httpRequest(ZEN_MODELS_URL, {
|
||||||
@@ -274,14 +308,15 @@ export class OpenCodeManager {
|
|||||||
if (response.statusCode === 200) {
|
if (response.statusCode === 200) {
|
||||||
const data = JSON.parse(response.body);
|
const data = JSON.parse(response.body);
|
||||||
if (data.data && Array.isArray(data.data)) {
|
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,
|
id: m.id,
|
||||||
name: this.formatModelName(m.id),
|
name: this.resolveName(m.id, catalogNames),
|
||||||
provider: this.detectProvider(m.id),
|
provider: this.detectProvider(m.id),
|
||||||
}));
|
vision: this.resolveVision(m.id, catalogVision),
|
||||||
this.cachedModels = models;
|
});
|
||||||
this.cachedModelsAt = Date.now();
|
}
|
||||||
return models;
|
fetched = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -289,13 +324,59 @@ export class OpenCodeManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build fallback from display name map
|
// Fetch Mistral models
|
||||||
const fallback = Object.entries(MODEL_DISPLAY_NAMES).map(([id, name]) => ({
|
if (this.mistralApiKey) {
|
||||||
id,
|
try {
|
||||||
name,
|
const response = await this.httpRequest(MISTRAL_MODELS_URL, {
|
||||||
provider: this.detectProvider(id),
|
method: 'GET',
|
||||||
}));
|
headers: {
|
||||||
return fallback;
|
'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 modelId = conversation.model || 'claude-sonnet-4';
|
||||||
const provider = this.detectProvider(modelId);
|
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
|
// Get system prompt
|
||||||
const systemMessage = conversation.messages.find(m => m.role === 'system');
|
const systemMessage = conversation.messages.find(m => m.role === 'system');
|
||||||
const basePrompt = systemMessage?.content || await this.chatEngine.getDefaultSystemPrompt();
|
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(
|
return this.sendOpenAIMessage(
|
||||||
modelId,
|
modelId,
|
||||||
prompt,
|
prompt,
|
||||||
@@ -395,6 +484,9 @@ export class OpenCodeManager {
|
|||||||
{ onDelta, onToolCall, onToolResult, onTokenUsage },
|
{ onDelta, onToolCall, onToolResult, onTokenUsage },
|
||||||
conversationId,
|
conversationId,
|
||||||
emitA2UIMessages,
|
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(
|
private async sendOpenAIMessage(
|
||||||
modelId: string,
|
modelId: string,
|
||||||
systemPrompt: string,
|
systemPrompt: string,
|
||||||
dbMessages: Array<{ role: string; content?: string }>,
|
dbMessages: Array<{ role: string; content?: string; toolCalls?: string; toolCallId?: string }>,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
callbacks: {
|
callbacks: {
|
||||||
onDelta?: (delta: string) => void;
|
onDelta?: (delta: string) => void;
|
||||||
@@ -750,16 +843,22 @@ export class OpenCodeManager {
|
|||||||
},
|
},
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
emitA2UIMessages: (messages: A2UIServerMessage[]) => void,
|
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 }> }> {
|
): 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>> = [
|
const allMessages: Array<Record<string, unknown>> = [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
...dbMessages
|
...dbMessages
|
||||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||||
.map(m => ({
|
.map(m => {
|
||||||
role: m.role,
|
let content = m.content || '';
|
||||||
content: m.content || '',
|
if (m.role === 'assistant') {
|
||||||
})),
|
content += this.buildToolCallSummary(m.toolCalls);
|
||||||
|
}
|
||||||
|
return { role: m.role, content };
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
// Build OpenAI tools format
|
// Build OpenAI tools format
|
||||||
@@ -775,12 +874,13 @@ export class OpenCodeManager {
|
|||||||
|
|
||||||
// Truncate conversation history to fit within context window
|
// Truncate conversation history to fit within context window
|
||||||
// Keep system message (index 0), truncate from oldest conversation messages
|
// Keep system message (index 0), truncate from oldest conversation messages
|
||||||
|
const contextBudget = (await this.modelCatalogEngine.getContextWindow(modelId)) ?? 150000;
|
||||||
const conversationMessages = allMessages.slice(1);
|
const conversationMessages = allMessages.slice(1);
|
||||||
const anthropicFmt = conversationMessages.map(m => ({
|
const anthropicFmt = conversationMessages.map(m => ({
|
||||||
role: m.role as 'user' | 'assistant',
|
role: m.role as 'user' | 'assistant',
|
||||||
content: (m.content as string) || '',
|
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>> = [
|
const messages: Array<Record<string, unknown>> = [
|
||||||
allMessages[0],
|
allMessages[0],
|
||||||
...truncated.map(m => ({ role: m.role, content: m.content })),
|
...truncated.map(m => ({ role: m.role, content: m.content })),
|
||||||
@@ -804,14 +904,19 @@ export class OpenCodeManager {
|
|||||||
stream_options: { include_usage: true },
|
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).
|
// 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.
|
// Event processing is outside retry scope to prevent double-emission of onDelta on retry.
|
||||||
const { events } = await withRetry(async () => {
|
const { events } = await withRetry(async () => {
|
||||||
return httpRequestStream(ZEN_OPENAI_URL, {
|
return httpRequestStream(apiUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${this.apiKey}`,
|
'Authorization': `Bearer ${apiKey}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
signal,
|
signal,
|
||||||
@@ -970,12 +1075,40 @@ export class OpenCodeManager {
|
|||||||
callbacks.onToolResult({ name: toolName, result });
|
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({
|
messages.push({
|
||||||
role: 'tool',
|
role: 'tool',
|
||||||
content: JSON.stringify(result),
|
content: JSON.stringify(result),
|
||||||
tool_call_id: toolCall.id,
|
tool_call_id: toolCall.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (signal.aborted) break;
|
if (signal.aborted) break;
|
||||||
}
|
}
|
||||||
@@ -1811,6 +1944,25 @@ export class OpenCodeManager {
|
|||||||
return truncated;
|
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.
|
* Build Anthropic-format messages from DB message history.
|
||||||
* For assistant messages that had tool calls, appends a summary annotation
|
* For assistant messages that had tool calls, appends a summary annotation
|
||||||
@@ -1825,23 +1977,7 @@ export class OpenCodeManager {
|
|||||||
if (msg.role === 'user') {
|
if (msg.role === 'user') {
|
||||||
messages.push({ role: 'user', content: msg.content || '' });
|
messages.push({ role: 'user', content: msg.content || '' });
|
||||||
} else if (msg.role === 'assistant') {
|
} else if (msg.role === 'assistant') {
|
||||||
let content = msg.content || '';
|
const content = (msg.content || '') + this.buildToolCallSummary(msg.toolCalls);
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
messages.push({ role: 'assistant', content });
|
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(
|
private async generateConversationTitle(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
@@ -1858,16 +1996,25 @@ export class OpenCodeManager {
|
|||||||
_assistantResponse: string
|
_assistantResponse: string
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
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 = {
|
const body = {
|
||||||
model: 'claude-haiku-4-5',
|
model: titleModel,
|
||||||
max_tokens: 20,
|
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.',
|
system: systemText,
|
||||||
messages: [
|
messages: [{ role: 'user', content: promptText }],
|
||||||
{
|
|
||||||
role: 'user',
|
|
||||||
content: `Topic: ${userMessage.substring(0, 100)}`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await this.httpRequest(ZEN_ANTHROPIC_URL, {
|
const response = await this.httpRequest(ZEN_ANTHROPIC_URL, {
|
||||||
@@ -1881,9 +2028,9 @@ export class OpenCodeManager {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.statusCode === 200) {
|
if (response.statusCode !== 200) return;
|
||||||
|
|
||||||
const data = JSON.parse(response.body);
|
const data = JSON.parse(response.body);
|
||||||
let title = '';
|
|
||||||
if (Array.isArray(data.content)) {
|
if (Array.isArray(data.content)) {
|
||||||
title = data.content
|
title = data.content
|
||||||
.filter((b: AnthropicContentBlock) => b.type === 'text')
|
.filter((b: AnthropicContentBlock) => b.type === 'text')
|
||||||
@@ -1892,11 +2039,36 @@ export class OpenCodeManager {
|
|||||||
} else {
|
} else {
|
||||||
title = data.content || '';
|
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
|
// Clean up and truncate title
|
||||||
title = title.trim().replace(/^["']|["']$/g, '').replace(/[.!?]+$/, '');
|
title = title.trim().replace(/^["']|["']$/g, '').replace(/[.!?]+$/, '');
|
||||||
|
|
||||||
// Hard limit on title length
|
|
||||||
const MAX_TITLE_LENGTH = 30;
|
const MAX_TITLE_LENGTH = 30;
|
||||||
if (title.length > MAX_TITLE_LENGTH) {
|
if (title.length > MAX_TITLE_LENGTH) {
|
||||||
title = title.substring(0, MAX_TITLE_LENGTH - 1) + '…';
|
title = title.substring(0, MAX_TITLE_LENGTH - 1) + '…';
|
||||||
@@ -1910,7 +2082,6 @@ export class OpenCodeManager {
|
|||||||
mainWindow.webContents.send('chat-title-updated', { conversationId, title });
|
mainWindow.webContents.send('chat-title-updated', { conversationId, title });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[OpenCodeManager] Error generating title:', 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;
|
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 {
|
private detectProvider(modelId: string): string {
|
||||||
const id = modelId.toLowerCase();
|
const id = modelId.toLowerCase();
|
||||||
if (id.startsWith('claude')) return 'anthropic';
|
if (id.startsWith('claude')) return 'anthropic';
|
||||||
if (id.startsWith('gpt') || id.startsWith('o3') || id.startsWith('o4')) return 'openai';
|
if (id.startsWith('gpt') || id.startsWith('o3') || id.startsWith('o4')) return 'openai';
|
||||||
if (id.startsWith('gemini')) return 'google';
|
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';
|
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 {
|
private parseErrorResponse(response: HttpResponse): string {
|
||||||
let errorMsg = `API error: ${response.statusCode}`;
|
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>;
|
tagMappings?: Record<string, string>;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
if (!this.apiKey) {
|
|
||||||
return { success: false, error: 'API key not set' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const provider = this.detectProvider(modelId);
|
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
|
// Build the prompt for taxonomy analysis
|
||||||
const existingCategories = categories.filter(c => c.existsInProject).map(c => c.name);
|
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 {
|
} else {
|
||||||
// OpenAI-compatible
|
// OpenAI-compatible (includes Mistral)
|
||||||
|
const config = this.getProviderConfig(provider);
|
||||||
const body = {
|
const body = {
|
||||||
model: modelId,
|
model: modelId,
|
||||||
max_tokens: 4096,
|
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',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
Authorization: `Bearer ${this.apiKey}`,
|
Authorization: `Bearer ${config.apiKey}`,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(body),
|
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
|
* 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<{
|
async analyzeMediaImage(mediaId: string, language: string = 'en'): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
@@ -2233,9 +2453,15 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu
|
|||||||
caption?: string;
|
caption?: string;
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
if (!this.apiKey) {
|
// Read configured image analysis model, with smart fallback based on available keys
|
||||||
return { success: false, error: 'API key not configured. Please set your OpenCode API key in Settings.' };
|
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
|
// Get media metadata
|
||||||
const mediaItem = await this.mediaEngine.getMedia(mediaId);
|
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": "..."}`;
|
Respond with JSON only: {"title": "...", "alt": "...", "caption": "..."}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Using Claude Sonnet 4.5 for best image analysis
|
let responseText = '';
|
||||||
const modelId = 'claude-sonnet-4-5';
|
|
||||||
|
|
||||||
|
if (provider === 'anthropic') {
|
||||||
const body = {
|
const body = {
|
||||||
model: modelId,
|
model: modelId,
|
||||||
max_tokens: 200,
|
max_tokens: 200,
|
||||||
@@ -2324,14 +2550,55 @@ Respond with JSON only: {"title": "...", "alt": "...", "caption": "..."}`;
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = JSON.parse(response.body);
|
const data = JSON.parse(response.body);
|
||||||
|
|
||||||
// Extract text from Anthropic response
|
|
||||||
let responseText = '';
|
|
||||||
for (const block of data.content || []) {
|
for (const block of data.content || []) {
|
||||||
if (block.type === 'text') {
|
if (block.type === 'text') {
|
||||||
responseText += block.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
|
// Parse the JSON response
|
||||||
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
const jsonMatch = responseText.match(/\{[\s\S]*\}/);
|
||||||
|
|||||||
@@ -33,7 +33,6 @@ export {
|
|||||||
OpenCodeManager,
|
OpenCodeManager,
|
||||||
type SendMessageOptions,
|
type SendMessageOptions,
|
||||||
type SendMessageResult,
|
type SendMessageResult,
|
||||||
type ModelInfo,
|
|
||||||
} from './OpenCodeManager';
|
} from './OpenCodeManager';
|
||||||
export {
|
export {
|
||||||
WxrParser,
|
WxrParser,
|
||||||
|
|||||||
@@ -78,6 +78,16 @@ async function getOpenCodeManager(): Promise<OpenCodeManager> {
|
|||||||
} catch {
|
} catch {
|
||||||
// Silently ignore errors loading the key
|
// 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,
|
ready: result.ready,
|
||||||
error: result.error,
|
error: result.error,
|
||||||
backend: 'opencode',
|
backend: 'opencode',
|
||||||
|
providers: result.providers,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Chat IPC] Error checking ready:', 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 ============
|
// ============ Chat Settings ============
|
||||||
|
|
||||||
// Get available models
|
// Get available models
|
||||||
@@ -219,6 +330,9 @@ export function registerChatHandlers(): void {
|
|||||||
try {
|
try {
|
||||||
const manager = await getOpenCodeManager();
|
const manager = await getOpenCodeManager();
|
||||||
const result = await manager.getModelCatalogEngine().refresh();
|
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;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Chat IPC] Error refreshing model catalog:', error);
|
console.error('[Chat IPC] Error refreshing model catalog:', error);
|
||||||
|
|||||||
@@ -309,6 +309,17 @@ export const electronAPI: ElectronAPI = {
|
|||||||
setApiKey: (apiKey: string) => ipcRenderer.invoke('chat:setApiKey', apiKey),
|
setApiKey: (apiKey: string) => ipcRenderer.invoke('chat:setApiKey', apiKey),
|
||||||
getApiKey: () => ipcRenderer.invoke('chat:getApiKey'),
|
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
|
// Settings
|
||||||
getAvailableModels: () => ipcRenderer.invoke('chat:getAvailableModels'),
|
getAvailableModels: () => ipcRenderer.invoke('chat:getAvailableModels'),
|
||||||
setDefaultModel: (modelId: string) => ipcRenderer.invoke('chat:setDefaultModel', modelId),
|
setDefaultModel: (modelId: string) => ipcRenderer.invoke('chat:setDefaultModel', modelId),
|
||||||
|
|||||||
@@ -421,7 +421,8 @@ export interface ChatMessage {
|
|||||||
export interface ChatModel {
|
export interface ChatModel {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
provider?: string;
|
provider: string;
|
||||||
|
vision?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ModelCatalogEntry {
|
export interface ModelCatalogEntry {
|
||||||
@@ -450,6 +451,7 @@ export interface ChatReadyStatus {
|
|||||||
ready: boolean;
|
ready: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
backend?: string;
|
backend?: string;
|
||||||
|
providers?: { opencode: boolean; mistral: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ChatApiKeyStatus {
|
export interface ChatApiKeyStatus {
|
||||||
@@ -825,12 +827,23 @@ export interface ElectronAPI {
|
|||||||
setApiKey: (apiKey: string) => Promise<{ success: boolean; error?: string }>;
|
setApiKey: (apiKey: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
getApiKey: () => Promise<ChatApiKeyStatus>;
|
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
|
// Settings
|
||||||
getAvailableModels: () => Promise<{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }>;
|
getAvailableModels: () => Promise<{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }>;
|
||||||
setDefaultModel: (modelId: string) => Promise<{ success: boolean; error?: string }>;
|
setDefaultModel: (modelId: string) => Promise<{ success: boolean; error?: string }>;
|
||||||
getSystemPrompt: () => Promise<{ success: boolean; prompt?: string; error?: string }>;
|
getSystemPrompt: () => Promise<{ success: boolean; prompt?: string; error?: string }>;
|
||||||
setSystemPrompt: (prompt: string) => Promise<{ success: boolean; 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
|
// Model Catalog
|
||||||
refreshModelCatalog: () => Promise<ModelCatalogRefreshResult>;
|
refreshModelCatalog: () => Promise<ModelCatalogRefreshResult>;
|
||||||
getModelCatalog: () => Promise<{ success: boolean; entries: ModelCatalogEntry[]; error?: string }>;
|
getModelCatalog: () => Promise<{ success: boolean; entries: ModelCatalogEntry[]; error?: string }>;
|
||||||
|
|||||||
@@ -85,6 +85,21 @@
|
|||||||
color: var(--vscode-list-activeSelectionForeground);
|
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 {
|
.chat-messages {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
@@ -23,9 +23,6 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
const [availableModels, setAvailableModels] = useState<ChatModel[]>([]);
|
const [availableModels, setAvailableModels] = useState<ChatModel[]>([]);
|
||||||
const [showModelSelector, setShowModelSelector] = useState(false);
|
const [showModelSelector, setShowModelSelector] = useState(false);
|
||||||
const [needsApiKey, setNeedsApiKey] = 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 [actionError, setActionError] = useState<string | null>(null);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||||
@@ -174,29 +171,6 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
scrollToBottom();
|
scrollToBottom();
|
||||||
}, [messages, streamingContent, 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 handleSend = async () => {
|
||||||
const message = inputValue.trim();
|
const message = inputValue.trim();
|
||||||
if (!message || isStreaming) return;
|
if (!message || isStreaming) return;
|
||||||
@@ -303,6 +277,11 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
|
|
||||||
// API key setup screen
|
// API key setup screen
|
||||||
if (needsApiKey) {
|
if (needsApiKey) {
|
||||||
|
const handleOpenSettings = () => {
|
||||||
|
useAppStore.getState().setActiveView('settings');
|
||||||
|
useAppStore.getState().openTab({ type: 'settings', id: 'settings', isTransient: false });
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="chat-panel chat-surface">
|
<div className="chat-panel chat-surface">
|
||||||
<div className="chat-panel-header">
|
<div className="chat-panel-header">
|
||||||
@@ -314,23 +293,12 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
<h2>{tr('chat.apiKeyRequiredTitle')}</h2>
|
<h2>{tr('chat.apiKeyRequiredTitle')}</h2>
|
||||||
<p>{tr('chat.apiKeyRequiredDescription')}</p>
|
<p>{tr('chat.apiKeyRequiredDescription')}</p>
|
||||||
<div className="api-key-form">
|
<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
|
<button
|
||||||
className="api-key-submit"
|
className="api-key-submit"
|
||||||
onClick={handleApiKeySubmit}
|
onClick={handleOpenSettings}
|
||||||
disabled={!apiKeyInput.trim() || isValidating}
|
|
||||||
>
|
>
|
||||||
{isValidating ? tr('chat.apiKeyValidating') : tr('chat.apiKeySave')}
|
{tr('chat.openSettings')}
|
||||||
</button>
|
</button>
|
||||||
{apiKeyError && <div className="api-key-error">{apiKeyError}</div>}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -355,7 +323,20 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
</button>
|
</button>
|
||||||
{showModelSelector && (
|
{showModelSelector && (
|
||||||
<div className="model-dropdown">
|
<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
|
<button
|
||||||
key={model.id}
|
key={model.id}
|
||||||
className={`model-option ${conversation?.model === model.id ? 'active' : ''}`}
|
className={`model-option ${conversation?.model === model.id ? 'active' : ''}`}
|
||||||
@@ -364,6 +345,9 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
|
|||||||
{model.name}
|
{model.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1371,7 +1371,19 @@ const TaxonomySection: React.FC<{
|
|||||||
</button>
|
</button>
|
||||||
{showModelSelector && (
|
{showModelSelector && (
|
||||||
<div className="taxonomy-model-dropdown">
|
<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
|
<button
|
||||||
key={model.id}
|
key={model.id}
|
||||||
className="taxonomy-model-option"
|
className="taxonomy-model-option"
|
||||||
@@ -1380,6 +1392,9 @@ const TaxonomySection: React.FC<{
|
|||||||
{model.name}
|
{model.name}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
</React.Fragment>
|
||||||
|
));
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 { useAppStore } from '../../store';
|
||||||
import { showToast } from '../Toast';
|
import { showToast } from '../Toast';
|
||||||
import { useI18n } from '../../i18n';
|
import { useI18n } from '../../i18n';
|
||||||
@@ -242,7 +242,12 @@ export const SettingsView: React.FC = () => {
|
|||||||
const [aiApiKeyMasked, setAiApiKeyMasked] = useState('');
|
const [aiApiKeyMasked, setAiApiKeyMasked] = useState('');
|
||||||
const [aiHasApiKey, setAiHasApiKey] = useState(false);
|
const [aiHasApiKey, setAiHasApiKey] = useState(false);
|
||||||
const [newApiKey, setNewApiKey] = useState('');
|
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 [selectedModel, setSelectedModel] = useState('');
|
||||||
const [modelCatalog, setModelCatalog] = useState<Map<string, {
|
const [modelCatalog, setModelCatalog] = useState<Map<string, {
|
||||||
maxOutputTokens: number | null;
|
maxOutputTokens: number | null;
|
||||||
@@ -403,6 +408,23 @@ export const SettingsView: React.FC = () => {
|
|||||||
setSelectedModel(modelsResult.selectedModel || '');
|
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
|
// Load model catalog metadata
|
||||||
const catalogResult = await window.electronAPI?.chat.getModelCatalog();
|
const catalogResult = await window.electronAPI?.chat.getModelCatalog();
|
||||||
if (catalogResult?.success && catalogResult.entries) {
|
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));
|
setAiApiKeyMasked('•'.repeat(Math.max(0, newApiKey.length - 4)) + newApiKey.slice(-4));
|
||||||
setNewApiKey('');
|
setNewApiKey('');
|
||||||
showToast.success(t('settings.toast.apiKeySaved'));
|
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 {
|
} else {
|
||||||
showToast.error(t('settings.toast.apiKeyInvalid'));
|
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) => {
|
const handleModelChange = async (modelId: string) => {
|
||||||
try {
|
try {
|
||||||
const result = await window.electronAPI?.chat.setDefaultModel(modelId);
|
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 = () => (
|
const renderAISettings = () => (
|
||||||
<SettingSection
|
<SettingSection
|
||||||
id="settings-section-ai"
|
id="settings-section-ai"
|
||||||
@@ -1185,27 +1305,58 @@ export const SettingsView: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</SettingRow>
|
</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
|
<SettingRow
|
||||||
id="ai-model"
|
id="ai-model"
|
||||||
label={t('settings.ai.defaultModelLabel')}
|
label={t('settings.ai.defaultModelLabel')}
|
||||||
description={t('settings.ai.defaultModelDescription')}
|
description={t('settings.ai.defaultModelDescription')}
|
||||||
>
|
>
|
||||||
<div className="setting-input-group">
|
<div className="setting-input-group">
|
||||||
<select
|
{renderModelSelect('ai-model', selectedModel, handleModelChange, !aiHasApiKey && !aiHasMistralKey)}
|
||||||
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>
|
|
||||||
<button
|
<button
|
||||||
className="secondary"
|
className="secondary"
|
||||||
onClick={handleRefreshModelCatalog}
|
onClick={handleRefreshModelCatalog}
|
||||||
disabled={refreshingCatalog || !aiHasApiKey}
|
disabled={refreshingCatalog || (!aiHasApiKey && !aiHasMistralKey)}
|
||||||
title={t('settings.ai.refreshModelCatalog')}
|
title={t('settings.ai.refreshModelCatalog')}
|
||||||
>
|
>
|
||||||
{refreshingCatalog ? t('settings.ai.refreshing') : t('settings.ai.refreshModelCatalog')}
|
{refreshingCatalog ? t('settings.ai.refreshing') : t('settings.ai.refreshModelCatalog')}
|
||||||
@@ -1232,6 +1383,22 @@ export const SettingsView: React.FC = () => {
|
|||||||
})()}
|
})()}
|
||||||
</SettingRow>
|
</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
|
<SettingRow
|
||||||
id="ai-system-prompt"
|
id="ai-system-prompt"
|
||||||
label={t('settings.ai.systemPromptLabel')}
|
label={t('settings.ai.systemPromptLabel')}
|
||||||
|
|||||||
@@ -192,13 +192,11 @@
|
|||||||
"settings.toast.thumbnailsComplete": "Generierung der Vorschaubilder abgeschlossen",
|
"settings.toast.thumbnailsComplete": "Generierung der Vorschaubilder abgeschlossen",
|
||||||
"settings.toast.thumbnailsFailed": "Vorschaubilder konnten nicht erzeugt werden",
|
"settings.toast.thumbnailsFailed": "Vorschaubilder konnten nicht erzeugt werden",
|
||||||
"chat.setupTitle": "KI-Chat-Einrichtung",
|
"chat.setupTitle": "KI-Chat-Einrichtung",
|
||||||
"chat.apiKeyRequiredTitle": "OpenCode Zen API-Schlüssel erforderlich",
|
"chat.apiKeyRequiredTitle": "API-Schlüssel erforderlich",
|
||||||
"chat.apiKeyRequiredDescription": "Gib deinen OpenCode API-Schlüssel ein, um den KI-Chat zu aktivieren.",
|
"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.apiKeyPlaceholder": "API-Schlüssel eingeben...",
|
||||||
"chat.apiKeySave": "Schlüssel speichern",
|
"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.newChat": "Neuer Chat",
|
||||||
"chat.welcomeTitle": "Willkommen beim KI-Assistenten",
|
"chat.welcomeTitle": "Willkommen beim KI-Assistenten",
|
||||||
"chat.welcomeDescription": "Ich kann dir helfen, deinen Blog mit anschaulichen Darstellungen zu verwalten. Frag mich zum Beispiel:",
|
"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.modelInfoOutputPrice": "Ausgabe",
|
||||||
"settings.ai.modelInfoTokens": "Token",
|
"settings.ai.modelInfoTokens": "Token",
|
||||||
"settings.ai.modelInfoPerMTok": "/MTok",
|
"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.modelCatalogRefreshed": "Modellkatalog aktualisiert ({{count}} Modelle)",
|
||||||
"settings.toast.modelCatalogUpToDate": "Modellkatalog ist bereits aktuell",
|
"settings.toast.modelCatalogUpToDate": "Modellkatalog ist bereits aktuell",
|
||||||
"settings.toast.modelCatalogRefreshFailed": "Modellkatalog konnte nicht aktualisiert werden",
|
"settings.toast.modelCatalogRefreshFailed": "Modellkatalog konnte nicht aktualisiert werden",
|
||||||
|
|||||||
@@ -192,13 +192,11 @@
|
|||||||
"settings.toast.thumbnailsComplete": "Thumbnail generation complete",
|
"settings.toast.thumbnailsComplete": "Thumbnail generation complete",
|
||||||
"settings.toast.thumbnailsFailed": "Failed to generate thumbnails",
|
"settings.toast.thumbnailsFailed": "Failed to generate thumbnails",
|
||||||
"chat.setupTitle": "AI Chat Setup",
|
"chat.setupTitle": "AI Chat Setup",
|
||||||
"chat.apiKeyRequiredTitle": "OpenCode Zen API Key Required",
|
"chat.apiKeyRequiredTitle": "API Key Required",
|
||||||
"chat.apiKeyRequiredDescription": "Enter your OpenCode API key to enable AI chat.",
|
"chat.apiKeyRequiredDescription": "Configure an API key in Settings to enable AI chat.",
|
||||||
|
"chat.openSettings": "Open Settings",
|
||||||
"chat.apiKeyPlaceholder": "Enter your API key...",
|
"chat.apiKeyPlaceholder": "Enter your API key...",
|
||||||
"chat.apiKeySave": "Save 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.newChat": "New Chat",
|
||||||
"chat.welcomeTitle": "Welcome to the AI Assistant",
|
"chat.welcomeTitle": "Welcome to the AI Assistant",
|
||||||
"chat.welcomeDescription": "I can help you manage your blog with rich visualizations. Try asking me to:",
|
"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.modelInfoOutputPrice": "Output",
|
||||||
"settings.ai.modelInfoTokens": "tokens",
|
"settings.ai.modelInfoTokens": "tokens",
|
||||||
"settings.ai.modelInfoPerMTok": "/MTok",
|
"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.modelCatalogRefreshed": "Model catalog updated ({{count}} models)",
|
||||||
"settings.toast.modelCatalogUpToDate": "Model catalog already up to date",
|
"settings.toast.modelCatalogUpToDate": "Model catalog already up to date",
|
||||||
"settings.toast.modelCatalogRefreshFailed": "Failed to refresh model catalog",
|
"settings.toast.modelCatalogRefreshFailed": "Failed to refresh model catalog",
|
||||||
|
|||||||
@@ -192,13 +192,11 @@
|
|||||||
"settings.toast.thumbnailsComplete": "Generación de miniaturas completa",
|
"settings.toast.thumbnailsComplete": "Generación de miniaturas completa",
|
||||||
"settings.toast.thumbnailsFailed": "No se pudieron generar miniaturas",
|
"settings.toast.thumbnailsFailed": "No se pudieron generar miniaturas",
|
||||||
"chat.setupTitle": "Configuración de chat IA",
|
"chat.setupTitle": "Configuración de chat IA",
|
||||||
"chat.apiKeyRequiredTitle": "Se requiere clave API de OpenCode Zen",
|
"chat.apiKeyRequiredTitle": "Clave API requerida",
|
||||||
"chat.apiKeyRequiredDescription": "Introduce tu clave API de OpenCode para habilitar el chat de IA.",
|
"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.apiKeyPlaceholder": "Introduce tu clave API...",
|
||||||
"chat.apiKeySave": "Guardar clave",
|
"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.newChat": "Nuevo chat",
|
||||||
"chat.welcomeTitle": "Bienvenido al asistente de IA",
|
"chat.welcomeTitle": "Bienvenido al asistente de IA",
|
||||||
"chat.welcomeDescription": "Puedo ayudarte a gestionar tu blog con visualizaciones interactivas. Prueba a pedirme que:",
|
"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.modelInfoOutputPrice": "Salida",
|
||||||
"settings.ai.modelInfoTokens": "tokens",
|
"settings.ai.modelInfoTokens": "tokens",
|
||||||
"settings.ai.modelInfoPerMTok": "/MTok",
|
"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.modelCatalogRefreshed": "Catálogo actualizado ({{count}} modelos)",
|
||||||
"settings.toast.modelCatalogUpToDate": "El catálogo ya está actualizado",
|
"settings.toast.modelCatalogUpToDate": "El catálogo ya está actualizado",
|
||||||
"settings.toast.modelCatalogRefreshFailed": "No se pudo actualizar el catálogo",
|
"settings.toast.modelCatalogRefreshFailed": "No se pudo actualizar el catálogo",
|
||||||
|
|||||||
@@ -190,13 +190,11 @@
|
|||||||
"settings.toast.thumbnailsComplete": "Génération des miniatures terminée",
|
"settings.toast.thumbnailsComplete": "Génération des miniatures terminée",
|
||||||
"settings.toast.thumbnailsFailed": "Impossible de générer les miniatures",
|
"settings.toast.thumbnailsFailed": "Impossible de générer les miniatures",
|
||||||
"chat.setupTitle": "Configuration du chat IA",
|
"chat.setupTitle": "Configuration du chat IA",
|
||||||
"chat.apiKeyRequiredTitle": "Clé API OpenCode Zen requise",
|
"chat.apiKeyRequiredTitle": "Clé API requise",
|
||||||
"chat.apiKeyRequiredDescription": "Saisissez votre clé API OpenCode pour activer le chat IA.",
|
"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.apiKeyPlaceholder": "Saisissez votre clé API...",
|
||||||
"chat.apiKeySave": "Enregistrer la clé",
|
"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.newChat": "Nouveau chat",
|
||||||
"chat.welcomeTitle": "Bienvenue dans l’assistant IA",
|
"chat.welcomeTitle": "Bienvenue dans l’assistant IA",
|
||||||
"chat.welcomeDescription": "Je peux vous aider à gérer votre blog avec des visualisations riches. Essayez par exemple :",
|
"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.modelInfoOutputPrice": "Sortie",
|
||||||
"settings.ai.modelInfoTokens": "tokens",
|
"settings.ai.modelInfoTokens": "tokens",
|
||||||
"settings.ai.modelInfoPerMTok": "/MTok",
|
"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.modelCatalogRefreshed": "Catalogue mis à jour ({{count}} modèles)",
|
||||||
"settings.toast.modelCatalogUpToDate": "Le catalogue est déjà à jour",
|
"settings.toast.modelCatalogUpToDate": "Le catalogue est déjà à jour",
|
||||||
"settings.toast.modelCatalogRefreshFailed": "Échec de l'actualisation du catalogue",
|
"settings.toast.modelCatalogRefreshFailed": "Échec de l'actualisation du catalogue",
|
||||||
|
|||||||
@@ -190,13 +190,11 @@
|
|||||||
"settings.toast.thumbnailsComplete": "Generazione miniature completata",
|
"settings.toast.thumbnailsComplete": "Generazione miniature completata",
|
||||||
"settings.toast.thumbnailsFailed": "Impossibile generare le miniature",
|
"settings.toast.thumbnailsFailed": "Impossibile generare le miniature",
|
||||||
"chat.setupTitle": "Configurazione chat IA",
|
"chat.setupTitle": "Configurazione chat IA",
|
||||||
"chat.apiKeyRequiredTitle": "Chiave API OpenCode Zen richiesta",
|
"chat.apiKeyRequiredTitle": "Chiave API richiesta",
|
||||||
"chat.apiKeyRequiredDescription": "Inserisci la tua chiave API OpenCode per abilitare la chat IA.",
|
"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.apiKeyPlaceholder": "Inserisci la tua chiave API...",
|
||||||
"chat.apiKeySave": "Salva chiave",
|
"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.newChat": "Nuova chat",
|
||||||
"chat.welcomeTitle": "Benvenuto nell’assistente IA",
|
"chat.welcomeTitle": "Benvenuto nell’assistente IA",
|
||||||
"chat.welcomeDescription": "Posso aiutarti a gestire il tuo blog con visualizzazioni interattive. Prova a chiedermi di:",
|
"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.modelInfoOutputPrice": "Output",
|
||||||
"settings.ai.modelInfoTokens": "token",
|
"settings.ai.modelInfoTokens": "token",
|
||||||
"settings.ai.modelInfoPerMTok": "/MTok",
|
"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.modelCatalogRefreshed": "Catalogo aggiornato ({{count}} modelli)",
|
||||||
"settings.toast.modelCatalogUpToDate": "Il catalogo è già aggiornato",
|
"settings.toast.modelCatalogUpToDate": "Il catalogo è già aggiornato",
|
||||||
"settings.toast.modelCatalogRefreshFailed": "Aggiornamento del catalogo non riuscito",
|
"settings.toast.modelCatalogRefreshFailed": "Aggiornamento del catalogo non riuscito",
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
* ModelCatalogEngine Tests
|
* ModelCatalogEngine Tests
|
||||||
*
|
*
|
||||||
* Tests the model catalog engine that fetches and caches
|
* 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';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
@@ -19,12 +20,37 @@ function createSelectChain(mockData: unknown[] = []) {
|
|||||||
return chain;
|
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[] = [];
|
const insertedValues: unknown[] = [];
|
||||||
|
|
||||||
function createDrizzleMock() {
|
function createDrizzleMock() {
|
||||||
return {
|
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(() => ({
|
insert: vi.fn(() => ({
|
||||||
values: vi.fn((data: unknown) => {
|
values: vi.fn((data: unknown) => {
|
||||||
insertedValues.push(data);
|
insertedValues.push(data);
|
||||||
@@ -49,13 +75,19 @@ vi.mock('../../src/main/database', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import { ModelCatalogEngine, DEFAULT_MAX_OUTPUT_TOKENS } from '../../src/main/engine/ModelCatalogEngine';
|
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() {
|
function sampleModelsDevResponse() {
|
||||||
return {
|
return {
|
||||||
opencode: {
|
opencode: {
|
||||||
id: '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: {
|
models: {
|
||||||
'claude-sonnet-4-5': {
|
'claude-sonnet-4-5': {
|
||||||
id: 'claude-sonnet-4-5',
|
id: 'claude-sonnet-4-5',
|
||||||
@@ -64,6 +96,7 @@ function sampleModelsDevResponse() {
|
|||||||
attachment: true,
|
attachment: true,
|
||||||
reasoning: false,
|
reasoning: false,
|
||||||
tool_call: true,
|
tool_call: true,
|
||||||
|
modalities: { input: ['text', 'image', 'pdf'], output: ['text'] },
|
||||||
cost: { input: 3, output: 15, cache_read: 0.3 },
|
cost: { input: 3, output: 15, cache_read: 0.3 },
|
||||||
limit: { context: 200000, output: 64000 },
|
limit: { context: 200000, output: 64000 },
|
||||||
},
|
},
|
||||||
@@ -74,6 +107,7 @@ function sampleModelsDevResponse() {
|
|||||||
attachment: true,
|
attachment: true,
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
tool_call: true,
|
tool_call: true,
|
||||||
|
modalities: { input: ['text', 'image'], output: ['text'] },
|
||||||
cost: { input: 1.07, output: 8.5, cache_read: 0.107 },
|
cost: { input: 1.07, output: 8.5, cache_read: 0.107 },
|
||||||
limit: { context: 400000, input: 272000, output: 128000 },
|
limit: { context: 400000, input: 272000, output: 128000 },
|
||||||
},
|
},
|
||||||
@@ -81,10 +115,32 @@ function sampleModelsDevResponse() {
|
|||||||
id: 'model-no-cost',
|
id: 'model-no-cost',
|
||||||
name: 'Free Model',
|
name: 'Free Model',
|
||||||
family: 'free',
|
family: 'free',
|
||||||
|
modalities: { input: ['text'], output: ['text'] },
|
||||||
limit: { context: 32000, output: 4096 },
|
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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
selectMockData = [];
|
modelMockData = [];
|
||||||
|
modalityMockData = [];
|
||||||
|
providerMockData = [];
|
||||||
|
metaMockData = [];
|
||||||
insertedValues.length = 0;
|
insertedValues.length = 0;
|
||||||
engine = new ModelCatalogEngine();
|
engine = new ModelCatalogEngine();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getAll', () => {
|
describe('getAll', () => {
|
||||||
it('returns all cached model catalog entries', async () => {
|
it('returns all cached model catalog entries with modalities', async () => {
|
||||||
selectMockData = [
|
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,
|
contextWindow: 200000, maxInputTokens: null, maxOutputTokens: 64000,
|
||||||
inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3,
|
inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3, cacheWritePrice: null,
|
||||||
supportsAttachments: true, supportsReasoning: false, supportsToolCall: true,
|
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();
|
const result = await engine.getAll();
|
||||||
expect(result).toHaveLength(1);
|
expect(result).toHaveLength(1);
|
||||||
expect(result[0].id).toBe('claude-sonnet-4-5');
|
expect(result[0].id).toBe('claude-sonnet-4-5');
|
||||||
|
expect(result[0].provider).toBe('opencode');
|
||||||
expect(result[0].maxOutputTokens).toBe(64000);
|
expect(result[0].maxOutputTokens).toBe(64000);
|
||||||
expect(result[0].inputPrice).toBe(3);
|
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 () => {
|
it('returns empty array when no catalog entries exist', async () => {
|
||||||
selectMockData = [];
|
|
||||||
const result = await engine.getAll();
|
const result = await engine.getAll();
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getModel', () => {
|
describe('getModel', () => {
|
||||||
it('returns a specific model by ID', async () => {
|
it('returns a specific model by ID (cross-provider search)', async () => {
|
||||||
selectMockData = [{
|
modelMockData = [{
|
||||||
id: 'gpt-5', name: 'GPT 5', family: 'gpt',
|
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,
|
contextWindow: 400000, maxInputTokens: 272000, maxOutputTokens: 128000,
|
||||||
inputPrice: 1.07, outputPrice: 8.5, cacheReadPrice: 0.107,
|
inputPrice: 1.07, outputPrice: 8.5, cacheReadPrice: 0.107, cacheWritePrice: null,
|
||||||
supportsAttachments: true, supportsReasoning: true, supportsToolCall: true,
|
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');
|
const result = await engine.getModel('gpt-5');
|
||||||
expect(result).not.toBeNull();
|
expect(result).not.toBeNull();
|
||||||
expect(result!.name).toBe('GPT 5');
|
expect(result!.name).toBe('GPT 5');
|
||||||
expect(result!.maxOutputTokens).toBe(128000);
|
expect(result!.maxOutputTokens).toBe(128000);
|
||||||
|
expect(result!.inputModalities).toEqual(['text', 'image']);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns null for unknown model', async () => {
|
it('returns null for unknown model', async () => {
|
||||||
selectMockData = [];
|
modelMockData = [];
|
||||||
|
modalityMockData = [];
|
||||||
const result = await engine.getModel('nonexistent');
|
const result = await engine.getModel('nonexistent');
|
||||||
expect(result).toBeNull();
|
expect(result).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -147,38 +225,92 @@ describe('ModelCatalogEngine', () => {
|
|||||||
|
|
||||||
describe('getMaxOutputTokens', () => {
|
describe('getMaxOutputTokens', () => {
|
||||||
it('returns output tokens from catalog when available', async () => {
|
it('returns output tokens from catalog when available', async () => {
|
||||||
selectMockData = [{
|
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,
|
contextWindow: 200000, maxInputTokens: null, maxOutputTokens: 64000,
|
||||||
inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3,
|
inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3, cacheWritePrice: null,
|
||||||
supportsAttachments: true, supportsReasoning: false, supportsToolCall: true,
|
interleaved: null, status: null, providerNpm: null,
|
||||||
}];
|
}];
|
||||||
|
modalityMockData = [];
|
||||||
|
|
||||||
const result = await engine.getMaxOutputTokens('claude-sonnet-4-5');
|
const result = await engine.getMaxOutputTokens('claude-sonnet-4-5');
|
||||||
expect(result).toBe(64000);
|
expect(result).toBe(64000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns DEFAULT_MAX_OUTPUT_TOKENS for uncatalogued model', async () => {
|
it('returns DEFAULT_MAX_OUTPUT_TOKENS for uncatalogued model', async () => {
|
||||||
selectMockData = [];
|
modelMockData = [];
|
||||||
|
modalityMockData = [];
|
||||||
const result = await engine.getMaxOutputTokens('unknown-model');
|
const result = await engine.getMaxOutputTokens('unknown-model');
|
||||||
expect(result).toBe(DEFAULT_MAX_OUTPUT_TOKENS);
|
expect(result).toBe(DEFAULT_MAX_OUTPUT_TOKENS);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns DEFAULT_MAX_OUTPUT_TOKENS when model has null maxOutputTokens', async () => {
|
it('returns DEFAULT_MAX_OUTPUT_TOKENS when model has null maxOutputTokens', async () => {
|
||||||
selectMockData = [{
|
modelMockData = [{
|
||||||
id: 'weird-model', name: 'Weird', family: null,
|
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,
|
contextWindow: null, maxInputTokens: null, maxOutputTokens: null,
|
||||||
inputPrice: null, outputPrice: null, cacheReadPrice: null,
|
inputPrice: null, outputPrice: null, cacheReadPrice: null, cacheWritePrice: null,
|
||||||
supportsAttachments: false, supportsReasoning: false, supportsToolCall: false,
|
interleaved: null, status: null, providerNpm: null,
|
||||||
}];
|
}];
|
||||||
|
modalityMockData = [];
|
||||||
|
|
||||||
const result = await engine.getMaxOutputTokens('weird-model');
|
const result = await engine.getMaxOutputTokens('weird-model');
|
||||||
expect(result).toBe(DEFAULT_MAX_OUTPUT_TOKENS);
|
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', () => {
|
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();
|
const mockResponse = sampleModelsDevResponse();
|
||||||
vi.spyOn(engine as any, 'httpGet').mockResolvedValue({
|
vi.spyOn(engine as any, 'httpGet').mockResolvedValue({
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
@@ -186,13 +318,16 @@ describe('ModelCatalogEngine', () => {
|
|||||||
headers: { etag: '"abc123"' },
|
headers: { etag: '"abc123"' },
|
||||||
});
|
});
|
||||||
|
|
||||||
// getMeta returns null (no existing etag)
|
metaMockData = [];
|
||||||
selectMockData = [];
|
|
||||||
|
|
||||||
const result = await engine.refresh();
|
const result = await engine.refresh();
|
||||||
expect(result.success).toBe(true);
|
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();
|
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 () => {
|
it('sends If-None-Match header when ETag is cached', async () => {
|
||||||
@@ -208,7 +343,9 @@ describe('ModelCatalogEngine', () => {
|
|||||||
mockLocalDb.select = vi.fn(() => {
|
mockLocalDb.select = vi.fn(() => {
|
||||||
metaCallCount++;
|
metaCallCount++;
|
||||||
if (metaCallCount === 1) {
|
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([]);
|
return createSelectChain([]);
|
||||||
}) as any;
|
}) as any;
|
||||||
@@ -231,7 +368,7 @@ describe('ModelCatalogEngine', () => {
|
|||||||
body: 'Internal Server Error',
|
body: 'Internal Server Error',
|
||||||
headers: {},
|
headers: {},
|
||||||
});
|
});
|
||||||
selectMockData = [];
|
metaMockData = [];
|
||||||
|
|
||||||
const result = await engine.refresh();
|
const result = await engine.refresh();
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
@@ -240,24 +377,24 @@ describe('ModelCatalogEngine', () => {
|
|||||||
|
|
||||||
it('handles network errors gracefully', async () => {
|
it('handles network errors gracefully', async () => {
|
||||||
vi.spyOn(engine as any, 'httpGet').mockRejectedValue(new Error('ECONNREFUSED'));
|
vi.spyOn(engine as any, 'httpGet').mockRejectedValue(new Error('ECONNREFUSED'));
|
||||||
selectMockData = [];
|
metaMockData = [];
|
||||||
|
|
||||||
const result = await engine.refresh();
|
const result = await engine.refresh();
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.error).toBe('ECONNREFUSED');
|
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({
|
vi.spyOn(engine as any, 'httpGet').mockResolvedValue({
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
body: JSON.stringify({ other_provider: { models: {} } }),
|
body: JSON.stringify({}),
|
||||||
headers: {},
|
headers: {},
|
||||||
});
|
});
|
||||||
selectMockData = [];
|
metaMockData = [];
|
||||||
|
|
||||||
const result = await engine.refresh();
|
const result = await engine.refresh();
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
expect(result.error).toContain('no opencode models');
|
expect(result.error).toContain('no providers');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('handles malformed JSON gracefully', async () => {
|
it('handles malformed JSON gracefully', async () => {
|
||||||
@@ -266,7 +403,7 @@ describe('ModelCatalogEngine', () => {
|
|||||||
body: 'not valid json {{{',
|
body: 'not valid json {{{',
|
||||||
headers: {},
|
headers: {},
|
||||||
});
|
});
|
||||||
selectMockData = [];
|
metaMockData = [];
|
||||||
|
|
||||||
const result = await engine.refresh();
|
const result = await engine.refresh();
|
||||||
expect(result.success).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
|
|||||||
679
tests/engine/OpenCodeManagerMistral.test.ts
Normal file
679
tests/engine/OpenCodeManagerMistral.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,7 +29,8 @@ vi.mock('../../src/main/database', () => ({
|
|||||||
getDatabase: vi.fn(() => ({})),
|
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
|
// Helper to create manager with mocked httpRequest
|
||||||
function createManager(): OpenCodeManager {
|
function createManager(): OpenCodeManager {
|
||||||
@@ -66,76 +67,21 @@ describe('OpenCodeManager model discovery', () => {
|
|||||||
vi.useRealTimers();
|
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', () => {
|
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();
|
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([
|
const zenResponse = createZenModelResponse([
|
||||||
'claude-sonnet-4',
|
'claude-sonnet-4',
|
||||||
'gpt-5.1-codex',
|
'gpt-5.1-codex',
|
||||||
@@ -150,40 +96,55 @@ describe('OpenCodeManager model discovery', () => {
|
|||||||
const models = await manager.getAvailableModels();
|
const models = await manager.getAvailableModels();
|
||||||
|
|
||||||
expect(models).toHaveLength(3);
|
expect(models).toHaveLength(3);
|
||||||
expect(models[0]).toEqual({ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', provider: 'anthropic' });
|
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' });
|
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' });
|
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();
|
const manager = createManager();
|
||||||
(manager as any).httpRequest = vi.fn().mockRejectedValue(new Error('Network error'));
|
(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();
|
const models = await manager.getAvailableModels();
|
||||||
|
|
||||||
expect(models.length).toBeGreaterThan(0);
|
expect(models.length).toBeGreaterThan(0);
|
||||||
// Should include well-known models from the display name map
|
const ids = models.map((m: ChatModel) => m.id);
|
||||||
const ids = models.map((m: ModelInfo) => m.id);
|
|
||||||
expect(ids).toContain('claude-sonnet-4');
|
expect(ids).toContain('claude-sonnet-4');
|
||||||
expect(ids).toContain('gpt-5');
|
expect(ids).toContain('gpt-5');
|
||||||
// Every model should have proper provider detection
|
const claudeModel = models.find((m: ChatModel) => m.id === 'claude-sonnet-4');
|
||||||
const claudeModel = models.find((m: ModelInfo) => m.id === 'claude-sonnet-4');
|
|
||||||
expect(claudeModel?.provider).toBe('anthropic');
|
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?.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();
|
const manager = createManager();
|
||||||
(manager as any).httpRequest = vi.fn().mockResolvedValue({
|
(manager as any).httpRequest = vi.fn().mockResolvedValue({
|
||||||
statusCode: 401,
|
statusCode: 401,
|
||||||
body: '{"error":"unauthorized"}',
|
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();
|
const models = await manager.getAvailableModels();
|
||||||
|
|
||||||
expect(models.length).toBeGreaterThan(0);
|
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');
|
expect(ids).toContain('claude-sonnet-4');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -219,7 +180,7 @@ describe('OpenCodeManager model discovery', () => {
|
|||||||
expect(httpRequest).toHaveBeenCalledTimes(2);
|
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 manager = createManager();
|
||||||
const zenResponse = createZenModelResponse(['some-new-model-v3']);
|
const zenResponse = createZenModelResponse(['some-new-model-v3']);
|
||||||
|
|
||||||
@@ -231,19 +192,31 @@ describe('OpenCodeManager model discovery', () => {
|
|||||||
const models = await manager.getAvailableModels();
|
const models = await manager.getAvailableModels();
|
||||||
|
|
||||||
expect(models).toHaveLength(1);
|
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');
|
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();
|
const manager = createManager();
|
||||||
(manager as any).apiKey = '';
|
(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();
|
const models = await manager.getAvailableModels();
|
||||||
|
|
||||||
|
// Only Mistral models will be in fallback since only Mistral key is set
|
||||||
expect(models.length).toBeGreaterThan(0);
|
expect(models.length).toBeGreaterThan(0);
|
||||||
const ids = models.map((m: ModelInfo) => m.id);
|
const providers = new Set(models.map((m: ChatModel) => m.provider));
|
||||||
expect(ids).toContain('claude-sonnet-4');
|
expect(providers.has('mistral')).toBe(true);
|
||||||
|
// OpenCode/Anthropic models should be filtered out (no OpenCode key)
|
||||||
|
expect(providers.has('anthropic')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -83,6 +83,13 @@ function setupChatApi() {
|
|||||||
onA2UIMessage: vi.fn(() => vi.fn()),
|
onA2UIMessage: vi.fn(() => vi.fn()),
|
||||||
onTokenUsage: vi.fn(() => vi.fn()),
|
onTokenUsage: vi.fn(() => vi.fn()),
|
||||||
dispatchA2UIAction: 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;
|
} as never;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ describe('AssistantSidebar wiring', () => {
|
|||||||
onA2UIMessage: vi.fn(() => vi.fn()),
|
onA2UIMessage: vi.fn(() => vi.fn()),
|
||||||
onTokenUsage: vi.fn(() => vi.fn()),
|
onTokenUsage: vi.fn(() => vi.fn()),
|
||||||
dispatchA2UIAction: 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;
|
} as never;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ describe('SettingsView i18n', () => {
|
|||||||
getSystemPrompt: vi.fn().mockResolvedValue({ success: true, prompt: '' }),
|
getSystemPrompt: vi.fn().mockResolvedValue({ success: true, prompt: '' }),
|
||||||
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
|
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
|
||||||
getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }),
|
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: {
|
templates: {
|
||||||
...(window as Window & { electronAPI: any }).electronAPI?.templates,
|
...(window as Window & { electronAPI: any }).electronAPI?.templates,
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ describe('MCPAgentButton uninstall', () => {
|
|||||||
getSystemPrompt: vi.fn().mockResolvedValue({ success: true, prompt: '' }),
|
getSystemPrompt: vi.fn().mockResolvedValue({ success: true, prompt: '' }),
|
||||||
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
|
getApiKey: vi.fn().mockResolvedValue({ hasKey: false, maskedKey: '' }),
|
||||||
getAvailableModels: vi.fn().mockResolvedValue({ success: true, models: [], selectedModel: '' }),
|
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([]) },
|
templates: { getEnabledByKind: vi.fn().mockResolvedValue([]) },
|
||||||
projects: { update: vi.fn().mockResolvedValue({}) },
|
projects: { update: vi.fn().mockResolvedValue({}) },
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ describe('assistant sidebar guard rails', () => {
|
|||||||
onA2UIMessage: vi.fn(() => vi.fn()),
|
onA2UIMessage: vi.fn(() => vi.fn()),
|
||||||
onTokenUsage: vi.fn(() => vi.fn()),
|
onTokenUsage: vi.fn(() => vi.fn()),
|
||||||
dispatchA2UIAction: 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;
|
} as never;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ describe('chat surface mode usage guards', () => {
|
|||||||
onA2UIMessage: vi.fn(() => vi.fn()),
|
onA2UIMessage: vi.fn(() => vi.fn()),
|
||||||
onTokenUsage: vi.fn(() => vi.fn()),
|
onTokenUsage: vi.fn(() => vi.fn()),
|
||||||
dispatchA2UIAction: 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;
|
} as never;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ describe('chat surface shared usage guards', () => {
|
|||||||
onA2UIMessage: vi.fn(() => vi.fn()),
|
onA2UIMessage: vi.fn(() => vi.fn()),
|
||||||
onTokenUsage: vi.fn(() => vi.fn()),
|
onTokenUsage: vi.fn(() => vi.fn()),
|
||||||
dispatchA2UIAction: 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;
|
} as never;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user