Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
99e3281
fix(google-genai): migrate span attrs to gen_ai.input/output.messages
abhyudayareddy Apr 6, 2026
7b328ac
test(google-genai): update assertions to use gen_ai.input/output.mess…
abhyudayareddy Apr 6, 2026
4862ab3
fix(google-genai): use actual finish_reason from candidates; omit whe…
abhyudayareddy Apr 8, 2026
e6f86a8
test(google-genai): strengthen assertions to validate parts structure…
abhyudayareddy Apr 8, 2026
08f2f74
test: assert deprecated gen_ai.prompt.* / gen_ai.completion.* attrs a…
abhyudayareddy Apr 8, 2026
56f5e51
fix(google-genai): handle single Content obj and distinguish list[Con…
abhyudayareddy Apr 8, 2026
6dc73dc
Update span_utils.py
abhyudayareddy Apr 8, 2026
e1f2f5c
Merge branch 'main' into fix/google-genai-span-attr-migration
abhyudayareddy Apr 11, 2026
78acebc
fix(google-genai): use _map_gemini_finish_reason() for OTel-compliant…
abhyudayareddy Apr 14, 2026
d0053a9
fix(google-genai): fix parts_acc NameError and undefined _parts_from_…
abhyudayareddy Apr 15, 2026
4405529
fix(google-genai): align test token attr to GEN_AI_USAGE_TOTAL_TOKENS…
abhyudayareddy Apr 15, 2026
2fc3bd0
fix(google-genai): normalize message roles via _normalize_message_rol…
abhyudayareddy Apr 15, 2026
3296ff3
fix(google-generativeai): fix SyntaxError, NameError, wrong iteration…
abhyudayareddy Apr 26, 2026
27fdf93
fix(google-generativeai): resolve remaining NameErrors, missing helpe…
abhyudayareddy Apr 26, 2026
4f7d9a4
Merge branch 'main' into fix/google-genai-span-attr-migration
abhyudayareddy May 5, 2026
6c7f80a
Merge branch 'main' into fix/google-genai-span-attr-migration
abhyudayareddy Aug 16, 2026
3317023
fix(google-genai): preserve gen_ai.response.id during semconv migration
abhyudayareddy Aug 29, 2026
bcdcefd
test(google-genai): fix list-of-strings input expectation
abhyudayareddy Aug 29, 2026
15f312c
Merge branch 'main' into fix/google-genai-span-attr-migration
abhyudayareddy Nov 5, 2026
021cedc
fix(google-genai): group contents into turns like google.genai t_cont…
abhyudayareddy Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,89 @@ def _process_argument_sync(argument, span):
return [{"type": "text", "content": str(argument)}]


def _is_content_like(item):
"""True for a Content (or Content dict) turn, as opposed to a bare part."""
if isinstance(item, dict):
return "parts" in item or "role" in item
return not isinstance(item, str) and hasattr(item, "parts")


def _is_model_part(item):
if isinstance(item, dict):
return "functionCall" in item or "function_call" in item
return bool(getattr(item, "function_call", None))


def _group_contents(contents):
"""Split ``contents`` into turns the way google.genai's ``t_contents`` does.

A single (non-list) value is one turn. In a list, every Content is its own
turn, while consecutive bare parts (strings, Parts, images) are grouped into
one turn: ``user``, or ``model`` for a run of function-call parts.
Returns ``(role, content_or_None, parts)`` tuples.
"""
if not isinstance(contents, list):
contents = [contents]
turns = []
pending = []
pending_is_model = False
for item in contents:
if _is_content_like(item):
if pending:
turns.append(("model" if pending_is_model else "user", None, pending))
pending = []
role = item.get("role") if isinstance(item, dict) else getattr(item, "role", None)
turns.append((role or "user", item, None))
continue
is_model = _is_model_part(item)
if pending and is_model != pending_is_model:
turns.append(("model" if pending_is_model else "user", None, pending))
pending = []
pending_is_model = is_model
pending.append(item)
if pending:
turns.append(("model" if pending_is_model else "user", None, pending))
return turns


async def _input_messages_from_contents(contents, span):
messages = []
for role, content, bare_parts in _group_contents(contents):
if content is not None:
parts = await _process_content_item(content, span)
else:
parts = []
for idx, item in enumerate(bare_parts):
if isinstance(item, str):
parts.append({"type": "text", "content": item})
elif isinstance(item, dict):
parts.append(_map_dict_part(item))
else:
parts.extend(await _parts_from_genai_part_async(item, span, idx))
if parts:
messages.append({"role": _normalize_message_role(role), "parts": parts})
return messages


def _input_messages_from_contents_sync(contents, span):
messages = []
for role, content, bare_parts in _group_contents(contents):
if content is not None:
parts = _process_content_item_sync(content, span)
else:
parts = []
for idx, item in enumerate(bare_parts):
if isinstance(item, str):
parts.append({"type": "text", "content": item})
elif isinstance(item, dict):
parts.append(_map_dict_part(item))
else:
parts.extend(_parts_from_genai_part_sync(item, span, idx))
if parts:
messages.append({"role": _normalize_message_role(role), "parts": parts})
return messages


def _collect_finish_reasons_from_response(response):
reasons = []
candidates = getattr(response, "candidates", None) or []
Expand Down Expand Up @@ -455,19 +538,8 @@ async def set_input_attributes(span, args, kwargs, llm_model):
"parts": [{"type": "text", "content": contents}],
}
)
elif isinstance(contents, list):
for content_item in contents:
parts = await _process_content_item(content_item, span)
if parts:
messages.append(
{
"role": _normalize_message_role(
content_item.get("role", "user") if isinstance(content_item, dict)
else getattr(content_item, "role", "user")
),
"parts": parts,
}
)
elif contents is not None:
messages.extend(await _input_messages_from_contents(contents, span))
elif args and len(args) > 0:
for argument in args:
parts = await _process_argument(argument, span)
Expand Down Expand Up @@ -506,19 +578,8 @@ def set_input_attributes_sync(span, args, kwargs, llm_model):
"parts": [{"type": "text", "content": contents}],
}
)
elif isinstance(contents, list):
for content in contents:
parts = _process_content_item_sync(content, span)
if parts:
messages.append(
{
"role": _normalize_message_role(
content.get("role", "user") if isinstance(content, dict)
else getattr(content, "role", "user")
),
"parts": parts,
}
)
elif contents is not None:
messages.extend(_input_messages_from_contents_sync(contents, span))
elif args and len(args) > 0:
for arg in args:
parts = _process_argument_sync(arg, span)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,73 @@ async def test_list_of_strings(self, monkeypatch):
span, (), {"contents": ["first", "second"]}, "gemini-pro"
)

