mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-15 09:12:18 +00:00
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:
parent
daf9a5e13a
commit
79e04e6051
4 changed files with 63 additions and 2 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue