From e65c56876f9aae1f94120212a2092592792f6538 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:42:51 -0700 Subject: [PATCH] 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. --- .../guardrail_translation/handler.py | 2 +- ...test_openai_responses_guardrail_handler.py | 46 +++++++++++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/litellm/llms/openai/responses/guardrail_translation/handler.py b/litellm/llms/openai/responses/guardrail_translation/handler.py index 447870fc0be..208ceb959e6 100644 --- a/litellm/llms/openai/responses/guardrail_translation/handler.py +++ b/litellm/llms/openai/responses/guardrail_translation/handler.py @@ -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) diff --git a/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py b/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py index a024fc7ff81..bdf7f83757c 100644 --- a/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py +++ b/tests/test_litellm/llms/openai/responses/test_openai_responses_guardrail_handler.py @@ -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()