3275ca88ec
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).
242 lines
10 KiB
Python
242 lines
10 KiB
Python
"""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"
|