Skip to content

Commit 5afe384

Browse files
authored
fix(anthropic): classify server tool message parts (#699)
* fix(anthropic): classify server tool message parts Assisted-by: Codex * chore: add changelog fragment * test(anthropic): cover server tool conformance Assisted-by: Codex * refactor(anthropic): simplify server result conversion Assisted-by: Codex * refactor(anthropic): prefer direct content block access Assisted-by: Codex
1 parent 4bce391 commit 5afe384

6 files changed

Lines changed: 307 additions & 11 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Represent Anthropic server tool calls and results with server tool message parts.

‎instrumentation/opentelemetry-instrumentation-genai-anthropic/src/opentelemetry/instrumentation/genai/anthropic/utils.py‎

Lines changed: 54 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import json
1010
from collections.abc import Mapping
1111
from dataclasses import dataclass
12-
from typing import TYPE_CHECKING, Any, cast
12+
from typing import TYPE_CHECKING, cast
1313

1414
from anthropic.types import (
1515
InputJSONDelta,
@@ -20,18 +20,28 @@
2020
ThinkingBlock,
2121
ThinkingDelta,
2222
ToolUseBlock,
23-
WebSearchToolResultBlock,
2423
)
2524

2625
from opentelemetry.util.genai.types import (
2726
BlobPart,
2827
MessagePart,
2928
ReasoningPart,
29+
ServerToolCallPart,
30+
ServerToolCallResponsePart,
3031
TextPart,
3132
ToolCallRequestPart,
3233
ToolCallResponsePart,
3334
)
3435

36+
_SERVER_TOOL_RESULT_TYPES = {
37+
"web_search_tool_result": "web_search",
38+
"web_fetch_tool_result": "web_fetch",
39+
"code_execution_tool_result": "code_execution",
40+
"bash_code_execution_tool_result": "bash_code_execution",
41+
"text_editor_code_execution_tool_result": "text_editor_code_execution",
42+
"tool_search_tool_result": "tool_search",
43+
}
44+
3545
if TYPE_CHECKING:
3646
from collections.abc import Iterable
3747

@@ -120,7 +130,7 @@ def _extract_base64_blob(source: object, modality: str) -> BlobPart | None:
120130

121131

122132
def _convert_dict_block_to_part(
123-
block: Mapping[str, Any],
133+
block: Mapping[str, object],
124134
) -> MessagePart | None:
125135
"""Convert a request-param content block (TypedDict/dict) to a MessagePart."""
126136
block_type = block.get("type")
@@ -137,12 +147,42 @@ def _convert_dict_block_to_part(
137147
id=str(block.get("id", "")),
138148
)
139149

150+
if block_type == "server_tool_use":
151+
name = str(block.get("name", ""))
152+
server_tool_call = {
153+
key: value
154+
for key, value in block.items()
155+
if key not in ("id", "name", "type")
156+
}
157+
server_tool_call["type"] = name
158+
block_id = block.get("id")
159+
return ServerToolCallPart(
160+
name=name,
161+
server_tool_call=server_tool_call,
162+
id=str(block_id) if block_id is not None else None,
163+
)
164+
140165
if block_type == "tool_result":
141166
return ToolCallResponsePart(
142167
response=block.get("content"),
143168
id=str(block.get("tool_use_id", "")),
144169
)
145170

171+
if isinstance(block_type, str) and (
172+
server_tool_name := _SERVER_TOOL_RESULT_TYPES.get(block_type)
173+
):
174+
server_tool_call_response = {
175+
key: value
176+
for key, value in block.items()
177+
if key not in ("tool_use_id", "type")
178+
}
179+
server_tool_call_response["type"] = server_tool_name
180+
tool_use_id = block.get("tool_use_id")
181+
return ServerToolCallResponsePart(
182+
server_tool_call_response=server_tool_call_response,
183+
id=str(tool_use_id) if tool_use_id is not None else None,
184+
)
185+
146186
if block_type in ("thinking", "redacted_thinking"):
147187
thinking = block.get("thinking") or block.get("data")
148188
return ReasoningPart(
@@ -159,29 +199,32 @@ def _convert_content_block_to_part(
159199
block: ContentBlock | ContentBlockParam,
160200
) -> MessagePart | None:
161201
"""Convert an Anthropic content block to a MessagePart."""
202+
if isinstance(block, Mapping):
203+
return _convert_dict_block_to_part(cast(Mapping[str, object], block))
204+
162205
if isinstance(block, TextBlock):
163206
return TextPart(content=block.text)
164207

165-
if isinstance(block, (ToolUseBlock, ServerToolUseBlock)):
208+
if isinstance(block, ToolUseBlock):
166209
return ToolCallRequestPart(
167210
arguments=block.input, name=block.name, id=block.id
168211
)
169212

213+
if isinstance(block, ServerToolUseBlock):
214+
return _convert_dict_block_to_part(block.model_dump(exclude_none=True))
215+
170216
if isinstance(block, (ThinkingBlock, RedactedThinkingBlock)):
171217
content = (
172218
block.thinking if isinstance(block, ThinkingBlock) else block.data
173219
)
174220
return ReasoningPart(content=content)
175221

176-
if isinstance(block, WebSearchToolResultBlock):
177-
return ToolCallResponsePart(
178-
response=block.model_dump().get("content"),
179-
id=block.tool_use_id,
222+
if block.type in _SERVER_TOOL_RESULT_TYPES:
223+
return _convert_dict_block_to_part(
224+
cast(Mapping[str, object], block.model_dump(exclude_none=True))
180225
)
181226

182-
if not hasattr(block, "get"):
183-
return None
184-
return _convert_dict_block_to_part(cast(Mapping[str, Any], block))
227+
return None
185228

186229

187230
def convert_content_to_parts(
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# TODO: this is generated by AI, re-record
2+
interactions:
3+
- request:
4+
body: |-
5+
{"max_tokens":256,"messages":[{"role":"user","content":"Search for OpenTelemetry."}],"model":"claude-sonnet-4-20250514","tools":[{"type":"web_search_20250305","name":"web_search","max_uses":1}]}
6+
headers:
7+
accept:
8+
- application/json
9+
content-type:
10+
- application/json
11+
host:
12+
- api.anthropic.com
13+
x-api-key:
14+
- test_anthropic_api_key
15+
method: POST
16+
uri: https://api.anthropic.com/v1/messages
17+
response:
18+
body:
19+
string: |-
20+
{
21+
"id": "msg_server_tool",
22+
"type": "message",
23+
"role": "assistant",
24+
"model": "claude-sonnet-4-20250514",
25+
"content": [
26+
{
27+
"type": "server_tool_use",
28+
"id": "srvtoolu_01",
29+
"name": "web_search",
30+
"input": {"query": "OpenTelemetry"}
31+
},
32+
{
33+
"type": "web_search_tool_result",
34+
"tool_use_id": "srvtoolu_01",
35+
"content": {
36+
"type": "web_search_tool_result_error",
37+
"error_code": "unavailable"
38+
}
39+
},
40+
{
41+
"type": "text",
42+
"text": "Search was unavailable."
43+
}
44+
],
45+
"stop_reason": "end_turn",
46+
"stop_sequence": null,
47+
"usage": {
48+
"input_tokens": 20,
49+
"output_tokens": 15
50+
}
51+
}
52+
headers:
53+
Content-Type:
54+
- application/json
55+
status:
56+
code: 200
57+
message: OK
58+
version: 1
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Copyright The OpenTelemetry Authors
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Conformance scenario: Anthropic chat with server-side tool calls."""
5+
6+
from __future__ import annotations
7+
8+
import json
9+
import os
10+
from typing import Any
11+
from unittest import mock
12+
13+
from anthropic import Anthropic
14+
15+
from opentelemetry.instrumentation.genai.anthropic import AnthropicInstrumentor
16+
from opentelemetry.sdk._logs import LoggerProvider
17+
from opentelemetry.sdk.metrics import MeterProvider
18+
from opentelemetry.sdk.trace import TracerProvider
19+
from opentelemetry.test.weaver_live_check import LiveCheckReport
20+
from opentelemetry.test_util_genai.conformance import Scenario
21+
from opentelemetry.test_util_genai.instrumentor import instrument
22+
23+
24+
class ServerToolCallingScenario(Scenario):
25+
expected_spans = {"chat": 1}
26+
expected_metrics = (
27+
"gen_ai.client.operation.duration",
28+
"gen_ai.client.token.usage",
29+
)
30+
31+
def validate(self, report: LiveCheckReport) -> None:
32+
super().validate(report)
33+
output_messages = [
34+
json.loads(attribute["value"])
35+
for entry in report["samples"]
36+
if "span" in entry
37+
for attribute in entry["span"]["attributes"]
38+
if attribute["name"] == "gen_ai.output.messages"
39+
]
40+
assert len(output_messages) == 1
41+
assert output_messages[0][0]["parts"][:2] == [
42+
{
43+
"name": "web_search",
44+
"server_tool_call": {
45+
"input": {"query": "OpenTelemetry"},
46+
"type": "web_search",
47+
},
48+
"id": "srvtoolu_01",
49+
"type": "server_tool_call",
50+
},
51+
{
52+
"server_tool_call_response": {
53+
"content": {
54+
"error_code": "unavailable",
55+
"type": "web_search_tool_result_error",
56+
},
57+
"type": "web_search",
58+
},
59+
"id": "srvtoolu_01",
60+
"type": "server_tool_call_response",
61+
},
62+
]
63+
64+
def run(
65+
self,
66+
*,
67+
tracer_provider: TracerProvider,
68+
meter_provider: MeterProvider,
69+
logger_provider: LoggerProvider,
70+
vcr: Any,
71+
) -> None:
72+
key_override = (
73+
{}
74+
if os.getenv("ANTHROPIC_API_KEY")
75+
else {"ANTHROPIC_API_KEY": "test_anthropic_api_key"}
76+
)
77+
with mock.patch.dict(os.environ, key_override):
78+
with instrument(
79+
AnthropicInstrumentor(),
80+
tracer_provider=tracer_provider,
81+
logger_provider=logger_provider,
82+
meter_provider=meter_provider,
83+
content_capture="SPAN_ONLY",
84+
):
85+
with vcr.use_cassette("server_tool_calling_conformance.yaml"):
86+
Anthropic().messages.create(
87+
model="claude-sonnet-4-20250514",
88+
max_tokens=256,
89+
messages=[
90+
{
91+
"role": "user",
92+
"content": "Search for OpenTelemetry.",
93+
}
94+
],
95+
tools=[
96+
{
97+
"type": "web_search_20250305",
98+
"name": "web_search",
99+
"max_uses": 1,
100+
}
101+
],
102+
)

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
InferenceRawResponseStreamingScenario,
2525
)
2626
from .conformance.inference_streaming import InferenceStreamingScenario
27+
from .conformance.server_tool_calling import ServerToolCallingScenario
2728
from .conformance.tool_calling import ToolCallingScenario
2829

2930

@@ -35,6 +36,7 @@
3536
InferenceRawResponseScenario(),
3637
InferenceRawResponseStreamingScenario(),
3738
ToolCallingScenario(),
39+
ServerToolCallingScenario(),
3840
],
3941
ids=lambda s: type(s).__name__,
4042
)

0 commit comments

Comments
 (0)