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( {