genai-otel-instrument 0.1.24__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 (69) hide show
  1. genai_otel/__init__.py +132 -0
  2. genai_otel/__version__.py +34 -0
  3. genai_otel/auto_instrument.py +602 -0
  4. genai_otel/cli.py +92 -0
  5. genai_otel/config.py +333 -0
  6. genai_otel/cost_calculator.py +467 -0
  7. genai_otel/cost_enriching_exporter.py +207 -0
  8. genai_otel/cost_enrichment_processor.py +174 -0
  9. genai_otel/evaluation/__init__.py +76 -0
  10. genai_otel/evaluation/bias_detector.py +364 -0
  11. genai_otel/evaluation/config.py +261 -0
  12. genai_otel/evaluation/hallucination_detector.py +525 -0
  13. genai_otel/evaluation/pii_detector.py +356 -0
  14. genai_otel/evaluation/prompt_injection_detector.py +262 -0
  15. genai_otel/evaluation/restricted_topics_detector.py +316 -0
  16. genai_otel/evaluation/span_processor.py +962 -0
  17. genai_otel/evaluation/toxicity_detector.py +406 -0
  18. genai_otel/exceptions.py +17 -0
  19. genai_otel/gpu_metrics.py +516 -0
  20. genai_otel/instrumentors/__init__.py +71 -0
  21. genai_otel/instrumentors/anthropic_instrumentor.py +134 -0
  22. genai_otel/instrumentors/anyscale_instrumentor.py +27 -0
  23. genai_otel/instrumentors/autogen_instrumentor.py +394 -0
  24. genai_otel/instrumentors/aws_bedrock_instrumentor.py +94 -0
  25. genai_otel/instrumentors/azure_openai_instrumentor.py +69 -0
  26. genai_otel/instrumentors/base.py +919 -0
  27. genai_otel/instrumentors/bedrock_agents_instrumentor.py +398 -0
  28. genai_otel/instrumentors/cohere_instrumentor.py +140 -0
  29. genai_otel/instrumentors/crewai_instrumentor.py +311 -0
  30. genai_otel/instrumentors/dspy_instrumentor.py +661 -0
  31. genai_otel/instrumentors/google_ai_instrumentor.py +310 -0
  32. genai_otel/instrumentors/groq_instrumentor.py +106 -0
  33. genai_otel/instrumentors/guardrails_ai_instrumentor.py +510 -0
  34. genai_otel/instrumentors/haystack_instrumentor.py +503 -0
  35. genai_otel/instrumentors/huggingface_instrumentor.py +399 -0
  36. genai_otel/instrumentors/hyperbolic_instrumentor.py +236 -0
  37. genai_otel/instrumentors/instructor_instrumentor.py +425 -0
  38. genai_otel/instrumentors/langchain_instrumentor.py +340 -0
  39. genai_otel/instrumentors/langgraph_instrumentor.py +328 -0
  40. genai_otel/instrumentors/llamaindex_instrumentor.py +36 -0
  41. genai_otel/instrumentors/mistralai_instrumentor.py +315 -0
  42. genai_otel/instrumentors/ollama_instrumentor.py +197 -0
  43. genai_otel/instrumentors/ollama_server_metrics_poller.py +336 -0
  44. genai_otel/instrumentors/openai_agents_instrumentor.py +291 -0
  45. genai_otel/instrumentors/openai_instrumentor.py +260 -0
  46. genai_otel/instrumentors/pydantic_ai_instrumentor.py +362 -0
  47. genai_otel/instrumentors/replicate_instrumentor.py +87 -0
  48. genai_otel/instrumentors/sambanova_instrumentor.py +196 -0
  49. genai_otel/instrumentors/togetherai_instrumentor.py +146 -0
  50. genai_otel/instrumentors/vertexai_instrumentor.py +106 -0
  51. genai_otel/llm_pricing.json +1676 -0
  52. genai_otel/logging_config.py +45 -0
  53. genai_otel/mcp_instrumentors/__init__.py +14 -0
  54. genai_otel/mcp_instrumentors/api_instrumentor.py +144 -0
  55. genai_otel/mcp_instrumentors/base.py +105 -0
  56. genai_otel/mcp_instrumentors/database_instrumentor.py +336 -0
  57. genai_otel/mcp_instrumentors/kafka_instrumentor.py +31 -0
  58. genai_otel/mcp_instrumentors/manager.py +139 -0
  59. genai_otel/mcp_instrumentors/redis_instrumentor.py +31 -0
  60. genai_otel/mcp_instrumentors/vector_db_instrumentor.py +265 -0
  61. genai_otel/metrics.py +148 -0
  62. genai_otel/py.typed +2 -0
  63. genai_otel/server_metrics.py +197 -0
  64. genai_otel_instrument-0.1.24.dist-info/METADATA +1404 -0
  65. genai_otel_instrument-0.1.24.dist-info/RECORD +69 -0
  66. genai_otel_instrument-0.1.24.dist-info/WHEEL +5 -0
  67. genai_otel_instrument-0.1.24.dist-info/entry_points.txt +2 -0
  68. genai_otel_instrument-0.1.24.dist-info/licenses/LICENSE +680 -0
  69. genai_otel_instrument-0.1.24.dist-info/top_level.txt +1 -0
