Generated image/video output now writes under cache/generated/images|videos instead of the swept inbound caches (cache/images, cache/videos). The hourly gateway housekeeping deletes 24h-old files in those dirs; for base64-returning providers the swept file was the only copy, breaking transcript references, the Artifacts panel and later edits. Rebased onto current main, where the writers delegate to agent/provider_media, so the media kind is now the generated/ subpath. cache/generated is added to tools/credential_files._CACHE_DIRS: without a mount/sync entry the Docker, SSH and Modal backends never receive the bytes and the provider result loses agent_visible_image. All callers consume the returned absolute paths, so no call-site changes were needed; inbound download paths (get_image_cache_dir / get_video_cache_dir) and the sweeper are untouched. RED: tests/agent/test_generated_media_durable.py (9 failed on main) — 25h-backdated generated files deleted by the real cleanup_image_cache / cleanup_video_cache, and no mount mapping for cache/generated. tests/tools/test_image_generation_artifacts.py ::test_real_generated_image_is_agent_visible_under_ssh_env covers the sandbox regression with the real writer. Green: 533 passed, 19 skipped. Co-authored-by: Froraut <79215537+Froraut@users.noreply.github.com> (cherry picked from commit fd23181f22232f66aec7076d6bf0868ed45661f2)
381 lines
17 KiB
Python
381 lines
17 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_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
|
||
|
||
|
||
|
||
# ── 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 hermes_yaml as 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"
|
||
|
||
|
||
# ── Endpoint / credential routing ───────────────────────────────────────────
|
||
|
||
|
||
class TestEndpointConfig:
|
||
"""``image_gen.openai.base_url`` / ``key_env`` reach the client and its request (#65309, #97928,
|
||
#13798); the project header is blanked (#60748); custom endpoints bypass system proxies (#64888)."""
|
||
|
||
def test_config_base_url_and_key_env_reach_client_and_availability(self, monkeypatch, tmp_path):
|
||
import hermes_yaml as yaml
|
||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||
monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
|
||
monkeypatch.setenv("IMAGE_GATEWAY_TOKEN", "gateway-token")
|
||
(tmp_path / "config.yaml").write_text(yaml.safe_dump({"image_gen": {"openai": {
|
||
"base_url": "http://localhost:18081/v1/", "key_env": "IMAGE_GATEWAY_TOKEN"}}}))
|
||
provider = openai_plugin.OpenAIImageGenProvider()
|
||
assert provider.is_available() is True # same resolver as generate(); no OPENAI_API_KEY needed
|
||
fake_client = MagicMock()
|
||
fake_client.images.generate.return_value = _fake_response(b64=_b64_png())
|
||
with _patched_openai(fake_client):
|
||
assert provider.generate("a cat")["success"] is True
|
||
kwargs = __import__("sys").modules["openai"].OpenAI.call_args.kwargs
|
||
assert kwargs["base_url"] == "http://localhost:18081/v1"
|
||
assert kwargs["api_key"] == "gateway-token"
|
||
assert kwargs["default_headers"]["OpenAI-Project"] == ""
|
||
kwargs["http_client"].close()
|
||
|
||
def test_custom_model_id_passes_through_without_quality(self, monkeypatch, tmp_path):
|
||
"""A non-catalog ``image_gen.openai.model`` reaches the gateway verbatim as ``model`` and no
|
||
``quality`` is sent (gateways reject unknown enum values); a stale top-level ``image_gen.model``
|
||
from another provider never passes through (#97928)."""
|
||
import hermes_yaml as yaml
|
||
monkeypatch.setenv("OPENAI_API_KEY", "k")
|
||
monkeypatch.delenv("OPENAI_IMAGE_MODEL", raising=False)
|
||
(tmp_path / "config.yaml").write_text(yaml.safe_dump({"image_gen": {
|
||
"model": "fal-ai/flux-2/klein/9b", "openai": {"model": "custom-image-model"}}}))
|
||
fake_client = MagicMock()
|
||
fake_client.images.generate.return_value = _fake_response(b64=_b64_png())
|
||
with _patched_openai(fake_client):
|
||
result = openai_plugin.OpenAIImageGenProvider().generate("a cat")
|
||
assert result["success"] is True and result["model"] == "custom-image-model"
|
||
request = fake_client.images.generate.call_args.kwargs
|
||
assert request["model"] == "custom-image-model" and "quality" not in request
|
||
(tmp_path / "config.yaml").write_text(yaml.safe_dump({"image_gen": {"model": "fal-ai/flux-2/klein/9b"}}))
|
||
assert openai_plugin._resolve_model()[0] == "gpt-image-2-medium"
|
||
|
||
def test_named_custom_endpoint_supplies_base_url_and_key(self, monkeypatch, tmp_path):
|
||
"""``image_gen.openai.provider: <name>`` inherits that ``providers:`` entry's base_url and
|
||
key_env when ``base_url``/``key_env`` are unset; explicit values still win (#83080)."""
|
||
import hermes_yaml as yaml
|
||
for key in ("OPENAI_API_KEY", "OPENAI_BASE_URL"):
|
||
monkeypatch.delenv(key, raising=False)
|
||
monkeypatch.setenv("MY_GW_KEY", "gw-token")
|
||
monkeypatch.setenv("IMAGE_ONLY_KEY", "image-token")
|
||
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
|
||
"providers": {"my-gateway": {"name": "My Gateway", "api": "https://gw.example/v1", "key_env": "MY_GW_KEY"}},
|
||
"image_gen": {"openai": {"provider": "my-gateway"}}}))
|
||
assert openai_plugin._resolve_endpoint() == ("https://gw.example/v1", "gw-token")
|
||
assert openai_plugin.OpenAIImageGenProvider().is_available() is True
|
||
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
|
||
"providers": {"my-gateway": {"name": "My Gateway", "api": "https://gw.example/v1", "key_env": "MY_GW_KEY"}},
|
||
"image_gen": {"openai": {"provider": "my-gateway", "key_env": "IMAGE_ONLY_KEY"}}}))
|
||
assert openai_plugin._resolve_endpoint() == ("https://gw.example/v1", "image-token")
|
||
(tmp_path / "config.yaml").write_text(yaml.safe_dump({"image_gen": {"openai": {"provider": "no-such"}}}))
|
||
assert openai_plugin._resolve_endpoint() == ("", "")
|
||
|
||
def test_custom_base_url_ignores_system_proxy(self, monkeypatch, tmp_path):
|
||
"""httpx only sees macOS system proxies via ``getproxies()`` (bound at import in
|
||
``httpx._utils``; ExceptionsList dropped): with a system proxy visible and no proxy env var,
|
||
``generate()`` must hand ``openai.OpenAI`` a client with no ``HTTPProxy`` mount, while a plain
|
||
``httpx.Client()`` under the same conditions (control) does pick the proxy up (#64888)."""
|
||
import httpx
|
||
import hermes_yaml as yaml
|
||
for key in ("HTTPS_PROXY", "HTTP_PROXY", "ALL_PROXY", "https_proxy", "http_proxy", "all_proxy",
|
||
"NO_PROXY", "no_proxy"):
|
||
monkeypatch.delenv(key, raising=False)
|
||
monkeypatch.setenv("OPENAI_API_KEY", "k")
|
||
(tmp_path / "config.yaml").write_text(yaml.safe_dump(
|
||
{"image_gen": {"openai": {"base_url": "http://localhost:18081/v1"}}}))
|
||
fake_client = MagicMock()
|
||
fake_client.images.generate.return_value = _fake_response(b64=_b64_png())
|
||
sys_proxy = {"http": "http://sysproxy:3128", "https": "http://sysproxy:3128"}
|
||
|
||
def proxy_mounts(client):
|
||
return [m for m in client._mounts.values()
|
||
if type(getattr(m, "_pool", None)).__name__ == "HTTPProxy"]
|
||
|
||
with patch("httpx._utils.getproxies", return_value=sys_proxy), _patched_openai(fake_client):
|
||
with httpx.Client() as control:
|
||
assert len(proxy_mounts(control)) == 2 # the fake system proxy IS visible to httpx
|
||
assert openai_plugin.OpenAIImageGenProvider().generate("a cat")["success"] is True
|
||
http_client = __import__("sys").modules["openai"].OpenAI.call_args.kwargs["http_client"]
|
||
assert isinstance(http_client, httpx.Client)
|
||
assert proxy_mounts(http_client) == []
|
||
http_client.close()
|
||
|
||
|
||
# ── 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" / "generated" / "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("has_image", [True, False])
|
||
def test_token_usage_reaches_session_accounting(self, provider, has_image):
|
||
"""gpt-image bills per token: the Images API ``usage`` block lands as one
|
||
``image_generation`` row keyed on the API model, not the Hermes tier label — also
|
||
when the billed HTTP 200 carries no image data."""
|
||
from agent import aux_accounting
|
||
|
||
recorded = []
|
||
|
||
class _DB:
|
||
def record_auxiliary_usage(self, *args, **kwargs):
|
||
recorded.append((args, kwargs))
|
||
|
||
response = _fake_response(b64=_b64_png())
|
||
if not has_image:
|
||
response.data = []
|
||
response.usage = SimpleNamespace(input_tokens=23, output_tokens=1056, total_tokens=1079)
|
||
fake_client = MagicMock()
|
||
fake_client.images.generate.return_value = response
|
||
token = aux_accounting.set_accounting_context(_DB(), "sess-1")
|
||
try:
|
||
with _patched_openai(fake_client):
|
||
result = provider.generate("a cat", aspect_ratio="landscape")
|
||
finally:
|
||
aux_accounting.reset_accounting_context(token)
|
||
|
||
assert result["success"] is has_image
|
||
if not has_image:
|
||
assert result["error_type"] == "empty_response"
|
||
((session_id, task), kwargs), = recorded
|
||
assert (session_id, task) == ("sess-1", "image_generation")
|
||
assert (kwargs["model"], kwargs["billing_provider"]) == ("gpt-image-2", "openai")
|
||
assert (kwargs["input_tokens"], kwargs["output_tokens"]) == (23, 1056)
|
||
|
||
@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 hermes_yaml as 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(str(Path("/")))
|
||
assert "example.com" not in result["image"]
|
||
mock_save_url.assert_called_once()
|
||
|