diff --git a/hermes_cli/models.py b/hermes_cli/models.py index afab5bac32de..1d6fa043e8ba 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -72,6 +72,7 @@ ("nvidia/nemotron-3-super-120b-a12b", ""), # OpenRouter routers ("openrouter/pareto-code", "auto-routes to cheapest coder meeting openrouter.min_coding_score"), + ("openrouter/fusion", "multi-model panel + judge deliberation (priced as sum of all calls)"), # Free tier ("openrouter/elephant-alpha", "free"), ("openrouter/owl-alpha", "free"), @@ -1287,6 +1288,21 @@ def _openrouter_model_is_free(pricing: Any) -> bool: return False +# OpenRouter "router" model aliases (openrouter/). These are not models +# themselves - they dispatch each request to an underlying tool-capable model - +# so the router alias advertises ``supported_parameters: []`` in /api/v1/models +# even though the request that reaches the chosen model fully supports tools. +# The plain tool-support filter below would therefore drop every router from the +# picker (openrouter/pareto-code is already shipped and hits exactly this), +# leaving the curated entry inert. Treat known routers as tool-capable so they +# survive the filter. Keep this list explicit (not a prefix glob) so a future +# non-router ``openrouter/*`` model that genuinely lacks tools is still filtered. +_OPENROUTER_TOOL_CAPABLE_ROUTERS = frozenset({ + "openrouter/pareto-code", + "openrouter/fusion", +}) + + def _openrouter_model_supports_tools(item: Any) -> bool: """Return True when the model's ``supported_parameters`` advertise tool calling. @@ -1301,10 +1317,19 @@ def _openrouter_model_supports_tools(item: Any) -> bool: so the picker doesn't silently empty for those users. Only hide models whose ``supported_parameters`` is an explicit list that omits ``tools``. + **Router aliases are tool-capable.** OpenRouter router models + (``openrouter/pareto-code``, ``openrouter/fusion``) report an empty + ``supported_parameters`` because the alias itself isn't the executor - it + routes to an underlying tool-capable model. Without this allowance the + filter silently drops them from the picker. See + ``_OPENROUTER_TOOL_CAPABLE_ROUTERS`` above. + Ported from Kilo-Org/kilocode#9068. """ if not isinstance(item, dict): return True + if str(item.get("id") or "").strip() in _OPENROUTER_TOOL_CAPABLE_ROUTERS: + return True params = item.get("supported_parameters") if not isinstance(params, list): # Field absent / malformed / None — be permissive. diff --git a/tests/hermes_cli/test_models.py b/tests/hermes_cli/test_models.py index 21f1557d7358..203f265a92e9 100644 --- a/tests/hermes_cli/test_models.py +++ b/tests/hermes_cli/test_models.py @@ -211,6 +211,79 @@ def test_empty_supported_parameters_list_drops_model(self): {"id": "x", "supported_parameters": []} ) is False + def test_openrouter_router_aliases_are_tool_capable(self): + """OpenRouter router aliases advertise supported_parameters:[] but are + tool-capable (they route to a tool-capable model). They must survive the + filter even with an empty list - otherwise the curated picker entry is + inert. openrouter/pareto-code ships today and hits exactly this.""" + from hermes_cli.models import _openrouter_model_supports_tools + for router_id in ("openrouter/pareto-code", "openrouter/fusion"): + assert _openrouter_model_supports_tools( + {"id": router_id, "supported_parameters": []} + ) is True, router_id + + def test_non_router_openrouter_model_without_tools_still_dropped(self): + """The router allowance is an explicit allowlist, not an openrouter/* glob: + a non-router openrouter/* model that genuinely omits tools is still dropped.""" + from hermes_cli.models import _openrouter_model_supports_tools + assert _openrouter_model_supports_tools( + {"id": "openrouter/some-image-model", "supported_parameters": []} + ) is False + + +class TestOpenRouterFusionCurated: + """openrouter/fusion is a curated, selectable OpenRouter model.""" + + def test_fusion_in_openrouter_models_snapshot(self): + from hermes_cli.models import OPENROUTER_MODELS + ids = [mid for mid, _ in OPENROUTER_MODELS] + assert "openrouter/fusion" in ids + + def test_fusion_survives_live_catalog_filter(self, monkeypatch): + """End-to-end picker path: even when the live /api/v1/models entry for + openrouter/fusion advertises supported_parameters:[] (its real shape + today), it must still appear in the curated picker output.""" + import hermes_cli.models as _models_mod + + class _Resp: + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self): + return ( + b'{"data":[' + b'{"id":"openrouter/fusion","pricing":{"prompt":"0","completion":"0"},' + b'"supported_parameters":[]},' + b'{"id":"anthropic/claude-opus-4.6","pricing":{"prompt":"0.000015","completion":"0.000075"},' + b'"supported_parameters":["tools"]}' + b']}' + ) + + monkeypatch.setattr( + _models_mod, + "OPENROUTER_MODELS", + [ + ("anthropic/claude-opus-4.6", ""), + ("openrouter/fusion", "multi-model panel + judge deliberation"), + ], + ) + monkeypatch.setattr(_models_mod, "_openrouter_catalog_cache", None) + # Force the in-repo fallback list (skip the remote curated manifest, + # which is imported lazily from hermes_cli.model_catalog inside the fn). + monkeypatch.setattr( + "hermes_cli.model_catalog.get_curated_openrouter_models", + lambda *a, **k: None, + raising=False, + ) + with patch("hermes_cli.models.urllib.request.urlopen", return_value=_Resp()): + models = _models_mod.fetch_openrouter_models(force_refresh=True) + + ids = [mid for mid, _ in models] + assert "openrouter/fusion" in ids + class TestFindOpenrouterSlug: def test_exact_match(self): diff --git a/website/static/api/model-catalog.json b/website/static/api/model-catalog.json index ff14a1ad5e4f..31624be3b40d 100644 --- a/website/static/api/model-catalog.json +++ b/website/static/api/model-catalog.json @@ -112,6 +112,10 @@ "id": "openrouter/pareto-code", "description": "auto-routes to cheapest coder meeting openrouter.min_coding_score" }, + { + "id": "openrouter/fusion", + "description": "multi-model panel + judge deliberation (priced as sum of all calls)" + }, { "id": "openrouter/elephant-alpha", "description": "free"