From 4b4f0588605a76bcd7ada14fba509f049c4d58cc Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Mon, 6 Jul 2026 13:06:37 -0600 Subject: [PATCH] fix(tui): probe active custom model provider --- hermes_cli/inventory.py | 6 ++ hermes_cli/model_switch.py | 54 +++++++++++---- hermes_cli/web_server.py | 1 + tests/hermes_cli/test_inventory.py | 18 +++++ .../test_model_switch_custom_providers.py | 66 +++++++++++++++++++ tests/test_tui_gateway_server.py | 2 + tui_gateway/server.py | 1 + 7 files changed, 137 insertions(+), 11 deletions(-) diff --git a/hermes_cli/inventory.py b/hermes_cli/inventory.py index 51569c73b2c..ad279ca4f72 100644 --- a/hermes_cli/inventory.py +++ b/hermes_cli/inventory.py @@ -119,6 +119,7 @@ def build_models_payload( force_fresh_nous_tier: bool = False, refresh: bool = False, probe_custom_providers: bool = True, + probe_current_custom_provider: bool = False, max_models: int | None = None, ) -> dict: """Build the ``{providers, model, provider}`` shape every consumer @@ -155,6 +156,10 @@ def build_models_payload( 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. + - ``probe_current_custom_provider``: when ``probe_custom_providers`` is + false, still live-probe the current custom endpoint. This keeps normal + GUI/TUI picker opens fast while making the active custom provider's model + list match the classic CLI picker. """ from hermes_cli.model_switch import list_authenticated_providers @@ -168,6 +173,7 @@ def build_models_payload( max_models=max_models, refresh=refresh, probe_custom_providers=probe_custom_providers, + probe_current_custom_provider=probe_current_custom_provider, ) moa_row = _moa_provider_row(ctx.current_provider) diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 638ecf9d50f..905cb46cd90 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1455,6 +1455,7 @@ def list_authenticated_providers( current_model: str = "", refresh: bool = False, probe_custom_providers: bool = True, + probe_current_custom_provider: bool = False, ) -> List[dict]: """Detect which providers have credentials and list their curated models. @@ -1486,6 +1487,11 @@ def list_authenticated_providers( 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. + + ``probe_current_custom_provider`` is the middle ground for GUI picker + opens: probe only the currently-selected custom endpoint so its model list + matches the active provider without blocking on every saved/offline custom + endpoint. """ import os from agent.models_dev import ( @@ -1515,6 +1521,12 @@ def list_authenticated_providers( results: List[dict] = [] seen_slugs: set = set() # lowercase-normalized to catch case variants (#9545) seen_mdev_ids: set = set() # prevent duplicate entries for aliases (e.g. kimi-coding + kimi-coding-cn) + _current_provider_norm = str(current_provider or "").strip().lower() + _current_base_url_norm = str(current_base_url or "").strip().rstrip("/").lower() + + def _can_probe_custom_provider(*, row_is_current: bool) -> bool: + return bool(probe_custom_providers or (probe_current_custom_provider and row_is_current)) + # Effective base URLs of every built-in row we emit (normalized lower+rstrip). # Section 4 uses this to hide ``custom_providers`` entries that point at the # same endpoint as a built-in (e.g. a user-defined "my-dashscope" on @@ -2021,7 +2033,19 @@ def list_authenticated_providers( if isinstance(discover, str): discover = discover.lower() not in {"false", "no", "0"} has_explicit_models = bool(models_list) - should_probe = probe_custom_providers and bool(api_url) and discover and ( + _ep_url_norm = str(api_url).strip().rstrip("/").lower() + _ep_slug_norm = str(ep_name).strip().lower() + _ep_custom_slug_norm = custom_provider_slug(display_name).lower() + _ep_is_current = ( + _ep_slug_norm == _current_provider_norm + or _ep_custom_slug_norm == _current_provider_norm + or ( + _current_provider_norm == "custom" + and bool(_current_base_url_norm) + and _ep_url_norm == _current_base_url_norm + ) + ) + should_probe = _can_probe_custom_provider(row_is_current=_ep_is_current) and bool(api_url) and discover and ( bool(api_key) or not has_explicit_models ) if should_probe: @@ -2040,7 +2064,7 @@ def list_authenticated_providers( results.append({ "slug": ep_name, "name": display_name, - "is_current": ep_name == current_provider, + "is_current": _ep_is_current, "is_user_defined": True, "models": models_list, "total_models": len(models_list) if models_list else 0, @@ -2064,7 +2088,6 @@ def list_authenticated_providers( # picker to render, but the gateway only passes this current model slice to # list_authenticated_providers(). Surface the active endpoint explicitly so # /model does not look like it ignored config.yaml. - _current_provider_norm = str(current_provider or "").strip().lower() if ( _current_provider_norm == "custom" and current_base_url @@ -2081,6 +2104,15 @@ def list_authenticated_providers( ) ): _models = [current_model] if current_model else [] + if _can_probe_custom_provider(row_is_current=True): + try: + from hermes_cli.models import fetch_api_models + + _live_models = fetch_api_models("", str(current_base_url).strip().rstrip("/")) + if _live_models: + _models = _live_models + except Exception: + pass results.append({ "slug": "custom", "name": "Custom endpoint", @@ -2203,7 +2235,6 @@ def list_authenticated_providers( groups[group_key]["models"].append(model_id) _section4_emitted_slugs: set = set() - _current_base_url_norm = str(current_base_url or "").strip().rstrip("/").lower() _current_base_url_group_count = sum( 1 for _grp in groups.values() @@ -2275,8 +2306,14 @@ def list_authenticated_providers( # api_key is present. This supports endpoints that expose a # full aggregator catalog via /models but only serve a subset # (parity with section 3's user ``providers:`` behaviour). + _grp_is_current = slug.lower() == _current_provider_norm or ( + _current_provider_norm == "custom" + and bool(_current_base_url_norm) + and _grp_url_norm == _current_base_url_norm + and _current_base_url_group_count == 1 + ) should_probe = ( - probe_custom_providers + _can_probe_custom_provider(row_is_current=_grp_is_current) and bool(api_url) and (bool(api_key) or not grp["models"]) and grp.get("discover_models", True) @@ -2298,12 +2335,7 @@ def list_authenticated_providers( results.append({ "slug": slug, "name": grp["name"], - "is_current": slug == current_provider or ( - current_provider == "custom" - and bool(_current_base_url_norm) - and _grp_url_norm == _current_base_url_norm - and _current_base_url_group_count == 1 - ), + "is_current": _grp_is_current, "is_user_defined": True, "models": grp["models"], "total_models": len(grp["models"]), diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index dc14423dcc2..a5292224cd5 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -4305,6 +4305,7 @@ def get_model_options(profile: Optional[str] = None, refresh: bool = False): capabilities=True, refresh=bool(refresh), probe_custom_providers=bool(refresh), + probe_current_custom_provider=not bool(refresh), ) except HTTPException: raise diff --git a/tests/hermes_cli/test_inventory.py b/tests/hermes_cli/test_inventory.py index 386cda0e0da..09a839f1c2e 100644 --- a/tests/hermes_cli/test_inventory.py +++ b/tests/hermes_cli/test_inventory.py @@ -258,6 +258,24 @@ def test_build_models_payload_can_skip_custom_provider_probes(): assert mock_list.call_args.kwargs["probe_custom_providers"] is False +def test_build_models_payload_can_probe_only_current_custom_provider(): + 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, + probe_current_custom_provider=True, + ) + + mock_list.assert_called_once() + assert mock_list.call_args.kwargs["probe_custom_providers"] is False + assert mock_list.call_args.kwargs["probe_current_custom_provider"] is True + + def test_list_authenticated_providers_force_fresh_is_keyword_only(): """``force_fresh_nous_tier`` must be keyword-only on the public listing API. diff --git a/tests/hermes_cli/test_model_switch_custom_providers.py b/tests/hermes_cli/test_model_switch_custom_providers.py index a1ec07117f2..4d80469b975 100644 --- a/tests/hermes_cli/test_model_switch_custom_providers.py +++ b/tests/hermes_cli/test_model_switch_custom_providers.py @@ -69,6 +69,49 @@ def test_list_authenticated_providers_can_skip_custom_provider_live_probe(monkey assert row["total_models"] == 1 +def test_list_authenticated_providers_can_probe_only_current_custom_provider(monkeypatch): + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {}) + + calls = [] + + def fetch(api_key, api_url, **kwargs): + calls.append(api_url) + if api_url == "http://active.local/v1": + return ["active-a", "active-b"] + raise AssertionError(f"unexpected probe for {api_url}") + + monkeypatch.setattr("hermes_cli.models.fetch_api_models", fetch) + + providers = list_authenticated_providers( + current_provider="custom:active-proxy", + user_providers={}, + custom_providers=[ + { + "name": "Active Proxy", + "base_url": "http://active.local/v1", + "api_key": "sk-active", + "model": "configured-active", + }, + { + "name": "Offline Proxy", + "base_url": "http://offline.local/v1", + "api_key": "sk-offline", + "model": "configured-offline", + }, + ], + probe_custom_providers=False, + probe_current_custom_provider=True, + ) + + active = next(p for p in providers if p["slug"] == "custom:active-proxy") + offline = next(p for p in providers if p["slug"] == "custom:offline-proxy") + assert calls == ["http://active.local/v1"] + assert active["is_current"] is True + assert active["models"] == ["active-a", "active-b"] + assert offline["models"] == ["configured-offline"] + + def test_resolve_provider_full_finds_named_custom_provider(): """Explicit /model --provider should resolve saved custom_providers entries.""" resolved = resolve_provider_full( @@ -119,6 +162,29 @@ def test_list_authenticated_providers_includes_active_bare_custom_endpoint(monke assert bare_custom["api_url"] == "https://www.ccsub.net/v1" +def test_list_authenticated_providers_can_probe_active_bare_custom_endpoint(monkeypatch): + monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {}) + monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {}) + monkeypatch.setattr( + "hermes_cli.models.fetch_api_models", + lambda api_key, api_url, **kwargs: ["gpt-4o", "gpt-4o-mini"], + ) + + providers = list_authenticated_providers( + current_provider="custom", + current_base_url="https://www.ccsub.net/v1", + current_model="gpt-4o", + user_providers={}, + custom_providers=[], + probe_custom_providers=False, + probe_current_custom_provider=True, + ) + + bare_custom = next(p for p in providers if p["slug"] == "custom") + assert bare_custom["is_current"] is True + assert bare_custom["models"] == ["gpt-4o", "gpt-4o-mini"] + + def test_switch_model_accepts_explicit_bare_custom_current_endpoint(monkeypatch): """Picker selections for bare custom endpoints should route to current base_url.""" monkeypatch.setattr("hermes_cli.models.validate_requested_model", lambda *a, **k: _MOCK_VALIDATION) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index a74c7d2a322..0933be0213f 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -5963,6 +5963,7 @@ def test_model_options_does_not_overwrite_curated_models(monkeypatch): # list_authenticated_providers is the single source. assert listing.call_count == 1 assert listing.call_args.kwargs["probe_custom_providers"] is False + assert listing.call_args.kwargs["probe_current_custom_provider"] is True def test_model_options_propagates_list_exception(monkeypatch): @@ -6002,6 +6003,7 @@ def test_model_options_refresh_allows_custom_provider_probes(monkeypatch): assert "result" in resp, resp assert listing.call_args.kwargs["probe_custom_providers"] is True + assert listing.call_args.kwargs["probe_current_custom_provider"] is False class _ImmediateThread: diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 1b2156f4c20..db9826bbb2f 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -12399,6 +12399,7 @@ def _(rid, params: dict) -> dict: capabilities=True, refresh=bool(params.get("refresh")), probe_custom_providers=bool(params.get("refresh")), + probe_current_custom_provider=not bool(params.get("refresh")), ) return _ok(rid, payload) except Exception as e: