diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 7be692fcad690..5db37f83fa6ef 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -26,6 +26,7 @@ from hermes_cli.config import ( from hermes_cli.colors import Colors, color from hermes_cli.nous_subscription import ( MANAGED_FEATURE_COVERAGE_CATEGORY, + NousSubscriptionFeatures, apply_nous_managed_defaults, get_nous_subscription_features, ) @@ -2618,6 +2619,7 @@ def _toolset_has_keys( config: dict = None, *, force_fresh: bool = False, + features: Optional[NousSubscriptionFeatures] = None, ) -> bool: """Check if a toolset's required API keys are configured.""" if config is None: @@ -2633,7 +2635,10 @@ def _toolset_has_keys( return False if ts_key in {"web", "image_gen", "video_gen", "tts", "stt", "browser"}: - features = get_nous_subscription_features(config, force_fresh=force_fresh) + if features is None: + features = get_nous_subscription_features( + config, force_fresh=force_fresh + ) feature = features.features.get(ts_key) if feature and (feature.available or feature.managed_by_nous): return True @@ -2641,7 +2646,12 @@ def _toolset_has_keys( # Check TOOL_CATEGORIES first (provider-aware) cat = TOOL_CATEGORIES.get(ts_key) if cat: - for provider in _visible_providers(cat, config, force_fresh=force_fresh): + for provider in _visible_providers( + cat, + config, + force_fresh=force_fresh, + features=features, + ): env_vars = provider.get("env_vars", []) if not env_vars: return True # No-key provider (e.g. Local Browser, Edge TTS) @@ -3076,6 +3086,7 @@ def _visible_providers( config: dict, *, force_fresh: bool = False, + features: Optional[NousSubscriptionFeatures] = None, ) -> list[dict]: """Return provider entries visible for the current auth/config state. @@ -3085,7 +3096,8 @@ def _visible_providers( login + entitlement check (see ``_configure_provider``); the row only *activates* the gateway once paid access is confirmed. """ - features = get_nous_subscription_features(config, force_fresh=force_fresh) + if features is None: + features = get_nous_subscription_features(config, force_fresh=force_fresh) acct = features.account_info # Pool-only users (entitled to managed tools via the free tool pool but with # no paid access) get image gen but NOT video gen — the pool doesn't fund diff --git a/hermes_cli/web_routers/tools.py b/hermes_cli/web_routers/tools.py index e96baa6f6b547..0fbb055cf55a2 100644 --- a/hermes_cli/web_routers/tools.py +++ b/hermes_cli/web_routers/tools.py @@ -58,6 +58,7 @@ async def get_toolsets(profile: Optional[str] = None): _get_platform_tools, _toolset_configuration_platform, _toolset_has_keys, + get_nous_subscription_features, gui_toolset_label, ) from hermes_cli.platforms import platform_label @@ -77,6 +78,7 @@ async def get_toolsets(profile: Optional[str] = None): ) for platform in target_platforms } + features = get_nous_subscription_features(config) result = [] for name, label, desc in toolset_rows: try: @@ -104,7 +106,7 @@ async def get_toolsets(profile: Optional[str] = None): ), "enabled": is_enabled, "available": is_enabled, - "configured": _toolset_has_keys(name, config), + "configured": _toolset_has_keys(name, config, features=features), "tools": tools, }) return result diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index 09af0c4e117a4..339e498503031 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -6,7 +6,8 @@ from unittest.mock import patch import pytest -from hermes_cli.nous_account import NousPortalAccountInfo +from hermes_cli.nous_account import NousPortalAccountInfo, NousToolAccessInfo +from hermes_cli.nous_subscription import NousSubscriptionFeatures from hermes_cli.tools_config import ( _DEFAULT_OFF_TOOLSETS, _RECENTLY_SHIPPED_TOOLSETS, @@ -544,6 +545,74 @@ def _fake_features(*, logged_in: bool, paid: bool = True): return SimpleNamespace(nous_auth_present=logged_in, account_info=account) +def test_visible_providers_reuses_logged_out_feature_snapshot(monkeypatch): + import hermes_cli.tools_config as tools_config + + account = NousPortalAccountInfo( + logged_in=False, + source="none", + fresh=False, + paid_service_access=None, + ) + features = NousSubscriptionFeatures( + subscribed=False, + nous_auth_present=False, + provider_is_nous=False, + features={}, + account_info=account, + ) + monkeypatch.setattr( + tools_config, + "get_nous_subscription_features", + lambda *args, **kwargs: pytest.fail("feature snapshot was resolved again"), + ) + + providers = _visible_providers( + TOOL_CATEGORIES["image_gen"], {}, features=features + ) + + assert any( + provider.get("managed_nous_feature") == "image_gen" + for provider in providers + ) + + +def test_visible_providers_reuses_pool_video_feature_snapshot(monkeypatch): + import hermes_cli.tools_config as tools_config + + account = NousPortalAccountInfo( + logged_in=True, + source="jwt", + fresh=False, + paid_service_access=False, + tool_access=NousToolAccessInfo( + enabled=True, + coverage={"fal-video": False}, + ), + ) + features = NousSubscriptionFeatures( + subscribed=True, + nous_auth_present=True, + provider_is_nous=False, + features={}, + account_info=account, + ) + monkeypatch.setattr( + tools_config, + "get_nous_subscription_features", + lambda *args, **kwargs: pytest.fail("feature snapshot was resolved again"), + ) + + providers = _visible_providers( + TOOL_CATEGORIES["video_gen"], {}, features=features + ) + + assert not any( + provider.get("managed_nous_feature") == "video_gen" + for provider in providers + ) + + # ── Windows console-flash guard for post-setup subprocess spawns ────────────── diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index c7f3c5fc85da8..f29284c0ae9c5 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -1948,6 +1948,36 @@ class TestNewEndpoints: config["platform_toolsets"]["discord"] ) + def test_toolsets_resolve_subscription_features_once(self, monkeypatch): + import hermes_cli.tools_config as tools_config + from hermes_cli.nous_subscription import NousSubscriptionFeatures + + calls = 0 + features = NousSubscriptionFeatures( + subscribed=False, + nous_auth_present=False, + provider_is_nous=False, + features={}, + account_info=None, + ) + + def resolve_features(config, *, force_fresh=False): + nonlocal calls + calls += 1 + return features + + monkeypatch.setattr( + tools_config, + "get_nous_subscription_features", + resolve_features, + ) + + resp = self.client.get("/api/tools/toolsets") + + assert resp.status_code == 200 + assert resp.json() + assert calls == 1 + def test_get_toolset_config_returns_provider_matrix(self): """GET .../config returns provider rows with structured env_vars."""