# NousResearch/hermes-agent · Offload blocking I/O calls off async event loops

## Open now

- 0 issues, 2 PRs open on GitHub.
- PR #56922: fix(desktop): fast-path model options and validate provider matches
- PR #59852: fix(tui): stop kill() from letting a stale respawn race a dying gateway child

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.

## TL;DR

- 6 member(s): 0 open issue(s), 1 closed issue(s), 2 open PR(s), 1 merged PR(s), 2 PR(s) closed without merging.
- A fix was merged to main in PR #58183. Verify it addresses your case.
- **Recommended:** read PR #58183 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `resolved` · deciding PR(s): `pr-58183`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.33`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #44560: model.options handler blocks on synchronous per-provider HTTP calls, causing WebSocket timeout

## Prior & competing PRs

- **#58183: fix(desktop): avoid probing custom providers on model picker open (salvage #49713)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - files: `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:
  - `hermes_cli/inventory.py`

    ```diff
    @@ -118,6 +118,7 @@ def build_models_payload(
         capabilities: bool = False,
         force_fresh_nous_tier: bool = False,
         refresh: bool = False,
    +    probe_custom_providers: bool = True,
         max_models: int | None = None,
     ) -> dict:
         """Build the ``{providers, model, provider}`` shape every consumer
    ```

    ```diff
    @@ -149,6 +150,11 @@ def build_models_payload(
           re-fetches its live catalog. Set only for an explicit user-triggered
           "refresh models" action; normal picker opens leave it false to stay
           snappy on the 1h cache.
    +    - ``probe_custom_providers``: allow saved custom/provider endpoints to
    +      run live ``/models`` discovery while building the payload. GUI picker
    +      opens should leave this false unless the user explicitly refreshes; the
    +      row can still render its configured model immediately, and slow/offline
    +      local endpoints no longer block the dialog.
         """
         from hermes_cli.model_switch import list_authenticated_providers
     
    ```

    ```diff
    @@ -161,6 +167,7 @@ def build_models_payload(
             force_fresh_nous_tier=force_fresh_nous_tier,
             max_models=max_models,
             refresh=refresh,
    +        probe_custom_providers=probe_custom_providers,
         )
     
         moa_row = _moa_provider_row(ctx.current_provider)
    ```
  - `hermes_cli/model_switch.py`

    ```diff
    @@ -1426,6 +1426,7 @@ def list_authenticated_providers(
         max_models: int | None = None,
         current_model: str = "",
         refresh: bool = False,
    +    probe_custom_providers: bool = True,
     ) -> List[dict]:
         """Detect which providers have credentials and list their curated models.
     
    ```

    ```diff
    @@ -1452,6 +1453,11 @@ def list_authenticated_providers(
         live catalog. Use for an explicit user-triggered "refresh models" action
         (e.g. the desktop picker's refresh control); leave false for normal picker
         opens so they stay snappy on the 1h cache.
    +
    +    ``probe_custom_providers`` controls live ``/models`` discovery for saved
    +    custom OpenAI-compatible endpoints. Keep the default true for CLI parity;
    +    GUI picker opens can pass false to show configured models immediately
    +    without waiting on offline local endpoints.
         """
         import os
         from agent.models_dev import (
    ```

    ```diff
    @@ -1994,7 +2000,7 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:
                 if isinstance(discover, str):
                     discover = discover.lower() not in {"false", "no", "0"}
                 has_explicit_models = bool(models_list)
    -            should_probe = bool(api_url) and discover and (
    +            should_probe = probe_custom_providers and bool(api_url) and discover and (
                     bool(api_key) or not has_explicit_models
                 )
                 if should_probe:
    ```

    ```diff
    @@ -2255,7 +2261,8 @@ def _has_aws_sdk_creds_for_listing(slug: str) -> bool:
                 #   full aggregator catalog via /models but only serve a subset
                 #   (parity with section 3's user ``providers:`` behaviour).
                 should_probe = (
    -                bool(api_url)
    +                probe_custom_providers
    +                and bool(api_url)
                     and (bool(api_key) or not grp["models"])
                     and grp.get("discover_models", True)
                 )
    ```
  - `hermes_cli/web_server.py`

    ```diff
    @@ -4225,6 +4225,7 @@ def get_model_options(profile: Optional[str] = None, refresh: bool = False):
                     pricing=True,
                     capabilities=True,
                     refresh=bool(refresh),
    +                probe_custom_providers=bool(refresh),
                 )
         except HTTPException:
             raise
    ```
  - `tests/hermes_cli/test_inventory.py`

    ```diff
    @@ -219,6 +219,19 @@ def test_build_models_payload_can_force_fresh_nous_tier():
         assert mock_list.call_args.kwargs["force_fresh_nous_tier"] is True
     
     
    +def test_build_models_payload_can_skip_custom_provider_probes():
    +    ctx = _empty_ctx()
    +    rows = []
    +    with patch(
    +        "hermes_cli.model_switch.list_authenticated_providers",
    +        return_value=rows,
    +    ) as mock_list:
    +        build_models_payload(ctx, probe_custom_providers=False)
    +
    +    mock_list.assert_called_once()
    +    assert mock_list.call_args.kwargs["probe_custom_providers"] is False
    +
    +
     def test_list_authenticated_providers_force_fresh_is_keyword_only():
         """``force_fresh_nous_tier`` must be keyword-only on the public listing API.
     
    ```
  - `tests/hermes_cli/test_model_switch_custom_providers.py`

    ```diff
    @@ -45,6 +45,30 @@ def test_list_authenticated_providers_includes_custom_providers(monkeypatch):
         )
     
     
    +def test_list_authenticated_providers_can_skip_custom_provider_live_probe(monkeypatch):
    +    monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
    +    monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
    +    fetch = lambda *a, **k: (_ for _ in ()).throw(AssertionError("unexpected probe"))
    +    monkeypatch.setattr("hermes_cli.models.fetch_api_models", fetch)
    +
    +    providers = list_authenticated_providers(
    +        user_providers={},
    +        custom_providers=[
    +            {
    +                "name": "Slow Local",
    +                "base_url": "http://127.0.0.1:8080/v1",
    +                "api_key": "[redacted:secret-assignment]",
    +                "model": "local-model",
    +            }
    +        ],
    +        probe_custom_providers=False,
    +    )
    +
    +    row = next(p for p in providers if p["slug"] == "custom:slow-local")
    +    assert row["models"] == ["local-model"]
    +    assert row["total_models"] == 1
    +
    +
     def test_resolve_provider_full_finds_named_custom_provider():
         """Explicit /model --provider should resolve saved custom_providers entries."""
         resolved = resolve_provider_full(
    ```
  - `tests/test_tui_gateway_server.py`

    ```diff
    @@ -5845,6 +5845,7 @@ def test_model_options_does_not_overwrite_curated_models(monkeypatch):
         live_fetch.assert_not_called()
         # list_authenticated_providers is the single source.
         assert listing.call_count == 1
    +    assert listing.call_args.kwargs["probe_custom_providers"] is False
     
     
     def test_model_options_propagates_list_exception(monkeypatch):
    ```

    ```diff
    @@ -5870,6 +5871,22 @@ def test_model_options_propagates_list_exception(monkeypatch):
     # ---------------------------------------------------------------------------
     
     
    +def test_model_options_refresh_allows_custom_provider_probes(monkeypatch):
    +    monkeypatch.setattr(
    +        server,
    +        "_load_cfg",
    +        lambda: {"providers": {}, "custom_providers": []},
    +    )
    +    with patch(
    +        "hermes_cli.model_switch.list_authenticated_providers",
    +        return_value=[],
    +    ) as listing:
    +        resp = server._methods["model.options"](78, {"session_id": "", "refresh": True})
    +
    +    assert "result" in resp, resp
    +    assert listing.call_args.kwargs["probe_custom_providers"] is True
    +
    +
     class _ImmediateThread:
         """Runs the target callable synchronously so assertions can follow."""
     
    ```
  - `tui_gateway/server.py`

    ```diff
    @@ -12325,6 +12325,7 @@ def _(rid, params: dict) -> dict:
                 pricing=True,
                 capabilities=True,
                 refresh=bool(params.get("refresh")),
    +            probe_custom_providers=bool(params.get("refresh")),
             )
             return _ok(rid, payload)
         except Exception as e:
    ```
- **#44577: fix(model-switch): cache custom provider model discovery to unblock WebSocket handler**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#58183`, `#49713`
- **#44598: fix(gateway): cache fetch_api_models per-provider with TTL and cap timeout to prevent model.options WS timeout**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#58183`, `#49713`
- **#56922: fix(desktop): fast-path model options and validate provider matches**. open
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `#58183`, `#49713`
  - files: `apps/desktop/src/app/session/hooks/use-model-controls.test.tsx`, `apps/desktop/src/app/session/hooks/use-model-controls.ts`, `hermes_cli/inventory.py`, `hermes_cli/model_switch.py`, `hermes_cli/web_server.py`, `tests/hermes_cli/test_model_options_fast_path.py`, `tui_gateway/server.py`
- **#59852: fix(tui): stop kill() from letting a stale respawn race a dying gateway child**. open
  - maintainer direction: category `uncategorized`; implication `needs_llm`
  - files: `ui-tui/src/__tests__/gatewayClientSpawn.test.ts`, `ui-tui/src/gatewayClient.ts`

## Fix status

- Merged to main in PR #58183 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-04`.
- Subsystem: `apps/desktop`
