fix(dashboard): clear the model mirror when its custom endpoint is deleted

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.
This commit is contained in:
Frowtek 2026-07-20 04:21:09 +03:00 committed by Teknium
parent 6bedec4734
commit b520f507cc
2 changed files with 87 additions and 0 deletions

View file

@ -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

View file

@ -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.