mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-30 06:41:51 +00:00
Mirrors the architecture established by the web (#25182), browser (#25214), and video_gen (#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 #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 #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 #26241
279 lines
10 KiB
Python
279 lines
10 KiB
Python
"""Tests for plugin image_gen providers injecting themselves into the picker.
|
|
|
|
Covers `_plugin_image_gen_providers`, `_visible_providers`, and
|
|
`_toolset_needs_configuration_prompt` handling of plugin providers.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from agent import image_gen_registry
|
|
from agent.image_gen_provider import ImageGenProvider
|
|
|
|
|
|
class _FakeProvider(ImageGenProvider):
|
|
def __init__(self, name: str, available: bool = True, schema=None, models=None):
|
|
self._name = name
|
|
self._available = available
|
|
self._schema = schema or {
|
|
"name": name.title(),
|
|
"badge": "test",
|
|
"tag": f"{name} test tag",
|
|
"env_vars": [{"key": f"{name.upper()}_API_KEY", "prompt": f"{name} key"}],
|
|
}
|
|
self._models = models or [
|
|
{"id": f"{name}-model-v1", "display": f"{name} v1",
|
|
"speed": "~5s", "strengths": "test", "price": "$"},
|
|
]
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return self._name
|
|
|
|
def is_available(self) -> bool:
|
|
return self._available
|
|
|
|
def list_models(self):
|
|
return list(self._models)
|
|
|
|
def default_model(self):
|
|
return self._models[0]["id"] if self._models else None
|
|
|
|
def get_setup_schema(self):
|
|
return dict(self._schema)
|
|
|
|
def generate(self, prompt, aspect_ratio="landscape", **kw):
|
|
return {"success": True, "image": f"{self._name}://{prompt}"}
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_registry():
|
|
image_gen_registry._reset_for_tests()
|
|
yield
|
|
image_gen_registry._reset_for_tests()
|
|
|
|
|
|
class TestPluginPickerInjection:
|
|
def test_plugin_providers_returns_registered(self, monkeypatch):
|
|
from hermes_cli import tools_config
|
|
|
|
image_gen_registry.register_provider(_FakeProvider("myimg"))
|
|
|
|
rows = tools_config._plugin_image_gen_providers()
|
|
names = [r["name"] for r in rows]
|
|
plugin_names = [r.get("image_gen_plugin_name") for r in rows]
|
|
|
|
assert "Myimg" in names
|
|
assert "myimg" in plugin_names
|
|
|
|
def test_fal_surfaced_alongside_other_plugins(self, monkeypatch):
|
|
from hermes_cli import tools_config
|
|
|
|
# After #26241, FAL is itself a plugin (`plugins/image_gen/fal/`)
|
|
# and the hardcoded `TOOL_CATEGORIES["image_gen"]` FAL row is
|
|
# gone. The plugin-row builder therefore surfaces it like any
|
|
# other backend — no deduplication step needed.
|
|
image_gen_registry.register_provider(_FakeProvider("fal"))
|
|
image_gen_registry.register_provider(_FakeProvider("openai"))
|
|
|
|
rows = tools_config._plugin_image_gen_providers()
|
|
names = [r.get("image_gen_plugin_name") for r in rows]
|
|
assert "fal" in names
|
|
assert "openai" in names
|
|
|
|
def test_visible_providers_includes_plugins_for_image_gen(self, monkeypatch):
|
|
from hermes_cli import tools_config
|
|
|
|
image_gen_registry.register_provider(_FakeProvider("someimg"))
|
|
|
|
cat = tools_config.TOOL_CATEGORIES["image_gen"]
|
|
visible = tools_config._visible_providers(cat, {})
|
|
plugin_names = [p.get("image_gen_plugin_name") for p in visible if p.get("image_gen_plugin_name")]
|
|
assert "someimg" in plugin_names
|
|
|
|
def test_visible_providers_does_not_inject_into_other_categories(self, monkeypatch):
|
|
from hermes_cli import tools_config
|
|
|
|
image_gen_registry.register_provider(_FakeProvider("someimg"))
|
|
|
|
# Browser category must NOT see image_gen plugins.
|
|
browser = tools_config.TOOL_CATEGORIES["browser"]
|
|
visible = tools_config._visible_providers(browser, {})
|
|
assert all(p.get("image_gen_plugin_name") is None for p in visible)
|
|
|
|
def test_post_setup_propagated_when_declared(self, monkeypatch):
|
|
from hermes_cli import tools_config
|
|
|
|
image_gen_registry.register_provider(_FakeProvider(
|
|
"xai_img",
|
|
schema={
|
|
"name": "xAI Grok Imagine",
|
|
"badge": "paid",
|
|
"tag": "grok image",
|
|
"env_vars": [],
|
|
"post_setup": "xai_grok",
|
|
},
|
|
))
|
|
|
|
rows = tools_config._plugin_image_gen_providers()
|
|
match = next(r for r in rows if r.get("image_gen_plugin_name") == "xai_img")
|
|
assert match["post_setup"] == "xai_grok"
|
|
|
|
def test_post_setup_omitted_when_not_declared(self, monkeypatch):
|
|
from hermes_cli import tools_config
|
|
|
|
image_gen_registry.register_provider(_FakeProvider("plain_img"))
|
|
|
|
rows = tools_config._plugin_image_gen_providers()
|
|
match = next(r for r in rows if r.get("image_gen_plugin_name") == "plain_img")
|
|
assert "post_setup" not in match
|
|
|
|
|
|
class TestPluginCatalog:
|
|
def test_plugin_catalog_returns_models(self):
|
|
from hermes_cli import tools_config
|
|
|
|
image_gen_registry.register_provider(_FakeProvider("catimg"))
|
|
|
|
catalog, default = tools_config._plugin_image_gen_catalog("catimg")
|
|
assert "catimg-model-v1" in catalog
|
|
assert default == "catimg-model-v1"
|
|
|
|
def test_plugin_catalog_empty_for_unknown(self):
|
|
from hermes_cli import tools_config
|
|
|
|
catalog, default = tools_config._plugin_image_gen_catalog("does-not-exist")
|
|
assert catalog == {}
|
|
assert default is None
|
|
|
|
|
|
class TestConfigPrompt:
|
|
def test_image_gen_satisfied_by_plugin_provider(self, monkeypatch, tmp_path):
|
|
"""When a plugin provider reports is_available(), the picker should
|
|
not force a setup prompt on the user."""
|
|
from hermes_cli import tools_config
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
monkeypatch.delenv("FAL_KEY", raising=False)
|
|
|
|
image_gen_registry.register_provider(_FakeProvider("avail-img", available=True))
|
|
|
|
assert tools_config._toolset_needs_configuration_prompt("image_gen", {}) is False
|
|
|
|
def test_image_gen_still_prompts_when_nothing_available(self, monkeypatch, tmp_path):
|
|
from hermes_cli import tools_config
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
monkeypatch.delenv("FAL_KEY", raising=False)
|
|
|
|
image_gen_registry.register_provider(_FakeProvider("unavail-img", available=False))
|
|
|
|
assert tools_config._toolset_needs_configuration_prompt("image_gen", {}) is True
|
|
|
|
|
|
class TestConfigWriting:
|
|
def test_picking_plugin_provider_writes_provider_and_model(self, monkeypatch, tmp_path):
|
|
"""When a user picks a plugin-backed image_gen provider with no
|
|
env vars needed, ``_configure_provider`` should write both
|
|
``image_gen.provider`` and ``image_gen.model``."""
|
|
from hermes_cli import tools_config
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
image_gen_registry.register_provider(_FakeProvider("noenv", schema={
|
|
"name": "NoEnv",
|
|
"badge": "free",
|
|
"tag": "",
|
|
"env_vars": [],
|
|
}))
|
|
|
|
# Stub out the interactive model picker — no TTY in tests.
|
|
monkeypatch.setattr(tools_config, "_prompt_choice", lambda *a, **kw: 0)
|
|
|
|
config: dict = {}
|
|
provider_row = {
|
|
"name": "NoEnv",
|
|
"env_vars": [],
|
|
"image_gen_plugin_name": "noenv",
|
|
}
|
|
tools_config._configure_provider(provider_row, config)
|
|
|
|
assert config["image_gen"]["provider"] == "noenv"
|
|
assert config["image_gen"]["model"] == "noenv-model-v1"
|
|
|
|
def test_reconfiguring_plugin_provider_writes_provider_and_model(self, monkeypatch, tmp_path):
|
|
"""The reconfigure path should switch image_gen away from managed FAL
|
|
and onto the selected plugin provider."""
|
|
from hermes_cli import tools_config
|
|
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
image_gen_registry.register_provider(_FakeProvider("testopenai"))
|
|
monkeypatch.setattr(tools_config, "_prompt_choice", lambda *a, **kw: 0)
|
|
monkeypatch.setattr(tools_config, "_prompt", lambda *a, **kw: "")
|
|
monkeypatch.setattr(
|
|
tools_config,
|
|
"get_env_value",
|
|
lambda key: "sk-test" if key == "OPENAI_API_KEY" else "",
|
|
)
|
|
|
|
config = {"image_gen": {"use_gateway": True}}
|
|
provider_row = {
|
|
"name": "OpenAI",
|
|
"env_vars": [{"key": "OPENAI_API_KEY", "prompt": "OpenAI API key"}],
|
|
"image_gen_plugin_name": "testopenai",
|
|
}
|
|
|
|
tools_config._reconfigure_provider(provider_row, config)
|
|
|
|
assert config["image_gen"]["provider"] == "testopenai"
|
|
assert config["image_gen"]["model"] == "testopenai-model-v1"
|
|
assert config["image_gen"]["use_gateway"] is False
|
|
|
|
def test_plugin_provider_active_overrides_managed_nous_active_label(self, monkeypatch):
|
|
from hermes_cli import tools_config
|
|
|
|
monkeypatch.setattr(
|
|
tools_config,
|
|
"get_nous_subscription_features",
|
|
lambda config: SimpleNamespace(
|
|
features={"image_gen": SimpleNamespace(managed_by_nous=True)}
|
|
),
|
|
)
|
|
|
|
config = {"image_gen": {"provider": "openai", "use_gateway": False}}
|
|
nous_row = {
|
|
"name": "Nous Subscription",
|
|
"managed_nous_feature": "image_gen",
|
|
}
|
|
openai_row = {
|
|
"name": "OpenAI",
|
|
"image_gen_plugin_name": "openai",
|
|
}
|
|
|
|
assert tools_config._is_provider_active(openai_row, config) is True
|
|
assert tools_config._is_provider_active(nous_row, config) is False
|
|
|
|
def test_reconfiguring_fal_clears_plugin_provider(self, monkeypatch):
|
|
from hermes_cli import tools_config
|
|
|
|
monkeypatch.setattr(tools_config, "_prompt_choice", lambda *a, **kw: 0)
|
|
monkeypatch.setattr(tools_config, "_prompt", lambda *a, **kw: "")
|
|
monkeypatch.setattr(
|
|
tools_config,
|
|
"get_env_value",
|
|
lambda key: "fal-key" if key == "FAL_KEY" else "",
|
|
)
|
|
|
|
config = {"image_gen": {"provider": "openai", "use_gateway": False}}
|
|
provider_row = {
|
|
"name": "FAL.ai",
|
|
"env_vars": [{"key": "FAL_KEY", "prompt": "FAL API key"}],
|
|
"imagegen_backend": "fal",
|
|
}
|
|
|
|
tools_config._reconfigure_provider(provider_row, config)
|
|
|
|
assert config["image_gen"]["provider"] == "fal"
|
|
assert config["image_gen"]["use_gateway"] is False
|