{"repo":"NousResearch/hermes-agent","public_id":"47077","display_label":"Model picker gaps and Windows notification fix","summary":"These items address missing models in the opencode-go picker, fixes to ensure reseller/first-party models appear correctly in the desktop picker, and a Windows-specific fix to set AppUserModelID so notifications work properly. The members span two adjacent but distinct themes: model catalog completeness and a desktop platform bug.","subsystem":"hermes_cli","counts":{"members":4,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":2,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":50809,"merged_at":"2026-06-22T13:09:08+00:00"},"resolution":{"state":"resolved","by":"pr-50808"},"freshness":{"state":"verified","last_verified_at":"2026-06-22T13:31:40+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.5},"fix_edges":[{"issue_number":41305,"pr_number":50808,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-22T13:31:40+00:00"},{"issue_number":47077,"pr_number":50809,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-22T13:09:08+00:00"}],"members":[{"kind":"issue","number":47077,"title":"[Bug]: /model picker for opencode-go missing MiniMax, GLM, deepseek-v4-flash models (shows 13 of 19)","url":null,"state":"closed"},{"kind":"pr","number":47104,"title":"fix(opencode-go): add missing models to curated catalog (minimax-m3, deepseek-v4-flash, qwen3.7-plus, kimi-k2.7-code, hy3-preview)","url":null,"state":"closed","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":50808,"title":"fix(desktop): set AppUserModelID on Windows so notifications fire","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/electron/main.cjs"],"fix_hunks":[{"file":"apps/desktop/electron/main.cjs","hunks":["@@ -620,6 +620,16 @@ function previewFileMetadata(filePath, mimeType) {\n }\n \n app.setName(APP_NAME)\n+// Windows toast notifications silently no-op unless an AppUserModelID is set:\n+// `new Notification().show()` returns without error and nothing appears. The\n+// AUMID must match the installed Start Menu shortcut's AUMID, which\n+// electron-builder derives from the build `appId` (com.nousresearch.hermes) —\n+// keep this string in sync with package.json `build.appId`. macOS/Linux don't\n+// need this, so gate it on Windows. (Fixes: desktop approval/turn notifications\n+// never firing on Windows.)\n+if (IS_WINDOWS) {\n+  app.setAppUserModelId('com.nousresearch.hermes')\n+}\n // Seed the native About panel with the live Hermes version. This is refreshed\n // on every open via the explicit \"About\" menu handler (refreshAboutPanel), so\n // an in-place `hermes update` mid-session is reflected without an app restart;\n"]}]},{"kind":"pr","number":50809,"title":"fix(picker): keep flat-namespace reseller (opencode-go/zen) first-party models in desktop picker","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/inventory.py","hermes_cli/providers.py","tests/hermes_cli/test_inventory.py","tests/hermes_cli/test_model_switch_custom_providers.py"],"fix_hunks":[{"file":"hermes_cli/inventory.py","hunks":["@@ -173,11 +173,11 @@ def build_models_payload(\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+        from hermes_cli.providers import is_routing_aggregator as _is_routing_aggregator\n     except Exception:\n-        _is_aggregator = None  # type: ignore[assignment]\n+        _is_routing_aggregator = None  # type: ignore[assignment]\n \n-    if _is_aggregator is not None:\n+    if _is_routing_aggregator is not None:\n         user_models: set[str] = set()\n         for row in rows:\n             if row.get(\"is_user_defined\"):","@@ -186,14 +186,21 @@ def build_models_payload(\n             for row in rows:\n                 # A user's own configured provider is never an \"aggregator\n                 # duplicate\" of itself: user_models is built from these very\n-                # rows, and is_aggregator() reports True for every custom:*\n-                # slug.  Without this guard the dedup strips a user-defined\n-                # custom provider's entire model list (all of it lives in\n-                # user_models), emptying its picker row.\n+                # rows, and is_routing_aggregator() reports True for every\n+                # custom:* slug.  Without this guard the dedup strips a\n+                # user-defined custom provider's entire model list (all of it\n+                # lives in user_models), emptying its picker row.\n                 if row.get(\"is_user_defined\"):\n                     continue\n                 slug = row.get(\"slug\", \"\")\n-                if not _is_aggregator(slug):\n+                # Only strip overlaps from TRUE routing aggregators (OpenRouter,\n+                # custom:* proxies). Flat-namespace resellers (opencode-go /\n+                # opencode-zen) serve every listed model as a first-party model,\n+                # so their rows must keep models that a user's proxy happens to\n+                # share a name with — otherwise a subscription provider's own\n+                # catalog (minimax-m3, glm-5, deepseek-v4-flash, ...) is silently\n+                # gutted in the picker. (#47077)\n+                if not _is_routing_aggregator(slug):\n                     continue\n                 original = row.get(\"models\") or []\n                 filtered = [m for m in original if m.lower() not in user_models]"]},{"file":"hermes_cli/providers.py","hunks":["@@ -489,6 +489,41 @@ def is_aggregator(provider: str) -> bool:\n     return pdef.is_aggregator if pdef else False\n \n \n+# Flat-namespace resellers (e.g. opencode-go, opencode-zen) are flagged\n+# ``is_aggregator=True`` because their live ``/v1/models`` returns bare model\n+# IDs (\"deepseek-v4-flash\") rather than ``vendor/model`` routing slugs — the\n+# model-switch resolver relies on that flag to search their flat catalog\n+# (see model_switch.py step d). But they are NOT routing aggregators: every\n+# model they list is a first-party model served under their own subscription,\n+# not a passthrough route to another provider's endpoint. The picker dedup\n+# (build_models_payload) must treat them differently from true routers like\n+# OpenRouter — a reseller's first-party \"minimax-m3\" must never be stripped\n+# just because a user's custom proxy also happens to serve a same-named model.\n+_FLAT_NAMESPACE_RESELLERS: frozenset[str] = frozenset({\n+    # Use normalized provider IDs: normalize_provider(\"opencode-zen\") -> \"opencode\".\n+    \"opencode-go\",\n+    \"opencode\",\n+})\n+\n+\n+def is_routing_aggregator(provider: str) -> bool:\n+    \"\"\"Return True only for TRUE routing aggregators (e.g. OpenRouter, named\n+    ``custom:*`` proxies) — those that route bare/vendor-slugged model names\n+    to *other* providers' endpoints.\n+\n+    Distinct from :func:`is_aggregator`, which also reports True for\n+    flat-namespace resellers (opencode-go/zen) whose catalog is entirely\n+    first-party. Use this gate when the question is \"would selecting this\n+    model silently re-route the call away from the user's intended provider?\"\n+    — i.e. the picker dedup. Resellers answer no: their listed models are\n+    their own, so their rows must not be deduped against user proxies.\n+    \"\"\"\n+    provider_norm = normalize_provider(provider or \"\")\n+    if provider_norm in _FLAT_NAMESPACE_RESELLERS:\n+        return False\n+    return is_aggregator(provider_norm)\n+\n+\n def determine_api_mode(provider: str, base_url: str = \"\") -> str:\n     \"\"\"Determine the API mode (wire protocol) for a provider/endpoint.\n "]},{"file":"tests/hermes_cli/test_inventory.py","hunks":["@@ -639,6 +639,46 @@ def test_aggregator_dedup_does_not_empty_user_defined_custom_provider():\n     assert or_row[\"total_models\"] == 1\n \n \n+def test_flat_namespace_reseller_keeps_first_party_models_overlapping_user_proxy():\n+    \"\"\"opencode-go / opencode-zen are flagged ``is_aggregator=True`` (their\n+    flat ``/v1/models`` returns bare IDs the model-switch resolver searches),\n+    but they are NOT routing aggregators — every model they list is a\n+    first-party model under the user's subscription. When a user also runs a\n+    custom proxy that happens to serve a same-named model, the picker dedup\n+    must NOT strip the reseller's own catalog. Regression for #47077, where\n+    opencode-go showed only 13 of 19 models because minimax-m3/m2.7/m2.5,\n+    glm-5/5.1, and deepseek-v4-flash were deduped against an overlapping\n+    custom provider.\n+    \"\"\"\n+    rows = [\n+        _user_provider_row(\"custom:my-proxy\", [\n+            \"minimax-m3\", \"minimax-m2.7\", \"glm-5\", \"deepseek-v4-flash\",\n+        ]),\n+        _aggregator_row(\"opencode-go\", [\n+            \"kimi-k2.6\", \"minimax-m3\", \"minimax-m2.7\", \"glm-5\",\n+            \"deepseek-v4-flash\", \"qwen3.7-max\",\n+        ]),\n+        _aggregator_row(\"openrouter\", [\"minimax-m3\", \"anthropic/claude-sonnet-4.6\"]),\n+    ]\n+    ctx = _empty_ctx()\n+    with _list_auth_returning(rows):\n+        payload = build_models_payload(ctx)\n+\n+    go_row = next(r for r in payload[\"providers\"] if r[\"slug\"] == \"opencode-go\")\n+    or_row = next(r for r in payload[\"providers\"] if r[\"slug\"] == \"openrouter\")\n+\n+    # The reseller keeps ALL of its first-party models — nothing stripped.\n+    assert go_row[\"models\"] == [\n+        \"kimi-k2.6\", \"minimax-m3\", \"minimax-m2.7\", \"glm-5\",\n+        \"deepseek-v4-flash\", \"qwen3.7-max\",\n+    ]\n+    assert go_row[\"total_models\"] == 6\n+\n+    # A TRUE routing aggregator is still deduped against the user's models.\n+    assert \"minimax-m3\" not in or_row[\"models\"]\n+    assert \"anthropic/claude-sonnet-4.6\" in or_row[\"models\"]\n+\n+\n def test_two_custom_providers_with_overlap_both_survive():\n     \"\"\"Two user-defined custom endpoints that happen to expose an\n     overlapping model must each keep their full catalog. Neither is the"]},{"file":"tests/hermes_cli/test_model_switch_custom_providers.py","hunks":["@@ -129,6 +129,23 @@ def test_is_aggregator_leaves_unknown_provider_non_aggregator():\n     assert providers_mod.is_aggregator(\"not-a-provider\") is False\n \n \n+def test_is_routing_aggregator_excludes_flat_namespace_resellers():\n+    \"\"\"opencode-go / opencode-zen stay ``is_aggregator=True`` (model-switch\n+    relies on it to search their flat bare-name catalog), but they are NOT\n+    routing aggregators — their models are first-party, so the picker dedup\n+    must not strip them. (#47077)\"\"\"\n+    # Still aggregators for model-switch flat-catalog resolution.\n+    assert providers_mod.is_aggregator(\"opencode-go\") is True\n+    assert providers_mod.is_aggregator(\"opencode-zen\") is True\n+    # But NOT routing aggregators for picker-dedup purposes.\n+    assert providers_mod.is_routing_aggregator(\"opencode-go\") is False\n+    assert providers_mod.is_routing_aggregator(\"opencode-zen\") is False\n+    # True routers and custom proxies remain routing aggregators.\n+    assert providers_mod.is_routing_aggregator(\"openrouter\") is True\n+    assert providers_mod.is_routing_aggregator(\"custom:litellm\") is True\n+    assert providers_mod.is_routing_aggregator(\"not-a-provider\") is False\n+\n+\n def test_switch_model_accepts_explicit_named_custom_provider(monkeypatch):\n     \"\"\"Shared /model switch pipeline should accept --provider for custom_providers.\"\"\"\n     monkeypatch.setattr(\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}