fix(otel v2): map Responses API output onto the Langfuse generation output

Responses API calls build the generation output only from response["choices"],
which Responses payloads do not carry, so Langfuse rendered a blank output.
Fold output[] into one assistant choice (output_text parts concatenated,
function_call and custom_tool_call items as tool_calls) and derive the finish
reason from status when choices are absent. Custom tool call input is now
redacted alongside function call arguments under turn_off_message_logging.

Carries the behavior of #41604 by @moshemorad (issue #41591) onto current
main with typed conversion and single-message output.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng 2026-09-19 18:26:59 +00:00
parent 77a4624f13
commit 364d897545
5 changed files with 253 additions and 2 deletions

View file

@ -7,9 +7,11 @@ from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from enum import Enum
from types import MappingProxyType
from typing import TYPE_CHECKING, ClassVar, Final, cast
from typing import TYPE_CHECKING, ClassVar, Final, Literal, cast
from urllib.parse import urlsplit
from typing_extensions import ReadOnly, TypedDict
from litellm.integrations.otel.model.metadata import RequestContext, RequestIdentity
from litellm.integrations.otel.model.semconv import (
GenAIOperation,
@ -25,6 +27,7 @@ from litellm.integrations.otel.model.utils import (
as_float,
as_int,
as_str,
as_str_mapping,
as_str_tuple,
)
@ -424,7 +427,7 @@ class LLMCallSpanData:
# plain ``.get`` — no repeated ``isinstance`` guards.
raw_response: Final = payload.get("response")
response: Final = cast(Mapping[str, object], raw_response if isinstance(raw_response, dict) else {})
choices_out: Final = _dicts(response.get("choices"))
choices_out: Final = _dicts(response.get("choices")) or _responses_choices(response)
# ``finish_reasons`` is metadata, not content, so derive it from
# ``choices_out`` before gating. The raw message/choice bodies are only
# retained when content capture is enabled (see ``capture_span_content``);
@ -703,6 +706,81 @@ def _finish_reasons(choices: tuple[Mapping[str, object], ...]) -> tuple[str, ...
return tuple(r for c in choices if (r := as_str(c.get("finish_reason"))))
class _ToolFunction(TypedDict):
name: ReadOnly[str]
arguments: ReadOnly[str]
class _ToolCall(TypedDict):
id: ReadOnly[str]
type: ReadOnly[Literal["function"]]
function: ReadOnly[_ToolFunction]
class _AssistantMessage(TypedDict):
role: ReadOnly[str]
content: ReadOnly[str | None]
tool_calls: ReadOnly[tuple[_ToolCall, ...] | None]
class _Choice(TypedDict):
message: ReadOnly[_AssistantMessage]
finish_reason: ReadOnly[str | None]
_RESPONSES_TOOL_CALL_TYPES: Final = frozenset({"function_call", "custom_tool_call"})
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
)
tool_calls: Final = tuple(
_responses_tool_call(item) for item in items if item.get("type") in _RESPONSES_TOOL_CALL_TYPES
)
if not messages and not tool_calls:
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,
"tool_calls": tool_calls or None,
}
choice: Final[_Choice] = {"message": message, "finish_reason": _responses_finish_reason(response, bool(tool_calls))}
return (choice,)
def _responses_tool_call(item: Mapping[str, object]) -> _ToolCall:
custom: Final = item.get("type") == "custom_tool_call"
function: Final[_ToolFunction] = {
"name": as_str(item.get("name")) or "",
"arguments": as_str(item.get("input" if custom else "arguments")) or "",
}
tool_call: Final[_ToolCall] = {
"id": as_str(item.get("call_id")) or as_str(item.get("id")) or "",
"type": "function",
"function": function,
}
return tool_call
def _responses_finish_reason(response: Mapping[str, object], has_tool_calls: bool) -> str | None:
status: Final = as_str(response.get("status"))
if status == "completed":
return "tool_calls" if has_tool_calls else "stop"
if status != "incomplete":
return None
details: Final = as_str_mapping(response.get("incomplete_details"))
reason: Final = details.get("reason") if details is not None else None
return "content_filter" if reason == "content_filter" else "length"
def _parse_error(payload: StandardLoggingPayload) -> SpanError | None:
"""A ``SpanError`` for a failed request, or ``None`` on success."""
if payload.get("status") != "failure":

