|
| 1 | +# 260710 — /v1/images relay for codex's built-in image_gen (issue #83) |
| 2 | + |
| 3 | +## Problem |
| 4 | + |
| 5 | +GitHub issue #83: the built-in `image_gen` tool fails instantly with |
| 6 | +`http 404 Not Found: {"error":{"message":"Unknown endpoint: POST /v1/images/generations",...}}`. |
| 7 | + |
| 8 | +The 404 is ours: codex-rs's standalone image-generation extension executes CLIENT-SIDE — |
| 9 | +it POSTs `{base_url}/images/generations` (edits variant: `/images/edits`) with the same |
| 10 | +ChatGPT bearer auth it uses for chat (`ext/image-generation`, request body |
| 11 | +`{prompt, background, model:"gpt-image-2", quality, size}`, expected response |
| 12 | +`{created, data:[{b64_json}]}`, both strict serde). Under Design B injection `base_url` |
| 13 | +IS the proxy, and the request died on the `/v1/*` JSON-404 guard (previously even |
| 14 | +test-pinned as an intentional 404 in server-auth.test.ts). |
| 15 | + |
| 16 | +Why now: codex 0.144.0 graduated `image_generation` from the disabled-by-default |
| 17 | +`imagegenext` experiment to Stage::Stable / default-enabled, so ChatGPT-signed-in users |
| 18 | +started hitting the tool organically. The hosted `image_generation` tools[] entry on |
| 19 | +`/v1/responses` was never affected (passthrough already forwards it); only the |
| 20 | +extension's direct REST call 404'd. |
| 21 | + |
| 22 | +## Fix |
| 23 | + |
| 24 | +New relay route `POST /v1/images/{generations,edits}` (src/server/images.ts), inserted |
| 25 | +before the /v1/* guard with the standard data-plane preamble (drain 503, requireApiAuth, |
| 26 | +origin check, withCors, request log with `client_cancel` meta on 499). |
| 27 | + |
| 28 | +Upstream selection (`findImagesUpstreams` collects BOTH candidates; selection is |
| 29 | +per-request): |
| 30 | + |
| 31 | +1. **ChatGPT forward provider** — preferred, same precedence as the vision/web-search |
| 32 | + sidecars; it is the backend codex itself would have called absent the base_url |
| 33 | + override, so request/response bodies relay verbatim (no mapping). Auth via |
| 34 | + `resolveCodexAuthContext`/`headersForCodexAuthContext`: forwarded caller bearer, or |
| 35 | + the routed multi-account pool token. Guards: |
| 36 | + - `startServer` auto-upserts a `chatgpt` forward entry into every config, so the |
| 37 | + forward candidate is only used when the resolved headers actually carry an |
| 38 | + authorization value — otherwise an unauthenticated request would bounce off |
| 39 | + chatgpt.com as an opaque `{"detail":"Unauthorized"}`. |
| 40 | + - A bearer equal to the proxy's own admission secret (non-loopback binds accept |
| 41 | + `Authorization: Bearer <OPENCODEX_API_AUTH_TOKEN>`) is stripped before selection — |
| 42 | + the proxy secret must never be relayed to chatgpt.com |
| 43 | + (`isProxyAdmissionSecret`, extracted from `hasValidApiAuth`). |
| 44 | + - Forward-auth FAILURES (pool cooldown 429, reauth 401, affinity 409) are captured, |
| 45 | + not returned: a configured keyed provider still serves the request, and the |
| 46 | + captured error only surfaces when no keyed candidate exists. Without this, "all |
| 47 | + pool accounts cooling down" would 429 image_gen while api.openai.com sat idle. |
| 48 | + - Pool upstream outcomes are recorded via `sidecarOutcomeRecorder` (status / |
| 49 | + timeout / connect_error), keeping rotation failover signals alive. |
| 50 | +2. **Keyed openai-responses provider** (e.g. api.openai.com) — fallback when no |
| 51 | + relayable ChatGPT auth; its `/v1/images/*` is the real platform Images API. URL is |
| 52 | + normalized like the adapter (`baseUrl.replace(/\/v1\/?$/,"")` + `/v1/images/…`), so |
| 53 | + baseUrl with or without `/v1` both work; forward URLs stay bare |
| 54 | + (`${baseUrl}/images/…`, the ChatGPT-backend convention). |
| 55 | +3. **Neither** — honest 400 (`invalid_request_error`) naming the fix |
| 56 | + (`codex features disable image_generation` or add an OpenAI provider). 400, not 5xx: |
| 57 | + codex retries every 5xx up to 5 total attempts and this is a permanent config state. |
| 58 | + |
| 59 | +Relay details: `readJsonRequestBody` (zstd/gzip decode + 256MB cap) → JSON re-serialize → |
| 60 | +`fetchWithResetRetry` (stale keep-alive resets) with a `config.images.timeoutMs` |
| 61 | +(default 300s) timeout linked to the client abort; response buffered (single JSON |
| 62 | +document, few MB) and passed through status+content-type+body verbatim so upstream |
| 63 | +plan-gating errors stay legible. Client cancel returns 499 (`client_closed_request`), |
| 64 | +never a fake 502; genuine timeout returns 504 (retriable by codex, acceptable for a |
| 65 | +transient hang). |
| 66 | + |
| 67 | +Deliberately NOT done: |
| 68 | + |
| 69 | +- No auto-injection of `[features] image_generation = false` — it would remove a |
| 70 | + capability the relay makes work, and openai/codex#21952 suggests app-server ignores |
| 71 | + the flag anyway. Documented as a user opt-out instead (docs-site codex-integration, |
| 72 | + en/ko/zh-cn). |
| 73 | +- No image generation via routed providers (Gemini image models etc.) — the adapter |
| 74 | + event pipeline has no image-output event; separate feature, not this bug fix. |
| 75 | + |
| 76 | +## Tests |
| 77 | + |
| 78 | +tests/server-images.test.ts (16): forward relay (auth + path + body), edits path, |
| 79 | +pool-token override (caller bearer must not leak), zstd body decode, keyed fallback + |
| 80 | +`/v1` URL normalization (with and without suffix), unauthenticated-request gate (keyed |
| 81 | +fallback / 401), forward-auth-failure fallback (keyed / surfaced 401), no-upstream 400, |
| 82 | +upstream error passthrough, 504 timeout via `config.images.timeoutMs`, GET falls to 404 |
| 83 | +guard, non-loopback auth/origin, admission-secret never relayed. |
| 84 | +server-auth.test.ts's 404-guard list swaps `/v1/images/generations` for |
| 85 | +`/v1/realtime/sessions`. |
0 commit comments