feat(video_gen): route FAL video gen through managed Nous gateway
Wire plugins/video_gen/fal/__init__.py to use the same _ManagedFalSyncClient pattern that image gen already uses. Changes: - Add managed gateway resolution, client caching, and _submit_fal_video_request() that routes between direct FAL_KEY and Nous gateway modes - Update is_available() to return True when either FAL_KEY or the managed gateway is reachable - Update generate() to use submit+get handle pattern instead of fal_client.subscribe() directly - Fix happy-horse endpoint namespace: fal-ai/ → alibaba/ (matches the tool-gateway allowlist from fal-video-gen branch) - Surface actionable error on 4xx gateway rejections Tests: - 4 new tests in test_managed_media_gateways.py (gateway routing, client reuse, direct mode fallback, alibaba namespace) - Updated existing test_fal_plugin.py fixture to use submit/handle pattern and patch _resolve_managed_fal_video_gateway for isolation
This commit is contained in:
committed by
Siddharth Balyan
parent
5cd0673217
commit
d04b3c193e
@@ -17,7 +17,7 @@ Model families (each with t2v + i2v endpoints):
|
||||
veo3.1 fal-ai/veo3.1 / fal-ai/veo3.1/image-to-video
|
||||
seedance-2.0 bytedance/seedance-2.0/text-to-video / bytedance/seedance-2.0/image-to-video
|
||||
kling-v3-4k fal-ai/kling-video/v3/4k/text-to-video / fal-ai/kling-video/v3/4k/image-to-video
|
||||
happy-horse fal-ai/happy-horse/text-to-video / fal-ai/happy-horse/image-to-video
|
||||
happy-horse alibaba/happy-horse/text-to-video / alibaba/happy-horse/image-to-video
|
||||
|
||||
Selection precedence for the active family:
|
||||
1. ``model=`` arg from the tool call
|
||||
@@ -26,14 +26,16 @@ Selection precedence for the active family:
|
||||
4. ``video_gen.model`` in ``config.yaml`` (when it's one of our family IDs)
|
||||
5. ``DEFAULT_MODEL``
|
||||
|
||||
Authentication via ``FAL_KEY``. Output is an HTTPS URL from FAL's CDN; the
|
||||
gateway downloads and delivers it.
|
||||
Authentication via ``FAL_KEY`` or the managed Nous gateway. Output is an
|
||||
HTTPS URL from FAL's CDN; the gateway downloads and delivers it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from agent.video_gen_provider import (
|
||||
@@ -148,8 +150,8 @@ FAL_FAMILIES: Dict[str, Dict[str, Any]] = {
|
||||
"price": "premium",
|
||||
"strengths": "Alibaba. New model, sparse public docs — conservative defaults.",
|
||||
"tier": "premium",
|
||||
"text_endpoint": "fal-ai/happy-horse/text-to-video",
|
||||
"image_endpoint": "fal-ai/happy-horse/image-to-video",
|
||||
"text_endpoint": "alibaba/happy-horse/text-to-video",
|
||||
"image_endpoint": "alibaba/happy-horse/image-to-video",
|
||||
# Docs don't expose duration/aspect/resolution — let the endpoint
|
||||
# apply its own defaults.
|
||||
"aspect_ratios": None,
|
||||
@@ -302,6 +304,92 @@ def _load_fal_client() -> Any:
|
||||
return _fal_client
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Managed FAL gateway (Nous Subscription)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_managed_fal_video_client: Any = None
|
||||
_managed_fal_video_client_config: Any = None
|
||||
_managed_fal_video_client_lock = threading.Lock()
|
||||
|
||||
|
||||
def _resolve_managed_fal_video_gateway():
|
||||
"""Return managed fal-queue gateway config when the user prefers the gateway
|
||||
or direct FAL credentials are absent."""
|
||||
from tools.tool_backend_helpers import fal_key_is_configured, prefers_gateway
|
||||
|
||||
if fal_key_is_configured() and not prefers_gateway("video_gen"):
|
||||
return None
|
||||
from tools.managed_tool_gateway import resolve_managed_tool_gateway
|
||||
|
||||
return resolve_managed_tool_gateway("fal-queue")
|
||||
|
||||
|
||||
def _get_managed_fal_video_client(managed_gateway):
|
||||
"""Reuse the managed FAL client so its internal httpx.Client is not leaked per call."""
|
||||
global _managed_fal_video_client, _managed_fal_video_client_config
|
||||
from tools.fal_common import _ManagedFalSyncClient
|
||||
|
||||
client_config = (
|
||||
managed_gateway.gateway_origin.rstrip("/"),
|
||||
managed_gateway.nous_user_token,
|
||||
)
|
||||
with _managed_fal_video_client_lock:
|
||||
if _managed_fal_video_client is not None and _managed_fal_video_client_config == client_config:
|
||||
return _managed_fal_video_client
|
||||
|
||||
_load_fal_client()
|
||||
_managed_fal_video_client = _ManagedFalSyncClient(
|
||||
_fal_client,
|
||||
key=managed_gateway.nous_user_token,
|
||||
queue_run_origin=managed_gateway.gateway_origin,
|
||||
)
|
||||
_managed_fal_video_client_config = client_config
|
||||
return _managed_fal_video_client
|
||||
|
||||
|
||||
def _submit_fal_video_request(endpoint: str, arguments: Dict[str, Any]):
|
||||
"""Submit a FAL video request using direct credentials or the managed queue gateway.
|
||||
|
||||
Returns a request handle whose ``.get()`` blocks until the result is ready.
|
||||
"""
|
||||
_load_fal_client()
|
||||
request_headers = {"x-idempotency-key": str(uuid.uuid4())}
|
||||
managed_gateway = _resolve_managed_fal_video_gateway()
|
||||
if managed_gateway is None:
|
||||
return _fal_client.submit(endpoint, arguments=arguments, headers=request_headers)
|
||||
|
||||
managed_client = _get_managed_fal_video_client(managed_gateway)
|
||||
try:
|
||||
return managed_client.submit(
|
||||
endpoint,
|
||||
arguments=arguments,
|
||||
headers=request_headers,
|
||||
)
|
||||
except Exception as exc:
|
||||
from tools.fal_common import _extract_http_status
|
||||
|
||||
status = _extract_http_status(exc)
|
||||
if status is not None and 400 <= status < 500:
|
||||
raise ValueError(
|
||||
f"Nous Subscription gateway rejected endpoint '{endpoint}' "
|
||||
f"(HTTP {status}). This model may not yet be enabled on "
|
||||
f"the Nous Portal's FAL proxy. Either:\n"
|
||||
f" • Set FAL_KEY in your environment to use FAL.ai directly, or\n"
|
||||
f" • Pick a different model via `hermes tools` → Video Generation."
|
||||
) from exc
|
||||
raise
|
||||
|
||||
|
||||
def _check_fal_video_available() -> bool:
|
||||
"""True if the FAL.ai video backend is reachable (direct key or managed gateway)."""
|
||||
from tools.tool_backend_helpers import fal_key_is_configured
|
||||
|
||||
if fal_key_is_configured():
|
||||
return True
|
||||
return _resolve_managed_fal_video_gateway() is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Provider
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -323,13 +411,10 @@ class FALVideoGenProvider(VideoGenProvider):
|
||||
return "FAL"
|
||||
|
||||
def is_available(self) -> bool:
|
||||
if not os.environ.get("FAL_KEY", "").strip():
|
||||
return False
|
||||
try:
|
||||
import fal_client # noqa: F401
|
||||
except ImportError:
|
||||
return _check_fal_video_available()
|
||||
except Exception: # noqa: BLE001 — never break the picker
|
||||
return False
|
||||
return True
|
||||
|
||||
def list_models(self) -> List[Dict[str, Any]]:
|
||||
out: List[Dict[str, Any]] = []
|
||||
@@ -394,11 +479,12 @@ class FALVideoGenProvider(VideoGenProvider):
|
||||
seed: Optional[int] = None,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
if not os.environ.get("FAL_KEY", "").strip():
|
||||
if not _check_fal_video_available():
|
||||
return error_response(
|
||||
error=(
|
||||
"FAL_KEY not set. Run `hermes tools` → Video Generation "
|
||||
"→ FAL to configure."
|
||||
"No FAL backend available. Either set FAL_KEY "
|
||||
"(run `hermes tools` → Video Generation → FAL to configure) "
|
||||
"or sign in to Nous (`hermes setup`) for managed gateway access."
|
||||
),
|
||||
error_type="auth_required",
|
||||
provider="fal",
|
||||
@@ -406,7 +492,7 @@ class FALVideoGenProvider(VideoGenProvider):
|
||||
)
|
||||
|
||||
try:
|
||||
fal_client = _load_fal_client()
|
||||
_load_fal_client()
|
||||
except ImportError:
|
||||
return error_response(
|
||||
error="fal_client Python package not installed (pip install fal-client)",
|
||||
@@ -467,11 +553,8 @@ class FALVideoGenProvider(VideoGenProvider):
|
||||
)
|
||||
|
||||
try:
|
||||
result = fal_client.subscribe(
|
||||
endpoint,
|
||||
arguments=payload,
|
||||
with_logs=False,
|
||||
)
|
||||
handle = _submit_fal_video_request(endpoint, payload)
|
||||
result = handle.get()
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"FAL video gen failed (family=%s, endpoint=%s): %s",
|
||||
|
||||
Reference in New Issue
Block a user