kalibr 1.0.28__py3-none-any.whl → 1.1.0__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 (52) hide show
  1. kalibr/__init__.py +170 -3
  2. kalibr/__main__.py +3 -203
  3. kalibr/capsule_middleware.py +108 -0
  4. kalibr/cli/__init__.py +5 -0
  5. kalibr/cli/capsule_cmd.py +174 -0
  6. kalibr/cli/deploy_cmd.py +114 -0
  7. kalibr/cli/main.py +67 -0
  8. kalibr/cli/run.py +200 -0
  9. kalibr/cli/serve.py +59 -0
  10. kalibr/client.py +293 -0
  11. kalibr/collector.py +173 -0
  12. kalibr/context.py +132 -0
  13. kalibr/cost_adapter.py +222 -0
  14. kalibr/decorators.py +140 -0
  15. kalibr/instrumentation/__init__.py +13 -0
  16. kalibr/instrumentation/anthropic_instr.py +282 -0
  17. kalibr/instrumentation/base.py +108 -0
  18. kalibr/instrumentation/google_instr.py +281 -0
  19. kalibr/instrumentation/openai_instr.py +265 -0
  20. kalibr/instrumentation/registry.py +153 -0
  21. kalibr/kalibr.py +144 -230
  22. kalibr/kalibr_app.py +53 -314
  23. kalibr/middleware/__init__.py +5 -0
  24. kalibr/middleware/auto_tracer.py +356 -0
  25. kalibr/models.py +41 -0
  26. kalibr/redaction.py +44 -0
  27. kalibr/schemas.py +116 -0
  28. kalibr/simple_tracer.py +255 -0
  29. kalibr/tokens.py +52 -0
  30. kalibr/trace_capsule.py +296 -0
  31. kalibr/trace_models.py +201 -0
  32. kalibr/tracer.py +354 -0
  33. kalibr/types.py +25 -93
  34. kalibr/utils.py +198 -0
  35. kalibr-1.1.0.dist-info/METADATA +97 -0
  36. kalibr-1.1.0.dist-info/RECORD +40 -0
  37. kalibr-1.1.0.dist-info/entry_points.txt +2 -0
  38. kalibr-1.1.0.dist-info/licenses/LICENSE +21 -0
  39. kalibr/deployment.py +0 -41
  40. kalibr/packager.py +0 -43
  41. kalibr/runtime_router.py +0 -138
  42. kalibr/schema_generators.py +0 -159
  43. kalibr/validator.py +0 -70
  44. kalibr-1.0.28.data/data/examples/README.md +0 -173
  45. kalibr-1.0.28.data/data/examples/basic_kalibr_example.py +0 -66
  46. kalibr-1.0.28.data/data/examples/enhanced_kalibr_example.py +0 -347
  47. kalibr-1.0.28.dist-info/METADATA +0 -175
  48. kalibr-1.0.28.dist-info/RECORD +0 -19
  49. kalibr-1.0.28.dist-info/entry_points.txt +0 -2
  50. kalibr-1.0.28.dist-info/licenses/LICENSE +0 -11
  51. {kalibr-1.0.28.dist-info → kalibr-1.1.0.dist-info}/WHEEL +0 -0
  52. {kalibr-1.0.28.dist-info → kalibr-1.1.0.dist-info}/top_level.txt +0 -0
