Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -7023,6 +7023,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()
Expand Down Expand Up @@ -7143,6 +7166,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
Expand Down
63 changes: 63 additions & 0 deletions tests/hermes_cli/test_web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -4252,6 +4252,69 @@ def test_custom_endpoint_upsert_persists_provider_and_sets_default(self):
assert cfg["model"]["default"] == "gpt-5.4"
assert cfg["model"]["base_url"] == "http://127.0.0.1:8081/v1"

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.
Expand Down
Loading