diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e38c0d..dac725c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # 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 ### Added diff --git a/api/config.py b/api/config.py index e7bbd17..520dffc 100644 --- a/api/config.py +++ b/api/config.py @@ -952,18 +952,37 @@ def get_available_models() -> dict: # 3b. Include models from custom_providers config entries. # These are explicitly configured and should always appear even when the # /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:" so that + # multiple named custom providers can coexist as separate groups. _custom_providers_cfg = cfg.get("custom_providers", []) + # Maps "custom:" -> (display_name, [model_dicts]) + _named_custom_groups: dict = {} if isinstance(_custom_providers_cfg, list): _seen_custom_ids = {m["id"] for m in auto_detected_models} for _cp in _custom_providers_cfg: if not isinstance(_cp, dict): continue _cp_model = _cp.get("model", "") + _cp_name = (_cp.get("name") or "").strip() if _cp_model and _cp_model not in _seen_custom_ids: _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) - 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 # 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 if active_provider and active_provider != "custom" and not _has_custom_providers: 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:" 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 if 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()) if pid == "openrouter": # OpenRouter uses provider/model format -- show the fallback list diff --git a/static/index.html b/static/index.html index d0b90fe..4236e08 100644 --- a/static/index.html +++ b/static/index.html @@ -553,7 +553,7 @@
System
- v0.50.57 + v0.50.58
diff --git a/tests/test_custom_provider_display_name.py b/tests/test_custom_provider_display_name.py new file mode 100644 index 0000000..151a21a --- /dev/null +++ b/tests/test_custom_provider_display_name.py @@ -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}"