kalibr 1.0.26__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.
- kalibr/__init__.py +170 -3
- kalibr/__main__.py +3 -203
- kalibr/capsule_middleware.py +108 -0
- kalibr/cli/__init__.py +5 -0
- kalibr/cli/capsule_cmd.py +174 -0
- kalibr/cli/deploy_cmd.py +114 -0
- kalibr/cli/main.py +67 -0
- kalibr/cli/run.py +200 -0
- kalibr/cli/serve.py +59 -0
- kalibr/client.py +293 -0
- kalibr/collector.py +173 -0
- kalibr/context.py +132 -0
- kalibr/cost_adapter.py +222 -0
- kalibr/decorators.py +140 -0
- kalibr/instrumentation/__init__.py +13 -0
- kalibr/instrumentation/anthropic_instr.py +282 -0
- kalibr/instrumentation/base.py +108 -0
- kalibr/instrumentation/google_instr.py +281 -0
- kalibr/instrumentation/openai_instr.py +265 -0
- kalibr/instrumentation/registry.py +153 -0
- kalibr/kalibr.py +144 -230
- kalibr/kalibr_app.py +53 -314
- kalibr/middleware/__init__.py +5 -0
- kalibr/middleware/auto_tracer.py +356 -0
- kalibr/models.py +41 -0
- kalibr/redaction.py +44 -0
- kalibr/schemas.py +116 -0
- kalibr/simple_tracer.py +255 -0
- kalibr/tokens.py +52 -0
- kalibr/trace_capsule.py +296 -0
- kalibr/trace_models.py +201 -0
- kalibr/tracer.py +354 -0
- kalibr/types.py +25 -93
- kalibr/utils.py +198 -0
- kalibr-1.1.0.dist-info/METADATA +97 -0
- kalibr-1.1.0.dist-info/RECORD +40 -0
- kalibr-1.1.0.dist-info/entry_points.txt +2 -0
- kalibr-1.1.0.dist-info/licenses/LICENSE +21 -0
- kalibr/deployment.py +0 -41
- kalibr/packager.py +0 -43
- kalibr/runtime_router.py +0 -138
- kalibr/schema_generators.py +0 -159
- kalibr/validator.py +0 -70
- kalibr-1.0.26.data/data/examples/README.md +0 -173
- kalibr-1.0.26.data/data/examples/basic_kalibr_example.py +0 -66
- kalibr-1.0.26.data/data/examples/enhanced_kalibr_example.py +0 -347
- kalibr-1.0.26.dist-info/METADATA +0 -176
- kalibr-1.0.26.dist-info/RECORD +0 -19
- kalibr-1.0.26.dist-info/entry_points.txt +0 -2
- kalibr-1.0.26.dist-info/licenses/LICENSE +0 -11
- {kalibr-1.0.26.dist-info → kalibr-1.1.0.dist-info}/WHEEL +0 -0
- {kalibr-1.0.26.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()
|