Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
View File
@@ -0,0 +1,300 @@
"""Behavior-parity check for the image-gen FAL plugin migration (#26241).
Spawns one subprocess per (version, scenario) cell — pinned to either
``origin/main`` (legacy in-tree FAL fall-through + ``configured == "fal"``
skip in ``_dispatch_to_plugin_provider``) or this PR's worktree (FAL is
itself a plugin and the dispatcher routes every set provider through
the registry). Each subprocess clears all FAL-related env vars + writes
a ``config.yaml``, then asks the dispatcher how it would route an
``image_generate`` call. The emitted shape tuple is
``{dispatch_kind, provider_name, model}``:
* ``dispatch_kind`` ∈ ``{"legacy_fal", "plugin", "error", None}`` —
whether the call would go straight to the in-tree pipeline,
through ``_dispatch_to_plugin_provider``, raise an explicit
provider-not-registered error, or fall through silently.
* ``provider_name`` — when ``dispatch_kind == "plugin"``, the
resolved provider name. ``None`` otherwise.
* ``model`` — the resolved FAL model id when applicable.
The parent process diffs the shapes per scenario. A diff means the
migration introduced an observable behaviour change vs origin/main —
likely a real regression for users on the existing config keys.
Run from the PR worktree:
python tests/plugins/image_gen/check_parity_vs_main.py
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[3]
# Pin one path to current main, one to the PR worktree.
# ``REPO_ROOT`` is ``.../.worktrees/<name>``; the main checkout lives
# two levels up. When running directly from a regular clone (no
# worktree), ``MAIN_DIR`` falls back to a sibling ``hermes-agent-main``
# checkout if one exists.
def _resolve_main_dir() -> Path:
candidate = REPO_ROOT.parent.parent
if (candidate / "tools" / "image_generation_tool.py").exists() and candidate != REPO_ROOT:
return candidate
sibling = REPO_ROOT.parent / "hermes-agent-main"
if (sibling / "tools" / "image_generation_tool.py").exists():
return sibling
return REPO_ROOT
MAIN_DIR = _resolve_main_dir()
PR_DIR = REPO_ROOT
assert (PR_DIR / "tools" / "image_generation_tool.py").exists(), (
f"PR_DIR={PR_DIR} doesn't look like a hermes-agent checkout"
)
SUBPROCESS_SCRIPT = r"""
import json, os, sys, tempfile
sys.path.insert(0, sys.argv[1])
# Isolated HERMES_HOME so the config write is hermetic.
home = tempfile.mkdtemp()
os.environ["HERMES_HOME"] = home
# Clear FAL-related env so dispatch decisions are config-driven.
for k in (
"FAL_KEY", "FAL_QUEUE_GATEWAY_URL",
"TOOL_GATEWAY_DOMAIN", "TOOL_GATEWAY_USER_TOKEN",
"FAL_IMAGE_MODEL",
):
os.environ.pop(k, None)
scenario_env = json.loads(sys.argv[2])
os.environ.update(scenario_env)
config_yaml = sys.argv[3]
config_path = os.path.join(home, "config.yaml")
with open(config_path, "w") as f:
f.write(config_yaml)
# Fresh import — must not have anything cached.
for name in list(sys.modules):
if (name.startswith("tools.")
or name.startswith("agent.")
or name.startswith("plugins.")
or name.startswith("hermes_cli.")):
sys.modules.pop(name, None)
import tools.image_generation_tool as image_tool
dispatch_kind = None
provider_name = None
model = None
error_text = None
try:
raw = image_tool._dispatch_to_plugin_provider("ping", "landscape")
if raw is None:
dispatch_kind = "legacy_fal"
else:
parsed = json.loads(raw) if isinstance(raw, str) else raw
if isinstance(parsed, dict):
if parsed.get("error_type") == "provider_not_registered":
dispatch_kind = "error"
error_text = parsed.get("error")
else:
dispatch_kind = "plugin"
provider_name = parsed.get("provider")
model = parsed.get("model")
else:
dispatch_kind = "unknown_payload"
if model is None:
# _resolve_fal_model still returns the active FAL model id even
# when dispatch goes to a non-FAL plugin — used for the diff
# only when applicable.
try:
model_id, _meta = image_tool._resolve_fal_model()
if dispatch_kind == "legacy_fal":
model = model_id
except Exception:
pass
except Exception as exc:
dispatch_kind = "exception"
error_text = repr(exc)
shape = {
"dispatch_kind": dispatch_kind,
"provider_name": provider_name,
"model": model,
"error_present": error_text is not None,
}
print(json.dumps(shape))
"""
SCENARIOS: list[tuple[str, str, dict[str, str]]] = [
# (label, config.yaml body, extra env vars)
("no-config-no-env", "", {}),
(
"explicit-fal-no-creds",
"image_gen:\n provider: fal\n",
{},
),
(
"explicit-fal-with-creds",
"image_gen:\n provider: fal\n",
{"FAL_KEY": "test-key"},
),
(
"explicit-fal-with-model",
"image_gen:\n provider: fal\n model: fal-ai/flux-2-pro\n",
{"FAL_KEY": "test-key"},
),
(
"explicit-typo-provider",
"image_gen:\n provider: not-a-real-backend\n",
{"FAL_KEY": "test-key"},
),
(
"managed-gateway-only",
"",
{
"TOOL_GATEWAY_DOMAIN": "nousresearch.com",
"TOOL_GATEWAY_USER_TOKEN": "nous-token",
},
),
]
def _run_scenario(repo_path: Path, label: str, config_yaml: str, env: dict) -> dict:
venv_python = repo_path / ".venv" / "bin" / "python"
if not venv_python.exists():
venv_python = MAIN_DIR / ".venv" / "bin" / "python"
if not venv_python.exists():
venv_python = Path("python3")
out = subprocess.run(
[
str(venv_python),
"-c",
SUBPROCESS_SCRIPT,
str(repo_path),
json.dumps(env),
config_yaml,
],
capture_output=True,
text=True,
timeout=60,
)
if out.returncode != 0:
return {
"error": "subprocess failed",
"stdout": out.stdout[-500:],
"stderr": out.stderr[-500:],
}
try:
return json.loads(out.stdout.strip().splitlines()[-1])
except Exception as exc:
return {"error": f"could not parse output: {exc}", "stdout": out.stdout}
def _reduce(shape: dict) -> dict:
"""Reduce to the parts that matter for user-visible parity.
On origin/main, ``explicit-fal-*`` scenarios short-circuit to
``legacy_fal`` because of the ``configured == "fal"`` skip. On the
PR, those same scenarios route through the plugin and emit
``dispatch_kind == "plugin"`` with ``provider_name == "fal"``.
Both shapes are functionally equivalent — the plugin's ``generate()``
re-enters the same in-tree pipeline via ``_it`` indirection — but
we want the diff to be visible so reviewers can sign off on the
intentional behaviour delta.
"""
return {
"dispatch_kind": shape.get("dispatch_kind"),
"provider_name": shape.get("provider_name"),
"model": shape.get("model"),
"error_present": shape.get("error_present"),
}
def main() -> int:
print(f"main: {MAIN_DIR}")
print(f"pr: {PR_DIR}")
print()
if MAIN_DIR == PR_DIR:
print(
"WARN: MAIN_DIR == PR_DIR — diffs will be trivially identical.\n"
" Set up a sibling 'hermes-agent-main' checkout pinned to "
"origin/main to get real parity coverage."
)
print()
failures: list[str] = []
errors: list[str] = []
intentional_diffs: list[tuple[str, dict, dict]] = []
for label, config_yaml, env in SCENARIOS:
main_shape = _run_scenario(MAIN_DIR, label, config_yaml, env)
pr_shape = _run_scenario(PR_DIR, label, config_yaml, env)
if "error" in main_shape or "error" in pr_shape:
print(f" [ERR ] {label}: subprocess failed")
print(f" main: {main_shape}")
print(f" pr: {pr_shape}")
errors.append(label)
continue
main_reduced = _reduce(main_shape)
pr_reduced = _reduce(pr_shape)
if main_reduced == pr_reduced:
print(f" [OK] {label}: {main_reduced}")
continue
# On main, "explicit-fal-*" returns legacy_fal; on PR, plugin
# dispatch. That's the only acceptable diff — flag everything
# else as a regression.
legacy_to_plugin_fal = (
main_reduced.get("dispatch_kind") == "legacy_fal"
and pr_reduced.get("dispatch_kind") == "plugin"
and pr_reduced.get("provider_name") == "fal"
)
if legacy_to_plugin_fal:
print(f" [DIFF] {label}: legacy_fal → plugin (fal) — expected")
intentional_diffs.append((label, main_reduced, pr_reduced))
else:
print(f" [FAIL] {label}")
print(f" main: {main_reduced}")
print(f" pr: {pr_reduced}")
failures.append(label)
print()
if errors:
print(f"SUBPROCESS ERRORS in {len(errors)} scenario(s):")
for e in errors:
print(f" - {e}")
if failures:
print(f"BEHAVIOUR REGRESSION in {len(failures)} scenario(s):")
for f in failures:
print(f" - {f}")
if intentional_diffs:
print(
f"INTENTIONAL DIFFS ({len(intentional_diffs)}): "
f"legacy_fal → plugin dispatch for explicit FAL paths."
)
if failures or errors:
return 1
print(f"PARITY OK across {len(SCENARIOS)} scenarios.")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,106 @@
"""Tests for the bundled DeepInfra image_gen plugin.
Invariants only — no snapshots of specific model ids. Most surface-level
contracts (network-failure → empty list, tag filtering, no-model error)
are covered by the shared tag-filter test in
``tests/hermes_cli/test_api_key_providers.py``; these two tests pin the
plugin-specific bits that wrapper doesn't reach.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
import plugins.image_gen.deepinfra as deepinfra_plugin
# 1×1 transparent PNG — valid bytes for save_b64_image()
_PNG_HEX = (
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4"
"890000000d49444154789c6300010000000500010d0a2db40000000049454e44"
"ae426082"
)
def _b64_png() -> str:
import base64
return base64.b64encode(bytes.fromhex(_PNG_HEX)).decode()
@pytest.fixture(autouse=True)
def _isolation(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
import hermes_cli.models as _models_mod
monkeypatch.setattr(_models_mod, "_deepinfra_catalog_cache", {})
monkeypatch.setenv("DEEPINFRA_API_KEY", "test-key")
yield
def test_list_models_filters_by_image_gen_tag(monkeypatch):
"""Plugin-side wiring: list_models() returns only ``image-gen``-tagged
catalog entries and surfaces pricing + default dims when present."""
import json
import hermes_cli.models as models
class _Resp:
def __enter__(self): return self
def __exit__(self, *a): return False
def read(self):
return json.dumps({"data": [
{"id": "vendor/chat", "metadata": {"tags": ["chat"]}},
{"id": "vendor/img", "metadata": {
"tags": ["image-gen"],
"pricing": {"per_image_unit": 0.005},
"default_width": 1024,
}},
]}).encode()
monkeypatch.setattr(
models, "_urlopen_model_catalog_request", lambda *a, **kw: _Resp()
)
rows = deepinfra_plugin.DeepInfraImageGenProvider().list_models()
ids = {row["id"] for row in rows}
assert ids == {"vendor/img"}
img = next(row for row in rows if row["id"] == "vendor/img")
assert "price" in img and img["default_width"] == 1024
def test_generate_calls_openai_sdk_with_deepinfra_base_url(monkeypatch):
"""Happy path: pinned model → openai SDK called with DeepInfra
base_url + Bearer key → b64 saved to cache."""
monkeypatch.setenv("DEEPINFRA_IMAGE_MODEL", "vendor/test-img")
captured: dict = {}
class _FakeImages:
def generate(self, **kwargs):
captured["kwargs"] = kwargs
return SimpleNamespace(data=[SimpleNamespace(b64_json=_b64_png(), url=None)])
class _FakeClient:
def __init__(self, api_key=None, base_url=None):
captured["api_key"] = api_key
captured["base_url"] = base_url
self.images = _FakeImages()
fake_openai = MagicMock()
fake_openai.OpenAI = _FakeClient
with patch.dict("sys.modules", {"openai": fake_openai}):
result = deepinfra_plugin.DeepInfraImageGenProvider().generate(
prompt="a cat", aspect_ratio="square",
)
assert result["success"] is True
assert "deepinfra" in captured["base_url"]
assert captured["api_key"] == "test-key"
assert captured["kwargs"]["model"] == "vendor/test-img"
def test_capabilities_advertise_text_to_image_only():
assert deepinfra_plugin.DeepInfraImageGenProvider().capabilities() == {
"modalities": ["text"],
"max_reference_images": 0,
}
@@ -0,0 +1,132 @@
#!/usr/bin/env python3
"""Tests for the FAL.ai image generation plugin.
The plugin is a thin registration adapter — actual FAL pipeline logic
lives in ``tools.image_generation_tool`` and is exercised by
``tests/tools/test_image_generation.py``. These tests focus on:
* the ``ImageGenProvider`` ABC surface (name, models, schema)
* call-time indirection (``_it`` resolution at ``generate()`` time so
``monkeypatch.setattr(image_tool, ...)`` keeps working)
* response shape stamping (provider/prompt/aspect_ratio/model)
"""
from __future__ import annotations
import json
from unittest.mock import MagicMock
# ---------------------------------------------------------------------------
# Provider surface
# ---------------------------------------------------------------------------
class TestFalImageGenProviderSurface:
def test_name(self):
from plugins.image_gen.fal import FalImageGenProvider
assert FalImageGenProvider().name == "fal"
def test_display_name(self):
from plugins.image_gen.fal import FalImageGenProvider
assert FalImageGenProvider().display_name == "FAL.ai"
def test_default_model_matches_legacy(self):
from plugins.image_gen.fal import FalImageGenProvider
from tools.image_generation_tool import DEFAULT_MODEL
assert FalImageGenProvider().default_model() == DEFAULT_MODEL
def test_list_models_uses_legacy_catalog(self):
from plugins.image_gen.fal import FalImageGenProvider
from tools.image_generation_tool import FAL_MODELS
provider = FalImageGenProvider()
models = provider.list_models()
ids = {m["id"] for m in models}
# Whatever FAL_MODELS ships, the provider mirrors verbatim.
assert ids == set(FAL_MODELS.keys())
# Spot-check the expected first-class fields are present.
for entry in models:
for field in ("id", "display", "speed", "strengths", "price"):
assert field in entry
def test_setup_schema_advertises_fal_key(self):
from plugins.image_gen.fal import FalImageGenProvider
schema = FalImageGenProvider().get_setup_schema()
assert schema["name"] == "FAL.ai"
assert schema["badge"] == "paid"
env_keys = {entry["key"] for entry in schema.get("env_vars", [])}
assert "FAL_KEY" in env_keys
class TestFalImageGenProviderAvailability:
def test_is_available_when_legacy_check_passes(self, monkeypatch):
import tools.image_generation_tool as image_tool
from plugins.image_gen.fal import FalImageGenProvider
monkeypatch.setattr(image_tool, "check_fal_api_key", lambda: True)
assert FalImageGenProvider().is_available() is True
# ---------------------------------------------------------------------------
# generate() — call-time indirection
# ---------------------------------------------------------------------------
class TestFalImageGenProviderGenerate:
def test_generate_delegates_to_legacy_image_generate_tool(self, monkeypatch):
"""Plugin must look up ``image_generate_tool`` at call time so
``monkeypatch.setattr(image_tool, "image_generate_tool", ...)``
takes effect."""
import tools.image_generation_tool as image_tool
from plugins.image_gen.fal import FalImageGenProvider
captured = {}
def fake_image_generate_tool(prompt, aspect_ratio, **kwargs):
captured["prompt"] = prompt
captured["aspect_ratio"] = aspect_ratio
captured["kwargs"] = kwargs
return json.dumps({"success": True, "image": "https://fake/image.png"})
monkeypatch.setattr(image_tool, "image_generate_tool", fake_image_generate_tool)
monkeypatch.setattr(image_tool, "_resolve_fal_model",
lambda: ("fal-ai/flux-2/klein/9b", {}))
result = FalImageGenProvider().generate(
"a serene mountain landscape",
aspect_ratio="square",
seed=42,
)
assert captured["prompt"] == "a serene mountain landscape"
assert captured["aspect_ratio"] == "square"
assert captured["kwargs"] == {"seed": 42}
assert result["success"] is True
assert result["image"] == "https://fake/image.png"
# Stamped fields for the unified response shape
assert result["provider"] == "fal"
assert result["prompt"] == "a serene mountain landscape"
assert result["aspect_ratio"] == "square"
assert result["model"] == "fal-ai/flux-2/klein/9b"
# ---------------------------------------------------------------------------
# Registry wiring
# ---------------------------------------------------------------------------
class TestFalImageGenPluginRegistration:
def test_register_wires_provider_into_registry(self):
from plugins.image_gen.fal import FalImageGenProvider, register
ctx = MagicMock()
register(ctx)
ctx.register_image_gen_provider.assert_called_once()
(registered,), _ = ctx.register_image_gen_provider.call_args
assert isinstance(registered, FalImageGenProvider)
@@ -0,0 +1,705 @@
#!/usr/bin/env python3
"""Tests for Krea image generation provider."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _fake_api_key(monkeypatch):
"""Ensure KREA_API_KEY is set for all tests."""
monkeypatch.setenv("KREA_API_KEY", "test-key-12345")
def _completed_job(url: str = "https://krea.cdn/img.png") -> dict:
return {
"job_id": "00000000-0000-0000-0000-000000000abc",
"status": "completed",
"created_at": "2026-05-27T00:00:00Z",
"completed_at": "2026-05-27T00:00:30Z",
"result": {"urls": [url]},
}
def _submit_response(job_id: str = "00000000-0000-0000-0000-000000000abc"):
resp = MagicMock()
resp.status_code = 200
resp.raise_for_status = MagicMock()
resp.json.return_value = {
"job_id": job_id,
"status": "queued",
"created_at": "2026-05-27T00:00:00Z",
"completed_at": None,
"result": None,
}
return resp
def _poll_response(body: dict):
resp = MagicMock()
resp.status_code = 200
resp.raise_for_status = MagicMock()
resp.json.return_value = body
return resp
# ---------------------------------------------------------------------------
# Provider class tests
# ---------------------------------------------------------------------------
class TestKreaImageGenProvider:
def test_name(self):
from plugins.image_gen.krea import KreaImageGenProvider
assert KreaImageGenProvider().name == "krea"
def test_display_name(self):
from plugins.image_gen.krea import KreaImageGenProvider
assert KreaImageGenProvider().display_name == "Krea"
def test_is_available_with_key(self, monkeypatch):
monkeypatch.setenv("KREA_API_KEY", "sk-test")
from plugins.image_gen.krea import KreaImageGenProvider
assert KreaImageGenProvider().is_available() is True
def test_list_models(self):
from plugins.image_gen.krea import KreaImageGenProvider
models = KreaImageGenProvider().list_models()
ids = {m["id"] for m in models}
assert {"krea-2-medium", "krea-2-large"} <= ids
# Each entry carries the picker fields the registry expects.
for m in models:
assert m["display"]
assert m["speed"]
assert m["strengths"]
assert m["price"]
def test_default_model_is_medium(self):
from plugins.image_gen.krea import KreaImageGenProvider
assert KreaImageGenProvider().default_model() == "krea-2-medium"
def test_get_setup_schema(self):
from plugins.image_gen.krea import KreaImageGenProvider
schema = KreaImageGenProvider().get_setup_schema()
assert schema["name"] == "Krea"
assert schema["badge"] == "paid"
env_vars = schema["env_vars"]
assert len(env_vars) == 1
assert env_vars[0]["key"] == "KREA_API_KEY"
assert "krea.ai" in env_vars[0]["url"]
# ---------------------------------------------------------------------------
# Model resolution
# ---------------------------------------------------------------------------
class TestModelResolution:
def test_env_override_large(self, monkeypatch):
monkeypatch.setenv("KREA_IMAGE_MODEL", "krea-2-large")
from plugins.image_gen.krea import _resolve_model
model_id, meta = _resolve_model()
assert model_id == "krea-2-large"
assert meta["path"] == "large"
def test_creativity_default(self):
from plugins.image_gen.krea import _resolve_creativity
assert _resolve_creativity(None) == "medium"
# ---------------------------------------------------------------------------
# Generate — main flow
# ---------------------------------------------------------------------------
class TestGenerate:
def test_missing_api_key(self, monkeypatch):
monkeypatch.delenv("KREA_API_KEY", raising=False)
from plugins.image_gen.krea import KreaImageGenProvider
result = KreaImageGenProvider().generate(prompt="test")
assert result["success"] is False
assert "KREA_API_KEY" in result["error"]
assert result["error_type"] == "auth_required"
def test_empty_prompt(self):
from plugins.image_gen.krea import KreaImageGenProvider
result = KreaImageGenProvider().generate(prompt=" ")
assert result["success"] is False
assert result["error_type"] == "invalid_argument"
def test_successful_generation(self):
"""Happy path: submit → one poll → completed → URL downloaded."""
from plugins.image_gen.krea import KreaImageGenProvider
submit = _submit_response()
poll = _poll_response(_completed_job("https://krea.cdn/result.png"))
with patch("plugins.image_gen.krea.requests.post", return_value=submit) as mock_post, \
patch("plugins.image_gen.krea.requests.get", return_value=poll) as mock_get, \
patch(
"plugins.image_gen.krea.save_url_image",
return_value=Path("/tmp/krea_krea-2-medium_test.png"),
) as mock_save, \
patch("plugins.image_gen.krea.time.sleep"): # skip real waits
result = KreaImageGenProvider().generate(prompt="A cinematic lamp", upscale=False)
assert result["success"] is True
assert result["image"] == "/tmp/krea_krea-2-medium_test.png"
assert result["provider"] == "krea"
assert result["model"] == "krea-2-medium"
assert result["aspect_ratio"] == "landscape"
assert result["job_id"] == "00000000-0000-0000-0000-000000000abc"
assert result["resolution"] == "1K"
assert result["creativity"] == "medium"
# Submit hit the medium endpoint
post_url = mock_post.call_args[0][0]
assert post_url.endswith("/generate/image/krea/krea-2/medium")
# Poll hit /jobs/{job_id}
poll_url = mock_get.call_args[0][0]
assert "/jobs/00000000-0000-0000-0000-000000000abc" in poll_url
# URL was materialised once
mock_save.assert_called_once()
def test_large_model_routes_to_large_endpoint(self, monkeypatch):
monkeypatch.setenv("KREA_IMAGE_MODEL", "krea-2-large")
from plugins.image_gen.krea import KreaImageGenProvider
submit = _submit_response()
poll = _poll_response(_completed_job())
with patch("plugins.image_gen.krea.requests.post", return_value=submit) as mock_post, \
patch("plugins.image_gen.krea.requests.get", return_value=poll), \
patch(
"plugins.image_gen.krea.save_url_image",
return_value=Path("/tmp/x.png"),
), \
patch("plugins.image_gen.krea.time.sleep"):
KreaImageGenProvider().generate(prompt="test")
post_url = mock_post.call_args[0][0]
assert post_url.endswith("/generate/image/krea/krea-2/large")
def test_aspect_ratio_mapping(self):
"""Hermes 'square' must map to Krea '1:1' in the wire payload."""
from plugins.image_gen.krea import KreaImageGenProvider
submit = _submit_response()
poll = _poll_response(_completed_job())
with patch("plugins.image_gen.krea.requests.post", return_value=submit) as mock_post, \
patch("plugins.image_gen.krea.requests.get", return_value=poll), \
patch(
"plugins.image_gen.krea.save_url_image",
return_value=Path("/tmp/x.png"),
), \
patch("plugins.image_gen.krea.time.sleep"):
KreaImageGenProvider().generate(prompt="test", aspect_ratio="square", upscale=False)
payload = mock_post.call_args.kwargs["json"]
assert payload["aspect_ratio"] == "1:1"
assert payload["resolution"] == "1K"
def test_auth_header(self):
from plugins.image_gen.krea import KreaImageGenProvider
submit = _submit_response()
poll = _poll_response(_completed_job())
with patch("plugins.image_gen.krea.requests.post", return_value=submit) as mock_post, \
patch("plugins.image_gen.krea.requests.get", return_value=poll), \
patch(
"plugins.image_gen.krea.save_url_image",
return_value=Path("/tmp/x.png"),
), \
patch("plugins.image_gen.krea.time.sleep"):
KreaImageGenProvider().generate(prompt="test")
headers = mock_post.call_args.kwargs["headers"]
assert headers["Authorization"] == "Bearer test-key-12345"
assert headers["Content-Type"] == "application/json"
def test_passthrough_seed_styles_moodboards(self):
from plugins.image_gen.krea import KreaImageGenProvider
submit = _submit_response()
poll = _poll_response(_completed_job())
with patch("plugins.image_gen.krea.requests.post", return_value=submit) as mock_post, \
patch("plugins.image_gen.krea.requests.get", return_value=poll), \
patch(
"plugins.image_gen.krea.save_url_image",
return_value=Path("/tmp/x.png"),
), \
patch("plugins.image_gen.krea.time.sleep"):
KreaImageGenProvider().generate(
prompt="test",
seed=42,
styles=[{"id": "lora-1", "strength": 0.7}],
moodboards=[{"url": "https://x.com/mood.png"}, {"url": "https://x.com/mood2.png"}],
image_style_references=[{"url": f"https://x.com/{i}.png"} for i in range(15)],
creativity="high",
upscale=False,
)
payload = mock_post.call_args.kwargs["json"]
assert payload["seed"] == 42
assert payload["styles"] == [{"id": "lora-1", "strength": 0.7}]
assert len(payload["moodboards"]) == 1 # capped at 1
assert len(payload["image_style_references"]) == 10 # capped at 10
assert payload["creativity"] == "high"
def test_string_style_references_converted_to_objects(self):
"""Krea requires {url, strength} objects; bare URL strings must be
converted (a string yields a 422 'Expected object, received string')."""
from plugins.image_gen.krea import KreaImageGenProvider
submit = _submit_response()
poll = _poll_response(_completed_job())
with patch("plugins.image_gen.krea.requests.post", return_value=submit) as mock_post, \
patch("plugins.image_gen.krea.requests.get", return_value=poll), \
patch(
"plugins.image_gen.krea.save_url_image",
return_value=Path("/tmp/x.png"),
), \
patch("plugins.image_gen.krea.time.sleep"):
KreaImageGenProvider().generate(
prompt="test",
image_style_references=[
"https://x.com/a.png",
{"url": "https://x.com/b.png", "strength": 1.2},
],
upscale=False,
)
payload = mock_post.call_args.kwargs["json"]
assert payload["image_style_references"] == [
{"url": "https://x.com/a.png", "strength": 0.6},
{"url": "https://x.com/b.png", "strength": 1.2},
]
def test_unknown_kwargs_ignored(self):
"""Forward-compat: unknown kwargs must not break generate()."""
from plugins.image_gen.krea import KreaImageGenProvider
submit = _submit_response()
poll = _poll_response(_completed_job())
with patch("plugins.image_gen.krea.requests.post", return_value=submit), \
patch("plugins.image_gen.krea.requests.get", return_value=poll), \
patch(
"plugins.image_gen.krea.save_url_image",
return_value=Path("/tmp/x.png"),
), \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(
prompt="test",
fictional_param="should be ignored",
num_images=4,
)
assert result["success"] is True
# ---------------------------------------------------------------------------
# Generate — error paths
# ---------------------------------------------------------------------------
class TestGenerateErrors:
def test_submit_http_error(self):
import requests as req_lib
from plugins.image_gen.krea import KreaImageGenProvider
resp = req_lib.Response()
resp.status_code = 401
resp._content = b'{"error": {"message": "Invalid API key"}}'
resp.headers["Content-Type"] = "application/json"
resp.raise_for_status = MagicMock(
side_effect=req_lib.HTTPError(response=resp)
)
with patch("plugins.image_gen.krea.requests.post", return_value=resp):
result = KreaImageGenProvider().generate(prompt="test")
assert result["success"] is False
assert result["error_type"] == "api_error"
assert "401" in result["error"]
assert "Invalid API key" in result["error"]
def test_job_failed(self):
from plugins.image_gen.krea import KreaImageGenProvider
failed = {
"job_id": "abc",
"status": "failed",
"completed_at": "2026-05-27T00:01:00Z",
"result": {"error": "NSFW content"},
}
submit = _submit_response()
with patch("plugins.image_gen.krea.requests.post", return_value=submit), \
patch(
"plugins.image_gen.krea.requests.get",
return_value=_poll_response(failed),
), \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(prompt="test")
assert result["success"] is False
assert result["error_type"] == "api_error"
assert "NSFW" in result["error"]
def test_completed_but_missing_urls(self):
from plugins.image_gen.krea import KreaImageGenProvider
completed_empty = {
"job_id": "abc",
"status": "completed",
"completed_at": "2026-05-27T00:01:00Z",
"result": {"urls": []},
}
with patch("plugins.image_gen.krea.requests.post", return_value=_submit_response()), \
patch(
"plugins.image_gen.krea.requests.get",
return_value=_poll_response(completed_empty),
), \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(prompt="test")
assert result["success"] is False
assert result["error_type"] == "empty_response"
def test_url_download_failure_falls_back_to_bare_url(self):
"""Mirror of xAI behaviour — if local cache fails, return the URL."""
import requests as req_lib
from plugins.image_gen.krea import KreaImageGenProvider
url = "https://krea.cdn/expired-soon.png"
submit = _submit_response()
poll = _poll_response(_completed_job(url))
with patch("plugins.image_gen.krea.requests.post", return_value=submit), \
patch("plugins.image_gen.krea.requests.get", return_value=poll), \
patch(
"plugins.image_gen.krea.save_url_image",
side_effect=req_lib.HTTPError("404"),
), \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(prompt="test")
assert result["success"] is True
assert result["image"] == url
def test_polling_picks_up_completed_at_with_unknown_status(self):
"""``completed_at`` set + unrecognised pending status → still terminal."""
from plugins.image_gen.krea import KreaImageGenProvider
# Use a status value that is NOT in our terminal set ("intermediate-complete")
# but with completed_at populated — Krea's spec says completed_at is the
# canonical terminal marker.
oddball = {
"job_id": "abc",
"status": "intermediate-complete",
"completed_at": "2026-05-27T00:01:00Z",
"result": {"urls": ["https://krea.cdn/done.png"]},
}
with patch("plugins.image_gen.krea.requests.post", return_value=_submit_response()), \
patch(
"plugins.image_gen.krea.requests.get",
return_value=_poll_response(oddball),
), \
patch(
"plugins.image_gen.krea.save_url_image",
return_value=Path("/tmp/x.png"),
), \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(prompt="test")
assert result["success"] is True
class TestPollRetryPolicy:
"""Polling fail-fast on permanent 4xx, retry on transient 5xx/429."""
def _http_error_response(self, status: int):
import requests as req_lib
resp = req_lib.Response()
resp.status_code = status
resp._content = b'{"error": "boom"}'
resp.headers["Content-Type"] = "application/json"
resp.raise_for_status = MagicMock(
side_effect=req_lib.HTTPError(response=resp)
)
return resp
def test_poll_fails_fast_on_401(self):
"""Auth failure mid-poll should not wait the 180s deadline."""
from plugins.image_gen.krea import KreaImageGenProvider
bad_poll = self._http_error_response(401)
with patch("plugins.image_gen.krea.requests.post", return_value=_submit_response()), \
patch("plugins.image_gen.krea.requests.get", return_value=bad_poll) as mock_get, \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(prompt="test")
assert result["success"] is False
assert result["error_type"] == "api_error"
assert "401" in result["error"]
# One call — no retry on permanent auth failure.
assert mock_get.call_count == 1
# ---------------------------------------------------------------------------
# Managed Nous gateway path
# ---------------------------------------------------------------------------
def _managed_cfg(
origin: str = "https://krea-gateway.example.com",
token: str = "nous-tok-abc",
):
from types import SimpleNamespace
return SimpleNamespace(
vendor="krea",
gateway_origin=origin,
nous_user_token=token,
managed_mode=True,
)
class TestManagedGateway:
def test_managed_submit_uses_gateway_origin_and_nous_token(self, monkeypatch):
"""Managed mode submits to the gateway origin with the Nous token."""
import plugins.image_gen.krea as krea_mod
from plugins.image_gen.krea import KreaImageGenProvider
# Even with a direct key present, an active managed gateway wins.
monkeypatch.setattr(krea_mod, "_resolve_managed_krea_gateway", lambda: _managed_cfg())
submit = _submit_response()
poll = _poll_response(_completed_job())
with patch("plugins.image_gen.krea.requests.post", return_value=submit) as mock_post, \
patch("plugins.image_gen.krea.requests.get", return_value=poll) as mock_get, \
patch(
"plugins.image_gen.krea.save_url_image",
return_value=Path("/tmp/x.png"),
), \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(prompt="A managed lamp", upscale=False)
assert result["success"] is True
post_url = mock_post.call_args[0][0]
assert post_url == (
"https://krea-gateway.example.com/generate/image/krea/krea-2/medium"
)
headers = mock_post.call_args.kwargs["headers"]
assert headers["Authorization"] == "Bearer nous-tok-abc"
# Idempotency key drives the gateway's per-generation billing boundary.
assert headers["x-idempotency-key"]
# Poll is bound to the same gateway + Nous token.
poll_url = mock_get.call_args[0][0]
assert poll_url.startswith("https://krea-gateway.example.com/jobs/")
poll_headers = mock_get.call_args.kwargs["headers"]
assert poll_headers["Authorization"] == "Bearer nous-tok-abc"
def test_managed_429_concurrency_hint(self, monkeypatch):
import requests as req_lib
import plugins.image_gen.krea as krea_mod
from plugins.image_gen.krea import KreaImageGenProvider
monkeypatch.setattr(krea_mod, "_resolve_managed_krea_gateway", lambda: _managed_cfg())
resp = req_lib.Response()
resp.status_code = 429
resp._content = b'{"error": {"message": "maximum number of concurrent jobs"}}'
resp.headers["Content-Type"] = "application/json"
resp.raise_for_status = MagicMock(side_effect=req_lib.HTTPError(response=resp))
with patch("plugins.image_gen.krea.requests.post", return_value=resp):
result = KreaImageGenProvider().generate(prompt="test")
assert result["success"] is False
assert "429" in result["error"]
assert "concurrency" in result["error"].lower()
class TestExplicitModelOverride:
def test_model_kwarg_overrides_config(self, monkeypatch):
"""An explicit ``model`` kwarg (managed routing) wins over config/default."""
from plugins.image_gen.krea import _resolve_model
model_id, meta = _resolve_model("krea-2-large")
assert model_id == "krea-2-large"
assert meta["path"] == "large"
def test_turbo_routes_to_medium_turbo_endpoint(self):
from plugins.image_gen.krea import KreaImageGenProvider
submit = _submit_response()
poll = _poll_response(_completed_job())
with patch("plugins.image_gen.krea.requests.post", return_value=submit) as mock_post, \
patch("plugins.image_gen.krea.requests.get", return_value=poll), \
patch(
"plugins.image_gen.krea.save_url_image",
return_value=Path("/tmp/x.png"),
), \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(prompt="test", model="krea-2-medium-turbo", upscale=False)
assert result["success"] is True
assert result["model"] == "krea-2-medium-turbo"
post_url = mock_post.call_args[0][0]
assert post_url.endswith("/generate/image/krea/krea-2/medium-turbo")
# ---------------------------------------------------------------------------
# Upscale pass (Krea Enhance)
# ---------------------------------------------------------------------------
class TestUpscalePass:
def _run_generate(self, *, upscale, enhance_job, model=None):
"""Drive generate() with sequenced post/get mocks.
Sequence: generation submit POST → generation poll GET; then (when
upscale fires) enhance submit POST → enhance poll GET.
"""
from plugins.image_gen.krea import KreaImageGenProvider
gen_submit = _submit_response()
gen_poll = _poll_response(_completed_job("https://krea.cdn/native.png"))
enh_submit = _submit_response("00000000-0000-0000-0000-00000000e0e0")
enh_poll = _poll_response(enhance_job) if enhance_job else None
posts = [gen_submit, enh_submit]
gets = [gen_poll] + ([enh_poll] if enh_poll else [])
kwargs = {"prompt": "a lamp", "upscale": upscale}
if model is not None:
kwargs["model"] = model
with patch("plugins.image_gen.krea.requests.post", side_effect=posts) as mock_post, \
patch("plugins.image_gen.krea.requests.get", side_effect=gets) as mock_get, \
patch(
"plugins.image_gen.krea.save_url_image",
side_effect=lambda url, prefix: Path(f"/tmp/{url.rsplit('/', 1)[-1]}"),
), \
patch("plugins.image_gen.krea.time.sleep"):
result = KreaImageGenProvider().generate(**kwargs)
return result, mock_post, mock_get
def test_upscale_routes_through_enhance_endpoint(self):
enhance_job = {
"job_id": "00000000-0000-0000-0000-00000000e0e0",
"status": "completed",
"created_at": "2026-05-27T00:00:00Z",
"completed_at": "2026-05-27T00:01:00Z",
"result": {"urls": ["https://krea.cdn/enhanced.png"]},
}
result, mock_post, _ = self._run_generate(upscale=True, enhance_job=enhance_job)
assert result["success"] is True
assert result["upscaled"] is True
assert result["upscale_factor"] == 2
assert result["image"].endswith("enhanced.png")
# Second POST hit the Enhance endpoint with the native image + factor.
assert mock_post.call_count == 2
enh_url = mock_post.call_args_list[1][0][0]
assert enh_url.endswith("/generate/enhance/krea/enhance")
enh_payload = mock_post.call_args_list[1].kwargs["json"]
assert enh_payload["image_url"] == "https://krea.cdn/native.png"
assert enh_payload["image_scaling_factor"] == 2
assert enh_payload["prompt"] == "a lamp"
def test_upscale_failure_falls_back_to_native(self):
failed_job = {
"job_id": "00000000-0000-0000-0000-00000000e0e0",
"status": "failed",
"created_at": "2026-05-27T00:00:00Z",
"completed_at": "2026-05-27T00:01:00Z",
"result": None,
}
result, mock_post, _ = self._run_generate(upscale=True, enhance_job=failed_job)
assert result["success"] is True
assert result["upscaled"] is False
assert result["image"].endswith("native.png")
assert mock_post.call_count == 2 # enhance attempted, fell back
def test_medium_skips_upscale_by_default(self):
"""Upscaling is opt-in only (Aug 2026 policy) — even for
krea-2-medium's 1.5K native output, no automatic Enhance pass."""
result, mock_post, _ = self._run_generate(upscale=None, enhance_job=None)
assert result["success"] is True
assert result["upscaled"] is False
assert result["image"].endswith("native.png")
assert mock_post.call_count == 1 # only the generation submit
def test_large_skips_upscale_by_default(self):
"""krea-2-large: no automatic Enhance pass either."""
result, mock_post, _ = self._run_generate(
upscale=None, enhance_job=None, model="krea-2-large",
)
assert result["success"] is True
assert result["upscaled"] is False
assert result["image"].endswith("native.png")
assert mock_post.call_count == 1 # only the generation submit
def test_explicit_false_disables_default(self):
"""Explicit upscale=False matches the off default."""
result, mock_post, _ = self._run_generate(upscale=False, enhance_job=None)
assert result["success"] is True
assert result["upscaled"] is False
assert result["image"].endswith("native.png")
assert mock_post.call_count == 1
# ---------------------------------------------------------------------------
# Registration
# ---------------------------------------------------------------------------
class TestRegistration:
def test_register(self):
from plugins.image_gen.krea import KreaImageGenProvider, register
mock_ctx = MagicMock()
register(mock_ctx)
mock_ctx.register_image_gen_provider.assert_called_once()
provider = mock_ctx.register_image_gen_provider.call_args[0][0]
assert isinstance(provider, KreaImageGenProvider)
assert provider.name == "krea"
@@ -0,0 +1,316 @@
"""Tests for the bundled Meta Model API image_gen plugin (muse-image)."""
from __future__ import annotations
import importlib
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
# The plugin directory uses a hyphen, which is not a valid Python identifier
# for the dotted-import form. Load it via importlib so tests don't need to
# touch sys.path or rename the directory.
meta_plugin = importlib.import_module("plugins.image_gen.meta-ai")
# 1×1 transparent PNG — valid bytes for save_b64_image()
_PNG_HEX = (
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4"
"890000000d49444154789c6300010000000500010d0a2db40000000049454e44"
"ae426082"
)
def _b64_png() -> str:
import base64
return base64.b64encode(bytes.fromhex(_PNG_HEX)).decode()
def _fake_response(*, b64=None, url=None, revised_prompt=None):
item = SimpleNamespace(b64_json=b64, url=url, revised_prompt=revised_prompt)
return SimpleNamespace(data=[item])
@pytest.fixture(autouse=True)
def _tmp_hermes_home(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
# Clear every auth + override env var so tests start from a clean slate.
for env in (
"MODEL_API_KEY",
"META_API_KEY",
"META_MODEL_API_KEY",
"META_BASE_URL",
"META_IMAGE_MODEL",
):
monkeypatch.delenv(env, raising=False)
yield tmp_path
@pytest.fixture
def provider(monkeypatch):
monkeypatch.setenv("META_MODEL_API_KEY", "test-key")
return meta_plugin.MetaImageGenProvider()
def _patched_openai(fake_client: MagicMock):
fake_openai = MagicMock()
fake_openai.OpenAI.return_value = fake_client
return patch.dict("sys.modules", {"openai": fake_openai})
# ── Metadata ────────────────────────────────────────────────────────────────
class TestMetadata:
def test_name(self, provider):
assert provider.name == "meta-ai"
def test_display_name(self, provider):
assert provider.display_name == "Meta Model API"
def test_default_model(self, provider):
assert provider.default_model() == "muse-image-1.0"
def test_list_models(self, provider):
ids = [m["id"] for m in provider.list_models()]
assert ids == ["muse-image-1.0"]
def test_catalog_entries_have_display_speed_strengths_price(self, provider):
for entry in provider.list_models():
assert entry["display"]
assert entry["speed"]
assert entry["strengths"]
assert entry["price"]
def test_text_only_capabilities(self, provider):
caps = provider.capabilities()
assert caps["modalities"] == ["text"]
assert caps["max_reference_images"] == 0
# ── Availability ────────────────────────────────────────────────────────────
class TestAvailability:
def test_no_api_key_unavailable(self):
assert meta_plugin.MetaImageGenProvider().is_available() is False
@pytest.mark.parametrize(
"env", ["MODEL_API_KEY", "META_API_KEY", "META_MODEL_API_KEY"]
)
def test_each_auth_alias_makes_available(self, monkeypatch, env):
monkeypatch.setenv(env, "test")
assert meta_plugin.MetaImageGenProvider().is_available() is True
# ── Auth / base-url resolution ────────────────────────────────────────────────
class TestResolution:
def test_api_key_priority_order(self, monkeypatch):
# MODEL_API_KEY wins over the aliases.
monkeypatch.setenv("META_MODEL_API_KEY", "third")
monkeypatch.setenv("META_API_KEY", "second")
monkeypatch.setenv("MODEL_API_KEY", "first")
assert meta_plugin._resolve_api_key() == "first"
def test_default_base_url(self):
assert meta_plugin._resolve_base_url() == "https://api.meta.ai/v1"
def test_base_url_override(self, monkeypatch):
monkeypatch.setenv("META_BASE_URL", "https://proxy.internal/v1")
assert meta_plugin._resolve_base_url() == "https://proxy.internal/v1"
# ── Model resolution ──────────────────────────────────────────────────────────
class TestModelResolution:
def test_default(self):
model_id, _meta = meta_plugin._resolve_model()
assert model_id == "muse-image-1.0"
def test_env_var_override_ignores_unknown(self, monkeypatch):
monkeypatch.setenv("META_IMAGE_MODEL", "not-a-real-model")
model_id, _meta = meta_plugin._resolve_model()
# Unknown id is ignored; falls through to the default.
assert model_id == "muse-image-1.0"
def test_caller_model_kwarg_wins(self, monkeypatch):
# The dispatcher forwards top-level image_gen.model as the `model`
# kwarg; it must beat the env override (#55893 bug class).
monkeypatch.setitem(
meta_plugin._MODELS,
"muse-image-test",
dict(meta_plugin._MODELS["muse-image-1.0"]),
)
monkeypatch.setenv("META_IMAGE_MODEL", "muse-image-1.0")
model_id, _meta = meta_plugin._resolve_model("muse-image-test")
assert model_id == "muse-image-test"
def test_caller_model_unknown_falls_through(self):
model_id, _meta = meta_plugin._resolve_model("not-a-real-model")
assert model_id == "muse-image-1.0"
# ── Generate ──────────────────────────────────────────────────────────────────
class TestGenerate:
def test_model_kwarg_reaches_payload(self, provider, monkeypatch):
monkeypatch.setitem(
meta_plugin._MODELS,
"muse-image-test",
dict(meta_plugin._MODELS["muse-image-1.0"]),
)
fake_client = MagicMock()
fake_client.images.generate.return_value = _fake_response(b64=_b64_png())
with _patched_openai(fake_client):
result = provider.generate("a cat", model="muse-image-test")
assert result["success"] is True
assert (
fake_client.images.generate.call_args.kwargs["model"] == "muse-image-test"
)
def test_badge_is_standard_paid(self, provider):
assert provider.get_setup_schema()["badge"] == "paid"
def test_empty_prompt_rejected(self, provider):
result = provider.generate("", aspect_ratio="square")
assert result["success"] is False
assert result["error_type"] == "invalid_argument"
assert result["provider"] == "meta-ai"
def test_missing_api_key(self):
result = meta_plugin.MetaImageGenProvider().generate("a cat")
assert result["success"] is False
assert result["error_type"] == "auth_required"
def test_b64_saves_to_cache(self, provider, tmp_path):
png_bytes = bytes.fromhex(_PNG_HEX)
fake_client = MagicMock()
fake_client.images.generate.return_value = _fake_response(b64=_b64_png())
with _patched_openai(fake_client):
result = provider.generate("a cat", aspect_ratio="landscape")
assert result["success"] is True
assert result["model"] == "muse-image-1.0"
assert result["aspect_ratio"] == "landscape"
assert result["provider"] == "meta-ai"
assert result["modality"] == "text"
saved = Path(result["image"])
assert saved.exists()
assert saved.parent == tmp_path / "cache" / "images"
assert saved.read_bytes() == png_bytes
call_kwargs = fake_client.images.generate.call_args.kwargs
assert call_kwargs["model"] == "muse-image-1.0"
assert call_kwargs["size"] == "1536x1024"
assert call_kwargs["n"] == 1
def test_client_uses_meta_base_url(self, provider):
fake_client = MagicMock()
fake_client.images.generate.return_value = _fake_response(b64=_b64_png())
fake_openai = MagicMock()
fake_openai.OpenAI.return_value = fake_client
with patch.dict("sys.modules", {"openai": fake_openai}):
provider.generate("a cat")
assert (
fake_openai.OpenAI.call_args.kwargs["base_url"] == "https://api.meta.ai/v1"
)
def test_base_url_override_reaches_client(self, provider, monkeypatch):
monkeypatch.setenv("META_BASE_URL", "https://proxy.internal/v1")
fake_client = MagicMock()
fake_client.images.generate.return_value = _fake_response(b64=_b64_png())
fake_openai = MagicMock()
fake_openai.OpenAI.return_value = fake_client
with patch.dict("sys.modules", {"openai": fake_openai}):
provider.generate("a cat")
assert (
fake_openai.OpenAI.call_args.kwargs["base_url"]
== "https://proxy.internal/v1"
)
@pytest.mark.parametrize(
"aspect,expected_size",
[
("landscape", "1536x1024"),
("square", "1024x1024"),
("portrait", "1024x1536"),
],
)
def test_aspect_ratio_mapping(self, provider, aspect, expected_size):
fake_client = MagicMock()
fake_client.images.generate.return_value = _fake_response(b64=_b64_png())
with _patched_openai(fake_client):
provider.generate("a cat", aspect_ratio=aspect)
assert fake_client.images.generate.call_args.kwargs["size"] == expected_size
def test_revised_prompt_passed_through(self, provider):
fake_client = MagicMock()
fake_client.images.generate.return_value = _fake_response(
b64=_b64_png(),
revised_prompt="A photo of a cat",
)
with _patched_openai(fake_client):
result = provider.generate("a cat")
assert result["revised_prompt"] == "A photo of a cat"
def test_url_response_is_cached_locally(self, provider):
"""A URL response is materialized locally (symmetric to the openai/xai
providers) so ephemeral signed URLs can't expire mid-flight."""
fake_client = MagicMock()
fake_client.images.generate.return_value = _fake_response(
b64=None,
url="https://example.com/img.webp",
)
with (
_patched_openai(fake_client),
patch.object(
meta_plugin,
"save_url_image",
return_value=Path("/tmp/meta_20260524_000000_deadbeef.webp"),
) as mock_save_url,
):
result = provider.generate("a cat")
assert result["success"] is True
assert result["image"].startswith("/")
assert "example.com" not in result["image"]
mock_save_url.assert_called_once()
def test_empty_response_errors(self, provider):
fake_client = MagicMock()
fake_client.images.generate.return_value = _fake_response(b64=None, url=None)
with _patched_openai(fake_client):
result = provider.generate("a cat")
assert result["success"] is False
assert result["error_type"] == "empty_response"
def test_api_error_surfaced(self, provider):
fake_client = MagicMock()
fake_client.images.generate.side_effect = RuntimeError("boom")
with _patched_openai(fake_client):
result = provider.generate("a cat")
assert result["success"] is False
assert result["error_type"] == "api_error"
assert "boom" in result["error"]
@@ -0,0 +1,467 @@
"""Tests for the bundled ``openai-codex`` image_gen plugin.
Mirrors ``test_openai_provider.py`` but targets the standalone
Codex/ChatGPT-OAuth-backed provider that uses the Responses
``image_generation`` tool path instead of the ``images.generate`` REST
endpoint.
"""
from __future__ import annotations
import importlib
import json
from pathlib import Path
import pytest
# The plugin directory uses a hyphen, which is not a valid Python identifier
# for the dotted-import form. Load it via importlib so tests don't need to
# touch sys.path or rename the directory.
codex_plugin = importlib.import_module("plugins.image_gen.openai-codex")
# 1×1 transparent PNG — valid bytes for save_b64_image()
_PNG_HEX = (
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4"
"890000000d49444154789c6300010000000500010d0a2db40000000049454e44"
"ae426082"
)
def _b64_png() -> str:
import base64
return base64.b64encode(bytes.fromhex(_PNG_HEX)).decode()
@pytest.fixture(autouse=True)
def _tmp_hermes_home(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
yield tmp_path
@pytest.fixture
def provider(monkeypatch):
# Codex plugin is API-key-independent; clear it to make the test honest.
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
return codex_plugin.OpenAICodexImageGenProvider()
# ── Metadata ────────────────────────────────────────────────────────────────
class TestMetadata:
def test_name(self, provider):
assert provider.name == "openai-codex"
def test_display_name(self, provider):
assert provider.display_name == "OpenAI (Codex auth)"
def test_default_model(self, provider):
assert provider.default_model() == "gpt-image-2-medium"
def test_list_models_three_tiers(self, provider):
ids = [m["id"] for m in provider.list_models()]
assert ids == ["gpt-image-2-low", "gpt-image-2-medium", "gpt-image-2-high"]
def test_setup_schema_has_no_required_env_vars(self, provider):
schema = provider.get_setup_schema()
assert schema["env_vars"] == []
assert schema["badge"] == "free"
# ── Availability ────────────────────────────────────────────────────────────
class TestAvailability:
def test_unavailable_without_codex_token(self, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: None)
assert codex_plugin.OpenAICodexImageGenProvider().is_available() is False
def test_available_with_codex_token(self, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
assert codex_plugin.OpenAICodexImageGenProvider().is_available() is True
def test_openai_api_key_alone_is_not_enough(self, monkeypatch):
# Codex plugin is intentionally orthogonal to the API-key plugin —
# the API key alone must NOT make it appear available.
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: None)
assert codex_plugin.OpenAICodexImageGenProvider().is_available() is False
# ── Generate ────────────────────────────────────────────────────────────────
class TestGenerate:
def test_returns_auth_error_without_codex_token(self, provider, monkeypatch):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: None)
result = provider.generate("a cat")
assert result["success"] is False
assert result["error_type"] == "auth_required"
def test_generate_uses_codex_stream_path(self, provider, monkeypatch, tmp_path):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
monkeypatch.setattr(codex_plugin, "_collect_image_b64", lambda *a, **kw: {"b64": _b64_png(), "source": "final"})
result = provider.generate("a cat", aspect_ratio="landscape")
assert result["success"] is True
assert result["model"] == "gpt-image-2-medium"
assert result["provider"] == "openai-codex"
assert result["quality"] == "medium"
assert result.get("image_source") == "final"
assert result.get("pixel_size") == "1x1"
saved = Path(result["image"])
assert saved.exists()
assert saved.parent == tmp_path / "cache" / "images"
# Filename prefix differs from the API-key plugin so cache audits can
# tell the two backends apart.
assert saved.name.startswith("openai_codex_")
def test_codex_stream_request_shape(self, provider, monkeypatch):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
captured = {}
def _collect(token, *, prompt, size, quality, input_images=None):
captured.update(codex_plugin._build_responses_payload(
prompt=prompt,
size=size,
quality=quality,
input_images=input_images,
))
return {"b64": _b64_png(), "source": "final"}
monkeypatch.setattr(codex_plugin, "_collect_image_b64", _collect)
result = provider.generate("a cat", aspect_ratio="portrait")
assert result["success"] is True
assert captured["model"] == "gpt-5.5"
assert captured["store"] is False
assert captured["input"][0]["type"] == "message"
assert captured["input"][0]["role"] == "user"
assert captured["input"][0]["content"][0]["type"] == "input_text"
# Regression for #19505: the Codex backend 400s on every tool_choice
# shape we have for the hosted ``image_generation`` tool, so the
# provider must omit tool_choice entirely and rely on instructions.
assert "tool_choice" not in captured
tool = captured["tools"][0]
assert tool["type"] == "image_generation"
assert tool["model"] == "gpt-image-2"
assert tool["quality"] == "medium"
assert tool["size"] == "1024x1536"
assert tool["output_format"] == "png"
assert tool["background"] == "opaque"
# Progressive previews disabled: partial frames were being saved as
# finals and presented as smeared/unfinished images.
assert tool["partial_images"] == 0
def test_capabilities_advertise_image_inputs(self, provider):
caps = provider.capabilities()
assert caps["modalities"] == ["text", "image"]
assert caps["max_reference_images"] == 16
def test_rejects_non_image_local_source(self, provider, monkeypatch, tmp_path):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
text_path = tmp_path / "not-image.txt"
text_path.write_text("hello")
result = provider.generate("edit this", image_url=str(text_path))
assert result["success"] is False
assert result["error_type"] == "invalid_image_input"
assert "not a supported image" in result["error"]
def test_partial_image_event_used_when_done_missing(self):
"""Extractor may surface partial b64 when no final exists (fallback only)."""
payload = {
"type": "response.image_generation_call.partial_image",
"partial_image_b64": _b64_png(),
}
assert codex_plugin._extract_image_b64(payload) == _b64_png()
result, partial = codex_plugin._extract_image_candidates(payload)
assert result is None
assert partial == _b64_png()
def test_final_result_wins_over_coexisting_partial_in_same_payload(self):
"""Blind spot that shipped the smear bug: both fields in one payload.
partial_image_b64 must never overwrite image_generation_call.result
when they coexist in the same event tree.
"""
final = _b64_png()
# Distinct non-empty stand-in so equality proves which field won.
partial = "cGFydGlhbC1vbmx5LW5vdC1hLXJlYWwtZmluYWw="
payload = {
"type": "response.output_item.done",
"item": {
"type": "image_generation_call",
"status": "completed",
"result": final,
"partial_image_b64": partial,
},
}
assert codex_plugin._extract_image_b64(payload) == final
result, got_partial = codex_plugin._extract_image_candidates(payload)
assert result == final
assert got_partial == partial
def test_nested_final_wins_over_sibling_partial(self):
payload = {
"type": "response.completed",
"response": {
"output": [{
"type": "image_generation_call",
"status": "completed",
"result": _b64_png(),
}],
},
"partial_image_b64": "cGFydGlhbC1zaWJsaW5n",
}
assert codex_plugin._extract_image_b64(payload) == _b64_png()
def test_sse_parser_handles_event_and_data_lines(self):
class _Response:
def iter_lines(self):
return iter([
"event: response.output_item.done",
'data: {"item": {"type": "image_generation_call", "result": "abc"}}',
"",
])
events = list(codex_plugin._iter_sse_json(_Response()))
assert events == [{
"type": "response.output_item.done",
"item": {"type": "image_generation_call", "result": "abc"},
}]
def test_final_response_sweep_recovers_image(self):
"""Completed response output is found by recursive payload scanning."""
payload = {
"type": "response.completed",
"response": {
"output": [{
"type": "image_generation_call",
"status": "completed",
"id": "ig_final",
"result": _b64_png(),
}],
},
}
assert codex_plugin._extract_image_b64(payload) == _b64_png()
def test_partial_only_stream_fails_closed_after_retry(self, provider, monkeypatch):
"""Partial-only streams must not return success:true with a smear frame."""
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
calls = {"n": 0}
def _partial_only(*args, **kwargs):
calls["n"] += 1
return {"b64": _b64_png(), "source": "partial"}
monkeypatch.setattr(codex_plugin, "_collect_image_b64", _partial_only)
result = provider.generate("a cat")
assert result["success"] is False
assert result["error_type"] == "incomplete_image"
assert "partial" in result["error"].lower()
# One initial attempt + one content-agnostic retry.
assert calls["n"] == codex_plugin._NONFINAL_RETRIES + 1
def test_empty_stream_retries_then_fails(self, provider, monkeypatch):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
calls = {"n": 0}
def _empty(*args, **kwargs):
calls["n"] += 1
return None
monkeypatch.setattr(codex_plugin, "_collect_image_b64", _empty)
result = provider.generate("a cat")
assert result["success"] is False
assert result["error_type"] == "empty_response"
assert calls["n"] == codex_plugin._NONFINAL_RETRIES + 1
def test_partial_then_final_on_retry_succeeds(self, provider, monkeypatch):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
calls = {"n": 0}
def _then_final(*args, **kwargs):
calls["n"] += 1
if calls["n"] == 1:
return {"b64": _b64_png(), "source": "partial"}
return {"b64": _b64_png(), "source": "final"}
monkeypatch.setattr(codex_plugin, "_collect_image_b64", _then_final)
result = provider.generate("a cat")
assert result["success"] is True
assert result.get("image_source") == "final"
assert calls["n"] == 2
def test_empty_then_final_on_retry_succeeds(self, provider, monkeypatch):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
calls = {"n": 0}
def _then_final(*args, **kwargs):
calls["n"] += 1
if calls["n"] == 1:
return None
return {"b64": _b64_png(), "source": "final"}
monkeypatch.setattr(codex_plugin, "_collect_image_b64", _then_final)
result = provider.generate("a cat")
assert result["success"] is True
assert result.get("image_source") == "final"
assert calls["n"] == 2
def test_empty_response_returns_error(self, provider, monkeypatch):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
monkeypatch.setattr(codex_plugin, "_NONFINAL_RETRIES", 0)
monkeypatch.setattr(codex_plugin, "_collect_image_b64", lambda *a, **kw: None)
result = provider.generate("a cat")
assert result["success"] is False
assert result["error_type"] == "empty_response"
def test_stream_exception_returns_api_error(self, provider, monkeypatch):
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
def _boom(*args, **kwargs):
raise RuntimeError("cloudflare 403")
monkeypatch.setattr(codex_plugin, "_collect_image_b64", _boom)
result = provider.generate("a cat")
assert result["success"] is False
assert result["error_type"] == "api_error"
assert "cloudflare 403" in result["error"]
def test_tool_choice_400_surfaces_verbatim_not_as_capability_error(
self, provider, monkeypatch
):
"""The tool_choice 400 must NOT be reported as an account limitation.
Regression for #19505 / #49008 / #31335: a previous version classified
this exact request-shape rejection as "Image generation is not enabled
for the current Codex account", telling every affected user to abandon
Codex over a bug in our own payload. The wire error must reach the user
unedited so it stays diagnosable.
Drives the REAL httpx boundary (not a mocked ``_collect_image_b64``) so
the classification path is actually exercised — mocking the collector
would skip the code under test entirely.
"""
import httpx
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
body = json.dumps({
"error": {
"message": "Tool choice 'image_generation' not found in 'tools' parameter.",
"type": "invalid_request_error",
"param": "tool_choice",
}
})
def _handler(request):
return httpx.Response(400, text=body, request=request)
real_client = httpx.Client
monkeypatch.setattr(
httpx,
"Client",
lambda *args, **kwargs: real_client(
transport=httpx.MockTransport(_handler),
headers=kwargs.get("headers"),
timeout=kwargs.get("timeout"),
),
)
result = provider.generate("a cat")
assert result["success"] is False
assert result["error_type"] == "api_error"
assert "HTTP 400" in result["error"]
assert "tools' parameter" in result["error"]
# The account-entitlement misdiagnosis must not come back.
assert "not enabled for the current Codex account" not in result["error"]
assert result["error_type"] != "capability_unsupported"
class TestRequestShape:
def test_payload_omits_tool_choice(self):
"""Codex rejects every tool_choice shape for hosted image_generation."""
payload = codex_plugin._build_responses_payload(
prompt="a red circle",
size="1024x1024",
quality="low",
)
assert "tool_choice" not in payload
# The hosted tool itself is still requested, and instructions do the steering.
assert payload["tools"][0]["type"] == "image_generation"
assert payload["instructions"]
def test_http_error_body_is_truncated_but_preserved(self, monkeypatch):
"""A large error body is capped at 500 chars and still surfaced."""
import httpx
body = json.dumps({
"metadata": "x" * 600,
"error": {
"message": "Tool choice 'image_generation' not found in 'tools' parameter."
},
})
def _handler(request):
return httpx.Response(400, text=body, request=request)
real_client = httpx.Client
monkeypatch.setattr(
httpx,
"Client",
lambda *args, **kwargs: real_client(
transport=httpx.MockTransport(_handler),
headers=kwargs.get("headers"),
timeout=kwargs.get("timeout"),
),
)
with pytest.raises(RuntimeError, match="HTTP 400") as excinfo:
codex_plugin._collect_image_b64(
"codex-token",
prompt="a cat",
size="1024x1024",
quality="low",
)
message = str(excinfo.value)
# Body is capped, but the actionable wire message still reaches the user.
assert "tools' parameter" in message
assert len(message) < len(body)
# ── Plugin entry point ──────────────────────────────────────────────────────
class TestRegistration:
def test_register_calls_register_image_gen_provider(self):
registered = []
class _Ctx:
def register_image_gen_provider(self, prov):
registered.append(prov)
codex_plugin.register(_Ctx())
assert len(registered) == 1
assert registered[0].name == "openai-codex"
@@ -0,0 +1,242 @@
"""Tests for the bundled OpenAI image_gen plugin (gpt-image-2, three tiers)."""
from __future__ import annotations
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
import plugins.image_gen.openai as openai_plugin
# 1×1 transparent PNG — valid bytes for save_b64_image()
_PNG_HEX = (
"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4"
"890000000d49444154789c6300010000000500010d0a2db40000000049454e44"
"ae426082"
)
def _b64_png() -> str:
import base64
return base64.b64encode(bytes.fromhex(_PNG_HEX)).decode()
def _fake_response(*, b64=None, url=None, revised_prompt=None):
item = SimpleNamespace(b64_json=b64, url=url, revised_prompt=revised_prompt)
return SimpleNamespace(data=[item])
@pytest.fixture(autouse=True)
def _tmp_hermes_home(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
yield tmp_path
@pytest.fixture
def provider(monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test-key")
return openai_plugin.OpenAIImageGenProvider()
def _patched_openai(fake_client: MagicMock):
fake_openai = MagicMock()
fake_openai.OpenAI.return_value = fake_client
return patch.dict("sys.modules", {"openai": fake_openai})
# ── Metadata ────────────────────────────────────────────────────────────────
class TestMetadata:
def test_name(self, provider):
assert provider.name == "openai"
def test_default_model(self, provider):
assert provider.default_model() == "gpt-image-2-medium"
def test_list_models_three_tiers(self, provider):
ids = [m["id"] for m in provider.list_models()]
assert ids == ["gpt-image-2-low", "gpt-image-2-medium", "gpt-image-2-high"]
def test_catalog_entries_have_display_speed_strengths(self, provider):
for entry in provider.list_models():
assert entry["display"].startswith("GPT Image 2")
assert entry["speed"]
assert entry["strengths"]
# ── Availability ────────────────────────────────────────────────────────────
class TestAvailability:
def test_no_api_key_unavailable(self, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
assert openai_plugin.OpenAIImageGenProvider().is_available() is False
def test_api_key_set_available(self, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "test")
assert openai_plugin.OpenAIImageGenProvider().is_available() is True
# ── Model resolution ────────────────────────────────────────────────────────
class TestModelResolution:
def test_env_var_override(self, monkeypatch):
monkeypatch.setenv("OPENAI_IMAGE_MODEL", "gpt-image-2-high")
model_id, meta = openai_plugin._resolve_model()
assert model_id == "gpt-image-2-high"
assert meta["quality"] == "high"
def test_config_openai_model(self, tmp_path):
import yaml
(tmp_path / "config.yaml").write_text(
yaml.safe_dump({"image_gen": {"openai": {"model": "gpt-image-2-low"}}})
)
model_id, meta = openai_plugin._resolve_model()
assert model_id == "gpt-image-2-low"
assert meta["quality"] == "low"
# ── Generate ────────────────────────────────────────────────────────────────
class TestSourceImageLoading:
def test_load_image_bytes_blocks_credential_store(self, tmp_path, monkeypatch):
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
auth_json = hermes_home / "auth.json"
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
with pytest.raises(ValueError, match="credential store"):
openai_plugin._load_image_bytes(str(auth_json))
def test_load_image_bytes_allows_legit_local_image(self, tmp_path, monkeypatch):
"""Negative control: a legitimate local image path is NOT blocked and
loads normally — proves the guard doesn't over-fire on everything."""
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
img = tmp_path / "pic.png"
img.write_bytes(b"\x89PNG\r\n\x1a\nfake-image-bytes")
data, name = openai_plugin._load_image_bytes(str(img))
assert data == b"\x89PNG\r\n\x1a\nfake-image-bytes"
assert name == "pic.png"
class TestGenerate:
def test_empty_prompt_rejected(self, provider):
result = provider.generate("", aspect_ratio="square")
assert result["success"] is False
assert result["error_type"] == "invalid_argument"
def test_missing_api_key(self, monkeypatch):
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
result = openai_plugin.OpenAIImageGenProvider().generate("a cat")
assert result["success"] is False
assert result["error_type"] == "auth_required"
def test_b64_saves_to_cache(self, provider, tmp_path):
png_bytes = bytes.fromhex(_PNG_HEX)
fake_client = MagicMock()
fake_client.images.generate.return_value = _fake_response(b64=_b64_png())
with _patched_openai(fake_client):
result = provider.generate("a cat", aspect_ratio="landscape")
assert result["success"] is True
assert result["model"] == "gpt-image-2-medium"
assert result["aspect_ratio"] == "landscape"
assert result["provider"] == "openai"
assert result["quality"] == "medium"
saved = Path(result["image"])
assert saved.exists()
assert saved.parent == tmp_path / "cache" / "images"
assert saved.read_bytes() == png_bytes
call_kwargs = fake_client.images.generate.call_args.kwargs
# All tiers hit the single underlying API model.
assert call_kwargs["model"] == "gpt-image-2"
assert call_kwargs["quality"] == "medium"
assert call_kwargs["size"] == "1536x1024"
# gpt-image-2 rejects response_format — we must NOT send it.
assert "response_format" not in call_kwargs
@pytest.mark.parametrize("tier,expected_quality", [
("gpt-image-2-low", "low"),
("gpt-image-2-medium", "medium"),
("gpt-image-2-high", "high"),
])
def test_tier_maps_to_quality(self, provider, monkeypatch, tier, expected_quality):
monkeypatch.setenv("OPENAI_IMAGE_MODEL", tier)
fake_client = MagicMock()
fake_client.images.generate.return_value = _fake_response(b64=_b64_png())
with _patched_openai(fake_client):
result = provider.generate("a cat")
assert result["model"] == tier
assert result["quality"] == expected_quality
assert fake_client.images.generate.call_args.kwargs["quality"] == expected_quality
# Always the same underlying API model regardless of tier.
assert fake_client.images.generate.call_args.kwargs["model"] == "gpt-image-2"
@pytest.mark.parametrize("aspect,expected_size", [
("landscape", "1536x1024"),
("square", "1024x1024"),
("portrait", "1024x1536"),
])
def test_aspect_ratio_mapping(self, provider, aspect, expected_size):
fake_client = MagicMock()
fake_client.images.generate.return_value = _fake_response(b64=_b64_png())
with _patched_openai(fake_client):
provider.generate("a cat", aspect_ratio=aspect)
assert fake_client.images.generate.call_args.kwargs["size"] == expected_size
def test_revised_prompt_passed_through(self, provider):
fake_client = MagicMock()
fake_client.images.generate.return_value = _fake_response(
b64=_b64_png(), revised_prompt="A photo of a cat",
)
with _patched_openai(fake_client):
result = provider.generate("a cat")
assert result["revised_prompt"] == "A photo of a cat"
def test_url_response_is_cached_locally(self, provider):
"""OpenAI URL response (if API ever returns one) is cached locally.
Pre-fix this asserted the bare URL passed through; symmetric to the
xAI #26942 fix. Even though gpt-image-2 returns b64 today, every
``image_gen`` provider must guarantee the gateway gets a stable
file path so ephemeral signed URLs can't expire mid-flight.
"""
fake_client = MagicMock()
fake_client.images.generate.return_value = _fake_response(
b64=None, url="https://example.com/img.png",
)
with _patched_openai(fake_client), patch(
"plugins.image_gen.openai.save_url_image",
return_value=Path("/tmp/openai_gpt-image-2_20260524_000000_deadbeef.png"),
) as mock_save_url:
result = provider.generate("a cat")
assert result["success"] is True
assert result["image"].startswith("/")
assert "example.com" not in result["image"]
mock_save_url.assert_called_once()
@@ -0,0 +1,787 @@
#!/usr/bin/env python3
"""Tests for the OpenRouter-compatible image gen provider (OpenRouter + Nous)."""
from __future__ import annotations
import base64
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
_RUNTIME = "hermes_cli.runtime_provider.resolve_runtime_provider"
_PNG_DATA_URI = "data:image/png;base64,dGVzdC1pbWFnZS1kYXRh" # "test-image-data"
def _runtime_ok(**over):
base = {
"provider": "openrouter",
"api_mode": "chat_completions",
"base_url": "https://openrouter.ai/api/v1",
"api_key": "sk-or-test",
"source": "env",
}
base.update(over)
return base
def _mock_chat_response(images):
resp = MagicMock()
resp.status_code = 200
resp.raise_for_status = MagicMock()
resp.json.return_value = {
"choices": [
{
"message": {
"role": "assistant",
"content": "",
"images": [
{"type": "image_url", "image_url": {"url": u}} for u in images
],
}
}
]
}
return resp
def _openrouter():
from plugins.image_gen.openrouter import OpenRouterCompatImageProvider
return OpenRouterCompatImageProvider(
provider_name="openrouter",
display_name="OpenRouter",
runtime_name="openrouter",
config_key="openrouter",
model_env_var="OPENROUTER_IMAGE_MODEL",
setup_schema={"name": "OpenRouter (image)", "badge": "paid", "env_vars": []},
)
# ---------------------------------------------------------------------------
# Provider class
# ---------------------------------------------------------------------------
class TestProviderClass:
def test_names(self):
from plugins.image_gen.openrouter import _build_providers
names = {p.name for p in _build_providers()}
assert names == {"openrouter", "nous"}
def test_display_names(self):
from plugins.image_gen.openrouter import _build_providers
by_name = {p.name: p for p in _build_providers()}
assert by_name["openrouter"].display_name == "OpenRouter"
assert by_name["nous"].display_name == "Nous Portal"
def test_capabilities_support_image_input(self):
caps = _openrouter().capabilities()
assert "image" in caps["modalities"]
assert caps["max_reference_images"] >= 1
def test_is_available_with_key(self):
with patch(_RUNTIME, return_value=_runtime_ok()):
assert _openrouter().is_available() is True
def test_default_model(self):
from plugins.image_gen.openrouter import DEFAULT_MODEL
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value={}):
assert _openrouter().default_model() == DEFAULT_MODEL
# Default must be an image-output model id (provider/model form).
assert "/" in DEFAULT_MODEL and "image" in DEFAULT_MODEL
def test_default_model_ignores_runtime_overrides(self, monkeypatch):
"""Catalog defaults must not inherit another provider's saved model."""
from plugins.image_gen.openrouter import DEFAULT_MODEL
monkeypatch.setenv("OPENROUTER_IMAGE_MODEL", "custom/provider-image-model")
stale = {"model": "gpt-image-2-medium"}
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value=stale):
provider = _openrouter()
assert provider.default_model() == DEFAULT_MODEL
assert provider._resolve_model() == "custom/provider-image-model"
def test_model_env_override(self, monkeypatch):
monkeypatch.setenv("OPENROUTER_IMAGE_MODEL", "black-forest-labs/flux.2-pro")
assert _openrouter()._resolve_model() == "black-forest-labs/flux.2-pro"
assert _openrouter()._resolve_model_chain() == ["black-forest-labs/flux.2-pro"]
def test_nous_honors_top_level_model(self):
from plugins.image_gen.openrouter import _build_providers
cfg = {"model": "openai/gpt-image-2"}
nous = {p.name: p for p in _build_providers()}["nous"]
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value=cfg):
assert nous._resolve_model_chain() == ["openai/gpt-image-2"]
def test_explicit_model_kwarg_wins_over_config(self):
cfg = {"model": "openai/gpt-image-2"}
with patch("plugins.image_gen.openrouter._load_image_gen_config", return_value=cfg):
assert _openrouter()._resolve_model_chain("google/gemini-3-pro-image") == [
"google/gemini-3-pro-image"
]
# ---------------------------------------------------------------------------
# Live model catalog
# ---------------------------------------------------------------------------
def _mock_models_response(entries):
resp = MagicMock()
resp.status_code = 200
resp.raise_for_status = MagicMock()
resp.json.return_value = {"data": entries}
return resp
class TestLiveCatalog:
def test_live_catalog_lists_all_image_output_models(self):
"""Every image-output model on the endpoint is selectable — including
ones released after this code shipped."""
entries = [
{
"id": "openai/gpt-5.4-image-2",
"name": "GPT-5.4 Image 2",
"architecture": {"output_modalities": ["image"], "input_modalities": ["text", "image"]},
},
{
"id": "some-lab/brand-new-image-model",
"name": "Brand New",
"architecture": {"output_modalities": ["image", "text"], "input_modalities": ["text"]},
},
{
"id": "openai/gpt-5.4", # text-only: excluded
"architecture": {"output_modalities": ["text"], "input_modalities": ["text"]},
},
{
"id": "openrouter/auto", # router pseudo-model: excluded
"architecture": {"output_modalities": ["image", "text"], "input_modalities": ["text"]},
},
]
provider = _openrouter()
with patch(_RUNTIME, return_value=_runtime_ok()), patch(
"requests.get", return_value=_mock_models_response(entries)
):
models = provider.list_models()
ids = [m["id"] for m in models]
assert "openai/gpt-5.4-image-2" in ids
assert "some-lab/brand-new-image-model" in ids
assert "openai/gpt-5.4" not in ids
assert "openrouter/auto" not in ids
# Default chain models sort first.
assert ids[0] == "openai/gpt-5.4-image-2"
def test_live_failure_falls_back_to_static_chain(self):
provider = _openrouter()
with patch(_RUNTIME, side_effect=RuntimeError("no creds")):
models = provider.list_models()
from plugins.image_gen.openrouter import DEFAULT_MODEL, _FALLBACK_MODEL
assert [m["id"] for m in models] == [DEFAULT_MODEL, _FALLBACK_MODEL]
def test_live_catalog_is_cached(self):
provider = _openrouter()
entries = [
{
"id": "openai/gpt-5.4-image-2",
"architecture": {"output_modalities": ["image"], "input_modalities": ["text"]},
}
]
with patch(_RUNTIME, return_value=_runtime_ok()), patch(
"requests.get", return_value=_mock_models_response(entries)
) as mock_get:
provider.list_models()
provider.list_models()
assert mock_get.call_count == 1
def test_picker_merges_image_api_and_chat_catalogs(self):
"""OpenRouter picker = union of /images/models and image-output
/models entries, deduped, defaults first."""
from plugins.image_gen.openrouter import _build_providers
orp = {p.name: p for p in _build_providers()}["openrouter"]
def fake_get(url, **kw):
resp = MagicMock()
resp.raise_for_status = MagicMock()
if url.endswith("/images/models"):
resp.json.return_value = {"data": [
{"id": "bytedance-seed/seedream-4.5", "name": "Seedream 4.5",
"architecture": {"input_modalities": ["text", "image"],
"output_modalities": ["image"]}},
{"id": "openai/gpt-5.4-image-2", "name": "GPT-5.4 Image 2",
"architecture": {"input_modalities": ["text", "image"],
"output_modalities": ["image"]}},
]}
else:
resp.json.return_value = {"data": [
{"id": "openai/gpt-5.4-image-2",
"architecture": {"output_modalities": ["image"],
"input_modalities": ["text", "image"]}},
{"id": "google/gemini-3-pro-image",
"architecture": {"output_modalities": ["image"],
"input_modalities": ["text", "image"]}},
]}
return resp
with patch(_RUNTIME, return_value=_runtime_ok()), patch("requests.get", side_effect=fake_get):
ids = [m["id"] for m in orp.list_models()]
assert ids[0] == "openai/gpt-5.4-image-2" # default first
assert "bytedance-seed/seedream-4.5" in ids # Image-API-only model present
assert "google/gemini-3-pro-image" in ids # chat-catalog model present
assert len(ids) == len(set(ids)) # deduped
def test_nous_portal_picker_excludes_image_api_catalog(self):
"""Nous Portal has no /images route; its picker must not offer
Image-API-only models it cannot serve."""
from plugins.image_gen.openrouter import _build_providers
nous = {p.name: p for p in _build_providers()}["nous"]
with patch(_RUNTIME, side_effect=RuntimeError("no creds")):
ids = [m["id"] for m in nous.list_models()]
from plugins.image_gen.openrouter import DEFAULT_MODEL, _FALLBACK_MODEL
assert ids == [DEFAULT_MODEL, _FALLBACK_MODEL]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class TestHelpers:
def test_to_image_url_part_passthrough_url(self):
from plugins.image_gen.openrouter import _to_image_url_part
assert _to_image_url_part("https://x/y.png") == "https://x/y.png"
assert _to_image_url_part("data:image/png;base64,AAAA") == "data:image/png;base64,AAAA"
def test_to_image_url_part_blocks_credential_store(self, tmp_path, monkeypatch):
from plugins.image_gen.openrouter import _to_image_url_part
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
auth_json = hermes_home / "auth.json"
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
with pytest.raises(ValueError, match="credential store"):
_to_image_url_part(str(auth_json))
def test_extract_images(self):
from plugins.image_gen.openrouter import _extract_images
payload = {
"choices": [
{"message": {"images": [{"image_url": {"url": "data:image/png;base64,AA"}}]}}
]
}
assert _extract_images(payload) == ["data:image/png;base64,AA"]
def test_access_error_hint_for_gated_openai_model(self):
from plugins.image_gen.openrouter import _FALLBACK_MODEL, _access_error_hint
hint = _access_error_hint(
"OpenRouter", "openai/gpt-5.4-image-2", "OPENROUTER_IMAGE_MODEL", 404, "No endpoints found"
)
assert hint is not None
assert "openai/gpt-5.4-image-2" in hint
assert "OPENROUTER_IMAGE_MODEL" in hint
assert _FALLBACK_MODEL in hint
# Stays a single line under the humanizer's 200-char truncation.
assert "\n" not in hint and len(hint) <= 200
# ---------------------------------------------------------------------------
# generate()
# ---------------------------------------------------------------------------
class TestGenerate:
def test_missing_credentials(self):
with patch(_RUNTIME, return_value=_runtime_ok(api_key="")):
result = _openrouter().generate(prompt="a pet")
assert result["success"] is False
assert result["error_type"] == "missing_api_key"
def test_success_data_uri(self):
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])), \
patch(
"plugins.image_gen.openrouter.save_b64_image",
return_value=Path("/tmp/openrouter_gen.png"),
) as mock_save:
result = _openrouter().generate(prompt="a pet")
assert result["success"] is True
assert result["image"] == "/tmp/openrouter_gen.png"
assert result["provider"] == "openrouter"
mock_save.assert_called_once()
def test_empty_response(self):
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_chat_response([])):
result = _openrouter().generate(prompt="a pet")
assert result["success"] is False
assert result["error_type"] == "empty_response"
def test_payload_shape_and_references(self, tmp_path):
"""Wire payload must carry image modalities, aspect_ratio, and the
reference image inlined as a data URI (this is what makes pet rows
stay on-model)."""
ref = tmp_path / "base.png"
ref.write_bytes(b"\x89PNG\r\n")
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])) as mock_post, \
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/x.png")):
_openrouter().generate(
prompt="a pet", aspect_ratio="square", reference_images=[str(ref)]
)
payload = mock_post.call_args.kwargs["json"]
assert payload["modalities"] == ["image", "text"]
assert payload["image_config"]["aspect_ratio"] == "1:1"
content = payload["messages"][0]["content"]
assert content[0] == {"type": "text", "text": "a pet"}
image_parts = [c for c in content if c["type"] == "image_url"]
assert len(image_parts) == 1
assert image_parts[0]["image_url"]["url"].startswith("data:image/png;base64,")
def test_auth_header(self):
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])) as mock_post, \
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/x.png")):
_openrouter().generate(prompt="a pet")
headers = mock_post.call_args.kwargs["headers"]
assert headers["Authorization"] == "Bearer sk-or-test"
def test_generate_uses_model_kwarg_from_dispatch(self):
"""image_generate passes image_gen.model as a model kwarg — honor it."""
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])) as mock_post, \
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/x.png")):
result = _openrouter().generate(prompt="a pet", model="openai/gpt-image-2")
assert result["success"] is True
assert result["model"] == "openai/gpt-image-2"
assert mock_post.call_args.kwargs["json"]["model"] == "openai/gpt-image-2"
def test_posts_to_resolved_base_url(self):
"""Nous routes to its own base URL — proves the same code serves both."""
nous_runtime = _runtime_ok(
provider="nous", base_url="https://inference.nousresearch.com/v1", api_key="nous-tok"
)
with patch(_RUNTIME, return_value=nous_runtime), \
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])) as mock_post, \
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/x.png")):
from plugins.image_gen.openrouter import _build_providers
nous = {p.name: p for p in _build_providers()}["nous"]
result = nous.generate(prompt="a pet")
assert result["success"] is True
assert result["provider"] == "nous"
url = mock_post.call_args[0][0]
assert url == "https://inference.nousresearch.com/v1/chat/completions"
def test_api_error(self):
import requests as req_lib
resp = MagicMock()
resp.status_code = 401
resp.text = "Unauthorized"
resp.json.return_value = {"error": {"message": "Invalid API key"}}
resp.raise_for_status.side_effect = req_lib.HTTPError(response=resp)
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=resp) as mock_post:
result = _openrouter().generate(prompt="a pet")
assert result["success"] is False
assert result["error_type"] == "api_error"
assert mock_post.call_count == 1
def test_timeout(self):
import requests as req_lib
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", side_effect=req_lib.Timeout()):
result = _openrouter().generate(prompt="a pet")
assert result["success"] is False
assert result["error_type"] == "timeout"
# ---------------------------------------------------------------------------
# Registration + pet integration
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Dedicated Image API surface (POST /images/generations)
# ---------------------------------------------------------------------------
def _openrouter_image_api():
"""The provider as `_build_providers` really configures it (surface on)."""
from plugins.image_gen.openrouter import _build_providers
return {p.name: p for p in _build_providers()}["openrouter"]
def _mock_image_api_response(entries=None, usage=None):
resp = MagicMock()
resp.status_code = 200
resp.raise_for_status = MagicMock()
body = {"created": 0, "data": entries if entries is not None else [
{"b64_json": "dGVzdA==", "media_type": "image/png"}
]}
if usage is not None:
body["usage"] = usage
resp.json.return_value = body
return resp
class TestImageApiSurface:
@pytest.fixture(autouse=True)
def _isolate(self, monkeypatch):
"""No config bleed, no catalog cache bleed between tests."""
import plugins.image_gen.openrouter as mod
mod._CATALOG_CACHE.clear()
monkeypatch.setattr(mod, "_load_image_gen_config", lambda: {})
for knob in ("QUALITY", "BACKGROUND", "RESOLUTION", "SEED", "N",
"ASPECT_RATIO", "TIMEOUT", "SURFACE"):
monkeypatch.delenv(f"OPENROUTER_IMAGE_API_{knob}", raising=False)
monkeypatch.delenv("OPENROUTER_IMAGE_MODEL", raising=False)
yield
mod._CATALOG_CACHE.clear()
# -- routing ---------------------------------------------------------
def test_curated_model_routes_without_any_probe(self):
"""The static table answers the common case offline."""
from plugins.image_gen.openrouter import _select_surface
with patch("requests.get", side_effect=AssertionError("must not probe")):
assert _select_surface("openai/gpt-image-2", "https://x/api/v1", "k", "openrouter") == "images"
def test_chat_defaults_stay_on_chat_even_though_the_catalog_lists_them(self):
"""The regression this guards: /images/models is a superset that
includes DEFAULT_MODEL and _FALLBACK_MODEL. Routing on catalog
membership would silently move every existing default call."""
from plugins.image_gen.openrouter import (
DEFAULT_MODEL,
_FALLBACK_MODEL,
_select_surface,
)
catalog = MagicMock()
catalog.raise_for_status = MagicMock()
catalog.json.return_value = {
"data": [{"id": DEFAULT_MODEL}, {"id": _FALLBACK_MODEL}]
}
with patch("requests.get", return_value=catalog):
assert _select_surface(DEFAULT_MODEL, "https://x/api/v1", "k", "openrouter") == "chat"
assert _select_surface(_FALLBACK_MODEL, "https://x/api/v1", "k", "openrouter") == "chat"
def test_unknown_catalog_model_routes_to_image_api(self):
"""An id past the curated snapshot but in the live catalog is served
by the dedicated API — a model picked from the live picker must not
fall onto chat-completions and 404."""
import plugins.image_gen.openrouter as orp
from plugins.image_gen.openrouter import _select_surface
orp._CATALOG_CACHE.clear()
catalog = MagicMock()
catalog.raise_for_status = MagicMock()
catalog.json.return_value = {"data": [{"id": "brandnew/model-9"}]}
with patch("requests.get", return_value=catalog) as mock_get:
assert _select_surface("brandnew/model-9", "https://x/api/v1", "k", "openrouter") == "images"
assert _select_surface("brandnew/model-9", "https://x/api/v1", "k", "openrouter") == "images"
# Catalog probe is cached — one fetch serves repeat calls.
assert mock_get.call_count == 1
def test_unknown_model_not_in_catalog_stays_on_chat(self):
import plugins.image_gen.openrouter as orp
from plugins.image_gen.openrouter import _select_surface
orp._CATALOG_CACHE.clear()
catalog = MagicMock()
catalog.raise_for_status = MagicMock()
catalog.json.return_value = {"data": [{"id": "something/else"}]}
with patch("requests.get", return_value=catalog):
assert _select_surface("not-served/model", "https://x/api/v1", "k", "openrouter") == "chat"
def test_failed_probe_costs_nothing(self):
import plugins.image_gen.openrouter as orp
from plugins.image_gen.openrouter import _select_surface
orp._CATALOG_CACHE.clear()
with patch("requests.get", side_effect=OSError("network down")):
assert _select_surface("unknown/model", "https://x/api/v1", "k", "openrouter") == "chat"
def test_surface_can_be_forced_both_ways(self, monkeypatch):
from plugins.image_gen.openrouter import DEFAULT_MODEL, _select_surface
monkeypatch.setenv("OPENROUTER_IMAGE_API_SURFACE", "images")
with patch("requests.get", side_effect=AssertionError("must not probe")):
assert _select_surface(DEFAULT_MODEL, "https://x/api/v1", "k", "openrouter") == "images"
monkeypatch.setenv("OPENROUTER_IMAGE_API_SURFACE", "chat")
with patch("requests.get", side_effect=AssertionError("must not probe")):
assert _select_surface("openai/gpt-image-2", "https://x/api/v1", "k", "openrouter") == "chat"
def test_image_api_model_posts_to_images_generations(self):
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_image_api_response()) as mock_post, \
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/i.png")):
result = _openrouter_image_api().generate(
prompt="a red square", aspect_ratio="square", model="openai/gpt-image-2"
)
assert result["success"] is True
assert mock_post.call_args[0][0] == "https://openrouter.ai/api/v1/images/generations"
payload = mock_post.call_args.kwargs["json"]
assert payload["model"] == "openai/gpt-image-2"
assert payload["prompt"] == "a red square"
assert payload["aspect_ratio"] == "1:1"
assert "messages" not in payload and "modalities" not in payload
assert result["endpoint"] == "images/generations"
def test_chat_model_still_uses_chat_completions(self):
"""The new surface must not capture the existing default chain."""
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])) as mock_post, \
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/x.png")):
result = _openrouter_image_api().generate(prompt="a pet")
assert result["success"] is True
assert mock_post.call_args[0][0].endswith("/chat/completions")
def test_nous_never_uses_the_image_api(self):
"""Nous Portal proxies chat-completions and has no /images route."""
from plugins.image_gen.openrouter import _build_providers
nous_runtime = _runtime_ok(
provider="nous", base_url="https://inference.nousresearch.com/v1", api_key="nous-tok"
)
with patch(_RUNTIME, return_value=nous_runtime), \
patch("requests.post", return_value=_mock_chat_response([_PNG_DATA_URI])) as mock_post, \
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/x.png")):
nous = {p.name: p for p in _build_providers()}["nous"]
result = nous.generate(prompt="a pet", model="openai/gpt-image-2")
assert result["success"] is True
assert mock_post.call_args[0][0] == "https://inference.nousresearch.com/v1/chat/completions"
# -- per-model parameter filtering ------------------------------------
def test_aspect_ratio_is_mapped_per_model(self):
from plugins.image_gen.openrouter import _build_image_api_payload
gemini, _ = _build_image_api_payload(
model_id="google/gemini-3.1-flash-lite-image", prompt="p",
semantic_aspect="landscape", references=[], config_key="openrouter", kwargs={},
)
mini, _ = _build_image_api_payload(
model_id="openai/gpt-image-1-mini", prompt="p",
semantic_aspect="landscape", references=[], config_key="openrouter", kwargs={},
)
# gpt-image-1-mini has no 16:9 at all, so landscape degrades to 3:2.
assert gemini["aspect_ratio"] == "16:9"
assert mini["aspect_ratio"] == "3:2"
def test_unsupported_parameter_is_dropped_and_explained(self):
"""The endpoint silently ignores unknown fields, so we must filter."""
from plugins.image_gen.openrouter import _build_image_api_payload
payload, notes = _build_image_api_payload(
model_id="openai/gpt-image-2", prompt="p", semantic_aspect="square",
references=[], config_key="openrouter", kwargs={"background": "transparent"},
)
assert "background" not in payload
assert any("background" in n for n in notes)
payload, notes = _build_image_api_payload(
model_id="openai/gpt-image-1-mini", prompt="p", semantic_aspect="square",
references=[], config_key="openrouter", kwargs={"background": "transparent"},
)
assert payload["background"] == "transparent"
def test_n_is_clamped_to_the_model_cap(self):
from plugins.image_gen.openrouter import _build_image_api_payload
payload, notes = _build_image_api_payload(
model_id="qwen/qwen-image-3-pro", prompt="p", semantic_aspect="square",
references=[], config_key="openrouter", kwargs={"n": 20},
)
assert payload["n"] == 6
assert any("cap of 6" in n for n in notes)
def test_unknown_model_omits_the_aspect_ratio(self):
"""An out-of-enum ratio is a hard 400, so never guess one."""
from plugins.image_gen.openrouter import _build_image_api_payload
payload, notes = _build_image_api_payload(
model_id="brandnew/model-9", prompt="p", semantic_aspect="landscape",
references=[], config_key="openrouter", kwargs={},
)
assert "aspect_ratio" not in payload
assert any("catalog" in n for n in notes)
def test_env_knob_applies(self, monkeypatch):
from plugins.image_gen.openrouter import _build_image_api_payload
monkeypatch.setenv("OPENROUTER_IMAGE_API_QUALITY", "high")
payload, _ = _build_image_api_payload(
model_id="openai/gpt-image-2", prompt="p", semantic_aspect="square",
references=[], config_key="openrouter", kwargs={},
)
assert payload["quality"] == "high"
# -- references --------------------------------------------------------
def test_references_use_the_per_model_cap(self, tmp_path):
"""Image API models take far more references than chat's 3."""
refs = []
for i in range(5):
p = tmp_path / f"r{i}.png"
p.write_bytes(b"\x89PNG\r\n")
refs.append(str(p))
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_image_api_response()) as mock_post, \
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/i.png")):
result = _openrouter_image_api().generate(
prompt="edit", model="openai/gpt-image-2", reference_image_urls=refs
)
payload = mock_post.call_args.kwargs["json"]
assert len(payload["input_references"]) == 5 # chat would have clamped to 3
assert payload["input_references"][0]["image_url"]["url"].startswith("data:image/png;base64,")
assert result["modality"] == "image"
def test_unreadable_sole_reference_fails_instead_of_degrading(self):
"""Degrading an edit to text-to-image bills an unrelated picture."""
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post") as mock_post:
result = _openrouter_image_api().generate(
prompt="edit this", model="openai/gpt-image-2",
image_url="/nonexistent/definitely-missing.png",
)
assert result["success"] is False
assert result["error_type"] == "io_error"
mock_post.assert_not_called()
# -- response handling -------------------------------------------------
def test_cost_and_extras_are_surfaced(self):
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_image_api_response(
usage={"cost": 0.0336, "total_tokens": 1128})), \
patch("plugins.image_gen.openrouter.save_b64_image", return_value=Path("/tmp/i.png")):
result = _openrouter_image_api().generate(
prompt="p", aspect_ratio="portrait", model="krea/krea-2-medium"
)
assert result["cost_usd"] == 0.0336
assert result["total_tokens"] == 1128
assert result["exact_aspect_ratio"] == "9:16"
assert result["image"] == "/tmp/i.png"
def test_multiple_images_land_in_additional_images(self):
entries = [
{"b64_json": "AA==", "media_type": "image/png"},
{"b64_json": "BB==", "media_type": "image/png"},
]
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_image_api_response(entries)), \
patch("plugins.image_gen.openrouter.save_b64_image",
side_effect=[Path("/tmp/a.png"), Path("/tmp/b.png")]):
result = _openrouter_image_api().generate(prompt="p", model="openai/gpt-image-2")
assert result["image"] == "/tmp/a.png"
assert result["additional_images"] == ["/tmp/b.png"]
def test_empty_data_is_typed(self):
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=_mock_image_api_response([])):
result = _openrouter_image_api().generate(prompt="p", model="openai/gpt-image-2")
assert result["success"] is False
assert result["error_type"] == "empty_response"
def test_zod_validation_error_is_flattened(self):
from plugins.image_gen.openrouter import _extract_image_api_error
resp = MagicMock()
resp.json.return_value = {
"success": False,
"error": {
"name": "ZodError",
"message": '[{"path":["aspect_ratio"],"message":"Invalid option"}]',
},
}
assert _extract_image_api_error(resp, "fb").startswith("aspect_ratio: Invalid option")
def test_auth_error_is_not_retried_as_api_error(self):
import requests as req_lib
resp = MagicMock()
resp.status_code = 401
resp.text = "Unauthorized"
resp.json.return_value = {"error": {"message": "Invalid API key"}}
resp.raise_for_status.side_effect = req_lib.HTTPError(response=resp)
with patch(_RUNTIME, return_value=_runtime_ok()), \
patch("requests.post", return_value=resp):
result = _openrouter_image_api().generate(prompt="p", model="openai/gpt-image-2")
assert result["success"] is False
assert result["error_type"] == "auth_error"
assert "_retryable" not in result
def test_catalog_models_are_offered_only_by_openrouter(self):
from plugins.image_gen.openrouter import _IMAGE_API_MODELS, _build_providers
by_name = {p.name: p for p in _build_providers()}
openrouter_ids = {m["id"] for m in by_name["openrouter"].list_models()}
nous_ids = {m["id"] for m in by_name["nous"].list_models()}
assert "openai/gpt-image-2" in openrouter_ids
assert set(_IMAGE_API_MODELS) <= openrouter_ids
assert not (set(_IMAGE_API_MODELS) & nous_ids)
def test_default_model_is_unchanged_by_the_new_surface(self):
from plugins.image_gen.openrouter import DEFAULT_MODEL
assert _openrouter_image_api().default_model() == DEFAULT_MODEL
class TestRegistration:
def test_register_both(self):
from plugins.image_gen.openrouter import register
ctx = MagicMock()
register(ctx)
registered = [c.args[0].name for c in ctx.register_image_gen_provider.call_args_list]
assert set(registered) == {"openrouter", "nous"}
def test_both_are_reference_capable_for_pets(self):
from agent.pet.generate.imagegen import _REF_CAPABLE
assert "openrouter" in _REF_CAPABLE
assert "nous" in _REF_CAPABLE
@@ -0,0 +1,539 @@
#!/usr/bin/env python3
"""Tests for xAI image generation provider."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _fake_api_key(monkeypatch, tmp_path):
"""Ensure XAI_API_KEY is set for all tests."""
monkeypatch.setenv("XAI_API_KEY", "test-key-12345")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
try:
import hermes_cli.config as cfg_mod
if hasattr(cfg_mod, "_invalidate_load_config_cache"):
cfg_mod._invalidate_load_config_cache()
except Exception:
pass
@pytest.fixture(autouse=True)
def _no_live_catalog(monkeypatch):
"""Keep unit tests hermetic: never hit xAI's live model-list endpoint.
The fake XAI_API_KEY above would otherwise let ``_fetch_live_models``
fire a real GET. Individual tests that exercise the live-merge path
re-patch ``_fetch_live_models`` themselves.
"""
import plugins.image_gen.xai as xai_mod
def _offline():
raise RuntimeError("offline (test)")
monkeypatch.setattr(xai_mod, "_fetch_live_models", _offline)
monkeypatch.setattr(xai_mod, "_LIVE_CACHE", None)
yield
xai_mod._LIVE_CACHE = None
# ---------------------------------------------------------------------------
# Provider class tests
# ---------------------------------------------------------------------------
class TestXAIImageGenProvider:
def test_name(self):
from plugins.image_gen.xai import XAIImageGenProvider
provider = XAIImageGenProvider()
assert provider.name == "xai"
def test_display_name(self):
from plugins.image_gen.xai import XAIImageGenProvider
provider = XAIImageGenProvider()
assert provider.display_name == "xAI (Grok)"
def test_is_available_with_key(self, monkeypatch):
monkeypatch.setenv("XAI_API_KEY", "sk-xxx")
from plugins.image_gen.xai import XAIImageGenProvider
provider = XAIImageGenProvider()
assert provider.is_available() is True
def test_list_models(self):
from plugins.image_gen.xai import XAIImageGenProvider
provider = XAIImageGenProvider()
models = provider.list_models()
assert len(models) >= 1
assert models[0]["id"] == "grok-imagine-image"
def test_default_model(self):
from plugins.image_gen.xai import XAIImageGenProvider
provider = XAIImageGenProvider()
assert provider.default_model() == "grok-imagine-image"
def test_get_setup_schema(self):
from plugins.image_gen.xai import XAIImageGenProvider
provider = XAIImageGenProvider()
schema = provider.get_setup_schema()
assert schema["name"] == "xAI Grok Imagine (image)"
assert schema["badge"] == "paid"
# Auth resolution is delegated to the shared "xai_grok" post_setup
# hook so the picker doesn't blindly prompt for XAI_API_KEY when the
# user is already signed in via xAI Grok OAuth.
assert schema["env_vars"] == []
assert schema["post_setup"] == "xai_grok"
def test_capabilities_expose_total_source_image_limit(self):
from plugins.image_gen.xai import XAIImageGenProvider
caps = XAIImageGenProvider().capabilities()
assert caps["max_reference_images"] == 2
assert caps["max_source_images"] == 3
# ---------------------------------------------------------------------------
# Config tests
# ---------------------------------------------------------------------------
class TestConfig:
def test_custom_model(self, monkeypatch):
monkeypatch.setenv("XAI_IMAGE_MODEL", "grok-imagine-image")
from plugins.image_gen.xai import _resolve_model
model_id, _ = _resolve_model()
assert model_id == "grok-imagine-image"
def test_caller_model_overrides_env(self, monkeypatch):
"""caller_model (from image_gen.model config key) must take priority
over XAI_IMAGE_MODEL env — mirrors the fix applied to the openrouter
provider in #55672."""
monkeypatch.setenv("XAI_IMAGE_MODEL", "grok-imagine-image")
from plugins.image_gen.xai import _resolve_model
model_id, _ = _resolve_model("grok-imagine-image-quality")
assert model_id == "grok-imagine-image-quality"
def test_unknown_caller_model_falls_back_to_env(self, monkeypatch):
"""An unrecognised caller_model must not crash — fall through to env."""
monkeypatch.setenv("XAI_IMAGE_MODEL", "grok-imagine-image")
from plugins.image_gen.xai import _resolve_model
model_id, _ = _resolve_model("not-a-real-model")
assert model_id == "grok-imagine-image"
def test_model_kwarg_forwarded_to_generate(self):
"""generate(model=...) must use the supplied model, not the default."""
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {"data": [{"b64_json": "dGVzdA=="}]}
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post:
with patch("plugins.image_gen.xai.save_b64_image", return_value="/tmp/out.png"):
provider = XAIImageGenProvider()
result = provider.generate(prompt="test", model="grok-imagine-image-quality")
assert result["success"] is True
assert result["model"] == "grok-imagine-image-quality"
payload = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json", {})
assert payload.get("model") == "grok-imagine-image-quality"
# ---------------------------------------------------------------------------
# Live catalog merge tests
# ---------------------------------------------------------------------------
class TestLiveCatalog:
def test_static_catalog_includes_image_2_0(self):
"""Curated table carries the 2.0 model even offline."""
from plugins.image_gen.xai import XAIImageGenProvider
ids = [m["id"] for m in XAIImageGenProvider().list_models()]
assert "grok-imagine-image-2.0" in ids
def test_unknown_live_model_appears_in_catalog(self, monkeypatch):
"""A model xAI ships tomorrow shows up without a code change."""
import plugins.image_gen.xai as xai_mod
live = {
"grok-imagine-image": {"input_modalities": ["text", "image"], "aliases": []},
"grok-imagine-image-3.0": {"input_modalities": ["text", "image"], "aliases": []},
}
monkeypatch.setattr(xai_mod, "_fetch_live_models", lambda: live)
monkeypatch.setattr(xai_mod, "_LIVE_CACHE", None)
catalog = xai_mod._catalog()
assert "grok-imagine-image-3.0" in catalog
# Curated metadata survives the merge for known models.
assert catalog["grok-imagine-image"]["display"] == "Grok Imagine Image"
# And the new model is selectable end to end.
monkeypatch.setenv("XAI_IMAGE_MODEL", "grok-imagine-image-3.0")
model_id, _ = xai_mod._resolve_model()
assert model_id == "grok-imagine-image-3.0"
def test_live_failure_falls_back_to_static(self, monkeypatch):
import plugins.image_gen.xai as xai_mod
monkeypatch.setattr(xai_mod, "_LIVE_CACHE", None)
catalog = xai_mod._catalog() # autouse fixture makes fetch raise
assert set(catalog) == set(xai_mod._MODELS)
def test_edit_model_honors_image_capable_selection(self, monkeypatch):
import plugins.image_gen.xai as xai_mod
live = {
"grok-imagine-image-2.0": {"input_modalities": ["text", "image"], "aliases": []},
"grok-imagine-image-quality": {"input_modalities": ["text", "image"], "aliases": []},
}
monkeypatch.setattr(xai_mod, "_fetch_live_models", lambda: live)
monkeypatch.setattr(xai_mod, "_LIVE_CACHE", None)
monkeypatch.setenv("XAI_IMAGE_MODEL", "grok-imagine-image-2.0")
assert xai_mod._resolve_edit_model() == "grok-imagine-image-2.0"
def test_edit_model_defaults_to_quality(self, monkeypatch):
import plugins.image_gen.xai as xai_mod
monkeypatch.setattr(xai_mod, "_LIVE_CACHE", None)
monkeypatch.delenv("XAI_IMAGE_MODEL", raising=False)
assert xai_mod._resolve_edit_model() == "grok-imagine-image-quality"
def test_edit_model_honors_caller_kwarg(self, monkeypatch):
"""The dispatched model kwarg reaches the edit path too."""
import plugins.image_gen.xai as xai_mod
live = {
"grok-imagine-image-2.0": {"input_modalities": ["text", "image"], "aliases": []},
"grok-imagine-image-quality": {"input_modalities": ["text", "image"], "aliases": []},
}
monkeypatch.setattr(xai_mod, "_fetch_live_models", lambda: live)
monkeypatch.setattr(xai_mod, "_LIVE_CACHE", None)
monkeypatch.delenv("XAI_IMAGE_MODEL", raising=False)
assert xai_mod._resolve_edit_model("grok-imagine-image-2.0") == "grok-imagine-image-2.0"
# Text-only caller model must not hijack the edit path.
live["grok-imagine-image-2.0"]["input_modalities"] = ["text"]
monkeypatch.setattr(xai_mod, "_LIVE_CACHE", None)
assert xai_mod._resolve_edit_model("grok-imagine-image-2.0") == "grok-imagine-image-quality"
# ---------------------------------------------------------------------------
# Generate tests
# ---------------------------------------------------------------------------
class TestGenerate:
def test_missing_api_key(self, monkeypatch):
monkeypatch.delenv("XAI_API_KEY", raising=False)
from plugins.image_gen.xai import XAIImageGenProvider
provider = XAIImageGenProvider()
result = provider.generate(prompt="test")
assert result["success"] is False
assert "XAI_API_KEY" in result["error"]
def test_successful_generation(self):
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {
"data": [{"b64_json": "dGVzdC1pbWFnZS1kYXRh"}], # base64 "test-image-data"
}
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp):
with patch("plugins.image_gen.xai.save_b64_image", return_value="/tmp/test.png"):
provider = XAIImageGenProvider()
result = provider.generate(prompt="A cat playing piano")
assert result["success"] is True
assert result["image"] == "/tmp/test.png"
assert result["provider"] == "xai"
assert result["model"] == "grok-imagine-image"
def test_url_response_falls_back_to_bare_url_when_download_fails(self):
"""If caching the URL fails (network blip, 404 in-flight), the
provider must NOT hard-error — fall through to returning the bare
URL so the agent surface at least sees *something*. The gateway's
existing URL-send fallback then has a chance to succeed; if it
too 404s, the user gets the original (now legible) error rather
than an opaque "image generation failed" tool result.
"""
import requests as req_lib
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {
"data": [{"url": "https://imgen.x.ai/xai-tmp-imgen-already-404.jpeg"}],
}
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp), \
patch(
"plugins.image_gen.xai.save_url_image",
side_effect=req_lib.HTTPError("404 from CDN"),
):
provider = XAIImageGenProvider()
result = provider.generate(prompt="A cat playing piano")
assert result["success"] is True, (
"Cache failure must not turn into a tool error — gateway gets a chance to retry"
)
assert result["image"] == "https://imgen.x.ai/xai-tmp-imgen-already-404.jpeg"
def test_api_error(self):
import requests as req_lib
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 401
mock_resp.text = "Unauthorized"
mock_resp.json.return_value = {"error": {"message": "Invalid API key"}}
mock_resp.raise_for_status.side_effect = req_lib.HTTPError(response=mock_resp)
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp):
provider = XAIImageGenProvider()
result = provider.generate(prompt="test")
assert result["success"] is False
assert result["error_type"] == "api_error"
def test_timeout(self):
import requests as req_lib
from plugins.image_gen.xai import XAIImageGenProvider
with patch("plugins.image_gen.xai.requests.post", side_effect=req_lib.Timeout()):
provider = XAIImageGenProvider()
result = provider.generate(prompt="test")
assert result["success"] is False
assert result["error_type"] == "timeout"
def test_empty_response(self):
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {"data": []}
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp):
provider = XAIImageGenProvider()
result = provider.generate(prompt="test")
assert result["success"] is False
assert result["error_type"] == "empty_response"
def test_auth_header(self):
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {
"data": [{"url": "https://xai.image/test.png"}],
}
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post:
provider = XAIImageGenProvider()
provider.generate(prompt="test")
call_args = mock_post.call_args
headers = call_args.kwargs.get("headers") or call_args[1].get("headers")
assert "Bearer test-key-12345" in headers["Authorization"]
assert "Hermes-Agent" in headers["User-Agent"]
def test_payload_resolution_is_literal_1k_or_2k(self):
"""Regression: xAI API rejects numeric resolutions ("1024"/"2048") with 422.
The endpoint expects the literal strings "1k" or "2k". Ensure the wire
payload carries that literal — not a numeric mapping. See PR #18678.
"""
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {"data": [{"url": "https://xai.image/test.png"}]}
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post:
provider = XAIImageGenProvider()
provider.generate(prompt="test")
payload = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json")
assert payload["resolution"] in {"1k", "2k"}, (
f"resolution must be the literal '1k' or '2k', got {payload['resolution']!r}"
)
def test_image_edit_rejects_bare_file_id_input(self):
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {"data": [{"url": "https://xai.image/edited.png"}]}
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post, \
patch("plugins.image_gen.xai.save_url_image", return_value="/tmp/edited.png"):
provider = XAIImageGenProvider()
result = provider.generate(
prompt="make the robot red",
image_url="file_03eb65b1-aa97-482f-9ef0-b04f9172ea00",
)
assert result["success"] is False
assert result["error_type"] == "invalid_image_url"
mock_post.assert_not_called()
def test_multi_image_edit_rejects_bare_file_id_inputs(self):
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {"data": [{"url": "https://xai.image/edited.png"}]}
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post, \
patch("plugins.image_gen.xai.save_url_image", return_value="/tmp/edited.png"):
provider = XAIImageGenProvider()
result = provider.generate(
prompt="combine these robots into one product shot",
image_url="file_03eb65b1-aa97-482f-9ef0-b04f9172ea00",
reference_image_urls=[
"file_54b48d6d-28ad-4982-9d72-bd3ac677c9bc",
"file_aa11bb22-cc33-44dd-88ee-ff0011223344",
],
)
assert result["success"] is False
assert result["error_type"] == "invalid_image_url"
mock_post.assert_not_called()
def test_storage_options_are_sent_by_default(self):
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {"data": [{"b64_json": "dGVzdA=="}]}
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post, \
patch("plugins.image_gen.xai.save_b64_image", return_value="/tmp/test.png"):
provider = XAIImageGenProvider()
provider.generate(prompt="test")
payload = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json")
assert payload["storage_options"]["public_url"] is True
assert "expires_after" not in payload["storage_options"]
assert payload["storage_options"]["filename"].endswith(".png")
def test_public_url_file_output_wins_over_temporary_url(self):
from plugins.image_gen.xai import XAIImageGenProvider
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = {
"data": [{
"url": "https://imgen.x.ai/xai-tmp-imgen-test.jpeg",
"file_output": {
"file_id": "file-123",
"filename": "stored.png",
"public_url": "https://xai-files.example/stored.png",
"public_url_expires_at": 1234567890,
},
}],
}
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp), \
patch("plugins.image_gen.xai.save_url_image") as mock_save_url:
provider = XAIImageGenProvider()
result = provider.generate(prompt="A cat playing piano")
assert result["success"] is True
assert result["image"] == "https://xai-files.example/stored.png"
assert result["public_url"] == "https://xai-files.example/stored.png"
assert "file_id" not in result
mock_save_url.assert_not_called()
# ---------------------------------------------------------------------------
# Registration test
# ---------------------------------------------------------------------------
class TestRegistration:
def test_register(self):
from plugins.image_gen.xai import XAIImageGenProvider, register
mock_ctx = MagicMock()
register(mock_ctx)
mock_ctx.register_image_gen_provider.assert_called_once()
provider = mock_ctx.register_image_gen_provider.call_args[0][0]
assert isinstance(provider, XAIImageGenProvider)
assert provider.name == "xai"
def test_xai_image_field_expands_user_home(tmp_path, monkeypatch):
"""A ~-prefixed local image path must load (expanduser), not raise io_error.
Pre-flight validation uses ``Path(source).expanduser()`` so a ``~/...`` path
passes; ``_xai_image_field`` must expand it too or the load fails spuriously.
"""
from plugins.image_gen.xai import _xai_image_field
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
img = tmp_path / "pic.png"
img.write_bytes(b"\x89PNG\r\n\x1a\n")
field = _xai_image_field("~/pic.png")
assert field["type"] == "image_url"
assert field["url"].startswith("data:image/png;base64,")
class TestXAIImageFieldReadGuard:
"""#57698: local image inputs must not read Hermes credential stores."""
def test_xai_image_field_blocks_credential_store(self, tmp_path, monkeypatch):
from plugins.image_gen.xai import _xai_image_field
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
auth_json = hermes_home / "auth.json"
auth_json.write_text('{"api_key":"sk-secret"}', encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
with pytest.raises(ValueError, match="credential store"):
_xai_image_field(str(auth_json))