mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
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:
parent
1705a44074
commit
6bedec4734
2 changed files with 99 additions and 5 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue