feat: add managed tool gateway and Nous subscription support
- add managed modal and gateway-backed tool integrations\n- improve CLI setup, auth, and configuration for subscriber flows\n- expand tests and docs for managed tool support
This commit is contained in:
418
tests/tools/test_managed_browserbase_and_modal.py
Normal file
418
tests/tools/test_managed_browserbase_and_modal.py
Normal file
@@ -0,0 +1,418 @@
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import types
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools"
|
||||
|
||||
|
||||
def _load_tool_module(module_name: str, filename: str):
|
||||
spec = spec_from_file_location(module_name, TOOLS_DIR / filename)
|
||||
assert spec and spec.loader
|
||||
module = module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _reset_modules(prefixes: tuple[str, ...]):
|
||||
for name in list(sys.modules):
|
||||
if name.startswith(prefixes):
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_tool_and_agent_modules():
|
||||
original_modules = {
|
||||
name: module
|
||||
for name, module in sys.modules.items()
|
||||
if name == "tools"
|
||||
or name.startswith("tools.")
|
||||
or name == "agent"
|
||||
or name.startswith("agent.")
|
||||
}
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
_reset_modules(("tools", "agent"))
|
||||
sys.modules.update(original_modules)
|
||||
|
||||
|
||||
def _install_fake_tools_package():
|
||||
_reset_modules(("tools", "agent"))
|
||||
|
||||
tools_package = types.ModuleType("tools")
|
||||
tools_package.__path__ = [str(TOOLS_DIR)] # type: ignore[attr-defined]
|
||||
sys.modules["tools"] = tools_package
|
||||
|
||||
env_package = types.ModuleType("tools.environments")
|
||||
env_package.__path__ = [str(TOOLS_DIR / "environments")] # type: ignore[attr-defined]
|
||||
sys.modules["tools.environments"] = env_package
|
||||
|
||||
agent_package = types.ModuleType("agent")
|
||||
agent_package.__path__ = [] # type: ignore[attr-defined]
|
||||
sys.modules["agent"] = agent_package
|
||||
sys.modules["agent.auxiliary_client"] = types.SimpleNamespace(
|
||||
call_llm=lambda *args, **kwargs: "",
|
||||
)
|
||||
|
||||
sys.modules["tools.managed_tool_gateway"] = _load_tool_module(
|
||||
"tools.managed_tool_gateway",
|
||||
"managed_tool_gateway.py",
|
||||
)
|
||||
|
||||
interrupt_event = threading.Event()
|
||||
sys.modules["tools.interrupt"] = types.SimpleNamespace(
|
||||
set_interrupt=lambda value=True: interrupt_event.set() if value else interrupt_event.clear(),
|
||||
is_interrupted=lambda: interrupt_event.is_set(),
|
||||
_interrupt_event=interrupt_event,
|
||||
)
|
||||
sys.modules["tools.approval"] = types.SimpleNamespace(
|
||||
detect_dangerous_command=lambda *args, **kwargs: None,
|
||||
check_dangerous_command=lambda *args, **kwargs: {"approved": True},
|
||||
check_all_command_guards=lambda *args, **kwargs: {"approved": True},
|
||||
load_permanent_allowlist=lambda *args, **kwargs: [],
|
||||
DANGEROUS_PATTERNS=[],
|
||||
)
|
||||
|
||||
class _Registry:
|
||||
def register(self, **kwargs):
|
||||
return None
|
||||
|
||||
sys.modules["tools.registry"] = types.SimpleNamespace(registry=_Registry())
|
||||
|
||||
class _DummyEnvironment:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
def cleanup(self):
|
||||
return None
|
||||
|
||||
sys.modules["tools.environments.base"] = types.SimpleNamespace(BaseEnvironment=_DummyEnvironment)
|
||||
sys.modules["tools.environments.local"] = types.SimpleNamespace(LocalEnvironment=_DummyEnvironment)
|
||||
sys.modules["tools.environments.singularity"] = types.SimpleNamespace(
|
||||
_get_scratch_dir=lambda: Path(tempfile.gettempdir()),
|
||||
SingularityEnvironment=_DummyEnvironment,
|
||||
)
|
||||
sys.modules["tools.environments.ssh"] = types.SimpleNamespace(SSHEnvironment=_DummyEnvironment)
|
||||
sys.modules["tools.environments.docker"] = types.SimpleNamespace(DockerEnvironment=_DummyEnvironment)
|
||||
sys.modules["tools.environments.modal"] = types.SimpleNamespace(ModalEnvironment=_DummyEnvironment)
|
||||
sys.modules["tools.environments.managed_modal"] = types.SimpleNamespace(ManagedModalEnvironment=_DummyEnvironment)
|
||||
|
||||
|
||||
def test_browserbase_explicit_local_mode_stays_local_even_when_managed_gateway_is_ready(tmp_path):
|
||||
_install_fake_tools_package()
|
||||
(tmp_path / "config.yaml").write_text("browser:\n cloud_provider: local\n", encoding="utf-8")
|
||||
env = os.environ.copy()
|
||||
env.pop("BROWSERBASE_API_KEY", None)
|
||||
env.pop("BROWSERBASE_PROJECT_ID", None)
|
||||
env.update({
|
||||
"HERMES_HOME": str(tmp_path),
|
||||
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
|
||||
"BROWSERBASE_GATEWAY_URL": "http://127.0.0.1:3009",
|
||||
})
|
||||
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
browser_tool = _load_tool_module("tools.browser_tool", "browser_tool.py")
|
||||
|
||||
local_mode = browser_tool._is_local_mode()
|
||||
provider = browser_tool._get_cloud_provider()
|
||||
|
||||
assert local_mode is True
|
||||
assert provider is None
|
||||
|
||||
|
||||
def test_browserbase_managed_gateway_adds_idempotency_key_and_persists_external_call_id():
|
||||
_install_fake_tools_package()
|
||||
env = os.environ.copy()
|
||||
env.pop("BROWSERBASE_API_KEY", None)
|
||||
env.pop("BROWSERBASE_PROJECT_ID", None)
|
||||
env.update({
|
||||
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
|
||||
"BROWSERBASE_GATEWAY_URL": "http://127.0.0.1:3009",
|
||||
})
|
||||
|
||||
class _Response:
|
||||
status_code = 200
|
||||
ok = True
|
||||
text = ""
|
||||
headers = {"x-external-call-id": "call-browserbase-1"}
|
||||
|
||||
def json(self):
|
||||
return {
|
||||
"id": "bb_local_session_1",
|
||||
"connectUrl": "wss://connect.browserbase.example/session",
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
browserbase_module = _load_tool_module(
|
||||
"tools.browser_providers.browserbase",
|
||||
"browser_providers/browserbase.py",
|
||||
)
|
||||
|
||||
with patch.object(browserbase_module.requests, "post", return_value=_Response()) as post:
|
||||
provider = browserbase_module.BrowserbaseProvider()
|
||||
session = provider.create_session("task-browserbase-managed")
|
||||
|
||||
sent_headers = post.call_args.kwargs["headers"]
|
||||
assert sent_headers["X-BB-API-Key"] == "nous-token"
|
||||
assert sent_headers["X-Idempotency-Key"].startswith("browserbase-session-create:")
|
||||
assert session["external_call_id"] == "call-browserbase-1"
|
||||
|
||||
|
||||
def test_browserbase_managed_gateway_reuses_pending_idempotency_key_after_timeout():
|
||||
_install_fake_tools_package()
|
||||
env = os.environ.copy()
|
||||
env.pop("BROWSERBASE_API_KEY", None)
|
||||
env.pop("BROWSERBASE_PROJECT_ID", None)
|
||||
env.update({
|
||||
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
|
||||
"BROWSERBASE_GATEWAY_URL": "http://127.0.0.1:3009",
|
||||
})
|
||||
|
||||
class _Response:
|
||||
status_code = 200
|
||||
ok = True
|
||||
text = ""
|
||||
headers = {"x-external-call-id": "call-browserbase-2"}
|
||||
|
||||
def json(self):
|
||||
return {
|
||||
"id": "bb_local_session_2",
|
||||
"connectUrl": "wss://connect.browserbase.example/session2",
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
browserbase_module = _load_tool_module(
|
||||
"tools.browser_providers.browserbase",
|
||||
"browser_providers/browserbase.py",
|
||||
)
|
||||
provider = browserbase_module.BrowserbaseProvider()
|
||||
timeout = browserbase_module.requests.Timeout("timed out")
|
||||
|
||||
with patch.object(
|
||||
browserbase_module.requests,
|
||||
"post",
|
||||
side_effect=[timeout, _Response()],
|
||||
) as post:
|
||||
try:
|
||||
provider.create_session("task-browserbase-timeout")
|
||||
except browserbase_module.requests.Timeout:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("Expected Browserbase create_session to propagate timeout")
|
||||
|
||||
provider.create_session("task-browserbase-timeout")
|
||||
|
||||
first_headers = post.call_args_list[0].kwargs["headers"]
|
||||
second_headers = post.call_args_list[1].kwargs["headers"]
|
||||
assert first_headers["X-Idempotency-Key"] == second_headers["X-Idempotency-Key"]
|
||||
|
||||
|
||||
def test_browserbase_managed_gateway_preserves_pending_idempotency_key_for_in_progress_conflicts():
|
||||
_install_fake_tools_package()
|
||||
env = os.environ.copy()
|
||||
env.pop("BROWSERBASE_API_KEY", None)
|
||||
env.pop("BROWSERBASE_PROJECT_ID", None)
|
||||
env.update({
|
||||
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
|
||||
"BROWSERBASE_GATEWAY_URL": "http://127.0.0.1:3009",
|
||||
})
|
||||
|
||||
class _ConflictResponse:
|
||||
status_code = 409
|
||||
ok = False
|
||||
text = '{"error":{"code":"CONFLICT","message":"Managed Browserbase session creation is already in progress for this idempotency key"}}'
|
||||
headers = {}
|
||||
|
||||
def json(self):
|
||||
return {
|
||||
"error": {
|
||||
"code": "CONFLICT",
|
||||
"message": "Managed Browserbase session creation is already in progress for this idempotency key",
|
||||
}
|
||||
}
|
||||
|
||||
class _SuccessResponse:
|
||||
status_code = 200
|
||||
ok = True
|
||||
text = ""
|
||||
headers = {"x-external-call-id": "call-browserbase-4"}
|
||||
|
||||
def json(self):
|
||||
return {
|
||||
"id": "bb_local_session_4",
|
||||
"connectUrl": "wss://connect.browserbase.example/session4",
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
browserbase_module = _load_tool_module(
|
||||
"tools.browser_providers.browserbase",
|
||||
"browser_providers/browserbase.py",
|
||||
)
|
||||
provider = browserbase_module.BrowserbaseProvider()
|
||||
|
||||
with patch.object(
|
||||
browserbase_module.requests,
|
||||
"post",
|
||||
side_effect=[_ConflictResponse(), _SuccessResponse()],
|
||||
) as post:
|
||||
try:
|
||||
provider.create_session("task-browserbase-conflict")
|
||||
except RuntimeError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("Expected Browserbase create_session to propagate the in-progress conflict")
|
||||
|
||||
provider.create_session("task-browserbase-conflict")
|
||||
|
||||
first_headers = post.call_args_list[0].kwargs["headers"]
|
||||
second_headers = post.call_args_list[1].kwargs["headers"]
|
||||
assert first_headers["X-Idempotency-Key"] == second_headers["X-Idempotency-Key"]
|
||||
|
||||
|
||||
def test_browserbase_managed_gateway_uses_new_idempotency_key_for_a_new_session_after_success():
|
||||
_install_fake_tools_package()
|
||||
env = os.environ.copy()
|
||||
env.pop("BROWSERBASE_API_KEY", None)
|
||||
env.pop("BROWSERBASE_PROJECT_ID", None)
|
||||
env.update({
|
||||
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
|
||||
"BROWSERBASE_GATEWAY_URL": "http://127.0.0.1:3009",
|
||||
})
|
||||
|
||||
class _Response:
|
||||
status_code = 200
|
||||
ok = True
|
||||
text = ""
|
||||
headers = {"x-external-call-id": "call-browserbase-3"}
|
||||
|
||||
def json(self):
|
||||
return {
|
||||
"id": "bb_local_session_3",
|
||||
"connectUrl": "wss://connect.browserbase.example/session3",
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
browserbase_module = _load_tool_module(
|
||||
"tools.browser_providers.browserbase",
|
||||
"browser_providers/browserbase.py",
|
||||
)
|
||||
provider = browserbase_module.BrowserbaseProvider()
|
||||
|
||||
with patch.object(browserbase_module.requests, "post", side_effect=[_Response(), _Response()]) as post:
|
||||
provider.create_session("task-browserbase-new")
|
||||
provider.create_session("task-browserbase-new")
|
||||
|
||||
first_headers = post.call_args_list[0].kwargs["headers"]
|
||||
second_headers = post.call_args_list[1].kwargs["headers"]
|
||||
assert first_headers["X-Idempotency-Key"] != second_headers["X-Idempotency-Key"]
|
||||
|
||||
|
||||
def test_terminal_tool_prefers_managed_modal_when_gateway_ready_and_no_direct_creds():
|
||||
_install_fake_tools_package()
|
||||
env = os.environ.copy()
|
||||
env.pop("MODAL_TOKEN_ID", None)
|
||||
env.pop("MODAL_TOKEN_SECRET", None)
|
||||
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
terminal_tool = _load_tool_module("tools.terminal_tool", "terminal_tool.py")
|
||||
|
||||
with (
|
||||
patch.object(terminal_tool, "is_managed_tool_gateway_ready", return_value=True),
|
||||
patch.object(terminal_tool, "_ManagedModalEnvironment", return_value="managed-modal-env") as managed_ctor,
|
||||
patch.object(terminal_tool, "_ModalEnvironment", return_value="direct-modal-env") as direct_ctor,
|
||||
patch.object(Path, "exists", return_value=False),
|
||||
):
|
||||
result = terminal_tool._create_environment(
|
||||
env_type="modal",
|
||||
image="python:3.11",
|
||||
cwd="/root",
|
||||
timeout=60,
|
||||
container_config={
|
||||
"container_cpu": 1,
|
||||
"container_memory": 2048,
|
||||
"container_disk": 1024,
|
||||
"container_persistent": True,
|
||||
"modal_mode": "auto",
|
||||
},
|
||||
task_id="task-modal-managed",
|
||||
)
|
||||
|
||||
assert result == "managed-modal-env"
|
||||
assert managed_ctor.called
|
||||
assert not direct_ctor.called
|
||||
|
||||
|
||||
def test_terminal_tool_keeps_direct_modal_when_direct_credentials_exist():
|
||||
_install_fake_tools_package()
|
||||
env = os.environ.copy()
|
||||
env.update({
|
||||
"MODAL_TOKEN_ID": "tok-id",
|
||||
"MODAL_TOKEN_SECRET": "tok-secret",
|
||||
})
|
||||
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
terminal_tool = _load_tool_module("tools.terminal_tool", "terminal_tool.py")
|
||||
|
||||
with (
|
||||
patch.object(terminal_tool, "is_managed_tool_gateway_ready", return_value=True),
|
||||
patch.object(terminal_tool, "_ManagedModalEnvironment", return_value="managed-modal-env") as managed_ctor,
|
||||
patch.object(terminal_tool, "_ModalEnvironment", return_value="direct-modal-env") as direct_ctor,
|
||||
):
|
||||
result = terminal_tool._create_environment(
|
||||
env_type="modal",
|
||||
image="python:3.11",
|
||||
cwd="/root",
|
||||
timeout=60,
|
||||
container_config={
|
||||
"container_cpu": 1,
|
||||
"container_memory": 2048,
|
||||
"container_disk": 1024,
|
||||
"container_persistent": True,
|
||||
"modal_mode": "auto",
|
||||
},
|
||||
task_id="task-modal-direct",
|
||||
)
|
||||
|
||||
assert result == "direct-modal-env"
|
||||
assert direct_ctor.called
|
||||
assert not managed_ctor.called
|
||||
|
||||
|
||||
def test_terminal_tool_respects_direct_modal_mode_without_falling_back_to_managed():
|
||||
_install_fake_tools_package()
|
||||
env = os.environ.copy()
|
||||
env.pop("MODAL_TOKEN_ID", None)
|
||||
env.pop("MODAL_TOKEN_SECRET", None)
|
||||
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
terminal_tool = _load_tool_module("tools.terminal_tool", "terminal_tool.py")
|
||||
|
||||
with (
|
||||
patch.object(terminal_tool, "is_managed_tool_gateway_ready", return_value=True),
|
||||
patch.object(Path, "exists", return_value=False),
|
||||
):
|
||||
with pytest.raises(ValueError, match="direct Modal credentials"):
|
||||
terminal_tool._create_environment(
|
||||
env_type="modal",
|
||||
image="python:3.11",
|
||||
cwd="/root",
|
||||
timeout=60,
|
||||
container_config={
|
||||
"container_cpu": 1,
|
||||
"container_memory": 2048,
|
||||
"container_disk": 1024,
|
||||
"container_persistent": True,
|
||||
"modal_mode": "direct",
|
||||
},
|
||||
task_id="task-modal-direct-only",
|
||||
)
|
||||
288
tests/tools/test_managed_media_gateways.py
Normal file
288
tests/tools/test_managed_media_gateways.py
Normal file
@@ -0,0 +1,288 @@
|
||||
import sys
|
||||
import types
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools"
|
||||
|
||||
|
||||
def _load_tool_module(module_name: str, filename: str):
|
||||
spec = spec_from_file_location(module_name, TOOLS_DIR / filename)
|
||||
assert spec and spec.loader
|
||||
module = module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _restore_tool_and_agent_modules():
|
||||
original_modules = {
|
||||
name: module
|
||||
for name, module in sys.modules.items()
|
||||
if name == "tools"
|
||||
or name.startswith("tools.")
|
||||
or name == "agent"
|
||||
or name.startswith("agent.")
|
||||
or name in {"fal_client", "openai"}
|
||||
}
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
for name in list(sys.modules):
|
||||
if (
|
||||
name == "tools"
|
||||
or name.startswith("tools.")
|
||||
or name == "agent"
|
||||
or name.startswith("agent.")
|
||||
or name in {"fal_client", "openai"}
|
||||
):
|
||||
sys.modules.pop(name, None)
|
||||
sys.modules.update(original_modules)
|
||||
|
||||
|
||||
def _install_fake_tools_package():
|
||||
tools_package = types.ModuleType("tools")
|
||||
tools_package.__path__ = [str(TOOLS_DIR)] # type: ignore[attr-defined]
|
||||
sys.modules["tools"] = tools_package
|
||||
sys.modules["tools.debug_helpers"] = types.SimpleNamespace(
|
||||
DebugSession=lambda *args, **kwargs: types.SimpleNamespace(
|
||||
active=False,
|
||||
session_id="debug-session",
|
||||
log_call=lambda *a, **k: None,
|
||||
save=lambda: None,
|
||||
get_session_info=lambda: {},
|
||||
)
|
||||
)
|
||||
sys.modules["tools.managed_tool_gateway"] = _load_tool_module(
|
||||
"tools.managed_tool_gateway",
|
||||
"managed_tool_gateway.py",
|
||||
)
|
||||
|
||||
|
||||
def _install_fake_fal_client(captured):
|
||||
def submit(model, arguments=None, headers=None):
|
||||
raise AssertionError("managed FAL gateway mode should use fal_client.SyncClient")
|
||||
|
||||
class FakeResponse:
|
||||
def json(self):
|
||||
return {
|
||||
"request_id": "req-123",
|
||||
"response_url": "http://127.0.0.1:3009/requests/req-123",
|
||||
"status_url": "http://127.0.0.1:3009/requests/req-123/status",
|
||||
"cancel_url": "http://127.0.0.1:3009/requests/req-123/cancel",
|
||||
}
|
||||
|
||||
def _maybe_retry_request(client, method, url, json=None, timeout=None, headers=None):
|
||||
captured["submit_via"] = "managed_client"
|
||||
captured["http_client"] = client
|
||||
captured["method"] = method
|
||||
captured["submit_url"] = url
|
||||
captured["arguments"] = json
|
||||
captured["timeout"] = timeout
|
||||
captured["headers"] = headers
|
||||
return FakeResponse()
|
||||
|
||||
class SyncRequestHandle:
|
||||
def __init__(self, request_id, response_url, status_url, cancel_url, client):
|
||||
captured["request_id"] = request_id
|
||||
captured["response_url"] = response_url
|
||||
captured["status_url"] = status_url
|
||||
captured["cancel_url"] = cancel_url
|
||||
captured["handle_client"] = client
|
||||
|
||||
class SyncClient:
|
||||
def __init__(self, key=None, default_timeout=120.0):
|
||||
captured["sync_client_inits"] = captured.get("sync_client_inits", 0) + 1
|
||||
captured["client_key"] = key
|
||||
captured["client_timeout"] = default_timeout
|
||||
self.default_timeout = default_timeout
|
||||
self._client = object()
|
||||
|
||||
fal_client_module = types.SimpleNamespace(
|
||||
submit=submit,
|
||||
SyncClient=SyncClient,
|
||||
client=types.SimpleNamespace(
|
||||
_maybe_retry_request=_maybe_retry_request,
|
||||
_raise_for_status=lambda response: None,
|
||||
SyncRequestHandle=SyncRequestHandle,
|
||||
),
|
||||
)
|
||||
sys.modules["fal_client"] = fal_client_module
|
||||
return fal_client_module
|
||||
|
||||
|
||||
def _install_fake_openai_module(captured, transcription_response=None):
|
||||
class FakeSpeechResponse:
|
||||
def stream_to_file(self, output_path):
|
||||
captured["stream_to_file"] = output_path
|
||||
|
||||
class FakeOpenAI:
|
||||
def __init__(self, api_key, base_url, **kwargs):
|
||||
captured["api_key"] = api_key
|
||||
captured["base_url"] = base_url
|
||||
captured["client_kwargs"] = kwargs
|
||||
captured["close_calls"] = captured.get("close_calls", 0)
|
||||
|
||||
def create_speech(**kwargs):
|
||||
captured["speech_kwargs"] = kwargs
|
||||
return FakeSpeechResponse()
|
||||
|
||||
def create_transcription(**kwargs):
|
||||
captured["transcription_kwargs"] = kwargs
|
||||
return transcription_response
|
||||
|
||||
self.audio = types.SimpleNamespace(
|
||||
speech=types.SimpleNamespace(
|
||||
create=create_speech
|
||||
),
|
||||
transcriptions=types.SimpleNamespace(
|
||||
create=create_transcription
|
||||
),
|
||||
)
|
||||
|
||||
def close(self):
|
||||
captured["close_calls"] += 1
|
||||
|
||||
fake_module = types.SimpleNamespace(
|
||||
OpenAI=FakeOpenAI,
|
||||
APIError=Exception,
|
||||
APIConnectionError=Exception,
|
||||
APITimeoutError=Exception,
|
||||
)
|
||||
sys.modules["openai"] = fake_module
|
||||
|
||||
|
||||
def test_managed_fal_submit_uses_gateway_origin_and_nous_token(monkeypatch):
|
||||
captured = {}
|
||||
_install_fake_tools_package()
|
||||
_install_fake_fal_client(captured)
|
||||
monkeypatch.delenv("FAL_KEY", raising=False)
|
||||
monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009")
|
||||
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token")
|
||||
|
||||
image_generation_tool = _load_tool_module(
|
||||
"tools.image_generation_tool",
|
||||
"image_generation_tool.py",
|
||||
)
|
||||
monkeypatch.setattr(image_generation_tool.uuid, "uuid4", lambda: "fal-submit-123")
|
||||
|
||||
image_generation_tool._submit_fal_request(
|
||||
"fal-ai/flux-2-pro",
|
||||
{"prompt": "test prompt", "num_images": 1},
|
||||
)
|
||||
|
||||
assert captured["submit_via"] == "managed_client"
|
||||
assert captured["client_key"] == "nous-token"
|
||||
assert captured["submit_url"] == "http://127.0.0.1:3009/fal-ai/flux-2-pro"
|
||||
assert captured["method"] == "POST"
|
||||
assert captured["arguments"] == {"prompt": "test prompt", "num_images": 1}
|
||||
assert captured["headers"] == {"x-idempotency-key": "fal-submit-123"}
|
||||
assert captured["sync_client_inits"] == 1
|
||||
|
||||
|
||||
def test_managed_fal_submit_reuses_cached_sync_client(monkeypatch):
|
||||
captured = {}
|
||||
_install_fake_tools_package()
|
||||
_install_fake_fal_client(captured)
|
||||
monkeypatch.delenv("FAL_KEY", raising=False)
|
||||
monkeypatch.setenv("FAL_QUEUE_GATEWAY_URL", "http://127.0.0.1:3009")
|
||||
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token")
|
||||
|
||||
image_generation_tool = _load_tool_module(
|
||||
"tools.image_generation_tool",
|
||||
"image_generation_tool.py",
|
||||
)
|
||||
|
||||
image_generation_tool._submit_fal_request("fal-ai/flux-2-pro", {"prompt": "first"})
|
||||
first_client = captured["http_client"]
|
||||
image_generation_tool._submit_fal_request("fal-ai/flux-2-pro", {"prompt": "second"})
|
||||
|
||||
assert captured["sync_client_inits"] == 1
|
||||
assert captured["http_client"] is first_client
|
||||
|
||||
|
||||
def test_openai_tts_uses_managed_audio_gateway_when_direct_key_absent(monkeypatch, tmp_path):
|
||||
captured = {}
|
||||
_install_fake_tools_package()
|
||||
_install_fake_openai_module(captured)
|
||||
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
|
||||
monkeypatch.setenv("TOOL_GATEWAY_DOMAIN", "nousresearch.com")
|
||||
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token")
|
||||
|
||||
tts_tool = _load_tool_module("tools.tts_tool", "tts_tool.py")
|
||||
monkeypatch.setattr(tts_tool.uuid, "uuid4", lambda: "tts-call-123")
|
||||
output_path = tmp_path / "speech.mp3"
|
||||
tts_tool._generate_openai_tts("hello world", str(output_path), {"openai": {}})
|
||||
|
||||
assert captured["api_key"] == "nous-token"
|
||||
assert captured["base_url"] == "https://openai-audio-gateway.nousresearch.com/v1"
|
||||
assert captured["speech_kwargs"]["model"] == "gpt-4o-mini-tts"
|
||||
assert captured["speech_kwargs"]["extra_headers"] == {"x-idempotency-key": "tts-call-123"}
|
||||
assert captured["stream_to_file"] == str(output_path)
|
||||
assert captured["close_calls"] == 1
|
||||
|
||||
|
||||
def test_openai_tts_accepts_openai_api_key_as_direct_fallback(monkeypatch, tmp_path):
|
||||
captured = {}
|
||||
_install_fake_tools_package()
|
||||
_install_fake_openai_module(captured)
|
||||
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "openai-direct-key")
|
||||
monkeypatch.setenv("TOOL_GATEWAY_DOMAIN", "nousresearch.com")
|
||||
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token")
|
||||
|
||||
tts_tool = _load_tool_module("tools.tts_tool", "tts_tool.py")
|
||||
output_path = tmp_path / "speech.mp3"
|
||||
tts_tool._generate_openai_tts("hello world", str(output_path), {"openai": {}})
|
||||
|
||||
assert captured["api_key"] == "openai-direct-key"
|
||||
assert captured["base_url"] == "https://api.openai.com/v1"
|
||||
assert captured["close_calls"] == 1
|
||||
|
||||
|
||||
def test_transcription_uses_model_specific_response_formats(monkeypatch, tmp_path):
|
||||
whisper_capture = {}
|
||||
_install_fake_tools_package()
|
||||
_install_fake_openai_module(whisper_capture, transcription_response="hello from whisper")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "config.yaml").write_text("stt:\n provider: openai\n")
|
||||
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
|
||||
monkeypatch.setenv("TOOL_GATEWAY_DOMAIN", "nousresearch.com")
|
||||
monkeypatch.setenv("TOOL_GATEWAY_USER_TOKEN", "nous-token")
|
||||
|
||||
transcription_tools = _load_tool_module(
|
||||
"tools.transcription_tools",
|
||||
"transcription_tools.py",
|
||||
)
|
||||
transcription_tools._load_stt_config = lambda: {"provider": "openai"}
|
||||
audio_path = tmp_path / "audio.wav"
|
||||
audio_path.write_bytes(b"RIFF0000WAVEfmt ")
|
||||
|
||||
whisper_result = transcription_tools.transcribe_audio(str(audio_path), model="whisper-1")
|
||||
assert whisper_result["success"] is True
|
||||
assert whisper_capture["base_url"] == "https://openai-audio-gateway.nousresearch.com/v1"
|
||||
assert whisper_capture["transcription_kwargs"]["response_format"] == "text"
|
||||
assert whisper_capture["close_calls"] == 1
|
||||
|
||||
json_capture = {}
|
||||
_install_fake_openai_module(
|
||||
json_capture,
|
||||
transcription_response=types.SimpleNamespace(text="hello from gpt-4o"),
|
||||
)
|
||||
transcription_tools = _load_tool_module(
|
||||
"tools.transcription_tools",
|
||||
"transcription_tools.py",
|
||||
)
|
||||
|
||||
json_result = transcription_tools.transcribe_audio(
|
||||
str(audio_path),
|
||||
model="gpt-4o-mini-transcribe",
|
||||
)
|
||||
assert json_result["success"] is True
|
||||
assert json_result["transcript"] == "hello from gpt-4o"
|
||||
assert json_capture["transcription_kwargs"]["response_format"] == "json"
|
||||
assert json_capture["close_calls"] == 1
|
||||
213
tests/tools/test_managed_modal_environment.py
Normal file
213
tests/tools/test_managed_modal_environment.py
Normal file
@@ -0,0 +1,213 @@
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import types
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
TOOLS_DIR = Path(__file__).resolve().parents[2] / "tools"
|
||||
|
||||
|
||||
def _load_tool_module(module_name: str, filename: str):
|
||||
spec = spec_from_file_location(module_name, TOOLS_DIR / filename)
|
||||
assert spec and spec.loader
|
||||
module = module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _reset_modules(prefixes: tuple[str, ...]):
|
||||
for name in list(sys.modules):
|
||||
if name.startswith(prefixes):
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
|
||||
def _install_fake_tools_package():
|
||||
_reset_modules(("tools", "agent", "hermes_cli"))
|
||||
|
||||
hermes_cli = types.ModuleType("hermes_cli")
|
||||
hermes_cli.__path__ = [] # type: ignore[attr-defined]
|
||||
sys.modules["hermes_cli"] = hermes_cli
|
||||
sys.modules["hermes_cli.config"] = types.SimpleNamespace(
|
||||
get_hermes_home=lambda: Path(tempfile.gettempdir()) / "hermes-home",
|
||||
)
|
||||
|
||||
tools_package = types.ModuleType("tools")
|
||||
tools_package.__path__ = [str(TOOLS_DIR)] # type: ignore[attr-defined]
|
||||
sys.modules["tools"] = tools_package
|
||||
|
||||
env_package = types.ModuleType("tools.environments")
|
||||
env_package.__path__ = [str(TOOLS_DIR / "environments")] # type: ignore[attr-defined]
|
||||
sys.modules["tools.environments"] = env_package
|
||||
|
||||
interrupt_event = threading.Event()
|
||||
sys.modules["tools.interrupt"] = types.SimpleNamespace(
|
||||
set_interrupt=lambda value=True: interrupt_event.set() if value else interrupt_event.clear(),
|
||||
is_interrupted=lambda: interrupt_event.is_set(),
|
||||
_interrupt_event=interrupt_event,
|
||||
)
|
||||
|
||||
class _DummyBaseEnvironment:
|
||||
def __init__(self, cwd: str, timeout: int, env=None):
|
||||
self.cwd = cwd
|
||||
self.timeout = timeout
|
||||
self.env = env or {}
|
||||
|
||||
def _prepare_command(self, command: str):
|
||||
return command, None
|
||||
|
||||
sys.modules["tools.environments.base"] = types.SimpleNamespace(BaseEnvironment=_DummyBaseEnvironment)
|
||||
sys.modules["tools.managed_tool_gateway"] = types.SimpleNamespace(
|
||||
resolve_managed_tool_gateway=lambda vendor: types.SimpleNamespace(
|
||||
vendor=vendor,
|
||||
gateway_origin="https://modal-gateway.example.com",
|
||||
nous_user_token="user-token",
|
||||
managed_mode=True,
|
||||
)
|
||||
)
|
||||
|
||||
return interrupt_event
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
def __init__(self, status_code: int, payload=None, text: str = ""):
|
||||
self.status_code = status_code
|
||||
self._payload = payload
|
||||
self.text = text
|
||||
|
||||
def json(self):
|
||||
if isinstance(self._payload, Exception):
|
||||
raise self._payload
|
||||
return self._payload
|
||||
|
||||
|
||||
def test_managed_modal_execute_polls_until_completed(monkeypatch):
|
||||
_install_fake_tools_package()
|
||||
managed_modal = _load_tool_module("tools.environments.managed_modal", "environments/managed_modal.py")
|
||||
|
||||
calls = []
|
||||
poll_count = {"value": 0}
|
||||
|
||||
def fake_request(method, url, headers=None, json=None, timeout=None):
|
||||
calls.append((method, url, json, timeout))
|
||||
if method == "POST" and url.endswith("/v1/sandboxes"):
|
||||
return _FakeResponse(200, {"id": "sandbox-1"})
|
||||
if method == "POST" and url.endswith("/execs"):
|
||||
return _FakeResponse(202, {"execId": json["execId"], "status": "running"})
|
||||
if method == "GET" and "/execs/" in url:
|
||||
poll_count["value"] += 1
|
||||
if poll_count["value"] == 1:
|
||||
return _FakeResponse(200, {"execId": url.rsplit("/", 1)[-1], "status": "running"})
|
||||
return _FakeResponse(200, {
|
||||
"execId": url.rsplit("/", 1)[-1],
|
||||
"status": "completed",
|
||||
"output": "hello",
|
||||
"returncode": 0,
|
||||
})
|
||||
if method == "POST" and url.endswith("/terminate"):
|
||||
return _FakeResponse(200, {"status": "terminated"})
|
||||
raise AssertionError(f"Unexpected request: {method} {url}")
|
||||
|
||||
monkeypatch.setattr(managed_modal.requests, "request", fake_request)
|
||||
monkeypatch.setattr(managed_modal.time, "sleep", lambda _: None)
|
||||
|
||||
env = managed_modal.ManagedModalEnvironment(image="python:3.11")
|
||||
result = env.execute("echo hello")
|
||||
env.cleanup()
|
||||
|
||||
assert result == {"output": "hello", "returncode": 0}
|
||||
assert any(call[0] == "POST" and call[1].endswith("/execs") for call in calls)
|
||||
|
||||
|
||||
def test_managed_modal_create_sends_a_stable_idempotency_key(monkeypatch):
|
||||
_install_fake_tools_package()
|
||||
managed_modal = _load_tool_module("tools.environments.managed_modal", "environments/managed_modal.py")
|
||||
|
||||
create_headers = []
|
||||
|
||||
def fake_request(method, url, headers=None, json=None, timeout=None):
|
||||
if method == "POST" and url.endswith("/v1/sandboxes"):
|
||||
create_headers.append(headers or {})
|
||||
return _FakeResponse(200, {"id": "sandbox-1"})
|
||||
if method == "POST" and url.endswith("/terminate"):
|
||||
return _FakeResponse(200, {"status": "terminated"})
|
||||
raise AssertionError(f"Unexpected request: {method} {url}")
|
||||
|
||||
monkeypatch.setattr(managed_modal.requests, "request", fake_request)
|
||||
|
||||
env = managed_modal.ManagedModalEnvironment(image="python:3.11")
|
||||
env.cleanup()
|
||||
|
||||
assert len(create_headers) == 1
|
||||
assert isinstance(create_headers[0].get("x-idempotency-key"), str)
|
||||
assert create_headers[0]["x-idempotency-key"]
|
||||
|
||||
|
||||
def test_managed_modal_execute_cancels_on_interrupt(monkeypatch):
|
||||
interrupt_event = _install_fake_tools_package()
|
||||
managed_modal = _load_tool_module("tools.environments.managed_modal", "environments/managed_modal.py")
|
||||
|
||||
calls = []
|
||||
|
||||
def fake_request(method, url, headers=None, json=None, timeout=None):
|
||||
calls.append((method, url, json, timeout))
|
||||
if method == "POST" and url.endswith("/v1/sandboxes"):
|
||||
return _FakeResponse(200, {"id": "sandbox-1"})
|
||||
if method == "POST" and url.endswith("/execs"):
|
||||
return _FakeResponse(202, {"execId": json["execId"], "status": "running"})
|
||||
if method == "GET" and "/execs/" in url:
|
||||
return _FakeResponse(200, {"execId": url.rsplit("/", 1)[-1], "status": "running"})
|
||||
if method == "POST" and url.endswith("/cancel"):
|
||||
return _FakeResponse(202, {"status": "cancelling"})
|
||||
if method == "POST" and url.endswith("/terminate"):
|
||||
return _FakeResponse(200, {"status": "terminated"})
|
||||
raise AssertionError(f"Unexpected request: {method} {url}")
|
||||
|
||||
def fake_sleep(_seconds):
|
||||
interrupt_event.set()
|
||||
|
||||
monkeypatch.setattr(managed_modal.requests, "request", fake_request)
|
||||
monkeypatch.setattr(managed_modal.time, "sleep", fake_sleep)
|
||||
|
||||
env = managed_modal.ManagedModalEnvironment(image="python:3.11")
|
||||
result = env.execute("sleep 30")
|
||||
env.cleanup()
|
||||
|
||||
assert result == {
|
||||
"output": "[Command interrupted - Modal sandbox exec cancelled]",
|
||||
"returncode": 130,
|
||||
}
|
||||
assert any(call[0] == "POST" and call[1].endswith("/cancel") for call in calls)
|
||||
poll_calls = [call for call in calls if call[0] == "GET" and "/execs/" in call[1]]
|
||||
cancel_calls = [call for call in calls if call[0] == "POST" and call[1].endswith("/cancel")]
|
||||
assert poll_calls[0][3] == (1.0, 5.0)
|
||||
assert cancel_calls[0][3] == (1.0, 5.0)
|
||||
|
||||
|
||||
def test_managed_modal_execute_returns_descriptive_error_on_missing_exec(monkeypatch):
|
||||
_install_fake_tools_package()
|
||||
managed_modal = _load_tool_module("tools.environments.managed_modal", "environments/managed_modal.py")
|
||||
|
||||
def fake_request(method, url, headers=None, json=None, timeout=None):
|
||||
if method == "POST" and url.endswith("/v1/sandboxes"):
|
||||
return _FakeResponse(200, {"id": "sandbox-1"})
|
||||
if method == "POST" and url.endswith("/execs"):
|
||||
return _FakeResponse(202, {"execId": json["execId"], "status": "running"})
|
||||
if method == "GET" and "/execs/" in url:
|
||||
return _FakeResponse(404, {"error": "not found"}, text="not found")
|
||||
if method == "POST" and url.endswith("/terminate"):
|
||||
return _FakeResponse(200, {"status": "terminated"})
|
||||
raise AssertionError(f"Unexpected request: {method} {url}")
|
||||
|
||||
monkeypatch.setattr(managed_modal.requests, "request", fake_request)
|
||||
monkeypatch.setattr(managed_modal.time, "sleep", lambda _: None)
|
||||
|
||||
env = managed_modal.ManagedModalEnvironment(image="python:3.11")
|
||||
result = env.execute("echo hello")
|
||||
env.cleanup()
|
||||
|
||||
assert result["returncode"] == 1
|
||||
assert "not found" in result["output"].lower()
|
||||
70
tests/tools/test_managed_tool_gateway.py
Normal file
70
tests/tools/test_managed_tool_gateway.py
Normal file
@@ -0,0 +1,70 @@
|
||||
import os
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
MODULE_PATH = Path(__file__).resolve().parents[2] / "tools" / "managed_tool_gateway.py"
|
||||
MODULE_SPEC = spec_from_file_location("managed_tool_gateway_test_module", MODULE_PATH)
|
||||
assert MODULE_SPEC and MODULE_SPEC.loader
|
||||
managed_tool_gateway = module_from_spec(MODULE_SPEC)
|
||||
sys.modules[MODULE_SPEC.name] = managed_tool_gateway
|
||||
MODULE_SPEC.loader.exec_module(managed_tool_gateway)
|
||||
resolve_managed_tool_gateway = managed_tool_gateway.resolve_managed_tool_gateway
|
||||
|
||||
|
||||
def test_resolve_managed_tool_gateway_derives_vendor_origin_from_shared_domain():
|
||||
with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False):
|
||||
result = resolve_managed_tool_gateway(
|
||||
"firecrawl",
|
||||
token_reader=lambda: "nous-token",
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.gateway_origin == "https://firecrawl-gateway.nousresearch.com"
|
||||
assert result.nous_user_token == "nous-token"
|
||||
assert result.managed_mode is True
|
||||
|
||||
|
||||
def test_resolve_managed_tool_gateway_uses_vendor_specific_override():
|
||||
with patch.dict(os.environ, {"BROWSERBASE_GATEWAY_URL": "http://browserbase-gateway.localhost:3009/"}, clear=False):
|
||||
result = resolve_managed_tool_gateway(
|
||||
"browserbase",
|
||||
token_reader=lambda: "nous-token",
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.gateway_origin == "http://browserbase-gateway.localhost:3009"
|
||||
|
||||
|
||||
def test_resolve_managed_tool_gateway_is_inactive_without_nous_token():
|
||||
with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}, clear=False):
|
||||
result = resolve_managed_tool_gateway(
|
||||
"firecrawl",
|
||||
token_reader=lambda: None,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_read_nous_access_token_refreshes_expiring_cached_token(tmp_path, monkeypatch):
|
||||
monkeypatch.delenv("TOOL_GATEWAY_USER_TOKEN", raising=False)
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
expires_at = (datetime.now(timezone.utc) + timedelta(seconds=30)).isoformat()
|
||||
(tmp_path / "auth.json").write_text(json.dumps({
|
||||
"providers": {
|
||||
"nous": {
|
||||
"access_token": "stale-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
}
|
||||
}))
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth.resolve_nous_access_token",
|
||||
lambda refresh_skew_seconds=120: "fresh-token",
|
||||
)
|
||||
|
||||
assert managed_tool_gateway.read_nous_access_token() == "fresh-token"
|
||||
188
tests/tools/test_modal_snapshot_isolation.py
Normal file
188
tests/tools/test_modal_snapshot_isolation.py
Normal file
@@ -0,0 +1,188 @@
|
||||
import json
|
||||
import sys
|
||||
import types
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
TOOLS_DIR = REPO_ROOT / "tools"
|
||||
|
||||
|
||||
def _load_module(module_name: str, path: Path):
|
||||
spec = spec_from_file_location(module_name, path)
|
||||
assert spec and spec.loader
|
||||
module = module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _reset_modules(prefixes: tuple[str, ...]):
|
||||
for name in list(sys.modules):
|
||||
if name.startswith(prefixes):
|
||||
sys.modules.pop(name, None)
|
||||
|
||||
|
||||
def _install_modal_test_modules(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
fail_on_snapshot_ids: set[str] | None = None,
|
||||
snapshot_id: str = "im-fresh",
|
||||
):
|
||||
_reset_modules(("tools", "hermes_cli", "swerex", "modal"))
|
||||
|
||||
hermes_cli = types.ModuleType("hermes_cli")
|
||||
hermes_cli.__path__ = [] # type: ignore[attr-defined]
|
||||
sys.modules["hermes_cli"] = hermes_cli
|
||||
hermes_home = tmp_path / "hermes-home"
|
||||
sys.modules["hermes_cli.config"] = types.SimpleNamespace(
|
||||
get_hermes_home=lambda: hermes_home,
|
||||
)
|
||||
|
||||
tools_package = types.ModuleType("tools")
|
||||
tools_package.__path__ = [str(TOOLS_DIR)] # type: ignore[attr-defined]
|
||||
sys.modules["tools"] = tools_package
|
||||
|
||||
env_package = types.ModuleType("tools.environments")
|
||||
env_package.__path__ = [str(TOOLS_DIR / "environments")] # type: ignore[attr-defined]
|
||||
sys.modules["tools.environments"] = env_package
|
||||
|
||||
class _DummyBaseEnvironment:
|
||||
def __init__(self, cwd: str, timeout: int, env=None):
|
||||
self.cwd = cwd
|
||||
self.timeout = timeout
|
||||
self.env = env or {}
|
||||
|
||||
def _prepare_command(self, command: str):
|
||||
return command, None
|
||||
|
||||
sys.modules["tools.environments.base"] = types.SimpleNamespace(BaseEnvironment=_DummyBaseEnvironment)
|
||||
sys.modules["tools.interrupt"] = types.SimpleNamespace(is_interrupted=lambda: False)
|
||||
|
||||
from_id_calls: list[str] = []
|
||||
registry_calls: list[tuple[str, list[str] | None]] = []
|
||||
deployment_calls: list[dict] = []
|
||||
|
||||
class _FakeImage:
|
||||
@staticmethod
|
||||
def from_id(image_id: str):
|
||||
from_id_calls.append(image_id)
|
||||
return {"kind": "snapshot", "image_id": image_id}
|
||||
|
||||
@staticmethod
|
||||
def from_registry(image: str, setup_dockerfile_commands=None):
|
||||
registry_calls.append((image, setup_dockerfile_commands))
|
||||
return {"kind": "registry", "image": image}
|
||||
|
||||
class _FakeRuntime:
|
||||
async def execute(self, _command):
|
||||
return types.SimpleNamespace(stdout="ok", exit_code=0)
|
||||
|
||||
class _FakeModalDeployment:
|
||||
def __init__(self, **kwargs):
|
||||
deployment_calls.append(dict(kwargs))
|
||||
self.image = kwargs["image"]
|
||||
self.runtime = _FakeRuntime()
|
||||
|
||||
async def _snapshot_aio():
|
||||
return types.SimpleNamespace(object_id=snapshot_id)
|
||||
|
||||
self._sandbox = types.SimpleNamespace(
|
||||
snapshot_filesystem=types.SimpleNamespace(aio=_snapshot_aio),
|
||||
)
|
||||
|
||||
async def start(self):
|
||||
image = self.image if isinstance(self.image, dict) else {}
|
||||
image_id = image.get("image_id")
|
||||
if fail_on_snapshot_ids and image_id in fail_on_snapshot_ids:
|
||||
raise RuntimeError(f"cannot restore {image_id}")
|
||||
|
||||
async def stop(self):
|
||||
return None
|
||||
|
||||
class _FakeRexCommand:
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
|
||||
sys.modules["modal"] = types.SimpleNamespace(Image=_FakeImage)
|
||||
|
||||
swerex = types.ModuleType("swerex")
|
||||
swerex.__path__ = [] # type: ignore[attr-defined]
|
||||
sys.modules["swerex"] = swerex
|
||||
swerex_deployment = types.ModuleType("swerex.deployment")
|
||||
swerex_deployment.__path__ = [] # type: ignore[attr-defined]
|
||||
sys.modules["swerex.deployment"] = swerex_deployment
|
||||
sys.modules["swerex.deployment.modal"] = types.SimpleNamespace(ModalDeployment=_FakeModalDeployment)
|
||||
swerex_runtime = types.ModuleType("swerex.runtime")
|
||||
swerex_runtime.__path__ = [] # type: ignore[attr-defined]
|
||||
sys.modules["swerex.runtime"] = swerex_runtime
|
||||
sys.modules["swerex.runtime.abstract"] = types.SimpleNamespace(Command=_FakeRexCommand)
|
||||
|
||||
return {
|
||||
"snapshot_store": hermes_home / "modal_snapshots.json",
|
||||
"deployment_calls": deployment_calls,
|
||||
"from_id_calls": from_id_calls,
|
||||
"registry_calls": registry_calls,
|
||||
}
|
||||
|
||||
|
||||
def test_modal_environment_migrates_legacy_snapshot_key_and_uses_snapshot_id(tmp_path):
|
||||
state = _install_modal_test_modules(tmp_path)
|
||||
snapshot_store = state["snapshot_store"]
|
||||
snapshot_store.parent.mkdir(parents=True, exist_ok=True)
|
||||
snapshot_store.write_text(json.dumps({"task-legacy": "im-legacy123"}))
|
||||
|
||||
modal_module = _load_module("tools.environments.modal", TOOLS_DIR / "environments" / "modal.py")
|
||||
env = modal_module.ModalEnvironment(image="python:3.11", task_id="task-legacy")
|
||||
|
||||
try:
|
||||
assert state["from_id_calls"] == ["im-legacy123"]
|
||||
assert state["deployment_calls"][0]["image"] == {"kind": "snapshot", "image_id": "im-legacy123"}
|
||||
assert json.loads(snapshot_store.read_text()) == {"direct:task-legacy": "im-legacy123"}
|
||||
finally:
|
||||
env.cleanup()
|
||||
|
||||
|
||||
def test_modal_environment_prunes_stale_direct_snapshot_and_retries_base_image(tmp_path):
|
||||
state = _install_modal_test_modules(tmp_path, fail_on_snapshot_ids={"im-stale123"})
|
||||
snapshot_store = state["snapshot_store"]
|
||||
snapshot_store.parent.mkdir(parents=True, exist_ok=True)
|
||||
snapshot_store.write_text(json.dumps({"direct:task-stale": "im-stale123"}))
|
||||
|
||||
modal_module = _load_module("tools.environments.modal", TOOLS_DIR / "environments" / "modal.py")
|
||||
env = modal_module.ModalEnvironment(image="python:3.11", task_id="task-stale")
|
||||
|
||||
try:
|
||||
assert [call["image"] for call in state["deployment_calls"]] == [
|
||||
{"kind": "snapshot", "image_id": "im-stale123"},
|
||||
{"kind": "registry", "image": "python:3.11"},
|
||||
]
|
||||
assert json.loads(snapshot_store.read_text()) == {}
|
||||
finally:
|
||||
env.cleanup()
|
||||
|
||||
|
||||
def test_modal_environment_cleanup_writes_namespaced_snapshot_key(tmp_path):
|
||||
state = _install_modal_test_modules(tmp_path, snapshot_id="im-cleanup456")
|
||||
snapshot_store = state["snapshot_store"]
|
||||
|
||||
modal_module = _load_module("tools.environments.modal", TOOLS_DIR / "environments" / "modal.py")
|
||||
env = modal_module.ModalEnvironment(image="python:3.11", task_id="task-cleanup")
|
||||
env.cleanup()
|
||||
|
||||
assert json.loads(snapshot_store.read_text()) == {"direct:task-cleanup": "im-cleanup456"}
|
||||
|
||||
|
||||
def test_resolve_modal_image_uses_snapshot_ids_and_registry_images(tmp_path):
|
||||
state = _install_modal_test_modules(tmp_path)
|
||||
modal_module = _load_module("tools.environments.modal", TOOLS_DIR / "environments" / "modal.py")
|
||||
|
||||
snapshot_image = modal_module._resolve_modal_image("im-snapshot123")
|
||||
registry_image = modal_module._resolve_modal_image("python:3.11")
|
||||
|
||||
assert snapshot_image == {"kind": "snapshot", "image_id": "im-snapshot123"}
|
||||
assert registry_image == {"kind": "registry", "image": "python:3.11"}
|
||||
assert state["from_id_calls"] == ["im-snapshot123"]
|
||||
assert state["registry_calls"][0][0] == "python:3.11"
|
||||
assert "ensurepip" in state["registry_calls"][0][1][0]
|
||||
@@ -8,9 +8,11 @@ def _clear_terminal_env(monkeypatch):
|
||||
"""Remove terminal env vars that could affect requirements checks."""
|
||||
keys = [
|
||||
"TERMINAL_ENV",
|
||||
"TERMINAL_MODAL_MODE",
|
||||
"TERMINAL_SSH_HOST",
|
||||
"TERMINAL_SSH_USER",
|
||||
"MODAL_TOKEN_ID",
|
||||
"MODAL_TOKEN_SECRET",
|
||||
"HOME",
|
||||
"USERPROFILE",
|
||||
]
|
||||
@@ -63,7 +65,7 @@ def test_modal_backend_without_token_or_config_logs_specific_error(monkeypatch,
|
||||
monkeypatch.setenv("TERMINAL_ENV", "modal")
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
# Pretend swerex is installed
|
||||
monkeypatch.setattr(terminal_tool_module, "is_managed_tool_gateway_ready", lambda _vendor: False)
|
||||
monkeypatch.setattr(terminal_tool_module.importlib.util, "find_spec", lambda _name: object())
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
@@ -71,6 +73,45 @@ def test_modal_backend_without_token_or_config_logs_specific_error(monkeypatch,
|
||||
|
||||
assert ok is False
|
||||
assert any(
|
||||
"Modal backend selected but no MODAL_TOKEN_ID environment variable" in record.getMessage()
|
||||
"Modal backend selected but no direct Modal credentials/config or managed tool gateway was found" in record.getMessage()
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
def test_modal_backend_with_managed_gateway_does_not_require_direct_creds_or_minisweagent(monkeypatch, tmp_path):
|
||||
_clear_terminal_env(monkeypatch)
|
||||
monkeypatch.setenv("TERMINAL_ENV", "modal")
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.setenv("TERMINAL_MODAL_MODE", "managed")
|
||||
monkeypatch.setattr(terminal_tool_module, "is_managed_tool_gateway_ready", lambda _vendor: True)
|
||||
monkeypatch.setattr(
|
||||
terminal_tool_module,
|
||||
"ensure_minisweagent_on_path",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("should not be called")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
terminal_tool_module.importlib.util,
|
||||
"find_spec",
|
||||
lambda _name: (_ for _ in ()).throw(AssertionError("should not be called")),
|
||||
)
|
||||
|
||||
assert terminal_tool_module.check_terminal_requirements() is True
|
||||
|
||||
|
||||
def test_modal_backend_direct_mode_does_not_fall_back_to_managed(monkeypatch, caplog, tmp_path):
|
||||
_clear_terminal_env(monkeypatch)
|
||||
monkeypatch.setenv("TERMINAL_ENV", "modal")
|
||||
monkeypatch.setenv("TERMINAL_MODAL_MODE", "direct")
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.setattr(terminal_tool_module, "is_managed_tool_gateway_ready", lambda _vendor: True)
|
||||
|
||||
with caplog.at_level(logging.ERROR):
|
||||
ok = terminal_tool_module.check_terminal_requirements()
|
||||
|
||||
assert ok is False
|
||||
assert any(
|
||||
"TERMINAL_MODAL_MODE=direct" in record.getMessage()
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
@@ -26,3 +26,30 @@ class TestTerminalRequirements:
|
||||
names = {tool["function"]["name"] for tool in tools}
|
||||
assert "terminal" in names
|
||||
assert {"read_file", "write_file", "patch", "search_files"}.issubset(names)
|
||||
|
||||
def test_terminal_and_execute_code_tools_resolve_for_managed_modal(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("MODAL_TOKEN_ID", raising=False)
|
||||
monkeypatch.delenv("MODAL_TOKEN_SECRET", raising=False)
|
||||
monkeypatch.setattr(
|
||||
terminal_tool_module,
|
||||
"_get_env_config",
|
||||
lambda: {"env_type": "modal", "modal_mode": "managed"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
terminal_tool_module,
|
||||
"is_managed_tool_gateway_ready",
|
||||
lambda _vendor: True,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
terminal_tool_module,
|
||||
"ensure_minisweagent_on_path",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("should not be called")),
|
||||
)
|
||||
|
||||
tools = get_tool_definitions(enabled_toolsets=["terminal", "code_execution"], quiet_mode=True)
|
||||
names = {tool["function"]["name"] for tool in tools}
|
||||
|
||||
assert "terminal" in names
|
||||
assert "execute_code" in names
|
||||
|
||||
@@ -231,6 +231,7 @@ class TestTranscribeGroq:
|
||||
assert result["success"] is True
|
||||
assert result["transcript"] == "hello world"
|
||||
assert result["provider"] == "groq"
|
||||
mock_client.close.assert_called_once()
|
||||
|
||||
def test_whitespace_stripped(self, monkeypatch, sample_wav):
|
||||
monkeypatch.setenv("GROQ_API_KEY", "gsk-test")
|
||||
@@ -272,6 +273,7 @@ class TestTranscribeGroq:
|
||||
|
||||
assert result["success"] is False
|
||||
assert "API error" in result["error"]
|
||||
mock_client.close.assert_called_once()
|
||||
|
||||
def test_permission_error(self, monkeypatch, sample_wav):
|
||||
monkeypatch.setenv("GROQ_API_KEY", "gsk-test")
|
||||
@@ -327,6 +329,7 @@ class TestTranscribeOpenAIExtended:
|
||||
result = _transcribe_openai(sample_wav, "whisper-1")
|
||||
|
||||
assert result["transcript"] == "hello"
|
||||
mock_client.close.assert_called_once()
|
||||
|
||||
def test_permission_error(self, monkeypatch, sample_wav):
|
||||
monkeypatch.setenv("VOICE_TOOLS_OPENAI_KEY", "sk-test")
|
||||
@@ -341,6 +344,7 @@ class TestTranscribeOpenAIExtended:
|
||||
|
||||
assert result["success"] is False
|
||||
assert "Permission denied" in result["error"]
|
||||
mock_client.close.assert_called_once()
|
||||
|
||||
|
||||
class TestTranscribeLocalCommand:
|
||||
|
||||
@@ -5,12 +5,14 @@ Coverage:
|
||||
constructor failure recovery, return value verification, edge cases.
|
||||
_get_backend() — backend selection logic with env var combinations.
|
||||
_get_parallel_client() — Parallel client configuration, singleton caching.
|
||||
check_web_api_key() — unified availability check.
|
||||
check_web_api_key() — unified availability check across all web backends.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from unittest.mock import patch, MagicMock, AsyncMock
|
||||
|
||||
|
||||
class TestFirecrawlClientConfig:
|
||||
@@ -20,14 +22,30 @@ class TestFirecrawlClientConfig:
|
||||
"""Reset client and env vars before each test."""
|
||||
import tools.web_tools
|
||||
tools.web_tools._firecrawl_client = None
|
||||
for key in ("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL"):
|
||||
tools.web_tools._firecrawl_client_config = None
|
||||
for key in (
|
||||
"FIRECRAWL_API_KEY",
|
||||
"FIRECRAWL_API_URL",
|
||||
"FIRECRAWL_GATEWAY_URL",
|
||||
"TOOL_GATEWAY_DOMAIN",
|
||||
"TOOL_GATEWAY_SCHEME",
|
||||
"TOOL_GATEWAY_USER_TOKEN",
|
||||
):
|
||||
os.environ.pop(key, None)
|
||||
|
||||
def teardown_method(self):
|
||||
"""Reset client after each test."""
|
||||
import tools.web_tools
|
||||
tools.web_tools._firecrawl_client = None
|
||||
for key in ("FIRECRAWL_API_KEY", "FIRECRAWL_API_URL"):
|
||||
tools.web_tools._firecrawl_client_config = None
|
||||
for key in (
|
||||
"FIRECRAWL_API_KEY",
|
||||
"FIRECRAWL_API_URL",
|
||||
"FIRECRAWL_GATEWAY_URL",
|
||||
"TOOL_GATEWAY_DOMAIN",
|
||||
"TOOL_GATEWAY_SCHEME",
|
||||
"TOOL_GATEWAY_USER_TOKEN",
|
||||
):
|
||||
os.environ.pop(key, None)
|
||||
|
||||
# ── Configuration matrix ─────────────────────────────────────────
|
||||
@@ -67,9 +85,152 @@ class TestFirecrawlClientConfig:
|
||||
def test_no_config_raises_with_helpful_message(self):
|
||||
"""Neither key nor URL → ValueError with guidance."""
|
||||
with patch("tools.web_tools.Firecrawl"):
|
||||
from tools.web_tools import _get_firecrawl_client
|
||||
with pytest.raises(ValueError, match="FIRECRAWL_API_KEY"):
|
||||
with patch("tools.web_tools._read_nous_access_token", return_value=None):
|
||||
from tools.web_tools import _get_firecrawl_client
|
||||
with pytest.raises(ValueError, match="FIRECRAWL_API_KEY"):
|
||||
_get_firecrawl_client()
|
||||
|
||||
def test_tool_gateway_domain_builds_firecrawl_gateway_origin(self):
|
||||
"""Shared gateway domain should derive the Firecrawl vendor hostname."""
|
||||
with patch.dict(os.environ, {"TOOL_GATEWAY_DOMAIN": "nousresearch.com"}):
|
||||
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
|
||||
with patch("tools.web_tools.Firecrawl") as mock_fc:
|
||||
from tools.web_tools import _get_firecrawl_client
|
||||
result = _get_firecrawl_client()
|
||||
mock_fc.assert_called_once_with(
|
||||
api_key="nous-token",
|
||||
api_url="https://firecrawl-gateway.nousresearch.com",
|
||||
)
|
||||
assert result is mock_fc.return_value
|
||||
|
||||
def test_tool_gateway_scheme_can_switch_derived_gateway_origin_to_http(self):
|
||||
"""Shared gateway scheme should allow local plain-http vendor hosts."""
|
||||
with patch.dict(os.environ, {
|
||||
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
|
||||
"TOOL_GATEWAY_SCHEME": "http",
|
||||
}):
|
||||
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
|
||||
with patch("tools.web_tools.Firecrawl") as mock_fc:
|
||||
from tools.web_tools import _get_firecrawl_client
|
||||
result = _get_firecrawl_client()
|
||||
mock_fc.assert_called_once_with(
|
||||
api_key="nous-token",
|
||||
api_url="http://firecrawl-gateway.nousresearch.com",
|
||||
)
|
||||
assert result is mock_fc.return_value
|
||||
|
||||
def test_invalid_tool_gateway_scheme_raises(self):
|
||||
"""Unexpected shared gateway schemes should fail fast."""
|
||||
with patch.dict(os.environ, {
|
||||
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
|
||||
"TOOL_GATEWAY_SCHEME": "ftp",
|
||||
}):
|
||||
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
|
||||
from tools.web_tools import _get_firecrawl_client
|
||||
with pytest.raises(ValueError, match="TOOL_GATEWAY_SCHEME"):
|
||||
_get_firecrawl_client()
|
||||
|
||||
def test_explicit_firecrawl_gateway_url_takes_precedence(self):
|
||||
"""An explicit Firecrawl gateway origin should override the shared domain."""
|
||||
with patch.dict(os.environ, {
|
||||
"FIRECRAWL_GATEWAY_URL": "https://firecrawl-gateway.localhost:3009/",
|
||||
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
|
||||
}):
|
||||
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
|
||||
with patch("tools.web_tools.Firecrawl") as mock_fc:
|
||||
from tools.web_tools import _get_firecrawl_client
|
||||
_get_firecrawl_client()
|
||||
mock_fc.assert_called_once_with(
|
||||
api_key="nous-token",
|
||||
api_url="https://firecrawl-gateway.localhost:3009",
|
||||
)
|
||||
|
||||
def test_default_gateway_domain_targets_nous_production_origin(self):
|
||||
"""Default gateway origin should point at the Firecrawl vendor hostname."""
|
||||
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
|
||||
with patch("tools.web_tools.Firecrawl") as mock_fc:
|
||||
from tools.web_tools import _get_firecrawl_client
|
||||
_get_firecrawl_client()
|
||||
mock_fc.assert_called_once_with(
|
||||
api_key="nous-token",
|
||||
api_url="https://firecrawl-gateway.nousresearch.com",
|
||||
)
|
||||
|
||||
def test_direct_mode_is_preferred_over_tool_gateway(self):
|
||||
"""Explicit Firecrawl config should win over the gateway fallback."""
|
||||
with patch.dict(os.environ, {
|
||||
"FIRECRAWL_API_KEY": "fc-test",
|
||||
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
|
||||
}):
|
||||
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
|
||||
with patch("tools.web_tools.Firecrawl") as mock_fc:
|
||||
from tools.web_tools import _get_firecrawl_client
|
||||
_get_firecrawl_client()
|
||||
mock_fc.assert_called_once_with(api_key="fc-test")
|
||||
|
||||
def test_nous_auth_token_respects_hermes_home_override(self, tmp_path):
|
||||
"""Auth lookup should read from HERMES_HOME/auth.json, not ~/.hermes/auth.json."""
|
||||
real_home = tmp_path / "real-home"
|
||||
(real_home / ".hermes").mkdir(parents=True)
|
||||
|
||||
hermes_home = tmp_path / "hermes-home"
|
||||
hermes_home.mkdir()
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"providers": {
|
||||
"nous": {
|
||||
"access_token": "nous-token",
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
with patch.dict(os.environ, {
|
||||
"HOME": str(real_home),
|
||||
"HERMES_HOME": str(hermes_home),
|
||||
}, clear=False):
|
||||
import tools.web_tools
|
||||
importlib.reload(tools.web_tools)
|
||||
assert tools.web_tools._read_nous_access_token() == "nous-token"
|
||||
|
||||
def test_check_auxiliary_model_re_resolves_backend_each_call(self):
|
||||
"""Availability checks should not be pinned to module import state."""
|
||||
import tools.web_tools
|
||||
|
||||
# Simulate the pre-fix import-time cache slot for regression coverage.
|
||||
tools.web_tools.__dict__["_aux_async_client"] = None
|
||||
|
||||
with patch(
|
||||
"tools.web_tools.get_async_text_auxiliary_client",
|
||||
side_effect=[(None, None), (MagicMock(base_url="https://api.openrouter.ai/v1"), "test-model")],
|
||||
):
|
||||
assert tools.web_tools.check_auxiliary_model() is False
|
||||
assert tools.web_tools.check_auxiliary_model() is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_summarizer_re_resolves_backend_after_initial_unavailable_state(self):
|
||||
"""Summarization should pick up a backend that becomes available later in-process."""
|
||||
import tools.web_tools
|
||||
|
||||
tools.web_tools.__dict__["_aux_async_client"] = None
|
||||
|
||||
response = MagicMock()
|
||||
response.choices = [MagicMock(message=MagicMock(content="summary text"))]
|
||||
|
||||
fake_client = MagicMock(base_url="https://api.openrouter.ai/v1")
|
||||
fake_client.chat.completions.create = AsyncMock(return_value=response)
|
||||
|
||||
with patch(
|
||||
"tools.web_tools.get_async_text_auxiliary_client",
|
||||
side_effect=[(None, None), (fake_client, "test-model")],
|
||||
):
|
||||
assert tools.web_tools.check_auxiliary_model() is False
|
||||
result = await tools.web_tools._call_summarizer_llm(
|
||||
"Some content worth summarizing",
|
||||
"Source: https://example.com\n\n",
|
||||
None,
|
||||
)
|
||||
|
||||
assert result == "summary text"
|
||||
fake_client.chat.completions.create.assert_awaited_once()
|
||||
|
||||
# ── Singleton caching ────────────────────────────────────────────
|
||||
|
||||
@@ -117,9 +278,10 @@ class TestFirecrawlClientConfig:
|
||||
"""FIRECRAWL_API_KEY='' with no URL → should raise."""
|
||||
with patch.dict(os.environ, {"FIRECRAWL_API_KEY": ""}):
|
||||
with patch("tools.web_tools.Firecrawl"):
|
||||
from tools.web_tools import _get_firecrawl_client
|
||||
with pytest.raises(ValueError):
|
||||
_get_firecrawl_client()
|
||||
with patch("tools.web_tools._read_nous_access_token", return_value=None):
|
||||
from tools.web_tools import _get_firecrawl_client
|
||||
with pytest.raises(ValueError):
|
||||
_get_firecrawl_client()
|
||||
|
||||
|
||||
class TestBackendSelection:
|
||||
@@ -130,7 +292,16 @@ class TestBackendSelection:
|
||||
setups.
|
||||
"""
|
||||
|
||||
_ENV_KEYS = ("PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "TAVILY_API_KEY")
|
||||
_ENV_KEYS = (
|
||||
"PARALLEL_API_KEY",
|
||||
"FIRECRAWL_API_KEY",
|
||||
"FIRECRAWL_API_URL",
|
||||
"FIRECRAWL_GATEWAY_URL",
|
||||
"TOOL_GATEWAY_DOMAIN",
|
||||
"TOOL_GATEWAY_SCHEME",
|
||||
"TOOL_GATEWAY_USER_TOKEN",
|
||||
"TAVILY_API_KEY",
|
||||
)
|
||||
|
||||
def setup_method(self):
|
||||
for key in self._ENV_KEYS:
|
||||
@@ -276,10 +447,47 @@ class TestParallelClientConfig:
|
||||
assert client1 is client2
|
||||
|
||||
|
||||
class TestWebSearchErrorHandling:
|
||||
"""Test suite for web_search_tool() error responses."""
|
||||
|
||||
def test_search_error_response_does_not_expose_diagnostics(self):
|
||||
import tools.web_tools
|
||||
|
||||
firecrawl_client = MagicMock()
|
||||
firecrawl_client.search.side_effect = RuntimeError("boom")
|
||||
|
||||
with patch("tools.web_tools._get_backend", return_value="firecrawl"), \
|
||||
patch("tools.web_tools._get_firecrawl_client", return_value=firecrawl_client), \
|
||||
patch("tools.interrupt.is_interrupted", return_value=False), \
|
||||
patch.object(tools.web_tools._debug, "log_call") as mock_log_call, \
|
||||
patch.object(tools.web_tools._debug, "save"):
|
||||
result = json.loads(tools.web_tools.web_search_tool("test query", limit=3))
|
||||
|
||||
assert result == {"error": "Error searching web: boom"}
|
||||
|
||||
debug_payload = mock_log_call.call_args.args[1]
|
||||
assert debug_payload["error"] == "Error searching web: boom"
|
||||
assert "traceback" not in debug_payload["error"]
|
||||
assert "exception_type" not in debug_payload["error"]
|
||||
assert "config" not in result
|
||||
assert "exception_type" not in result
|
||||
assert "exception_chain" not in result
|
||||
assert "traceback" not in result
|
||||
|
||||
|
||||
class TestCheckWebApiKey:
|
||||
"""Test suite for check_web_api_key() unified availability check."""
|
||||
|
||||
_ENV_KEYS = ("PARALLEL_API_KEY", "FIRECRAWL_API_KEY", "FIRECRAWL_API_URL", "TAVILY_API_KEY")
|
||||
_ENV_KEYS = (
|
||||
"PARALLEL_API_KEY",
|
||||
"FIRECRAWL_API_KEY",
|
||||
"FIRECRAWL_API_URL",
|
||||
"FIRECRAWL_GATEWAY_URL",
|
||||
"TOOL_GATEWAY_DOMAIN",
|
||||
"TOOL_GATEWAY_SCHEME",
|
||||
"TOOL_GATEWAY_USER_TOKEN",
|
||||
"TAVILY_API_KEY",
|
||||
)
|
||||
|
||||
def setup_method(self):
|
||||
for key in self._ENV_KEYS:
|
||||
@@ -329,3 +537,22 @@ class TestCheckWebApiKey:
|
||||
}):
|
||||
from tools.web_tools import check_web_api_key
|
||||
assert check_web_api_key() is True
|
||||
|
||||
def test_tool_gateway_returns_true(self):
|
||||
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
|
||||
from tools.web_tools import check_web_api_key
|
||||
assert check_web_api_key() is True
|
||||
|
||||
def test_configured_backend_must_match_available_provider(self):
|
||||
with patch("tools.web_tools._load_web_config", return_value={"backend": "parallel"}):
|
||||
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
|
||||
with patch.dict(os.environ, {"FIRECRAWL_GATEWAY_URL": "http://127.0.0.1:3002"}, clear=False):
|
||||
from tools.web_tools import check_web_api_key
|
||||
assert check_web_api_key() is False
|
||||
|
||||
def test_configured_firecrawl_backend_accepts_managed_gateway(self):
|
||||
with patch("tools.web_tools._load_web_config", return_value={"backend": "firecrawl"}):
|
||||
with patch("tools.web_tools._read_nous_access_token", return_value="nous-token"):
|
||||
with patch.dict(os.environ, {"FIRECRAWL_GATEWAY_URL": "http://127.0.0.1:3002"}, clear=False):
|
||||
from tools.web_tools import check_web_api_key
|
||||
assert check_web_api_key() is True
|
||||
|
||||
Reference in New Issue
Block a user