Skip to content
Closed
Show file tree
Hide file tree
Changes from 33 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
396f0c7
feat: add OpenRouter model selector and configurable fallback provider
luiggibcn Apr 25, 2026
c960f2a
fix: address PR review issues
luiggibcn Apr 25, 2026
5444660
test: add coverage for fallback provider and Ollama model loading
luiggibcn Apr 25, 2026
6e07556
feat: add OPENROUTER_LIST_MODELS IPC handler
luiggibcn Apr 25, 2026
9f2e3e0
docs: add before/after screenshots for PR #2014
luiggibcn Apr 25, 2026
043882f
fix: address pre-merge check warnings
luiggibcn Apr 25, 2026
cbd331a
test: add coverage for sanitizeReasoningFromMessages (#1988)
luiggibcn Apr 25, 2026
4340676
fix: use [redacted] placeholder for fully-stripped reasoning messages
luiggibcn Apr 25, 2026
2ec4f4f
docs: fix JSDoc placement in factory.ts provider detection
luiggibcn Apr 25, 2026
285313a
i18n: add modelCombobox keys and use them in ProviderModelCombobox
luiggibcn Apr 25, 2026
a70d784
fix: type fallbackProviderId as BuiltinProvider and handle save errors
luiggibcn Apr 25, 2026
d46a3d8
test: use SupportedProvider enum and isolate cache in detectProviderF…
luiggibcn Apr 25, 2026
6b637fa
refactor: move OpenRouter IPC handler to openrouter-handlers.ts
luiggibcn Apr 25, 2026
ab61637
feat: add refresh() to useOpenRouterModels hook
luiggibcn Apr 25, 2026
aad83d5
refactor: use cn() helper for class composition in ProviderModelCombobox
luiggibcn Apr 25, 2026
07e98e5
test: assert prefix is defined before checking MODEL_PROVIDER_MAP value
luiggibcn Apr 25, 2026
dba035e
fix: validate OpenRouter API response shape before mapping
luiggibcn Apr 25, 2026
e212026
fix: log fetch errors and guard empty providerSlug in useOpenRouterMo…
luiggibcn Apr 25, 2026
feea7c8
fix: warn on invalid or unreadable fallbackProviderId in factory
luiggibcn Apr 25, 2026
ecb8963
fix(a11y): associate fallback provider Select with its visible label
luiggibcn Apr 25, 2026
c22ba67
test: fix test description and remove redundant vi.stubGlobal
luiggibcn Apr 25, 2026
0f5f311
fix: add 10s AbortController timeout to OpenRouter model list fetch
luiggibcn Apr 25, 2026
33fc2cb
fix: Import hooks via the tsconfig path alias
luiggibcn Apr 25, 2026
870de13
refactor: gate useOpenRouterModels subscriber behind OpenRouterCombob…
luiggibcn Apr 25, 2026
ebcde13
refactor: extract OpenRouterModel type in useOpenRouterModels
luiggibcn Apr 25, 2026
44541ab
fix(i18n): replace hardcoded Loading… string in Combobox with transla…
luiggibcn Apr 25, 2026
978a8fb
Revert "fix(i18n): replace hardcoded Loading… string in Combobox with…
luiggibcn Apr 25, 2026
465c64e
fix(i18n): add loadingMessage prop to Combobox instead of using useTr…
luiggibcn Apr 25, 2026
a808ddd
fix(vitest): align @/ alias with tsconfig (src/renderer) to fix CI te…
luiggibcn Apr 25, 2026
9dc59ce
chore: remove redundant @renderer alias from vitest config
luiggibcn Apr 25, 2026
96da49b
fix(worker): prevent electron.app import crash in worker threads
luiggibcn Apr 27, 2026
2d73d3b
fix(settings): invalidate fallback provider cache when settings saved
luiggibcn Apr 27, 2026
d34b69b
fix: remove unused import
luiggibcn Apr 27, 2026
4e9708c
fix(i18n): make combobox custom value label translatable
luiggibcn Apr 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 2 additions & 2 deletions apps/desktop/src/main/agent/agent-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,9 +189,9 @@ export class AgentManager extends EventEmitter {
// Fallback: legacy Claude profile system
const profileManager = getClaudeProfileManager();
const activeProfile = profileManager?.getActiveProfile();
const configDir = activeProfile?.configDir;
const auth = await resolveAuth({ provider: 'anthropic', configDir });
const provider = detectProviderFromModel(requestedModel) ?? 'anthropic';
const configDir = provider === 'anthropic' ? activeProfile?.configDir : undefined;
const auth = await resolveAuth({ provider, configDir });
return { auth, provider, modelId: requestedModel, configDir };
}

Expand Down
20 changes: 19 additions & 1 deletion apps/desktop/src/main/ai/config/__tests__/types.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { buildThinkingProviderOptions } from '../types';
import { buildThinkingProviderOptions, MODEL_PROVIDER_MAP } from '../types';
import type { ThinkingLevel } from '../types';

describe('buildThinkingProviderOptions', () => {
Expand Down Expand Up @@ -63,3 +63,21 @@ describe('buildThinkingProviderOptions', () => {
}
});
});

