diff --git a/studio/backend/routes/inference.py b/studio/backend/routes/inference.py index 9dc9feb920..3333db9d07 100644 --- a/studio/backend/routes/inference.py +++ b/studio/backend/routes/inference.py @@ -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", diff --git a/studio/backend/tests/test_responses_tool_passthrough.py b/studio/backend/tests/test_responses_tool_passthrough.py index beeee66b77..69fb0a78c2 100644 --- a/studio/backend/tests/test_responses_tool_passthrough.py +++ b/studio/backend/tests/test_responses_tool_passthrough.py @@ -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))