Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -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))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user