fix(proxy): strip Codex lite header on the HTTP /responses path (#1663)

## 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 # <!-- 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 #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>
This commit is contained in:
gglucass 2026-07-02 06:54:09 +02:00 committed by GitHub
parent 646e705514
commit 9fbd47ba6b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 41 additions and 0 deletions

View file

@ -3071,6 +3071,14 @@ class OpenAIHandlerMixin:
_pre_strip_count_resp = sum(1 for k in headers if k.lower().startswith("x-headroom-")) _pre_strip_count_resp = sum(1 for k in headers if k.lower().startswith("x-headroom-"))
headers = _strip_internal_headers(headers) 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( log_outbound_headers(
forwarder="openai_responses", forwarder="openai_responses",
stripped_count=_pre_strip_count_resp, stripped_count=_pre_strip_count_resp,

View file

@ -288,6 +288,39 @@ def test_handle_openai_responses_routes_chatgpt_auth_to_backend_api(monkeypatch)
assert response.status_code == 200 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): def test_handle_openai_responses_chatgpt_codex_timeout_fails_open(monkeypatch):
token = _jwt( token = _jwt(
{ {