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):