From 9fbd47ba6bdf38b618795541ee517b7e2fa2c6df Mon Sep 17 00:00:00 2001 From: gglucass Date: Thu, 2 Jul 2026 06:54:09 +0200 Subject: [PATCH] fix(proxy): strip Codex lite header on the HTTP /responses path (#1663) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The WebSocket `/responses` handler already drops `X-OpenAI-Internal-Codex-Responses-Lite` before forwarding upstream (#1543) — OpenAI rejects newer Codex models (gpt-5.5 / gpt-5.4 / gpt-5.4-mini) when this client-only header leaks. The **HTTP POST `/responses`** handler (`handle_openai_responses`), however, forwards request headers verbatim after `_strip_internal_headers` (which removes only `x-headroom-*`), so on the HTTP path the lite header still reaches `chatgpt.com/backend-api/codex/responses`. This closes that remaining un-stripped path so both `/responses` transports behave identically. Closes # ## 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 - `headroom/proxy/handlers/openai.py`: in `handle_openai_responses` (HTTP POST path), immediately after `headers = _strip_internal_headers(headers)`, drop any header whose lowercased name equals `_CODEX_RESPONSES_LITE_HEADER` — mirroring the existing WS-handler filter. No new imports (the constant is module-level); the WS path is unchanged. - `tests/test_openai_codex_routing.py`: add `test_handle_openai_responses_strips_codex_lite_header_upstream`, which pushes the lite header plus an adjacent header through the HTTP POST handler and asserts the lite header is dropped upstream while the adjacent header survives. ## Testing - [x] Unit tests pass (`pytest`) — directly-relevant files (see output) - [x] Linting passes (`ruff check .`) - [x] Type checking passes (`mypy headroom`) - [x] New tests added for new functionality - [ ] Manual testing performed — no live upstream traffic (see Real Behavior Proof) ### Test Output ```text $ uv run --extra dev pytest tests/test_openai_codex_routing.py tests/test_openai_codex_ws_lifecycle.py -q 39 passed in 1.13s $ uv run ruff check . All checks passed! $ uv run --extra dev mypy headroom Success: no issues found in 404 source files ``` ## Real Behavior Proof - Environment: local `uv` venv (Python 3.10), no live provider required. - Exact command / steps: `uv run --extra dev pytest tests/test_openai_codex_routing.py::test_handle_openai_responses_strips_codex_lite_header_upstream tests/test_openai_codex_ws_lifecycle.py::test_ws_codex_responses_lite_header_is_not_forwarded_upstream` - Observed result: the new test drives a ChatGPT-auth HTTP POST `/responses` request carrying `X-OpenAI-Internal-Codex-Responses-Lite: true` and an adjacent `X-OpenAI-Debug: keep-me`; the captured upstream headers contain the adjacent header but not the lite header. The WS regression test still passes. - Not tested: live Codex traffic against OpenAI with real credentials. (Separately: for a WebSocket-only ChatGPT-auth client the lite signal is not carried as an HTTP header on the handshake — that case is out of scope here.) ## 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 - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation — N/A (no doc-facing behavior change) - [x] My changes generate no new warnings - [x] I have added tests that prove my fix is effective - [x] New and existing unit tests pass locally with my changes - [ ] I have updated the CHANGELOG.md if applicable — N/A (changelog is generated from conventional commits; commit is `fix(proxy): …`) ## Screenshots (if applicable) N/A — backend header-handling change. ## Additional Notes - Scope of checks: `pytest` was run on the two directly-relevant files (`test_openai_codex_routing.py`, `test_openai_codex_ws_lifecycle.py`), not the entire suite; `ruff check .` and `mypy headroom` were run repo-/package-wide. - Complements #1543 (WS path) by closing the HTTP POST path; it is the minimal mirror of that filter. - `Closes #` intentionally blank: found during a support investigation with no tracking issue. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 Co-authored-by: JD Davis --- headroom/proxy/handlers/openai.py | 8 ++++++++ tests/test_openai_codex_routing.py | 33 ++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/headroom/proxy/handlers/openai.py b/headroom/proxy/handlers/openai.py index 4e7379924..11de719ac 100644 --- a/headroom/proxy/handlers/openai.py +++ b/headroom/proxy/handlers/openai.py @@ -3071,6 +3071,14 @@ class OpenAIHandlerMixin: _pre_strip_count_resp = sum(1 for k in headers if k.lower().startswith("x-headroom-")) headers = _strip_internal_headers(headers) + # Mirror the WS handler: never forward Codex's client-only lite header + # upstream. OpenAI rejects newer Codex models when it leaks, and the HTTP + # POST path (unlike the WS path) otherwise forwards request headers verbatim. + headers = { + key: value + for key, value in headers.items() + if key.lower() != _CODEX_RESPONSES_LITE_HEADER + } log_outbound_headers( forwarder="openai_responses", stripped_count=_pre_strip_count_resp, diff --git a/tests/test_openai_codex_routing.py b/tests/test_openai_codex_routing.py index d8be9e6e3..f08ab7eed 100644 --- a/tests/test_openai_codex_routing.py +++ b/tests/test_openai_codex_routing.py @@ -288,6 +288,39 @@ def test_handle_openai_responses_routes_chatgpt_auth_to_backend_api(monkeypatch) assert response.status_code == 200 +def test_handle_openai_responses_strips_codex_lite_header_upstream(monkeypatch): + # OpenAI rejects newer Codex models when the client-only lite header leaks + # upstream. The HTTP POST path must drop it like the WS handler does, while + # leaving adjacent headers intact. + token = _jwt( + { + "https://api.openai.com/auth": { + "chatgpt_account_id": "acct-from-jwt", + } + } + ) + request = _build_request( + {"model": "gpt-5.4", "input": "hello"}, + { + "Authorization": f"Bearer {token}", + "X-OpenAI-Internal-Codex-Responses-Lite": "true", + "X-OpenAI-Debug": "keep-me", + }, + ) + handler = _DummyOpenAIHandler() + + monkeypatch.setattr("headroom.tokenizers.get_tokenizer", lambda model: _DummyTokenizer()) + + response = anyio.run(handler.handle_openai_responses, request) + + assert response.status_code == 200 + assert handler.captured_request is not None + _method, _url, headers, _body = handler.captured_request + lowered = {k.lower(): v for k, v in headers.items()} + assert "x-openai-internal-codex-responses-lite" not in lowered + assert lowered.get("x-openai-debug") == "keep-me" + + def test_handle_openai_responses_chatgpt_codex_timeout_fails_open(monkeypatch): token = _jwt( {