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,87 @@
1
+ """OpenTelemetry instrumentor for the Replicate API client.
2
+
3
+ This instrumentor automatically traces calls to Replicate models, capturing
4
+ relevant attributes such as the model name.
5
+
6
+ Note: Replicate uses hardware-based pricing (per second of GPU/CPU time),
7
+ not token-based pricing. Cost tracking is not applicable as the pricing model
8
+ is fundamentally different from token-based LLM APIs.
9
+ """
10
+
11
+ import logging
12
+ from typing import Any, Dict, Optional
13
+
14
+ from ..config import OTelConfig
15
+ from .base import BaseInstrumentor
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class ReplicateInstrumentor(BaseInstrumentor):
21
+ """Instrumentor for Replicate.
22
+
23
+ Note: Replicate uses hardware-based pricing ($/second), not token-based.
24
+ Cost tracking returns None as pricing is based on execution time and hardware type.
25
+ """
26
+
27
+ def instrument(self, config: OTelConfig):
28
+ """Instrument Replicate SDK if available."""
29
+ self.config = config
30
+ try:
31
+ import replicate
32
+
33
+ original_run = replicate.run
34
+
35
+ # Wrap using create_span_wrapper
36
+ wrapped_run = self.create_span_wrapper(
37
+ span_name="replicate.run",
38
+ extract_attributes=self._extract_run_attributes,
39
+ )(original_run)
40
+
41
+ replicate.run = wrapped_run
42
+ self._instrumented = True
43
+ logger.info("Replicate instrumentation enabled")
44
+
45
+ except ImportError:
46
+ logger.debug("Replicate library not installed, instrumentation will be skipped")
47
+ except Exception as e:
48
+ logger.error("Failed to instrument Replicate: %s", e, exc_info=True)
49
+ if config.fail_on_error:
50
+ raise
51
+
52
+ def _extract_run_attributes(self, instance: Any, args: Any, kwargs: Any) -> Dict[str, Any]:
53
+ """Extract attributes from Replicate run call.
54
+
55
+ Args:
56
+ instance: The instance (None for module-level functions).
57
+ args: Positional arguments (first arg is typically the model).
58
+ kwargs: Keyword arguments.
59
+
60
+ Returns:
61
+ Dict[str, Any]: Dictionary of attributes to set on the span.
62
+ """
63
+ attrs = {}
64
+ model = args[0] if args else kwargs.get("model", "unknown")
65
+
66
+ attrs["gen_ai.system"] = "replicate"
67
+ attrs["gen_ai.request.model"] = model
68
+ attrs["gen_ai.operation.name"] = "run"
69
+
70
+ return attrs
71
+
72
+ def _extract_usage(self, result) -> Optional[Dict[str, int]]:
73
+ """Extract token usage from Replicate response.
74
+
75
+ Note: Replicate uses hardware-based pricing ($/second of GPU/CPU time),
76
+ not token-based pricing. Returns None as the pricing model is incompatible
77
+ with token-based cost calculation.
78
+
79
+ Args:
80
+ result: The API response.
81
+
82
+ Returns:
83
+ None: Replicate uses hardware-based pricing, not token-based.
84
+ """
85
+ # Replicate uses hardware-based pricing ($/second), not tokens
86
+ # Cannot track costs with token-based calculator
87
+ return None
@@ -0,0 +1,196 @@
1
+ """OpenTelemetry instrumentor for the SambaNova SDK.
2
+
3
+ This instrumentor automatically traces chat completion calls to SambaNova models,
4
+ capturing relevant attributes such as the model name and token usage.
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ from typing import Any, Dict, Optional
10
+
11
+ from ..config import OTelConfig
12
+ from .base import BaseInstrumentor
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class SambaNovaInstrumentor(BaseInstrumentor):
18
+ """Instrumentor for SambaNova"""
19
+
20
+ def __init__(self):
21
+ """Initialize the instrumentor."""
22
+ super().__init__()
23
+ self._sambanova_available = False
24
+ self._check_availability()
25
+
26
+ def _check_availability(self):
27
+ """Check if SambaNova library is available."""
28
+ try:
29
+ import sambanova
30
+
31
+ self._sambanova_available = True
32
+ logger.debug("SambaNova library detected and available for instrumentation")
33
+ except ImportError:
34
+ logger.debug("SambaNova library not installed, instrumentation will be skipped")
35
+ self._sambanova_available = False
36
+
37
+ def instrument(self, config: OTelConfig):
38
+ """Instrument SambaNova SDK if available.
39
+
40
+ Args:
41
+ config (OTelConfig): The OpenTelemetry configuration object.
42
+ """
43
+ if not self._sambanova_available:
44
+ logger.debug("Skipping SambaNova instrumentation - library not available")
45
+ return
46
+
47
+ self.config = config
48
+
49
+ try:
50
+ import sambanova
51
+
52
+ original_init = sambanova.SambaNova.__init__
53
+
54
+ def wrapped_init(instance, *args, **kwargs):
55
+ original_init(instance, *args, **kwargs)
56
+ self._instrument_client(instance)
57
+ return instance
58
+
59
+ sambanova.SambaNova.__init__ = wrapped_init
60
+ self._instrumented = True
61
+ logger.info("SambaNova instrumentation enabled")
62
+
63
+ except Exception as e:
64
+ logger.error("Failed to instrument SambaNova: %s", e, exc_info=True)
65
+ if config.fail_on_error:
66
+ raise
67
+
68
+ def _instrument_client(self, client):
69
+ """Instrument SambaNova client methods.
70
+
71
+ Args:
72
+ client: The SambaNova client instance to instrument.
73
+ """
74
+ if (
75
+ hasattr(client, "chat")
76
+ and hasattr(client.chat, "completions")
77
+ and hasattr(client.chat.completions, "create")
78
+ ):
79
+ original_create = client.chat.completions.create
80
+ instrumented_create_method = self.create_span_wrapper(
81
+ span_name="sambanova.chat.completion",
82
+ extract_attributes=self._extract_sambanova_attributes,
83
+ )(original_create)
84
+ client.chat.completions.create = instrumented_create_method
85
+
86
+ def _extract_sambanova_attributes(
87
+ self, instance: Any, args: Any, kwargs: Any
88
+ ) -> Dict[str, Any]:
89
+ """Extract attributes from SambaNova API call.
90
+
91
+ Args:
92
+ instance: The client instance.
93
+ args: Positional arguments.
94
+ kwargs: Keyword arguments.
95
+
96
+ Returns:
97
+ Dict[str, Any]: Dictionary of attributes to set on the span.
98
+ """
99
+ attrs = {}
100
+ model = kwargs.get("model", "unknown")
101
+ messages = kwargs.get("messages", [])
102
+
103
+ # Core attributes
104
+ attrs["gen_ai.system"] = "sambanova"
105
+ attrs["gen_ai.request.model"] = model
106
+ attrs["gen_ai.operation.name"] = "chat"
107
+ attrs["gen_ai.request.message_count"] = len(messages)
108
+
109
+ # Request parameters
110
+ if "temperature" in kwargs:
111
+ attrs["gen_ai.request.temperature"] = kwargs["temperature"]
112
+ if "top_p" in kwargs:
113
+ attrs["gen_ai.request.top_p"] = kwargs["top_p"]
114
+ if "max_tokens" in kwargs:
115
+ attrs["gen_ai.request.max_tokens"] = kwargs["max_tokens"]
116
+
117
+ # Tool/function definitions
118
+ if "tools" in kwargs:
119
+ try:
120
+ attrs["llm.tools"] = json.dumps(kwargs["tools"])
121
+ except (TypeError, ValueError) as e:
122
+ logger.debug("Failed to serialize tools: %s", e)
123
+
124
+ if messages:
125
+ # Only capture first 200 chars to avoid sensitive data and span size issues
126
+ first_message = str(messages[0])[:200]
127
+ attrs["gen_ai.request.first_message"] = first_message
128
+
129
+ return attrs
130
+
131
+ def _extract_usage(self, result) -> Optional[Dict[str, int]]:
132
+ """Extract token usage from SambaNova response.
133
+
134
+ Args:
135
+ result: The API response object.
136
+
137
+ Returns:
138
+ Optional[Dict[str, int]]: Dictionary with token counts or None.
139
+ """
140
+ if hasattr(result, "usage") and result.usage:
141
+ usage = result.usage
142
+ return {
143
+ "prompt_tokens": getattr(usage, "prompt_tokens", 0),
144
+ "completion_tokens": getattr(usage, "completion_tokens", 0),
145
+ "total_tokens": getattr(usage, "total_tokens", 0),
146
+ }
147
+ return None
148
+
149
+ def _extract_response_attributes(self, result) -> Dict[str, Any]:
150
+ """Extract response attributes from SambaNova response.
151
+
152
+ Args:
153
+ result: The API response object.
154
+
155
+ Returns:
156
+ Dict[str, Any]: Dictionary of response attributes.
157
+ """
158
+ attrs = {}
159
+
160
+ # Response ID
161
+ if hasattr(result, "id"):
162
+ attrs["gen_ai.response.id"] = result.id
163
+
164
+ # Response model (actual model used, may differ from request)
165
+ if hasattr(result, "model"):
166
+ attrs["gen_ai.response.model"] = result.model
167
+
168
+ # Finish reasons
169
+ if hasattr(result, "choices") and result.choices:
170
+ finish_reasons = [
171
+ choice.finish_reason
172
+ for choice in result.choices
173
+ if hasattr(choice, "finish_reason")
174
+ ]
175
+ if finish_reasons:
176
+ attrs["gen_ai.response.finish_reasons"] = finish_reasons
177
+
178
+ return attrs
179
+
180
+ def _extract_finish_reason(self, result) -> Optional[str]:
181
+ """Extract finish reason from SambaNova response.
182
+
183
+ Args:
184
+ result: The SambaNova API response object.
185
+
186
+ Returns:
187
+ Optional[str]: The finish reason string or None if not available.
188
+ """
189
+ try:
190
+ if hasattr(result, "choices") and result.choices:
191
+ first_choice = result.choices[0]
192
+ if hasattr(first_choice, "finish_reason"):
193
+ return first_choice.finish_reason
194
+ except Exception as e:
195
+ logger.debug("Failed to extract finish_reason: %s", e)
196
+ return None
@@ -0,0 +1,146 @@
1
+ """OpenTelemetry instrumentor for the Together AI SDK.
2
+
3
+ This instrumentor automatically traces completion calls to Together AI models,
4
+ capturing 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 TogetherAIInstrumentor(BaseInstrumentor):
17
+ """Instrumentor for Together AI"""
18
+
19
+ def instrument(self, config: OTelConfig):
20
+ """Instrument Together AI SDK if available."""
21
+ self.config = config
22
+ try:
23
+ import together
24
+
25
+ # Instrument chat completions (newer API)
26
+ if hasattr(together, "Together"):
27
+ # This is the newer Together SDK with client-based API
28
+ original_init = together.Together.__init__
29
+
30
+ def wrapped_init(instance, *args, **kwargs):
31
+ original_init(instance, *args, **kwargs)
32
+ self._instrument_client(instance)
33
+
34
+ together.Together.__init__ = wrapped_init
35
+ self._instrumented = True
36
+ logger.info("Together AI instrumentation enabled (client-based API)")
37
+ # Fallback to older Complete API if available
38
+ elif hasattr(together, "Complete"):
39
+ original_complete = together.Complete.create
40
+
41
+ wrapped_complete = self.create_span_wrapper(
42
+ span_name="together.complete",
43
+ extract_attributes=self._extract_complete_attributes,
44
+ )(original_complete)
45
+
46
+ together.Complete.create = wrapped_complete
47
+ self._instrumented = True
48
+ logger.info("Together AI instrumentation enabled (Complete API)")
49
+
50
+ except ImportError:
51
+ logger.debug("Together AI library not installed, instrumentation will be skipped")
52
+ except Exception as e:
53
+ logger.error("Failed to instrument Together AI: %s", e, exc_info=True)
54
+ if config.fail_on_error:
55
+ raise
56
+
57
+ def _instrument_client(self, client):
58
+ """Instrument Together AI client methods."""
59
+ if hasattr(client, "chat") and hasattr(client.chat, "completions"):
60
+ original_create = client.chat.completions.create
61
+
62
+ wrapped_create = self.create_span_wrapper(
63
+ span_name="together.chat.completion",
64
+ extract_attributes=self._extract_chat_attributes,
65
+ )(original_create)
66
+
67
+ client.chat.completions.create = wrapped_create
68
+
69
+ def _extract_chat_attributes(self, instance: Any, args: Any, kwargs: Any) -> Dict[str, Any]:
70
+ """Extract attributes from Together AI chat completion call.
71
+
72
+ Args:
73
+ instance: The client instance.
74
+ args: Positional arguments.
75
+ kwargs: Keyword arguments.
76
+
77
+ Returns:
78
+ Dict[str, Any]: Dictionary of attributes to set on the span.
79
+ """
80
+ attrs = {}
81
+ model = kwargs.get("model", "unknown")
82
+ messages = kwargs.get("messages", [])
83
+
84
+ attrs["gen_ai.system"] = "together"
85
+ attrs["gen_ai.request.model"] = model
86
+ attrs["gen_ai.operation.name"] = "chat"
87
+ attrs["gen_ai.request.message_count"] = len(messages)
88
+
89
+ # Optional parameters
90
+ if "temperature" in kwargs:
91
+ attrs["gen_ai.request.temperature"] = kwargs["temperature"]
92
+ if "top_p" in kwargs:
93
+ attrs["gen_ai.request.top_p"] = kwargs["top_p"]
94
+ if "max_tokens" in kwargs:
95
+ attrs["gen_ai.request.max_tokens"] = kwargs["max_tokens"]
96
+
97
+ return attrs
98
+
99
+ def _extract_complete_attributes(self, instance: Any, args: Any, kwargs: Any) -> Dict[str, Any]:
100
+ """Extract attributes from Together AI complete call.
101
+
102
+ Args:
103
+ instance: The instance (None for class methods).
104
+ args: Positional arguments.
105
+ kwargs: Keyword arguments.
106
+
107
+ Returns:
108
+ Dict[str, Any]: Dictionary of attributes to set on the span.
109
+ """
110
+ attrs = {}
111
+ model = kwargs.get("model", "unknown")
112
+
113
+ attrs["gen_ai.system"] = "together"
114
+ attrs["gen_ai.request.model"] = model
115
+ attrs["gen_ai.operation.name"] = "complete"
116
+
117
+ return attrs
118
+
119
+ def _extract_usage(self, result) -> Optional[Dict[str, int]]:
120
+ """Extract token usage from Together AI response.
121
+
122
+ Together AI uses OpenAI-compatible format with usage field containing:
123
+ - prompt_tokens: Input tokens
124
+ - completion_tokens: Output tokens
125
+ - total_tokens: Total tokens
126
+
127
+ Args:
128
+ result: The API response object.
129
+
130
+ Returns:
131
+ Optional[Dict[str, int]]: Dictionary with token counts or None.
132
+ """
133
+ try:
134
+ # Handle OpenAI-compatible response format
135
+ if hasattr(result, "usage") and result.usage:
136
+ usage = result.usage
137
+ return {
138
+ "prompt_tokens": getattr(usage, "prompt_tokens", 0),
139
+ "completion_tokens": getattr(usage, "completion_tokens", 0),
140
+ "total_tokens": getattr(usage, "total_tokens", 0),
141
+ }
142
+
143
+ return None
144
+ except Exception as e:
145
+ logger.debug("Failed to extract usage from Together AI response: %s", e)
146
+ return None
@@ -0,0 +1,106 @@
1
+ """OpenTelemetry instrumentor for Google Vertex AI SDK.
2
+
3
+ This instrumentor automatically traces content generation calls to Vertex AI models,
4
+ capturing 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 VertexAIInstrumentor(BaseInstrumentor):
17
+ """Instrumentor for Google Vertex AI"""
18
+
19
+ def instrument(self, config: OTelConfig):
20
+ """Instrument Vertex AI SDK if available."""
21
+ self.config = config
22
+ try:
23
+ from vertexai.preview.generative_models import GenerativeModel
24
+
25
+ original_generate = GenerativeModel.generate_content
26
+
27
+ # Wrap using create_span_wrapper
28
+ wrapped_generate = self.create_span_wrapper(
29
+ span_name="vertexai.generate_content",
30
+ extract_attributes=self._extract_generate_attributes,
31
+ )(original_generate)
32
+
33
+ GenerativeModel.generate_content = wrapped_generate
34
+ self._instrumented = True
35
+ logger.info("Vertex AI instrumentation enabled")
36
+
37
+ except ImportError:
38
+ logger.debug("Vertex AI library not installed, instrumentation will be skipped")
39
+ except Exception as e:
40
+ logger.error("Failed to instrument Vertex AI: %s", e, exc_info=True)
41
+ if config.fail_on_error:
42
+ raise
43
+
44
+ def _extract_generate_attributes(self, instance: Any, args: Any, kwargs: Any) -> Dict[str, Any]:
45
+ """Extract attributes from Vertex AI generate_content call.
46
+
47
+ Args:
48
+ instance: The GenerativeModel instance.
49
+ args: Positional arguments.
50
+ kwargs: Keyword arguments.
51
+
52
+ Returns:
53
+ Dict[str, Any]: Dictionary of attributes to set on the span.
54
+ """
55
+ attrs = {}
56
+ model_name = getattr(instance, "_model_name", "unknown")
57
+
58
+ attrs["gen_ai.system"] = "vertexai"
59
+ attrs["gen_ai.request.model"] = model_name
60
+ attrs["gen_ai.operation.name"] = "generate_content"
61
+
62
+ return attrs
63
+
64
+ def _extract_usage(self, result) -> Optional[Dict[str, int]]:
65
+ """Extract token usage from Vertex AI response.
66
+
67
+ Vertex AI responses include usage_metadata with:
68
+ - prompt_token_count: Input tokens
69
+ - candidates_token_count: Output tokens
70
+ - total_token_count: Total tokens
71
+
72
+ Args:
73
+ result: The API response object.
74
+
75
+ Returns:
76
+ Optional[Dict[str, int]]: Dictionary with token counts or None.
77
+ """
78
+ try:
79
+ # Handle response with usage_metadata
80
+ if hasattr(result, "usage_metadata") and result.usage_metadata:
81
+ usage_metadata = result.usage_metadata
82
+
83
+ # Try snake_case first (Python SDK style)
84
+ prompt_tokens = getattr(usage_metadata, "prompt_token_count", None)
85
+ candidates_tokens = getattr(usage_metadata, "candidates_token_count", None)
86
+ total_tokens = getattr(usage_metadata, "total_token_count", None)
87
+
88
+ # Fallback to camelCase (REST API style)
89
+ if prompt_tokens is None:
90
+ prompt_tokens = getattr(usage_metadata, "promptTokenCount", 0)
91
+ if candidates_tokens is None:
92
+ candidates_tokens = getattr(usage_metadata, "candidatesTokenCount", 0)
93
+ if total_tokens is None:
94
+ total_tokens = getattr(usage_metadata, "totalTokenCount", 0)
95
+
96
+ if prompt_tokens or candidates_tokens:
97
+ return {
98
+ "prompt_tokens": int(prompt_tokens or 0),
99
+ "completion_tokens": int(candidates_tokens or 0),
100
+ "total_tokens": int(total_tokens or 0),
101
+ }
102
+
103
+ return None
104
+ except Exception as e:
105
+ logger.debug("Failed to extract usage from Vertex AI response: %s", e)
106
+ return None