diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 10510251ff3b..c9afe3872079 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -7060,20 +7060,33 @@ def _write_custom_endpoint(cfg: Dict[str, Any], body: CustomEndpointUpdate) -> T if not isinstance(existing, dict): existing = {} - entry: Dict[str, Any] = { + # Merge onto the existing entry rather than replacing it. A providers. + # block is not owned by this panel: it can carry hand-written keys the + # dashboard has no field for — ``api_mode``, ``key_env``/``api_key_env``, + # ``extra_headers`` (which may themselves carry credentials), + # ``request_overrides`` — and rebuilding from scratch silently dropped every + # one of them on an unrelated edit, leaving a provider that no longer + # authenticates or speaks the right protocol. + entry: Dict[str, Any] = dict(existing) + entry.update({ "name": name, "base_url": base_url, "model": model, - "models": {model: {}}, "discover_models": bool(body.discover_models), - } + }) + # Same for the model map: the panel names one default model, it does not + # enumerate the provider's catalogue. Keep the other models (and their + # context lengths) and just ensure this one is present. + existing_models = entry.get("models") + models_map: Dict[str, Any] = dict(existing_models) if isinstance(existing_models, dict) else {} + current_model_entry = models_map.get(model) + models_map[model] = dict(current_model_entry) if isinstance(current_model_entry, dict) else {} + entry["models"] = models_map if body.context_length and body.context_length > 0: entry["context_length"] = int(body.context_length) entry["models"][model]["context_length"] = int(body.context_length) if body.api_key is not None and body.api_key.strip(): entry["api_key"] = body.api_key.strip() - elif isinstance(existing.get("api_key"), str) and existing.get("api_key"): - entry["api_key"] = existing["api_key"] providers[endpoint_id] = entry cfg["providers"] = providers diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index ac4a7e04ca41..b5e323a2d669 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -4308,6 +4308,87 @@ class TestWebServerEndpoints: # The sibling base_url fill is unaffected. assert model_cfg["base_url"] == "https://llm.acme.corp/v1" + def test_custom_endpoint_edit_preserves_hand_written_provider_fields(self): + """The panel edits a few fields; it does not own the whole entry. + + A ``providers.`` block can carry keys the dashboard has no field + for — ``api_mode``, ``key_env``, ``extra_headers`` (which may carry + credentials), ``request_overrides``. Rebuilding the entry from scratch + on an unrelated edit silently dropped all of them, leaving a provider + that no longer authenticates or speaks the right protocol. + """ + from hermes_cli.config import load_config, save_config + + cfg = load_config() + cfg["providers"] = { + "acme": { + "name": "Acme", + "base_url": "https://llm.acme.corp/v1", + "model": "acme/model-1", + "api_mode": "responses", + "key_env": "ACME_API_KEY", + "extra_headers": {"X-Org-Id": "org_123"}, + "request_overrides": {"reasoning_effort": "high"}, + "models": { + "acme/model-1": {"context_length": 200000}, + "acme/model-2": {"context_length": 400000}, + }, + } + } + save_config(cfg) + + # The user opens the panel and only switches the default model. + resp = self.client.post( + "/api/providers/custom-endpoints", + json={ + "id": "acme", + "name": "Acme", + "base_url": "https://llm.acme.corp/v1", + "model": "acme/model-2", + }, + ) + assert resp.status_code == 200 + + entry = load_config()["providers"]["acme"] + assert entry["api_mode"] == "responses" + assert entry["key_env"] == "ACME_API_KEY" + assert entry["extra_headers"] == {"X-Org-Id": "org_123"} + assert entry["request_overrides"] == {"reasoning_effort": "high"} + # The edit still applies. + assert entry["model"] == "acme/model-2" + + def test_custom_endpoint_edit_keeps_the_other_models(self): + """The panel names one default model; it doesn't enumerate the catalogue.""" + from hermes_cli.config import load_config, save_config + + cfg = load_config() + cfg["providers"] = { + "acme": { + "name": "Acme", + "base_url": "https://llm.acme.corp/v1", + "model": "acme/model-1", + "models": { + "acme/model-1": {"context_length": 200000}, + "acme/model-2": {"context_length": 400000}, + }, + } + } + save_config(cfg) + + self.client.post( + "/api/providers/custom-endpoints", + json={ + "id": "acme", + "name": "Acme", + "base_url": "https://llm.acme.corp/v1", + "model": "acme/model-2", + }, + ) + + models = load_config()["providers"]["acme"]["models"] + assert sorted(models) == ["acme/model-1", "acme/model-2"] + assert models["acme/model-1"]["context_length"] == 200000 + 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.