fix(guardrails): hand a legacy post-call hook every choice in one call

The per-choice rounds exist so a guardrail reads one candidate completion at a time instead of a response no provider would return. A legacy post-call hook's contract is the opposite: one call with the response its route would have given it, which on an n=2 tool stream it stopped getting. Let a guardrail say which it is, and hand the hook the whole response again.
This commit is contained in:
Alessandro Pignati 2026-11-05 00:39:47 +01:00
parent daf9a5e13a
commit 79e04e6051
4 changed files with 63 additions and 2 deletions

View file

@ -1136,6 +1136,16 @@ class CustomGuardrail(CustomLogger):
"""
return True
def inspects_choices_separately(self) -> bool:
"""Whether a stream's choices may be handed over one at a time.
An n>1 stream carrying tool calls is inspected choice by choice, so a guardrail reads
one candidate completion at a time instead of a response no provider would return. A
guardrail whose contract is the whole response, like a legacy post-call hook, overrides
this to return False and is handed every choice in one call.
"""
return True
def structured_messages_cover_full_request(self) -> bool:
"""Whether returned ``structured_messages`` span the whole request.

View file

@ -672,7 +672,9 @@ class OpenAIChatCompletionsHandler(BaseTranslation):
model_response.model_copy(update=MappingProxyType({"choices": [choice]}))
for choice in model_response.choices
)
if pre_guardrail_tool_calls and len(model_response.choices) > 1
if pre_guardrail_tool_calls
and len(model_response.choices) > 1
and guardrail_to_apply.inspects_choices_separately()
else (model_response,)
)
from litellm.proxy.policy_engine.pipeline_executor import UndeliverableStreamRewrite
@ -977,7 +979,8 @@ class OpenAIChatCompletionsHandler(BaseTranslation):
)
if inspected is not None:
resolved_request_data["response"] = inspected
if inspected is not None and len(inspected.choices) > 1:
inspects_each: Final = guardrail_to_apply.inspects_choices_separately()
if inspected is not None and len(inspected.choices) > 1 and inspects_each:
choice_rounds: Final = tuple(
(
choice,

View file

@ -139,6 +139,9 @@ class _StreamRewriteObserver(CustomGuardrail):
def structured_messages_cover_full_request(self) -> bool:
return self.inner.structured_messages_cover_full_request()
def inspects_choices_separately(self) -> bool:
return self.inner.inspects_choices_separately()
@_logged_by_inner_guardrail
async def apply_guardrail(
self,
@ -225,6 +228,9 @@ class _LegacyHookStreamAdapter(CustomGuardrail):
def structured_messages_cover_full_request(self) -> bool:
return self.inner.structured_messages_cover_full_request()
def inspects_choices_separately(self) -> bool:
return False
@_logged_by_inner_guardrail
async def apply_guardrail(
self,

View file

@ -1682,6 +1682,48 @@ class TestOpenAIChatCompletionsHandlerStreamingOutput:
assert sent is not None
assert [c["function"]["arguments"] for c in sent] == ['{"city": "Paris"}', '{"city": "Rome"}']
@pytest.mark.asyncio
async def test_a_legacy_post_call_hook_sees_the_whole_response_once(self) -> None:
"""The legacy hook's contract is one call with the response the route would have given it,
so the per-choice rounds, which exist for guardrails that read one choice at a time, do not
apply to it."""
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.policy_engine.pipeline_executor import _LegacyHookStreamAdapter
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices
class LegacyHookGuardrail(CustomGuardrail):
choice_counts: list[int] = []
async def async_post_call_success_hook(
self, data: dict[str, object], user_api_key_dict: UserAPIKeyAuth, response: object
) -> None:
type(self).choice_counts.append(len(getattr(response, "choices", None) or ()))
LegacyHookGuardrail.choice_counts = []
handler: Final = OpenAIChatCompletionsHandler()
adapter: Final = _LegacyHookStreamAdapter(
LegacyHookGuardrail(guardrail_name="legacy"), handler, UserAPIKeyAuth(api_key="hashed-key")
)
chunks: Final = [ModelResponseStream(choices=[
StreamingChoices(
index=index,
delta=Delta(content="looking", tool_calls=[
{"index": 0, "id": f"call_{index}", "type": "function",
"function": {"name": "weather", "arguments": '{"city": "Paris"}'}},
]),
finish_reason="tool_calls",
)
for index in (0, 1)
])]
await handler.process_output_streaming_response(
responses_so_far=chunks,
guardrail_to_apply=adapter,
litellm_logging_obj=None,
request_data={},
deliver_ended_stream_rewrites=True,
)
assert LegacyHookGuardrail.choice_counts == [2]
@pytest.mark.asyncio
async def test_a_buffered_stream_that_carried_no_choices_is_not_an_error(self) -> None:
"""A stream can end carrying only usage, and a policy step still runs over it."""