fix(proxy): preserve streaming passthrough beta headers (#1783)

## Description

Anthropic-compatible custom upstreams can reject streaming passthrough
requests when Headroom expands the client's `anthropic-beta` header with
sticky session tokens. The request body is still forwarded
byte-faithfully, but the header no longer matches the direct request
that succeeds against the same upstream. This change keeps sticky beta
learning intact while preserving the direct client beta header for the
custom-upstream streaming passthrough path that owns the 503.

Closes #1724

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- Preserves client `anthropic-beta` headers on Vertex
`:streamRawPredict` and custom Anthropic API URL streaming passthrough
requests.
- Keeps sticky beta tracking and adjacent sticky-header behavior for
non-hazard paths.
- Adds focused regression coverage that captures outgoing streaming
headers and preserves existing byte-faithful body checks.

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_proxy_byte_faithful_forwarding.py
tests/test_anthropic_beta_session_sticky.py -q`)
- [x] Linting passes (`uvx ruff==0.15.17 check .` and `uvx ruff==0.15.17
format --check .`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
pytest base: exit_code=1, stdout excerpt: AssertionError: assert 'sticky-beta-2024-01-01,claude-code-20250219' == 'claude-code-20250219'
pytest head: exit_code=0, stdout excerpt: 64 passed, 1 warning in 3.57s
ruff: exit_code=0, stdout excerpt: All checks passed! / 1044 files already formatted
```

## Real Behavior Proof

- Environment: Windows, focused local proxy tests through the headless
runner.
- Exact command / steps: Pre-seed sticky beta state, send streaming
Vertex `:streamRawPredict` and `/v1/messages` requests through custom
upstream routing with `anthropic-beta: claude-code-20250219`, and
capture the outgoing request headers. Run the same focused pytest
command on the base checkout, then on the fixed checkout. Run pinned
Ruff 0.15.17 check and format validation against the final branch.
- Observed result: The base checkout expands the streaming
custom-upstream beta header, and the fixed checkout preserves the direct
client beta header for both streaming routes while adjacent
non-streaming custom-upstream requests still carry the sticky union.
- Not tested: The reporter's live MaaS upstream and the full test suite.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable

## Additional Notes

Documentation is left unchecked because the fix preserves the existing
passthrough contract rather than adding a new user-facing option. The
changelog box is left unchecked because Headroom generates changelog
entries from conventional commits.
This commit is contained in:
Rod Boev 2026-07-08 00:16:59 -04:00 committed by GitHub
parent be51008c70
commit 0f553a8ebb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 163 additions and 5 deletions

View file

@ -1055,6 +1055,7 @@ class AnthropicHandlerMixin:
headroom_added=[],
request_id=request_id,
)
_headroom_beta_added = False
# In cache mode, avoid rewriting any message body bytes. The latest user
# turn becomes historical on the next request, so even "latest turn only"
@ -1955,6 +1956,7 @@ class AnthropicHandlerMixin:
continue
existing_value = headers.get(key, "")
required_tokens = [t.strip() for t in value.split(",") if t.strip()]
_headroom_beta_added = True
merged = merge_anthropic_beta(existing_value, required_tokens)
_existing_count = (
len([t for t in existing_value.split(",") if t.strip()])
@ -2118,6 +2120,17 @@ class AnthropicHandlerMixin:
except (json.JSONDecodeError, ValueError):
body_mutation_tracker.mark_mutated("original_unparseable")
if (
(upstream_base_url or self.ANTHROPIC_API_URL != "https://api.anthropic.com")
and stream
and _client_beta_value
and _sticky_beta_value
and _sticky_beta_value != _client_beta_value
and not body_mutation_tracker.mutated
and not _headroom_beta_added
):
headers["anthropic-beta"] = _client_beta_value
# Forward request - use Bedrock backend if configured, otherwise direct API
if self.anthropic_backend is not None:
# Route through Bedrock backend

View file

@ -32,8 +32,10 @@ from fastapi.testclient import TestClient
from headroom.pipeline import PipelineStage
from headroom.proxy.helpers import (
BodyMutationTracker,
_reset_session_beta_tracker_for_test,
append_text_to_latest_user_chat_message,
get_python_forwarder_mode,
get_session_beta_tracker,
log_outbound_request,
prepare_outbound_body_bytes,
serialize_body_canonical,
@ -928,17 +930,19 @@ class _StreamingCapturingTransport(httpx.AsyncBaseTransport):
self.captured_body = body
self.captured_headers = dict(request.headers.items())
async def _empty_sse(): # pragma: no cover - generator
yield b'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_s","type":"message","role":"assistant","model":"claude","usage":{"input_tokens":1,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}\n\n'
yield b'event: message_stop\ndata: {"type":"message_stop"}\n\n'
return httpx.Response(
200,
headers={"content-type": "text/event-stream"},
stream=httpx.AsyncByteStream(_empty_sse()), # type: ignore[arg-type]
stream=_SSEByteStream(),
)
class _SSEByteStream(httpx.AsyncByteStream):
async def __aiter__(self):
yield b'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_s","type":"message","role":"assistant","model":"claude","usage":{"input_tokens":1,"output_tokens":0,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}\n\n'
yield b'event: message_stop\ndata: {"type":"message_stop"}\n\n'
def test_streaming_forwarder_byte_faithful() -> None:
"""Streaming forwarder uses the same byte-faithful path as non-streaming."""
config = ProxyConfig(
@ -992,6 +996,147 @@ def test_streaming_forwarder_byte_faithful() -> None:
)
def test_vertex_stream_rawpredict_preserves_client_beta_header_on_passthrough() -> None:
_reset_session_beta_tracker_for_test()
try:
client, transport = _make_anthropic_app(optimize=False)
get_session_beta_tracker().record_and_get_sticky_betas(
provider="anthropic",
session_id="s1",
client_value="sticky-beta-2024-01-01",
)
inbound_bytes = (
b'{"model":"claude-sonnet-4-6","stream":true,'
b'"messages":[{"role":"user","content":"hi"}]}'
)
client_beta = "claude-code-20250219"
with client.stream(
"POST",
"/projects/p/locations/us-central1/publishers/anthropic/models/"
"claude-sonnet-4-6:streamRawPredict",
headers={
"x-api-key": "test-key",
"x-headroom-session-id": "vertex-stream-beta-1",
"anthropic-version": "2023-06-01",
"anthropic-beta": client_beta,
"content-type": "application/json",
},
content=inbound_bytes,
) as resp:
response_body = b"".join(resp.iter_bytes())
assert resp.status_code == 200, response_body
assert transport.captured_body == inbound_bytes
assert transport.captured_headers is not None
captured_headers = {key.lower(): value for key, value in transport.captured_headers.items()}
assert captured_headers["anthropic-beta"] == client_beta
finally:
_reset_session_beta_tracker_for_test()
def test_messages_custom_upstream_stream_preserves_client_beta_header() -> None:
_reset_session_beta_tracker_for_test()
old_anthropic_url = None
try:
config = ProxyConfig(
optimize=False,
cache_enabled=False,
rate_limit_enabled=False,
cost_tracking_enabled=False,
log_requests=False,
ccr_inject_tool=False,
ccr_handle_responses=False,
ccr_context_tracking=False,
image_optimize=False,
)
app = create_app(config)
proxy = app.state.proxy
old_anthropic_url = type(proxy).ANTHROPIC_API_URL
type(proxy).ANTHROPIC_API_URL = "https://custom.example"
fake_tracker = _FakePrefixTracker(frozen_count=0)
proxy.session_tracker_store.compute_session_id = lambda request, model, messages: (
"custom-stream-beta-1"
)
proxy.session_tracker_store.get_or_create = lambda session_id, provider: fake_tracker
transport = _StreamingCapturingTransport()
proxy.http_client = httpx.AsyncClient(transport=transport)
client = TestClient(app)
get_session_beta_tracker().record_and_get_sticky_betas(
provider="anthropic",
session_id="custom-stream-beta-1",
client_value="sticky-beta-2024-01-01",
)
inbound_bytes = (
b'{"model":"claude-sonnet-4-6","max_tokens":16,"stream":true,'
b'"messages":[{"role":"user","content":"hi"}]}'
)
client_beta = "claude-code-20250219"
with client.stream(
"POST",
"/v1/messages",
headers={
"x-api-key": "test-key",
"x-headroom-session-id": "custom-stream-beta-1",
"anthropic-version": "2023-06-01",
"anthropic-beta": client_beta,
"content-type": "application/json",
},
content=inbound_bytes,
) as resp:
response_body = b"".join(resp.iter_bytes())
assert resp.status_code == 200, response_body
assert transport.captured_body == inbound_bytes
assert transport.captured_headers is not None
captured_headers = {key.lower(): value for key, value in transport.captured_headers.items()}
assert captured_headers["anthropic-beta"] == client_beta
finally:
if old_anthropic_url is not None:
type(proxy).ANTHROPIC_API_URL = old_anthropic_url
_reset_session_beta_tracker_for_test()
def test_vertex_rawpredict_keeps_sticky_beta_union_on_non_stream_passthrough() -> None:
_reset_session_beta_tracker_for_test()
try:
client, transport = _make_anthropic_app(optimize=False)
get_session_beta_tracker().record_and_get_sticky_betas(
provider="anthropic",
session_id="s1",
client_value="sticky-beta-2024-01-01",
)
inbound_bytes = b'{"model":"claude-sonnet-4-6","messages":[{"role":"user","content":"hi"}]}'
client_beta = "claude-code-20250219"
response = client.post(
"/projects/p/locations/us-central1/publishers/anthropic/models/"
"claude-sonnet-4-6:rawPredict",
headers={
"x-api-key": "test-key",
"x-headroom-session-id": "vertex-raw-beta-1",
"anthropic-version": "2023-06-01",
"anthropic-beta": client_beta,
"content-type": "application/json",
},
content=inbound_bytes,
)
assert response.status_code == 200
assert transport.captured_body == inbound_bytes
assert transport.captured_headers is not None
captured_headers = {key.lower(): value for key, value in transport.captured_headers.items()}
assert captured_headers["anthropic-beta"] == "sticky-beta-2024-01-01,claude-code-20250219"
finally:
_reset_session_beta_tracker_for_test()
def test_openai_responses_gzip_nonstream_passthrough_strips_content_encoding() -> None:
client, transport = _make_no_optimize_app()
decoded_body = _openai_responses_body_bytes(stream=False)