diff --git a/litellm/integrations/otel/model/payloads.py b/litellm/integrations/otel/model/payloads.py index 484f4a4c294..c23b3291365 100644 --- a/litellm/integrations/otel/model/payloads.py +++ b/litellm/integrations/otel/model/payloads.py @@ -720,6 +720,7 @@ class _ToolCall(TypedDict): class _AssistantMessage(TypedDict): role: ReadOnly[str] content: ReadOnly[str | None] + refusal: ReadOnly[str | None] tool_calls: ReadOnly[tuple[_ToolCall, ...] | None] @@ -735,13 +736,7 @@ def _responses_choices(response: Mapping[str, object]) -> tuple[_Choice, ...]: """A Responses API ``output`` folded into one chat-shaped assistant choice.""" items: Final = _dicts(response.get("output")) messages: Final = tuple(item for item in items if item.get("type") == "message") - content: Final = "".join( - text - for item in messages - for part in _dicts(item.get("content")) - if part.get("type") == "output_text" - if (text := as_str(part.get("text"))) is not None - ) + parts: Final = tuple(part for item in messages for part in _dicts(item.get("content"))) tool_calls: Final = tuple( _responses_tool_call(item) for item in items if item.get("type") in _RESPONSES_TOOL_CALL_TYPES ) @@ -749,13 +744,21 @@ def _responses_choices(response: Mapping[str, object]) -> tuple[_Choice, ...]: return () message: Final[_AssistantMessage] = { "role": next((role for item in messages if (role := as_str(item.get("role")))), "assistant"), - "content": content if messages else None, + "content": _responses_parts_text(parts, "output_text", "text"), + "refusal": _responses_parts_text(parts, "refusal", "refusal"), "tool_calls": tool_calls or None, } choice: Final[_Choice] = {"message": message, "finish_reason": _responses_finish_reason(response, bool(tool_calls))} return (choice,) +def _responses_parts_text(parts: tuple[Mapping[str, object], ...], part_type: str, field: str) -> str | None: + texts: Final = tuple( + text for part in parts if part.get("type") == part_type if (text := as_str(part.get(field))) is not None + ) + return "".join(texts) if texts else None + + def _responses_tool_call(item: Mapping[str, object]) -> _ToolCall: custom: Final = item.get("type") == "custom_tool_call" function: Final[_ToolFunction] = { diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py b/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py index 972c91670f8..17de3cf1e8a 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_sources_of_truth.py @@ -761,7 +761,7 @@ def test_responses_output_text_becomes_one_assistant_choice_with_stop(): assert json.loads(json.dumps(data.choices_out)) == [ { - "message": {"role": "assistant", "content": "pong", "tool_calls": None}, + "message": {"role": "assistant", "content": "pong", "refusal": None, "tool_calls": None}, "finish_reason": "stop", } ] @@ -835,6 +835,23 @@ def test_responses_content_only_reads_output_text_parts(): data = LLMCallSpanData.from_standard_logging_payload(_responses_payload([item]), capture_content=True) assert data.choices_out[0]["message"]["content"] == "ok" + assert data.choices_out[0]["message"]["refusal"] == "no" + + +def test_responses_refusal_only_output_keeps_the_refusal_text(): + item = { + "type": "message", + "role": "assistant", + "content": [{"type": "refusal", "refusal": "I can't "}, {"type": "refusal", "refusal": "help with that."}], + } + data = LLMCallSpanData.from_standard_logging_payload(_responses_payload([item]), capture_content=True) + + assert json.loads(json.dumps(data.choices_out)) == [ + { + "message": {"role": "assistant", "content": None, "refusal": "I can't help with that.", "tool_calls": None}, + "finish_reason": "stop", + } + ] def test_responses_output_without_messages_or_tool_calls_stays_empty(): diff --git a/tests/test_litellm/integrations/otel/test_otel_v2_vendor_mappers.py b/tests/test_litellm/integrations/otel/test_otel_v2_vendor_mappers.py index 5b4d1e7a802..4e375de0494 100644 --- a/tests/test_litellm/integrations/otel/test_otel_v2_vendor_mappers.py +++ b/tests/test_litellm/integrations/otel/test_otel_v2_vendor_mappers.py @@ -218,6 +218,7 @@ def test_langfuse_mapper_renders_a_responses_api_call_from_the_standard_logging_ { "role": "assistant", "content": "Checking.", + "refusal": None, "tool_calls": [ {"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": '{"city": "sf"}'}} ],