Skip to content

Commit dcd5cb0

Browse files
feat(llamaindex): Instrumentation adjustment for Otel GenAI semconv support (#3979)
1 parent a9f5e2d commit dcd5cb0

17 files changed

+2757
-261
lines changed
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""Pure functions for building OTel GenAI semconv-compliant message JSON."""
2+
3+
import json
4+
from typing import Any, Dict, List, Optional
5+
6+
# Finish reason mapping: covers OpenAI, Cohere, Anthropic, Google Gemini.
7+
# OTel spec uses "tool_call" (singular) — OpenAI's "tool_calls" (plural) must be mapped.
8+
_FINISH_REASON_MAP = {
9+
# OpenAI
10+
"tool_calls": "tool_call",
11+
"function_call": "tool_call",
12+
# Cohere
13+
"COMPLETE": "stop",
14+
"MAX_TOKENS": "length",
15+
"ERROR": "error",
16+
"ERROR_TOXIC": "content_filter",
17+
# Anthropic
18+
"end_turn": "stop",
19+
"stop_sequence": "stop",
20+
"tool_use": "tool_call",
21+
"max_tokens": "length",
22+
# Google Gemini
23+
"STOP": "stop",
24+
"SAFETY": "content_filter",
25+
"RECITATION": "content_filter",
26+
"BLOCKLIST": "content_filter",
27+
"PROHIBITED_CONTENT": "content_filter",
28+
"SPII": "content_filter",
29+
"FINISH_REASON_UNSPECIFIED": "error",
30+
"OTHER": "error",
31+
}
32+
33+
34+
def map_finish_reason(reason: Optional[str]) -> Optional[str]:
35+
"""Map provider finish_reason to OTel enum value.
36+
37+
Returns None if reason is None or empty (callers for top-level attr should omit).
38+
Returns mapped OTel value or pass-through for unmapped values.
39+
For per-message finish_reason, callers MUST apply fallback:
40+
``map_finish_reason(r) or ""``.
41+
"""
42+
if not reason:
43+
return None
44+
return _FINISH_REASON_MAP.get(reason, reason)
45+
46+
47+
def _parse_arguments(arguments: Any) -> Any:
48+
"""Parse tool call arguments to an object. Best-effort json.loads with fallback."""
49+
if arguments is None:
50+
return None
51+
if isinstance(arguments, dict):
52+
return arguments
53+
if isinstance(arguments, str):
54+
try:
55+
return json.loads(arguments)
56+
except (json.JSONDecodeError, ValueError):
57+
return arguments
58+
return arguments
59+
60+
61+
def _content_to_parts(content: Any) -> List[Dict]:
62+
"""Convert LlamaIndex message content to OTel parts array.
63+
64+
Handles: str/None → single TextPart or empty, list of content blocks → mapped by type.
65+
"""
66+
if content is None:
67+
return []
68+
if isinstance(content, str):
69+
return [{"type": "text", "content": content}] if content else []
70+
if isinstance(content, list):
71+
return [_block_to_part(block) for block in content]
72+
return [{"type": "text", "content": str(content)}]
73+
74+
75+
def _block_to_part(block: Any) -> Dict:
76+
"""Convert a single content block to an OTel part dict."""
77+
if isinstance(block, str):
78+
return {"type": "text", "content": block}
79+
if not isinstance(block, dict):
80+
return {"type": "text", "content": str(block)}
81+
82+
block_type = block.get("type", "")
83+
if block_type == "text":
84+
return {"type": "text", "content": block.get("content", block.get("text", ""))}
85+
if block_type in ("thinking", "reasoning"):
86+
return {"type": "reasoning", "content": block.get("thinking", block.get("content", block.get("text", "")))}
87+
if block_type == "image_url":
88+
url = block.get("image_url", {}).get("url", "")
89+
return {"type": "uri", "modality": "image", "uri": url}
90+
if block_type == "image":
91+
return _image_block_to_part(block)
92+
93+
# Fallback: treat as text if it has recognizable content
94+
if "text" in block:
95+
return {"type": "text", "content": block["text"]}
96+
if "content" in block:
97+
return {"type": "text", "content": str(block["content"])}
98+
return {"type": "text", "content": str(block)}
99+
100+
101+
def _image_block_to_part(block: Dict) -> Dict:
102+
"""Convert an image content block to BlobPart or UriPart."""
103+
source = block.get("source", {})
104+
if source.get("type") == "base64":
105+
return {
106+
"type": "blob",
107+
"modality": "image",
108+
"mime_type": source.get("media_type", ""),
109+
"content": source.get("data", ""),
110+
}
111+
if source.get("type") == "url":
112+
return {"type": "uri", "modality": "image", "uri": source.get("url", "")}
113+
return {"type": "text", "content": str(block)}
114+
115+
116+
def _extract_tool_calls(msg: Any) -> List[Dict]:
117+
"""Extract tool_call parts from a LlamaIndex ChatMessage's additional_kwargs."""
118+
tool_calls = getattr(msg, "additional_kwargs", {}).get("tool_calls") or []
119+
parts = []
120+
for tc in tool_calls:
121+
if not isinstance(tc, dict):
122+
continue
123+
func = tc.get("function", {})
124+
parts.append({
125+
"type": "tool_call",
126+
"id": tc.get("id"),
127+
"name": func.get("name", ""),
128+
"arguments": _parse_arguments(func.get("arguments")),
129+
})
130+
return parts
131+
132+
133+
def build_input_messages(messages: Any) -> List[Dict]:
134+
"""Build OTel-compliant input messages from LlamaIndex ChatMessage list."""
135+
if not messages:
136+
return []
137+
result = []
138+
for msg in messages:
139+
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
140+
parts = _content_to_parts(msg.content)
141+
142+
if role == "assistant":
143+
parts.extend(_extract_tool_calls(msg))
144+
145+
if role == "tool":
146+
parts = _maybe_wrap_tool_response(msg, parts)
147+
148+
result.append({"role": role, "parts": parts})
149+
return result
150+
151+
152+
def _maybe_wrap_tool_response(msg: Any, parts: List[Dict]) -> List[Dict]:
153+
"""Wrap content as tool_call_response for tool-role messages if tool_call_id present."""
154+
tool_call_id = getattr(msg, "additional_kwargs", {}).get("tool_call_id")
155+
if not tool_call_id or not parts:
156+
return parts
157+
response_content = parts[0].get("content", "") if parts else ""
158+
return [{"type": "tool_call_response", "id": tool_call_id, "response": response_content}]
159+
160+
161+
def build_output_message(response_message: Any, finish_reason: Optional[str] = None) -> Dict:
162+
"""Build a single OTel-compliant output message from a LlamaIndex response message."""
163+
role = response_message.role.value if hasattr(response_message.role, "value") else "assistant"
164+
parts = _content_to_parts(response_message.content)
165+
parts.extend(_extract_tool_calls(response_message))
166+
fr = map_finish_reason(finish_reason) or ""
167+
return {"role": role, "parts": parts, "finish_reason": fr}
168+
169+
170+
def build_completion_output_message(text: str, finish_reason: Optional[str] = None) -> Dict:
171+
"""Build output message for text completion responses."""
172+
fr = map_finish_reason(finish_reason) or ""
173+
parts = [{"type": "text", "content": text}] if text else []
174+
return {"role": "assistant", "parts": parts, "finish_reason": fr}
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
"""Utilities for extracting structured data from LlamaIndex raw responses."""
2+
3+
from dataclasses import dataclass
4+
from typing import Any, List, Optional
5+
6+
from ._message_utils import map_finish_reason
7+
8+
# Map LlamaIndex LLM class names to OTel well-known provider values.
9+
_PROVIDER_MAP = {
10+
"OpenAI": "openai",
11+
"AzureOpenAI": "azure.ai.openai",
12+
"Anthropic": "anthropic",
13+
"Cohere": "cohere",
14+
"Groq": "groq",
15+
"MistralAI": "mistral_ai",
16+
"Bedrock": "aws.bedrock",
17+
"Gemini": "gcp.gemini",
18+
"VertexAI": "gcp.vertex_ai",
19+
"DeepSeek": "deepseek",
20+
"Perplexity": "perplexity",
21+
}
22+
23+
24+
@dataclass
25+
class TokenUsage:
26+
input_tokens: Optional[int] = None
27+
output_tokens: Optional[int] = None
28+
total_tokens: Optional[int] = None
29+
30+
31+
def detect_provider_name(instance_or_class_name: Any) -> Optional[str]:
32+
"""Detect OTel provider name from a LlamaIndex LLM instance or class name string.
33+
34+
Returns OTel well-known value if available, otherwise lowercase class name.
35+
Returns None if input is None.
36+
"""
37+
if instance_or_class_name is None:
38+
return None
39+
class_name = (
40+
instance_or_class_name
41+
if isinstance(instance_or_class_name, str)
42+
else instance_or_class_name.__class__.__name__
43+
)
44+
return _PROVIDER_MAP.get(class_name, class_name.lower())
45+
46+
47+
def extract_model_from_raw(raw: Any) -> Optional[str]:
48+
"""Extract model name from raw LLM response (object or dict)."""
49+
if hasattr(raw, "model"):
50+
return raw.model
51+
if isinstance(raw, dict):
52+
return raw.get("model")
53+
return None
54+
55+
56+
def extract_response_id(raw: Any) -> Optional[str]:
57+
"""Extract response ID from raw LLM response (object or dict)."""
58+
if hasattr(raw, "id"):
59+
return raw.id
60+
if isinstance(raw, dict):
61+
return raw.get("id")
62+
return None
63+
64+
65+
def extract_token_usage(raw: Any) -> TokenUsage:
66+
"""Extract token usage from raw response. Handles OpenAI, Cohere, and dict formats."""
67+
usage = _get_nested(raw, "usage")
68+
if usage:
69+
result = _extract_openai_usage(usage)
70+
if result.input_tokens is not None:
71+
return result
72+
73+
meta = _get_nested(raw, "meta")
74+
if meta:
75+
return _extract_cohere_usage(meta)
76+
77+
return TokenUsage()
78+
79+
80+
def _get_nested(obj: Any, key: str) -> Any:
81+
"""Get a nested attribute or dict key from obj."""
82+
val = getattr(obj, key, None)
83+
if val is not None:
84+
return val
85+
if isinstance(obj, dict):
86+
return obj.get(key)
87+
return None
88+
89+
90+
def _extract_openai_usage(usage: Any) -> TokenUsage:
91+
"""Extract tokens from OpenAI-style usage object/dict."""
92+
if hasattr(usage, "completion_tokens"):
93+
return TokenUsage(
94+
input_tokens=usage.prompt_tokens,
95+
output_tokens=usage.completion_tokens,
96+
total_tokens=usage.total_tokens,
97+
)
98+
if isinstance(usage, dict):
99+
return TokenUsage(
100+
input_tokens=usage.get("prompt_tokens"),
101+
output_tokens=usage.get("completion_tokens"),
102+
total_tokens=usage.get("total_tokens"),
103+
)
104+
return TokenUsage()
105+
106+
107+
def _extract_cohere_usage(meta: Any) -> TokenUsage:
108+
"""Extract tokens from Cohere-style meta.tokens or meta.billed_units."""
109+
tokens = _get_nested(meta, "tokens")
110+
if tokens:
111+
inp = _get_int(tokens, "input_tokens")
112+
out = _get_int(tokens, "output_tokens")
113+
if inp is not None:
114+
return TokenUsage(input_tokens=inp, output_tokens=out, total_tokens=_safe_sum(inp, out))
115+
116+
billed = _get_nested(meta, "billed_units")
117+
if billed:
118+
inp = _get_int(billed, "input_tokens")
119+
out = _get_int(billed, "output_tokens")
120+
if inp is not None:
121+
return TokenUsage(input_tokens=inp, output_tokens=out, total_tokens=_safe_sum(inp, out))
122+
123+
return TokenUsage()
124+
125+
126+
def _get_int(obj: Any, key: str) -> Optional[int]:
127+
"""Get an integer attribute or dict key from obj."""
128+
val = getattr(obj, key, None)
129+
if val is None and isinstance(obj, dict):
130+
val = obj.get(key)
131+
return int(val) if val is not None else None
132+
133+
134+
def _safe_sum(a: Optional[int], b: Optional[int]) -> Optional[int]:
135+
if a is not None and b is not None:
136+
return a + b
137+
return None
138+
139+
140+
def extract_finish_reasons(raw: Any) -> List[str]:
141+
"""Extract and map finish reasons from raw LLM response.
142+
143+
Handles OpenAI choices[], Google Gemini candidates[], Anthropic stop_reason,
144+
Cohere finish_reason, and Ollama done_reason.
145+
Returns empty list if no finish reason found.
146+
"""
147+
if raw is None:
148+
return []
149+
150+
# OpenAI format: choices[].finish_reason
151+
choices = _get_nested(raw, "choices")
152+
if choices and isinstance(choices, (list, tuple)):
153+
reasons = _collect_finish_reasons_from_choices(choices)
154+
if reasons:
155+
return reasons
156+
157+
# Google Gemini format: candidates[].finish_reason
158+
candidates = _get_nested(raw, "candidates")
159+
if candidates and isinstance(candidates, (list, tuple)):
160+
reasons = _collect_finish_reasons_from_candidates(candidates)
161+
if reasons:
162+
return reasons
163+
164+
# Anthropic format: stop_reason
165+
stop_reason = _get_nested(raw, "stop_reason")
166+
if stop_reason and isinstance(stop_reason, str):
167+
mapped = map_finish_reason(stop_reason)
168+
if mapped:
169+
return [mapped]
170+
171+
# Cohere / generic: finish_reason (direct attr or in meta)
172+
fr = _get_nested(raw, "finish_reason")
173+
if fr and isinstance(fr, str):
174+
mapped = map_finish_reason(fr)
175+
if mapped:
176+
return [mapped]
177+
178+
# Ollama format: done_reason
179+
done_reason = _get_nested(raw, "done_reason")
180+
if done_reason and isinstance(done_reason, str):
181+
mapped = map_finish_reason(done_reason)
182+
if mapped:
183+
return [mapped]
184+
185+
return []
186+
187+
188+
def _collect_finish_reasons_from_choices(choices: Any) -> List[str]:
189+
"""Collect mapped finish reasons from an OpenAI-style choices array."""
190+
reasons = []
191+
try:
192+
for choice in choices:
193+
fr = getattr(choice, "finish_reason", None)
194+
if fr is None and isinstance(choice, dict):
195+
fr = choice.get("finish_reason")
196+
mapped = map_finish_reason(fr)
197+
if mapped:
198+
reasons.append(mapped)
199+
except (TypeError, StopIteration):
200+
pass
201+
return reasons
202+
203+
204+
def _collect_finish_reasons_from_candidates(candidates: Any) -> List[str]:
205+
"""Collect mapped finish reasons from a Google Gemini-style candidates array."""
206+
reasons = []
207+
try:
208+
for candidate in candidates:
209+
fr = getattr(candidate, "finish_reason", None)
210+
if fr is None and isinstance(candidate, dict):
211+
fr = candidate.get("finish_reason")
212+
# Gemini finish_reason may be an enum; convert to string name
213+
if fr is not None and not isinstance(fr, str):
214+
fr = fr.name if hasattr(fr, "name") else str(fr)
215+
mapped = map_finish_reason(fr)
216+
if mapped:
217+
reasons.append(mapped)
218+
except (TypeError, StopIteration):
219+
pass
220+
return reasons

0 commit comments

Comments
 (0)