mseep-agentops 0.4.18__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. agentops/__init__.py +488 -0
  2. agentops/client/__init__.py +5 -0
  3. agentops/client/api/__init__.py +71 -0
  4. agentops/client/api/base.py +162 -0
  5. agentops/client/api/types.py +21 -0
  6. agentops/client/api/versions/__init__.py +10 -0
  7. agentops/client/api/versions/v3.py +65 -0
  8. agentops/client/api/versions/v4.py +104 -0
  9. agentops/client/client.py +211 -0
  10. agentops/client/http/__init__.py +0 -0
  11. agentops/client/http/http_adapter.py +116 -0
  12. agentops/client/http/http_client.py +215 -0
  13. agentops/config.py +268 -0
  14. agentops/enums.py +36 -0
  15. agentops/exceptions.py +38 -0
  16. agentops/helpers/__init__.py +44 -0
  17. agentops/helpers/dashboard.py +54 -0
  18. agentops/helpers/deprecation.py +50 -0
  19. agentops/helpers/env.py +52 -0
  20. agentops/helpers/serialization.py +137 -0
  21. agentops/helpers/system.py +178 -0
  22. agentops/helpers/time.py +11 -0
  23. agentops/helpers/version.py +36 -0
  24. agentops/instrumentation/__init__.py +598 -0
  25. agentops/instrumentation/common/__init__.py +82 -0
  26. agentops/instrumentation/common/attributes.py +278 -0
  27. agentops/instrumentation/common/instrumentor.py +147 -0
  28. agentops/instrumentation/common/metrics.py +100 -0
  29. agentops/instrumentation/common/objects.py +26 -0
  30. agentops/instrumentation/common/span_management.py +176 -0
  31. agentops/instrumentation/common/streaming.py +218 -0
  32. agentops/instrumentation/common/token_counting.py +177 -0
  33. agentops/instrumentation/common/version.py +71 -0
  34. agentops/instrumentation/common/wrappers.py +235 -0
  35. agentops/legacy/__init__.py +277 -0
  36. agentops/legacy/event.py +156 -0
  37. agentops/logging/__init__.py +4 -0
  38. agentops/logging/config.py +86 -0
  39. agentops/logging/formatters.py +34 -0
  40. agentops/logging/instrument_logging.py +91 -0
  41. agentops/sdk/__init__.py +27 -0
  42. agentops/sdk/attributes.py +151 -0
  43. agentops/sdk/core.py +607 -0
  44. agentops/sdk/decorators/__init__.py +51 -0
  45. agentops/sdk/decorators/factory.py +486 -0
  46. agentops/sdk/decorators/utility.py +216 -0
  47. agentops/sdk/exporters.py +87 -0
  48. agentops/sdk/processors.py +71 -0
  49. agentops/sdk/types.py +21 -0
  50. agentops/semconv/__init__.py +36 -0
  51. agentops/semconv/agent.py +29 -0
  52. agentops/semconv/core.py +19 -0
  53. agentops/semconv/enum.py +11 -0
  54. agentops/semconv/instrumentation.py +13 -0
  55. agentops/semconv/langchain.py +63 -0
  56. agentops/semconv/message.py +61 -0
  57. agentops/semconv/meters.py +24 -0
  58. agentops/semconv/resource.py +52 -0
  59. agentops/semconv/span_attributes.py +118 -0
  60. agentops/semconv/span_kinds.py +50 -0
  61. agentops/semconv/status.py +11 -0
  62. agentops/semconv/tool.py +15 -0
  63. agentops/semconv/workflow.py +69 -0
  64. agentops/validation.py +357 -0
  65. mseep_agentops-0.4.18.dist-info/METADATA +49 -0
  66. mseep_agentops-0.4.18.dist-info/RECORD +94 -0
  67. mseep_agentops-0.4.18.dist-info/WHEEL +5 -0
  68. mseep_agentops-0.4.18.dist-info/licenses/LICENSE +21 -0
  69. mseep_agentops-0.4.18.dist-info/top_level.txt +2 -0
  70. tests/__init__.py +0 -0
  71. tests/conftest.py +10 -0
  72. tests/unit/__init__.py +0 -0
  73. tests/unit/client/__init__.py +1 -0
  74. tests/unit/client/test_http_adapter.py +221 -0
  75. tests/unit/client/test_http_client.py +206 -0
  76. tests/unit/conftest.py +54 -0
  77. tests/unit/sdk/__init__.py +1 -0
  78. tests/unit/sdk/instrumentation_tester.py +207 -0
  79. tests/unit/sdk/test_attributes.py +392 -0
  80. tests/unit/sdk/test_concurrent_instrumentation.py +468 -0
  81. tests/unit/sdk/test_decorators.py +763 -0
  82. tests/unit/sdk/test_exporters.py +241 -0
  83. tests/unit/sdk/test_factory.py +1188 -0
  84. tests/unit/sdk/test_internal_span_processor.py +397 -0
  85. tests/unit/sdk/test_resource_attributes.py +35 -0
  86. tests/unit/test_config.py +82 -0
  87. tests/unit/test_context_manager.py +777 -0
  88. tests/unit/test_events.py +27 -0
  89. tests/unit/test_host_env.py +54 -0
  90. tests/unit/test_init_py.py +501 -0
  91. tests/unit/test_serialization.py +433 -0
  92. tests/unit/test_session.py +676 -0
  93. tests/unit/test_user_agent.py +34 -0
  94. tests/unit/test_validation.py +405 -0
