diff --git a/src/main/engine/OpenCodeManager.ts b/src/main/engine/OpenCodeManager.ts index cdf7972..124301d 100644 --- a/src/main/engine/OpenCodeManager.ts +++ b/src/main/engine/OpenCodeManager.ts @@ -24,42 +24,52 @@ 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'; -// Full model catalog from OpenCode Zen (fallback when API unavailable) -const AVAILABLE_MODELS: ModelInfo[] = [ +// Known model display names: maps model IDs to polished names and serves as offline fallback +const MODEL_DISPLAY_NAMES: Record = { // Anthropic Claude - { id: 'claude-sonnet-4', name: 'Claude Sonnet 4', provider: 'anthropic' }, - { id: 'claude-sonnet-4-5', name: 'Claude Sonnet 4.5', provider: 'anthropic' }, - { id: 'claude-haiku-4-5', name: 'Claude Haiku 4.5', provider: 'anthropic' }, - { id: 'claude-3-5-haiku', name: 'Claude Haiku 3.5', provider: 'anthropic' }, - { id: 'claude-opus-4-5', name: 'Claude Opus 4.5', provider: 'anthropic' }, - { id: 'claude-opus-4-1', name: 'Claude Opus 4.1', provider: 'anthropic' }, + '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 - { id: 'gpt-5.2', name: 'GPT 5.2', provider: 'openai' }, - { id: 'gpt-5.2-codex', name: 'GPT 5.2 Codex', provider: 'openai' }, - { id: 'gpt-5.1', name: 'GPT 5.1', provider: 'openai' }, - { id: 'gpt-5.1-codex', name: 'GPT 5.1 Codex', provider: 'openai' }, - { id: 'gpt-5.1-codex-max', name: 'GPT 5.1 Codex Max', provider: 'openai' }, - { id: 'gpt-5.1-codex-mini', name: 'GPT 5.1 Codex Mini', provider: 'openai' }, - { id: 'gpt-5', name: 'GPT 5', provider: 'openai' }, - { id: 'gpt-5-codex', name: 'GPT 5 Codex', provider: 'openai' }, - { id: 'gpt-5-nano', name: 'GPT 5 Nano (Free)', provider: 'openai' }, + '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 - { id: 'gemini-3-pro', name: 'Gemini 3 Pro', provider: 'google' }, - { id: 'gemini-3-flash', name: 'Gemini 3 Flash', provider: 'google' }, + 'gemini-3.1-pro': 'Gemini 3.1 Pro', + 'gemini-3-pro': 'Gemini 3 Pro', + 'gemini-3-flash': 'Gemini 3 Flash', // Other providers - { id: 'qwen3-coder', name: 'Qwen3 Coder 480B', provider: 'other' }, - { id: 'minimax-m2.1', name: 'MiniMax M2.1', provider: 'other' }, - { id: 'minimax-m2.1-free', name: 'MiniMax M2.1 (Free)', provider: 'other' }, - { id: 'glm-4.7', name: 'GLM 4.7', provider: 'other' }, - { id: 'glm-4.7-free', name: 'GLM 4.7 (Free)', provider: 'other' }, - { id: 'glm-4.6', name: 'GLM 4.6', provider: 'other' }, - { id: 'kimi-k2.5', name: 'Kimi K2.5', provider: 'other' }, - { id: 'kimi-k2.5-free', name: 'Kimi K2.5 (Free)', provider: 'other' }, - { id: 'kimi-k2', name: 'Kimi K2', provider: 'other' }, - { id: 'kimi-k2-thinking', name: 'Kimi K2 Thinking', provider: 'other' }, - { id: 'big-pickle', name: 'Big Pickle (Free)', provider: 'other' }, - { id: 'trinity-large-preview-free', name: 'Trinity Large Preview (Free)', provider: 'other' }, -]; + 'glm-5': 'GLM 5', + 'glm-5-free': 'GLM 5 Free', + 'glm-4.7': 'GLM 4.7', + 'glm-4.6': 'GLM 4.6', + 'qwen3-coder': 'Qwen3 Coder', + 'minimax-m2.5': 'MiniMax M2.5', + 'minimax-m2.5-free': 'MiniMax M2.5 Free', + 'minimax-m2.1': 'MiniMax M2.1', + 'minimax-m2.1-free': 'MiniMax M2.1 Free', + 'kimi-k2.5': 'Kimi K2.5', + 'kimi-k2.5-free': 'Kimi K2.5 Free', + 'kimi-k2': 'Kimi K2', + 'kimi-k2-thinking': 'Kimi K2 Thinking', + 'big-pickle': 'Big Pickle', + 'trinity-large-preview-free': 'Trinity Large Preview Free', +}; + +// Uppercase prefixes that should not be title-cased +const UPPERCASE_PREFIXES = ['gpt', 'glm']; export interface ModelInfo { id: string; @@ -148,6 +158,9 @@ export class OpenCodeManager { private getMainWindow: () => BrowserWindow | null; private apiKey: string = ''; private abortControllers: Map = new Map(); + private cachedModels: ModelInfo[] | null = null; + private cachedModelsAt: number = 0; + private static MODEL_CACHE_TTL = 5 * 60 * 1000; // 5 minutes private conversationUsage: Map= 200 && response.statusCode < 300) { - return { isValid: true, models: AVAILABLE_MODELS }; + return { isValid: true, models: Object.entries(MODEL_DISPLAY_NAMES).map(([id, name]) => ({ id, name, provider: this.detectProvider(id) })) }; } } catch { // Try next auth method @@ -223,10 +236,15 @@ export class OpenCodeManager { } /** - * Get available models + * Get available models (cached with 5-minute TTL) */ async getAvailableModels(): Promise { - // Try fetching from API, fall back to hardcoded list + // Return cached models if within TTL + if (this.cachedModels && Date.now() - this.cachedModelsAt < OpenCodeManager.MODEL_CACHE_TTL) { + return this.cachedModels; + } + + // Try fetching from API if (this.apiKey) { try { const response = await this.httpRequest(ZEN_MODELS_URL, { @@ -239,18 +257,28 @@ export class OpenCodeManager { if (response.statusCode === 200) { const data = JSON.parse(response.body); if (data.data && Array.isArray(data.data)) { - return data.data.map((m: { id: string; name?: string }) => ({ + const models = data.data.map((m: { id: string }) => ({ id: m.id, - name: m.name || this.formatModelName(m.id), + name: this.formatModelName(m.id), provider: this.detectProvider(m.id), })); + this.cachedModels = models; + this.cachedModelsAt = Date.now(); + return models; } } } catch { - // Fall through to hardcoded + // Fall through to fallback } } - return AVAILABLE_MODELS; + + // Build fallback from display name map + const fallback = Object.entries(MODEL_DISPLAY_NAMES).map(([id, name]) => ({ + id, + name, + provider: this.detectProvider(id), + })); + return fallback; } /** @@ -1823,9 +1851,21 @@ NOTE: Use pagination (offset/limit) in list_posts and search_posts to access all } private formatModelName(modelId: string): string { - return modelId - .split('-') - .map(word => word.charAt(0).toUpperCase() + word.slice(1)) + // 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(' '); } diff --git a/tests/engine/OpenCodeModelDiscovery.test.ts b/tests/engine/OpenCodeModelDiscovery.test.ts new file mode 100644 index 0000000..8aef268 --- /dev/null +++ b/tests/engine/OpenCodeModelDiscovery.test.ts @@ -0,0 +1,249 @@ +/** + * OpenCodeManager Model Discovery Tests + * + * Tests the model discovery, display name formatting, and caching behavior. + * Following TDD: these tests describe the expected behavior. + */ + +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(); + setSetting = vi.fn(); + getSelectedModel = vi.fn(); + getDefaultSystemPrompt = 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, ModelInfo } from '../../src/main/engine/OpenCodeManager'; + +// Helper to create manager with mocked httpRequest +function createManager(): OpenCodeManager { + const manager = new OpenCodeManager( + { getSetting: vi.fn(), setSetting: vi.fn() } as never, + {} as never, + {} as never, + () => null, + ); + manager.setApiKey('test-key'); + return manager; +} + +// Mock API response in the Zen format (id, object, created, owned_by — no name field) +function createZenModelResponse(ids: string[]) { + return { + object: 'list', + data: ids.map(id => ({ + id, + object: 'model', + created: 1772132920, + owned_by: 'opencode', + })), + }; +} + +describe('OpenCodeManager model discovery', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + 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 () => { + const manager = createManager(); + const zenResponse = createZenModelResponse([ + 'claude-sonnet-4', + 'gpt-5.1-codex', + 'gemini-3-pro', + ]); + + (manager as any).httpRequest = vi.fn().mockResolvedValue({ + statusCode: 200, + body: JSON.stringify(zenResponse), + }); + + const models = await manager.getAvailableModels(); + + expect(models).toHaveLength(3); + expect(models[0]).toEqual({ id: 'claude-sonnet-4', name: 'Claude Sonnet 4', provider: 'anthropic' }); + expect(models[1]).toEqual({ id: 'gpt-5.1-codex', name: 'GPT 5.1 Codex', provider: 'openai' }); + expect(models[2]).toEqual({ id: 'gemini-3-pro', name: 'Gemini 3 Pro', provider: 'google' }); + }); + + it('falls back to known models when API fails', async () => { + const manager = createManager(); + (manager as any).httpRequest = vi.fn().mockRejectedValue(new Error('Network error')); + + const models = await manager.getAvailableModels(); + + expect(models.length).toBeGreaterThan(0); + // Should include well-known models from the display name map + const ids = models.map((m: ModelInfo) => m.id); + 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'); + expect(claudeModel?.provider).toBe('anthropic'); + const gptModel = models.find((m: ModelInfo) => m.id === 'gpt-5'); + expect(gptModel?.provider).toBe('openai'); + }); + + it('falls back when API returns non-200 status', async () => { + const manager = createManager(); + (manager as any).httpRequest = vi.fn().mockResolvedValue({ + statusCode: 401, + body: '{"error":"unauthorized"}', + }); + + const models = await manager.getAvailableModels(); + + expect(models.length).toBeGreaterThan(0); + const ids = models.map((m: ModelInfo) => m.id); + expect(ids).toContain('claude-sonnet-4'); + }); + + it('caches models and does not re-fetch within TTL', async () => { + const manager = createManager(); + const httpRequest = vi.fn().mockResolvedValue({ + statusCode: 200, + body: JSON.stringify(createZenModelResponse(['claude-sonnet-4'])), + }); + (manager as any).httpRequest = httpRequest; + + await manager.getAvailableModels(); + await manager.getAvailableModels(); + + expect(httpRequest).toHaveBeenCalledTimes(1); + }); + + it('re-fetches after cache TTL expires', async () => { + const manager = createManager(); + const httpRequest = vi.fn().mockResolvedValue({ + statusCode: 200, + body: JSON.stringify(createZenModelResponse(['claude-sonnet-4'])), + }); + (manager as any).httpRequest = httpRequest; + + await manager.getAvailableModels(); + expect(httpRequest).toHaveBeenCalledTimes(1); + + // Advance past 5-minute TTL + vi.advanceTimersByTime(6 * 60 * 1000); + + await manager.getAvailableModels(); + expect(httpRequest).toHaveBeenCalledTimes(2); + }); + + it('handles unknown model IDs from API with auto-formatting', async () => { + const manager = createManager(); + const zenResponse = createZenModelResponse(['some-new-model-v3']); + + (manager as any).httpRequest = vi.fn().mockResolvedValue({ + statusCode: 200, + body: JSON.stringify(zenResponse), + }); + + const models = await manager.getAvailableModels(); + + expect(models).toHaveLength(1); + 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 () => { + const manager = createManager(); + (manager as any).apiKey = ''; + + const models = await manager.getAvailableModels(); + + expect(models.length).toBeGreaterThan(0); + const ids = models.map((m: ModelInfo) => m.id); + expect(ids).toContain('claude-sonnet-4'); + }); + }); +});