@@ -0,0 +1,398 @@
1
+ """OpenTelemetry instrumentor for AWS Bedrock Agents.
2
+
3
+ This instrumentor automatically traces agent invocations, action groups,
4
+ knowledge base queries, and agent orchestration using AWS Bedrock Agents.
5
+
6
+ AWS Bedrock Agents is a managed service that helps you build and deploy
7
+ generative AI applications with agents that can reason, take actions, and
8
+ access knowledge bases.
9
+
10
+ Requirements:
11
+ pip install boto3 botocore
12
+ """
13
+
14
+ import json
15
+ import logging
16
+ from typing import Any, Dict, Optional
17
+
18
+ from ..config import OTelConfig
19
+ from .base import BaseInstrumentor
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
+ class BedrockAgentsInstrumentor(BaseInstrumentor):
25
+ """Instrumentor for AWS Bedrock Agents"""
26
+
27
+ def __init__(self):
28
+ """Initialize the instrumentor."""
29
+ super().__init__()
30
+ self._bedrock_agents_available = False
31
+ self._check_availability()
32
+
33
+ def _check_availability(self):
34
+ """Check if Bedrock Agents runtime is available."""
35
+ try:
36
+ import boto3
37
+
38
+ # Check if bedrock-agent-runtime service is available
39
+ session = boto3.session.Session()
40
+ if "bedrock-agent-runtime" in session.get_available_services():
41
+ self._bedrock_agents_available = True
42
+ logger.debug(
43
+ "AWS Bedrock Agents runtime detected and available for instrumentation"
44
+ )
45
+ else:
46
+ logger.debug("AWS Bedrock Agents runtime service not available")
47
+ self._bedrock_agents_available = False
48
+ except ImportError:
49
+ logger.debug("boto3 not installed, Bedrock Agents instrumentation will be skipped")
50
+ self._bedrock_agents_available = False
51
+
52
+ def instrument(self, config: OTelConfig):
53
+ """Instrument AWS Bedrock Agents if available.
54
+
55
+ Args:
56
+ config (OTelConfig): The OpenTelemetry configuration object.
57
+ """
58
+ if not self._bedrock_agents_available:
59
+ logger.debug("Skipping Bedrock Agents instrumentation - library not available")
60
+ return
61
+
62
+ self.config = config
63
+
64
+ try:
65
+ import wrapt
66
+ from botocore.client import BaseClient
67
+
68
+ # Store original make_request method
69
+ original_make_request = BaseClient._make_request
70
+
71
+ # Wrap the _make_request method
72
+ def wrapped_make_request(self, operation_model, request_dict, *args, **kwargs):
73
+ # Only instrument bedrock-agent-runtime operations
74
+ if (
75
+ hasattr(self, "_service_model")
76
+ and self._service_model.service_name == "bedrock-agent-runtime"
77
+ ):
78
+ operation_name = operation_model.name
79
+
80
+ # Instrument invoke_agent operation
81
+ if operation_name == "InvokeAgent":
82
+ return self._instrumentor.create_span_wrapper(
83
+ span_name="bedrock.agents.invoke_agent",
84
+ extract_attributes=lambda inst, args, kwargs: self._instrumentor._extract_invoke_agent_attributes(
85
+ request_dict
86
+ ),
87
+ )(original_make_request)(
88
+ self, operation_model, request_dict, *args, **kwargs
89
+ )
90
+
91
+ # Instrument retrieve operation
92
+ elif operation_name == "Retrieve":
93
+ return self._instrumentor.create_span_wrapper(
94
+ span_name="bedrock.agents.retrieve",
95
+ extract_attributes=lambda inst, args, kwargs: self._instrumentor._extract_retrieve_attributes(
96
+ request_dict
97
+ ),
98
+ )(original_make_request)(
99
+ self, operation_model, request_dict, *args, **kwargs
100
+ )
101
+
102
+ # Instrument retrieve_and_generate operation
103
+ elif operation_name == "RetrieveAndGenerate":
104
+ return self._instrumentor.create_span_wrapper(
105
+ span_name="bedrock.agents.retrieve_and_generate",
106
+ extract_attributes=lambda inst, args, kwargs: self._instrumentor._extract_retrieve_and_generate_attributes(
107
+ request_dict
108
+ ),
109
+ )(original_make_request)(
110
+ self, operation_model, request_dict, *args, **kwargs
111
+ )
112
+
113
+ # Call original for non-bedrock-agent-runtime operations
114
+ return original_make_request(self, operation_model, request_dict, *args, **kwargs)
115
+
116
+ # Attach instrumentor reference for access in wrapper
117
+ BaseClient._instrumentor = self
118
+
119
+ # Replace the method
120
+ BaseClient._make_request = wrapped_make_request
121
+
122
+ self._instrumented = True
123
+ logger.info("AWS Bedrock Agents instrumentation enabled")
124
+
125
+ except Exception as e:
126
+ logger.error("Failed to instrument AWS Bedrock Agents: %s", e, exc_info=True)
127
+ if config.fail_on_error:
128
+ raise
129
+
130
+ def _extract_invoke_agent_attributes(self, request_dict: Dict[str, Any]) -> Dict[str, Any]:
131
+ """Extract attributes from InvokeAgent request.
132
+
133
+ Args:
134
+ request_dict: The request dictionary from boto3.
135
+
136
+ Returns:
137
+ Dict[str, Any]: Dictionary of attributes to set on the span.
138
+ """
139
+ attrs = {}
140
+
141
+ # Core attributes
142
+ attrs["gen_ai.system"] = "bedrock_agents"
143
+ attrs["gen_ai.operation.name"] = "invoke_agent"
144
+
145
+ try:
146
+ # Extract from body (already serialized JSON)
147
+ body = request_dict.get("body", {})
148
+ if isinstance(body, str):
149
+ body = json.loads(body)
150
+
151
+ # Extract agent identifiers
152
+ if "agentId" in body:
153
+ attrs["bedrock.agent.id"] = body["agentId"]
154
+ if "agentAliasId" in body:
155
+ attrs["bedrock.agent.alias_id"] = body["agentAliasId"]
156
+
157
+ # Extract session information
158
+ if "sessionId" in body:
159
+ attrs["bedrock.agent.session_id"] = body["sessionId"]
160
+
161
+ # Extract input text
162
+ if "inputText" in body:
163
+ attrs["bedrock.agent.input_text"] = str(body["inputText"])[:500]
164
+
165
+ # Extract session state if present
166
+ if "sessionState" in body:
167
+ session_state = body["sessionState"]
168
+ if isinstance(session_state, dict):
169
+ # Extract prompt session attributes
170
+ if "promptSessionAttributes" in session_state:
171
+ attrs["bedrock.agent.prompt_attributes"] = str(
172
+ session_state["promptSessionAttributes"]
173
+ )[:200]
174
+
175
+ # Extract session attributes
176
+ if "sessionAttributes" in session_state:
177
+ attrs["bedrock.agent.session_attributes"] = str(
178
+ session_state["sessionAttributes"]
179
+ )[:200]
180
+
181
+ # Extract enable trace flag
182
+ if "enableTrace" in body:
183
+ attrs["bedrock.agent.enable_trace"] = body["enableTrace"]
184
+
185
+ except Exception as e:
186
+ logger.debug("Failed to extract invoke_agent attributes: %s", e)
187
+
188
+ return attrs
189
+
190
+ def _extract_retrieve_attributes(self, request_dict: Dict[str, Any]) -> Dict[str, Any]:
191
+ """Extract attributes from Retrieve request.
192
+
193
+ Args:
194
+ request_dict: The request dictionary from boto3.
195
+
196
+ Returns:
197
+ Dict[str, Any]: Dictionary of attributes to set on the span.
198
+ """
199
+ attrs = {}
200
+
201
+ # Core attributes
202
+ attrs["gen_ai.system"] = "bedrock_agents"
203
+ attrs["gen_ai.operation.name"] = "retrieve"
204
+
205
+ try:
206
+ body = request_dict.get("body", {})
207
+ if isinstance(body, str):
208
+ body = json.loads(body)
209
+
210
+ # Extract knowledge base ID
211
+ if "knowledgeBaseId" in body:
212
+ attrs["bedrock.knowledge_base.id"] = body["knowledgeBaseId"]
213
+
214
+ # Extract retrieval query
215
+ if "retrievalQuery" in body:
216
+ query = body["retrievalQuery"]
217
+ if isinstance(query, dict) and "text" in query:
218
+ attrs["bedrock.retrieval.query"] = str(query["text"])[:500]
219
+
220
+ # Extract retrieval configuration
221
+ if "retrievalConfiguration" in body:
222
+ config = body["retrievalConfiguration"]
223
+ if isinstance(config, dict):
224
+ # Extract vector search config
225
+ if "vectorSearchConfiguration" in config:
226
+ vector_config = config["vectorSearchConfiguration"]
227
+ if "numberOfResults" in vector_config:
228
+ attrs["bedrock.retrieval.number_of_results"] = vector_config[
229
+ "numberOfResults"
230
+ ]
231
+ if "overrideSearchType" in vector_config:
232
+ attrs["bedrock.retrieval.search_type"] = vector_config[
233
+ "overrideSearchType"
234
+ ]
235
+
236
+ except Exception as e:
237
+ logger.debug("Failed to extract retrieve attributes: %s", e)
238
+
239
+ return attrs
240
+
241
+ def _extract_retrieve_and_generate_attributes(
242
+ self, request_dict: Dict[str, Any]
243
+ ) -> Dict[str, Any]:
244
+ """Extract attributes from RetrieveAndGenerate request.
245
+
246
+ Args:
247
+ request_dict: The request dictionary from boto3.
248
+
249
+ Returns:
250
+ Dict[str, Any]: Dictionary of attributes to set on the span.
251
+ """
252
+ attrs = {}
253
+
254
+ # Core attributes
255
+ attrs["gen_ai.system"] = "bedrock_agents"
256
+ attrs["gen_ai.operation.name"] = "retrieve_and_generate"
257
+
258
+ try:
259
+ body = request_dict.get("body", {})
260
+ if isinstance(body, str):
261
+ body = json.loads(body)
262
+
263
+ # Extract input
264
+ if "input" in body:
265
+ input_data = body["input"]
266
+ if isinstance(input_data, dict) and "text" in input_data:
267
+ attrs["bedrock.rag.input_text"] = str(input_data["text"])[:500]
268
+
269
+ # Extract session ID
270
+ if "sessionId" in body:
271
+ attrs["bedrock.rag.session_id"] = body["sessionId"]
272
+
273
+ # Extract retrieve and generate configuration
274
+ if "retrieveAndGenerateConfiguration" in body:
275
+ config = body["retrieveAndGenerateConfiguration"]
276
+ if isinstance(config, dict):
277
+ # Extract type
278
+ if "type" in config:
279
+ attrs["bedrock.rag.type"] = config["type"]
280
+
281
+ # Extract knowledge base configuration
282
+ if "knowledgeBaseConfiguration" in config:
283
+ kb_config = config["knowledgeBaseConfiguration"]
284
+ if "knowledgeBaseId" in kb_config:
285
+ attrs["bedrock.knowledge_base.id"] = kb_config["knowledgeBaseId"]
286
+ if "modelArn" in kb_config:
287
+ attrs["gen_ai.request.model"] = kb_config["modelArn"]
288
+
289
+ # Extract generation configuration
290
+ if "generationConfiguration" in kb_config:
291
+ gen_config = kb_config["generationConfiguration"]
292
+ if "inferenceConfig" in gen_config:
293
+ inference = gen_config["inferenceConfig"]
294
+ if "temperature" in inference:
295
+ attrs["gen_ai.request.temperature"] = inference["temperature"]
296
+ if "maxTokens" in inference:
297
+ attrs["gen_ai.request.max_tokens"] = inference["maxTokens"]
298
+ if "topP" in inference:
299
+ attrs["gen_ai.request.top_p"] = inference["topP"]
300
+
301
+ except Exception as e:
302
+ logger.debug("Failed to extract retrieve_and_generate attributes: %s", e)
303
+
304
+ return attrs
305
+
306
+ def _extract_usage(self, result) -> Optional[Dict[str, int]]:
307
+ """Extract token usage from agent response.
308
+
309
+ Note: Bedrock Agents may not expose token usage directly.
310
+ Token usage is captured by underlying Bedrock model calls.
311
+
312
+ Args:
313
+ result: The agent invocation result.
314
+
315
+ Returns:
316
+ Optional[Dict[str, int]]: Dictionary with token counts or None.
317
+ """
318
+ # Bedrock Agents responses don't typically include token usage
319
+ # Token usage is tracked by underlying Bedrock model instrumentor
320
+ return None
321
+
322
+ def _extract_response_attributes(self, result) -> Dict[str, Any]:
323
+ """Extract response attributes from agent result.
324
+
325
+ Args:
326
+ result: The agent invocation result.
327
+
328
+ Returns:
329
+ Dict[str, Any]: Dictionary of response attributes.
330
+ """
331
+ attrs = {}
332
+
333
+ try:
334
+ # For invoke_agent responses
335
+ if isinstance(result, dict):
336
+ # Extract session ID
337
+ if "sessionId" in result:
338
+ attrs["bedrock.agent.response.session_id"] = result["sessionId"]
339
+
340
+ # Extract content type
341
+ if "contentType" in result:
342
+ attrs["bedrock.agent.response.content_type"] = result["contentType"]
343
+
344
+ # For streaming responses, we get an event stream
345
+ # The actual response parsing would happen in the application code
346
+ # We can log that a response was received
347
+ if "completion" in result:
348
+ attrs["bedrock.agent.response.has_completion"] = True
349
+
350
+ # For retrieve responses
351
+ if "retrievalResults" in result:
352
+ retrieval_results = result["retrievalResults"]
353
+ if isinstance(retrieval_results, list):
354
+ attrs["bedrock.retrieval.results_count"] = len(retrieval_results)
355
+
356
+ # For retrieve_and_generate responses
357
+ if "output" in result:
358
+ output = result["output"]
359
+ if isinstance(output, dict):
360
+ if "text" in output:
361
+ attrs["bedrock.rag.output_text"] = str(output["text"])[:500]
362
+
363
+ # Extract citations if present
364
+ if "citations" in result:
365
+ citations = result["citations"]
366
+ if isinstance(citations, list):
367
+ attrs["bedrock.rag.citations_count"] = len(citations)
368
+
369
+ except Exception as e:
370
+ logger.debug("Failed to extract response attributes: %s", e)
371
+
372
+ return attrs
373
+
374
+ def _extract_finish_reason(self, result) -> Optional[str]:
375
+ """Extract finish reason from agent result.
376
+
377
+ Args:
378
+ result: The agent invocation result.
379
+
380
+ Returns:
381
+ Optional[str]: The finish reason string or None if not available.
382
+ """
383
+ try:
384
+ # For streaming responses, finish reason might be in the final event
385
+ # For non-streaming, completion indicates success
386
+ if isinstance(result, dict):
387
+ # Check for stop reason in streaming events
388
+ if "stopReason" in result:
389
+ return result["stopReason"]
390
+
391
+ # If we have output/completion, assume successful completion
392
+ if "output" in result or "completion" in result:
393
+ return "completed"
394
+
395
+ except Exception as e:
396
+ logger.debug("Failed to extract finish reason: %s", e)
397
+
398
+ return None
@@ -0,0 +1,140 @@
1
+ """OpenTelemetry instrumentor for the Cohere SDK.
2
+
3
+ This instrumentor automatically traces calls to Cohere models, capturing
4
+ relevant attributes such as the model name and token usage.
5
+ """
6
+
7
+ import logging
8
+ from typing import Any, Dict, Optional
9
+
10
+ from ..config import OTelConfig
11
+ from .base import BaseInstrumentor
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class CohereInstrumentor(BaseInstrumentor):
17
+ """Instrumentor for Cohere"""
18
+
19
+ def __init__(self):
20
+ """Initialize the instrumentor."""
21
+ super().__init__()
22
+ self._cohere_available = False
23
+ self._check_availability()
24
+
25
+ def _check_availability(self):
26
+ """Check if cohere library is available."""
27
+ try:
28
+ import cohere
29
+
30
+ self._cohere_available = True
31
+ logger.debug("cohere library detected and available for instrumentation")
32
+ except ImportError:
33
+ logger.debug("cohere library not installed, instrumentation will be skipped")
34
+ self._cohere_available = False
35
+
36
+ def instrument(self, config: OTelConfig):
37
+ """Instrument cohere if available."""
38
+ if not self._cohere_available:
39
+ logger.debug("Skipping instrumentation - library not available")
40
+ return
41
+
42
+ self.config = config
43
+ try:
44
+ import cohere
45
+
46
+ original_init = cohere.Client.__init__
47
+
48
+ def wrapped_init(instance, *args, **kwargs):
49
+ original_init(instance, *args, **kwargs)
50
+ self._instrument_client(instance)
51
+
52
+ cohere.Client.__init__ = wrapped_init
53
+ self._instrumented = True
54
+ logger.info("Cohere instrumentation enabled")
55
+
56
+ except Exception as e:
57
+ logger.error("Failed to instrument Cohere: %s", e, exc_info=True)
58
+ if config.fail_on_error:
59
+ raise
60
+
61
+ def _instrument_client(self, client):
62
+ """Instrument Cohere client methods."""
63
+ original_generate = client.generate
64
+
65
+ # Wrap using create_span_wrapper
66
+ wrapped_generate = self.create_span_wrapper(
67
+ span_name="cohere.generate",
68
+ extract_attributes=self._extract_generate_attributes,
69
+ )(original_generate)
70
+
71
+ client.generate = wrapped_generate
72
+
73
+ def _extract_generate_attributes(self, instance: Any, args: Any, kwargs: Any) -> Dict[str, Any]:
74
+ """Extract attributes from Cohere generate call.
75
+
76
+ Args:
77
+ instance: The client instance.
78
+ args: Positional arguments.
79
+ kwargs: Keyword arguments.
80
+
81
+ Returns:
82
+ Dict[str, Any]: Dictionary of attributes to set on the span.
83
+ """
84
+ attrs = {}
85
+ model = kwargs.get("model", "command")
86
+ prompt = kwargs.get("prompt", "")
87
+
88
+ attrs["gen_ai.system"] = "cohere"
89
+ attrs["gen_ai.request.model"] = model
90
+ attrs["gen_ai.operation.name"] = "generate"
91
+ attrs["gen_ai.request.message_count"] = 1 if prompt else 0
92
+
93
+ return attrs
94
+
95
+ def _extract_usage(self, result) -> Optional[Dict[str, int]]:
96
+ """Extract token usage from Cohere response.
97
+
98
+ Cohere responses include meta.tokens with:
99
+ - input_tokens: Input tokens
100
+ - output_tokens: Output tokens
101
+
102
+ Args:
103
+ result: The API response object.
104
+
105
+ Returns:
106
+ Optional[Dict[str, int]]: Dictionary with token counts or None.
107
+ """
108
+ try:
109
+ # Handle object response
110
+ if hasattr(result, "meta") and result.meta:
111
+ meta = result.meta
112
+ # Check for tokens object
113
+ if hasattr(meta, "tokens") and meta.tokens:
114
+ tokens = meta.tokens
115
+ input_tokens = getattr(tokens, "input_tokens", 0)
116
+ output_tokens = getattr(tokens, "output_tokens", 0)
117
+
118
+ if input_tokens or output_tokens:
119
+ return {
120
+ "prompt_tokens": int(input_tokens) if input_tokens else 0,
121
+ "completion_tokens": int(output_tokens) if output_tokens else 0,
122
+ "total_tokens": int(input_tokens or 0) + int(output_tokens or 0),
123
+ }
124
+ # Fallback to billed_units
125
+ elif hasattr(meta, "billed_units") and meta.billed_units:
126
+ billed = meta.billed_units
127
+ input_tokens = getattr(billed, "input_tokens", 0)
128
+ output_tokens = getattr(billed, "output_tokens", 0)
129
+
130
+ if input_tokens or output_tokens:
131
+ return {
132
+ "prompt_tokens": int(input_tokens) if input_tokens else 0,
133
+ "completion_tokens": int(output_tokens) if output_tokens else 0,
134
+ "total_tokens": int(input_tokens or 0) + int(output_tokens or 0),
135
+ }
136
+
137
+ return None
138
+ except Exception as e:
139
+ logger.debug("Failed to extract usage from Cohere response: %s", e)
140
+ return None