fix(otel): populate gen_ai.output.messages and gen_ai.system_instructions for Responses API

Fixes #25840

The OTel integration's set_attributes() method never populates
gen_ai.output.messages, gen_ai.system_instructions, or
gen_ai.response.finish_reasons for /v1/responses calls because
ResponsesAPIResponse uses 'output' instead of 'choices' and the
system prompt arrives as 'instructions' instead of 'system_instructions'.

Changes:
- Add elif branch for response_obj.get('output') to extract response
  text from Responses API output items (type='message'/output_text)
  and tool calls (type='function_call')
- Coalesce system_instructions/instructions/system kwargs so the
  system prompt is captured for Responses API, Anthropic Messages
  API, and Vertex AI Gemini paths
- Handle plain-string system prompts without unnecessary wrapping
- Extract response_obj.get('status') as finish reason for Responses API
- Add _transform_responses_api_output_to_otel() method
This commit is contained in:
Aneesh-Fiddler 2026-04-27 22:51:39 +05:30
parent 3d2b8fed32
commit c0da139540

View file

@ -1678,17 +1678,35 @@ class OpenTelemetry(CustomLogger):
value=safe_dumps(transformed_messages),
)
if kwargs.get("system_instructions"):
transformed_system_instructions = (
self._transform_messages_to_otel_semantic_conventions(
kwargs.get("system_instructions")
# Coalesce the different kwarg names that carry the system
# prompt depending on the call path:
# - "system_instructions" — Vertex AI Gemini chat-completion
# - "instructions" — OpenAI Responses API
# - "system" — Anthropic Messages API
system_instructions = (
kwargs.get("system_instructions")
or kwargs.get("instructions")
or kwargs.get("system")
)
if system_instructions:
if isinstance(system_instructions, str):
# Plain text system prompt — no transformation needed
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_SYSTEM_INSTRUCTIONS.value,
value=system_instructions,
)
else:
transformed_system_instructions = (
self._transform_messages_to_otel_semantic_conventions(
system_instructions
)
)
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_SYSTEM_INSTRUCTIONS.value,
value=safe_dumps(transformed_system_instructions),
)
)
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_SYSTEM_INSTRUCTIONS.value,
value=safe_dumps(transformed_system_instructions),
)
self.safe_set_attribute(
span=span,
@ -1747,6 +1765,32 @@ class OpenTelemetry(CustomLogger):
value=value,
)
elif response_obj.get("output"):
# Responses API: ResponsesAPIResponse has an "output"
# list instead of "choices". Each item with
# type="message" contains a "content" list of
# OutputText objects (type="output_text").
output_messages = (
self._transform_responses_api_output_to_otel(
response_obj.get("output")
)
)
if output_messages:
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_OUTPUT_MESSAGES.value,
value=safe_dumps(output_messages),
)
# Extract finish reason from ResponsesAPIResponse.status
status = response_obj.get("status")
if status:
self.safe_set_attribute(
span=span,
key=SpanAttributes.GEN_AI_RESPONSE_FINISH_REASONS.value,
value=safe_dumps([status]),
)
except Exception as e:
self.handle_callback_failure(
callback_name=self.callback_name or "opentelemetry"
@ -1842,6 +1886,53 @@ class OpenTelemetry(CustomLogger):
transformed.append(transformed_msg)
return transformed
def _transform_responses_api_output_to_otel(
self, output: List[dict]
) -> List[dict]:
"""
Transform Responses API output items into OTEL GenAI 1.38 format.
The Responses API returns output as a list of items, each with a
``type`` field. Message items (``type="message"``) contain a
``content`` list of ``OutputText`` objects with ``type="output_text"``
and ``text`` fields.
This method converts them to the same ``{"role": ..., "parts": [...]}``
format used by ``_transform_choices_to_otel_semantic_conventions``.
"""
transformed = []
for item in output:
if not isinstance(item, dict):
continue
if item.get("type") == "message":
role = item.get("role", "assistant")
parts = []
for content in item.get("content", []):
if not isinstance(content, dict):
continue
if content.get("type") == "output_text":
text = content.get("text", "")
if text:
parts.append({"type": "text", "content": text})
if parts:
transformed.append({"role": role, "parts": parts})
elif item.get("type") == "function_call":
# Surface tool calls from Responses API output
tool_call = {
"role": "assistant",
"parts": [
{
"type": "tool_call",
"name": item.get("name", ""),
"arguments": item.get("arguments", ""),
}
],
}
if item.get("call_id"):
tool_call["parts"][0]["id"] = item["call_id"]
transformed.append(tool_call)
return transformed
def set_raw_request_attributes(self, span: Span, kwargs, response_obj):
try:
# Only set provider-specific raw payload attributes on this span.