Skip to content

Commit 2d1afc0

Browse files
committed
botocore: handle amazon nova tool calls events for InvokeModelWithResponseStream
1 parent e9ca00c commit 2d1afc0

8 files changed

+2236
-993
lines changed

instrumentation/opentelemetry-instrumentation-botocore/src/opentelemetry/instrumentation/botocore/extensions/bedrock_utils.py

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,25 +216,45 @@ def _process_amazon_titan_chunk(self, chunk):
216216

217217
def _process_amazon_nova_chunk(self, chunk):
218218
# pylint: disable=too-many-branches
219-
# TODO: handle tool calls!
220219
if "messageStart" in chunk:
221220
# {'messageStart': {'role': 'assistant'}}
222221
if chunk["messageStart"].get("role") == "assistant":
223222
self._record_message = True
224223
self._message = {"role": "assistant", "content": []}
225224
return
226225

226+
if "contentBlockStart" in chunk:
227+
# {'contentBlockStart': {'start': {'toolUse': {'toolUseId': 'id', 'name': 'name'}}, 'contentBlockIndex': 31}}
228+
if self._record_message:
229+
self._message["content"].append(self._content_block)
230+
self._content_block = {}
231+
start = chunk["contentBlockStart"].get("start", {})
232+
if "toolUse" in start:
233+
self._content_block = start
234+
return
235+
227236
if "contentBlockDelta" in chunk:
228237
# {'contentBlockDelta': {'delta': {'text': "Hello"}, 'contentBlockIndex': 0}}
238+
# {'contentBlockDelta': {'delta': {'toolUse': {'input': '{"location":"San Francisco"}'}}, 'contentBlockIndex': 31}}
229239
if self._record_message:
230240
delta = chunk["contentBlockDelta"].get("delta", {})
231241
if "text" in delta:
232242
self._content_block.setdefault("text", "")
233243
self._content_block["text"] += delta["text"]
244+
elif "toolUse" in delta:
245+
self._content_block.setdefault("toolUse", {})
246+
self._content_block["toolUse"]["input"] = json.loads(
247+
delta["toolUse"]["input"]
248+
)
234249
return
235250

236251
if "contentBlockStop" in chunk:
237252
# {'contentBlockStop': {'contentBlockIndex': 0}}
253+
if self._record_message:
254+
# create a new content block only for tools
255+
if "toolUse" in self._content_block:
256+
self._message["content"].append(self._content_block)
257+
self._content_block = {}
238258
return
239259

240260
if "messageStop" in chunk:

instrumentation/opentelemetry-instrumentation-botocore/tests/cassettes/test_invoke_model_with_response_stream_no_content_tool_call.yaml

Lines changed: 0 additions & 468 deletions
This file was deleted.

instrumentation/opentelemetry-instrumentation-botocore/tests/cassettes/test_invoke_model_with_response_stream_no_content_tool_call[amazon.nova].yaml

Lines changed: 456 additions & 0 deletions
Large diffs are not rendered by default.

instrumentation/opentelemetry-instrumentation-botocore/tests/cassettes/test_invoke_model_with_response_stream_no_content_tool_call[anthropic.claude].yaml

Lines changed: 460 additions & 0 deletions
Large diffs are not rendered by default.

instrumentation/opentelemetry-instrumentation-botocore/tests/cassettes/test_invoke_model_with_response_stream_with_content_tool_call.yaml

Lines changed: 0 additions & 473 deletions
This file was deleted.

instrumentation/opentelemetry-instrumentation-botocore/tests/cassettes/test_invoke_model_with_response_stream_with_content_tool_call[amazon.nova].yaml

Lines changed: 643 additions & 0 deletions
Large diffs are not rendered by default.

instrumentation/opentelemetry-instrumentation-botocore/tests/cassettes/test_invoke_model_with_response_stream_with_content_tool_call[anthropic.claude].yaml

Lines changed: 484 additions & 0 deletions
Large diffs are not rendered by default.

instrumentation/opentelemetry-instrumentation-botocore/tests/test_botocore_bedrock.py

