From a6ce9b2fbbdfbe1fecf6c72d28d02a72adccf82f Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 22 Jun 2026 05:56:56 -0700 Subject: [PATCH] fix(picker): keep flat-namespace reseller first-party models in desktop picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OpenCode Go (and OpenCode Zen) showed only a subset of the models they serve in the desktop/CLI model picker — e.g. opencode-go rendered 13 of 19, silently dropping minimax-m3/m2.7/m2.5, glm-5/5.1, deepseek-v4-flash. Root cause: the picker dedup in build_models_payload strips any model from an aggregator row that overlaps a user-defined provider's catalog (so a local proxy isn't shadowed by OpenRouter). It gated on is_aggregator(), which is True for opencode-go/zen because their flat /v1/models returns bare IDs the model-switch resolver searches. But those are flat-namespace RESELLERS, not routing aggregators — every model they list is first-party, so deduping them against a user proxy that happens to serve a same-named model guts their own catalog. Fix: add is_routing_aggregator() (True only for true routers like OpenRouter and custom:* proxies; False for opencode-go/zen) and gate the picker dedup on it. is_aggregator() is unchanged so model-switch flat catalog resolution keeps working. Both desktop entry points (model.options JSON-RPC and /api/model/options REST) and hermes model share build_models_payload, so all surfaces get the full list. Fixes #47077 --- hermes_cli/inventory.py | 23 +++++++---- hermes_cli/providers.py | 35 ++++++++++++++++ tests/hermes_cli/test_inventory.py | 40 +++++++++++++++++++ .../test_model_switch_custom_providers.py | 17 ++++++++ 4 files changed, 107 insertions(+), 8 deletions(-) diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index 7f0d3d220e6..eefc7479fa1 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -173,11 +173,11 @@ def build_models_payload( # aggregator rows honest: they only show models the user can't get # from a more-specific provider. (#45954) try: - from hermes_cli.providers import is_aggregator as _is_aggregator + from hermes_cli.providers import is_routing_aggregator as _is_routing_aggregator except Exception: - _is_aggregator = None # type: ignore[assignment] + _is_routing_aggregator = None # type: ignore[assignment] - if _is_aggregator is not None: + if _is_routing_aggregator is not None: user_models: set[str] = set() for row in rows: if row.get("is_user_defined"): @@ -186,14 +186,21 @@ def build_models_payload( for row in rows: # A user's own configured provider is never an "aggregator # duplicate" of itself: user_models is built from these very - # rows, and is_aggregator() reports True for every custom:* - # slug. Without this guard the dedup strips a user-defined - # custom provider's entire model list (all of it lives in - # user_models), emptying its picker row. + # rows, and is_routing_aggregator() reports True for every + # custom:* slug. Without this guard the dedup strips a + # user-defined custom provider's entire model list (all of it + # lives in user_models), emptying its picker row. if row.get("is_user_defined"): continue slug = row.get("slug", "") - if not _is_aggregator(slug): + # Only strip overlaps from TRUE routing aggregators (OpenRouter, + # custom:* proxies). Flat-namespace resellers (opencode-go / + # opencode-zen) serve every listed model as a first-party model, + # so their rows must keep models that a user's proxy happens to + # share a name with — otherwise a subscription provider's own + # catalog (minimax-m3, glm-5, deepseek-v4-flash, ...) is silently + # gutted in the picker. (#47077) + if not _is_routing_aggregator(slug): continue original = row.get("models") or [] filtered = [m for m in original if m.lower() not in user_models] diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 44f1892d5de..3876b02b9ef 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -489,6 +489,41 @@ def is_aggregator(provider: str) -> bool: return pdef.is_aggregator if pdef else False +# Flat-namespace resellers (e.g. opencode-go, opencode-zen) are flagged +# ``is_aggregator=True`` because their live ``/v1/models`` returns bare model +# IDs ("deepseek-v4-flash") rather than ``vendor/model`` routing slugs — the +# model-switch resolver relies on that flag to search their flat catalog +# (see model_switch.py step d). But they are NOT routing aggregators: every +# model they list is a first-party model served under their own subscription, +# not a passthrough route to another provider's endpoint. The picker dedup +# (build_models_payload) must treat them differently from true routers like +# OpenRouter — a reseller's first-party "minimax-m3" must never be stripped +# just because a user's custom proxy also happens to serve a same-named model. +_FLAT_NAMESPACE_RESELLERS: frozenset[str] = frozenset({ + # Use normalized provider IDs: normalize_provider("opencode-zen") -> "opencode". + "opencode-go", + "opencode", +}) + + +def is_routing_aggregator(provider: str) -> bool: + """Return True only for TRUE routing aggregators (e.g. OpenRouter, named + ``custom:*`` proxies) — those that route bare/vendor-slugged model names + to *other* providers' endpoints. + + Distinct from :func:`is_aggregator`, which also reports True for + flat-namespace resellers (opencode-go/zen) whose catalog is entirely + first-party. Use this gate when the question is "would selecting this + model silently re-route the call away from the user's intended provider?" + — i.e. the picker dedup. Resellers answer no: their listed models are + their own, so their rows must not be deduped against user proxies. + """ + provider_norm = normalize_provider(provider or "") + if provider_norm in _FLAT_NAMESPACE_RESELLERS: + return False + return is_aggregator(provider_norm) + + def determine_api_mode(provider: str, base_url: str = "") -> str: """Determine the API mode (wire protocol) for a provider/endpoint. diff --git a/tests/hermes_cli/test_inventory.py b/tests/hermes_cli/test_inventory.py index 2eff7bd460d..af65f90a321 100644 --- a/tests/hermes_cli/test_inventory.py +++ b/tests/hermes_cli/test_inventory.py @@ -639,6 +639,46 @@ def test_aggregator_dedup_does_not_empty_user_defined_custom_provider(): assert or_row["total_models"] == 1 +def test_flat_namespace_reseller_keeps_first_party_models_overlapping_user_proxy(): + """opencode-go / opencode-zen are flagged ``is_aggregator=True`` (their + flat ``/v1/models`` returns bare IDs the model-switch resolver searches), + but they are NOT routing aggregators — every model they list is a + first-party model under the user's subscription. When a user also runs a + custom proxy that happens to serve a same-named model, the picker dedup + must NOT strip the reseller's own catalog. Regression for #47077, where + opencode-go showed only 13 of 19 models because minimax-m3/m2.7/m2.5, + glm-5/5.1, and deepseek-v4-flash were deduped against an overlapping + custom provider. + """ + rows = [ + _user_provider_row("custom:my-proxy", [ + "minimax-m3", "minimax-m2.7", "glm-5", "deepseek-v4-flash", + ]), + _aggregator_row("opencode-go", [ + "kimi-k2.6", "minimax-m3", "minimax-m2.7", "glm-5", + "deepseek-v4-flash", "qwen3.7-max", + ]), + _aggregator_row("openrouter", ["minimax-m3", "anthropic/claude-sonnet-4.6"]), + ] + ctx = _empty_ctx() + with _list_auth_returning(rows): + payload = build_models_payload(ctx) + + go_row = next(r for r in payload["providers"] if r["slug"] == "opencode-go") + or_row = next(r for r in payload["providers"] if r["slug"] == "openrouter") + + # The reseller keeps ALL of its first-party models — nothing stripped. + assert go_row["models"] == [ + "kimi-k2.6", "minimax-m3", "minimax-m2.7", "glm-5", + "deepseek-v4-flash", "qwen3.7-max", + ] + assert go_row["total_models"] == 6 + + # A TRUE routing aggregator is still deduped against the user's models. + assert "minimax-m3" not in or_row["models"] + assert "anthropic/claude-sonnet-4.6" in or_row["models"] + + def test_two_custom_providers_with_overlap_both_survive(): """Two user-defined custom endpoints that happen to expose an overlapping model must each keep their full catalog. Neither is the diff --git a/tests/hermes_cli/test_model_switch_custom_providers.py b/tests/hermes_cli/test_model_switch_custom_providers.py index 388c82bd3e6..2456af11db9 100644 --- a/tests/hermes_cli/test_model_switch_custom_providers.py +++ b/tests/hermes_cli/test_model_switch_custom_providers.py @@ -129,6 +129,23 @@ def test_is_aggregator_leaves_unknown_provider_non_aggregator(): assert providers_mod.is_aggregator("not-a-provider") is False +def test_is_routing_aggregator_excludes_flat_namespace_resellers(): + """opencode-go / opencode-zen stay ``is_aggregator=True`` (model-switch + relies on it to search their flat bare-name catalog), but they are NOT + routing aggregators — their models are first-party, so the picker dedup + must not strip them. (#47077)""" + # Still aggregators for model-switch flat-catalog resolution. + assert providers_mod.is_aggregator("opencode-go") is True + assert providers_mod.is_aggregator("opencode-zen") is True + # But NOT routing aggregators for picker-dedup purposes. + assert providers_mod.is_routing_aggregator("opencode-go") is False + assert providers_mod.is_routing_aggregator("opencode-zen") is False + # True routers and custom proxies remain routing aggregators. + assert providers_mod.is_routing_aggregator("openrouter") is True + assert providers_mod.is_routing_aggregator("custom:litellm") is True + assert providers_mod.is_routing_aggregator("not-a-provider") is False + + def test_switch_model_accepts_explicit_named_custom_provider(monkeypatch): """Shared /model switch pipeline should accept --provider for custom_providers.""" monkeypatch.setattr(