Files
hermes-agent/tests/plugins/image_gen/test_openai_provider.py
finn763 823385e5cb fix(agent): keep generated images/videos out of swept media caches (Closes #126445)
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)
2026-10-02 01:18:03 +05:30

381 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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()