Lines changed: 172 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -2032,20 +2032,24 @@ def invoke_model_with_response_stream_tool_call(
20322032
expect_content,
20332033
):
20342034
# pylint:disable=too-many-locals,too-many-statements,too-many-branches
2035-
messages = [
2036-
{
2037-
"role": "user",
2038-
"content": [
2039-
{
2040-
"text": "What is the weather in Seattle and San Francisco today? Please expect one tool call for Seattle and one for San Francisco",
2041-
"type": "text",
2042-
}
2043-
],
2035+
user_prompt = "What is the weather in Seattle and San Francisco today? Please give one tool call for Seattle and one for San Francisco"
2036+
if "anthropic.claude" in llm_model_value:
2037+
user_msg_content = {
2038+
"text": user_prompt,
2039+
"type": "text",
20442040
}
2045-
]
2041+
else:
2042+
user_msg_content = {
2043+
"text": user_prompt,
2044+
}
2045+
messages = [{"role": "user", "content": [user_msg_content]}]
20462046

20472047
max_tokens = 1000
2048-
tool_config = get_anthropic_tool_config()
2048+
if "anthropic.claude" in llm_model_value:
2049+
tool_config = get_anthropic_tool_config()
2050+
else:
2051+
tool_config = get_tool_config()
2052+
20492053
body = get_invoke_model_body(
20502054
llm_model_value,
20512055
messages=messages,
@@ -2059,12 +2063,16 @@ def invoke_model_with_response_stream_tool_call(
20592063

20602064
content = []
20612065
content_block = {}
2066+
# used only by anthropic claude
20622067
input_json_buf = ""
2068+
# used only by amazon nova
2069+
tool_use = None
20632070
for event in response_0["body"]:
20642071
json_bytes = event["chunk"].get("bytes", b"")
20652072
decoded = json_bytes.decode("utf-8")
20662073
chunk = json.loads(decoded)
20672074

2075+
# anthropic claude
20682076
if (message_type := chunk.get("type")) is not None:
20692077
if message_type == "content_block_start":
20702078
content_block = chunk["content_block"]
@@ -2079,28 +2087,81 @@ def invoke_model_with_response_stream_tool_call(
20792087
content.append(content_block)
20802088
content_block = None
20812089
input_json_buf = ""
2090+
else:
2091+
if "contentBlockDelta" in chunk:
2092+
delta = chunk["contentBlockDelta"]["delta"]
2093+
if "text" in delta:
2094+
content_block.setdefault("text", "")
2095+
content_block["text"] += delta["text"]
2096+
elif "toolUse" in delta:
2097+
tool_use["toolUse"]["input"] = json.loads(
2098+
delta["toolUse"]["input"]
2099+
)
2100+
elif "contentBlockStart" in chunk:
2101+
if content_block:
2102+
content.append(content_block)
2103+
content_block = {}
2104+
start = chunk["contentBlockStart"]["start"]
2105+
if "toolUse" in start:
2106+
tool_use = start
2107+
elif "contentBlockStop" in chunk:
2108+
if tool_use:
2109+
content.append(tool_use)
2110+
tool_use = {}
20822111

20832112
assert content
20842113

2085-
tool_requests_ids = [
2086-
item["id"] for item in content if item["type"] == "tool_use"
2087-
]
2114+
if "anthropic.claude" in llm_model_value:
2115+
tool_requests_ids = [
2116+
item["id"] for item in content if item["type"] == "tool_use"
2117+
]
2118+
else:
2119+
tool_requests_ids = [
2120+
item["toolUse"]["toolUseId"]
2121+
for item in content
2122+
if "toolUse" in item
2123+
]
2124+
20882125
assert len(tool_requests_ids) == 2
2089-
tool_call_result = {
2090-
"role": "user",
2091-
"content": [
2092-
{
2093-
"type": "tool_result",
2094-
"tool_use_id": tool_requests_ids[0],
2095-
"content": "50 degrees and raining",
2096-
},
2097-
{
2098-
"type": "tool_result",
2099-
"tool_use_id": tool_requests_ids[1],
2100-
"content": "70 degrees and sunny",
2101-
},
2102-
],
2103-
}
2126+
2127+
if "anthropic.claude" in llm_model_value:
2128+
tool_call_result = {
2129+
"role": "user",
2130+
"content": [
2131+
{
2132+
"type": "tool_result",
2133+
"tool_use_id": tool_requests_ids[0],
2134+
"content": "50 degrees and raining",
2135+
},
2136+
{
2137+
"type": "tool_result",
2138+
"tool_use_id": tool_requests_ids[1],
2139+
"content": "70 degrees and sunny",
2140+
},
2141+
],
2142+
}
2143+
else:
2144+
tool_call_result = {
2145+
"role": "user",
2146+
"content": [
2147+
{
2148+
"toolResult": {
2149+
"toolUseId": tool_requests_ids[0],
2150+
"content": [
2151+
{"json": {"weather": "50 degrees and raining"}}
2152+
],
2153+
}
2154+
},
2155+
{
2156+
"toolResult": {
2157+
"toolUseId": tool_requests_ids[1],
2158+
"content": [
2159+
{"json": {"weather": "70 degrees and sunny"}}
2160+
],
2161+
}
2162+
},
2163+
],
2164+
}
21042165

21052166
# remove extra attributes from response
21062167
messages.append({"role": "assistant", "content": content})
@@ -2112,14 +2173,43 @@ def invoke_model_with_response_stream_tool_call(
21122173
max_tokens=max_tokens,
21132174
tools=tool_config,
21142175
)
2176+
import pprint
2177+
2178+
pprint.pprint(messages[1])
21152179
response_1 = bedrock_runtime_client.invoke_model_with_response_stream(
21162180
body=body,
21172181
modelId=llm_model_value,
21182182
)
21192183

2120-
# consume the body to have it traced
2121-
for _ in response_1["body"]:
2122-
pass
2184+
content_block = {}
2185+
response_1_content = []
2186+
for event in response_1["body"]:
2187+
json_bytes = event["chunk"].get("bytes", b"")
2188+
decoded = json_bytes.decode("utf-8")
2189+
chunk = json.loads(decoded)
2190+
2191+
# anthropic claude
2192+
if (message_type := chunk.get("type")) is not None:
2193+
if message_type == "content_block_start":
2194+
content_block = chunk["content_block"]
2195+
elif message_type == "content_block_delta":
2196+
if chunk["delta"]["type"] == "text_delta":
2197+
content_block["text"] += chunk["delta"]["text"]
2198+
elif message_type == "content_block_stop":
2199+
response_1_content.append(content_block)
2200+
content_block = None
2201+
else:
2202+
if "contentBlockDelta" in chunk:
2203+
delta = chunk["contentBlockDelta"]["delta"]
2204+
if "text" in delta:
2205+
content_block.setdefault("text", "")
2206+
content_block["text"] += delta["text"]
2207+
elif "messageStop" in chunk:
2208+
if content_block:
2209+
response_1_content.append(content_block)
2210+
content_block = {}
2211+
2212+
assert response_1_content
21232213

21242214
(span_0, span_1) = span_exporter.get_finished_spans()
21252215
assert_stream_completion_attributes(
@@ -2194,21 +2284,38 @@ def invoke_model_with_response_stream_tool_call(
21942284
assistant_body,
21952285
span_1,
21962286
)
2197-
tool_message_0 = {
2198-
"id": tool_requests_ids[0],
2199-
"content": tool_call_result["content"][0]["content"]
2200-
if expect_content
2201-
else None,
2202-
}
2287+
2288+
if "anthropic.claude" in llm_model_value:
2289+
tool_message_0 = {
2290+
"id": tool_requests_ids[0],
2291+
"content": tool_call_result["content"][0]["content"]
2292+
if expect_content
2293+
else None,
2294+
}
2295+
tool_message_1 = {
2296+
"id": tool_requests_ids[1],
2297+
"content": tool_call_result["content"][1]["content"]
2298+
if expect_content
2299+
else None,
2300+
}
2301+
else:
2302+
tool_message_0 = {
2303+
"id": tool_requests_ids[0],
2304+
"content": tool_call_result["content"][0]["toolResult"]["content"]
2305+
if expect_content
2306+
else None,
2307+
}
2308+
tool_message_1 = {
2309+
"id": tool_requests_ids[1],
2310+
"content": tool_call_result["content"][1]["toolResult"]["content"]
2311+
if expect_content
2312+
else None,
2313+
}
2314+
22032315
assert_message_in_logs(
22042316
logs[4], "gen_ai.tool.message", tool_message_0, span_1
22052317
)
2206-
tool_message_1 = {
2207-
"id": tool_requests_ids[1],
2208-
"content": tool_call_result["content"][1]["content"]
2209-
if expect_content
2210-
else None,
2211-
}
2318+
22122319
assert_message_in_logs(
22132320
logs[5], "gen_ai.tool.message", tool_message_1, span_1
22142321
)
@@ -2225,27 +2332,31 @@ def invoke_model_with_response_stream_tool_call(
22252332
"finish_reason": "end_turn",
22262333
"message": {
22272334
"role": "assistant",
2228-
"content": [
2229-
{
2230-
"type": "text",
2231-
"text": "\n\nGreat! I have the current weather information for both cities. Here's the weather in Seattle and San Francisco today:\n\nSeattle: 50 degrees and raining\nSan Francisco: 70 degrees and sunny\n\nAs you can see, the weather is quite different in these two cities today. Seattle is experiencing cooler temperatures with rain, which is fairly typical for the city. On the other hand, San Francisco is enjoying a warm and sunny day. If you're planning any activities, you might want to consider indoor options for Seattle, while it's a great day for outdoor activities in San Francisco.\n\nIs there anything else you'd like to know about the weather in these cities or any other locations?",
2232-
}
2233-
],
2335+
"content": response_1_content,
22342336
},
22352337
}
22362338
if not expect_content:
22372339
choice_body["message"].pop("content")
22382340
assert_message_in_logs(logs[7], "gen_ai.choice", choice_body, span_1)
22392341

22402342

2343+
@pytest.mark.parametrize(
2344+
"model_family",
2345+
["amazon.nova", "anthropic.claude"],
2346+
)
22412347
@pytest.mark.vcr()
22422348
def test_invoke_model_with_response_stream_with_content_tool_call(
22432349
span_exporter,
22442350
log_exporter,
22452351
bedrock_runtime_client,
22462352
instrument_with_content,
2353+
model_family,
22472354
):
2248-
llm_model_value = "us.anthropic.claude-3-5-sonnet-20240620-v1:0"
2355+
if model_family == "amazon.nova":
2356+
llm_model_value = "amazon.nova-micro-v1:0"
2357+
elif model_family == "anthropic.claude":
2358+
llm_model_value = "us.anthropic.claude-3-5-sonnet-20240620-v1:0"
2359+
22492360
invoke_model_with_response_stream_tool_call(
22502361
span_exporter,
22512362
log_exporter,
@@ -2412,13 +2523,23 @@ def test_invoke_model_with_response_stream_no_content_different_events(
24122523
assert_message_in_logs(logs[4], "gen_ai.choice", choice_body, span)
24132524

24142525

2526+
@pytest.mark.parametrize(
2527+
"model_family",
2528+
["amazon.nova", "anthropic.claude"],
2529+
)
24152530
@pytest.mark.vcr()
24162531
def test_invoke_model_with_response_stream_no_content_tool_call(
24172532
span_exporter,
24182533
log_exporter,
24192534
bedrock_runtime_client,
24202535
instrument_no_content,
2536+
model_family,
24212537
):
2538+
if model_family == "amazon.nova":
2539+
llm_model_value = "amazon.nova-micro-v1:0"
2540+
elif model_family == "anthropic.claude":
2541+
llm_model_value = "us.anthropic.claude-3-5-sonnet-20240620-v1:0"
2542+
24222543
llm_model_value = "us.anthropic.claude-3-5-sonnet-20240620-v1:0"
24232544
invoke_model_with_response_stream_tool_call(
24242545
span_exporter,

0 commit comments

Comments
 (0)