From a4c18f65d45dbaa80a9f86241fd63ed4c55efce4 Mon Sep 17 00:00:00 2001 From: alt-glitch Date: Fri, 29 May 2026 18:23:37 +0530 Subject: [PATCH] feat(video_gen): wire Nous subscription override into hermes tools UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the same managed-gateway UX that image_gen already has: - TOOL_CATEGORIES['video_gen'] gets a 'Nous Subscription' provider row with managed_nous_feature='video_gen' + video_gen_plugin_name='fal' - NousSubscriptionFeatures gains a video_gen property + feature state computation (managed/active/available using the fal-queue gateway) - _GATEWAY_TOOL_LABELS, _GATEWAY_DIRECT_LABELS, _ALL_GATEWAY_KEYS, _get_gateway_direct_credentials, opted_in all include video_gen - apply_nous_managed_defaults and apply_gateway_defaults handle video_gen - _is_toolset_satisfied checks Nous features for video_gen - _is_provider_active detects managed video_gen (use_gateway + fal provider) - _select_plugin_video_gen_provider accepts use_gateway kwarg, propagated from all 4 call sites in _configure_provider when managed_feature is set - hermes setup status shows 'Video Generation (FAL via Nous subscription)' Users on a Nous subscription can now pick 'Nous Subscription' under hermes tools → Video Generation, which sets video_gen.provider=fal + video_gen.use_gateway=true. The FAL plugin's _resolve_managed_fal_video_gateway then routes through the managed queue gateway — no FAL_KEY needed. --- hermes_cli/nous_subscription.py | 51 +++++++++++++++++-- hermes_cli/setup.py | 35 +++++++------ hermes_cli/tools_config.py | 50 +++++++++++++----- tests/agent/test_prompt_builder.py | 2 + tests/hermes_cli/test_nous_subscription.py | 4 +- tests/hermes_cli/test_setup_model_provider.py | 2 + .../hermes_cli/test_status_model_provider.py | 1 + 7 files changed, 111 insertions(+), 34 deletions(-) diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py index a3d077f0319..5f29101eb01 100644 --- a/hermes_cli/nous_subscription.py +++ b/hermes_cli/nous_subscription.py @@ -71,12 +71,16 @@ class NousSubscriptionFeatures: def browser(self) -> NousFeatureState: return self.features["browser"] + @property + def video_gen(self) -> NousFeatureState: + return self.features["video_gen"] + @property def modal(self) -> NousFeatureState: return self.features["modal"] def items(self) -> Iterable[NousFeatureState]: - ordered = ("web", "image_gen", "tts", "browser", "modal") + ordered = ("web", "image_gen", "video_gen", "tts", "browser", "modal") for key in ordered: yield self.features[key] @@ -255,6 +259,7 @@ def get_nous_subscription_features( web_tool_enabled = _toolset_enabled(config, "web") image_tool_enabled = _toolset_enabled(config, "image_gen") + video_tool_enabled = _toolset_enabled(config, "video_gen") tts_tool_enabled = _toolset_enabled(config, "tts") browser_tool_enabled = _toolset_enabled(config, "browser") modal_tool_enabled = _toolset_enabled(config, "terminal") @@ -289,6 +294,8 @@ def get_nous_subscription_features( browser_use_gateway = _uses_gateway(browser_cfg) image_gen_cfg = config.get("image_gen") if isinstance(config.get("image_gen"), dict) else {} image_use_gateway = _uses_gateway(image_gen_cfg) + video_gen_cfg = config.get("video_gen") if isinstance(config.get("video_gen"), dict) else {} + video_use_gateway = _uses_gateway(video_gen_cfg) direct_exa = bool(get_env_value("EXA_API_KEY")) direct_firecrawl = bool(get_env_value("FIRECRAWL_API_KEY") or get_env_value("FIRECRAWL_API_URL")) @@ -296,6 +303,7 @@ def get_nous_subscription_features( direct_tavily = bool(get_env_value("TAVILY_API_KEY")) direct_searxng = bool(get_env_value("SEARXNG_URL")) direct_fal = fal_key_is_configured() + direct_fal_video = direct_fal # same FAL_KEY; separate var so use_gateway is independent direct_openai_tts = bool(resolve_openai_audio_api_key()) direct_elevenlabs = bool(get_env_value("ELEVENLABS_API_KEY")) direct_camofox = bool(get_env_value("CAMOFOX_URL")) @@ -311,6 +319,8 @@ def get_nous_subscription_features( direct_tavily = False if image_use_gateway: direct_fal = False + if video_use_gateway: + direct_fal_video = False if tts_use_gateway: direct_openai_tts = False direct_elevenlabs = False @@ -320,6 +330,8 @@ def get_nous_subscription_features( managed_web_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("firecrawl") managed_image_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("fal-queue") + # Video gen uses the same fal-queue gateway as image gen. + managed_video_available = managed_image_available managed_tts_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("openai-audio") managed_browser_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("browser-use") managed_modal_available = managed_tools_flag and nous_auth_present and is_managed_tool_gateway_ready("modal") @@ -357,6 +369,10 @@ def get_nous_subscription_features( image_active = bool(image_tool_enabled and (image_managed or direct_fal)) image_available = bool(managed_image_available or direct_fal) + video_managed = video_tool_enabled and managed_video_available and not direct_fal_video + video_active = bool(video_tool_enabled and (video_managed or direct_fal_video)) + video_available = bool(managed_video_available or direct_fal_video) + tts_current_provider = tts_provider or "edge" tts_managed = ( tts_tool_enabled @@ -451,6 +467,18 @@ def get_nous_subscription_features( current_provider="FAL" if direct_fal else ("Nous Subscription" if image_managed else ""), explicit_configured=direct_fal, ), + "video_gen": NousFeatureState( + key="video_gen", + label="Video generation", + included_by_default=False, + available=video_available, + active=video_active, + managed_by_nous=video_managed, + direct_override=video_active and not video_managed, + toolset_enabled=video_tool_enabled, + current_provider="FAL" if direct_fal_video else ("Nous Subscription" if video_managed else ""), + explicit_configured=direct_fal_video, + ), "tts": NousFeatureState( key="tts", label="OpenAI TTS", @@ -561,6 +589,9 @@ def apply_nous_managed_defaults( if "image_gen" in selected_toolsets and not fal_key_is_configured(): changed.add("image_gen") + if "video_gen" in selected_toolsets and not fal_key_is_configured(): + changed.add("video_gen") + return changed @@ -571,6 +602,7 @@ def apply_nous_managed_defaults( _GATEWAY_TOOL_LABELS = { "web": "Web search & extract (Firecrawl)", "image_gen": "Image generation (FAL)", + "video_gen": "Video generation (FAL)", "tts": "Text-to-speech (OpenAI TTS)", "browser": "Browser automation (Browser Use)", } @@ -578,6 +610,7 @@ _GATEWAY_TOOL_LABELS = { def _get_gateway_direct_credentials() -> Dict[str, bool]: """Return a dict of tool_key -> has_direct_credentials.""" + fal_direct = fal_key_is_configured() return { "web": bool( get_env_value("FIRECRAWL_API_KEY") @@ -586,7 +619,8 @@ def _get_gateway_direct_credentials() -> Dict[str, bool]: or get_env_value("TAVILY_API_KEY") or get_env_value("EXA_API_KEY") ), - "image_gen": fal_key_is_configured(), + "image_gen": fal_direct, + "video_gen": fal_direct, "tts": bool( resolve_openai_audio_api_key() or get_env_value("ELEVENLABS_API_KEY") @@ -601,11 +635,12 @@ def _get_gateway_direct_credentials() -> Dict[str, bool]: _GATEWAY_DIRECT_LABELS = { "web": "Firecrawl/Exa/Parallel/Tavily key", "image_gen": "FAL key", + "video_gen": "FAL key", "tts": "OpenAI/ElevenLabs key", "browser": "Browser Use/Browserbase key", } -_ALL_GATEWAY_KEYS = ("web", "image_gen", "tts", "browser") +_ALL_GATEWAY_KEYS = ("web", "image_gen", "video_gen", "tts", "browser") def get_gateway_eligible_tools( @@ -646,6 +681,7 @@ def get_gateway_eligible_tools( opted_in = { "web": _uses_gateway(config.get("web")), "image_gen": _uses_gateway(config.get("image_gen")), + "video_gen": _uses_gateway(config.get("video_gen")), "tts": _uses_gateway(config.get("tts")), "browser": _uses_gateway(config.get("browser")), } @@ -714,6 +750,15 @@ def apply_gateway_defaults( image_cfg["use_gateway"] = True changed.add("image_gen") + if "video_gen" in tool_keys: + video_cfg = config.get("video_gen") + if not isinstance(video_cfg, dict): + video_cfg = {} + config["video_gen"] = video_cfg + video_cfg["provider"] = "fal" + video_cfg["use_gateway"] = True + changed.add("video_gen") + return changed diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 3e7a8e6c69a..b65bffabf1c 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -454,22 +454,25 @@ def _print_setup_summary(config: dict, hermes_home): # Video generation — opt-in via `hermes tools` → Video Generation. # Only show the row when a plugin reports available so we don't badger # users who don't care about video gen with a "missing" status line. - try: - from agent.video_gen_registry import list_providers as _list_video_providers - from hermes_cli.plugins import _ensure_plugins_discovered as _ensure_plugins - _ensure_plugins() - _video_backend = None - for _vp in _list_video_providers(): - try: - if _vp.is_available(): - _video_backend = _vp.display_name - break - except Exception: - continue - except Exception: - _video_backend = None - if _video_backend: - tool_status.append((f"Video Generation ({_video_backend})", True, None)) + if subscription_features.video_gen.managed_by_nous: + tool_status.append(("Video Generation (FAL via Nous subscription)", True, None)) + else: + try: + from agent.video_gen_registry import list_providers as _list_video_providers + from hermes_cli.plugins import _ensure_plugins_discovered as _ensure_plugins + _ensure_plugins() + _video_backend = None + for _vp in _list_video_providers(): + try: + if _vp.is_available(): + _video_backend = _vp.display_name + break + except Exception: + continue + except Exception: + _video_backend = None + if _video_backend: + tool_status.append((f"Video Generation ({_video_backend})", True, None)) # TTS — show configured provider tts_provider = cfg_get(config, "tts", "provider", default="edge") diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 786da72a896..433b938c556 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -339,11 +339,26 @@ TOOL_CATEGORIES = { "video_gen": { "name": "Video Generation", "icon": "🎬", - # Providers list is intentionally empty — every video gen backend - # is a plugin, surfaced by ``_plugin_video_gen_providers()`` and - # injected by ``_visible_providers``. Mirrors the design we'll - # converge image_gen toward. - "providers": [], + # "Nous Subscription" row mirrors the image_gen pattern — managed + # FAL video generation billed via the Nous Portal. Plugin-backed + # provider rows (FAL BYOK, xAI, …) are injected at runtime by + # ``_plugin_video_gen_providers()`` in ``_visible_providers``. + "providers": [ + { + "name": "Nous Subscription", + "badge": "subscription", + "tag": "Managed FAL video generation billed to your subscription", + "env_vars": [], + "requires_nous_auth": True, + "managed_nous_feature": "video_gen", + "override_env_vars": ["FAL_KEY"], + # The underlying plugin backend — when the user picks + # "Nous Subscription" we set video_gen.provider = "fal" + # and video_gen.use_gateway = True so the FAL plugin + # routes through the managed queue gateway. + "video_gen_plugin_name": "fal", + }, + ], }, "x_search": { "name": "X (Twitter) Search", @@ -1438,7 +1453,7 @@ def _toolset_has_keys( except Exception: return False - if ts_key in {"web", "image_gen", "tts", "browser"}: + if ts_key in {"web", "image_gen", "video_gen", "tts", "browser"}: features = get_nous_subscription_features(config, force_fresh=force_fresh) feature = features.features.get(ts_key) if feature and (feature.available or feature.managed_by_nous): @@ -2153,7 +2168,7 @@ def _is_provider_active( return isinstance(image_cfg, dict) and image_cfg.get("provider") == plugin_name video_plugin_name = provider.get("video_gen_plugin_name") - if video_plugin_name: + if video_plugin_name and not provider.get("managed_nous_feature"): video_cfg = config.get("video_gen", {}) return isinstance(video_cfg, dict) and video_cfg.get("provider") == video_plugin_name @@ -2172,6 +2187,15 @@ def _is_provider_active( if image_cfg.get("use_gateway") is not None and not is_truthy_value(image_cfg.get("use_gateway"), default=False): return False return feature.managed_by_nous + if managed_feature == "video_gen": + video_cfg = config.get("video_gen", {}) + if isinstance(video_cfg, dict): + configured_provider = video_cfg.get("provider") + if configured_provider not in {None, "", "fal"}: + return False + if video_cfg.get("use_gateway") is not None and not is_truthy_value(video_cfg.get("use_gateway"), default=False): + return False + return feature.managed_by_nous if provider.get("tts_provider"): return ( feature.managed_by_nous @@ -2505,14 +2529,14 @@ def _configure_videogen_model_for_plugin(plugin_name: str, config: dict) -> None _print_success(f" Model set to: {chosen}") -def _select_plugin_video_gen_provider(plugin_name: str, config: dict) -> None: +def _select_plugin_video_gen_provider(plugin_name: str, config: dict, *, use_gateway: bool = False) -> None: """Persist a plugin-backed video generation provider selection.""" vid_cfg = config.setdefault("video_gen", {}) if not isinstance(vid_cfg, dict): vid_cfg = {} config["video_gen"] = vid_cfg vid_cfg["provider"] = plugin_name - vid_cfg["use_gateway"] = False + vid_cfg["use_gateway"] = use_gateway _print_success(f" video_gen.provider set to: {plugin_name}") _configure_videogen_model_for_plugin(plugin_name, config) @@ -2597,7 +2621,7 @@ def _configure_provider( # registry. video_plugin = provider.get("video_gen_plugin_name") if video_plugin: - _select_plugin_video_gen_provider(video_plugin, config) + _select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature)) return # Imagegen backends prompt for model selection after backend pick. backend = provider.get("imagegen_backend") @@ -2676,7 +2700,7 @@ def _configure_provider( return video_plugin = provider.get("video_gen_plugin_name") if video_plugin: - _select_plugin_video_gen_provider(video_plugin, config) + _select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature)) return # Imagegen backends prompt for model selection after env vars are in. backend = provider.get("imagegen_backend") @@ -2957,7 +2981,7 @@ def _reconfigure_provider( # Plugin-registered video_gen provider — same flow, different registry. video_plugin = provider.get("video_gen_plugin_name") if video_plugin: - _select_plugin_video_gen_provider(video_plugin, config) + _select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature)) return # Imagegen backends prompt for model selection on reconfig too. backend = provider.get("imagegen_backend") @@ -2997,7 +3021,7 @@ def _reconfigure_provider( # Plugin-registered video_gen provider — same flow, different registry. video_plugin = provider.get("video_gen_plugin_name") if video_plugin: - _select_plugin_video_gen_provider(video_plugin, config) + _select_plugin_video_gen_provider(video_plugin, config, use_gateway=bool(managed_feature)) return backend = provider.get("imagegen_backend") diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index f309c84e25c..3f4b0f46209 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -440,6 +440,7 @@ class TestBuildNousSubscriptionPrompt: features={ "web": NousFeatureState("web", "Web tools", True, True, True, True, False, True, "firecrawl"), "image_gen": NousFeatureState("image_gen", "Image generation", True, True, True, True, False, True, "Nous Subscription"), + "video_gen": NousFeatureState("video_gen", "Video generation", False, False, False, False, False, False, ""), "tts": NousFeatureState("tts", "OpenAI TTS", True, True, True, True, False, True, "OpenAI TTS"), "browser": NousFeatureState("browser", "Browser automation", True, True, True, True, False, True, "Browser Use"), "modal": NousFeatureState("modal", "Modal execution", False, True, False, False, False, True, "local"), @@ -464,6 +465,7 @@ class TestBuildNousSubscriptionPrompt: features={ "web": NousFeatureState("web", "Web tools", True, False, False, False, False, True, ""), "image_gen": NousFeatureState("image_gen", "Image generation", True, False, False, False, False, True, ""), + "video_gen": NousFeatureState("video_gen", "Video generation", False, False, False, False, False, False, ""), "tts": NousFeatureState("tts", "OpenAI TTS", True, False, False, False, False, True, ""), "browser": NousFeatureState("browser", "Browser automation", True, False, False, False, False, True, ""), "modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, ""), diff --git a/tests/hermes_cli/test_nous_subscription.py b/tests/hermes_cli/test_nous_subscription.py index 8dc3a898c24..2c89d245301 100644 --- a/tests/hermes_cli/test_nous_subscription.py +++ b/tests/hermes_cli/test_nous_subscription.py @@ -218,7 +218,7 @@ def test_get_gateway_eligible_tools_ignores_quoted_false_opt_in(monkeypatch): monkeypatch.setattr( ns, "_get_gateway_direct_credentials", - lambda: {"web": True, "image_gen": False, "tts": False, "browser": False}, + lambda: {"web": True, "image_gen": False, "video_gen": False, "tts": False, "browser": False}, ) unconfigured, has_direct, already_managed = ns.get_gateway_eligible_tools( @@ -230,4 +230,4 @@ def test_get_gateway_eligible_tools_ignores_quoted_false_opt_in(monkeypatch): assert "web" in has_direct assert "web" not in already_managed - assert set(unconfigured) == {"image_gen", "tts", "browser"} + assert set(unconfigured) == {"image_gen", "video_gen", "tts", "browser"} diff --git a/tests/hermes_cli/test_setup_model_provider.py b/tests/hermes_cli/test_setup_model_provider.py index b79b33315d8..aa8a9c182ba 100644 --- a/tests/hermes_cli/test_setup_model_provider.py +++ b/tests/hermes_cli/test_setup_model_provider.py @@ -498,6 +498,7 @@ def test_setup_summary_shows_camofox_when_browser_feature_is_camofox(tmp_path, m features={ "web": NousFeatureState("web", "Web tools", True, False, False, False, False, True, ""), "image_gen": NousFeatureState("image_gen", "Image generation", True, False, False, False, False, True, ""), + "video_gen": NousFeatureState("video_gen", "Video generation", False, False, False, False, False, False, ""), "tts": NousFeatureState("tts", "OpenAI TTS", True, False, False, False, False, True, ""), "browser": NousFeatureState("browser", "Browser automation", True, True, True, False, True, True, "Camofox"), "modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, "local"), @@ -525,6 +526,7 @@ def test_setup_summary_does_not_mark_incomplete_browserbase_as_available(tmp_pat features={ "web": NousFeatureState("web", "Web tools", True, False, False, False, False, True, ""), "image_gen": NousFeatureState("image_gen", "Image generation", True, False, False, False, False, True, ""), + "video_gen": NousFeatureState("video_gen", "Video generation", False, False, False, False, False, False, ""), "tts": NousFeatureState("tts", "OpenAI TTS", True, False, False, False, False, True, ""), "browser": NousFeatureState("browser", "Browser automation", True, False, False, False, False, True, "Browserbase"), "modal": NousFeatureState("modal", "Modal execution", False, False, False, False, False, True, "local"), diff --git a/tests/hermes_cli/test_status_model_provider.py b/tests/hermes_cli/test_status_model_provider.py index d807df2e8c1..6608955d404 100644 --- a/tests/hermes_cli/test_status_model_provider.py +++ b/tests/hermes_cli/test_status_model_provider.py @@ -88,6 +88,7 @@ def test_show_status_reports_managed_nous_features(monkeypatch, capsys, tmp_path features={ "web": NousFeatureState("web", "Web tools", True, True, True, True, False, True, "firecrawl"), "image_gen": NousFeatureState("image_gen", "Image generation", True, True, True, True, False, True, "Nous Subscription"), + "video_gen": NousFeatureState("video_gen", "Video generation", False, False, False, False, False, False, ""), "tts": NousFeatureState("tts", "OpenAI TTS", True, True, True, True, False, True, "OpenAI TTS"), "browser": NousFeatureState("browser", "Browser automation", True, True, True, True, False, True, "Browser Use"), "modal": NousFeatureState("modal", "Modal execution", False, True, False, False, False, True, "local"),