From b520f507cc7d2f12a4558164ad3356e12d12a4bf Mon Sep 17 00:00:00 2001 From: Frowtek Date: Mon, 20 Jul 2026 04:21:09 +0300 Subject: [PATCH] fix(dashboard): clear the model mirror when its custom endpoint is deleted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit activate_custom_endpoint copies the endpoint's base_url and api_key onto cfg["model"]. delete_custom_endpoint pops the providers entry and saves — it never touches that mirror. So deleting the endpoint the agent is currently using leaves both behind: DELETE /api/providers/custom-endpoints/acme -> 200 providers entry gone : True model.api_key : sk-CUSTOM-ENDPOINT-SECRET model.base_url : https://llm.acme.corp/v1 Two consequences, both silent: * The agent keeps authenticating to the deleted host with the deleted key. model.api_key outranks the environment at client construction, so this also shadows whatever the operator configures next — the persistent-401 shape credential_lifecycle.py documents as #62269. * A credential the operator just removed through the dashboard stays sitting in config.yaml. Scrub the main-slot mirror on delete, but only when it actually names the deleted provider — an endpoint deleted while a different one is active must leave that active assignment untouched. Both directions are pinned by tests. --- hermes_cli/web_server.py | 24 +++++++++++ tests/hermes_cli/test_web_server.py | 63 +++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index c9afe3872079..62df7d1f21b9 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -7037,6 +7037,29 @@ def _custom_endpoint_response(cfg: Dict[str, Any]) -> Dict[str, Any]: } +def _detach_main_model_from_provider(cfg: Dict[str, Any], provider_key: str) -> None: + """Drop the main-slot mirror of a provider that no longer exists. + + ``activate_custom_endpoint`` copies the endpoint's ``base_url`` and + ``api_key`` onto ``model``. That mirror outranks the environment at client + construction (#62269), so deleting the endpoint without clearing it leaves + the agent still authenticating to the deleted host with the deleted key — + and leaves that key sitting in config.yaml after the operator believes the + dashboard removed it. + + Only touches ``model`` when it actually names the deleted provider, so an + endpoint deleted while a *different* provider is active is left alone. + """ + model_cfg = cfg.get("model") + if not isinstance(model_cfg, dict): + return + if str(model_cfg.get("provider") or "").strip().lower() != provider_key: + return + for field in ("provider", "base_url", "api_key"): + model_cfg.pop(field, None) + cfg["model"] = model_cfg + + def _write_custom_endpoint(cfg: Dict[str, Any], body: CustomEndpointUpdate) -> Tuple[str, Dict[str, Any]]: endpoint_id = _custom_endpoint_id(body.id or body.name) name = (body.name or "").strip() @@ -7170,6 +7193,7 @@ def delete_custom_endpoint(endpoint_id: str): raise HTTPException(status_code=404, detail="custom endpoint not found") providers.pop(provider_key, None) cfg["providers"] = providers + _detach_main_model_from_provider(cfg, provider_key) save_config(cfg) response = _custom_endpoint_response(cfg) response["ok"] = True diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index b5e323a2d669..1ac14feee3a1 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -4389,6 +4389,69 @@ class TestWebServerEndpoints: assert sorted(models) == ["acme/model-1", "acme/model-2"] assert models["acme/model-1"]["context_length"] == 200000 + def test_deleting_the_active_custom_endpoint_clears_its_model_mirror(self): + """Deleting an endpoint must not leave its key running the agent. + + ``activate`` copies the endpoint's base_url + api_key onto ``model``, + and ``model.api_key`` outranks the environment at client construction + (#62269). Without clearing that mirror the agent keeps authenticating + to the deleted host with the deleted key, and the key the operator + just removed through the dashboard stays in config.yaml. + """ + from hermes_cli.config import load_config + + self.client.post( + "/api/providers/custom-endpoints", + json={ + "id": "acme", + "name": "Acme", + "base_url": "https://llm.acme.corp/v1", + "model": "acme/model-1", + "api_key": "sk-acme-secret", + }, + ) + assert self.client.post( + "/api/providers/custom-endpoints/acme/activate", json={} + ).status_code == 200 + + cfg = load_config() + assert cfg["model"]["api_key"] == "sk-acme-secret" + + assert self.client.request( + "DELETE", "/api/providers/custom-endpoints/acme" + ).status_code == 200 + + cfg = load_config() + assert "acme" not in (cfg.get("providers") or {}) + model_cfg = cfg.get("model") or {} + assert not model_cfg.get("api_key"), "deleted endpoint's key still in config.yaml" + assert not model_cfg.get("base_url"), "deleted endpoint's host still routed to" + assert not model_cfg.get("provider") + + def test_deleting_an_inactive_custom_endpoint_leaves_the_active_one_alone(self): + """Only the mirror of the DELETED provider is scrubbed.""" + from hermes_cli.config import load_config + + for name, key in (("acme", "sk-acme"), ("other", "sk-other")): + self.client.post( + "/api/providers/custom-endpoints", + json={ + "id": name, + "name": name, + "base_url": f"https://llm.{name}.corp/v1", + "model": f"{name}/m", + "api_key": key, + }, + ) + + self.client.post("/api/providers/custom-endpoints/other/activate", json={}) + self.client.request("DELETE", "/api/providers/custom-endpoints/acme") + + model_cfg = load_config().get("model") or {} + assert model_cfg.get("provider") == "other" + assert model_cfg.get("api_key") == "sk-other" + assert model_cfg.get("base_url") == "https://llm.other.corp/v1" + def test_set_model_main_preserves_base_url_for_named_custom_provider(self): """Selecting a named custom endpoint from the Desktop model picker should keep its endpoint URL attached to model config.