View file

@ -138,6 +138,8 @@ def _redact_responses_api_output(output_items):
if hasattr(output_item, "type") and output_item.type == "function_call" and hasattr(output_item, "arguments"):
output_item.arguments = REDACTED_BY_LITELLM
if hasattr(output_item, "type") and output_item.type == "custom_tool_call" and hasattr(output_item, "input"):
output_item.input = REDACTED_BY_LITELLM
def _redact_responses_api_output_dict(output_items, redacted_str: str):
@ -161,6 +163,8 @@ def _redact_responses_api_output_dict(output_items, redacted_str: str):
if output_item.get("type") == "function_call" and "arguments" in output_item:
output_item["arguments"] = redacted_str
if output_item.get("type") == "custom_tool_call" and "input" in output_item:
output_item["input"] = redacted_str
def redacted_standard_logging_payload(payload: Mapping[str, object]) -> Mapping[str, object]:

View file

@ -738,6 +738,123 @@ def test_embedding_summary_is_absent_without_vectors_and_for_chat_data_lists():
assert chat.embedding_output is None
def _responses_payload(output: list[object], status: str = "completed", **response_fields: object):
return _sample_payload(
call_type="aresponses",
model="gpt-5.4-nano",
response={"id": "resp_1", "object": "response", "status": status, "output": output, **response_fields},
)
_RESPONSES_TEXT_ITEM = {
"type": "message",
"role": "assistant",
"status": "completed",
"content": [{"type": "output_text", "text": "po", "annotations": []}, {"type": "output_text", "text": "ng"}],
}
def test_responses_output_text_becomes_one_assistant_choice_with_stop():
data = LLMCallSpanData.from_standard_logging_payload(
_responses_payload([{"type": "reasoning", "summary": []}, _RESPONSES_TEXT_ITEM]), capture_content=True
)
assert json.loads(json.dumps(data.choices_out)) == [
{
"message": {"role": "assistant", "content": "pong", "tool_calls": None},
"finish_reason": "stop",
}
]
assert data.finish_reasons == ("stop",)
assert data.response_id == "resp_1"
def test_responses_tool_calls_fold_into_the_assistant_message_with_tool_calls_finish_reason():
data = LLMCallSpanData.from_standard_logging_payload(
_responses_payload(
[
_RESPONSES_TEXT_ITEM,
{"type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": '{"city": "sf"}'},
{"type": "custom_tool_call", "call_id": "call_2", "name": "grep", "input": "-r TODO"},
]
),
capture_content=True,
)
assert len(data.choices_out) == 1
message = data.choices_out[0]["message"]
assert message["content"] == "pong"
assert json.loads(json.dumps(message["tool_calls"])) == [
{"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": '{"city": "sf"}'}},
{"id": "call_2", "type": "function", "function": {"name": "grep", "arguments": "-r TODO"}},
]
assert data.finish_reasons == ("tool_calls",)
def test_responses_tool_call_only_output_has_no_content():
data = LLMCallSpanData.from_standard_logging_payload(
_responses_payload([{"type": "function_call", "id": "fc_1", "name": "get_weather", "arguments": "{}"}]),
capture_content=True,
)
assert data.choices_out[0]["message"]["content"] is None
assert data.choices_out[0]["message"]["tool_calls"][0]["id"] == "fc_1"
@pytest.mark.parametrize(
("status", "response_fields", "expected"),
[
("incomplete", {"incomplete_details": {"reason": "max_output_tokens"}}, ("length",)),
("incomplete", {"incomplete_details": {"reason": "content_filter"}}, ("content_filter",)),
("incomplete", {}, ("length",)),
("failed", {}, ()),
],
)
def test_responses_status_maps_to_finish_reasons(status, response_fields, expected):
data = LLMCallSpanData.from_standard_logging_payload(
_responses_payload([_RESPONSES_TEXT_ITEM], status=status, **response_fields), capture_content=True
)
assert data.finish_reasons == expected
assert data.choices_out[0]["message"]["content"] == "pong"
def test_responses_output_follows_the_content_capture_gate_but_finish_reasons_do_not():
data = LLMCallSpanData.from_standard_logging_payload(_responses_payload([_RESPONSES_TEXT_ITEM]))
assert data.choices_out == ()
assert data.finish_reasons == ("stop",)
def test_responses_content_only_reads_output_text_parts():
item = {
"type": "message",
"role": "assistant",
"content": [{"type": "refusal", "refusal": "no", "text": "not output"}, {"type": "output_text", "text": "ok"}],
}
data = LLMCallSpanData.from_standard_logging_payload(_responses_payload([item]), capture_content=True)
assert data.choices_out[0]["message"]["content"] == "ok"
def test_responses_output_without_messages_or_tool_calls_stays_empty():
data = LLMCallSpanData.from_standard_logging_payload(
_responses_payload([{"type": "reasoning", "summary": []}]), capture_content=True
)
assert data.choices_out == ()
assert data.finish_reasons == ()
def test_chat_choices_win_over_a_responses_output_list():
payload = _sample_payload(response={"choices": [{"finish_reason": "stop", "message": {"content": "chat"}}]})
payload["response"]["output"] = [_RESPONSES_TEXT_ITEM]
data = LLMCallSpanData.from_standard_logging_payload(payload, capture_content=True)
assert data.choices_out[0]["message"]["content"] == "chat"
assert data.finish_reasons == ("stop",)
def test_request_identity_prefers_canonical_team_keys():
from litellm.integrations.otel.model.payloads import RequestIdentity

