-
Notifications
You must be signed in to change notification settings - Fork 807
fix(openai-agents): group tool calls into single completion for streaming responses #3351
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -292,8 +292,40 @@ def on_span_end(self, span): | |
|
||
# Extract completions and add directly to response span using OpenAI semantic conventions | ||
if hasattr(response, 'output') and response.output: | ||
for i, output in enumerate(response.output): | ||
# Handle different output types | ||
# Group outputs by type: separate tool calls from content | ||
tool_calls = [] | ||
content_outputs = [] | ||
|
||
for output in response.output: | ||
if hasattr(output, 'name'): # Tool call | ||
tool_calls.append(output) | ||
else: # Content output | ||
content_outputs.append(output) | ||
|
||
completion_index = 0 | ||
|
||
# Handle tool calls as a single completion with multiple tool_calls | ||
if tool_calls: | ||
otel_span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.role", "assistant") | ||
otel_span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.content", "") | ||
otel_span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", "tool_calls") | ||
|
||
Comment on lines
+305
to
+312
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Set top-level finish_reason once and avoid writing None values. Write llm.response.finish_reason for dashboards expecting a single top-level value. Also guard against None to keep SDKs happy. Apply this diff: @@
- completion_index = 0
+ completion_index = 0
+ # Set top-level finish reason once
+ _fr = getattr(response, "finish_reason", None)
+ if _fr is not None:
+ otel_span.set_attribute(SpanAttributes.LLM_RESPONSE_FINISH_REASON, _fr)
+ elif tool_calls:
+ otel_span.set_attribute(SpanAttributes.LLM_RESPONSE_FINISH_REASON, "tool_calls")
@@
- # Add finish reason if available
- if hasattr(response, 'finish_reason'):
- otel_span.set_attribute(
- f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", response.finish_reason)
+ # Add finish reason if available
+ if _fr is not None:
+ otel_span.set_attribute(
+ f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", _fr)
@@
- # Add finish reason if available
- if hasattr(response, 'finish_reason'):
- otel_span.set_attribute(
- f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", response.finish_reason)
+ # Add finish reason if available
+ if _fr is not None:
+ otel_span.set_attribute(
+ f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", _fr) Also applies to: 343-347, 357-361 🤖 Prompt for AI Agents
|
||
for tool_index, output in enumerate(tool_calls): | ||
tool_name = getattr(output, 'name', 'unknown_tool') | ||
arguments = getattr(output, 'arguments', '{}') | ||
tool_call_id = getattr(output, 'call_id', f"call_{tool_index}") | ||
|
||
otel_span.set_attribute( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.tool_calls.{tool_index}.name", tool_name) | ||
otel_span.set_attribute( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.tool_calls.{tool_index}.arguments", arguments) | ||
otel_span.set_attribute( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.tool_calls.{tool_index}.id", tool_call_id) | ||
Comment on lines
+313
to
+323
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Serialize tool call arguments to JSON string (OTel attribute types must be scalar/array). arguments may be a dict/object; setting it directly can drop the attr or coerce inconsistently across SDKs. Emit a compact JSON string. Apply these diffs: @@
- arguments = getattr(output, 'arguments', '{}')
+ arguments = getattr(output, 'arguments', None)
+ if not isinstance(arguments, str):
+ try:
+ arguments = json.dumps(arguments, separators=(',', ':'), ensure_ascii=False)
+ except TypeError:
+ arguments = "{}" if arguments is None else str(arguments)
@@
- otel_span.set_attribute(
- f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.tool_calls.{tool_index}.arguments", arguments)
+ otel_span.set_attribute(
+ f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.tool_calls.{tool_index}.arguments", arguments) @@
- arguments = getattr(output, 'arguments', '{}')
+ arguments = getattr(output, 'arguments', None)
+ if not isinstance(arguments, str):
+ try:
+ arguments = json.dumps(arguments, separators=(',', ':'), ensure_ascii=False)
+ except TypeError:
+ arguments = "{}" if arguments is None else str(arguments)
@@
- otel_span.set_attribute(
- f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.tool_calls.{tool_index}.arguments", arguments)
+ otel_span.set_attribute(
+ f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.tool_calls.{tool_index}.arguments", arguments) Also applies to: 441-450 🤖 Prompt for AI Agents
|
||
|
||
completion_index += 1 | ||
|
||
# Handle content outputs as separate completions | ||
for output in content_outputs: | ||
if hasattr(output, 'content') and output.content: | ||
# Text message with content array (ResponseOutputMessage) | ||
content_text = "" | ||
|
@@ -303,39 +335,31 @@ def on_span_end(self, span): | |
|
||
if content_text: | ||
otel_span.set_attribute( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.content", content_text) | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.content", content_text) | ||
otel_span.set_attribute( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.role", getattr( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.role", getattr( | ||
output, 'role', 'assistant')) | ||
|
||
elif hasattr(output, 'name'): | ||
# Function/tool call (ResponseFunctionToolCall) - use OpenAI tool call format | ||
tool_name = getattr(output, 'name', 'unknown_tool') | ||
arguments = getattr(output, 'arguments', '{}') | ||
tool_call_id = getattr(output, 'call_id', f"call_{i}") | ||
|
||
# Set completion with tool call following OpenAI format | ||
otel_span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{i}.role", "assistant") | ||
otel_span.set_attribute( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.finish_reason", "tool_calls") | ||
otel_span.set_attribute( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.tool_calls.0.name", tool_name) | ||
otel_span.set_attribute( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.tool_calls.0.arguments", arguments) | ||
otel_span.set_attribute( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.tool_calls.0.id", tool_call_id) | ||
|
||
# Add finish reason if available | ||
if hasattr(response, 'finish_reason'): | ||
otel_span.set_attribute( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", response.finish_reason) | ||
|
||
completion_index += 1 | ||
|
||
elif hasattr(output, 'text'): | ||
# Direct text content | ||
otel_span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{i}.content", output.text) | ||
otel_span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.content", output.text) | ||
otel_span.set_attribute( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.role", getattr( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.role", getattr( | ||
output, 'role', 'assistant')) | ||
|
||
# Add finish reason if available (for non-tool-call cases) | ||
if hasattr(response, 'finish_reason') and not hasattr(output, 'name'): | ||
otel_span.set_attribute( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.finish_reason", response.finish_reason) | ||
|
||
# Add finish reason if available | ||
if hasattr(response, 'finish_reason'): | ||
otel_span.set_attribute( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", response.finish_reason) | ||
|
||
completion_index += 1 | ||
|
||
# Extract usage data and add directly to response span | ||
if hasattr(response, 'usage') and response.usage: | ||
|
@@ -395,8 +419,40 @@ def on_span_end(self, span): | |
|
||
# Extract completions and add directly to response span using OpenAI semantic conventions | ||
if hasattr(response, 'output') and response.output: | ||
for i, output in enumerate(response.output): | ||
# Handle different output types | ||
# Group outputs by type: separate tool calls from content | ||
tool_calls = [] | ||
content_outputs = [] | ||
|
||
for output in response.output: | ||
if hasattr(output, 'name'): # Tool call | ||
tool_calls.append(output) | ||
else: # Content output | ||
content_outputs.append(output) | ||
|
||
completion_index = 0 | ||
|
||
# Handle tool calls as a single completion with multiple tool_calls | ||
if tool_calls: | ||
otel_span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.role", "assistant") | ||
otel_span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.content", "") | ||
otel_span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", "tool_calls") | ||
|
||
Comment on lines
+432
to
+439
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mirror finish_reason fixes in legacy path. Ensure top-level finish_reason is set and avoid None in nested attrs here too. Apply this diff: @@
- completion_index = 0
+ completion_index = 0
+ _fr = getattr(response, "finish_reason", None)
+ if _fr is not None:
+ otel_span.set_attribute(SpanAttributes.LLM_RESPONSE_FINISH_REASON, _fr)
+ elif tool_calls:
+ otel_span.set_attribute(SpanAttributes.LLM_RESPONSE_FINISH_REASON, "tool_calls")
@@
- # Add finish reason if available
- if hasattr(response, 'finish_reason'):
- otel_span.set_attribute(
- f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", response.finish_reason)
+ # Add finish reason if available
+ if _fr is not None:
+ otel_span.set_attribute(
+ f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", _fr)
@@
- # Add finish reason if available
- if hasattr(response, 'finish_reason'):
- otel_span.set_attribute(
- f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", response.finish_reason)
+ # Add finish reason if available
+ if _fr is not None:
+ otel_span.set_attribute(
+ f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", _fr) Also applies to: 471-474, 485-487 🤖 Prompt for AI Agents
|
||
for tool_index, output in enumerate(tool_calls): | ||
tool_name = getattr(output, 'name', 'unknown_tool') | ||
arguments = getattr(output, 'arguments', '{}') | ||
tool_call_id = getattr(output, 'call_id', f"call_{tool_index}") | ||
|
||
otel_span.set_attribute( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.tool_calls.{tool_index}.name", tool_name) | ||
otel_span.set_attribute( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.tool_calls.{tool_index}.arguments", arguments) | ||
otel_span.set_attribute( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.tool_calls.{tool_index}.id", tool_call_id) | ||
|
||
completion_index += 1 | ||
|
||
# Handle content outputs as separate completions | ||
for output in content_outputs: | ||
if hasattr(output, 'content') and output.content: | ||
# Text message with content array (ResponseOutputMessage) | ||
content_text = "" | ||
|
@@ -406,39 +462,31 @@ def on_span_end(self, span): | |
|
||
if content_text: | ||
otel_span.set_attribute( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.content", content_text) | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.content", content_text) | ||
otel_span.set_attribute( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.role", getattr( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.role", getattr( | ||
output, 'role', 'assistant')) | ||
|
||
elif hasattr(output, 'name'): | ||
# Function/tool call (ResponseFunctionToolCall) - use OpenAI tool call format | ||
tool_name = getattr(output, 'name', 'unknown_tool') | ||
arguments = getattr(output, 'arguments', '{}') | ||
tool_call_id = getattr(output, 'call_id', f"call_{i}") | ||
|
||
# Set completion with tool call following OpenAI format | ||
otel_span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{i}.role", "assistant") | ||
otel_span.set_attribute( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.finish_reason", "tool_calls") | ||
otel_span.set_attribute( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.tool_calls.0.name", tool_name) | ||
otel_span.set_attribute( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.tool_calls.0.arguments", arguments) | ||
otel_span.set_attribute( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.tool_calls.0.id", tool_call_id) | ||
|
||
# Add finish reason if available | ||
if hasattr(response, 'finish_reason'): | ||
otel_span.set_attribute( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", response.finish_reason) | ||
|
||
completion_index += 1 | ||
|
||
elif hasattr(output, 'text'): | ||
# Direct text content | ||
otel_span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{i}.content", output.text) | ||
otel_span.set_attribute(f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.content", output.text) | ||
otel_span.set_attribute( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.role", getattr( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.role", getattr( | ||
output, 'role', 'assistant')) | ||
|
||
# Add finish reason if available (for non-tool-call cases) | ||
if hasattr(response, 'finish_reason') and not hasattr(output, 'name'): | ||
otel_span.set_attribute( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{i}.finish_reason", response.finish_reason) | ||
|
||
# Add finish reason if available | ||
if hasattr(response, 'finish_reason'): | ||
otel_span.set_attribute( | ||
f"{SpanAttributes.LLM_COMPLETIONS}.{completion_index}.finish_reason", response.finish_reason) | ||
|
||
completion_index += 1 | ||
|
||
# Extract usage data and add directly to response span | ||
if hasattr(response, 'usage') and response.usage: | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Duplicate grouping logic for tool calls and content outputs appears multiple times. Consider extracting this into a helper to improve maintainability.