@@ -0,0 +1,176 @@
1
+ """Common span management utilities for AgentOps instrumentation.
2
+
3
+ This module provides utilities for creating and managing spans with
4
+ consistent attributes and error handling.
5
+ """
6
+
7
+ import time
8
+ from contextlib import contextmanager
9
+ from typing import Optional, Dict, Any, Callable, Tuple
10
+ from functools import wraps
11
+
12
+ from opentelemetry.trace import Tracer, Span, SpanKind, Status, StatusCode, get_current_span
13
+ from opentelemetry.sdk.resources import SERVICE_NAME, TELEMETRY_SDK_NAME, DEPLOYMENT_ENVIRONMENT
14
+ from opentelemetry import context as context_api
15
+
16
+ from agentops.logging import logger
17
+ from agentops.semconv import CoreAttributes
18
+
19
+
20
+ class SpanAttributeManager:
21
+ """Manages common span attributes across instrumentations."""
22
+
23
+ def __init__(self, service_name: str = "agentops", deployment_environment: str = "production"):
24
+ self.service_name = service_name
25
+ self.deployment_environment = deployment_environment
26
+
27
+ def set_common_attributes(self, span: Span):
28
+ """Set common attributes on a span."""
29
+ span.set_attribute(TELEMETRY_SDK_NAME, "agentops")
30
+ span.set_attribute(SERVICE_NAME, self.service_name)
31
+ span.set_attribute(DEPLOYMENT_ENVIRONMENT, self.deployment_environment)
32
+
33
+ def set_config_tags(self, span: Span):
34
+ """Set tags from AgentOps config on a span."""
35
+ # Import locally to avoid circular dependency
36
+ from agentops import get_client
37
+
38
+ client = get_client()
39
+ if client and client.config.default_tags and len(client.config.default_tags) > 0:
40
+ tag_list = list(client.config.default_tags)
41
+ span.set_attribute(CoreAttributes.TAGS, tag_list)
42
+
43
+
44
+ @contextmanager
45
+ def create_span(
46
+ tracer: Tracer,
47
+ name: str,
48
+ kind: SpanKind = SpanKind.CLIENT,
49
+ attributes: Optional[Dict[str, Any]] = None,
50
+ set_common_attributes: bool = True,
51
+ attribute_manager: Optional[SpanAttributeManager] = None,
52
+ ):
53
+ """Context manager for creating spans with consistent error handling.
54
+
55
+ Args:
56
+ tracer: The tracer to use for creating the span
57
+ name: The name of the span
58
+ kind: The kind of span to create
59
+ attributes: Initial attributes to set on the span
60
+ set_common_attributes: Whether to set common attributes
61
+ attribute_manager: Optional attribute manager for setting common attributes
62
+
63
+ Yields:
64
+ The created span
65
+ """
66
+ with tracer.start_as_current_span(name, kind=kind, attributes=attributes or {}) as span:
67
+ try:
68
+ if set_common_attributes and attribute_manager:
69
+ attribute_manager.set_common_attributes(span)
70
+ yield span
71
+ span.set_status(Status(StatusCode.OK))
72
+ except Exception as e:
73
+ span.set_status(Status(StatusCode.ERROR, str(e)))
74
+ span.record_exception(e)
75
+ logger.error(f"Error in span {name}: {e}")
76
+ raise
77
+
78
+
79
+ def timed_span(tracer: Tracer, name: str, record_duration: Optional[Callable[[float], None]] = None, **span_kwargs):
80
+ """Decorator for creating timed spans around functions.
81
+
82
+ Args:
83
+ tracer: The tracer to use
84
+ name: The name of the span
85
+ record_duration: Optional callback to record duration
86
+ **span_kwargs: Additional arguments for span creation
87
+ """
88
+
89
+ def decorator(func):
90
+ @wraps(func)
91
+ def wrapper(*args, **kwargs):
92
+ start_time = time.time()
93
+ with create_span(tracer, name, **span_kwargs):
94
+ result = func(*args, **kwargs)
95
+ if record_duration:
96
+ duration = time.time() - start_time
97
+ record_duration(duration)
98
+ return result
99
+
100
+ return wrapper
101
+
102
+ return decorator
103
+
104
+
105
+ class StreamingSpanManager:
106
+ """Manages spans for streaming operations."""
107
+
108
+ def __init__(self, tracer: Tracer):
109
+ self.tracer = tracer
110
+ self._active_spans: Dict[Any, Span] = {}
111
+
112
+ def start_streaming_span(self, stream_id: Any, name: str, **span_kwargs) -> Span:
113
+ """Start a span for a streaming operation."""
114
+ span = self.tracer.start_span(name, **span_kwargs)
115
+ self._active_spans[stream_id] = span
116
+ return span
117
+
118
+ def get_streaming_span(self, stream_id: Any) -> Optional[Span]:
119
+ """Get an active streaming span."""
120
+ return self._active_spans.get(stream_id)
121
+
122
+ def end_streaming_span(self, stream_id: Any, status: Optional[Status] = None):
123
+ """End a streaming span."""
124
+ span = self._active_spans.pop(stream_id, None)
125
+ if span:
126
+ if status:
127
+ span.set_status(status)
128
+ else:
129
+ span.set_status(Status(StatusCode.OK))
130
+ span.end()
131
+
132
+
133
+ def extract_parent_context(parent_span: Optional[Span] = None) -> Any:
134
+ """Extract parent context for span creation.
135
+
136
+ Args:
137
+ parent_span: Optional parent span to use
138
+
139
+ Returns:
140
+ Context to use as parent
141
+ """
142
+ if parent_span:
143
+ from opentelemetry.trace import set_span_in_context
144
+
145
+ return set_span_in_context(parent_span)
146
+ return context_api.get_current()
147
+
148
+
149
+ def safe_set_attribute(span: Span, key: str, value: Any, max_length: int = 1000):
150
+ """Safely set an attribute on a span, handling None values and truncating long strings."""
151
+ if value is None:
152
+ return
153
+
154
+ if isinstance(value, str) and len(value) > max_length:
155
+ value = value[: max_length - 3] + "..."
156
+
157
+ try:
158
+ span.set_attribute(key, value)
159
+ except Exception as e:
160
+ logger.debug(f"Failed to set span attribute {key}: {e}")
161
+
162
+
163
+ def get_span_context_info(span: Optional[Span] = None) -> Tuple[str, str]:
164
+ """Get trace and span IDs from a span for debugging.
165
+
166
+ Returns:
167
+ Tuple of (trace_id, span_id) as strings
168
+ """
169
+ if not span:
170
+ span = get_current_span()
171
+
172
+ span_context = span.get_span_context()
173
+ trace_id = format(span_context.trace_id, "032x") if span_context.trace_id else "unknown"
174
+ span_id = format(span_context.span_id, "016x") if span_context.span_id else "unknown"
175
+
176
+ return trace_id, span_id
@@ -0,0 +1,218 @@
1
+ """Common streaming utilities for handling streaming responses.
2
+
3
+ This module provides utilities for instrumenting streaming API responses
4
+ in a consistent way across different providers.
5
+ """
6
+
7
+ from typing import Optional, Any, Dict, Callable
8
+ from abc import ABC
9
+ import time
10
+
11
+ from opentelemetry.trace import Tracer, Span, Status, StatusCode
12
+
13
+ from agentops.logging import logger
14
+ from agentops.instrumentation.common.span_management import safe_set_attribute
15
+ from agentops.instrumentation.common.token_counting import TokenUsage, TokenUsageExtractor
16
+
17
+
18
+ class BaseStreamWrapper(ABC):
19
+ """Base class for wrapping streaming responses."""
20
+
21
+ def __init__(
22
+ self,
23
+ stream: Any,
24
+ span: Span,
25
+ extract_chunk_content: Callable[[Any], Optional[str]],
26
+ extract_chunk_attributes: Optional[Callable[[Any], Dict[str, Any]]] = None,
27
+ ):
28
+ self.stream = stream
29
+ self.span = span
30
+ self.extract_chunk_content = extract_chunk_content
31
+ self.extract_chunk_attributes = extract_chunk_attributes or (lambda x: {})
32
+
33
+ self.start_time = time.time()
34
+ self.first_token_time: Optional[float] = None
35
+ self.chunks_received = 0
36
+ self.accumulated_content = []
37
+ self.token_usage = TokenUsage()
38
+
39
+ def _process_chunk(self, chunk: Any):
40
+ """Process a single chunk from the stream."""
41
+ # Record time to first token
42
+ if self.first_token_time is None:
43
+ self.first_token_time = time.time()
44
+ time_to_first_token = self.first_token_time - self.start_time
45
+ safe_set_attribute(self.span, "streaming.time_to_first_token", time_to_first_token)
46
+
47
+ self.chunks_received += 1
48
+
49
+ # Extract content from chunk
50
+ content = self.extract_chunk_content(chunk)
51
+ if content:
52
+ self.accumulated_content.append(content)
53
+
54
+ # Extract and set additional attributes
55
+ attributes = self.extract_chunk_attributes(chunk)
56
+ for key, value in attributes.items():
57
+ safe_set_attribute(self.span, key, value)
58
+
59
+ # Try to extract token usage if available
60
+ if hasattr(chunk, "usage") or hasattr(chunk, "usage_metadata"):
61
+ chunk_usage = TokenUsageExtractor.extract_from_response(chunk)
62
+ # Accumulate token counts
63
+ if chunk_usage.prompt_tokens:
64
+ self.token_usage.prompt_tokens = chunk_usage.prompt_tokens
65
+ if chunk_usage.completion_tokens:
66
+ self.token_usage.completion_tokens = (
67
+ self.token_usage.completion_tokens or 0
68
+ ) + chunk_usage.completion_tokens
69
+
70
+ def _finalize(self):
71
+ """Finalize the stream processing."""
72
+ try:
73
+ # Set final content
74
+ final_content = "".join(self.accumulated_content)
75
+ safe_set_attribute(self.span, "streaming.final_content", final_content)
76
+ safe_set_attribute(self.span, "streaming.chunk_count", self.chunks_received)
77
+
78
+ # Set timing metrics
79
+ total_time = time.time() - self.start_time
80
+ safe_set_attribute(self.span, "streaming.total_duration", total_time)
81
+
82
+ if self.first_token_time:
83
+ generation_time = time.time() - self.first_token_time
84
+ safe_set_attribute(self.span, "streaming.generation_duration", generation_time)
85
+
86
+ # Set token usage
87
+ for attr_name, value in self.token_usage.to_attributes().items():
88
+ safe_set_attribute(self.span, attr_name, value)
89
+
90
+ self.span.set_status(Status(StatusCode.OK))
91
+ except Exception as e:
92
+ logger.error(f"Error finalizing stream: {e}")
93
+ self.span.set_status(Status(StatusCode.ERROR, str(e)))
94
+ self.span.record_exception(e)
95
+ finally:
96
+ self.span.end()
97
+
98
+
99
+ class SyncStreamWrapper(BaseStreamWrapper):
100
+ """Wrapper for synchronous streaming responses."""
101
+
102
+ def __iter__(self):
103
+ try:
104
+ for chunk in self.stream:
105
+ self._process_chunk(chunk)
106
+ yield chunk
107
+ except Exception as e:
108
+ self.span.set_status(Status(StatusCode.ERROR, str(e)))
109
+ self.span.record_exception(e)
110
+ raise
111
+ finally:
112
+ self._finalize()
113
+
114
+
115
+ class AsyncStreamWrapper(BaseStreamWrapper):
116
+ """Wrapper for asynchronous streaming responses."""
117
+
118
+ async def __aiter__(self):
119
+ try:
120
+ async for chunk in self.stream:
121
+ self._process_chunk(chunk)
122
+ yield chunk
123
+ except Exception as e:
124
+ self.span.set_status(Status(StatusCode.ERROR, str(e)))
125
+ self.span.record_exception(e)
126
+ raise
127
+ finally:
128
+ self._finalize()
129
+
130
+
131
+ def create_stream_wrapper_factory(
132
+ tracer: Tracer,
133
+ span_name: str,
134
+ extract_chunk_content: Callable[[Any], Optional[str]],
135
+ extract_chunk_attributes: Optional[Callable[[Any], Dict[str, Any]]] = None,
136
+ initial_attributes: Optional[Dict[str, Any]] = None,
137
+ ) -> Callable:
138
+ """Create a factory function for wrapping streaming methods.
139
+
140
+ Args:
141
+ tracer: The tracer to use for creating spans
142
+ span_name: Name for the streaming span
143
+ extract_chunk_content: Function to extract content from chunks
144
+ extract_chunk_attributes: Optional function to extract attributes from chunks
145
+ initial_attributes: Initial attributes to set on the span
146
+
147
+ Returns:
148
+ A wrapper function suitable for use with wrapt
149
+ """
150
+
151
+ def wrapper(wrapped, instance, args, kwargs):
152
+ # Start the span
153
+ span = tracer.start_span(span_name)
154
+
155
+ # Set initial attributes
156
+ if initial_attributes:
157
+ for key, value in initial_attributes.items():
158
+ safe_set_attribute(span, key, value)
159
+
160
+ try:
161
+ # Call the wrapped method
162
+ stream = wrapped(*args, **kwargs)
163
+
164
+ # Determine if it's async or sync
165
+ if hasattr(stream, "__aiter__"):
166
+ return AsyncStreamWrapper(stream, span, extract_chunk_content, extract_chunk_attributes)
167
+ else:
168
+ return SyncStreamWrapper(stream, span, extract_chunk_content, extract_chunk_attributes)
169
+ except Exception as e:
170
+ span.set_status(Status(StatusCode.ERROR, str(e)))
171
+ span.record_exception(e)
172
+ span.end()
173
+ raise
174
+
175
+ return wrapper
176
+
177
+
178
+ class StreamingResponseHandler:
179
+ """Handles common patterns for streaming responses."""
180
+
181
+ @staticmethod
182
+ def extract_openai_chunk_content(chunk: Any) -> Optional[str]:
183
+ """Extract content from OpenAI-style streaming chunks."""
184
+ if hasattr(chunk, "choices") and chunk.choices:
185
+ delta = getattr(chunk.choices[0], "delta", None)
186
+ if delta and hasattr(delta, "content"):
187
+ return delta.content
188
+ return None
189
+
190
+ @staticmethod
191
+ def extract_anthropic_chunk_content(chunk: Any) -> Optional[str]:
192
+ """Extract content from Anthropic-style streaming chunks."""
193
+ if hasattr(chunk, "type"):
194
+ if chunk.type == "content_block_delta":
195
+ if hasattr(chunk, "delta") and hasattr(chunk.delta, "text"):
196
+ return chunk.delta.text
197
+ elif chunk.type == "message_delta":
198
+ if hasattr(chunk, "delta") and hasattr(chunk.delta, "content"):
199
+ return chunk.delta.content
200
+ return None
201
+
202
+ @staticmethod
203
+ def extract_generic_chunk_content(chunk: Any) -> Optional[str]:
204
+ """Extract content from generic streaming chunks."""
205
+ # Try common patterns
206
+ if hasattr(chunk, "content"):
207
+ return str(chunk.content)
208
+ elif hasattr(chunk, "text"):
209
+ return str(chunk.text)
210
+ elif hasattr(chunk, "delta"):
211
+ delta = chunk.delta
212
+ if hasattr(delta, "content"):
213
+ return str(delta.content)
214
+ elif hasattr(delta, "text"):
215
+ return str(delta.text)
216
+ elif isinstance(chunk, str):
217
+ return chunk
218
+ return None
@@ -0,0 +1,177 @@
1
+ """Common token counting and usage extraction utilities.
2
+
3
+ This module provides utilities for extracting and recording token usage
4
+ information from various response formats.
5
+ """
6
+
7
+ from typing import Dict, Any, Optional
8
+ from dataclasses import dataclass
9
+
10
+ from agentops.logging import logger
11
+ from agentops.semconv import SpanAttributes
12
+
13
+
14
+ @dataclass
15
+ class TokenUsage:
16
+ """Represents token usage information."""
17
+
18
+ prompt_tokens: Optional[int] = None
19
+ completion_tokens: Optional[int] = None
20
+ total_tokens: Optional[int] = None
21
+ cached_prompt_tokens: Optional[int] = None
22
+ cached_read_tokens: Optional[int] = None
23
+ reasoning_tokens: Optional[int] = None
24
+
25
+ def to_attributes(self) -> Dict[str, int]:
26
+ """Convert to span attributes dictionary.
27
+
28
+ Only metrics greater than zero are included so that non‑LLM spans do
29
+ not contain empty token usage fields.
30
+ """
31
+ attributes = {}
32
+
33
+ if self.prompt_tokens:
34
+ attributes[SpanAttributes.LLM_USAGE_PROMPT_TOKENS] = self.prompt_tokens
35
+
36
+ if self.completion_tokens:
37
+ attributes[SpanAttributes.LLM_USAGE_COMPLETION_TOKENS] = self.completion_tokens
38
+
39
+ if self.total_tokens:
40
+ attributes[SpanAttributes.LLM_USAGE_TOTAL_TOKENS] = self.total_tokens
41
+
42
+ if self.cached_prompt_tokens:
43
+ attributes[SpanAttributes.LLM_USAGE_CACHE_CREATION_INPUT_TOKENS] = self.cached_prompt_tokens
44
+
45
+ if self.cached_read_tokens:
46
+ attributes[SpanAttributes.LLM_USAGE_CACHE_READ_INPUT_TOKENS] = self.cached_read_tokens
47
+
48
+ if self.reasoning_tokens:
49
+ attributes[SpanAttributes.LLM_USAGE_REASONING_TOKENS] = self.reasoning_tokens
50
+
51
+ return attributes
52
+
53
+
54
+ class TokenUsageExtractor:
55
+ """Extracts token usage from various response formats."""
56
+
57
+ @staticmethod
58
+ def extract_from_response(response: Any) -> TokenUsage:
59
+ """Extract token usage from a generic response object.
60
+
61
+ Handles various response formats from different providers.
62
+ """
63
+ usage = TokenUsage()
64
+
65
+ # Try direct usage attribute
66
+ if hasattr(response, "usage"):
67
+ usage_data = response.usage
68
+ usage = TokenUsageExtractor._extract_from_usage_object(usage_data)
69
+
70
+ # Try usage_metadata (Anthropic style)
71
+ elif hasattr(response, "usage_metadata"):
72
+ usage_data = response.usage_metadata
73
+ usage = TokenUsageExtractor._extract_from_usage_object(usage_data)
74
+
75
+ # Try token_usage attribute (CrewAI style)
76
+ elif hasattr(response, "token_usage"):
77
+ usage = TokenUsageExtractor._extract_from_crewai_format(response.token_usage)
78
+
79
+ # Try direct attributes on response
80
+ elif hasattr(response, "prompt_tokens") or hasattr(response, "completion_tokens"):
81
+ usage = TokenUsageExtractor._extract_from_attributes(response)
82
+
83
+ return usage
84
+
85
+ @staticmethod
86
+ def _extract_from_usage_object(usage_data: Any) -> TokenUsage:
87
+ """Extract from a usage object with standard attributes."""
88
+ if not usage_data:
89
+ return TokenUsage()
90
+
91
+ return TokenUsage(
92
+ prompt_tokens=getattr(usage_data, "prompt_tokens", None),
93
+ completion_tokens=getattr(usage_data, "completion_tokens", None),
94
+ total_tokens=getattr(usage_data, "total_tokens", None),
95
+ cached_prompt_tokens=getattr(usage_data, "cached_prompt_tokens", None),
96
+ cached_read_tokens=getattr(usage_data, "cache_read_input_tokens", None),
97
+ reasoning_tokens=getattr(usage_data, "reasoning_tokens", None),
98
+ )
99
+
100
+ @staticmethod
101
+ def _extract_from_crewai_format(token_usage_str: str) -> TokenUsage:
102
+ """Extract from CrewAI's string format (e.g., 'prompt_tokens=100 completion_tokens=50')."""
103
+ usage = TokenUsage()
104
+
105
+ try:
106
+ metrics = {}
107
+ for item in str(token_usage_str).split():
108
+ if "=" in item:
109
+ key, value = item.split("=")
110
+ try:
111
+ metrics[key] = int(value)
112
+ except ValueError:
113
+ pass
114
+
115
+ usage.prompt_tokens = metrics.get("prompt_tokens")
116
+ usage.completion_tokens = metrics.get("completion_tokens")
117
+ usage.total_tokens = metrics.get("total_tokens")
118
+ usage.cached_prompt_tokens = metrics.get("cached_prompt_tokens")
119
+
120
+ except Exception as e:
121
+ logger.debug(f"Failed to parse CrewAI token usage: {e}")
122
+
123
+ return usage
124
+
125
+ @staticmethod
126
+ def _extract_from_attributes(response: Any) -> TokenUsage:
127
+ """Extract from direct attributes on the response."""
128
+ return TokenUsage(
129
+ prompt_tokens=getattr(response, "prompt_tokens", None),
130
+ completion_tokens=getattr(response, "completion_tokens", None),
131
+ total_tokens=getattr(response, "total_tokens", None),
132
+ )
133
+
134
+
135
+ def calculate_token_efficiency(usage: TokenUsage) -> Optional[float]:
136
+ """Calculate token efficiency ratio (completion/prompt).
137
+
138
+ Returns:
139
+ Efficiency ratio or None if cannot be calculated
140
+ """
141
+ if usage.prompt_tokens and usage.completion_tokens and usage.prompt_tokens > 0:
142
+ return usage.completion_tokens / usage.prompt_tokens
143
+ return None
144
+
145
+
146
+ def calculate_cache_efficiency(usage: TokenUsage) -> Optional[float]:
147
+ """Calculate cache efficiency ratio (cached/total prompt).
148
+
149
+ Returns:
150
+ Cache ratio or None if cannot be calculated
151
+ """
152
+ if usage.prompt_tokens and usage.cached_prompt_tokens and usage.prompt_tokens > 0:
153
+ return usage.cached_prompt_tokens / usage.prompt_tokens
154
+ return None
155
+
156
+
157
+ def set_token_usage_attributes(span: Any, response: Any):
158
+ """Extract and set token usage attributes on a span.
159
+
160
+ Args:
161
+ span: The span to set attributes on
162
+ response: The response object to extract usage from
163
+ """
164
+ usage = TokenUsageExtractor.extract_from_response(response)
165
+
166
+ # Set basic token attributes
167
+ for attr_name, value in usage.to_attributes().items():
168
+ span.set_attribute(attr_name, value)
169
+
170
+ # Calculate and set efficiency metrics
171
+ efficiency = calculate_token_efficiency(usage)
172
+ if efficiency is not None:
173
+ span.set_attribute("llm.token_efficiency", f"{efficiency:.4f}")
174
+
175
+ cache_efficiency = calculate_cache_efficiency(usage)
176
+ if cache_efficiency is not None:
177
+ span.set_attribute("llm.cache_efficiency", f"{cache_efficiency:.4f}")
@@ -0,0 +1,71 @@
1
+ """Version utilities for AgentOps instrumentation.
2
+
3
+ This module provides common functionality for retrieving and managing
4
+ library versions across all instrumentation modules.
5
+ """
6
+
7
+ import logging
8
+ from typing import Optional
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def get_library_version(package_name: str, default_version: str = "unknown") -> str:
14
+ """Get the version of a library package.
15
+
16
+ Attempts to retrieve the installed version of a package using importlib.metadata.
17
+ Falls back to the default version if the version cannot be determined.
18
+
19
+ Args:
20
+ package_name: The name of the package to get the version for (as used in pip/importlib.metadata)
21
+ default_version: The default version to return if the package version cannot be found
22
+
23
+ Returns:
24
+ The version string of the package or the default version
25
+
26
+ Examples:
27
+ >>> get_library_version("openai")
28
+ "1.0.0"
29
+
30
+ >>> get_library_version("nonexistent-package")
31
+ "unknown"
32
+
33
+ >>> get_library_version("ibm-watsonx-ai", "1.3.11")
34
+ "1.3.11" # If not found
35
+ """
36
+ try:
37
+ from importlib.metadata import version
38
+
39
+ return version(package_name)
40
+ except (ImportError, Exception) as e:
41
+ logger.debug(f"Could not find {package_name} version: {e}")
42
+ return default_version
43
+
44
+
45
+ class LibraryInfo:
46
+ """Container for library information used in instrumentation.
47
+
48
+ This class provides a standardized way to store and access library
49
+ information (name and version) across all instrumentors.
50
+
51
+ Attributes:
52
+ name: The library name used for identification
53
+ version: The library version string
54
+ package_name: The package name used in pip/importlib.metadata (optional)
55
+ """
56
+
57
+ def __init__(self, name: str, package_name: Optional[str] = None, default_version: str = "unknown"):
58
+ """Initialize library information.
59
+
60
+ Args:
61
+ name: The library name used for identification
62
+ package_name: The package name used in pip/importlib.metadata.
63
+ If not provided, uses the library name.
64
+ default_version: Default version if package version cannot be determined
65
+ """
66
+ self.name = name
67
+ self.package_name = package_name or name
68
+ self.version = get_library_version(self.package_name, default_version)
69
+
70
+ def __repr__(self) -> str:
71
+ return f"LibraryInfo(name={self.name!r}, version={self.version!r})"