View file

@ -196,6 +196,36 @@ def test_langfuse_mapper_keeps_chat_output_when_no_embedding_summary():
assert json.loads(attrs["langfuse.observation.output"]) == [{"role": "assistant", "content": "Sunny."}]
def test_langfuse_mapper_renders_a_responses_api_call_from_the_standard_logging_payload():
payload = {
"call_type": "aresponses",
"custom_llm_provider": "openai",
"model": "gpt-5.4-nano",
"messages": [{"role": "user", "content": "weather in sf?"}],
"response": {
"id": "resp_1",
"status": "completed",
"output": [
{"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Checking."}]},
{"type": "function_call", "call_id": "call_1", "name": "get_weather", "arguments": '{"city": "sf"}'},
],
},
}
data = LLMCallSpanData.from_standard_logging_payload(payload, capture_content=True)
attrs = LangfuseMapper().map(data)
assert json.loads(attrs["langfuse.observation.output"]) == [
{
"role": "assistant",
"content": "Checking.",
"tool_calls": [
{"id": "call_1", "type": "function", "function": {"name": "get_weather", "arguments": '{"city": "sf"}'}}
],
}
]
assert attrs["langfuse.observation.type"] == "generation"
# --------------------------------------------------------------------------- #
# Weave
# --------------------------------------------------------------------------- #

View file

@ -493,6 +493,20 @@ class TestPerformRedaction:
assert redacted["output"][0]["arguments"] == "redacted-by-litellm"
assert redacted["output"][0]["name"] == "get_weather"
def test_redacts_responses_api_custom_tool_call_input_dict(self):
result = {
"output": [
{"type": "custom_tool_call", "name": "grep", "input": "-r secret-token", "call_id": "call_1"},
{"type": "function_call", "name": "get_weather", "input": "not-a-custom-input", "call_id": "call_2"},
]
}
redacted = perform_redaction({}, result)
assert redacted["output"][0]["input"] == "redacted-by-litellm"
assert redacted["output"][0]["name"] == "grep"
assert redacted["output"][1]["input"] == "not-a-custom-input"
def test_redacts_every_tool_call_in_multi_element_list(self):
result = litellm.ModelResponse(
id="resp-multi",
@ -563,6 +577,14 @@ class TestPerformRedaction:
assert output_item.arguments == "redacted-by-litellm"
assert output_item.name == "get_weather"
def test_redacts_responses_api_custom_tool_call_input_object(self):
output_item = SimpleNamespace(type="custom_tool_call", name="grep", input="-r secret-token", call_id="call_1")
_redact_responses_api_output([output_item])
assert output_item.input == "redacted-by-litellm"
assert output_item.name == "grep"
def test_redacts_response_output_objects_with_top_level_text(self):
output_items = [
SimpleNamespace(text="top-level output"),