fix(guardrails): write tool-call rewrites into typed Responses envelopes

The response.completed envelope carries its function_call output items as
SDK objects without a get shim, so the write-back skipped them and the
envelope still showed the original arguments after every stream event had
been rewritten. Write the item whenever one is present, and cover the typed
event shape the live proxy carries in the handler test.
This commit is contained in:
mateo-berri 2026-09-08 11:42:51 -07:00
parent cbe340a31c
commit e65c56876f
2 changed files with 47 additions and 1 deletions

View file

@ -996,7 +996,7 @@ class OpenAIResponsesHandler(BaseTranslation):
@staticmethod
def _write_function_call_item(item: object, name: str | None, arguments: str | None) -> None:
if not (isinstance(item, dict) or hasattr(item, "get")):
if item is None:
return
if name is not None:
OpenAIResponsesHandler._write_event_field(item, "name", name)

View file

@ -1222,6 +1222,7 @@ class TestOpenAIResponsesHandlerStreamingOutputProcessing:
"type": "response.completed",
"response": {
"id": "resp_123",
"created_at": 1,
"model": "gpt-4o",
"output": [item('{"fruit": "persimmon"}', "completed")],
"status": "completed",
@ -1268,6 +1269,51 @@ class TestOpenAIResponsesHandlerStreamingOutputProcessing:
assert events[5]["response"]["output"][0]["arguments"] == '{"fruit": "[MASKED]"}'
assert events[5]["response"]["output"][0]["name"] == "lookup_fruit"
@pytest.mark.asyncio
async def test_deliver_ended_stream_rewrites_syncs_typed_function_call_events(self):
from litellm.types.llms.openai import (
FunctionCallArgumentsDeltaEvent,
FunctionCallArgumentsDoneEvent,
OutputItemAddedEvent,
OutputItemDoneEvent,
ResponseCompletedEvent,
ResponsesAPIResponse,
)
handler = OpenAIResponsesHandler()
typed_events: List[Any] = [
model.model_validate(event)
for model, event in zip(
(
OutputItemAddedEvent,
FunctionCallArgumentsDeltaEvent,
FunctionCallArgumentsDeltaEvent,
FunctionCallArgumentsDoneEvent,
OutputItemDoneEvent,
ResponseCompletedEvent,
),
self._ended_function_call_stream_events(),
)
]
completed_event = typed_events[5]
assert isinstance(completed_event, ResponseCompletedEvent)
assert isinstance(completed_event.response, ResponsesAPIResponse)
assert isinstance(completed_event.response.output[0], ResponseFunctionToolCall)
await handler.process_output_streaming_response(
responses_so_far=typed_events,
guardrail_to_apply=self._argument_masking_guardrail(),
litellm_logging_obj=None,
deliver_ended_stream_rewrites=True,
)
assert typed_events[1].delta == '{"fruit": "[MASKED]"}'
assert typed_events[2].delta == ""
assert typed_events[3].arguments == '{"fruit": "[MASKED]"}'
assert typed_events[4].item.arguments == '{"fruit": "[MASKED]"}'
assert completed_event.response.output[0].arguments == '{"fruit": "[MASKED]"}'
assert completed_event.response.output[0].name == "lookup_fruit"
@pytest.mark.asyncio
async def test_ended_stream_function_call_rewrite_leaves_events_untouched_by_default(self):
handler = OpenAIResponsesHandler()