Skip to content

Commit 38b0273

Browse files
authored
Use new OpenTelemetry GenAI chat span attribute conventions (#2349)
1 parent d3d3ded commit 38b0273

File tree

5 files changed

+718
-196
lines changed

5 files changed

+718
-196
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Type definitions of OpenTelemetry GenAI spec message parts.
2+
3+
Based on https://github.com/lmolkova/semantic-conventions/blob/eccd1f806e426a32c98271c3ce77585492d26de2/docs/gen-ai/non-normative/models.ipynb
4+
"""
5+
6+
from __future__ import annotations
7+
8+
from typing import Literal
9+
10+
from pydantic import JsonValue
11+
from typing_extensions import NotRequired, TypeAlias, TypedDict
12+
13+
14+
class TextPart(TypedDict):
15+
type: Literal['text']
16+
content: NotRequired[str]
17+
18+
19+
class ToolCallPart(TypedDict):
20+
type: Literal['tool_call']
21+
id: str
22+
name: str
23+
arguments: NotRequired[JsonValue]
24+
25+
26+
class ToolCallResponsePart(TypedDict):
27+
type: Literal['tool_call_response']
28+
id: str
29+
name: str
30+
result: NotRequired[JsonValue]
31+
32+
33+
class MediaUrlPart(TypedDict):
34+
type: Literal['image-url', 'audio-url', 'video-url', 'document-url']
35+
url: NotRequired[str]
36+
37+
38+
class BinaryDataPart(TypedDict):
39+
type: Literal['binary']
40+
media_type: str
41+
content: NotRequired[str]
42+
43+
44+
class ThinkingPart(TypedDict):
45+
type: Literal['thinking']
46+
content: NotRequired[str]
47+
48+
49+
MessagePart: TypeAlias = 'TextPart | ToolCallPart | ToolCallResponsePart | MediaUrlPart | BinaryDataPart | ThinkingPart'
50+
51+
52+
Role = Literal['system', 'user', 'assistant']
53+
54+
55+
class ChatMessage(TypedDict):
56+
role: Role
57+
parts: list[MessagePart]
58+
59+
60+
InputMessages: TypeAlias = list[ChatMessage]
61+
62+
63+
class OutputMessage(ChatMessage):
64+
finish_reason: NotRequired[str]
65+
66+
67+
OutputMessages: TypeAlias = list[OutputMessage]

pydantic_ai_slim/pydantic_ai/messages.py

Lines changed: 92 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from opentelemetry._events import Event # pyright: ignore[reportPrivateImportUsage]
1515
from typing_extensions import TypeAlias, deprecated
1616

17-
from . import _utils
17+
from . import _otel_messages, _utils
1818
from ._utils import (
1919
generate_tool_call_id as _generate_tool_call_id,
2020
now_utc as _now_utc,
@@ -83,6 +83,9 @@ def otel_event(self, settings: InstrumentationSettings) -> Event:
8383
body={'role': 'system', **({'content': self.content} if settings.include_content else {})},
8484
)
8585

86+
def otel_message_parts(self, settings: InstrumentationSettings) -> list[_otel_messages.MessagePart]:
87+
return [_otel_messages.TextPart(type='text', **{'content': self.content} if settings.include_content else {})]
88+
8689
__repr__ = _utils.dataclasses_no_defaults_repr
8790

8891

@@ -505,25 +508,41 @@ class UserPromptPart:
505508
"""Part type identifier, this is available on all parts as a discriminator."""
506509

507510
def otel_event(self, settings: InstrumentationSettings) -> Event:
508-
content: str | list[dict[str, Any] | str] | dict[str, Any]
509-
if isinstance(self.content, str):
510-
content = self.content if settings.include_content else {'kind': 'text'}
511-
else:
512-
content = []
513-
for part in self.content:
514-
if isinstance(part, str):
515-
content.append(part if settings.include_content else {'kind': 'text'})
516-
elif isinstance(part, (ImageUrl, AudioUrl, DocumentUrl, VideoUrl)):
517-
content.append({'kind': part.kind, **({'url': part.url} if settings.include_content else {})})
518-
elif isinstance(part, BinaryContent):
519-
converted_part = {'kind': part.kind, 'media_type': part.media_type}
520-
if settings.include_content and settings.include_binary_content:
521-
converted_part['binary_content'] = base64.b64encode(part.data).decode()
522-
content.append(converted_part)
523-
else:
524-
content.append({'kind': part.kind}) # pragma: no cover
511+
content = [{'kind': part.pop('type'), **part} for part in self.otel_message_parts(settings)]
512+
for part in content:
513+
if part['kind'] == 'binary' and 'content' in part:
514+
part['binary_content'] = part.pop('content')
515+
content = [
516+
part['content'] if part == {'kind': 'text', 'content': part.get('content')} else part for part in content
517+
]
518+
if content in ([{'kind': 'text'}], [self.content]):
519+
content = content[0]
525520
return Event('gen_ai.user.message', body={'content': content, 'role': 'user'})
526521

522+
def otel_message_parts(self, settings: InstrumentationSettings) -> list[_otel_messages.MessagePart]:
523+
parts: list[_otel_messages.MessagePart] = []
524+
content: Sequence[UserContent] = [self.content] if isinstance(self.content, str) else self.content
525+
for part in content:
526+
if isinstance(part, str):
527+
parts.append(
528+
_otel_messages.TextPart(type='text', **({'content': part} if settings.include_content else {}))
529+
)
530+
elif isinstance(part, (ImageUrl, AudioUrl, DocumentUrl, VideoUrl)):
531+
parts.append(
532+
_otel_messages.MediaUrlPart(
533+
type=part.kind,
534+
**{'url': part.url} if settings.include_content else {},
535+
)
536+
)
537+
elif isinstance(part, BinaryContent):
538+
converted_part = _otel_messages.BinaryDataPart(type='binary', media_type=part.media_type)
539+
if settings.include_content and settings.include_binary_content:
540+
converted_part['content'] = base64.b64encode(part.data).decode()
541+
parts.append(converted_part)
542+
else:
543+
parts.append({'type': part.kind}) # pragma: no cover
544+
return parts
545+
527546
__repr__ = _utils.dataclasses_no_defaults_repr
528547

529548

@@ -577,6 +596,18 @@ def otel_event(self, settings: InstrumentationSettings) -> Event:
577596
},
578597
)
579598

599+
def otel_message_parts(self, settings: InstrumentationSettings) -> list[_otel_messages.MessagePart]:
600+
from .models.instrumented import InstrumentedModel
601+
602+
return [
603+
_otel_messages.ToolCallResponsePart(
604+
type='tool_call_response',
605+
id=self.tool_call_id,
606+
name=self.tool_name,
607+
**({'result': InstrumentedModel.serialize_any(self.content)} if settings.include_content else {}),
608+
)
609+
]
610+
580611
def has_content(self) -> bool:
581612
"""Return `True` if the tool return has content."""
582613
return self.content is not None # pragma: no cover
@@ -670,6 +701,19 @@ def otel_event(self, settings: InstrumentationSettings) -> Event:
670701
},
671702
)
672703

704+
def otel_message_parts(self, settings: InstrumentationSettings) -> list[_otel_messages.MessagePart]:
705+
if self.tool_name is None:
706+
return [_otel_messages.TextPart(type='text', content=self.model_response())]
707+
else:
708+
return [
709+
_otel_messages.ToolCallResponsePart(
710+
type='tool_call_response',
711+
id=self.tool_call_id,
712+
name=self.tool_name,
713+
**({'result': self.model_response()} if settings.include_content else {}),
714+
)
715+
]
716+
673717
__repr__ = _utils.dataclasses_no_defaults_repr
674718

675719

@@ -911,6 +955,36 @@ def new_event_body():
911955

912956
return result
913957

958+
def otel_message_parts(self, settings: InstrumentationSettings) -> list[_otel_messages.MessagePart]:
959+
parts: list[_otel_messages.MessagePart] = []
960+
for part in self.parts:
961+
if isinstance(part, TextPart):
962+
parts.append(
963+
_otel_messages.TextPart(
964+
type='text',
965+
**({'content': part.content} if settings.include_content else {}),
966+
)
967+
)
968+
elif isinstance(part, ThinkingPart):
969+
parts.append(
970+
_otel_messages.ThinkingPart(
971+
type='thinking',
972+
**({'content': part.content} if settings.include_content else {}),
973+
)
974+
)
975+
elif isinstance(part, ToolCallPart):
976+
call_part = _otel_messages.ToolCallPart(type='tool_call', id=part.tool_call_id, name=part.tool_name)
977+
if settings.include_content and part.args is not None:
978+
from .models.instrumented import InstrumentedModel
979+
980+
if isinstance(part.args, str):
981+
call_part['arguments'] = part.args
982+
else:
983+
call_part['arguments'] = {k: InstrumentedModel.serialize_any(v) for k, v in part.args.items()}
984+
985+
parts.append(call_part)
986+
return parts
987+
914988
@property
915989
@deprecated('`vendor_details` is deprecated, use `provider_details` instead')
916990
def vendor_details(self) -> dict[str, Any] | None:

0 commit comments

Comments
 (0)