Skip to content

Commit d5339be

Browse files
committed
Vertex capture tool requests and responses
1 parent 231d26c commit d5339be

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 (
@@ -219,12 +223,39 @@ 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+
# NOTE: For VertexAI, tool/function results may actually be additional
230+
# parts inside of a user message or in a separate content entry without a role so. See:
231+
# https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/function-calling
232+
#
233+
# For now, just duplicate the data into separate events. It might be cleaner for
234+
# semconv to emit one event per part instead.
235+
function_responses = [
236+
part.function_response
237+
for part in content.parts
238+
if "function_response" in part
239+
]
240+
for i, function_response in enumerate(function_responses):
241+
yield tool_event(
242+
id_=f"{function_response.name}_{i}",
243+
role=content.role,
244+
content=json_format.MessageToDict(
245+
function_response._pb.response # type: ignore[reportUnknownMemberType]
246+
)
247+
if capture_content
248+
else None,
226249
)
227-
yield user_event(role=content.role, content=request_content)
250+
251+
if len(function_responses) == len(content.parts):
252+
# If the content only contained function responses, don't emit a user event
253+
continue
254+
255+
request_content = _parts_to_any_value(
256+
capture_content=capture_content, parts=content.parts
257+
)
258+
yield user_event(role=content.role, content=request_content)
228259

229260

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

250319

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)