{"repo":"NousResearch/hermes-agent","public_id":"45954","display_label":"Model deduplication and provider attribution fixes","summary":"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.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":46829,"merged_at":"2026-06-15T19:25:42+00:00"},"resolution":{"state":"resolved","by":"pr-46829"},"freshness":{"state":"verified","last_verified_at":"2026-06-15T19:25:42+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.6666666666666666},"fix_edges":[{"issue_number":45954,"pr_number":46829,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-15T19:25:42+00:00"}],"members":[{"kind":"issue","number":45954,"title":"[Desktop] Model picker rewrites model.provider to wrong aggregator (e.g. openrouter) when a local proxy exposes same-prefix models","url":null,"state":"closed"},{"kind":"pr","number":45960,"title":"fix(inventory): deduplicate models between user-defined and aggregator providers","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#46829","number":46829}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":46829,"title":"fix(inventory): deduplicate aggregator model rows","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/inventory.py","tests/hermes_cli/test_inventory.py"],"fix_hunks":[{"file":"hermes_cli/inventory.py","hunks":["@@ -157,6 +157,36 @@ def build_models_payload(\n         max_models=max_models,\n     )\n \n+    # --- Deduplicate: remove models from aggregators that overlap with\n+    # user-defined providers.  When a local proxy (e.g. litellm-proxy)\n+    # serves a model whose name also appears in an aggregator's curated\n+    # catalog, the picker would show the model under both providers.\n+    # Selecting it from the aggregator row sets model.provider to the\n+    # aggregator (e.g. openrouter) instead of the user's proxy — silently\n+    # breaking the call.  Filtering at the payload level keeps the\n+    # aggregator rows honest: they only show models the user can't get\n+    # from a more-specific provider.  (#45954)\n+    try:\n+        from hermes_cli.providers import is_aggregator as _is_aggregator\n+    except Exception:\n+        _is_aggregator = None  # type: ignore[assignment]\n+\n+    if _is_aggregator is not None:\n+        user_models: set[str] = set()\n+        for row in rows:\n+            if row.get(\"is_user_defined\"):\n+                user_models.update(m.lower() for m in (row.get(\"models\") or []))\n+        if user_models:\n+            for row in rows:\n+                slug = row.get(\"slug\", \"\")\n+                if not _is_aggregator(slug):\n+                    continue\n+                original = row.get(\"models\") or []\n+                filtered = [m for m in original if m.lower() not in user_models]\n+                if len(filtered) < len(original):\n+                    row[\"models\"] = filtered\n+                    row[\"total_models\"] = len(filtered)\n+\n     if include_unconfigured:\n         rows = list(rows) + _append_unconfigured_rows(rows, ctx)\n     if picker_hints:"]},{"file":"tests/hermes_cli/test_inventory.py","hunks":["@@ -482,3 +482,127 @@ def test_payload_shape_compatible_with_modelpickerdialog_frontend():\n     for row in payload[\"providers\"]:\n         missing = required_keys - row.keys()\n         assert not missing, f\"row {row['slug']} missing keys: {missing}\"\n+\n+\n+# ─── Aggregator dedup (issue #45954) ───────────────────────────────────\n+\n+\n+def _user_provider_row(slug: str, models: list[str]) -> dict:\n+    return {\n+        \"slug\": slug,\n+        \"name\": slug.title(),\n+        \"models\": models,\n+        \"total_models\": len(models),\n+        \"is_current\": False,\n+        \"is_user_defined\": True,\n+        \"source\": \"user-config\",\n+    }\n+\n+\n+def _aggregator_row(slug: str, models: list[str]) -> dict:\n+    return {\n+        \"slug\": slug,\n+        \"name\": slug.title(),\n+        \"models\": models,\n+        \"total_models\": len(models),\n+        \"is_current\": False,\n+        \"is_user_defined\": False,\n+        \"source\": \"built-in\",\n+    }\n+\n+\n+def test_aggregator_dedup_removes_overlapping_models():\n+    \"\"\"Models served by a user-defined provider are removed from\n+    aggregator rows so the picker doesn't show them under the wrong\n+    provider.  (#45954)\"\"\"\n+    rows = [\n+        _user_provider_row(\"litellm-proxy\", [\n+            \"nvidia/nim/minimax-m3\",\n+            \"nvidia/nim/kimi-k2.6\",\n+        ]),\n+        _aggregator_row(\"openrouter\", [\n+            \"minimax/minimax-m3\",\n+            \"nvidia/nim/minimax-m3\",  # overlaps with litellm-proxy\n+            \"anthropic/claude-sonnet-4.6\",\n+        ]),\n+    ]\n+    ctx = _empty_ctx()\n+    with _list_auth_returning(rows):\n+        payload = build_models_payload(ctx)\n+\n+    or_row = next(r for r in payload[\"providers\"] if r[\"slug\"] == \"openrouter\")\n+    proxy_row = next(r for r in payload[\"providers\"] if r[\"slug\"] == \"litellm-proxy\")\n+\n+    # User-defined provider keeps all its models\n+    assert proxy_row[\"models\"] == [\"nvidia/nim/minimax-m3\", \"nvidia/nim/kimi-k2.6\"]\n+\n+    # Aggregator lost the overlapping model but kept the rest\n+    assert \"nvidia/nim/minimax-m3\" not in or_row[\"models\"]\n+    assert \"minimax/minimax-m3\" in or_row[\"models\"]\n+    assert \"anthropic/claude-sonnet-4.6\" in or_row[\"models\"]\n+    assert or_row[\"total_models\"] == 2\n+\n+\n+def test_aggregator_dedup_case_insensitive():\n+    \"\"\"Dedup uses case-insensitive matching.  (#45954)\"\"\"\n+    rows = [\n+        _user_provider_row(\"my-proxy\", [\"NVIDIA/NIM/MiniMax-M3\"]),\n+        _aggregator_row(\"openrouter\", [\"nvidia/nim/minimax-m3\", \"other/model\"]),\n+    ]\n+    ctx = _empty_ctx()\n+    with _list_auth_returning(rows):\n+        payload = build_models_payload(ctx)\n+\n+    or_row = next(r for r in payload[\"providers\"] if r[\"slug\"] == \"openrouter\")\n+    assert \"nvidia/nim/minimax-m3\" not in or_row[\"models\"]\n+    assert or_row[\"total_models\"] == 1\n+\n+\n+def test_aggregator_dedup_no_overlap_unchanged():\n+    \"\"\"When there's no overlap, aggregator models are untouched.  (#45954)\"\"\"\n+    rows = [\n+        _user_provider_row(\"litellm-proxy\", [\"custom/model-a\"]),\n+        _aggregator_row(\"openrouter\", [\"anthropic/claude-sonnet-4.6\"]),\n+    ]\n+    ctx = _empty_ctx()\n+    with _list_auth_returning(rows):\n+        payload = build_models_payload(ctx)\n+\n+    or_row = next(r for r in payload[\"providers\"] if r[\"slug\"] == \"openrouter\")\n+    assert or_row[\"models\"] == [\"anthropic/claude-sonnet-4.6\"]\n+    assert or_row[\"total_models\"] == 1\n+\n+\n+def test_aggregator_dedup_no_user_providers_unchanged():\n+    \"\"\"When there are no user-defined providers, nothing is filtered.\n+    (#45954)\"\"\"\n+    rows = [\n+        _aggregator_row(\"openrouter\", [\n+            \"nvidia/nim/minimax-m3\",\n+            \"anthropic/claude-sonnet-4.6\",\n+        ]),\n+    ]\n+    ctx = _empty_ctx()\n+    with _list_auth_returning(rows):\n+        payload = build_models_payload(ctx)\n+\n+    or_row = payload[\"providers\"][0]\n+    assert len(or_row[\"models\"]) == 2\n+\n+\n+def test_aggregator_dedup_multiple_user_providers():\n+    \"\"\"Models from all user-defined providers are excluded from aggregators.\n+    (#45954)\"\"\"\n+    rows = [\n+        _user_provider_row(\"proxy-a\", [\"model-x\"]),\n+        _user_provider_row(\"proxy-b\", [\"model-y\"]),\n+        _aggregator_row(\"openrouter\", [\"model-x\", \"model-y\", \"model-z\"]),\n+    ]\n+    ctx = _empty_ctx()\n+    with _list_auth_returning(rows):\n+        payload = build_models_payload(ctx)\n+\n+    or_row = next(r for r in payload[\"providers\"] if r[\"slug\"] == \"openrouter\")\n+    assert or_row[\"models\"] == [\"model-z\"]\n+    assert or_row[\"total_models\"] == 1\n+\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}