Skip to content

Commit 44a7259

Browse files
committed
Vertex capture tool requests and responses
1 parent bf60da4 commit 44a7259

File tree

8 files changed

+1072
-14
lines changed

8 files changed

+1072
-14
lines changed

instrumentation-genai/opentelemetry-instrumentation-vertexai/CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,5 @@ 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))

instrumentation-genai/opentelemetry-instrumentation-vertexai/src/opentelemetry/instrumentation/vertexai/events.py

Lines changed: 57 additions & 8 deletions
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+
)

instrumentation-genai/opentelemetry-instrumentation-vertexai/src/opentelemetry/instrumentation/vertexai/utils.py

Lines changed: 74 additions & 5 deletions
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 (
@@ -216,12 +220,39 @@ def request_to_events(
216220
)
217221

218222
yield assistant_event(role=content.role, content=request_content)
219-
# Assume user event but role should be "user"
220-
else:
221-
request_content = _parts_to_any_value(
222-
capture_content=capture_content, parts=content.parts
223+
continue
224+
225+
# Tool event.
226+
# NOTE: For VertexAI, tool/function results may actually be additional
227+
# parts inside of a user message or in a separate content entry without a role so. See:
228+
# https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/function-calling
229+
#
230+
# For now, just duplicate the data into separate events. It might be cleaner for
231+
# semconv to emit one event per part instead.
232+
function_responses = [
233+
part.function_response
234+
for part in content.parts
235+
if "function_response" in part
236+
]
237+
for i, function_response in enumerate(function_responses):
238+
yield tool_event(
239+
id_=f"{function_response.name}_{i}",
240+
role=content.role,
241+
content=json_format.MessageToDict(
242+
function_response._pb.response # type: ignore[reportUnknownMemberType]
243+
)
244+
if capture_content
245+
else None,
223246
)
224-
yield user_event(role=content.role, content=request_content)
247+
248+
if len(function_responses) == len(content.parts):
249+
# If the content only contained function responses, don't emit a user event
250+
continue
251+
252+
request_content = _parts_to_any_value(
253+
capture_content=capture_content, parts=content.parts
254+
)
255+
yield user_event(role=content.role, content=request_content)
225256

226257

227258
def response_to_events(
@@ -231,6 +262,19 @@ def response_to_events(
231262
capture_content: bool,
232263
) -> Iterable[Event]:
233264
for candidate in response.candidates:
265+
# NOTE: since function_call appears in content.parts, it will also be in the choice
266+
# event's content. This is different from OpenAI where the tool calls are outside of
267+
# content: https://platform.openai.com/docs/api-reference/chat/object. I would prefer
268+
# not to filter from choice event to keep indexing obvious.
269+
#
270+
# There is similarly a pair of executable_code and
271+
# code_execution_result which are similar to tool call in that the model is asking for
272+
# you to do something rather than generating content:
273+
# https://github.com/googleapis/googleapis/blob/ae87dc8a3830f37d575e2cff577c9b5a4737176b/google/cloud/aiplatform/v1beta1/content.proto#L123-L128
274+
tool_calls = _extract_tool_calls(
275+
candidate=candidate, capture_content=capture_content
276+
)
277+
234278
yield choice_event(
235279
finish_reason=_map_finish_reason(candidate.finish_reason),
236280
index=candidate.index,
@@ -242,6 +286,31 @@ def response_to_events(
242286
parts=candidate.content.parts,
243287
),
244288
),
289+
tool_calls=tool_calls,
290+
)
291+
292+
293+
def _extract_tool_calls(
294+
*,
295+
candidate: content.Candidate | content_v1beta1.Candidate,
296+
capture_content: bool,
297+
) -> Iterable[ChoiceToolCall]:
298+
for i, part in enumerate(candidate.content.parts):
299+
if "function_call" not in part:
300+
continue
301+
302+
yield ChoiceToolCall(
303+
# Make up an id with index since vertex expects the indices to line up instead of
304+
# using ids.
305+
id=f"{part.function_call.name}_{i}",
306+
function=ChoiceToolCall.Function(
307+
name=part.function_call.name,
308+
arguments=json_format.MessageToDict(
309+
part.function_call._pb.args # type: ignore[reportUnknownMemberType]
310+
)
311+
if capture_content
312+
else None,
313+
),
245314
)
246315

247316

Lines changed: 119 additions & 0 deletions
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)