{"repo":"NousResearch/hermes-agent","public_id":"44560","display_label":"Offload blocking I/O calls off async event loops","summary":"These issues and PRs address synchronous HTTP calls, model discovery, and security checks running on async event loops, causing freezes and timeouts in Discord, Telegram, WebSocket, and desktop contexts. Fixes involve threading, caching, TTLs, and deferring work to executors.","subsystem":"apps/desktop","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":2,"closed_issues":1,"merged_prs":1,"attempt_prs":4},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":58183,"merged_at":"2026-07-04T20:29:01+00:00"},"resolution":{"state":"resolved","by":"pr-58183"},"freshness":{"state":"verified","last_verified_at":"2026-07-04T20:29:01+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.3333333333333333},"fix_edges":[{"issue_number":44560,"pr_number":58183,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-07-04T20:29:01+00:00"}],"members":[{"kind":"issue","number":44560,"title":"model.options handler blocks on synchronous per-provider HTTP calls, causing WebSocket timeout","url":null,"state":"closed"},{"kind":"pr","number":44577,"title":"fix(model-switch): cache custom provider model discovery to unblock WebSocket handler","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#58183","number":58183},{"kind":"pr","value":"#49713","number":49713}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":44598,"title":"fix(gateway): cache fetch_api_models per-provider with TTL and cap timeout to prevent model.options WS timeout","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#58183","number":58183},{"kind":"pr","value":"#49713","number":49713}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":56922,"title":"fix(desktop): fast-path model options and validate provider matches","url":null,"state":"open","close_reason":{"refs":[{"kind":"pr","value":"#58183","number":58183},{"kind":"pr","value":"#49713","number":49713}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":58183,"title":"fix(desktop): avoid probing custom providers on model picker open (salvage #49713)","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/model_switch.py","hermes_cli/web_server.py","tests/hermes_cli/test_inventory.py","tests/hermes_cli/test_model_switch_custom_providers.py","tests/test_tui_gateway_server.py","tui_gateway/server.py"],"fix_hunks":[{"file":"hermes_cli/inventory.py","hunks":["@@ -118,6 +118,7 @@ def build_models_payload(\n     capabilities: bool = False,\n     force_fresh_nous_tier: bool = False,\n     refresh: bool = False,\n+    probe_custom_providers: bool = True,\n     max_models: int | None = None,\n ) -> dict:\n     \"\"\"Build the ``{providers, model, provider}`` shape every consumer","@@ -149,6 +150,11 @@ def build_models_payload(\n       re-fetches its live catalog. Set only for an explicit user-triggered\n       \"refresh models\" action; normal picker opens leave it false to stay\n       snappy on the 1h cache.\n+    - ``probe_custom_providers``: allow saved custom/provider endpoints to\n+      run live ``/models`` discovery while building the payload. GUI picker\n+      opens should leave this false unless the user explicitly refreshes; the\n+      row can still render its configured model immediately, and slow/offline\n+      local endpoints no longer block the dialog.\n     \"\"\"\n     from hermes_cli.model_switch import list_authenticated_providers\n ","@@ -161,6 +167,7 @@ def build_models_payload(\n         force_fresh_nous_tier=force_fresh_nous_tier,\n         max_models=max_models,\n         refresh=refresh,\n+        probe_custom_providers=probe_custom_providers,\n     )\n \n     moa_row = _moa_provider_row(ctx.current_provider)"]},{"file":"hermes_cli/model_switch.py","hunks":["@@ -1426,6 +1426,7 @@ def list_authenticated_providers(\n     max_models: int | None = None,\n     current_model: str = \"\",\n     refresh: bool = False,\n+    probe_custom_providers: bool = True,\n ) -> List[dict]:\n     \"\"\"Detect which providers have credentials and list their curated models.\n ","@@ -1452,6 +1453,11 @@ def list_authenticated_providers(\n     live catalog. Use for an explicit user-triggered \"refresh models\" action\n     (e.g. the desktop picker's refresh control); leave false for normal picker\n     opens so they stay snappy on the 1h cache.\n+\n+    ``probe_custom_providers`` controls live ``/models`` discovery for saved\n+    custom OpenAI-compatible endpoints. Keep the default true for CLI parity;\n+    GUI picker opens can pass false to show configured models immediately\n+    without waiting on offline local endpoints.\n     \"\"\"\n     import os\n     from agent.models_dev import (","@@ -1994,7 +2000,7 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:\n             if isinstance(discover, str):\n                 discover = discover.lower() not in {\"false\", \"no\", \"0\"}\n             has_explicit_models = bool(models_list)\n-            should_probe = bool(api_url) and discover and (\n+            should_probe = probe_custom_providers and bool(api_url) and discover and (\n                 bool(api_key) or not has_explicit_models\n             )\n             if should_probe:","@@ -2255,7 +2261,8 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:\n             #   full aggregator catalog via /models but only serve a subset\n             #   (parity with section 3's user ``providers:`` behaviour).\n             should_probe = (\n-                bool(api_url)\n+                probe_custom_providers\n+                and bool(api_url)\n                 and (bool(api_key) or not grp[\"models\"])\n                 and grp.get(\"discover_models\", True)\n             )"]},{"file":"hermes_cli/web_server.py","hunks":["@@ -4225,6 +4225,7 @@ def get_model_options(profile: Optional[str] = None, refresh: bool = False):\n                 pricing=True,\n                 capabilities=True,\n                 refresh=bool(refresh),\n+                probe_custom_providers=bool(refresh),\n             )\n     except HTTPException:\n         raise"]},{"file":"tests/hermes_cli/test_inventory.py","hunks":["@@ -219,6 +219,19 @@ def test_build_models_payload_can_force_fresh_nous_tier():\n     assert mock_list.call_args.kwargs[\"force_fresh_nous_tier\"] is True\n \n \n+def test_build_models_payload_can_skip_custom_provider_probes():\n+    ctx = _empty_ctx()\n+    rows = []\n+    with patch(\n+        \"hermes_cli.model_switch.list_authenticated_providers\",\n+        return_value=rows,\n+    ) as mock_list:\n+        build_models_payload(ctx, probe_custom_providers=False)\n+\n+    mock_list.assert_called_once()\n+    assert mock_list.call_args.kwargs[\"probe_custom_providers\"] is False\n+\n+\n def test_list_authenticated_providers_force_fresh_is_keyword_only():\n     \"\"\"``force_fresh_nous_tier`` must be keyword-only on the public listing API.\n "]},{"file":"tests/hermes_cli/test_model_switch_custom_providers.py","hunks":["@@ -45,6 +45,30 @@ def test_list_authenticated_providers_includes_custom_providers(monkeypatch):\n     )\n \n \n+def test_list_authenticated_providers_can_skip_custom_provider_live_probe(monkeypatch):\n+    monkeypatch.setattr(\"agent.models_dev.fetch_models_dev\", lambda: {})\n+    monkeypatch.setattr(providers_mod, \"HERMES_OVERLAYS\", {})\n+    fetch = lambda *a, **k: (_ for _ in ()).throw(AssertionError(\"unexpected probe\"))\n+    monkeypatch.setattr(\"hermes_cli.models.fetch_api_models\", fetch)\n+\n+    providers = list_authenticated_providers(\n+        user_providers={},\n+        custom_providers=[\n+            {\n+                \"name\": \"Slow Local\",\n+                \"base_url\": \"http://127.0.0.1:8080/v1\",\n+                \"api_key\": \"[redacted:secret-assignment]\",\n+                \"model\": \"local-model\",\n+            }\n+        ],\n+        probe_custom_providers=False,\n+    )\n+\n+    row = next(p for p in providers if p[\"slug\"] == \"custom:slow-local\")\n+    assert row[\"models\"] == [\"local-model\"]\n+    assert row[\"total_models\"] == 1\n+\n+\n def test_resolve_provider_full_finds_named_custom_provider():\n     \"\"\"Explicit /model --provider should resolve saved custom_providers entries.\"\"\"\n     resolved = resolve_provider_full("]},{"file":"tests/test_tui_gateway_server.py","hunks":["@@ -5845,6 +5845,7 @@ def test_model_options_does_not_overwrite_curated_models(monkeypatch):\n     live_fetch.assert_not_called()\n     # list_authenticated_providers is the single source.\n     assert listing.call_count == 1\n+    assert listing.call_args.kwargs[\"probe_custom_providers\"] is False\n \n \n def test_model_options_propagates_list_exception(monkeypatch):","@@ -5870,6 +5871,22 @@ def test_model_options_propagates_list_exception(monkeypatch):\n # ---------------------------------------------------------------------------\n \n \n+def test_model_options_refresh_allows_custom_provider_probes(monkeypatch):\n+    monkeypatch.setattr(\n+        server,\n+        \"_load_cfg\",\n+        lambda: {\"providers\": {}, \"custom_providers\": []},\n+    )\n+    with patch(\n+        \"hermes_cli.model_switch.list_authenticated_providers\",\n+        return_value=[],\n+    ) as listing:\n+        resp = server._methods[\"model.options\"](78, {\"session_id\": \"\", \"refresh\": True})\n+\n+    assert \"result\" in resp, resp\n+    assert listing.call_args.kwargs[\"probe_custom_providers\"] is True\n+\n+\n class _ImmediateThread:\n     \"\"\"Runs the target callable synchronously so assertions can follow.\"\"\"\n "]},{"file":"tui_gateway/server.py","hunks":["@@ -12325,6 +12325,7 @@ def _(rid, params: dict) -> dict:\n             pricing=True,\n             capabilities=True,\n             refresh=bool(params.get(\"refresh\")),\n+            probe_custom_providers=bool(params.get(\"refresh\")),\n         )\n         return _ok(rid, payload)\n     except Exception as e:\n"]}]},{"kind":"pr","number":59852,"title":"fix(tui): stop kill() from letting a stale respawn race a dying gateway child","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}