describe('MODEL_PROVIDER_MAP — native prefix detection', () => {
it('maps claude- prefix to anthropic', () => {
const prefix = Object.keys(MODEL_PROVIDER_MAP).find((p) => 'claude-sonnet-4-6'.startsWith(p));
expect(prefix).toBeDefined();
expect(MODEL_PROVIDER_MAP[prefix!]).toBe('anthropic');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

it('has no slash-format entries (slash routing is handled by detectProviderFromModel)', () => {
const slashKeys = Object.keys(MODEL_PROVIDER_MAP).filter((k) => k.includes('/'));
expect(slashKeys).toHaveLength(0);
});

it('returns undefined for unknown model prefixes', () => {
const prefix = Object.keys(MODEL_PROVIDER_MAP).find((p) => 'unknown-model-xyz'.startsWith(p));
expect(prefix).toBeUndefined();
});
});
74 changes: 66 additions & 8 deletions apps/desktop/src/main/ai/providers/__tests__/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* Validates provider instantiation, detection, and error handling.
*/

import { describe, expect, it, vi } from 'vitest';
import { describe, expect, it, vi, beforeEach } from 'vitest';

// Mock all @ai-sdk/* providers
vi.mock('@ai-sdk/anthropic', () => ({
Expand Down Expand Up @@ -80,9 +80,18 @@ vi.mock('@openrouter/ai-sdk-provider', () => ({
}));

import { createAnthropic } from '@ai-sdk/anthropic';
import { createProvider, detectProviderFromModel, createProviderFromModelId } from '../factory';
import { createProvider, detectProviderFromModel, createProviderFromModelId, resetFallbackProviderCache, configureSettingsReader } from '../factory';
import { SupportedProvider } from '../types';

const mockReadSettingsFile = vi.fn<() => { fallbackProviderId?: string } | undefined>();

// Global reset — runs before every test in this file regardless of describe.
// Ensures cache and reader don't bleed between suites.
beforeEach(() => {
resetFallbackProviderCache();
configureSettingsReader(() => undefined);
});

describe('createProvider', () => {
const allProviders = Object.values(SupportedProvider);

Expand Down Expand Up @@ -138,34 +147,83 @@ describe('createProvider', () => {
});
});

describe('getConfiguredFallbackProvider (via detectProviderFromModel without explicit fallback)', () => {
beforeEach(() => {
mockReadSettingsFile.mockReset();
configureSettingsReader(mockReadSettingsFile);
});

it('returns configured fallback when fallbackProviderId is a valid provider', () => {
mockReadSettingsFile.mockReturnValue({ fallbackProviderId: SupportedProvider.OpenAI });
expect(detectProviderFromModel('any/model')).toBe(SupportedProvider.OpenAI);
});

it('defaults to openrouter when fallbackProviderId is invalid', () => {
mockReadSettingsFile.mockReturnValue({ fallbackProviderId: 'not-a-real-provider' });
expect(detectProviderFromModel('any/model')).toBe(SupportedProvider.OpenRouter);
});

it('defaults to openrouter when readSettingsFile throws', () => {
mockReadSettingsFile.mockImplementation(() => { throw new Error('disk error'); });
expect(detectProviderFromModel('any/model')).toBe(SupportedProvider.OpenRouter);
});

it('defaults to openrouter when no fallbackProviderId is set', () => {
mockReadSettingsFile.mockReturnValue({});
expect(detectProviderFromModel('any/model')).toBe(SupportedProvider.OpenRouter);
});
});

describe('detectProviderFromModel', () => {
beforeEach(() => {
mockReadSettingsFile.mockReset();
mockReadSettingsFile.mockReturnValue({});
configureSettingsReader(mockReadSettingsFile);
});

it('detects Anthropic from claude- prefix', () => {
expect(detectProviderFromModel('claude-sonnet-4-5-20250929')).toBe('anthropic');
expect(detectProviderFromModel('claude-sonnet-4-5-20250929')).toBe(SupportedProvider.Anthropic);
});

it('detects OpenAI from gpt- prefix', () => {
expect(detectProviderFromModel('gpt-4o')).toBe('openai');
expect(detectProviderFromModel('gpt-4o')).toBe(SupportedProvider.OpenAI);
});

it('detects OpenAI from o1- prefix', () => {
expect(detectProviderFromModel('o1-preview')).toBe('openai');
expect(detectProviderFromModel('o1-preview')).toBe(SupportedProvider.OpenAI);
});

it('detects Google from gemini- prefix', () => {
expect(detectProviderFromModel('gemini-pro')).toBe('google');
expect(detectProviderFromModel('gemini-pro')).toBe(SupportedProvider.Google);
});

it('detects Groq from llama- prefix', () => {
expect(detectProviderFromModel('llama-3.1-70b')).toBe('groq');
expect(detectProviderFromModel('llama-3.1-70b')).toBe(SupportedProvider.Groq);
});

it('detects XAI from grok- prefix', () => {
expect(detectProviderFromModel('grok-2')).toBe('xai');
expect(detectProviderFromModel('grok-2')).toBe(SupportedProvider.XAI);
});

it('returns undefined for unknown model', () => {
expect(detectProviderFromModel('unknown-model')).toBeUndefined();
});

it('routes slash-format models to the explicit fallback provider', () => {
expect(detectProviderFromModel('anthropic/claude-sonnet-4-5', SupportedProvider.OpenRouter)).toBe(SupportedProvider.OpenRouter);
expect(detectProviderFromModel('deepseek/deepseek-chat', SupportedProvider.OpenRouter)).toBe(SupportedProvider.OpenRouter);
expect(detectProviderFromModel('meta-llama/llama-3.3-70b', SupportedProvider.OpenRouter)).toBe(SupportedProvider.OpenRouter);
});

it('routes slash-format models to a custom fallback provider when specified', () => {
expect(detectProviderFromModel('any-provider/any-model', SupportedProvider.Anthropic)).toBe(SupportedProvider.Anthropic);
expect(detectProviderFromModel('some/model', SupportedProvider.OpenAI)).toBe(SupportedProvider.OpenAI);
});

it('native prefixes take priority over slash fallback', () => {
expect(detectProviderFromModel('claude-sonnet-4-6', SupportedProvider.OpenRouter)).toBe(SupportedProvider.Anthropic);
expect(detectProviderFromModel('gpt-4o', SupportedProvider.OpenRouter)).toBe(SupportedProvider.OpenAI);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

describe('createProviderFromModelId', () => {
Expand Down
74 changes: 69 additions & 5 deletions apps/desktop/src/main/ai/providers/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,14 @@ import { createOpenRouter } from '@openrouter/ai-sdk-provider';
import { createXai } from '@ai-sdk/xai';
import type { LanguageModel } from 'ai';

import { isMainThread } from 'node:worker_threads';
import { MODEL_PROVIDER_MAP } from '../config/types';
import { createOAuthProviderFetch } from './oauth-fetch';
import { type ProviderConfig, SupportedProvider } from './types';

/** Minimal settings shape consumed by factory — avoids importing AppSettings here. */
type FactorySettings = { fallbackProviderId?: string };

// =============================================================================
// OAuth Token Detection
// =============================================================================
Expand Down Expand Up @@ -230,19 +234,79 @@ export function createProvider(options: CreateProviderOptions): LanguageModel {
// Provider Detection
// =============================================================================

/** Cached fallback provider — read once from disk, reset on process restart. */
let _fallbackProviderCache: SupportedProvider | null = null;

/**
* Pluggable settings reader. Defaults to a no-op so worker threads (which have
* no access to electron.app) never pull in settings-utils at module load time.
* Each Worker Thread gets its own module instance (Node isolates module graphs
* per thread), so this default is safe — workers receive their provider already
* resolved via workerData and never hit the slash-format path.
* The main process wires this up via `configureSettingsReader(readSettingsFile)`
* at startup. Tests inject their own mock via the same function.
*/
let _settingsReader: () => FactorySettings | undefined = () => undefined;
let _readerConfigured = false;

export function configureSettingsReader(reader: () => FactorySettings | undefined): void {
_settingsReader = reader;
_readerConfigured = true;
}

/**
* Detects the provider for a model ID based on its prefix.
* Uses MODEL_PROVIDER_MAP for prefix-based matching.
* Reads the user-configured fallback provider from settings (result is cached).
* Called when a model ID contains a slash but no explicit provider is given.
* Defaults to OpenRouter if the setting is absent or invalid.
*/
function getConfiguredFallbackProvider(): SupportedProvider {
if (_fallbackProviderCache !== null) return _fallbackProviderCache;
if (!_readerConfigured && isMainThread) {
console.warn('[factory] configureSettingsReader() not called — fallbackProviderId from settings will be ignored');
}
try {
const settings = _settingsReader();
const id = settings?.fallbackProviderId;
if (id) {
if (Object.values(SupportedProvider).includes(id as SupportedProvider)) {
_fallbackProviderCache = id as SupportedProvider;
return _fallbackProviderCache;
}
console.warn(`[factory] fallbackProviderId "${id}" is not a known SupportedProvider — falling back to openrouter`);
}
} catch (err) {
console.warn('[factory] Failed to read fallbackProviderId from settings:', err);
}
_fallbackProviderCache = SupportedProvider.OpenRouter;
return _fallbackProviderCache;
}

/** Invalidates the fallback provider cache (call after settings are saved). */
export function resetFallbackProviderCache(): void {
_fallbackProviderCache = null;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* Detects the provider for a model ID based on its prefix or slash format.
* Uses MODEL_PROVIDER_MAP for prefix-based matching. For slash-format IDs
* (e.g. `"openrouter/gpt-4o"`), falls back to the user-configured fallback provider.
*
* @param modelId - Full model ID (e.g., 'claude-sonnet-4-5-20250929', 'gpt-4o')
* @returns The detected provider, or undefined if no match
* @param modelId - Full model ID (e.g., `'claude-sonnet-4-5'`, `'gpt-4o'`, `'openrouter/llama-3'`)
* @param fallbackProvider - Override for slash-format fallback (skips settings read)
* @returns The detected provider, or `undefined` if no match
*/
export function detectProviderFromModel(modelId: string): SupportedProvider | undefined {
export function detectProviderFromModel(
modelId: string,
fallbackProvider?: SupportedProvider,
): SupportedProvider | undefined {
for (const [prefix, provider] of Object.entries(MODEL_PROVIDER_MAP)) {
if (modelId.startsWith(prefix)) {
return provider;
}
}
if (modelId.includes('/')) {
return fallbackProvider ?? getConfiguredFallbackProvider();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return undefined;
}

Expand Down
Loading
Loading