From 3d59df7be889d6d7218c5552e40a4f736d80a3af Mon Sep 17 00:00:00 2001 From: Parideboy Date: Tue, 23 Jun 2026 14:48:34 +0200 Subject: [PATCH] fix(proxy): forward request-id headers on the streaming path (#1100) (#1258) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description On the streaming (SSE) path the proxy rebuilt response headers from a deny-by-default allowlist that only kept rate-limit and Codex headers, so Anthropic's `request-id` was dropped. Claude Code needs that header to write `requestId` into transcripts; without it, usage/cost tools that dedup on `messageId` + `requestId` over-count tokens. This widens the streaming allowlist to also forward the `request-id` family, matching the non-streaming path. Closes #1100 ## Type of Change - [x] Bug fix (non-breaking change that fixes an issue) ## Changes Made - `headroom/proxy/handlers/streaming.py`: widened the streaming response-header allowlist to also forward `request-id`, `anthropic-request-id`, and `x-request-id`. - `tests/test_proxy_streaming_ratelimit_headers.py`: flipped two assertions that expected `x-request-id` to be dropped, and added `test_request_id_headers_forwarded_in_streaming`. ## Testing - [x] Unit tests pass (`pytest`) ### Test Output ```text $ pytest tests/test_proxy_streaming_ratelimit_headers.py -q 11 passed $ pytest tests/ -k "header or stream" -q 75 passed ``` ## Real Behavior Proof - Environment: Windows 11, Python 3.12, pytest-asyncio 1.4.0 (asyncio_mode=auto) - Exact command / steps: Ran the streaming rate-limit header suite plus adjacent header/stream tests after widening the allowlist. - Observed result: All 11 tests in the targeted file pass including the new request-id forwarding test; 75 adjacent header/stream tests stay green. - Not tested: Did not run a live end-to-end `claude -p` round-trip through the proxy to inspect transcript `requestId`. ## Review Readiness - [x] I have performed a self-review - [x] This PR is ready for human review 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 --- headroom/proxy/handlers/streaming.py | 8 ++- .../test_proxy_streaming_ratelimit_headers.py | 60 +++++++++++++++++-- 2 files changed, 61 insertions(+), 7 deletions(-) diff --git a/headroom/proxy/handlers/streaming.py b/headroom/proxy/handlers/streaming.py index 0239c9722..2a160a07c 100644 --- a/headroom/proxy/handlers/streaming.py +++ b/headroom/proxy/handlers/streaming.py @@ -1080,10 +1080,16 @@ class StreamingMixin: # window/credit headers — the latter do not contain the ``ratelimit`` # substring, so without the second clause the Codex CLI's own # session/weekly display would stop updating on the streaming path. + # We also forward the ``request-id`` family: clients such as Claude Code + # record it per transcript turn, and downstream usage/cost tools dedup by + # message id + request id. The buffered (non-streaming) path already + # forwards every upstream header, so this keeps the two paths symmetric. forwarded_headers = { k: v for k, v in upstream_response.headers.items() - if "ratelimit" in k.lower() or k.lower().startswith("x-codex") + if "ratelimit" in k.lower() + or k.lower().startswith("x-codex") + or k.lower() in ("request-id", "anthropic-request-id", "x-request-id") } async def generate(): diff --git a/tests/test_proxy_streaming_ratelimit_headers.py b/tests/test_proxy_streaming_ratelimit_headers.py index a4f771938..dfedc4915 100644 --- a/tests/test_proxy_streaming_ratelimit_headers.py +++ b/tests/test_proxy_streaming_ratelimit_headers.py @@ -87,7 +87,8 @@ class TestStreamingRatelimitHeaderForwarding: "anthropic-ratelimit-output-tokens-limit": "30000", "anthropic-ratelimit-output-tokens-remaining": "27000", "anthropic-ratelimit-output-tokens-reset": "2026-03-25T12:00:00Z", - # Non-ratelimit headers that should NOT be forwarded + # request-id is forwarded (clients record it per transcript turn); + # cf-ray is a non-allowlisted header that must NOT be forwarded. "x-request-id": "req-12345", "cf-ray": "abc123", } @@ -151,7 +152,7 @@ class TestStreamingRatelimitHeaderForwarding: @pytest.mark.asyncio async def test_non_ratelimit_headers_not_forwarded(self): - """Only ratelimit headers should be forwarded, not arbitrary upstream headers.""" + """Arbitrary upstream headers stay dropped; the request-id family is allowed.""" proxy = self._create_mock_proxy() mock_response = self._create_mock_upstream_response() @@ -179,8 +180,54 @@ class TestStreamingRatelimitHeaderForwarding: optimization_latency=0.0, ) - # Non-ratelimit headers should NOT be in the response - assert result.headers.get("x-request-id") is None + # request-id is forwarded; other non-ratelimit headers are not. + assert result.headers.get("x-request-id") == "req-12345" + assert result.headers.get("cf-ray") is None + + @pytest.mark.asyncio + async def test_request_id_headers_forwarded_in_streaming(self): + """The request-id family is forwarded on the streaming path (#1100).""" + proxy = self._create_mock_proxy() + mock_response = self._create_mock_upstream_response() + mock_response.headers = httpx.Headers( + { + "content-type": "text/event-stream", + "request-id": "req-aaa", + "anthropic-request-id": "req-bbb", + "x-request-id": "req-ccc", + # Non-allowlisted header: must NOT be forwarded. + "cf-ray": "ray-123", + } + ) + + mock_request = MagicMock() + proxy.http_client.build_request = MagicMock(return_value=mock_request) + proxy.http_client.send = AsyncMock(return_value=mock_response) + + result = await proxy._stream_response( + url="https://api.anthropic.com/v1/messages", + headers={"x-api-key": "sk-test"}, + body={ + "model": "claude-sonnet-4-20250514", + "max_tokens": 100, + "stream": True, + "messages": [{"role": "user", "content": "hi"}], + }, + provider="anthropic", + model="claude-sonnet-4-20250514", + request_id="test-1100", + original_tokens=10, + optimized_tokens=10, + tokens_saved=0, + transforms_applied=[], + tags={}, + optimization_latency=0.0, + ) + + assert result.media_type == "text/event-stream" + assert result.headers.get("request-id") == "req-aaa" + assert result.headers.get("anthropic-request-id") == "req-bbb" + assert result.headers.get("x-request-id") == "req-ccc" assert result.headers.get("cf-ray") is None @pytest.mark.asyncio @@ -475,9 +522,10 @@ class TestStreamingRatelimitHeaderForwarding: # keeps working through the proxy on the streaming path. assert result.headers.get("x-codex-primary-used-percent") == "42.0" assert result.headers.get("x-codex-limit-name") == "gpt-5.4-codex" - # 3. Generic ratelimit headers still forwarded; unrelated headers dropped. + # 3. Generic ratelimit headers and the request-id family forwarded; + # other unrelated headers dropped. assert result.headers.get("anthropic-ratelimit-tokens-limit") == "80000" - assert result.headers.get("x-request-id") is None + assert result.headers.get("x-request-id") == "req-12345" @pytest.mark.asyncio async def test_codex_rate_limit_captured_on_streaming_429(self):