fb13457f6f
CI / Supply-chain scan (push) Has been cancelled
Docker Build, Test, and Publish / Detect affected areas (push) Has been cancelled
auto-fix lint issues & formatting / Generate eslint --fix patch (push) Has been cancelled
Nix flake check / Detect affected areas (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
CI / Detect affected areas (push) Has been cancelled
Docker Build, Test, and Publish / build (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
CI / Desktop E2E (push) Has been cancelled
CI / Docs Site (push) Has been cancelled
CI / Deny unrelated histories (push) Has been cancelled
CI / Check contributors (push) Has been cancelled
Docker Build, Test, and Publish / build (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
CI / Check uv.lock (push) Has been cancelled
CI / Check no committed infographics (push) Has been cancelled
CI / Profile artifact check (push) Has been cancelled
Docker Build, Test, and Publish / publish (amd64, type=gha,scope=docker-amd64, type=gha,mode=max,scope=docker-amd64, linux/amd64, ubuntu-latest-32-core) (push) Has been cancelled
Docker Build, Test, and Publish / publish (arm64, type=gha,scope=docker-arm64, type=gha,mode=max,scope=docker-arm64, linux/arm64, ubuntu-latest-32-arm-core) (push) Has been cancelled
Docker Build, Test, and Publish / merge (push) Has been cancelled
auto-fix lint issues & formatting / Apply patch (push) Has been cancelled
CI / Check no case-colliding filenames (push) Has been cancelled
CI / package-lock.json diff (push) Has been cancelled
CI / Lint Docker scripts (push) Has been cancelled
CI / Review label gate (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
CI / CI timing report (push) Has been cancelled
CI / Python tests (push) Has been cancelled
CI / OS-specific tests (push) Has been cancelled
CI / Python lints (push) Has been cancelled
CI / JS & TS checks (push) Has been cancelled
CI / Installer tests (push) Has been cancelled
CI / Rust tests (push) Has been cancelled
Nix flake check / nix flake check (push) Has been cancelled
308 lines
12 KiB
Python
308 lines
12 KiB
Python
"""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_picker_matches_resolvable_catalog(self, provider):
|
||
ids = [m["id"] for m in provider.list_models()]
|
||
assert set(ids) == set(provider.models)
|
||
assert provider.default_model() in ids
|
||
|
||
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"]
|
||
|
||
def test_setup_schema_uses_configured_image_key(self, provider, tmp_path):
|
||
import yaml
|
||
|
||
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
|
||
"image_gen": {"openai": {"key_env": "OPENAI_IMAGE_GEN_API_KEY"}}
|
||
}))
|
||
|
||
schema = provider.get_setup_schema()
|
||
|
||
assert schema["env_vars"] == [{
|
||
"key": "OPENAI_IMAGE_GEN_API_KEY",
|
||
"prompt": "Image generation API key (OPENAI_IMAGE_GEN_API_KEY)",
|
||
"url": "",
|
||
}]
|
||
|
||
|
||
# ── 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
|
||
|
||
def test_configured_key_is_independent_from_generic_openai_key(self, tmp_path, monkeypatch):
|
||
import yaml
|
||
|
||
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
|
||
"image_gen": {"openai": {"key_env": "OPENAI_IMAGE_GEN_API_KEY"}}
|
||
}))
|
||
monkeypatch.setenv("OPENAI_API_KEY", "chat-provider-key")
|
||
monkeypatch.delenv("OPENAI_IMAGE_GEN_API_KEY", raising=False)
|
||
|
||
assert openai_plugin.OpenAIImageGenProvider().is_available() is False
|
||
|
||
|
||
# ── 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
|
||
|
||
def test_configured_key_and_base_url_reach_image_client(self, provider, tmp_path, monkeypatch):
|
||
import yaml
|
||
|
||
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
|
||
"image_gen": {"openai": {
|
||
"key_env": "OPENAI_IMAGE_GEN_API_KEY",
|
||
"base_url": "http://image-gateway.test/v1",
|
||
}}
|
||
}))
|
||
monkeypatch.setenv("OPENAI_IMAGE_GEN_API_KEY", "image-key")
|
||
monkeypatch.setenv("OPENAI_BASE_URL", "http://chat-gateway.test/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}):
|
||
result = provider.generate("a cat")
|
||
|
||
assert result["success"] is True
|
||
fake_openai.OpenAI.assert_called_once_with(
|
||
api_key="image-key", base_url="http://image-gateway.test/v1")
|
||
|
||
@pytest.mark.parametrize("api_model,quality", [
|
||
("gpt-image-2", quality) for quality in ("low", "medium", "high")
|
||
] + [
|
||
(model, quality)
|
||
for model in ("gpt-image-2.5-flare", "gpt-image-2.5-sunburst")
|
||
for quality in ("auto", "low", "medium", "high", "xhigh", "max")
|
||
])
|
||
@pytest.mark.parametrize("editing", [False, True])
|
||
def test_selection_reaches_image_request(
|
||
self, provider, monkeypatch, tmp_path, api_model, quality, editing
|
||
):
|
||
import yaml
|
||
|
||
tier = api_model if quality == "auto" else f"{api_model}-{quality}"
|
||
monkeypatch.delenv("OPENAI_IMAGE_MODEL", raising=False)
|
||
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
|
||
"image_gen": {"openai": {"model": tier}}
|
||
}))
|
||
source = tmp_path / "source.png"
|
||
source.write_bytes(bytes.fromhex(_PNG_HEX))
|
||
fake_client = MagicMock()
|
||
call = fake_client.images.edit if editing else fake_client.images.generate
|
||
call.return_value = _fake_response(b64=_b64_png())
|
||
|
||
with _patched_openai(fake_client):
|
||
result = provider.generate("a cat", image_url=str(source) if editing else None)
|
||
|
||
assert result["success"] is True
|
||
assert result["model"] == tier
|
||
assert result["quality"] == quality
|
||
assert call.call_args.kwargs["quality"] == quality
|
||
assert call.call_args.kwargs["model"] == api_model
|
||
assert "response_format" not in call.call_args.kwargs
|
||
assert Path(result["image"]).read_bytes() == bytes.fromhex(_PNG_HEX)
|
||
|
||
@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._common.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()
|