fix(studio): handle empty Responses tool output (#6167)
• fix: handle empty responses tool output Normalize empty Responses `function_call_output.output` values before converting them into Chat Completions `role="tool"` messages. Empty strings, whitespace-only strings, and empty arrays now use the existing no-output sentinel, while non-empty text and content arrays are preserved. Add regression coverage for empty tool outputs, image payloads outside `output`, content-array serialization, validator round trips, and preserving non-empty text. --------- Co-authored-by: wasimysaid <wasimysdev@gmail.com> Co-authored-by: Tai An <antai12232931@outlook.com> Co-authored-by: Datta Nimmaturi <venkatadattasainimmaturi@gmail.com>
This commit is contained in:
parent
120daf9d8b
commit
181288e118
2 changed files with 123 additions and 4 deletions
|
|
@ -4897,6 +4897,17 @@ def _responses_message_text(content: Union[str, list]) -> str:
|
|||
return "\n".join(parts)
|
||||
|
||||
|
||||
def _responses_tool_output_text(output: Union[str, list]) -> str:
|
||||
"""Return Chat Completions-safe content for a Responses tool result."""
|
||||
if isinstance(output, str):
|
||||
return output if output.strip() else "(no output)"
|
||||
|
||||
if output:
|
||||
return json.dumps(output)
|
||||
|
||||
return "(no output)"
|
||||
|
||||
|
||||
def _normalise_responses_input(payload: ResponsesRequest) -> list[ChatMessage]:
|
||||
"""Convert a ResponsesRequest's ``input`` into a Chat-format ``ChatMessage`` list.
|
||||
|
||||
|
|
@ -4956,10 +4967,9 @@ def _normalise_responses_input(payload: ResponsesRequest) -> list[ChatMessage]:
|
|||
|
||||
if isinstance(item, ResponsesFunctionCallOutputInputItem):
|
||||
# Chat Completions `role="tool"` requires string content; serialize
|
||||
# a Responses content-array output.
|
||||
output = item.output
|
||||
if not isinstance(output, str):
|
||||
output = json.dumps(output)
|
||||
# a Responses content-array output and keep empty outputs from
|
||||
# tripping the stricter ChatMessage role validator.
|
||||
output = _responses_tool_output_text(item.output)
|
||||
messages.append(
|
||||
ChatMessage(
|
||||
role = "tool",
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ from routes.inference import (
|
|||
_build_chat_request,
|
||||
_chat_tool_calls_to_responses_output,
|
||||
_normalise_responses_input,
|
||||
_responses_tool_output_text,
|
||||
_responses_stream,
|
||||
_translate_responses_tool_choice_to_chat,
|
||||
_translate_responses_tools_to_chat,
|
||||
|
|
@ -393,6 +394,100 @@ class TestNormaliseResponsesInputWithTools:
|
|||
# Content is serialised so llama-server sees a string.
|
||||
assert json.loads(msgs[0].content) == [{"type": "output_text", "text": "ok"}]
|
||||
|
||||
def test_empty_function_call_output_gets_no_output_sentinel(self):
|
||||
payload = ResponsesRequest(
|
||||
input = [
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_1",
|
||||
"output": "",
|
||||
}
|
||||
],
|
||||
)
|
||||
msgs = _normalise_responses_input(payload)
|
||||
assert msgs[0].role == "tool"
|
||||
assert msgs[0].tool_call_id == "call_1"
|
||||
assert msgs[0].content == "(no output)"
|
||||
ChatMessage(**msgs[0].model_dump(exclude_none = True))
|
||||
|
||||
def test_whitespace_function_call_output_gets_no_output_sentinel(self):
|
||||
payload = ResponsesRequest(
|
||||
input = [
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_1",
|
||||
"output": " \n\t",
|
||||
}
|
||||
],
|
||||
)
|
||||
msgs = _normalise_responses_input(payload)
|
||||
assert msgs[0].content == "(no output)"
|
||||
|
||||
def test_empty_content_array_output_gets_no_output_sentinel(self):
|
||||
payload = ResponsesRequest(
|
||||
input = [
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_1",
|
||||
"output": [],
|
||||
}
|
||||
],
|
||||
)
|
||||
msgs = _normalise_responses_input(payload)
|
||||
assert msgs[0].content == "(no output)"
|
||||
|
||||
def test_image_content_array_tool_output_is_serialised(self):
|
||||
payload = ResponsesRequest(
|
||||
input = [
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_1",
|
||||
"output": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": "iVBORw0KGgo=",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
msgs = _normalise_responses_input(payload)
|
||||
assert msgs[0].role == "tool"
|
||||
assert json.loads(msgs[0].content)[0]["type"] == "image"
|
||||
|
||||
def test_image_payload_outside_output_gets_no_output_sentinel(self):
|
||||
payload = ResponsesRequest(
|
||||
input = [
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_1",
|
||||
"output": "",
|
||||
"content": [
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": "iVBORw0KGgo=",
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
msgs = _normalise_responses_input(payload)
|
||||
assert msgs[0].role == "tool"
|
||||
assert msgs[0].tool_call_id == "call_1"
|
||||
assert msgs[0].content == "(no output)"
|
||||
|
||||
def test_tool_output_serializer_preserves_non_empty_text(self):
|
||||
assert _responses_tool_output_text("done") == "done"
|
||||
assert _responses_tool_output_text(" done ") == " done "
|
||||
|
||||
|
||||
# =====================================================================
|
||||
# Response mapping — tool_calls → function_call output items
|
||||
|
|
@ -810,3 +905,17 @@ class TestTranslatedMessagesValidate:
|
|||
# Building a fresh ChatMessage from the dump round-trips the
|
||||
# role-shape validator — the passthrough's key invariant.
|
||||
ChatMessage(**m.model_dump(exclude_none = True))
|
||||
|
||||
def test_empty_tool_output_round_trips_through_chat_message_validator(self):
|
||||
payload = ResponsesRequest(
|
||||
input = [
|
||||
{
|
||||
"type": "function_call_output",
|
||||
"call_id": "call_empty",
|
||||
"output": "",
|
||||
},
|
||||
],
|
||||
)
|
||||
msgs = _normalise_responses_input(payload)
|
||||
for m in msgs:
|
||||
ChatMessage(**m.model_dump(exclude_none = True))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue