Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -905,7 +905,58 @@ def _normalize_output_messages_to_role_parts(
output = getattr(response, "output", None)
if isinstance(output, Sequence):
for item in output:
# ResponseOutputMessage may have a string representation
item_type = getattr(item, "type", None)

# Tool call items (ResponseFunctionToolCall)
if item_type == "function_call" or (
hasattr(item, "arguments")
and hasattr(item, "call_id")
and hasattr(item, "name")
):
p: dict[str, Any] = {"type": "tool_call"}
p["id"] = getattr(
item, "call_id", None
) or getattr(item, "id", None)
p["name"] = getattr(item, "name", None)
p["arguments"] = (
"readacted"
if not self.include_sensitive_data
else getattr(item, "arguments", None)
)
parts.append(p)
if not finish_reason:
finish_reason = "tool_calls"
continue

# Message items (ResponseOutputMessage)
content_attr = getattr(item, "content", None)
if item_type == "message" or isinstance(
content_attr, (list, tuple)
):
if isinstance(content_attr, (list, tuple)):
for sub in content_attr:
txt = getattr(
sub, "text", None
) or getattr(sub, "content", None)
if isinstance(txt, str) and txt:
parts.append(
{
"type": "text",
"content": (
"readacted"
if not self.include_sensitive_data
else txt
),
}
)
fr = getattr(
item, "finish_reason", None
) or getattr(item, "status", None)
if isinstance(fr, str) and not finish_reason:
finish_reason = fr
continue

# Fallback for plain string content
txt = getattr(item, "content", None)
if isinstance(txt, str) and txt:
parts.append(
Expand All @@ -919,7 +970,6 @@ def _normalize_output_messages_to_role_parts(
}
)
else:
# Fallback: stringified
parts.append(
{
"type": "text",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -550,3 +550,132 @@ def test_chat_span_renamed_with_model(processor_setup):

span_names = {span.name for span in exporter.get_finished_spans()}
assert "chat gpt-4o" in span_names


def test_output_messages_tool_call_and_message(processor_setup):
"""ResponseFunctionToolCall items are emitted as tool_call, not text."""
processor, exporter = processor_setup

class _ToolCall:
type = "function_call"
call_id = "call_abc123"
name = "get_weather"
arguments = '{"city": "Barcelona"}'
id = "fc_xyz"
status = "completed"

class _TextContent:
type = "output_text"
text = "The weather is sunny."

class _OutputMessage:
type = "message"
content = [_TextContent()]
status = "completed"

class _Usage:
input_tokens = 10
prompt_tokens = 10
output_tokens = 5
completion_tokens = 5
total_tokens = 15

class _Response:
id = "resp-tool"
model = "gpt-4o"
usage = _Usage()
output = [_ToolCall(), _OutputMessage()]
output_text = None

trace = FakeTrace(
name="tool-trace",
trace_id="trace-tool",
started_at="2024-01-01T00:00:00Z",
ended_at="2024-01-01T00:00:02Z",
)
processor.on_trace_start(trace)

response_data = ResponseSpanData(response=_Response())
span = FakeSpan(
trace_id="trace-tool",
span_id="span-tool",
span_data=response_data,
started_at="2024-01-01T00:00:00Z",
ended_at="2024-01-01T00:00:01Z",
)
processor.on_span_start(span)
processor.on_span_end(span)
processor.on_trace_end(trace)

finished = exporter.get_finished_spans()
response_span = next(s for s in finished if "chat" in s.name)
out_messages = json.loads(
response_span.attributes[sp.GEN_AI_OUTPUT_MESSAGES]
)
assert len(out_messages) == 1
msg = out_messages[0]
assert msg["role"] == "assistant"
parts = msg["parts"]
# Should have a tool_call part and a text part
tool_parts = [p for p in parts if p.get("type") == "tool_call"]
text_parts = [p for p in parts if p.get("type") == "text"]
assert len(tool_parts) == 1
assert tool_parts[0]["id"] == "call_abc123"
assert tool_parts[0]["name"] == "get_weather"
assert tool_parts[0]["arguments"] == '{"city": "Barcelona"}'
assert len(text_parts) == 1
assert text_parts[0]["content"] == "The weather is sunny."


def test_output_messages_tool_call_redacted(processor_setup):
"""Tool call arguments are redacted when sensitive data is off."""
processor, exporter = processor_setup
processor.include_sensitive_data = False

class _ToolCall:
type = "function_call"
call_id = "call_red"
name = "get_weather"
arguments = '{"city": "Secret"}'
id = "fc_red"
status = "completed"

class _Usage:
input_tokens = 10
prompt_tokens = 10
output_tokens = 5
completion_tokens = 5
total_tokens = 15

class _Response:
id = "resp-red"
model = "gpt-4o"
usage = _Usage()
output = [_ToolCall()]
output_text = None

trace = FakeTrace(
name="red-trace",
trace_id="trace-red",
started_at="2024-01-01T00:00:00Z",
ended_at="2024-01-01T00:00:02Z",
)
processor.on_trace_start(trace)

response_data = ResponseSpanData(response=_Response())
span = FakeSpan(
trace_id="trace-red",
span_id="span-red",
span_data=response_data,
started_at="2024-01-01T00:00:00Z",
ended_at="2024-01-01T00:00:01Z",
)
processor.on_span_start(span)
processor.on_span_end(span)
processor.on_trace_end(trace)

finished = exporter.get_finished_spans()
response_span = next(s for s in finished if "chat" in s.name)
# When sensitive data is off, output messages should not be captured
# (the _build_content_payload returns empty payload)
assert sp.GEN_AI_OUTPUT_MESSAGES not in response_span.attributes
Loading