diff --git a/agent/video_gen_provider.py b/agent/video_gen_provider.py index c444979f7e..92c548f816 100644 --- a/agent/video_gen_provider.py +++ b/agent/video_gen_provider.py @@ -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, } diff --git a/plugins/video_gen/deepinfra/__init__.py b/plugins/video_gen/deepinfra/__init__.py index e35f69733e..4dcb391e94 100644 --- a/plugins/video_gen/deepinfra/__init__.py +++ b/plugins/video_gen/deepinfra/__init__.py @@ -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, } diff --git a/plugins/video_gen/fal/__init__.py b/plugins/video_gen/fal/__init__.py index 26cde23d06..e63e2e16e3 100644 --- a/plugins/video_gen/fal/__init__.py +++ b/plugins/video_gen/fal/__init__.py @@ -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, } diff --git a/plugins/video_gen/xai/__init__.py b/plugins/video_gen/xai/__init__.py index 90dfa57bf8..4e45302e5e 100644 --- a/plugins/video_gen/xai/__init__.py +++ b/plugins/video_gen/xai/__init__.py @@ -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, } diff --git a/tests/plugins/video_gen/test_fal_plugin.py b/tests/plugins/video_gen/test_fal_plugin.py index 883ed2ed5b..821d2db24d 100644 --- a/tests/plugins/video_gen/test_fal_plugin.py +++ b/tests/plugins/video_gen/test_fal_plugin.py @@ -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 diff --git a/tests/tools/test_video_generate_schema.py b/tests/tools/test_video_generate_schema.py new file mode 100644 index 0000000000..390a4eb2c8 --- /dev/null +++ b/tests/tools/test_video_generate_schema.py @@ -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() diff --git a/tests/tools/test_video_generation_dispatch.py b/tests/tools/test_video_generation_dispatch.py index c7101bd4ba..3cf1e823b7 100644 --- a/tests/tools/test_video_generation_dispatch.py +++ b/tests/tools/test_video_generation_dispatch.py @@ -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) diff --git a/tests/tools/test_video_generation_dynamic_schema.py b/tests/tools/test_video_generation_dynamic_schema.py index 54a8fa8599..55de50afb5 100644 --- a/tests/tools/test_video_generation_dynamic_schema.py +++ b/tests/tools/test_video_generation_dynamic_schema.py @@ -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 diff --git a/tools/video_generation_tool.py b/tools/video_generation_tool.py index 1b4864151b..ffd10a2f7f 100644 --- a/tools/video_generation_tool.py +++ b/tools/video_generation_tool.py @@ -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"], + }, + } # ---------------------------------------------------------------------------