diff --git a/apps/desktop/src/app/settings/model-settings.test.tsx b/apps/desktop/src/app/settings/model-settings.test.tsx index d349e5a1114..bed7c879e1c 100644 --- a/apps/desktop/src/app/settings/model-settings.test.tsx +++ b/apps/desktop/src/app/settings/model-settings.test.tsx @@ -316,6 +316,41 @@ describe('ModelSettings', () => { ) }) + it('carries the user-defined endpoint when an aux slot is set to a local main model', async () => { + getGlobalModelOptions.mockResolvedValueOnce({ + providers: [ + { + name: 'Ollama', + slug: 'local-ollama', + models: ['qwen3:latest'], + authenticated: true, + is_user_defined: true, + api_url: 'http://localhost:11434/v1' + } + ] + }) + getGlobalModelInfo.mockResolvedValueOnce({ provider: 'local-ollama', model: 'qwen3:latest' }) + getAuxiliaryModels.mockResolvedValueOnce({ + main: { provider: 'local-ollama', model: 'qwen3:latest' }, + tasks: [{ task: 'vision', provider: 'auto', model: '', base_url: '' }] + }) + + await renderModelSettings() + + const setToMainButtons = await screen.findAllByRole('button', { name: 'Set to main' }) + fireEvent.click(setToMainButtons[0]) + + await waitFor(() => + expect(setModelAssignment).toHaveBeenCalledWith({ + model: 'qwen3:latest', + provider: 'local-ollama', + scope: 'auxiliary', + task: 'vision', + base_url: 'http://localhost:11434/v1' + }) + ) + }) + it('warns when a main switch leaves auxiliary tasks pinned to another provider', async () => { setModelAssignment.mockResolvedValueOnce({ provider: 'openrouter', diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index c59e4fb3f64..fd19d165c4e 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -643,6 +643,20 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { } }, [onMainModelChanged, refresh, selectedModel, selectedProvider, selectedProviderRow]) + // Sibling of the applyMainModel endpoint passthrough (#65254): auxiliary + // assignments targeting a user-defined provider must carry that provider's + // endpoint too, or the backend pins the slot without a base_url and the + // aux resolver falls back to the (possibly different, possibly cleared) + // main endpoint. + const endpointForProvider = useCallback( + (provider: string) => { + const row = providers.find(entry => entry.slug === provider) + + return row?.api_url ? { base_url: row.api_url } : {} + }, + [providers] + ) + const setAuxiliaryToMain = useCallback( async (task: string) => { if (!mainModel) { @@ -653,7 +667,13 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { setError('') try { - await setModelAssignment({ model: mainModel.model, provider: mainModel.provider, scope: 'auxiliary', task }) + await setModelAssignment({ + model: mainModel.model, + provider: mainModel.provider, + scope: 'auxiliary', + task, + ...endpointForProvider(mainModel.provider) + }) await refresh() } catch (err) { setError(err instanceof Error ? err.message : String(err)) @@ -661,7 +681,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { setApplying(false) } }, - [mainModel, refresh] + [endpointForProvider, mainModel, refresh] ) const applyAuxiliaryDraft = useCallback( @@ -674,7 +694,13 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { setError('') try { - await setModelAssignment({ model: auxDraft.model, provider: auxDraft.provider, scope: 'auxiliary', task }) + await setModelAssignment({ + model: auxDraft.model, + provider: auxDraft.provider, + scope: 'auxiliary', + task, + ...endpointForProvider(auxDraft.provider) + }) setEditingAuxTask(null) await refresh() } catch (err) { @@ -683,7 +709,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { setApplying(false) } }, - [auxDraft, refresh] + [auxDraft, endpointForProvider, refresh] ) const beginAuxiliaryEdit = useCallback( diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 8c81b21ba06..abd26e6b603 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -1392,17 +1392,18 @@ class ModelAssignment(BaseModel): provider: str model: str task: str = "" - # Optional OpenAI-compatible endpoint URL. Only honored for custom/local - # providers on the main slot — lets the GUI configure a self-hosted endpoint - # (vLLM, llama.cpp, Ollama, …) that needs no API key. The runtime resolver - # reads model.base_url from config (it ignores OPENAI_BASE_URL), so this is - # the path that actually wires a local endpoint into resolution. + # Optional OpenAI-compatible endpoint URL. Honored for custom/local + # providers on the main slot AND on auxiliary slots — lets the GUI wire a + # self-hosted endpoint (vLLM, llama.cpp, Ollama, …) that needs no API key. + # The runtime resolvers read model.base_url / auxiliary..base_url + # from config (they ignore OPENAI_BASE_URL), so this is the path that + # actually wires a local endpoint into resolution. base_url: str = "" # Optional API key for a custom/local endpoint. Persisted to - # ``model.api_key`` (where the runtime resolver reads it) so a self-hosted - # endpoint that requires auth works from the GUI — mirrors the key the - # ``hermes model`` custom flow collects. Honored only on the main slot for - # custom/local providers. + # ``model.api_key`` (main slot) or ``auxiliary..api_key`` (aux + # slots) — where the runtime resolvers read it — so a self-hosted + # endpoint that requires auth works from the GUI. Mirrors the key the + # ``hermes model`` custom flow collects. api_key: str = "" confirm_expensive_model: bool = False profile: Optional[str] = None @@ -7244,7 +7245,19 @@ def _apply_model_assignment_sync( new_provider = provider.strip().lower() slot_cfg["provider"] = provider slot_cfg["model"] = model - if new_provider != prev_provider and new_provider != "custom": + if base_url: + # Sibling of the main-slot endpoint handling (#65254): an aux + # assignment for a custom/local endpoint must carry its own + # base_url, or the slot silently rebinds to whatever + # model.base_url happens to hold — and breaks entirely once the + # main slot switches away and clears it. The auxiliary resolver + # already reads auxiliary..base_url/api_key + # (_resolve_task_provider_model), so persisting them here is + # what actually wires the endpoint in. + slot_cfg["base_url"] = base_url + if api_key: + slot_cfg["api_key"] = api_key + elif new_provider != prev_provider and new_provider != "custom": slot_cfg.pop("base_url", None) clear_model_endpoint_credentials(slot_cfg) aux[slot] = slot_cfg diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 08c0bfc6f23..b3dfc7dfe46 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -4616,6 +4616,67 @@ class TestWebServerEndpoints: assert model_cfg["provider"] == "openrouter" assert model_cfg.get("base_url", "") == "" + def test_set_model_auxiliary_persists_base_url_and_api_key(self): + """Aux assignments for a custom/local endpoint must persist the slot's + own base_url/api_key (sibling of the main-slot fix, #65254). Without + them the aux resolver falls back to model.base_url — which breaks the + moment the main slot switches away and clears it.""" + from hermes_cli.config import load_config + + resp = self.client.post( + "/api/model/set", + json={ + "scope": "auxiliary", + "task": "vision", + "provider": "custom", + "model": "qwen3:latest", + "base_url": "http://localhost:11434/v1", + "api_key": "sk-local", + }, + ) + assert resp.status_code == 200 + assert resp.json()["ok"] is True + + slot = load_config()["auxiliary"]["vision"] + assert slot["provider"] == "custom" + assert slot["model"] == "qwen3:latest" + assert slot["base_url"] == "http://localhost:11434/v1" + assert slot["api_key"] == "sk-local" + + def test_set_model_auxiliary_provider_switch_still_clears_stale_endpoint(self): + """The existing stale-endpoint scrub on provider switch is preserved + when the new assignment carries no base_url.""" + from hermes_cli.config import load_config, save_config + + cfg = load_config() + cfg["auxiliary"] = { + "vision": { + "provider": "custom", + "model": "qwen3:latest", + "base_url": "http://localhost:11434/v1", + "api_key": "sk-local", + }, + } + save_config(cfg) + + resp = self.client.post( + "/api/model/set", + json={ + "scope": "auxiliary", + "task": "vision", + "provider": "openrouter", + "model": "google/gemini-2.5-flash", + }, + ) + assert resp.status_code == 200 + + slot = load_config()["auxiliary"]["vision"] + assert slot["provider"] == "openrouter" + # load_config deep-merges DEFAULT_CONFIG, which re-materializes the + # keys as empty strings — assert the stale endpoint VALUES are gone. + assert not slot.get("base_url") + assert not slot.get("api_key") + def test_custom_endpoints_list_includes_direct_custom_config(self): """A bare model.provider=custom config should show up in Desktop even before the user has materialized it under providers.