Skip to content

Commit f111f55

Browse files
tcdentdot-agi
andauthored
OpenAI Agents voice support (#900)
* Update attribute extraction to support dict as well as object. * Adjust tests to match serialization format of `list[str]`. Patch JSON encode in tests to handle MagicMock objects. * Instrumentor and wrappers for OpenAI responses. * Collect base usage attributes, too. * Move Response attribute parsing to openai module. Move common attribute parsing to common module. * Include tags in parent span. Helpers for accessing global config and tags. Tests for helpers and common insrumentation attributes. * Add tags to an example. * Remove duplicate library attributes. * Pass OpenAI responses objects through our new instrumentor. * Incorporate common attributes, too. * Add indexed PROMPT semconv to MessageAttributes. Provide reusable wrapping functionality from instrumentation.common. Include prompts in OpenAI Responses attributes. * Type checking. * Test coverage for instrumentation.common * Type in method def should be string in case of missing import. * Wrap third party module imports from openai in try except block * OpenAI instrumentation tests. (Relocated to openai_core to avoid import hijack) * OpenAI Agents voice support * Additional voice-specific fields. * update pyproject.toml and uv.lock to use correct dependency * Remove `upload_object` helper. * Remove tag helpers. * Move agents example scripts. --------- Co-authored-by: Pratyush Shukla <[email protected]>
1 parent 1a2c4ff commit f111f55

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+3471
-1398
lines changed

agentops/client/api/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from agentops.client.api.base import BaseApiClient
1010
from agentops.client.api.types import AuthTokenResponse
1111
from agentops.client.api.versions.v3 import V3Client
12+
from agentops.client.api.versions.v4 import V4Client
1213

1314
# Define a type variable for client classes
1415
T = TypeVar("T", bound=BaseApiClient)
@@ -44,6 +45,16 @@ def v3(self) -> V3Client:
4445
"""
4546
return self._get_client("v3", V3Client)
4647

48+
@property
49+
def v4(self) -> V4Client:
50+
"""
51+
Get the V4 API client.
52+
53+
Returns:
54+
The V4 API client
55+
"""
56+
return self._get_client("v4", V4Client)
57+
4758
def _get_client(self, version: str, client_class: Type[T]) -> T:
4859
"""
4960
Get or create a version-specific client.

agentops/client/api/types.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,18 @@
55
"""
66

77
from typing import TypedDict
8+
from pydantic import BaseModel
89

910

1011
class AuthTokenResponse(TypedDict):
1112
"""Response from the auth/token endpoint"""
1213

1314
token: str
1415
project_id: str
16+
17+
18+
class UploadedObjectResponse(BaseModel):
19+
"""Response from the v4/objects/upload endpoint"""
20+
url: str
21+
size: int
22+

agentops/client/api/versions/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@
55
"""
66

77
from agentops.client.api.versions.v3 import V3Client
8+
from agentops.client.api.versions.v4 import V4Client
89

9-
__all__ = ["V3Client"]
10+
__all__ = ["V3Client", "V4Client"]

agentops/client/api/versions/v4.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
"""
2+
V4 API client for the AgentOps API.
3+
4+
This module provides the client for the V4 version of the AgentOps API.
5+
"""
6+
from typing import Optional, Union, Dict
7+
8+
from agentops.client.api.base import BaseApiClient
9+
from agentops.exceptions import ApiServerException
10+
from agentops.client.api.types import UploadedObjectResponse
11+
12+
13+
class V4Client(BaseApiClient):
14+
"""Client for the AgentOps V4 API"""
15+
auth_token: str
16+
17+
def set_auth_token(self, token: str):
18+
"""
19+
Set the authentication token for API requests.
20+
21+
Args:
22+
token: The authentication token to set
23+
"""
24+
self.auth_token = token
25+
26+
def prepare_headers(self, custom_headers: Optional[Dict[str, str]] = None) -> Dict[str, str]:
27+
"""
28+
Prepare headers for API requests.
29+
30+
Args:
31+
custom_headers: Additional headers to include
32+
Returns:
33+
Headers dictionary with standard headers and any custom headers
34+
"""
35+
headers = {
36+
"Authorization": f"Bearer {self.auth_token}",
37+
}
38+
if custom_headers:
39+
headers.update(custom_headers)
40+
return headers
41+
42+
def upload_object(self, body: Union[str, bytes]) -> UploadedObjectResponse:
43+
"""
44+
Upload an object to the API and return the response.
45+
46+
Args:
47+
body: The object to upload, either as a string or bytes.
48+
Returns:
49+
UploadedObjectResponse: The response from the API after upload.
50+
"""
51+
if isinstance(body, bytes):
52+
body = body.decode("utf-8")
53+
54+
response = self.post("/v4/objects/upload/", body, self.prepare_headers())
55+
56+
if response.status_code != 200:
57+
error_msg = f"Upload failed: {response.status_code}"
58+
try:
59+
error_data = response.json()
60+
if "error" in error_data:
61+
error_msg = error_data["error"]
62+
except Exception:
63+
pass
64+
raise ApiServerException(error_msg)
65+
66+
try:
67+
response_data = response.json()
68+
return UploadedObjectResponse(**response_data)
69+
except Exception as e:
70+
raise ApiServerException(f"Failed to process upload response: {str(e)}")
71+

agentops/client/client.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ def init(self, **kwargs):
4545
# Prefetch JWT token if enabled
4646
# TODO: Move this validation somewhere else (and integrate with self.config.prefetch_jwt_token once we have a solution to that)
4747
response = self.api.v3.fetch_auth_token(self.config.api_key)
48+
49+
# Save the bearer for use with the v4 API
50+
self.api.v4.set_auth_token(response["token"])
4851

4952
# Initialize TracingCore with the current configuration and project_id
5053
tracing_config = self.config.dict()

agentops/helpers/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,5 @@
4545
"get_env_bool",
4646
"get_env_int",
4747
"get_env_list",
48+
"get_tags_from_config",
4849
]

