mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-31 06:56:59 +00:00
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:
parent
ccdc8c7d10
commit
f7f87e6193
5 changed files with 86 additions and 9 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"}],
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue