{"repo":"NousResearch/hermes-agent","public_id":"26241","display_label":"FAL image generation backend plugin migration","summary":"These items concern porting or migrating the FAL image generation backend into the plugins/image_gen/fal directory. The work appears to involve multiple attempts or salvage efforts toward the same migration goal.","subsystem":null,"counts":{"members":3,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":30380,"merged_at":"2026-05-22T11:10:46+00:00"},"resolution":{"state":"resolved","by":"pr-30380"},"freshness":{"state":"verified","last_verified_at":"2026-05-22T11:10:46+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":1.0},"fix_edges":[{"issue_number":26241,"pr_number":30380,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-05-22T11:10:46+00:00"}],"members":[{"kind":"issue","number":26241,"title":"Mirror web/browser plugin migration for FAL image generation backend","url":null,"state":"closed"},{"kind":"pr","number":27966,"title":"feat(image_gen): port FAL backend to plugins/image_gen/fal","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#30380","number":30380}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":30380,"title":"feat(image_gen): port FAL backend to plugins/image_gen/fal (salvage #27966)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["hermes_cli/tools_config.py","plugins/image_gen/fal/__init__.py","plugins/image_gen/fal/plugin.yaml","plugins/video_gen/fal/__init__.py","tests/hermes_cli/test_image_gen_picker.py","tests/plugins/image_gen/check_parity_vs_main.py","tests/plugins/image_gen/test_fal_provider.py","tools/fal_common.py","tools/image_generation_tool.py"],"fix_hunks":[{"file":"hermes_cli/tools_config.py","hunks":["@@ -311,6 +311,16 @@ def _get_plugin_toolset_keys() -> set:\n     \"image_gen\": {\n         \"name\": \"Image Generation\",\n         \"icon\": \"🎨\",\n+        # Per-provider rows for FAL.ai (`plugins/image_gen/fal`), OpenAI,\n+        # OpenAI Codex, and xAI are injected at runtime from each\n+        # ``plugins.image_gen.<vendor>`` package via\n+        # ``_plugin_image_gen_providers()`` in ``_visible_providers``.\n+        # Only non-provider UX setup-flow rows remain here:\n+        #   - \"Nous Subscription\" — managed FAL billed via the Nous\n+        #     subscription (requires_nous_auth + override_env_vars).\n+        #     Uses the fal plugin as the underlying backend but has a\n+        #     distinct setup UX.\n+        # Mirrors the shape browser/video_gen ship today.\n         \"providers\": [\n             {\n                 \"name\": \"Nous Subscription\",","@@ -322,15 +332,6 @@ def _get_plugin_toolset_keys() -> set:\n                 \"override_env_vars\": [\"FAL_KEY\"],\n                 \"imagegen_backend\": \"fal\",\n             },\n-            {\n-                \"name\": \"FAL.ai\",\n-                \"badge\": \"paid\",\n-                \"tag\": \"Pick from flux-2-klein, flux-2-pro, gpt-image, nano-banana, etc.\",\n-                \"env_vars\": [\n-                    {\"key\": \"FAL_KEY\", \"prompt\": \"FAL API key\", \"url\": \"https://fal.ai/dashboard/keys\"},\n-                ],\n-                \"imagegen_backend\": \"fal\",\n-            },\n         ],\n     },\n     \"video_gen\": {","@@ -1567,12 +1568,9 @@ def _plugin_image_gen_providers() -> list[dict]:\n     Each returned dict looks like a regular ``TOOL_CATEGORIES`` provider\n     row but carries an ``image_gen_plugin_name`` marker so downstream\n     code (config writing, model picker) knows to route through the\n-    plugin registry instead of the in-tree FAL backend.\n-\n-    FAL is skipped — it's already exposed by the hardcoded\n-    ``TOOL_CATEGORIES[\"image_gen\"]`` entries. When FAL gets ported to\n-    a plugin in a follow-up PR, the hardcoded entries go away and this\n-    function surfaces it alongside OpenAI automatically.\n+    plugin registry. Every image-gen backend is a plugin now — there\n+    are no hardcoded rows left in ``TOOL_CATEGORIES[\"image_gen\"]`` for\n+    this function to dedupe against (see issue #26241).\n     \"\"\"\n     try:\n         from agent.image_gen_registry import list_providers","@@ -1585,9 +1583,6 @@ def _plugin_image_gen_providers() -> list[dict]:\n \n     rows: list[dict] = []\n     for provider in providers:\n-        if getattr(provider, \"name\", None) == \"fal\":\n-            # FAL has its own hardcoded rows today.\n-            continue\n         try:\n             schema = provider.get_setup_schema()\n         except Exception:"]},{"file":"plugins/image_gen/fal/__init__.py","hunks":["@@ -0,0 +1,182 @@\n+\"\"\"FAL.ai image generation backend.\n+\n+Wraps the 18-model FAL catalog (FLUX 2, Z-Image, Nano Banana, GPT\n+Image 1.5, Recraft, Imagen 4, Qwen, Ideogram, …) as an\n+:class:`ImageGenProvider` implementation.\n+\n+The heavy lifting — model catalog, payload construction, request\n+submission, managed-Nous-gateway selection, Clarity Upscaler chaining\n+— lives in :mod:`tools.image_generation_tool`. This plugin reaches into\n+that module via call-time indirection (``import tools.image_generation_tool as _it``)\n+so:\n+\n+* the existing test suite (``tests/tools/test_image_generation.py``,\n+  ``tests/tools/test_managed_media_gateways.py``) keeps patching\n+  ``image_tool._submit_fal_request`` / ``image_tool.fal_client`` /\n+  ``image_tool._managed_fal_client`` without modification, and\n+* there's exactly one canonical FAL code path on disk — the plugin is a\n+  registration adapter, not a parallel implementation.\n+\n+See issue #26241 for the migration plan and the\n+``plugin-extraction-test-patch-compatibility.md`` rules this follows.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import json\n+import logging\n+import os\n+from typing import Any, Dict, List, Optional\n+\n+from agent.image_gen_provider import (\n+    DEFAULT_ASPECT_RATIO,\n+    ImageGenProvider,\n+    resolve_aspect_ratio,\n+)\n+\n+logger = logging.getLogger(__name__)\n+\n+\n+class FalImageGenProvider(ImageGenProvider):\n+    \"\"\"FAL.ai image generation backend.\n+\n+    Delegates to ``tools.image_generation_tool.image_generate_tool`` so\n+    the in-tree FAL implementation (model catalog, payload builder,\n+    managed-gateway selection, Clarity Upscaler chaining) is the single\n+    source of truth. Everything is resolved at call time via the\n+    ``_it`` indirection so tests can monkey-patch the legacy module.\n+    \"\"\"\n+\n+    @property\n+    def name(self) -> str:\n+        return \"fal\"\n+\n+    @property\n+    def display_name(self) -> str:\n+        return \"FAL.ai\"\n+\n+    def is_available(self) -> bool:\n+        # Available when direct FAL_KEY is set OR the managed Nous\n+        # gateway resolves a fal-queue origin. Both checks come from the\n+        # legacy module so this provider tracks whatever logic ships\n+        # there.\n+        import tools.image_generation_tool as _it\n+        try:\n+            return bool(_it.check_fal_api_key())\n+        except Exception:  # noqa: BLE001 — defensive; never break the picker\n+            return False\n+\n+    def list_models(self) -> List[Dict[str, Any]]:\n+        import tools.image_generation_tool as _it\n+        return [\n+            {\n+                \"id\": model_id,\n+                \"display\": meta.get(\"display\", model_id),\n+                \"speed\": meta.get(\"speed\", \"\"),\n+                \"strengths\": meta.get(\"strengths\", \"\"),\n+                \"price\": meta.get(\"price\", \"\"),\n+            }\n+            for model_id, meta in _it.FAL_MODELS.items()\n+        ]\n+\n+    def default_model(self) -> Optional[str]:\n+        import tools.image_generation_tool as _it\n+        return _it.DEFAULT_MODEL\n+\n+    def get_setup_schema(self) -> Dict[str, Any]:\n+        return {\n+            \"name\": \"FAL.ai\",\n+            \"badge\": \"paid\",\n+            \"tag\": \"Pick from flux-2-klein, flux-2-pro, gpt-image, nano-banana, etc.\",\n+            \"env_vars\": [\n+                {\n+                    \"key\": \"FAL_KEY\",\n+                    \"prompt\": \"FAL API key\",\n+                    \"url\": \"https://fal.ai/dashboard/keys\",\n+                },\n+            ],\n+        }\n+\n+    def generate(\n+        self,\n+        prompt: str,\n+        aspect_ratio: str = DEFAULT_ASPECT_RATIO,\n+        **kwargs: Any,\n+    ) -> Dict[str, Any]:\n+        \"\"\"Generate an image via the legacy FAL pipeline.\n+\n+        Forwards prompt + aspect_ratio (and any forward-compat extras\n+        the schema supports) into :func:`tools.image_generation_tool.image_generate_tool`,\n+        then reshapes its JSON-string response into the provider-ABC\n+        dict format consumed by ``_dispatch_to_plugin_provider``.\n+        \"\"\"\n+        import tools.image_generation_tool as _it\n+\n+        aspect = resolve_aspect_ratio(aspect_ratio)\n+        passthrough = {\n+            key: kwargs[key]\n+            for key in (\n+                \"num_inference_steps\",\n+                \"guidance_scale\",\n+                \"num_images\",\n+                \"output_format\",\n+                \"seed\",\n+            )\n+            if key in kwargs and kwargs[key] is not None\n+        }\n+\n+        try:\n+            raw = _it.image_generate_tool(\n+                prompt=prompt,\n+                aspect_ratio=aspect,\n+                **passthrough,\n+            )\n+        except Exception as exc:  # noqa: BLE001 — never raise out of generate\n+            logger.warning(\"FAL image_generate_tool raised: %s\", exc, exc_info=True)\n+            return {\n+                \"success\": False,\n+                \"image\": None,\n+                \"error\": f\"FAL image generation failed: {exc}\",\n+                \"error_type\": type(exc).__name__,\n+                \"provider\": \"fal\",\n+                \"prompt\": prompt,\n+                \"aspect_ratio\": aspect,\n+            }\n+\n+        try:\n+            response = json.loads(raw) if isinstance(raw, str) else raw\n+        except Exception:  # noqa: BLE001\n+            response = {\"success\": False, \"image\": None, \"error\": \"Invalid JSON from FAL pipeline\"}\n+\n+        if not isinstance(response, dict):\n+            response = {\n+                \"success\": False,\n+                \"image\": None,\n+                \"error\": \"FAL pipeline returned a non-dict response\",\n+                \"error_type\": \"provider_contract\",\n+            }\n+\n+        # Stamp provider/prompt/aspect_ratio so downstream consumers see\n+        # the uniform shape declared in ``agent.image_gen_provider``.\n+        response.setdefault(\"provider\", \"fal\")\n+        response.setdefault(\"prompt\", prompt)\n+        response.setdefault(\"aspect_ratio\", aspect)\n+        # Annotate model best-effort — the legacy pipeline resolves it\n+        # internally, so query it after the fact for the response shape.\n+        if \"model\" not in response:\n+            try:\n+                model_id, _meta = _it._resolve_fal_model()\n+                response[\"model\"] = model_id\n+            except Exception:  # noqa: BLE001\n+                pass\n+        return response\n+\n+\n+# ---------------------------------------------------------------------------\n+# Plugin entry point\n+# ---------------------------------------------------------------------------\n+\n+\n+def register(ctx) -> None:\n+    \"\"\"Plugin entry point — wire ``FalImageGenProvider`` into the registry.\"\"\"\n+    ctx.register_image_gen_provider(FalImageGenProvider())"]},{"file":"plugins/image_gen/fal/plugin.yaml","hunks":["@@ -0,0 +1,7 @@\n+name: fal\n+version: 1.0.0\n+description: \"FAL.ai image generation backend (flux-2-klein, flux-2-pro, nano-banana, gpt-image-1.5, recraft-v3, etc.).\"\n+author: NousResearch\n+kind: backend\n+requires_env:\n+  - FAL_KEY"]},{"file":"plugins/video_gen/fal/__init__.py","hunks":["@@ -282,20 +282,24 @@ def _build_payload(\n \n \n # ---------------------------------------------------------------------------\n-# fal_client lazy import (same pattern as image_generation_tool)\n+# fal_client lazy import (shared with image_generation_tool via fal_common)\n # ---------------------------------------------------------------------------\n \n _fal_client: Any = None\n \n \n def _load_fal_client() -> Any:\n+    \"\"\"Lazy-load the ``fal_client`` SDK and cache it on this module.\n+\n+    Delegates the actual import to :func:`tools.fal_common.import_fal_client`\n+    so the ``lazy_deps`` ensure-install handling stays in one place.\n+    \"\"\"\n     global _fal_client\n     if _fal_client is not None:\n         return _fal_client\n-    import fal_client  # type: ignore\n-\n-    _fal_client = fal_client\n-    return fal_client\n+    from tools.fal_common import import_fal_client\n+    _fal_client = import_fal_client()\n+    return _fal_client\n \n \n # ---------------------------------------------------------------------------"]},{"file":"tests/hermes_cli/test_image_gen_picker.py","hunks":["@@ -69,18 +69,19 @@ def test_plugin_providers_returns_registered(self, monkeypatch):\n         assert \"Myimg\" in names\n         assert \"myimg\" in plugin_names\n \n-    def test_fal_skipped_to_avoid_duplicate(self, monkeypatch):\n+    def test_fal_surfaced_alongside_other_plugins(self, monkeypatch):\n         from hermes_cli import tools_config\n \n-        # Simulate a FAL plugin being registered — the picker already has\n-        # hardcoded FAL rows in TOOL_CATEGORIES, so plugin-FAL must be\n-        # skipped to avoid showing FAL twice.\n+        # After #26241, FAL is itself a plugin (`plugins/image_gen/fal/`)\n+        # and the hardcoded `TOOL_CATEGORIES[\"image_gen\"]` FAL row is\n+        # gone. The plugin-row builder therefore surfaces it like any\n+        # other backend — no deduplication step needed.\n         image_gen_registry.register_provider(_FakeProvider(\"fal\"))\n         image_gen_registry.register_provider(_FakeProvider(\"openai\"))\n \n         rows = tools_config._plugin_image_gen_providers()\n         names = [r.get(\"image_gen_plugin_name\") for r in rows]\n-        assert \"fal\" not in names\n+        assert \"fal\" in names\n         assert \"openai\" in names\n \n     def test_visible_providers_includes_plugins_for_image_gen(self, monkeypatch):"]},{"file":"tests/plugins/image_gen/check_parity_vs_main.py","hunks":["@@ -0,0 +1,300 @@\n+\"\"\"Behavior-parity check for the image-gen FAL plugin migration (#26241).\n+\n+Spawns one subprocess per (version, scenario) cell — pinned to either\n+``origin/main`` (legacy in-tree FAL fall-through + ``configured == \"fal\"``\n+skip in ``_dispatch_to_plugin_provider``) or this PR's worktree (FAL is\n+itself a plugin and the dispatcher routes every set provider through\n+the registry). Each subprocess clears all FAL-related env vars + writes\n+a ``config.yaml``, then asks the dispatcher how it would route an\n+``image_generate`` call. The emitted shape tuple is\n+``{dispatch_kind, provider_name, model}``:\n+\n+* ``dispatch_kind`` ∈ ``{\"legacy_fal\", \"plugin\", \"error\", None}`` —\n+  whether the call would go straight to the in-tree pipeline,\n+  through ``_dispatch_to_plugin_provider``, raise an explicit\n+  provider-not-registered error, or fall through silently.\n+* ``provider_name`` — when ``dispatch_kind == \"plugin\"``, the\n+  resolved provider name. ``None`` otherwise.\n+* ``model`` — the resolved FAL model id when applicable.\n+\n+The parent process diffs the shapes per scenario. A diff means the\n+migration introduced an observable behaviour change vs origin/main —\n+likely a real regression for users on the existing config keys.\n+\n+Run from the PR worktree:\n+\n+    python tests/plugins/image_gen/check_parity_vs_main.py\n+\"\"\"\n+from __future__ import annotations\n+\n+import json\n+import subprocess\n+import sys\n+from pathlib import Path\n+\n+\n+REPO_ROOT = Path(__file__).resolve().parents[3]\n+\n+\n+# Pin one path to current main, one to the PR worktree.\n+# ``REPO_ROOT`` is ``.../.worktrees/<name>``; the main checkout lives\n+# two levels up. When running directly from a regular clone (no\n+# worktree), ``MAIN_DIR`` falls back to a sibling ``hermes-agent-main``\n+# checkout if one exists.\n+def _resolve_main_dir() -> Path:\n+    candidate = REPO_ROOT.parent.parent\n+    if (candidate / \"tools\" / \"image_generation_tool.py\").exists() and candidate != REPO_ROOT:\n+        return candidate\n+    sibling = REPO_ROOT.parent / \"hermes-agent-main\"\n+    if (sibling / \"tools\" / \"image_generation_tool.py\").exists():\n+        return sibling\n+    return REPO_ROOT\n+\n+\n+MAIN_DIR = _resolve_main_dir()\n+PR_DIR = REPO_ROOT\n+assert (PR_DIR / \"tools\" / \"image_generation_tool.py\").exists(), (\n+    f\"PR_DIR={PR_DIR} doesn't look like a hermes-agent checkout\"\n+)\n+\n+\n+SUBPROCESS_SCRIPT = r\"\"\"\n+import json, os, sys, tempfile\n+sys.path.insert(0, sys.argv[1])\n+\n+# Isolated HERMES_HOME so the config write is hermetic.\n+home = tempfile.mkdtemp()\n+os.environ[\"HERMES_HOME\"] = home\n+\n+# Clear FAL-related env so dispatch decisions are config-driven.\n+for k in (\n+    \"FAL_KEY\", \"FAL_QUEUE_GATEWAY_URL\",\n+    \"TOOL_GATEWAY_DOMAIN\", \"TOOL_GATEWAY_USER_TOKEN\",\n+    \"FAL_IMAGE_MODEL\",\n+):\n+    os.environ.pop(k, None)\n+\n+scenario_env = json.loads(sys.argv[2])\n+os.environ.update(scenario_env)\n+\n+config_yaml = sys.argv[3]\n+config_path = os.path.join(home, \"config.yaml\")\n+with open(config_path, \"w\") as f:\n+    f.write(config_yaml)\n+\n+# Fresh import — must not have anything cached.\n+for name in list(sys.modules):\n+    if (name.startswith(\"tools.\")\n+            or name.startswith(\"agent.\")\n+            or name.startswith(\"plugins.\")\n+            or name.startswith(\"hermes_cli.\")):\n+        sys.modules.pop(name, None)\n+\n+import tools.image_generation_tool as image_tool\n+\n+dispatch_kind = None\n+provider_name = None\n+model = None\n+error_text = None\n+\n+try:\n+    raw = image_tool._dispatch_to_plugin_provider(\"ping\", \"landscape\")\n+    if raw is None:\n+        dispatch_kind = \"legacy_fal\"\n+    else:\n+        parsed = json.loads(raw) if isinstance(raw, str) else raw\n+        if isinstance(parsed, dict):\n+            if parsed.get(\"error_type\") == \"provider_not_registered\":\n+                dispatch_kind = \"error\"\n+                error_text = parsed.get(\"error\")\n+            else:\n+                dispatch_kind = \"plugin\"\n+                provider_name = parsed.get(\"provider\")\n+                model = parsed.get(\"model\")\n+        else:\n+            dispatch_kind = \"unknown_payload\"\n+\n+    if model is None:\n+        # _resolve_fal_model still returns the active FAL model id even\n+        # when dispatch goes to a non-FAL plugin — used for the diff\n+        # only when applicable.\n+        try:\n+            model_id, _meta = image_tool._resolve_fal_model()\n+            if dispatch_kind == \"legacy_fal\":\n+                model = model_id\n+        except Exception:\n+            pass\n+except Exception as exc:\n+    dispatch_kind = \"exception\"\n+    error_text = repr(exc)\n+\n+shape = {\n+    \"dispatch_kind\": dispatch_kind,\n+    \"provider_name\": provider_name,\n+    \"model\": model,\n+    \"error_present\": error_text is not None,\n+}\n+print(json.dumps(shape))\n+\"\"\"\n+\n+\n+SCENARIOS: list[tuple[str, str, dict[str, str]]] = [\n+    # (label, config.yaml body, extra env vars)\n+    (\"no-config-no-env\", \"\", {}),\n+    (\n+        \"explicit-fal-no-creds\",\n+        \"image_gen:\\n  provider: fal\\n\",\n+        {},\n+    ),\n+    (\n+        \"explicit-fal-with-creds\",\n+        \"image_gen:\\n  provider: fal\\n\",\n+        {\"FAL_KEY\": \"test-key\"},\n+    ),\n+    (\n+        \"explicit-fal-with-model\",\n+        \"image_gen:\\n  provider: fal\\n  model: fal-ai/flux-2-pro\\n\",\n+        {\"FAL_KEY\": \"test-key\"},\n+    ),\n+    (\n+        \"explicit-typo-provider\",\n+        \"image_gen:\\n  provider: not-a-real-backend\\n\",\n+        {\"FAL_KEY\": \"test-key\"},\n+    ),\n+    (\n+        \"managed-gateway-only\",\n+        \"\",\n+        {\n+            \"TOOL_GATEWAY_DOMAIN\": \"nousresearch.com\",\n+            \"TOOL_GATEWAY_USER_TOKEN\": \"[redacted:secret-assignment]\",\n+        },\n+    ),\n+]\n+\n+\n+def _run_scenario(repo_path: Path, label: str, config_yaml: str, env: dict) -> dict:\n+    venv_python = repo_path / \".venv\" / \"bin\" / \"python\"\n+    if not venv_python.exists():\n+        venv_python = MAIN_DIR / \".venv\" / \"bin\" / \"python\"\n+    if not venv_python.exists():\n+        venv_python = Path(\"python3\")\n+\n+    out = subprocess.run(\n+        [\n+            str(venv_python),\n+            \"-c\",\n+            SUBPROCESS_SCRIPT,\n+            str(repo_path),\n+            json.dumps(env),\n+            config_yaml,\n+        ],\n+        capture_output=True,\n+        text=True,\n+        timeout=60,\n+    )\n+    if out.returncode != 0:\n+        return {\n+            \"error\": \"subprocess failed\",\n+            \"stdout\": out.stdout[-500:],\n+            \"stderr\": out.stderr[-500:],\n+        }\n+    try:\n+        return json.loads(out.stdout.strip().splitlines()[-1])\n+    except Exception as exc:\n+        return {\"error\": f\"could not parse output: {exc}\", \"stdout\": out.stdout}\n+\n+\n+def _reduce(shape: dict) -> dict:\n+    \"\"\"Reduce to the parts that matter for user-visible parity.\n+\n+    On origin/main, ``explicit-fal-*`` scenarios short-circuit to\n+    ``legacy_fal`` because of the ``configured == \"fal\"`` skip. On the\n+    PR, those same scenarios route through the plugin and emit\n+    ``dispatch_kind == \"plugin\"`` with ``provider_name == \"fal\"``.\n+\n+    Both shapes are functionally equivalent — the plugin's ``generate()``\n+    re-enters the same in-tree pipeline via ``_it`` indirection — but\n+    we want the diff to be visible so reviewers can sign off on the\n+    intentional behaviour delta.\n+    \"\"\"\n+    return {\n+        \"dispatch_kind\": shape.get(\"dispatch_kind\"),\n+        \"provider_name\": shape.get(\"provider_name\"),\n+        \"model\": shape.get(\"model\"),\n+        \"error_present\": shape.get(\"error_present\"),\n+    }\n+\n+\n+def main() -> int:\n+    print(f\"main:    {MAIN_DIR}\")\n+    print(f\"pr:      {PR_DIR}\")\n+    print()\n+\n+    if MAIN_DIR == PR_DIR:\n+        print(\n+            \"WARN: MAIN_DIR == PR_DIR — diffs will be trivially identical.\\n\"\n+            \"      Set up a sibling 'hermes-agent-main' checkout pinned to \"\n+            \"origin/main to get real parity coverage.\"\n+        )\n+        print()\n+\n+    failures: list[str] = []\n+    errors: list[str] = []\n+    intentional_diffs: list[tuple[str, dict, dict]] = []\n+    for label, config_yaml, env in SCENARIOS:\n+        main_shape = _run_scenario(MAIN_DIR, label, config_yaml, env)\n+        pr_shape = _run_scenario(PR_DIR, label, config_yaml, env)\n+\n+        if \"error\" in main_shape or \"error\" in pr_shape:\n+            print(f\"  [ERR ] {label}: subprocess failed\")\n+            print(f\"    main: {main_shape}\")\n+            print(f\"    pr:   {pr_shape}\")\n+            errors.append(label)\n+            continue\n+\n+        main_reduced = _reduce(main_shape)\n+        pr_reduced = _reduce(pr_shape)\n+\n+        if main_reduced == pr_reduced:\n+            print(f\"  [OK]   {label}: {main_reduced}\")\n+            continue\n+\n+        # On main, \"explicit-fal-*\" returns legacy_fal; on PR, plugin\n+        # dispatch. That's the only acceptable diff — flag everything\n+        # else as a regression.\n+        legacy_to_plugin_fal = (\n+            main_reduced.get(\"dispatch_kind\") == \"legacy_fal\"\n+            and pr_reduced.get(\"dispatch_kind\") == \"plugin\"\n+            and pr_reduced.get(\"provider_name\") == \"fal\"\n+        )\n+        if legacy_to_plugin_fal:\n+            print(f\"  [DIFF] {label}: legacy_fal → plugin (fal) — expected\")\n+            intentional_diffs.append((label, main_reduced, pr_reduced))\n+        else:\n+            print(f\"  [FAIL] {label}\")\n+            print(f\"    main: {main_reduced}\")\n+            print(f\"    pr:   {pr_reduced}\")\n+            failures.append(label)\n+\n+    print()\n+    if errors:\n+        print(f\"SUBPROCESS ERRORS in {len(errors)} scenario(s):\")\n+        for e in errors:\n+            print(f\"  - {e}\")\n+    if failures:\n+        print(f\"BEHAVIOUR REGRESSION in {len(failures)} scenario(s):\")\n+        for f in failures:\n+            print(f\"  - {f}\")\n+    if intentional_diffs:\n+        print(\n+            f\"INTENTIONAL DIFFS ({len(intentional_diffs)}): \"\n+            f\"legacy_fal → plugin dispatch for explicit FAL paths.\"\n+        )\n+    if failures or errors:\n+        return 1\n+    print(f\"PARITY OK across {len(SCENARIOS)} scenarios.\")\n+    return 0\n+\n+\n+if __name__ == \"__main__\":\n+    sys.exit(main())"]},{"file":"tests/plugins/image_gen/test_fal_provider.py","hunks":["@@ -0,0 +1,226 @@\n+#!/usr/bin/env python3\n+\"\"\"Tests for the FAL.ai image generation plugin.\n+\n+The plugin is a thin registration adapter — actual FAL pipeline logic\n+lives in ``tools.image_generation_tool`` and is exercised by\n+``tests/tools/test_image_generation.py``. These tests focus on:\n+\n+* the ``ImageGenProvider`` ABC surface (name, models, schema)\n+* call-time indirection (``_it`` resolution at ``generate()`` time so\n+  ``monkeypatch.setattr(image_tool, ...)`` keeps working)\n+* response shape stamping (provider/prompt/aspect_ratio/model)\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import json\n+from unittest.mock import MagicMock\n+\n+import pytest\n+\n+\n+# ---------------------------------------------------------------------------\n+# Provider surface\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestFalImageGenProviderSurface:\n+    def test_name(self):\n+        from plugins.image_gen.fal import FalImageGenProvider\n+\n+        assert FalImageGenProvider().name == \"fal\"\n+\n+    def test_display_name(self):\n+        from plugins.image_gen.fal import FalImageGenProvider\n+\n+        assert FalImageGenProvider().display_name == \"FAL.ai\"\n+\n+    def test_default_model_matches_legacy(self):\n+        from plugins.image_gen.fal import FalImageGenProvider\n+        from tools.image_generation_tool import DEFAULT_MODEL\n+\n+        assert FalImageGenProvider().default_model() == DEFAULT_MODEL\n+\n+    def test_list_models_uses_legacy_catalog(self):\n+        from plugins.image_gen.fal import FalImageGenProvider\n+        from tools.image_generation_tool import FAL_MODELS\n+\n+        provider = FalImageGenProvider()\n+        models = provider.list_models()\n+        ids = {m[\"id\"] for m in models}\n+        # Whatever FAL_MODELS ships, the provider mirrors verbatim.\n+        assert ids == set(FAL_MODELS.keys())\n+        # Spot-check the expected first-class fields are present.\n+        for entry in models:\n+            for field in (\"id\", \"display\", \"speed\", \"strengths\", \"price\"):\n+                assert field in entry\n+\n+    def test_setup_schema_advertises_fal_key(self):\n+        from plugins.image_gen.fal import FalImageGenProvider\n+\n+        schema = FalImageGenProvider().get_setup_schema()\n+        assert schema[\"name\"] == \"FAL.ai\"\n+        assert schema[\"badge\"] == \"paid\"\n+        env_keys = {entry[\"key\"] for entry in schema.get(\"env_vars\", [])}\n+        assert \"FAL_KEY\" in env_keys\n+\n+\n+class TestFalImageGenProviderAvailability:\n+    def test_is_available_when_legacy_check_passes(self, monkeypatch):\n+        import tools.image_generation_tool as image_tool\n+        from plugins.image_gen.fal import FalImageGenProvider\n+\n+        monkeypatch.setattr(image_tool, \"check_fal_api_key\", lambda: True)\n+        assert FalImageGenProvider().is_available() is True\n+\n+    def test_is_available_false_when_legacy_check_fails(self, monkeypatch):\n+        import tools.image_generation_tool as image_tool\n+        from plugins.image_gen.fal import FalImageGenProvider\n+\n+        monkeypatch.setattr(image_tool, \"check_fal_api_key\", lambda: False)\n+        assert FalImageGenProvider().is_available() is False\n+\n+    def test_is_available_handles_legacy_exception(self, monkeypatch):\n+        import tools.image_generation_tool as image_tool\n+        from plugins.image_gen.fal import FalImageGenProvider\n+\n+        def _boom():\n+            raise RuntimeError(\"config broke\")\n+\n+        monkeypatch.setattr(image_tool, \"check_fal_api_key\", _boom)\n+        # Picker must not propagate exceptions — show as \"not available\".\n+        assert FalImageGenProvider().is_available() is False\n+\n+\n+# ---------------------------------------------------------------------------\n+# generate() — call-time indirection\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestFalImageGenProviderGenerate:\n+    def test_generate_delegates_to_legacy_image_generate_tool(self, monkeypatch):\n+        \"\"\"Plugin must look up ``image_generate_tool`` at call time so\n+        ``monkeypatch.setattr(image_tool, \"image_generate_tool\", ...)``\n+        takes effect.\"\"\"\n+        import tools.image_generation_tool as image_tool\n+        from plugins.image_gen.fal import FalImageGenProvider\n+\n+        captured = {}\n+\n+        def fake_image_generate_tool(prompt, aspect_ratio, **kwargs):\n+            captured[\"prompt\"] = prompt\n+            captured[\"aspect_ratio\"] = aspect_ratio\n+            captured[\"kwargs\"] = kwargs\n+            return json.dumps({\"success\": True, \"image\": \"https://fake/image.png\"})\n+\n+        monkeypatch.setattr(image_tool, \"image_generate_tool\", fake_image_generate_tool)\n+        monkeypatch.setattr(image_tool, \"_resolve_fal_model\",\n+                            lambda: (\"fal-ai/flux-2/klein/9b\", {}))\n+\n+        result = FalImageGenProvider().generate(\n+            \"a serene mountain landscape\",\n+            aspect_ratio=\"square\",\n+            seed=42,\n+        )\n+\n+        assert captured[\"prompt\"] == \"a serene mountain landscape\"\n+        assert captured[\"aspect_ratio\"] == \"square\"\n+        assert captured[\"kwargs\"] == {\"seed\": 42}\n+        assert result[\"success\"] is True\n+        assert result[\"image\"] == \"https://fake/image.png\"\n+        # Stamped fields for the unified response shape\n+        assert result[\"provider\"] == \"fal\"\n+        assert result[\"prompt\"] == \"a serene mountain landscape\"\n+        assert result[\"aspect_ratio\"] == \"square\"\n+        assert result[\"model\"] == \"fal-ai/flux-2/klein/9b\"\n+\n+    def test_generate_invalid_aspect_ratio_is_coerced(self, monkeypatch):\n+        import tools.image_generation_tool as image_tool\n+        from plugins.image_gen.fal import FalImageGenProvider\n+\n+        seen_aspect = {}\n+\n+        def fake(prompt, aspect_ratio, **kwargs):\n+            seen_aspect[\"v\"] = aspect_ratio\n+            return json.dumps({\"success\": True, \"image\": \"x\"})\n+\n+        monkeypatch.setattr(image_tool, \"image_generate_tool\", fake)\n+        monkeypatch.setattr(image_tool, \"_resolve_fal_model\",\n+                            lambda: (\"fal-ai/flux-2/klein/9b\", {}))\n+\n+        FalImageGenProvider().generate(\"p\", aspect_ratio=\"not-a-real-ratio\")\n+        # ``resolve_aspect_ratio`` clamps to landscape.\n+        assert seen_aspect[\"v\"] == \"landscape\"\n+\n+    def test_generate_passthrough_drops_none_kwargs(self, monkeypatch):\n+        import tools.image_generation_tool as image_tool\n+        from plugins.image_gen.fal import FalImageGenProvider\n+\n+        seen = {}\n+\n+        def fake(prompt, aspect_ratio, **kwargs):\n+            seen.update(kwargs)\n+            return json.dumps({\"success\": True, \"image\": \"x\"})\n+\n+        monkeypatch.setattr(image_tool, \"image_generate_tool\", fake)\n+        monkeypatch.setattr(image_tool, \"_resolve_fal_model\",\n+                            lambda: (\"fal-ai/flux-2/klein/9b\", {}))\n+\n+        FalImageGenProvider().generate(\n+            \"p\",\n+            aspect_ratio=\"landscape\",\n+            seed=None,\n+            num_images=2,\n+            guidance_scale=None,\n+        )\n+\n+        # ``None`` values must not be forwarded — they'd override the\n+        # model's defaults inside the legacy payload builder.\n+        assert \"seed\" not in seen\n+        assert \"guidance_scale\" not in seen\n+        assert seen.get(\"num_images\") == 2\n+\n+    def test_generate_catches_exception_from_legacy(self, monkeypatch):\n+        import tools.image_generation_tool as image_tool\n+        from plugins.image_gen.fal import FalImageGenProvider\n+\n+        def boom(*args, **kwargs):\n+            raise RuntimeError(\"FAL endpoint exploded\")\n+\n+        monkeypatch.setattr(image_tool, \"image_generate_tool\", boom)\n+\n+        result = FalImageGenProvider().generate(\"p\")\n+        assert result[\"success\"] is False\n+        assert \"FAL image generation failed\" in result[\"error\"]\n+        assert result[\"error_type\"] == \"RuntimeError\"\n+        assert result[\"provider\"] == \"fal\"\n+\n+    def test_generate_invalid_json_response(self, monkeypatch):\n+        import tools.image_generation_tool as image_tool\n+        from plugins.image_gen.fal import FalImageGenProvider\n+\n+        monkeypatch.setattr(image_tool, \"image_generate_tool\", lambda **kw: \"not-json\")\n+        monkeypatch.setattr(image_tool, \"_resolve_fal_model\",\n+                            lambda: (\"fal-ai/flux-2/klein/9b\", {}))\n+\n+        result = FalImageGenProvider().generate(\"p\")\n+        assert result[\"success\"] is False\n+        assert \"Invalid JSON\" in result[\"error\"]\n+        assert result[\"provider\"] == \"fal\"\n+\n+\n+# ---------------------------------------------------------------------------\n+# Registry wiring\n+# ---------------------------------------------------------------------------\n+\n+\n+class TestFalImageGenPluginRegistration:\n+    def test_register_wires_provider_into_registry(self):\n+        from plugins.image_gen.fal import FalImageGenProvider, register\n+\n+        ctx = MagicMock()\n+        register(ctx)\n+\n+        ctx.register_image_gen_provider.assert_called_once()\n+        (registered,), _ = ctx.register_image_gen_provider.call_args\n+        assert isinstance(registered, FalImageGenProvider)"]},{"file":"tools/fal_common.py","hunks":["@@ -0,0 +1,163 @@\n+\"\"\"Shared FAL.ai SDK plumbing.\n+\n+Holds the stateless atoms that every FAL-backed tool needs:\n+\n+* :func:`import_fal_client` — lazy import + ``lazy_deps`` integration so\n+  ``fal_client`` isn't pulled at cold start (it added ~64 ms per CLI\n+  invocation when imported eagerly).\n+* :class:`_ManagedFalSyncClient` — wrapper that drives a Nous-managed\n+  fal-queue gateway through the standard ``fal_client.SyncClient``\n+  primitives.\n+* :func:`_normalize_fal_queue_url_format`, :func:`_extract_http_status`\n+  — small helpers used by both the managed client wrapper and\n+  ``_submit_fal_request``.\n+\n+Stateful pieces (cache globals, ``_managed_fal_client*`` selectors,\n+``_submit_fal_request``) intentionally stay on\n+:mod:`tools.image_generation_tool`. That module is the patch target for\n+existing test suites (``tests/tools/test_image_generation.py``,\n+``tests/tools/test_managed_media_gateways.py``) and for the\n+``plugins/image_gen/fal/`` plugin's ``_it`` indirection — moving the\n+caches here would silently defeat ``monkeypatch.setattr(image_tool,\n+\"_managed_fal_client\", None)`` because the lookups would go against\n+``fal_common``'s namespace instead. See the per-rule walkthrough at\n+issue #26241 for details.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+from typing import Any, Dict, Optional, Union\n+from urllib.parse import urlencode\n+\n+\n+def import_fal_client() -> Any:\n+    \"\"\"Import ``fal_client`` (via ``lazy_deps`` when available) and return\n+    the module reference.\n+\n+    Callers are responsible for caching the result on their own module\n+    global — keeping per-module globals lets tests monkey-patch the\n+    target module's ``fal_client`` attribute and have the patched value\n+    stick for that module's call sites.\n+\n+    Raises :class:`ImportError` if the package is genuinely unavailable.\n+    \"\"\"\n+    try:\n+        from tools.lazy_deps import ensure as _lazy_ensure\n+        _lazy_ensure(\"image.fal\", prompt=False)\n+    except ImportError:\n+        pass\n+    except Exception as exc:  # noqa: BLE001 — lazy_deps surfaces install hints\n+        raise ImportError(str(exc))\n+    import fal_client  # type: ignore  # noqa: WPS433 — intentionally lazy\n+    return fal_client\n+\n+\n+def _normalize_fal_queue_url_format(queue_run_origin: str) -> str:\n+    normalized_origin = str(queue_run_origin or \"\").strip().rstrip(\"/\")\n+    if not normalized_origin:\n+        raise ValueError(\"Managed FAL queue origin is required\")\n+    return f\"{normalized_origin}/\"\n+\n+\n+def _extract_http_status(exc: BaseException) -> Optional[int]:\n+    \"\"\"Return an HTTP status code from httpx/fal exceptions, else None.\n+\n+    Defensive across exception shapes — httpx.HTTPStatusError exposes\n+    ``.response.status_code`` while fal_client wrappers may expose\n+    ``.status_code`` directly.\n+    \"\"\"\n+    response = getattr(exc, \"response\", None)\n+    if response is not None:\n+        status = getattr(response, \"status_code\", None)\n+        if isinstance(status, int):\n+            return status\n+    status = getattr(exc, \"status_code\", None)\n+    if isinstance(status, int):\n+        return status\n+    return None\n+\n+\n+class _ManagedFalSyncClient:\n+    \"\"\"Small per-instance wrapper around ``fal_client.SyncClient`` for\n+    managed queue hosts.\n+\n+    The wrapper carries its own ``fal_client`` module reference instead\n+    of reaching into a module global, so callers stay in control of\n+    which module's ``fal_client`` is in scope (matters for the test\n+    patches that swap the legacy module's ``fal_client`` attribute).\n+    \"\"\"\n+\n+    def __init__(self, fal_client: Any, *, key: str, queue_run_origin: str):\n+        sync_client_class = getattr(fal_client, \"SyncClient\", None)\n+        if sync_client_class is None:\n+            raise RuntimeError(\"fal_client.SyncClient is required for managed FAL gateway mode\")\n+\n+        client_module = getattr(fal_client, \"client\", None)\n+        if client_module is None:\n+            raise RuntimeError(\"fal_client.client is required for managed FAL gateway mode\")\n+\n+        self._queue_url_format = _normalize_fal_queue_url_format(queue_run_origin)\n+        self._sync_client = sync_client_class(key=key)\n+        self._http_client = getattr(self._sync_client, \"_client\", None)\n+        self._maybe_retry_request = getattr(client_module, \"_maybe_retry_request\", None)\n+        self._raise_for_status = getattr(client_module, \"_raise_for_status\", None)\n+        self._request_handle_class = getattr(client_module, \"SyncRequestHandle\", None)\n+        self._add_hint_header = getattr(client_module, \"add_hint_header\", None)\n+        self._add_priority_header = getattr(client_module, \"add_priority_header\", None)\n+        self._add_timeout_header = getattr(client_module, \"add_timeout_header\", None)\n+\n+        if self._http_client is None:\n+            raise RuntimeError(\"fal_client.SyncClient._client is required for managed FAL gateway mode\")\n+        if self._maybe_retry_request is None or self._raise_for_status is None:\n+            raise RuntimeError(\"fal_client.client request helpers are required for managed FAL gateway mode\")\n+        if self._request_handle_class is None:\n+            raise RuntimeError(\"fal_client.client.SyncRequestHandle is required for managed FAL gateway mode\")\n+\n+    def submit(\n+        self,\n+        application: str,\n+        arguments: Dict[str, Any],\n+        *,\n+        path: str = \"\",\n+        hint: Optional[str] = None,\n+        webhook_url: Optional[str] = None,\n+        priority: Any = None,\n+        headers: Optional[Dict[str, str]] = None,\n+        start_timeout: Optional[Union[int, float]] = None,\n+    ):\n+        url = self._queue_url_format + application\n+        if path:\n+            url += \"/\" + path.lstrip(\"/\")\n+        if webhook_url is not None:\n+            url += \"?\" + urlencode({\"fal_webhook\": webhook_url})\n+\n+        request_headers = dict(headers or {})\n+        if hint is not None and self._add_hint_header is not None:\n+            self._add_hint_header(hint, request_headers)\n+        if priority is not None:\n+            if self._add_priority_header is None:\n+                raise RuntimeError(\"fal_client.client.add_priority_header is required for priority requests\")\n+            self._add_priority_header(priority, request_headers)\n+        if start_timeout is not None:\n+            if self._add_timeout_header is None:\n+                raise RuntimeError(\"fal_client.client.add_timeout_header is required for timeout requests\")\n+            self._add_timeout_header(start_timeout, request_headers)\n+\n+        response = self._maybe_retry_request(\n+            self._http_client,\n+            \"POST\",\n+            url,\n+            json=arguments,\n+            timeout=getattr(self._sync_client, \"default_timeout\", 120.0),\n+            headers=request_headers,\n+        )\n+        self._raise_for_status(response)\n+\n+        data = response.json()\n+        return self._request_handle_class(\n+            request_id=data[\"request_id\"],\n+            response_url=data[\"response_url\"],\n+            status_url=data[\"status_url\"],\n+            cancel_url=data[\"cancel_url\"],\n+            client=self._http_client,\n+        )"]},{"file":"tools/image_generation_tool.py","hunks":["@@ -26,8 +26,7 @@\n import datetime\n import threading\n import uuid\n-from typing import Any, Dict, Optional, Union\n-from urllib.parse import urlencode\n+from typing import Any, Dict, Optional\n \n # fal_client is imported lazily — see _load_fal_client(). Pulling it\n # eagerly added ~64 ms to every CLI cold start because","@@ -52,19 +51,17 @@ def _load_fal_client() -> Any:\n     global fal_client\n     if fal_client is not None:\n         return fal_client\n-    try:\n-        from tools.lazy_deps import ensure as _lazy_ensure\n-        _lazy_ensure(\"image.fal\", prompt=False)\n-    except ImportError:\n-        pass\n-    except Exception as e:\n-        raise ImportError(str(e))\n-    import fal_client as _fal_client  # noqa: F811 — module-global rebind\n-    fal_client = _fal_client\n+    from tools.fal_common import import_fal_client\n+    fal_client = import_fal_client()\n     return fal_client\n \n \n from tools.debug_helpers import DebugSession\n+from tools.fal_common import (\n+    _ManagedFalSyncClient,\n+    _extract_http_status,\n+    _normalize_fal_queue_url_format,  # noqa: F401 — re-exported for tests\n+)\n from tools.managed_tool_gateway import resolve_managed_tool_gateway\n from tools.tool_backend_helpers import (\n     fal_key_is_configured,","@@ -360,95 +357,6 @@ def _resolve_managed_fal_gateway():\n     return resolve_managed_tool_gateway(\"fal-queue\")\n \n \n-def _normalize_fal_queue_url_format(queue_run_origin: str) -> str:\n-    normalized_origin = str(queue_run_origin or \"\").strip().rstrip(\"/\")\n-    if not normalized_origin:\n-        raise ValueError(\"Managed FAL queue origin is required\")\n-    return f\"{normalized_origin}/\"\n-\n-\n-class _ManagedFalSyncClient:\n-    \"\"\"Small per-instance wrapper around fal_client.SyncClient for managed queue hosts.\"\"\"\n-\n-    def __init__(self, *, key: str, queue_run_origin: str):\n-        # Trigger the lazy import on first construction. Idempotent — the\n-        # placeholder is overwritten with the real module on first call.\n-        _load_fal_client()\n-        sync_client_class = getattr(fal_client, \"SyncClient\", None)\n-        if sync_client_class is None:\n-            raise RuntimeError(\"fal_client.SyncClient is required for managed FAL gateway mode\")\n-\n-        client_module = getattr(fal_client, \"client\", None)\n-        if client_module is None:\n-            raise RuntimeError(\"fal_client.client is required for managed FAL gateway mode\")\n-\n-        self._queue_url_format = _normalize_fal_queue_url_format(queue_run_origin)\n-        self._sync_client = sync_client_class(key=key)\n-        self._http_client = getattr(self._sync_client, \"_client\", None)\n-        self._maybe_retry_request = getattr(client_module, \"_maybe_retry_request\", None)\n-        self._raise_for_status = getattr(client_module, \"_raise_for_status\", None)\n-        self._request_handle_class = getattr(client_module, \"SyncRequestHandle\", None)\n-        self._add_hint_header = getattr(client_module, \"add_hint_header\", None)\n-        self._add_priority_header = getattr(client_module, \"add_priority_header\", None)\n-        self._add_timeout_header = getattr(client_module, \"add_timeout_header\", None)\n-\n-        if self._http_client is None:\n-            raise RuntimeError(\"fal_client.SyncClient._client is required for managed FAL gateway mode\")\n-        if self._maybe_retry_request is None or self._raise_for_status is None:\n-            raise RuntimeError(\"fal_client.client request helpers are required for managed FAL gateway mode\")\n-        if self._request_handle_class is None:\n-            raise RuntimeError(\"fal_client.client.SyncRequestHandle is required for managed FAL gateway mode\")\n-\n-    def submit(\n-        self,\n-        application: str,\n-        arguments: Dict[str, Any],\n-        *,\n-        path: str = \"\",\n-        hint: Optional[str] = None,\n-        webhook_url: Optional[str] = None,\n-        priority: Any = None,\n-        headers: Optional[Dict[str, str]] = None,\n-        start_timeout: Optional[Union[int, float]] = None,\n-    ):\n-        url = self._queue_url_format + application\n-        if path:\n-            url += \"/\" + path.lstrip(\"/\")\n-        if webhook_url is not None:\n-            url += \"?\" + urlencode({\"fal_webhook\": webhook_url})\n-\n-        request_headers = dict(headers or {})\n-        if hint is not None and self._add_hint_header is not None:\n-            self._add_hint_header(hint, request_headers)\n-        if priority is not None:\n-            if self._add_priority_header is None:\n-                raise RuntimeError(\"fal_client.client.add_priority_header is required for priority requests\")\n-            self._add_priority_header(priority, request_headers)\n-        if start_timeout is not None:\n-            if self._add_timeout_header is None:\n-                raise RuntimeError(\"fal_client.client.add_timeout_header is required for timeout requests\")\n-            self._add_timeout_header(start_timeout, request_headers)\n-\n-        response = self._maybe_retry_request(\n-            self._http_client,\n-            \"POST\",\n-            url,\n-            json=arguments,\n-            timeout=getattr(self._sync_client, \"default_timeout\", 120.0),\n-            headers=request_headers,\n-        )\n-        self._raise_for_status(response)\n-\n-        data = response.json()\n-        return self._request_handle_class(\n-            request_id=data[\"request_id\"],\n-            response_url=data[\"response_url\"],\n-            status_url=data[\"status_url\"],\n-            cancel_url=data[\"cancel_url\"],\n-            client=self._http_client,\n-        )\n-\n-\n def _get_managed_fal_client(managed_gateway):\n     \"\"\"Reuse the managed FAL client so its internal httpx.Client is not leaked per call.\"\"\"\n     global _managed_fal_client, _managed_fal_client_config","@@ -461,7 +369,11 @@ def _get_managed_fal_client(managed_gateway):\n         if _managed_fal_client is not None and _managed_fal_client_config == client_config:\n             return _managed_fal_client\n \n+        # Resolve fal_client on the legacy module — preserves the test\n+        # pattern of monkey-patching ``image_generation_tool.fal_client``.\n+        _load_fal_client()\n         _managed_fal_client = _ManagedFalSyncClient(\n+            fal_client,\n             key=managed_gateway.nous_user_token,\n             queue_run_origin=managed_gateway.gateway_origin,\n         )","@@ -502,24 +414,6 @@ def _submit_fal_request(model: str, arguments: Dict[str, Any]):\n         raise\n \n \n-def _extract_http_status(exc: BaseException) -> Optional[int]:\n-    \"\"\"Return an HTTP status code from httpx/fal exceptions, else None.\n-\n-    Defensive across exception shapes — httpx.HTTPStatusError exposes\n-    ``.response.status_code`` while fal_client wrappers may expose\n-    ``.status_code`` directly.\n-    \"\"\"\n-    response = getattr(exc, \"response\", None)\n-    if response is not None:\n-        status = getattr(response, \"status_code\", None)\n-        if isinstance(status, int):\n-            return status\n-    status = getattr(exc, \"status_code\", None)\n-    if isinstance(status, int):\n-        return status\n-    return None\n-\n-\n # ---------------------------------------------------------------------------\n # Model resolution + payload construction\n # ---------------------------------------------------------------------------","@@ -973,9 +867,12 @@ def _read_configured_image_provider():\n     \"\"\"Return the value of ``image_gen.provider`` from config.yaml, or None.\n \n     We only consult the plugin registry when this is explicitly set — an\n-    unset value keeps users on the legacy in-tree FAL path even when other\n+    unset value keeps users on the in-tree FAL fallback even when other\n     providers happen to be registered (e.g. a user has OPENAI_API_KEY set\n-    for other features but never asked for OpenAI image gen).\n+    for other features but never asked for OpenAI image gen). ``\"fal\"``\n+    explicitly routes through ``plugins/image_gen/fal/`` (which delegates\n+    back into this module's pipeline via call-time indirection — see\n+    issue #26241).\n     \"\"\"\n     try:\n         from hermes_cli.config import load_config","@@ -994,15 +891,16 @@ def _dispatch_to_plugin_provider(prompt: str, aspect_ratio: str):\n     \"\"\"Route the call to a plugin-registered provider when one is selected.\n \n     Returns a JSON string on dispatch, or ``None`` to fall through to the\n-    built-in FAL path.\n+    in-tree FAL fallback in ``image_generate_tool``.\n \n-    Dispatch only fires when ``image_gen.provider`` is explicitly set AND\n-    it does not point to ``fal`` (FAL still lives in-tree in this PR;\n-    a later PR ports it into ``plugins/image_gen/fal/``). Any other value\n-    that matches a registered plugin provider wins.\n+    Dispatch fires when ``image_gen.provider`` is explicitly set — including\n+    ``\"fal\"`` itself, which now resolves to the\n+    ``plugins/image_gen/fal/`` plugin (the plugin re-enters this module's\n+    pipeline via ``_it`` indirection so behavior is identical to the\n+    direct call, just routed through the registry).\n     \"\"\"\n     configured = _read_configured_image_provider()\n-    if not configured or configured == \"fal\":\n+    if not configured:\n         return None\n \n     # Also read configured model so we can pass it to the plugin\n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}