# A bare list of strings is one user turn with several parts, which is
# how google.genai's t_contents sends it.
messages = _recorded_input_messages(span)
assert messages is not None
assert len(messages) == 2
assert messages[0]["parts"][0]["content"] == "first"
assert messages[1]["parts"][0]["content"] == "second"
assert messages == [
{
"role": "user",
"parts": [
{"type": "text", "content": "first"},
{"type": "text", "content": "second"},
],
}
]

@pytest.mark.asyncio
async def test_single_content_object(self, monkeypatch):
"""A Content passed directly (not in a list) is one turn."""
from google.genai import types

monkeypatch.setattr(su, "should_send_prompts", lambda: True)
span = self._make_span()

content = types.Content(role="user", parts=[types.Part(text="Hi there")])
await su.set_input_attributes(span, (), {"contents": content}, "gemini-pro")

messages = _recorded_input_messages(span)
assert messages == [
{"role": "user", "parts": [{"type": "text", "content": "Hi there"}]}
]

@pytest.mark.asyncio
async def test_bare_parts_grouped_between_contents(self, monkeypatch):
"""Bare parts are grouped into turns; function calls form a model turn."""
from google.genai import types

monkeypatch.setattr(su, "should_send_prompts", lambda: True)
span = self._make_span()

contents = [
"What is the weather?",
types.Part(text="In Paris."),
types.Part(
function_call=types.FunctionCall(
id="c1", name="get_weather", args={"city": "Paris"}
)
),
types.Content(
role="user",
parts=[
types.Part(
function_response=types.FunctionResponse(
id="c1", name="get_weather", response={"temp": 20}
)
)
],
),
]
await su.set_input_attributes(span, (), {"contents": contents}, "gemini-pro")

messages = _recorded_input_messages(span)
assert [m["role"] for m in messages] == ["user", "assistant", "user"]
assert [p["content"] for p in messages[0]["parts"]] == [
"What is the weather?",
"In Paris.",
]
assert messages[1]["parts"][0]["type"] == "tool_call"
assert messages[1]["parts"][0]["name"] == "get_weather"
assert messages[2]["parts"][0]["type"] == "tool_call_response"

@pytest.mark.asyncio
async def test_prompt_kwarg(self, monkeypatch):
Expand Down Expand Up @@ -207,6 +269,27 @@ async def test_list_contents_match(self, monkeypatch):
async_messages = _recorded_input_messages(span_async)
assert sync_messages == async_messages

@pytest.mark.asyncio
async def test_single_content_object_match(self, monkeypatch):
from google.genai import types

monkeypatch.setattr(su, "should_send_prompts", lambda: True)
contents = types.Content(role="user", parts=[types.Part(text="Hi")])

span_sync = MagicMock()
span_sync.is_recording.return_value = True
su.set_input_attributes_sync(span_sync, (), {"contents": contents}, "gemini-pro")

span_async = MagicMock()
span_async.is_recording.return_value = True
await su.set_input_attributes(
span_async, (), {"contents": contents}, "gemini-pro"
)

sync_messages = _recorded_input_messages(span_sync)
assert sync_messages is not None
assert sync_messages == _recorded_input_messages(span_async)

@pytest.mark.asyncio
async def test_prompt_kwarg_match(self, monkeypatch):
monkeypatch.setattr(su, "should_send_prompts", lambda: True)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,12 @@ def test_client_spans(exporter, genai_client):
assert output_messages[0]["parts"][0]["type"] == "text"
assert "content" in output_messages[0]["parts"][0]

# The deprecated indexed attributes must not be emitted alongside them
assert not any(key.startswith("gen_ai.prompt.") for key in attrs)
assert not any(key.startswith("gen_ai.completion.") for key in attrs)

assert attrs[GenAIAttributes.GEN_AI_RESPONSE_ID] == "-hk4afOSMZKkjuMPnJWGkAk"

assert GenAIAttributes.GEN_AI_RESPONSE_FINISH_REASONS in attrs
finish_reasons = attrs[GenAIAttributes.GEN_AI_RESPONSE_FINISH_REASONS]
assert isinstance(finish_reasons, (list, tuple))
Expand Down