fix(headroom): resolve CCR retrieval on streaming /chat/completions

Streaming chat completions returned a CustomStreamWrapper, so the agentic loop dispatch in main.py (guarded on ModelResponse) never ran and the headroom_retrieve tool call was streamed straight to a client that never declared the tool. The guardrail now converts a CCR stream request to a non-streaming call in its deployment hook and the loop fake-streams the resolved answer back.
This commit is contained in:
Devin AI 2026-07-29 00:24:30 +00:00
parent cd9c410ae2
commit fa5a4f06ed
6 changed files with 202 additions and 15 deletions

View file

@ -1,12 +1,14 @@
# this is a patch to allow for agentic loops covering llm_http_handler.py and openai sdk based calling flows for the .completion() api # this is a patch to allow for agentic loops covering llm_http_handler.py and openai sdk based calling flows for the .completion() api
import json import json
from collections.abc import Mapping
from typing import cast from typing import cast
from litellm._logging import verbose_logger from litellm._logging import verbose_logger
from litellm.integrations.custom_logger import CustomLogger from litellm.integrations.custom_logger import CustomLogger
from litellm.types.integrations.custom_logger import ( from litellm.types.integrations.custom_logger import (
CHAT_COMPLETION_AGENTIC_SURFACE, CHAT_COMPLETION_AGENTIC_SURFACE,
HEADROOM_CONVERTED_STREAM_KEY,
NON_CODE_INTERPRETER_INTERCEPTION_INTERNAL_PREFIXES, NON_CODE_INTERPRETER_INTERCEPTION_INTERNAL_PREFIXES,
AgenticLoopPlan, AgenticLoopPlan,
AgenticLoopRequestPatch, AgenticLoopRequestPatch,
@ -46,6 +48,12 @@ def _post_hook_overridden(callback: CustomLogger) -> bool:
return getattr(func, "__func__", func) is not getattr(base, "__func__", base) return getattr(func, "__func__", func) is not getattr(base, "__func__", base)
def _converted_stream_requested(kwargs: Mapping[str, object]) -> bool:
return bool(
kwargs.get("_code_interpreter_interception_converted_stream") or kwargs.get(HEADROOM_CONVERTED_STREAM_KEY)
)
def _coerce_int(value: object, default: int) -> int: def _coerce_int(value: object, default: int) -> int:
return int(value) if isinstance(value, (int, str)) else default return int(value) if isinstance(value, (int, str)) else default
@ -80,16 +88,25 @@ def _check_agentic_loop_safety(
return fingerprint return fingerprint
def _wrap_response_as_fake_stream(response: object) -> object: def _wrap_response_as_fake_stream(
if getattr(response, "object", None) == "chat.completion.chunk": response: object,
*,
model: str,
custom_llm_provider: str,
logging_obj: object,
) -> object:
if isinstance(response, CustomStreamWrapper):
return response return response
if not hasattr(response, "choices"): if not isinstance(response, ModelResponse):
return response return response
from litellm.llms.base_llm.base_model_iterator import ( from litellm.llms.base_llm.base_model_iterator import MockResponseIterator
convert_model_response_to_streaming,
)
return convert_model_response_to_streaming(cast(ModelResponse, response)) return CustomStreamWrapper(
completion_stream=MockResponseIterator(model_response=response),
model=model,
custom_llm_provider=custom_llm_provider,
logging_obj=logging_obj,
)
def _add_agentic_loop_metadata(kwargs_for_followup: dict[str, object]) -> None: def _add_agentic_loop_metadata(kwargs_for_followup: dict[str, object]) -> None:
@ -170,8 +187,13 @@ async def _execute_chat_completion_agentic_plan(
model, model,
str(e), str(e),
) )
if kwargs.get("_code_interpreter_interception_converted_stream") and not depth: if _converted_stream_requested(kwargs) and not depth:
return _wrap_response_as_fake_stream(response_followup) return _wrap_response_as_fake_stream(
response_followup,
model=model,
custom_llm_provider=custom_llm_provider,
logging_obj=logging_obj,
)
return response_followup return response_followup
finally: finally:
try: try:
@ -295,9 +317,14 @@ async def maybe_run_chat_completion_agentic_loop(
str(e), str(e),
) )
if kwargs.get("_code_interpreter_interception_converted_stream") and not depth and hasattr(response, "choices"): if _converted_stream_requested(kwargs) and not depth:
return cast( return cast(
"ModelResponse | CustomStreamWrapper", "ModelResponse | CustomStreamWrapper",
_wrap_response_as_fake_stream(response), _wrap_response_as_fake_stream(
response,
model=model,
custom_llm_provider=custom_llm_provider,
logging_obj=logging_obj,
),
) )
return None return None

View file

@ -4,6 +4,7 @@ import json
import re import re
import time import time
import uuid import uuid
from collections.abc import Mapping
from typing import TYPE_CHECKING, Any, ClassVar, List, Literal, Optional from typing import TYPE_CHECKING, Any, ClassVar, List, Literal, Optional
import httpx import httpx
@ -35,8 +36,12 @@ from litellm.proxy.guardrails.guardrail_hooks.content_text import (
) )
from litellm.secret_managers.main import get_secret_str from litellm.secret_managers.main import get_secret_str
from litellm.types.guardrails import GuardrailEventHooks, Mode from litellm.types.guardrails import GuardrailEventHooks, Mode
from litellm.types.integrations.custom_logger import AgenticLoopPlan, AgenticLoopRequestPatch from litellm.types.integrations.custom_logger import (
from litellm.types.utils import GenericGuardrailAPIInputs HEADROOM_CONVERTED_STREAM_KEY,
AgenticLoopPlan,
AgenticLoopRequestPatch,
)
from litellm.types.utils import CallTypes, GenericGuardrailAPIInputs
if TYPE_CHECKING: if TYPE_CHECKING:
from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj from litellm.litellm_core_utils.litellm_logging import Logging as LiteLLMLoggingObj
@ -606,6 +611,19 @@ class HeadroomGuardrail(CustomGuardrail):
return {**inputs, "structured_messages": compressed, "tools": merged_tools} # pyright: ignore[reportReturnType] return {**inputs, "structured_messages": compressed, "tools": merged_tools} # pyright: ignore[reportReturnType]
async def async_pre_call_deployment_hook(
self,
kwargs: Mapping[str, Any],
call_type: CallTypes | None,
) -> dict[str, Any] | None: # mutable-ok: overrides CustomLogger hook whose contract is a plain dict
if call_type not in (CallTypes.completion, CallTypes.acompletion):
return None
if not kwargs.get("stream"):
return None
if not has_headroom_retrieve_tool(kwargs.get("tools")):
return None
return {**kwargs, "stream": False, HEADROOM_CONVERTED_STREAM_KEY: True}
async def async_should_run_agentic_loop( async def async_should_run_agentic_loop(
self, self,
response: Any, response: Any,

View file

@ -180,6 +180,7 @@ _UNTRUSTED_ROOT_CONTROL_FIELDS = (
"_code_interpreter_interception_converted_stream", "_code_interpreter_interception_converted_stream",
"_code_interpreter_interception_sandbox_key", "_code_interpreter_interception_sandbox_key",
"_code_interpreter_interception_session_scoped", "_code_interpreter_interception_session_scoped",
"_headroom_interception_converted_stream",
"max_agentic_loops", "max_agentic_loops",
) )

View file

@ -5,8 +5,14 @@ from pydantic import BaseModel, Field
CHAT_COMPLETION_AGENTIC_SURFACE = "chat_completions" CHAT_COMPLETION_AGENTIC_SURFACE = "chat_completions"
RESPONSES_AGENTIC_SURFACE = "responses" RESPONSES_AGENTIC_SURFACE = "responses"
CODE_INTERPRETER_INTERCEPTION_PREFIX = "_code_interpreter_interception" CODE_INTERPRETER_INTERCEPTION_PREFIX = "_code_interpreter_interception"
HEADROOM_INTERCEPTION_PREFIX = "_headroom_interception"
HEADROOM_CONVERTED_STREAM_KEY = f"{HEADROOM_INTERCEPTION_PREFIX}_converted_stream"
NON_CODE_INTERPRETER_INTERCEPTION_INTERNAL_PREFIXES = frozenset( NON_CODE_INTERPRETER_INTERCEPTION_INTERNAL_PREFIXES = frozenset(
("_websearch_interception", "_compression_interception") (
"_websearch_interception",
"_compression_interception",
HEADROOM_INTERCEPTION_PREFIX,
)
) )
INTERCEPTION_INTERNAL_PREFIXES = frozenset( INTERCEPTION_INTERNAL_PREFIXES = frozenset(
( (

View file

@ -3192,6 +3192,7 @@ agentic_loop_internal_litellm_params = [
"_code_interpreter_interception_sandbox_key", "_code_interpreter_interception_sandbox_key",
"_code_interpreter_interception_session_scoped", "_code_interpreter_interception_session_scoped",
"_code_interpreter_interception_converted_stream", "_code_interpreter_interception_converted_stream",
"_headroom_interception_converted_stream",
] ]
all_litellm_params = ( all_litellm_params = (

View file

@ -17,10 +17,13 @@ Tests cover:
- CCR: headroom_retrieve tool injected when compressed messages contain hashes - CCR: headroom_retrieve tool injected when compressed messages contain hashes
- CCR: async_should_run_agentic_loop returns True when response has headroom_retrieve tool calls - CCR: async_should_run_agentic_loop returns True when response has headroom_retrieve tool calls
- CCR: async_build_agentic_loop_plan calls retrieve endpoint and builds follow-up messages - CCR: async_build_agentic_loop_plan calls retrieve endpoint and builds follow-up messages
- CCR: streaming /chat/completions is converted to a non-streaming call so the agentic
loop resolves the retrieve tool call, then fake-streamed back to the client
""" """
import json import json
import time import time
from typing import Optional
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import httpx import httpx
@ -38,7 +41,16 @@ from litellm.proxy.guardrails.guardrail_hooks.headroom.headroom import (
from litellm.proxy.spend_tracking.compression_savings import ( from litellm.proxy.spend_tracking.compression_savings import (
extract_compression_saved_tokens, extract_compression_saved_tokens,
) )
from litellm.types.utils import GenericGuardrailAPIInputs from litellm.types.integrations.custom_logger import HEADROOM_CONVERTED_STREAM_KEY
from litellm.types.utils import (
CallTypes,
ChatCompletionMessageToolCall,
Choices,
Function,
GenericGuardrailAPIInputs,
Message,
ModelResponse,
)
FAKE_API_BASE = "https://headroom.example.com" FAKE_API_BASE = "https://headroom.example.com"
FAKE_API_KEY = "test-key" FAKE_API_KEY = "test-key"
@ -1782,3 +1794,125 @@ async def test_fail_open_returns_original_parts_shapes():
messages = result["structured_messages"] messages = result["structured_messages"]
assert [m["content"] for m in messages] == [m["content"] for m in PARTS_MESSAGES] assert [m["content"] for m in messages] == [m["content"] for m in PARTS_MESSAGES]
CCR_HASH = "b573993006976af767214fac"
def _retrieve_tool_definition() -> dict:
return {
"type": "function",
"function": {
"name": HEADROOM_RETRIEVE_TOOL_NAME,
"description": "retrieve compressed content",
"parameters": {"type": "object", "properties": {"hash": {"type": "string"}}},
},
}
def _model_response_with_retrieve_call() -> ModelResponse:
return ModelResponse(
choices=[
Choices(
finish_reason="tool_calls",
message=Message(
role="assistant",
content=None,
tool_calls=[
ChatCompletionMessageToolCall(
id="call_ccr",
type="function",
function=Function(
name=HEADROOM_RETRIEVE_TOOL_NAME,
arguments=json.dumps({"hash": CCR_HASH}),
),
)
],
),
)
]
)
@pytest.mark.parametrize(
"call_type, stream, tools, expect_conversion",
[
(CallTypes.acompletion, True, [_retrieve_tool_definition()], True),
(CallTypes.completion, True, [_retrieve_tool_definition()], True),
(CallTypes.acompletion, False, [_retrieve_tool_definition()], False),
(CallTypes.acompletion, True, [{"type": "function", "function": {"name": "get_weather"}}], False),
(CallTypes.acompletion, True, None, False),
(CallTypes.aresponses, True, [_retrieve_tool_definition()], False),
(CallTypes.anthropic_messages, True, [_retrieve_tool_definition()], False),
],
)
@pytest.mark.asyncio
async def test_pre_call_deployment_hook_converts_stream_only_for_ccr_chat_completions(
guardrail: HeadroomGuardrail,
call_type: CallTypes,
stream: bool,
tools: Optional[list],
expect_conversion: bool,
):
kwargs = {"model": "gpt-4o", "stream": stream, "tools": tools}
result = await guardrail.async_pre_call_deployment_hook(kwargs=kwargs, call_type=call_type)
if not expect_conversion:
assert result is None
assert kwargs["stream"] is stream
return
assert result is not None
assert result["stream"] is False
assert result[HEADROOM_CONVERTED_STREAM_KEY] is True
assert kwargs["stream"] is True
@pytest.mark.asyncio
async def test_streaming_chat_completion_resolves_ccr_retrieval_end_to_end(
guardrail: HeadroomGuardrail,
):
"""Regression test for streaming /chat/completions: the retrieve tool call the
model emits must be resolved by the agentic loop instead of being streamed back
to a client that never declared the tool."""
original_content = "the full uncompressed document"
final_answer = "the document says hello"
guardrail._issued_hashes_by_call_id["ccr-call-id"] = (
frozenset({CCR_HASH}),
time.monotonic() + 999,
)
real_acompletion = litellm.acompletion
async def acompletion_with_followup_answer(*args, **kwargs):
if kwargs.get("_agentic_loop_depth"):
kwargs["mock_response"] = final_answer
return await real_acompletion(*args, **kwargs)
saved_callbacks = list(litellm.callbacks)
litellm.callbacks = [guardrail]
try:
with patch.object(
guardrail.async_handler,
"get",
new_callable=AsyncMock,
return_value=_make_retrieve_response(original_content),
) as mock_get, patch.object(litellm, "acompletion", new=acompletion_with_followup_answer):
response = await litellm.acompletion(
model="openai/gpt-4o",
messages=[{"role": "user", "content": f"summarize hash={CCR_HASH}"}],
tools=[_retrieve_tool_definition()],
stream=True,
litellm_call_id="ccr-call-id",
mock_response=_model_response_with_retrieve_call(),
)
chunks = [chunk async for chunk in response]
finally:
litellm.callbacks = saved_callbacks
streamed_text = "".join(chunk.choices[0].delta.content or "" for chunk in chunks if chunk.choices)
assert streamed_text == final_answer
assert not any(chunk.choices and chunk.choices[0].delta.tool_calls for chunk in chunks)
mock_get.assert_called_once()
assert CCR_HASH in (mock_get.call_args.kwargs.get("url") or mock_get.call_args.args[0])