From 886083ebc950fc6feb544b6c34b0a6bff9caaf9a Mon Sep 17 00:00:00 2001 From: hugo Date: Sun, 1 Mar 2026 14:20:09 +0100 Subject: [PATCH 1/7] chore: agent instructions --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 9a4525e..475368f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ This document provides context and best practices for GitHub Copilot when workin ## Commits +- our default branch is origin/master - commit messages are short - one sentence. do not write long articles. - pull requests are more verbose and especially give reasoning for changes From c911ec23543c89d707a0cbcb3dbf8795be0edd0f Mon Sep 17 00:00:00 2001 From: hugo Date: Sun, 1 Mar 2026 14:41:42 +0100 Subject: [PATCH 2/7] feat: add Mistral AI as first-class alternative provider --- src/main/engine/OpenCodeManager.ts | 565 ++++++++++++++---- src/main/ipc/chatHandlers.ts | 111 ++++ src/main/preload.ts | 11 + src/main/shared/electronApi.ts | 13 + .../components/ChatPanel/ChatPanel.css | 15 + .../components/ChatPanel/ChatPanel.tsx | 34 +- .../ImportAnalysisView/ImportAnalysisView.tsx | 33 +- .../components/SettingsView/SettingsView.tsx | 183 +++++- src/renderer/i18n/locales/de.json | 12 + src/renderer/i18n/locales/en.json | 12 + src/renderer/i18n/locales/es.json | 12 + src/renderer/i18n/locales/fr.json | 12 + src/renderer/i18n/locales/it.json | 12 + tests/engine/OpenCodeManagerMistral.test.ts | 511 ++++++++++++++++ tests/engine/OpenCodeModelDiscovery.test.ts | 13 +- .../a2ui/surfaceActionWiring.test.tsx | 7 + .../AssistantSidebar.wiring.test.tsx | 7 + .../components/SettingsView.i18n.test.tsx | 4 + .../renderer/components/SettingsView.test.tsx | 4 + .../navigation/assistantSidebarGuards.test.ts | 7 + .../chatSurfaceModeUsageGuards.test.ts | 7 + .../navigation/chatSurfaceUsageGuards.test.ts | 7 + 22 files changed, 1425 insertions(+), 167 deletions(-) create mode 100644 tests/engine/OpenCodeManagerMistral.test.ts diff --git a/src/main/engine/OpenCodeManager.ts b/src/main/engine/OpenCodeManager.ts index 5cfa531..757106d 100644 --- a/src/main/engine/OpenCodeManager.ts +++ b/src/main/engine/OpenCodeManager.ts @@ -33,6 +33,10 @@ const ZEN_ANTHROPIC_URL = 'https://opencode.ai/zen/v1/messages'; const ZEN_OPENAI_URL = 'https://opencode.ai/zen/v1/chat/completions'; const ZEN_MODELS_URL = 'https://opencode.ai/zen/v1/models'; +// Mistral API endpoints +const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions'; +const MISTRAL_MODELS_URL = 'https://api.mistral.ai/v1/models'; + // Known model display names: maps model IDs to polished names and serves as offline fallback const MODEL_DISPLAY_NAMES: Record = { // Anthropic Claude @@ -75,6 +79,12 @@ const MODEL_DISPLAY_NAMES: Record = { 'kimi-k2-thinking': 'Kimi K2 Thinking', 'big-pickle': 'Big Pickle', 'trinity-large-preview-free': 'Trinity Large Preview Free', + // Mistral AI + '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', }; @@ -82,10 +92,49 @@ const MODEL_DISPLAY_NAMES: Record = { // Uppercase prefixes that should not be title-cased const UPPERCASE_PREFIXES = ['gpt', 'glm']; +// Per-model context token budgets for truncation +// OpenCode models default to 150,000; Mistral models have specific budgets +const MODEL_CONTEXT_BUDGETS: Record = { + '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, +}; + +// Vision capabilities per model (APIs don't expose this) +const MODEL_CAPABILITIES: Record = { + // Anthropic Claude — all vision-capable + 'claude-opus-4-6': { vision: true }, + 'claude-opus-4-5': { vision: true }, + 'claude-opus-4-1': { vision: true }, + 'claude-sonnet-4-6': { vision: true }, + 'claude-sonnet-4-5': { vision: true }, + 'claude-sonnet-4': { vision: true }, + 'claude-haiku-4-5': { vision: true }, + 'claude-3-5-haiku': { vision: true }, + // OpenAI GPT — most are vision-capable + 'gpt-5': { vision: true }, + 'gpt-5.1': { vision: true }, + 'gpt-5.2': { vision: true }, + 'gpt-5-nano': { vision: true }, + // Google Gemini — vision-capable + 'gemini-3.1-pro': { vision: true }, + 'gemini-3-pro': { vision: true }, + 'gemini-3-flash': { vision: true }, + // Mistral AI + '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 }, +}; + export interface ModelInfo { id: string; name: string; provider: string; + vision?: boolean; } export interface SendMessageOptions { @@ -171,6 +220,7 @@ export class OpenCodeManager { private postMediaEngine: PostMediaEngine; private getMainWindow: () => BrowserWindow | null; private apiKey: string = ''; + private mistralApiKey: string = ''; private abortControllers: Map = new Map(); private cachedModels: ModelInfo[] | null = null; private cachedModelsAt: number = 0; @@ -212,17 +262,38 @@ export class OpenCodeManager { } /** - * Check if the service is configured and ready + * Set API key for Mistral AI */ - async checkReady(): Promise<{ ready: boolean; error?: string }> { - if (!this.apiKey) { - return { ready: false, error: 'API key not configured' }; - } - return { ready: true }; + setMistralApiKey(key: string): void { + this.mistralApiKey = key; + // Invalidate model cache so merged list is re-fetched + this.cachedModels = null; + this.cachedModelsAt = 0; } /** - * Validate an API key by calling the models endpoint + * Get current Mistral API key + */ + 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: ModelInfo[] }> { if (!apiKey || apiKey.length < 3) { @@ -242,7 +313,11 @@ export class OpenCodeManager { headers, }); if (response.statusCode >= 200 && response.statusCode < 300) { - return { isValid: true, models: Object.entries(MODEL_DISPLAY_NAMES).map(([id, name]) => ({ id, name, provider: this.detectProvider(id) })) }; + // Filter to only OpenCode models (not Mistral) + const models = Object.entries(MODEL_DISPLAY_NAMES) + .map(([id, name]) => ({ id, name, provider: this.detectProvider(id), vision: MODEL_CAPABILITIES[id]?.vision ?? false })) + .filter(m => this.isProviderKeySet(m.provider) || m.provider !== 'mistral'); + return { isValid: true, models }; } } catch { // Try next auth method @@ -252,8 +327,42 @@ export class OpenCodeManager { return { isValid: false, models: [] }; } + /** + * Validate a Mistral API key by calling the Mistral models endpoint + */ + async validateMistralApiKey(apiKey: string): Promise<{ isValid: boolean; models: ModelInfo[] }> { + if (!apiKey || apiKey.length < 3) { + return { isValid: false, models: [] }; + } + + 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) { + // Return Mistral models from display name map + const models = Object.entries(MODEL_DISPLAY_NAMES) + .filter(([id]) => this.detectProvider(id) === 'mistral') + .map(([id, name]) => ({ id, name, provider: 'mistral', vision: MODEL_CAPABILITIES[id]?.vision ?? false })); + 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 { // Return cached models if within TTL @@ -261,7 +370,10 @@ export class OpenCodeManager { return this.cachedModels; } - // Try fetching from API + const allModels: ModelInfo[] = []; + let fetched = false; + + // Fetch OpenCode models if (this.apiKey) { try { const response = await this.httpRequest(ZEN_MODELS_URL, { @@ -274,14 +386,15 @@ export class OpenCodeManager { if (response.statusCode === 200) { const data = JSON.parse(response.body); if (data.data && Array.isArray(data.data)) { - const models = data.data.map((m: { id: string }) => ({ - id: m.id, - name: this.formatModelName(m.id), - provider: this.detectProvider(m.id), - })); - this.cachedModels = models; - this.cachedModelsAt = Date.now(); - return models; + for (const m of data.data as Array<{ id: string }>) { + allModels.push({ + id: m.id, + name: this.formatModelName(m.id), + provider: this.detectProvider(m.id), + vision: MODEL_CAPABILITIES[m.id]?.vision ?? false, + }); + } + fetched = true; } } } catch { @@ -289,12 +402,52 @@ export class OpenCodeManager { } } - // Build fallback from display name map - const fallback = Object.entries(MODEL_DISPLAY_NAMES).map(([id, name]) => ({ - id, - name, - provider: this.detectProvider(id), - })); + // Fetch Mistral models + if (this.mistralApiKey) { + try { + const response = await this.httpRequest(MISTRAL_MODELS_URL, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${this.mistralApiKey}`, + }, + }); + if (response.statusCode === 200) { + const data = JSON.parse(response.body); + if (data.data && Array.isArray(data.data)) { + for (const m of data.data as Array<{ id: string }>) { + // Only include models we know about (have display names) + if (MODEL_DISPLAY_NAMES[m.id]) { + allModels.push({ + id: m.id, + name: this.formatModelName(m.id), + provider: 'mistral', + vision: MODEL_CAPABILITIES[m.id]?.vision ?? false, + }); + } + } + fetched = true; + } + } + } catch { + // Fall through to fallback + } + } + + if (fetched && allModels.length > 0) { + this.cachedModels = allModels; + this.cachedModelsAt = Date.now(); + return allModels; + } + + // Build fallback from display name map, filtered by available provider keys + const fallback = Object.entries(MODEL_DISPLAY_NAMES) + .map(([id, name]) => ({ + id, + name, + provider: this.detectProvider(id), + vision: MODEL_CAPABILITIES[id]?.vision ?? false, + })) + .filter(m => this.isProviderKeySet(m.provider)); return fallback; } @@ -335,6 +488,12 @@ export class OpenCodeManager { const modelId = conversation.model || 'claude-sonnet-4'; const provider = this.detectProvider(modelId); + // Check that the provider's API key is available + if (!this.isProviderKeySet(provider)) { + const providerLabel = provider === 'mistral' ? 'Mistral' : 'OpenCode'; + return { success: false, error: `The model '${modelId}' requires a ${providerLabel} API key. Configure it in Settings.` }; + } + // Get system prompt const systemMessage = conversation.messages.find(m => m.role === 'system'); const basePrompt = systemMessage?.content || await this.chatEngine.getDefaultSystemPrompt(); @@ -387,6 +546,8 @@ export class OpenCodeManager { ); } + // Get provider-specific config (URL, key, options) + const config = this.getProviderConfig(provider); return this.sendOpenAIMessage( modelId, prompt, @@ -395,6 +556,9 @@ export class OpenCodeManager { { onDelta, onToolCall, onToolResult, onTokenUsage }, conversationId, emitA2UIMessages, + config.apiUrl, + config.apiKey, + config.options, ); }; @@ -735,7 +899,8 @@ export class OpenCodeManager { } /** - * Send via OpenAI-compatible API (non-Claude models) + * Send via OpenAI-compatible API (non-Claude models, including Mistral) + * Parameterized to support multiple providers with identical API format. */ private async sendOpenAIMessage( modelId: string, @@ -750,6 +915,9 @@ export class OpenCodeManager { }, conversationId: string, emitA2UIMessages: (messages: A2UIServerMessage[]) => void, + apiUrl: string = ZEN_OPENAI_URL, + apiKey: string = this.apiKey, + providerOptions?: { parallelToolCalls?: boolean }, ): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> { // Build OpenAI-format messages const allMessages: Array> = [ @@ -775,12 +943,13 @@ export class OpenCodeManager { // Truncate conversation history to fit within context window // Keep system message (index 0), truncate from oldest conversation messages + const contextBudget = MODEL_CONTEXT_BUDGETS[modelId] ?? 150000; const conversationMessages = allMessages.slice(1); const anthropicFmt = conversationMessages.map(m => ({ role: m.role as 'user' | 'assistant', content: (m.content as string) || '', })); - const truncated = this.truncateToTokenBudget(anthropicFmt, systemPrompt, anthropicTools); + const truncated = this.truncateToTokenBudget(anthropicFmt, systemPrompt, anthropicTools, contextBudget); const messages: Array> = [ allMessages[0], ...truncated.map(m => ({ role: m.role, content: m.content })), @@ -804,14 +973,19 @@ export class OpenCodeManager { stream_options: { include_usage: true }, }; + // Set parallel_tool_calls based on provider options (Mistral needs false) + if (providerOptions?.parallelToolCalls === false) { + body.parallel_tool_calls = false; + } + // Retry only the HTTP connection (429/502/503 are caught before any events are emitted). // Event processing is outside retry scope to prevent double-emission of onDelta on retry. const { events } = await withRetry(async () => { - return httpRequestStream(ZEN_OPENAI_URL, { + return httpRequestStream(apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', - 'Authorization': `Bearer ${this.apiKey}`, + 'Authorization': `Bearer ${apiKey}`, }, body: JSON.stringify(body), signal, @@ -970,11 +1144,39 @@ export class OpenCodeManager { callbacks.onToolResult({ name: toolName, result }); } - messages.push({ - role: 'tool', - content: JSON.stringify(result), - tool_call_id: toolCall.id, - }); + // Check for image result that needs multimodal formatting (OpenAI image_url format) + if (result && typeof result === 'object' && (result as Record).__isImageResult) { + const imageResult = result as { + __isImageResult: boolean; + success: boolean; + mediaType: string; + base64: string; + metadata: Record; + }; + + messages.push({ + role: 'tool', + content: [ + { + type: 'image_url', + image_url: { + url: `data:${imageResult.mediaType};base64,${imageResult.base64}`, + }, + }, + { + type: 'text', + text: JSON.stringify({ success: true, metadata: imageResult.metadata }), + }, + ], + tool_call_id: toolCall.id, + }); + } else { + messages.push({ + role: 'tool', + content: JSON.stringify(result), + tool_call_id: toolCall.id, + }); + } } if (signal.aborted) break; @@ -1850,7 +2052,9 @@ export class OpenCodeManager { } /** - * Generate a title for a conversation + * Generate a title for a conversation. + * Uses the configured title model (fallback: claude-haiku-4-5) and routes + * the request to the correct provider API. */ private async generateConversationTitle( conversationId: string, @@ -1858,32 +2062,40 @@ export class OpenCodeManager { _assistantResponse: string ): Promise { try { - const body = { - model: 'claude-haiku-4-5', - max_tokens: 20, - system: 'Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Output ONLY the title text.', - messages: [ - { - role: 'user', - content: `Topic: ${userMessage.substring(0, 100)}`, + // Read configured title model + const titleModel = await this.chatEngine.getSetting('chat_title_model') || 'claude-haiku-4-5'; + const provider = this.detectProvider(titleModel); + + // Ensure we have the key for this provider + if (!this.isProviderKeySet(provider)) return; + + const promptText = `Topic: ${userMessage.substring(0, 100)}`; + const systemText = 'Generate an ultra-short title (2-3 words, max 25 characters) for this conversation. Focus ONLY on the topic. Ignore any capability disclaimers. Output ONLY the title text.'; + + let title = ''; + + if (provider === 'anthropic') { + const body = { + model: titleModel, + max_tokens: 20, + system: systemText, + messages: [{ role: 'user', content: promptText }], + }; + + const response = await this.httpRequest(ZEN_ANTHROPIC_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey, + 'Authorization': `Bearer ${this.apiKey}`, + 'anthropic-version': '2023-06-01', }, - ], - }; + body: JSON.stringify(body), + }); - const response = await this.httpRequest(ZEN_ANTHROPIC_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': this.apiKey, - 'Authorization': `Bearer ${this.apiKey}`, - 'anthropic-version': '2023-06-01', - }, - body: JSON.stringify(body), - }); + if (response.statusCode !== 200) return; - if (response.statusCode === 200) { const data = JSON.parse(response.body); - let title = ''; if (Array.isArray(data.content)) { title = data.content .filter((b: AnthropicContentBlock) => b.type === 'text') @@ -1892,23 +2104,47 @@ export class OpenCodeManager { } else { title = data.content || ''; } + } else { + // OpenAI-compatible (includes Mistral) + const config = this.getProviderConfig(provider); + const body = { + model: titleModel, + max_tokens: 20, + messages: [ + { role: 'system', content: systemText }, + { role: 'user', content: promptText }, + ], + }; - // Clean up and truncate title - title = title.trim().replace(/^["']|["']$/g, '').replace(/[.!?]+$/, ''); - - // Hard limit on title length - const MAX_TITLE_LENGTH = 30; - if (title.length > MAX_TITLE_LENGTH) { - title = title.substring(0, MAX_TITLE_LENGTH - 1) + '…'; - } + const response = await this.httpRequest(config.apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${config.apiKey}`, + }, + body: JSON.stringify(body), + }); - if (title) { - await this.chatEngine.updateConversation(conversationId, { title }); + if (response.statusCode !== 200) return; - const mainWindow = this.getMainWindow(); - if (mainWindow) { - mainWindow.webContents.send('chat-title-updated', { conversationId, title }); - } + const data = JSON.parse(response.body); + title = data.choices?.[0]?.message?.content || ''; + } + + // Clean up and truncate title + title = title.trim().replace(/^["']|["']$/g, '').replace(/[.!?]+$/, ''); + + const MAX_TITLE_LENGTH = 30; + if (title.length > MAX_TITLE_LENGTH) { + title = title.substring(0, MAX_TITLE_LENGTH - 1) + '…'; + } + + if (title) { + await this.chatEngine.updateConversation(conversationId, { title }); + + const mainWindow = this.getMainWindow(); + if (mainWindow) { + mainWindow.webContents.send('chat-title-updated', { conversationId, title }); } } } catch (error) { @@ -1996,11 +2232,32 @@ NOTE: Use pagination (offset/limit) in list_posts and search_posts to access all return this.modelCatalogEngine; } + /** + * 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; + } + + /** + * Return API URL, key and provider-specific options for a given provider. + * Used to parameterise sendOpenAIMessage() for non-Anthropic providers. + */ + private getProviderConfig(provider: string): { apiUrl: string; apiKey: string; options?: { parallelToolCalls?: boolean } } { + if (provider === 'mistral') { + return { apiUrl: MISTRAL_API_URL, apiKey: this.mistralApiKey, options: { parallelToolCalls: false } }; + } + return { apiUrl: ZEN_OPENAI_URL, apiKey: this.apiKey }; + } + private detectProvider(modelId: string): string { const id = modelId.toLowerCase(); if (id.startsWith('claude')) return 'anthropic'; if (id.startsWith('gpt') || id.startsWith('o3') || id.startsWith('o4')) return 'openai'; if (id.startsWith('gemini')) return 'google'; + if (id.startsWith('mistral') || id.startsWith('ministral') || id.startsWith('devstral') || id.startsWith('codestral') || id.startsWith('pixtral')) return 'mistral'; return 'other'; } @@ -2053,11 +2310,11 @@ NOTE: Use pagination (offset/limit) in list_posts and search_posts to access all tagMappings?: Record; error?: string; }> { - if (!this.apiKey) { - return { success: false, error: 'API key not set' }; - } - const provider = this.detectProvider(modelId); + if (!this.isProviderKeySet(provider)) { + const providerLabel = provider === 'mistral' ? 'Mistral' : 'OpenCode'; + return { success: false, error: `${providerLabel} API key not set` }; + } // Build the prompt for taxonomy analysis const existingCategories = categories.filter(c => c.existsInProject).map(c => c.name); @@ -2148,7 +2405,8 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu } } } else { - // OpenAI-compatible + // OpenAI-compatible (includes Mistral) + const config = this.getProviderConfig(provider); const body = { model: modelId, max_tokens: 4096, @@ -2158,11 +2416,11 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu ], }; - const response = await this.httpRequest(ZEN_OPENAI_URL, { + const response = await this.httpRequest(config.apiUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${this.apiKey}`, + Authorization: `Bearer ${config.apiKey}`, }, body: JSON.stringify(body), }); @@ -2224,7 +2482,8 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu /** * Analyze a media image and generate title, alt text, and caption using AI - * This is a one-shot request that looks at the image and suggests metadata + * This is a one-shot request that looks at the image and suggests metadata. + * Uses the configured image analysis model and routes to the correct provider. */ async analyzeMediaImage(mediaId: string, language: string = 'en'): Promise<{ success: boolean; @@ -2233,8 +2492,13 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu caption?: string; error?: string; }> { - if (!this.apiKey) { - return { success: false, error: 'API key not configured. Please set your OpenCode API key in Settings.' }; + // Read configured image analysis model (default: claude-sonnet-4-5) + const modelId = await this.chatEngine.getSetting('chat_image_analysis_model') || 'claude-sonnet-4-5'; + const provider = this.detectProvider(modelId); + + if (!this.isProviderKeySet(provider)) { + const providerLabel = provider === 'mistral' ? 'Mistral' : 'OpenCode'; + return { success: false, error: `API key not configured. Please set your ${providerLabel} API key in Settings.` }; } // Get media metadata @@ -2278,59 +2542,100 @@ CAPTION: Short, engaging blog caption (5-20 words). Respond with JSON only: {"title": "...", "alt": "...", "caption": "..."}`; try { - // Using Claude Sonnet 4.5 for best image analysis - const modelId = 'claude-sonnet-4-5'; - - const body = { - model: modelId, - max_tokens: 200, - system: systemPrompt, - messages: [ - { - role: 'user', - content: [ - { - type: 'image', - source: { - type: 'base64', - media_type: 'image/webp', - data: base64Data, - }, - }, - { - type: 'text', - text: 'Analyze and respond with JSON.', - }, - ], - }, - ], - }; - - const response = await this.httpRequest(ZEN_ANTHROPIC_URL, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': this.apiKey, - 'Authorization': `Bearer ${this.apiKey}`, - 'anthropic-version': '2023-06-01', - }, - 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); - - // Extract text from Anthropic response let responseText = ''; - for (const block of data.content || []) { - if (block.type === 'text') { - responseText += block.text; + + if (provider === 'anthropic') { + const body = { + model: modelId, + max_tokens: 200, + system: systemPrompt, + messages: [ + { + role: 'user', + content: [ + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/webp', + data: base64Data, + }, + }, + { + type: 'text', + text: 'Analyze and respond with JSON.', + }, + ], + }, + ], + }; + + const response = await this.httpRequest(ZEN_ANTHROPIC_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': this.apiKey, + 'Authorization': `Bearer ${this.apiKey}`, + 'anthropic-version': '2023-06-01', + }, + 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); + for (const block of data.content || []) { + if (block.type === 'text') { + responseText += block.text; + } + } + } else { + // OpenAI-compatible (includes Mistral) + const config = this.getProviderConfig(provider); + const body = { + model: modelId, + max_tokens: 200, + messages: [ + { role: 'system', content: systemPrompt }, + { + role: 'user', + content: [ + { + type: 'image_url', + image_url: { + url: `data:image/webp;base64,${base64Data}`, + }, + }, + { + type: 'text', + text: 'Analyze and respond with JSON.', + }, + ], + }, + ], + }; + + const response = await this.httpRequest(config.apiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${config.apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (response.statusCode !== 200) { + console.error('[OpenCodeManager] Image analysis failed:', response.body); + const errorMsg = this.parseErrorResponse(response); + return { success: false, error: errorMsg }; + } + + const data = JSON.parse(response.body); + responseText = data.choices?.[0]?.message?.content || ''; } // Parse the JSON response diff --git a/src/main/ipc/chatHandlers.ts b/src/main/ipc/chatHandlers.ts index cea3395..31485a2 100644 --- a/src/main/ipc/chatHandlers.ts +++ b/src/main/ipc/chatHandlers.ts @@ -78,6 +78,16 @@ async function getOpenCodeManager(): Promise { } catch { // Silently ignore errors loading the key } + + // Load Mistral API key from encrypted storage + try { + const mistralKey = await keyStore.retrieve('mistral_api_key'); + if (mistralKey) { + openCodeManager!.setMistralApiKey(mistralKey); + } + } catch { + // Silently ignore errors loading the Mistral key + } })(); } @@ -104,6 +114,7 @@ export function registerChatHandlers(): void { ready: result.ready, error: result.error, backend: 'opencode', + providers: result.providers, }; } catch (error) { console.error('[Chat IPC] Error checking ready:', error); @@ -160,6 +171,106 @@ export function registerChatHandlers(): void { } }); + // ============ Mistral API Key ============ + + // Validate Mistral API key + ipcMain.handle('chat:validateMistralApiKey', async (_, apiKey: string) => { + try { + const manager = await getOpenCodeManager(); + const result = await manager.validateMistralApiKey(apiKey); + return result; + } catch (error) { + console.error('[Chat IPC] Error validating Mistral API key:', error); + return { isValid: false, models: [] }; + } + }); + + // Set Mistral API key + ipcMain.handle('chat:setMistralApiKey', async (_, apiKey: string) => { + try { + const manager = await getOpenCodeManager(); + const previousKey = manager.getMistralApiKey(); + manager.setMistralApiKey(apiKey); + + // Persist to encrypted storage — roll back in-memory key on failure + try { + await getSecureKeyStore().store('mistral_api_key', apiKey); + } catch (storeError) { + manager.setMistralApiKey(previousKey); + throw storeError; + } + + return { success: true }; + } catch (error) { + console.error('[Chat IPC] Error setting Mistral API key:', error); + return { success: false, error: (error as Error).message }; + } + }); + + // Get Mistral API key (masked) + ipcMain.handle('chat:getMistralApiKey', async () => { + try { + const manager = await getOpenCodeManager(); + const key = manager.getMistralApiKey(); + if (!key) return { hasKey: false, maskedKey: '' }; + const masked = '•'.repeat(Math.max(0, key.length - 4)) + key.slice(-4); + return { hasKey: true, maskedKey: masked }; + } catch (error) { + console.error('[Chat IPC] Error getting Mistral API key:', error); + return { hasKey: false, maskedKey: '' }; + } + }); + + // ============ Per-Purpose Model Preferences ============ + + // Get title generation model + ipcMain.handle('chat:getTitleModel', async () => { + try { + const engine = getChatEngine(); + const model = await engine.getSetting('chat_title_model'); + return { success: true, modelId: model || 'claude-haiku-4-5' }; + } catch (error) { + console.error('[Chat IPC] Error getting title model:', error); + return { success: false, modelId: 'claude-haiku-4-5' }; + } + }); + + // 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 || 'claude-sonnet-4-5' }; + } catch (error) { + console.error('[Chat IPC] Error getting image analysis model:', error); + return { success: false, modelId: 'claude-sonnet-4-5' }; + } + }); + + // Set image analysis model + ipcMain.handle('chat:setImageAnalysisModel', async (_, modelId: string) => { + try { + const engine = getChatEngine(); + await engine.setSetting('chat_image_analysis_model', modelId); + return { success: true }; + } catch (error) { + console.error('[Chat IPC] Error setting image analysis model:', error); + return { success: false, error: (error as Error).message }; + } + }); + // ============ Chat Settings ============ // Get available models diff --git a/src/main/preload.ts b/src/main/preload.ts index 57afbf5..1ba9d79 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -309,6 +309,17 @@ export const electronAPI: ElectronAPI = { setApiKey: (apiKey: string) => ipcRenderer.invoke('chat:setApiKey', apiKey), getApiKey: () => ipcRenderer.invoke('chat:getApiKey'), + // Mistral API Key Management + validateMistralApiKey: (apiKey: string) => ipcRenderer.invoke('chat:validateMistralApiKey', apiKey), + setMistralApiKey: (apiKey: string) => ipcRenderer.invoke('chat:setMistralApiKey', apiKey), + getMistralApiKey: () => ipcRenderer.invoke('chat:getMistralApiKey'), + + // Per-Purpose Model Preferences + getTitleModel: () => ipcRenderer.invoke('chat:getTitleModel'), + setTitleModel: (modelId: string | null) => ipcRenderer.invoke('chat:setTitleModel', modelId), + getImageAnalysisModel: () => ipcRenderer.invoke('chat:getImageAnalysisModel'), + setImageAnalysisModel: (modelId: string | null) => ipcRenderer.invoke('chat:setImageAnalysisModel', modelId), + // Settings getAvailableModels: () => ipcRenderer.invoke('chat:getAvailableModels'), setDefaultModel: (modelId: string) => ipcRenderer.invoke('chat:setDefaultModel', modelId), diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index c75b41e..91dfad0 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -422,6 +422,7 @@ export interface ChatModel { id: string; name: string; provider?: string; + vision?: boolean; } export interface ModelCatalogEntry { @@ -450,6 +451,7 @@ export interface ChatReadyStatus { ready: boolean; error?: string; backend?: string; + providers?: { opencode: boolean; mistral: boolean }; } export interface ChatApiKeyStatus { @@ -825,12 +827,23 @@ export interface ElectronAPI { setApiKey: (apiKey: string) => Promise<{ success: boolean; error?: string }>; getApiKey: () => Promise; + // Mistral API Key + validateMistralApiKey: (apiKey: string) => Promise<{ isValid: boolean; models: ChatModel[] }>; + setMistralApiKey: (apiKey: string) => Promise<{ success: boolean; error?: string }>; + getMistralApiKey: () => Promise; + // Settings getAvailableModels: () => Promise<{ success: boolean; models?: ChatModel[]; selectedModel?: string; error?: string }>; setDefaultModel: (modelId: string) => Promise<{ success: boolean; error?: string }>; getSystemPrompt: () => Promise<{ success: boolean; prompt?: string; error?: string }>; setSystemPrompt: (prompt: string) => Promise<{ success: boolean; error?: string }>; + // Per-purpose model preferences + getTitleModel: () => Promise<{ success: boolean; modelId?: string | null; error?: string }>; + setTitleModel: (modelId: string | null) => Promise<{ success: boolean; error?: string }>; + getImageAnalysisModel: () => Promise<{ success: boolean; modelId?: string | null; error?: string }>; + setImageAnalysisModel: (modelId: string | null) => Promise<{ success: boolean; error?: string }>; + // Model Catalog refreshModelCatalog: () => Promise; getModelCatalog: () => Promise<{ success: boolean; entries: ModelCatalogEntry[]; error?: string }>; diff --git a/src/renderer/components/ChatPanel/ChatPanel.css b/src/renderer/components/ChatPanel/ChatPanel.css index 8db2caf..81695ce 100644 --- a/src/renderer/components/ChatPanel/ChatPanel.css +++ b/src/renderer/components/ChatPanel/ChatPanel.css @@ -85,6 +85,21 @@ color: var(--vscode-list-activeSelectionForeground); } +.model-group-header { + padding: 6px 12px 4px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--vscode-descriptionForeground); + pointer-events: none; + border-top: 1px solid var(--vscode-dropdown-border); +} + +.model-group-header:first-child { + border-top: none; +} + .chat-messages { flex: 1; overflow-y: auto; diff --git a/src/renderer/components/ChatPanel/ChatPanel.tsx b/src/renderer/components/ChatPanel/ChatPanel.tsx index 8de6bc6..7fedb38 100644 --- a/src/renderer/components/ChatPanel/ChatPanel.tsx +++ b/src/renderer/components/ChatPanel/ChatPanel.tsx @@ -355,15 +355,31 @@ export const ChatPanel: React.FC = ({ conversationId }) => { {showModelSelector && (
- {availableModels.map(model => ( - - ))} + {(() => { + // Group models by provider for visual separation + const groups: Record = {}; + 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]) => ( + + {Object.keys(groups).length > 1 && ( +
{provider === 'mistral' ? 'Mistral' : 'OpenCode'}
+ )} + {models.map(model => ( + + ))} +
+ )); + })()}
)} diff --git a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx index 01c5fd4..985b586 100644 --- a/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx +++ b/src/renderer/components/ImportAnalysisView/ImportAnalysisView.tsx @@ -1371,15 +1371,30 @@ const TaxonomySection: React.FC<{ {showModelSelector && (
- {availableModels.map(model => ( - - ))} + {(() => { + const groups: Record = {}; + 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]) => ( + + {Object.keys(groups).length > 1 && ( +
{provider === 'mistral' ? 'Mistral' : 'OpenCode'}
+ )} + {models.map(model => ( + + ))} +
+ )); + })()}
)} diff --git a/src/renderer/components/SettingsView/SettingsView.tsx b/src/renderer/components/SettingsView/SettingsView.tsx index fa95e99..eac422c 100644 --- a/src/renderer/components/SettingsView/SettingsView.tsx +++ b/src/renderer/components/SettingsView/SettingsView.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useRef, useCallback } from 'react'; +import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useAppStore } from '../../store'; import { showToast } from '../Toast'; import { useI18n } from '../../i18n'; @@ -242,7 +242,12 @@ export const SettingsView: React.FC = () => { const [aiApiKeyMasked, setAiApiKeyMasked] = useState(''); const [aiHasApiKey, setAiHasApiKey] = useState(false); const [newApiKey, setNewApiKey] = useState(''); - const [availableModels, setAvailableModels] = useState<{id: string; name: string}[]>([]); + const [aiHasMistralKey, setAiHasMistralKey] = useState(false); + const [aiMistralKeyMasked, setAiMistralKeyMasked] = useState(''); + const [newMistralKey, setNewMistralKey] = useState(''); + const [titleModel, setTitleModel] = useState('claude-haiku-4-5'); + const [imageAnalysisModel, setImageAnalysisModel] = useState('claude-sonnet-4-5'); + const [availableModels, setAvailableModels] = useState<{id: string; name: string; provider?: string; vision?: boolean}[]>([]); const [selectedModel, setSelectedModel] = useState(''); const [modelCatalog, setModelCatalog] = useState { setSelectedModel(modelsResult.selectedModel || ''); } + // Load Mistral API key status + const mistralKeyResult = await window.electronAPI?.chat.getMistralApiKey(); + if (mistralKeyResult) { + setAiHasMistralKey(mistralKeyResult.hasKey); + setAiMistralKeyMasked(mistralKeyResult.maskedKey || ''); + } + + // Load per-purpose model preferences + const titleModelResult = await window.electronAPI?.chat.getTitleModel(); + if (titleModelResult?.success && titleModelResult.modelId) { + setTitleModel(titleModelResult.modelId); + } + const imageModelResult = await window.electronAPI?.chat.getImageAnalysisModel(); + if (imageModelResult?.success && imageModelResult.modelId) { + setImageAnalysisModel(imageModelResult.modelId); + } + // Load model catalog metadata const catalogResult = await window.electronAPI?.chat.getModelCatalog(); if (catalogResult?.success && catalogResult.entries) { @@ -1080,6 +1102,13 @@ export const SettingsView: React.FC = () => { setAiApiKeyMasked('•'.repeat(Math.max(0, newApiKey.length - 4)) + newApiKey.slice(-4)); setNewApiKey(''); showToast.success(t('settings.toast.apiKeySaved')); + + // Refresh models after key change + const modelsResult = await window.electronAPI?.chat.getAvailableModels(); + if (modelsResult?.success && modelsResult.models) { + setAvailableModels(modelsResult.models); + setSelectedModel(modelsResult.selectedModel || ''); + } } else { showToast.error(t('settings.toast.apiKeyInvalid')); } @@ -1089,6 +1118,54 @@ export const SettingsView: React.FC = () => { } }; + const handleSaveMistralApiKey = async () => { + if (!newMistralKey.trim()) return; + try { + const validateResult = await window.electronAPI?.chat.validateMistralApiKey(newMistralKey.trim()); + if (validateResult?.isValid) { + await window.electronAPI?.chat.setMistralApiKey(newMistralKey.trim()); + setAiHasMistralKey(true); + setAiMistralKeyMasked('•'.repeat(Math.max(0, newMistralKey.length - 4)) + newMistralKey.slice(-4)); + setNewMistralKey(''); + showToast.success(t('settings.toast.apiKeySaved')); + + // Refresh models after key change + const modelsResult = await window.electronAPI?.chat.getAvailableModels(); + if (modelsResult?.success && modelsResult.models) { + setAvailableModels(modelsResult.models); + setSelectedModel(modelsResult.selectedModel || ''); + } + } else { + showToast.error(t('settings.toast.apiKeyInvalid')); + } + } catch (error) { + console.error('Failed to save Mistral API key:', error); + showToast.error(t('settings.toast.apiKeySaveFailed')); + } + }; + + const handleTitleModelChange = async (modelId: string) => { + try { + const result = await window.electronAPI?.chat.setTitleModel(modelId); + if (result?.success) { + setTitleModel(modelId); + } + } catch (error) { + console.error('Failed to set title model:', error); + } + }; + + const handleImageAnalysisModelChange = async (modelId: string) => { + try { + const result = await window.electronAPI?.chat.setImageAnalysisModel(modelId); + if (result?.success) { + setImageAnalysisModel(modelId); + } + } catch (error) { + console.error('Failed to set image analysis model:', error); + } + }; + const handleModelChange = async (modelId: string) => { try { const result = await window.electronAPI?.chat.setDefaultModel(modelId); @@ -1137,6 +1214,37 @@ export const SettingsView: React.FC = () => { } }; + // Group models by provider for optgroup display + const groupedModels = useMemo(() => { + const groups: Record = {}; + for (const model of availableModels) { + const provider = model.provider || 'other'; + if (!groups[provider]) groups[provider] = []; + groups[provider].push(model); + } + return groups; + }, [availableModels]); + + 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 onChange(e.target.value)} disabled={disabled}> + {availableModels.length === 0 && } + {Object.entries(groupedModels).map(([provider, models]) => ( + + {models.map(model => ( + + ))} + + ))} + + ); + const renderAISettings = () => ( { )} + +
+ {aiHasMistralKey ? ( + <> + + {t('settings.ai.configured')} + + ) : ( + <> + setNewMistralKey(e.target.value)} + placeholder={t('chat.apiKeyPlaceholder')} + /> + + + )} +
+ {aiHasMistralKey && ( +
+ +
+ )} +
+
- + {renderModelSelect('ai-model', selectedModel, handleModelChange, !aiHasApiKey && !aiHasMistralKey)} - {apiKeyError &&
{apiKeyError}
}
diff --git a/src/renderer/i18n/locales/de.json b/src/renderer/i18n/locales/de.json index 70bca31..455df03 100644 --- a/src/renderer/i18n/locales/de.json +++ b/src/renderer/i18n/locales/de.json @@ -192,13 +192,11 @@ "settings.toast.thumbnailsComplete": "Generierung der Vorschaubilder abgeschlossen", "settings.toast.thumbnailsFailed": "Vorschaubilder konnten nicht erzeugt werden", "chat.setupTitle": "KI-Chat-Einrichtung", - "chat.apiKeyRequiredTitle": "OpenCode Zen API-Schlüssel erforderlich", - "chat.apiKeyRequiredDescription": "Gib deinen OpenCode API-Schlüssel ein, um den KI-Chat zu aktivieren.", + "chat.apiKeyRequiredTitle": "API-Schlüssel erforderlich", + "chat.apiKeyRequiredDescription": "Konfiguriere einen API-Schlüssel in den Einstellungen, um den KI-Chat zu aktivieren.", + "chat.openSettings": "Einstellungen öffnen", "chat.apiKeyPlaceholder": "API-Schlüssel eingeben...", "chat.apiKeySave": "Schlüssel speichern", - "chat.apiKeyValidating": "Wird validiert...", - "chat.apiKeyInvalid": "Ungültiger API-Schlüssel. Bitte prüfen und erneut versuchen.", - "chat.apiKeyValidationFailed": "API-Schlüssel konnte nicht validiert werden.", "chat.newChat": "Neuer Chat", "chat.welcomeTitle": "Willkommen beim KI-Assistenten", "chat.welcomeDescription": "Ich kann dir helfen, deinen Blog mit anschaulichen Darstellungen zu verwalten. Frag mich zum Beispiel:", diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json index 1c24a3a..ec5a0a4 100644 --- a/src/renderer/i18n/locales/en.json +++ b/src/renderer/i18n/locales/en.json @@ -192,13 +192,11 @@ "settings.toast.thumbnailsComplete": "Thumbnail generation complete", "settings.toast.thumbnailsFailed": "Failed to generate thumbnails", "chat.setupTitle": "AI Chat Setup", - "chat.apiKeyRequiredTitle": "OpenCode Zen API Key Required", - "chat.apiKeyRequiredDescription": "Enter your OpenCode API key to enable AI chat.", + "chat.apiKeyRequiredTitle": "API Key Required", + "chat.apiKeyRequiredDescription": "Configure an API key in Settings to enable AI chat.", + "chat.openSettings": "Open Settings", "chat.apiKeyPlaceholder": "Enter your API key...", "chat.apiKeySave": "Save Key", - "chat.apiKeyValidating": "Validating...", - "chat.apiKeyInvalid": "Invalid API key. Please check and try again.", - "chat.apiKeyValidationFailed": "Failed to validate API key.", "chat.newChat": "New Chat", "chat.welcomeTitle": "Welcome to the AI Assistant", "chat.welcomeDescription": "I can help you manage your blog with rich visualizations. Try asking me to:", diff --git a/src/renderer/i18n/locales/es.json b/src/renderer/i18n/locales/es.json index e9220d6..985c05c 100644 --- a/src/renderer/i18n/locales/es.json +++ b/src/renderer/i18n/locales/es.json @@ -192,13 +192,11 @@ "settings.toast.thumbnailsComplete": "Generación de miniaturas completa", "settings.toast.thumbnailsFailed": "No se pudieron generar miniaturas", "chat.setupTitle": "Configuración de chat IA", - "chat.apiKeyRequiredTitle": "Se requiere clave API de OpenCode Zen", - "chat.apiKeyRequiredDescription": "Introduce tu clave API de OpenCode para habilitar el chat de IA.", + "chat.apiKeyRequiredTitle": "Clave API requerida", + "chat.apiKeyRequiredDescription": "Configura una clave API en Ajustes para habilitar el chat de IA.", + "chat.openSettings": "Abrir Ajustes", "chat.apiKeyPlaceholder": "Introduce tu clave API...", "chat.apiKeySave": "Guardar clave", - "chat.apiKeyValidating": "Validando...", - "chat.apiKeyInvalid": "Clave API no válida. Compruébala e inténtalo de nuevo.", - "chat.apiKeyValidationFailed": "No se pudo validar la clave API.", "chat.newChat": "Nuevo chat", "chat.welcomeTitle": "Bienvenido al asistente de IA", "chat.welcomeDescription": "Puedo ayudarte a gestionar tu blog con visualizaciones interactivas. Prueba a pedirme que:", diff --git a/src/renderer/i18n/locales/fr.json b/src/renderer/i18n/locales/fr.json index f78cbab..8356f65 100644 --- a/src/renderer/i18n/locales/fr.json +++ b/src/renderer/i18n/locales/fr.json @@ -190,13 +190,11 @@ "settings.toast.thumbnailsComplete": "Génération des miniatures terminée", "settings.toast.thumbnailsFailed": "Impossible de générer les miniatures", "chat.setupTitle": "Configuration du chat IA", - "chat.apiKeyRequiredTitle": "Clé API OpenCode Zen requise", - "chat.apiKeyRequiredDescription": "Saisissez votre clé API OpenCode pour activer le chat IA.", + "chat.apiKeyRequiredTitle": "Clé API requise", + "chat.apiKeyRequiredDescription": "Configurez une clé API dans les Réglages pour activer le chat IA.", + "chat.openSettings": "Ouvrir les Réglages", "chat.apiKeyPlaceholder": "Saisissez votre clé API...", "chat.apiKeySave": "Enregistrer la clé", - "chat.apiKeyValidating": "Validation...", - "chat.apiKeyInvalid": "Clé API invalide. Veuillez vérifier et réessayer.", - "chat.apiKeyValidationFailed": "Impossible de valider la clé API.", "chat.newChat": "Nouveau chat", "chat.welcomeTitle": "Bienvenue dans l’assistant IA", "chat.welcomeDescription": "Je peux vous aider à gérer votre blog avec des visualisations riches. Essayez par exemple :", diff --git a/src/renderer/i18n/locales/it.json b/src/renderer/i18n/locales/it.json index 76769be..e56364d 100644 --- a/src/renderer/i18n/locales/it.json +++ b/src/renderer/i18n/locales/it.json @@ -190,13 +190,11 @@ "settings.toast.thumbnailsComplete": "Generazione miniature completata", "settings.toast.thumbnailsFailed": "Impossibile generare le miniature", "chat.setupTitle": "Configurazione chat IA", - "chat.apiKeyRequiredTitle": "Chiave API OpenCode Zen richiesta", - "chat.apiKeyRequiredDescription": "Inserisci la tua chiave API OpenCode per abilitare la chat IA.", + "chat.apiKeyRequiredTitle": "Chiave API richiesta", + "chat.apiKeyRequiredDescription": "Configura una chiave API nelle Impostazioni per abilitare la chat IA.", + "chat.openSettings": "Apri Impostazioni", "chat.apiKeyPlaceholder": "Inserisci la tua chiave API...", "chat.apiKeySave": "Salva chiave", - "chat.apiKeyValidating": "Convalida in corso...", - "chat.apiKeyInvalid": "Chiave API non valida. Controlla e riprova.", - "chat.apiKeyValidationFailed": "Impossibile convalidare la chiave API.", "chat.newChat": "Nuova chat", "chat.welcomeTitle": "Benvenuto nell’assistente IA", "chat.welcomeDescription": "Posso aiutarti a gestire il tuo blog con visualizzazioni interattive. Prova a chiedermi di:", diff --git a/tests/engine/OpenCodeManagerMistral.test.ts b/tests/engine/OpenCodeManagerMistral.test.ts index d2c1f98..d7fefe6 100644 --- a/tests/engine/OpenCodeManagerMistral.test.ts +++ b/tests/engine/OpenCodeManagerMistral.test.ts @@ -45,7 +45,8 @@ vi.mock('../../src/main/database', () => ({ getDatabase: vi.fn(() => ({})), })); -import { OpenCodeManager, type ModelInfo } from '../../src/main/engine/OpenCodeManager'; +import { OpenCodeManager } from '../../src/main/engine/OpenCodeManager'; +import type { ChatModel } from '../../src/main/shared/electronApi'; // Helper to create manager with mocked httpRequest function createManager(): OpenCodeManager { @@ -278,7 +279,7 @@ describe('OpenCodeManager Mistral integration', () => { }); const models = await manager.getAvailableModels(); - const providers = new Set(models.map((m: ModelInfo) => m.provider)); + const providers = new Set(models.map((m: ChatModel) => m.provider)); expect(providers.has('mistral')).toBe(false); }); @@ -301,7 +302,7 @@ describe('OpenCodeManager Mistral integration', () => { const models = await manager.getAvailableModels(); expect(models.length).toBe(2); - expect(models.every((m: ModelInfo) => m.provider === 'mistral')).toBe(true); + expect(models.every((m: ChatModel) => m.provider === 'mistral')).toBe(true); }); it('merges models from both providers when both keys are set', async () => { @@ -330,7 +331,7 @@ describe('OpenCodeManager Mistral integration', () => { const models = await manager.getAvailableModels(); expect(models.length).toBe(4); - const providers = new Set(models.map((m: ModelInfo) => m.provider)); + const providers = new Set(models.map((m: ChatModel) => m.provider)); expect(providers.has('anthropic')).toBe(true); expect(providers.has('mistral')).toBe(true); }); @@ -353,8 +354,8 @@ describe('OpenCodeManager Mistral integration', () => { }); const models = await manager.getAvailableModels(); - const large = models.find((m: ModelInfo) => m.id === 'mistral-large-latest'); - const devstral = models.find((m: ModelInfo) => m.id === 'devstral-small-latest'); + 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); @@ -369,7 +370,7 @@ describe('OpenCodeManager Mistral integration', () => { const models = await manager.getAvailableModels(); // Should only have Mistral models from fallback - const providers = new Set(models.map((m: ModelInfo) => m.provider)); + 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); @@ -580,13 +581,13 @@ describe('OpenCodeManager Mistral integration', () => { const models = await manager.getAvailableModels(); // Vision-capable models - expect(models.find((m: ModelInfo) => m.id === 'mistral-large-latest')?.vision).toBe(true); - expect(models.find((m: ModelInfo) => m.id === 'mistral-medium-latest')?.vision).toBe(true); - expect(models.find((m: ModelInfo) => m.id === 'mistral-small-latest')?.vision).toBe(true); + 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 - expect(models.find((m: ModelInfo) => m.id === 'devstral-small-latest')?.vision).toBe(false); - expect(models.find((m: ModelInfo) => m.id === 'devstral-large-latest')?.vision).toBe(false); + 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); }); }); diff --git a/tests/engine/OpenCodeModelDiscovery.test.ts b/tests/engine/OpenCodeModelDiscovery.test.ts index d5281ec..e213ad8 100644 --- a/tests/engine/OpenCodeModelDiscovery.test.ts +++ b/tests/engine/OpenCodeModelDiscovery.test.ts @@ -29,7 +29,8 @@ vi.mock('../../src/main/database', () => ({ getDatabase: vi.fn(() => ({})), })); -import { OpenCodeManager, ModelInfo } from '../../src/main/engine/OpenCodeManager'; +import { OpenCodeManager } from '../../src/main/engine/OpenCodeManager'; +import type { ChatModel } from '../../src/main/shared/electronApi'; // Helper to create manager with mocked httpRequest function createManager(): OpenCodeManager { @@ -163,13 +164,13 @@ describe('OpenCodeManager model discovery', () => { expect(models.length).toBeGreaterThan(0); // Should include well-known models from the display name map - const ids = models.map((m: ModelInfo) => m.id); + const ids = models.map((m: ChatModel) => m.id); expect(ids).toContain('claude-sonnet-4'); expect(ids).toContain('gpt-5'); // Every model should have proper provider detection - const claudeModel = models.find((m: ModelInfo) => m.id === 'claude-sonnet-4'); + const claudeModel = models.find((m: ChatModel) => m.id === 'claude-sonnet-4'); expect(claudeModel?.provider).toBe('anthropic'); - const gptModel = models.find((m: ModelInfo) => m.id === 'gpt-5'); + const gptModel = models.find((m: ChatModel) => m.id === 'gpt-5'); expect(gptModel?.provider).toBe('openai'); }); @@ -183,7 +184,7 @@ describe('OpenCodeManager model discovery', () => { const models = await manager.getAvailableModels(); expect(models.length).toBeGreaterThan(0); - const ids = models.map((m: ModelInfo) => m.id); + const ids = models.map((m: ChatModel) => m.id); expect(ids).toContain('claude-sonnet-4'); }); @@ -245,7 +246,7 @@ describe('OpenCodeManager model discovery', () => { // Only Mistral models will be in fallback since only Mistral key is set expect(models.length).toBeGreaterThan(0); - const providers = new Set(models.map((m: ModelInfo) => m.provider)); + const providers = new Set(models.map((m: ChatModel) => m.provider)); expect(providers.has('mistral')).toBe(true); }); }); From 63674266f52eb75c22553168345c837bacd6bbf8 Mon Sep 17 00:00:00 2001 From: hugo Date: Sun, 1 Mar 2026 17:03:50 +0100 Subject: [PATCH 5/7] fix: better models.dev support --- drizzle/0009_model_catalog_v2.sql | 53 + drizzle/meta/0009_snapshot.json | 1432 +++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/main/database/schema.ts | 77 +- src/main/engine/ModelCatalogEngine.ts | 296 +++- src/main/engine/OpenCodeManager.ts | 216 ++- tests/engine/ModelCatalogEngine.test.ts | 213 ++- tests/engine/OpenCodeManagerMistral.test.ts | 96 +- tests/engine/OpenCodeModelDiscovery.test.ts | 121 +- 9 files changed, 2140 insertions(+), 371 deletions(-) create mode 100644 drizzle/0009_model_catalog_v2.sql create mode 100644 drizzle/meta/0009_snapshot.json diff --git a/drizzle/0009_model_catalog_v2.sql b/drizzle/0009_model_catalog_v2.sql new file mode 100644 index 0000000..ec45d05 --- /dev/null +++ b/drizzle/0009_model_catalog_v2.sql @@ -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`; \ No newline at end of file diff --git a/drizzle/meta/0009_snapshot.json b/drizzle/meta/0009_snapshot.json new file mode 100644 index 0000000..cf5988d --- /dev/null +++ b/drizzle/meta/0009_snapshot.json @@ -0,0 +1,1432 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b3e7e63c-d906-48af-98a0-e3d0741ff13a", + "prevId": "dfaeea68-90b7-4d86-bb5b-90b2a69d71ec", + "tables": { + "chat_conversations": { + "name": "chat_conversations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "copilot_session_id": { + "name": "copilot_session_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_messages": { + "name": "chat_messages", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tool_calls": { + "name": "tool_calls", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "db_notifications": { + "name": "db_notifications", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "entity": { + "name": "entity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_cli": { + "name": "from_cli", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "seen_at": { + "name": "seen_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "generated_file_hashes": { + "name": "generated_file_hashes", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "relative_path": { + "name": "relative_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "generated_file_hashes_project_path_idx": { + "name": "generated_file_hashes_project_path_idx", + "columns": [ + "project_id", + "relative_path" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "import_definitions": { + "name": "import_definitions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "wxr_file_path": { + "name": "wxr_file_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uploads_folder_path": { + "name": "uploads_folder_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_analysis_result": { + "name": "last_analysis_result", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "media": { + "name": "media", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alt": { + "name": "alt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sidecar_path": { + "name": "sidecar_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ai_models": { + "name": "ai_models", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "family": { + "name": "family", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attachment": { + "name": "attachment", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "reasoning": { + "name": "reasoning", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "tool_call": { + "name": "tool_call", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "structured_output": { + "name": "structured_output", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "temperature": { + "name": "temperature", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "knowledge": { + "name": "knowledge", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "release_date": { + "name": "release_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_updated_date": { + "name": "last_updated_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "open_weights": { + "name": "open_weights", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "input_price": { + "name": "input_price", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "output_price": { + "name": "output_price", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_read_price": { + "name": "cache_read_price", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cache_write_price": { + "name": "cache_write_price", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "context_window": { + "name": "context_window", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_input_tokens": { + "name": "max_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "max_output_tokens": { + "name": "max_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "interleaved": { + "name": "interleaved", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "provider_npm": { + "name": "provider_npm", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ai_models_provider_model_id_pk": { + "columns": [ + "provider", + "model_id" + ], + "name": "ai_models_provider_model_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ai_catalog_meta": { + "name": "ai_catalog_meta", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ai_model_modalities": { + "name": "ai_model_modalities", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "direction": { + "name": "direction", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "modality": { + "name": "modality", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "ai_model_modalities_provider_model_id_direction_modality_pk": { + "columns": [ + "provider", + "model_id", + "direction", + "modality" + ], + "name": "ai_model_modalities_provider_model_id_direction_modality_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "ai_providers": { + "name": "ai_providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "env": { + "name": "env", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "npm": { + "name": "npm", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api": { + "name": "api", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "doc": { + "name": "doc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "post_links": { + "name": "post_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "source_post_id": { + "name": "source_post_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "target_post_id": { + "name": "target_post_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "link_text": { + "name": "link_text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "post_media": { + "name": "post_media", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "post_id": { + "name": "post_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "media_id": { + "name": "media_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "post_media_post_media_idx": { + "name": "post_media_post_media_idx", + "columns": [ + "post_id", + "media_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "posts": { + "name": "posts", + "columns": { + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "excerpt": { + "name": "excerpt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "checksum": { + "name": "checksum", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "tags": { + "name": "tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "categories": { + "name": "categories", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_slug": { + "name": "template_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_title": { + "name": "published_title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_content": { + "name": "published_content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_tags": { + "name": "published_tags", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_categories": { + "name": "published_categories", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_excerpt": { + "name": "published_excerpt", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "posts_project_slug_idx": { + "name": "posts_project_slug_idx", + "columns": [ + "project_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "data_path": { + "name": "data_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_active": { + "name": "is_active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "projects_slug_unique": { + "name": "projects_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "scripts": { + "name": "scripts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'utility'" + }, + "entrypoint": { + "name": "entrypoint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'render'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'published'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "scripts_project_slug_idx": { + "name": "scripts_project_slug_idx", + "columns": [ + "project_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "post_template_slug": { + "name": "post_template_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tags_project_name_idx": { + "name": "tags_project_name_idx", + "columns": [ + "project_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "templates": { + "name": "templates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'post'" + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'published'" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "templates_project_slug_idx": { + "name": "templates_project_slug_idx", + "columns": [ + "project_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 6b9c4c0..07a31d4 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1772369331600, "tag": "0008_third_cable", "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1772380619098, + "tag": "0009_model_catalog_v2", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 0bdcb1c..cda75aa 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text, integer, real, uniqueIndex } from 'drizzle-orm/sqlite-core'; +import { sqliteTable, text, integer, real, uniqueIndex, primaryKey } from 'drizzle-orm/sqlite-core'; // Projects table - stores blog projects/websites export const projects = sqliteTable('projects', { @@ -206,27 +206,64 @@ export const dbNotifications = sqliteTable('db_notifications', { createdAt: integer('created_at').notNull(), }); -// Model catalog table - cached model metadata from models.dev API -// Stores per-model data (limits, pricing, capabilities) for the OpenCode provider. +// ── Model Catalog ── +// Normalised tables from models.dev API. // Refreshed on user action via conditional GET (ETag). Survives offline use. -export const modelCatalog = sqliteTable('model_catalog', { - id: text('id').primaryKey(), // model ID (e.g. 'claude-sonnet-4-5') - name: text('name').notNull(), // display name - family: text('family'), // model family (e.g. 'claude-sonnet') - contextWindow: integer('context_window'), // max context tokens - maxInputTokens: integer('max_input_tokens'), // max input tokens (null = same as context) - maxOutputTokens: integer('max_output_tokens'), // max output tokens - inputPrice: real('input_price'), // cost per 1M input tokens (USD) - outputPrice: real('output_price'), // cost per 1M output tokens (USD) - cacheReadPrice: real('cache_read_price'), // cost per 1M cached input tokens (USD) - supportsAttachments: integer('supports_attachments', { mode: 'boolean' }).default(false), - supportsReasoning: integer('supports_reasoning', { mode: 'boolean' }).default(false), - supportsToolCall: integer('supports_tool_call', { mode: 'boolean' }).default(false), + +// Provider table — one row per models.dev top-level provider +export const modelCatalogProviders = sqliteTable('ai_providers', { + id: text('id').primaryKey(), // provider key (e.g. 'opencode', 'mistral') + name: text('name').notNull(), // display name (e.g. 'OpenCode Zen') + env: text('env'), // JSON array of env var names + npm: text('npm'), // primary npm package + api: text('api'), // API base URL + doc: text('doc'), // documentation URL updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), }); -// Model catalog HTTP cache metadata (ETag for conditional GET) -export const modelCatalogMeta = sqliteTable('model_catalog_meta', { +// Model table — one row per (provider, modelId) pair +export const modelCatalog = sqliteTable('ai_models', { + provider: text('provider').notNull(), // FK → ai_providers.id + modelId: text('model_id').notNull(), + name: text('name').notNull(), // display name (e.g. 'Claude Sonnet 4.5') + family: text('family'), // model family (e.g. 'claude-sonnet') + attachment: integer('attachment', { mode: 'boolean' }).default(false), + reasoning: integer('reasoning', { mode: 'boolean' }).default(false), + toolCall: integer('tool_call', { mode: 'boolean' }).default(false), + structuredOutput: integer('structured_output', { mode: 'boolean' }).default(false), + temperature: integer('temperature', { mode: 'boolean' }).default(false), + knowledge: text('knowledge'), // knowledge cutoff (e.g. '2025-03-31') + releaseDate: text('release_date'), + lastUpdatedDate: text('last_updated_date'), + openWeights: integer('open_weights', { mode: 'boolean' }).default(false), + inputPrice: real('input_price'), // USD per 1M input tokens + outputPrice: real('output_price'), // USD per 1M output tokens + cacheReadPrice: real('cache_read_price'), + cacheWritePrice: real('cache_write_price'), + contextWindow: integer('context_window'), // max context tokens + maxInputTokens: integer('max_input_tokens'), + maxOutputTokens: integer('max_output_tokens'), + interleaved: text('interleaved'), // JSON object (e.g. '{"field":"reasoning_content"}') + status: text('status'), // e.g. 'deprecated' + providerNpm: text('provider_npm'), // per-model npm override + updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(), +}, (table) => ({ + pk: primaryKey({ columns: [table.provider, table.modelId] }), +})); + +// Modality junction table — each row is one (direction, modality) tag for a model +// e.g. ('opencode', 'claude-sonnet-4', 'input', 'image') +export const modelCatalogModalities = sqliteTable('ai_model_modalities', { + provider: text('provider').notNull(), + modelId: text('model_id').notNull(), + direction: text('direction').notNull(), // 'input' | 'output' + modality: text('modality').notNull(), // 'text' | 'image' | 'pdf' | 'audio' | 'video' +}, (table) => ({ + pk: primaryKey({ columns: [table.provider, table.modelId, table.direction, table.modality] }), +})); + +// HTTP cache metadata (ETag for conditional GET) +export const modelCatalogMeta = sqliteTable('ai_catalog_meta', { key: text('key').primaryKey(), // 'etag' | 'lastFetchedAt' value: text('value').notNull(), }); @@ -260,7 +297,11 @@ export type Template = typeof templates.$inferSelect; export type NewTemplate = typeof templates.$inferInsert; export type DbNotification = typeof dbNotifications.$inferSelect; export type NewDbNotification = typeof dbNotifications.$inferInsert; +export type ModelCatalogProviderEntry = typeof modelCatalogProviders.$inferSelect; +export type NewModelCatalogProviderEntry = typeof modelCatalogProviders.$inferInsert; export type ModelCatalogEntry = typeof modelCatalog.$inferSelect; export type NewModelCatalogEntry = typeof modelCatalog.$inferInsert; +export type ModelCatalogModalityEntry = typeof modelCatalogModalities.$inferSelect; +export type NewModelCatalogModalityEntry = typeof modelCatalogModalities.$inferInsert; export type ModelCatalogMetaEntry = typeof modelCatalogMeta.$inferSelect; export type NewModelCatalogMetaEntry = typeof modelCatalogMeta.$inferInsert; diff --git a/src/main/engine/ModelCatalogEngine.ts b/src/main/engine/ModelCatalogEngine.ts index 9d014f1..c968a30 100644 --- a/src/main/engine/ModelCatalogEngine.ts +++ b/src/main/engine/ModelCatalogEngine.ts @@ -1,41 +1,65 @@ /** * ModelCatalogEngine — Fetches and caches model metadata from models.dev * - * Provides model output token limits, pricing info, and capabilities - * for all models available through the OpenCode Zen gateway. + * The full catalog is stored in three normalised SQLite tables: + * model_catalog_providers — one row per provider (opencode, mistral, …) + * model_catalog — one row per (provider, modelId) pair + * model_catalog_modalities — junction table with (provider, modelId, direction, modality) tags * - * Data is persisted in SQLite (model_catalog + model_catalog_meta tables) - * and refreshed on user action via conditional GET (ETag). + * Data is refreshed on user action via conditional GET (ETag). * Works fully offline after first successful fetch. */ import https from 'https'; import http from 'http'; import { URL } from 'url'; -import { eq } from 'drizzle-orm'; +import { eq, and } from 'drizzle-orm'; import { getDatabase } from '../database'; -import { modelCatalog, modelCatalogMeta } from '../database/schema'; +import { modelCatalog, modelCatalogMeta, modelCatalogProviders, modelCatalogModalities } from '../database/schema'; import type { ModelCatalogEntry } from '../database/schema'; const MODELS_DEV_URL = 'https://models.dev/api.json'; -const PROVIDER_KEY = 'opencode'; // Default max output tokens when no catalog data is available export const DEFAULT_MAX_OUTPUT_TOKENS = 16384; +/** Provider-level metadata from models.dev. */ +export interface ProviderInfo { + id: string; + name: string; + env: string[]; + npm: string | null; + api: string | null; + doc: string | null; +} + +/** Flattened model info returned by query methods. */ export interface ModelCatalogInfo { + provider: string; id: string; name: string; family: string | null; - contextWindow: number | null; - maxInputTokens: number | null; - maxOutputTokens: number | null; + attachment: boolean; + reasoning: boolean; + toolCall: boolean; + structuredOutput: boolean; + temperature: boolean; + knowledge: string | null; + releaseDate: string | null; + lastUpdatedDate: string | null; + openWeights: boolean; inputPrice: number | null; outputPrice: number | null; cacheReadPrice: number | null; - supportsAttachments: boolean | null; - supportsReasoning: boolean | null; - supportsToolCall: boolean | null; + cacheWritePrice: number | null; + contextWindow: number | null; + maxInputTokens: number | null; + maxOutputTokens: number | null; + interleaved: string | null; + status: string | null; + providerNpm: string | null; + inputModalities: string[]; + outputModalities: string[]; } export interface RefreshResult { @@ -58,30 +82,87 @@ export class ModelCatalogEngine { async getAll(): Promise { const db = getDatabase().getLocal(); const rows = await db.select().from(modelCatalog); - return rows.map(toInfo); + const modalities = await db.select().from(modelCatalogModalities); + return rows.map(r => toInfo(r, modalities)); } /** - * Get a single model's catalog entry by ID. + * Get all models for a specific provider. */ - async getModel(modelId: string): Promise { + async getByProvider(provider: string): Promise { const db = getDatabase().getLocal(); - const rows = await db.select().from(modelCatalog).where(eq(modelCatalog.id, modelId)); - return rows.length > 0 ? toInfo(rows[0]) : null; + const rows = await db.select().from(modelCatalog).where(eq(modelCatalog.provider, provider)); + const modalities = await db.select().from(modelCatalogModalities).where(eq(modelCatalogModalities.provider, provider)); + return rows.map(r => toInfo(r, modalities)); + } + + /** + * Get a single model by provider and model ID. + */ + async getModel(modelId: string, provider?: string): Promise { + 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 { + const db = getDatabase().getLocal(); + const rows = await db.select().from(modelCatalogProviders); + return rows.map(r => ({ + id: r.id, + name: r.name, + env: r.env ? JSON.parse(r.env) as string[] : [], + npm: r.npm, + api: r.api, + doc: r.doc, + })); } /** * Get the max output tokens for a model (used by OpenCodeManager for max_tokens). * Returns DEFAULT_MAX_OUTPUT_TOKENS if the model is not in the catalog. */ - async getMaxOutputTokens(modelId: string): Promise { - const model = await this.getModel(modelId); + async getMaxOutputTokens(modelId: string, provider?: string): Promise { + const model = await this.getModel(modelId, provider); return model?.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS; } + /** + * Get the context window size for a model. + * Returns null if the model is not in the catalog. + */ + async getContextWindow(modelId: string, provider?: string): Promise { + 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 { + const model = await this.getModel(modelId, provider); + return model?.inputModalities.includes(modality) ?? false; + } + /** * Refresh the model catalog from models.dev using conditional GET (ETag). - * Returns the number of models updated, or notModified if the data hasn't changed. + * Stores ALL providers and ALL models from the API. */ async refresh(): Promise { try { @@ -109,9 +190,16 @@ export class ModelCatalogEngine { // Parse response const data = JSON.parse(response.body); - const models = data?.[PROVIDER_KEY]?.models; - if (!models || typeof models !== 'object') { - return { success: false, modelsUpdated: 0, error: 'Invalid response: no opencode models found' }; + if (!data || typeof data !== 'object') { + return { success: false, modelsUpdated: 0, error: 'Invalid response: not an object' }; + } + + // Count providers with models + const providerEntries = Object.entries(data).filter( + ([, v]) => v && typeof v === 'object' && 'models' in (v as Record), + ); + if (providerEntries.length === 0) { + return { success: false, modelsUpdated: 0, error: 'Invalid response: no providers found' }; } // Store new ETag @@ -121,10 +209,18 @@ export class ModelCatalogEngine { } await this.setMeta('lastFetchedAt', new Date().toISOString()); - // Upsert all models - const count = await this.upsertModels(models); + // Upsert all providers and their models + let totalModels = 0; + for (const [providerId, providerData] of providerEntries) { + const prov = providerData as Record; + await this.upsertProvider(providerId, prov); + const models = prov.models as Record | undefined; + if (models && typeof models === 'object') { + totalModels += await this.upsertModels(providerId, models); + } + } - return { success: true, modelsUpdated: count }; + return { success: true, modelsUpdated: totalModels }; } catch (error) { return { success: false, modelsUpdated: 0, error: (error as Error).message }; } @@ -140,10 +236,42 @@ export class ModelCatalogEngine { // ── Internal ── /** - * Parse models.dev model entries and upsert into database. + * Upsert a provider row. + */ + private async upsertProvider(id: string, data: Record): Promise { + const db = getDatabase().getLocal(); + const now = new Date(); + const env = Array.isArray(data.env) ? JSON.stringify(data.env) : null; + + await db.insert(modelCatalogProviders) + .values({ + id, + name: (data.name as string) || id, + env, + npm: (data.npm as string) || null, + api: (data.api as string) || null, + doc: (data.doc as string) || null, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: modelCatalogProviders.id, + set: { + name: (data.name as string) || id, + env, + npm: (data.npm as string) || null, + api: (data.api as string) || null, + doc: (data.doc as string) || null, + updatedAt: now, + }, + }); + } + + /** + * Parse and upsert model entries for a given provider. + * Also writes modality rows to the junction table. */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - private async upsertModels(models: Record): Promise { + private async upsertModels(providerId: string, models: Record): Promise { const db = getDatabase().getLocal(); const now = new Date(); let count = 0; @@ -152,40 +280,87 @@ export class ModelCatalogEngine { if (!info || typeof info !== 'object') continue; const entry = { - id, + provider: providerId, + modelId: id, name: info.name || id, family: info.family || null, - contextWindow: info.limit?.context ?? null, - maxInputTokens: info.limit?.input ?? null, - maxOutputTokens: info.limit?.output ?? null, + attachment: info.attachment ?? false, + reasoning: info.reasoning ?? false, + toolCall: info.tool_call ?? false, + structuredOutput: info.structured_output ?? false, + temperature: info.temperature ?? false, + knowledge: info.knowledge || null, + releaseDate: info.release_date || null, + lastUpdatedDate: info.last_updated || null, + openWeights: info.open_weights ?? false, inputPrice: info.cost?.input ?? null, outputPrice: info.cost?.output ?? null, cacheReadPrice: info.cost?.cache_read ?? null, - supportsAttachments: info.attachment ?? false, - supportsReasoning: info.reasoning ?? false, - supportsToolCall: info.tool_call ?? false, + cacheWritePrice: info.cost?.cache_write ?? null, + contextWindow: info.limit?.context ?? null, + maxInputTokens: info.limit?.input ?? null, + maxOutputTokens: info.limit?.output ?? null, + interleaved: info.interleaved ? JSON.stringify(info.interleaved) : null, + status: info.status || null, + providerNpm: info.provider?.npm || null, updatedAt: now, }; await db.insert(modelCatalog) .values(entry) .onConflictDoUpdate({ - target: modelCatalog.id, + target: [modelCatalog.provider, modelCatalog.modelId], set: { name: entry.name, family: entry.family, - contextWindow: entry.contextWindow, - maxInputTokens: entry.maxInputTokens, - maxOutputTokens: entry.maxOutputTokens, + attachment: entry.attachment, + reasoning: entry.reasoning, + toolCall: entry.toolCall, + structuredOutput: entry.structuredOutput, + temperature: entry.temperature, + knowledge: entry.knowledge, + releaseDate: entry.releaseDate, + lastUpdatedDate: entry.lastUpdatedDate, + openWeights: entry.openWeights, inputPrice: entry.inputPrice, outputPrice: entry.outputPrice, cacheReadPrice: entry.cacheReadPrice, - supportsAttachments: entry.supportsAttachments, - supportsReasoning: entry.supportsReasoning, - supportsToolCall: entry.supportsToolCall, + cacheWritePrice: entry.cacheWritePrice, + contextWindow: entry.contextWindow, + maxInputTokens: entry.maxInputTokens, + maxOutputTokens: entry.maxOutputTokens, + interleaved: entry.interleaved, + status: entry.status, + providerNpm: entry.providerNpm, updatedAt: now, }, }); + + // Upsert modality tags + const mods = info.modalities; + if (mods && typeof mods === 'object') { + for (const direction of ['input', 'output'] as const) { + const tags = mods[direction]; + if (Array.isArray(tags)) { + for (const modality of tags) { + if (typeof modality === 'string') { + await db.insert(modelCatalogModalities) + .values({ provider: providerId, modelId: id, direction, modality }) + .onConflictDoUpdate({ + target: [ + modelCatalogModalities.provider, + modelCatalogModalities.modelId, + modelCatalogModalities.direction, + modelCatalogModalities.modality, + ], + set: { modality }, // no-op update to satisfy ON CONFLICT + }); + } + } + } + } + } + count++; } @@ -240,19 +415,38 @@ export class ModelCatalogEngine { } } -function toInfo(row: ModelCatalogEntry): ModelCatalogInfo { +// ── Helpers ── + +/** Map of (provider, modelId) → { input: string[], output: string[] } for modalities */ +type ModalityEntry = { provider: string; modelId: string; direction: string; modality: string }; + +function toInfo(row: ModelCatalogEntry, allModalities: ModalityEntry[]): ModelCatalogInfo { + const rowModalities = allModalities.filter(m => m.provider === row.provider && m.modelId === row.modelId); return { - id: row.id, + provider: row.provider, + id: row.modelId, name: row.name, family: row.family, - contextWindow: row.contextWindow, - maxInputTokens: row.maxInputTokens, - maxOutputTokens: row.maxOutputTokens, + attachment: row.attachment ?? false, + reasoning: row.reasoning ?? false, + toolCall: row.toolCall ?? false, + structuredOutput: row.structuredOutput ?? false, + temperature: row.temperature ?? false, + knowledge: row.knowledge, + releaseDate: row.releaseDate, + lastUpdatedDate: row.lastUpdatedDate, + openWeights: row.openWeights ?? false, inputPrice: row.inputPrice, outputPrice: row.outputPrice, cacheReadPrice: row.cacheReadPrice, - supportsAttachments: row.supportsAttachments, - supportsReasoning: row.supportsReasoning, - supportsToolCall: row.supportsToolCall, + cacheWritePrice: row.cacheWritePrice ?? null, + contextWindow: row.contextWindow, + maxInputTokens: row.maxInputTokens, + maxOutputTokens: row.maxOutputTokens, + interleaved: row.interleaved, + status: row.status, + providerNpm: row.providerNpm, + inputModalities: rowModalities.filter(m => m.direction === 'input').map(m => m.modality), + outputModalities: rowModalities.filter(m => m.direction === 'output').map(m => m.modality), }; } diff --git a/src/main/engine/OpenCodeManager.ts b/src/main/engine/OpenCodeManager.ts index 4af29ac..f8e4cf7 100644 --- a/src/main/engine/OpenCodeManager.ts +++ b/src/main/engine/OpenCodeManager.ts @@ -38,99 +38,8 @@ const ZEN_MODELS_URL = 'https://opencode.ai/zen/v1/models'; const MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions'; const MISTRAL_MODELS_URL = 'https://api.mistral.ai/v1/models'; -// Known model display names: maps model IDs to polished names and serves as offline fallback -const MODEL_DISPLAY_NAMES: Record = { - // Anthropic Claude - 'claude-opus-4-6': 'Claude Opus 4.6', - 'claude-opus-4-5': 'Claude Opus 4.5', - 'claude-opus-4-1': 'Claude Opus 4.1', - 'claude-sonnet-4-6': 'Claude Sonnet 4.6', - 'claude-sonnet-4-5': 'Claude Sonnet 4.5', - 'claude-sonnet-4': 'Claude Sonnet 4', - 'claude-haiku-4-5': 'Claude Haiku 4.5', - 'claude-3-5-haiku': 'Claude 3.5 Haiku', - // OpenAI GPT - 'gpt-5.3-codex': 'GPT 5.3 Codex', - 'gpt-5.2': 'GPT 5.2', - 'gpt-5.2-codex': 'GPT 5.2 Codex', - 'gpt-5.1': 'GPT 5.1', - 'gpt-5.1-codex': 'GPT 5.1 Codex', - 'gpt-5.1-codex-max': 'GPT 5.1 Codex Max', - 'gpt-5.1-codex-mini': 'GPT 5.1 Codex Mini', - 'gpt-5': 'GPT 5', - 'gpt-5-codex': 'GPT 5 Codex', - 'gpt-5-nano': 'GPT 5 Nano', - // Google Gemini - 'gemini-3.1-pro': 'Gemini 3.1 Pro', - 'gemini-3-pro': 'Gemini 3 Pro', - 'gemini-3-flash': 'Gemini 3 Flash', - // Other providers - 'glm-5': 'GLM 5', - 'glm-5-free': 'GLM 5 Free', - 'glm-4.7': 'GLM 4.7', - 'glm-4.6': 'GLM 4.6', - 'qwen3-coder': 'Qwen3 Coder', - 'minimax-m2.5': 'MiniMax M2.5', - 'minimax-m2.5-free': 'MiniMax M2.5 Free', - 'minimax-m2.1': 'MiniMax M2.1', - 'minimax-m2.1-free': 'MiniMax M2.1 Free', - 'kimi-k2.5': 'Kimi K2.5', - 'kimi-k2.5-free': 'Kimi K2.5 Free', - 'kimi-k2': 'Kimi K2', - 'kimi-k2-thinking': 'Kimi K2 Thinking', - 'big-pickle': 'Big Pickle', - 'trinity-large-preview-free': 'Trinity Large Preview Free', - // Mistral AI - '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', -}; - -// Uppercase prefixes that should not be title-cased -const UPPERCASE_PREFIXES = ['gpt', 'glm']; - -// Per-model context token budgets for truncation -// OpenCode models default to 150,000; Mistral models have specific budgets -const MODEL_CONTEXT_BUDGETS: Record = { - '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, -}; - -// Vision capabilities per model (APIs don't expose this) -const MODEL_CAPABILITIES: Record = { - // Anthropic Claude — all vision-capable - 'claude-opus-4-6': { vision: true }, - 'claude-opus-4-5': { vision: true }, - 'claude-opus-4-1': { vision: true }, - 'claude-sonnet-4-6': { vision: true }, - 'claude-sonnet-4-5': { vision: true }, - 'claude-sonnet-4': { vision: true }, - 'claude-haiku-4-5': { vision: true }, - 'claude-3-5-haiku': { vision: true }, - // OpenAI GPT — most are vision-capable - 'gpt-5': { vision: true }, - 'gpt-5.1': { vision: true }, - 'gpt-5.2': { vision: true }, - 'gpt-5-nano': { vision: true }, - // Google Gemini — vision-capable - 'gemini-3.1-pro': { vision: true }, - 'gemini-3-pro': { vision: true }, - 'gemini-3-flash': { vision: true }, - // Mistral AI - '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 }, -}; - export interface SendMessageOptions { metadata?: { surface?: 'tab' | 'sidebar'; @@ -303,6 +212,8 @@ export class OpenCodeManager { { 'x-api-key': apiKey }, ]; + const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups(); + for (const headers of attempts) { try { const response = await this.httpRequest(ZEN_MODELS_URL, { @@ -310,10 +221,15 @@ export class OpenCodeManager { headers, }); if (response.statusCode >= 200 && response.statusCode < 300) { - // Filter to only OpenCode models (not Mistral) - const models = Object.entries(MODEL_DISPLAY_NAMES) - .map(([id, name]) => ({ id, name, provider: this.detectProvider(id), vision: MODEL_CAPABILITIES[id]?.vision ?? false })) - .filter(m => this.isProviderKeySet(m.provider)); + 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 { @@ -332,6 +248,8 @@ export class OpenCodeManager { return { isValid: false, models: [] }; } + const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups(); + try { const response = await this.httpRequest(MISTRAL_MODELS_URL, { method: 'GET', @@ -343,10 +261,14 @@ export class OpenCodeManager { if (response.statusCode >= 200 && response.statusCode < 300) { const data = JSON.parse(response.body); if (data.data && Array.isArray(data.data) && data.data.length > 0) { - // Return Mistral models from display name map - const models = Object.entries(MODEL_DISPLAY_NAMES) - .filter(([id]) => this.detectProvider(id) === 'mistral') - .map(([id, name]) => ({ id, name, provider: 'mistral', vision: MODEL_CAPABILITIES[id]?.vision ?? false })); + 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 }; } } @@ -370,6 +292,9 @@ export class OpenCodeManager { const allModels: ChatModel[] = []; let fetched = false; + // Load catalog for vision + name cross-referencing + const { vision: catalogVision, names: catalogNames } = await this.getCatalogLookups(); + // Fetch OpenCode models if (this.apiKey) { try { @@ -386,9 +311,9 @@ export class OpenCodeManager { for (const m of data.data as Array<{ id: string }>) { allModels.push({ id: m.id, - name: this.formatModelName(m.id), + name: this.resolveName(m.id, catalogNames), provider: this.detectProvider(m.id), - vision: MODEL_CAPABILITIES[m.id]?.vision ?? false, + vision: this.resolveVision(m.id, catalogVision), }); } fetched = true; @@ -412,13 +337,12 @@ export class OpenCodeManager { const data = JSON.parse(response.body); if (data.data && Array.isArray(data.data)) { for (const m of data.data as Array<{ id: string }>) { - // Only include models we know about (have display names) - if (MODEL_DISPLAY_NAMES[m.id]) { + if (this.detectProvider(m.id) === 'mistral') { allModels.push({ id: m.id, - name: this.formatModelName(m.id), + name: this.resolveName(m.id, catalogNames), provider: 'mistral', - vision: MODEL_CAPABILITIES[m.id]?.vision ?? false, + vision: this.resolveVision(m.id, catalogVision), }); } } @@ -436,16 +360,23 @@ export class OpenCodeManager { return allModels; } - // Build fallback from display name map, filtered by available provider keys - const fallback = Object.entries(MODEL_DISPLAY_NAMES) - .map(([id, name]) => ({ - id, - name, - provider: this.detectProvider(id), - vision: MODEL_CAPABILITIES[id]?.vision ?? false, - })) - .filter(m => this.isProviderKeySet(m.provider)); - return fallback; + // 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 []; } /** @@ -943,7 +874,7 @@ export class OpenCodeManager { // Truncate conversation history to fit within context window // Keep system message (index 0), truncate from oldest conversation messages - const contextBudget = MODEL_CONTEXT_BUDGETS[modelId] ?? 150000; + const contextBudget = (await this.modelCatalogEngine.getContextWindow(modelId)) ?? 150000; const conversationMessages = allMessages.slice(1); const anthropicFmt = conversationMessages.map(m => ({ role: m.role as 'user' | 'assistant', @@ -2245,6 +2176,40 @@ NOTE: Use pagination (offset/limit) in list_posts and search_posts to access all 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; names: Map }> { + const vision = new Map(); + const names = new Map(); + 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): 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 { + 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. @@ -2265,24 +2230,7 @@ NOTE: Use pagination (offset/limit) in list_posts and search_posts to access all return 'other'; } - private formatModelName(modelId: string): string { - // Check display name map first - if (MODEL_DISPLAY_NAMES[modelId]) { - return MODEL_DISPLAY_NAMES[modelId]; - } - // Auto-format: split on hyphens, handle uppercase prefixes and version dots - const words = modelId.split('-'); - return words - .map((word, index) => { - // First word: check for uppercase prefixes - if (index === 0 && UPPERCASE_PREFIXES.includes(word.toLowerCase())) { - return word.toUpperCase(); - } - // Capitalize first letter - return word.charAt(0).toUpperCase() + word.slice(1); - }) - .join(' '); - } + private parseErrorResponse(response: HttpResponse): string { let errorMsg = `API error: ${response.statusCode}`; diff --git a/tests/engine/ModelCatalogEngine.test.ts b/tests/engine/ModelCatalogEngine.test.ts index d168165..15525da 100644 --- a/tests/engine/ModelCatalogEngine.test.ts +++ b/tests/engine/ModelCatalogEngine.test.ts @@ -2,7 +2,8 @@ * ModelCatalogEngine Tests * * Tests the model catalog engine that fetches and caches - * model metadata from models.dev for the OpenCode provider. + * model metadata from models.dev for ALL providers. + * Three normalised tables: providers → models → modalities. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; @@ -19,12 +20,37 @@ function createSelectChain(mockData: unknown[] = []) { return chain; } -let selectMockData: unknown[] = []; +// Per-table mock data keyed by table name reference +let modelMockData: unknown[] = []; +let modalityMockData: unknown[] = []; +let providerMockData: unknown[] = []; +let metaMockData: unknown[] = []; const insertedValues: unknown[] = []; function createDrizzleMock() { return { - select: vi.fn(() => createSelectChain(selectMockData)), + select: vi.fn(() => { + // Returns a chain whose `.from()` picks the right dataset by table reference + const chain: Record = { + from: vi.fn().mockImplementation((table: unknown) => { + let data: unknown[]; + if (table === modelCatalogModalities) { + data = modalityMockData; + } else if (table === modelCatalogProviders) { + data = providerMockData; + } else if (table === modelCatalogMeta) { + data = metaMockData; + } else { + data = modelMockData; + } + const inner = createSelectChain(data); + return inner; + }), + where: vi.fn().mockImplementation(() => chain), + then: (resolve: (v: unknown) => void) => Promise.resolve(modelMockData).then(resolve), + }; + return chain; + }), insert: vi.fn(() => ({ values: vi.fn((data: unknown) => { insertedValues.push(data); @@ -49,13 +75,19 @@ vi.mock('../../src/main/database', () => ({ })); import { ModelCatalogEngine, DEFAULT_MAX_OUTPUT_TOKENS } from '../../src/main/engine/ModelCatalogEngine'; +import { modelCatalog, modelCatalogModalities, modelCatalogProviders, modelCatalogMeta } from '../../src/main/database/schema'; -// ── Sample models.dev response ── +// ── Sample models.dev response (multi-provider) ── function sampleModelsDevResponse() { return { opencode: { id: 'opencode', + name: 'OpenCode Zen', + env: ['OPENCODE_API_KEY'], + npm: '@ai-sdk/openai-compatible', + api: 'https://opencode.ai/zen/v1', + doc: 'https://opencode.ai/docs/zen', models: { 'claude-sonnet-4-5': { id: 'claude-sonnet-4-5', @@ -64,6 +96,7 @@ function sampleModelsDevResponse() { attachment: true, reasoning: false, tool_call: true, + modalities: { input: ['text', 'image', 'pdf'], output: ['text'] }, cost: { input: 3, output: 15, cache_read: 0.3 }, limit: { context: 200000, output: 64000 }, }, @@ -74,6 +107,7 @@ function sampleModelsDevResponse() { attachment: true, reasoning: true, tool_call: true, + modalities: { input: ['text', 'image'], output: ['text'] }, cost: { input: 1.07, output: 8.5, cache_read: 0.107 }, limit: { context: 400000, input: 272000, output: 128000 }, }, @@ -81,10 +115,32 @@ function sampleModelsDevResponse() { id: 'model-no-cost', name: 'Free Model', family: 'free', + modalities: { input: ['text'], output: ['text'] }, limit: { context: 32000, output: 4096 }, }, }, }, + mistral: { + id: 'mistral', + name: 'Mistral AI', + env: ['MISTRAL_API_KEY'], + npm: '@mistralai/mistralai', + api: 'https://api.mistral.ai/v1', + doc: 'https://docs.mistral.ai', + models: { + 'mistral-large-latest': { + id: 'mistral-large-latest', + name: 'Mistral Large', + family: 'mistral', + attachment: true, + reasoning: false, + tool_call: true, + modalities: { input: ['text', 'image'], output: ['text'] }, + cost: { input: 2, output: 6 }, + limit: { context: 128000, output: 8192 }, + }, + }, + }, }; } @@ -93,53 +149,75 @@ describe('ModelCatalogEngine', () => { beforeEach(() => { vi.clearAllMocks(); - selectMockData = []; + modelMockData = []; + modalityMockData = []; + providerMockData = []; + metaMockData = []; insertedValues.length = 0; engine = new ModelCatalogEngine(); }); describe('getAll', () => { - it('returns all cached model catalog entries', async () => { - selectMockData = [ + it('returns all cached model catalog entries with modalities', async () => { + modelMockData = [ { - id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', family: 'claude-sonnet', + provider: 'opencode', modelId: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', + family: 'claude-sonnet', attachment: true, reasoning: false, toolCall: true, + structuredOutput: false, temperature: false, knowledge: null, + releaseDate: null, lastUpdatedDate: null, openWeights: false, contextWindow: 200000, maxInputTokens: null, maxOutputTokens: 64000, - inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3, - supportsAttachments: true, supportsReasoning: false, supportsToolCall: true, + inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3, cacheWritePrice: null, + interleaved: null, status: null, providerNpm: null, }, ]; + modalityMockData = [ + { provider: 'opencode', modelId: 'claude-sonnet-4-5', direction: 'input', modality: 'text' }, + { provider: 'opencode', modelId: 'claude-sonnet-4-5', direction: 'input', modality: 'image' }, + { provider: 'opencode', modelId: 'claude-sonnet-4-5', direction: 'output', modality: 'text' }, + ]; const result = await engine.getAll(); expect(result).toHaveLength(1); expect(result[0].id).toBe('claude-sonnet-4-5'); + expect(result[0].provider).toBe('opencode'); expect(result[0].maxOutputTokens).toBe(64000); expect(result[0].inputPrice).toBe(3); + expect(result[0].inputModalities).toEqual(['text', 'image']); + expect(result[0].outputModalities).toEqual(['text']); }); it('returns empty array when no catalog entries exist', async () => { - selectMockData = []; const result = await engine.getAll(); expect(result).toEqual([]); }); }); describe('getModel', () => { - it('returns a specific model by ID', async () => { - selectMockData = [{ - id: 'gpt-5', name: 'GPT 5', family: 'gpt', + it('returns a specific model by ID (cross-provider search)', async () => { + modelMockData = [{ + provider: 'opencode', modelId: 'gpt-5', name: 'GPT 5', family: 'gpt', + attachment: true, reasoning: true, toolCall: true, + structuredOutput: false, temperature: false, knowledge: null, + releaseDate: null, lastUpdatedDate: null, openWeights: false, contextWindow: 400000, maxInputTokens: 272000, maxOutputTokens: 128000, - inputPrice: 1.07, outputPrice: 8.5, cacheReadPrice: 0.107, - supportsAttachments: true, supportsReasoning: true, supportsToolCall: true, + inputPrice: 1.07, outputPrice: 8.5, cacheReadPrice: 0.107, cacheWritePrice: null, + interleaved: null, status: null, providerNpm: null, }]; + modalityMockData = [ + { provider: 'opencode', modelId: 'gpt-5', direction: 'input', modality: 'text' }, + { provider: 'opencode', modelId: 'gpt-5', direction: 'input', modality: 'image' }, + ]; const result = await engine.getModel('gpt-5'); expect(result).not.toBeNull(); expect(result!.name).toBe('GPT 5'); expect(result!.maxOutputTokens).toBe(128000); + expect(result!.inputModalities).toEqual(['text', 'image']); }); it('returns null for unknown model', async () => { - selectMockData = []; + modelMockData = []; + modalityMockData = []; const result = await engine.getModel('nonexistent'); expect(result).toBeNull(); }); @@ -147,38 +225,92 @@ describe('ModelCatalogEngine', () => { describe('getMaxOutputTokens', () => { it('returns output tokens from catalog when available', async () => { - selectMockData = [{ - id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', family: 'claude-sonnet', + modelMockData = [{ + provider: 'opencode', modelId: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', + family: 'claude-sonnet', attachment: true, reasoning: false, toolCall: true, + structuredOutput: false, temperature: false, knowledge: null, + releaseDate: null, lastUpdatedDate: null, openWeights: false, contextWindow: 200000, maxInputTokens: null, maxOutputTokens: 64000, - inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3, - supportsAttachments: true, supportsReasoning: false, supportsToolCall: true, + inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3, cacheWritePrice: null, + interleaved: null, status: null, providerNpm: null, }]; + modalityMockData = []; const result = await engine.getMaxOutputTokens('claude-sonnet-4-5'); expect(result).toBe(64000); }); it('returns DEFAULT_MAX_OUTPUT_TOKENS for uncatalogued model', async () => { - selectMockData = []; + modelMockData = []; + modalityMockData = []; const result = await engine.getMaxOutputTokens('unknown-model'); expect(result).toBe(DEFAULT_MAX_OUTPUT_TOKENS); }); it('returns DEFAULT_MAX_OUTPUT_TOKENS when model has null maxOutputTokens', async () => { - selectMockData = [{ - id: 'weird-model', name: 'Weird', family: null, + modelMockData = [{ + provider: 'opencode', modelId: 'weird-model', name: 'Weird', family: null, + attachment: false, reasoning: false, toolCall: false, + structuredOutput: false, temperature: false, knowledge: null, + releaseDate: null, lastUpdatedDate: null, openWeights: false, contextWindow: null, maxInputTokens: null, maxOutputTokens: null, - inputPrice: null, outputPrice: null, cacheReadPrice: null, - supportsAttachments: false, supportsReasoning: false, supportsToolCall: false, + inputPrice: null, outputPrice: null, cacheReadPrice: null, cacheWritePrice: null, + interleaved: null, status: null, providerNpm: null, }]; + modalityMockData = []; const result = await engine.getMaxOutputTokens('weird-model'); expect(result).toBe(DEFAULT_MAX_OUTPUT_TOKENS); }); }); + describe('hasInputModality', () => { + it('returns true when model has the modality', async () => { + modelMockData = [{ + provider: 'opencode', modelId: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', + family: 'claude-sonnet', attachment: true, reasoning: false, toolCall: true, + structuredOutput: false, temperature: false, knowledge: null, + releaseDate: null, lastUpdatedDate: null, openWeights: false, + contextWindow: 200000, maxInputTokens: null, maxOutputTokens: 64000, + inputPrice: 3, outputPrice: 15, cacheReadPrice: 0.3, cacheWritePrice: null, + interleaved: null, status: null, providerNpm: null, + }]; + modalityMockData = [ + { provider: 'opencode', modelId: 'claude-sonnet-4-5', direction: 'input', modality: 'image' }, + ]; + + const result = await engine.hasInputModality('claude-sonnet-4-5', 'image'); + expect(result).toBe(true); + }); + + it('returns false when model lacks the modality', async () => { + modelMockData = [{ + provider: 'opencode', modelId: 'text-only', name: 'Text Only', + family: null, attachment: false, reasoning: false, toolCall: false, + structuredOutput: false, temperature: false, knowledge: null, + releaseDate: null, lastUpdatedDate: null, openWeights: false, + contextWindow: 32000, maxInputTokens: null, maxOutputTokens: 4096, + inputPrice: null, outputPrice: null, cacheReadPrice: null, cacheWritePrice: null, + interleaved: null, status: null, providerNpm: null, + }]; + modalityMockData = [ + { provider: 'opencode', modelId: 'text-only', direction: 'input', modality: 'text' }, + ]; + + const result = await engine.hasInputModality('text-only', 'image'); + expect(result).toBe(false); + }); + + it('returns false for unknown model', async () => { + modelMockData = []; + modalityMockData = []; + const result = await engine.hasInputModality('nonexistent', 'image'); + expect(result).toBe(false); + }); + }); + describe('refresh', () => { - it('parses models.dev response and inserts models into DB', async () => { + it('parses multi-provider models.dev response and inserts all providers and models', async () => { const mockResponse = sampleModelsDevResponse(); vi.spyOn(engine as any, 'httpGet').mockResolvedValue({ statusCode: 200, @@ -186,13 +318,16 @@ describe('ModelCatalogEngine', () => { headers: { etag: '"abc123"' }, }); - // getMeta returns null (no existing etag) - selectMockData = []; + metaMockData = []; const result = await engine.refresh(); expect(result.success).toBe(true); - expect(result.modelsUpdated).toBe(3); + // 3 opencode models + 1 mistral model = 4 + expect(result.modelsUpdated).toBe(4); expect(result.notModified).toBeUndefined(); + + // Should have inserted provider rows and model rows and modality rows + expect(insertedValues.length).toBeGreaterThan(0); }); it('sends If-None-Match header when ETag is cached', async () => { @@ -208,7 +343,9 @@ describe('ModelCatalogEngine', () => { mockLocalDb.select = vi.fn(() => { metaCallCount++; if (metaCallCount === 1) { - return createSelectChain([{ key: 'etag', value: '"old-etag"' }]); + // getMeta('etag') → picks up model_catalog_meta table + const chain = createSelectChain([{ key: 'etag', value: '"old-etag"' }]); + return { from: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue(chain), ...chain }), ...chain }; } return createSelectChain([]); }) as any; @@ -231,7 +368,7 @@ describe('ModelCatalogEngine', () => { body: 'Internal Server Error', headers: {}, }); - selectMockData = []; + metaMockData = []; const result = await engine.refresh(); expect(result.success).toBe(false); @@ -240,24 +377,24 @@ describe('ModelCatalogEngine', () => { it('handles network errors gracefully', async () => { vi.spyOn(engine as any, 'httpGet').mockRejectedValue(new Error('ECONNREFUSED')); - selectMockData = []; + metaMockData = []; const result = await engine.refresh(); expect(result.success).toBe(false); expect(result.error).toBe('ECONNREFUSED'); }); - it('handles invalid response (missing opencode provider)', async () => { + it('handles invalid response (no providers)', async () => { vi.spyOn(engine as any, 'httpGet').mockResolvedValue({ statusCode: 200, - body: JSON.stringify({ other_provider: { models: {} } }), + body: JSON.stringify({}), headers: {}, }); - selectMockData = []; + metaMockData = []; const result = await engine.refresh(); expect(result.success).toBe(false); - expect(result.error).toContain('no opencode models'); + expect(result.error).toContain('no providers'); }); it('handles malformed JSON gracefully', async () => { @@ -266,7 +403,7 @@ describe('ModelCatalogEngine', () => { body: 'not valid json {{{', headers: {}, }); - selectMockData = []; + metaMockData = []; const result = await engine.refresh(); expect(result.success).toBe(false); diff --git a/tests/engine/OpenCodeManagerMistral.test.ts b/tests/engine/OpenCodeManagerMistral.test.ts index d7fefe6..6745df1 100644 --- a/tests/engine/OpenCodeManagerMistral.test.ts +++ b/tests/engine/OpenCodeManagerMistral.test.ts @@ -8,8 +8,7 @@ * - getAvailableModels() merge from both providers * - getProviderConfig() helper * - isProviderKeySet() helper - * - MODEL_CONTEXT_BUDGETS correctness - * - MODEL_CAPABILITIES (vision flags) + * - Vision from catalog modalities * - validateMistralApiKey() * - Provider-aware routing in sendOpenAIMessage() * - generateConversationTitle() provider routing @@ -336,10 +335,20 @@ describe('OpenCodeManager Mistral integration', () => { expect(providers.has('mistral')).toBe(true); }); - it('includes vision field on models', async () => { + 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({ @@ -367,6 +376,14 @@ describe('OpenCodeManager Mistral integration', () => { // 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 @@ -409,19 +426,6 @@ describe('OpenCodeManager Mistral integration', () => { }); }); - describe('MODEL_DISPLAY_NAMES includes Mistral models', () => { - it('has display names for all target Mistral models', () => { - const manager = createManager(); - const format = (manager as any).formatModelName.bind(manager); - - expect(format('mistral-large-latest')).toBe('Mistral Large'); - expect(format('mistral-medium-latest')).toBe('Mistral Medium'); - expect(format('mistral-small-latest')).toBe('Mistral Small'); - expect(format('devstral-small-latest')).toBe('Devstral Small'); - expect(format('devstral-large-latest')).toBe('Devstral Large'); - }); - }); - describe('generateConversationTitle provider routing', () => { it('uses Mistral API when conversation model is a Mistral model', async () => { const manager = createManager(); @@ -529,39 +533,24 @@ describe('OpenCodeManager Mistral integration', () => { }); }); - describe('MODEL_CONTEXT_BUDGETS', () => { - it('has correct budget values for all Mistral models', () => { - // Access the constant via a model that triggers truncation path - const manager = createManager(); - // We verify the budgets via the getProviderConfig indirectly, - // but here we check them via the module-level constant accessed via the manager - // by using sendOpenAIMessage truncation behavior. - // Since the budgets map is not exported, we test the values are correct - // by checking the truncation call parameter via a mock. - - // Access budgets through internal reference - const budgets: Record = { - '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, - }; - - // Verify each budget is reasonable (within expected ranges) - expect(budgets['mistral-large-latest']).toBe(35_000); - expect(budgets['mistral-medium-latest']).toBe(35_000); - expect(budgets['mistral-small-latest']).toBe(120_000); - expect(budgets['devstral-small-latest']).toBe(120_000); - expect(budgets['devstral-large-latest']).toBe(240_000); - }); - }); - - describe('MODEL_CAPABILITIES', () => { - it('vision flags are correct for Mistral models via getAvailableModels', async () => { + 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({ @@ -580,12 +569,12 @@ describe('OpenCodeManager Mistral integration', () => { const models = await manager.getAvailableModels(); - // Vision-capable models + // 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 + // 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); }); @@ -670,10 +659,9 @@ describe('OpenCodeManager Mistral integration', () => { }); }); - describe('validateApiKey model filtering', () => { - it('filters out models whose provider key is not set', async () => { + describe('validateApiKey returns models from API response', () => { + it('returns models from the actual API response', async () => { const manager = createManager(); - // Only OpenCode key — no Mistral key manager.setApiKey('oc-key'); (manager as any).httpRequest = vi.fn().mockResolvedValue({ @@ -683,9 +671,9 @@ describe('OpenCodeManager Mistral integration', () => { const result = await manager.validateApiKey('oc-key'); expect(result.isValid).toBe(true); - // Should NOT include Mistral models - const mistralModels = result.models.filter(m => m.provider === 'mistral'); - expect(mistralModels.length).toBe(0); + expect(result.models).toHaveLength(1); + expect(result.models[0].id).toBe('claude-sonnet-4'); + expect(result.models[0].provider).toBe('anthropic'); }); }); }); diff --git a/tests/engine/OpenCodeModelDiscovery.test.ts b/tests/engine/OpenCodeModelDiscovery.test.ts index e213ad8..c716f51 100644 --- a/tests/engine/OpenCodeModelDiscovery.test.ts +++ b/tests/engine/OpenCodeModelDiscovery.test.ts @@ -67,76 +67,21 @@ describe('OpenCodeManager model discovery', () => { vi.useRealTimers(); }); - describe('formatModelName', () => { - it('formats Claude model IDs with proper spacing', () => { - const manager = createManager(); - const format = (manager as any).formatModelName.bind(manager); - - expect(format('claude-opus-4-6')).toBe('Claude Opus 4.6'); - expect(format('claude-sonnet-4-5')).toBe('Claude Sonnet 4.5'); - expect(format('claude-sonnet-4')).toBe('Claude Sonnet 4'); - expect(format('claude-haiku-4-5')).toBe('Claude Haiku 4.5'); - expect(format('claude-3-5-haiku')).toBe('Claude 3.5 Haiku'); - }); - - it('formats GPT model IDs with uppercase prefix', () => { - const manager = createManager(); - const format = (manager as any).formatModelName.bind(manager); - - expect(format('gpt-5')).toBe('GPT 5'); - expect(format('gpt-5.1')).toBe('GPT 5.1'); - expect(format('gpt-5.1-codex')).toBe('GPT 5.1 Codex'); - expect(format('gpt-5.1-codex-max')).toBe('GPT 5.1 Codex Max'); - expect(format('gpt-5.1-codex-mini')).toBe('GPT 5.1 Codex Mini'); - expect(format('gpt-5-nano')).toBe('GPT 5 Nano'); - expect(format('gpt-5.3-codex')).toBe('GPT 5.3 Codex'); - }); - - it('formats GLM model IDs with uppercase prefix', () => { - const manager = createManager(); - const format = (manager as any).formatModelName.bind(manager); - - expect(format('glm-5')).toBe('GLM 5'); - expect(format('glm-4.7')).toBe('GLM 4.7'); - expect(format('glm-4.6')).toBe('GLM 4.6'); - }); - - it('formats Gemini model IDs properly', () => { - const manager = createManager(); - const format = (manager as any).formatModelName.bind(manager); - - expect(format('gemini-3-pro')).toBe('Gemini 3 Pro'); - expect(format('gemini-3-flash')).toBe('Gemini 3 Flash'); - expect(format('gemini-3.1-pro')).toBe('Gemini 3.1 Pro'); - }); - - it('formats free/preview suffixes', () => { - const manager = createManager(); - const format = (manager as any).formatModelName.bind(manager); - - expect(format('gpt-5-nano')).toBe('GPT 5 Nano'); - expect(format('minimax-m2.5-free')).toBe('MiniMax M2.5 Free'); - expect(format('kimi-k2.5-free')).toBe('Kimi K2.5 Free'); - expect(format('trinity-large-preview-free')).toBe('Trinity Large Preview Free'); - }); - - it('formats other provider model IDs', () => { - const manager = createManager(); - const format = (manager as any).formatModelName.bind(manager); - - expect(format('minimax-m2.5')).toBe('MiniMax M2.5'); - expect(format('minimax-m2.1')).toBe('MiniMax M2.1'); - expect(format('kimi-k2.5')).toBe('Kimi K2.5'); - expect(format('kimi-k2')).toBe('Kimi K2'); - expect(format('kimi-k2-thinking')).toBe('Kimi K2 Thinking'); - expect(format('qwen3-coder')).toBe('Qwen3 Coder'); - expect(format('big-pickle')).toBe('Big Pickle'); - }); - }); - describe('getAvailableModels', () => { - it('returns models from API with proper names and providers', async () => { + it('returns models from API with catalog names and catalog-derived vision', async () => { const manager = createManager(); + + // Mock catalog with modality data and display names + (manager as any).modelCatalogEngine = { + getAll: vi.fn().mockResolvedValue([ + { id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image', 'pdf'], outputModalities: ['text'] }, + { id: 'gpt-5.1-codex', name: 'GPT 5.1 Codex', inputModalities: ['text'], outputModalities: ['text'] }, + { id: 'gemini-3-pro', name: 'Gemini 3 Pro', inputModalities: ['text', 'image', 'video'], outputModalities: ['text'] }, + ]), + getMaxOutputTokens: vi.fn().mockResolvedValue(16384), + getContextWindow: vi.fn().mockResolvedValue(null), + }; + const zenResponse = createZenModelResponse([ 'claude-sonnet-4', 'gpt-5.1-codex', @@ -156,30 +101,45 @@ describe('OpenCodeManager model discovery', () => { expect(models[2]).toEqual({ id: 'gemini-3-pro', name: 'Gemini 3 Pro', provider: 'google', vision: true }); }); - it('falls back to known models when API fails', async () => { + it('falls back to model catalog when API fails', async () => { const manager = createManager(); (manager as any).httpRequest = vi.fn().mockRejectedValue(new Error('Network error')); + (manager as any).modelCatalogEngine = { + getAll: vi.fn().mockResolvedValue([ + { id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image'], outputModalities: ['text'] }, + { id: 'gpt-5', name: 'GPT 5', inputModalities: ['text', 'image'], outputModalities: ['text'] }, + ]), + getMaxOutputTokens: vi.fn().mockResolvedValue(16384), + getContextWindow: vi.fn().mockResolvedValue(null), + }; const models = await manager.getAvailableModels(); expect(models.length).toBeGreaterThan(0); - // Should include well-known models from the display name map const ids = models.map((m: ChatModel) => m.id); expect(ids).toContain('claude-sonnet-4'); expect(ids).toContain('gpt-5'); - // Every model should have proper provider detection const claudeModel = models.find((m: ChatModel) => m.id === 'claude-sonnet-4'); expect(claudeModel?.provider).toBe('anthropic'); + expect(claudeModel?.name).toBe('Claude Sonnet 4'); const gptModel = models.find((m: ChatModel) => m.id === 'gpt-5'); expect(gptModel?.provider).toBe('openai'); + expect(gptModel?.name).toBe('GPT 5'); }); - it('falls back when API returns non-200 status', async () => { + it('falls back to model catalog when API returns non-200 status', async () => { const manager = createManager(); (manager as any).httpRequest = vi.fn().mockResolvedValue({ statusCode: 401, body: '{"error":"unauthorized"}', }); + (manager as any).modelCatalogEngine = { + getAll: vi.fn().mockResolvedValue([ + { id: 'claude-sonnet-4', name: 'Claude Sonnet 4', inputModalities: ['text', 'image'], outputModalities: ['text'] }, + ]), + getMaxOutputTokens: vi.fn().mockResolvedValue(16384), + getContextWindow: vi.fn().mockResolvedValue(null), + }; const models = await manager.getAvailableModels(); @@ -220,7 +180,7 @@ describe('OpenCodeManager model discovery', () => { expect(httpRequest).toHaveBeenCalledTimes(2); }); - it('handles unknown model IDs from API with auto-formatting', async () => { + it('handles unknown model IDs from API with raw IDs as fallback names', async () => { const manager = createManager(); const zenResponse = createZenModelResponse(['some-new-model-v3']); @@ -232,15 +192,22 @@ describe('OpenCodeManager model discovery', () => { const models = await manager.getAvailableModels(); expect(models).toHaveLength(1); - expect(models[0].name).toBe('Some New Model V3'); + expect(models[0].name).toBe('some-new-model-v3'); expect(models[0].provider).toBe('other'); }); - it('falls back to known models when no API key is set', async () => { + it('falls back to model catalog when no API key is set', async () => { const manager = createManager(); (manager as any).apiKey = ''; - // Set a key so fallback filtering works (at least one provider must have a key) 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(); @@ -248,6 +215,8 @@ describe('OpenCodeManager model discovery', () => { expect(models.length).toBeGreaterThan(0); const providers = new Set(models.map((m: ChatModel) => m.provider)); expect(providers.has('mistral')).toBe(true); + // OpenCode/Anthropic models should be filtered out (no OpenCode key) + expect(providers.has('anthropic')).toBe(false); }); }); }); From c02ddc6d7ab354b230b912947a24e58e84c93ea7 Mon Sep 17 00:00:00 2001 From: hugo Date: Sun, 1 Mar 2026 17:09:29 +0100 Subject: [PATCH 6/7] fix: better handling of empty model catalog --- src/main/engine/OpenCodeManager.ts | 9 +++++++++ src/main/ipc/chatHandlers.ts | 3 +++ 2 files changed, 12 insertions(+) diff --git a/src/main/engine/OpenCodeManager.ts b/src/main/engine/OpenCodeManager.ts index f8e4cf7..f2ed18b 100644 --- a/src/main/engine/OpenCodeManager.ts +++ b/src/main/engine/OpenCodeManager.ts @@ -2167,6 +2167,15 @@ NOTE: Use pagination (offset/limit) in list_posts and search_posts to access all return this.modelCatalogEngine; } + /** + * Invalidate the in-memory model cache so the next getAvailableModels() + * re-fetches and re-cross-references with the catalog. + */ + invalidateModelCache(): void { + this.cachedModels = null; + this.cachedModelsAt = 0; + } + /** * Check whether the given provider's API key is configured. * All non-mistral providers are routed through OpenCode Zen and share apiKey. diff --git a/src/main/ipc/chatHandlers.ts b/src/main/ipc/chatHandlers.ts index 0755db4..fdfc156 100644 --- a/src/main/ipc/chatHandlers.ts +++ b/src/main/ipc/chatHandlers.ts @@ -330,6 +330,9 @@ export function registerChatHandlers(): void { try { const manager = await getOpenCodeManager(); const result = await manager.getModelCatalogEngine().refresh(); + // Invalidate the in-memory model cache so vision/name data + // from the freshly populated catalog is picked up immediately. + manager.invalidateModelCache(); return result; } catch (error) { console.error('[Chat IPC] Error refreshing model catalog:', error); From 84a5e0b64a034d6892abd847c74df5ab9327ea10 Mon Sep 17 00:00:00 2001 From: hugo Date: Sun, 1 Mar 2026 17:11:58 +0100 Subject: [PATCH 7/7] chore: closed plan that is implemented --- MISTRAL_PLAN.md | 641 ------------------------------------------------ 1 file changed, 641 deletions(-) delete mode 100644 MISTRAL_PLAN.md diff --git a/MISTRAL_PLAN.md b/MISTRAL_PLAN.md deleted file mode 100644 index a98a754..0000000 --- a/MISTRAL_PLAN.md +++ /dev/null @@ -1,641 +0,0 @@ -# Plan: Add Mistral AI as Alternative Chat Provider - -## Context -bDS currently routes all AI chat through the OpenCode Zen gateway (`opencode.ai/zen/v1/...`) with two code paths: Anthropic Messages API and OpenAI-compatible. The user wants Mistral AI added as a direct alternative provider with frontier models that support chat completion, tool use, and vision. Mistral's API is OpenAI-compatible (`api.mistral.ai/v1/chat/completions`), making integration straightforward. - -**Important architecture facts:** -- HTTP requests are currently **non-streaming** (full response body collected, text emitted after each complete call) — to be converted to SSE streaming in a **separate prerequisite PR** (PR 1) -- API keys are stored in **plain-text SQLite** — to be migrated to Electron `safeStorage` (OS keychain) for all providers in a **separate prerequisite PR** (PR 2) -- Neither `sendAnthropicMessage()` nor `sendOpenAIMessage()` currently sets `tool_choice` -- `sendOpenAIMessage()` does **not** convert `view_image` results to `image_url` format — they are JSON-stringified -- `generateConversationTitle()` is hardcoded to `claude-haiku-4-5` via `ZEN_ANTHROPIC_URL` -- `analyzeMediaImage()` is hardcoded to `claude-sonnet-4-5` via `ZEN_ANTHROPIC_URL` -- `checkReady()` only checks the OpenCode key — blocks `sendMessage()` for keyless users -- Internal `ModelInfo` type (returned by `getAvailableModels()`) is `{ id, name, provider }` — same shape as `ChatModel` in `electronApi.ts`; ensure they stay aligned when adding `vision` field - -## PR Structure - -This work is split into **3 sequential PRs** to reduce risk: - -| PR | Scope | Key Changes | -|----|-------|-------------| -| **PR 1 — SSE Streaming** ✅ | Standalone feature, no Mistral dependency | `httpRequestStream()`, SSE parsers (Anthropic + OpenAI formats), `stream: true` in request bodies, tool-call accumulation during streaming | -| **PR 2 — Keychain Migration** ✅ | Standalone security improvement | Migrate OpenCode API key from plain-text SQLite to `safeStorage`; add encryption/decryption wrappers; delete old plain-text keys (no migration); cross-platform (macOS Keychain, Windows DPAPI, Linux libsecret) | -| **PR 3 — Mistral Integration** | Builds on PR 1 + PR 2 | Mistral constants, model detection, key storage (using keychain from PR 2), parameterized `sendOpenAIMessage()`, vision fix, provider-aware routing, UI changes, i18n | - -## Target Models - -Use **latest aliases** (not dated IDs) so models auto-update when Mistral releases new versions. `getAvailableModels()` fetches the actual model list from the API; `MODEL_DISPLAY_NAMES` provides human-readable names for known models. - -| Model ID (latest alias) | Display Name | Vision | Tools | Context Window | Context Budget | -|------------------------|-------------|--------|-------|----------------|----------------| -| `mistral-large-latest` | Mistral Large | yes | yes | 40k | 35,000 | -| `mistral-medium-latest` | Mistral Medium | yes | yes | 40k | 35,000 | -| `mistral-small-latest` | Mistral Small | yes | yes | 128k | 120,000 | -| `devstral-small-latest` | Devstral Small | no | yes | 128k | 120,000 | -| `devstral-large-latest` | Devstral Large | no | yes | 256k | 240,000 | - -## Files to Modify - -### 1. `src/main/engine/OpenCodeManager.ts` - Core provider logic - -**A. Add Mistral constants** (near lines 23-25) -- `MISTRAL_API_URL = 'https://api.mistral.ai/v1/chat/completions'` -- `MISTRAL_MODELS_URL = 'https://api.mistral.ai/v1/models'` - -**B. Add Mistral models to `MODEL_DISPLAY_NAMES`** (lines 28-69) -``` -'mistral-large-latest': 'Mistral Large' -'mistral-medium-latest': 'Mistral Medium' -'mistral-small-latest': 'Mistral Small' -'devstral-small-latest': 'Devstral Small' -'devstral-large-latest': 'Devstral Large' -``` - -**C. Update `detectProvider()`** (lines 1839-1845) -- Add: `if (id.startsWith('mistral') || id.startsWith('ministral') || id.startsWith('devstral') || id.startsWith('codestral') || id.startsWith('pixtral')) return 'mistral';` -- This covers all current and foreseeable Mistral model prefixes (Mistral, Ministral, Devstral, Codestral, Pixtral) - -**C2. Update `formatModelName()` and `UPPERCASE_PREFIXES`** -- `formatModelName()` (L1869) first checks `MODEL_DISPLAY_NAMES`, then auto-formats via hyphen splitting + capitalization -- All 5 Mistral models are in `MODEL_DISPLAY_NAMES`, so auto-format is a fallback for future unknown models — no changes needed -- `UPPERCASE_PREFIXES` (L72) contains `['gpt', 'glm']` — no Mistral prefixes need uppercasing, so no changes needed - -**D. Add Mistral API key storage (using keychain from PR 2)** -- New field: `private mistralApiKey: string = ''` -- New methods: `setMistralApiKey()`, `getMistralApiKey()`, `validateMistralApiKey()` -- Load on init via `SecureKeyStore.retrieve()` (keychain infrastructure from PR 2) -- Store/retrieve using the same `SecureKeyStore` wrapper that PR 2 introduces for the OpenCode key -- No plain-text fallback — `safeStorage` is required - -**E. Update `checkReady()`** -- Return `ready: true` if **either** OpenCode key or Mistral key is set -- Extend `ChatReadyStatus` to report per-provider availability, e.g. `providers: { opencode: boolean, mistral: boolean }` -- Callers (`Sidebar.tsx`, `sendMessage()`) must gate on the relevant provider, not a single boolean - -**F. Parameterize `sendOpenAIMessage()` for Mistral (no separate method)** -- Mistral uses the identical OpenAI-compatible chat/completions format — creating a separate `sendMistralRequest()` would be a near-duplicate -- Instead, parameterize `sendOpenAIMessage()` to accept URL, API key, and provider-specific options: - - Add params: `apiUrl: string`, `apiKey: string`, `providerOptions?: { parallelToolCalls?: boolean }` - - `sendMessage()` determines provider via `detectProvider()` and calls `sendOpenAIMessage()` with the correct URL/key/options - - For OpenCode OpenAI path: URL = `ZEN_OPENAI_URL`, key = `this.apiKey` - - For Mistral: URL = `MISTRAL_API_URL`, key = `this.mistralApiKey`, `parallelToolCalls: false` -- `tool_choice`: omit entirely for all OpenAI-compatible providers (default `"auto"` is correct) -- `parallel_tool_calls: false` — set explicitly for Mistral only; our tool executor runs tools sequentially, so parallel tool calls would break the execution loop - -**F1b. Update `requestProvider` closure in `sendMessage()`** -- The `requestProvider` lambda (~line 362) dispatches to `sendAnthropicMessage()` or `sendOpenAIMessage()` based on `detectProvider()` -- The else branch must pass provider-specific URL/key/options when calling `sendOpenAIMessage()`: - - `provider === 'mistral'`: URL = `MISTRAL_API_URL`, key = `this.mistralApiKey`, options = `{ parallelToolCalls: false }` - - All other non-Anthropic providers: URL = `ZEN_OPENAI_URL`, key = `this.apiKey` (existing behavior) -- Helper method `getProviderConfig(provider)` could return `{ apiUrl, apiKey, options }` to keep `requestProvider` clean - -**F2. Add `MODEL_CONTEXT_BUDGETS` map** -- New constant map `MODEL_CONTEXT_BUDGETS: Record` with per-model token budgets -- `truncateToTokenBudget()` (L1654) currently defaults to `maxContextTokens = 150000` -- In `sendAnthropicMessage()` and `sendOpenAIMessage()`: pass the model's context budget from the map (defaulting to 150,000 for OpenCode models) -- The parameterized `sendOpenAIMessage()` looks up `MODEL_CONTEXT_BUDGETS[modelId]` for Mistral models and passes to truncation -- Values (keyed by latest aliases): - - `'mistral-large-latest': 35_000` - - `'mistral-medium-latest': 35_000` - - `'mistral-small-latest': 120_000` - - `'devstral-small-latest': 120_000` - - `'devstral-large-latest': 240_000` - -**G. Fix tool-call message history in OpenAI-compatible path** -- Within a single `sendMessage()` call, the tool loop correctly tracks tool results across rounds -- However, `tool` role messages are not persisted to DB-backed conversation history — on conversation resume, the model loses context about prior tool results -- Ensure `tool` role messages are included when persisting conversation history so cross-session continuity works -- This affects all OpenAI-compatible providers (OpenCode OpenAI path + Mistral) - -**H. Fix vision in OpenAI-compatible path (affects Mistral too)** -- `sendOpenAIMessage()` currently JSON-stringifies `view_image` results — no `image_url` conversion -- Add `image_url` format conversion for `__isImageResult` objects in the OpenAI path: - `{ type: 'image_url', image_url: { url: 'data:image/webp;base64,...' } }` -- This fixes vision for all OpenAI-compatible providers, not just Mistral - -**I. Update `getAvailableModels()` — merge from both providers** -- **Model list merge strategy**: fetch models from each configured provider's API endpoint and merge into a single list. When both keys are configured, return models from both; when only one key is set, return only that provider's models; when no key is set, return an empty list (UI disables the dropdown) -- OpenCode models: fetched from existing OpenCode API (as today) -- Mistral models: fetched from `GET https://api.mistral.ai/v1/models` when Mistral key is set; cross-reference returned IDs with `MODEL_DISPLAY_NAMES` to use display names + static `vision`/`contextBudget` metadata -- Every model entry carries `provider: 'opencode' | 'mistral'` so the UI and engine can resolve the correct API URL + key -- Invalidate `cachedModels`/`cachedModelsAt` when any provider key is added or removed - -**I2. Filter fallback model list by available keys** -- `getAvailableModels()` currently falls back to the full `MODEL_DISPLAY_NAMES` map when the API call fails -- With Mistral models added to `MODEL_DISPLAY_NAMES`, the fallback would show Mistral models even without a Mistral key -- Filter fallback: `fallback.filter(m => this.isProviderKeySet(m.provider))` — only include models whose provider has a configured key -- Add helper `isProviderKeySet(provider: string): boolean` that checks the relevant key field -- **Same issue in `validateApiKey()`**: currently returns models from the full `MODEL_DISPLAY_NAMES` map regardless of which provider key was validated. Once Mistral models are added, a successful OpenCode validation would incorrectly include Mistral models. Apply the same `isProviderKeySet()` filter to `validateApiKey()` results - -**I3. Add `MODEL_CAPABILITIES` map for vision flags** -- The Mistral API's `/v1/models` endpoint does NOT include a `vision` field — vision capability must come from a local static map -- New constant: `MODEL_CAPABILITIES: Record` keyed by model ID -- Entries for Mistral models: - - `'mistral-large-latest': { vision: true }` - - `'mistral-medium-latest': { vision: true }` - - `'mistral-small-latest': { vision: true }` - - `'devstral-small-latest': { vision: false }` - - `'devstral-large-latest': { vision: false }` -- OpenCode models also need vision flags (e.g., `'claude-sonnet-4-5': { vision: true }`, `'o3': { vision: false }`) for the image analysis model dropdown filter -- `getAvailableModels()` attaches `vision` from this map to each returned model -- Falls back to `vision: false` for unknown models (safe default; prevents non-vision models from appearing in the image analysis dropdown) - -**I4. Reconcile `ModelInfo` and `ChatModel` types** -- Internal `ModelInfo` (returned by `getAvailableModels()`) is `{ id, name, provider }` — same shape as `ChatModel` in `electronApi.ts` -- When adding `vision: boolean` to `ChatModel`, also update `ModelInfo` (or alias/merge them) so the engine and renderer use the same type -- Simplest approach: remove `ModelInfo`, use `ChatModel` everywhere (engine + IPC + renderer) - -**J. Update `generateConversationTitle()` — make configurable in Preferences** -- Currently hardcoded to `claude-haiku-4-5` via `ZEN_ANTHROPIC_URL` with OpenCode key -- Add a **"Title generation model"** preference in Settings so users can pick the cheapest model for this task -- Default: `claude-haiku-4-5` (OpenCode) or `mistral-small-latest` (Mistral) based on available keys -- Route to the correct provider URL + API key based on the selected model's provider -- Must work with any configured provider, not just OpenCode - -**K. Update `analyzeMediaImage()` — make configurable in Preferences** (lines 2066-2192) -- Currently hardcoded to `claude-sonnet-4-5` via `ZEN_ANTHROPIC_URL` -- Add an **"Image analysis model"** preference in Settings so users can select a dedicated vision model independent of their chat model (e.g. use Devstral for chat but Mistral Large 3 for images) -- **Only vision-capable models may be offered** — filter model list by a `vision` capability flag (e.g. Devstral models have no vision and must be excluded) -- Default: `claude-sonnet-4-5` (OpenCode) or first vision-capable Mistral model based on available keys -- Route to the correct provider URL + API key based on the selected model's provider -- When routed to Mistral: use `image_url` format with base64 data URI -- When routed to OpenCode/Anthropic: keep current Anthropic-native `image` block format - -**L. Update `analyzeTaxonomy()`** -- Currently uses `this.apiKey` (OpenCode) for both Anthropic and OpenAI paths -- Has an early-return guard `if (!this.apiKey)` that must become provider-aware — check Mistral key when provider is Mistral -- When a Mistral model is selected: use Mistral API key + `MISTRAL_API_URL` -- Must branch on provider to select correct key and URL -- **Note**: the OpenAI branch inside `analyzeTaxonomy()` also hardcodes `ZEN_OPENAI_URL` and `this.apiKey` — both must become provider-aware (use `getProviderConfig(provider)` helper from F1b) - -**L2. Update `analyzeMediaImage()` API key guard** -- Same issue: has `if (!this.apiKey)` early-return guard -- Must become provider-aware — check the relevant provider's key based on the selected image analysis model -- When routed to Mistral: check `this.mistralApiKey` instead of `this.apiKey` - -**M. Convert chat HTTP calls to SSE streaming (PR 1 — separate prerequisite PR)** - -> **This entire section (M1–M6) is implemented in PR 1, before the Mistral PR.** The Mistral PR (PR 3) inherits streaming support and only needs to pass the correct URL/key/options to the already-streaming `sendOpenAIMessage()`. - -Currently `httpRequest()` buffers the entire response body before any text reaches the UI. Users wait 5–30s per API round with only a bouncing-dots indicator. All three providers (Anthropic, OpenAI, Mistral) support `stream: true` with SSE. - -**M1. Core streaming infrastructure — `httpRequestStream()`** -- New method (~100 lines) — uses Node.js `https.request()` but reads `res` as a readable stream -- Returns an async iterable of parsed SSE events (or accepts an `onEvent` callback) -- SSE line protocol: lines separated by `\n\n`, each line prefixed with `event: ` or `data: ` -- Must handle: - - Buffering partial lines across `data` chunks (TCP may split mid-line) - - Empty `data:` lines (keep-alive pings) - - `data: [DONE]` sentinel — terminates the stream for OpenAI/Mistral (do NOT try to JSON.parse this) - - Multiple `data:` lines between double-newlines (concatenate per SSE spec) -- Supports `AbortSignal` — calls `req.destroy()` to terminate immediately -- 120-second timeout matching existing `httpRequest()` -- On non-2xx status: collect the error body (not streamed) and throw with parsed error message - -**M2. SSE parser for OpenAI/Mistral format** (~50 lines) -OpenAI and Mistral use identical SSE event structure: -``` -data: {"id":"...","choices":[{"delta":{"content":"Hello"}}]} -data: {"id":"...","choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_...","function":{"name":"search_posts","arguments":""}}]}}]} -data: {"id":"...","choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"query\""}}]}}]} -... -data: {"id":"...","usage":{"prompt_tokens":150,"completion_tokens":42,"total_tokens":192}} -data: [DONE] -``` -- **Text deltas**: `choices[0].delta.content` — emit via `onDelta(content)` immediately -- **Tool call start**: `delta.tool_calls[i]` with `id` + `function.name` — begin accumulating arguments for tool call at index `i` -- **Tool call argument fragments**: `delta.tool_calls[i].function.arguments` — append to argument accumulator string for index `i` -- **Finish reason**: `choices[0].finish_reason === 'tool_calls'` or `'stop'` — signals end of this chunk -- **Token usage**: arrives in the **final chunk before `[DONE]`** only if `stream_options: { include_usage: true }` is set in the request body — parse `usage.prompt_tokens`, `usage.completion_tokens`, `usage.total_tokens` -- **`[DONE]` sentinel**: stop iteration, do NOT JSON.parse -- After stream ends: if tool calls were accumulated, JSON.parse each tool's assembled arguments string and execute - -**M3. SSE parser for Anthropic format** (~60 lines) -Anthropic uses named event types: -``` -event: message_start -data: {"type":"message_start","message":{"id":"...","model":"...","usage":{"input_tokens":150}}} - -event: content_block_start -data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}} - -event: content_block_delta -data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}} - -event: content_block_start -data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_...","name":"search_posts"}} - -event: content_block_delta -data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"query\""}} - -event: content_block_stop -data: {"type":"content_block_stop","index":1} - -event: message_delta -data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":42}} - -event: message_stop -data: {"type":"message_stop"} -``` -- **`message_start`**: extract `usage.input_tokens` (prompt tokens) + `usage.cache_read_input_tokens` + `usage.cache_creation_input_tokens` -- **`content_block_start`** with `type: 'text'`: no-op (empty initial text) -- **`content_block_start`** with `type: 'tool_use'`: record tool call `id` and `name` at block index -- **`content_block_delta`** with `type: 'text_delta'`: emit via `onDelta(delta.text)` immediately -- **`content_block_delta`** with `type: 'input_json_delta'`: append `delta.partial_json` to argument accumulator -- **`content_block_stop`**: if tool block, JSON.parse the accumulated arguments for that block -- **`message_delta`**: extract `usage.output_tokens` (completion tokens), `delta.stop_reason` -- **`message_stop`**: stream complete -- **`ping`**: ignore (keep-alive) -- **`error`**: throw with `data.error.message` — handles mid-stream server errors (e.g. overloaded) - -**M4. Request body changes** -- `sendAnthropicMessage()`: add `"stream": true` to request body -- `sendOpenAIMessage()` (used for both OpenCode OpenAI and Mistral): add `"stream": true` and `"stream_options": { "include_usage": true }` to request body — this is **required** to receive token usage in streaming mode (without it, usage is omitted from streamed responses) - -**M5. Tool call accumulation during streaming** -- Tool call arguments arrive as partial JSON fragments across many SSE events -- Maintain a per-stream accumulator: `Map` keyed by tool call index -- Append each `arguments` fragment to the accumulator string -- On stream completion (finish_reason `tool_calls`/`tool_use`, or `content_block_stop` for Anthropic): JSON.parse the full accumulated arguments string and execute the tool -- If JSON.parse fails on accumulated arguments, report a tool error to the model and continue - -**M6. Error handling during streaming** -- **Non-2xx status on connection**: do NOT stream; collect the full error body and throw (same as current `httpRequest()` behavior) -- **Mid-stream TCP disconnect / network error**: `res.on('error')` handler — emit whatever text was accumulated so far, then throw so the tool-call loop can surface the error to the user -- **Mid-stream API error event**: Anthropic sends `event: error` with error details; OpenAI/Mistral return an error JSON in a `data:` line — detect and throw with parsed error message -- **Abort during streaming**: `req.destroy()` triggers `res.on('error')` or `res.on('close')` — handle gracefully without surfacing as an error to the user (it's intentional cancellation) - -**M7. Retry with exponential backoff for transient errors** -- Applies to **all providers** (Anthropic, OpenAI, Mistral) for both streaming and non-streaming calls -- Retry on HTTP status codes: `429` (rate limit), `503` (service unavailable), `502` (bad gateway) -- Max 3 retries with exponential backoff: ~1s, ~2s, ~4s (with jitter) -- For `429`: respect `Retry-After` header if present (use as minimum delay) -- For streaming: retry the entire request (cannot resume a partial SSE stream) -- Do NOT retry on `4xx` errors other than 429 (client errors like 400, 401, 403 are not transient) -- Do NOT retry on abort (intentional cancellation) -- Emit a brief status via `onDelta` or logging so the user knows a retry is in progress (e.g., "[Retrying...]") — or silently retry if preferred -- Wrap in a helper: `withRetry(fn, { maxRetries: 3, retryableStatuses: [429, 502, 503] })` - -**What does NOT change:** -- The renderer pipeline — `onDelta` → IPC `chat-stream-delta` → `appendStreamDelta` → React state → live Markdown rendering already works token-by-token; it just receives one big chunk today -- `AbortController` abort support — `req.destroy()` stops the stream immediately instead of wasting a buffered response -- The tool-call loop structure — still max 10 rounds, still sequential - -**What to keep non-streaming:** -- `generateConversationTitle()` — small one-shot request, buffering is fine -- `analyzeMediaImage()` — one-shot, no UI streaming needed -- `analyzeTaxonomy()` — one-shot, no UI streaming needed -- `validateApiKey()` / `validateMistralApiKey()` — small validation requests -- Note: `validateMistralApiKey()` must call `GET https://api.mistral.ai/v1/models` with `Authorization: Bearer ${key}`. Mistral returns `{ data: [{ id, object, created, owned_by }] }` — check for HTTP 200 + non-empty `data` array. On 401, return invalid. On success, optionally cross-reference returned model IDs with `MODEL_DISPLAY_NAMES` to verify expected models are available - -**Estimated scope:** ~350 lines of new code in `OpenCodeManager.ts` (streaming ~200 lines + retry ~50 lines + parsers ~100 lines) - -### 1b. Keychain Migration (PR 2 — separate prerequisite PR) - -> **This section is implemented in PR 2, before the Mistral PR.** PR 3 (Mistral) uses the keychain infrastructure introduced here. - -**Scope:** Migrate all API keys from plain-text SQLite to Electron `safeStorage` (OS keychain). Cross-platform: macOS Keychain, Windows DPAPI, Linux libsecret. No legacy fallback — old plain-text keys are deleted on startup; users re-enter keys. - -**1b-A. `SecureKeyStore` utility class** (~60 lines) -- New module: `src/main/engine/SecureKeyStore.ts` -- `store(key: string, value: string)` — encrypts with `safeStorage.encryptString()`, stores encrypted Buffer in SQLite settings table (as base64 string under a `__encrypted_` prefixed key) -- `retrieve(key: string): string | null` — reads encrypted base64 from SQLite, decrypts with `safeStorage.decryptString()` -- `remove(key: string)` — deletes the encrypted entry -- `isAvailable(): boolean` — wraps `safeStorage.isEncryptionAvailable()` -- No plain-text fallback — `store()` throws if `safeStorage` is unavailable - -**1b-B. Cleanup of old plain-text keys** (~10 lines) -- On app startup (in `getOpenCodeManager()` init): delete plain-text `opencode_api_key` from settings if it exists -- No migration — users re-enter their API key after the update -- Simple and secure: no window where both plain-text and encrypted keys coexist - -**1b-C. Update `setApiKey()` / `getApiKey()` in chatHandlers** -- Use `SecureKeyStore.store()` / `SecureKeyStore.retrieve()` instead of direct `getSetting()`/`setSetting()` -- `getApiKey()` returns masked key as before (for UI display) -- `validateApiKey()` unchanged — works with the decrypted key in memory - -**1b-D. Tests** -- `SecureKeyStore` unit tests: encrypt/decrypt round-trip, error when `safeStorage` unavailable, cleanup of old plain-text keys -- Mock `safeStorage` in tests (it's an Electron API, not available in Node) - -**Estimated scope:** ~120 lines of new code + ~80 lines of tests - -### 2. `src/main/engine/ChatEngine.ts` - Settings persistence - -**A. Add Mistral key helpers** -- Use existing generic `getSetting()`/`setSetting()` with key `'mistral_api_key'` — no dedicated methods needed, avoids unnecessary boilerplate -- ChatEngine already exposes generic helpers for reading/writing the settings table -- Note: the actual encrypted key storage goes through `SecureKeyStore` (PR 2) — `getSetting()`/`setSetting()` is used only for non-sensitive preferences - -**B. Default model is user-driven** -- `getSelectedModel()` defaults to `'claude-sonnet-4-5'` -- When user configures providers in Preferences, they explicitly select their default model — no automatic fallback logic needed -- All surfaces (ChatPanel, AssistantSidebar, ImportAnalysisView) use this preference as default -- If selected model's provider key is later removed: - - `sendMessage()` returns a clear error string: "The selected model requires a {provider} API key. Configure it in Settings." - - `checkReady()` still returns `ready: true` if any other provider is available - - ChatPanel shows an inline error banner (not a toast) with a link/button to open Settings - - i18n key: `chat.providerKeyMissing` — "The model '{model}' requires a {provider} API key. Go to Settings to configure it." - - Add this key to all 5 locale files - - This applies equally to **existing open conversations** whose model belongs to the removed provider — the next `sendMessage()` in those conversations shows the same inline error, not a silent failure - -**C. Add per-purpose model preferences** -- `getTitleModel()` / `setTitleModel(modelId)` — settings key `'chat_title_model'` -- `getImageAnalysisModel()` / `setImageAnalysisModel(modelId)` — settings key `'chat_image_analysis_model'` -- Both default to `null` (= use hardcoded defaults per provider) - -### 3. `src/main/ipc/chatHandlers.ts` - IPC bridge - -**A. Add Mistral-specific handlers** -- `chat:setMistralApiKey` - validate + persist Mistral key, invalidate model cache -- `chat:getMistralApiKey` - return masked key -- `chat:validateMistralApiKey` - test key against Mistral API - -**B. Update `chat:getAvailableModels`** -- Include Mistral models when Mistral key is configured -- Return provider info per model - -**C. Update `chat:checkReady`** -- Report readiness for both providers independently - -**D. Update `getOpenCodeManager()` init** -- Load Mistral API key via `SecureKeyStore.retrieve('mistral_api_key')` on first call (alongside OpenCode key) -- Call `manager.setMistralApiKey()` during init - -**E. Add per-purpose model preference handlers** -- `chat:setTitleModel` / `chat:getTitleModel` — persist + load title generation model preference -- `chat:setImageAnalysisModel` / `chat:getImageAnalysisModel` — persist + load image analysis model preference - -### 4. `src/main/shared/electronApi.ts` - Type definitions - -**A. Extend `ChatModel` interface** -- Add `provider: 'opencode' | 'mistral'` field (already optional, ensure populated) -- Add `vision: boolean` field — indicates whether the model supports image inputs (used to filter the image analysis model dropdown) - -**B. Extend `ChatReadyStatus` interface** -- Add `providers?: { opencode: boolean; mistral: boolean }` for per-provider status - -**C. Add Mistral IPC methods to `ElectronAPI.chat`** -- `setMistralApiKey(key: string)` -- `getMistralApiKey()` -- `validateMistralApiKey(key: string)` - -**D. Add per-purpose model preference methods to `ElectronAPI.chat`** -- `setTitleModel(modelId: string | null)` / `getTitleModel()` -- `setImageAnalysisModel(modelId: string | null)` / `getImageAnalysisModel()` - -### 5. `src/renderer/components/SettingsView/SettingsView.tsx` - UI settings - -**A. Add Mistral API key section** -- Separate input field for Mistral API key (below OpenCode key) -- Same pattern: masked display, change button, validation on save - -**B. Update model selector** -- SettingsView uses a native `` dropdown -- When both keys configured, show merged list from both providers; when only one key set, show only that provider's models -- **Note**: `availableModels` state is currently typed as `{id: string; name: string}[]` — must be updated to `ChatModel[]` (which includes `provider` and `vision` fields) so provider grouping and vision filtering work - -**C. Add per-purpose model preferences** -- "Title generation model" dropdown — select cheapest/fastest model for auto-titling conversations -- "Image analysis model" dropdown — select a dedicated vision model for media metadata (independent of chat model, e.g. use Devstral for chat but Mistral Large 3 for images); **only show vision-capable models** (filter out models without vision support like Devstral) -- Both show available models from all configured providers, grouped by provider -- Both allow a "Default" option that auto-selects per provider defaults - -### 6. `src/renderer/components/ChatPanel/ChatPanel.tsx` - Chat UI - -**A. Update model selector in chat** -- ChatPanel uses a custom dropdown (CSS `model-dropdown` with `