fix(otel v2): keep Responses refusal text on the folded assistant message

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yucheng 2026-09-19 19:30:57 +00:00
parent 364d897545
commit 7d93821e41
3 changed files with 30 additions and 9 deletions

View file

@ -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] = {

View file

@ -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():

View file

@ -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"}'}}
],