diff --git a/apps/desktop/src/app/settings/model-settings.test.tsx b/apps/desktop/src/app/settings/model-settings.test.tsx index bb9f1f9ec1cf..d349e5a1114f 100644 --- a/apps/desktop/src/app/settings/model-settings.test.tsx +++ b/apps/desktop/src/app/settings/model-settings.test.tsx @@ -212,6 +212,53 @@ describe('ModelSettings', () => { expect(screen.queryByRole('button', { name: 'Set up provider' })).toBeNull() }) + it('preserves a user-defined provider endpoint when applying the main model', async () => { + getGlobalModelOptions.mockResolvedValueOnce({ + providers: [ + { + name: 'Nous', + slug: 'nous', + models: ['hermes-4'], + authenticated: true + }, + { + name: 'Ollama', + slug: 'local-ollama', + models: ['qwen3:latest'], + authenticated: true, + is_user_defined: true, + api_url: 'http://localhost:11434/v1' + } + ] + }) + setModelAssignment.mockResolvedValueOnce({ + provider: 'local-ollama', + model: 'qwen3:latest', + gateway_tools: [] + }) + + await renderModelSettings() + + const providerSelect = (await screen.findAllByRole('combobox'))[0] + fireEvent.click(providerSelect) + fireEvent.click(await screen.findByRole('option', { name: 'Ollama' })) + + const modelSelect = (await screen.findAllByRole('combobox'))[1] + fireEvent.click(modelSelect) + fireEvent.click(await screen.findByRole('option', { name: 'qwen3:latest' })) + + fireEvent.click(await screen.findByRole('button', { name: 'Apply' })) + + await waitFor(() => + expect(setModelAssignment).toHaveBeenCalledWith({ + model: 'qwen3:latest', + provider: 'local-ollama', + scope: 'main', + base_url: 'http://localhost:11434/v1' + }) + ) + }) + it('writes the profile default speed (service_tier) when the fast switch is toggled', async () => { await renderModelSettings() await waitFor(() => expect(getHermesConfigRecord).toHaveBeenCalled()) diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index 3b1b8997ab06..c59e4fb3f641 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -619,7 +619,12 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { setError('') try { - const result = await setModelAssignment({ model: selectedModel, provider: selectedProvider, scope: 'main' }) + const result = await setModelAssignment({ + model: selectedModel, + provider: selectedProvider, + scope: 'main', + ...(selectedProviderRow?.api_url ? { base_url: selectedProviderRow.api_url } : {}) + }) if (profileEpoch.current !== epoch) { return @@ -636,7 +641,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { } finally { setApplying(false) } - }, [onMainModelChanged, refresh, selectedModel, selectedProvider]) + }, [onMainModelChanged, refresh, selectedModel, selectedProvider, selectedProviderRow]) const setAuxiliaryToMain = useCallback( async (task: string) => { diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index bba14f145d72..7f4da0706a8b 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -386,6 +386,10 @@ export interface ModelOptionProvider { key_env?: string /** True for providers defined via the user's `providers:` config block. */ is_user_defined?: boolean + /** OpenAI-compatible endpoint for a user-defined provider. The backend + * exposes this as `api_url`; model assignments send it back as `base_url` + * so switching providers does not discard the selected local endpoint. */ + api_url?: string /** Per-model pricing keyed by model id (present when the picker requested * pricing and the provider supports live pricing). */ pricing?: Record