mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
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:
parent
646e705514
commit
9fbd47ba6b
2 changed files with 41 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue