From 57a78e9284a4dec10abb2ca51ae012149896b54d Mon Sep 17 00:00:00 2001 From: nesquena-hermes Date: Wed, 24 Jun 2026 20:17:29 +0000 Subject: [PATCH] fix(#4728): clear stale custom:* base_url + default wins without loaded session (gate findings) Two gate-found CORE/SILENT edges fixed on-branch: - api/config.py: switching the main default to a different custom:* provider kept the OLD provider's base_url, routing New Chat to the wrong endpoint. The base_url-clear on provider-change now covers custom:* too (was exempted). - static/sessions.js: newSession() skipped the configured default when S.session was null but the picker held stale state (e.g. after deleting the last session), inheriting the stale model. Default now wins over legacy picker regardless of loaded-session state (explicit empty-composer override still takes priority). - Updated 2 stale source-grep precedence assertions + widened a brittle 6000-char newSession() slice window to 7000 (assignment block grew; intent unchanged). - 2 new non-vacuous regression tests (custom base_url clear; precedence). Co-authored-by: Rod Boev --- api/config.py | 7 +++- static/sessions.js | 4 +- ...test_issue2518_active_provider_fallback.py | 2 +- tests/test_new_chat_default_model_frontend.py | 10 ++--- tests/test_provider_mismatch.py | 42 +++++++++++++++++++ 5 files changed, 57 insertions(+), 8 deletions(-) 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