From c6f87deb2c38d75518c793790f1cc9afa37f0695 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sat, 12 Sep 2026 12:58:29 -0700 Subject: [PATCH] feat(video): OpenRouter backend covers every model on the live video catalog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- plugins/video_gen/openrouter/__init__.py | 507 +++++++++--------- plugins/video_gen/openrouter/plugin.yaml | 2 +- tests/plugins/video_gen/test_openrouter.py | 333 ++++-------- tests/tools/test_video_generate_schema.py | 11 +- tools/video_generation_tool.py | 2 +- .../video-gen-provider-plugin.md | 2 +- website/docs/reference/tools-reference.md | 2 + 7 files changed, 358 insertions(+), 501 deletions(-) diff --git a/plugins/video_gen/openrouter/__init__.py b/plugins/video_gen/openrouter/__init__.py index 9f436b3d77..e4b79f0fed 100644 --- a/plugins/video_gen/openrouter/__init__.py +++ b/plugins/video_gen/openrouter/__init__.py @@ -1,340 +1,339 @@ -"""OpenRouter MiniMax H3 Max video generation backend. +"""OpenRouter video generation backend. -Uses OpenRouter's dedicated asynchronous video API rather than the chat -completions API: submit ``POST /api/v1/videos``, poll the returned job, then -materialize the completed clip under Hermes' video cache. +OpenRouter fronts every major video model (Veo, Sora, Kling, Seedance, Wan, Hailuo, Grok +Imagine, FLUX Video, …) behind one asynchronous API: ``POST /api/v1/videos`` → poll +``GET /api/v1/videos/{id}`` → download ``GET /api/v1/videos/{id}/content`` with the +bearer key. The catalog and each model's limits come live from ``GET /api/v1/videos/models`` +(public, no key), so new releases are selectable without a patch and a retired model drops +out on its own. ``capabilities()`` reflects the *selected* model so the dynamic +``video_generate`` schema advertises only what that model honours. + +Docs: https://openrouter.ai/docs/guides/overview/multimodal/video-generation """ from __future__ import annotations -import ipaddress import logging import os import time -from typing import Any, Dict, List, Optional -from urllib.parse import urlsplit +from typing import Any, Dict, List, Optional, Tuple -from agent.video_gen_provider import ( - VideoGenProvider, - error_response, - save_url_video, - success_response, -) +from agent.video_gen_provider import VideoGenProvider, error_response, save_url_video, success_response logger = logging.getLogger(__name__) DEFAULT_MODEL = "minimax/hailuo-3-max" DEFAULT_BASE_URL = "https://openrouter.ai/api/v1" -DEFAULT_DURATION = 5 -DEFAULT_RESOLUTION = "768p" -DEFAULT_ASPECT_RATIO = "16:9" -SUPPORTED_DURATIONS = tuple(range(5, 16)) -SUPPORTED_RESOLUTIONS = ("480p", "768p") -SUPPORTED_ASPECT_RATIOS = ("21:9", "16:9", "4:3", "1:1", "3:4", "9:16") +_CATALOG_TTL_S = 300.0 +_CATALOG_TIMEOUT_S = 10.0 _TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled", "canceled", "expired"}) +# Every value the API accepts (docs "Supported Resolutions / Aspect Ratios"); the union fallback +# when the live catalog is unreachable. Heights drive nearest-match clamping. +_RESOLUTION_HEIGHT = {"480p": 480, "540p": 540, "720p": 720, "768p": 768, "1080p": 1080, "1K": 1024, "2K": 1440, "4K": 2160} +_ALL_ASPECT_RATIOS = ("16:9", "9:16", "1:1", "4:3", "3:4", "3:2", "2:3", "21:9", "9:21") +_MAX_REFERENCE_IMAGES = 3 # image references are accepted by every provider per the API schema + +# Offline snapshot so the picker/default work before the first successful catalog fetch. +_FALLBACK_CATALOG: List[Dict[str, Any]] = [ + {"id": DEFAULT_MODEL, "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", "last_frame"], "generate_audio": False, "seed": False, + "pricing_skus": {"duration_seconds_480p": "0.05", "duration_seconds_768p": "0.08"}}, +] -def _is_public_first_frame_url(value: str) -> bool: - """Accept provider-fetchable HTTPS URLs, never local/private inputs.""" - try: - parsed = urlsplit(value) - host = (parsed.hostname or "").strip().lower().rstrip(".") - if parsed.scheme.lower() != "https" or not host: - return False - if host == "localhost" or host.endswith((".localhost", ".local", ".lan", ".internal")): - return False +def _price_label(skus: Any) -> str: + """Compact per-second price from ``pricing_skus`` (``$0.10/s`` or ``$0.05–0.28/s``); ``""`` when the + SKU shape is token- or megapixel-priced (Seedance, FLUX upscale) — a wrong number is worse than none.""" + if not isinstance(skus, dict): + return "" + per_second: List[float] = [] + for key, value in skus.items(): try: - return ipaddress.ip_address(host).is_global - except ValueError: - return True + amount = float(value) + except (TypeError, ValueError): + continue + if key.startswith("duration_seconds"): + per_second.append(amount) + elif key.startswith(("cents_per_second_output", "cents_per_video_output_second")): + per_second.append(amount / 100) + if not per_second: + return "" + lo, hi = min(per_second), max(per_second) + return f"${lo:.2f}/s" if lo == hi else f"${lo:.2f}–{hi:.2f}/s" + + +def _ratio(value: Any) -> Optional[float]: + try: + w, h = str(value).split(":") + return float(w) / float(h) + except (ValueError, ZeroDivisionError): + return None + + +def _nearest(value: Any, supported: List[Any], height: Optional[Dict[str, int]] = None) -> Any: + """Closest supported value (durations by distance, resolutions by pixel height, aspect ratios by + numeric ratio); the request is otherwise a guaranteed 400 because the tool always sends a default + resolution/aspect ratio.""" + if not supported or value in supported: + return value + if height is not None: + target = height.get(str(value)) + if target is None: + return supported[0] + scored = [(abs(height[str(s)] - target), i, s) for i, s in enumerate(supported) if str(s) in height] + return min(scored)[2] if scored else supported[0] + if ":" in str(value): + target = _ratio(value) + if target is None: + return supported[0] + scored = [(abs(r - target), i, s) for i, s in enumerate(supported) if (r := _ratio(s)) is not None] + return min(scored)[2] if scored else supported[0] + try: + return min(supported, key=lambda s: (abs(int(s) - int(value)), s)) except (TypeError, ValueError): - return False + return supported[0] -def _nearest(value: int, supported: tuple[int, ...]) -> int: - return min(supported, key=lambda candidate: (abs(candidate - value), candidate)) +def _is_generative(entry: Dict[str, Any]) -> bool: + """Text/image-to-video models declare durations; edit, upscale and avatar models (video/audio input) + do not and are outside the unified ``video_generate`` surface.""" + return bool(entry.get("supported_durations")) + + +def _entry_capabilities(entry: Dict[str, Any]) -> Dict[str, Any]: + durations = [int(d) for d in entry.get("supported_durations") or [] if isinstance(d, (int, float))] + return { + "modalities": ["text", "image"] if entry.get("supported_frame_images") else ["text"], + "aspect_ratios": list(entry.get("supported_aspect_ratios") or _ALL_ASPECT_RATIOS), + "resolutions": list(entry.get("supported_resolutions") or _RESOLUTION_HEIGHT), + "max_duration": max(durations) if durations else 30, "min_duration": min(durations) if durations else 1, + "supports_audio": bool(entry.get("generate_audio")), "supports_negative_prompt": False, + "supports_seed": bool(entry.get("seed")), "supports_upscale": False, + "max_reference_images": _MAX_REFERENCE_IMAGES, + } + + +def _image_part(url: str) -> Dict[str, Any]: + return {"type": "image_url", "image_url": {"url": url}} + + +def _acceptable_image_ref(value: str) -> bool: + """OpenRouter fetches the frame itself, so it needs a public HTTPS URL or an inline ``data:image/`` URL + (what the sandbox confinement chokepoint hands us for local files).""" + lowered = value.lower() + return lowered.startswith("https://") or lowered.startswith("data:image/") def _build_payload( - *, - prompt: str, - image_url: Optional[str], - duration: Optional[int], - aspect_ratio: str, - resolution: str, + entry: Dict[str, Any], *, model: str, prompt: str, image_url: Optional[str], reference_image_urls: Optional[List[str]], + duration: Optional[int], aspect_ratio: str, resolution: str, audio: Optional[bool], seed: Optional[int], ) -> Dict[str, Any]: - """Translate the unified Hermes inputs to OpenRouter's video API.""" - requested_duration = duration if duration is not None else DEFAULT_DURATION - try: - requested_duration = int(requested_duration) - except (TypeError, ValueError): - requested_duration = DEFAULT_DURATION - - payload: Dict[str, Any] = { - "model": DEFAULT_MODEL, - "prompt": prompt, - "duration": _nearest(requested_duration, SUPPORTED_DURATIONS), - "resolution": resolution if resolution in SUPPORTED_RESOLUTIONS else DEFAULT_RESOLUTION, - "aspect_ratio": ( - aspect_ratio if aspect_ratio in SUPPORTED_ASPECT_RATIOS else DEFAULT_ASPECT_RATIO - ), - } + """Unified inputs → OpenRouter body, clamped to the model's live limits; unsupported toggles are dropped + rather than sent (the API 400s on ``seed``/``generate_audio`` for models that lack them).""" + payload: Dict[str, Any] = {"model": model, "prompt": prompt} + if aspect_ratio: + payload["aspect_ratio"] = _nearest(aspect_ratio, list(entry.get("supported_aspect_ratios") or [])) + if resolution: + payload["resolution"] = _nearest(resolution, list(entry.get("supported_resolutions") or []), _RESOLUTION_HEIGHT) + if duration: + payload["duration"] = int(_nearest(int(duration), list(entry.get("supported_durations") or []))) if image_url: - payload["frame_images"] = [ - { - "type": "image_url", - "image_url": {"url": image_url}, - "frame_type": "first_frame", - } - ] + payload["frame_images"] = [{**_image_part(image_url), "frame_type": "first_frame"}] + if reference_image_urls: + payload["input_references"] = [_image_part(u) for u in reference_image_urls[:_MAX_REFERENCE_IMAGES]] + if audio is not None and entry.get("generate_audio"): + payload["generate_audio"] = bool(audio) + if seed is not None and entry.get("seed"): + payload["seed"] = int(seed) return payload class OpenRouterVideoGenProvider(VideoGenProvider): - """MiniMax H3 Max text-to-video and first-frame image-to-video.""" + """Every generative model on OpenRouter's video API, catalog and limits discovered live.""" - _IGNORES_SEED = True # Unified ABC input; H3 Max's OpenRouter SKU rejects it. - _poll_interval_s = 5.0 + name = "openrouter" + display_name = "OpenRouter" + _poll_interval_s = 10.0 _poll_deadline_s = 900.0 _request_timeout_s = 60.0 + def __init__(self) -> None: + self._catalog_cache: Optional[Tuple[List[Dict[str, Any]], float]] = None - @property - def name(self) -> str: - return "openrouter" - - @property - def display_name(self) -> str: - return "OpenRouter" - + # ---- credentials / transport ------------------------------------------------------------- def _api_key(self) -> str: - return os.getenv("OPENROUTER_API_KEY", "").strip() + return os.environ.get("OPENROUTER_API_KEY", "").strip() def _base_url(self) -> str: - return os.getenv("OPENROUTER_BASE_URL", DEFAULT_BASE_URL).strip().rstrip("/") - - def _session(self): - import requests - - return requests.Session() + return (os.environ.get("OPENROUTER_BASE_URL", "").strip() or DEFAULT_BASE_URL).rstrip("/") def _headers(self) -> Dict[str, str]: - return { - "Authorization": f"Bearer {self._api_key()}", - "Content-Type": "application/json", - "User-Agent": "hermes-agent/video_gen", - } + return {"Authorization": f"Bearer {self._api_key()}", "Content-Type": "application/json", + "HTTP-Referer": "https://github.com/NousResearch/hermes-agent", "X-Title": "Hermes Agent"} + + def _session(self) -> Any: + import requests + return requests.Session() def is_available(self) -> bool: return bool(self._api_key()) + # ---- catalog ------------------------------------------------------------------------------- + def _catalog(self) -> List[Dict[str, Any]]: + """Live ``/videos/models`` entries (public endpoint), cached per TTL; the snapshot when unreachable.""" + if self._catalog_cache and time.monotonic() - self._catalog_cache[1] < _CATALOG_TTL_S: + return self._catalog_cache[0] + entries: List[Dict[str, Any]] = [] + try: + import requests + response = requests.get(f"{self._base_url()}/videos/models", timeout=_CATALOG_TIMEOUT_S) + response.raise_for_status() + data = response.json().get("data") + entries = [e for e in (data if isinstance(data, list) else []) if isinstance(e, dict) and e.get("id")] + except Exception as exc: # noqa: BLE001 — offline picker keeps working on the snapshot + logger.debug("OpenRouter video catalog unavailable: %s", exc) + if not entries: + return _FALLBACK_CATALOG + self._catalog_cache = (entries, time.monotonic()) + return entries + + def _entry(self, model_id: str) -> Dict[str, Any]: + """Catalog row for *model_id*; ``{}`` for an unknown id (request passes through unclamped so a + brand-new model works before our cache refreshes — the API validates).""" + return next((e for e in self._catalog() if e.get("id") == model_id), {}) + + def _configured_model(self) -> str: + try: + from hermes_cli.config import cfg_get, load_config + value = cfg_get(load_config(), "video_gen", "model") + except Exception as exc: # noqa: BLE001 + logger.debug("Could not read video_gen.model: %s", exc) + value = None + return value.strip() if isinstance(value, str) and value.strip() else DEFAULT_MODEL + def list_models(self) -> List[Dict[str, Any]]: - return [ - { - "id": DEFAULT_MODEL, - "display": "MiniMax H3 Max", - "speed": "~20-60s", - "strengths": "Fast text-to-video and first-frame image-to-video.", - "price": "$0.05/s (480p), $0.08/s (768p)", - "modalities": ["text", "image"], - } - ] + rows = [] + for entry in self._catalog(): + if not _is_generative(entry): + continue + caps = _entry_capabilities(entry) + extras = [f"{caps['min_duration']}-{caps['max_duration']}s", "/".join(caps["resolutions"])] + extras += ["audio"] if caps["supports_audio"] else [] + extras += ["i2v"] if "image" in caps["modalities"] else [] + rows.append({"id": entry["id"], "display": entry.get("name") or entry["id"], "strengths": ", ".join(extras), + "price": _price_label(entry.get("pricing_skus")), "modalities": caps["modalities"], + "min_duration": caps["min_duration"], "max_duration": caps["max_duration"]}) + return rows def default_model(self) -> Optional[str]: return DEFAULT_MODEL def capabilities(self) -> Dict[str, Any]: - return { - "modalities": ["text", "image"], - "aspect_ratios": list(SUPPORTED_ASPECT_RATIOS), - "resolutions": list(SUPPORTED_RESOLUTIONS), - "max_duration": max(SUPPORTED_DURATIONS), - "min_duration": min(SUPPORTED_DURATIONS), - "supports_audio": False, - "supports_negative_prompt": False, - "supports_seed": False, - "supports_upscale": False, - "max_reference_images": 0, - } + """The selected model's live surface; the API-wide union when the id is unknown or the catalog is down. + ``supports_seed``/``supports_audio`` are per-model (Veo/Wan/Seedance: yes; Hailuo/Grok: no).""" + entry = self._entry(self._configured_model()) + if entry: + return _entry_capabilities(entry) + return {"modalities": ["text", "image"], "aspect_ratios": list(_ALL_ASPECT_RATIOS), + "resolutions": list(_RESOLUTION_HEIGHT), "max_duration": 30, "min_duration": 1, + "supports_audio": True, "supports_negative_prompt": False, "supports_seed": True, + "supports_upscale": False, "max_reference_images": _MAX_REFERENCE_IMAGES} def get_setup_schema(self) -> Dict[str, Any]: - return { - "name": "OpenRouter", - "badge": "paid", - "tag": "MiniMax H3 Max — text-to-video and first-frame image-to-video", - "env_vars": [ - { - "key": "OPENROUTER_API_KEY", - "prompt": "OpenRouter API key", - "url": "https://openrouter.ai/settings/keys", - } - ], - } + return {"name": "OpenRouter", "badge": "paid", + "tag": "Veo 3.1, Sora 2 Pro, Kling 3, Seedance 2, Wan 3, Hailuo 3, Grok Imagine & more — live catalog; " + "text-to-video, image-to-video & reference-to-video; uses OPENROUTER_API_KEY", + "env_vars": [{"key": "OPENROUTER_API_KEY", "prompt": "OpenRouter API key", "url": "https://openrouter.ai/settings/keys"}]} + # ---- generation ---------------------------------------------------------------------------- def _poll(self, session: Any, job_id: str) -> Dict[str, Any]: deadline = time.monotonic() + self._poll_deadline_s url = f"{self._base_url()}/videos/{job_id}" last_status = "unknown" - - def raise_timeout() -> None: - raise TimeoutError( - f"video job {job_id} did not finish within {int(self._poll_deadline_s)}s " - f"(last status={last_status})" - ) - while True: remaining = deadline - time.monotonic() if remaining <= 0: - raise_timeout() - response = session.get( - url, - headers=self._headers(), - timeout=max(0.001, min(self._request_timeout_s, remaining)), - ) + raise TimeoutError(f"video job {job_id} did not finish within {int(self._poll_deadline_s)}s (last status={last_status})") + response = session.get(url, headers=self._headers(), timeout=max(0.001, min(self._request_timeout_s, remaining))) response.raise_for_status() payload = response.json() last_status = str(payload.get("status") or "").lower() or "unknown" - remaining = deadline - time.monotonic() - if remaining <= 0: - raise_timeout() if last_status in _TERMINAL_STATUSES: return payload - time.sleep(min(self._poll_interval_s, remaining)) + time.sleep(min(self._poll_interval_s, max(0.0, deadline - time.monotonic()))) def _save_completed_video(self, job_id: str) -> str: - """Stream the authenticated content endpoint through the shared size cap. - - Do not follow a provider-supplied ``unsigned_urls`` destination: deriving - the endpoint from the configured API origin keeps the bearer request on - the same operator-selected host and avoids a second unbounded code path. - """ - return str( - save_url_video( - f"{self._base_url()}/videos/{job_id}/content", - prefix="openrouter-hailuo", - headers=self._headers(), - require_video_content_type=True, - ) - ) + # The content endpoint is derived from our configured origin, never from ``unsigned_urls``: the + # bearer key must only ever be sent to the host the operator selected. + return str(save_url_video(f"{self._base_url()}/videos/{job_id}/content", prefix="openrouter", + headers=self._headers(), require_video_content_type=True)) def generate( - self, - prompt: str, - *, - model: Optional[str] = None, - image_url: Optional[str] = None, - reference_image_urls: Optional[List[str]] = None, - duration: Optional[int] = None, - aspect_ratio: str = DEFAULT_ASPECT_RATIO, - resolution: str = DEFAULT_RESOLUTION, - negative_prompt: Optional[str] = None, - audio: Optional[bool] = None, - seed: Optional[int] = None, - **kwargs: Any, + self, prompt: str, *, model: Optional[str] = None, image_url: Optional[str] = None, + reference_image_urls: Optional[List[str]] = None, duration: Optional[int] = None, + aspect_ratio: str = "16:9", resolution: str = "720p", negative_prompt: Optional[str] = None, + audio: Optional[bool] = None, seed: Optional[int] = None, **kwargs: Any, ) -> Dict[str, Any]: - del negative_prompt, audio, seed, kwargs - cleaned_prompt = (prompt or "").strip() - if not cleaned_prompt: - return error_response( - error="prompt is required", - error_type="invalid_request", - provider=self.name, - ) - model_id = (model or DEFAULT_MODEL).strip() - if model_id != DEFAULT_MODEL: - return error_response( - error=f"OpenRouter video currently supports only {DEFAULT_MODEL}", - error_type="invalid_model", - provider=self.name, - model=model_id, - prompt=cleaned_prompt, - ) - if reference_image_urls: - return error_response( - error=f"{DEFAULT_MODEL} does not support reference_image_urls", - error_type="unsupported_input", - provider=self.name, - model=model_id, - prompt=cleaned_prompt, - ) - cleaned_image_url = (image_url or "").strip() or None - if cleaned_image_url and not _is_public_first_frame_url(cleaned_image_url): - return error_response( - error="image_url must be a public HTTPS URL", - error_type="invalid_request", - provider=self.name, - model=model_id, - prompt=cleaned_prompt, - ) - if not self._api_key(): - return error_response( - error="OPENROUTER_API_KEY is not set", - error_type="missing_credentials", - provider=self.name, - model=model_id, - prompt=cleaned_prompt, - ) + del negative_prompt, kwargs # no top-level negative_prompt on this API; unknown kwargs are ignored per the ABC + prompt = (prompt or "").strip() + model_id = (model or "").strip() or self._configured_model() - payload = _build_payload( - prompt=cleaned_prompt, - image_url=cleaned_image_url, - duration=duration, - aspect_ratio=aspect_ratio, - resolution=resolution, - ) + def fail(error: str, error_type: str) -> Dict[str, Any]: + return error_response(error=error, error_type=error_type, provider=self.name, model=model_id, prompt=prompt, + aspect_ratio=aspect_ratio) + + if not prompt: + return fail("prompt is required", "invalid_request") + if not self._api_key(): + return fail("OPENROUTER_API_KEY is not set", "missing_credentials") + image_url = (image_url or "").strip() or None + refs = [r.strip() for r in (reference_image_urls or []) if isinstance(r, str) and r.strip()] + for ref in ([image_url] if image_url else []) + refs: + if not _acceptable_image_ref(ref): + return fail("image inputs must be public HTTPS URLs or data:image/ URLs (OpenRouter fetches them itself)", + "invalid_request") + + entry = self._entry(model_id) + payload = _build_payload(entry, model=model_id, prompt=prompt, image_url=image_url, reference_image_urls=refs, + duration=duration, aspect_ratio=aspect_ratio, resolution=resolution, audio=audio, seed=seed) session = self._session() try: - submitted = session.post( - f"{self._base_url()}/videos", - headers=self._headers(), - json=payload, - timeout=self._request_timeout_s, - ) - submitted.raise_for_status() - submission = submitted.json() - job_id = str(submission.get("id") or "").strip() + submitted = session.post(f"{self._base_url()}/videos", headers=self._headers(), json=payload, + timeout=self._request_timeout_s) + if submitted.status_code >= 400: + detail = "" + try: + detail = str((submitted.json().get("error") or {}).get("message") or "") + except Exception: # noqa: BLE001 — non-JSON error body + detail = "" + return fail(f"OpenRouter rejected the request (HTTP {submitted.status_code}): {detail or submitted.text[:300]}", + "api_error") + job_id = str(submitted.json().get("id") or "").strip() if not job_id: - raise ValueError("OpenRouter submit response did not contain a job id") + return fail("OpenRouter submit response did not contain a job id", "api_error") job = self._poll(session, job_id) status = str(job.get("status") or "").lower() if status != "completed": - return error_response( - error=str(job.get("error") or f"video job ended with status={status!r}"), - error_type="job_failed", - provider=self.name, - model=model_id, - prompt=cleaned_prompt, - aspect_ratio=payload["aspect_ratio"], - ) + return fail(str(job.get("error") or f"video job ended with status={status!r}"), "job_failed") video_path = self._save_completed_video(job_id) - except Exception as exc: # noqa: BLE001 - normalize transport/API failures for tool callers - logger.debug("OpenRouter H3 Max video generation failed", exc_info=True) - return error_response( - error=f"OpenRouter video generation failed: {exc}", - error_type="api_error", - provider=self.name, - model=model_id, - prompt=cleaned_prompt, - aspect_ratio=payload["aspect_ratio"], - ) + except Exception as exc: # noqa: BLE001 — normalize transport/timeout failures for tool callers + logger.debug("OpenRouter video generation failed", exc_info=True) + return fail(f"OpenRouter video generation failed: {exc}", "api_error") + finally: + close = getattr(session, "close", None) + if callable(close): + close() raw_usage = job.get("usage") usage: Dict[str, Any] = raw_usage if isinstance(raw_usage, dict) else {} - extra: Dict[str, Any] = {"job_id": job_id} - if usage.get("cost") is not None: - extra["cost"] = usage["cost"] + extra: Dict[str, Any] = {"job_id": job_id, **({"cost": usage["cost"]} if usage.get("cost") is not None else {})} return success_response( - video=video_path, - model=model_id, - prompt=cleaned_prompt, - modality="image" if cleaned_image_url else "text", - aspect_ratio=payload["aspect_ratio"], - duration=payload["duration"], - provider=self.name, - extra=extra, - ) + video=video_path, model=model_id, prompt=prompt, modality="image" if image_url else "text", + aspect_ratio=str(payload.get("aspect_ratio") or ""), duration=int(payload.get("duration") or 0), + provider=self.name, extra=extra) def register(ctx) -> None: - """Plugin entry point.""" + """Plugin entry point — wire ``OpenRouterVideoGenProvider`` into the registry.""" ctx.register_video_gen_provider(OpenRouterVideoGenProvider()) diff --git a/plugins/video_gen/openrouter/plugin.yaml b/plugins/video_gen/openrouter/plugin.yaml index a7aa96932c..e1ab62a31a 100644 --- a/plugins/video_gen/openrouter/plugin.yaml +++ b/plugins/video_gen/openrouter/plugin.yaml @@ -1,6 +1,6 @@ name: openrouter version: 1.0.0 -description: "OpenRouter MiniMax H3 Max video generation via the asynchronous /api/v1/videos API." +description: "OpenRouter video generation backend. Every generative model on the /api/v1/videos API (Veo, Sora, Kling, Seedance, Wan, Hailuo, Grok Imagine, FLUX Video, …) — text-to-video, image-to-video and reference-to-video; catalog and per-model limits discovered live." author: Nous Research kind: backend requires_env: diff --git a/tests/plugins/video_gen/test_openrouter.py b/tests/plugins/video_gen/test_openrouter.py index 59f4dec859..82425dddb5 100644 --- a/tests/plugins/video_gen/test_openrouter.py +++ b/tests/plugins/video_gen/test_openrouter.py @@ -1,247 +1,134 @@ +"""OpenRouter video_gen plugin — live-catalog shape, per-model clamping, and the submit→poll→download flow.""" + from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field -import pytest - -from tools.video_generation_tool import VIDEO_GENERATE_SCHEMA from agent import video_gen_registry -from plugins.video_gen.openrouter import ( - DEFAULT_MODEL, - OpenRouterVideoGenProvider, - _build_payload, -) +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 - content: bytes = b"" + 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}: {self.payload}") + raise RuntimeError(f"HTTP {self.status_code}") +@dataclass class _Session: - def __init__(self): - self.posts = [] - self.gets = [] - self.polls = [ - _Response({"id": "job-1", "status": "in_progress"}), - _Response( - { - "id": "job-1", - "status": "completed", - "unsigned_urls": ["https://cdn.example/video.mp4"], - "usage": {"cost": 0.4}, - } - ), - ] + 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": "https://openrouter.ai/api/v1/videos/job-1", - "status": "pending", - }, - status_code=202, - ) + 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_poll_caps_request_timeout_and_rejects_late_terminal_response(monkeypatch): - provider = OpenRouterVideoGenProvider() - provider._poll_deadline_s = 10 - provider._request_timeout_s = 60 + +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() - session.polls = [_Response({"id": "job-1", "status": "completed"})] - ticks = iter([100.0, 109.0, 111.0]) - monkeypatch.setattr("plugins.video_gen.openrouter.time.monotonic", lambda: next(ticks)) - - with pytest.raises(TimeoutError, match="did not finish within 10s"): - provider._poll(session, "job-1") - - assert session.gets[0][1]["timeout"] == 1.0 - - -def test_unified_video_schema_exposes_hailuo_resolution_and_aspect_ratio(): - properties = VIDEO_GENERATE_SCHEMA["parameters"]["properties"] - - assert "768p" in properties["resolution"]["enum"] - assert "21:9" in properties["aspect_ratio"]["enum"] - - -def test_hailuo_catalog_and_capabilities_are_pinned_to_openrouter_contract(): - provider = OpenRouterVideoGenProvider() - - assert DEFAULT_MODEL == "minimax/hailuo-3-max" - assert provider.default_model() == DEFAULT_MODEL - assert provider.list_models() == [ - { - "id": DEFAULT_MODEL, - "display": "MiniMax H3 Max", - "speed": "~20-60s", - "strengths": "Fast text-to-video and first-frame image-to-video.", - "price": "$0.05/s (480p), $0.08/s (768p)", - "modalities": ["text", "image"], - } - ] - assert provider.capabilities() == { - "modalities": ["text", "image"], - "aspect_ratios": ["21:9", "16:9", "4:3", "1:1", "3:4", "9:16"], - "resolutions": ["480p", "768p"], - "max_duration": 15, - "min_duration": 5, - "supports_audio": False, - "supports_negative_prompt": False, - "supports_seed": False, - "supports_upscale": False, - "max_reference_images": 0, - } - - -def test_build_payload_uses_openrouter_video_fields_and_clamps_values(): - payload = _build_payload( - prompt="A lighthouse in a storm", - image_url="https://example.com/start.png", - duration=99, - aspect_ratio="2:3", - resolution="720p", - ) - - assert payload == { - "model": DEFAULT_MODEL, - "prompt": "A lighthouse in a storm", - "duration": 15, - "resolution": "768p", - "aspect_ratio": "16:9", - "frame_images": [ - { - "type": "image_url", - "image_url": {"url": "https://example.com/start.png"}, - "frame_type": "first_frame", - } - ], - } - - -def test_generate_submits_polls_and_materializes_completed_video(monkeypatch, tmp_path): - provider = OpenRouterVideoGenProvider() - session = _Session() - saved = [] - monkeypatch.setenv("OPENROUTER_API_KEY", "test-key") monkeypatch.setattr(provider, "_session", lambda: session) - monkeypatch.setattr("plugins.video_gen.openrouter.time.sleep", lambda _seconds: None) - monkeypatch.setattr( - "plugins.video_gen.openrouter.save_url_video", - lambda url, prefix, headers, require_video_content_type: saved.append( - (url, prefix, headers, require_video_content_type) - ) - or tmp_path / "hailuo.mp4", - ) + monkeypatch.setattr("plugins.video_gen.openrouter.time.sleep", lambda s: None) + saved = [] - result = provider.generate( - "A slow cinematic push-in", - duration=5, - aspect_ratio="9:16", - resolution="480p", - ) + 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) - assert result["success"] is True - assert result["video"] == str(tmp_path / "hailuo.mp4") - assert result["provider"] == "openrouter" - assert result["model"] == DEFAULT_MODEL - assert result["modality"] == "text" - assert result["duration"] == 5 - assert result["cost"] == 0.4 + 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"]["resolution"] == "480p" - assert [url for url, _kwargs in session.gets] == [ - "https://openrouter.ai/api/v1/videos/job-1", - "https://openrouter.ai/api/v1/videos/job-1", - ] - assert saved == [ - ( - "https://openrouter.ai/api/v1/videos/job-1/content", - "openrouter-hailuo", - { - "Authorization": "Bearer test-key", - "Content-Type": "application/json", - "User-Agent": "hermes-agent/video_gen", - }, - True, - ) - ] + 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_non_http_image_input_without_calling_api(monkeypatch): - provider = OpenRouterVideoGenProvider() - monkeypatch.setenv("OPENROUTER_API_KEY", "test-key") - - result = provider.generate("animate this", image_url="/private/start.png") - - assert result["success"] is False - assert result["error_type"] == "invalid_request" - assert "public HTTP" in result["error"] +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_generate_rejects_private_first_frame_without_calling_api(monkeypatch): - provider = OpenRouterVideoGenProvider() - monkeypatch.setenv("OPENROUTER_API_KEY", "test-key") - monkeypatch.setattr( - provider, - "_session", - lambda: (_ for _ in ()).throw(AssertionError("API must not be called")), - ) - - result = provider.generate("animate this", image_url="https://127.0.0.1/start.png") - - assert result["success"] is False - assert result["error_type"] == "invalid_request" - - -def test_reference_images_are_rejected_before_session_creation(monkeypatch): - provider = OpenRouterVideoGenProvider() - monkeypatch.setenv("OPENROUTER_API_KEY", "test-key") - monkeypatch.setattr( - provider, - "_session", - lambda: (_ for _ in ()).throw(AssertionError("API must not be called")), - ) - - result = provider.generate( - "use these references", - reference_image_urls=["https://example.com/reference.png"], - ) - - assert result["success"] is False - assert result["error_type"] == "unsupported_input" - - -def test_generate_rejects_unknown_model_without_calling_api(monkeypatch): - provider = OpenRouterVideoGenProvider() - monkeypatch.setenv("OPENROUTER_API_KEY", "test-key") - - result = provider.generate("test", model="other/video-model") - - assert result["success"] is False - assert result["error_type"] == "invalid_model" - assert DEFAULT_MODEL in result["error"] - - -def test_register_and_picker_discovery_expose_openrouter(monkeypatch): - from hermes_cli import tools_config - from hermes_cli import plugins as plugin_loader +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: @@ -252,35 +139,7 @@ def test_register_and_picker_discovery_expose_openrouter(monkeypatch): try: register(_Context()) monkeypatch.setattr(plugin_loader, "_ensure_plugins_discovered", lambda: None) - - registered = video_gen_registry.get_provider("openrouter") - rows = tools_config._plugin_video_gen_providers() - - assert isinstance(registered, OpenRouterVideoGenProvider) - row = next(item for item in rows if item["video_gen_plugin_name"] == "openrouter") - assert row["name"] == "OpenRouter" - assert row["env_vars"][0]["key"] == "OPENROUTER_API_KEY" + 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() - - -def test_dynamic_schema_is_capability_scoped(monkeypatch): - from tools import video_generation_tool - - provider = OpenRouterVideoGenProvider() - monkeypatch.setattr(video_generation_tool, "_resolve_active_provider", lambda: provider) - monkeypatch.setattr(video_generation_tool, "_read_configured_video_model", lambda: DEFAULT_MODEL) - - schema = video_generation_tool._build_dynamic_video_schema() - properties = schema["parameters"]["properties"] - - assert properties["duration"]["minimum"] == 5 - assert properties["duration"]["maximum"] == 15 - assert properties["resolution"]["enum"] == ["480p", "768p"] - assert properties["aspect_ratio"]["enum"] == [ - "21:9", "16:9", "4:3", "1:1", "3:4", "9:16", - ] - assert "image_url" in properties - assert "reference_image_urls" not in properties - assert "audio" not in properties - assert "seed" not in properties diff --git a/tests/tools/test_video_generate_schema.py b/tests/tools/test_video_generate_schema.py index f07e938eb1..8a4bf5c50d 100644 --- a/tests/tools/test_video_generate_schema.py +++ b/tests/tools/test_video_generate_schema.py @@ -168,13 +168,10 @@ class TestFleetCapabilityCoverage(unittest.TestCase): f"({implements_upscale})", ) declares_seed = '"supports_seed": True' in src - # Merely accepting ``seed`` in generate() is part of the ABC - # contract, not proof that the backend implements it. Providers - # can mark that contract-only parameter explicitly. - implements_seed = ("payload[\"seed\"]" in impl_src - or "\"seed\": seed" in impl_src - or ("seed: Optional[int]" in impl_src - and "_IGNORES_SEED = True" not in impl_src)) + implements_seed = ("seed" in impl_src + and ("payload[\"seed\"]" in impl_src + or "seed: Optional[int]" in impl_src + or "\"seed\": seed" in impl_src)) self.assertEqual( declares_seed, implements_seed, f"{name}: supports_seed declaration ({declares_seed}) " diff --git a/tools/video_generation_tool.py b/tools/video_generation_tool.py index 3f10d846e4..4f33309939 100644 --- a/tools/video_generation_tool.py +++ b/tools/video_generation_tool.py @@ -134,7 +134,7 @@ def _missing_provider_error(configured: Optional[str]) -> str: error_type="provider_not_registered", provider=configured)) return json.dumps(error_response( error=("No video generation backend is configured. Run `hermes tools` → " - "Video Generation to enable one (xAI, FAL, or Google Veo)."), + "Video Generation to enable one (xAI, FAL, OpenRouter, or DeepInfra)."), error_type="no_provider_configured")) diff --git a/website/docs/developer-guide/video-gen-provider-plugin.md b/website/docs/developer-guide/video-gen-provider-plugin.md index 4301b6bd26..ddb4a8aa31 100644 --- a/website/docs/developer-guide/video-gen-provider-plugin.md +++ b/website/docs/developer-guide/video-gen-provider-plugin.md @@ -6,7 +6,7 @@ description: "How to build a video-generation backend plugin for Hermes Agent" # Building a Video Generation Provider Plugin -Video-gen provider plugins register a backend that services every `video_generate` tool call. Built-in providers (xAI, FAL, DeepInfra) ship as plugins. Add a new one, or override a bundled one, by dropping a directory into `plugins/video_gen//`. +Video-gen provider plugins register a backend that services every `video_generate` tool call. Built-in providers (xAI, FAL, OpenRouter, DeepInfra) ship as plugins. Add a new one, or override a bundled one, by dropping a directory into `plugins/video_gen//`. :::tip Video-gen mirrors [Image Generation Provider Plugins](/developer-guide/image-gen-provider-plugin) almost line-for-line — if you've built an image-gen backend, you already know the shape. The main differences: a `capabilities()` method advertising modalities/aspect-ratios/durations, and a routing convention (pass `image_url` to use image-to-video, omit it to use text-to-video — the provider picks the right endpoint internally). diff --git a/website/docs/reference/tools-reference.md b/website/docs/reference/tools-reference.md index 94f8518c5c..58296c9640 100644 --- a/website/docs/reference/tools-reference.md +++ b/website/docs/reference/tools-reference.md @@ -309,6 +309,8 @@ Backends ship as plugins under `plugins/video_gen//`: - **xAI Grok-Imagine** — text-to-video and image-to-video (SuperGrok OAuth or `XAI_API_KEY`). - **FAL.ai** — Veo 3.1, Pixverse v6, Kling O3 (requires `FAL_KEY`). +- **OpenRouter** — every generative model on OpenRouter's video API (Veo 3.1, Sora 2 Pro, Kling 3, Seedance 2, Wan 3, Hailuo 3, Grok Imagine, FLUX 3 Video, …); text-to-video, image-to-video and reference-to-video; catalog and per-model limits fetched live (requires `OPENROUTER_API_KEY`, billed to your OpenRouter credit). +- **DeepInfra** — live `video-gen` catalog over the OpenAI-compatible videos endpoint (requires `DEEPINFRA_API_KEY`). The single `video_generate` tool covers both modalities — pass `image_url` to animate a still, omit it to generate from text alone. The active backend auto-routes to the right endpoint. The tool's description is rebuilt at session start to reflect the active backend's actual capabilities (modalities, aspect ratios, resolutions, duration range, max reference images, audio support). See [Video Generation Provider Plugins](/developer-guide/video-gen-provider-plugin) for backend authoring.