|
31 | 31 | SpeechSpanData,
|
32 | 32 | TranscriptionSpanData,
|
33 | 33 | )
|
34 |
| -from openai.types.responses import ( |
35 |
| - ResponseOutputMessage, |
36 |
| -) |
37 | 34 |
|
38 | 35 | from opentelemetry.context import attach, detach
|
39 | 36 | from opentelemetry.metrics import Histogram, get_meter
|
@@ -579,6 +576,81 @@ def _normalize_messages_to_role_parts(
|
579 | 576 |
|
580 | 577 | return normalized
|
581 | 578 |
|
| 579 | + def _normalize_output_messages_to_role_parts( |
| 580 | + self, span_data: Any |
| 581 | + ) -> list[dict[str, Any]]: |
| 582 | + """Normalize output messages to enforced role+parts schema. |
| 583 | +
|
| 584 | + Produces: [{"role": "assistant", "parts": [{"type": "text", "content": "..."}], |
| 585 | + optional "finish_reason": "..." }] |
| 586 | + """ |
| 587 | + messages: list[dict[str, Any]] = [] |
| 588 | + parts: list[dict[str, Any]] = [] |
| 589 | + finish_reason: Optional[str] = None |
| 590 | + |
| 591 | + # Response span: prefer consolidated output_text |
| 592 | + response = getattr(span_data, "response", None) |
| 593 | + if response is not None: |
| 594 | + # Collect text content |
| 595 | + output_text = getattr(response, "output_text", None) |
| 596 | + if isinstance(output_text, str) and output_text: |
| 597 | + parts.append({"type": "text", "content": output_text}) |
| 598 | + else: |
| 599 | + output = getattr(response, "output", None) |
| 600 | + if isinstance(output, Sequence): |
| 601 | + for item in output: |
| 602 | + # ResponseOutputMessage may have a string representation |
| 603 | + txt = getattr(item, "content", None) |
| 604 | + if isinstance(txt, str) and txt: |
| 605 | + parts.append({"type": "text", "content": txt}) |
| 606 | + else: |
| 607 | + # Fallback: stringified |
| 608 | + parts.append( |
| 609 | + {"type": "text", "content": str(item)} |
| 610 | + ) |
| 611 | + # Capture finish_reason from parts when present |
| 612 | + fr = getattr(item, "finish_reason", None) |
| 613 | + if isinstance(fr, str) and not finish_reason: |
| 614 | + finish_reason = fr |
| 615 | + |
| 616 | + # Generation span: use span_data.output |
| 617 | + if not parts: |
| 618 | + output = getattr(span_data, "output", None) |
| 619 | + if isinstance(output, Sequence): |
| 620 | + for item in output: |
| 621 | + if isinstance(item, dict): |
| 622 | + if item.get("type") == "text": |
| 623 | + txt = item.get("content") or item.get("text") |
| 624 | + if isinstance(txt, str) and txt: |
| 625 | + parts.append({"type": "text", "content": txt}) |
| 626 | + elif "content" in item and isinstance( |
| 627 | + item["content"], str |
| 628 | + ): |
| 629 | + parts.append( |
| 630 | + {"type": "text", "content": item["content"]} |
| 631 | + ) |
| 632 | + else: |
| 633 | + parts.append( |
| 634 | + {"type": "text", "content": str(item)} |
| 635 | + ) |
| 636 | + if not finish_reason and isinstance( |
| 637 | + item.get("finish_reason"), str |
| 638 | + ): |
| 639 | + finish_reason = item.get("finish_reason") |
| 640 | + elif isinstance(item, str): |
| 641 | + parts.append({"type": "text", "content": item}) |
| 642 | + else: |
| 643 | + parts.append({"type": "text", "content": str(item)}) |
| 644 | + |
| 645 | + # Build assistant message |
| 646 | + msg: dict[str, Any] = {"role": "assistant", "parts": parts} |
| 647 | + if finish_reason: |
| 648 | + msg["finish_reason"] = finish_reason |
| 649 | + # Only include if there is content |
| 650 | + if parts: |
| 651 | + messages.append(msg) |
| 652 | + return messages |
| 653 | + |
582 | 654 | def _infer_output_type(self, span_data: Any) -> str:
|
583 | 655 | """Infer gen_ai.output.type for multiple span kinds."""
|
584 | 656 | if isinstance(span_data, FunctionSpanData):
|
@@ -960,9 +1032,18 @@ def _get_attributes_from_generation_span_data(
|
960 | 1032 | safe_json_dumps(sys_instr),
|
961 | 1033 | )
|
962 | 1034 |
|
963 |
| - # Output messages (leave as-is; not normalized here) |
964 |
| - if self._capture_messages and span_data.output: |
965 |
| - yield GEN_AI_OUTPUT_MESSAGES, safe_json_dumps(span_data.output) |
| 1035 | + # Output messages (normalized to role+parts) |
| 1036 | + if self._capture_messages and ( |
| 1037 | + span_data.output or getattr(span_data, "response", None) |
| 1038 | + ): |
| 1039 | + normalized_out = self._normalize_output_messages_to_role_parts( |
| 1040 | + span_data |
| 1041 | + ) |
| 1042 | + if normalized_out: |
| 1043 | + yield ( |
| 1044 | + GEN_AI_OUTPUT_MESSAGES, |
| 1045 | + safe_json_dumps(normalized_out), |
| 1046 | + ) |
966 | 1047 |
|
967 | 1048 | # Output type
|
968 | 1049 | yield (
|
@@ -1165,27 +1246,17 @@ def _get_attributes_from_response_span_data(
|
1165 | 1246 | safe_json_dumps(sys_instr),
|
1166 | 1247 | )
|
1167 | 1248 |
|
1168 |
| - # Output messages (leave as-is; not normalized here) |
| 1249 | + # Output messages (normalized to role+parts) |
1169 | 1250 | if self._capture_messages:
|
1170 |
| - output_messages = getattr( |
1171 |
| - getattr(span_data, "response", None), "output", None |
| 1251 | + # normalized from span_data response/output |
| 1252 | + normalized_out = self._normalize_output_messages_to_role_parts( |
| 1253 | + span_data |
1172 | 1254 | )
|
1173 |
| - if output_messages: |
1174 |
| - collected = [] |
1175 |
| - for part in output_messages: |
1176 |
| - if isinstance(part, ResponseOutputMessage): |
1177 |
| - content = getattr( |
1178 |
| - getattr(span_data, "response", None), |
1179 |
| - "output_text", |
1180 |
| - None, |
1181 |
| - ) |
1182 |
| - if content: |
1183 |
| - collected.append(content) |
1184 |
| - if collected: |
1185 |
| - yield ( |
1186 |
| - GEN_AI_OUTPUT_MESSAGES, |
1187 |
| - safe_json_dumps(collected), |
1188 |
| - ) |
| 1255 | + if normalized_out: |
| 1256 | + yield ( |
| 1257 | + GEN_AI_OUTPUT_MESSAGES, |
| 1258 | + safe_json_dumps(normalized_out), |
| 1259 | + ) |
1189 | 1260 |
|
1190 | 1261 | yield (
|
1191 | 1262 | GEN_AI_OUTPUT_TYPE,
|
|
0 commit comments