diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index 18550c92..a7026fe5 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -84,6 +84,7 @@ "worktree:rm": "pnpm exec tsx scripts/worktree-rm.ts" }, "dependencies": { + "@ai-sdk/openai-compatible": "^2.0.48", "@ai-sdk/react": "^3.0.49", "@assistant-ui/react": "0.11.56", "@assistant-ui/react-ai-sdk": "1.1.21", diff --git a/apps/readest-app/src/__tests__/ai/providers.test.ts b/apps/readest-app/src/__tests__/ai/providers.test.ts index 220906b8..392b0f08 100644 --- a/apps/readest-app/src/__tests__/ai/providers.test.ts +++ b/apps/readest-app/src/__tests__/ai/providers.test.ts @@ -24,8 +24,18 @@ vi.mock('ai-sdk-ollama', () => ({ }), })); +// mock @ai-sdk/openai-compatible so OpenRouterProvider can be constructed +// without going over the network during unit tests. +vi.mock('@ai-sdk/openai-compatible', () => ({ + createOpenAICompatible: vi.fn(() => ({ + chatModel: vi.fn(), + textEmbeddingModel: vi.fn(), + })), +})); + import { OllamaProvider } from '@/services/ai/providers/OllamaProvider'; import { AIGatewayProvider } from '@/services/ai/providers/AIGatewayProvider'; +import { OpenRouterProvider } from '@/services/ai/providers/OpenRouterProvider'; import { getAIProvider } from '@/services/ai/providers'; import type { AISettings } from '@/services/ai/types'; import { DEFAULT_AI_SETTINGS } from '@/services/ai/constants'; @@ -161,6 +171,93 @@ describe('AIGatewayProvider', () => { }); }); +describe('OpenRouterProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test('should throw if no API key', () => { + const settings: AISettings = { ...DEFAULT_AI_SETTINGS, enabled: true, provider: 'openrouter' }; + expect(() => new OpenRouterProvider(settings)).toThrow('OpenRouter API key required'); + }); + + test('should create provider with API key and default base URL', () => { + const settings: AISettings = { + ...DEFAULT_AI_SETTINGS, + enabled: true, + provider: 'openrouter', + openrouterApiKey: 'sk-or-test', + }; + const provider = new OpenRouterProvider(settings); + + expect(provider.id).toBe('openrouter'); + expect(provider.name).toBe('OpenRouter (Custom)'); + expect(provider.requiresAuth).toBe(true); + }); + + test('isAvailable should return true if key exists', async () => { + const settings: AISettings = { + ...DEFAULT_AI_SETTINGS, + enabled: true, + provider: 'openrouter', + openrouterApiKey: 'sk-or-test', + }; + const provider = new OpenRouterProvider(settings); + + expect(await provider.isAvailable()).toBe(true); + }); + + test('healthCheck succeeds when /models responds OK', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + const settings: AISettings = { + ...DEFAULT_AI_SETTINGS, + enabled: true, + provider: 'openrouter', + openrouterApiKey: 'sk-or-test', + openrouterBaseUrl: 'https://openrouter.ai/api/v1', + }; + const provider = new OpenRouterProvider(settings); + + expect(await provider.healthCheck()).toBe(true); + // verifies we hit `${baseUrl}/models` with the Authorization header + expect(mockFetch).toHaveBeenCalledWith( + 'https://openrouter.ai/api/v1/models', + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ Authorization: 'Bearer sk-or-test' }), + }), + ); + }); + + test('healthCheck returns false when /models fails', async () => { + mockFetch.mockResolvedValueOnce({ ok: false, status: 401 }); + const settings: AISettings = { + ...DEFAULT_AI_SETTINGS, + enabled: true, + provider: 'openrouter', + openrouterApiKey: 'sk-or-bad', + }; + const provider = new OpenRouterProvider(settings); + + expect(await provider.healthCheck()).toBe(false); + }); + + test('healthCheck strips trailing slashes from base URL', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, status: 200 }); + const settings: AISettings = { + ...DEFAULT_AI_SETTINGS, + enabled: true, + provider: 'openrouter', + openrouterApiKey: 'sk-or-test', + openrouterBaseUrl: 'https://example.com/v1////', + }; + const provider = new OpenRouterProvider(settings); + + await provider.healthCheck(); + expect(mockFetch).toHaveBeenCalledWith('https://example.com/v1/models', expect.any(Object)); + }); +}); + describe('getAIProvider', () => { test('should return OllamaProvider for ollama', () => { const settings: AISettings = { ...DEFAULT_AI_SETTINGS, enabled: true, provider: 'ollama' }; @@ -181,6 +278,27 @@ describe('getAIProvider', () => { expect(provider.id).toBe('ai-gateway'); }); + test('should return OpenRouterProvider for openrouter', () => { + const settings: AISettings = { + ...DEFAULT_AI_SETTINGS, + enabled: true, + provider: 'openrouter', + openrouterApiKey: 'sk-or-test', + }; + const provider = getAIProvider(settings); + + expect(provider.id).toBe('openrouter'); + }); + + test('getAIProvider throws when openrouter has no API key', () => { + const settings: AISettings = { + ...DEFAULT_AI_SETTINGS, + enabled: true, + provider: 'openrouter', + }; + expect(() => getAIProvider(settings)).toThrow('API key required for OpenRouter'); + }); + test('should throw for unknown provider', () => { const settings = { ...DEFAULT_AI_SETTINGS, diff --git a/apps/readest-app/src/__tests__/services/backup-settings.test.ts b/apps/readest-app/src/__tests__/services/backup-settings.test.ts index 59ba4d06..3f367b10 100644 --- a/apps/readest-app/src/__tests__/services/backup-settings.test.ts +++ b/apps/readest-app/src/__tests__/services/backup-settings.test.ts @@ -65,6 +65,8 @@ function makeSettings(overrides: Partial = {}): SystemSettings { provider: 'ollama', ollamaBaseUrl: 'http://localhost', aiGatewayApiKey: 'ai-secret-key', + openrouterApiKey: 'or-secret-key', + openrouterBaseUrl: 'https://openrouter.ai/api/v1', }, globalReadSettings: { sideBarWidth: '20%', @@ -146,6 +148,9 @@ describe('sanitizeSettingsForBackup - credentials', () => { expect(rec(out.readwise)['accessToken']).toBeUndefined(); expect(rec(out.hardcover)['accessToken']).toBeUndefined(); expect(rec(out.aiSettings)['aiGatewayApiKey']).toBeUndefined(); + expect(rec(out.aiSettings)['openrouterApiKey']).toBeUndefined(); + // non-credential aiSettings fields (e.g. base URL) survive + expect(rec(out.aiSettings)['openrouterBaseUrl']).toBe('https://openrouter.ai/api/v1'); expect(out.opdsCatalogs[0]!.username).toBeUndefined(); expect(out.opdsCatalogs[0]!.password).toBeUndefined(); }); @@ -162,6 +167,7 @@ describe('sanitizeSettingsForBackup - credentials', () => { expect(out.readwise.accessToken).toBe('rw-token'); expect(out.hardcover.accessToken).toBe('hc-token'); expect(rec(out.aiSettings)['aiGatewayApiKey']).toBe('ai-secret-key'); + expect(rec(out.aiSettings)['openrouterApiKey']).toBe('or-secret-key'); expect(out.opdsCatalogs[0]!.username).toBe('opds-user'); expect(out.opdsCatalogs[0]!.password).toBe('opds-pass'); }); diff --git a/apps/readest-app/src/components/settings/AIPanel.tsx b/apps/readest-app/src/components/settings/AIPanel.tsx index 433de3ea..9f490a8e 100644 --- a/apps/readest-app/src/components/settings/AIPanel.tsx +++ b/apps/readest-app/src/components/settings/AIPanel.tsx @@ -6,6 +6,10 @@ import { useTranslation } from '@/hooks/useTranslation'; import { useSettingsStore } from '@/store/settingsStore'; import { useEnv } from '@/context/EnvContext'; import { getAIProvider } from '@/services/ai/providers'; +import { + fetchOpenRouterModels, + type OpenRouterModelInfo, +} from '@/services/ai/providers/OpenRouterProvider'; import { DEFAULT_AI_SETTINGS, GATEWAY_MODELS, MODEL_PRICING } from '@/services/ai/constants'; import type { AISettings, AIProviderName } from '@/services/ai/types'; import { BoxedList, SettingLabel, SettingsRow, SettingsSwitchRow } from './primitives'; @@ -77,6 +81,19 @@ const AIPanel: React.FC = () => { const [fetchingModels, setFetchingModels] = useState(false); const [gatewayKey, setGatewayKey] = useState(aiSettings.aiGatewayApiKey ?? ''); + // ---- OpenRouter (OpenAI-compatible) state ---- + const [openrouterKey, setOpenrouterKey] = useState(aiSettings.openrouterApiKey ?? ''); + const [openrouterUrl, setOpenrouterUrl] = useState( + aiSettings.openrouterBaseUrl ?? DEFAULT_AI_SETTINGS.openrouterBaseUrl ?? '', + ); + const [openrouterModel, setOpenrouterModel] = useState(aiSettings.openrouterModel ?? ''); + const [openrouterEmbeddingModel, setOpenrouterEmbeddingModel] = useState( + aiSettings.openrouterEmbeddingModel ?? '', + ); + const [openrouterModels, setOpenrouterModels] = useState([]); + const [openrouterFetchingModels, setOpenrouterFetchingModels] = useState(false); + const [openrouterModelsError, setOpenrouterModelsError] = useState(''); + const savedCustomModel = aiSettings.aiGatewayCustomModel ?? ''; const savedModel = aiSettings.aiGatewayModel ?? DEFAULT_AI_SETTINGS.aiGatewayModel ?? ''; const isCustomModelSaved = savedCustomModel.length > 0; @@ -147,6 +164,39 @@ const AIPanel: React.FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [provider, enabled, ollamaUrl]); + // ---- OpenRouter: fetch /models list ---- + const fetchOpenrouterModelList = useCallback(async () => { + if (!enabled || !openrouterUrl || !openrouterKey) { + setOpenrouterModels([]); + return; + } + setOpenrouterFetchingModels(true); + setOpenrouterModelsError(''); + try { + const models = await fetchOpenRouterModels(openrouterUrl, openrouterKey); + // Sort by id for a stable picker. Keep raw entries — UI uses + // `name || id` so OpenRouter's friendly labels still show up. + models.sort((a, b) => a.id.localeCompare(b.id)); + setOpenrouterModels(models); + if (models.length > 0 && !models.some((m) => m.id === openrouterModel)) { + setOpenrouterModel(models[0]!.id); + } + } catch (e) { + setOpenrouterModels([]); + setOpenrouterModelsError((e as Error).message || _('Failed to fetch models')); + } finally { + setOpenrouterFetchingModels(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enabled, openrouterUrl, openrouterKey, openrouterModel]); + + useEffect(() => { + if (provider === 'openrouter' && enabled && openrouterKey && openrouterUrl) { + fetchOpenrouterModelList(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [provider, enabled, openrouterKey, openrouterUrl]); + useEffect(() => { isMounted.current = true; }, []); @@ -199,6 +249,39 @@ const AIPanel: React.FC = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [gatewayKey]); + // ---- OpenRouter save effects ---- + useEffect(() => { + if (!isMounted.current) return; + if (openrouterKey !== (aiSettings.openrouterApiKey ?? '')) { + saveAiSetting('openrouterApiKey', openrouterKey); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [openrouterKey]); + + useEffect(() => { + if (!isMounted.current) return; + if (openrouterUrl !== (aiSettings.openrouterBaseUrl ?? '')) { + saveAiSetting('openrouterBaseUrl', openrouterUrl); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [openrouterUrl]); + + useEffect(() => { + if (!isMounted.current) return; + if (openrouterModel !== (aiSettings.openrouterModel ?? '')) { + saveAiSetting('openrouterModel', openrouterModel); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [openrouterModel]); + + useEffect(() => { + if (!isMounted.current) return; + if (openrouterEmbeddingModel !== (aiSettings.openrouterEmbeddingModel ?? '')) { + saveAiSetting('openrouterEmbeddingModel', openrouterEmbeddingModel); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [openrouterEmbeddingModel]); + // Get the effective model ID to use (either selected or custom) const getEffectiveModelId = useCallback(() => { if (selectedModel === CUSTOM_MODEL_VALUE && customModelStatus === 'valid') { @@ -289,6 +372,10 @@ const AIPanel: React.FC = () => { ollamaEmbeddingModel, aiGatewayApiKey: gatewayKey, aiGatewayModel: effectiveModel, + openrouterApiKey: openrouterKey, + openrouterBaseUrl: openrouterUrl, + openrouterModel, + openrouterEmbeddingModel, }; const aiProvider = getAIProvider(testSettings); const isHealthy = await aiProvider.healthCheck(); @@ -341,6 +428,16 @@ const AIPanel: React.FC = () => { disabled={!enabled} /> + + setProvider('openrouter')} + disabled={!enabled} + /> + {provider === 'ollama' && ( @@ -501,6 +598,144 @@ const AIPanel: React.FC = () => { )} + {provider === 'openrouter' && ( + + {/* API key */} +
+
+ {_('API Key')} + + {_('Get Key')} + +
+ setOpenrouterKey(e.target.value)} + placeholder='sk-or-...' + disabled={!enabled} + autoComplete='off' + /> +
+ + {/* Base URL + refresh */} +
+
+ {_('Base URL')} + +
+ setOpenrouterUrl(e.target.value)} + placeholder='https://openrouter.ai/api/v1' + disabled={!enabled} + /> +
+ + {/* Model picker — populated from the endpoint's /models */} +
+ {_('LLM Model')} + {openrouterModels.length > 0 ? ( + + ) : ( + // Fallback: free-text input when /models isn't reachable yet, + // so the user isn't locked out before refreshing succeeds. + setOpenrouterModel(e.target.value)} + placeholder='openai/gpt-4o-mini' + disabled={!enabled} + /> + )} + {openrouterModelsError && ( + {openrouterModelsError} + )} + {!openrouterModelsError && !openrouterKey && ( + + {_('Enter an API key, then refresh to load available models.')} + + )} +
+ + {/* Embedding model — same /models listing as the LLM picker. + OpenAI's /v1/models doesn't tag chat vs embedding, so the two + selects share one list and the user picks the right one. + Falls back to free text when the list isn't loaded yet, so + the user can still type a known ID before refreshing. */} +
+ {_('Embedding Model')} + {openrouterModels.length > 0 ? ( + + ) : ( + setOpenrouterEmbeddingModel(e.target.value)} + placeholder='openai/text-embedding-3-small' + disabled={!enabled} + /> + )} + + {_( + 'Optional. Leave blank if your endpoint does not support embeddings — chat will still work but RAG features will be unavailable.', + )} + +
+
+ )} +