Files
hermes-agent/tests/plugins/image_gen/test_openai_codex_provider.py
T
teknium1 3275ca88ec fix(image_gen): Codex-auth images use the native images endpoints, no chat host model
The openai-codex image provider rode a Responses call with a hosted
image_generation tool on a pinned chat model (gpt-5.5). Two failure
classes came with that shape: when OpenAI withdrew gpt-5.5 from an
account cohort every image call 404'd while chat kept working
(#105398, #107076), and the host model was free to answer in text
instead of calling the tool, so we streamed SSE, kept partial frames
and retried on empty streams.

Post to chatgpt.com/backend-api/codex/images/generations and
images/edits instead - the route the official Codex client uses
(codex-rs/ext/image-generation). No host model, no SSE, no
partial-frame handling; the response is a plain JSON body with
b64_json. Remote source URLs are fetched client-side and inlined as
data URLs because the backend's own downloader 400s on ordinary
public images.

The backend treats model/quality/size as advisory (#107233), so the
result now reports reported_quality/reported_size next to the
requested values plus the x-codex-imagegen-request-id for support.
GPT Image 2.5 is deliberately not added to this catalog: the backend
accepts any model id, including nonexistent ones, and generates with
its server-managed engine (C2PA reports gpt-image 2.0), so a 2.5 tier
here would be a label with no effect (#106708).
2026-09-14 10:18:13 -07:00

242 lines
10 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-codex`` image_gen plugin.
Mirrors ``test_openai_provider.py`` but targets the ChatGPT-OAuth-backed provider that posts to
the Codex backend's native ``images/generations`` / ``images/edits`` endpoints (the route the
official Codex client uses) — no chat host model, no hosted-tool SSE stream (#105398, #107076).
"""
from __future__ import annotations
import base64
import importlib
import json
from pathlib import Path
import httpx
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 _png_bytes() -> bytes:
return bytes.fromhex(_PNG_HEX)
def _b64_png() -> str:
return base64.b64encode(_png_bytes()).decode()
@pytest.fixture(autouse=True)
def _tmp_hermes_home(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.delenv("OPENAI_IMAGE_MODEL", raising=False)
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()
@pytest.fixture
def codex_backend(monkeypatch):
"""Route the plugin's ``httpx.Client`` at a fake Codex images backend; returns the request log
and lets a test swap the response via ``state["respond"]``."""
monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
state = {"requests": [], "respond": None}
def _default(request):
return httpx.Response(200, json={
"created": 1, "data": [{"b64_json": _b64_png(), "generation_id": "gen_1"}],
"background": "opaque", "output_format": "png", "quality": "low", "size": "1254x1254",
}, headers={"x-codex-imagegen-request-id": "req_abc"}, request=request)
def _handler(request):
state["requests"].append(request)
return (state["respond"] or _default)(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")),
)
return state
# ── 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 "hermes auth codex" in schema["post_setup_hint"]
# ── Availability ────────────────────────────────────────────────────────────
class TestAvailability:
def test_unavailable_without_codex_token(self, monkeypatch):
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.setattr(codex_plugin, "_read_codex_access_token", lambda: "tok")
assert codex_plugin.OpenAICodexImageGenProvider().is_available() is True
def test_openai_api_key_alone_is_not_enough(self, monkeypatch):
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
# ── Generation ──────────────────────────────────────────────────────────────
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_text_to_image_posts_generations_with_no_host_model(self, provider, codex_backend, tmp_path):
result = provider.generate("a cat", aspect_ratio="portrait")
assert result["success"] is True
assert result["model"] == "gpt-image-2-medium"
assert result["provider"] == "openai-codex"
assert result["quality"] == "medium"
assert result["pixel_size"] == "1x1"
# Backend-reported values travel separately from what we asked for (#107233).
assert result["reported_quality"] == "low"
assert result["reported_size"] == "1254x1254"
assert result["imagegen_request_id"] == "req_abc"
saved = Path(result["image"])
assert saved.exists() and saved.parent == tmp_path / "cache" / "images"
assert saved.name.startswith("openai_codex_")
(request,) = codex_backend["requests"]
assert request.url.path.endswith("/backend-api/codex/images/generations")
assert request.headers["Authorization"] == "Bearer codex-token"
assert request.headers["x-codex-image-turn-id"]
body = json.loads(request.content)
assert body == {
"prompt": "a cat", "model": "gpt-image-2", "n": 1, "quality": "medium",
"size": "1024x1536", "background": "opaque",
}
# The whole point of the native route: nothing about a chat model in the request.
assert not any(key in body for key in ("tools", "input", "instructions"))
def test_source_images_post_edits_with_inline_data_urls(self, provider, codex_backend, tmp_path):
local = tmp_path / "ref.png"
local.write_bytes(_png_bytes())
data_url = "data:image/png;base64," + _b64_png()
result = provider.generate("edit these", image_url=str(local), reference_image_urls=[data_url])
assert result["success"] is True
assert result["modality"] == "image"
assert result["input_image_count"] == 2
(request,) = codex_backend["requests"]
assert request.url.path.endswith("/backend-api/codex/images/edits")
body = json.loads(request.content)
assert [img["image_url"] for img in body["images"]] == [data_url, data_url]
def test_remote_source_url_is_fetched_and_inlined(self, provider, codex_backend, monkeypatch):
# The backend's own URL downloader 400s on ordinary public images; we fetch client-side.
monkeypatch.setattr(
httpx, "get",
lambda url, **kw: httpx.Response(200, content=_png_bytes(), request=httpx.Request("GET", url)))
result = provider.generate("edit", image_url="https://example.com/ref.png")
assert result["success"] is True
body = json.loads(codex_backend["requests"][0].content)
assert body["images"] == [{"image_url": "data:image/png;base64," + _b64_png()}]
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, codex_backend, tmp_path):
text_path = tmp_path / "not-image.txt"
text_path.write_text("hello", encoding="utf-8")
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"]
assert codex_backend["requests"] == []
def test_http_error_message_surfaces_verbatim_and_bounded(self, provider, codex_backend):
body = json.dumps({
"metadata": "x" * 600,
"error": {"message": "Missing required parameter: 'prompt'.", "type": "invalid_request_error"},
})
codex_backend["respond"] = lambda request: httpx.Response(400, text=body, request=request)
result = provider.generate("a cat")
assert result["success"] is False
assert result["error_type"] == "api_error"
assert "HTTP 400" in result["error"]
assert "Missing required parameter: 'prompt'." in result["error"]
assert len(result["error"]) < len(body)
def test_missing_image_data_is_empty_response(self, provider, codex_backend):
codex_backend["respond"] = lambda request: httpx.Response(
200, json={"created": 1, "data": []}, request=request)
result = provider.generate("a cat")
assert result["success"] is False
assert result["error_type"] == "empty_response"
# ── 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"