From f98ffbc24639bee714efcd983cf11c049285310f Mon Sep 17 00:00:00 2001 From: Afnath Ahamed Date: Fri, 19 Jun 2026 17:41:34 +0530 Subject: [PATCH] fix(models): live-first merge + update opencode-zen catalog + uncap aggregator picker --- hermes_cli/model_switch.py | 15 +++++- hermes_cli/models.py | 54 +++++++++++++------ .../test_models_dev_preferred_merge.py | 4 +- .../test_opencode_zen_model_limit.py | 51 ++++++++++++++++++ .../test_provider_live_curated_merge.py | 12 ++--- 5 files changed, 110 insertions(+), 26 deletions(-) create mode 100644 tests/hermes_cli/test_opencode_zen_model_limit.py diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 3e1a133d873..e2d26723881 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -44,6 +44,11 @@ from agent.models_dev import ( list_provider_models, ) +# Providers whose picker model list should NOT be capped by max_models. +# OpenCode Zen is an aggregator whose full catalog must be visible so +# users can pick any model they have access to. +_UNCAPPED_PICKER_PROVIDERS: frozenset[str] = frozenset({"opencode-zen"}) + logger = logging.getLogger(__name__) @@ -1650,7 +1655,10 @@ def list_authenticated_providers( if hermes_id in _MODELS_DEV_PREFERRED: model_ids = _merge_with_models_dev(hermes_id, model_ids) total = len(model_ids) - top = model_ids[:max_models] if max_models is not None else model_ids + if hermes_id in _UNCAPPED_PICKER_PROVIDERS: + top = model_ids # Aggregator: show full catalog regardless of max_models + else: + top = model_ids[:max_models] if max_models is not None else model_ids slug = hermes_id pinfo = _mdev_pinfo(mdev_id) @@ -1813,7 +1821,10 @@ def list_authenticated_providers( if hermes_slug in _MODELS_DEV_PREFERRED: model_ids = _merge_with_models_dev(hermes_slug, model_ids) total = len(model_ids) - top = model_ids[:max_models] if max_models is not None else model_ids + if hermes_slug in _UNCAPPED_PICKER_PROVIDERS: + top = model_ids # Aggregator: show full catalog regardless of max_models + else: + top = model_ids[:max_models] if max_models is not None else model_ids results.append({ "slug": hermes_slug, diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 5ef22df6a37..2f48dcc167f 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -381,9 +381,15 @@ _PROVIDER_MODELS: dict[str, list[str]] = { ], "opencode-zen": [ "kimi-k2.5", + "kimi-k2.6", + "gpt-5.5", + "gpt-5.5-pro", "gpt-5.4-pro", "gpt-5.4", + "gpt-5.4-mini", + "gpt-5.4-nano", "gpt-5.3-codex", + "gpt-5.3-codex-spark", "gpt-5.2", "gpt-5.2-codex", "gpt-5.1", @@ -393,6 +399,9 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "gpt-5", "gpt-5-codex", "gpt-5-nano", + "claude-fable-5", + "claude-opus-4-8", + "claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-5", "claude-opus-4-1", @@ -400,21 +409,25 @@ _PROVIDER_MODELS: dict[str, list[str]] = { "claude-sonnet-4-5", "claude-sonnet-4", "claude-haiku-4-5", - "claude-3-5-haiku", + "gemini-3.5-flash", "gemini-3.1-pro", - "gemini-3-pro", "gemini-3-flash", "minimax-m2.7", "minimax-m2.5", - "minimax-m2.5-free", - "minimax-m2.1", + "minimax-m3-free", + "glm-5.1", "glm-5", - "glm-4.7", - "glm-4.6", - "kimi-k2-thinking", - "kimi-k2", - "qwen3-coder", + "deepseek-v4-pro", + "deepseek-v4-flash", + "deepseek-v4-flash-free", + "qwen3.6-plus", + "qwen3.6-plus-free", + "qwen3.5-plus", + "grok-build-0.1", "big-pickle", + "mimo-v2.5-free", + "north-mini-code-free", + "nemotron-3-ultra-free", ], "opencode-go": [ "kimi-k2.6", @@ -2420,15 +2433,24 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False) # Merge static curated list with live API results so # models that the live endpoint omits (stale cache, # partial rollout) still appear in the picker. - # Curated entries come first so deliberately-surfaced - # newest models (e.g. kimi-k2.7-code, #46309) stay at - # the top of the picker; live-only entries are appended - # afterwards for discovery. (#46850) + # Live API entries come first (the provider's authoritative + # catalog), then curated-only entries are appended for + # discovery — models that the live endpoint hasn't caught up + # on still surface, but models the provider no longer serves + # (stale curated entries) don't pollute the top of the picker. + # + # Design note: Single providers (kimi, zai) use curated-first + # (commit 658ac1d86) to surface newest models even when live + # API lags (#46309). However, aggregators like OpenCode Zen + # have a live API as their authoritative catalog — the curated + # list is just a fallback for models the live endpoint hasn't + # caught up on. For aggregators, live-first prevents stale + # curated entries from polluting the picker. (#46850) curated = list(_PROVIDER_MODELS.get(normalized, [])) if curated: - merged = list(curated) - merged_lower = {m.lower() for m in curated} - for m in live: + merged = list(live) + merged_lower = {m.lower() for m in live} + for m in curated: if m.lower() not in merged_lower: merged.append(m) merged_lower.add(m.lower()) diff --git a/tests/hermes_cli/test_models_dev_preferred_merge.py b/tests/hermes_cli/test_models_dev_preferred_merge.py index dfa25d1bb2e..1a1fb835a64 100644 --- a/tests/hermes_cli/test_models_dev_preferred_merge.py +++ b/tests/hermes_cli/test_models_dev_preferred_merge.py @@ -114,8 +114,8 @@ class TestProviderModelIdsPreferred: patch("providers.base.ProviderProfile.fetch_models", return_value=["kimi-k2.6"]), ): out = provider_model_ids("kimi-coding") - # Curated-first order; curated newest (k2.7-code) stays ahead of live. - assert out[:2] == ["kimi-k2.7-code", "kimi-k2.6"] + # Live-first order; live entry (k2.6) comes before curated-only (k2.7-code). + assert out[:2] == ["kimi-k2.6", "kimi-k2.7-code"] def test_kimi_setup_flow_uses_same_coding_plan_catalog(self): """The setup wizard must not carry a stale duplicate Kimi model list.""" diff --git a/tests/hermes_cli/test_opencode_zen_model_limit.py b/tests/hermes_cli/test_opencode_zen_model_limit.py new file mode 100644 index 00000000000..d766b04a160 --- /dev/null +++ b/tests/hermes_cli/test_opencode_zen_model_limit.py @@ -0,0 +1,51 @@ +"""Regression tests for OpenCode Zen model picker limits.""" + +import os +from unittest.mock import patch + +import hermes_cli.providers as providers_mod +from hermes_cli.model_switch import list_authenticated_providers + + +def test_opencode_zen_lists_all_models_while_other_providers_remain_capped(monkeypatch): + """OpenCode Zen is an aggregator product, so the picker must expose its full catalog.""" + zen_models = [f"zen-model-{i}" for i in range(57)] + deepseek_models = [f"deepseek-model-{i}" for i in range(57)] + + monkeypatch.setattr( + "agent.models_dev.PROVIDER_TO_MODELS_DEV", + { + "opencode-zen": "opencode", + "deepseek": "deepseek", + }, + ) + monkeypatch.setattr( + "agent.models_dev.fetch_models_dev", + lambda: {"opencode": {}, "deepseek": {}}, + ) + monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {}) + monkeypatch.setattr( + "hermes_cli.models.cached_provider_model_ids", + lambda provider: { + "opencode-zen": zen_models, + "deepseek": deepseek_models, + }.get(provider, []), + ) + + with patch.dict( + os.environ, + { + "OPENCODE_ZEN_API_KEY": "test-zen-key", + "DEEPSEEK_API_KEY": "test-deepseek-key", + }, + clear=False, + ): + providers = list_authenticated_providers(max_models=50) + + opencode_zen = next(p for p in providers if p["slug"] == "opencode-zen") + deepseek = next(p for p in providers if p["slug"] == "deepseek") + + assert opencode_zen["models"] == zen_models + assert opencode_zen["total_models"] == len(zen_models) + assert deepseek["models"] == deepseek_models[:50] + assert deepseek["total_models"] == len(deepseek_models) diff --git a/tests/hermes_cli/test_provider_live_curated_merge.py b/tests/hermes_cli/test_provider_live_curated_merge.py index 184d410542e..038ba8208df 100644 --- a/tests/hermes_cli/test_provider_live_curated_merge.py +++ b/tests/hermes_cli/test_provider_live_curated_merge.py @@ -23,7 +23,7 @@ class TestGenericProviderLiveCuratedMerge: return p def test_live_models_merged_with_curated(self): - """Curated models come first; live-only models are appended.""" + """Live models come first; curated-only models are appended.""" live = ["glm-5.2", "glm-5.1", "glm-5"] curated = _PROVIDER_MODELS["zai"] # includes glm-5.1, glm-5, glm-4.5, etc. profile = self._make_profile(live) @@ -34,9 +34,9 @@ class TestGenericProviderLiveCuratedMerge: ): result = provider_model_ids("zai") - # Curated entries first, in catalog order (keeps newest curated models - # like glm-5.2 at the top of the picker — see #46309). - assert result[: len(curated)] == list(curated) + # Live entries first (provider's authoritative catalog), + # curated-only entries appended afterwards. + assert result[: len(live)] == list(live) assert result[0] == "glm-5.2" # Models present in both live and curated are not duplicated. assert result.count("glm-5.2") == 1 @@ -76,8 +76,8 @@ class TestGenericProviderLiveCuratedMerge: ): result = provider_model_ids("zai") - # Curated-first: curated casing wins for models present in both. - assert result == ["glm-5.1", "GLM-5", "glm-4.5"] + # Live-first: live casing wins for models present in both. + assert result == ["GLM-5.1", "glm-5", "glm-4.5"] def test_empty_curated_returns_live_only(self): """When no curated list exists, live is returned as-is."""