fix(proxy): forward request-id headers on the streaming path (#1100) (#1258)

## 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 <noreply@anthropic.com>
This commit is contained in:
Parideboy 2026-06-23 14:48:34 +02:00 committed by GitHub
parent 38f1404432
commit 3d59df7be8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 61 additions and 7 deletions

View file

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

View file

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