agentops/instrumentation/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ class InstrumentorLoader:
2323
We use the `provider_import_name` to determine if the library is installed i
2424
n the environment.
2525
26-
`modue_name` is the name of the module to import from.
26+
`module_name` is the name of the module to import from.
2727
`class_name` is the name of the class to instantiate from the module.
2828
`provider_import_name` is the name of the package to check for availability.
2929
"""
@@ -53,7 +53,7 @@ def get_instance(self) -> BaseInstrumentor:
5353

5454
available_instrumentors: list[InstrumentorLoader] = [
5555
InstrumentorLoader(
56-
module_name="opentelemetry.instrumentation.openai",
56+
module_name="agentops.instrumentation.openai",
5757
class_name="OpenAIInstrumentor",
5858
provider_import_name="openai",
5959
),
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# AgentOps Instrumentation Common Module
2+
3+
The `agentops.instrumentation.common` module provides shared utilities for OpenTelemetry instrumentation across different LLM service providers.
4+
5+
## Core Components
6+
7+
### Attribute Handler Example
8+
9+
Attribute handlers extract data from method inputs and outputs:
10+
11+
```python
12+
from typing import Optional, Any, Tuple, Dict
13+
from agentops.instrumentation.common.attributes import AttributeMap
14+
from agentops.semconv import SpanAttributes
15+
16+
def my_attribute_handler(args: Optional[Tuple] = None, kwargs: Optional[Dict] = None, return_value: Optional[Any] = None) -> AttributeMap:
17+
attributes = {}
18+
19+
# Extract attributes from kwargs (method inputs)
20+
if kwargs:
21+
if "model" in kwargs:
22+
attributes[SpanAttributes.MODEL_NAME] = kwargs["model"]
23+
# ...
24+
25+
# Extract attributes from return value (method outputs)
26+
if return_value:
27+
if hasattr(return_value, "model"):
28+
attributes[SpanAttributes.LLM_RESPONSE_MODEL] = return_value.model
29+
# ...
30+
31+
return attributes
32+
```
33+
34+
### `WrapConfig` Class
35+
36+
Config object defining how a method should be wrapped:
37+
38+
```python
39+
from agentops.instrumentation.common.wrappers import WrapConfig
40+
from opentelemetry.trace import SpanKind
41+
42+
config = WrapConfig(
43+
trace_name="llm.completion", # Name that will appear in trace spans
44+
package="openai.resources", # Path to the module containing the class
45+
class_name="Completions", # Name of the class containing the method
46+
method_name="create", # Name of the method to wrap
47+
handler=my_attribute_handler, # Function that extracts attributes
48+
span_kind=SpanKind.CLIENT # Type of span to create
49+
)
50+
```
51+
52+
### Wrapping/Unwrapping Methods
53+
54+
```python
55+
from opentelemetry.trace import get_tracer
56+
from agentops.instrumentation.common.wrappers import wrap, unwrap
57+
58+
# Create a tracer and wrap a method
59+
tracer = get_tracer("openai", "0.0.0")
60+
wrap(config, tracer)
61+
62+
# Later, unwrap the method
63+
unwrap(config)
64+
```
65+
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from .attributes import AttributeMap, _extract_attributes_from_mapping
2+
3+
__all__ = [
4+
"AttributeMap",
5+
"_extract_attributes_from_mapping",
6+
]
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
"""Common attribute processing utilities shared across all instrumentors.
2+
3+
This module provides core utilities for extracting and formatting
4+
OpenTelemetry-compatible attributes from span data. These functions
5+
are provider-agnostic and used by all instrumentors in the AgentOps
6+
package.
7+
8+
The module includes:
9+
10+
1. Helper functions for attribute extraction and mapping
11+
2. Common attribute getters used across all providers
12+
3. Base trace and span attribute functions
13+
14+
All functions follow a consistent pattern:
15+
- Accept span/trace data as input
16+
- Process according to semantic conventions
17+
- Return a dictionary of formatted attributes
18+
19+
These utilities ensure consistent attribute handling across different
20+
LLM service instrumentors while maintaining separation of concerns.
21+
"""
22+
from typing import Dict, Any, Optional, List
23+
from agentops.logging import logger
24+
from agentops.helpers import safe_serialize, get_agentops_version
25+
from agentops.semconv import (
26+
CoreAttributes,
27+
InstrumentationAttributes,
28+
WorkflowAttributes,
29+
)
30+
31+
# target_attribute_key: source_attribute
32+
AttributeMap = Dict[str, Any]
33+
34+
35+
def _extract_attributes_from_mapping(span_data: Any, attribute_mapping: AttributeMap) -> AttributeMap:
36+
"""Helper function to extract attributes based on a mapping.
37+
38+
Args:
39+
span_data: The span data object or dict to extract attributes from
40+
attribute_mapping: Dictionary mapping target attributes to source attributes
41+
42+
Returns:
43+
Dictionary of extracted attributes
44+
"""
45+
attributes = {}
46+
for target_attr, source_attr in attribute_mapping.items():
47+
if hasattr(span_data, source_attr):
48+
# Use getattr to handle properties
49+
value = getattr(span_data, source_attr)
50+
elif isinstance(span_data, dict) and source_attr in span_data:
51+
# Use direct key access for dicts
52+
value = span_data[source_attr]
53+
else:
54+
continue
55+
56+
# Skip if value is None or empty
57+
if value is None or (isinstance(value, (list, dict, str)) and not value):
58+
continue
59+
60+
# Serialize complex objects
61+
elif isinstance(value, (dict, list, object)) and not isinstance(value, (str, int, float, bool)):
62+
value = safe_serialize(value)
63+
64+
attributes[target_attr] = value
65+
66+
return attributes
67+
68+
69+
def get_common_attributes() -> AttributeMap:
70+
"""Get common instrumentation attributes used across traces and spans.
71+
72+
Returns:
73+
Dictionary of common instrumentation attributes
74+
"""
75+
return {
76+
InstrumentationAttributes.NAME: "agentops",
77+
InstrumentationAttributes.VERSION: get_agentops_version(),
78+
}
79+
80+
81+
def get_base_trace_attributes(trace: Any) -> AttributeMap:
82+
"""Create the base attributes dictionary for an OpenTelemetry trace.
83+
84+
Args:
85+
trace: The trace object to extract attributes from
86+
87+
Returns:
88+
Dictionary containing base trace attributes
89+
"""
90+
if not hasattr(trace, 'trace_id'):
91+
logger.warning("Cannot create trace attributes: missing trace_id")
92+
return {}
93+
94+
attributes = {
95+
WorkflowAttributes.WORKFLOW_NAME: trace.name,
96+
CoreAttributes.TRACE_ID: trace.trace_id,
97+
WorkflowAttributes.WORKFLOW_STEP_TYPE: "trace",
98+
**get_common_attributes(),
99+
}
100+
101+
# Add tags from the config to the trace attributes (these should only be added to the trace)
102+
from agentops import get_client
103+
104+
config = get_client().config
105+
tags = []
106+
if config.default_tags:
107+
# `default_tags` can either be a `set` or a `list`
108+
tags = list(config.default_tags)
109+
110+
attributes[CoreAttributes.TAGS] = tags
111+
112+
return attributes
113+
114+
115+
def get_base_span_attributes(span: Any) -> AttributeMap:
116+
"""Create the base attributes dictionary for an OpenTelemetry span.
117+
118+
Args:
119+
span: The span object to extract attributes from
120+
121+
Returns:
122+
Dictionary containing base span attributes
123+
"""
124+
span_id = getattr(span, 'span_id', 'unknown')
125+
trace_id = getattr(span, 'trace_id', 'unknown')
126+
parent_id = getattr(span, 'parent_id', None)
127+
128+
attributes = {
129+
CoreAttributes.TRACE_ID: trace_id,
130+
CoreAttributes.SPAN_ID: span_id,
131+
**get_common_attributes(),
132+
}
133+
134+
if parent_id:
135+
attributes[CoreAttributes.PARENT_ID] = parent_id
136+
137+
return attributes

0 commit comments

Comments
 (0)