diff --git a/headroom/proxy/handlers/openai.py b/headroom/proxy/handlers/openai.py index 2eb4d0d65..effb7b982 100644 --- a/headroom/proxy/handlers/openai.py +++ b/headroom/proxy/handlers/openai.py @@ -95,6 +95,33 @@ def _codex_ws_compression_timeout_seconds() -> float: _WS_ALLOWED_ORIGINS_ENV = "HEADROOM_WS_ORIGINS" _CORS_ALLOWED_ORIGINS_ENV = "HEADROOM_CORS_ORIGINS" _CODEX_RESPONSES_LITE_HEADER = "x-openai-internal-codex-responses-lite" +# Codex mirrors the responses-lite request header into the response.create +# frame body under client_metadata; upstream rejects gpt-5.x when it is +# truthy. Stripping the WS handshake header alone is insufficient +# (headroomlabs-ai/headroom#1523) — the frame-body mirror must be removed too. +_CODEX_LITE_METADATA_KEY = "ws_request_header_x_openai_internal_codex_responses_lite" + + +def _strip_codex_lite_metadata(raw_msg: str) -> str: + """Remove the Codex responses-lite marker mirrored into a response.create + frame's client_metadata. Fail-safe: returns raw_msg unchanged on any + parse issue or when the marker is absent.""" + try: + frame = json.loads(raw_msg) + except (json.JSONDecodeError, TypeError, ValueError): + return raw_msg + if not isinstance(frame, dict): + return raw_msg + changed = False + for container in (frame, frame.get("response")): + if isinstance(container, dict): + cm = container.get("client_metadata") + if isinstance(cm, dict) and _CODEX_LITE_METADATA_KEY in cm: + del cm[_CODEX_LITE_METADATA_KEY] + changed = True + return json.dumps(frame) if changed else raw_msg + + _OPENAI_CHAT_COMPLETIONS_PATH = "/chat/completions" _OPENAI_RESPONSES_PATH = "/responses" _OPENAI_ORIGINAL_PATH_HEADER = "x-headroom-original-path" @@ -5838,7 +5865,7 @@ class OpenAIHandlerMixin: if ws_connected: async with upstream: - await upstream.send(first_msg_raw) + await upstream.send(_strip_codex_lite_metadata(first_msg_raw)) # Unit 3: flag the upstream side flips on seeing # ``response.completed`` so the outer cause @@ -6211,7 +6238,7 @@ class OpenAIHandlerMixin: "transforms_applied": transforms_applied, }, ) - await upstream.send(msg) + await upstream.send(_strip_codex_lite_metadata(msg)) except asyncio.CancelledError: # Explicit cancel from the outer # orchestrator — re-raise so diff --git a/tests/test_openai_codex_ws_lifecycle.py b/tests/test_openai_codex_ws_lifecycle.py index a7ac5d915..5b1a5bc0a 100644 --- a/tests/test_openai_codex_ws_lifecycle.py +++ b/tests/test_openai_codex_ws_lifecycle.py @@ -1080,6 +1080,49 @@ async def test_ws_without_codex_lite_preserves_adjacent_headers_and_api_key_rout assert "ChatGPT-Account-ID" not in forwarded_headers +@pytest.mark.asyncio +async def test_ws_first_frame_strips_codex_lite_metadata_mirror(): + """Codex mirrors the lite header into response.create's client_metadata + (regression for #1523): stripping the handshake header alone is not + enough, upstream rejects gpt-5.x when the frame-body mirror survives. + """ + upstream_events = [ + json.dumps({"type": "response.created", "response": {"id": "r_1"}}), + json.dumps({"type": "response.completed", "response": {"id": "r_1"}}), + ] + upstream = _FakeUpstream(upstream_events) + fake_ws_mod = _make_fake_websockets_module(upstream) + + first_frame = json.dumps( + { + "type": "response.create", + "response": { + "model": "gpt-5.5", + "input": "hi", + "client_metadata": { + "thread_id": "t_1", + "ws_request_header_x_openai_internal_codex_responses_lite": True, + }, + }, + } + ) + client_ws = _FakeWebSocket( + frames=[first_frame], + headers=_codex_lite_headers(chatgpt=True), + ) + handler = _DummyOpenAIHandler() + + with patch.dict(sys.modules, {"websockets": fake_ws_mod}): + await handler.handle_openai_responses_ws(client_ws) + + assert len(upstream.sent) == 1 + sent_body = json.loads(upstream.sent[0]) + client_metadata = sent_body["response"]["client_metadata"] + assert "ws_request_header_x_openai_internal_codex_responses_lite" not in client_metadata + # Sibling metadata must survive the strip. + assert client_metadata["thread_id"] == "t_1" + + @pytest.mark.asyncio async def test_ws_connect_happens_before_accept(): """The upstream connect must complete before the client 101 is sent,