refactor(video_generate): capability-gated dynamic schema (~814 → 458/377 tok/call) (#97095)

* refactor(video_generate): capability-gated dynamic schema — 6 optional args render only when the active provider/model honors them; fleet capability declarations + declaration<->implementation contract tests

* fix(video_gen): H3/Grok/Happy-Horse/Gemini audio is ALWAYS-ON native, not absent — new audio_native family key + audio_always_on capability surfaces as description line (maintainer catch)

* test(video_gen): duration-span test pins the active-model contract — resolved family's real window, short families not inflated, union fallback still spans 30s
This commit is contained in:
Teknium
2026-08-28 05:10:33 -07:00
committed by GitHub
parent 902cd05147
commit 536adb35c4
9 changed files with 499 additions and 139 deletions
+10 -2
View File
@@ -148,11 +148,17 @@ class VideoGenProvider(abc.ABC):
"min_duration": 1,
"supports_audio": True,
"supports_negative_prompt": True,
"supports_seed": True,
"supports_upscale": True,
"max_reference_images": 7,
}
Used by the tool layer for soft validation and by ``hermes tools``
for the picker. Default: text-only.
Used by the tool layer for soft validation, for capability-gated
param rendering in the dynamic ``video_generate`` schema (args a
backend can't honor are not advertised), and by ``hermes tools``
for the picker. Default fails closed: text-only, no optional
features — a provider that doesn't declare a capability doesn't
advertise it.
"""
return {
"modalities": ["text"],
@@ -162,6 +168,8 @@ class VideoGenProvider(abc.ABC):
"min_duration": 1,
"supports_audio": False,
"supports_negative_prompt": False,
"supports_seed": False,
"supports_upscale": False,
"max_reference_images": 0,
}
+2
View File
@@ -67,6 +67,8 @@ class DeepInfraVideoGenProvider(OpenAICompatibleVideoGenProvider):
"min_duration": 1,
"supports_audio": False,
"supports_negative_prompt": True,
"supports_seed": True,
"supports_upscale": False,
"max_reference_images": 0,
}
+59 -7
View File
@@ -95,6 +95,7 @@ FAL_FAMILIES: Dict[str, Dict[str, Any]] = {
"durations": None,
"audio": True,
"negative": True,
"seed": True,
},
"pixverse-v6": {
"display": "Pixverse v6",
@@ -109,6 +110,7 @@ FAL_FAMILIES: Dict[str, Dict[str, Any]] = {
"durations": (1, 15),
"audio": True,
"negative": True,
"seed": True,
},
"seedance-2.0-mini": {
"display": "Seedance 2.0 Mini",
@@ -140,6 +142,7 @@ FAL_FAMILIES: Dict[str, Dict[str, Any]] = {
"duration_suffix": "s", # FAL veo3.1 wants "4s" not "4"
"audio": True,
"negative": True,
"seed": True,
},
"seedance-2.0": {
"display": "Seedance 2.0",
@@ -201,7 +204,8 @@ FAL_FAMILIES: Dict[str, Dict[str, Any]] = {
"1080p": "2K", "2k": "2K", "4k": "4K", "2160p": "4K",
},
"durations": (5, 15),
"audio": False, # audio is native/always-on; no generate_audio key
"audio": False, # no generate_audio TOGGLE — audio is always on
"audio_native": True, # native audio in every generation (fal docs) # audio is native/always-on; no generate_audio key
"negative": False,
"seed": False,
},
@@ -231,8 +235,10 @@ FAL_FAMILIES: Dict[str, Dict[str, Any]] = {
# `prompt_expansion_mode` is in the schema's required array (with a
# "balanced" default) — always send it.
"static_payload": {"prompt_expansion_mode": "balanced"},
"audio": False, # audio is native/always-on; no generate_audio key
"audio": False, # no generate_audio TOGGLE — audio is always on
"audio_native": True, # native audio in every generation (fal docs) # audio is native/always-on; no generate_audio key
"negative": False,
"seed": True,
# Unlike base H3, Max declares `seed` on both endpoints.
},
"flux-3": {
@@ -266,7 +272,8 @@ FAL_FAMILIES: Dict[str, Dict[str, Any]] = {
"aspect_ratios": ("16:9", "4:3", "3:2", "1:1", "2:3", "3:4", "9:16"),
"resolutions": ("480p", "720p", "1080p"),
"durations": (1, 15),
"audio": False, # audio is native; no generate_audio key
"audio": False, # no generate_audio TOGGLE — audio is always on
"audio_native": True, # native audio in every generation (fal docs) # audio is native; no generate_audio key
"negative": False,
"seed": False,
},
@@ -283,7 +290,8 @@ FAL_FAMILIES: Dict[str, Dict[str, Any]] = {
"aspect_ratios": ("16:9", "9:16"),
"resolutions": None,
"durations": (3, 10),
"audio": False, # audio is native; no generate_audio key
"audio": False, # no generate_audio TOGGLE — audio is always on
"audio_native": True, # native audio in every generation (fal docs) # audio is native; no generate_audio key
"negative": False,
"seed": False,
},
@@ -303,6 +311,7 @@ FAL_FAMILIES: Dict[str, Dict[str, Any]] = {
"durations": (3, 15),
"audio": True,
"negative": True,
"seed": True,
},
"happy-horse": {
"display": "Happy Horse 1.0",
@@ -317,8 +326,10 @@ FAL_FAMILIES: Dict[str, Dict[str, Any]] = {
"aspect_ratios": None,
"resolutions": None,
"durations": None,
"audio": False,
"audio": False, # no generate_audio TOGGLE — audio is always on
"audio_native": True, # native audio in every generation (fal docs)
"negative": False,
"seed": True,
},
}
@@ -806,8 +817,47 @@ class FALVideoGenProvider(VideoGenProvider):
}
def capabilities(self) -> Dict[str, Any]:
# Union across families so the tool schema doesn't understate the
# longest-running models (Seedance 2.5 = 30s, FLUX 3 = 20s).
# Active-model-aware (mirrors the image_gen fal plugin, #97057):
# report the RESOLVED family's actual surface so the dynamic tool
# schema gates params on what the selected model honors, not a
# union that overstates every axis. Falls back to the cross-family
# union if resolution fails (never raises).
try:
_family_id, family = _resolve_family(None)
except Exception: # noqa: BLE001
family = None
if family:
modalities = []
if family.get("text_endpoint"):
modalities.append("text")
if family.get("image_endpoint"):
modalities.append("image")
durs = family.get("durations") or (1, 1)
if _is_duration_range(durs):
lo, hi = durs
else:
lo, hi = min(durs), max(durs)
return {
"modalities": modalities or ["text"],
"aspect_ratios": list(family.get("aspect_ratios") or []),
"resolutions": list(family.get("resolutions") or []),
"max_duration": hi,
"min_duration": lo,
"supports_audio": bool(family.get("audio")),
# Always-on native audio (no toggle): surfaces as a
# description line, not a param. Verified per-family
# against fal model pages (H3, Grok 1.5, Happy Horse,
# Gemini Omni Flash all return native audio every run).
"audio_always_on": bool(family.get("audio_native")),
"supports_negative_prompt": bool(family.get("negative")),
# Explicit per-family key (contract-tested); absent would
# mean a catalog bug, so fail closed here.
"supports_seed": bool(family.get("seed", False)),
# SeedVR upscaler chains for any FAL video family.
"supports_upscale": True,
"max_reference_images": 0,
}
# Fallback: union across families (legacy shape).
max_dur = 1
min_dur: Optional[int] = None
for meta in FAL_FAMILIES.values():
@@ -828,6 +878,8 @@ class FALVideoGenProvider(VideoGenProvider):
"min_duration": min_dur if min_dur is not None else 1,
"supports_audio": True,
"supports_negative_prompt": True,
"supports_seed": True,
"supports_upscale": True,
"max_reference_images": 0,
}
+2
View File
@@ -423,6 +423,8 @@ class XAIVideoGenProvider(VideoGenProvider):
"min_duration": 1,
"supports_audio": False,
"supports_negative_prompt": False,
"supports_seed": True,
"supports_upscale": False,
"max_reference_images": MAX_REFERENCE_IMAGES,
}
+27 -3
View File
@@ -268,10 +268,34 @@ class TestFamilyKeyNormalization:
assert _normalize_family_key("blackforestlabs/flux-3") == "flux-3"
def test_capabilities_span_longest_family_duration(self):
"""Provider caps must not understate Seedance 2.5's 30s ceiling."""
from plugins.video_gen.fal import FALVideoGenProvider
"""capabilities() is active-MODEL-aware (#95681 diet): it reports
the resolved family's real window, so the schema doesn't overstate
short families or understate Seedance 2.5. The union fallback
(resolution failure) must still span the 30s ceiling."""
from unittest.mock import patch as _patch
caps = FALVideoGenProvider().capabilities()
import plugins.video_gen.fal as _fp
from plugins.video_gen.fal import FAL_FAMILIES, FALVideoGenProvider
# Active model resolved → that family's actual window.
meta = FAL_FAMILIES["seedance-2.5"]
with _patch.object(_fp, "_resolve_family",
return_value=("seedance-2.5", meta)):
caps = FALVideoGenProvider().capabilities()
assert caps["max_duration"] >= 30
# A short family must NOT be inflated to the union ceiling.
short = FAL_FAMILIES["pixverse-v6"]
durs = short.get("durations")
hi = durs[1] if isinstance(durs, tuple) else max(durs)
with _patch.object(_fp, "_resolve_family",
return_value=("pixverse-v6", short)):
caps = FALVideoGenProvider().capabilities()
assert caps["max_duration"] == hi
# Resolution failure → union fallback still spans the ceiling.
with _patch.object(_fp, "_resolve_family",
side_effect=RuntimeError("no config")):
caps = FALVideoGenProvider().capabilities()
assert caps["max_duration"] >= 30
assert caps["min_duration"] <= 1
+248
View File
@@ -0,0 +1,248 @@
"""video_generate dynamic schema — capability-gated params (#95681 diet).
Mirrors tests/tools/test_image_generate_schema.py (#97057). Coverage is
guaranteed three ways:
1. every in-tree video_gen plugin's capabilities() must declare EVERY axis
the schema builder reads (a new axis added to the builder without fleet
declarations fails here);
2. every FAL video family must carry the per-family keys the fal provider's
active-model capabilities() resolution reads;
3. declaration⇄implementation: a provider that declares seed/upscale must
implement it, and vice versa (source-level sweep, both directions).
"""
import os
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
import tools.video_generation_tool as vt
from tools.video_generation_tool import (
VIDEO_GENERATE_SCHEMA,
_build_dynamic_video_schema,
)
# Every axis _build_dynamic_video_schema reads from capabilities().
CAPABILITY_AXES = (
"modalities",
"aspect_ratios",
"resolutions",
"max_duration",
"min_duration",
"supports_audio",
"supports_negative_prompt",
"supports_seed",
"supports_upscale",
"max_reference_images",
)
# Per-family keys the FAL provider's capabilities() resolution reads.
FAL_FAMILY_KEYS = ("durations", "aspect_ratios", "resolutions", "audio",
"negative", "seed")
def _plugin_sources():
import pathlib
plugins_dir = (pathlib.Path(__file__).resolve().parents[2]
/ "plugins" / "video_gen")
assert plugins_dir.is_dir(), plugins_dir
out = {}
for plugin in sorted(plugins_dir.iterdir()):
src_file = plugin / "__init__.py"
if src_file.is_file():
out[plugin.name] = src_file.read_text(encoding="utf-8")
return out
class TestFleetCapabilityCoverage(unittest.TestCase):
def test_every_provider_declares_every_axis(self):
"""Instantiate each in-tree provider class and check the RETURNED
capabilities dict — source grep can't see inherited keys."""
checked = 0
# fal
from plugins.video_gen.fal import FALVideoGenProvider
caps = FALVideoGenProvider().capabilities()
for axis in CAPABILITY_AXES:
self.assertIn(axis, caps, f"fal missing {axis}")
checked += 1
# xai
from plugins.video_gen.xai import XAIVideoGenProvider
caps = XAIVideoGenProvider().capabilities()
for axis in CAPABILITY_AXES:
self.assertIn(axis, caps, f"xai missing {axis}")
checked += 1
# deepinfra
from plugins.video_gen.deepinfra import DeepInfraVideoGenProvider
caps = DeepInfraVideoGenProvider().capabilities()
for axis in CAPABILITY_AXES:
self.assertIn(axis, caps, f"deepinfra missing {axis}")
checked += 1
self.assertGreaterEqual(checked, 3)
def test_abc_default_fails_closed(self):
from agent.video_gen_provider import VideoGenProvider
class _P(VideoGenProvider):
name = "t"
display_name = "T"
def generate(self, prompt, **kw):
return {}
def list_models(self):
return []
caps = _P().capabilities()
self.assertEqual(caps.get("modalities"), ["text"])
for axis in ("supports_audio", "supports_negative_prompt",
"supports_seed", "supports_upscale"):
self.assertFalse(caps.get(axis), axis)
for axis in CAPABILITY_AXES:
self.assertIn(axis, caps, f"ABC default missing {axis}")
def test_every_fal_family_declares_per_family_keys(self):
from plugins.video_gen.fal import FAL_FAMILIES
for fam, meta in FAL_FAMILIES.items():
with self.subTest(family=fam):
for key in FAL_FAMILY_KEYS:
self.assertIn(key, meta, f"{fam} missing {key}")
self.assertTrue(
meta.get("text_endpoint") or meta.get("image_endpoint"),
f"{fam} declares no endpoint",
)
# Audio truth model: "audio" means a generate_audio TOGGLE
# exists; "audio_native" means audio is ALWAYS ON with no
# toggle. A family may have neither (silent model) but
# never both — that would be contradictory.
self.assertFalse(
meta.get("audio") and meta.get("audio_native"),
f"{fam}: audio toggle and always-on are mutually "
"exclusive",
)
def test_always_on_audio_surfaces_in_description_not_param(self):
"""Maintainer catch (H3 has audio!): families whose audio is
always-on (no API toggle) must TELL the model about the audio in
the description rather than advertise a dead `audio` param."""
schema = TestDynamicParamGating._schema_with(
TestDynamicParamGating(), {
"modalities": ["text", "image"],
"supports_audio": False, "audio_always_on": True,
"supports_negative_prompt": False, "supports_seed": False,
"supports_upscale": False, "max_reference_images": 0,
})
self.assertNotIn("audio", schema["parameters"]["properties"])
self.assertIn("always on", schema["description"])
def test_declaration_matches_implementation(self):
"""supports_seed / supports_upscale: declaration ⇄ implementation,
source-level, both directions, every in-tree plugin.
deepinfra inherits generate() from OpenAICompatibleVideoGenProvider
(agent/video_gen_provider.py), so its implementation source is the
base class file."""
import pathlib
base_src = (pathlib.Path(__file__).resolve().parents[2]
/ "agent" / "video_gen_provider.py").read_text(encoding="utf-8")
for name, src in _plugin_sources().items():
with self.subTest(provider=name):
impl_src = src if "def generate" in src else src + base_src
declares_upscale = '"supports_upscale": True' in src
implements_upscale = ("_upscale_video" in impl_src
or "UPSCALER_ENDPOINT" in impl_src)
self.assertEqual(
declares_upscale, implements_upscale,
f"{name}: supports_upscale declaration "
f"({declares_upscale}) != implementation "
f"({implements_upscale})",
)
declares_seed = '"supports_seed": True' in 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}) "
f"!= implementation ({implements_seed})",
)
class TestDynamicParamGating(unittest.TestCase):
def _schema_with(self, caps, model_meta=None):
class _Prov:
name = "fake"
display_name = "Fake"
def capabilities(self):
return caps
def list_models(self):
return [dict({"id": "m1"}, **(model_meta or {}))]
def default_model(self):
return "m1"
with patch.object(vt, "_resolve_active_provider",
return_value=_Prov()), \
patch.object(vt, "_read_configured_video_model",
return_value="m1"):
return _build_dynamic_video_schema()
def test_full_featured_backend_gets_all_params(self):
schema = self._schema_with({
"modalities": ["text", "image"],
"aspect_ratios": ["16:9"], "resolutions": ["720p"],
"min_duration": 2, "max_duration": 12,
"supports_audio": True, "supports_negative_prompt": True,
"supports_seed": True, "supports_upscale": True,
"max_reference_images": 7,
})
props = schema["parameters"]["properties"]
for p in ("image_url", "reference_image_urls", "negative_prompt",
"audio", "seed", "upscale"):
self.assertIn(p, props, p)
self.assertEqual(props["reference_image_urls"]["maxItems"], 7)
self.assertEqual(props["duration"]["minimum"], 2)
self.assertEqual(props["duration"]["maximum"], 12)
self.assertEqual(props["aspect_ratio"]["enum"], ["16:9"])
def test_minimal_backend_gets_bare_params(self):
schema = self._schema_with({
"modalities": ["text"],
"supports_audio": False, "supports_negative_prompt": False,
"supports_seed": False, "supports_upscale": False,
"max_reference_images": 0,
})
props = schema["parameters"]["properties"]
for p in ("image_url", "reference_image_urls", "negative_prompt",
"audio", "seed", "upscale"):
self.assertNotIn(p, props, p)
self.assertIn("text-to-video only", schema["description"])
def test_i2v_only_model_overrides_backend_union(self):
# gemini-omni-flash case: dual-modality backend, i2v-only model.
schema = self._schema_with(
{"modalities": ["text", "image"], "max_reference_images": 0,
"supports_audio": False, "supports_negative_prompt": False,
"supports_seed": False, "supports_upscale": False},
model_meta={"modalities": ["image"]},
)
self.assertIn("image_url", schema["parameters"]["properties"])
self.assertIn("image-to-video only", schema["description"])
def test_no_provider_serves_prompt_only(self):
with patch.object(vt, "_resolve_active_provider", return_value=None):
schema = _build_dynamic_video_schema()
self.assertEqual(sorted(schema["parameters"]["properties"]), ["prompt"])
def test_static_schema_carries_no_capability_args(self):
props = VIDEO_GENERATE_SCHEMA["parameters"]["properties"]
self.assertEqual(
sorted(props),
["aspect_ratio", "duration", "model", "prompt", "resolution"],
)
if __name__ == "__main__":
unittest.main()
@@ -97,11 +97,16 @@ class TestUnifiedDispatch:
def test_upscale_in_schema_and_forwarded(self):
"""`upscale` is an agent-facing param, forwarded to providers when
set and omitted (not None) when unset."""
from tools.video_generation_tool import VIDEO_GENERATE_SCHEMA
"""`upscale` is advertised per-capability by the dynamic builder
(#95681 diet — static schema no longer carries it) and forwarded
to providers when set, omitted (not None) when unset."""
from tools.video_generation_tool import (
VIDEO_GENERATE_SCHEMA,
_build_dynamic_video_schema,
)
# Static placeholder: capability args live in the dynamic override.
props = VIDEO_GENERATE_SCHEMA["parameters"]["properties"]
assert props["upscale"]["type"] == "boolean"
assert "upscale" not in props
provider = _RecordingProvider()
video_gen_registry.register_provider(provider)
@@ -110,9 +110,13 @@ class TestDynamicSchemaBuilder:
video_gen_registry.register_provider(_BothModalitiesProvider())
_write_cfg(cfg_home, {"video_gen": {"provider": "both", "model": "family-a"}})
desc = _build_dynamic_video_schema()["description"]
assert "supports both text-to-video" in desc
assert "duration range: 1-15s" in desc
schema = _build_dynamic_video_schema()
# Dual-modality (#95681 diet): capability surfaces as PARAMS —
# image_url advertised; duration bounds from the model window.
props = schema["parameters"]["properties"]
assert "image_url" in props
assert props["duration"]["minimum"] == 1
assert props["duration"]["maximum"] == 15
def test_i2v_only_model_does_not_claim_text_to_video(self, cfg_home):
"""A dual-modality backend with an i2v-only active model must not
@@ -154,8 +158,14 @@ class TestDynamicSchemaBuilder:
{"video_gen": {"provider": "dual-i2v", "model": "gemini-like"}},
)
desc = _build_dynamic_video_schema()["description"]
schema = _build_dynamic_video_schema()
desc = schema["description"]
assert "image-to-video only" in desc
assert "supports both text-to-video" not in desc
# Prefer the active model's duration window over the backend union.
assert "duration range: 3-10s" in desc
# Prefer the active model's duration window over the backend union
# — now expressed as param bounds, not prose.
props = schema["parameters"]["properties"]
assert props["duration"]["minimum"] == 3
assert props["duration"]["maximum"] == 10
# i2v-only still advertises image_url.
assert "image_url" in props
+126 -117
View File
@@ -61,12 +61,13 @@ logger = logging.getLogger(__name__)
VIDEO_GENERATE_SCHEMA: Dict[str, Any] = {
"name": "video_generate",
# Placeholder — the real description is built dynamically at
# get_tool_definitions() time so it reflects the active backend's
# actual capabilities (which modalities / resolutions / duration
# ranges the user's currently-selected model supports).
# See _build_dynamic_video_schema() below and the dynamic-tool-schemas
# skill at github/hermes-agent-dev/references/dynamic-tool-schemas.md.
# Placeholder — description AND params are rebuilt dynamically at
# get_tool_definitions() time from the active provider's declared
# capabilities() and the active model's catalog entry. Optional args
# (image_url, reference_image_urls, negative_prompt, audio, seed,
# upscale) are advertised ONLY when the active backend/model honors
# them; the handler accepts them regardless (replay compat — providers
# clamp/ignore). See _build_dynamic_video_schema().
"description": "(rebuilt at get_definitions() time — see _build_dynamic_video_schema)",
"parameters": {
"type": "object",
@@ -78,93 +79,36 @@ VIDEO_GENERATE_SCHEMA: Dict[str, Any] = {
"subject, style, camera movement, etc."
),
},
"image_url": {
"type": "string",
"description": (
"Optional public HTTPS URL of a still image. When provided, "
"the active backend routes to its image-to-video "
"endpoint (animate the image); when omitted, it routes "
"to text-to-video. For xAI chaining, use the `image` or "
"`public_url` HTTPS URL from a prior Imagine result."
),
},
"reference_image_urls": {
"type": "array",
"items": {"type": "string"},
"description": (
"Optional list of public HTTPS reference image URLs "
"(style or character refs). For xAI chaining, use "
"`image` or `public_url` from prior Imagine results."
),
},
"duration": {
"type": "integer",
"description": (
"Desired video duration in seconds. Providers clamp to "
"their supported range (commonly 4-15s). Omit to use the "
"provider's default."
"their supported range. Omit for the provider default."
),
},
"aspect_ratio": {
"type": "string",
"enum": list(COMMON_ASPECT_RATIOS),
"description": (
"Output aspect ratio. Providers clamp to their supported "
"set."
),
"description": "Output aspect ratio.",
"default": DEFAULT_ASPECT_RATIO,
},
"resolution": {
"type": "string",
"enum": list(COMMON_RESOLUTIONS),
"description": (
"Output resolution. Providers clamp to their supported "
"set."
),
"description": "Output resolution.",
"default": DEFAULT_RESOLUTION,
},
"negative_prompt": {
"type": "string",
"description": (
"Optional negative prompt — content to avoid in the "
"output. Supported by Pixverse, Kling, and similar; "
"ignored by providers that do not support it."
),
},
"audio": {
"type": "boolean",
"description": (
"Optional audio generation toggle. Supported by Veo3 and "
"Pixverse (affects pricing tier); ignored elsewhere."
),
},
"seed": {
"type": "integer",
"description": (
"Optional seed for reproducible outputs (provider-"
"dependent)."
),
},
"upscale": {
"type": "boolean",
"description": (
"Optional high-resolution pass: when true, the generated "
"video is run through the active backend's video upscaler "
"(extra cost and latency, roughly 2x resolution). Use when "
"the user asks for high-res / 4K output. Omit for the "
"model's native resolution. Ignored by backends without "
"an upscaler."
),
},
"model": {
"type": "string",
"description": (
"Optional model override. If omitted, the user's "
"configured ``video_gen.model`` (set via `hermes tools` "
"→ Video Generation) is used. Models that the active "
"provider does not know are rejected."
"Optional model override; defaults to the configured "
"``video_gen.model``. Unknown models are rejected."
),
},
# NOTE (schema diet, #95681): image_url / reference_image_urls /
# negative_prompt / audio / seed / upscale are added
# per-capability by _build_dynamic_video_schema. Do not re-add
# them statically.
},
"required": ["prompt"],
},
@@ -487,22 +431,18 @@ def _format_model_caveats(
def _build_dynamic_video_schema() -> Dict[str, Any]:
"""Build a description that reflects the active backend's actual surface.
"""Render description AND params from the active backend's declared surface.
Cheap: reads config (already memoized by the caller), asks the active
provider for `capabilities()` and the active model's catalog entry,
and formats a few lines of prose. Falls back to the generic
description when no provider is configured or registered.
Optional args are advertised only when the resolved provider/model
honors them (capabilities() + the model's catalog entry — coverage is
contract-tested per provider); enums and duration bounds tighten to
the active model's actual sets. The handler still accepts unadvertised
args (replay compat): providers clamp or ignore, as before.
"""
static_props = VIDEO_GENERATE_SCHEMA["parameters"]["properties"]
parts: List[str] = [_GENERIC_DESCRIPTION]
configured_model = _read_configured_video_model()
# Reflect the *resolved* active provider (same resolution the handler uses
# in _resolve_active_provider): an explicit ``video_gen.provider``, or —
# when unset — the single available registered backend. Keeping the
# description in sync with execution stops the agent from being told
# "no backend configured" while a call would actually succeed.
provider = _resolve_active_provider()
if provider is None:
@@ -510,7 +450,14 @@ def _build_dynamic_video_schema() -> Dict[str, Any]:
"\nNo video backend is available. Calls will return an error "
"until the user picks one via `hermes tools` → Video Generation."
)
return {"description": "\n".join(parts)}
return {
"description": "\n".join(parts),
"parameters": {
"type": "object",
"properties": {"prompt": static_props["prompt"]},
"required": ["prompt"],
},
}
try:
caps = provider.capabilities() or {}
@@ -527,47 +474,22 @@ def _build_dynamic_video_schema() -> Dict[str, Any]:
{},
)
backend_label = provider.display_name
line = f"\nActive backend: {backend_label}"
if active_model:
line += f" · model: {active_model}"
parts.append(line)
# Model-specific caveats (the high-signal stuff)
# ---- description -------------------------------------------------
for c in _format_model_caveats(model_meta, caps):
parts.append(f"- {c}")
# Prefer the active model's modalities over the backend union. An
# i2v-only family on a dual-modality backend (e.g. gemini-omni-flash
# on FAL) must not also claim text-to-video support.
model_modalities = set(model_meta.get("modalities") or [])
modality = model_meta.get("modality")
if modality:
model_modalities.add(modality)
effective_modalities = model_modalities or set(caps.get("modalities") or [])
if "text" in effective_modalities and "image" in effective_modalities:
parts.append(
"- supports both text-to-video (omit image_url) and "
"image-to-video (pass image_url) — routes automatically"
)
can_i2v = "image" in effective_modalities
t2v = "text" in effective_modalities
if can_i2v and not t2v:
parts.append("- image-to-video only: image_url is REQUIRED")
elif not can_i2v:
parts.append("- text-to-video only (no image input)")
if caps.get("aspect_ratios"):
parts.append(f"- aspect_ratio choices: {', '.join(caps['aspect_ratios'])}")
if caps.get("resolutions"):
parts.append(f"- resolution choices: {', '.join(caps['resolutions'])}")
min_duration = model_meta.get("min_duration", caps.get("min_duration"))
max_duration = model_meta.get("max_duration", caps.get("max_duration"))
if min_duration and max_duration:
parts.append(
f"- duration range: {min_duration}-{max_duration}s"
)
if caps.get("supports_audio"):
parts.append("- audio: pass `audio=true` to enable native audio (pricing tier)")
if caps.get("supports_negative_prompt"):
parts.append("- negative_prompt: supported")
max_refs = caps.get("max_reference_images") or 0
if max_refs:
parts.append(f"- reference_image_urls: up to {max_refs} images")
if provider.name == "xai":
parts.append(
"- chaining: for edit/extend pass the public HTTPS MP4 in `video` "
@@ -584,7 +506,94 @@ def _build_dynamic_video_schema() -> Dict[str, Any]:
if notice:
parts.append(f"- storage: {notice}")
return {"description": "\n".join(parts)}
# ---- params ------------------------------------------------------
properties: Dict[str, Any] = {"prompt": static_props["prompt"]}
if can_i2v:
properties["image_url"] = {
"type": "string",
"description": (
"Public HTTPS URL of a still image to animate "
"(image-to-video). Omit for text-to-video."
),
}
max_refs = int(caps.get("max_reference_images") or 0)
if max_refs > 0:
properties["reference_image_urls"] = {
"type": "array",
"items": {"type": "string"},
"maxItems": max_refs,
"description": (
f"Up to {max_refs} public HTTPS reference image URLs "
"(style or character refs)."
),
}
min_duration = model_meta.get("min_duration", caps.get("min_duration"))
max_duration = model_meta.get("max_duration", caps.get("max_duration"))
duration_param = dict(static_props["duration"])
if min_duration and max_duration:
duration_param["minimum"] = int(min_duration)
duration_param["maximum"] = int(max_duration)
duration_param["description"] = (
f"Video duration in seconds ({min_duration}-{max_duration}). "
"Omit for the provider default."
)
properties["duration"] = duration_param
# Tighten enums to the active backend's actual sets when declared.
aspect_param = dict(static_props["aspect_ratio"])
if caps.get("aspect_ratios"):
aspect_param["enum"] = list(caps["aspect_ratios"])
properties["aspect_ratio"] = aspect_param
resolution_param = dict(static_props["resolution"])
if caps.get("resolutions"):
resolution_param["enum"] = list(caps["resolutions"])
properties["resolution"] = resolution_param
if caps.get("supports_negative_prompt"):
properties["negative_prompt"] = {
"type": "string",
"description": "Content to avoid in the output.",
}
if caps.get("supports_audio"):
properties["audio"] = {
"type": "boolean",
"description": (
"Enable native audio generation (affects pricing tier)."
),
}
elif caps.get("audio_always_on"):
parts.append(
"- audio: native stereo audio is generated with every video "
"(always on; no toggle) — describe the desired sound in the "
"prompt"
)
if caps.get("supports_seed"):
properties["seed"] = {
"type": "integer",
"description": "Seed for reproducible outputs.",
}
if caps.get("supports_upscale"):
properties["upscale"] = {
"type": "boolean",
"description": (
"High-resolution pass via the backend's video upscaler "
"(~2x, extra cost/latency). Omit for native resolution."
),
}
properties["model"] = static_props["model"]
return {
"description": "\n".join(parts),
"parameters": {
"type": "object",
"properties": properties,
"required": ["prompt"],
},
}
# ---------------------------------------------------------------------------