Files
hermes-agent/tests/plugins/video_gen/test_openrouter.py
T
teknium1 c6f87deb2c feat(video): OpenRouter backend covers every model on the live video catalog
The salvaged #103267 plugin hardcoded a single model (minimax/hailuo-3-max) and
rejected any other id. OpenRouter's public GET /api/v1/videos/models already
publishes every generative model with its supported durations, resolutions,
aspect ratios, frame-image support, audio and seed flags, and pricing SKUs, so
the provider now reads that catalog (5-min TTL, offline snapshot fallback):

- list_models(): all 25+ generative models (edit/upscale/avatar rows that take
  no duration are outside the unified video_generate surface and are dropped)
  with a per-second price label where the SKU is per-second
- capabilities(): the CONFIGURED model's surface, so the dynamic schema only
  advertises audio/seed/resolutions the selected model honours
- _build_payload(): clamps duration/resolution/aspect ratio to the model's
  live limits (nearest by value/height/ratio) and drops generate_audio/seed
  for models that lack them (the API 400s otherwise); reference images ride
  in input_references; local file inputs are refused (OpenRouter fetches
  URLs itself), data:image/ URLs from the sandbox chokepoint pass through
- bearer key only ever goes to the configured origin (poll + /content),
  never to a provider-supplied unsigned_urls host (kept from #103267)

Also drops the source-grep `_IGNORES_SEED` escape hatch #103267 added to the
declaration⇄implementation sweep; the provider now implements seed for real.
Docs list OpenRouter and DeepInfra as bundled video backends.

Requested by Don Piedro Savastano (Discord): OpenRouter credit for video_generate.
2026-09-12 13:44:52 -07:00

146 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""OpenRouter video_gen plugin — live-catalog shape, per-model clamping, and the submit→poll→download flow."""
from __future__ import annotations
from dataclasses import dataclass, field
from agent import video_gen_registry
from plugins.video_gen.openrouter import OpenRouterVideoGenProvider, _build_payload
_VEO = {"id": "google/veo-3.1", "name": "Google: Veo 3.1", "supported_durations": [4, 6, 8],
"supported_resolutions": ["720p", "1080p", "4K"], "supported_aspect_ratios": ["16:9", "9:16"],
"supported_frame_images": ["first_frame", "last_frame"], "generate_audio": True, "seed": True,
"pricing_skus": {"duration_seconds_with_audio": "0.40", "duration_seconds_without_audio": "0.20"}}
_HAILUO = {"id": "minimax/hailuo-3-max", "name": "MiniMax: Hailuo 3 Max", "supported_durations": list(range(5, 16)),
"supported_resolutions": ["768p", "480p"], "supported_aspect_ratios": ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"],
"supported_frame_images": ["first_frame"], "generate_audio": False, "seed": False,
"pricing_skus": {"duration_seconds_480p": "0.05", "duration_seconds_768p": "0.08"}}
_EDIT = {"id": "black-forest-labs/flux-video-edit", "name": "FLUX Video Edit", "supported_durations": None,
"supported_resolutions": None, "supported_aspect_ratios": None, "supported_frame_images": None,
"generate_audio": False, "seed": False, "pricing_skus": {"cents_per_second_output": "3"}}
def _provider(monkeypatch, catalog, configured="google/veo-3.1"):
provider = OpenRouterVideoGenProvider()
monkeypatch.setattr(provider, "_catalog", lambda: catalog)
monkeypatch.setattr(provider, "_configured_model", lambda: configured)
return provider
def test_catalog_drives_picker_rows_and_selected_model_capabilities(monkeypatch):
"""Rows come from the live catalog minus edit/upscale models; capabilities() follows the CONFIGURED
model (Veo: audio+seed; Hailuo: neither) so the dynamic schema never advertises a dead toggle."""
provider = _provider(monkeypatch, [_VEO, _HAILUO, _EDIT], configured="google/veo-3.1")
rows = provider.list_models()
assert [r["id"] for r in rows] == ["google/veo-3.1", "minimax/hailuo-3-max"]
assert rows[0]["price"] == "$0.20–0.40/s" and rows[1]["max_duration"] == 15
veo = provider.capabilities()
assert veo["supports_audio"] and veo["supports_seed"] and veo["resolutions"] == ["720p", "1080p", "4K"]
monkeypatch.setattr(provider, "_configured_model", lambda: "minimax/hailuo-3-max")
hailuo = provider.capabilities()
assert not hailuo["supports_audio"] and not hailuo["supports_seed"] and hailuo["max_duration"] == 15
def test_payload_clamps_to_model_limits_and_drops_unsupported_toggles():
payload = _build_payload(_HAILUO, model=_HAILUO["id"], prompt="p", image_url="https://x/a.png",
reference_image_urls=["https://x/r.png"], duration=99, aspect_ratio="2:3",
resolution="720p", audio=True, seed=7)
assert payload["duration"] == 15 and payload["resolution"] == "768p" and payload["aspect_ratio"] == "3:4"
assert payload["frame_images"][0]["frame_type"] == "first_frame"
assert payload["input_references"] == [{"type": "image_url", "image_url": {"url": "https://x/r.png"}}]
assert "generate_audio" not in payload and "seed" not in payload # Hailuo lacks both → would 400
veo = _build_payload(_VEO, model=_VEO["id"], prompt="p", image_url=None, reference_image_urls=None,
duration=5, aspect_ratio="16:9", resolution="1080p", audio=False, seed=7)
assert veo["duration"] == 4 and veo["generate_audio"] is False and veo["seed"] == 7
@dataclass
class _Response:
payload: dict
status_code: int = 200
text: str = ""
def json(self):
return self.payload
def raise_for_status(self):
if self.status_code >= 400:
raise RuntimeError(f"HTTP {self.status_code}")
@dataclass
class _Session:
posts: list = field(default_factory=list)
gets: list = field(default_factory=list)
polls: list = field(default_factory=lambda: [
_Response({"id": "job-1", "status": "in_progress"}),
_Response({"id": "job-1", "status": "completed", "unsigned_urls": ["https://evil.example/steal"],
"usage": {"cost": 0.4}})])
def post(self, url, **kwargs):
self.posts.append((url, kwargs))
return _Response({"id": "job-1", "polling_url": f"{url}/job-1", "status": "pending"}, status_code=202)
def get(self, url, **kwargs):
self.gets.append((url, kwargs))
return self.polls.pop(0)
def close(self):
pass
def test_generate_submits_polls_and_downloads_from_configured_origin(monkeypatch, tmp_path):
"""The bearer key goes to the poll URL and to ``{base}/videos/{id}/content`` derived from OUR base URL,
never to a provider-supplied ``unsigned_urls`` host."""
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
monkeypatch.delenv("OPENROUTER_BASE_URL", raising=False)
provider = _provider(monkeypatch, [_VEO], configured="google/veo-3.1")
session = _Session()
monkeypatch.setattr(provider, "_session", lambda: session)
monkeypatch.setattr("plugins.video_gen.openrouter.time.sleep", lambda s: None)
saved = []
def fake_save(url, **kwargs):
saved.append((url, kwargs))
return tmp_path / "clip.mp4"
monkeypatch.setattr("plugins.video_gen.openrouter.save_url_video", fake_save)
result = provider.generate("a fox", duration=6, resolution="1080p", aspect_ratio="16:9", audio=True)
assert result["success"], result
assert result["video"] == str(tmp_path / "clip.mp4") and result["cost"] == 0.4 and result["duration"] == 6
assert session.posts[0][0] == "https://openrouter.ai/api/v1/videos"
assert session.posts[0][1]["json"]["model"] == "google/veo-3.1" and session.posts[0][1]["json"]["generate_audio"] is True
assert session.posts[0][1]["headers"]["Authorization"] == "Bearer sk-or-test"
assert [g[0] for g in session.gets] == ["https://openrouter.ai/api/v1/videos/job-1"] * 2
assert saved[0][0] == "https://openrouter.ai/api/v1/videos/job-1/content"
assert saved[0][1]["headers"]["Authorization"] == "Bearer sk-or-test" and saved[0][1]["require_video_content_type"]
def test_generate_rejects_local_image_paths_before_spending(monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-test")
provider = _provider(monkeypatch, [_VEO])
monkeypatch.setattr(provider, "_session", lambda: (_ for _ in ()).throw(AssertionError("must not submit")))
result = provider.generate("p", image_url="/home/me/frame.png")
assert not result["success"] and result["error_type"] == "invalid_request"
def test_register_exposes_openrouter_in_the_video_gen_picker(monkeypatch):
from hermes_cli import plugins as plugin_loader, tools_config
from plugins.video_gen.openrouter import register
class _Context:
def register_video_gen_provider(self, provider):
video_gen_registry.register_provider(provider)
video_gen_registry._reset_for_tests()
try:
register(_Context())
monkeypatch.setattr(plugin_loader, "_ensure_plugins_discovered", lambda: None)
row = next(r for r in tools_config._plugin_video_gen_providers() if r["video_gen_plugin_name"] == "openrouter")
assert row["name"] == "OpenRouter" and row["env_vars"][0]["key"] == "OPENROUTER_API_KEY"
finally:
video_gen_registry._reset_for_tests()