|
14 | 14 | from opentelemetry._events import Event # pyright: ignore[reportPrivateImportUsage]
|
15 | 15 | from typing_extensions import TypeAlias, deprecated
|
16 | 16 |
|
17 |
| -from . import _utils |
| 17 | +from . import _otel_messages, _utils |
18 | 18 | from ._utils import (
|
19 | 19 | generate_tool_call_id as _generate_tool_call_id,
|
20 | 20 | now_utc as _now_utc,
|
@@ -83,6 +83,9 @@ def otel_event(self, settings: InstrumentationSettings) -> Event:
|
83 | 83 | body={'role': 'system', **({'content': self.content} if settings.include_content else {})},
|
84 | 84 | )
|
85 | 85 |
|
| 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 | + |
86 | 89 | __repr__ = _utils.dataclasses_no_defaults_repr
|
87 | 90 |
|
88 | 91 |
|
@@ -505,25 +508,41 @@ class UserPromptPart:
|
505 | 508 | """Part type identifier, this is available on all parts as a discriminator."""
|
506 | 509 |
|
507 | 510 | 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] |
525 | 520 | return Event('gen_ai.user.message', body={'content': content, 'role': 'user'})
|
526 | 521 |
|
| 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 | + |
527 | 546 | __repr__ = _utils.dataclasses_no_defaults_repr
|
528 | 547 |
|
529 | 548 |
|
@@ -577,6 +596,18 @@ def otel_event(self, settings: InstrumentationSettings) -> Event:
|
577 | 596 | },
|
578 | 597 | )
|
579 | 598 |
|
| 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 | + |
580 | 611 | def has_content(self) -> bool:
|
581 | 612 | """Return `True` if the tool return has content."""
|
582 | 613 | return self.content is not None # pragma: no cover
|
@@ -670,6 +701,19 @@ def otel_event(self, settings: InstrumentationSettings) -> Event:
|
670 | 701 | },
|
671 | 702 | )
|
672 | 703 |
|
| 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 | + |
673 | 717 | __repr__ = _utils.dataclasses_no_defaults_repr
|
674 | 718 |
|
675 | 719 |
|
@@ -911,6 +955,36 @@ def new_event_body():
|
911 | 955 |
|
912 | 956 | return result
|
913 | 957 |
|
| 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 | + |
914 | 988 | @property
|
915 | 989 | @deprecated('`vendor_details` is deprecated, use `provider_details` instead')
|
916 | 990 | def vendor_details(self) -> dict[str, Any] | None:
|
|
0 commit comments