fix(dashboard): don't wipe hand-written provider fields on custom-endpoint edit

_write_custom_endpoint builds a fresh entry dict from the request body and
assigns it over providers[endpoint_id], carrying nothing forward but api_key.

A providers.<name> block is not owned by that panel. It can carry keys the
dashboard has no field for, all of them load-bearing:

  api_mode          the protocol the endpoint speaks
  key_env           where the credential comes from
  extra_headers     per-provider HTTP headers (may carry credentials)
  request_overrides extra body params

and a models map with more than the one model the panel names.

So an edit that only changes the default model destroys the rest:

    BEFORE  api_mode, base_url, extra_headers, key_env, model, models,
            name, request_overrides
    AFTER   base_url, discover_models, model, models, name

    FIELDS DESTROYED: ['api_mode', 'extra_headers', 'key_env',
                       'request_overrides']

The provider is left with no credential wiring (key_env gone, no api_key),
talking the wrong protocol, missing its proxy auth header — from a UI action
that said nothing about any of that. The models map also collapses to the one
named model, dropping the others and their context_length.

Merge onto the existing entry instead of replacing it, and merge the models
map rather than overwriting it. Managed fields still win, so the edit itself
still applies; a brand-new endpoint is unchanged. api_key keeps its previous
semantics — a supplied key overwrites, an omitted one leaves the stored key
in place (now via the merge rather than an explicit carry-forward branch).
This commit is contained in:
Frowtek 2026-07-20 04:40:51 +03:00 committed by Teknium
parent 1705a44074
commit 6bedec4734
2 changed files with 99 additions and 5 deletions

View file

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

View file

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