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:
@@ -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}],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user