Skip to content

feat(image_gen): port FAL backend to plugins/image_gen/fal#27966

Closed
0xDevNinja wants to merge 1 commit into
NousResearch:mainfrom
0xDevNinja:feat/26241-fal-plugin-migration
Closed

feat(image_gen): port FAL backend to plugins/image_gen/fal#27966
0xDevNinja wants to merge 1 commit into
NousResearch:mainfrom
0xDevNinja:feat/26241-fal-plugin-migration

Conversation

@0xDevNinja

Copy link
Copy Markdown
Contributor

What does this PR do?

Ports the FAL.ai image-generation backend out of
tools/image_generation_tool.py and into a real plugin at
plugins/image_gen/fal/, matching the architecture established by the
web (#25182), browser (#25214), and video_gen (#25126) plugin
migrations. After this lands,
TOOL_CATEGORIES["image_gen"]["providers"] carries only the "Nous
Subscription" setup-flow row — same shape browser kept after #25214.

Per the discussion at
#26241 (comment)
and the rules in plugin-extraction-test-patch-compatibility.md,
nothing in the existing tests/tools/test_image_generation*.py or
tests/tools/test_managed_media_gateways.py patch surface needs to
move — the plugin reaches into the legacy module via
import tools.image_generation_tool as _it and resolves every
patchable name (_submit_fal_request, _resolve_managed_fal_gateway,
_get_managed_fal_client, fal_client, _managed_fal_client) at
call time.

Architecture

  • tools/fal_common.py (new) — stateless atoms shared by both
    FAL-backed plugins:

    • import_fal_client() — the lazy fal_client import +
      tools.lazy_deps.ensure integration that previously lived
      duplicated in image_generation_tool and
      plugins/video_gen/fal/__init__.py.
    • _ManagedFalSyncClient — managed-queue wrapper. Now takes
      fal_client as an injected argument instead of reading a module
      global, so the legacy module's monkeypatch.setattr(image_tool, "fal_client", mock) keeps working when the wrapper is constructed
      via _get_managed_fal_client.
    • _normalize_fal_queue_url_format, _extract_http_status — pure
      helpers.

    Stateful pieces stay on tools.image_generation_tool (the
    fal_client module global, _managed_fal_client* cache + lock,
    _submit_fal_request, _resolve_managed_fal_gateway,
    _get_managed_fal_client). Moving them into fal_common would
    silently defeat the existing patch sites that reach for
    image_tool._managed_fal_client etc., because the function's
    free-variable resolution would go against fal_common's namespace
    instead. Rule 3 of the contributor doc, intact.

  • plugins/video_gen/fal/__init__.py — drops its inline
    _load_fal_client duplicate; the cached _fal_client module
    global stays on the video plugin (per-module caches don't leak
    across consumers). One small commit-scoped diff.

  • plugins/image_gen/fal/{plugin.yaml,__init__.py} — new plugin.
    FalImageGenProvider is a thin registration adapter that resolves
    import tools.image_generation_tool as _it inside every method
    (is_available, list_models, default_model, generate). The
    18-model catalog, _build_fal_payload, managed-gateway selection,
    and Clarity Upscaler chaining all remain in the legacy module —
    the plugin is a registration adapter, not a parallel implementation.
    generate() JSON-parses the legacy image_generate_tool response
    and stamps provider/prompt/aspect_ratio/model for the unified
    ImageGenProvider response shape.

  • tools/image_generation_tool.py::_dispatch_to_plugin_provider
    drops the configured == "fal" skip. Setting
    image_gen.provider: fal now routes through the registry like any
    other provider; the plugin re-enters this module's pipeline so the
    behaviour is identical (the parity harness verifies this). Unset
    image_gen.provider still falls through to the in-tree pipeline,
    preserving the no-config-with-FAL_KEY UX from refactor(memory): remove flush_memories entirely #15696.

  • hermes_cli/tools_config.py — drops the hardcoded "FAL.ai" row
    from TOOL_CATEGORIES["image_gen"]["providers"] and the
    provider.name == "fal" skip in _plugin_image_gen_providers.
    The "Nous Subscription" row stays — same shape browser kept "Nous
    Subscription (Browser Use cloud)" after Mirror web-provider plugin migration for browser providers #25214.

Test coverage

  • tests/plugins/image_gen/test_fal_provider.py (new, 14 cases):

    • ABC surface (name, display_name, list_models parity with
      FAL_MODELS, default_model matches legacy DEFAULT_MODEL,
      get_setup_schema advertises FAL_KEY).
    • Availability (delegates to _it.check_fal_api_key, swallows
      exceptions so the picker never propagates them).
    • Call-time indirection — verifies that
      monkeypatch.setattr(image_tool, "image_generate_tool", fake)
      is picked up inside FalImageGenProvider.generate(). This is the
      invariant that lets every existing patch site keep working.
    • Aspect-ratio coercion (resolve_aspect_ratio clamps invalids to
      landscape), passthrough kwarg filtering (drops Nones before
      forwarding into the legacy payload builder), exception handling
      (legacy image_generate_tool raising → success=False with
      typed error), invalid JSON response handling, response-shape
      stamping (provider/prompt/aspect_ratio/model).
    • Registry wiring (register() calls
      ctx.register_image_gen_provider).
  • tests/plugins/image_gen/check_parity_vs_main.py (new) —
    subprocess harness mirroring
    tests/plugins/browser/check_parity_vs_main.py. Pins one venv to
    origin/main, one to the worktree, runs six scenarios:

    1. no-config-no-env → both legacy_fal
    2. explicit-fal-no-creds → main: legacy_fal; PR: plugin (fal)
    3. explicit-fal-with-creds → main: legacy_fal; PR: plugin (fal)
    4. explicit-fal-with-model → as Architecture planning #3 + model field matches
    5. explicit-typo-provider → both surface the
      provider_not_registered error
    6. managed-gateway-only → both legacy_fal

    The only acceptable diff is "legacy_fal → plugin (fal)" for
    explicit-FAL paths — flagged as [DIFF] rather than [FAIL].
    Everything else is treated as a behavioural regression and exits
    non-zero.

  • tests/hermes_cli/test_image_gen_picker.py — flipped
    test_fal_skipped_to_avoid_duplicatetest_fal_surfaced_alongside_other_plugins.
    Asserts the new shape: FAL is a plugin, _plugin_image_gen_providers
    surfaces it like every other backend, no dedup needed.

Verification

uv run pytest tests/tools/test_image_generation.py \
              tests/tools/test_managed_media_gateways.py \
              tests/tools/test_image_generation_plugin_dispatch.py \
              tests/tools/test_image_generation_env.py \
              tests/plugins/image_gen/ \
              tests/plugins/video_gen/ \
              tests/hermes_cli/test_image_gen_picker.py -q
# 195 passed in 3.15s

tests/tools/test_image_generation*.py and
tests/tools/test_managed_media_gateways.py patch sites are
unchanged — the indirection pattern delivers on its promise.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update
  • Code refactor / cleanup
  • Performance improvement
  • Test coverage improvement

Related

Out of scope (per issue body)

  • _upscale_image becoming pluggable — coupled to per-model
    upscale: bool; can ship as its own plugin category later.
  • Adding managed-gateway support to plugins/video_gen/fal — file a
    separate issue if useful.
  • Restructuring the 18-model catalog into first-class
    ImageGenProvider.list_models() entries with stricter typing.

Mirrors the architecture established by the web (NousResearch#25182), browser
(NousResearch#25214), and video_gen (NousResearch#25126) plugin migrations:

* `tools/fal_common.py` — stateless atoms shared by both FAL-backed
  plugins (image_gen + video_gen). Holds the lazy `fal_client` import
  helper, `_ManagedFalSyncClient`, `_normalize_fal_queue_url_format`,
  `_extract_http_status`. Stateful pieces (`fal_client` module global,
  `_managed_fal_client*` cache, `_submit_fal_request`,
  `_resolve_managed_fal_gateway`, `_get_managed_fal_client`)
  intentionally stay on `tools.image_generation_tool` so the existing
  `monkeypatch.setattr(image_tool, ...)` patch sites keep working
  unchanged.

* `plugins/video_gen/fal/__init__.py` — drops its inline
  `_load_fal_client` duplicate; consumes `tools.fal_common.import_fal_client`.

* `plugins/image_gen/fal/{plugin.yaml,__init__.py}` — new plugin.
  `FalImageGenProvider` is a thin registration adapter that resolves
  the legacy module via `import tools.image_generation_tool as _it`
  and calls `_it.image_generate_tool` + `_it._resolve_fal_model` at
  call time. The 18-model catalog, `_build_fal_payload`, managed-
  gateway selection, and Clarity Upscaler chaining all remain in
  `tools.image_generation_tool` as the single source of truth —
  the plugin is a registration adapter, not a parallel implementation.

* `tools/image_generation_tool.py::_dispatch_to_plugin_provider` —
  drops the `configured == "fal"` skip. Setting `image_gen.provider:
  fal` now routes through the registry like any other provider; the
  plugin re-enters this module's pipeline so behavior is identical.
  Unset `image_gen.provider` still falls through to the in-tree
  pipeline (preserves no-config-with-FAL_KEY UX from NousResearch#15696).

* `hermes_cli/tools_config.py` — drops the hardcoded "FAL.ai" row from
  `TOOL_CATEGORIES["image_gen"]["providers"]` (now injected by
  `_plugin_image_gen_providers` like every other backend) and the
  `getattr(provider, "name") == "fal"` skip that protected against
  duplication with the hardcoded row. The "Nous Subscription" row
  stays as a setup-flow entry — same shape browser kept "Nous
  Subscription (Browser Use cloud)" after NousResearch#25214.

* `tests/plugins/image_gen/test_fal_provider.py` — 14 cases covering
  the ABC surface, call-time indirection (verifying
  `monkeypatch.setattr(image_tool, "image_generate_tool", ...)` takes
  effect through the plugin), response-shape stamping, exception
  handling, and registry wiring.

* `tests/plugins/image_gen/check_parity_vs_main.py` — subprocess
  harness mirroring `tests/plugins/browser/check_parity_vs_main.py`.
  Pins one path to origin/main, one to the worktree; runs six
  scenarios (unset, explicit-fal-no-creds, explicit-fal-with-creds,
  explicit-fal-with-model, typo provider, managed-gateway-only) and
  diffs the reduced shape `{dispatch_kind, provider_name, model}`
  per scenario. The only acceptable diff is "legacy_fal → plugin
  (fal)" for explicit-FAL paths — every other delta is flagged as
  a regression.

* `tests/hermes_cli/test_image_gen_picker.py::test_fal_surfaced_alongside_other_plugins`
  — flips the previous `test_fal_skipped_to_avoid_duplicate` to
  match the new shape (FAL is a plugin now, no dedup needed).

Verified: 195/195 tests across
`tests/{tools/test_image_generation*,tools/test_managed_media_gateways,plugins/image_gen,plugins/video_gen,hermes_cli/test_image_gen_picker}.py`
pass on this branch with no test patches modified outside the picker
test that asserted the old skip behaviour.

Fixes NousResearch#26241
@BoardJames-Bot

Copy link
Copy Markdown

BoardJames triage: this looks shared/systemic rather than branch-local. The PR-specific checks (lint/nix/e2e/builds/attribution/history) are green where completed; the remaining blocker is the main Tests / test job, which is currently failing/timing out across unrelated PRs and on main itself (latest main run hit the same aux/session_search + kanban dashboard + compression/Anthropic test drift). I pushed the missing aux/session_search default fix onto the existing systemic fix PR #27931 and validated the affected files locally (125 passed). Next action is maintainer review/workflow approval/merge of #27931, then rerun this PR's Tests / test; no branch-local author action is indicated from the logs I can see.

@alt-glitch alt-glitch added type/refactor Code restructuring, no behavior change comp/plugins Plugin system and bundled plugins tool/vision Vision analysis and image generation P3 Low — cosmetic, nice to have labels May 18, 2026
@teknium1

Copy link
Copy Markdown
Contributor

Merged via PR #30380 — your commit was cherry-picked onto current main (your branch was 475 commits behind, cherry-pick was clean) and rebase-merged so your authorship is preserved in git log (commit 3ac2125). Thanks for the thorough work — the call-time indirection pattern + parity harness + zero-test-file-moves was exactly the right shape.

#30380

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have tool/vision Vision analysis and image generation type/refactor Code restructuring, no behavior change

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Mirror web/browser plugin migration for FAL image generation backend

4 participants