mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
Merge pull request #555 from ashishpatel26/fix/531-codex-wrap-fail-open-on-compression
fix(codex): auto-enable fail-open on compression timeout in headroom wrap codex (#531)
This commit is contained in:
commit
5f5f261a03
4 changed files with 88 additions and 5 deletions
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -794,7 +794,8 @@ class CompressionFailureAction:
|
|||
reason: str
|
||||
"""Short machine-readable label for telemetry. One of:
|
||||
``timeout``, ``oversize:bytes=<n>>threshold=<m>``,
|
||||
``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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue