Skip to content

Commit 76cec71

Browse files
authored
fix(openai): capture server tool output items (#700)
* fix(openai): capture server tool output items Assisted-by: Codex * chore(openai): add changelog fragment Assisted-by: Codex * fix(openai): address server tool review feedback Assisted-by: Codex
1 parent 5afe384 commit 76cec71

6 files changed

Lines changed: 491 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Capture provider-executed Responses API tools in output messages.

‎instrumentation/opentelemetry-instrumentation-genai-openai/src/opentelemetry/instrumentation/genai/openai/response_extractors.py‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626

2727
if TYPE_CHECKING:
2828
from openai.types.responses.response import Response
29+
from openai.types.responses.response_output_item import ResponseOutputItem
2930
from openai.types.responses.response_usage import ResponseUsage
3031
from openai.types.responses.tool_param import ToolParam
3132

@@ -71,6 +72,8 @@
7172
OutputMessage,
7273
ReasoningPart,
7374
Role,
75+
ServerToolCallPart,
76+
ServerToolCallResponsePart,
7477
TextPart,
7578
)
7679
from opentelemetry.util.genai.types import (
@@ -84,6 +87,8 @@
8487
OutputMessage = None
8588
ReasoningPart = None
8689
Role = None
90+
ServerToolCallPart = None
91+
ServerToolCallResponsePart = None
8792
TextPart = None
8893
ToolCall = None
8994

@@ -298,6 +303,97 @@ def _extract_reasoning_parts(
298303
return parts
299304

300305

306+
_SERVER_TOOL_NAMES = {
307+
"code_interpreter_call": "code_interpreter",
308+
"file_search_call": "file_search",
309+
"image_generation_call": "image_generation",
310+
"mcp_call": "mcp",
311+
"mcp_list_tools": "mcp_list_tools",
312+
"tool_search_call": "tool_search",
313+
"web_search_call": "web_search",
314+
}
315+
316+
_SERVER_TOOL_RESPONSE_NAMES = {
317+
"tool_search_output": "tool_search",
318+
}
319+
320+
321+
def _extract_server_tool_part(
322+
item: ResponseOutputItem,
323+
) -> tuple[ServerToolCallPart | ServerToolCallResponsePart, str] | None:
324+
if ServerToolCallPart is None or ServerToolCallResponsePart is None:
325+
return None
326+
327+
item_type = item.type
328+
tool_name = _SERVER_TOOL_NAMES.get(item_type)
329+
response_name = _SERVER_TOOL_RESPONSE_NAMES.get(item_type)
330+
if tool_name is None and response_name is None:
331+
return None
332+
if (
333+
item_type
334+
in (
335+
"tool_search_call",
336+
"tool_search_output",
337+
)
338+
and item.execution != "server"
339+
):
340+
return None
341+
342+
finish_reason = _server_tool_finish_reason(item)
343+
if finish_reason is None:
344+
return None
345+
346+
payload = item.model_dump(exclude_none=True, mode="json")
347+
348+
item_id = payload.pop("id", None)
349+
call_id = payload.pop("call_id", None)
350+
part_id = call_id if isinstance(call_id, str) else item_id
351+
payload.pop("type", None)
352+
name = payload.pop("name", None)
353+
canonical_name = tool_name or response_name
354+
if canonical_name is None:
355+
return None
356+
payload["type"] = canonical_name
357+
if response_name is not None:
358+
return (
359+
ServerToolCallResponsePart(
360+
server_tool_call_response=payload,
361+
id=call_id if isinstance(call_id, str) else None,
362+
),
363+
finish_reason,
364+
)
365+
return (
366+
ServerToolCallPart(
367+
name=name if isinstance(name, str) else canonical_name,
368+
server_tool_call=payload,
369+
id=part_id if isinstance(part_id, str) else None,
370+
),
371+
finish_reason,
372+
)
373+
374+
375+
def _server_tool_finish_reason(item: ResponseOutputItem) -> str | None:
376+
match item.type:
377+
case "mcp_list_tools":
378+
return "error" if item.error else "stop"
379+
case (
380+
"code_interpreter_call"
381+
| "file_search_call"
382+
| "image_generation_call"
383+
| "mcp_call"
384+
| "tool_search_call"
385+
| "tool_search_output"
386+
| "web_search_call"
387+
):
388+
return (
389+
_finish_reason_from_status(item.status)
390+
if item.status is not None
391+
else None
392+
)
393+
case _:
394+
return None
395+
396+
301397
# `incomplete_details.reason` values that map onto a cross-provider finish
302398
# reason; an unrecognized reason is reported as-is.
303399
_INCOMPLETE_REASON_TO_FINISH_REASON = {
@@ -448,6 +544,17 @@ def get_output_messages_from_response(
448544
finish_reason=finish_reason,
449545
)
450546
)
547+
continue
548+
549+
if server_tool := _extract_server_tool_part(item):
550+
server_tool_part, finish_reason = server_tool
551+
messages.append(
552+
OutputMessage(
553+
role=Role.ASSISTANT.value,
554+
parts=[server_tool_part],
555+
finish_reason=finish_reason,
556+
)
557+
)
451558

452559
return messages
453560

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# TODO: this is generated by AI, re-record
2+
interactions:
3+
- request:
4+
body: |-
5+
{"input":"Search for OpenTelemetry.","model":"gpt-4o-mini","tools":[{"type":"web_search"}]}
6+
headers:
7+
Accept:
8+
- application/json
9+
Content-Type:
10+
- application/json
11+
Host:
12+
- api.openai.com
13+
authorization:
14+
- Bearer test_openai_api_key
15+
method: POST
16+
uri: https://api.openai.com/v1/responses
17+
response:
18+
body:
19+
string: |-
20+
{
21+
"id": "resp_server_tool",
22+
"object": "response",
23+
"created_at": 1776481259,
24+
"status": "completed",
25+
"error": null,
26+
"incomplete_details": null,
27+
"instructions": null,
28+
"max_output_tokens": null,
29+
"model": "gpt-4o-mini-2024-07-18",
30+
"output": [
31+
{
32+
"id": "ws_1",
33+
"type": "web_search_call",
34+
"status": "completed",
35+
"action": {
36+
"type": "search",
37+
"query": "OpenTelemetry"
38+
}
39+
},
40+
{
41+
"id": "msg_1",
42+
"type": "message",
43+
"status": "completed",
44+
"content": [
45+
{
46+
"type": "output_text",
47+
"annotations": [],
48+
"text": "OpenTelemetry is an observability framework."
49+
}
50+
],
51+
"role": "assistant"
52+
}
53+
],
54+
"parallel_tool_calls": true,
55+
"previous_response_id": null,
56+
"service_tier": "default",
57+
"store": true,
58+
"temperature": 1.0,
59+
"tool_choice": "auto",
60+
"tools": [],
61+
"top_p": 1.0,
62+
"usage": {
63+
"input_tokens": 20,
64+
"input_tokens_details": {"cached_tokens": 0},
65+
"output_tokens": 15,
66+
"output_tokens_details": {"reasoning_tokens": 0},
67+
"total_tokens": 35
68+
},
69+
"metadata": {}
70+
}
71+
headers:
72+
Content-Type:
73+
- application/json
74+
status:
75+
code: 200
76+
message: OK
77+
version: 1
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# Copyright The OpenTelemetry Authors
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Conformance scenario: OpenAI Responses API hosted tool calling."""
5+
6+
from __future__ import annotations
7+
8+
import json
9+
from typing import Any
10+
11+
from openai import OpenAI
12+
13+
from opentelemetry.instrumentation.genai.openai import OpenAIInstrumentor
14+
from opentelemetry.sdk._logs import LoggerProvider
15+
from opentelemetry.sdk.metrics import MeterProvider
16+
from opentelemetry.sdk.trace import TracerProvider
17+
from opentelemetry.test.weaver_live_check import LiveCheckReport
18+
from opentelemetry.test_util_genai.conformance import Scenario
19+
from opentelemetry.test_util_genai.instrumentor import instrument
20+
21+
22+
class ResponsesServerToolCallingScenario(Scenario):
23+
expected_spans = {"chat": 1}
24+
expected_metrics = (
25+
"gen_ai.client.operation.duration",
26+
"gen_ai.client.token.usage",
27+
)
28+
29+
def validate(self, report: LiveCheckReport) -> None:
30+
super().validate(report)
31+
output_messages = [
32+
json.loads(attribute["value"])
33+
for entry in report["samples"]
34+
if "span" in entry
35+
for attribute in entry["span"]["attributes"]
36+
if attribute["name"] == "gen_ai.output.messages"
37+
]
38+
assert len(output_messages) == 1
39+
assert output_messages[0][0]["parts"] == [
40+
{
41+
"name": "web_search",
42+
"server_tool_call": {
43+
"action": {
44+
"query": "OpenTelemetry",
45+
"type": "search",
46+
},
47+
"status": "completed",
48+
"type": "web_search",
49+
},
50+
"id": "ws_1",
51+
"type": "server_tool_call",
52+
}
53+
]
54+
55+
def run(
56+
self,
57+
*,
58+
tracer_provider: TracerProvider,
59+
meter_provider: MeterProvider,
60+
logger_provider: LoggerProvider,
61+
vcr: Any,
62+
) -> None:
63+
with instrument(
64+
OpenAIInstrumentor(),
65+
tracer_provider=tracer_provider,
66+
logger_provider=logger_provider,
67+
meter_provider=meter_provider,
68+
content_capture="SPAN_ONLY",
69+
):
70+
with vcr.use_cassette(
71+
"responses_server_tool_calling_conformance.yaml"
72+
):
73+
OpenAI().responses.create(
74+
model="gpt-4o-mini",
75+
input="Search for OpenTelemetry.",
76+
tools=[{"type": "web_search"}],
77+
)

‎instrumentation/opentelemetry-instrumentation-genai-openai/tests/test_conformance.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@
3232
)
3333
from .conformance.responses_conversation import ResponsesConversationScenario
3434
from .conformance.responses_fetch import ResponsesFetchScenario
35+
from .conformance.responses_server_tool_calling import (
36+
ResponsesServerToolCallingScenario,
37+
)
3538
from .conformance.responses_stream import ResponsesStreamScenario
3639
from .conformance.responses_streaming import ResponsesStreamingScenario
3740
from .conformance.responses_tool_calling import (
@@ -54,6 +57,7 @@
5457
ResponsesStreamScenario(),
5558
ResponsesStreamingScenario(),
5659
ResponsesToolCallingScenario(),
60+
ResponsesServerToolCallingScenario(),
5761
],
5862
ids=lambda s: type(s).__name__,
5963
)

0 commit comments

Comments
 (0)