fix(responses): normalize empty function_call_output to an empty tool result (#48845)

* fix(responses): normalize empty function_call_output to an empty tool result

* test(responses): preserve empty tool outputs through guardrails

---------

Co-authored-by: Tin <tin@berri.ai>
This commit is contained in:
Ahmet Selim Fedakar 2026-10-30 21:01:00 +03:00 • committed by GitHub
parent ccdc8c7d10
commit f7f87e6193
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 86 additions and 9 deletions

View file

@ -398,23 +398,25 @@ def tool_output_to_chat_content(
if single_media is not None:
return [single_media]
if isinstance(output, list):
parts: Final = cast(Sequence[object], output) # cast-ok: checked list shape, elements remain unvalidated
normalized_blocks: Final[list[ToolMessageContentPart]] = []
text_acc: Final[list[str]] = []
for part in output:
for part in parts:
if not isinstance(part, dict):
continue
part_type = part.get("type")
if part_type in ("input_text", "output_text", "text"):
txt = part.get("text")
if isinstance(txt, str) and txt:
if isinstance(txt, str):
text_acc.append(txt)
normalized_blocks.append({"type": "text", "text": txt})
if txt:
normalized_blocks.append({"type": "text", "text": txt})
elif (media := decode_responses_media(part)) is not None:
normalized_blocks.append(media)
if any(b.get("type") != "text" for b in normalized_blocks):
return normalized_blocks
if text_acc:
if any(text_acc) or len(text_acc) == len(parts):
return "".join(text_acc)
try:
return json.dumps(output)

View file

@ -724,16 +724,23 @@ class TestToolResultItemsReachTextsGuardrails:
assert blocked.value.status_code == 400
@pytest.mark.asyncio
@pytest.mark.parametrize("empty_output", ["", None], ids=["empty_string", "null"])
async def test_empty_tool_output_sends_no_text_and_stays_as_sent(self, empty_output: str | None) -> None:
@pytest.mark.parametrize(("call_item", "output_type"), _TOOL_TURNS, ids=["function", "custom"])
@pytest.mark.parametrize(
"empty_output",
["", None, [], *[[{"type": kind, "text": ""}] for kind in ("input_text", "output_text", "text")]],
ids=["empty_string", "null", "empty_list", "input_text", "output_text", "text"],
)
async def test_empty_tool_output_sends_no_text_and_stays_as_sent(
self, call_item: dict[str, str], output_type: str, empty_output: object
) -> None:
guardrail: Final = RecordingMaskingGuardrail(guardrail_name="test")
call_item, output_type = _TOOL_TURNS[0]
result: Final = await OpenAIResponsesHandler().process_input_messages(
_tool_result_request(call_item, output_type, empty_output), guardrail
)
assert guardrail.seen_texts == [["Weather?"]]
assert guardrail.seen_message_contents == [["Weather?", None]]
assert result["input"][2] == {"type": output_type, "call_id": "call_1", "output": empty_output}
@pytest.mark.asyncio
@ -1103,7 +1110,6 @@ class TestToolResultItemsReachTextsGuardrails:
["visible"],
id="non_dict_part",
),
pytest.param([], ["[]"], id="empty"),
pytest.param({"result": "SECRET forecast"}, ['{"result": "SECRET forecast"}'], id="dict"),
pytest.param(
[{"type": "input_image", "file_id": "file-map"}],

View file

@ -23,6 +23,7 @@ from litellm.responses.litellm_completion_transformation.transformation import (
)
from litellm.types.llms.openai import ResponsesAPIStreamEvents
from litellm.types.utils import Message, ModelResponse
from tests.unit.proxy.conftest import httpx_transport
def test_response_api_handler_merges_metadata_and_service_tier_without_error():
@ -593,6 +594,31 @@ def _anthropic_message(*content: Mapping[str, object]) -> httpx.Response:
)
@pytest.mark.usefixtures(httpx_transport.__name__)
@pytest.mark.parametrize("output", ["", [], [{"type": "input_text", "text": ""}]])
def test_empty_responses_tool_output_reaches_anthropic_without_json_text(
respx_mock: respx.MockRouter, output: str | list[dict[str, str]]
) -> None:
route: Final = respx_mock.post(_ANTHROPIC_MESSAGES_URL).mock(
return_value=_anthropic_message({"type": "text", "text": "ok"})
)
response: Final = litellm.responses(
model=_MODEL,
api_key="mock_api_key",
input=[
{"role": "user", "content": "Check the weather"},
{"type": "function_call", "call_id": "call_empty", "name": "get_weather", "arguments": "{}"},
{"type": "function_call_output", "call_id": "call_empty", "output": output},
],
)
sent: Final = json.loads(route.calls.last.request.content)
assert response.output_text == "ok"
assert sent["messages"][-1] == {
"role": "user",
"content": [{"type": "tool_result", "tool_use_id": "call_empty", "content": ""}],
}
def _replay(output: Sequence[BaseModel]) -> list[object]:
return [*_FIRST_INPUT, *(item.model_dump(exclude_none=True) for item in output), *_NEXT_INPUT]

View file

@ -50,9 +50,10 @@ def test_image_detail_mime_and_adjacent_text_survive_normalization(kind: str, de
else "data:application/octet-stream;base64,AAAA",
**({} if nested_url else metadata),
}
parts: Final = [{"type": "input_text", "text": "Screenshot follows"}, image]
parts: Final = [{"type": "input_text", "text": ""}, {"type": "input_text", "text": "Screenshot follows"}, image]
original: Final = deepcopy(parts)
assert _normalize_media_content(kind, parts) == [
*([{"type": "text", "text": ""}] if kind == "direct" else []),
{"type": "text", "text": "Screenshot follows"},
{"type": "image_url", "image_url": {"url": "data:application/octet-stream;base64,AAAA", **metadata}},
]
@ -97,6 +98,12 @@ def test_media_normalization_preserves_formats_references_and_metadata(kind: str
("output", "expected"),
[
(None, ""),
("", ""),
([], ""),
([{"type": "input_text", "text": ""}], ""),
([{"type": kind, "text": ""} for kind in ("input_text", "output_text", "text")], ""),
("[]", "[]"),
([{"type": "input_text", "text": " \n"}], " \n"),
('{"ok":true}', '{"ok":true}'),
([{"type": "input_text", "text": "hello"}, {"type": "input_text", "text": " world"}], "hello world"),
({"type": "file", "status": "ready"}, '{"type": "file", "status": "ready"}'),
@ -106,6 +113,14 @@ def test_media_normalization_preserves_formats_references_and_metadata(kind: str
),
({"type": "input_image", "format": 17}, '{"type": "input_image", "format": 17}'),
([{"type": "unknown", "value": 17}], '[{"type": "unknown", "value": 17}]'),
([{"type": "input_text", "text": 17}], '[{"type": "input_text", "text": 17}]'),
([{"type": "input_text"}], '[{"type": "input_text"}]'),
([{"type": "input_text", "text": None}], '[{"type": "input_text", "text": null}]'),
(
[{"type": "input_text", "text": ""}, {"type": "unknown", "value": 17}],
'[{"type": "input_text", "text": ""}, {"type": "unknown", "value": 17}]',
),
([{"type": "input_text", "text": ""}, 17], '[{"type": "input_text", "text": ""}, 17]'),
(
[{"type": "input_text", "text": "keep"}, {"type": "input_image", "file_id": "file-only"}],
"keep",

View file

@ -84,6 +84,34 @@ async def test_web_search_preview_is_sent_as_google_search() -> None:
assert request_body["tools"] == [{"googleSearch": {}}]
@pytest.mark.asyncio
@pytest.mark.parametrize("output", ["", [], [{"type": "input_text", "text": ""}]])
async def test_empty_responses_tool_output_reaches_gemini_without_json_text(
output: str | list[dict[str, str]],
) -> None:
with respx.mock() as mock_router:
route: Final = mock_router.post(url__regex=_GEMINI_URL).mock(
return_value=httpx.Response(status_code=200, json=_function_call_response("signature"))
)
response: Final = await litellm.aresponses(
model="gemini/gemini-2.5-flash",
api_key="test-key",
input=[
{"role": "user", "content": "Check the weather"},
{"type": "function_call", "call_id": "call_empty", "name": "get_weather", "arguments": "{}"},
{"type": "function_call_output", "call_id": "call_empty", "output": output},
],
)
request_body: Final = _JSON_OBJECT.validate_json(route.calls.last.request.content)
assert isinstance(response, ResponsesAPIResponse)
assert request_body["contents"] == [
{"role": "user", "parts": [{"text": "Check the weather"}]},
{"role": "model", "parts": [{"function_call": {"name": "get_weather", "args": {}}}]},
{"role": "user", "parts": [{"function_response": {"name": "get_weather", "response": {"content": ""}}}]},
]
@pytest.mark.asyncio
@pytest.mark.parametrize(
("assistant_content", "assistant_parts"),