-
Notifications
You must be signed in to change notification settings - Fork 54
feat: add OpenAI Agents SDK support to OpenInference mapper #366
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 10 commits
d9d6a26
489c0fc
bd069c1
6c72f91
f11d964
7c5269f
ccc3086
7de7eb9
dd325f3
d6f18bd
cb15d94
8bf879e
02a76ed
8756547
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ | |
| - openinference.instrumentation.langchain (LangChain / LangGraph) | ||
| - openinference.instrumentation.smolagents (HuggingFace smolagents) | ||
| - openinference.instrumentation.claude_agent_sdk (Claude Agent SDK) | ||
| - openinference.instrumentation.openai_agents (OpenAI Agents SDK) | ||
|
|
||
| Each producer emits spans following the OpenInference semantic conventions but | ||
| with producer-specific encoding differences (e.g. attribute paths for message | ||
|
|
@@ -38,11 +39,12 @@ | |
| from .constants import ( | ||
| SCOPE_OPENINFERENCE, | ||
| SCOPE_OPENINFERENCE_CLAUDE_AGENT_SDK, | ||
| SCOPE_OPENINFERENCE_OPENAI_AGENTS, | ||
| SCOPE_OPENINFERENCE_SMOLAGENTS, | ||
| SCOPES_OPENINFERENCE_FAMILY, | ||
| ) | ||
| from .session_mapper import SessionMapper | ||
| from .utils import safe_json_parse | ||
| from .utils import bridge_parent_gaps, safe_json_parse | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
@@ -67,12 +69,13 @@ class OpenInferenceSessionMapper(SessionMapper): | |
| - openinference-instrumentation-langchain (LangChain / LangGraph) | ||
| - openinference-instrumentation-smolagents (HuggingFace smolagents) | ||
| - openinference-instrumentation-claude-agent-sdk (Claude Agent SDK) | ||
| - openinference-instrumentation-openai-agents (OpenAI Agents SDK) | ||
|
|
||
| Span type identification uses the openinference.span.kind attribute: | ||
| - Inference spans: "LLM" | ||
| - Tool execution spans: "TOOL" | ||
| - Agent invocation spans: "AGENT" (smolagents CodeAgent.run, Claude Agent SDK query) or | ||
| "CHAIN" with name="LangGraph" (LangGraph root graph) | ||
| - Agent invocation spans: "AGENT" (smolagents CodeAgent.run, Claude Agent SDK query, | ||
| OpenAI Agents SDK agent) or "CHAIN" with name="LangGraph" (LangGraph root graph) | ||
|
|
||
| Producer-specific encoding differences (e.g. message attribute paths, | ||
| tool argument wrapping) are normalized before shared conversion logic runs. | ||
|
|
@@ -90,6 +93,8 @@ def __init__(self): | |
| self._trace_tools_map: dict[str, dict[str, ToolConfig]] = defaultdict(dict) | ||
| # Track system prompts per trace | ||
| self._trace_system_prompt_map: dict[str, str] = defaultdict(str) | ||
| # Track span_id -> parent_span_id for ALL raw spans (including non-OpenInference scopes) | ||
| self._raw_parent_map: dict[str, str | None] = {} | ||
| # Cache: span_id -> (input_messages, output_messages) from _get_messages_from_span_events | ||
| # Avoids re-parsing span_events body during detection and again during conversion. | ||
| self._span_messages_cache: dict[str, tuple[list[dict], list[dict]]] = {} | ||
|
|
@@ -118,21 +123,39 @@ def map_to_session(self, data: Any, session_id: str) -> Session: | |
| # Normalize input to flat spans | ||
| spans = self._normalize_to_flat_spans(data) | ||
|
|
||
| # Build parent map from ALL spans | ||
| self._raw_parent_map = {s.get("span_id", ""): s.get("parent_span_id") for s in spans} | ||
|
|
||
| # Filter to only spans from this scope (including smolagents variant) | ||
| openinference_spans = [s for s in spans if self._get_scope_name(s) in SCOPES_OPENINFERENCE_FAMILY] | ||
|
|
||
| # Per-producer normalization: canonicalize encoding differences so the | ||
| # shared conversion logic receives a uniform representation. | ||
| for span in openinference_spans: | ||
| if self._get_scope_name(span) == SCOPE_OPENINFERENCE_SMOLAGENTS: | ||
| self._normalize_smolagents_span(span) | ||
| scope = self._get_scope_name(span) | ||
| try: | ||
| if scope == SCOPE_OPENINFERENCE_SMOLAGENTS: | ||
| self._normalize_smolagents_span(span) | ||
| elif scope == SCOPE_OPENINFERENCE_OPENAI_AGENTS: | ||
| self._normalize_openai_agents_span(span) | ||
| except Exception as e: | ||
| span_id = span.get("span_id", "unknown") | ||
| logger.warning("scope=<%s>, span_id=<%s> | failed to normalize span: %s", scope, span_id, e) | ||
|
|
||
| # Group spans by trace_id | ||
| grouped = defaultdict(list) | ||
| for span in openinference_spans: | ||
| trace_id = span.get("trace_id", "") | ||
| grouped[trace_id].append(span) | ||
|
|
||
| # OpenAI Agents SDK normalization requires the full trace group, so it runs after grouping. | ||
| for trace_spans in grouped.values(): | ||
| if any(self._get_scope_name(s) == SCOPE_OPENINFERENCE_OPENAI_AGENTS for s in trace_spans): | ||
| try: | ||
| self._normalize_openai_agents_trace(trace_spans) | ||
| except Exception as e: | ||
| logger.warning("failed to normalize openai agents trace: %s", e) | ||
|
|
||
| # Build traces | ||
| result_traces: list[Trace] = [] | ||
| for trace_id, trace_spans in grouped.items(): | ||
|
|
@@ -258,6 +281,167 @@ def _normalize_smolagents_tool_attrs(self, attrs: dict) -> None: | |
| # Fallback: no tool.parameters available to map positional args | ||
| attrs["input.value"] = json.dumps({"args": args}) | ||
|
|
||
| @classmethod | ||
| def _normalize_openai_agents_span(cls, span: dict) -> None: | ||
| """Normalize OpenAI Agents spans in-place. | ||
|
|
||
| This normalizes by: | ||
| 1. Unwrapping tool schemas in the format {"type": "function", "function": {...}}. | ||
| 2. Aliasing "parameters" to "input_schema" on tool schemas. | ||
| """ | ||
| attrs = span.get("attributes") or {} | ||
| for idx in cls._get_message_indices(attrs, "llm.tools"): | ||
| key = f"llm.tools.{idx}.tool.json_schema" | ||
| raw = attrs.get(key) | ||
| if isinstance(raw, str): | ||
| try: | ||
| schema = json.loads(raw) | ||
| except json.JSONDecodeError: | ||
| continue | ||
| else: | ||
| schema = raw | ||
| if not isinstance(schema, dict): | ||
| continue | ||
| if "name" not in schema and isinstance(schema.get("function"), dict): | ||
| schema = schema["function"] | ||
| if "input_schema" not in schema and "parameters" in schema: | ||
| schema["input_schema"] = schema["parameters"] | ||
| attrs[key] = json.dumps(schema) | ||
|
|
||
| def _normalize_openai_agents_trace(self, spans: list[dict]) -> None: | ||
| """Normalize OpenAI Agents SDK trace in-place. | ||
|
|
||
| This normalizes by: | ||
| 1. Copying the user prompt, assistant response, and tool schemas from | ||
| LLM spans to parent AGENT spans | ||
| 2. Falling back the assistant response to the last tool calls in the message | ||
| """ | ||
| # Collect all spans keyed by parent id for span traversal | ||
| spans_by_parent_id: dict[str, list[dict]] = defaultdict(list) | ||
| for s in spans: | ||
| parent = s.get("parent_span_id") | ||
| if parent: | ||
| spans_by_parent_id[parent].append(s) | ||
|
|
||
| for span in spans: | ||
| attrs = span.get("attributes") or {} | ||
| span_id = span.get("span_id", "") | ||
| if attrs.get("openinference.span.kind") != "AGENT" or attrs.get("input.value") or not span_id: | ||
| continue | ||
|
|
||
| llm_spans = self._get_descendant_llm_spans(span_id, spans_by_parent_id) | ||
| if not llm_spans: | ||
| continue | ||
|
|
||
| first_attrs = llm_spans[0].get("attributes", {}) | ||
| user_prompt = self._get_last_message_text(first_attrs, "llm.input_messages", role="user") | ||
| if user_prompt: | ||
| attrs["input.value"] = user_prompt | ||
|
|
||
| # Copy LLM tool schemas onto the AGENT span for available_tools back-filling | ||
| for tool_idx in self._get_message_indices(first_attrs, "llm.tools"): | ||
| key = f"llm.tools.{tool_idx}.tool.json_schema" | ||
| schema = first_attrs.get(key) | ||
| if schema is not None: | ||
| attrs[key] = schema | ||
|
|
||
| last_attrs = llm_spans[-1].get("attributes", {}) | ||
| agent_response = self._get_last_message_text(last_attrs, "llm.output_messages") | ||
|
|
||
| # Fallback the response to the last message's tool calls if no LLM output was found | ||
| if not agent_response: | ||
| tool_calls = self._get_last_tool_calls(last_attrs) | ||
| if tool_calls: | ||
| agent_response = f"[delegated] {tool_calls}" | ||
| if agent_response: | ||
| attrs["output.value"] = agent_response | ||
|
|
||
| def _get_descendant_llm_spans(self, span_id: str, spans_by_parent_id: dict[str, list[dict]]) -> list[dict]: | ||
| """Return the LLM spans descending from `span_id`, earliest first.""" | ||
| llm_spans: list[dict] = [] | ||
| stack = [span_id] | ||
| # Traverse the spans in top-down order | ||
| seen: set[str] = set() | ||
| while stack: | ||
| current = stack.pop() | ||
| if current in seen: | ||
| continue | ||
| seen.add(current) | ||
| for child in spans_by_parent_id.get(current, []): | ||
| kind = (child.get("attributes") or {}).get("openinference.span.kind", "") | ||
| if kind == "LLM": | ||
| llm_spans.append(child) | ||
| elif kind == "CHAIN": | ||
| cid = child.get("span_id", "") | ||
| if cid: | ||
| stack.append(cid) | ||
| llm_spans.sort(key=lambda s: self.parse_timestamp(s.get("start_time"))) | ||
| return llm_spans | ||
|
|
||
| @staticmethod | ||
| def _get_message_indices(attrs: dict, prefix: str) -> list[int]: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit: overall, can we make function naming more accurate
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in 8bf879e. Standardized the verbs from |
||
| """Return the numeric indices N found in keys starting with `{prefix}.N.`, highest first.""" | ||
| prefix_dot = f"{prefix}." | ||
| indices: set[int] = set() | ||
| for key in attrs: | ||
| if key.startswith(prefix_dot): | ||
| seg = key.removeprefix(prefix_dot).split(".", 1)[0] | ||
| if seg.isascii() and seg.isdigit(): | ||
| indices.add(int(seg)) | ||
| return sorted(indices, reverse=True) | ||
|
|
||
| @classmethod | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do these helpers need class-level invocation or subclass override behavior? Origin code is just some private helpers.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. They don't need access to |
||
| def _get_last_message_text(cls, attrs: dict, prefix: str, role: str | None = None) -> str | None: | ||
| """Return the text of the last matching message, or None.""" | ||
| for idx in cls._get_message_indices(attrs, prefix): | ||
| base = f"{prefix}.{idx}.message" | ||
| role_match = role is None or attrs.get(f"{base}.role") == role | ||
| is_reasoning = attrs.get(f"{base}.contents.0.message_content.type") == "reasoning" | ||
| text = attrs.get(f"{base}.content") or cls._join_content_text(attrs, base) | ||
| if role_match and not is_reasoning and text: | ||
| return text | ||
| return None | ||
|
|
||
| @staticmethod | ||
| def _join_content_text(attrs: dict, base: str) -> str | None: | ||
| """Concatenate all `text` parts under `{base}.contents.N`, or None. | ||
|
|
||
| Multimodal messages emit ordered parts (e.g. [image, text]), so scan every | ||
| part rather than only index 0. | ||
| """ | ||
| parts: list[str] = [] | ||
| i = 0 | ||
| while True: | ||
| part_type = attrs.get(f"{base}.contents.{i}.message_content.type") | ||
| if part_type is None: | ||
| break | ||
| if part_type == "text": | ||
| text = attrs.get(f"{base}.contents.{i}.message_content.text") | ||
| if text: | ||
| parts.append(text) | ||
| i += 1 | ||
| return "".join(parts) or None | ||
|
|
||
| @classmethod | ||
| def _get_last_tool_calls(cls, attrs: dict) -> str | None: | ||
| """Return a text rendering of the last assistant message's tool calls, or None.""" | ||
| prefix = "llm.output_messages" | ||
| for idx in cls._get_message_indices(attrs, prefix): | ||
| base = f"{prefix}.{idx}.message" | ||
| calls: list[str] = [] | ||
| # Convert the tool calls to a string representation | ||
| i = 0 | ||
| while True: | ||
| name = attrs.get(f"{base}.tool_calls.{i}.tool_call.function.name") | ||
| if not name: | ||
| break | ||
| args = attrs.get(f"{base}.tool_calls.{i}.tool_call.function.arguments", "") | ||
| calls.append(f"{name}({args})" if args else f"{name}()") | ||
| i += 1 | ||
| if calls: | ||
| return "; ".join(calls) | ||
| return None | ||
|
|
||
| def _build_trace(self, trace_id: str, spans: list[dict], session_id: str) -> Trace: | ||
| """Build a Trace from spans with the same trace_id.""" | ||
| converted_spans: list[InferenceSpan | ToolExecutionSpan | AgentInvocationSpan] = [] | ||
|
|
@@ -305,6 +489,9 @@ def _build_trace(self, trace_id: str, spans: list[dict], session_id: str) -> Tra | |
| if isinstance(converted, AgentInvocationSpan) and not converted.system_prompt: | ||
| converted.system_prompt = system_prompt | ||
|
|
||
| # Fix parent_span_id on converted spans that point to skipped intermediaries. | ||
| converted_spans = bridge_parent_gaps(converted_spans, self._raw_parent_map) | ||
|
|
||
| return Trace(spans=converted_spans, trace_id=trace_id, session_id=session_id) | ||
|
|
||
| # ========================================================================= | ||
|
|
@@ -380,7 +567,11 @@ def _is_agent_invocation_span(self, span: dict) -> bool: | |
| # routing nodes that aren't true agent invocations — reject those by default. | ||
| if span_kind == "AGENT": | ||
| scope_name = self._get_scope_name(span) | ||
| if scope_name in (SCOPE_OPENINFERENCE_SMOLAGENTS, SCOPE_OPENINFERENCE_CLAUDE_AGENT_SDK): | ||
| if scope_name in ( | ||
| SCOPE_OPENINFERENCE_SMOLAGENTS, | ||
| SCOPE_OPENINFERENCE_CLAUDE_AGENT_SDK, | ||
| SCOPE_OPENINFERENCE_OPENAI_AGENTS, | ||
| ): | ||
| input_val = attrs.get("input.value") | ||
| if input_val: | ||
| output_val = attrs.get("output.value") | ||
|
|
@@ -636,7 +827,7 @@ def _convert_agent_invocation_span(self, span: dict, session_id: str) -> AgentIn | |
| logger.warning(f"No agent_response for agent span {span.get('span_id')}") | ||
| return None | ||
|
|
||
| available_tools = sorted( | ||
| available_tools = self._extract_tools_from_attributes(attrs) or sorted( | ||
| self._trace_tools_map.get(trace_id, {}).values(), | ||
| key=lambda t: t.name, | ||
| ) | ||
|
|
@@ -833,7 +1024,7 @@ def _extract_tools_from_attributes(self, attrs: dict) -> list[ToolConfig]: | |
| parameters=tool_info.get("input_schema"), | ||
| ) | ||
| ) | ||
| except (json.JSONDecodeError, AttributeError): | ||
| except (json.JSONDecodeError, AttributeError, ValueError): | ||
| pass | ||
|
|
||
| return sorted(tools, key=lambda t: t.name or "") | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
how do we pick up this naming? Wondering
claude-agent-sdkbut for openai agent sdk we only doopenai-agentsThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These are the official package names from the OpenInference repository: https://github.com/Arize-ai/openinference/tree/main
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks!