mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +00:00
Advance OSS contribution for [Bug]: Guardrail response modification ignored when response contains tool_calls
Nightly Codex produced a focused contribution for https://github.com/BerriAI/litellm/issues/20230. Constraint: Automated nightly run; keep changes small and reviewable. Confidence: medium Scope-risk: narrow Tested: See uploaded nightly artifacts and workflow logs. Not-tested: Maintainer CI beyond this workflow.
This commit is contained in:
parent
98cd057f38
commit
d443b8a16a
2 changed files with 92 additions and 5 deletions
|
|
@ -364,6 +364,8 @@ class OpenAIChatCompletionsHandler(BaseTranslation):
|
|||
)
|
||||
|
||||
guardrailed_texts = guardrailed_inputs.get("texts", [])
|
||||
guardrail_returned_tool_calls = "tool_calls" in guardrailed_inputs
|
||||
guardrailed_tool_calls = guardrailed_inputs.get("tool_calls", [])
|
||||
|
||||
# Step 3: Map guardrail responses back to original response structure
|
||||
if guardrailed_texts and texts_to_check:
|
||||
|
|
@ -372,12 +374,17 @@ class OpenAIChatCompletionsHandler(BaseTranslation):
|
|||
responses=guardrailed_texts,
|
||||
task_mappings=text_task_mappings,
|
||||
)
|
||||
elif guardrailed_texts:
|
||||
self._replace_output_with_guardrail_text(
|
||||
response=response,
|
||||
guardrailed_texts=guardrailed_texts,
|
||||
)
|
||||
|
||||
# Step 4: Apply guardrailed tool calls back to response
|
||||
if tool_calls_to_check:
|
||||
if guardrail_returned_tool_calls and tool_calls_to_check:
|
||||
await self._apply_guardrail_responses_to_output_tool_calls(
|
||||
response=response,
|
||||
tool_calls=tool_calls_to_check,
|
||||
tool_calls=guardrailed_tool_calls, # type: ignore[arg-type]
|
||||
task_mappings=tool_call_task_mappings,
|
||||
)
|
||||
|
||||
|
|
@ -743,6 +750,14 @@ class OpenAIChatCompletionsHandler(BaseTranslation):
|
|||
|
||||
Override this method to customize how tool call responses are applied.
|
||||
"""
|
||||
if not tool_calls:
|
||||
for choice_idx, _ in task_mappings:
|
||||
choice = cast(Choices, response.choices[choice_idx])
|
||||
choice.message.tool_calls = None
|
||||
if choice.finish_reason == "tool_calls" and choice.message.content:
|
||||
choice.finish_reason = "stop"
|
||||
return
|
||||
|
||||
for task_idx, (choice_idx, tool_call_idx) in enumerate(task_mappings):
|
||||
if task_idx < len(tool_calls):
|
||||
guardrailed_tool_call = tool_calls[task_idx]
|
||||
|
|
@ -765,6 +780,25 @@ class OpenAIChatCompletionsHandler(BaseTranslation):
|
|||
if "name" in func_dict:
|
||||
existing_tool_call.function.name = func_dict["name"]
|
||||
|
||||
def _replace_output_with_guardrail_text(
|
||||
self,
|
||||
response: "ModelResponse",
|
||||
guardrailed_texts: List[str],
|
||||
) -> None:
|
||||
"""
|
||||
Apply guardrail text when the original response had no text slots.
|
||||
|
||||
Tool-call-only responses have no text mappings, but response guardrails can
|
||||
still intentionally replace the output with a block/mask message.
|
||||
"""
|
||||
if not response.choices or not guardrailed_texts:
|
||||
return
|
||||
|
||||
choice = cast(Choices, response.choices[0])
|
||||
choice.message.content = guardrailed_texts[0]
|
||||
if choice.finish_reason == "tool_calls":
|
||||
choice.finish_reason = "stop"
|
||||
|
||||
async def _apply_guardrail_responses_to_output_streaming(
|
||||
self,
|
||||
responses: List["ModelResponseStream"],
|
||||
|
|
|
|||
|
|
@ -8,8 +8,7 @@ with guardrail transformations, including tool calls.
|
|||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any, List, Literal, Optional, Tuple
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
import pytest
|
||||
|
||||
|
|
@ -509,6 +508,60 @@ class TestOpenAIChatCompletionsHandlerToolCallsInput:
|
|||
class TestOpenAIChatCompletionsHandlerToolCallsOutput:
|
||||
"""Test output processing with tool calls"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_guardrail_can_replace_tool_call_only_output(self):
|
||||
"""Test a response guardrail can replace tool-call-only output with text."""
|
||||
handler = OpenAIChatCompletionsHandler()
|
||||
|
||||
class BlockingGuardrail(CustomGuardrail):
|
||||
async def apply_guardrail(
|
||||
self,
|
||||
inputs: GenericGuardrailAPIInputs,
|
||||
request_data: dict,
|
||||
input_type: Literal["request", "response"],
|
||||
logging_obj: Optional[Any] = None,
|
||||
) -> GenericGuardrailAPIInputs:
|
||||
assert input_type == "response"
|
||||
assert len(inputs.get("tool_calls", [])) == 1
|
||||
return {
|
||||
"texts": ["guardrail blocked the request"],
|
||||
"tool_calls": [],
|
||||
}
|
||||
|
||||
response = ModelResponse(
|
||||
id="chatcmpl-tool-call-blocked",
|
||||
created=1234567890,
|
||||
model="gpt-4",
|
||||
object="chat.completion",
|
||||
choices=[
|
||||
Choices(
|
||||
finish_reason="tool_calls",
|
||||
index=0,
|
||||
message=Message(
|
||||
content=None,
|
||||
role="assistant",
|
||||
tool_calls=[
|
||||
ChatCompletionMessageToolCall(
|
||||
id="call_789",
|
||||
type="function",
|
||||
function=Function(
|
||||
name="get_weather",
|
||||
arguments=json.dumps({"location": "Paris"}),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
await handler.process_output_response(response, BlockingGuardrail())
|
||||
|
||||
choice = response.choices[0]
|
||||
assert choice.message.content == "guardrail blocked the request"
|
||||
assert choice.message.tool_calls is None
|
||||
assert choice.finish_reason == "stop"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_tool_calls_from_output_response(self):
|
||||
"""Test that tool calls are extracted from output responses"""
|
||||
|
|
@ -765,7 +818,7 @@ class TestOpenAIChatCompletionsHandlerStreamingOutput:
|
|||
This test verifies the fix for the bug where accessing chunk.choices[0]
|
||||
would raise IndexError when a streaming chunk has an empty choices list.
|
||||
"""
|
||||
from litellm.types.utils import Delta, ModelResponseStream, StreamingChoices
|
||||
from litellm.types.utils import ModelResponseStream
|
||||
|
||||
handler = OpenAIChatCompletionsHandler()
|
||||
guardrail = MockPassThroughGuardrail(guardrail_name="test")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue