{"repo":"NousResearch/hermes-agent","public_id":"11195","display_label":"Codex image generation backend: integration, errors, and gating","summary":"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.","subsystem":"agent","counts":{"members":11,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":4,"merged_prs":2,"attempt_prs":5},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":14317},"resolution":{"state":"partially_resolved","by":"pr-14317"},"freshness":{"state":"verified","last_verified_at":"2026-07-25T23:41:57+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.15345454545454548},"fix_edges":[{"issue_number":11195,"pr_number":14317,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true},{"issue_number":49008,"pr_number":71635,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":11195,"title":"[Feature]: Add Codex as an image generation backend","url":null,"state":"closed"},{"kind":"issue","number":19505,"title":"bug(image-gen): openai-codex gpt-image-2 fails with tool_choice 400, then empty_response without image_generation_call","url":null,"state":"closed"},{"kind":"issue","number":31335,"title":"hermes-codex-bug-report","url":null,"state":"closed"},{"kind":"issue","number":49008,"title":"openai-codex image gen plugin: Codex backend rejects image_generation tool_choice (HTTP 400)","url":null,"state":"closed"},{"kind":"pr","number":14047,"title":"fix(image_gen/openai): support Codex auth fallback in OpenAI provider","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#14317","number":14317}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":14317,"title":"feat(image_gen): add openai-codex plugin (gpt-image-2 via Codex OAuth)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["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":[{"file":"plugins/image_gen/openai-codex/__init__.py","hunks":["@@ -0,0 +1,378 @@\n+\"\"\"OpenAI image generation backend — ChatGPT/Codex OAuth variant.\n+\n+Identical model catalog and tier semantics to the ``openai`` image-gen plugin\n+(``gpt-image-2`` at low/medium/high quality), but routes the request through\n+the Codex Responses API ``image_generation`` tool instead of the\n+``images.generate`` REST endpoint. This lets users who are already\n+authenticated with Codex/ChatGPT generate images without configuring a\n+separate ``OPENAI_API_KEY``.\n+\n+Selection precedence for the tier (first hit wins):\n+\n+1. ``OPENAI_IMAGE_MODEL`` env var (escape hatch for scripts / tests)\n+2. ``image_gen.openai-codex.model`` in ``config.yaml``\n+3. ``image_gen.model`` in ``config.yaml`` (when it's one of our tier IDs)\n+4. :data:`DEFAULT_MODEL` — ``gpt-image-2-medium``\n+\n+Output is saved as PNG under ``$HERMES_HOME/cache/images/``.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import logging\n+from typing import Any, Dict, List, Optional, Tuple\n+\n+from agent.image_gen_provider import (\n+    DEFAULT_ASPECT_RATIO,\n+    ImageGenProvider,\n+    error_response,\n+    resolve_aspect_ratio,\n+    save_b64_image,\n+    success_response,\n+)\n+\n+logger = logging.getLogger(__name__)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Model catalog — mirrors the ``openai`` plugin so the picker UX is identical.\n+# ---------------------------------------------------------------------------\n+\n+API_MODEL = \"gpt-image-2\"\n+\n+_MODELS: Dict[str, Dict[str, Any]] = {\n+    \"gpt-image-2-low\": {\n+        \"display\": \"GPT Image 2 (Low)\",\n+        \"speed\": \"~15s\",\n+        \"strengths\": \"Fast iteration, lowest cost\",\n+        \"quality\": \"low\",\n+    },\n+    \"gpt-image-2-medium\": {\n+        \"display\": \"GPT Image 2 (Medium)\",\n+        \"speed\": \"~40s\",\n+        \"strengths\": \"Balanced — default\",\n+        \"quality\": \"medium\",\n+    },\n+    \"gpt-image-2-high\": {\n+        \"display\": \"GPT Image 2 (High)\",\n+        \"speed\": \"~2min\",\n+        \"strengths\": \"Highest fidelity, strongest prompt adherence\",\n+        \"quality\": \"high\",\n+    },\n+}\n+\n+DEFAULT_MODEL = \"gpt-image-2-medium\"\n+\n+_SIZES = {\n+    \"landscape\": \"1536x1024\",\n+    \"square\": \"1024x1024\",\n+    \"portrait\": \"1024x1536\",\n+}\n+\n+# Codex Responses surface used for the request. The chat model itself is only\n+# the host that calls the ``image_generation`` tool; the actual image work is\n+# done by ``API_MODEL``.\n+_CODEX_CHAT_MODEL = \"gpt-5.4\"\n+_CODEX_BASE_URL = \"https://chatgpt.com/backend-api/codex\"\n+_CODEX_INSTRUCTIONS = (\n+    \"You are an assistant that must fulfill image generation requests by \"\n+    \"using the image_generation tool when provided.\"\n+)\n+\n+\n+# ---------------------------------------------------------------------------\n+# Config + auth helpers\n+# ---------------------------------------------------------------------------\n+\n+\n+def _load_image_gen_config() -> Dict[str, Any]:\n+    \"\"\"Read ``image_gen`` from config.yaml (returns {} on any failure).\"\"\"\n+    try:\n+        from hermes_cli.config import load_config\n+\n+        cfg = load_config()\n+        section = cfg.get(\"image_gen\") if isinstance(cfg, dict) else None\n+        return section if isinstance(section, dict) else {}\n+    except Exception as exc:\n+        logger.debug(\"Could not load image_gen config: %s\", exc)\n+        return {}\n+\n+\n+def _resolve_model() -> Tuple[str, Dict[str, Any]]:\n+    \"\"\"Decide which tier to use and return ``(model_id, meta)``.\"\"\"\n+    import os\n+\n+    env_override = os.environ.get(\"OPENAI_IMAGE_MODEL\")\n+    if env_override and env_override in _MODELS:\n+        return env_override, _MODELS[env_override]\n+\n+    cfg = _load_image_gen_config()\n+    sub = cfg.get(\"openai-codex\") if isinstance(cfg.get(\"openai-codex\"), dict) else {}\n+    candidate: Optional[str] = None\n+    if isinstance(sub, dict):\n+        value = sub.get(\"model\")\n+        if isinstance(value, str) and value in _MODELS:\n+            candidate = value\n+    if candidate is None:\n+        top = cfg.get(\"model\")\n+        if isinstance(top, str) and top in _MODELS:\n+            candidate = top\n+\n+    if candidate is not None:\n+        return candidate, _MODELS[candidate]\n+\n+    return DEFAULT_MODEL, _MODELS[DEFAULT_MODEL]\n+\n+\n+def _read_codex_access_token() -> Optional[str]:\n+    \"\"\"Return a usable Codex OAuth token, or None.\n+\n+    Delegates to the canonical reader in ``agent.auxiliary_client`` so token\n+    expiry, credential pool selection, and JWT decoding stay in one place.\n+    \"\"\"\n+    try:\n+        from agent.auxiliary_client import _read_codex_access_token as _reader\n+\n+        token = [redacted:secret-assignment]\n+        if isinstance(token, str) and token.strip():\n+            return token.strip()\n+        return None\n+    except Exception as exc:\n+        logger.debug(\"Could not resolve Codex access token: %s\", exc)\n+        return None\n+\n+\n+def _build_codex_client():\n+    \"\"\"Return an OpenAI client pointed at the ChatGPT/Codex backend, or None.\"\"\"\n+    token = [redacted:secret-assignment]\n+    if not token:\n+        return None\n+    try:\n+        import openai\n+        from agent.auxiliary_client import _codex_cloudflare_headers\n+\n+        return openai.OpenAI(\n+            api_key=[redacted:secret-assignment]\n+            base_url=_CODEX_BASE_URL,\n+            default_headers=_codex_cloudflare_headers(token),\n+        )\n+    except Exception as exc:\n+        logger.debug(\"Could not build Codex image client: %s\", exc)\n+        return None\n+\n+\n+def _collect_image_b64(client: Any, *, prompt: str, size: str, quality: str) -> Optional[str]:\n+    \"\"\"Stream a Codex Responses image_generation call and return the b64 image.\"\"\"\n+    image_b64: Optional[str] = None\n+\n+    with client.responses.stream(\n+        model=_CODEX_CHAT_MODEL,\n+        store=False,\n+        instructions=_CODEX_INSTRUCTIONS,\n+        input=[{\n+            \"type\": \"message\",\n+            \"role\": \"user\",\n+            \"content\": [{\"type\": \"input_text\", \"text\": prompt}],\n+        }],\n+        tools=[{\n+            \"type\": \"image_generation\",\n+            \"model\": API_MODEL,\n+            \"size\": size,\n+            \"quality\": quality,\n+            \"output_format\": \"png\",\n+            \"background\": \"opaque\",\n+            \"partial_images\": 1,\n+        }],\n+        tool_choice={\n+            \"type\": \"allowed_tools\",\n+            \"mode\": \"required\",\n+            \"tools\": [{\"type\": \"image_generation\"}],\n+        },\n+    ) as stream:\n+        for event in stream:\n+            event_type = getattr(event, \"type\", \"\")\n+            if event_type == \"response.output_item.done\":\n+                item = getattr(event, \"item\", None)\n+                if getattr(item, \"type\", None) == \"image_generation_call\":\n+                    result = getattr(item, \"result\", None)\n+                    if isinstance(result, str) and result:\n+                        image_b64 = result\n+            elif event_type == \"response.image_generation_call.partial_image\":\n+                partial = getattr(event, \"partial_image_b64\", None)\n+                if isinstance(partial, str) and partial:\n+                    image_b64 = partial\n+        final = stream.get_final_response()\n+\n+    # Final-response sweep covers the case where the stream finished before\n+    # we observed the ``output_item.done`` event for the image call.\n+    for item in getattr(final, \"output\", None) or []:\n+        if getattr(item, \"type\", None) == \"image_generation_call\":\n+            result = getattr(item, \"result\", None)\n+            if isinstance(result, str) and result:\n+                image_b64 = result\n+\n+    return image_b64\n+\n+\n+# ---------------------------------------------------------------------------\n+# Provider\n+# ---------------------------------------------------------------------------\n+\n+\n+class OpenAICodexImageGenProvider(ImageGenProvider):\n+    \"\"\"gpt-image-2 routed through ChatGPT/Codex OAuth instead of an API key.\"\"\"\n+\n+    @property\n+    def name(self) -> str:\n+        return \"openai-codex\"\n+\n+    @property\n+    def display_name(self) -> str:\n+        return \"OpenAI (Codex auth)\"\n+\n+    def is_available(self) -> bool:\n+        if not _read_codex_access_token():\n+            return False\n+        try:\n+            import openai  # noqa: F401\n+        except ImportError:\n+            return False\n+        return True\n+\n+    def list_models(self) -> List[Dict[str, Any]]:\n+        return [\n+            {\n+                \"id\": model_id,\n+                \"display\": meta[\"display\"],\n+                \"speed\": meta[\"speed\"],\n+                \"strengths\": meta[\"strengths\"],\n+                \"price\": \"varies\",\n+            }\n+            for model_id, meta in _MODELS.items()\n+        ]\n+\n+    def default_model(self) -> Optional[str]:\n+        return DEFAULT_MODEL\n+\n+    def get_setup_schema(self) -> Dict[str, Any]:\n+        return {\n+            \"name\": \"OpenAI (Codex auth)\",\n+            \"badge\": \"free\",\n+            \"tag\": \"gpt-image-2 via ChatGPT/Codex OAuth — no API key required\",\n+            \"env_vars\": [],\n+            \"post_setup_hint\": (\n+                \"Sign in with `hermes auth codex` (or `hermes setup` → Codex) \"\n+                \"if you haven't already. No API key needed.\"\n+            ),\n+        }\n+\n+    def generate(\n+        self,\n+        prompt: str,\n+        aspect_ratio: str = DEFAULT_ASPECT_RATIO,\n+        **kwargs: Any,\n+    ) -> Dict[str, Any]:\n+        prompt = (prompt or \"\").strip()\n+        aspect = resolve_aspect_ratio(aspect_ratio)\n+\n+        if not prompt:\n+            return error_response(\n+                error=\"Prompt is required and must be a non-empty string\",\n+                error_type=\"invalid_argument\",\n+                provider=\"openai-codex\",\n+                aspect_ratio=aspect,\n+            )\n+\n+        if not _read_codex_access_token():\n+            return error_response(\n+                error=(\n+                    \"No Codex/ChatGPT OAuth credentials available. Run \"\n+                    \"`hermes auth codex` (or `hermes setup` → Codex) to sign in.\"\n+                ),\n+                error_type=\"auth_required\",\n+                provider=\"openai-codex\",\n+                aspect_ratio=aspect,\n+            )\n+\n+        try:\n+            import openai  # noqa: F401\n+        except ImportError:\n+            return error_response(\n+                error=\"openai Python package not installed (pip install openai)\",\n+                error_type=\"missing_dependency\",\n+                provider=\"openai-codex\",\n+                aspect_ratio=aspect,\n+            )\n+\n+        tier_id, meta = _resolve_model()\n+        size = _SIZES.get(aspect, _SIZES[\"square\"])\n+\n+        client = _build_codex_client()\n+        if client is None:\n+            return error_response(\n+                error=\"Could not initialize Codex image client\",\n+                error_type=\"auth_required\",\n+                provider=\"openai-codex\",\n+                model=tier_id,\n+                prompt=prompt,\n+                aspect_ratio=aspect,\n+            )\n+\n+        try:\n+            b64 = _collect_image_b64(\n+                client,\n+                prompt=prompt,\n+                size=size,\n+                quality=meta[\"quality\"],\n+            )\n+        except Exception as exc:\n+            logger.debug(\"Codex image generation failed\", exc_info=True)\n+            return error_response(\n+                error=f\"OpenAI image generation via Codex auth failed: {exc}\",\n+                error_type=\"api_error\",\n+                provider=\"openai-codex\",\n+                model=tier_id,\n+                prompt=prompt,\n+                aspect_ratio=aspect,\n+            )\n+\n+        if not b64:\n+            return error_response(\n+                error=\"Codex response contained no image_generation_call result\",\n+                error_type=\"empty_response\",\n+                provider=\"openai-codex\",\n+                model=tier_id,\n+                prompt=prompt,\n+                aspect_ratio=aspect,\n+            )\n+\n+        try:\n+            saved_path = save_b64_image(b64, prefix=f\"openai_codex_{tier_id}\")\n+        except Exception as exc:\n+            return error_response(\n+                error=f\"Could not save image to cache: {exc}\",\n+                error_type=\"io_error\",\n+                provider=\"openai-codex\",\n+                model=tier_id,\n+                prompt=prompt,\n+                aspect_ratio=aspect,\n+            )\n+\n+        return success_response(\n+            image=str(saved_path),\n+            model=tier_id,\n+            prompt=prompt,\n+            aspect_ratio=aspect,\n+            provider=\"openai-codex\",\n+            extra={\"size\": size, \"quality\": meta[\"quality\"]},\n+        )\n+\n+\n+# ---------------------------------------------------------------------------\n+# Plugin entry point\n+# ---------------------------------------------------------------------------\n+\n+\n+def register(ctx) -> None:\n+    \"\"\"Plugin entry point — register the Codex-backed image-gen provider.\"\"\"\n+    ctx.register_image_gen_provider(OpenAICodexImageGenProvider())"]},{"file":"plugins/image_gen/openai-codex/plugin.yaml","hunks":["@@ -0,0 +1,5 @@\n+name: openai-codex\n+version: 1.0.0\n+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/.\"\n+author: NousResearch\n+kind: backend"]},{"file":"tests/plugins/image_gen/test_openai_codex_provider.py","hunks":["@@ -0,0 +1,299 @@\n+\"\"\"Tests for the bundled ``openai-codex`` image_gen plugin.\n+\n+Mirrors ``test_openai_provider.py`` but targets the standalone\n+Codex/ChatGPT-OAuth-backed provider that uses the Responses\n+``image_generation`` tool path instead of the ``images.generate`` REST\n+endpoint.\n+\"\"\"\n+\n+from __future__ import annotations\n+\n+import importlib\n+from pathlib import Path\n+from types import SimpleNamespace\n+\n+import pytest\n+\n+# The plugin directory uses a hyphen, which is not a valid Python identifier\n+# for the dotted-import form. Load it via importlib so tests don't need to\n+# touch sys.path or rename the directory.\n+codex_plugin = importlib.import_module(\"plugins.image_gen.openai-codex\")\n+\n+\n+# 1×1 transparent PNG — valid bytes for save_b64_image()\n+_PNG_HEX = (\n+    \"89504e470d0a1a0a0000000d49484452000000010000000108060000001f15c4\"\n+    \"890000000d49444154789c6300010000000500010d0a2db40000000049454e44\"\n+    \"ae426082\"\n+)\n+\n+\n+def _b64_png() -> str:\n+    import base64\n+    return base64.b64encode(bytes.fromhex(_PNG_HEX)).decode()\n+\n+\n+class _FakeStream:\n+    def __init__(self, events, final_response):\n+        self._events = list(events)\n+        self._final = final_response\n+\n+    def __enter__(self):\n+        return self\n+\n+    def __exit__(self, exc_type, exc, tb):\n+        return False\n+\n+    def __iter__(self):\n+        return iter(self._events)\n+\n+    def get_final_response(self):\n+        return self._final\n+\n+\n[redacted:email](autouse=True)\n+def _tmp_hermes_home(tmp_path, monkeypatch):\n+    monkeypatch.setenv(\"HERMES_HOME\", str(tmp_path))\n+    yield tmp_path\n+\n+\n[redacted:email]\n+def provider(monkeypatch):\n+    # Codex plugin is API-key-independent; clear it to make the test honest.\n+    monkeypatch.delenv(\"OPENAI_API_KEY\", raising=False)\n+    return codex_plugin.OpenAICodexImageGenProvider()\n+\n+\n+# ── Metadata ────────────────────────────────────────────────────────────────\n+\n+\n+class TestMetadata:\n+    def test_name(self, provider):\n+        assert provider.name == \"openai-codex\"\n+\n+    def test_display_name(self, provider):\n+        assert provider.display_name == \"OpenAI (Codex auth)\"\n+\n+    def test_default_model(self, provider):\n+        assert provider.default_model() == \"gpt-image-2-medium\"\n+\n+    def test_list_models_three_tiers(self, provider):\n+        ids = [m[\"id\"] for m in provider.list_models()]\n+        assert ids == [\"gpt-image-2-low\", \"gpt-image-2-medium\", \"gpt-image-2-high\"]\n+\n+    def test_setup_schema_has_no_required_env_vars(self, provider):\n+        schema = provider.get_setup_schema()\n+        assert schema[\"env_vars\"] == []\n+        assert schema[\"badge\"] == \"free\"\n+\n+\n+# ── Availability ────────────────────────────────────────────────────────────\n+\n+\n+class TestAvailability:\n+    def test_unavailable_without_codex_token(self, monkeypatch):\n+        monkeypatch.delenv(\"OPENAI_API_KEY\", raising=False)\n+        monkeypatch.setattr(codex_plugin, \"_read_codex_access_token\", lambda: None)\n+        assert codex_plugin.OpenAICodexImageGenProvider().is_available() is False\n+\n+    def test_available_with_codex_token(self, monkeypatch):\n+        monkeypatch.delenv(\"OPENAI_API_KEY\", raising=False)\n+        monkeypatch.setattr(codex_plugin, \"_read_codex_access_token\", lambda: \"codex-token\")\n+        assert codex_plugin.OpenAICodexImageGenProvider().is_available() is True\n+\n+    def test_openai_api_key_alone_is_not_enough(self, monkeypatch):\n+        # Codex plugin is intentionally orthogonal to the API-key plugin —\n+        # the API key alone must NOT make it appear available.\n+        monkeypatch.setenv(\"OPENAI_API_KEY\", \"sk-test\")\n+        monkeypatch.setattr(codex_plugin, \"_read_codex_access_token\", lambda: None)\n+        assert codex_plugin.OpenAICodexImageGenProvider().is_available() is False\n+\n+\n+# ── Generate ────────────────────────────────────────────────────────────────\n+\n+\n+class TestGenerate:\n+    def test_returns_auth_error_without_codex_token(self, provider, monkeypatch):\n+        monkeypatch.setattr(codex_plugin, \"_read_codex_access_token\", lambda: None)\n+        result = provider.generate(\"a cat\")\n+        assert result[\"success\"] is False\n+        assert result[\"error_type\"] == \"auth_required\"\n+\n+    def test_returns_invalid_argument_for_empty_prompt(self, provider, monkeypatch):\n+        monkeypatch.setattr(codex_plugin, \"_read_codex_access_token\", lambda: \"codex-token\")\n+        result = provider.generate(\"   \")\n+        assert result[\"success\"] is False\n+        assert result[\"error_type\"] == \"invalid_argument\"\n+\n+    def test_generate_uses_codex_stream_path(self, provider, monkeypatch, tmp_path):\n+        monkeypatch.setattr(codex_plugin, \"_read_codex_access_token\", lambda: \"codex-token\")\n+\n+        output_item = SimpleNamespace(\n+            type=\"image_generation_call\",\n+            status=\"generating\",\n+            id=\"ig_test\",\n+            result=_b64_png(),\n+        )\n+        done_event = SimpleNamespace(type=\"response.output_item.done\", item=output_item)\n+        final_response = SimpleNamespace(output=[], status=\"completed\", output_text=\"\")\n+\n+        fake_client = SimpleNamespace(\n+            responses=SimpleNamespace(\n+                stream=lambda **kwargs: _FakeStream([done_event], final_response)\n+            )\n+        )\n+        monkeypatch.setattr(codex_plugin, \"_build_codex_client\", lambda: fake_client)\n+\n+        result = provider.generate(\"a cat\", aspect_ratio=\"landscape\")\n+\n+        assert result[\"success\"] is True\n+        assert result[\"model\"] == \"gpt-image-2-medium\"\n+        assert result[\"provider\"] == \"openai-codex\"\n+        assert result[\"quality\"] == \"medium\"\n+\n+        saved = Path(result[\"image\"])\n+        assert saved.exists()\n+        assert saved.parent == tmp_path / \"cache\" / \"images\"\n+        # Filename prefix differs from the API-key plugin so cache audits can\n+        # tell the two backends apart.\n+        assert saved.name.startswith(\"openai_codex_\")\n+\n+    def test_codex_stream_request_shape(self, provider, monkeypatch):\n+        monkeypatch.setattr(codex_plugin, \"_read_codex_access_token\", lambda: \"codex-token\")\n+\n+        captured = {}\n+\n+        def _stream(**kwargs):\n+            captured.update(kwargs)\n+            output_item = SimpleNamespace(\n+                type=\"image_generation_call\",\n+                status=\"generating\",\n+                id=\"ig_test\",\n+                result=_b64_png(),\n+            )\n+            done_event = SimpleNamespace(type=\"response.output_item.done\", item=output_item)\n+            final_response = SimpleNamespace(output=[], status=\"completed\", output_text=\"\")\n+            return _FakeStream([done_event], final_response)\n+\n+        fake_client = SimpleNamespace(responses=SimpleNamespace(stream=_stream))\n+        monkeypatch.setattr(codex_plugin, \"_build_codex_client\", lambda: fake_client)\n+\n+        result = provider.generate(\"a cat\", aspect_ratio=\"portrait\")\n+        assert result[\"success\"] is True\n+\n+        assert captured[\"model\"] == \"gpt-5.4\"\n+        assert captured[\"store\"] is False\n+        assert captured[\"input\"][0][\"type\"] == \"message\"\n+        assert captured[\"input\"][0][\"role\"] == \"user\"\n+        assert captured[\"input\"][0][\"content\"][0][\"type\"] == \"input_text\"\n+        assert captured[\"tool_choice\"][\"type\"] == \"allowed_tools\"\n+        assert captured[\"tool_choice\"][\"mode\"] == \"required\"\n+        assert captured[\"tool_choice\"][\"tools\"] == [{\"type\": \"image_generation\"}]\n+\n+        tool = captured[\"tools\"][0]\n+        assert tool[\"type\"] == \"image_generation\"\n+        assert tool[\"model\"] == \"gpt-image-2\"\n+        assert tool[\"quality\"] == \"medium\"\n+        assert tool[\"size\"] == \"1024x1536\"\n+        assert tool[\"output_format\"] == \"png\"\n+        assert tool[\"background\"] == \"opaque\"\n+        assert tool[\"partial_images\"] == 1\n+\n+    def test_partial_image_event_used_when_done_missing(self, provider, monkeypatch):\n+        \"\"\"If the stream never emits output_item.done, fall back to the\n+        partial_image event so users at least get the latest preview frame.\"\"\"\n+        monkeypatch.setattr(codex_plugin, \"_read_codex_access_token\", lambda: \"codex-token\")\n+\n+        partial_event = SimpleNamespace(\n+            type=\"response.image_generation_call.partial_image\",\n+            partial_image_b64=_b64_png(),\n+        )\n+        final_response = SimpleNamespace(output=[], status=\"completed\", output_text=\"\")\n+\n+        fake_client = SimpleNamespace(\n+            responses=SimpleNamespace(\n+                stream=lambda **kwargs: _FakeStream([partial_event], final_response)\n+            )\n+        )\n+        monkeypatch.setattr(codex_plugin, \"_build_codex_client\", lambda: fake_client)\n+\n+        result = provider.generate(\"a cat\")\n+        assert result[\"success\"] is True\n+        assert Path(result[\"image\"]).exists()\n+\n+    def test_final_response_sweep_recovers_image(self, provider, monkeypatch):\n+        \"\"\"If no image_generation_call event arrives mid-stream, the\n+        post-stream final-response sweep should still find the image.\"\"\"\n+        monkeypatch.setattr(codex_plugin, \"_read_codex_access_token\", lambda: \"codex-token\")\n+\n+        final_item = SimpleNamespace(\n+            type=\"image_generation_call\",\n+            status=\"completed\",\n+            id=\"ig_final\",\n+            result=_b64_png(),\n+        )\n+        final_response = SimpleNamespace(output=[final_item], status=\"completed\", output_text=\"\")\n+\n+        fake_client = SimpleNamespace(\n+            responses=SimpleNamespace(\n+                stream=lambda **kwargs: _FakeStream([], final_response)\n+            )\n+        )\n+        monkeypatch.setattr(codex_plugin, \"_build_codex_client\", lambda: fake_client)\n+\n+        result = provider.generate(\"a cat\")\n+        assert result[\"success\"] is True\n+        assert Path(result[\"image\"]).exists()\n+\n+    def test_empty_response_returns_error(self, provider, monkeypatch):\n+        monkeypatch.setattr(codex_plugin, \"_read_codex_access_token\", lambda: \"codex-token\")\n+\n+        final_response = SimpleNamespace(output=[], status=\"completed\", output_text=\"\")\n+        fake_client = SimpleNamespace(\n+            responses=SimpleNamespace(\n+                stream=lambda **kwargs: _FakeStream([], final_response)\n+            )\n+        )\n+        monkeypatch.setattr(codex_plugin, \"_build_codex_client\", lambda: fake_client)\n+\n+        result = provider.generate(\"a cat\")\n+        assert result[\"success\"] is False\n+        assert result[\"error_type\"] == \"empty_response\"\n+\n+    def test_client_init_failure_returns_auth_error(self, provider, monkeypatch):\n+        monkeypatch.setattr(codex_plugin, \"_read_codex_access_token\", lambda: \"codex-token\")\n+        monkeypatch.setattr(codex_plugin, \"_build_codex_client\", lambda: None)\n+\n+        result = provider.generate(\"a cat\")\n+        assert result[\"success\"] is False\n+        assert result[\"error_type\"] == \"auth_required\"\n+\n+    def test_stream_exception_returns_api_error(self, provider, monkeypatch):\n+        monkeypatch.setattr(codex_plugin, \"_read_codex_access_token\", lambda: \"codex-token\")\n+\n+        def _boom(**kwargs):\n+            raise RuntimeError(\"cloudflare 403\")\n+\n+        fake_client = SimpleNamespace(responses=SimpleNamespace(stream=_boom))\n+        monkeypatch.setattr(codex_plugin, \"_build_codex_client\", lambda: fake_client)\n+\n+        result = provider.generate(\"a cat\")\n+        assert result[\"success\"] is False\n+        assert result[\"error_type\"] == \"api_error\"\n+        assert \"cloudflare 403\" in result[\"error\"]\n+\n+\n+# ── Plugin entry point ──────────────────────────────────────────────────────\n+\n+\n+class TestRegistration:\n+    def test_register_calls_register_image_gen_provider(self):\n+        registered = []\n+\n+        class _Ctx:\n+            def register_image_gen_provider(self, prov):\n+                registered.append(prov)\n+\n+        codex_plugin.register(_Ctx())\n+        assert len(registered) == 1\n+        assert registered[0].name == \"openai-codex\"\n"]}]},{"kind":"pr","number":49054,"title":"fix(plugins): surface clear error when Codex backend lacks image generation","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"402969670d","number":0},{"kind":"commit","value":"064b6e40c5","number":0},{"kind":"pr","value":"#71635","number":71635},{"kind":"pr","value":"#19979","number":19979}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":49065,"title":"fix(image_gen): gate openai-codex image generation on Codex tool support","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"402969670d","number":0},{"kind":"commit","value":"064b6e40c5","number":0},{"kind":"pr","value":"#71635","number":71635},{"kind":"pr","value":"#19979","number":19979}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":49161,"title":"fix(plugins): remove unsupported tool_choice from codex image gen (#49008)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"402969670d","number":0},{"kind":"commit","value":"064b6e40c5","number":0},{"kind":"pr","value":"#71635","number":71635},{"kind":"pr","value":"#19979","number":19979}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":50579,"title":"fix(codex): gate unsupported image_generation with a clear error","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"402969670d","number":0},{"kind":"commit","value":"064b6e40c5","number":0},{"kind":"pr","value":"#71635","number":71635},{"kind":"pr","value":"#19979","number":19979}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":71635,"title":"fix(image-gen): unbreak Codex-auth image generation and stop blaming the user's account","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["plugins/image_gen/openai-codex/__init__.py","tests/plugins/image_gen/test_openai_codex_provider.py","website/docs/user-guide/features/image-generation.md"]}],"trust":{"default_tier":"verified","includes_suggested":false}}