6bf4375755
- Added support for keyless Tavily integration in the onboarding flow, allowing it to be recognized as available without an API key.
399 lines
14 KiB
Python
399 lines
14 KiB
Python
"""Tests for Nous subscription feature detection."""
|
|
|
|
import shutil
|
|
import sys
|
|
|
|
from hermes_cli.nous_account import NousPortalAccountInfo, NousToolAccessInfo
|
|
from hermes_cli import nous_subscription as ns
|
|
|
|
|
|
_POOL_COVERAGE = {
|
|
"firecrawl": True,
|
|
"fal": True,
|
|
"fal-video": False,
|
|
"openai-audio": True,
|
|
"browser-use": True,
|
|
"modal": True,
|
|
}
|
|
|
|
|
|
def _account(*, logged_in: bool, paid: bool | None = None) -> NousPortalAccountInfo:
|
|
return NousPortalAccountInfo(
|
|
logged_in=logged_in,
|
|
source="jwt" if logged_in else "none",
|
|
fresh=False,
|
|
paid_service_access=paid,
|
|
)
|
|
|
|
|
|
def _pool_account() -> NousPortalAccountInfo:
|
|
"""A $0 subscriber with a live free tool pool (no paid access)."""
|
|
return NousPortalAccountInfo(
|
|
logged_in=True,
|
|
source="jwt",
|
|
fresh=False,
|
|
paid_service_access=False,
|
|
tool_access=NousToolAccessInfo(enabled=True, coverage=_POOL_COVERAGE),
|
|
)
|
|
|
|
|
|
def test_get_nous_subscription_features_recognizes_direct_exa_backend(monkeypatch):
|
|
env = {"EXA_API_KEY": "exa-test"}
|
|
|
|
monkeypatch.setattr(ns, "get_env_value", lambda name: env.get(name, ""))
|
|
monkeypatch.setattr(
|
|
ns, "get_nous_portal_account_info", lambda: _account(logged_in=False)
|
|
)
|
|
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "web")
|
|
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
|
|
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
|
|
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
|
|
|
|
features = ns.get_nous_subscription_features({"web": {"backend": "exa"}})
|
|
|
|
assert features.web.available is True
|
|
assert features.web.active is True
|
|
assert features.web.managed_by_nous is False
|
|
assert features.web.direct_override is True
|
|
assert features.web.current_provider == "exa"
|
|
|
|
|
|
def test_get_nous_subscription_features_recognizes_keyless_tavily_backend(monkeypatch):
|
|
"""Selecting Tavily in setup/tools counts as available with no API key.
|
|
|
|
Mirrors tools.web_tools._is_backend_available('tavily'): keyless is
|
|
opt-in via web.backend / search_backend / extract_backend, not a
|
|
silent empty-install default. The setup summary previously required
|
|
TAVILY_API_KEY and printed a false 'missing' after a skipped key prompt.
|
|
"""
|
|
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
|
|
monkeypatch.setattr(
|
|
ns, "get_nous_portal_account_info", lambda: _account(logged_in=False)
|
|
)
|
|
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "web")
|
|
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
|
|
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
|
|
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
|
|
|
|
features = ns.get_nous_subscription_features({"web": {"backend": "tavily"}})
|
|
|
|
assert features.web.available is True
|
|
assert features.web.active is True
|
|
assert features.web.managed_by_nous is False
|
|
assert features.web.direct_override is True
|
|
assert features.web.current_provider == "tavily"
|
|
assert features.web.explicit_configured is True
|
|
|
|
|
|
def test_keyless_tavily_search_backend_without_shared_backend(monkeypatch):
|
|
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
|
|
monkeypatch.setattr(
|
|
ns, "get_nous_portal_account_info", lambda: _account(logged_in=False)
|
|
)
|
|
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "web")
|
|
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
|
|
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
|
|
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
|
|
|
|
features = ns.get_nous_subscription_features(
|
|
{"web": {"search_backend": "tavily"}}
|
|
)
|
|
|
|
assert features.web.available is True
|
|
assert features.web.active is True
|
|
assert features.web.current_provider == "tavily"
|
|
|
|
|
|
def test_unconfigured_web_without_keys_is_unavailable(monkeypatch):
|
|
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
|
|
monkeypatch.setattr(
|
|
ns, "get_nous_portal_account_info", lambda: _account(logged_in=False)
|
|
)
|
|
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "web")
|
|
monkeypatch.setattr(ns, "_has_agent_browser", lambda: False)
|
|
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
|
|
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
|
|
|
|
features = ns.get_nous_subscription_features({})
|
|
|
|
assert features.web.available is False
|
|
assert features.web.active is False
|
|
assert features.web.explicit_configured is False
|
|
def _stub_browser_probes(monkeypatch, *, has_agent_browser, chromium, lightpanda=False):
|
|
"""Common monkeypatches for local-browser readiness scenarios.
|
|
|
|
``chromium`` / ``lightpanda`` drive the runtime probes that
|
|
``_local_browser_runnable`` reuses from ``tools.browser_tool`` (lazy import,
|
|
so patching the module attributes is enough).
|
|
"""
|
|
monkeypatch.setattr(ns, "get_env_value", lambda name: "")
|
|
monkeypatch.setattr(
|
|
ns, "get_nous_portal_account_info", lambda: _account(logged_in=False)
|
|
)
|
|
monkeypatch.setattr(ns, "_toolset_enabled", lambda config, key: key == "browser")
|
|
monkeypatch.setattr(ns, "_has_agent_browser", lambda: has_agent_browser)
|
|
monkeypatch.setattr(ns, "resolve_openai_audio_api_key", lambda: "")
|
|
monkeypatch.setattr(ns, "has_direct_modal_credentials", lambda: False)
|
|
monkeypatch.setattr(ns, "is_managed_tool_gateway_ready", lambda vendor: False)
|
|
monkeypatch.setattr("tools.browser_tool._chromium_installed", lambda: chromium)
|
|
monkeypatch.setattr(
|
|
"tools.browser_tool._using_lightpanda_engine", lambda: lightpanda
|
|
)
|
|
|
|
|
|
def test_local_browser_unavailable_without_chromium(monkeypatch):
|
|
"""agent-browser present but Chromium absent must NOT advertise local browser.
|
|
|
|
The runtime (``check_browser_requirements``) refuses local mode without a
|
|
Chromium build, so the setup/status surface must report unavailable too —
|
|
otherwise the user sees "Browser Automation available" and the first real
|
|
call fails. Regression for the false-positive setup bug.
|
|
"""
|
|
_stub_browser_probes(monkeypatch, has_agent_browser=True, chromium=False)
|
|
|
|
features = ns.get_nous_subscription_features(
|
|
{"browser": {"cloud_provider": "local"}}
|
|
)
|
|
|
|
assert features.browser.available is False
|
|
assert features.browser.active is False
|
|
assert features.browser.managed_by_nous is False
|
|
assert features.browser.current_provider == "Local browser"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _capture_checklist(monkeypatch, *, selected_idx):
|
|
"""Patch prompt_checklist to capture its args and return chosen indices."""
|
|
captured = {}
|
|
|
|
def _fake_checklist(title, items, pre_selected=None):
|
|
captured["title"] = title
|
|
captured["items"] = list(items)
|
|
captured["pre_selected"] = list(pre_selected or [])
|
|
return list(selected_idx)
|
|
|
|
import hermes_cli.setup as setup_mod
|
|
|
|
monkeypatch.setattr(setup_mod, "prompt_checklist", _fake_checklist, raising=False)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.config.save_config", lambda cfg: None, raising=False
|
|
)
|
|
return captured
|
|
|
|
|
|
def test_prompt_enable_tool_gateway_pool_offers_covered_tools_only(monkeypatch):
|
|
"""Pool user's checklist lists web/image/tts/browser and never video."""
|
|
monkeypatch.setattr(ns, "get_nous_portal_account_info", lambda **kw: _pool_account())
|
|
monkeypatch.setattr(
|
|
ns,
|
|
"_get_gateway_direct_credentials",
|
|
lambda: {"web": False, "image_gen": False, "video_gen": False, "tts": False, "browser": False},
|
|
)
|
|
captured = _capture_checklist(monkeypatch, selected_idx=[])
|
|
|
|
config = {"model": {"provider": "nous"}}
|
|
ns.prompt_enable_tool_gateway(config)
|
|
|
|
blob = " ".join(captured["items"]).lower()
|
|
assert "firecrawl" in blob # web offered
|
|
assert "video" not in blob # video NOT offered to a pool user
|
|
# Pool-aware framing, not "subscription".
|
|
assert "free" in captured["title"].lower() and "pool" in captured["title"].lower()
|
|
|
|
|
|
|
|
|
|
def test_apply_nous_managed_defaults_writes_video_gen_config(monkeypatch):
|
|
"""apply_nous_managed_defaults must store the managed 'nous' selection
|
|
when a Nous subscriber selects video_gen without a direct FAL_KEY."""
|
|
monkeypatch.setattr(ns, "managed_nous_tools_enabled", lambda **kw: True)
|
|
monkeypatch.delenv("FAL_KEY", raising=False)
|
|
monkeypatch.setattr(ns, "fal_key_is_configured", lambda: False)
|
|
monkeypatch.setattr(
|
|
ns, "get_nous_portal_account_info",
|
|
lambda **kw: _account(logged_in=True, paid=True),
|
|
)
|
|
|
|
config = {"model": {"provider": "nous"}}
|
|
changed = ns.apply_nous_managed_defaults(
|
|
config, enabled_toolsets=["video_gen"],
|
|
)
|
|
|
|
assert "video_gen" in changed
|
|
assert config["video_gen"]["provider"] == "nous"
|
|
assert "use_gateway" not in config["video_gen"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# ensure_nous_portal_access — inline login gate for `hermes tools`
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# STT — managed-by-Nous detection (Phase 4 follow-up)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
def _stt_features_stub(*, account_info):
|
|
return ns.NousSubscriptionFeatures(
|
|
subscribed=True,
|
|
nous_auth_present=True,
|
|
provider_is_nous=True,
|
|
account_info=account_info,
|
|
features={
|
|
key: ns.NousFeatureState(
|
|
key=key, label=key, included_by_default=True,
|
|
available=False, active=False, managed_by_nous=False,
|
|
direct_override=False, toolset_enabled=False,
|
|
explicit_configured=False,
|
|
)
|
|
for key in ("web", "image_gen", "video_gen", "tts", "stt", "browser", "modal")
|
|
},
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _block_legacy_agent_browser_checks(monkeypatch):
|
|
"""Make the legacy checks (PATH lookup + local node_modules/.bin) find nothing."""
|
|
real_which = shutil.which
|
|
monkeypatch.setattr(
|
|
shutil,
|
|
"which",
|
|
lambda cmd, *args, **kwargs: (
|
|
None if cmd == "agent-browser" else real_which(cmd, *args, **kwargs)
|
|
),
|
|
)
|
|
monkeypatch.setattr("hermes_constants.agent_browser_runnable", lambda path: False)
|
|
|
|
|
|
def test_has_agent_browser_true_for_npx_only_resolution(monkeypatch):
|
|
"""No PATH binary and no runnable node_modules copy, but the browser_tool
|
|
cascade resolves the npx fallback: browser capability is available."""
|
|
_block_legacy_agent_browser_checks(monkeypatch)
|
|
import tools.browser_tool as browser_tool
|
|
|
|
calls = []
|
|
|
|
def fake_find_agent_browser(*, validate=True):
|
|
calls.append({"validate": validate})
|
|
return "npx agent-browser"
|
|
|
|
monkeypatch.setattr(browser_tool, "_find_agent_browser", fake_find_agent_browser)
|
|
monkeypatch.setattr(
|
|
browser_tool, "_requires_real_termux_browser_install", lambda cmd: False
|
|
)
|
|
|
|
assert ns._has_agent_browser() is True
|
|
# A readiness probe must resolve without spawning the daemon.
|
|
assert calls and all(call["validate"] is False for call in calls)
|
|
|
|
|
|
def test_has_agent_browser_false_for_termux_local_bare_npx(monkeypatch):
|
|
"""On Termux in local mode the bare npx fallback is not a usable install."""
|
|
_block_legacy_agent_browser_checks(monkeypatch)
|
|
import tools.browser_tool as browser_tool
|
|
|
|
monkeypatch.setattr(
|
|
browser_tool,
|
|
"_find_agent_browser",
|
|
lambda *, validate=True: "npx agent-browser",
|
|
)
|
|
monkeypatch.setattr(
|
|
browser_tool,
|
|
"_requires_real_termux_browser_install",
|
|
lambda cmd: cmd.strip() == "npx agent-browser",
|
|
)
|
|
|
|
assert ns._has_agent_browser() is False
|
|
|
|
|
|
def test_has_agent_browser_false_when_nothing_resolvable(monkeypatch):
|
|
_block_legacy_agent_browser_checks(monkeypatch)
|
|
import tools.browser_tool as browser_tool
|
|
|
|
def raise_not_found(*, validate=True):
|
|
raise FileNotFoundError("agent-browser CLI not found")
|
|
|
|
monkeypatch.setattr(browser_tool, "_find_agent_browser", raise_not_found)
|
|
|
|
assert ns._has_agent_browser() is False
|
|
|
|
|
|
def test_has_agent_browser_import_failure_falls_back_to_path_check(monkeypatch):
|
|
"""If tools.browser_tool cannot be imported, the old PATH + node_modules
|
|
check must still answer (prior behaviour), not crash."""
|
|
monkeypatch.setitem(sys.modules, "tools.browser_tool", None)
|
|
real_which = shutil.which
|
|
monkeypatch.setattr(
|
|
shutil,
|
|
"which",
|
|
lambda cmd, *args, **kwargs: (
|
|
"/fake/bin/agent-browser"
|
|
if cmd == "agent-browser"
|
|
else real_which(cmd, *args, **kwargs)
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
"hermes_constants.agent_browser_runnable",
|
|
lambda path: path == "/fake/bin/agent-browser",
|
|
)
|
|
|
|
assert ns._has_agent_browser() is True
|
|
|
|
|
|
def test_has_agent_browser_import_failure_falls_back_to_hermes_managed_node_path(
|
|
monkeypatch, tmp_path
|
|
):
|
|
"""If tools.browser_tool cannot be imported, the managed-Node rung must
|
|
still find a runnable agent-browser under the Hermes Node dir even when
|
|
it's absent from the probe process's PATH — the Windows installer shape
|
|
where install succeeded but the GUI still said needs setup."""
|
|
monkeypatch.setitem(sys.modules, "tools.browser_tool", None)
|
|
managed_dir = tmp_path / "node"
|
|
managed_dir.mkdir()
|
|
managed_bin = managed_dir / "agent-browser"
|
|
managed_bin.write_text("#!/bin/sh\nexit 0\n")
|
|
managed_bin.chmod(0o755)
|
|
|
|
real_which = shutil.which
|
|
monkeypatch.setattr(
|
|
shutil,
|
|
"which",
|
|
lambda cmd, *args, **kwargs: (
|
|
None
|
|
if cmd == "agent-browser" and not kwargs.get("path")
|
|
else real_which(cmd, *args, **kwargs)
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
"hermes_constants.with_hermes_node_path", lambda: {"PATH": str(managed_dir)}
|
|
)
|
|
monkeypatch.setattr(
|
|
"hermes_constants.agent_browser_runnable",
|
|
lambda p: bool(p) and str(p) == str(managed_bin),
|
|
)
|
|
|
|
assert ns._has_agent_browser() is True
|
|
|
|
|
|
def test_has_agent_browser_import_failure_and_no_binary_is_false(monkeypatch):
|
|
monkeypatch.setitem(sys.modules, "tools.browser_tool", None)
|
|
_block_legacy_agent_browser_checks(monkeypatch)
|
|
|
|
assert ns._has_agent_browser() is False
|