Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1344,6 +1344,41 @@ def _canonical_api_mode(api_mode: str) -> str:
return _API_MODE_ALIASES.get(cleaned.lower(), cleaned)


def _declared_models_to_mapping(value: Any) -> Dict[str, Any]:
"""Coerce a custom-provider model declaration into ``{id: metadata}``.

Accepts the two shapes seen in real configs: a mapping (already
canonical) or a list of plain ids / ``{id: ...}`` rows written by hand
or by older Hermes versions. Anything else yields an empty mapping so
the caller leaves ``models`` unset rather than writing a bad shape.
"""
if isinstance(value, dict) and value:
# Shallow-copy: the caller's `entry` may alias a cached config
# sub-dict, and the normalized entry escapes into long-lived runtime
# state (agent._custom_providers) — don't share the cached mapping.
return dict(value)

if not isinstance(value, list):
return {}

mapping: Dict[str, Any] = {}
for item in value:
if isinstance(item, str) and item.strip():
mapping[item.strip()] = {}
continue
if not isinstance(item, dict):
continue
model_id = item.get("id")
if not isinstance(model_id, str) or not model_id.strip():
model_id = item.get("name")
if not isinstance(model_id, str) or not model_id.strip():
continue
mapping[model_id.strip()] = {
k: v for k, v in item.items() if k not in {"id", "name"}
}
return mapping


def _normalize_custom_provider_entry(
entry: Any,
*,
Expand Down Expand Up @@ -1388,6 +1423,8 @@ def _normalize_custom_provider_entry(
"key_cmd",
"api_mode", "transport", "model", "default_model", "models",
"models_discovered",
# Hand-written alias for ``models`` (gh-52266); merged in below.
"available_models",
"context_length", "rate_limit_delay",
"request_timeout_seconds", "stale_timeout_seconds",
"discover_models", "extra_body", "extra_headers",
Expand Down Expand Up @@ -1517,6 +1554,18 @@ def _normalize_custom_provider_entry(
if normalized_models:
normalized["models"] = normalized_models

# ``models`` is the canonical declaration key, but hand-written configs
# also use ``available_models`` (gh-52266). That alias previously fell
# through to the unknown-key warning and was dropped, leaving the
# provider with (0) models: the picker showed nothing and every
# ``/model <id>`` for that provider failed to resolve. Merge it in,
# with ``models`` metadata winning for ids declared in both.
available = _declared_models_to_mapping(entry.get("available_models"))
if available:
merged_models = dict(available)
merged_models.update(normalized.get("models") or {})
normalized["models"] = merged_models

if models_discovered:
normalized["models_discovered"] = True

Expand Down
87 changes: 87 additions & 0 deletions tests/hermes_cli/test_custom_provider_available_models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
"""Regression (gh-52266): ``available_models`` must declare provider models.

Hand-written custom-provider configs use ``available_models`` where Hermes'
own writer emits ``models``. The normalizer previously knew only ``models``,
so the alias hit the unknown-key warning and was dropped — leaving the entry
with no ``models`` at all. Downstream that means the picker lists the
provider with (0) models and every ``/model <id>`` against it fails to
resolve, because the routing paths all read the normalized ``models`` key.
"""

import copy

from hermes_cli.config import _normalize_custom_provider_entry

# The provider config from the issue report, verbatim in shape.
_ISSUE_ENTRY = {
"name": "tokenplan",
"api_mode": "chat_completions",
"base_url": "https://token-plan.example/compatible-mode/v1",
"model": "qwen3.7-plus",
"available_models": [
"qwen3.7-plus",
"qwen3.7-max",
"deepseek-v4-pro",
"deepseek-v4-flash",
"kimi-k2.7-code",
"glm-5.2",
],
}


def test_available_models_list_declares_models():
out = _normalize_custom_provider_entry(_ISSUE_ENTRY, provider_key="tokenplan")
assert out is not None
assert sorted(out["models"]) == sorted(_ISSUE_ENTRY["available_models"])


def test_available_models_accepts_id_rows():
entry = {
"name": "p",
"base_url": "https://x.example/v1",
"available_models": [{"id": "m-1", "context_length": 8}, {"name": "m-2"}],
}
out = _normalize_custom_provider_entry(entry, provider_key="p")
assert out is not None
assert out["models"] == {"m-1": {"context_length": 8}, "m-2": {}}


def test_models_metadata_wins_over_available_models():
"""Both keys may appear; the canonical ``models`` owns shared ids."""
entry = {
"name": "p",
"base_url": "https://x.example/v1",
"available_models": ["shared", "alias-only"],
"models": {"shared": {"context_length": 128}, "models-only": {}},
}
out = _normalize_custom_provider_entry(entry, provider_key="p")
assert out is not None
assert sorted(out["models"]) == ["alias-only", "models-only", "shared"]
assert out["models"]["shared"] == {"context_length": 128}


def test_models_only_entry_is_unchanged():
entry = {
"name": "p",
"base_url": "https://x.example/v1",
"models": {"only": {"context_length": 4}},
}
out = _normalize_custom_provider_entry(entry, provider_key="p")
assert out is not None
assert out["models"] == {"only": {"context_length": 4}}


def test_no_declaration_leaves_models_unset():
entry = {"name": "p", "base_url": "https://x.example/v1"}
out = _normalize_custom_provider_entry(entry, provider_key="p")
assert out is not None
assert "models" not in out


def test_available_models_does_not_mutate_input():
"""Entries alias the shared read-only config cache — see
test_custom_provider_normalize_no_mutate.py."""
entry = copy.deepcopy(_ISSUE_ENTRY)
snapshot = copy.deepcopy(entry)
_normalize_custom_provider_entry(entry, provider_key="tokenplan")
assert entry == snapshot
Loading