fix(proxy): route Codex OAuth image requests (#1215)

## Description

Closes #1189.

After a recent Codex Desktop update, its built-in image generation
started going
through Codex's image client, which POSTs to `images/generations` and
`images/edits` relative to the configured provider base URL. In Headroom
Proxy
mode Codex is pointed at Headroom's `/v1` surface, so those land as
`/v1/images/generations` and `/v1/images/edits`.

Headroom already had `/v1/images/generations`, but it only ever hit the
OpenAI
API-key passthrough, and there was no `/v1/images/edits` route at all.
So under
ChatGPT/Codex OAuth the image calls had nowhere correct to go. This
change routes
OAuth image requests to
`https://chatgpt.com/backend-api/codex/images/{generations,edits}`
and leaves the API-key passthrough untouched.

Latest upstream re-check: current `openai/codex` main is now `aaf737f`,
and the
relevant `ImagesClient`/provider-base source still resolves image
generation and
edit requests to
`https://chatgpt.com/backend-api/codex/images/{generations,edits}`
under ChatGPT-family auth. One issue-thread datapoint reports Codex
Desktop
`0.142.0-alpha.6` on macOS generating images successfully via the
`/v1/responses`
WebSocket path. The requester has now checked this against the latest
timestamped
Codex update, so this is ready for maintainer review with the remaining
full-suite caveat documented below.

**Reproduction / test contract**

- Reporter's setup: Codex Desktop 0.142.0-alpha.1 on Windows 10,
Headroom v0.26.0
Proxy mode, OAuth auth. `/v1/models` and `/v1/responses` work; built-in
image
  generation fails.
- Why the route was confirmed from source: the reporter's sanitized logs
only
show `/v1/models` and `/v1/responses`, so I traced the rest in current
Codex
source — image generation/edit go through `ImagesClient` as
`images/generations`
  and `images/edits` against the provider base URL.
- Regression test:
`test_openai_image_routes_use_codex_backend_under_chatgpt_auth`
asserts both OAuth image routes now resolve to the ChatGPT Codex image
backend.
Before this patch, `/v1/images/generations` used the OpenAI API-key
target under
  OAuth and `/v1/images/edits` didn't exist.
- Hardening tests: additional regressions cover stale upstream
compression
headers, OpenAI API-key fall-through for edits, and multipart edit body
  byte-preservation.

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Route ChatGPT/Codex OAuth `/v1/images/generations` and
`/v1/images/edits` to
  the ChatGPT Codex image backend.
- Strip internal `x-headroom-*`, `Host`, and `Accept-Encoding` headers
before
  forwarding Codex OAuth image requests upstream.
- Strip stale `Content-Encoding` and `Content-Length` headers from image
  responses because httpx has already decoded the body.
- Keep API-key image requests on the existing OpenAI passthrough.
- Add regression coverage for both OAuth image routes, OpenAI image-edit
passthrough, compressed-response header handling, and multipart edit
bodies.
- Add a `CHANGELOG.md` entry.

## Testing

- [ ] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed

### Test Output

```text
$ uv run pytest tests/test_provider_proxy_routes.py tests/test_proxy_codex_route_aliases.py tests/test_openai_codex_routing.py -q
42 passed, 1 warning in 7.63s

$ UV_PROJECT_ENVIRONMENT=.venv-py312 uv run --python 3.12 --extra dev --extra proxy pytest tests/test_provider_proxy_routes.py tests/test_proxy_codex_route_aliases.py tests/test_openai_codex_routing.py -q
42 passed, 1 warning in 8.54s

$ uv run ruff check .
All checks passed!

$ uv run ruff format --check .
895 files already formatted

$ uv run mypy headroom
headroom/proxy/server.py:1152: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs  [annotation-unchecked]
headroom/proxy/server.py:1222: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs  [annotation-unchecked]
headroom/proxy/server.py:1226: note: By default the bodies of untyped functions are not checked, consider using --check-untyped-defs  [annotation-unchecked]
Success: no issues found in 380 source files
```

Earlier full-suite attempt in this branch/environment, before the F1-F8
hardening pass (not rerun after hardening because the failures were
unrelated
to this route and expensive):

```text
$ UV_PROJECT_ENVIRONMENT=.venv-py312 uv run --python 3.12 --extra dev --extra proxy pytest
6 failed, 6499 passed, 486 skipped, 5807 warnings in 219.53s
```

All 6 failures are outside the touched routes and unrelated to this
change:

- `tests/test_corrupt_golden_bytes_recovery.py` — 3 log-capture
assertions
-
`tests/test_forwarded_headers.py::test_non_allowlisted_peer_ignores_forwarded_and_logs`
— 1 log-capture assertion
-
`tests/test_image_compression.py::TestOnnxRouter::test_full_classify_with_image`
— `ModuleNotFoundError: No module named 'PIL'` (only `dev,proxy` extras
installed)
-
`tests/test_transforms/test_kompress_compressor.py::TestKompressBackendSelection::test_unrecognized_backend_warns_and_falls_back_to_auto`
— 1 warning-capture assertion

On Python 3.14.4, plain `uv run pytest` can't even collect: the
project's
dependency marker intentionally excludes `litellm` on 3.14, while
`tests/test_memory_eval.py` imports the eval runner at collection time.

## Real Behavior Proof

- **Environment:** macOS (Darwin arm64). Python 3.14.4 via uv for the
default
project env; Python 3.12.13 via `UV_PROJECT_ENVIRONMENT=.venv-py312` for
the
  broader suite. Headroom FastAPI proxy route test harness.
- **Exact command / steps:** read the reporter's sanitized issue logs;
traced
current Codex image-generation source; ran the focused Codex/proxy route
tests
on 3.14 and 3.12; ran lint, format check, and mypy; attempted the full
3.12
  suite (output above).
- **After-fix evidence + observed result:** the regression test captures
the
  OAuth image requests and confirms they forward to
  `https://chatgpt.com/backend-api/codex/images/generations` and
`.../images/edits` — auth and account headers preserved,
internal/host/accept
encoding headers stripped, query string carried through, JSON and
multipart
  request bodies forwarded byte-for-byte, and stale upstream response
  compression headers removed. API-key image generation still uses
`images/generations`, and image edits now have the matching
`images/edits`
  passthrough.
- **Source evidence:** Re-verified against current `openai/codex` HEAD
`aaf737f`. `ImagesClient` still sends relative paths
`images/generations` and
  `images/edits`; `Provider::url_for_path()` appends those to the active
  provider base; ChatGPT-family auth modes default that base to
`CHATGPT_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"`.
Therefore
the source-resolved upstream paths are
`/backend-api/codex/images/generations`
  and `/backend-api/codex/images/edits`, not `/backend-api/images/...`.
- **Latest-build caveat:** an issue-thread report says Codex Desktop
`0.142.0-alpha.6` on macOS uses `/v1/responses` WebSocket image
generation and
works through the proxy. That may mean the original Windows
`0.142.0-alpha.1`
regression is fixed client-side in newer desktop builds, even though the
source image endpoint route remains valid and now covered here. The
requester
has checked this against the latest timestamped Codex update before
moving the
  PR out of draft.
- **Not fully tested:** a fully green `uv run pytest` remains
unavailable in
  this local environment for the unrelated failures listed above.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable

## Screenshots (if applicable)

N/A

## Additional Notes

No dependency or version changes. The remaining caveat is that the full
local
suite isn't green in this environment for the unrelated failures listed
above.
Happy to follow up with additional runtime logs or to re-run the suite
in a
maintainer's preferred dev container if that's the cleaner path.

---------

Co-authored-by: Johnson <johnsond@brightops.com>
This commit is contained in:
Johnson Deng 2026-06-22 00:25:49 +08:00 committed by GitHub
parent 08fb845fe3
commit 381d771e46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 322 additions and 0 deletions

View file

@ -27,6 +27,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Bug Fixes
* **proxy:** route Codex OAuth image generation and edit requests through the ChatGPT Codex image backend, while preserving OpenAI API-key image passthrough ([#1215](https://github.com/chopratejas/headroom/pull/1215)).
* **proxy:** enable SSO credential resolution in the native Bedrock route via the `aws-config` `sso` feature flag, making the credential chain match what `docs/bedrock.md` already documented ([#999](https://github.com/chopratejas/headroom/pull/999)).
* **proxy:** route native Bedrock `/model/{id}/converse` requests to the upstream Converse endpoint instead of the hard-coded `/invoke` action — the non-streaming handler now resolves the action from the inbound path, matching the streaming handler ([#999](https://github.com/chopratejas/headroom/pull/999)).
* **ccr:** make retrieval store TTL configurable with `HEADROOM_CCR_TTL_SECONDS`, expose the effective TTL in `/v1/retrieve/stats`, and distinguish expired retrievals from missing hashes.

View file

@ -411,6 +411,63 @@ async def _handle_chatgpt_model_metadata(
return Response(content=str(exc), status_code=502)
async def _handle_chatgpt_codex_images(
proxy: Any,
request: Request,
sub_path: str,
) -> Response | None:
"""Forward Codex OAuth image requests to ChatGPT's Codex image backend."""
from headroom.proxy.helpers import _strip_internal_headers
headers = dict(request.headers.items())
headers.pop("host", None)
headers.pop("accept-encoding", None)
headers = _strip_internal_headers(headers)
headers, is_chatgpt_auth = _resolve_codex_routing_headers(headers)
if not is_chatgpt_auth:
return None
url = f"https://chatgpt.com/backend-api/codex/images/{sub_path}"
if request.url.query:
url = f"{url}?{request.url.query}"
body = await request.body()
try:
client = getattr(proxy, "http_client_h1", None) or getattr(proxy, "http_client", None)
if client is None:
raise RuntimeError("No HTTP client configured for Codex image forwarding")
# OAuth image traffic intentionally skips request-outcome telemetry; no token usage is available here.
resp = await client.request(
request.method,
url,
headers=headers,
content=body,
timeout=120.0,
)
response_headers = dict(resp.headers)
response_headers.pop("content-encoding", None)
response_headers.pop("content-length", None)
return Response(
content=resp.content,
status_code=resp.status_code,
headers=response_headers,
)
except Exception as exc:
logger.error("Passthrough /v1/images/%s failed: %s", sub_path, exc)
return Response(
content=json.dumps(
{
"error": {
"type": "upstream_error",
"message": "Failed to forward Codex image request",
}
}
),
status_code=502,
media_type="application/json",
)
def register_provider_routes(app: FastAPI, proxy: Any) -> None:
"""Register provider-specific proxy endpoints."""
@ -753,6 +810,14 @@ def register_provider_routes(app: FastAPI, proxy: Any) -> None:
@app.post("/v1/images/generations")
async def openai_images_generations(request: Request):
chatgpt_response = await _handle_chatgpt_codex_images(
proxy,
request,
"generations",
)
if chatgpt_response is not None:
return chatgpt_response
return await proxy.handle_passthrough(
request,
_api_target(proxy, "openai"),
@ -760,6 +825,23 @@ def register_provider_routes(app: FastAPI, proxy: Any) -> None:
"openai",
)
@app.post("/v1/images/edits")
async def openai_images_edits(request: Request):
chatgpt_response = await _handle_chatgpt_codex_images(
proxy,
request,
"edits",
)
if chatgpt_response is not None:
return chatgpt_response
return await proxy.handle_passthrough(
request,
_api_target(proxy, "openai"),
"images/edits",
"openai",
)
@app.post("/v1/audio/transcriptions")
async def openai_audio_transcriptions(request: Request):
return await proxy.handle_passthrough(

View file

@ -129,6 +129,7 @@ def test_provider_passthrough_routes_forward_expected_targets(monkeypatch) -> No
assert client.post("/v1/embeddings").json()["provider"] == "openai"
assert client.post("/v1/moderations").json()["sub_path"] == "moderations"
assert client.post("/v1/images/generations").json()["sub_path"] == "images/generations"
assert client.post("/v1/images/edits").json()["sub_path"] == "images/edits"
assert client.post("/v1/audio/transcriptions").json()["sub_path"] == "audio/transcriptions"
assert client.post("/v1/audio/speech").json()["sub_path"] == "audio/speech"
assert client.get("/v1beta/models").json()["provider"] == "gemini"
@ -464,6 +465,244 @@ def test_openai_response_subpath_aliases_and_chatgpt_auth_use_expected_targets(m
]
def test_openai_image_routes_use_codex_backend_under_chatgpt_auth(monkeypatch) -> None:
monkeypatch.setattr(
"headroom.providers.proxy_routes._resolve_codex_routing_headers",
lambda headers: ({**headers, "ChatGPT-Account-ID": "acct_123"}, True),
)
class FakeAsyncClient:
def __init__(self) -> None:
self.calls: list[tuple[str, str, dict[str, str], bytes]] = []
async def request(self, method, url, **kwargs): # type: ignore[no-untyped-def]
self.calls.append(
(
method,
url,
dict(kwargs.get("headers", {})),
kwargs.get("content", b""),
)
)
return httpx.Response(200, json={"url": url})
async def aclose(self) -> None:
return None
with TestClient(_app()) as client:
fake = FakeAsyncClient()
client.app.state.proxy.http_client = fake
client.app.state.proxy.http_client_h1 = fake
generate_response = client.post(
"/v1/images/generations?client_version=0.142.0",
headers={
"Authorization": "Bearer oauth-token",
"Accept-Encoding": "gzip",
"X-Headroom-Bypass": "1",
},
json={"model": "gpt-image-2", "prompt": "a route probe"},
)
edit_response = client.post(
"/v1/images/edits",
headers={"Authorization": "Bearer oauth-token"},
json={"model": "gpt-image-2", "prompt": "edit route probe", "images": []},
)
assert generate_response.status_code == 200
assert edit_response.status_code == 200
assert len(fake.calls) == 2
generate_method, generate_url, generate_headers, generate_body = fake.calls[0]
assert generate_method == "POST"
assert (
generate_url
== "https://chatgpt.com/backend-api/codex/images/generations?client_version=0.142.0"
)
assert generate_headers["authorization"] == "Bearer oauth-token"
assert generate_headers["ChatGPT-Account-ID"] == "acct_123"
assert "host" not in generate_headers
assert "accept-encoding" not in generate_headers
assert "x-headroom-bypass" not in generate_headers
assert generate_body == b'{"model":"gpt-image-2","prompt":"a route probe"}'
edit_method, edit_url, edit_headers, edit_body = fake.calls[1]
assert edit_method == "POST"
assert edit_url == "https://chatgpt.com/backend-api/codex/images/edits"
assert edit_headers["authorization"] == "Bearer oauth-token"
assert edit_headers["ChatGPT-Account-ID"] == "acct_123"
assert "host" not in edit_headers
assert edit_body == b'{"model":"gpt-image-2","prompt":"edit route probe","images":[]}'
def test_openai_image_codex_response_strips_stale_compression_headers(monkeypatch) -> None:
upstream_body = b'{"ok":true}'
stale_content_length = "9999"
monkeypatch.setattr(
"headroom.providers.proxy_routes._resolve_codex_routing_headers",
lambda headers: ({**headers, "ChatGPT-Account-ID": "acct_123"}, True),
)
class FakeAsyncClient:
def __init__(self) -> None:
self.calls: list[tuple[str, str, bytes]] = []
async def request(self, method, url, **kwargs): # type: ignore[no-untyped-def]
self.calls.append((method, url, kwargs.get("content", b"")))
return FakeUpstreamResponse(
content=upstream_body,
status_code=200,
headers={
"content-encoding": "gzip",
"content-length": stale_content_length,
"content-type": "application/json",
"x-upstream": "kept",
},
)
async def aclose(self) -> None:
return None
class FakeUpstreamResponse:
def __init__(self, content: bytes, status_code: int, headers: dict[str, str]) -> None:
self.content = content
self.status_code = status_code
self.headers = headers
with TestClient(_app()) as client:
fake = FakeAsyncClient()
client.app.state.proxy.http_client = fake
client.app.state.proxy.http_client_h1 = fake
response = client.post(
"/v1/images/generations",
headers={"Authorization": "Bearer oauth-token"},
json={"model": "gpt-image-2", "prompt": "compressed response"},
)
assert response.status_code == 200
assert response.content == upstream_body
assert response.headers["x-upstream"] == "kept"
assert response.headers.get("content-encoding") is None
assert response.headers.get("content-length") == str(len(upstream_body))
assert fake.calls == [
(
"POST",
"https://chatgpt.com/backend-api/codex/images/generations",
b'{"model":"gpt-image-2","prompt":"compressed response"}',
)
]
def test_openai_image_edits_api_key_auth_falls_through_to_openai_passthrough(
monkeypatch,
) -> None:
calls: list[tuple[str, str, str, str, str]] = []
async def fake_passthrough(self, request, base_url, sub_path="", provider_name=""): # type: ignore[no-untyped-def]
calls.append((request.method, request.url.path, base_url, sub_path, provider_name))
return JSONResponse(
{
"base_url": base_url,
"sub_path": sub_path,
"provider": provider_name,
}
)
monkeypatch.setattr(HeadroomProxy, "handle_passthrough", fake_passthrough)
with TestClient(_app()) as client:
response = client.post(
"/v1/images/edits",
headers={"Authorization": "Bearer sk-proj-openai-test"},
json={"model": "gpt-image-1", "prompt": "fall through", "image": "file-1"},
)
assert response.status_code == 200
assert response.json() == {
"base_url": "https://api.openai.test",
"sub_path": "images/edits",
"provider": "openai",
}
assert calls == [
(
"POST",
"/v1/images/edits",
"https://api.openai.test",
"images/edits",
"openai",
)
]
def test_openai_image_edits_preserves_multipart_body_under_chatgpt_auth(monkeypatch) -> None:
monkeypatch.setattr(
"headroom.providers.proxy_routes._resolve_codex_routing_headers",
lambda headers: ({**headers, "ChatGPT-Account-ID": "acct_123"}, True),
)
boundary = "----headroom-boundary"
body = (
(
f"--{boundary}\r\n"
'Content-Disposition: form-data; name="model"\r\n\r\n'
"gpt-image-2\r\n"
f"--{boundary}\r\n"
'Content-Disposition: form-data; name="prompt"\r\n\r\n'
"preserve these bytes\r\n"
f"--{boundary}\r\n"
'Content-Disposition: form-data; name="image"; filename="input.png"\r\n'
"Content-Type: image/png\r\n\r\n"
).encode()
+ b"\x89PNG\r\n\x1a\nraw-bytes\r\n"
+ f"--{boundary}--\r\n".encode()
)
content_type = f"multipart/form-data; boundary={boundary}"
class FakeAsyncClient:
def __init__(self) -> None:
self.calls: list[tuple[str, str, dict[str, str], bytes]] = []
async def request(self, method, url, **kwargs): # type: ignore[no-untyped-def]
self.calls.append(
(
method,
url,
dict(kwargs.get("headers", {})),
kwargs.get("content", b""),
)
)
return httpx.Response(200, json={"ok": True})
async def aclose(self) -> None:
return None
with TestClient(_app()) as client:
fake = FakeAsyncClient()
client.app.state.proxy.http_client = fake
client.app.state.proxy.http_client_h1 = fake
response = client.post(
"/v1/images/edits",
headers={
"Authorization": "Bearer oauth-token",
"Content-Type": content_type,
},
content=body,
)
assert response.status_code == 200
assert len(fake.calls) == 1
method, url, headers, forwarded_body = fake.calls[0]
assert method == "POST"
assert url == "https://chatgpt.com/backend-api/codex/images/edits"
assert headers["authorization"] == "Bearer oauth-token"
assert headers["ChatGPT-Account-ID"] == "acct_123"
assert headers["content-type"] == content_type
assert "host" not in headers
assert forwarded_body == body
def test_gemini_batch_embed_contents_passthrough_uses_gemini_target(monkeypatch) -> None:
calls: list[tuple[str, str, str]] = []