headroom/tests/test_ccr_inline_resolve_handlers.py
Parideboy ce8ce8313f
fix(ccr): resolve <<ccr:...>> markers inline when no retrieve-tool path exists (#2512)
## Description

Fixes #2509. CCR marker resolution today depends entirely on the model
calling `headroom_retrieve` back a tool-call round-trip. Callers with no
such round-trip (e.g. Headroom running as a LiteLLM guardrail/proxy hop,
per the issue's repro) never get an offered path to redeem a marker, so
raw `<<ccr:HASH,type,size>>` text leaks straight to the agent.

This adds an explicit, opt-in fallback: `--ccr-inline-resolve` /
`HEADROOM_CCR_INLINE_RESOLVE`. When set, the proxy resolves markers
directly from the compression store on the response path instead of
waiting for a tool call. Off by default, guessing "this caller can't use
tools" is fragile, so operators opt in explicitly for guardrail/proxy
deployments.

## Type of Change

- [x] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change
- [ ] Documentation update

## Changes Made

- `headroom/ccr/marker_resolution.py` (new): `resolve_markers_in_text` /
`resolve_markers_in_response` regex-match `<<ccr:HASH,...>>`, look up
the hash in `CompressionStore`, splice the original content back in. A
miss (expired/evicted hash) leaves the marker in place with the miss
reason appended, since there's no tool-call round-trip to report it back
to the model.
- `headroom/proxy/models.py`: `ProxyConfig.ccr_resolve_markers_inline:
bool = False`.
- `headroom/cli/proxy.py`: `--ccr-inline-resolve` flag /
`HEADROOM_CCR_INLINE_RESOLVE` env, wired into `ProxyConfig`.
- `headroom/proxy/handlers/anthropic.py`,
`headroom/proxy/handlers/openai.py`:
call `resolve_markers_in_response` on the finalized response JSON, right
after existing CCR tool-call handling, at all three non-streaming
response sites (Anthropic Messages, OpenAI Chat Completions backend
path, OpenAI Responses API). Streaming responses are out of scope for
this PR, tracked as follow-up, noted in the module docstring's scope.

## Testing

- [x] Added new tests
- [x] All tests pass locally

```
$ python -m pytest tests/test_ccr_marker_resolution.py -q
============================= test session starts =============================
collected 6 items

tests\test_ccr_marker_resolution.py ......                               [100%]

============================== 6 passed in 0.45s ==============================

$ python -m pytest tests/test_ccr.py tests/test_ccr_response_handler.py tests/test_ccr_response_handler_extra.py tests/test_ccr_response_handler_openai_responses.py tests/test_proxy/test_openai_responses_ccr.py tests/test_proxy/test_anthropic_ccr_raise.py -q
======================= 83 passed, 1 warning in 34.50s ========================
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.13.11, local headroom repo
(`G:\Programmi Aggiuntivi\headroom`)
- Exact command / steps: `python -m pytest
tests/test_ccr_marker_resolution.py tests/test_ccr.py
tests/test_ccr_response_handler.py
tests/test_ccr_response_handler_extra.py
tests/test_ccr_response_handler_openai_responses.py
tests/test_proxy/test_openai_responses_ccr.py
tests/test_proxy/test_anthropic_ccr_raise.py -q`
- Observed result: 89 passed, 0 failed (6 new + 83 existing CCR tests,
no regressions). `ruff check`, `ruff format --check`, and `mypy
--ignore-missing-imports` all clean on every changed/new file.
- Not tested: the actual Docker Compose / LiteLLM guardrail deployment
from the issue's repro steps (no such environment available here);
streaming response paths (out of scope, see Changes Made).

## Review Readiness

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

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-12 00:18:43 -05:00

173 lines
5.2 KiB
Python

"""Handler-level wiring for ``--ccr-inline-resolve`` (issue #2509).
The pure-module tests in ``test_ccr_marker_resolution.py`` prove the
substitution logic. These tests prove the thing that actually broke: the
resolve call has to run on the response path *when the model never emitted a
``headroom_retrieve`` tool call at all*. That is the whole #2509 shape —
Headroom behind a LiteLLM guardrail hop with no tool-call turn — so any wiring
that sits behind a ``has_ccr_tool_calls`` gate is a no-op for its own use case.
Every response fixture below therefore has zero tool calls.
"""
from __future__ import annotations
import pytest
fastapi = pytest.importorskip("fastapi")
httpx = pytest.importorskip("httpx")
from fastapi.testclient import TestClient # noqa: E402
from headroom.cache.compression_store import ( # noqa: E402
get_compression_store,
reset_compression_store,
)
from headroom.proxy.server import ProxyConfig, create_app # noqa: E402
ORIGINAL = "the original uncompressed content"
@pytest.fixture(autouse=True)
def reset_store():
reset_compression_store()
yield
reset_compression_store()
def _marker() -> str:
hash_key = get_compression_store().store(
original=ORIGINAL,
compressed="[]",
original_item_count=1,
compressed_item_count=0,
)
return f"<<ccr:{hash_key},string,23.6KB>>"
def _config(*, inline_resolve: bool) -> ProxyConfig:
# No backend -> the "Direct OpenAI API (no backend configured)" path.
return ProxyConfig(
optimize=False,
cache_enabled=False,
rate_limit_enabled=False,
ccr_resolve_markers_inline=inline_resolve,
)
def _chat_response(marker: str) -> dict:
return {
"id": "chatcmpl-1",
"object": "chat.completion",
"model": "gpt-4o",
"choices": [
{
"index": 0,
"message": {"role": "assistant", "content": f"here it is: {marker}"},
"finish_reason": "stop",
}
],
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
}
def _responses_response(marker: str) -> dict:
return {
"id": "resp_1",
"object": "response",
"model": "gpt-4o",
"status": "completed",
"output": [
{
"type": "message",
"role": "assistant",
"content": [{"type": "output_text", "text": f"here it is: {marker}"}],
}
],
"usage": {"input_tokens": 10, "output_tokens": 5, "total_tokens": 15},
}
def _anthropic_response(marker: str) -> dict:
return {
"id": "msg_1",
"type": "message",
"role": "assistant",
"model": "claude-sonnet-4-20250514",
"content": [{"type": "text", "text": f"here it is: {marker}"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 10, "output_tokens": 5},
}
def _run(config: ProxyConfig, path: str, body: dict, upstream: dict) -> httpx.Response:
async def fake_retry(method, url, headers, req_body, *args, **kwargs):
return httpx.Response(200, json=upstream, headers={"content-type": "application/json"})
app = create_app(config)
with TestClient(app) as client:
client.app.state.proxy._retry_request = fake_retry
return client.post(
path,
json=body,
headers={"Authorization": "Bearer test-key", "x-api-key": "test-key"},
)
@pytest.mark.parametrize("inline_resolve", [True, False])
def test_openai_chat_direct_path(inline_resolve):
marker = _marker()
resp = _run(
_config(inline_resolve=inline_resolve),
"/v1/chat/completions",
{"model": "gpt-4o", "messages": [{"role": "user", "content": "hi"}], "stream": False},
_chat_response(marker),
)
assert resp.status_code == 200, resp.text
text = resp.json()["choices"][0]["message"]["content"]
if inline_resolve:
assert text == f"here it is: {ORIGINAL}"
else:
assert text == f"here it is: {marker}"
@pytest.mark.parametrize("inline_resolve", [True, False])
def test_openai_responses_path(inline_resolve):
marker = _marker()
resp = _run(
_config(inline_resolve=inline_resolve),
"/v1/responses",
{"model": "gpt-4o", "input": "hi", "stream": False},
_responses_response(marker),
)
assert resp.status_code == 200, resp.text
text = resp.json()["output"][0]["content"][0]["text"]
if inline_resolve:
assert text == f"here it is: {ORIGINAL}"
else:
assert text == f"here it is: {marker}"
@pytest.mark.parametrize("inline_resolve", [True, False])
def test_anthropic_messages_path(inline_resolve):
marker = _marker()
resp = _run(
_config(inline_resolve=inline_resolve),
"/v1/messages",
{
"model": "claude-sonnet-4-20250514",
"max_tokens": 64,
"messages": [{"role": "user", "content": "hi"}],
"stream": False,
},
_anthropic_response(marker),
)
assert resp.status_code == 200, resp.text
text = resp.json()["content"][0]["text"]
if inline_resolve:
assert text == f"here it is: {ORIGINAL}"
else:
assert text == f"here it is: {marker}"