kalibr/cost_adapter.py ADDED
@@ -0,0 +1,222 @@
1
+ """Vendor-agnostic cost adapters for LLM pricing.
2
+
3
+ Each adapter computes cost in USD based on:
4
+ - Model name
5
+ - Input tokens
6
+ - Output tokens
7
+ - Pricing table (versioned)
8
+
9
+ Supports:
10
+ - OpenAI (GPT-4, GPT-3.5, etc.)
11
+ - Anthropic (Claude models)
12
+ - Extensible for other vendors
13
+ """
14
+
15
+ import json
16
+ import os
17
+ from abc import ABC, abstractmethod
18
+ from typing import Dict, Optional
19
+
20
+
21
+ class BaseCostAdapter(ABC):
22
+ """Base class for vendor cost adapters."""
23
+
24
+ @abstractmethod
25
+ def compute_cost(self, model_name: str, tokens_in: int, tokens_out: int) -> float:
26
+ """Compute cost in USD for given model and token counts.
27
+
28
+ Args:
29
+ model_name: Model identifier
30
+ tokens_in: Input token count
31
+ tokens_out: Output token count
32
+
33
+ Returns:
34
+ Cost in USD (e.g., 0.0123)
35
+ """
36
+ pass
37
+
38
+ @abstractmethod
39
+ def get_vendor_name(self) -> str:
40
+ """Return vendor name (e.g., 'openai', 'anthropic')."""
41
+ pass
42
+
43
+
44
+ class OpenAICostAdapter(BaseCostAdapter):
45
+ """Cost adapter for OpenAI models."""
46
+
47
+ # OpenAI pricing as of 2025 (per 1M tokens)
48
+ # Source: https://openai.com/pricing
49
+ PRICING = {
50
+ "gpt-4": {
51
+ "input": 30.00, # $30/1M input tokens
52
+ "output": 60.00, # $60/1M output tokens
53
+ },
54
+ "gpt-4-turbo": {
55
+ "input": 10.00,
56
+ "output": 30.00,
57
+ },
58
+ "gpt-4o": {
59
+ "input": 2.50,
60
+ "output": 10.00,
61
+ },
62
+ "gpt-3.5-turbo": {
63
+ "input": 0.50,
64
+ "output": 1.50,
65
+ },
66
+ "gpt-4o-mini": {
67
+ "input": 0.15,
68
+ "output": 0.60,
69
+ },
70
+ }
71
+
72
+ def get_vendor_name(self) -> str:
73
+ return "openai"
74
+
75
+ def compute_cost(self, model_name: str, tokens_in: int, tokens_out: int) -> float:
76
+ """Compute cost for OpenAI models."""
77
+ # Normalize model name
78
+ model_key = self._normalize_model_name(model_name)
79
+
80
+ # Get pricing (default to gpt-4 if unknown)
81
+ pricing = self.PRICING.get(model_key, self.PRICING["gpt-4"])
82
+
83
+ # Calculate cost (pricing is per 1M tokens)
84
+ input_cost = (tokens_in / 1_000_000) * pricing["input"]
85
+ output_cost = (tokens_out / 1_000_000) * pricing["output"]
86
+
87
+ return round(input_cost + output_cost, 6)
88
+
89
+ def _normalize_model_name(self, model_name: str) -> str:
90
+ """Normalize model name to match pricing table."""
91
+ model_lower = model_name.lower()
92
+
93
+ # Direct matches
94
+ if model_lower in self.PRICING:
95
+ return model_lower
96
+
97
+ # Fuzzy matches
98
+ if "gpt-4o-mini" in model_lower:
99
+ return "gpt-4o-mini"
100
+ elif "gpt-4o" in model_lower:
101
+ return "gpt-4o"
102
+ elif "gpt-4-turbo" in model_lower:
103
+ return "gpt-4-turbo"
104
+ elif "gpt-4" in model_lower:
105
+ return "gpt-4"
106
+ elif "gpt-3.5" in model_lower:
107
+ return "gpt-3.5-turbo"
108
+
109
+ # Default to gpt-4 for unknown models
110
+ return "gpt-4"
111
+
112
+
113
+ class AnthropicCostAdapter(BaseCostAdapter):
114
+ """Cost adapter for Anthropic Claude models."""
115
+
116
+ # Anthropic pricing as of 2025 (per 1M tokens)
117
+ # Source: https://www.anthropic.com/pricing
118
+ PRICING = {
119
+ "claude-3-opus": {
120
+ "input": 15.00,
121
+ "output": 75.00,
122
+ },
123
+ "claude-3-sonnet": {
124
+ "input": 3.00,
125
+ "output": 15.00,
126
+ },
127
+ "claude-3-haiku": {
128
+ "input": 0.25,
129
+ "output": 1.25,
130
+ },
131
+ "claude-3.5-sonnet": {
132
+ "input": 3.00,
133
+ "output": 15.00,
134
+ },
135
+ }
136
+
137
+ def get_vendor_name(self) -> str:
138
+ return "anthropic"
139
+
140
+ def compute_cost(self, model_name: str, tokens_in: int, tokens_out: int) -> float:
141
+ """Compute cost for Anthropic models."""
142
+ # Normalize model name
143
+ model_key = self._normalize_model_name(model_name)
144
+
145
+ # Get pricing (default to opus if unknown)
146
+ pricing = self.PRICING.get(model_key, self.PRICING["claude-3-opus"])
147
+
148
+ # Calculate cost (pricing is per 1M tokens)
149
+ input_cost = (tokens_in / 1_000_000) * pricing["input"]
150
+ output_cost = (tokens_out / 1_000_000) * pricing["output"]
151
+
152
+ return round(input_cost + output_cost, 6)
153
+
154
+ def _normalize_model_name(self, model_name: str) -> str:
155
+ """Normalize model name to match pricing table."""
156
+ model_lower = model_name.lower()
157
+
158
+ # Direct matches
159
+ if model_lower in self.PRICING:
160
+ return model_lower
161
+
162
+ # Fuzzy matches
163
+ if "claude-3.5-sonnet" in model_lower or "claude-3-5-sonnet" in model_lower:
164
+ return "claude-3.5-sonnet"
165
+ elif "claude-3-opus" in model_lower:
166
+ return "claude-3-opus"
167
+ elif "claude-3-sonnet" in model_lower:
168
+ return "claude-3-sonnet"
169
+ elif "claude-3-haiku" in model_lower:
170
+ return "claude-3-haiku"
171
+
172
+ # Default to opus for unknown models
173
+ return "claude-3-opus"
174
+
175
+
176
+ class CostAdapterFactory:
177
+ """Factory to get appropriate cost adapter for a vendor."""
178
+
179
+ _adapters: Dict[str, BaseCostAdapter] = {
180
+ "openai": OpenAICostAdapter(),
181
+ "anthropic": AnthropicCostAdapter(),
182
+ }
183
+
184
+ @classmethod
185
+ def get_adapter(cls, vendor: str) -> Optional[BaseCostAdapter]:
186
+ """Get cost adapter for vendor.
187
+
188
+ Args:
189
+ vendor: Vendor name (openai, anthropic, etc.)
190
+
191
+ Returns:
192
+ Cost adapter instance or None if not supported
193
+ """
194
+ return cls._adapters.get(vendor.lower())
195
+
196
+ @classmethod
197
+ def register_adapter(cls, vendor: str, adapter: BaseCostAdapter):
198
+ """Register a custom cost adapter.
199
+
200
+ Args:
201
+ vendor: Vendor name
202
+ adapter: Cost adapter instance
203
+ """
204
+ cls._adapters[vendor.lower()] = adapter
205
+
206
+ @classmethod
207
+ def compute_cost(cls, vendor: str, model_name: str, tokens_in: int, tokens_out: int) -> float:
208
+ """Convenience method to compute cost.
209
+
210
+ Args:
211
+ vendor: Vendor name
212
+ model_name: Model identifier
213
+ tokens_in: Input token count
214
+ tokens_out: Output token count
215
+
216
+ Returns:
217
+ Cost in USD, or 0.0 if vendor not supported
218
+ """
219
+ adapter = cls.get_adapter(vendor)
220
+ if adapter:
221
+ return adapter.compute_cost(model_name, tokens_in, tokens_out)
222
+ return 0.0
kalibr/decorators.py ADDED
@@ -0,0 +1,140 @@
1
+ """Decorator functions for automatic tracing.
2
+
3
+ Provides clean decorator-based API for tracing LLM calls:
4
+
5
+ @trace(operation="chat_completion", vendor="openai", model="gpt-4")
6
+ def my_llm_call(prompt):
7
+ return client.chat.completions.create(...)
8
+ """
9
+
10
+ from functools import wraps
11
+ from typing import Any, Callable, Optional
12
+
13
+ from .tokens import count_tokens
14
+ from .tracer import Tracer
15
+
16
+
17
+ def create_trace_decorator(tracer: Tracer):
18
+ """Create a trace decorator bound to a tracer instance.
19
+
20
+ Args:
21
+ tracer: Tracer instance
22
+
23
+ Returns:
24
+ Trace decorator function
25
+ """
26
+
27
+ def trace(
28
+ operation: str = "model_call",
29
+ vendor: str = "unknown",
30
+ model: str = "unknown",
31
+ endpoint: Optional[str] = None,
32
+ extract_tokens: bool = True,
33
+ ):
34
+ """Decorator to trace function calls.
35
+
36
+ Args:
37
+ operation: Operation type (chat_completion, embedding, etc.)
38
+ vendor: Vendor name (openai, anthropic, etc.)
39
+ model: Model identifier
40
+ endpoint: API endpoint or function name
41
+ extract_tokens: Whether to extract token counts from args/result
42
+
43
+ Example:
44
+ @trace(operation="chat_completion", vendor="openai", model="gpt-4")
45
+ def call_openai(prompt):
46
+ return openai.chat.completions.create(
47
+ model="gpt-4",
48
+ messages=[{"role": "user", "content": prompt}]
49
+ )
50
+ """
51
+
52
+ def decorator(func: Callable) -> Callable:
53
+ @wraps(func)
54
+ def wrapper(*args, **kwargs):
55
+ # Create span context
56
+ with tracer.create_span(
57
+ operation=operation,
58
+ vendor=vendor,
59
+ model_name=model,
60
+ endpoint=endpoint or func.__name__,
61
+ ) as span:
62
+ try:
63
+ # Execute function
64
+ result = func(*args, **kwargs)
65
+
66
+ # Extract tokens if enabled
67
+ if extract_tokens:
68
+ tokens_in, tokens_out = _extract_tokens(args, kwargs, result, model)
69
+ span.set_tokens(tokens_in, tokens_out)
70
+
71
+ return result
72
+
73
+ except Exception as e:
74
+ # Capture error
75
+ span.set_error(e)
76
+ raise
77
+
78
+ return wrapper
79
+
80
+ return decorator
81
+
82
+ return trace
83
+
84
+
85
+ def _extract_tokens(args, kwargs, result, model: str) -> tuple[int, int]:
86
+ """Extract token counts from function args and result.
87
+
88
+ Args:
89
+ args: Function positional arguments
90
+ kwargs: Function keyword arguments
91
+ result: Function return value
92
+ model: Model identifier
93
+
94
+ Returns:
95
+ Tuple of (tokens_in, tokens_out)
96
+ """
97
+ tokens_in = 0
98
+ tokens_out = 0
99
+
100
+ # Try to extract prompt from common arg patterns
101
+ prompt = None
102
+ response_text = None
103
+
104
+ # Extract from OpenAI-style calls
105
+ if "messages" in kwargs:
106
+ messages = kwargs["messages"]
107
+ if isinstance(messages, list):
108
+ prompt = "\n".join([str(m.get("content", "")) for m in messages])
109
+ elif "prompt" in kwargs:
110
+ prompt = kwargs["prompt"]
111
+ elif args and isinstance(args[0], str):
112
+ prompt = args[0]
113
+
114
+ # Extract response
115
+ if hasattr(result, "choices") and result.choices: # OpenAI response
116
+ choice = result.choices[0]
117
+ if hasattr(choice, "message") and hasattr(choice.message, "content"):
118
+ response_text = choice.message.content
119
+ elif hasattr(result, "content"): # Anthropic response
120
+ if isinstance(result.content, list):
121
+ response_text = "\n".join(
122
+ [block.text for block in result.content if hasattr(block, "text")]
123
+ )
124
+ else:
125
+ response_text = str(result.content)
126
+ elif isinstance(result, dict):
127
+ if "content" in result:
128
+ response_text = result["content"]
129
+ elif "text" in result:
130
+ response_text = result["text"]
131
+ elif isinstance(result, str):
132
+ response_text = result
133
+
134
+ # Count tokens
135
+ if prompt:
136
+ tokens_in = count_tokens(prompt, model)
137
+ if response_text:
138
+ tokens_out = count_tokens(response_text, model)
139
+
140
+ return tokens_in, tokens_out
@@ -0,0 +1,13 @@
1
+ """
2
+ Kalibr SDK Instrumentation Module
3
+
4
+ Provides automatic instrumentation for LLM SDKs (OpenAI, Anthropic, Google)
5
+ using monkey-patching to emit OpenTelemetry-compatible spans.
6
+ """
7
+
8
+ import os
9
+ from typing import List, Optional
10
+
11
+ from .registry import auto_instrument, get_instrumented_providers
12
+
13
+ __all__ = ["auto_instrument", "get_instrumented_providers"]
@@ -0,0 +1,282 @@
1
+ """
2
+ Anthropic SDK Instrumentation
3
+
4
+ Monkey-patches the Anthropic SDK to automatically emit OpenTelemetry spans
5
+ for all message API calls.
6
+ """
7
+
8
+ import time
9
+ from functools import wraps
10
+ from typing import Any, Dict, Optional
11
+
12
+ from opentelemetry.trace import SpanKind
13
+
14
+ from .base import BaseCostAdapter, BaseInstrumentation
15
+
16
+
17
+ class AnthropicCostAdapter(BaseCostAdapter):
18
+ """Cost calculation adapter for Anthropic models"""
19
+
20
+ # Pricing per 1K tokens (USD) - Updated November 2025
21
+ PRICING = {
22
+ # Claude 4 models
23
+ "claude-4-opus": {"input": 0.015, "output": 0.075},
24
+ "claude-4-sonnet": {"input": 0.003, "output": 0.015},
25
+ # Claude 3 models (Sonnet 4 is actually Claude 3.7)
26
+ "claude-sonnet-4": {"input": 0.003, "output": 0.015},
27
+ "claude-3-7-sonnet": {"input": 0.003, "output": 0.015},
28
+ "claude-3-5-sonnet": {"input": 0.003, "output": 0.015},
29
+ "claude-3-opus": {"input": 0.015, "output": 0.075},
30
+ "claude-3-sonnet": {"input": 0.003, "output": 0.015},
31
+ "claude-3-haiku": {"input": 0.00025, "output": 0.00125},
32
+ # Claude 2 models
33
+ "claude-2.1": {"input": 0.008, "output": 0.024},
34
+ "claude-2.0": {"input": 0.008, "output": 0.024},
35
+ "claude-instant-1.2": {"input": 0.0008, "output": 0.0024},
36
+ }
37
+
38
+ def calculate_cost(self, model: str, usage: Dict[str, int]) -> float:
39
+ """Calculate cost in USD for an Anthropic API call"""
40
+ # Normalize model name
41
+ base_model = model.lower()
42
+
43
+ # Try exact match first
44
+ pricing = self.get_pricing(base_model)
45
+
46
+ # Try fuzzy matching for versioned models
47
+ if not pricing:
48
+ for known_model in self.PRICING.keys():
49
+ if known_model in base_model or base_model in known_model:
50
+ pricing = self.PRICING[known_model]
51
+ break
52
+
53
+ if not pricing:
54
+ # Default to Claude 3 Sonnet pricing if unknown
55
+ pricing = {"input": 0.003, "output": 0.015}
56
+
57
+ input_tokens = usage.get("input_tokens", 0)
58
+ output_tokens = usage.get("output_tokens", 0)
59
+
60
+ input_cost = (input_tokens / 1000) * pricing["input"]
61
+ output_cost = (output_tokens / 1000) * pricing["output"]
62
+
63
+ return round(input_cost + output_cost, 6)
64
+
65
+
66
+ class AnthropicInstrumentation(BaseInstrumentation):
67
+ """Instrumentation for Anthropic SDK"""
68
+
69
+ def __init__(self):
70
+ super().__init__("kalibr.anthropic")
71
+ self._original_create = None
72
+ self._original_async_create = None
73
+ self.cost_adapter = AnthropicCostAdapter()
74
+
75
+ def instrument(self) -> bool:
76
+ """Apply monkey-patching to Anthropic SDK"""
77
+ if self._is_instrumented:
78
+ return True
79
+
80
+ try:
81
+ import anthropic
82
+ from anthropic.resources import messages
83
+
84
+ # Patch sync method
85
+ if hasattr(messages.Messages, "create"):
86
+ self._original_create = messages.Messages.create
87
+ messages.Messages.create = self._traced_create_wrapper(messages.Messages.create)
88
+
89
+ # Patch async method
90
+ if hasattr(messages.AsyncMessages, "create"):
91
+ self._original_async_create = messages.AsyncMessages.create
92
+ messages.AsyncMessages.create = self._traced_async_create_wrapper(
93
+ messages.AsyncMessages.create
94
+ )
95
+
96
+ self._is_instrumented = True
97
+ return True
98
+
99
+ except ImportError:
100
+ print("⚠️ Anthropic SDK not installed, skipping instrumentation")
101
+ return False
102
+ except Exception as e:
103
+ print(f"❌ Failed to instrument Anthropic SDK: {e}")
104
+ return False
105
+
106
+ def uninstrument(self) -> bool:
107
+ """Remove monkey-patching from Anthropic SDK"""
108
+ if not self._is_instrumented:
109
+ return True
110
+
111
+ try:
112
+ import anthropic
113
+ from anthropic.resources import messages
114
+
115
+ # Restore sync method
116
+ if self._original_create:
117
+ messages.Messages.create = self._original_create
118
+
119
+ # Restore async method
120
+ if self._original_async_create:
121
+ messages.AsyncMessages.create = self._original_async_create
122
+
123
+ self._is_instrumented = False
124
+ return True
125
+
126
+ except Exception as e:
127
+ print(f"❌ Failed to uninstrument Anthropic SDK: {e}")
128
+ return False
129
+
130
+ def _traced_create_wrapper(self, original_func):
131
+ """Wrapper for sync create method"""
132
+
133
+ @wraps(original_func)
134
+ def wrapper(self_instance, *args, **kwargs):
135
+ # Extract model from kwargs
136
+ model = kwargs.get("model", "unknown")
137
+
138
+ # Create span with initial attributes
139
+ with self.tracer.start_as_current_span(
140
+ "anthropic.messages.create",
141
+ kind=SpanKind.CLIENT,
142
+ attributes={
143
+ "llm.vendor": "anthropic",
144
+ "llm.request.model": model,
145
+ "llm.system": "anthropic",
146
+ },
147
+ ) as span:
148
+ start_time = time.time()
149
+
150
+ # Phase 3: Inject Kalibr context for HTTP→SDK linking
151
+ try:
152
+ from kalibr.context import inject_kalibr_context_into_span
153
+
154
+ inject_kalibr_context_into_span(span)
155
+ except Exception:
156
+ pass # Fail silently if context not available
157
+
158
+ try:
159
+ # Call original method
160
+ result = original_func(self_instance, *args, **kwargs)
161
+
162
+ # Extract and set response metadata
163
+ self._set_response_attributes(span, result, start_time)
164
+
165
+ return result
166
+
167
+ except Exception as e:
168
+ self.set_error(span, e)
169
+ raise
170
+
171
+ return wrapper
172
+
173
+ def _traced_async_create_wrapper(self, original_func):
174
+ """Wrapper for async create method"""
175
+
176
+ @wraps(original_func)
177
+ async def wrapper(self_instance, *args, **kwargs):
178
+ # Extract model from kwargs
179
+ model = kwargs.get("model", "unknown")
180
+
181
+ # Create span with initial attributes
182
+ with self.tracer.start_as_current_span(
183
+ "anthropic.messages.create",
184
+ kind=SpanKind.CLIENT,
185
+ attributes={
186
+ "llm.vendor": "anthropic",
187
+ "llm.request.model": model,
188
+ "llm.system": "anthropic",
189
+ },
190
+ ) as span:
191
+ start_time = time.time()
192
+
193
+ # Phase 3: Inject Kalibr context for HTTP→SDK linking
194
+ try:
195
+ from kalibr.context import inject_kalibr_context_into_span
196
+
197
+ inject_kalibr_context_into_span(span)
198
+ except Exception:
199
+ pass # Fail silently if context not available
200
+
201
+ try:
202
+ # Call original async method
203
+ result = await original_func(self_instance, *args, **kwargs)
204
+
205
+ # Extract and set response metadata
206
+ self._set_response_attributes(span, result, start_time)
207
+
208
+ return result
209
+
210
+ except Exception as e:
211
+ self.set_error(span, e)
212
+ raise
213
+
214
+ return wrapper
215
+
216
+ def _set_response_attributes(self, span, result, start_time: float) -> None:
217
+ """Extract metadata from response and set span attributes"""
218
+ try:
219
+ # Model
220
+ if hasattr(result, "model"):
221
+ span.set_attribute("llm.response.model", result.model)
222
+
223
+ # Token usage
224
+ if hasattr(result, "usage") and result.usage:
225
+ usage = result.usage
226
+ if hasattr(usage, "input_tokens"):
227
+ span.set_attribute("llm.usage.input_tokens", usage.input_tokens)
228
+ span.set_attribute("llm.usage.prompt_tokens", usage.input_tokens) # Alias
229
+ if hasattr(usage, "output_tokens"):
230
+ span.set_attribute("llm.usage.output_tokens", usage.output_tokens)
231
+ span.set_attribute("llm.usage.completion_tokens", usage.output_tokens) # Alias
232
+
233
+ total_tokens = usage.input_tokens + usage.output_tokens
234
+ span.set_attribute("llm.usage.total_tokens", total_tokens)
235
+
236
+ # Calculate cost
237
+ cost = self.cost_adapter.calculate_cost(
238
+ result.model,
239
+ {
240
+ "input_tokens": usage.input_tokens,
241
+ "output_tokens": usage.output_tokens,
242
+ },
243
+ )
244
+ span.set_attribute("llm.cost_usd", cost)
245
+
246
+ # Latency
247
+ latency_ms = (time.time() - start_time) * 1000
248
+ span.set_attribute("llm.latency_ms", round(latency_ms, 2))
249
+
250
+ # Response ID
251
+ if hasattr(result, "id"):
252
+ span.set_attribute("llm.response.id", result.id)
253
+
254
+ # Stop reason
255
+ if hasattr(result, "stop_reason"):
256
+ span.set_attribute("llm.response.stop_reason", result.stop_reason)
257
+
258
+ except Exception as e:
259
+ # Don't fail the call if metadata extraction fails
260
+ span.set_attribute("llm.metadata_extraction_error", str(e))
261
+
262
+
263
+ # Singleton instance
264
+ _anthropic_instrumentation = None
265
+
266
+
267
+ def get_instrumentation() -> AnthropicInstrumentation:
268
+ """Get or create the Anthropic instrumentation singleton"""
269
+ global _anthropic_instrumentation
270
+ if _anthropic_instrumentation is None:
271
+ _anthropic_instrumentation = AnthropicInstrumentation()
272
+ return _anthropic_instrumentation
273
+
274
+
275
+ def instrument() -> bool:
276
+ """Instrument Anthropic SDK"""
277
+ return get_instrumentation().instrument()
278
+
279
+
280
+ def uninstrument() -> bool:
281
+ """Uninstrument Anthropic SDK"""
282
+ return get_instrumentation().uninstrument()