diff --git a/headroom/proxy/handlers/openai.py b/headroom/proxy/handlers/openai.py index 9835ad6a1..b892f8410 100644 --- a/headroom/proxy/handlers/openai.py +++ b/headroom/proxy/handlers/openai.py @@ -2957,7 +2957,11 @@ class OpenAIHandlerMixin: decide_compression_failure_action, ) - _http_action = decide_compression_failure_action(_e, _http_body_bytes) + _http_action = decide_compression_failure_action( + _e, + _http_body_bytes, + client=client, + ) if _http_action.refuse: logger.error( "[%s] /v1/responses REFUSING to forward request " @@ -4125,7 +4129,11 @@ class OpenAIHandlerMixin: decide_compression_failure_action, ) - _ws_action = decide_compression_failure_action(_ce, _ws_frame_bytes) + _ws_action = decide_compression_failure_action( + _ce, + _ws_frame_bytes, + client=client, + ) if _ws_action.refuse: logger.error( "[%s] WS /v1/responses REFUSING to forward " diff --git a/headroom/proxy/helpers.py b/headroom/proxy/helpers.py index 61d3e3ad2..7c8696f7e 100644 --- a/headroom/proxy/helpers.py +++ b/headroom/proxy/helpers.py @@ -794,7 +794,8 @@ class CompressionFailureAction: reason: str """Short machine-readable label for telemetry. One of: ``timeout``, ``oversize:bytes=>threshold=``, - ``small_frame_transient``, or ``env_override:fail_open``.""" + ``small_frame_transient``, ``client_override:codex``, or + ``env_override:fail_open``.""" frame_bytes: int """Original frame size in bytes (for logging / metrics).""" @@ -803,6 +804,8 @@ class CompressionFailureAction: def decide_compression_failure_action( exception: BaseException, frame_bytes: int, + *, + client: str | None = None, ) -> CompressionFailureAction: """Decide whether to refuse-and-close vs forward-original after the proxy's compression pipeline fails on a Realtime WebSocket frame @@ -812,6 +815,10 @@ def decide_compression_failure_action( * env :data:`WS_COMPRESSION_FAIL_OPEN_ENV` truthy → forward (legacy behaviour, opt-in for debugging or strict compatibility). + * Codex client compression timeout → forward. Codex currently treats + the proxy's 1009/413 refusal path as a hard connection failure, so + fail-open is safer for Codex sessions even when the proxy is run + standalone rather than through ``headroom wrap codex``. * exception is :class:`asyncio.TimeoutError` → refuse (the compression stage hit its own timeout, which only fires on frames Headroom thought were big enough to need compression in the first place). @@ -834,6 +841,15 @@ def decide_compression_failure_action( frame_bytes=frame_bytes, ) + if (client or "").strip().lower() == "codex" and isinstance( + exception, asyncio.TimeoutError + ): + return CompressionFailureAction( + refuse=False, + reason="client_override:codex", + frame_bytes=frame_bytes, + ) + threshold = WS_COMPRESSION_OVERSIZE_BYTES_DEFAULT raw_threshold = os.environ.get(WS_COMPRESSION_OVERSIZE_BYTES_ENV, "").strip() if raw_threshold: diff --git a/tests/test_openai_codex_routing.py b/tests/test_openai_codex_routing.py index 7c96f826a..c070c0568 100644 --- a/tests/test_openai_codex_routing.py +++ b/tests/test_openai_codex_routing.py @@ -340,6 +340,40 @@ def test_handle_openai_responses_memory_timeout_fails_open(monkeypatch): assert body.get("instructions") is None +def test_codex_responses_timeout_fails_open_in_standalone_proxy(monkeypatch): + """Codex users running only the proxy still get fail-open on timeout.""" + request = _build_request( + { + "model": "gpt-5.4", + "input": [ + { + "type": "function_call_output", + "call_id": "call-1", + "output": "large tool output", + } + ], + }, + {"Authorization": "Bearer sk-test", "x-client": "codex"}, + ) + handler = _DummyOpenAIHandler() + handler.config.optimize = True + + monkeypatch.setattr("headroom.tokenizers.get_tokenizer", lambda model: _DummyTokenizer()) + monkeypatch.setattr( + handler, + "_compress_openai_responses_payload", + lambda *args, **kwargs: (_ for _ in ()).throw(TimeoutError()), + ) + + response = anyio.run(handler.handle_openai_responses, request) + + assert response.status_code == 200 + assert handler.captured_request is not None + _, url, _, body = handler.captured_request + assert url == "https://api.openai.com/v1/responses" + assert body["input"][0]["output"] == "large tool output" + + class _DummyWebSocket: def __init__(self, headers: dict[str, str]): self.headers = headers diff --git a/tests/test_proxy/test_compression_failure_action.py b/tests/test_proxy/test_compression_failure_action.py index 389bbfc77..ec23702d0 100644 --- a/tests/test_proxy/test_compression_failure_action.py +++ b/tests/test_proxy/test_compression_failure_action.py @@ -49,8 +49,8 @@ def _env(**overrides: str | None) -> Iterator[None]: os.environ[key] = prior -def test_timeout_always_refuses_regardless_of_frame_size() -> None: - """asyncio.TimeoutError → refuse, even for a tiny frame. +def test_timeout_refuses_without_client_override_regardless_of_frame_size() -> None: + """asyncio.TimeoutError → refuse, even for a tiny non-Codex frame. Compression timeout fires after ``COMPRESSION_TIMEOUT_SECONDS``, which means the pipeline already started work on the frame. A small frame @@ -64,6 +64,31 @@ def test_timeout_always_refuses_regardless_of_frame_size() -> None: assert action.frame_bytes == 128 +def test_codex_client_timeout_fails_open_without_env_override() -> None: + """Codex direct-proxy traffic should keep flowing on compression timeout.""" + with _env(**{WS_COMPRESSION_FAIL_OPEN_ENV: None, WS_COMPRESSION_OVERSIZE_BYTES_ENV: None}): + action = decide_compression_failure_action( + asyncio.TimeoutError(), + frame_bytes=128, + client="codex", + ) + assert action.refuse is False + assert action.reason == "client_override:codex" + assert action.frame_bytes == 128 + + +def test_non_codex_timeout_still_refuses_without_env_override() -> None: + """The Codex override must not restore global fail-open behavior.""" + with _env(**{WS_COMPRESSION_FAIL_OPEN_ENV: None, WS_COMPRESSION_OVERSIZE_BYTES_ENV: None}): + action = decide_compression_failure_action( + asyncio.TimeoutError(), + frame_bytes=128, + client="claude-code", + ) + assert action.refuse is True + assert action.reason == "timeout" + + def test_small_transient_error_falls_through_to_passthrough() -> None: """Non-timeout error on a small frame: forward original (legacy).""" with _env(**{WS_COMPRESSION_FAIL_OPEN_ENV: None, WS_COMPRESSION_OVERSIZE_BYTES_ENV: None}):