d8053f4806
fal's LTX 2.5 fast endpoints accept 6-20s only up to 1080p — "At 1440p and 2160p, all frame rates support up to 10 seconds" — so a 4K request with the family's 20s ceiling was rejected by the vendor. Families can now declare `duration_cap_by_resolution`, applied after the enum snap / range clamp on the resolved resolution enum. An unset duration on a duration_enum family also snapped to enum[0] (6s), silently overriding the endpoint's own "auto" default; None now omits the key for enum families exactly as it already did for range families. test_managed_media_gateways asserts the alibaba/happy-horse/ namespace by prefix rather than the exact v1.1 literal so the next version bump doesn't flip an unrelated gateway test.
351 lines
13 KiB
Python
351 lines
13 KiB
Python
import sys
|
|
import types
|
|
from importlib.util import module_from_spec, spec_from_file_location
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from hermes_cli.nous_account import NousPortalAccountInfo
|
|
|
|
|
|
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)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _enable_managed_nous_tools(monkeypatch):
|
|
"""Patch the source modules so managed_nous_tools_enabled() returns True
|
|
even after tool modules are dynamically reloaded."""
|
|
monkeypatch.setattr(
|
|
"hermes_cli.nous_account.get_nous_portal_account_info",
|
|
lambda: NousPortalAccountInfo(
|
|
logged_in=True,
|
|
source="jwt",
|
|
fresh=False,
|
|
paid_service_access=True,
|
|
),
|
|
)
|
|
|
|
|
|
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 _capture_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()
|
|
|
|
def _maybe_retry_request(client, method, url, json=None, timeout=None, headers=None):
|
|
return _capture_request(client, method, url, json, timeout, headers)
|
|
|
|
class FakeHttpClient:
|
|
def request(self, method, url, json=None, timeout=None, headers=None):
|
|
return _capture_request(self, method, url, json, timeout, headers)
|
|
|
|
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 = FakeHttpClient()
|
|
|
|
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,
|
|
BadRequestError=type("BadRequestError", (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_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.delenv("OPENAI_API_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(sys.modules["tools.tts_tool_openai"].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))
|
|
# The managed audio route is the stored "nous" selection (strict model);
|
|
# a stored "openai" selection now means direct credentials only.
|
|
(tmp_path / "config.yaml").write_text("stt:\n provider: nous\n")
|
|
monkeypatch.delenv("VOICE_TOOLS_OPENAI_KEY", raising=False)
|
|
monkeypatch.delenv("OPENAI_API_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": "nous"}
|
|
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
|
|
|
|
|
|
PLUGINS_DIR = Path(__file__).resolve().parents[2] / "plugins"
|
|
|
|
|
|
def _load_video_gen_plugin(monkeypatch):
|
|
"""Load the FAL video gen plugin in isolation."""
|
|
_install_fake_tools_package()
|
|
|
|
# Also need the agent.video_gen_provider ABC
|
|
agent_dir = Path(__file__).resolve().parents[2] / "agent"
|
|
spec = spec_from_file_location(
|
|
"agent.video_gen_provider",
|
|
agent_dir / "video_gen_provider.py",
|
|
)
|
|
assert spec and spec.loader
|
|
mod = module_from_spec(spec)
|
|
sys.modules["agent.video_gen_provider"] = mod
|
|
spec.loader.exec_module(mod)
|
|
|
|
# Load the plugin
|
|
plugin_init = PLUGINS_DIR / "video_gen" / "fal" / "__init__.py"
|
|
spec = spec_from_file_location("plugins.video_gen.fal", plugin_init)
|
|
assert spec and spec.loader
|
|
plugin_mod = module_from_spec(spec)
|
|
sys.modules["plugins.video_gen.fal"] = plugin_mod
|
|
spec.loader.exec_module(plugin_mod)
|
|
return plugin_mod
|
|
|
|
|
|
def test_video_gen_happy_horse_uses_alibaba_namespace():
|
|
"""Verify the happy-horse family uses alibaba/ not fal-ai/ endpoints."""
|
|
_install_fake_tools_package()
|
|
|
|
# Load just the plugin module to check the catalog
|
|
plugin_init = PLUGINS_DIR / "video_gen" / "fal" / "__init__.py"
|
|
|
|
agent_dir = Path(__file__).resolve().parents[2] / "agent"
|
|
spec = spec_from_file_location(
|
|
"agent.video_gen_provider",
|
|
agent_dir / "video_gen_provider.py",
|
|
)
|
|
mod = module_from_spec(spec)
|
|
sys.modules["agent.video_gen_provider"] = mod
|
|
spec.loader.exec_module(mod)
|
|
|
|
spec = spec_from_file_location("plugins.video_gen.fal", plugin_init)
|
|
plugin_mod = module_from_spec(spec)
|
|
sys.modules["plugins.video_gen.fal"] = plugin_mod
|
|
spec.loader.exec_module(plugin_mod)
|
|
|
|
hh = plugin_mod.FAL_FAMILIES["happy-horse"]
|
|
assert hh["text_endpoint"].startswith("alibaba/happy-horse/") and hh["text_endpoint"].endswith("/text-to-video")
|
|
assert hh["image_endpoint"].startswith("alibaba/happy-horse/") and hh["image_endpoint"].endswith("/image-to-video")
|