mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-21 00:21:49 +00:00
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:
parent
77a4624f13
commit
364d897545
5 changed files with 253 additions and 2 deletions
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue