Skip to content

Commit 9fbd47b

Browse files
gglucassclaudeJerrettDavis
authored
fix(proxy): strip Codex lite header on the HTTP /responses path (headroomlabs-ai#1663)
## Description The WebSocket `/responses` handler already drops `X-OpenAI-Internal-Codex-Responses-Lite` before forwarding upstream (headroomlabs-ai#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 # <!-- no tracking issue; found during a live support investigation --> ## 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 headroomlabs-ai#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 <noreply@anthropic.com> Co-authored-by: JD Davis <mxjerrett@gmail.com>
1 parent 646e705 commit 9fbd47b

2 files changed

Lines changed: 41 additions & 0 deletions

File tree

headroom/proxy/handlers/openai.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3071,6 +3071,14 @@ async def handle_openai_responses(
30713071

30723072
_pre_strip_count_resp = sum(1 for k in headers if k.lower().startswith("x-headroom-"))
30733073
headers = _strip_internal_headers(headers)
3074+
# Mirror the WS handler: never forward Codex's client-only lite header
3075+
# upstream. OpenAI rejects newer Codex models when it leaks, and the HTTP
3076+
# POST path (unlike the WS path) otherwise forwards request headers verbatim.
3077+
headers = {
3078+
key: value
3079+
for key, value in headers.items()
3080+
if key.lower() != _CODEX_RESPONSES_LITE_HEADER
3081+
}
30743082
log_outbound_headers(
30753083
forwarder="openai_responses",
30763084
stripped_count=_pre_strip_count_resp,

tests/test_openai_codex_routing.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,39 @@ def test_handle_openai_responses_routes_chatgpt_auth_to_backend_api(monkeypatch)
288288
assert response.status_code == 200
289289

290290

291+
def test_handle_openai_responses_strips_codex_lite_header_upstream(monkeypatch):
292+
# OpenAI rejects newer Codex models when the client-only lite header leaks
293+
# upstream. The HTTP POST path must drop it like the WS handler does, while
294+
# leaving adjacent headers intact.
295+
token = _jwt(
296+
{
297+
"https://api.openai.com/auth": {
298+
"chatgpt_account_id": "acct-from-jwt",
299+
}
300+
}
301+
)
302+
request = _build_request(
303+
{"model": "gpt-5.4", "input": "hello"},
304+
{
305+
"Authorization": f"Bearer {token}",
306+
"X-OpenAI-Internal-Codex-Responses-Lite": "true",
307+
"X-OpenAI-Debug": "keep-me",
308+
},
309+
)
310+
handler = _DummyOpenAIHandler()
311+
312+
monkeypatch.setattr("headroom.tokenizers.get_tokenizer", lambda model: _DummyTokenizer())
313+
314+
response = anyio.run(handler.handle_openai_responses, request)
315+
316+
assert response.status_code == 200
317+
assert handler.captured_request is not None
318+
_method, _url, headers, _body = handler.captured_request
319+
lowered = {k.lower(): v for k, v in headers.items()}
320+
assert "x-openai-internal-codex-responses-lite" not in lowered
321+
assert lowered.get("x-openai-debug") == "keep-me"
322+
323+
291324
def test_handle_openai_responses_chatgpt_codex_timeout_fails_open(monkeypatch):
292325
token = _jwt(
293326
{

0 commit comments

Comments
 (0)