forked from akai/readest
feat(ai): add OpenRouter provider and unify provider HTTP transport (#4289)
* OpenRouter: new OpenAI-compatible provider with chat + embedding model routing, health check via /models, and a fetchOpenRouterModels() helper for the settings UI. API key, base URL and model fields are persisted in AISettings, surfaced in AIPanel, indexed by commandRegistry, and added to backupService's credential allow-list so the key round-trips through encrypted backups. * utils/httpFetch: introduce getAIFetch() as the single decision point for outbound AI traffic. In Tauri it returns @tauri-apps/plugin-http's fetch (Rust/reqwest transport, no renderer CORS preflight, no Android cleartext block); on the web build it falls back to window.fetch. OllamaProvider is migrated end-to-end — both ai-sdk-ollama streaming and the /api/tags health probe — and the new OpenRouterProvider uses the same path, so any future provider only has to call getAIFetch(). * Tests: unit tests for OpenRouter provider behavior (model selection, availability, health check) and a backup-settings round-trip test ensuring openrouterApiKey is treated as a credential field.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -65,6 +65,8 @@ function makeSettings(overrides: Partial<SystemSettings> = {}): 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');
|
||||
});
|
||||
|
||||
@@ -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<OpenRouterModelInfo[]>([]);
|
||||
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}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow label={_('OpenAI Compatible')} asLabel>
|
||||
<input
|
||||
type='radio'
|
||||
name='ai-provider'
|
||||
className='radio'
|
||||
checked={provider === 'openrouter'}
|
||||
onChange={() => setProvider('openrouter')}
|
||||
disabled={!enabled}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</BoxedList>
|
||||
|
||||
{provider === 'ollama' && (
|
||||
@@ -501,6 +598,144 @@ const AIPanel: React.FC = () => {
|
||||
</BoxedList>
|
||||
)}
|
||||
|
||||
{provider === 'openrouter' && (
|
||||
<BoxedList
|
||||
title={_('OpenAI Compatible Configuration')}
|
||||
description={_(
|
||||
'Bring your own API key for OpenAI or any OpenAI-compatible endpoint. Also works with Together / Groq / vLLM / OpenRouter and other OpenAI-compatible services. The model list is fetched live from the endpoint you configure.',
|
||||
)}
|
||||
className={disabledSection}
|
||||
>
|
||||
{/* API key */}
|
||||
<div className='flex flex-col gap-2 px-4 py-3'>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<SettingLabel>{_('API Key')}</SettingLabel>
|
||||
<a
|
||||
href='https://openrouter.ai/keys'
|
||||
target='_blank'
|
||||
rel='noopener noreferrer'
|
||||
className={clsx('link text-xs', !enabled && 'pointer-events-none')}
|
||||
>
|
||||
{_('Get Key')}
|
||||
</a>
|
||||
</div>
|
||||
<input
|
||||
type='password'
|
||||
className='input input-bordered input-sm w-full'
|
||||
value={openrouterKey}
|
||||
onChange={(e) => setOpenrouterKey(e.target.value)}
|
||||
placeholder='sk-or-...'
|
||||
disabled={!enabled}
|
||||
autoComplete='off'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Base URL + refresh */}
|
||||
<div className='flex flex-col gap-2 px-4 py-3'>
|
||||
<div className='flex w-full items-center justify-between'>
|
||||
<SettingLabel>{_('Base URL')}</SettingLabel>
|
||||
<button
|
||||
className='hover:bg-base-200 inline-flex h-7 w-7 items-center justify-center rounded-md transition-colors duration-150'
|
||||
onClick={fetchOpenrouterModelList}
|
||||
disabled={!enabled || openrouterFetchingModels || !openrouterKey}
|
||||
title={_('Refresh Models')}
|
||||
aria-label={_('Refresh Models')}
|
||||
>
|
||||
{openrouterFetchingModels ? (
|
||||
<PiSpinner className='size-4 animate-spin' />
|
||||
) : (
|
||||
<PiArrowsClockwise className='size-4' />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type='text'
|
||||
className='input input-bordered input-sm w-full'
|
||||
value={openrouterUrl}
|
||||
onChange={(e) => setOpenrouterUrl(e.target.value)}
|
||||
placeholder='https://openrouter.ai/api/v1'
|
||||
disabled={!enabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Model picker — populated from the endpoint's /models */}
|
||||
<div className='flex flex-col gap-2 px-4 py-3'>
|
||||
<SettingLabel>{_('LLM Model')}</SettingLabel>
|
||||
{openrouterModels.length > 0 ? (
|
||||
<select
|
||||
className='select select-bordered select-sm bg-base-100 text-base-content w-full'
|
||||
value={openrouterModel}
|
||||
onChange={(e) => setOpenrouterModel(e.target.value)}
|
||||
disabled={!enabled}
|
||||
>
|
||||
{openrouterModels.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name ? `${m.name} (${m.id})` : m.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
// Fallback: free-text input when /models isn't reachable yet,
|
||||
// so the user isn't locked out before refreshing succeeds.
|
||||
<input
|
||||
type='text'
|
||||
className='input input-bordered input-sm w-full'
|
||||
value={openrouterModel}
|
||||
onChange={(e) => setOpenrouterModel(e.target.value)}
|
||||
placeholder='openai/gpt-4o-mini'
|
||||
disabled={!enabled}
|
||||
/>
|
||||
)}
|
||||
{openrouterModelsError && (
|
||||
<span className='text-error text-xs'>{openrouterModelsError}</span>
|
||||
)}
|
||||
{!openrouterModelsError && !openrouterKey && (
|
||||
<span className='text-base-content/60 text-xs'>
|
||||
{_('Enter an API key, then refresh to load available models.')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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. */}
|
||||
<div className='flex flex-col gap-2 px-4 py-3'>
|
||||
<SettingLabel>{_('Embedding Model')}</SettingLabel>
|
||||
{openrouterModels.length > 0 ? (
|
||||
<select
|
||||
className='select select-bordered select-sm bg-base-100 text-base-content w-full'
|
||||
value={openrouterEmbeddingModel}
|
||||
onChange={(e) => setOpenrouterEmbeddingModel(e.target.value)}
|
||||
disabled={!enabled}
|
||||
>
|
||||
<option value=''>{_('None (disable RAG)')}</option>
|
||||
{openrouterModels.map((m) => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{m.name ? `${m.name} (${m.id})` : m.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
type='text'
|
||||
className='input input-bordered input-sm w-full'
|
||||
value={openrouterEmbeddingModel}
|
||||
onChange={(e) => setOpenrouterEmbeddingModel(e.target.value)}
|
||||
placeholder='openai/text-embedding-3-small'
|
||||
disabled={!enabled}
|
||||
/>
|
||||
)}
|
||||
<span className='text-base-content/60 text-xs'>
|
||||
{_(
|
||||
'Optional. Leave blank if your endpoint does not support embeddings — chat will still work but RAG features will be unavailable.',
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</BoxedList>
|
||||
)}
|
||||
|
||||
<BoxedList title={_('Connection')} className={disabledSection}>
|
||||
<div className='flex min-h-14 items-center justify-between gap-3 pe-4'>
|
||||
<button
|
||||
|
||||
@@ -30,6 +30,10 @@ export const DEFAULT_AI_SETTINGS: AISettings = {
|
||||
aiGatewayModel: 'google/gemini-2.5-flash-lite',
|
||||
aiGatewayEmbeddingModel: 'openai/text-embedding-3-small',
|
||||
|
||||
openrouterBaseUrl: 'https://openrouter.ai/api/v1',
|
||||
openrouterModel: '',
|
||||
openrouterEmbeddingModel: '',
|
||||
|
||||
spoilerProtection: true,
|
||||
maxContextChunks: 10,
|
||||
indexingMode: 'on-demand',
|
||||
|
||||
@@ -3,7 +3,18 @@ import type { LanguageModel, EmbeddingModel } from 'ai';
|
||||
import type { AIProvider, AISettings, AIProviderName } from '../types';
|
||||
import { aiLogger } from '../logger';
|
||||
import { AI_TIMEOUTS } from '../utils/retry';
|
||||
import { getAIFetch } from '../utils/httpFetch';
|
||||
|
||||
/**
|
||||
* Provider for a local (or LAN) Ollama instance.
|
||||
*
|
||||
* All outbound HTTP — both the streaming chat/embedding traffic going
|
||||
* through ai-sdk-ollama and the lightweight `/api/tags` probes used by
|
||||
* the availability/health checks — goes through {@link getAIFetch}. In
|
||||
* the Tauri app that hands the request off to the Rust HTTP transport
|
||||
* (no CORS preflight, no Android cleartext restriction); on the web
|
||||
* build it falls back to `window.fetch`.
|
||||
*/
|
||||
export class OllamaProvider implements AIProvider {
|
||||
id: AIProviderName = 'ollama';
|
||||
name = 'Ollama (Local)';
|
||||
@@ -11,11 +22,14 @@ export class OllamaProvider implements AIProvider {
|
||||
|
||||
private ollama;
|
||||
private settings: AISettings;
|
||||
private httpFetch: typeof fetch;
|
||||
|
||||
constructor(settings: AISettings) {
|
||||
this.settings = settings;
|
||||
this.httpFetch = getAIFetch();
|
||||
this.ollama = createOllama({
|
||||
baseURL: settings.ollamaBaseUrl || 'http://127.0.0.1:11434',
|
||||
fetch: this.httpFetch,
|
||||
});
|
||||
aiLogger.provider.init('ollama', settings.ollamaModel || 'llama3.2');
|
||||
}
|
||||
@@ -32,7 +46,7 @@ export class OllamaProvider implements AIProvider {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), AI_TIMEOUTS.OLLAMA_CONNECT);
|
||||
const response = await fetch(`${this.settings.ollamaBaseUrl}/api/tags`, {
|
||||
const response = await this.httpFetch(`${this.settings.ollamaBaseUrl}/api/tags`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
@@ -46,7 +60,7 @@ export class OllamaProvider implements AIProvider {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), AI_TIMEOUTS.HEALTH_CHECK);
|
||||
const response = await fetch(`${this.settings.ollamaBaseUrl}/api/tags`, {
|
||||
const response = await this.httpFetch(`${this.settings.ollamaBaseUrl}/api/tags`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
|
||||
import type { LanguageModel, EmbeddingModel } from 'ai';
|
||||
import type { AIProvider, AISettings, AIProviderName } from '../types';
|
||||
import { aiLogger } from '../logger';
|
||||
import { AI_TIMEOUTS } from '../utils/retry';
|
||||
import { getAIFetch } from '../utils/httpFetch';
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1';
|
||||
const DEFAULT_MODEL = 'openai/gpt-4o-mini';
|
||||
const DEFAULT_EMBEDDING_MODEL = 'openai/text-embedding-3-small';
|
||||
|
||||
/**
|
||||
* Provider for any OpenAI-compatible /v1/chat/completions endpoint, with
|
||||
* OpenRouter as the default. Users supply their own API key and base URL.
|
||||
*
|
||||
* Distinct from `AIGatewayProvider` (which is bound to Vercel AI Gateway's
|
||||
* proprietary protocol) — this one targets the OpenAI REST schema and so
|
||||
* works with OpenRouter, Together, Groq, vLLM, LiteLLM, OpenAI itself, etc.
|
||||
*
|
||||
* Transport: every outbound HTTP call from this provider is routed through
|
||||
* {@link getAIFetch} so that in the Tauri app it goes via the Rust
|
||||
* `@tauri-apps/plugin-http` transport (no CORS preflight, no Android
|
||||
* cleartext block, behaves like `curl`). In a pure web build it falls
|
||||
* back to `window.fetch` and the upstream must serve correct CORS headers.
|
||||
*/
|
||||
export class OpenRouterProvider implements AIProvider {
|
||||
id: AIProviderName = 'openrouter';
|
||||
name = 'OpenRouter (Custom)';
|
||||
requiresAuth = true;
|
||||
|
||||
private settings: AISettings;
|
||||
private client: ReturnType<typeof createOpenAICompatible>;
|
||||
private baseUrl: string;
|
||||
private apiKey: string;
|
||||
private httpFetch: typeof fetch;
|
||||
|
||||
constructor(settings: AISettings) {
|
||||
this.settings = settings;
|
||||
if (!settings.openrouterApiKey) {
|
||||
throw new Error('OpenRouter API key required');
|
||||
}
|
||||
this.apiKey = settings.openrouterApiKey;
|
||||
this.baseUrl = (settings.openrouterBaseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
||||
this.httpFetch = getAIFetch();
|
||||
this.client = createOpenAICompatible({
|
||||
name: 'openrouter',
|
||||
baseURL: this.baseUrl,
|
||||
apiKey: this.apiKey,
|
||||
// Optional OpenRouter app attribution. Harmless for other OpenAI-
|
||||
// compatible backends (they ignore unknown headers).
|
||||
headers: {
|
||||
'HTTP-Referer': 'https://readest.com',
|
||||
'X-Title': 'Readest',
|
||||
},
|
||||
// Route chat completions / embeddings through our environment-aware
|
||||
// fetch so streaming responses bypass the renderer's CORS sandbox
|
||||
// when running inside Tauri.
|
||||
fetch: this.httpFetch,
|
||||
});
|
||||
aiLogger.provider.init('openrouter', settings.openrouterModel || DEFAULT_MODEL);
|
||||
}
|
||||
|
||||
getModel(): LanguageModel {
|
||||
const modelId = this.settings.openrouterModel || DEFAULT_MODEL;
|
||||
return this.client.chatModel(modelId);
|
||||
}
|
||||
|
||||
getEmbeddingModel(): EmbeddingModel {
|
||||
const modelId = this.settings.openrouterEmbeddingModel || DEFAULT_EMBEDDING_MODEL;
|
||||
return this.client.textEmbeddingModel(modelId);
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return !!this.apiKey;
|
||||
}
|
||||
|
||||
async healthCheck(): Promise<boolean> {
|
||||
if (!this.apiKey) return false;
|
||||
try {
|
||||
const modelId = this.settings.openrouterModel || DEFAULT_MODEL;
|
||||
aiLogger.provider.init('openrouter', `healthCheck starting with model: ${modelId}`);
|
||||
// OpenAI-compatible servers all expose /models for listing; using it
|
||||
// as a lightweight check (no token spend, fast).
|
||||
const response = await this.httpFetch(`${this.baseUrl}/models`, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${this.apiKey}` },
|
||||
signal: AbortSignal.timeout(AI_TIMEOUTS.HEALTH_CHECK),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Health check failed: ${response.status}`);
|
||||
}
|
||||
aiLogger.provider.init('openrouter', 'healthCheck success');
|
||||
return true;
|
||||
} catch (e) {
|
||||
aiLogger.provider.error('openrouter', `healthCheck failed: ${(e as Error).message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lightweight model entry returned by an OpenAI-compatible `/models`
|
||||
* endpoint. Only the fields we actually consume are typed; the upstream
|
||||
* response is allowed to carry arbitrary extras (OpenRouter for example
|
||||
* returns pricing, context length, modality, etc).
|
||||
*/
|
||||
export interface OpenRouterModelInfo {
|
||||
id: string;
|
||||
name?: string;
|
||||
description?: string;
|
||||
context_length?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the list of models exposed by an OpenAI-compatible endpoint.
|
||||
* Used by the settings UI to populate a model picker.
|
||||
*
|
||||
* Goes through {@link getAIFetch} so that in Tauri the request hits the
|
||||
* Rust HTTP transport rather than the renderer, avoiding CORS preflight
|
||||
* and Android cleartext restrictions.
|
||||
*/
|
||||
export async function fetchOpenRouterModels(
|
||||
baseUrl: string,
|
||||
apiKey: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<OpenRouterModelInfo[]> {
|
||||
const trimmed = (baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '');
|
||||
const url = `${trimmed}/models`;
|
||||
const httpFetch = getAIFetch();
|
||||
const response = await httpFetch(url, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch models: ${response.status}`);
|
||||
}
|
||||
const json = (await response.json()) as { data?: OpenRouterModelInfo[] };
|
||||
return Array.isArray(json.data) ? json.data : [];
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { OllamaProvider } from './OllamaProvider';
|
||||
import { AIGatewayProvider } from './AIGatewayProvider';
|
||||
import { OpenRouterProvider } from './OpenRouterProvider';
|
||||
import type { AIProvider, AISettings } from '../types';
|
||||
|
||||
export { OllamaProvider, AIGatewayProvider };
|
||||
export { OllamaProvider, AIGatewayProvider, OpenRouterProvider };
|
||||
|
||||
export function getAIProvider(settings: AISettings): AIProvider {
|
||||
switch (settings.provider) {
|
||||
@@ -13,6 +14,11 @@ export function getAIProvider(settings: AISettings): AIProvider {
|
||||
throw new Error('API key required for AI Gateway');
|
||||
}
|
||||
return new AIGatewayProvider(settings);
|
||||
case 'openrouter':
|
||||
if (!settings.openrouterApiKey) {
|
||||
throw new Error('API key required for OpenRouter');
|
||||
}
|
||||
return new OpenRouterProvider(settings);
|
||||
default:
|
||||
throw new Error(`Unknown provider: ${settings.provider}`);
|
||||
}
|
||||
|
||||
@@ -134,7 +134,9 @@ export async function indexBook(
|
||||
const embeddingModelName =
|
||||
settings.provider === 'ollama'
|
||||
? settings.ollamaEmbeddingModel
|
||||
: settings.aiGatewayEmbeddingModel || 'text-embedding-3-small';
|
||||
: settings.provider === 'openrouter'
|
||||
? settings.openrouterEmbeddingModel || 'text-embedding-3-small'
|
||||
: settings.aiGatewayEmbeddingModel || 'text-embedding-3-small';
|
||||
aiLogger.embedding.start(embeddingModelName, allChunks.length);
|
||||
|
||||
const texts = allChunks.map((c) => c.text);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { LanguageModel, EmbeddingModel } from 'ai';
|
||||
|
||||
export type AIProviderName = 'ollama' | 'ai-gateway';
|
||||
export type AIProviderName = 'ollama' | 'ai-gateway' | 'openrouter';
|
||||
|
||||
export interface AIProvider {
|
||||
id: AIProviderName;
|
||||
@@ -27,6 +27,13 @@ export interface AISettings {
|
||||
aiGatewayCustomModel?: string;
|
||||
aiGatewayEmbeddingModel?: string;
|
||||
|
||||
// OpenAI-compatible provider (OpenRouter, Together, Groq, vLLM, ...).
|
||||
// Default base URL is OpenRouter's, but any compatible endpoint works.
|
||||
openrouterApiKey?: string;
|
||||
openrouterBaseUrl?: string;
|
||||
openrouterModel?: string;
|
||||
openrouterEmbeddingModel?: string;
|
||||
|
||||
spoilerProtection: boolean;
|
||||
maxContextChunks: number;
|
||||
indexingMode: 'on-demand' | 'background';
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
|
||||
/**
|
||||
* AI providers need to call arbitrary third-party HTTP endpoints
|
||||
* (OpenRouter, OpenAI-compatible proxies, self-hosted Ollama, internal
|
||||
* LLM gateways, etc.). In a browser/webview context the standard
|
||||
* `window.fetch` is subject to CORS preflight rules AND — on Android —
|
||||
* the platform's cleartext-traffic policy. Neither restriction makes
|
||||
* sense for an AI provider call: the user explicitly typed the endpoint
|
||||
* URL into our settings and authenticated to it themselves, so we want
|
||||
* the same semantics as `curl` or `reqwest` (raw HTTP, no Origin header,
|
||||
* no preflight, no cleartext block).
|
||||
*
|
||||
* `@tauri-apps/plugin-http` gives us exactly that: the request is sent
|
||||
* from the Rust side via reqwest, bypassing the renderer entirely. We
|
||||
* expose a single helper so every provider goes through the same
|
||||
* decision rather than each file re-implementing the platform check.
|
||||
*
|
||||
* For web builds (no Tauri runtime) we fall back to `window.fetch` and
|
||||
* rely on the upstream server to send the right `Access-Control-Allow-*`
|
||||
* headers — there is no alternative in that environment.
|
||||
*/
|
||||
export const getAIFetch = (): typeof fetch => {
|
||||
if (isTauriAppPlatform()) {
|
||||
// tauriFetch matches the standard `fetch` signature, so ai-sdk
|
||||
// providers can take it directly via their `fetch` option.
|
||||
return tauriFetch as unknown as typeof fetch;
|
||||
}
|
||||
return window.fetch.bind(window);
|
||||
};
|
||||
@@ -69,6 +69,7 @@ export const BACKUP_SETTINGS_CREDENTIAL_FIELDS = [
|
||||
'readwise.accessToken',
|
||||
'hardcover.accessToken',
|
||||
'aiSettings.aiGatewayApiKey',
|
||||
'aiSettings.openrouterApiKey',
|
||||
] as const;
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||
|
||||
@@ -582,6 +582,24 @@ const aiPanelItems = [
|
||||
keywords: ['gateway', 'model', 'openai', 'gpt', 'claude'],
|
||||
section: 'AI Gateway',
|
||||
},
|
||||
{
|
||||
id: 'settings.ai.openrouterApiKey',
|
||||
labelKey: _('OpenRouter API Key'),
|
||||
keywords: ['openrouter', 'api', 'key', 'token', 'secret'],
|
||||
section: 'OpenRouter',
|
||||
},
|
||||
{
|
||||
id: 'settings.ai.openrouterBaseUrl',
|
||||
labelKey: _('OpenRouter Base URL'),
|
||||
keywords: ['openrouter', 'base', 'url', 'endpoint', 'openai', 'compatible'],
|
||||
section: 'OpenRouter',
|
||||
},
|
||||
{
|
||||
id: 'settings.ai.openrouterModel',
|
||||
labelKey: _('OpenRouter Model'),
|
||||
keywords: ['openrouter', 'model', 'claude', 'gpt', 'llama', 'deepseek'],
|
||||
section: 'OpenRouter',
|
||||
},
|
||||
];
|
||||
|
||||
// custom panel items
|
||||
|
||||
Generated
+15
@@ -60,6 +60,9 @@ importers:
|
||||
|
||||
apps/readest-app:
|
||||
dependencies:
|
||||
'@ai-sdk/openai-compatible':
|
||||
specifier: ^2.0.48
|
||||
version: 2.0.48(zod@4.4.3)
|
||||
'@ai-sdk/react':
|
||||
specifier: ^3.0.49
|
||||
version: 3.0.179(react@19.2.5)(zod@4.4.3)
|
||||
@@ -673,6 +676,12 @@ packages:
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/openai-compatible@2.0.48':
|
||||
resolution: {integrity: sha512-z9MC6M4Oh/yUY/F/eszOtO8wc2nMz99XmZQKd2gWTtyIfe716xTfrKe3aYZKg20NZDtyjqPPKPSR+wqz7q1T7Q==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/provider-utils@3.0.25':
|
||||
resolution: {integrity: sha512-CvsRu+32Y8a167s+lrIBtsybvgTHp8j9y+6BeTvLeoW3Q+okw/b4CnNUFOLIXsRaKHQKAH+IHNJPYWywfpw0LA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -8929,6 +8938,12 @@ snapshots:
|
||||
'@vercel/oidc': 3.2.0
|
||||
zod: 4.4.3
|
||||
|
||||
'@ai-sdk/openai-compatible@2.0.48(zod@4.4.3)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.10
|
||||
'@ai-sdk/provider-utils': 4.0.27(patch_hash=168f9924f516aa1264359fc6b3520c6aaba761842888ccd504660fc30ee96d6f)(zod@4.4.3)
|
||||
zod: 4.4.3
|
||||
|
||||
'@ai-sdk/provider-utils@3.0.25(patch_hash=4611a356f4bb4e6f1196858719f8cb03947a41850257ee0527b0ec03f927dd6d)(zod@4.4.3)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 2.0.3
|
||||
|
||||
Reference in New Issue
Block a user