NousResearch/hermes-agent
Model deduplication and provider attribution fixes
Cluster 45954. Data as of
Nothing open on GitHub. Fix merged in #46829 on 15 Jun 2026. Check it covers your case.
The short answer
0open issues
0open PRs
3items grouped
- Known?
- Yes. 3 related items grouped here, 1 issue already closed.
- Fix event
- A fix was merged to main in PR #46829. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
resolved, deciding PR #46829. That is separate from the fix event. - Still open
- Nothing open on GitHub.
These items address bugs where models from local proxies or user-defined providers are incorrectly attributed to aggregators, and where duplicate model entries appear when multiple sources expose the same models.
Open issues0
Open PRs0
Fixes on record
-
#46829 fix(inventory): deduplicate aggregator model rows
files:
hermes_cli/inventory.py,tests/hermes_cli/test_inventory.pyShow the diff
-
hermes_cli/inventory.py@@ -157,6 +157,36 @@ def build_models_payload( max_models=max_models, ) + # --- Deduplicate: remove models from aggregators that overlap with + # user-defined providers. When a local proxy (e.g. litellm-proxy) + # serves a model whose name also appears in an aggregator's curated + # catalog, the picker would show the model under both providers. + # Selecting it from the aggregator row sets model.provider to the + # aggregator (e.g. openrouter) instead of the user's proxy — silently + # breaking the call. Filtering at the payload level keeps the + # 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 + except Exception: + _is_aggregator = None # type: ignore[assignment] + + if _is_aggregator is not None: + user_models: set[str] = set() + for row in rows: + if row.get("is_user_defined"): + user_models.update(m.lower() for m in (row.get("models") or [])) + if user_models: + for row in rows: + slug = row.get("slug", "") + if not _is_aggregator(slug): + continue + original = row.get("models") or [] + filtered = [m for m in original if m.lower() not in user_models] + if len(filtered) < len(original): + row["models"] = filtered + row["total_models"] = len(filtered) + if include_unconfigured: rows = list(rows) + _append_unconfigured_rows(rows, ctx) if picker_hints: -
tests/hermes_cli/test_inventory.py@@ -482,3 +482,127 @@ def test_payload_shape_compatible_with_modelpickerdialog_frontend(): for row in payload["providers"]: missing = required_keys - row.keys() assert not missing, f"row {row['slug']} missing keys: {missing}" + + +# ─── Aggregator dedup (issue #45954) ─────────────────────────────────── + + +def _user_provider_row(slug: str, models: list[str]) -> dict: + return { + "slug": slug, + "name": slug.title(), + "models": models, + "total_models": len(models), + "is_current": False, + "is_user_defined": True, + "source": "user-config", + } + + +def _aggregator_row(slug: str, models: list[str]) -> dict: + return { + "slug": slug, + "name": slug.title(), + "models": models, + "total_models": len(models), + "is_current": False, + "is_user_defined": False, + "source": "built-in", + } + + +def test_aggregator_dedup_removes_overlapping_models(): + """Models served by a user-defined provider are removed from + aggregator rows so the picker doesn't show them under the wrong + provider. (#45954)""" + rows = [ + _user_provider_row("litellm-proxy", [ + "nvidia/nim/minimax-m3", + "nvidia/nim/kimi-k2.6", + ]), + _aggregator_row("openrouter", [ + "minimax/minimax-m3", + "nvidia/nim/minimax-m3", # overlaps with litellm-proxy + "anthropic/claude-sonnet-4.6", + ]), + ] + ctx = _empty_ctx() + with _list_auth_returning(rows): + payload = build_models_payload(ctx) + + or_row = next(r for r in payload["providers"] if r["slug"] == "openrouter") + proxy_row = next(r for r in payload["providers"] if r["slug"] == "litellm-proxy") + + # User-defined provider keeps all its models + assert proxy_row["models"] == ["nvidia/nim/minimax-m3", "nvidia/nim/kimi-k2.6"] + + # Aggregator lost the overlapping model but kept the rest + assert "nvidia/nim/minimax-m3" not in or_row["models"] + assert "minimax/minimax-m3" in or_row["models"] + assert "anthropic/claude-sonnet-4.6" in or_row["models"] + assert or_row["total_models"] == 2 + + +def test_aggregator_dedup_case_insensitive(): + """Dedup uses case-insensitive matching. (#45954)""" + rows = [ + _user_provider_row("my-proxy", ["NVIDIA/NIM/MiniMax-M3"]), + _aggregator_row("openrouter", ["nvidia/nim/minimax-m3", "other/model"]), + ] + ctx = _empty_ctx() + with _list_auth_returning(rows): + payload = build_models_payload(ctx) + + or_row = next(r for r in payload["providers"] if r["slug"] == "openrouter") + assert "nvidia/nim/minimax-m3" not in or_row["models"] + assert or_row["total_models"] == 1 + + +def test_aggregator_dedup_no_overlap_unchanged(): + """When there's no overlap, aggregator models are untouched. (#45954)""" + rows = [ + _user_provider_row("litellm-proxy", ["custom/model-a"]), + _aggregator_row("openrouter", ["anthropic/claude-sonnet-4.6"]), + ] + ctx = _empty_ctx() + with _list_auth_returning(rows): + payload = build_models_payload(ctx) + + or_row = next(r for r in payload["providers"] if r["slug"] == "openrouter") + assert or_row["models"] == ["anthropic/claude-sonnet-4.6"] + assert or_row["total_models"] == 1 + + +def test_aggregator_dedup_no_user_providers_unchanged(): + """When there are no user-defined providers, nothing is filtered. + (#45954)""" + rows = [ + _aggregator_row("openrouter", [ + "nvidia/nim/minimax-m3", + "anthropic/claude-sonnet-4.6", + ]), + ] + ctx = _empty_ctx() + with _list_auth_returning(rows): + payload = build_models_payload(ctx) + + or_row = payload["providers"][0] + assert len(or_row["models"]) == 2 + + +def test_aggregator_dedup_multiple_user_providers(): + """Models from all user-defined providers are excluded from aggregators. + (#45954)""" + rows = [ + _user_provider_row("proxy-a", ["model-x"]), + _user_provider_row("proxy-b", ["model-y"]), + _aggregator_row("openrouter", ["model-x", "model-y", "model-z"]), + ] + ctx = _empty_ctx() + with _list_auth_returning(rows): + payload = build_models_payload(ctx) + + or_row = next(r for r in payload["providers"] if r["slug"] == "openrouter") + assert or_row["models"] == ["model-z"] + assert or_row["total_models"] == 1 +
-
1 PR closed without merging
From the close comments: merged via another change 1
-
#45960 fix(inventory): deduplicate models between user-defined and aggregator providers · merged via another change →
#46829
Closed issues · 1
- Closed#45954[Desktop] Model picker rewrites model.provider to wrong aggregator (e.g. openrouter) when a local proxy exposes same-prefix models
How this cluster was grouped
-
Link evidence (
structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference. -
Pairwise link density
0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Merged PRs
- #46829
- Closed issues
- 1
- Attempts
- 1 PR closed without merging
- Fix link checked
- 15 Jun 2026