diff --git a/api/config.py b/api/config.py index edd01c08..2b48872b 100644 --- a/api/config.py +++ b/api/config.py @@ -3605,7 +3605,12 @@ def set_hermes_default_model(model_id: str, provider: str | None = None, advance elif persisted_provider != previous_provider: if persisted_provider == "openai": model_cfg["base_url"] = "https://api.openai.com/v1" - elif not persisted_provider.startswith("custom:"): + else: + # Provider changed and we have no resolved URL for the new one. + # Drop the previous provider's base_url so New Chat doesn't route + # to the old endpoint — this MUST also cover custom:* providers + # (a different custom provider has a different URL); leaving the + # stale base_url sent requests to the wrong host (#4728). model_cfg.pop("base_url", None) _apply_advanced_model_options(model_cfg, advanced) diff --git a/static/sessions.js b/static/sessions.js index 636ef98d..25fc5a33 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -886,7 +886,9 @@ async function newSession(flash, options={}){ if(!hasLoadedSession&&explicitModelOverride&&explicitModelOverride.model){ newModelState=explicitModelOverride; consumedExplicitModelOverride=true; - }else if(hasLoadedSession&&window._defaultModel){ + }else if(window._defaultModel){ + // Configured default wins over stale picker/persisted state even with no + // loaded session (deleting the last session left S.session null + stale picker) (#4728). newModelState={model:window._defaultModel,model_provider:null}; usingConfiguredDefault=true; }else if(modelSelForNew&&modelSelForNew.value&&typeof _modelStateForSelect==='function'){ diff --git a/tests/test_issue2518_active_provider_fallback.py b/tests/test_issue2518_active_provider_fallback.py index 2763484b..e61608ba 100644 --- a/tests/test_issue2518_active_provider_fallback.py +++ b/tests/test_issue2518_active_provider_fallback.py @@ -214,7 +214,7 @@ def _provider_assignment_in_new_session() -> str: src = _read("static/sessions.js") idx = src.find("async function newSession(flash, options={}){") assert idx != -1, "newSession() must be defined in static/sessions.js" - body = src[idx : idx + 6000] + body = src[idx : idx + 7000] guard_start = body.find("const _bareModel") assert guard_start != -1, ( "newSession() must declare a 'const _bareModel' guard for the " diff --git a/tests/test_new_chat_default_model_frontend.py b/tests/test_new_chat_default_model_frontend.py index 714e1f0d..6ce232d9 100644 --- a/tests/test_new_chat_default_model_frontend.py +++ b/tests/test_new_chat_default_model_frontend.py @@ -98,7 +98,7 @@ def test_new_session_posts_picker_model_before_server_default(): fn = _new_session_function() assert "reqBody.model=newModelState.model" in fn assert "explicitModelOverride" in fn - assert "hasLoadedSession&&window._defaultModel" in fn + assert "}else if(window._defaultModel){" in fn assert "modelSelForNew&&modelSelForNew.value&&typeof _modelStateForSelect==='function'" in fn provider_assignment = fn[fn.index("reqBody.model_provider="):].split(";", 1)[0] assert "newModelState.model_provider" in provider_assignment @@ -106,10 +106,10 @@ def test_new_session_posts_picker_model_before_server_default(): assert "window._activeProvider" in fn assert "S.session&&S.session.model_provider" in fn pos_override = fn.index("explicitModelOverride") - pos_default = fn.index("hasLoadedSession&&window._defaultModel") + pos_default = fn.index("}else if(window._defaultModel){") pos_legacy = fn.index("modelSelForNew&&modelSelForNew.value&&typeof _modelStateForSelect==='function'") assert pos_override < pos_default < pos_legacy, ( - "newSession() must prefer the empty-composer override first, then the configured default for loaded-session flows, then legacy picker state" + "newSession() must prefer the empty-composer override first, then the configured default, then legacy picker state" ) assert "_familyMismatch" in fn assert "_readPersistedModelState" in fn @@ -140,8 +140,8 @@ def test_new_chat_prefers_explicit_empty_composer_override_before_configured_def assert "usingConfiguredDefault" in fn assert "_clearEmptyComposerModelOverride" in fn assert "newModelState={model:window._defaultModel,model_provider:null};" in fn - assert fn.index("explicitModelOverride") < fn.index("hasLoadedSession&&window._defaultModel") < fn.index("_modelStateForSelect"), ( - "newSession() must prefer the empty-composer override first, then the configured default for loaded-session flows, then legacy picker state" + assert fn.index("explicitModelOverride") < fn.index("}else if(window._defaultModel){") < fn.index("_modelStateForSelect"), ( + "newSession() must prefer the empty-composer override first, then the configured default, then legacy picker state" ) diff --git a/tests/test_provider_mismatch.py b/tests/test_provider_mismatch.py index e146dfcb..0c02452d 100644 --- a/tests/test_provider_mismatch.py +++ b/tests/test_provider_mismatch.py @@ -380,6 +380,48 @@ def test_default_model_save_persists_codex_provider_for_qualified_model(tmp_path assert saved["model"].get("base_url") != "https://openrouter.ai/api/v1" +def test_default_model_save_clears_stale_custom_base_url_on_provider_change(tmp_path, monkeypatch): + """Switching the main default from one custom provider to another must drop + the previous provider's base_url so New Chat doesn't route to the old + endpoint (#4728). Previously custom:* was exempted from the base_url clear, + so the stale URL lingered.""" + import yaml + import api.config as config + + config_file = tmp_path / "config.yaml" + config_file.write_text( + "model:\n" + " provider: custom:old-local\n" + " default: old-model\n" + " base_url: http://old.local/v1\n", + encoding="utf-8", + ) + old_cfg = dict(config.cfg) + old_mtime = config._cfg_mtime + monkeypatch.setattr(config, "_get_config_path", lambda: config_file) + config.cfg["model"] = { + "provider": "custom:old-local", + "default": "old-model", + "base_url": "http://old.local/v1", + } + config._cfg_mtime = config_file.stat().st_mtime + try: + result = config.set_hermes_default_model("new-model", provider="custom:new-local") + saved = yaml.safe_load(config_file.read_text(encoding="utf-8")) + finally: + config.cfg.clear() + config.cfg.update(old_cfg) + config._cfg_mtime = old_mtime + config.invalidate_models_cache() + + assert result["ok"] is True + assert saved["model"]["provider"] == "custom:new-local" + # The stale base_url from the OLD custom provider must not survive. + assert saved["model"].get("base_url") != "http://old.local/v1", ( + f"stale custom base_url leaked: {saved['model'].get('base_url')!r}" + ) + + def test_active_codex_at_provider_session_model_preserved(monkeypatch): """@openai-codex:gpt-5.5 session selections must keep their provider hint.""" import api.routes as routes