Skip to content

Commit ba636fe

Browse files
committed
Vertex capture tool requests and responses
1 parent 6e61ff0 commit ba636fe

File tree

8 files changed

+1065
-14
lines changed

8 files changed

+1065
-14
lines changed

Diff for: instrumentation-genai/opentelemetry-instrumentation-vertexai/CHANGELOG.md

+3-1
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1515
([#3208](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3208))
1616
- VertexAI emit user, system, and assistant events
1717
([#3203](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3203))
18-
- Add Vertex gen AI response span attributes
18+
- Add Vertex gen AI response attributes and `gen_ai.choice` events
1919
([#3227](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3227))
2020
- VertexAI stop serializing unset fields into event
2121
([#3236](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3236))
22+
- Vertex capture tool requests and responses
23+
([#3255](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3255))

Diff for: instrumentation-genai/opentelemetry-instrumentation-vertexai/src/opentelemetry/instrumentation/vertexai/events.py

+57-8
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from __future__ import annotations
2424

2525
from dataclasses import asdict, dataclass
26-
from typing import Literal
26+
from typing import Any, Iterable, Literal
2727

2828
from opentelemetry._events import Event
2929
from opentelemetry.semconv._incubating.attributes import gen_ai_attributes
@@ -96,6 +96,33 @@ def system_event(
9696
)
9797

9898

99+
def tool_event(
100+
*,
101+
role: str | None,
102+
id_: str,
103+
content: AnyValue = None,
104+
) -> Event:
105+
"""Creates a Tool message event
106+
https://github.com/open-telemetry/semantic-conventions/blob/v1.28.0/docs/gen-ai/gen-ai-events.md#event-gen_aitoolmessage
107+
"""
108+
if not role:
109+
role = "tool"
110+
111+
body: dict[str, AnyValue] = {
112+
"role": role,
113+
"id": id_,
114+
}
115+
if content is not None:
116+
body["content"] = content
117+
return Event(
118+
name="gen_ai.tool.message",
119+
attributes={
120+
gen_ai_attributes.GEN_AI_SYSTEM: gen_ai_attributes.GenAiSystemValues.VERTEX_AI.value,
121+
},
122+
body=body,
123+
)
124+
125+
99126
@dataclass
100127
class ChoiceMessage:
101128
"""The message field for a gen_ai.choice event"""
@@ -104,36 +131,58 @@ class ChoiceMessage:
104131
role: str = "assistant"
105132

106133

134+
@dataclass
135+
class ChoiceToolCall:
136+
"""The tool_calls field for a gen_ai.choice event"""
137+
138+
@dataclass
139+
class Function:
140+
name: str
141+
arguments: AnyValue = None
142+
143+
function: Function
144+
id: str
145+
type: Literal["function"] = "function"
146+
147+
107148
FinishReason = Literal[
108149
"content_filter", "error", "length", "stop", "tool_calls"
109150
]
110151

111152

112-
# TODO add tool calls
113-
# https://github.com/open-telemetry/opentelemetry-python-contrib/issues/3216
114153
def choice_event(
115154
*,
116155
finish_reason: FinishReason | str,
117156
index: int,
118157
message: ChoiceMessage,
158+
tool_calls: Iterable[ChoiceToolCall] = (),
119159
) -> Event:
120160
"""Creates a choice event, which describes the Gen AI response message.
121161
https://github.com/open-telemetry/semantic-conventions/blob/v1.28.0/docs/gen-ai/gen-ai-events.md#event-gen_aichoice
122162
"""
123163
body: dict[str, AnyValue] = {
124164
"finish_reason": finish_reason,
125165
"index": index,
126-
"message": asdict(
127-
message,
128-
# filter nulls
129-
dict_factory=lambda kvs: {k: v for (k, v) in kvs if v is not None},
130-
),
166+
"message": _asdict_filter_nulls(message),
131167
}
132168

169+
tool_calls_list = [
170+
_asdict_filter_nulls(tool_call) for tool_call in tool_calls
171+
]
172+
if tool_calls_list:
173+
body["tool_calls"] = tool_calls_list
174+
133175
return Event(
134176
name="gen_ai.choice",
135177
attributes={
136178
gen_ai_attributes.GEN_AI_SYSTEM: gen_ai_attributes.GenAiSystemValues.VERTEX_AI.value,
137179
},
138180
body=body,
139181
)
182+
183+
184+
def _asdict_filter_nulls(instance: Any) -> dict[str, AnyValue]:
185+
return asdict(
186+
instance,
187+
dict_factory=lambda kvs: {k: v for (k, v) in kvs if v is not None},
188+
)

Diff for: instrumentation-genai/opentelemetry-instrumentation-vertexai/src/opentelemetry/instrumentation/vertexai/utils.py

+65-5
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,17 @@
2626
)
2727
from urllib.parse import urlparse
2828

29+
from google.protobuf import json_format
30+
2931
from opentelemetry._events import Event
3032
from opentelemetry.instrumentation.vertexai.events import (
3133
ChoiceMessage,
34+
ChoiceToolCall,
3235
FinishReason,
3336
assistant_event,
3437
choice_event,
3538
system_event,
39+
tool_event,
3640
user_event,
3741
)
3842
from opentelemetry.semconv._incubating.attributes import (
@@ -219,12 +223,37 @@ def request_to_events(
219223
)
220224

221225
yield assistant_event(role=content.role, content=request_content)
222-
# Assume user event but role should be "user"
223-
else:
224-
request_content = _parts_to_any_value(
225-
capture_content=capture_content, parts=content.parts
226+
continue
227+
228+
# Tool event
229+
#
230+
# Function call results can be parts inside of a user Content or in a separate Content
231+
# entry without a role. That may cause duplication in a user event, see
232+
# https://github.com/open-telemetry/semantic-conventions/issues/1883
233+
function_responses = [
234+
part.function_response
235+
for part in content.parts
236+
if "function_response" in part
237+
]
238+
for idx, function_response in enumerate(function_responses):
239+
yield tool_event(
240+
id_=f"{function_response.name}_{idx}",
241+
role=content.role,
242+
content=json_format.MessageToDict(
243+
function_response._pb.response # type: ignore[reportUnknownMemberType]
244+
)
245+
if capture_content
246+
else None,
226247
)
227-
yield user_event(role=content.role, content=request_content)
248+
249+
if len(function_responses) == len(content.parts):
250+
# If the content only contained function responses, don't emit a user event
251+
continue
252+
253+
request_content = _parts_to_any_value(
254+
capture_content=capture_content, parts=content.parts
255+
)
256+
yield user_event(role=content.role, content=request_content)
228257

229258

230259
def response_to_events(
@@ -234,6 +263,12 @@ def response_to_events(
234263
capture_content: bool,
235264
) -> Iterable[Event]:
236265
for candidate in response.candidates:
266+
tool_calls = _extract_tool_calls(
267+
candidate=candidate, capture_content=capture_content
268+
)
269+
270+
# The original function_call Part is still duplicated in message, see
271+
# https://github.com/open-telemetry/semantic-conventions/issues/1883
237272
yield choice_event(
238273
finish_reason=_map_finish_reason(candidate.finish_reason),
239274
index=candidate.index,
@@ -245,6 +280,31 @@ def response_to_events(
245280
parts=candidate.content.parts,
246281
),
247282
),
283+
tool_calls=tool_calls,
284+
)
285+
286+
287+
def _extract_tool_calls(
288+
*,
289+
candidate: content.Candidate | content_v1beta1.Candidate,
290+
capture_content: bool,
291+
) -> Iterable[ChoiceToolCall]:
292+
for idx, part in enumerate(candidate.content.parts):
293+
if "function_call" not in part:
294+
continue
295+
296+
yield ChoiceToolCall(
297+
# Make up an id with index since vertex expects the indices to line up instead of
298+
# using ids.
299+
id=f"{part.function_call.name}_{idx}",
300+
function=ChoiceToolCall.Function(
301+
name=part.function_call.name,
302+
arguments=json_format.MessageToDict(
303+
part.function_call._pb.args # type: ignore[reportUnknownMemberType]
304+
)
305+
if capture_content
306+
else None,
307+
),
248308
)
249309

250310

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
interactions:
2+
- request:
3+
body: |-
4+
{
5+
"contents": [
6+
{
7+
"role": "user",
8+
"parts": [
9+
{
10+
"text": "Get weather details in New Delhi and San Francisco?"
11+
}
12+
]
13+
}
14+
],
15+
"tools": [
16+
{
17+
"functionDeclarations": [
18+
{
19+
"name": "get_current_weather",
20+
"description": "Get the current weather in a given location",
21+
"parameters": {
22+
"type": 6,
23+
"properties": {
24+
"location": {
25+
"type": 1,
26+
"description": "The location for which to get the weather. It can be a city name, a city name and state, or a zip code. Examples: 'San Francisco', 'San Francisco, CA', '95616', etc."
27+
}
28+
},
29+
"propertyOrdering": [
30+
"location"
31+
]
32+
}
33+
}
34+
]
35+
}
36+
]
37+
}
38+
headers:
39+
Accept:
40+
- '*/*'
41+
Accept-Encoding:
42+
- gzip, deflate
43+
Connection:
44+
- keep-alive
45+
Content-Length:
46+
- '824'
47+
Content-Type:
48+
- application/json
49+
User-Agent:
50+
- python-requests/2.32.3
51+
method: POST
52+
uri: https://us-central1-aiplatform.googleapis.com/v1/projects/fake-project/locations/us-central1/publishers/google/models/gemini-1.5-flash-002:generateContent?%24alt=json%3Benum-encoding%3Dint
53+
response:
54+
body:
55+
string: |-
56+
{
57+
"candidates": [
58+
{
59+
"content": {
60+
"role": "model",
61+
"parts": [
62+
{
63+
"functionCall": {
64+
"name": "get_current_weather",
65+
"args": {
66+
"location": "New Delhi"
67+
}
68+
}
69+
},
70+
{
71+
"functionCall": {
72+
"name": "get_current_weather",
73+
"args": {
74+
"location": "San Francisco"
75+
}
76+
}
77+
}
78+
]
79+
},
80+
"finishReason": 1,
81+
"avgLogprobs": -0.00018152029952034354
82+
}
83+
],
84+
"usageMetadata": {
85+
"promptTokenCount": 72,
86+
"candidatesTokenCount": 16,
87+
"totalTokenCount": 88,
88+
"promptTokensDetails": [
89+
{
90+
"modality": 1,
91+
"tokenCount": 72
92+
}
93+
],
94+
"candidatesTokensDetails": [
95+
{
96+
"modality": 1,
97+
"tokenCount": 16
98+
}
99+
]
100+
},
101+
"modelVersion": "gemini-1.5-flash-002",
102+
"createTime": "2025-02-06T04:26:30.610859Z",
103+
"responseId": "9jmkZ6ukJb382PgPrp7zsQw"
104+
}
105+
headers:
106+
Content-Type:
107+
- application/json; charset=UTF-8
108+
Transfer-Encoding:
109+
- chunked
110+
Vary:
111+
- Origin
112+
- X-Origin
113+
- Referer
114+
content-length:
115+
- '1029'
116+
status:
117+
code: 200
118+
message: OK
119+
version: 1

0 commit comments

Comments
 (0)