Skip to content

Commit 008479a

Browse files
committed
fix(vertexai): normalize content message roles
1 parent c2d2cd3 commit 008479a

5 files changed

Lines changed: 97 additions & 30 deletions

File tree

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

Lines changed: 9 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@
2424

2525
from opentelemetry.instrumentation.vertexai.utils import (
2626
GenerateContentParams,
27-
_map_finish_reason,
27+
convert_candidate_to_output_message,
28+
convert_content_to_input_message,
2829
convert_content_to_message_parts,
2930
extract_tool_definitions,
3031
get_genai_request_attributes,
@@ -125,12 +126,7 @@ def _build_invocation(
125126
)
126127
if params.contents:
127128
for c in params.contents:
128-
input_messages.append(
129-
InputMessage(
130-
role=c.role or "user",
131-
parts=convert_content_to_message_parts(c),
132-
)
133-
)
129+
input_messages.append(convert_content_to_input_message(c))
134130

135131
# Tool definitions are request metadata, not message content.
136132
request_functions = extract_tool_definitions(params.tools)
@@ -206,19 +202,13 @@ def _apply_response_to_invocation(
206202
finish_reasons = []
207203
output_messages: list[OutputMessage] = []
208204
for candidate in response.candidates:
209-
# Vertex AI has no TOOL_CALLS finish reason; STOP is returned even for function calls.
210-
fr = _map_finish_reason(candidate.finish_reason)
211-
parts = []
212-
if capture_content:
213-
parts = convert_content_to_message_parts(candidate.content)
214-
finish_reasons.append(fr)
215-
output_messages.append(
216-
OutputMessage(
217-
role=getattr(candidate.content, "role", None) or "model",
218-
parts=parts,
219-
finish_reason=fr,
220-
)
205+
output_message = convert_candidate_to_output_message(
206+
candidate,
207+
capture_content=capture_content,
221208
)
209+
fr = output_message.finish_reason
210+
finish_reasons.append(fr)
211+
output_messages.append(output_message)
222212

223213
invocation.response_finish_reasons = finish_reasons
224214
invocation.output_messages = output_messages

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,9 @@
3636
from opentelemetry.util.genai.types import (
3737
ContentCapturingMode,
3838
FinishReason,
39+
InputMessage,
3940
MessagePart,
41+
OutputMessage,
4042
Text,
4143
ToolCall,
4244
ToolCallResponse,
@@ -219,6 +221,53 @@ def convert_content_to_message_parts(
219221
return parts
220222

221223

224+
def convert_content_to_input_message(
225+
content: content.Content | content_v1beta1.Content,
226+
) -> InputMessage:
227+
"""Convert Vertex AI Content proto to a normalized util-genai InputMessage."""
228+
parts = convert_content_to_message_parts(content)
229+
return InputMessage(
230+
role=_normalize_content_role(getattr(content, "role", None), parts),
231+
parts=parts,
232+
)
233+
234+
235+
def convert_candidate_to_output_message(
236+
candidate: content.Candidate | content_v1beta1.Candidate,
237+
*,
238+
capture_content: bool,
239+
) -> OutputMessage:
240+
"""Convert a Vertex AI candidate to a normalized util-genai OutputMessage."""
241+
parts = (
242+
convert_content_to_message_parts(candidate.content)
243+
if capture_content
244+
else []
245+
)
246+
return OutputMessage(
247+
role=_normalize_content_role(
248+
getattr(candidate.content, "role", None), parts
249+
),
250+
parts=parts,
251+
finish_reason=_map_finish_reason(candidate.finish_reason),
252+
)
253+
254+
255+
def _normalize_content_role(
256+
role: str | None,
257+
parts: Sequence[MessagePart],
258+
) -> str:
259+
"""Map Vertex AI provider roles to OTel GenAI message roles."""
260+
if role == "model":
261+
return "assistant"
262+
if (
263+
role == "user"
264+
and parts
265+
and all(isinstance(part, ToolCallResponse) for part in parts)
266+
):
267+
return "tool"
268+
return role or "user"
269+
270+
222271
def _map_finish_reason(
223272
finish_reason: content.Candidate.FinishReason
224273
| content_v1beta1.Candidate.FinishReason

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ def test_generate_content(
8686
output_msgs = json.loads(attrs["gen_ai.output.messages"])
8787
assert output_msgs == [
8888
{
89-
"role": "model",
89+
"role": "assistant",
9090
"parts": [{"type": "text", "content": "This is a test."}],
9191
"finish_reason": "stop",
9292
}
@@ -104,7 +104,7 @@ def test_generate_content(
104104
]
105105
assert body["gen_ai.output.messages"] == [
106106
{
107-
"role": "model",
107+
"role": "assistant",
108108
"parts": [{"type": "text", "content": "This is a test."}],
109109
"finish_reason": "stop",
110110
}
@@ -384,7 +384,7 @@ def generate_content_all_input_messages(
384384
assert input_msgs[0]["parts"] == [
385385
{"type": "text", "content": "My name is OpenTelemetry"}
386386
]
387-
assert input_msgs[1]["role"] == "model"
387+
assert input_msgs[1]["role"] == "assistant"
388388
assert input_msgs[1]["parts"] == [
389389
{"type": "text", "content": "Hello OpenTelemetry!"}
390390
]
@@ -400,7 +400,7 @@ def generate_content_all_input_messages(
400400
assert "gen_ai.output.messages" in attrs
401401
output_msgs = json.loads(attrs["gen_ai.output.messages"])
402402
assert len(output_msgs) == 1
403-
assert output_msgs[0]["role"] == "model"
403+
assert output_msgs[0]["role"] == "assistant"
404404
assert output_msgs[0]["parts"] == [
405405
{"type": "text", "content": "OpenTelemetry, this is a test."}
406406
]

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

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ def test_function_call_choice(
7676
assert "gen_ai.output.messages" in attrs
7777
output_msgs = json.loads(attrs["gen_ai.output.messages"])
7878
assert len(output_msgs) == 1
79-
assert output_msgs[0]["role"] == "model"
79+
assert output_msgs[0]["role"] == "assistant"
8080
assert output_msgs[0]["finish_reason"] == "stop"
8181
assert len(output_msgs[0]["parts"]) == 2
8282
assert output_msgs[0]["parts"][0]["type"] == "tool_call"
@@ -163,7 +163,7 @@ def test_tool_events(
163163
)
164164
assert "gen_ai.request.function.0.parameters" in attrs
165165

166-
# Content on span: user text, model function_call, user tool responses, model text response
166+
# Content on span: user text, assistant function_call, tool responses, assistant text response
167167
assert "gen_ai.input.messages" in attrs
168168
input_msgs = json.loads(attrs["gen_ai.input.messages"])
169169
assert len(input_msgs) == 3
@@ -175,8 +175,8 @@ def test_tool_events(
175175
"content": "Get weather details in New Delhi and San Francisco?",
176176
}
177177
]
178-
# Second message: model with function_call parts now mapped to ToolCall
179-
assert input_msgs[1]["role"] == "model"
178+
# Second message: assistant with function_call parts now mapped to ToolCall
179+
assert input_msgs[1]["role"] == "assistant"
180180
assert len(input_msgs[1]["parts"]) == 2
181181
assert input_msgs[1]["parts"][0]["type"] == "tool_call"
182182
assert input_msgs[1]["parts"][0]["name"] == "get_current_weather"
@@ -186,8 +186,8 @@ def test_tool_events(
186186
assert input_msgs[1]["parts"][1]["arguments"] == {
187187
"location": "San Francisco"
188188
}
189-
# Third message: user with tool call responses
190-
assert input_msgs[2]["role"] == "user"
189+
# Third message: tool with tool call responses
190+
assert input_msgs[2]["role"] == "tool"
191191
assert len(input_msgs[2]["parts"]) == 2
192192
assert input_msgs[2]["parts"][0]["type"] == "tool_call_response"
193193
assert input_msgs[2]["parts"][1]["type"] == "tool_call_response"
@@ -196,7 +196,7 @@ def test_tool_events(
196196
assert "gen_ai.output.messages" in attrs
197197
output_msgs = json.loads(attrs["gen_ai.output.messages"])
198198
assert len(output_msgs) == 1
199-
assert output_msgs[0]["role"] == "model"
199+
assert output_msgs[0]["role"] == "assistant"
200200
assert output_msgs[0]["finish_reason"] == "stop"
201201
assert len(output_msgs[0]["parts"]) == 1
202202
assert output_msgs[0]["parts"][0]["type"] == "text"

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323

2424
from opentelemetry.instrumentation.vertexai.utils import (
2525
_map_finish_reason,
26+
convert_content_to_input_message,
2627
convert_content_to_message_parts,
2728
extract_tool_definitions,
2829
get_server_attributes,
@@ -126,6 +127,33 @@ def test_convert_content_mixed_parts():
126127
assert parts[2].response == {"answer": "world"}
127128

128129

130+
def test_convert_content_to_input_message_normalizes_roles():
131+
model_content = content.Content(
132+
{
133+
"role": "model",
134+
"parts": [{"text": "hello"}],
135+
}
136+
)
137+
model_message = convert_content_to_input_message(model_content)
138+
assert model_message.role == "assistant"
139+
140+
tool_content = content.Content(
141+
{
142+
"role": "user",
143+
"parts": [
144+
{
145+
"function_response": {
146+
"name": "search",
147+
"response": {"answer": "world"},
148+
}
149+
}
150+
],
151+
}
152+
)
153+
tool_message = convert_content_to_input_message(tool_content)
154+
assert tool_message.role == "tool"
155+
156+
129157
def test_extract_tool_definitions():
130158
"""extract_tool_definitions converts Tool protos to dicts."""
131159
t = tool.Tool(

0 commit comments

Comments
 (0)