import { describe, expect, it } from 'vitest'; import { loadSettings, saveSettings, setActiveProvider, getActiveProviderConfig, isProviderConfigured, clearSettings, getProviderDisplayName, getAvailableModels, getProviderCapabilities, } from '../../src/core/llm/settings-service'; import { getMiniMaxModelCapabilities, MINIMAX_ANTHROPIC_BASE_URLS, MINIMAX_MODEL_IDS, } from '../../src/core/llm/types'; import { createChatModel } from '../../src/core/llm/agent'; describe('loadSettings', () => { it('returns defaults when nothing is stored', () => { const settings = loadSettings(); expect(settings.activeProvider).toBeDefined(); expect(settings.openai).toBeDefined(); expect(settings.ollama).toBeDefined(); expect(settings.minimax).toMatchObject({ model: MINIMAX_MODEL_IDS[0], baseUrl: MINIMAX_ANTHROPIC_BASE_URLS.global_en, thinkingMode: 'adaptive', }); }); it('merges stored values with defaults', () => { sessionStorage.setItem( 'gitnexus-llm-settings', JSON.stringify({ activeProvider: 'ollama', ollama: { model: 'qwen3-coder:30b' }, }), ); const settings = loadSettings(); expect(settings.activeProvider).toBe('ollama'); expect(settings.ollama.model).toBe('qwen3-coder:30b'); // Should still have other provider defaults expect(settings.openai).toBeDefined(); }); it('migrates unsupported legacy MiniMax models to the current default', () => { sessionStorage.setItem( 'gitnexus-llm-settings', JSON.stringify({ activeProvider: 'minimax', minimax: { apiKey: 'minimax-test-key', model: 'MiniMax-M2.5', temperature: 0.1, }, }), ); const settings = loadSettings(); expect(settings.minimax).toMatchObject({ model: MINIMAX_MODEL_IDS[0], thinkingMode: 'adaptive', }); const model = createChatModel(getActiveProviderConfig()!) as any; expect(model.model).toBe(MINIMAX_MODEL_IDS[0]); expect(model.thinking).toEqual({ type: 'adaptive' }); }); it('returns defaults on corrupted JSON', () => { sessionStorage.setItem('gitnexus-llm-settings', 'not-json{{{'); const settings = loadSettings(); expect(settings.activeProvider).toBeDefined(); }); it('migrates legacy localStorage to sessionStorage', () => { localStorage.setItem( 'gitnexus-llm-settings', JSON.stringify({ activeProvider: 'ollama', ollama: { model: 'migrated-model' }, }), ); const settings = loadSettings(); expect(settings.ollama.model).toBe('migrated-model'); expect(sessionStorage.getItem('gitnexus-llm-settings')).not.toBeNull(); expect(localStorage.getItem('gitnexus-llm-settings')).toBeNull(); }); }); describe('saveSettings / clearSettings', () => { it('persists settings to sessionStorage', () => { const settings = loadSettings(); settings.activeProvider = 'anthropic'; saveSettings(settings); expect(loadSettings().activeProvider).toBe('anthropic'); }); it('clearSettings removes settings from both storages', () => { saveSettings({ ...loadSettings(), activeProvider: 'anthropic' }); expect(sessionStorage.getItem('gitnexus-llm-settings')).not.toBeNull(); clearSettings(); expect(sessionStorage.getItem('gitnexus-llm-settings')).toBeNull(); expect(localStorage.getItem('gitnexus-llm-settings')).toBeNull(); }); }); describe('setActiveProvider', () => { it('changes the active provider and persists', () => { setActiveProvider('gemini'); expect(loadSettings().activeProvider).toBe('gemini'); }); }); describe('getActiveProviderConfig', () => { it('returns null for unconfigured providers requiring API keys', () => { setActiveProvider('openai'); expect(getActiveProviderConfig()).toBeNull(); }); it('returns config for ollama without API key', () => { setActiveProvider('ollama'); const config = getActiveProviderConfig(); expect(config).not.toBeNull(); expect(config!.provider).toBe('ollama'); }); it('returns config for openai when API key is set', () => { const settings = loadSettings(); settings.activeProvider = 'openai'; settings.openai = { ...settings.openai, apiKey: 'sk-test-123' }; saveSettings(settings); const config = getActiveProviderConfig(); expect(config).not.toBeNull(); expect(config!.provider).toBe('openai'); }); it('returns config for deepseek when API key is set', () => { const settings = loadSettings(); settings.activeProvider = 'deepseek'; settings.deepseek = { ...settings.deepseek, apiKey: 'sk-deepseek-123' }; saveSettings(settings); const config = getActiveProviderConfig(); expect(config).not.toBeNull(); expect(config!.provider).toBe('deepseek'); }); it('returns the regional endpoint and thinking mode for MiniMax', () => { const settings = loadSettings(); settings.activeProvider = 'minimax'; settings.minimax = { ...settings.minimax, apiKey: 'minimax-test-key', model: MINIMAX_MODEL_IDS[0], baseUrl: MINIMAX_ANTHROPIC_BASE_URLS.cn_zh, thinkingMode: 'disabled', }; saveSettings(settings); expect(getActiveProviderConfig()).toMatchObject({ provider: 'minimax', model: MINIMAX_MODEL_IDS[0], baseUrl: MINIMAX_ANTHROPIC_BASE_URLS.cn_zh, thinkingMode: 'disabled', }); }); it('returns null for openrouter with empty API key', () => { const settings = loadSettings(); settings.activeProvider = 'openrouter'; settings.openrouter = { ...settings.openrouter, apiKey: ' ' }; saveSettings(settings); expect(getActiveProviderConfig()).toBeNull(); }); }); describe('isProviderConfigured', () => { it('returns false when provider requires API key and none is set', () => { // Manually build a clean openai config with no API key saveSettings({ ...loadSettings(), activeProvider: 'openai', openai: { apiKey: '', model: 'gpt-4o', temperature: 0.1 }, }); expect(isProviderConfigured()).toBe(false); }); it('returns true for ollama (no key required)', () => { setActiveProvider('ollama'); expect(isProviderConfigured()).toBe(true); }); }); describe('getProviderDisplayName', () => { it('returns human-readable names', () => { expect(getProviderDisplayName('openai')).toBe('OpenAI'); expect(getProviderDisplayName('azure-openai')).toBe('Azure OpenAI'); expect(getProviderDisplayName('gemini')).toBe('Google Gemini'); expect(getProviderDisplayName('anthropic')).toBe('Anthropic'); expect(getProviderDisplayName('ollama')).toBe('Ollama (Local)'); expect(getProviderDisplayName('openrouter')).toBe('OpenRouter'); expect(getProviderDisplayName('deepseek')).toBe('DeepSeek'); }); }); describe('getAvailableModels', () => { it('returns models for known providers', () => { expect(getAvailableModels('openai').length).toBeGreaterThan(0); expect(getAvailableModels('ollama').length).toBeGreaterThan(0); expect(getAvailableModels('anthropic')).toContain('claude-sonnet-4-20250514'); expect(getAvailableModels('deepseek')).toContain('deepseek-v4-flash'); expect(getAvailableModels('minimax')).toEqual([...MINIMAX_MODEL_IDS]); }); it('describes MiniMax model input and thinking capabilities', () => { expect(getMiniMaxModelCapabilities(MINIMAX_MODEL_IDS[0])).toEqual({ contextWindow: 1_000_000, inputModalities: ['text', 'image', 'video'], thinkingModes: ['adaptive', 'disabled'], }); expect(getMiniMaxModelCapabilities(MINIMAX_MODEL_IDS[1])).toEqual({ contextWindow: 204_800, inputModalities: ['text'], thinkingModes: ['always_on'], }); }); it('returns empty array for unknown provider', () => { expect(getAvailableModels('unknown' as any)).toEqual([]); }); }); describe('getProviderCapabilities', () => { it('enables transcript replay only for providers that require it', () => { expect(getProviderCapabilities('deepseek').preserveAssistantTranscript).toBe(true); expect(getProviderCapabilities('openai').preserveAssistantTranscript).toBe(false); expect(getProviderCapabilities('anthropic').preserveAssistantTranscript).toBe(false); }); });