mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## 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:
parent
38f1404432
commit
3d59df7be8
2 changed files with 61 additions and 7 deletions
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue