fix(models): show named custom provider label in model dropdown — v0.50.58
fix(models): show named custom provider label in model dropdown — v0.50.58
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
# Hermes Web UI -- Changelog
|
# Hermes Web UI -- Changelog
|
||||||
|
|
||||||
|
## [v0.50.58] — 2026-04-16
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Custom provider name in model dropdown** — when a `custom_providers` entry in `config.yaml` has a `name` field (e.g. `Agent37`), the model picker now shows that name as the group header instead of the generic `Custom` label. Multiple named providers each get their own group. Unnamed entries still fall back to `Custom`. Brings the web UI into parity with the terminal's provider display. (Fixes #557)
|
||||||
|
|
||||||
## [v0.50.57] — 2026-04-15
|
## [v0.50.57] — 2026-04-15
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -952,18 +952,37 @@ def get_available_models() -> dict:
|
|||||||
# 3b. Include models from custom_providers config entries.
|
# 3b. Include models from custom_providers config entries.
|
||||||
# These are explicitly configured and should always appear even when the
|
# These are explicitly configured and should always appear even when the
|
||||||
# /v1/models endpoint is unreachable or returns a subset.
|
# /v1/models endpoint is unreachable or returns a subset.
|
||||||
|
#
|
||||||
|
# Each entry may carry a `name` field (e.g. "Agent37"). When present we
|
||||||
|
# use it as the dropdown section header instead of the generic "Custom"
|
||||||
|
# label. Internally we key these providers as "custom:<slug>" so that
|
||||||
|
# multiple named custom providers can coexist as separate groups.
|
||||||
_custom_providers_cfg = cfg.get("custom_providers", [])
|
_custom_providers_cfg = cfg.get("custom_providers", [])
|
||||||
|
# Maps "custom:<slug>" -> (display_name, [model_dicts])
|
||||||
|
_named_custom_groups: dict = {}
|
||||||
if isinstance(_custom_providers_cfg, list):
|
if isinstance(_custom_providers_cfg, list):
|
||||||
_seen_custom_ids = {m["id"] for m in auto_detected_models}
|
_seen_custom_ids = {m["id"] for m in auto_detected_models}
|
||||||
for _cp in _custom_providers_cfg:
|
for _cp in _custom_providers_cfg:
|
||||||
if not isinstance(_cp, dict):
|
if not isinstance(_cp, dict):
|
||||||
continue
|
continue
|
||||||
_cp_model = _cp.get("model", "")
|
_cp_model = _cp.get("model", "")
|
||||||
|
_cp_name = (_cp.get("name") or "").strip()
|
||||||
if _cp_model and _cp_model not in _seen_custom_ids:
|
if _cp_model and _cp_model not in _seen_custom_ids:
|
||||||
_cp_label = _cp_model.split("/")[-1] if "/" in _cp_model else _cp_model
|
_cp_label = _cp_model.split("/")[-1] if "/" in _cp_model else _cp_model
|
||||||
auto_detected_models.append({"id": _cp_model, "label": _cp_label})
|
|
||||||
_seen_custom_ids.add(_cp_model)
|
_seen_custom_ids.add(_cp_model)
|
||||||
detected_providers.add("custom")
|
if _cp_name:
|
||||||
|
# Named custom provider — own group keyed by slug
|
||||||
|
_slug = "custom:" + _cp_name.lower().replace(" ", "-")
|
||||||
|
if _slug not in _named_custom_groups:
|
||||||
|
_named_custom_groups[_slug] = (_cp_name, [])
|
||||||
|
detected_providers.add(_slug)
|
||||||
|
_named_custom_groups[_slug][1].append(
|
||||||
|
{"id": _cp_model, "label": _cp_label}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Unnamed — falls into the generic "Custom" bucket
|
||||||
|
auto_detected_models.append({"id": _cp_model, "label": _cp_label})
|
||||||
|
detected_providers.add("custom")
|
||||||
|
|
||||||
# If the user configured a real model.provider, the base_url belongs to
|
# If the user configured a real model.provider, the base_url belongs to
|
||||||
# THAT provider, not to a separate "Custom" group. hermes_cli reports
|
# THAT provider, not to a separate "Custom" group. hermes_cli reports
|
||||||
@@ -975,10 +994,34 @@ def get_available_models() -> dict:
|
|||||||
_has_custom_providers = isinstance(_custom_providers_cfg, list) and len(_custom_providers_cfg) > 0
|
_has_custom_providers = isinstance(_custom_providers_cfg, list) and len(_custom_providers_cfg) > 0
|
||||||
if active_provider and active_provider != "custom" and not _has_custom_providers:
|
if active_provider and active_provider != "custom" and not _has_custom_providers:
|
||||||
detected_providers.discard("custom")
|
detected_providers.discard("custom")
|
||||||
|
# Also drop named custom slugs when active provider is a real named one
|
||||||
|
# and there are no custom_providers entries to show.
|
||||||
|
for _slug in list(detected_providers):
|
||||||
|
if _slug.startswith("custom:") and not _has_custom_providers:
|
||||||
|
detected_providers.discard(_slug)
|
||||||
|
elif active_provider == "custom" and _has_custom_providers:
|
||||||
|
# When the active provider is 'custom' and all custom_providers entries
|
||||||
|
# are named (i.e. every entry produced a "custom:<slug>" key), the bare
|
||||||
|
# "custom" bucket is empty noise — discard it so the dropdown only shows
|
||||||
|
# the named groups. We keep "custom" if there are unnamed entries (they
|
||||||
|
# were added to auto_detected_models and will render under the generic
|
||||||
|
# "Custom" header via the else branch in the group builder).
|
||||||
|
_has_unnamed = any(
|
||||||
|
isinstance(_cp, dict) and not (_cp.get("name") or "").strip()
|
||||||
|
for _cp in _custom_providers_cfg
|
||||||
|
)
|
||||||
|
if not _has_unnamed:
|
||||||
|
detected_providers.discard("custom")
|
||||||
|
|
||||||
# 5. Build model groups
|
# 5. Build model groups
|
||||||
if detected_providers:
|
if detected_providers:
|
||||||
for pid in sorted(detected_providers):
|
for pid in sorted(detected_providers):
|
||||||
|
if pid.startswith("custom:") and pid in _named_custom_groups:
|
||||||
|
# Named custom provider — use the stored display name and its own model list
|
||||||
|
_nc_display, _nc_models = _named_custom_groups[pid]
|
||||||
|
if _nc_models:
|
||||||
|
groups.append({"provider": _nc_display, "models": _nc_models})
|
||||||
|
continue
|
||||||
provider_name = _PROVIDER_DISPLAY.get(pid, pid.title())
|
provider_name = _PROVIDER_DISPLAY.get(pid, pid.title())
|
||||||
if pid == "openrouter":
|
if pid == "openrouter":
|
||||||
# OpenRouter uses provider/model format -- show the fallback list
|
# OpenRouter uses provider/model format -- show the fallback list
|
||||||
|
|||||||
@@ -553,7 +553,7 @@
|
|||||||
<div class="settings-section-title">System</div>
|
<div class="settings-section-title">System</div>
|
||||||
<div class="settings-section-meta">Instance version and access controls.</div>
|
<div class="settings-section-meta">Instance version and access controls.</div>
|
||||||
</div>
|
</div>
|
||||||
<span class="settings-version-badge">v0.50.57</span>
|
<span class="settings-version-badge">v0.50.58</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
<div class="settings-field" style="border-top:1px solid var(--border);padding-top:12px;margin-top:8px">
|
||||||
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
<label for="settingsPassword" data-i18n="settings_label_password">Access Password</label>
|
||||||
|
|||||||
135
tests/test_custom_provider_display_name.py
Normal file
135
tests/test_custom_provider_display_name.py
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
"""
|
||||||
|
Tests for named custom provider display in the model dropdown (issue #557).
|
||||||
|
|
||||||
|
When a custom_providers entry carries a `name` field (e.g. "Agent37"), the
|
||||||
|
web UI model picker should show that name as the group header rather than the
|
||||||
|
generic "Custom" label.
|
||||||
|
"""
|
||||||
|
import api.config as config
|
||||||
|
|
||||||
|
|
||||||
|
def _models_with_cfg(model_cfg=None, custom_providers=None, active_provider=None):
|
||||||
|
"""Temporarily patch config.cfg, call get_available_models(), restore."""
|
||||||
|
old_cfg = dict(config.cfg)
|
||||||
|
config.cfg.clear()
|
||||||
|
if model_cfg:
|
||||||
|
config.cfg["model"] = model_cfg
|
||||||
|
if custom_providers is not None:
|
||||||
|
config.cfg["custom_providers"] = custom_providers
|
||||||
|
try:
|
||||||
|
return config.get_available_models()
|
||||||
|
finally:
|
||||||
|
config.cfg.clear()
|
||||||
|
config.cfg.update(old_cfg)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Named provider shows its name in the dropdown ─────────────────────────────
|
||||||
|
|
||||||
|
class TestNamedCustomProviderGroup:
|
||||||
|
|
||||||
|
def test_named_provider_uses_name_as_group_header(self):
|
||||||
|
"""A custom_provider entry with name='Agent37' should produce
|
||||||
|
a group whose 'provider' key is 'Agent37', not 'Custom'."""
|
||||||
|
result = _models_with_cfg(
|
||||||
|
model_cfg={"provider": "custom", "base_url": "https://agent37.example.com/v1"},
|
||||||
|
custom_providers=[
|
||||||
|
{"name": "Agent37", "model": "default", "base_url": "https://agent37.example.com/v1"}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
group_names = [g["provider"] for g in result.get("groups", [])]
|
||||||
|
assert "Agent37" in group_names, (
|
||||||
|
f"Expected 'Agent37' in group names, got {group_names}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_named_provider_does_not_produce_generic_custom(self):
|
||||||
|
"""When all custom_provider entries have names, no group called 'Custom'
|
||||||
|
should appear alongside them."""
|
||||||
|
result = _models_with_cfg(
|
||||||
|
model_cfg={"provider": "custom", "base_url": "https://agent37.example.com/v1"},
|
||||||
|
custom_providers=[
|
||||||
|
{"name": "Agent37", "model": "default", "base_url": "https://agent37.example.com/v1"}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
group_names = [g["provider"] for g in result.get("groups", [])]
|
||||||
|
assert "Custom" not in group_names, (
|
||||||
|
f"Expected no generic 'Custom' group when all entries are named, got {group_names}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_named_provider_model_appears_in_its_group(self):
|
||||||
|
"""The model ID from the named entry should be inside the named group."""
|
||||||
|
result = _models_with_cfg(
|
||||||
|
model_cfg={"provider": "custom"},
|
||||||
|
custom_providers=[
|
||||||
|
{"name": "Agent37", "model": "my-llm", "base_url": "https://agent37.example.com/v1"}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
agent37_group = next(
|
||||||
|
(g for g in result.get("groups", []) if g["provider"] == "Agent37"), None
|
||||||
|
)
|
||||||
|
assert agent37_group is not None, "Expected an 'Agent37' group"
|
||||||
|
model_ids = [m["id"] for m in agent37_group.get("models", [])]
|
||||||
|
assert "my-llm" in model_ids, (
|
||||||
|
f"Expected 'my-llm' in Agent37 group models, got {model_ids}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_multiple_named_providers_each_get_their_own_group(self):
|
||||||
|
"""Two named custom providers should produce two distinct groups."""
|
||||||
|
result = _models_with_cfg(
|
||||||
|
model_cfg={"provider": "custom"},
|
||||||
|
custom_providers=[
|
||||||
|
{"name": "Agent37", "model": "fast-model"},
|
||||||
|
{"name": "PrivateProxy", "model": "private-llm"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
group_names = [g["provider"] for g in result.get("groups", [])]
|
||||||
|
assert "Agent37" in group_names, f"Expected 'Agent37' group, got {group_names}"
|
||||||
|
assert "PrivateProxy" in group_names, f"Expected 'PrivateProxy' group, got {group_names}"
|
||||||
|
assert "Custom" not in group_names, f"No generic 'Custom' group expected, got {group_names}"
|
||||||
|
|
||||||
|
def test_multiple_models_in_same_named_provider(self):
|
||||||
|
"""Multiple entries with the same name should be collapsed into one group."""
|
||||||
|
result = _models_with_cfg(
|
||||||
|
model_cfg={"provider": "custom"},
|
||||||
|
custom_providers=[
|
||||||
|
{"name": "Agent37", "model": "model-a"},
|
||||||
|
{"name": "Agent37", "model": "model-b"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
agent37_groups = [g for g in result.get("groups", []) if g["provider"] == "Agent37"]
|
||||||
|
assert len(agent37_groups) == 1, (
|
||||||
|
f"Expected exactly one 'Agent37' group, got {len(agent37_groups)}"
|
||||||
|
)
|
||||||
|
model_ids = [m["id"] for m in agent37_groups[0].get("models", [])]
|
||||||
|
assert "model-a" in model_ids
|
||||||
|
assert "model-b" in model_ids
|
||||||
|
|
||||||
|
|
||||||
|
# ── Unnamed entry still falls back to 'Custom' ─────────────────────────────────
|
||||||
|
|
||||||
|
class TestUnnamedCustomProviderFallback:
|
||||||
|
|
||||||
|
def test_unnamed_entry_still_produces_custom_group(self):
|
||||||
|
"""A custom_provider entry without a name should still show as 'Custom'."""
|
||||||
|
result = _models_with_cfg(
|
||||||
|
model_cfg={"provider": "custom"},
|
||||||
|
custom_providers=[
|
||||||
|
{"model": "unnamed-model"}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
group_names = [g["provider"] for g in result.get("groups", [])]
|
||||||
|
assert "Custom" in group_names, (
|
||||||
|
f"Expected generic 'Custom' group for unnamed entry, got {group_names}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_mixed_named_and_unnamed_entries(self):
|
||||||
|
"""Named and unnamed entries should appear in their respective groups."""
|
||||||
|
result = _models_with_cfg(
|
||||||
|
model_cfg={"provider": "custom"},
|
||||||
|
custom_providers=[
|
||||||
|
{"name": "Agent37", "model": "named-model"},
|
||||||
|
{"model": "unnamed-model"},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
group_names = [g["provider"] for g in result.get("groups", [])]
|
||||||
|
assert "Agent37" in group_names, f"Expected 'Agent37' group, got {group_names}"
|
||||||
|
assert "Custom" in group_names, f"Expected 'Custom' group for unnamed entry, got {group_names}"
|
||||||
Reference in New Issue
Block a user