Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions hermes_cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1741,10 +1741,10 @@ def _ensure_hermes_home_managed(home: Path):
"category": "tool",
},
"FAL_KEY": {
"description": "FAL API key for image generation",
"description": "FAL API key for image and video generation",
"prompt": "FAL API key",
"url": "https://fal.ai/",
"tools": ["image_generate"],
"tools": ["image_generate", "video_generate"],
"password": True,
"category": "tool",
},
Expand Down
24 changes: 24 additions & 0 deletions hermes_cli/tools_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
("code_execution", "⚡ Code Execution", "execute_code"),
("vision", "👁️ Vision / Image Analysis", "vision_analyze"),
("image_gen", "🎨 Image Generation", "image_generate"),
("video_gen", "🎬 Video Generation", "video_generate"),
("moa", "🧠 Mixture of Agents", "mixture_of_agents"),
("tts", "🔊 Text-to-Speech", "text_to_speech"),
("skills", "📚 Skills", "list, view, manage"),
Expand Down Expand Up @@ -325,6 +326,29 @@ def _get_plugin_toolset_keys() -> set:
},
],
},
"video_gen": {
"name": "Video Generation",
"icon": "🎬",
"providers": [
{
"name": "Nous Subscription",
"badge": "subscription",
"tag": "Managed FAL video generation billed to your subscription",
"env_vars": [],
"requires_nous_auth": True,
"managed_nous_feature": "image_gen",
"override_env_vars": ["FAL_KEY"],
},
{
"name": "FAL.ai",
"badge": "paid",
"tag": "Text-to-video and image-to-video via FAL-compatible endpoints",
"env_vars": [
{"key": "FAL_KEY", "prompt": "FAL API key", "url": "https://fal.ai/dashboard/keys"},
],
},
],
},
"browser": {
"name": "Browser Automation",
"icon": "🌐",
Expand Down
5 changes: 5 additions & 0 deletions tests/test_model_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,11 @@ def test_get_all_tool_names_returns_list(self):
assert "web_search" in names
assert "terminal" in names

def test_video_generate_is_discovered(self):
names = get_all_tool_names()
assert "video_generate" in names
assert get_toolset_for_tool("video_generate") == "video_gen"

def test_get_toolset_for_tool(self):
result = get_toolset_for_tool("web_search")
assert result is not None
Expand Down
5 changes: 5 additions & 0 deletions tests/test_toolsets.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,11 @@ def test_all_includes_reference_existing_toolsets(self):
for inc in ts["includes"]:
assert inc in TOOLSETS, f"{name} includes unknown toolset '{inc}'"

def test_video_toolset_includes_video_generate(self):
assert "video_gen" in TOOLSETS
assert "video_generate" in resolve_toolset("video_gen")
assert "video_generate" in resolve_toolset("hermes-cli")

def test_hermes_platforms_share_core_tools(self):
"""All hermes-* platform toolsets share the same core tools.

Expand Down
121 changes: 121 additions & 0 deletions tests/tools/test_video_generation_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import json
from importlib import import_module, reload


class _FakeDownloadResponse:
def __init__(self, body=b"fake-video-bytes"):
self._body = body

def __enter__(self):
return self

def __exit__(self, *exc):
return False

def read(self):
return self._body


def _reload_video_tool(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("FAL_KEY", "fal-test-key")
module = import_module("tools.video_generation_tool")
return reload(module)


def test_text_to_video_submits_default_fal_endpoint_and_saves_mp4(monkeypatch, tmp_path):
video_generation_tool = _reload_video_tool(monkeypatch, tmp_path)
captured = {}

class FakeHandle:
def get(self):
return {
"video": {
"url": "https://v3.fal.media/files/lion/render_output.mp4",
"content_type": "video/mp4",
"file_name": "render_output.mp4",
}
}

def fake_submit(model, arguments):
captured["model"] = model
captured["arguments"] = arguments
return FakeHandle()

def fake_urlopen(request, timeout=None):
captured["download_url"] = request.full_url
captured["download_timeout"] = timeout
return _FakeDownloadResponse()

monkeypatch.setattr(video_generation_tool, "_submit_fal_request", fake_submit)
monkeypatch.setattr(video_generation_tool.urllib.request, "urlopen", fake_urlopen)

result = json.loads(video_generation_tool.video_generate_tool(
prompt="a small robot walking through rainy neon streets",
mode="text_to_video",
duration="5",
aspect_ratio="portrait",
))

assert result["success"] is True
assert result["provider"] == "fal"
assert result["mode"] == "text_to_video"
assert result["model"] == "fal-ai/kling-video/v2.1/master/text-to-video"
assert result["video_url"] == result["video"]
assert result["media_path"].startswith(str(tmp_path / "generated-videos"))
assert open(result["media_path"], "rb").read() == b"fake-video-bytes"
assert captured["download_url"] == result["video"]
assert captured["download_timeout"] == 300
assert captured["arguments"] == {
"prompt": "a small robot walking through rainy neon streets",
"duration": "5",
"aspect_ratio": "9:16",
"negative_prompt": "blur, distort, and low quality",
"cfg_scale": 0.5,
}


def test_image_to_video_requires_image_url_and_submits_image_endpoint(monkeypatch, tmp_path):
video_generation_tool = _reload_video_tool(monkeypatch, tmp_path)
captured = {}

class FakeHandle:
def get(self):
return {"video": {"url": "https://v3.fal.media/files/rabbit/i2v.mp4"}}

monkeypatch.setattr(video_generation_tool, "_submit_fal_request", lambda model, arguments: captured.update({"model": model, "arguments": arguments}) or FakeHandle())
monkeypatch.setattr(video_generation_tool.urllib.request, "urlopen", lambda request, timeout=None: _FakeDownloadResponse(b"image-to-video"))

result = json.loads(video_generation_tool.video_generate_tool(
prompt="gentle camera push-in, leaves moving in the wind",
mode="image_to_video",
image_url="https://example.com/source.png",
duration=10,
aspect_ratio="square",
cfg_scale=0.7,
))

assert result["success"] is True
assert result["mode"] == "image_to_video"
assert captured["model"] == "fal-ai/kling-video/v2.1/master/image-to-video"
assert captured["arguments"] == {
"prompt": "gentle camera push-in, leaves moving in the wind",
"image_url": "https://example.com/source.png",
"duration": "10",
"aspect_ratio": "1:1",
"negative_prompt": "blur, distort, and low quality",
"cfg_scale": 0.7,
}


def test_image_to_video_without_image_url_returns_validation_error(monkeypatch, tmp_path):
video_generation_tool = _reload_video_tool(monkeypatch, tmp_path)
result = json.loads(video_generation_tool.video_generate_tool(prompt="slow cinematic motion", mode="image_to_video"))
assert result["success"] is False
assert result["error_type"] == "ValueError"
assert "image_url is required" in result["error"]


def test_check_requirements_accepts_direct_fal_key(monkeypatch, tmp_path):
video_generation_tool = _reload_video_tool(monkeypatch, tmp_path)
assert video_generation_tool.check_video_generation_requirements() is True
Loading