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:
loveheaven
2026-05-26 00:32:07 +08:00
committed by GitHub
parent 7324b2b2bf
commit 98049282eb
14 changed files with 603 additions and 5 deletions
+1
View File
@@ -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);
+8 -1
View File
@@ -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
+15
View File
@@ -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