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
23 changes: 18 additions & 5 deletions hermes_cli/web_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -7046,20 +7046,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.<name>
# 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
Expand Down
81 changes: 81 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,87 @@ 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_custom_endpoint_edit_preserves_hand_written_provider_fields(self):
"""The panel edits a few fields; it does not own the whole entry.

A ``providers.<name>`` 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.
Expand Down
Loading