mirror of
https://github.com/headroomlabs-ai/headroom.git
synced 2026-08-27 14:17:10 -04:00
## Description
`StreamingCCRHandler` (`headroom/ccr/response_handler.py`) was written
against the Anthropic wire format. Constructed with `provider="openai"`
it does not work: it silently drops the response, reports the wrong
`finish_reason`, and emits a stream shape no OpenAI client can read.
This PR fixes all three.
**Reachability, stated up front:** `StreamingCCRHandler` is exported
from `headroom/ccr/__init__.py` but no proxy handler instantiates it
today. Every live CCR path (`handlers/openai.py:4276`,
`handlers/openai.py:5936`, `handlers/anthropic.py`,
`handlers/gemini.py`) calls `CCRResponseHandler.handle_response` on a
non-streaming body instead. So these defects are not currently hit by
proxy traffic. They bite anyone importing the public
`headroom.ccr.StreamingCCRHandler` export, and they would bite the
moment streaming CCR gets wired up. I would rather fix them while they
are cheap than have them surface as a mysterious truncation bug later.
**This PR does not fix #1026.** I found these while investigating that
issue and they turned out to be unrelated to it. #1026 needs information
from the reporter before anyone can say whether Headroom is even in the
request path; I have asked for it there.
### The three defects
**1. The whole OpenAI response was dropped.**
`StreamingCCRBuffer.add_chunk` detected a tool call by scanning the
accumulated bytes for the literal `"type":"tool_use"`. That is
Anthropic-only. An OpenAI-compatible stream carries tool calls as a
`tool_calls` array inside `choices[].delta` and never emits that marker,
so `detected_ccr` could never become `True`.
Independently, `process_stream` decided the stream had ended by scanning
for `"stop_reason"`, another Anthropic-only field. An OpenAI stream has
no such field; it terminates with the `[DONE]` sentinel.
With neither marker ever matching, and nothing flushing the buffer once
the source iterator ran out, the outcome was:
- OpenAI stream under 10 000 bytes: **nothing at all was yielded**. The
client got an empty response.
- OpenAI stream over 10 000 bytes: chunks flushed in ~10 KB batches, and
the final sub-threshold batch was never flushed. The response visibly
stopped mid-sentence.
**2. `finish_reason` was hardcoded.**
`_reconstruct_openai_response` always returned `"finish_reason":
"stop"`, even when it had just finished reconstructing a non-empty
`tool_calls` array, where the OpenAI API requires `"tool_calls"`. A
client that drives its agent loop off `finish_reason` reads `stop`,
concludes the turn is over, and never executes the tool calls. The
Anthropic sibling `_reconstruct_anthropic_response` does this correctly,
carrying `stop_reason` through from `message_delta`.
It also discarded `id`, `object`, `created`, `model`, and `usage`,
returning a bare `choices` list that is not a valid `chat.completion`.
**3. `_response_to_sse` emitted the wrong shape.**
The OpenAI branch serialised the reconstructed **non-streaming** body
into a single SSE frame. A streaming client parses `choices[].delta`;
this frame has `choices[].message`. Both the text and the tool calls
were invisible to it.
### Why CI did not catch it
`tests/test_ccr_response_handler_extra.py` exercised
`_reconstruct_openai_response` but never asserted `finish_reason`, and
the one `process_stream` test that passed `provider="openai"` fed it
Anthropic-shaped bytes (`"type":"tool_use"` plus `"stop_reason"`). No
test had ever run a real OpenAI stream through this class. That test now
uses the real OpenAI wire shape, so it actually covers the path it
claims to.
## Type of Change
- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to not work as expected)
- [ ] Documentation update
- [ ] Refactor / internal change
## Changes Made
All in `headroom/ccr/response_handler.py`:
- `StreamingCCRBuffer` gained a `provider` field (defaults to
`"anthropic"`, so existing construction is unchanged) and picks its
tool-call marker from it: `"type":"tool_use"` for Anthropic,
`"tool_calls"` for everything else. `StreamingCCRHandler.__init__` now
passes its own provider down.
- `process_stream` selects the end-of-stream marker by provider
(`"stop_reason"` for Anthropic, `data: [DONE]` for OpenAI), and **always
flushes whatever is still buffered once the source iterator is
exhausted**. That second part is deliberately unconditional on the
marker: upstream can truncate, a gateway can omit the sentinel, and a
future stream shape may not be recognised. Buffered bytes at that point
are real response data, so they get flushed rather than dropped.
- Removed the dead re-iteration block that followed the detection loop.
Its guard was `not detection_complete and not self.buffer.detected_ccr`,
and the only `break` out of the loop above required `detected_ccr` to be
`True`, so it could only ever be reached with an already-exhausted
iterator. The new flush takes its place.
- `_reconstruct_openai_response` derives `finish_reason`: `"tool_calls"`
when the message carries tool calls, otherwise the last non-null
upstream value (so a truncated turn stays reported as `"length"`),
defaulting to `"stop"`. It carries `id` / `created` / `model` /
`system_fingerprint` / `usage` through from the chunk envelope and
stamps `"object": "chat.completion"`. It also tolerates `"delta": null`
on a terminal chunk, which some OpenAI-compatible providers send instead
of `{}`, in the same spirit as #2467.
- New `_openai_response_to_chunks` splits a non-streaming
`chat.completion` body into proper `chat.completion.chunk` frames (a
role delta, a content delta, one delta per tool call, then a terminal
frame carrying `finish_reason`). `_response_to_sse` uses it and then
emits `[DONE]`. The Anthropic branch still delegates to
`StreamingMixin._response_to_sse` and is untouched.
Tests in `tests/test_ccr_response_handler_extra.py`:
- Seven new tests: OpenAI CCR detection on a `tool_calls` delta (plus a
non-CCR negative case), a short OpenAI stream passing through byte for
byte, a stream past the 10 000-byte flush threshold keeping its tail, a
stream with no `[DONE]` sentinel still flushing, `finish_reason`
becoming `"tool_calls"` with the envelope preserved, the upstream
`finish_reason` being kept when there are no tool calls, and
`_response_to_sse` emitting parseable chunk frames.
- `test_streaming_handler_falls_back_to_buffer_on_processing_error` now
feeds genuine OpenAI SSE bytes instead of Anthropic ones, so it
exercises the OpenAI detection path it was always meant to.
- `test_response_to_sse_formats` asserts the new chunk-frame shape for
OpenAI. The Anthropic half is unchanged.
No behaviour change for `provider="anthropic"` beyond the
end-of-iterator flush, which can only add data that was previously
discarded.
## Testing
- [x] Unit tests added/updated
- [x] Existing tests pass
- [ ] Manual testing performed
- [ ] Integration tests added
Each of the seven new tests was confirmed to fail against the unmodified
source (`git stash` on `response_handler.py` alone, tests untouched), so
they are genuine regression tests rather than assertions written to
match current behaviour:
```
$ git stash push -- headroom/ccr/response_handler.py
$ python -m pytest tests/test_ccr_response_handler_extra.py -q -k openai
FAILED tests/test_ccr_response_handler_extra.py::test_streaming_buffer_detects_ccr_in_openai_tool_calls_delta
FAILED tests/test_ccr_response_handler_extra.py::test_openai_stream_without_ccr_yields_every_chunk
FAILED tests/test_ccr_response_handler_extra.py::test_openai_stream_past_flush_threshold_keeps_the_tail
FAILED tests/test_ccr_response_handler_extra.py::test_openai_stream_without_done_sentinel_still_flushes
FAILED tests/test_ccr_response_handler_extra.py::test_reconstruct_openai_response_marks_tool_calls_finish_reason
FAILED tests/test_ccr_response_handler_extra.py::test_reconstruct_openai_response_keeps_upstream_finish_reason
FAILED tests/test_ccr_response_handler_extra.py::test_response_to_sse_emits_openai_chunk_frames
7 failed, 2 passed, 13 deselected in 0.79s
```
With the fix applied, the full CCR response-handler suite passes:
```
$ python -m pytest tests/test_ccr_response_handler_extra.py tests/test_ccr_response_handler.py -q
collected 57 items
tests\test_ccr_response_handler_extra.py ...................... [ 38%]
tests\test_ccr_response_handler.py ................................... [100%]
============================= 57 passed in 1.74s ==============================
```
Wider CCR and streaming surface:
```
$ python -m pytest tests/ -k "ccr or streaming" -q
4 failed, 696 passed, 73 skipped, 10949 deselected, 2 warnings in 175.80s (0:02:55)
```
The 4 failures are pre-existing on a clean `upstream/main` and unrelated
to this change (verified by stashing both changed files and re-running
exactly those four):
`test_ccr_mcp_http.py::test_streamable_http_initialize_and_list_tools`,
`test_cli_proxy_env.py::TestCLICompressionOnlyFlags::test_ccr_defaults_on`,
and two in `test_transforms/test_smart_crusher_ccr_roundtrip.py`.
Lint and types:
```
$ python -m ruff check .
All checks passed!
$ python -m ruff format --check .
1505 files already formatted
$ python -m mypy headroom --ignore-missing-imports
Found 12 errors in 3 files (checked 521 source files)
```
Zero mypy errors in `headroom/ccr/response_handler.py`. The 12 are
pre-existing, in `ccr/mcp_server.py`, `memory/mcp_server.py`, and
`release_version.py`, none of which this PR touches (they come from a
locally installed `mcp` whose stubs differ from CI's).
## Real Behavior Proof
- Environment: Windows 11, Python 3.13.11, pytest 9.1.1, ruff and mypy
from the repo's pinned config, branch `fix/ccr-streaming-openai-path`
off `upstream/main` at `cbb950a4`.
- Exact command / steps: `python -m pytest
tests/test_ccr_response_handler_extra.py
tests/test_ccr_response_handler.py -q`; then `git stash push --
headroom/ccr/response_handler.py` and `python -m pytest
tests/test_ccr_response_handler_extra.py -q -k openai` to confirm the
new tests fail without the source fix; then `python -m pytest tests/ -k
"ccr or streaming" -q`; then `python -m ruff check .`, `python -m ruff
format --check .`, `python -m mypy headroom --ignore-missing-imports`.
- Observed result: 57/57 pass in the CCR response-handler suites with
the fix; all 7 new tests fail without it. The wider run is 696 passed
with 4 failures that reproduce identically on an unmodified tree. Ruff
clean, mypy clean on the changed file. In
`test_openai_stream_without_ccr_yields_every_chunk` the handler now
returns every input chunk byte for byte, where before it returned an
empty list.
- Not tested: no end-to-end run against a live OpenAI-compatible
backend, because no proxy handler instantiates `StreamingCCRHandler`
today, so there is no wired path to drive. Coverage is at the class
level using recorded-shape SSE frames. The Anthropic path is covered
only by the existing tests, which still pass unchanged.
## Runtime Rollout Safety
- Rollout-managed feature(s): none. `StreamingCCRHandler` is not gated
by a rollout feature and is not reachable from any proxy handler.
- Minimum rollout channel: not applicable; no rollout gate is involved.
- Stable/default behavior changed: no. For `provider="anthropic"` the
only behavioural difference is that bytes left buffered when the source
iterator ends are now flushed instead of discarded, which can only add
data the client previously lost. For `provider="openai"` the class was
non-functional, so there is no prior behaviour to preserve.
- Kill switch / disable path: not applicable; no new configuration, env
var, or feature flag is introduced.
- Unsafe override required: no.
- Qualification impact: none. No qualification-gated surface is touched.
- Rollback path: revert this commit. It is self-contained in
`headroom/ccr/response_handler.py` and
`tests/test_ccr_response_handler_extra.py`, with no schema, config, or
persisted-state changes.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
Two judgement calls worth a reviewer's attention:
1. **Removing the dead re-iteration block** in `process_stream`. I am
confident it was unreachable (the only `break` above it requires
`detected_ccr`, which its own guard excludes), but it is the one
deletion in this diff rather than an addition, so it is worth a second
pair of eyes.
2. **The unconditional end-of-iterator flush.** I chose to flush
regardless of whether an end marker matched, rather than only fixing the
OpenAI marker. That makes the truncation bug unreachable even if a
future provider uses a shape neither marker recognises. The cost is that
a stream whose trailing bytes are genuinely not meant for the client
would now be forwarded. Given the buffer only ever holds upstream
response bytes, forwarding is the safer default, but flag it if you
disagree.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
776 lines
30 KiB
Python
776 lines
30 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from headroom.ccr.response_handler import (
|
|
CCRResponseHandler,
|
|
CCRToolCall,
|
|
CCRToolResult,
|
|
StreamingCCRBuffer,
|
|
StreamingCCRHandler,
|
|
)
|
|
from headroom.ccr.tool_injection import CCR_TOOL_NAME
|
|
|
|
|
|
class FakeStore:
|
|
def __init__(self, *, retrieve_error: Exception | None = None) -> None:
|
|
self.retrieve_error = retrieve_error
|
|
|
|
def retrieve(self, hash_key: str):
|
|
if self.retrieve_error:
|
|
raise self.retrieve_error
|
|
return {"unexpected": True}
|
|
|
|
|
|
async def _async_iter(items: list[bytes]):
|
|
for item in items:
|
|
yield item
|
|
|
|
|
|
def _sse_json_events(chunks: list[bytes]) -> list[dict[str, Any]]:
|
|
events = []
|
|
for chunk in chunks:
|
|
for line in chunk.decode("utf-8").splitlines():
|
|
if not line.startswith("data: "):
|
|
continue
|
|
payload = line[len("data: ") :]
|
|
if payload == "[DONE]":
|
|
continue
|
|
events.append(json.loads(payload))
|
|
return events
|
|
|
|
|
|
def test_extract_tool_calls_google_and_invalid_shapes() -> None:
|
|
handler = CCRResponseHandler()
|
|
google_response = {
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"parts": [
|
|
{"text": "hello"},
|
|
{"functionCall": {"name": CCR_TOOL_NAME, "args": {"hash": "abc"}}},
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
assert handler._extract_tool_calls(google_response, "google") == [
|
|
{"functionCall": {"name": CCR_TOOL_NAME, "args": {"hash": "abc"}}}
|
|
]
|
|
assert handler._extract_tool_calls({"content": "bad"}, "anthropic") == []
|
|
with pytest.raises(IndexError):
|
|
handler._extract_tool_calls({"choices": []}, "openai")
|
|
assert handler._extract_tool_calls({"candidates": []}, "google") == []
|
|
|
|
|
|
def test_parse_ccr_tool_calls_google_and_other_calls() -> None:
|
|
handler = CCRResponseHandler()
|
|
response = {
|
|
"candidates": [
|
|
{
|
|
"content": {
|
|
"parts": [
|
|
{
|
|
"functionCall": {
|
|
"name": CCR_TOOL_NAME,
|
|
"args": {
|
|
"hash": "aaaaaaaaaaaaaaaaaaaaaaaa",
|
|
"query": "pizza",
|
|
},
|
|
}
|
|
},
|
|
{"functionCall": {"name": "other_tool", "args": {}}},
|
|
]
|
|
}
|
|
}
|
|
]
|
|
}
|
|
ccr_calls, other_calls = handler._parse_ccr_tool_calls(response, "google")
|
|
assert ccr_calls == [
|
|
CCRToolCall(
|
|
tool_call_id=CCR_TOOL_NAME,
|
|
hash_key="aaaaaaaaaaaaaaaaaaaaaaaa",
|
|
)
|
|
]
|
|
assert other_calls == [{"functionCall": {"name": "other_tool", "args": {}}}]
|
|
|
|
|
|
def test_execute_retrieval_error_paths(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
handler = CCRResponseHandler()
|
|
# Retrieval is by hash only; a store error surfaces as a failed result.
|
|
monkeypatch.setattr(
|
|
"headroom.ccr.response_handler.get_compression_store",
|
|
lambda: FakeStore(retrieve_error=RuntimeError("retrieve boom")),
|
|
)
|
|
retrieve_result = handler._execute_retrieval(CCRToolCall(tool_call_id="t2", hash_key="abc"))
|
|
assert retrieve_result.success is False
|
|
assert "Retrieval failed: retrieve boom" in retrieve_result.content
|
|
|
|
|
|
def test_create_tool_result_message_google_and_generic_formats() -> None:
|
|
handler = CCRResponseHandler()
|
|
results = [
|
|
CCRToolResult(tool_call_id="headroom_retrieve", content='{"count": 1}', success=True)
|
|
]
|
|
google_message = handler._create_tool_result_message(results, "google")
|
|
assert google_message == {
|
|
"role": "user",
|
|
"parts": [{"functionResponse": {"name": "headroom_retrieve", "response": {"count": 1}}}],
|
|
}
|
|
|
|
generic_message = handler._create_tool_result_message(
|
|
[CCRToolResult(tool_call_id="tool-1", content="not-json", success=False)],
|
|
"other",
|
|
)
|
|
assert generic_message["role"] == "tool"
|
|
assert json.loads(generic_message["content"]) == [
|
|
{"tool_call_id": "tool-1", "result": "not-json"}
|
|
]
|
|
|
|
invalid_google = handler._create_tool_result_message(
|
|
[CCRToolResult(tool_call_id="headroom_retrieve", content="not-json", success=True)],
|
|
"google",
|
|
)
|
|
assert invalid_google["parts"][0]["functionResponse"]["response"] == {"content": "not-json"}
|
|
|
|
|
|
def test_create_tool_result_message_google_preserves_call_id() -> None:
|
|
handler = CCRResponseHandler()
|
|
message = handler._create_tool_result_message(
|
|
[
|
|
CCRToolResult(
|
|
tool_call_id="call-1",
|
|
tool_name="headroom_retrieve",
|
|
content='{"count": 1}',
|
|
success=True,
|
|
)
|
|
],
|
|
"google",
|
|
)
|
|
|
|
assert message["parts"][0]["functionResponse"] == {
|
|
"name": "headroom_retrieve",
|
|
"id": "call-1",
|
|
"response": {"count": 1},
|
|
}
|
|
|
|
|
|
def test_extract_assistant_message_google_and_generic() -> None:
|
|
handler = CCRResponseHandler()
|
|
google_message = handler._extract_assistant_message(
|
|
{"candidates": [{"content": {"parts": [{"text": "hello"}]}}]},
|
|
"google",
|
|
)
|
|
assert google_message == {"role": "model", "parts": [{"text": "hello"}]}
|
|
|
|
assert handler._extract_assistant_message({}, "google") == {"role": "model", "parts": []}
|
|
assert handler._extract_assistant_message({"content": "plain"}, "other") == {
|
|
"role": "assistant",
|
|
"content": "plain",
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_response_openai_success_and_failure(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
handler = CCRResponseHandler()
|
|
initial_response = {
|
|
"choices": [
|
|
{
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": None,
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_1",
|
|
"type": "function",
|
|
"function": {
|
|
"name": CCR_TOOL_NAME,
|
|
"arguments": '{"hash":"aaaaaaaaaaaaaaaaaaaaaaaa"}',
|
|
},
|
|
}
|
|
],
|
|
}
|
|
}
|
|
]
|
|
}
|
|
monkeypatch.setattr(
|
|
handler,
|
|
"_execute_retrieval",
|
|
lambda call: CCRToolResult(
|
|
tool_call_id=call.tool_call_id,
|
|
content='{"hash":"aaaaaaaaaaaaaaaaaaaaaaaa"}',
|
|
success=True,
|
|
),
|
|
)
|
|
|
|
captured_messages: list[list[dict[str, Any]]] = []
|
|
|
|
async def success_api_call(messages, tools):
|
|
captured_messages.append(messages)
|
|
return {"choices": [{"message": {"role": "assistant", "content": "done"}}]}
|
|
|
|
result = await handler.handle_response(
|
|
initial_response, [{"role": "user", "content": "hi"}], [], success_api_call, "openai"
|
|
)
|
|
assert result == {"choices": [{"message": {"role": "assistant", "content": "done"}}]}
|
|
assert captured_messages[0][1]["role"] == "assistant"
|
|
assert captured_messages[0][2]["role"] == "tool"
|
|
assert handler.get_stats()["total_retrievals"] == 1
|
|
|
|
async def failing_api_call(messages, tools):
|
|
raise RuntimeError("continuation failed")
|
|
|
|
failed = await handler.handle_response(initial_response, [], [], failing_api_call, "openai")
|
|
assert failed == initial_response
|
|
|
|
|
|
def test_streaming_buffer_and_parse_sse_helpers() -> None:
|
|
buffer = StreamingCCRBuffer()
|
|
assert buffer.add_chunk(b"plain") is False
|
|
assert buffer.get_accumulated() == b"plain"
|
|
|
|
handler = StreamingCCRHandler(CCRResponseHandler(), provider="anthropic")
|
|
# Per SSE spec each event is terminated by `\n\n`. The byte-buffer
|
|
# parser introduced in PR-A8 requires the spec terminator so partial
|
|
# multi-byte UTF-8 reads don't corrupt event boundaries.
|
|
stop_details = {"type": "refusal", "message": "policy refusal"}
|
|
anthropic_data = b"\n\n".join(
|
|
[
|
|
b'data: {"type":"message_start","message":{"id":"msg_1","model":"claude-fable-5","role":"assistant","usage":{"input_tokens":7}}}',
|
|
b'data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}',
|
|
b'data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":""}}',
|
|
b'data: {"type":"content_block_delta","index":0,"delta":{"type":"signature_delta","signature":"sig_fable"}}',
|
|
b'data: {"type":"content_block_stop","index":0}',
|
|
b'data: {"type":"content_block_start","index":1,"content_block":{"type":"redacted_thinking","data":"ENC:abc"}}',
|
|
b'data: {"type":"content_block_stop","index":1}',
|
|
b'data: {"type":"content_block_start","content_block":{"type":"text","text":"Hel"}}',
|
|
b'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"lo"}}',
|
|
b'data: {"type":"content_block_stop"}',
|
|
b'data: {"type":"content_block_start","content_block":{"type":"tool_use","id":"tool_1","name":"headroom_retrieve"}}',
|
|
b'data: {"type":"content_block_delta","delta":{"type":"input_json_delta","partial_json":"{\\"hash\\":\\"abc\\"}"}}',
|
|
b'data: {"type":"content_block_stop"}',
|
|
(
|
|
b'data: {"type":"message_delta","delta":{"stop_reason":"refusal",'
|
|
b'"stop_details":{"type":"refusal","message":"policy refusal"}},'
|
|
b'"usage":{"output_tokens":3}}'
|
|
),
|
|
b"data: [DONE]\n\n",
|
|
]
|
|
)
|
|
parsed = handler._parse_sse_stream(anthropic_data)
|
|
assert parsed["content"][0] == {
|
|
"type": "thinking",
|
|
"signature": "sig_fable",
|
|
"thinking": "",
|
|
}
|
|
assert parsed["content"][1] == {"type": "redacted_thinking", "data": "ENC:abc"}
|
|
assert parsed["content"][2] == {"type": "text", "text": "Hello"}
|
|
assert parsed["content"][3]["name"] == "headroom_retrieve"
|
|
assert parsed["content"][3]["input"] == {"hash": "abc"}
|
|
assert parsed["stop_reason"] == "refusal"
|
|
assert parsed["stop_details"] == stop_details
|
|
assert parsed["usage"]["output_tokens"] == 3
|
|
|
|
openai_handler = StreamingCCRHandler(CCRResponseHandler(), provider="openai")
|
|
parsed_openai = openai_handler._reconstruct_openai_response(
|
|
[
|
|
{"choices": [{"delta": {"content": "Hi"}}]},
|
|
{
|
|
"choices": [
|
|
{
|
|
"delta": {
|
|
"tool_calls": [
|
|
{
|
|
"index": 0,
|
|
"id": "call_1",
|
|
"function": {
|
|
"name": "headroom_retrieve",
|
|
"arguments": '{"hash":"aaaaaaaaaaaa',
|
|
},
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"choices": [
|
|
{
|
|
"delta": {
|
|
"tool_calls": [
|
|
{
|
|
"index": 0,
|
|
"function": {"arguments": 'aaaaaaaaaaaa"}'},
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
},
|
|
]
|
|
)
|
|
message = parsed_openai["choices"][0]["message"]
|
|
assert message["content"] == "Hi"
|
|
assert message["tool_calls"][0]["id"] == "call_1"
|
|
assert message["tool_calls"][0]["function"]["arguments"] == (
|
|
'{"hash":"aaaaaaaaaaaaaaaaaaaaaaaa"}'
|
|
)
|
|
|
|
|
|
def test_reconstruct_openai_response_tolerates_null_tool_calls_and_function() -> None:
|
|
# Some OpenAI-compatible providers put ``"tool_calls": null`` (and
|
|
# ``"function": null``) in a streaming delta instead of omitting the key.
|
|
# Only checking key presence made the reconstruction iterate ``None`` and
|
|
# raise ``TypeError``, aborting the whole CCR round.
|
|
handler = StreamingCCRHandler(CCRResponseHandler(), provider="openai")
|
|
|
|
parsed = handler._reconstruct_openai_response(
|
|
[
|
|
{"choices": [{"delta": {"content": "Hi", "tool_calls": None}}]},
|
|
{"choices": [{"delta": {"tool_calls": [{"index": 0, "function": None}]}}]},
|
|
{
|
|
"choices": [
|
|
{
|
|
"delta": {
|
|
"tool_calls": [
|
|
{
|
|
"index": 0,
|
|
"id": "call_1",
|
|
"function": {"name": "f", "arguments": "{}"},
|
|
}
|
|
]
|
|
}
|
|
}
|
|
]
|
|
},
|
|
]
|
|
)
|
|
|
|
message = parsed["choices"][0]["message"]
|
|
# The null frames did not crash, and the real tool call still reconstructs.
|
|
assert message["content"] == "Hi"
|
|
assert message["tool_calls"][0]["id"] == "call_1"
|
|
assert message["tool_calls"][0]["function"] == {"name": "f", "arguments": "{}"}
|
|
|
|
|
|
def test_extract_assistant_message_responses_output_null_coerces_to_list() -> None:
|
|
# A Responses turn with a present-but-null `output` (some gateways send this
|
|
# on an empty/filtered turn) must not become None: handle_response later
|
|
# does `current_messages.extend(...)` on it, which would raise TypeError.
|
|
handler = CCRResponseHandler()
|
|
|
|
assert handler._extract_assistant_message({"output": None}, "openai_responses") == {
|
|
"_openai_responses_output_items": []
|
|
}
|
|
# An absent output is also an empty list, and a real output passes through.
|
|
assert handler._extract_assistant_message({}, "openai_responses") == {
|
|
"_openai_responses_output_items": []
|
|
}
|
|
assert handler._extract_assistant_message(
|
|
{"output": [{"type": "message"}]}, "openai_responses"
|
|
) == {"_openai_responses_output_items": [{"type": "message"}]}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_handler_process_stream_pass_through_and_ccr(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
response_handler = CCRResponseHandler()
|
|
handler = StreamingCCRHandler(response_handler, provider="anthropic")
|
|
|
|
passthrough_chunks = [
|
|
b'data: {"type":"content_block_delta","delta":{"text":"hello"}}',
|
|
b'data: {"stop_reason":"end_turn"}',
|
|
]
|
|
yielded = [
|
|
chunk
|
|
async for chunk in handler.process_stream(
|
|
_async_iter(passthrough_chunks), [], None, lambda m, t: None
|
|
)
|
|
]
|
|
assert yielded == passthrough_chunks
|
|
|
|
ccr_handler = StreamingCCRHandler(response_handler, provider="anthropic")
|
|
monkeypatch.setattr(
|
|
ccr_handler,
|
|
"_parse_sse_stream",
|
|
lambda data: {
|
|
"content": [
|
|
{
|
|
"type": "tool_use",
|
|
"id": "tool_1",
|
|
"name": CCR_TOOL_NAME,
|
|
"input": {"hash": "abc"},
|
|
}
|
|
]
|
|
},
|
|
)
|
|
|
|
async def fake_handle_response(response, messages, tools, api_call_fn, provider): # noqa: ANN001
|
|
return {"content": [{"type": "text", "text": "done"}]}
|
|
|
|
async def fake_response_to_sse(response): # noqa: ANN001
|
|
yield b"event: message_start\n"
|
|
yield b"event: message_stop\n"
|
|
|
|
monkeypatch.setattr(response_handler, "handle_response", fake_handle_response)
|
|
monkeypatch.setattr(ccr_handler, "_response_to_sse", fake_response_to_sse)
|
|
|
|
ccr_chunks = [
|
|
b'{"type":"tool_use","name":"headroom_retrieve"',
|
|
b',"stop_reason":"tool_use"}',
|
|
b"tail",
|
|
]
|
|
streamed = [
|
|
chunk
|
|
async for chunk in ccr_handler.process_stream(
|
|
_async_iter(ccr_chunks), [], None, lambda m, t: None
|
|
)
|
|
]
|
|
assert streamed == [b"event: message_start\n", b"event: message_stop\n"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_handler_falls_back_to_buffer_on_processing_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
response_handler = CCRResponseHandler()
|
|
handler = StreamingCCRHandler(response_handler, provider="openai")
|
|
monkeypatch.setattr(
|
|
handler,
|
|
"_parse_sse_stream",
|
|
lambda data: (_ for _ in ()).throw(RuntimeError("parse failed")),
|
|
)
|
|
|
|
# Real OpenAI wire shape: a `tool_calls` delta naming the CCR tool, then
|
|
# the `[DONE]` sentinel. This test previously fed Anthropic-shaped bytes to
|
|
# an ``openai`` handler, so it never reached the OpenAI detection path.
|
|
chunks = [
|
|
b'data: {"choices":[{"index":0,"delta":{"tool_calls":[{"index":0,'
|
|
b'"id":"call_1","function":{"name":"headroom_retrieve",'
|
|
b'"arguments":"{}"}}]}}]}\n\n',
|
|
b"data: [DONE]\n\n",
|
|
]
|
|
streamed = [
|
|
chunk
|
|
async for chunk in handler.process_stream(_async_iter(chunks), [], None, lambda m, t: None)
|
|
]
|
|
assert streamed == [b"".join(chunks)]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_response_to_sse_formats() -> None:
|
|
anthropic = StreamingCCRHandler(CCRResponseHandler(), provider="anthropic")
|
|
anthropic_chunks = [chunk async for chunk in anthropic._response_to_sse({"content": []})]
|
|
assert anthropic_chunks[0].startswith(b"event: message_start\n")
|
|
assert anthropic_chunks[-1] == b'event: message_stop\ndata: {"type": "message_stop"}\n\n'
|
|
|
|
openai = StreamingCCRHandler(CCRResponseHandler(), provider="openai")
|
|
openai_chunks = [chunk async for chunk in openai._response_to_sse({"choices": []})]
|
|
# An empty body still produces well-formed chunk frames (role, then a
|
|
# terminal frame carrying finish_reason) rather than a single non-streaming
|
|
# body a streaming client cannot read.
|
|
assert openai_chunks[-1] == b"data: [DONE]\n\n"
|
|
frames = [json.loads(chunk.decode()[len("data: ") :]) for chunk in openai_chunks[:-1]]
|
|
assert [frame["object"] for frame in frames] == ["chat.completion.chunk"] * 2
|
|
assert frames[0]["choices"][0]["delta"] == {"role": "assistant"}
|
|
assert frames[-1]["choices"][0]["finish_reason"] == "stop"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_response_to_sse_preserves_anthropic_shape() -> None:
|
|
handler = StreamingCCRHandler(CCRResponseHandler(), provider="anthropic")
|
|
stop_details = {"type": "refusal", "message": "policy refusal"}
|
|
response = {
|
|
"id": "msg_1",
|
|
"model": "claude-fable-5",
|
|
"role": "assistant",
|
|
"content": [
|
|
{"type": "thinking", "thinking": "", "signature": "sig_fable"},
|
|
{"type": "redacted_thinking", "data": "ENC:abc"},
|
|
{"type": "text", "text": "done"},
|
|
],
|
|
"stop_reason": "refusal",
|
|
"stop_details": stop_details,
|
|
"usage": {"input_tokens": 7, "output_tokens": 3},
|
|
}
|
|
|
|
chunks = [chunk async for chunk in handler._response_to_sse(response)]
|
|
events = _sse_json_events(chunks)
|
|
message_delta = next(event for event in events if event["type"] == "message_delta")
|
|
|
|
assert message_delta["delta"]["stop_reason"] == "refusal"
|
|
assert message_delta["delta"]["stop_details"] == stop_details
|
|
assert any(event.get("delta", {}).get("type") == "signature_delta" for event in events)
|
|
assert any(
|
|
event.get("content_block", {}).get("type") == "redacted_thinking" for event in events
|
|
)
|
|
|
|
parsed = handler._parse_sse_stream(b"".join(chunks))
|
|
assert parsed["content"][0]["signature"] == "sig_fable"
|
|
assert parsed["content"][0]["thinking"] == ""
|
|
assert parsed["content"][1]["data"] == "ENC:abc"
|
|
assert parsed["stop_reason"] == "refusal"
|
|
assert parsed["stop_details"] == stop_details
|
|
|
|
|
|
def test_reconstruct_server_tool_use_input_from_partial_json() -> None:
|
|
# StreamingCCRHandler._reconstruct_anthropic_response must parse streamed
|
|
# input_json_delta into `input` for server_tool_use, not only tool_use.
|
|
# The narrow type gate left server_tool_use.input malformed and leaked the
|
|
# `_partial_json` scratch key into replayed assistant history → Anthropic
|
|
# 400 `server_tool_use.input: Input should be an object` (#2438).
|
|
handler = StreamingCCRHandler(CCRResponseHandler(), provider="anthropic")
|
|
events = [
|
|
{"type": "message_start", "message": {"id": "msg_1", "model": "claude-opus-4"}},
|
|
{
|
|
"type": "content_block_start",
|
|
"index": 0,
|
|
"content_block": {
|
|
"type": "server_tool_use",
|
|
"id": "srvtoolu_1",
|
|
"name": "web_search",
|
|
"input": {},
|
|
},
|
|
},
|
|
{
|
|
"type": "content_block_delta",
|
|
"index": 0,
|
|
"delta": {"type": "input_json_delta", "partial_json": '{"query": "x"}'},
|
|
},
|
|
{"type": "content_block_stop", "index": 0},
|
|
]
|
|
response = handler._reconstruct_anthropic_response(events)
|
|
block = response["content"][0]
|
|
assert block["type"] == "server_tool_use"
|
|
assert block["input"] == {"query": "x"}
|
|
assert "_partial_json" not in block
|
|
|
|
|
|
def _openai_chunk(delta: dict[str, Any], finish_reason: str | None = None) -> bytes:
|
|
"""One `chat.completion.chunk` SSE frame in the shape a real backend sends."""
|
|
payload = {
|
|
"id": "chatcmpl_1",
|
|
"object": "chat.completion.chunk",
|
|
"created": 1700000000,
|
|
"model": "gpt-4o-mini",
|
|
"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
|
|
}
|
|
return f"data: {json.dumps(payload)}\n\n".encode()
|
|
|
|
|
|
def test_streaming_buffer_detects_ccr_in_openai_tool_calls_delta() -> None:
|
|
# An OpenAI-compatible stream never emits Anthropic's `"type":"tool_use"`
|
|
# marker; its tool calls arrive as a `tool_calls` array inside
|
|
# `choices[].delta`. Scanning only for the Anthropic marker meant CCR was
|
|
# never detected on this provider.
|
|
buffer = StreamingCCRBuffer(provider="openai")
|
|
|
|
assert buffer.add_chunk(_openai_chunk({"role": "assistant"})) is False
|
|
detected = buffer.add_chunk(
|
|
_openai_chunk(
|
|
{
|
|
"tool_calls": [
|
|
{
|
|
"index": 0,
|
|
"id": "call_1",
|
|
"function": {"name": CCR_TOOL_NAME, "arguments": ""},
|
|
}
|
|
]
|
|
}
|
|
)
|
|
)
|
|
|
|
assert detected is True
|
|
assert buffer.detected_ccr is True
|
|
|
|
# A non-CCR tool call on the same provider must not trip detection.
|
|
other = StreamingCCRBuffer(provider="openai")
|
|
assert (
|
|
other.add_chunk(
|
|
_openai_chunk(
|
|
{"tool_calls": [{"index": 0, "id": "c", "function": {"name": "other_tool"}}]}
|
|
)
|
|
)
|
|
is False
|
|
)
|
|
assert other.detected_ccr is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_openai_stream_without_ccr_yields_every_chunk() -> None:
|
|
# A short OpenAI stream with no CCR call must pass through byte for byte.
|
|
# End-of-stream was detected by scanning for Anthropic's `stop_reason`,
|
|
# which an OpenAI stream never contains, so nothing was ever flushed and
|
|
# the client received an empty response.
|
|
handler = StreamingCCRHandler(CCRResponseHandler(), provider="openai")
|
|
chunks = [
|
|
_openai_chunk({"role": "assistant"}),
|
|
_openai_chunk({"content": "hello "}),
|
|
_openai_chunk({"content": "world"}),
|
|
_openai_chunk({}, finish_reason="stop"),
|
|
b"data: [DONE]\n\n",
|
|
]
|
|
|
|
streamed = [
|
|
chunk
|
|
async for chunk in handler.process_stream(_async_iter(chunks), [], None, lambda m, t: None)
|
|
]
|
|
|
|
assert streamed == chunks
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_openai_stream_past_flush_threshold_keeps_the_tail() -> None:
|
|
# Past 10 000 buffered bytes the handler flushes in batches. Without an
|
|
# end-of-stream match the final sub-threshold batch was never flushed, so
|
|
# a long response visibly stopped mid-sentence.
|
|
handler = StreamingCCRHandler(CCRResponseHandler(), provider="openai")
|
|
chunks = [
|
|
_openai_chunk({"role": "assistant"}),
|
|
_openai_chunk({"content": "x" * 11000}),
|
|
_openai_chunk({"content": "the tail that used to be dropped"}),
|
|
_openai_chunk({}, finish_reason="stop"),
|
|
b"data: [DONE]\n\n",
|
|
]
|
|
|
|
streamed = [
|
|
chunk
|
|
async for chunk in handler.process_stream(_async_iter(chunks), [], None, lambda m, t: None)
|
|
]
|
|
|
|
assert streamed == chunks
|
|
assert b"the tail that used to be dropped" in b"".join(streamed)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_openai_stream_without_done_sentinel_still_flushes() -> None:
|
|
# Upstream can truncate before `[DONE]`, and some gateways omit it. Bytes
|
|
# left in the buffer when the source iterator is exhausted are real
|
|
# response data, so they are flushed rather than discarded.
|
|
handler = StreamingCCRHandler(CCRResponseHandler(), provider="openai")
|
|
chunks = [_openai_chunk({"role": "assistant"}), _openai_chunk({"content": "partial answer"})]
|
|
|
|
streamed = [
|
|
chunk
|
|
async for chunk in handler.process_stream(_async_iter(chunks), [], None, lambda m, t: None)
|
|
]
|
|
|
|
assert streamed == chunks
|
|
|
|
|
|
def test_reconstruct_openai_response_marks_tool_calls_finish_reason() -> None:
|
|
# OpenAI requires `finish_reason: "tool_calls"` when the message carries
|
|
# tool calls. It was hardcoded to "stop", so a client driving its agent
|
|
# loop off `finish_reason` ended the turn instead of running the tools.
|
|
handler = StreamingCCRHandler(CCRResponseHandler(), provider="openai")
|
|
|
|
parsed = handler._reconstruct_openai_response(
|
|
[
|
|
{"id": "chatcmpl_1", "model": "gpt-4o-mini", "created": 1700000000},
|
|
{
|
|
"choices": [
|
|
{
|
|
"delta": {
|
|
"tool_calls": [
|
|
{
|
|
"index": 0,
|
|
"id": "call_1",
|
|
"function": {
|
|
"name": CCR_TOOL_NAME,
|
|
"arguments": '{"hash":"abc"}',
|
|
},
|
|
}
|
|
]
|
|
},
|
|
"finish_reason": None,
|
|
}
|
|
]
|
|
},
|
|
{
|
|
"choices": [{"delta": None, "finish_reason": "tool_calls"}],
|
|
"usage": {"prompt_tokens": 12, "completion_tokens": 3},
|
|
},
|
|
]
|
|
)
|
|
|
|
assert parsed["choices"][0]["finish_reason"] == "tool_calls"
|
|
# The chunk envelope is carried through so the reconstructed body is a
|
|
# valid `chat.completion` rather than a bare `choices` list.
|
|
assert parsed["object"] == "chat.completion"
|
|
assert parsed["id"] == "chatcmpl_1"
|
|
assert parsed["model"] == "gpt-4o-mini"
|
|
assert parsed["created"] == 1700000000
|
|
assert parsed["usage"] == {"prompt_tokens": 12, "completion_tokens": 3}
|
|
|
|
|
|
def test_reconstruct_openai_response_keeps_upstream_finish_reason() -> None:
|
|
# With no tool calls, the upstream reason is preserved instead of being
|
|
# rewritten to "stop": a truncated turn must stay reported as truncated.
|
|
handler = StreamingCCRHandler(CCRResponseHandler(), provider="openai")
|
|
|
|
parsed = handler._reconstruct_openai_response(
|
|
[
|
|
{"choices": [{"delta": {"content": "half an ans"}, "finish_reason": None}]},
|
|
{"choices": [{"delta": {}, "finish_reason": "length"}]},
|
|
]
|
|
)
|
|
|
|
assert parsed["choices"][0]["finish_reason"] == "length"
|
|
assert parsed["choices"][0]["message"]["content"] == "half an ans"
|
|
|
|
# And an absent reason still defaults to "stop".
|
|
defaulted = handler._reconstruct_openai_response(
|
|
[{"choices": [{"delta": {"content": "hi"}}]}],
|
|
)
|
|
assert defaulted["choices"][0]["finish_reason"] == "stop"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_response_to_sse_emits_openai_chunk_frames() -> None:
|
|
# A streaming client reads `choices[].delta`. Re-serialising the
|
|
# reconstructed non-streaming body (`choices[].message`) into one SSE frame
|
|
# made both the content and the tool calls invisible to it.
|
|
handler = StreamingCCRHandler(CCRResponseHandler(), provider="openai")
|
|
response = {
|
|
"id": "chatcmpl_2",
|
|
"object": "chat.completion",
|
|
"created": 1700000001,
|
|
"model": "gpt-4o-mini",
|
|
"choices": [
|
|
{
|
|
"index": 0,
|
|
"message": {
|
|
"role": "assistant",
|
|
"content": "done",
|
|
"tool_calls": [
|
|
{
|
|
"id": "call_9",
|
|
"type": "function",
|
|
"function": {"name": "do_thing", "arguments": '{"a":1}'},
|
|
}
|
|
],
|
|
},
|
|
"finish_reason": "tool_calls",
|
|
}
|
|
],
|
|
}
|
|
|
|
chunks = [chunk async for chunk in handler._response_to_sse(response)]
|
|
|
|
assert chunks[-1] == b"data: [DONE]\n\n"
|
|
frames = [json.loads(chunk.decode()[len("data: ") :]) for chunk in chunks[:-1]]
|
|
assert all(frame["object"] == "chat.completion.chunk" for frame in frames)
|
|
assert all("delta" in frame["choices"][0] for frame in frames)
|
|
assert all(frame["id"] == "chatcmpl_2" for frame in frames)
|
|
|
|
deltas = [frame["choices"][0]["delta"] for frame in frames]
|
|
assert deltas[0] == {"role": "assistant"}
|
|
assert deltas[1] == {"content": "done"}
|
|
assert deltas[2]["tool_calls"][0]["id"] == "call_9"
|
|
assert deltas[2]["tool_calls"][0]["index"] == 0
|
|
assert deltas[2]["tool_calls"][0]["function"] == {"name": "do_thing", "arguments": '{"a":1}'}
|
|
assert frames[-1]["choices"][0]["finish_reason"] == "tool_calls"
|