fix(picker): keep flat-namespace reseller first-party models in desktop picker

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
This commit is contained in:
teknium1 2026-06-22 05:56:56 -07:00 committed by Teknium
parent ef6492b648
commit a6ce9b2fbb
4 changed files with 107 additions and 8 deletions

View file

@ -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]

View file

@ -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.

View file

@ -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

View file

@ -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(