fix(proxy): keep buffered CCR streams alive (#2479)

## Description

Buffered CCR streaming currently waits for the full upstream response
before sending any bytes back to the client. On the Anthropic path this
shows up as `API Error: Stream idle timeout - no chunks received`, and
the same buffer-then-synthesize mechanism still exists on the
`/v1/responses` CCR path. This adds a narrow buffered-stream heartbeat
layer so the client sees early stream activity while Headroom preserves
the existing server-side retrieval round trip and final synthesized
provider events.

Closes #2465

## 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

- open buffered CCR streams early and emit client-visible `event: ping`
heartbeats while the buffered upstream call is still in flight
- preserve the existing terminal Anthropic and Responses synthesis
helpers instead of replacing their event-building logic
- preserve early non-streaming failure semantics before the first
heartbeat, including normal 429 passthrough and normal JSON 502 failures
- log late buffered-task exceptions server-side and record one failed
provider metric on that post-keepalive branch, while keeping the
client-facing SSE error sanitized
- add focused delayed-upstream regression coverage for both buffered
provider paths, their early-failure branches, and their late-failure
branches

## Testing

- [x] Unit tests pass (`uv run pytest
tests/test_proxy/test_anthropic_streaming_ccr_retrieve.py
tests/test_proxy/test_openai_responses_ccr.py -q`)
- [x] Linting passes (`uv run ruff check
headroom/proxy/handlers/anthropic.py headroom/proxy/handlers/openai.py
tests/test_proxy/test_anthropic_streaming_ccr_retrieve.py
tests/test_proxy/test_openai_responses_ccr.py`)
- [ ] Type checking passes (`uv run mypy headroom`)
- [x] New tests added for new functionality when applicable
- [ ] Manual testing performed

### Test Output

```text
uv run pytest tests/test_proxy/test_anthropic_streaming_ccr_retrieve.py tests/test_proxy/test_openai_responses_ccr.py -q
======================= 17 passed, 1 warning in 42.47s ========================

uv run ruff check headroom/proxy/handlers/anthropic.py headroom/proxy/handlers/openai.py tests/test_proxy/test_anthropic_streaming_ccr_retrieve.py tests/test_proxy/test_openai_responses_ccr.py
All checks passed!
```

## Real Behavior Proof

- Environment: Windows, Python via `uv`, proxy handler tests with gated
buffered upstream fixtures
- Exact command / steps: run the focused Anthropic and Responses CCR
suites above; delayed-upstream tests consume the first client-visible
SSE event before releasing the upstream, then consume the synthesized
final events
- Observed result: both buffered paths emitted `event: ping` before
upstream release; pre-keepalive 429 responses preserved their real
status and headers, pre-keepalive exceptions returned the normal JSON
502 shape, late transport failures recorded one failed provider metric
and one server error log before emitting one sanitized SSE error,
Anthropic preserved `done`, and Responses preserved `Resolved!`
- Not tested: live slow upstream run with Claude Code or a real
Responses client

## 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] 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

`CHANGELOG.md` stays untouched because Headroom generates release notes
from conventional commits. The PR should only claim the local
buffered-stream contract and focused regression coverage; live client
proof remains an owner check on a slow real upstream.
This commit is contained in:
Rod Boev 2026-07-22 09:09:05 -04:00 committed by GitHub
parent 43a7b578a1
commit a2e42fb877
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 1808 additions and 1035 deletions

File diff suppressed because it is too large Load diff

View file

@ -4911,379 +4911,506 @@ class OpenAIHandlerMixin:
waste_signals=waste_signals_dict,
)
else:
headers = await apply_copilot_api_auth(headers, url=url)
response = await self._retry_request(
"POST",
url,
headers,
body,
original_body_bytes=original_body_bytes,
body_mutated=body_mutation_tracker.mutated,
mutation_reasons=body_mutation_tracker.reasons,
request_id=request_id,
forwarder_name="openai_responses",
path_for_log=url,
)
_response_body_for_debug: Any = None
_response_raw_for_debug: str | None = None
try:
_response_body_for_debug = response.json()
except Exception:
try:
_response_raw_for_debug = response.text[:200_000]
except Exception:
_response_raw_for_debug = None
capture_codex_wire_debug(
"http_upstream_response",
request_id=request_id,
transport="http",
direction="upstream_to_headroom",
method="POST",
url=url,
headers=dict(response.headers),
body=_response_body_for_debug,
raw_text=_response_raw_for_debug,
status_code=response.status_code,
metadata={"stream": stream, "auth_mode": auth_mode.value},
)
total_latency = (time.time() - start_time) * 1000
total_input_tokens = original_tokens # fallback
output_tokens = 0
cache_read_tokens = 0
try:
resp_json = response.json()
usage = resp_json.get("usage", {})
def _usage_int(value: Any, default: int = 0) -> int:
try:
return max(int(value), 0)
except (TypeError, ValueError):
return default
total_input_tokens = _usage_int(
usage.get("input_tokens"),
original_tokens,
)
output_tokens = _usage_int(usage.get("output_tokens"))
details = usage.get("input_tokens_details")
if isinstance(details, dict):
cache_read_tokens = _usage_int(details.get("cached_tokens"))
except (KeyError, TypeError, AttributeError) as e:
logger.debug(
f"[{request_id}] Failed to extract cached tokens from OpenAI passthrough response: {e}"
)
# CCR Response Handling: intercept headroom_retrieve tool
# calls server-side so a Responses API function_call the
# downstream caller can't resolve (e.g. Strands, or a
# buffered-stream request) never reaches the client. Mirrors
# the chat-completions backend-path block (handle_openai_chat
# ~2775-2848), adapted for the Responses API's flat
# function_call / output[] shape instead of Messages API
# tool_calls. Runs before memory tool handling below so a
# retrieve call never gets treated as an unresolved tool_call
# by the memory-tool branch.
if (
_ccr_response_handler
and resp_json
and response.status_code == 200
and _ccr_response_handler.has_ccr_tool_calls(resp_json, "openai_responses")
):
logger.info(
f"[{request_id}] CCR: Detected retrieval tool call (responses), handling..."
)
async def api_call_fn(
items: list[dict[str, Any]],
tls: list[dict[str, Any]] | None,
) -> dict[str, Any]:
continuation_body = {**body, "input": items}
if tls is not None:
continuation_body["tools"] = tls
# Fresh stateless continuation: resend the full
# item history rather than chaining through
# previous_response_id, matching how
# CCRResponseHandler accumulates `current_messages`
# for every other provider. `body["stream"]` is
# left as-is: for a buffered_stream_ccr request it
# was already forced False above, and continuations
# must stay non-streaming so this handler (not
# `_stream_response`) can parse the JSON reply.
continuation_body.pop("previous_response_id", None)
continuation_body["stream"] = False
continuation_headers = {
k: v
for k, v in headers.items()
if k.lower()
not in (
"content-encoding",
"transfer-encoding",
"accept-encoding",
"content-length",
)
}
logger.info(
f"[{request_id}] CCR: Issuing Responses continuation "
f"({len(items)} input items)"
)
cont_response = await self._retry_request(
"POST",
url,
continuation_headers,
continuation_body,
request_id=request_id,
forwarder_name="openai_responses_ccr_continuation",
path_for_log=url,
)
return cont_response.json()
try:
final_resp_json = await _ccr_response_handler.handle_response(
resp_json,
_responses_input_to_items(body.get("input")),
body.get("tools"),
api_call_fn,
provider="openai_responses",
)
resp_json = final_resp_json
# Remove encoding headers since content is now
# uncompressed JSON we synthesized.
ccr_response_headers = {
k: v
for k, v in response.headers.items()
if k.lower() not in ("content-encoding", "content-length")
}
response = httpx.Response(
status_code=200,
content=json.dumps(final_resp_json).encode(),
headers=ccr_response_headers,
)
logger.info(
f"[{request_id}] CCR: Retrieval handled successfully (responses)"
)
except Exception as e:
logger.error(
f"[{request_id}] CCR: Response handling failed (responses): {e}"
)
# NO SILENT FALLBACK: re-raise so the client sees a
# clear failure instead of an unresolved tool_call
# it can't act on. Matches the OpenAI backend-path
# block in handle_openai_chat; see
# feedback_no_silent_fallbacks.
raise
# Memory: handle memory tool calls in Responses API response
if (
self.memory_handler
and memory_user_id
and responses_memory_tools_allowed
and resp_json
and response.status_code == 200
and self.memory_handler.has_memory_tool_calls(resp_json, "openai")
):
try:
# Extract function_call items from output
from headroom.proxy.memory_handler import MEMORY_TOOL_NAMES
output_items = resp_json.get("output", [])
memory_fc_items = [
item
for item in output_items
if isinstance(item, dict)
and item.get("type") == "function_call"
and item.get("name") in MEMORY_TOOL_NAMES
]
# Execute memory tool calls
tool_outputs: list[dict[str, Any]] = []
for fc in memory_fc_items:
call_id = fc.get("call_id", fc.get("id", ""))
name = fc.get("name", "")
args_str = fc.get("arguments", "{}")
try:
args = json.loads(args_str)
except json.JSONDecodeError:
args = {}
await self.memory_handler._ensure_initialized()
if self.memory_handler._backend:
result = await self.memory_handler._execute_memory_tool(
name, args, memory_user_id, "openai"
)
else:
result = json.dumps({"error": "Memory backend not initialized"})
tool_outputs.append(
{
"type": "function_call_output",
"call_id": call_id,
"output": result,
}
)
if tool_outputs:
# Make continuation request with tool results
response_id = resp_json.get("id")
continuation_body = {
"model": model,
"input": tool_outputs,
}
if response_id:
continuation_body["previous_response_id"] = response_id
existing_tools = body.get("tools")
if existing_tools:
continuation_body["tools"] = existing_tools
cont_response = await self._retry_request(
"POST", url, headers, continuation_body
)
resp_json = cont_response.json()
response = cont_response
logger.info(
f"[{request_id}] Memory: Handled {len(tool_outputs)} "
f"tool call(s) with continuation for user {memory_user_id} (responses)"
)
except Exception as e:
logger.warning(
f"[{request_id}] Memory tool handling failed (responses): {e}"
)
if self.cost_tracker:
cache_write_tokens = _infer_openai_cache_write_tokens(
total_input_tokens,
cache_read_tokens,
)
uncached_input_tokens = max(0, total_input_tokens - cache_read_tokens)
# (record_tokens clamps negative savings to 0 universally.)
self.cost_tracker.record_tokens(
model,
tokens_saved,
total_input_tokens,
cache_read_tokens=cache_read_tokens,
cache_write_tokens=cache_write_tokens,
uncached_tokens=uncached_input_tokens,
)
else:
cache_write_tokens = _infer_openai_cache_write_tokens(
total_input_tokens,
cache_read_tokens,
)
uncached_input_tokens = max(0, total_input_tokens - cache_read_tokens)
effective_optimized_tokens = (
total_input_tokens if total_input_tokens > 0 else optimized_tokens
)
effective_original_tokens = max(
original_tokens,
effective_optimized_tokens + tokens_saved,
)
_resp_log_tags = {
**(tags or {}),
"auth_mode": auth_mode.value if auth_mode else "payg",
"endpoint": "responses_http",
}
# OpenAI Responses HTTP (non-WS, non-streaming). Codex
# uses this path when configured for HTTP transport.
# Pre-refactor `cache_hit` was hardcoded False on
# RequestLog even when cache_read>0 — funnel derives
# it correctly.
from headroom.proxy.helpers import compute_turn_id
await self._record_request_outcome(
RequestOutcome(
async def _buffered_ccr_operation():
nonlocal headers
headers = await apply_copilot_api_auth(headers, url=url)
response = await self._retry_request(
"POST",
url,
headers,
body,
original_body_bytes=original_body_bytes,
body_mutated=body_mutation_tracker.mutated,
mutation_reasons=body_mutation_tracker.reasons,
request_id=request_id,
provider="openai",
model=model,
status_code=response.status_code,
original_tokens=effective_original_tokens,
optimized_tokens=effective_optimized_tokens,
output_tokens=output_tokens,
tokens_saved=tokens_saved,
attempted_input_tokens=attempted_input_tokens,
cache_read_tokens=cache_read_tokens,
cache_write_tokens=cache_write_tokens,
uncached_input_tokens=uncached_input_tokens,
total_latency_ms=total_latency,
overhead_ms=optimization_latency,
transforms_applied=tuple(transforms_applied),
waste_signals=waste_signals_dict,
num_messages=len(messages) if isinstance(messages, list) else 0,
tags=_resp_log_tags,
turn_id=compute_turn_id(model, body.get("instructions"), messages),
request_messages=messages
if getattr(self.config, "log_full_messages", False)
else None,
client=client,
forwarder_name="openai_responses",
path_for_log=url,
)
)
_response_body_for_debug: Any = None
_response_raw_for_debug: str | None = None
try:
_response_body_for_debug = response.json()
except Exception:
try:
_response_raw_for_debug = response.text[:200_000]
except Exception:
_response_raw_for_debug = None
capture_codex_wire_debug(
"http_upstream_response",
request_id=request_id,
transport="http",
direction="upstream_to_headroom",
method="POST",
url=url,
headers=dict(response.headers),
body=_response_body_for_debug,
raw_text=_response_raw_for_debug,
status_code=response.status_code,
metadata={"stream": stream, "auth_mode": auth_mode.value},
)
total_latency = (time.time() - start_time) * 1000
logger.info(f"[{request_id}] /v1/responses {model}: {total_input_tokens:,} tokens")
total_input_tokens = original_tokens # fallback
output_tokens = 0
cache_read_tokens = 0
try:
resp_json = response.json()
usage = resp_json.get("usage", {})
# Capture Codex rate-limit window data from response headers
from headroom.subscription.codex_rate_limits import (
get_codex_rate_limit_state,
)
def _usage_int(value: Any, default: int = 0) -> int:
try:
return max(int(value), 0)
except (TypeError, ValueError):
return default
get_codex_rate_limit_state().update_from_headers(dict(response.headers))
# Remove compression headers
response_headers = _sanitize_forwarded_response_headers(response.headers)
if buffered_stream_ccr and response.status_code == 200 and resp_json:
sse_headers = {
k: v
for k, v in response_headers.items()
if k.lower() not in ("content-length", "content-type")
}
if _ccr_response_handler and _ccr_response_handler.has_ccr_tool_calls(
resp_json, "openai_responses"
):
# Handling above didn't fully resolve the retrieve
# call (e.g. max rounds hit, or it was mixed with a
# non-CCR tool call). Fail closed rather than stream
# a response the client can't act on — matches the
# Anthropic buffered path's residual-CCR guard.
logger.warning(
f"[{request_id}] CCR: Buffered streaming Responses "
"reply still contains headroom_retrieve after "
"handling; failing closed"
total_input_tokens = _usage_int(
usage.get("input_tokens"),
original_tokens,
)
output_tokens = _usage_int(usage.get("output_tokens"))
details = usage.get("input_tokens_details")
if isinstance(details, dict):
cache_read_tokens = _usage_int(details.get("cached_tokens"))
except (KeyError, TypeError, AttributeError) as e:
logger.debug(
f"[{request_id}] Failed to extract cached tokens from OpenAI passthrough response: {e}"
)
async def _residual_ccr_error_sse():
error_event = {
"type": "error",
"error": {
"message": "Unable to safely complete streamed CCR retrieval.",
},
# CCR Response Handling: intercept headroom_retrieve tool
# calls server-side so a Responses API function_call the
# downstream caller can't resolve (e.g. Strands, or a
# buffered-stream request) never reaches the client. Mirrors
# the chat-completions backend-path block (handle_openai_chat
# ~2775-2848), adapted for the Responses API's flat
# function_call / output[] shape instead of Messages API
# tool_calls. Runs before memory tool handling below so a
# retrieve call never gets treated as an unresolved tool_call
# by the memory-tool branch.
if (
_ccr_response_handler
and resp_json
and response.status_code == 200
and _ccr_response_handler.has_ccr_tool_calls(resp_json, "openai_responses")
):
logger.info(
f"[{request_id}] CCR: Detected retrieval tool call (responses), handling..."
)
async def api_call_fn(
items: list[dict[str, Any]],
tls: list[dict[str, Any]] | None,
) -> dict[str, Any]:
continuation_body = {**body, "input": items}
if tls is not None:
continuation_body["tools"] = tls
# Fresh stateless continuation: resend the full
# item history rather than chaining through
# previous_response_id, matching how
# CCRResponseHandler accumulates `current_messages`
# for every other provider. `body["stream"]` is
# left as-is: for a buffered_stream_ccr request it
# was already forced False above, and continuations
# must stay non-streaming so this handler (not
# `_stream_response`) can parse the JSON reply.
continuation_body.pop("previous_response_id", None)
continuation_body["stream"] = False
continuation_headers = {
k: v
for k, v in headers.items()
if k.lower()
not in (
"content-encoding",
"transfer-encoding",
"accept-encoding",
"content-length",
)
}
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode()
logger.info(
f"[{request_id}] CCR: Issuing Responses continuation "
f"({len(items)} input items)"
)
cont_response = await self._retry_request(
"POST",
url,
continuation_headers,
continuation_body,
request_id=request_id,
forwarder_name="openai_responses_ccr_continuation",
path_for_log=url,
)
return cont_response.json()
try:
final_resp_json = await _ccr_response_handler.handle_response(
resp_json,
_responses_input_to_items(body.get("input")),
body.get("tools"),
api_call_fn,
provider="openai_responses",
)
resp_json = final_resp_json
# Remove encoding headers since content is now
# uncompressed JSON we synthesized.
ccr_response_headers = {
k: v
for k, v in response.headers.items()
if k.lower() not in ("content-encoding", "content-length")
}
response = httpx.Response(
status_code=200,
content=json.dumps(final_resp_json).encode(),
headers=ccr_response_headers,
)
logger.info(
f"[{request_id}] CCR: Retrieval handled successfully (responses)"
)
except Exception as e:
logger.error(
f"[{request_id}] CCR: Response handling failed (responses): {e}"
)
# NO SILENT FALLBACK: re-raise so the client sees a
# clear failure instead of an unresolved tool_call
# it can't act on. Matches the OpenAI backend-path
# block in handle_openai_chat; see
# feedback_no_silent_fallbacks.
raise
# Memory: handle memory tool calls in Responses API response
if (
self.memory_handler
and memory_user_id
and responses_memory_tools_allowed
and resp_json
and response.status_code == 200
and self.memory_handler.has_memory_tool_calls(resp_json, "openai")
):
try:
# Extract function_call items from output
from headroom.proxy.memory_handler import MEMORY_TOOL_NAMES
output_items = resp_json.get("output", [])
memory_fc_items = [
item
for item in output_items
if isinstance(item, dict)
and item.get("type") == "function_call"
and item.get("name") in MEMORY_TOOL_NAMES
]
# Execute memory tool calls
tool_outputs: list[dict[str, Any]] = []
for fc in memory_fc_items:
call_id = fc.get("call_id", fc.get("id", ""))
name = fc.get("name", "")
args_str = fc.get("arguments", "{}")
try:
args = json.loads(args_str)
except json.JSONDecodeError:
args = {}
await self.memory_handler._ensure_initialized()
if self.memory_handler._backend:
result = await self.memory_handler._execute_memory_tool(
name, args, memory_user_id, "openai"
)
else:
result = json.dumps({"error": "Memory backend not initialized"})
tool_outputs.append(
{
"type": "function_call_output",
"call_id": call_id,
"output": result,
}
)
if tool_outputs:
# Make continuation request with tool results
response_id = resp_json.get("id")
continuation_body = {
"model": model,
"input": tool_outputs,
}
if response_id:
continuation_body["previous_response_id"] = response_id
existing_tools = body.get("tools")
if existing_tools:
continuation_body["tools"] = existing_tools
cont_response = await self._retry_request(
"POST", url, headers, continuation_body
)
resp_json = cont_response.json()
response = cont_response
logger.info(
f"[{request_id}] Memory: Handled {len(tool_outputs)} "
f"tool call(s) with continuation for user {memory_user_id} (responses)"
)
except Exception as e:
logger.warning(
f"[{request_id}] Memory tool handling failed (responses): {e}"
)
if self.cost_tracker:
cache_write_tokens = _infer_openai_cache_write_tokens(
total_input_tokens,
cache_read_tokens,
)
uncached_input_tokens = max(0, total_input_tokens - cache_read_tokens)
# (record_tokens clamps negative savings to 0 universally.)
self.cost_tracker.record_tokens(
model,
tokens_saved,
total_input_tokens,
cache_read_tokens=cache_read_tokens,
cache_write_tokens=cache_write_tokens,
uncached_tokens=uncached_input_tokens,
)
else:
cache_write_tokens = _infer_openai_cache_write_tokens(
total_input_tokens,
cache_read_tokens,
)
uncached_input_tokens = max(0, total_input_tokens - cache_read_tokens)
effective_optimized_tokens = (
total_input_tokens if total_input_tokens > 0 else optimized_tokens
)
effective_original_tokens = max(
original_tokens,
effective_optimized_tokens + tokens_saved,
)
_resp_log_tags = {
**(tags or {}),
"auth_mode": auth_mode.value if auth_mode else "payg",
"endpoint": "responses_http",
}
# OpenAI Responses HTTP (non-WS, non-streaming). Codex
# uses this path when configured for HTTP transport.
# Pre-refactor `cache_hit` was hardcoded False on
# RequestLog even when cache_read>0 — funnel derives
# it correctly.
from headroom.proxy.helpers import compute_turn_id
await self._record_request_outcome(
RequestOutcome(
request_id=request_id,
provider="openai",
model=model,
status_code=response.status_code,
original_tokens=effective_original_tokens,
optimized_tokens=effective_optimized_tokens,
output_tokens=output_tokens,
tokens_saved=tokens_saved,
attempted_input_tokens=attempted_input_tokens,
cache_read_tokens=cache_read_tokens,
cache_write_tokens=cache_write_tokens,
uncached_input_tokens=uncached_input_tokens,
total_latency_ms=total_latency,
overhead_ms=optimization_latency,
transforms_applied=tuple(transforms_applied),
waste_signals=waste_signals_dict,
num_messages=len(messages) if isinstance(messages, list) else 0,
tags=_resp_log_tags,
turn_id=compute_turn_id(model, body.get("instructions"), messages),
request_messages=messages
if getattr(self.config, "log_full_messages", False)
else None,
client=client,
)
)
logger.info(
f"[{request_id}] /v1/responses {model}: {total_input_tokens:,} tokens"
)
# Capture Codex rate-limit window data from response headers
from headroom.subscription.codex_rate_limits import (
get_codex_rate_limit_state,
)
get_codex_rate_limit_state().update_from_headers(dict(response.headers))
# Remove compression headers
response_headers = _sanitize_forwarded_response_headers(response.headers)
if buffered_stream_ccr and response.status_code == 200 and resp_json:
sse_headers = {
k: v
for k, v in response_headers.items()
if k.lower() not in ("content-length", "content-type")
}
if _ccr_response_handler and _ccr_response_handler.has_ccr_tool_calls(
resp_json, "openai_responses"
):
# Handling above didn't fully resolve the retrieve
# call (e.g. max rounds hit, or it was mixed with a
# non-CCR tool call). Fail closed rather than stream
# a response the client can't act on — matches the
# Anthropic buffered path's residual-CCR guard.
logger.warning(
f"[{request_id}] CCR: Buffered streaming Responses "
"reply still contains headroom_retrieve after "
"handling; failing closed"
)
async def _residual_ccr_error_sse():
error_event = {
"type": "error",
"error": {
"message": "Unable to safely complete streamed CCR retrieval.",
},
}
yield f"event: error\ndata: {json.dumps(error_event)}\n\n".encode()
return StreamingResponse(
_residual_ccr_error_sse(),
media_type="text/event-stream",
headers=sse_headers,
status_code=502,
)
async def _buffered_ccr_sse():
for event in _openai_responses_to_sse(resp_json):
yield event
return StreamingResponse(
_residual_ccr_error_sse(),
_buffered_ccr_sse(),
media_type="text/event-stream",
headers=sse_headers,
status_code=502,
)
async def _buffered_ccr_sse():
for event in _openai_responses_to_sse(resp_json):
yield event
return StreamingResponse(
_buffered_ccr_sse(),
media_type="text/event-stream",
headers=sse_headers,
return Response(
content=response.content,
status_code=response.status_code,
headers=response_headers,
)
return Response(
content=response.content,
status_code=response.status_code,
headers=response_headers,
)
if buffered_stream_ccr:
operation = asyncio.create_task(_buffered_ccr_operation())
record_failed = self.metrics.record_failed
class _BufferedCCRResponse(Response):
async def __call__(self, scope, receive, send): # noqa: ANN001
await asyncio.sleep(0)
loop = asyncio.get_running_loop()
keepalive_deadline = loop.time() + 1.0
started = False
try:
while True:
timeout = (
0.25 if started else max(0.0, keepalive_deadline - loop.time())
)
done, _ = await asyncio.wait({operation}, timeout=timeout)
if done:
try:
result = operation.result()
except Exception as e:
await record_failed(provider="openai")
logger.error(
f"[{request_id}] OpenAI responses request failed: {type(e).__name__}: {e}"
)
if not started:
await send(
{
"type": "http.response.start",
"status": 502,
"headers": [
(b"content-type", b"application/json")
],
}
)
await send(
{
"type": "http.response.body",
"body": json.dumps(
{
"error": {
"message": "An error occurred while processing your request. Please try again.",
"type": "server_error",
"code": "proxy_error",
}
}
).encode(),
"more_body": False,
}
)
return
await send(
{
"type": "http.response.body",
"body": b'event: error\ndata: {"type":"error","error":{"message":"An error occurred while processing the request."}}\n\n',
"more_body": False,
}
)
return
if not started:
await result(scope, receive, send)
return
body_iterator = getattr(result, "body_iterator", None)
if body_iterator is not None:
async for chunk in body_iterator:
await send(
{
"type": "http.response.body",
"body": chunk,
"more_body": True,
}
)
await send(
{
"type": "http.response.body",
"body": b"",
"more_body": False,
}
)
return
await send(
{
"type": "http.response.body",
"body": b'event: error\ndata: {"type":"error","error":{"message":"An error occurred while processing the request."}}\n\n',
"more_body": False,
}
)
return
if not started:
await send(
{
"type": "http.response.start",
"status": 200,
"headers": [(b"content-type", b"text/event-stream")],
}
)
started = True
await send(
{
"type": "http.response.body",
"body": b'event: ping\ndata: {"type":"ping"}\n\n',
"more_body": True,
}
)
except asyncio.CancelledError:
raise
finally:
if not operation.done():
operation.cancel()
try:
await operation
except asyncio.CancelledError:
pass
except Exception:
pass
return _BufferedCCRResponse(media_type="text/event-stream")
return await _buffered_ccr_operation()
except Exception as e:
await self.metrics.record_failed(provider="openai")
logger.error(f"[{request_id}] OpenAI responses request failed: {type(e).__name__}: {e}")

View file

@ -2,7 +2,9 @@
from __future__ import annotations
import asyncio
import json
import logging
from unittest.mock import AsyncMock, patch
import pytest
@ -12,6 +14,7 @@ httpx = pytest.importorskip("httpx")
from fastapi.responses import StreamingResponse # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from starlette.requests import Request # noqa: E402
from headroom.cache.compression_store import get_compression_store # noqa: E402
from headroom.ccr.tool_injection import create_ccr_tool_definition # noqa: E402
@ -49,6 +52,10 @@ def _message_response(content: list[dict], *, stop_reason: str = "end_turn") ->
}
def _is_client_visible_sse(body: bytes) -> bool:
return b"event:" in body or b"data:" in body
class _ContinuationClient:
def __init__(self, response_json: dict) -> None:
self.response_json = response_json
@ -299,9 +306,8 @@ def test_unresolved_ccr_only_streams_through_as_200() -> None:
"""CCR-only turn that never resolves: the model keeps re-emitting
headroom_retrieve so the continuation exhausts its retrieval rounds with a
residual marker and no accompanying client tool. Per #2089 the streaming
path no longer hard-502s here it streams the residual headroom_retrieve
back as a 200 SSE so the client (which owns the tool) can resolve or retry
it, matching the non-streaming path. It must NOT 502."""
path streams the residual headroom_retrieve back as a 200 SSE so the client
can resolve or retry it, matching the non-streaming path."""
config = _make_config()
persistent_ccr = _message_response(
[
@ -343,8 +349,266 @@ def test_unresolved_ccr_only_streams_through_as_200() -> None:
},
)
# Fails closed no longer: residual CCR is handed back to the client as 200 SSE.
assert resp.status_code == 200, resp.text
assert "text/event-stream" in resp.headers["content-type"]
assert "headroom_retrieve" in resp.text
assert "Unable to safely complete streamed CCR retrieval" not in resp.text
@pytest.mark.asyncio
async def test_buffered_ccr_emits_keepalive_before_delayed_upstream() -> None:
config = _make_config()
final_response = _message_response([{"type": "text", "text": "done"}])
started = asyncio.Event()
release = asyncio.Event()
body = {
"model": "claude-sonnet-4-6",
"max_tokens": 64,
"stream": True,
"tools": [create_ccr_tool_definition("anthropic")],
"messages": [{"role": "user", "content": "wait"}],
}
async def receive():
return {"type": "http.request", "body": json.dumps(body).encode(), "more_body": False}
scope = {
"type": "http",
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"path": "/v1/messages",
"raw_path": b"/v1/messages",
"query_string": b"",
"headers": [(b"x-api-key", b"test-key"), (b"anthropic-version", b"2023-06-01")],
"server": ("testserver", 80),
"client": ("testclient", 123),
"root_path": "",
}
with patch("headroom.proxy.server.AnyLLMBackend"):
app = create_app(config)
with TestClient(app):
proxy = app.state.proxy
async def delayed_retry(*args, **kwargs): # noqa: ANN002, ANN003
started.set()
await release.wait()
return httpx.Response(200, json=final_response)
proxy._retry_request = delayed_retry
task = asyncio.create_task(proxy.handle_anthropic_messages(Request(scope, receive)))
await started.wait()
response = await asyncio.wait_for(asyncio.shield(task), 1)
events: list[dict] = []
first_visible_body = asyncio.Event()
async def send(message): # noqa: ANN001
events.append(message)
if message["type"] == "http.response.body" and _is_client_visible_sse(
message["body"]
):
first_visible_body.set()
response_task = asyncio.create_task(response(scope, receive, send))
await asyncio.wait_for(first_visible_body.wait(), 2)
assert not release.is_set()
release.set()
await response_task
bodies = [event["body"] for event in events if event["type"] == "http.response.body"]
assert bodies[0] == b'event: ping\ndata: {"type":"ping"}\n\n'
assert b"done" in b"".join(bodies)
@pytest.mark.asyncio
async def test_buffered_ccr_preserves_early_failure_status_and_headers() -> None:
config = _make_config()
body = {
"model": "claude-sonnet-4-6",
"max_tokens": 64,
"stream": True,
"tools": [create_ccr_tool_definition("anthropic")],
"messages": [{"role": "user", "content": "fail early"}],
}
async def receive():
return {"type": "http.request", "body": json.dumps(body).encode(), "more_body": False}
scope = {
"type": "http",
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"path": "/v1/messages",
"raw_path": b"/v1/messages",
"query_string": b"",
"headers": [(b"x-api-key", b"test-key"), (b"anthropic-version", b"2023-06-01")],
"server": ("testserver", 80),
"client": ("testclient", 123),
"root_path": "",
}
with patch("headroom.proxy.server.AnyLLMBackend"):
app = create_app(config)
with TestClient(app):
proxy = app.state.proxy
async def early_failure(*args, **kwargs): # noqa: ANN002, ANN003
await asyncio.sleep(0.05)
return httpx.Response(
429,
headers={"retry-after": "7"},
json={"error": {"message": "slow down"}},
)
proxy._retry_request = early_failure
response = await proxy.handle_anthropic_messages(Request(scope, receive))
events: list[dict] = []
async def send(message): # noqa: ANN001
events.append(message)
await response(scope, receive, send)
start = next(event for event in events if event["type"] == "http.response.start")
headers = dict(start["headers"])
assert start["status"] == 429
assert headers[b"retry-after"] == b"7"
assert b": headroom-keepalive\n\n" not in b"".join(
event["body"] for event in events if event["type"] == "http.response.body"
)
@pytest.mark.asyncio
async def test_buffered_ccr_late_failure_emits_sanitized_error_event() -> None:
config = _make_config()
started = asyncio.Event()
release = asyncio.Event()
body = {
"model": "claude-sonnet-4-6",
"max_tokens": 64,
"stream": True,
"tools": [create_ccr_tool_definition("anthropic")],
"messages": [{"role": "user", "content": "wait"}],
}
async def receive():
return {"type": "http.request", "body": json.dumps(body).encode(), "more_body": False}
scope = {
"type": "http",
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"path": "/v1/messages",
"raw_path": b"/v1/messages",
"query_string": b"",
"headers": [(b"x-api-key", b"test-key"), (b"anthropic-version", b"2023-06-01")],
"server": ("testserver", 80),
"client": ("testclient", 123),
"root_path": "",
}
with patch("headroom.proxy.server.AnyLLMBackend"):
app = create_app(config)
with TestClient(app):
proxy = app.state.proxy
proxy_logger = logging.getLogger("headroom.proxy")
error_records: list[logging.LogRecord] = []
log_handler = logging.Handler()
log_handler.setLevel(logging.ERROR)
log_handler.emit = error_records.append
proxy_logger.addHandler(log_handler)
async def delayed_failure(*args, **kwargs): # noqa: ANN002, ANN003
started.set()
await release.wait()
raise RuntimeError("boom")
with patch.object(
proxy.metrics, "record_failed", new_callable=AsyncMock
) as record_failed:
proxy._retry_request = delayed_failure
task = asyncio.create_task(proxy.handle_anthropic_messages(Request(scope, receive)))
await started.wait()
response = await asyncio.wait_for(asyncio.shield(task), 1)
events: list[dict] = []
first_body = asyncio.Event()
async def send(message): # noqa: ANN001
events.append(message)
if message["type"] == "http.response.body" and message["body"]:
first_body.set()
response_task = asyncio.create_task(response(scope, receive, send))
await asyncio.wait_for(first_body.wait(), 2)
release.set()
await response_task
record_failed.assert_awaited_once_with(provider="anthropic")
proxy_logger.removeHandler(log_handler)
bodies = [event["body"] for event in events if event["type"] == "http.response.body"]
assert bodies[0] == b'event: ping\ndata: {"type":"ping"}\n\n'
assert b"An error occurred while processing the request." in bodies[-1]
assert b"boom" not in bodies[-1]
assert events[-1]["more_body"] is False
assert any(
record.levelno == logging.ERROR and "RuntimeError: boom" in record.getMessage()
for record in error_records
)
@pytest.mark.asyncio
async def test_buffered_ccr_pre_keepalive_exception_returns_json_error() -> None:
config = _make_config()
body = {
"model": "claude-sonnet-4-6",
"max_tokens": 64,
"stream": True,
"tools": [create_ccr_tool_definition("anthropic")],
"messages": [{"role": "user", "content": "fail before keepalive"}],
}
async def receive():
return {"type": "http.request", "body": json.dumps(body).encode(), "more_body": False}
scope = {
"type": "http",
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"path": "/v1/messages",
"raw_path": b"/v1/messages",
"query_string": b"",
"headers": [(b"x-api-key", b"test-key"), (b"anthropic-version", b"2023-06-01")],
"server": ("testserver", 80),
"client": ("testclient", 123),
"root_path": "",
}
with patch("headroom.proxy.server.AnyLLMBackend"):
app = create_app(config)
with TestClient(app):
proxy = app.state.proxy
async def early_exception(*args, **kwargs): # noqa: ANN002, ANN003
raise RuntimeError("boom")
proxy._retry_request = early_exception
response = await proxy.handle_anthropic_messages(Request(scope, receive))
events: list[dict] = []
async def send(message): # noqa: ANN001
events.append(message)
await response(scope, receive, send)
start = next(event for event in events if event["type"] == "http.response.start")
bodies = [event["body"] for event in events if event["type"] == "http.response.body"]
assert start["status"] == 502
assert dict(start["headers"])[b"content-type"] == b"application/json"
payload = json.loads(bodies[-1].decode())
assert (
payload["error"]["message"]
== "An error occurred while processing your request. Please try again."
)

View file

@ -10,16 +10,19 @@ tests/test_proxy/test_openai_backend_path.py for that precedent.
from __future__ import annotations
import asyncio
import json
import logging
import pytest
fastapi = pytest.importorskip("fastapi")
httpx = pytest.importorskip("httpx")
from unittest.mock import AsyncMock, MagicMock # noqa: E402
from unittest.mock import AsyncMock, MagicMock, patch # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from starlette.requests import Request # noqa: E402
from headroom.cache.compression_store import reset_compression_store # noqa: E402
from headroom.ccr.tool_injection import CCR_TOOL_NAME # noqa: E402
@ -260,3 +263,247 @@ def test_streaming_request_without_retrieve_tool_uses_normal_stream_path():
assert resp.status_code == 200, resp.text
assert stream_called["value"] is True
@pytest.mark.asyncio
async def test_buffered_responses_ccr_emits_keepalive_before_delayed_upstream():
app = _make_app()
body = {
"model": "gpt-5-codex",
"input": "please wait",
"tools": [_RETRIEVE_TOOL],
"stream": True,
}
started = asyncio.Event()
release = asyncio.Event()
async def receive():
return {"type": "http.request", "body": json.dumps(body).encode(), "more_body": False}
scope = {
"type": "http",
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"path": "/v1/responses",
"raw_path": b"/v1/responses",
"query_string": b"",
"headers": [(b"authorization", b"Bearer sk-test")],
"server": ("testserver", 80),
"client": ("testclient", 123),
"root_path": "",
}
with TestClient(app):
server = app.state.proxy
async def delayed_retry(*args, **kwargs): # noqa: ANN002, ANN003
started.set()
await release.wait()
return _final_response("https://api.openai.com/v1/responses")
server._retry_request = delayed_retry
task = asyncio.create_task(server.handle_openai_responses(Request(scope, receive)))
await started.wait()
response = await asyncio.wait_for(asyncio.shield(task), 1)
events: list[dict] = []
first_body = asyncio.Event()
async def send(message): # noqa: ANN001
events.append(message)
if message["type"] == "http.response.body" and message["body"]:
first_body.set()
response_task = asyncio.create_task(response(scope, receive, send))
await asyncio.wait_for(first_body.wait(), 2)
assert not release.is_set()
release.set()
await response_task
bodies = [event["body"] for event in events if event["type"] == "http.response.body"]
assert bodies[0] == b'event: ping\ndata: {"type":"ping"}\n\n'
assert b"Resolved!" in b"".join(bodies)
@pytest.mark.asyncio
async def test_buffered_responses_ccr_preserves_early_failure_status_and_headers():
app = _make_app()
body = {
"model": "gpt-5-codex",
"input": "fail early",
"tools": [_RETRIEVE_TOOL],
"stream": True,
}
async def receive():
return {"type": "http.request", "body": json.dumps(body).encode(), "more_body": False}
scope = {
"type": "http",
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"path": "/v1/responses",
"raw_path": b"/v1/responses",
"query_string": b"",
"headers": [(b"authorization", b"Bearer sk-test")],
"server": ("testserver", 80),
"client": ("testclient", 123),
"root_path": "",
}
with TestClient(app):
server = app.state.proxy
async def early_failure(*args, **kwargs): # noqa: ANN002, ANN003
await asyncio.sleep(0.05)
return httpx.Response(
429,
headers={"retry-after": "7"},
json={"error": {"message": "slow down"}},
request=httpx.Request("POST", "https://api.openai.com/v1/responses"),
)
server._retry_request = early_failure
response = await server.handle_openai_responses(Request(scope, receive))
events: list[dict] = []
async def send(message): # noqa: ANN001
events.append(message)
await response(scope, receive, send)
start = next(event for event in events if event["type"] == "http.response.start")
headers = dict(start["headers"])
assert start["status"] == 429
assert headers[b"retry-after"] == b"7"
assert b": headroom-keepalive\n\n" not in b"".join(
event["body"] for event in events if event["type"] == "http.response.body"
)
@pytest.mark.asyncio
async def test_buffered_responses_ccr_late_failure_emits_sanitized_error_event():
app = _make_app()
body = {
"model": "gpt-5-codex",
"input": "please wait",
"tools": [_RETRIEVE_TOOL],
"stream": True,
}
started = asyncio.Event()
release = asyncio.Event()
async def receive():
return {"type": "http.request", "body": json.dumps(body).encode(), "more_body": False}
scope = {
"type": "http",
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"path": "/v1/responses",
"raw_path": b"/v1/responses",
"query_string": b"",
"headers": [(b"authorization", b"Bearer sk-test")],
"server": ("testserver", 80),
"client": ("testclient", 123),
"root_path": "",
}
with TestClient(app):
server = app.state.proxy
proxy_logger = logging.getLogger("headroom.proxy")
error_records: list[logging.LogRecord] = []
log_handler = logging.Handler()
log_handler.setLevel(logging.ERROR)
log_handler.emit = error_records.append
proxy_logger.addHandler(log_handler)
async def delayed_failure(*args, **kwargs): # noqa: ANN002, ANN003
started.set()
await release.wait()
raise RuntimeError("boom")
with patch.object(server.metrics, "record_failed", new_callable=AsyncMock) as record_failed:
server._retry_request = delayed_failure
task = asyncio.create_task(server.handle_openai_responses(Request(scope, receive)))
await started.wait()
response = await asyncio.wait_for(asyncio.shield(task), 1)
events: list[dict] = []
first_body = asyncio.Event()
async def send(message): # noqa: ANN001
events.append(message)
if message["type"] == "http.response.body" and message["body"]:
first_body.set()
response_task = asyncio.create_task(response(scope, receive, send))
await asyncio.wait_for(first_body.wait(), 2)
release.set()
await response_task
record_failed.assert_awaited_once_with(provider="openai")
proxy_logger.removeHandler(log_handler)
bodies = [event["body"] for event in events if event["type"] == "http.response.body"]
assert bodies[0] == b'event: ping\ndata: {"type":"ping"}\n\n'
assert b"An error occurred while processing the request." in bodies[-1]
assert b"boom" not in bodies[-1]
assert events[-1]["more_body"] is False
assert any(
record.levelno == logging.ERROR and "RuntimeError: boom" in record.getMessage()
for record in error_records
)
@pytest.mark.asyncio
async def test_buffered_responses_ccr_pre_keepalive_exception_returns_json_error():
app = _make_app()
body = {
"model": "gpt-5-codex",
"input": "fail before keepalive",
"tools": [_RETRIEVE_TOOL],
"stream": True,
}
async def receive():
return {"type": "http.request", "body": json.dumps(body).encode(), "more_body": False}
scope = {
"type": "http",
"http_version": "1.1",
"method": "POST",
"scheme": "http",
"path": "/v1/responses",
"raw_path": b"/v1/responses",
"query_string": b"",
"headers": [(b"authorization", b"Bearer sk-test")],
"server": ("testserver", 80),
"client": ("testclient", 123),
"root_path": "",
}
with TestClient(app):
server = app.state.proxy
async def early_exception(*args, **kwargs): # noqa: ANN002, ANN003
raise RuntimeError("boom")
server._retry_request = early_exception
response = await server.handle_openai_responses(Request(scope, receive))
events: list[dict] = []
async def send(message): # noqa: ANN001
events.append(message)
await response(scope, receive, send)
start = next(event for event in events if event["type"] == "http.response.start")
bodies = [event["body"] for event in events if event["type"] == "http.response.body"]
assert start["status"] == 502
assert dict(start["headers"])[b"content-type"] == b"application/json"
payload = json.loads(bodies[-1].decode())
assert (
payload["error"]["message"]
== "An error occurred while processing your request. Please try again."
)