# NousResearch/hermes-agent · Codex image generation backend: integration, errors, and gating

## Open now

- Nothing open on GitHub.

These items concern adding Codex as an image generation backend (gpt-image-2 via Codex OAuth), along with bug reports and fixes related to unsupported tool_choice parameters causing HTTP 400 errors and empty responses when Codex lacks image generation capability.

## TL;DR

- 11 member(s): 0 open issue(s), 4 closed issue(s), 0 open PR(s), 2 merged PR(s), 5 PR(s) closed without merging.
- A fix was merged to main in PR #14317. Verify it addresses your case.
- **Recommended:** read PR #14317 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `partially_resolved` · deciding PR(s): `pr-14317`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
- Members outside that structural core may belong to a different underlying problem.
- Pairwise link density `0.15`: the share of member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #11195: \[Feature\]: Add Codex as an image generation backend
- #19505: bug(image-gen): openai-codex gpt-image-2 fails with tool_choice 400, then empty_response without image_generation_call
- #31335: hermes-codex-bug-report
- #49008: openai-codex image gen plugin: Codex backend rejects image_generation tool_choice (HTTP 400)

## Prior & competing PRs

- **#14317: feat(image_gen): add openai-codex plugin (gpt-image-2 via Codex OAuth)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `plugins/image_gen/openai-codex/__init__.py`, `plugins/image_gen/openai-codex/plugin.yaml`, `tests/plugins/image_gen/test_openai_codex_provider.py`
  - fix hunks:
  - `plugins/image_gen/openai-codex/__init__.py`

    ```diff
    @@ -0,0 +1,378 @@
    +"""OpenAI image generation backend — ChatGPT/Codex OAuth variant.
    +
    +Identical model catalog and tier semantics to the ``openai`` image-gen plugin
    +(``gpt-image-2`` at low/medium/high quality), but routes the request through
    +the Codex Responses API ``image_generation`` tool instead of the
    +``images.generate`` REST endpoint. This lets users who are already
    +authenticated with Codex/ChatGPT generate images without configuring a
    +separate ``OPENAI_API_KEY``.
    +
    +Selection precedence for the tier (first hit wins):
    +
    +1. ``OPENAI_IMAGE_MODEL`` env var (escape hatch for scripts / tests)
    +2. ``image_gen.openai-codex.model`` in ``config.yaml``
    +3. ``image_gen.model`` in ``config.yaml`` (when it's one of our tier IDs)
    +4. :data:`DEFAULT_MODEL` — ``gpt-image-2-medium``
    +
    +Output is saved as PNG under ``$HERMES_HOME/cache/images/``.
    +"""
    +
    +from __future__ import annotations
    +
    +import logging
    +from typing import Any, Dict, List, Optional, Tuple
    +
    +from agent.image_gen_provider import (
    +    DEFAULT_ASPECT_RATIO,
    +    ImageGenProvider,
    +    error_response,
    +    resolve_aspect_ratio,
    +    save_b64_image,
    +    success_response,
    +)
    +
    +logger = logging.getLogger(__name__)
    +
    +
    +# ---------------------------------------------------------------------------
    +# Model catalog — mirrors the ``openai`` plugin so the picker UX is identical.
    +# ---------------------------------------------------------------------------
    +
    +API_MODEL = "gpt-image-2"
    +
    +_MODELS: Dict[str, Dict[str, Any]] = {
    +    "gpt-image-2-low": {
    +        "display": "GPT Image 2 (Low)",
    +        "speed": "~15s",
    +        "strengths": "Fast iteration, lowest cost",
    +        "quality": "low",
    +    },
    +    "gpt-image-2-medium": {
    +        "display": "GPT Image 2 (Medium)",
    +        "speed": "~40s",
    +        "strengths": "Balanced — default",
    +        "quality": "medium",
    +    },
    +    "gpt-image-2-high": {
    +        "display": "GPT Image 2 (High)",
    +        "speed": "~2min",
    +        "strengths": "Highest fidelity, strongest prompt adherence",
    +        "quality": "high",
    +    },
    +}
    +
    +DEFAULT_MODEL = "gpt-image-2-medium"
    +
    +_SIZES = {
    +    "landscape": "1536x1024",
    +    "square": "1024x1024",
    +    "portrait": "1024x1536",
    +}
    +
    +# Codex Responses surface used for the request. The chat model itself is only
    +# the host that calls the ``image_generation`` tool; the actual image work is
    +# done by ``API_MODEL``.
    +_CODEX_CHAT_MODEL = "gpt-5.4"
    +_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
    +_CODEX_INSTRUCTIONS = (
    +    "You are an assistant that must fulfill image generation requests by "
    +    "using the image_generation tool when provided."
    +)
    +
    +
    +# ---------------------------------------------------------------------------
    +# Config + auth helpers
    +# ---------------------------------------------------------------------------
    +
    +
    +def _load_image_gen_config() -> Dict[str, Any]:
    +    """Read ``image_gen`` from config.yaml (returns {} on any failure)."""
    +    try:
    +        from hermes_cli.config import load_config
    +
    +        cfg = load_config()
    +        section = cfg.get("image_gen") if isinstance(cfg, dict) else None
    +        return section if isinstance(section, dict) else {}
    +    except Exception as exc:
    +        logger.debug("Could not load image_gen config: %s", exc)
    +        return {}
    +
    +
    +def _resolve_model() -> Tuple[str, Dict[str, Any]]:
    +    """Decide which tier to use and return ``(model_id, meta)``."""
    +    import os
    +
    +    env_override = os.environ.get("OPENAI_IMAGE_MODEL")
    +    if env_override and env_override in _MODELS:
    +        return env_override, _MODELS[env_override]
    +
    +    cfg = _load_image_gen_config()
    +    sub = cfg.get("openai-codex") if isinstance(cfg.get("openai-codex"), dict) else {}
    +    candidate: Optional[str] = None
    +    if isinstance(sub, dict):
    +        value = sub.get("model")
    +        if isinstance(value, str) and value in _MODELS:
    +            candidate = value
    +    if candidate is None:
    +        top = cfg.get("model")
    +        if isinstance(top, str) and top in _MODELS:
    +            candidate = top
    +
    +    if candidate is not None:
    +        return candidate, _MODELS[candidate]
    +
    +    return DEFAULT_MODEL, _MODELS[DEFAULT_MODEL]
    +
    +
    +def _read_codex_access_token() -> Optional[str]:
    +    """Return a usable Codex OAuth token, or None.
    +
    +    Delegates to the canonical reader in ``agent.auxiliary_client`` so token
    +    expiry, credential pool selection, and JWT decoding stay in one place.
    +    """
    +    try:
    +        from agent.auxiliary_client import _read_codex_access_token as _reader
    +
    +        token = [redacted:secret-assignment]
    +        if isinstance(token, str) and token.strip():
    +            return token.strip()
    +        return None
    +    except Exception as exc:
    +        logger.debug("Could not resolve Codex access token: %s", exc)
    +        return None
    +
    +
    +def _build_codex_client():
    +    """Return an OpenAI client pointed at the ChatGPT/Codex backend, or None."""
    +    token = [redacted:secret-assignment]
    +    if not token:
    +        return None
    +    try:
    +        import openai
    +        from agent.auxiliary_client import _codex_cloudflare_headers
    +
    +        return openai.OpenAI(
    +            api_key=[redacted:secret-assignment]
    +            base_url=_CODEX_BASE_URL,
    +            default_headers=_codex_cloudflare_headers(token),
    +        )
    +    except Exception as exc:
    +        logger.debug("Could not build Codex image client: %s", exc)
    +        return None
    +
    +
    +def _collect_image_b64(client: Any, *, prompt: str, size: str, quality: str) -> Optional[str]:
    +    """Stream a Codex Responses image_generation call and return the b64 image."""
    +    image_b64: Optional[str] = None
    +
    +    with client.responses.stream(
    +        model=_CODEX_CHAT_MODEL,
    +        store=False,
    +        instructions=_CODEX_INSTRUCTIONS,
    +        input=[{
    +            "type": "message",
    +            "role": "user",
    +            "content": [{"type": "input_text", "text": prompt}],
    +        }],
    +        tools=[{
    +            "type": "image_generation",
    +            "model": API_MODEL,
    +            "size": size,
    +            "quality": quality,
    +            "output_format": "png",
    +            "background": "opaque",
    +            "partial_images": 1,
    +        }],
    +        tool_choice={
    +            "type": "allowed_tools",
    +            "mode": "required",
    +            "tools": [{"type": "image_generation"}],
    +        },
    +    ) as stream:
    +        for event in stream:
    +            event_type = getattr(event, "type", "")
    +            if event_type == "response.output_item.done":
    +                item = getattr(event, "item", None)
    +                if getattr(item, "type", None) == "image_generation_call":
    +                    result = getattr(item, "result", None)
    +                    if isinstance(result, str) and result:
    +                        image_b64 = result
    +            elif event_type == "response.image_generation_call.partial_image":
    +                partial = getattr(event, "partial_image_b64", None)
    +                if isinstance(partial, str) and partial:
    +                    image_b64 = partial
    +        final = stream.get_final_response()
    +
    +    # Final-response sweep covers the case where the stream finished before
    +    # we observed the ``output_item.done`` event for the image call.
    +    for item in getattr(final, "output", None) or []:
    +        if getattr(item, "type", None) == "image_generation_call":
    +            result = getattr(item, "result", None)
    +            if isinstance(result, str) and result:
    +                image_b64 = result
    +
    +    return image_b64
    +
    +
    +# ---------------------------------------------------------------------------
    +# Provider
    +# ---------------------------------------------------------------------------
    +
    +
    +class OpenAICodexImageGenProvider(ImageGenProvider):
    +    """gpt-image-2 routed through ChatGPT/Codex OAuth instead of an API key."""
    +
    +    @property
    +    def name(self) -> str:
    +        return "openai-codex"
    +
    +    @property
    +    def display_name(self) -> str:
    +        return "OpenAI (Codex auth)"
    +
    +    def is_available(self) -> bool:
    +        if not _read_codex_access_token():
    +            return False
    +        try:
    +            import openai  # noqa: F401
    +        except ImportError:
    +            return False
    +        return True
    +
    +    def list_models(self) -> List[Dict[str, Any]]:
    +        return [
    +            {
    +                "id": model_id,
    +                "display": meta["display"],
    +                "speed": meta["speed"],
    +                "strengths": meta["strengths"],
    +                "price": "varies",
    +            }
    +            for model_id, meta in _MODELS.items()
    +        ]
    +
    +    def default_model(self) -> Optional[str]:
    +        return DEFAULT_MODEL
    +
    +    def get_setup_schema(self) -> Dict[str, Any]:
    +        return {
    +            "name": "OpenAI (Codex auth)",
    +            "badge": "free",
    +            "tag": "gpt-image-2 via ChatGPT/Codex OAuth — no API key required",
    +            "env_vars": [],
    +            "post_setup_hint": (
    +                "Sign in with `hermes auth codex` (or `hermes setup` → Codex) "
    +                "if you haven't already. No API key needed."
    +            ),
    +        }
    +
    +    def generate(
    +        self,
    +        prompt: str,
    +        aspect_ratio: str = DEFAULT_ASPECT_RATIO,
    +        **kwargs: Any,
    +    ) -> Dict[str, Any]:
    +        prompt = (prompt or "").strip()
    +        aspect = resolve_aspect_ratio(aspect_ratio)
    +
    +        if not prompt:
    +            return error_response(
    +                error="Prompt is required and must be a non-empty string",
    +                error_type="invalid_argument",
    +                provider="openai-codex",
    +                aspect_ratio=aspect,
    +            )
    +
    +        if not _read_codex_access_token():
    +            return error_response(
    +                error=(
    +                    "No Codex/ChatGPT OAuth credentials available. Run "
    +                    "`hermes auth codex` (or `hermes setup` → Codex) to sign in."
    +                ),
    +                error_type="auth_required",
    +                provider="openai-codex",
    +                aspect_ratio=aspect,
    +            )
    +
    +        try:
    +            import openai  # noqa: F401
    +        except ImportError:
    +            return error_response(
    +                error="openai Python package not installed (pip install openai)",
    +                error_type="missing_dependency",
    +                provider="openai-codex",
    +                aspect_ratio=aspect,
    +            )
    +
    +        tier_id, meta = _resolve_model()
    +        size = _SIZES.get(aspect, _SIZES["square"])
    +
    +        client = _build_codex_client()
    +        if client is None:
    +            return error_response(
    +                error="Could not initialize Codex image client",
    +                error_type="auth_required",
    +                provider="openai-codex",
    +                model=tier_id,
    +                prompt=prompt,
    +                aspect_ratio=aspect,
    +            )
    +
    +        try:
    +            b64 = _collect_image_b64(
    +                client,
    +                prompt=prompt,
    +                size=size,
    +                quality=meta["quality"],
    +            )
    +        except Exception as exc:
    +            logger.debug("Codex image generation failed", exc_info=True)
    +            return error_response(
    +                error=f"OpenAI image generation via Codex auth failed: {exc}",
    +                error_type="api_error",
    +                provider="openai-codex",
    +                model=tier_id,
    +                prompt=prompt,
    +                aspect_ratio=aspect,
    +            )
    +
    +        if not b64:
    +            return error_response(
    +                error="Codex response contained no image_generation_call result",
    +                error_type="empty_response",
    +                provider="openai-codex",
    +                model=tier_id,
    +                prompt=prompt,
    +                aspect_ratio=aspect,
    +            )
    +
    +        try:
    +            saved_path = save_b64_image(b64, prefix=f"openai_codex_{tier_id}")
    +        except Exception as exc:
    +            return error_response(
    +                error=f"Could not save image to cache: {exc}",
    +                error_type="io_error",
    +                provider="openai-codex",
    +                model=tier_id,
    +                prompt=prompt,
    +                aspect_ratio=aspect,
    +            )
    +
    +        return success_response(
    +            image=str(saved_path),
    +            model=tier_id,
    +            prompt=prompt,
    +            aspect_ratio=aspect,
    +            provider="openai-codex",
    +            extra={"size": size, "quality": meta["quality"]},
    +        )
    +
    +
    +# ---------------------------------------------------------------------------
    +# Plugin entry point
    +# ---------------------------------------------------------------------------
    +
    +
    +def register(ctx) -> None:
    +    """Plugin entry point — register the Codex-backed image-gen provider."""
    +    ctx.register_image_gen_provider(OpenAICodexImageGenProvider())
    ```
  - `plugins/image_gen/openai-codex/plugin.yaml`

    ```diff
    @@ -0,0 +1,5 @@
    +name: openai-codex
    +version: 1.0.0
    +description: "OpenAI image generation backed by ChatGPT/Codex OAuth (gpt-image-2 via the Responses image_generation tool). Saves generated images to $HERMES_HOME/cache/images/."
    +author: NousResearch
    +kind: backend
    ```
  - `tests/plugins/image_gen/test_openai_codex_provider.py`

    ```diff
    @@ -0,0 +1,299 @@
    +"""Tests for the bundled ``openai-codex`` image_gen plugin.
    +
    +Mirrors ``test_openai_provider.py`` but targets the standalone
    +Codex/ChatGPT-OAuth-backed provider that uses the Responses
    +``image_generation`` tool path instead of the ``images.generate`` REST
    +endpoint.
    +"""
    +
    +from __future__ import annotations
    +
    +import importlib
    +from pathlib import Path
    +from types import SimpleNamespace
    +
    +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 _b64_png() -> str:
    +    import base64
    +    return base64.b64encode(bytes.fromhex(_PNG_HEX)).decode()
    +
    +
    +class _FakeStream:
    +    def __init__(self, events, final_response):
    +        self._events = list(events)
    +        self._final = final_response
    +
    +    def __enter__(self):
    +        return self
    +
    +    def __exit__(self, exc_type, exc, tb):
    +        return False
    +
    +    def __iter__(self):
    +        return iter(self._events)
    +
    +    def get_final_response(self):
    +        return self._final
    +
    +
    [redacted:email](autouse=True)
    +def _tmp_hermes_home(tmp_path, monkeypatch):
    +    monkeypatch.setenv("HERMES_HOME", str(tmp_path))
    +    yield tmp_path
    +
    +
    [redacted:email]
    +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()
    +
    +
    +# ── 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 schema["badge"] == "free"
    +
    +
    +# ── Availability ────────────────────────────────────────────────────────────
    +
    +
    +class TestAvailability:
    +    def test_unavailable_without_codex_token(self, monkeypatch):
    +        monkeypatch.delenv("OPENAI_API_KEY", raising=False)
    +        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.delenv("OPENAI_API_KEY", raising=False)
    +        monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
    +        assert codex_plugin.OpenAICodexImageGenProvider().is_available() is True
    +
    +    def test_openai_api_key_alone_is_not_enough(self, monkeypatch):
    +        # Codex plugin is intentionally orthogonal to the API-key plugin —
    +        # the API key alone must NOT make it appear available.
    +        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
    +
    +
    +# ── Generate ────────────────────────────────────────────────────────────────
    +
    +
    +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_returns_invalid_argument_for_empty_prompt(self, provider, monkeypatch):
    +        monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
    +        result = provider.generate("   ")
    +        assert result["success"] is False
    +        assert result["error_type"] == "invalid_argument"
    +
    +    def test_generate_uses_codex_stream_path(self, provider, monkeypatch, tmp_path):
    +        monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
    +
    +        output_item = SimpleNamespace(
    +            type="image_generation_call",
    +            status="generating",
    +            id="ig_test",
    +            result=_b64_png(),
    +        )
    +        done_event = SimpleNamespace(type="response.output_item.done", item=output_item)
    +        final_response = SimpleNamespace(output=[], status="completed", output_text="")
    +
    +        fake_client = SimpleNamespace(
    +            responses=SimpleNamespace(
    +                stream=lambda **kwargs: _FakeStream([done_event], final_response)
    +            )
    +        )
    +        monkeypatch.setattr(codex_plugin, "_build_codex_client", lambda: fake_client)
    +
    +        result = provider.generate("a cat", aspect_ratio="landscape")
    +
    +        assert result["success"] is True
    +        assert result["model"] == "gpt-image-2-medium"
    +        assert result["provider"] == "openai-codex"
    +        assert result["quality"] == "medium"
    +
    +        saved = Path(result["image"])
    +        assert saved.exists()
    +        assert saved.parent == tmp_path / "cache" / "images"
    +        # Filename prefix differs from the API-key plugin so cache audits can
    +        # tell the two backends apart.
    +        assert saved.name.startswith("openai_codex_")
    +
    +    def test_codex_stream_request_shape(self, provider, monkeypatch):
    +        monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
    +
    +        captured = {}
    +
    +        def _stream(**kwargs):
    +            captured.update(kwargs)
    +            output_item = SimpleNamespace(
    +                type="image_generation_call",
    +                status="generating",
    +                id="ig_test",
    +                result=_b64_png(),
    +            )
    +            done_event = SimpleNamespace(type="response.output_item.done", item=output_item)
    +            final_response = SimpleNamespace(output=[], status="completed", output_text="")
    +            return _FakeStream([done_event], final_response)
    +
    +        fake_client = SimpleNamespace(responses=SimpleNamespace(stream=_stream))
    +        monkeypatch.setattr(codex_plugin, "_build_codex_client", lambda: fake_client)
    +
    +        result = provider.generate("a cat", aspect_ratio="portrait")
    +        assert result["success"] is True
    +
    +        assert captured["model"] == "gpt-5.4"
    +        assert captured["store"] is False
    +        assert captured["input"][0]["type"] == "message"
    +        assert captured["input"][0]["role"] == "user"
    +        assert captured["input"][0]["content"][0]["type"] == "input_text"
    +        assert captured["tool_choice"]["type"] == "allowed_tools"
    +        assert captured["tool_choice"]["mode"] == "required"
    +        assert captured["tool_choice"]["tools"] == [{"type": "image_generation"}]
    +
    +        tool = captured["tools"][0]
    +        assert tool["type"] == "image_generation"
    +        assert tool["model"] == "gpt-image-2"
    +        assert tool["quality"] == "medium"
    +        assert tool["size"] == "1024x1536"
    +        assert tool["output_format"] == "png"
    +        assert tool["background"] == "opaque"
    +        assert tool["partial_images"] == 1
    +
    +    def test_partial_image_event_used_when_done_missing(self, provider, monkeypatch):
    +        """If the stream never emits output_item.done, fall back to the
    +        partial_image event so users at least get the latest preview frame."""
    +        monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
    +
    +        partial_event = SimpleNamespace(
    +            type="response.image_generation_call.partial_image",
    +            partial_image_b64=_b64_png(),
    +        )
    +        final_response = SimpleNamespace(output=[], status="completed", output_text="")
    +
    +        fake_client = SimpleNamespace(
    +            responses=SimpleNamespace(
    +                stream=lambda **kwargs: _FakeStream([partial_event], final_response)
    +            )
    +        )
    +        monkeypatch.setattr(codex_plugin, "_build_codex_client", lambda: fake_client)
    +
    +        result = provider.generate("a cat")
    +        assert result["success"] is True
    +        assert Path(result["image"]).exists()
    +
    +    def test_final_response_sweep_recovers_image(self, provider, monkeypatch):
    +        """If no image_generation_call event arrives mid-stream, the
    +        post-stream final-response sweep should still find the image."""
    +        monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
    +
    +        final_item = SimpleNamespace(
    +            type="image_generation_call",
    +            status="completed",
    +            id="ig_final",
    +            result=_b64_png(),
    +        )
    +        final_response = SimpleNamespace(output=[final_item], status="completed", output_text="")
    +
    +        fake_client = SimpleNamespace(
    +            responses=SimpleNamespace(
    +                stream=lambda **kwargs: _FakeStream([], final_response)
    +            )
    +        )
    +        monkeypatch.setattr(codex_plugin, "_build_codex_client", lambda: fake_client)
    +
    +        result = provider.generate("a cat")
    +        assert result["success"] is True
    +        assert Path(result["image"]).exists()
    +
    +    def test_empty_response_returns_error(self, provider, monkeypatch):
    +        monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
    +
    +        final_response = SimpleNamespace(output=[], status="completed", output_text="")
    +        fake_client = SimpleNamespace(
    +            responses=SimpleNamespace(
    +                stream=lambda **kwargs: _FakeStream([], final_response)
    +            )
    +        )
    +        monkeypatch.setattr(codex_plugin, "_build_codex_client", lambda: fake_client)
    +
    +        result = provider.generate("a cat")
    +        assert result["success"] is False
    +        assert result["error_type"] == "empty_response"
    +
    +    def test_client_init_failure_returns_auth_error(self, provider, monkeypatch):
    +        monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
    +        monkeypatch.setattr(codex_plugin, "_build_codex_client", lambda: None)
    +
    +        result = provider.generate("a cat")
    +        assert result["success"] is False
    +        assert result["error_type"] == "auth_required"
    +
    +    def test_stream_exception_returns_api_error(self, provider, monkeypatch):
    +        monkeypatch.setattr(codex_plugin, "_read_codex_access_token", lambda: "codex-token")
    +
    +        def _boom(**kwargs):
    +            raise RuntimeError("cloudflare 403")
    +
    +        fake_client = SimpleNamespace(responses=SimpleNamespace(stream=_boom))
    +        monkeypatch.setattr(codex_plugin, "_build_codex_client", lambda: fake_client)
    +
    +        result = provider.generate("a cat")
    +        assert result["success"] is False
    +        assert result["error_type"] == "api_error"
    +        assert "cloudflare 403" in result["error"]
    +
    +
    +# ── 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"
    ```
- **#71635: fix(image-gen): unbreak Codex-auth image generation and stop blaming the user's account**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `plugins/image_gen/openai-codex/__init__.py`, `tests/plugins/image_gen/test_openai_codex_provider.py`, `website/docs/user-guide/features/image-generation.md`
- **#14047: fix(image_gen/openai): support Codex auth fallback in OpenAI provider**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#14317`
- **#49054: fix(plugins): surface clear error when Codex backend lacks image generation**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `402969670d`, `064b6e40c5`, `#71635`, `#19979`
- **#49065: fix(image_gen): gate openai-codex image generation on Codex tool support**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `402969670d`, `064b6e40c5`, `#71635`, `#19979`
- **#49161: fix(plugins): remove unsupported tool_choice from codex image gen (#49008)**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `402969670d`, `064b6e40c5`, `#71635`, `#19979`
- **#50579: fix(codex): gate unsupported image_generation with a clear error**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `402969670d`, `064b6e40c5`, `#71635`, `#19979`

## Fix status

- Merged to main in PR #14317 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-25`.
- Subsystem: `agent`
