Skip to content

Commit 1d3984d

Browse files
committed
feat(vertexai): add function calling support via ToolCall and ToolCallResponse
- Map function_call parts to ToolCall, function_response to ToolCallResponse - Populate request_functions from tool definitions (always, independent of content capture) - Add tests for function call spans, tool events, and request function attributes
1 parent dc5e3dc commit 1d3984d

5 files changed

Lines changed: 207 additions & 13 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
- Migrate to `TelemetryHandler` from `opentelemetry-util-genai`
1111
- Remove `_StabilityMode` branching, `events.py`, and experimental test files
12+
- Add function calling support via util-genai `ToolCall` and `ToolCallResponse` types
1213

1314
## Version 2.2b0 (2025-12-19)
1415
- Fix overwritten log attributes in vertexai instrumentation

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

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
GenerateContentParams,
2626
_map_finish_reason,
2727
convert_content_to_message_parts,
28+
extract_tool_definitions,
2829
get_genai_request_attributes,
2930
get_server_attributes,
3031
)
@@ -129,6 +130,9 @@ def _build_invocation(
129130
)
130131
)
131132

133+
# Tool definitions are request metadata, not message content.
134+
request_functions = extract_tool_definitions(params.tools)
135+
132136
invocation = LLMInvocation(
133137
request_model=request_attributes.get(
134138
GenAIAttributes.GEN_AI_REQUEST_MODEL, ""
@@ -161,6 +165,7 @@ def _build_invocation(
161165
request_seed=request_attributes.get(
162166
GenAIAttributes.GEN_AI_REQUEST_SEED
163167
),
168+
request_functions=request_functions,
164169
)
165170

166171
# Propagate extra attributes that don't map to LLMInvocation fields
@@ -200,14 +205,15 @@ def _apply_response_to_invocation(
200205
finish_reasons = []
201206
output_messages: list[OutputMessage] = []
202207
for candidate in response.candidates:
208+
# Vertex AI has no TOOL_CALLS finish reason; STOP is returned even for function calls.
203209
fr = _map_finish_reason(candidate.finish_reason)
204-
finish_reasons.append(fr)
205210
parts = []
206211
if capture_content:
207212
parts = convert_content_to_message_parts(candidate.content)
213+
finish_reasons.append(fr)
208214
output_messages.append(
209215
OutputMessage(
210-
role=candidate.content.role or "model",
216+
role=getattr(candidate.content, "role", None) or "model",
211217
parts=parts,
212218
finish_reason=fr,
213219
)

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

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
from dataclasses import dataclass
2222
from typing import (
2323
TYPE_CHECKING,
24+
Any,
2425
Mapping,
2526
Sequence,
2627
)
@@ -37,6 +38,7 @@
3738
FinishReason,
3839
MessagePart,
3940
Text,
41+
ToolCall,
4042
ToolCallResponse,
4143
)
4244
from opentelemetry.util.genai.utils import get_content_capturing_mode
@@ -175,24 +177,32 @@ def convert_content_to_message_parts(
175177
) -> list[MessagePart]:
176178
"""Convert Vertex AI Content proto to a list of util-genai MessagePart objects.
177179
178-
Only Text and ToolCallResponse parts are supported in this version.
179-
Unsupported part types (inline_data, file_data, function_call) are
180-
skipped until the corresponding util-genai types are available (HYBIM-604).
180+
Maps Vertex AI part types to util-genai equivalents:
181+
function_call → ToolCall, function_response → ToolCallResponse, text → Text.
182+
Unsupported types (inline_data, file_data) are skipped (HYBIM-604).
183+
184+
Note: ``._pb`` is used to access the underlying protobuf message because
185+
proto-plus wrappers don't support direct ``MessageToDict`` conversion.
181186
"""
182187
parts: list[MessagePart] = []
183188
for idx, part in enumerate(content.parts):
184189
if "function_response" in part:
185190
part = part.function_response
186191
parts.append(
187192
ToolCallResponse(
188-
id=f"{part.name}_{idx}",
193+
id=f"{part.name}_{idx}", # synthetic (Vertex AI has no call id)
189194
response=json_format.MessageToDict(part._pb.response), # type: ignore[reportUnknownMemberType]
190195
)
191196
)
192197
elif "function_call" in part:
193-
# ToolCallRequest not yet in util-genai (HYBIM-604) — skip
194-
logging.debug(
195-
"function_call part skipped (ToolCallRequest not yet supported)"
198+
fc = part.function_call
199+
args = json_format.MessageToDict(fc._pb.args) if fc.args else {} # type: ignore[reportUnknownMemberType]
200+
parts.append(
201+
ToolCall(
202+
name=fc.name,
203+
arguments=args,
204+
id=f"{fc.name}_{idx}", # synthetic (Vertex AI has no call id)
205+
)
196206
)
197207
elif "text" in part:
198208
parts.append(Text(content=part.text))
@@ -227,3 +237,31 @@ def _map_finish_reason(
227237

228238
# If there is no 1:1 mapping to an OTel preferred enum value, use the exact vertex reason
229239
return finish_reason.name
240+
241+
242+
def extract_tool_definitions(
243+
tools: Sequence[tool.Tool] | Sequence[tool_v1beta1.Tool] | None,
244+
) -> list[dict[str, Any]]:
245+
"""Extract function declarations from Vertex AI Tools into a list of dicts.
246+
247+
Each dict has keys: name, description, parameters (matching the format
248+
used by LLMInvocation.request_functions).
249+
250+
Note: Only ``function_declarations`` are extracted. Other tool types
251+
(Google Search, retrieval, code execution) do not carry function
252+
metadata and are silently skipped.
253+
"""
254+
if not tools:
255+
return []
256+
result: list[dict] = []
257+
for t in tools:
258+
for fd in t.function_declarations:
259+
entry: dict = {"name": fd.name}
260+
if fd.description:
261+
entry["description"] = fd.description
262+
if fd.parameters:
263+
entry["parameters"] = json_format.MessageToDict(
264+
fd.parameters._pb
265+
) # type: ignore[reportUnknownMemberType]
266+
result.append(entry)
267+
return result

instrumentation-genai/opentelemetry-instrumentation-vertexai/tests/test_function_calling.py

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ def test_function_call_choice(
6363
assert attrs["server.address"] == "us-central1-aiplatform.googleapis.com"
6464
assert attrs["server.port"] == 443
6565

66+
# Tool definitions are always emitted (independent of content capture)
67+
assert attrs["gen_ai.request.function.0.name"] == "get_current_weather"
68+
assert (
69+
attrs["gen_ai.request.function.0.description"]
70+
== "Get the current weather in a given location"
71+
)
72+
assert "gen_ai.request.function.0.parameters" in attrs
73+
6674
# Content on span
6775
assert "gen_ai.input.messages" in attrs
6876
input_msgs = json.loads(attrs["gen_ai.input.messages"])
@@ -78,12 +86,21 @@ def test_function_call_choice(
7886
}
7987
]
8088

81-
# Output messages on span - function_call parts are skipped (HYBIM-604)
89+
# Output messages on span function_call parts now appear as ToolCall
8290
assert "gen_ai.output.messages" in attrs
8391
output_msgs = json.loads(attrs["gen_ai.output.messages"])
8492
assert len(output_msgs) == 1
8593
assert output_msgs[0]["role"] == "model"
8694
assert output_msgs[0]["finish_reason"] == "stop"
95+
assert len(output_msgs[0]["parts"]) == 2
96+
assert output_msgs[0]["parts"][0]["type"] == "tool_call"
97+
assert output_msgs[0]["parts"][0]["name"] == "get_current_weather"
98+
assert output_msgs[0]["parts"][0]["arguments"] == {"location": "New Delhi"}
99+
assert output_msgs[0]["parts"][1]["type"] == "tool_call"
100+
assert output_msgs[0]["parts"][1]["name"] == "get_current_weather"
101+
assert output_msgs[0]["parts"][1]["arguments"] == {
102+
"location": "San Francisco"
103+
}
87104

88105
# Content events emitter emits a single event
89106
logs = log_exporter.get_finished_logs()
@@ -108,7 +125,17 @@ def test_function_call_choice_no_content(
108125
attrs = dict(spans[0].attributes)
109126
assert attrs["gen_ai.operation.name"] == "chat"
110127
assert attrs["gen_ai.request.model"] == "gemini-2.5-pro"
128+
assert attrs["gen_ai.response.finish_reasons"] == ("stop",)
111129
assert attrs["gen_ai.provider.name"] == "vertex_ai"
130+
131+
# Tool definitions are always emitted (independent of content capture)
132+
assert attrs["gen_ai.request.function.0.name"] == "get_current_weather"
133+
assert (
134+
attrs["gen_ai.request.function.0.description"]
135+
== "Get the current weather in a given location"
136+
)
137+
assert "gen_ai.request.function.0.parameters" in attrs
138+
112139
assert "gen_ai.input.messages" not in attrs
113140
assert "gen_ai.output.messages" not in attrs
114141

@@ -142,7 +169,15 @@ def test_tool_events(
142169
assert attrs["server.address"] == "us-central1-aiplatform.googleapis.com"
143170
assert attrs["server.port"] == 443
144171

145-
# Content on span: user text, model function_call (skipped), user tool responses, model text response
172+
# Tool definitions are always emitted
173+
assert attrs["gen_ai.request.function.0.name"] == "get_current_weather"
174+
assert (
175+
attrs["gen_ai.request.function.0.description"]
176+
== "Get the current weather in a given location"
177+
)
178+
assert "gen_ai.request.function.0.parameters" in attrs
179+
180+
# Content on span: user text, model function_call, user tool responses, model text response
146181
assert "gen_ai.input.messages" in attrs
147182
input_msgs = json.loads(attrs["gen_ai.input.messages"])
148183
assert len(input_msgs) == 3
@@ -154,9 +189,17 @@ def test_tool_events(
154189
"content": "Get weather details in New Delhi and San Francisco?",
155190
}
156191
]
157-
# Second message: model with function_call parts (skipped by convert_content_to_message_parts)
192+
# Second message: model with function_call parts now mapped to ToolCall
158193
assert input_msgs[1]["role"] == "model"
159-
assert input_msgs[1]["parts"] == []
194+
assert len(input_msgs[1]["parts"]) == 2
195+
assert input_msgs[1]["parts"][0]["type"] == "tool_call"
196+
assert input_msgs[1]["parts"][0]["name"] == "get_current_weather"
197+
assert input_msgs[1]["parts"][0]["arguments"] == {"location": "New Delhi"}
198+
assert input_msgs[1]["parts"][1]["type"] == "tool_call"
199+
assert input_msgs[1]["parts"][1]["name"] == "get_current_weather"
200+
assert input_msgs[1]["parts"][1]["arguments"] == {
201+
"location": "San Francisco"
202+
}
160203
# Third message: user with tool call responses
161204
assert input_msgs[2]["role"] == "user"
162205
assert len(input_msgs[2]["parts"]) == 2
@@ -202,6 +245,17 @@ def test_tool_events_no_content(
202245
assert attrs["gen_ai.usage.output_tokens"] == 22
203246
assert attrs["server.address"] == "us-central1-aiplatform.googleapis.com"
204247
assert attrs["server.port"] == 443
248+
249+
# Tool definitions are always emitted (independent of content capture)
250+
assert attrs["gen_ai.request.function.0.name"] == "get_current_weather"
251+
assert (
252+
attrs["gen_ai.request.function.0.description"]
253+
== "Get the current weather in a given location"
254+
)
255+
assert "gen_ai.request.function.0.parameters" in attrs
256+
257+
# finish_reason stays "stop" because the *response* is a final text
258+
# answer (no function_call parts in the response candidates)
205259
assert "gen_ai.input.messages" not in attrs
206260
assert "gen_ai.output.messages" not in attrs
207261

instrumentation-genai/opentelemetry-instrumentation-vertexai/tests/test_utils.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,16 @@
1515

1616
from google.cloud.aiplatform_v1.types import (
1717
content,
18+
tool,
1819
)
1920
from google.cloud.aiplatform_v1beta1.types import (
2021
content as content_v1beta1,
2122
)
2223

2324
from opentelemetry.instrumentation.vertexai.utils import (
2425
_map_finish_reason,
26+
convert_content_to_message_parts,
27+
extract_tool_definitions,
2528
get_server_attributes,
2629
)
2730

@@ -62,3 +65,95 @@ def test_map_finish_reason():
6265
(Enum.SPII, "SPII"),
6366
]:
6467
assert _map_finish_reason(finish_reason) == expect
68+
69+
70+
def test_convert_content_function_call():
71+
"""function_call parts are mapped to ToolCall message parts."""
72+
c = content.Content(
73+
{
74+
"role": "model",
75+
"parts": [
76+
{
77+
"function_call": {
78+
"name": "get_weather",
79+
"args": {"location": "New Delhi"},
80+
}
81+
}
82+
],
83+
}
84+
)
85+
parts = convert_content_to_message_parts(c)
86+
assert len(parts) == 1
87+
tc = parts[0]
88+
assert tc.type == "tool_call"
89+
assert tc.name == "get_weather"
90+
assert tc.arguments == {"location": "New Delhi"}
91+
assert tc.id == "get_weather_0"
92+
93+
94+
def test_convert_content_mixed_parts():
95+
"""Text, function_call, and function_response parts are all mapped."""
96+
c = content.Content(
97+
{
98+
"role": "model",
99+
"parts": [
100+
{"text": "intro"},
101+
{
102+
"function_call": {
103+
"name": "search",
104+
"args": {"q": "hello"},
105+
}
106+
},
107+
{
108+
"function_response": {
109+
"name": "search",
110+
"response": {"answer": "world"},
111+
}
112+
},
113+
],
114+
}
115+
)
116+
parts = convert_content_to_message_parts(c)
117+
assert len(parts) == 3
118+
assert parts[0].type == "text"
119+
assert parts[0].content == "intro"
120+
assert parts[1].type == "tool_call"
121+
assert parts[1].name == "search"
122+
assert parts[2].type == "tool_call_response"
123+
assert parts[2].response == {"answer": "world"}
124+
125+
126+
def test_extract_tool_definitions():
127+
"""extract_tool_definitions converts Tool protos to dicts."""
128+
t = tool.Tool(
129+
{
130+
"function_declarations": [
131+
{
132+
"name": "get_weather",
133+
"description": "Get weather",
134+
"parameters": {
135+
"type_": "OBJECT",
136+
"properties": {
137+
"loc": {"type_": "STRING"},
138+
},
139+
},
140+
},
141+
{
142+
"name": "get_time",
143+
"description": "Get time",
144+
},
145+
]
146+
}
147+
)
148+
result = extract_tool_definitions([t])
149+
assert len(result) == 2
150+
assert result[0]["name"] == "get_weather"
151+
assert result[0]["description"] == "Get weather"
152+
assert "properties" in result[0]["parameters"]
153+
assert result[1]["name"] == "get_time"
154+
assert result[1]["description"] == "Get time"
155+
156+
157+
def test_extract_tool_definitions_none():
158+
"""extract_tool_definitions returns empty list for None input."""
159+
assert extract_tool_definitions(None) == []

0 commit comments

Comments
 (0)