feat: add Mistral AI as first-class alternative provider

This commit is contained in:
2026-03-01 14:41:42 +01:00
parent 886083ebc9
commit c911ec2354
22 changed files with 1425 additions and 167 deletions

View File

@@ -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_OPENAI_URL = 'https://opencode.ai/zen/v1/chat/completions';
const ZEN_MODELS_URL = 'https://opencode.ai/zen/v1/models'; const ZEN_MODELS_URL = 'https://opencode.ai/zen/v1/models';
// 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 // Known model display names: maps model IDs to polished names and serves as offline fallback
const MODEL_DISPLAY_NAMES: Record<string, string> = { const MODEL_DISPLAY_NAMES: Record<string, string> = {
// Anthropic Claude // Anthropic Claude
@@ -75,6 +79,12 @@ const MODEL_DISPLAY_NAMES: Record<string, string> = {
'kimi-k2-thinking': 'Kimi K2 Thinking', 'kimi-k2-thinking': 'Kimi K2 Thinking',
'big-pickle': 'Big Pickle', 'big-pickle': 'Big Pickle',
'trinity-large-preview-free': 'Trinity Large Preview Free', '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<string, string> = {
// Uppercase prefixes that should not be title-cased // Uppercase prefixes that should not be title-cased
const UPPERCASE_PREFIXES = ['gpt', 'glm']; 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<string, number> = {
'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<string, { vision: boolean }> = {
// 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 { export interface ModelInfo {
id: string; id: string;
name: string; name: string;
provider: string; provider: string;
vision?: boolean;
} }
export interface SendMessageOptions { export interface SendMessageOptions {
@@ -171,6 +220,7 @@ export class OpenCodeManager {
private postMediaEngine: PostMediaEngine; private postMediaEngine: PostMediaEngine;
private getMainWindow: () => BrowserWindow | null; private getMainWindow: () => BrowserWindow | null;
private apiKey: string = ''; private apiKey: string = '';
private mistralApiKey: string = '';
private abortControllers: Map<string, AbortController> = new Map(); private abortControllers: Map<string, AbortController> = new Map();
private cachedModels: ModelInfo[] | null = null; private cachedModels: ModelInfo[] | null = null;
private cachedModelsAt: number = 0; 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 }> { setMistralApiKey(key: string): void {
if (!this.apiKey) { this.mistralApiKey = key;
return { ready: false, error: 'API key not configured' }; // Invalidate model cache so merged list is re-fetched
} this.cachedModels = null;
return { ready: true }; this.cachedModelsAt = 0;
} }
/** /**
* Validate an API key by calling the models endpoint * Get current Mistral API key
*/
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[] }> { async validateApiKey(apiKey: string): Promise<{ isValid: boolean; models: ModelInfo[] }> {
if (!apiKey || apiKey.length < 3) { if (!apiKey || apiKey.length < 3) {
@@ -242,7 +313,11 @@ export class OpenCodeManager {
headers, headers,
}); });
if (response.statusCode >= 200 && response.statusCode < 300) { if (response.statusCode >= 200 && response.statusCode < 300) {
return { isValid: true, models: Object.entries(MODEL_DISPLAY_NAMES).map(([id, name]) => ({ id, name, provider: this.detectProvider(id) })) }; // 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 { } catch {
// Try next auth method // Try next auth method
@@ -252,8 +327,42 @@ export class OpenCodeManager {
return { isValid: false, models: [] }; 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) * Get available models (cached with 5-minute TTL)
* Merges models from all configured providers.
*/ */
async getAvailableModels(): Promise<ModelInfo[]> { async getAvailableModels(): Promise<ModelInfo[]> {
// Return cached models if within TTL // Return cached models if within TTL
@@ -261,7 +370,10 @@ export class OpenCodeManager {
return this.cachedModels; return this.cachedModels;
} }
// Try fetching from API const allModels: ModelInfo[] = [];
let fetched = false;
// Fetch OpenCode models
if (this.apiKey) { if (this.apiKey) {
try { try {
const response = await this.httpRequest(ZEN_MODELS_URL, { const response = await this.httpRequest(ZEN_MODELS_URL, {
@@ -274,14 +386,15 @@ export class OpenCodeManager {
if (response.statusCode === 200) { if (response.statusCode === 200) {
const data = JSON.parse(response.body); const data = JSON.parse(response.body);
if (data.data && Array.isArray(data.data)) { if (data.data && Array.isArray(data.data)) {
const models = data.data.map((m: { id: string }) => ({ for (const m of data.data as Array<{ id: string }>) {
id: m.id, allModels.push({
name: this.formatModelName(m.id), id: m.id,
provider: this.detectProvider(m.id), name: this.formatModelName(m.id),
})); provider: this.detectProvider(m.id),
this.cachedModels = models; vision: MODEL_CAPABILITIES[m.id]?.vision ?? false,
this.cachedModelsAt = Date.now(); });
return models; }
fetched = true;
} }
} }
} catch { } catch {
@@ -289,12 +402,52 @@ export class OpenCodeManager {
} }
} }
// Build fallback from display name map // Fetch Mistral models
const fallback = Object.entries(MODEL_DISPLAY_NAMES).map(([id, name]) => ({ if (this.mistralApiKey) {
id, try {
name, const response = await this.httpRequest(MISTRAL_MODELS_URL, {
provider: this.detectProvider(id), method: 'GET',
})); headers: {
'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; return fallback;
} }
@@ -335,6 +488,12 @@ export class OpenCodeManager {
const modelId = conversation.model || 'claude-sonnet-4'; const modelId = conversation.model || 'claude-sonnet-4';
const provider = this.detectProvider(modelId); const provider = this.detectProvider(modelId);
// Check that the provider's API key is available
if (!this.isProviderKeySet(provider)) {
const providerLabel = provider === 'mistral' ? 'Mistral' : 'OpenCode';
return { success: false, error: `The model '${modelId}' requires a ${providerLabel} API key. Configure it in Settings.` };
}
// Get system prompt // Get system prompt
const systemMessage = conversation.messages.find(m => m.role === 'system'); const systemMessage = conversation.messages.find(m => m.role === 'system');
const basePrompt = systemMessage?.content || await this.chatEngine.getDefaultSystemPrompt(); const basePrompt = systemMessage?.content || await this.chatEngine.getDefaultSystemPrompt();
@@ -387,6 +546,8 @@ export class OpenCodeManager {
); );
} }
// Get provider-specific config (URL, key, options)
const config = this.getProviderConfig(provider);
return this.sendOpenAIMessage( return this.sendOpenAIMessage(
modelId, modelId,
prompt, prompt,
@@ -395,6 +556,9 @@ export class OpenCodeManager {
{ onDelta, onToolCall, onToolResult, onTokenUsage }, { onDelta, onToolCall, onToolResult, onTokenUsage },
conversationId, conversationId,
emitA2UIMessages, 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( private async sendOpenAIMessage(
modelId: string, modelId: string,
@@ -750,6 +915,9 @@ export class OpenCodeManager {
}, },
conversationId: string, conversationId: string,
emitA2UIMessages: (messages: A2UIServerMessage[]) => void, emitA2UIMessages: (messages: A2UIServerMessage[]) => void,
apiUrl: string = ZEN_OPENAI_URL,
apiKey: string = this.apiKey,
providerOptions?: { parallelToolCalls?: boolean },
): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> { ): Promise<{ content: string; toolCalls: Array<{ name: string; args: unknown }> }> {
// Build OpenAI-format messages // Build OpenAI-format messages
const allMessages: Array<Record<string, unknown>> = [ const allMessages: Array<Record<string, unknown>> = [
@@ -775,12 +943,13 @@ export class OpenCodeManager {
// Truncate conversation history to fit within context window // Truncate conversation history to fit within context window
// Keep system message (index 0), truncate from oldest conversation messages // Keep system message (index 0), truncate from oldest conversation messages
const contextBudget = MODEL_CONTEXT_BUDGETS[modelId] ?? 150000;
const conversationMessages = allMessages.slice(1); const conversationMessages = allMessages.slice(1);
const anthropicFmt = conversationMessages.map(m => ({ const anthropicFmt = conversationMessages.map(m => ({
role: m.role as 'user' | 'assistant', role: m.role as 'user' | 'assistant',
content: (m.content as string) || '', content: (m.content as string) || '',
})); }));
const truncated = this.truncateToTokenBudget(anthropicFmt, systemPrompt, anthropicTools); const truncated = this.truncateToTokenBudget(anthropicFmt, systemPrompt, anthropicTools, contextBudget);
const messages: Array<Record<string, unknown>> = [ const messages: Array<Record<string, unknown>> = [
allMessages[0], allMessages[0],
...truncated.map(m => ({ role: m.role, content: m.content })), ...truncated.map(m => ({ role: m.role, content: m.content })),
@@ -804,14 +973,19 @@ export class OpenCodeManager {
stream_options: { include_usage: true }, stream_options: { include_usage: true },
}; };
// Set parallel_tool_calls based on provider options (Mistral needs false)
if (providerOptions?.parallelToolCalls === false) {
body.parallel_tool_calls = false;
}
// Retry only the HTTP connection (429/502/503 are caught before any events are emitted). // Retry only the HTTP connection (429/502/503 are caught before any events are emitted).
// Event processing is outside retry scope to prevent double-emission of onDelta on retry. // Event processing is outside retry scope to prevent double-emission of onDelta on retry.
const { events } = await withRetry(async () => { const { events } = await withRetry(async () => {
return httpRequestStream(ZEN_OPENAI_URL, { return httpRequestStream(apiUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`, 'Authorization': `Bearer ${apiKey}`,
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
signal, signal,
@@ -970,11 +1144,39 @@ export class OpenCodeManager {
callbacks.onToolResult({ name: toolName, result }); callbacks.onToolResult({ name: toolName, result });
} }
messages.push({ // Check for image result that needs multimodal formatting (OpenAI image_url format)
role: 'tool', if (result && typeof result === 'object' && (result as Record<string, unknown>).__isImageResult) {
content: JSON.stringify(result), const imageResult = result as {
tool_call_id: toolCall.id, __isImageResult: boolean;
}); success: boolean;
mediaType: string;
base64: string;
metadata: Record<string, unknown>;
};
messages.push({
role: 'tool',
content: [
{
type: 'image_url',
image_url: {
url: `data:${imageResult.mediaType};base64,${imageResult.base64}`,
},
},
{
type: 'text',
text: JSON.stringify({ success: true, metadata: imageResult.metadata }),
},
],
tool_call_id: toolCall.id,
});
} else {
messages.push({
role: 'tool',
content: JSON.stringify(result),
tool_call_id: toolCall.id,
});
}
} }
if (signal.aborted) break; 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( private async generateConversationTitle(
conversationId: string, conversationId: string,
@@ -1858,32 +2062,40 @@ export class OpenCodeManager {
_assistantResponse: string _assistantResponse: string
): Promise<void> { ): Promise<void> {
try { try {
const body = { // Read configured title model
model: 'claude-haiku-4-5', const titleModel = await this.chatEngine.getSetting('chat_title_model') || 'claude-haiku-4-5';
max_tokens: 20, const provider = this.detectProvider(titleModel);
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: [ // Ensure we have the key for this provider
{ if (!this.isProviderKeySet(provider)) return;
role: 'user',
content: `Topic: ${userMessage.substring(0, 100)}`, 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, { if (response.statusCode !== 200) return;
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) {
const data = JSON.parse(response.body); const data = JSON.parse(response.body);
let title = '';
if (Array.isArray(data.content)) { if (Array.isArray(data.content)) {
title = data.content title = data.content
.filter((b: AnthropicContentBlock) => b.type === 'text') .filter((b: AnthropicContentBlock) => b.type === 'text')
@@ -1892,23 +2104,47 @@ export class OpenCodeManager {
} else { } else {
title = data.content || ''; title = data.content || '';
} }
} else {
// OpenAI-compatible (includes Mistral)
const config = this.getProviderConfig(provider);
const body = {
model: titleModel,
max_tokens: 20,
messages: [
{ role: 'system', content: systemText },
{ role: 'user', content: promptText },
],
};
// Clean up and truncate title const response = await this.httpRequest(config.apiUrl, {
title = title.trim().replace(/^["']|["']$/g, '').replace(/[.!?]+$/, ''); method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.apiKey}`,
},
body: JSON.stringify(body),
});
// Hard limit on title length if (response.statusCode !== 200) return;
const MAX_TITLE_LENGTH = 30;
if (title.length > MAX_TITLE_LENGTH) {
title = title.substring(0, MAX_TITLE_LENGTH - 1) + '…';
}
if (title) { const data = JSON.parse(response.body);
await this.chatEngine.updateConversation(conversationId, { title }); title = data.choices?.[0]?.message?.content || '';
}
const mainWindow = this.getMainWindow(); // Clean up and truncate title
if (mainWindow) { title = title.trim().replace(/^["']|["']$/g, '').replace(/[.!?]+$/, '');
mainWindow.webContents.send('chat-title-updated', { conversationId, title });
} 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) { } catch (error) {
@@ -1996,11 +2232,32 @@ NOTE: Use pagination (offset/limit) in list_posts and search_posts to access all
return this.modelCatalogEngine; 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 { private detectProvider(modelId: string): string {
const id = modelId.toLowerCase(); const id = modelId.toLowerCase();
if (id.startsWith('claude')) return 'anthropic'; if (id.startsWith('claude')) return 'anthropic';
if (id.startsWith('gpt') || id.startsWith('o3') || id.startsWith('o4')) return 'openai'; if (id.startsWith('gpt') || id.startsWith('o3') || id.startsWith('o4')) return 'openai';
if (id.startsWith('gemini')) return 'google'; if (id.startsWith('gemini')) return 'google';
if (id.startsWith('mistral') || id.startsWith('ministral') || id.startsWith('devstral') || id.startsWith('codestral') || id.startsWith('pixtral')) return 'mistral';
return 'other'; return 'other';
} }
@@ -2053,11 +2310,11 @@ NOTE: Use pagination (offset/limit) in list_posts and search_posts to access all
tagMappings?: Record<string, string>; tagMappings?: Record<string, string>;
error?: string; error?: string;
}> { }> {
if (!this.apiKey) {
return { success: false, error: 'API key not set' };
}
const provider = this.detectProvider(modelId); const provider = this.detectProvider(modelId);
if (!this.isProviderKeySet(provider)) {
const providerLabel = provider === 'mistral' ? 'Mistral' : 'OpenCode';
return { success: false, error: `${providerLabel} API key not set` };
}
// Build the prompt for taxonomy analysis // Build the prompt for taxonomy analysis
const existingCategories = categories.filter(c => c.existsInProject).map(c => c.name); const existingCategories = categories.filter(c => c.existsInProject).map(c => c.name);
@@ -2148,7 +2405,8 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu
} }
} }
} else { } else {
// OpenAI-compatible // OpenAI-compatible (includes Mistral)
const config = this.getProviderConfig(provider);
const body = { const body = {
model: modelId, model: modelId,
max_tokens: 4096, max_tokens: 4096,
@@ -2158,11 +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', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${this.apiKey}`, Authorization: `Bearer ${config.apiKey}`,
}, },
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
@@ -2224,7 +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 * Analyze a media image and generate title, alt text, and caption using AI
* This is a one-shot request that looks at the image and suggests metadata * This is a one-shot request that looks at the image and suggests metadata.
* Uses the configured image analysis model and routes to the correct provider.
*/ */
async analyzeMediaImage(mediaId: string, language: string = 'en'): Promise<{ async analyzeMediaImage(mediaId: string, language: string = 'en'): Promise<{
success: boolean; success: boolean;
@@ -2233,8 +2492,13 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu
caption?: string; caption?: string;
error?: string; error?: string;
}> { }> {
if (!this.apiKey) { // Read configured image analysis model (default: claude-sonnet-4-5)
return { success: false, error: 'API key not configured. Please set your OpenCode API key in Settings.' }; 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 // Get media metadata
@@ -2278,59 +2542,100 @@ CAPTION: Short, engaging blog caption (5-20 words).
Respond with JSON only: {"title": "...", "alt": "...", "caption": "..."}`; Respond with JSON only: {"title": "...", "alt": "...", "caption": "..."}`;
try { try {
// Using Claude Sonnet 4.5 for best image analysis
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 = ''; let responseText = '';
for (const block of data.content || []) {
if (block.type === 'text') { if (provider === 'anthropic') {
responseText += block.text; 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 // Parse the JSON response

View File

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

View File

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

View File

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

View File

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

View File

@@ -355,15 +355,31 @@ export const ChatPanel: React.FC<ChatPanelProps> = ({ conversationId }) => {
</button> </button>
{showModelSelector && ( {showModelSelector && (
<div className="model-dropdown"> <div className="model-dropdown">
{availableModels.map(model => ( {(() => {
<button // Group models by provider for visual separation
key={model.id} const groups: Record<string, ChatModel[]> = {};
className={`model-option ${conversation?.model === model.id ? 'active' : ''}`} for (const model of availableModels) {
onClick={() => handleModelChange(model.id)} const p = model.provider || 'other';
> if (!groups[p]) groups[p] = [];
{model.name} groups[p].push(model);
</button> }
))} return Object.entries(groups).map(([provider, models]) => (
<React.Fragment key={provider}>
{Object.keys(groups).length > 1 && (
<div className="model-group-header">{provider === 'mistral' ? 'Mistral' : 'OpenCode'}</div>
)}
{models.map(model => (
<button
key={model.id}
className={`model-option ${conversation?.model === model.id ? 'active' : ''}`}
onClick={() => handleModelChange(model.id)}
>
{model.name}
</button>
))}
</React.Fragment>
));
})()}
</div> </div>
)} )}
</div> </div>

View File

@@ -1371,15 +1371,30 @@ const TaxonomySection: React.FC<{
</button> </button>
{showModelSelector && ( {showModelSelector && (
<div className="taxonomy-model-dropdown"> <div className="taxonomy-model-dropdown">
{availableModels.map(model => ( {(() => {
<button const groups: Record<string, ChatModel[]> = {};
key={model.id} for (const model of availableModels) {
className="taxonomy-model-option" const p = model.provider || 'other';
onClick={() => handleAnalyze(model.id)} if (!groups[p]) groups[p] = [];
> groups[p].push(model);
{model.name} }
</button> return Object.entries(groups).map(([provider, models]) => (
))} <React.Fragment key={provider}>
{Object.keys(groups).length > 1 && (
<div className="model-group-header">{provider === 'mistral' ? 'Mistral' : 'OpenCode'}</div>
)}
{models.map(model => (
<button
key={model.id}
className="taxonomy-model-option"
onClick={() => handleAnalyze(model.id)}
>
{model.name}
</button>
))}
</React.Fragment>
));
})()}
</div> </div>
)} )}
</div> </div>

View File

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

View File

@@ -733,6 +733,18 @@
"settings.ai.modelInfoOutputPrice": "Ausgabe", "settings.ai.modelInfoOutputPrice": "Ausgabe",
"settings.ai.modelInfoTokens": "Token", "settings.ai.modelInfoTokens": "Token",
"settings.ai.modelInfoPerMTok": "/MTok", "settings.ai.modelInfoPerMTok": "/MTok",
"settings.ai.mistralApiKeyLabel": "Mistral API-Schlüssel",
"settings.ai.mistralApiKeyDescription": "Ihr API-Schlüssel von Mistral AI. Ermöglicht Mistral-Modelle als Alternative zu OpenCode.",
"settings.ai.mistralApiKeyConfigured": "Mistral API-Schlüssel konfiguriert",
"settings.ai.changeMistralApiKey": "Mistral API-Schlüssel ändern",
"settings.ai.titleModelLabel": "Titelgenerierungsmodell",
"settings.ai.titleModelDescription": "Modell zur automatischen Generierung von Konversationstiteln.",
"settings.ai.imageAnalysisModelLabel": "Bildanalyse-Modell",
"settings.ai.imageAnalysisModelDescription": "Modell für die automatische Bildanalyse (Titel, Alt-Text, Bildunterschrift).",
"settings.ai.providerOpenCode": "OpenCode",
"settings.ai.providerMistral": "Mistral",
"settings.ai.providerOther": "Andere",
"chat.providerKeyMissing": "Das Modell '{{model}}' benötigt einen {{provider}} API-Schlüssel. Konfigurieren Sie ihn in den Einstellungen.",
"settings.toast.modelCatalogRefreshed": "Modellkatalog aktualisiert ({{count}} Modelle)", "settings.toast.modelCatalogRefreshed": "Modellkatalog aktualisiert ({{count}} Modelle)",
"settings.toast.modelCatalogUpToDate": "Modellkatalog ist bereits aktuell", "settings.toast.modelCatalogUpToDate": "Modellkatalog ist bereits aktuell",
"settings.toast.modelCatalogRefreshFailed": "Modellkatalog konnte nicht aktualisiert werden", "settings.toast.modelCatalogRefreshFailed": "Modellkatalog konnte nicht aktualisiert werden",

View File

@@ -733,6 +733,18 @@
"settings.ai.modelInfoOutputPrice": "Output", "settings.ai.modelInfoOutputPrice": "Output",
"settings.ai.modelInfoTokens": "tokens", "settings.ai.modelInfoTokens": "tokens",
"settings.ai.modelInfoPerMTok": "/MTok", "settings.ai.modelInfoPerMTok": "/MTok",
"settings.ai.mistralApiKeyLabel": "Mistral API Key",
"settings.ai.mistralApiKeyDescription": "Your API key from Mistral AI. Enables Mistral models as an alternative to OpenCode.",
"settings.ai.mistralApiKeyConfigured": "Mistral API key configured",
"settings.ai.changeMistralApiKey": "Change Mistral API Key",
"settings.ai.titleModelLabel": "Title Generation Model",
"settings.ai.titleModelDescription": "Model used to generate conversation titles automatically.",
"settings.ai.imageAnalysisModelLabel": "Image Analysis Model",
"settings.ai.imageAnalysisModelDescription": "Model used for automatic image analysis (title, alt text, caption).",
"settings.ai.providerOpenCode": "OpenCode",
"settings.ai.providerMistral": "Mistral",
"settings.ai.providerOther": "Other",
"chat.providerKeyMissing": "The model '{{model}}' requires a {{provider}} API key. Configure it in Settings.",
"settings.toast.modelCatalogRefreshed": "Model catalog updated ({{count}} models)", "settings.toast.modelCatalogRefreshed": "Model catalog updated ({{count}} models)",
"settings.toast.modelCatalogUpToDate": "Model catalog already up to date", "settings.toast.modelCatalogUpToDate": "Model catalog already up to date",
"settings.toast.modelCatalogRefreshFailed": "Failed to refresh model catalog", "settings.toast.modelCatalogRefreshFailed": "Failed to refresh model catalog",

View File

@@ -733,6 +733,18 @@
"settings.ai.modelInfoOutputPrice": "Salida", "settings.ai.modelInfoOutputPrice": "Salida",
"settings.ai.modelInfoTokens": "tokens", "settings.ai.modelInfoTokens": "tokens",
"settings.ai.modelInfoPerMTok": "/MTok", "settings.ai.modelInfoPerMTok": "/MTok",
"settings.ai.mistralApiKeyLabel": "Clave API de Mistral",
"settings.ai.mistralApiKeyDescription": "Su clave API de Mistral AI. Habilita los modelos Mistral como alternativa a OpenCode.",
"settings.ai.mistralApiKeyConfigured": "Clave API de Mistral configurada",
"settings.ai.changeMistralApiKey": "Cambiar clave API de Mistral",
"settings.ai.titleModelLabel": "Modelo de generación de títulos",
"settings.ai.titleModelDescription": "Modelo utilizado para generar títulos de conversación automáticamente.",
"settings.ai.imageAnalysisModelLabel": "Modelo de análisis de imágenes",
"settings.ai.imageAnalysisModelDescription": "Modelo utilizado para el análisis automático de imágenes (título, texto alternativo, leyenda).",
"settings.ai.providerOpenCode": "OpenCode",
"settings.ai.providerMistral": "Mistral",
"settings.ai.providerOther": "Otro",
"chat.providerKeyMissing": "El modelo '{{model}}' requiere una clave API de {{provider}}. Configúrela en Ajustes.",
"settings.toast.modelCatalogRefreshed": "Catálogo actualizado ({{count}} modelos)", "settings.toast.modelCatalogRefreshed": "Catálogo actualizado ({{count}} modelos)",
"settings.toast.modelCatalogUpToDate": "El catálogo ya está actualizado", "settings.toast.modelCatalogUpToDate": "El catálogo ya está actualizado",
"settings.toast.modelCatalogRefreshFailed": "No se pudo actualizar el catálogo", "settings.toast.modelCatalogRefreshFailed": "No se pudo actualizar el catálogo",

View File

@@ -731,6 +731,18 @@
"settings.ai.modelInfoOutputPrice": "Sortie", "settings.ai.modelInfoOutputPrice": "Sortie",
"settings.ai.modelInfoTokens": "tokens", "settings.ai.modelInfoTokens": "tokens",
"settings.ai.modelInfoPerMTok": "/MTok", "settings.ai.modelInfoPerMTok": "/MTok",
"settings.ai.mistralApiKeyLabel": "Clé API Mistral",
"settings.ai.mistralApiKeyDescription": "Votre clé API Mistral AI. Permet d'utiliser les modèles Mistral comme alternative à OpenCode.",
"settings.ai.mistralApiKeyConfigured": "Clé API Mistral configurée",
"settings.ai.changeMistralApiKey": "Modifier la clé API Mistral",
"settings.ai.titleModelLabel": "Modèle de génération de titres",
"settings.ai.titleModelDescription": "Modèle utilisé pour générer automatiquement les titres de conversation.",
"settings.ai.imageAnalysisModelLabel": "Modèle d'analyse d'images",
"settings.ai.imageAnalysisModelDescription": "Modèle utilisé pour l'analyse automatique d'images (titre, texte alternatif, légende).",
"settings.ai.providerOpenCode": "OpenCode",
"settings.ai.providerMistral": "Mistral",
"settings.ai.providerOther": "Autre",
"chat.providerKeyMissing": "Le modèle '{{model}}' nécessite une clé API {{provider}}. Configurez-la dans les paramètres.",
"settings.toast.modelCatalogRefreshed": "Catalogue mis à jour ({{count}} modèles)", "settings.toast.modelCatalogRefreshed": "Catalogue mis à jour ({{count}} modèles)",
"settings.toast.modelCatalogUpToDate": "Le catalogue est déjà à jour", "settings.toast.modelCatalogUpToDate": "Le catalogue est déjà à jour",
"settings.toast.modelCatalogRefreshFailed": "Échec de l'actualisation du catalogue", "settings.toast.modelCatalogRefreshFailed": "Échec de l'actualisation du catalogue",

View File

@@ -731,6 +731,18 @@
"settings.ai.modelInfoOutputPrice": "Output", "settings.ai.modelInfoOutputPrice": "Output",
"settings.ai.modelInfoTokens": "token", "settings.ai.modelInfoTokens": "token",
"settings.ai.modelInfoPerMTok": "/MTok", "settings.ai.modelInfoPerMTok": "/MTok",
"settings.ai.mistralApiKeyLabel": "Chiave API Mistral",
"settings.ai.mistralApiKeyDescription": "La tua chiave API Mistral AI. Abilita i modelli Mistral come alternativa a OpenCode.",
"settings.ai.mistralApiKeyConfigured": "Chiave API Mistral configurata",
"settings.ai.changeMistralApiKey": "Cambia chiave API Mistral",
"settings.ai.titleModelLabel": "Modello di generazione titoli",
"settings.ai.titleModelDescription": "Modello utilizzato per generare automaticamente i titoli delle conversazioni.",
"settings.ai.imageAnalysisModelLabel": "Modello di analisi immagini",
"settings.ai.imageAnalysisModelDescription": "Modello utilizzato per l'analisi automatica delle immagini (titolo, testo alternativo, didascalia).",
"settings.ai.providerOpenCode": "OpenCode",
"settings.ai.providerMistral": "Mistral",
"settings.ai.providerOther": "Altro",
"chat.providerKeyMissing": "Il modello '{{model}}' richiede una chiave API {{provider}}. Configurala nelle Impostazioni.",
"settings.toast.modelCatalogRefreshed": "Catalogo aggiornato ({{count}} modelli)", "settings.toast.modelCatalogRefreshed": "Catalogo aggiornato ({{count}} modelli)",
"settings.toast.modelCatalogUpToDate": "Il catalogo è già aggiornato", "settings.toast.modelCatalogUpToDate": "Il catalogo è già aggiornato",
"settings.toast.modelCatalogRefreshFailed": "Aggiornamento del catalogo non riuscito", "settings.toast.modelCatalogRefreshFailed": "Aggiornamento del catalogo non riuscito",

View File

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

View File

@@ -150,9 +150,9 @@ describe('OpenCodeManager model discovery', () => {
const models = await manager.getAvailableModels(); const models = await manager.getAvailableModels();
expect(models).toHaveLength(3); expect(models).toHaveLength(3);
expect(models[0]).toEqual({ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', provider: 'anthropic' }); expect(models[0]).toEqual({ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', provider: 'anthropic', vision: true });
expect(models[1]).toEqual({ id: 'gpt-5.1-codex', name: 'GPT 5.1 Codex', provider: 'openai' }); expect(models[1]).toEqual({ id: 'gpt-5.1-codex', name: 'GPT 5.1 Codex', provider: 'openai', vision: false });
expect(models[2]).toEqual({ id: 'gemini-3-pro', name: 'Gemini 3 Pro', provider: 'google' }); expect(models[2]).toEqual({ id: 'gemini-3-pro', name: 'Gemini 3 Pro', provider: 'google', vision: true });
}); });
it('falls back to known models when API fails', async () => { it('falls back to known models when API fails', async () => {
@@ -238,12 +238,15 @@ describe('OpenCodeManager model discovery', () => {
it('falls back to known models when no API key is set', async () => { it('falls back to known models when no API key is set', async () => {
const manager = createManager(); const manager = createManager();
(manager as any).apiKey = ''; (manager as any).apiKey = '';
// Set a key so fallback filtering works (at least one provider must have a key)
manager.setMistralApiKey('test-key');
const models = await manager.getAvailableModels(); const models = await manager.getAvailableModels();
// Only Mistral models will be in fallback since only Mistral key is set
expect(models.length).toBeGreaterThan(0); expect(models.length).toBeGreaterThan(0);
const ids = models.map((m: ModelInfo) => m.id); const providers = new Set(models.map((m: ModelInfo) => m.provider));
expect(ids).toContain('claude-sonnet-4'); expect(providers.has('mistral')).toBe(true);
}); });
}); });
}); });

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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