fix: BYOK/custom provider models missing from WebUI model dropdown (#815)

Closes #815.

Three root causes fixed:

1. Provider aliases (z.ai/x.ai/google/grok/claude/aws-bedrock/dashscope/~25 more) not
   normalized before _PROVIDER_MODELS lookup — provider fell to empty else-branch while
   TUI worked (it normalizes at startup). Fixed via _resolve_provider_alias() + inlined
   _PROVIDER_ALIASES table in api/config.py.

2. Silent ImportError in original normalization: 'from hermes_cli.models import
   _PROVIDER_ALIASES' inside try/except silently failed without hermes-agent on sys.path
   (CI, minimal installs). The inlined table fixes this — normalization now works
   regardless of whether hermes-agent is installed.

3. /api/models/live?provider=custom now falls back to custom_providers entries from
   config.yaml when provider_model_ids() returns empty.

Also: provider_id on every group in /api/models response for deterministic JS optgroup
matching (no substring false positives). 17 targeted tests, 1725/1725 full suite.
This commit is contained in:
nesquena-hermes
2026-04-21 17:24:54 -07:00
committed by GitHub
parent a4d59b9e6c
commit 8f1f582caf
5 changed files with 496 additions and 3 deletions

View File

@@ -512,6 +512,73 @@ _PROVIDER_DISPLAY = {
"x-ai": "xAI",
}
# Provider alias → canonical slug. Users configure providers using the
# dotted/hyphenated form they see on the provider website (``z.ai``,
# ``x.ai``, ``google``) but the internal catalog (``_PROVIDER_MODELS``)
# uses slugs without punctuation (``zai``, ``xai``, ``gemini``). Without
# normalisation the provider lands in the ``else`` branch of the group
# builder and no models are returned — the bug behind #815.
#
# This table is authoritative for the WebUI. When ``hermes_cli.models``
# is importable we also merge its ``_PROVIDER_ALIASES`` on top so any
# new aliases added to the agent automatically apply. Keeping the local
# copy means the fix works even in environments where the agent tree is
# not on ``sys.path`` (CI, installs without hermes-agent cloned
# alongside the WebUI).
_PROVIDER_ALIASES = {
"glm": "zai",
"z-ai": "zai",
"z.ai": "zai",
"zhipu": "zai",
"github": "copilot",
"github-copilot": "copilot",
"github-models": "copilot",
"github-model": "copilot",
"google": "gemini",
"google-gemini": "gemini",
"google-ai-studio": "gemini",
"kimi": "kimi-coding",
"moonshot": "kimi-coding",
"claude": "anthropic",
"claude-code": "anthropic",
"deep-seek": "deepseek",
"opencode": "opencode-zen",
"grok": "xai",
"x-ai": "xai",
"x.ai": "xai",
"aws": "bedrock",
"aws-bedrock": "bedrock",
"amazon": "bedrock",
"amazon-bedrock": "bedrock",
"qwen": "alibaba",
"aliyun": "alibaba",
"dashscope": "alibaba",
"alibaba-cloud": "alibaba",
}
def _resolve_provider_alias(name: str) -> str:
"""Return the canonical provider slug for *name*.
Applies the WebUI's local alias table first, then merges any
additional aliases the agent provides (when hermes_cli is on
sys.path). Lookup is case-insensitive and whitespace-trimmed.
Unknown names pass through unchanged.
"""
if not name:
return name
raw = str(name).strip().lower()
# Prefer the agent's table when available so new aliases added there
# work automatically; otherwise fall through to our local copy.
try:
from hermes_cli.models import _PROVIDER_ALIASES as _agent_aliases
if raw in _agent_aliases:
return _agent_aliases[raw]
except Exception:
pass
return _PROVIDER_ALIASES.get(raw, name)
# Well-known models per provider (used to populate dropdown for direct API providers)
_PROVIDER_MODELS = {
"anthropic": [
@@ -974,6 +1041,13 @@ def get_available_models() -> dict:
if cfg_default:
default_model = cfg_default
# Normalize active_provider to its canonical key so it matches the
# _PROVIDER_MODELS lookup below (e.g. 'z.ai' -> 'zai', 'x.ai' -> 'xai',
# 'google' -> 'gemini'). Works even when hermes_cli is not on sys.path
# because the WebUI ships its own _PROVIDER_ALIASES table.
if active_provider:
active_provider = _resolve_provider_alias(active_provider)
# 2. Try to read auth store for active provider (if hermes is installed)
if not active_provider:
try:
@@ -1289,7 +1363,7 @@ def get_available_models() -> dict:
# 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})
groups.append({"provider": _nc_display, "provider_id": pid, "models": _nc_models})
continue
provider_name = _PROVIDER_DISPLAY.get(pid, pid.title())
if pid == "openrouter":
@@ -1297,6 +1371,7 @@ def get_available_models() -> dict:
groups.append(
{
"provider": "OpenRouter",
"provider_id": "openrouter",
"models": [
{"id": m["id"], "label": m["label"]}
for m in _FALLBACK_MODELS
@@ -1334,6 +1409,7 @@ def get_available_models() -> dict:
groups.append(
{
"provider": provider_name,
"provider_id": pid,
"models": models,
}
)
@@ -1346,6 +1422,7 @@ def get_available_models() -> dict:
groups.append(
{
"provider": provider_name,
"provider_id": pid,
"models": auto_detected_models,
}
)
@@ -1356,7 +1433,7 @@ def get_available_models() -> dict:
if default_model:
label = default_model.split("/")[-1] if "/" in default_model else default_model
groups.append(
{"provider": "Default", "models": [{"id": default_model, "label": label}]}
{"provider": "Default", "provider_id": "default", "models": [{"id": default_model, "label": label}]}
)
# Ensure the user's configured default_model always appears in the dropdown.
@@ -1396,6 +1473,7 @@ def get_available_models() -> dict:
groups.append(
{
"provider": "Default",
"provider_id": "default",
"models": [{"id": default_model, "label": label}],
}
)
@@ -1403,6 +1481,7 @@ def get_available_models() -> dict:
groups.append(
{
"provider": active_provider or "Default",
"provider_id": active_provider or "default",
"models": [{"id": default_model, "label": label}],
}
)

View File

@@ -2028,6 +2028,14 @@ def _handle_live_models(handler, parsed):
if not provider:
return j(handler, {"error": "no_provider", "models": []})
# Normalize provider alias so 'z.ai' -> 'zai', 'x.ai' -> 'xai', etc.
# The browser sends whatever active_provider the static endpoint returned;
# without normalization, provider_model_ids() misses the alias and returns [].
# Uses the WebUI-owned table (api/config._resolve_provider_alias) which
# works even when hermes_cli is not on sys.path.
from api.config import _resolve_provider_alias
provider = _resolve_provider_alias(provider)
# Delegate to the agent's live-fetch + fallback resolver.
# provider_model_ids() tries live endpoints first and falls back to
# the static _PROVIDER_MODELS list — it never raises.
@@ -2048,7 +2056,23 @@ def _handle_live_models(handler, parsed):
ids = [m["id"] for m in _pm.get(provider, [])]
if not ids:
return j(handler, {"provider": provider, "models": [], "count": 0})
# For 'custom' provider, provider_model_ids() returns [] because
# 'custom' isn't a real endpoint. Fall back to the custom_providers
# entries from config.yaml so the live-model enrichment step can
# add any models that weren't already in the static list.
if provider == "custom":
try:
_cp_entries = cfg.get("custom_providers", [])
if isinstance(_cp_entries, list):
ids = [
_cp.get("model", "")
for _cp in _cp_entries
if isinstance(_cp, dict) and _cp.get("model", "")
]
except Exception:
pass
if not ids:
return j(handler, {"provider": provider, "models": [], "count": 0})
# Normalise to {id, label} — provider_model_ids() returns plain string IDs
def _make_label(mid):