kalibr 1.2.7__tar.gz → 1.2.8__tar.gz

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 (66) hide show
  1. {kalibr-1.2.7 → kalibr-1.2.8}/PKG-INFO +58 -1
  2. {kalibr-1.2.7 → kalibr-1.2.8}/README.md +57 -0
  3. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/collector.py +74 -58
  4. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/cost_adapter.py +36 -104
  5. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/instrumentation/anthropic_instr.py +34 -40
  6. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/instrumentation/base.py +27 -9
  7. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/instrumentation/google_instr.py +34 -39
  8. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/instrumentation/openai_instr.py +34 -28
  9. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/instrumentation/registry.py +38 -13
  10. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/intelligence.py +26 -16
  11. kalibr-1.2.8/kalibr/pricing.py +245 -0
  12. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/router.py +64 -12
  13. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/simple_tracer.py +15 -14
  14. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/trace_capsule.py +19 -12
  15. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr.egg-info/PKG-INFO +58 -1
  16. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr.egg-info/SOURCES.txt +6 -1
  17. {kalibr-1.2.7 → kalibr-1.2.8}/pyproject.toml +1 -1
  18. kalibr-1.2.8/tests/test_cost_adapter.py +218 -0
  19. kalibr-1.2.8/tests/test_http_client_leak.py +298 -0
  20. {kalibr-1.2.7 → kalibr-1.2.8}/tests/test_instrumentation.py +94 -6
  21. kalibr-1.2.8/tests/test_pricing.py +268 -0
  22. kalibr-1.2.8/tests/test_thread_safety.py +501 -0
  23. {kalibr-1.2.7 → kalibr-1.2.8}/LICENSE +0 -0
  24. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/__init__.py +0 -0
  25. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/__main__.py +0 -0
  26. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/capsule_middleware.py +0 -0
  27. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/cli/__init__.py +0 -0
  28. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/cli/capsule_cmd.py +0 -0
  29. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/cli/deploy_cmd.py +0 -0
  30. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/cli/main.py +0 -0
  31. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/cli/run.py +0 -0
  32. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/cli/serve.py +0 -0
  33. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/client.py +0 -0
  34. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/context.py +0 -0
  35. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/decorators.py +0 -0
  36. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/instrumentation/__init__.py +0 -0
  37. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/kalibr.py +0 -0
  38. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/kalibr_app.py +0 -0
  39. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/middleware/__init__.py +0 -0
  40. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/middleware/auto_tracer.py +0 -0
  41. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/models.py +0 -0
  42. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/redaction.py +0 -0
  43. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/schemas.py +0 -0
  44. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/tokens.py +0 -0
  45. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/trace_models.py +0 -0
  46. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/tracer.py +0 -0
  47. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/types.py +0 -0
  48. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr/utils.py +0 -0
  49. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr.egg-info/dependency_links.txt +0 -0
  50. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr.egg-info/entry_points.txt +0 -0
  51. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr.egg-info/requires.txt +0 -0
  52. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr.egg-info/top_level.txt +0 -0
  53. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr_crewai/__init__.py +0 -0
  54. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr_crewai/callbacks.py +0 -0
  55. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr_crewai/instrumentor.py +0 -0
  56. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr_langchain/__init__.py +0 -0
  57. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr_langchain/async_callback.py +0 -0
  58. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr_langchain/callback.py +0 -0
  59. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr_langchain/chat_model.py +0 -0
  60. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr_openai_agents/__init__.py +0 -0
  61. {kalibr-1.2.7 → kalibr-1.2.8}/kalibr_openai_agents/processor.py +0 -0
  62. {kalibr-1.2.7 → kalibr-1.2.8}/setup.cfg +0 -0
  63. {kalibr-1.2.7 → kalibr-1.2.8}/tests/test_capsule_builder.py +0 -0
  64. {kalibr-1.2.7 → kalibr-1.2.8}/tests/test_intelligence.py +0 -0
  65. {kalibr-1.2.7 → kalibr-1.2.8}/tests/test_langchain_routing.py +0 -0
  66. {kalibr-1.2.7 → kalibr-1.2.8}/tests/test_router.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: kalibr
3
- Version: 1.2.7
3
+ Version: 1.2.8
4
4
  Summary: Adaptive routing for AI agents. Learns which models work best and routes automatically.
5
5
  Author-email: Kalibr Team <support@kalibr.systems>
6
6
  License: Apache-2.0
@@ -123,6 +123,63 @@ paths = [
123
123
  ]
124
124
  ```
125
125
 
126
+ ## Advanced Path Configuration
127
+
128
+ ### Routing Between Parameters
129
+
130
+ Kalibr can route between different parameter configurations of the same model:
131
+ ```python
132
+ from kalibr import Router
133
+
134
+ router = Router(
135
+ goal="creative_writing",
136
+ paths=[
137
+ {"model": "gpt-4o", "params": {"temperature": 0.3}},
138
+ {"model": "gpt-4o", "params": {"temperature": 0.9}},
139
+ {"model": "claude-sonnet-4-20250514", "params": {"temperature": 0.7}}
140
+ ]
141
+ )
142
+
143
+ response = router.completion(messages=[...])
144
+ router.report(success=True)
145
+ ```
146
+
147
+ Each unique `(model, params)` combination is tracked separately. Kalibr learns which configuration works best for your specific goal.
148
+
149
+ ### Routing Between Tools
150
+ ```python
151
+ router = Router(
152
+ goal="research_task",
153
+ paths=[
154
+ {"model": "gpt-4o", "tools": ["web_search"]},
155
+ {"model": "gpt-4o", "tools": ["code_interpreter"]},
156
+ {"model": "claude-sonnet-4-20250514"}
157
+ ]
158
+ )
159
+ ```
160
+
161
+ ### When to Use get_policy() Instead of Router
162
+
163
+ For most use cases, use `Router`. It handles provider dispatching and response conversion automatically.
164
+
165
+ Use `get_policy()` for advanced scenarios:
166
+ - Integrating with frameworks like LangChain that wrap LLM calls
167
+ - Custom retry logic or provider-specific features
168
+ - Building tools that need fine-grained control
169
+ ```python
170
+ from kalibr import get_policy, report_outcome
171
+
172
+ policy = get_policy(goal="summarize")
173
+ model = policy["recommended_model"]
174
+
175
+ # You call the provider yourself
176
+ if model.startswith("gpt"):
177
+ client = OpenAI()
178
+ response = client.chat.completions.create(model=model, messages=[...])
179
+
180
+ report_outcome(trace_id=trace_id, goal="summarize", success=True)
181
+ ```
182
+
126
183
  ## Outcome Reporting
127
184
 
128
185
  ### Automatic (with success_when)
@@ -56,6 +56,63 @@ paths = [
56
56
  ]
57
57
  ```
58
58
 
59
+ ## Advanced Path Configuration
60
+
61
+ ### Routing Between Parameters
62
+
63
+ Kalibr can route between different parameter configurations of the same model:
64
+ ```python
65
+ from kalibr import Router
66
+
67
+ router = Router(
68
+ goal="creative_writing",
69
+ paths=[
70
+ {"model": "gpt-4o", "params": {"temperature": 0.3}},
71
+ {"model": "gpt-4o", "params": {"temperature": 0.9}},
72
+ {"model": "claude-sonnet-4-20250514", "params": {"temperature": 0.7}}
73
+ ]
74
+ )
75
+
76
+ response = router.completion(messages=[...])
77
+ router.report(success=True)
78
+ ```
79
+
80
+ Each unique `(model, params)` combination is tracked separately. Kalibr learns which configuration works best for your specific goal.
81
+
82
+ ### Routing Between Tools
83
+ ```python
84
+ router = Router(
85
+ goal="research_task",
86
+ paths=[
87
+ {"model": "gpt-4o", "tools": ["web_search"]},
88
+ {"model": "gpt-4o", "tools": ["code_interpreter"]},
89
+ {"model": "claude-sonnet-4-20250514"}
90
+ ]
91
+ )
92
+ ```
93
+
94
+ ### When to Use get_policy() Instead of Router
95
+
96
+ For most use cases, use `Router`. It handles provider dispatching and response conversion automatically.
97
+
98
+ Use `get_policy()` for advanced scenarios:
99
+ - Integrating with frameworks like LangChain that wrap LLM calls
100
+ - Custom retry logic or provider-specific features
101
+ - Building tools that need fine-grained control
102
+ ```python
103
+ from kalibr import get_policy, report_outcome
104
+
105
+ policy = get_policy(goal="summarize")
106
+ model = policy["recommended_model"]
107
+
108
+ # You call the provider yourself
109
+ if model.startswith("gpt"):
110
+ client = OpenAI()
111
+ response = client.chat.completions.create(model=model, messages=[...])
112
+
113
+ report_outcome(trace_id=trace_id, goal="summarize", success=True)
114
+ ```
115
+
59
116
  ## Outcome Reporting
60
117
 
61
118
  ### Automatic (with success_when)
@@ -5,10 +5,13 @@ Configures OpenTelemetry tracer provider with multiple exporters:
5
5
  1. OTLP exporter for sending to OpenTelemetry collectors
6
6
  2. Kalibr HTTP exporter for sending to Kalibr backend
7
7
  3. File exporter for local JSONL fallback
8
+
9
+ Thread-safe singleton pattern for collector setup.
8
10
  """
9
11
 
10
12
  import json
11
13
  import os
14
+ import threading
12
15
  from datetime import datetime, timezone
13
16
  from pathlib import Path
14
17
  from typing import Optional
@@ -167,7 +170,6 @@ class KalibrHTTPSpanExporter(SpanExporter):
167
170
 
168
171
  def _convert_span(self, span) -> dict:
169
172
  """Convert OTel span to Kalibr event format"""
170
- attrs = dict(span.attributes) if span.attributes else {}
171
173
 
172
174
  # Calculate duration from span times (nanoseconds to milliseconds)
173
175
  duration_ms = 0
@@ -236,6 +238,7 @@ class KalibrHTTPSpanExporter(SpanExporter):
236
238
 
237
239
  _tracer_provider: Optional[TracerProvider] = None
238
240
  _is_configured = False
241
+ _collector_lock = threading.Lock()
239
242
 
240
243
 
241
244
  def setup_collector(
@@ -247,6 +250,8 @@ def setup_collector(
247
250
  """
248
251
  Setup OpenTelemetry collector with multiple exporters
249
252
 
253
+ Thread-safe: Uses double-checked locking to ensure single initialization.
254
+
250
255
  Args:
251
256
  service_name: Service name for the tracer provider
252
257
  otlp_endpoint: OTLP collector endpoint (e.g., "http://localhost:4317")
@@ -259,60 +264,67 @@ def setup_collector(
259
264
  """
260
265
  global _tracer_provider, _is_configured
261
266
 
267
+ # First check without lock (fast path)
262
268
  if _is_configured and _tracer_provider:
263
269
  return _tracer_provider
264
270
 
265
- # Create resource with service name
266
- resource = Resource(attributes={SERVICE_NAME: service_name})
267
-
268
- # Create tracer provider
269
- provider = TracerProvider(resource=resource)
270
-
271
- # Add OTLP exporter if endpoint is configured
272
- otlp_endpoint = otlp_endpoint or os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
273
- if otlp_endpoint:
274
- try:
275
- otlp_exporter = OTLPSpanExporter(endpoint=otlp_endpoint)
276
- provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
277
- print(f"✅ OTLP exporter configured: {otlp_endpoint}")
278
- except Exception as e:
279
- print(f"⚠️ Failed to configure OTLP exporter: {e}")
280
-
281
- # Add Kalibr HTTP exporter if API key is configured
282
- kalibr_api_key = os.getenv("KALIBR_API_KEY")
283
- if kalibr_api_key:
284
- try:
285
- kalibr_exporter = KalibrHTTPSpanExporter()
286
- provider.add_span_processor(BatchSpanProcessor(kalibr_exporter))
287
- print(f"✅ Kalibr backend exporter configured: {kalibr_exporter.url}")
288
- except Exception as e:
289
- print(f"⚠️ Failed to configure Kalibr backend exporter: {e}")
290
-
291
- # Add file exporter for local fallback
292
- if file_export:
293
- try:
294
- file_exporter = FileSpanExporter("/tmp/kalibr_otel_spans.jsonl")
295
- provider.add_span_processor(BatchSpanProcessor(file_exporter))
296
- print("✅ File exporter configured: /tmp/kalibr_otel_spans.jsonl")
297
- except Exception as e:
298
- print(f"⚠️ Failed to configure file exporter: {e}")
299
-
300
- # Add console exporter for debugging
301
- if console_export:
302
- try:
303
- console_exporter = ConsoleSpanExporter()
304
- provider.add_span_processor(BatchSpanProcessor(console_exporter))
305
- print("✅ Console exporter configured")
306
- except Exception as e:
307
- print(f"⚠️ Failed to configure console exporter: {e}")
308
-
309
- # Set as global tracer provider
310
- trace.set_tracer_provider(provider)
311
-
312
- _tracer_provider = provider
313
- _is_configured = True
314
-
315
- return provider
271
+ # Acquire lock for initialization
272
+ with _collector_lock:
273
+ # Double-check inside lock
274
+ if _is_configured and _tracer_provider:
275
+ return _tracer_provider
276
+
277
+ # Create resource with service name
278
+ resource = Resource(attributes={SERVICE_NAME: service_name})
279
+
280
+ # Create tracer provider
281
+ provider = TracerProvider(resource=resource)
282
+
283
+ # Add OTLP exporter if endpoint is configured
284
+ otlp_endpoint = otlp_endpoint or os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
285
+ if otlp_endpoint:
286
+ try:
287
+ otlp_exporter = OTLPSpanExporter(endpoint=otlp_endpoint)
288
+ provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
289
+ print(f"✅ OTLP exporter configured: {otlp_endpoint}")
290
+ except Exception as e:
291
+ print(f"⚠️ Failed to configure OTLP exporter: {e}")
292
+
293
+ # Add Kalibr HTTP exporter if API key is configured
294
+ kalibr_api_key = os.getenv("KALIBR_API_KEY")
295
+ if kalibr_api_key:
296
+ try:
297
+ kalibr_exporter = KalibrHTTPSpanExporter()
298
+ provider.add_span_processor(BatchSpanProcessor(kalibr_exporter))
299
+ print(f"✅ Kalibr backend exporter configured: {kalibr_exporter.url}")
300
+ except Exception as e:
301
+ print(f"⚠️ Failed to configure Kalibr backend exporter: {e}")
302
+
303
+ # Add file exporter for local fallback
304
+ if file_export:
305
+ try:
306
+ file_exporter = FileSpanExporter("/tmp/kalibr_otel_spans.jsonl")
307
+ provider.add_span_processor(BatchSpanProcessor(file_exporter))
308
+ print("✅ File exporter configured: /tmp/kalibr_otel_spans.jsonl")
309
+ except Exception as e:
310
+ print(f"⚠️ Failed to configure file exporter: {e}")
311
+
312
+ # Add console exporter for debugging
313
+ if console_export:
314
+ try:
315
+ console_exporter = ConsoleSpanExporter()
316
+ provider.add_span_processor(BatchSpanProcessor(console_exporter))
317
+ print("✅ Console exporter configured")
318
+ except Exception as e:
319
+ print(f"⚠️ Failed to configure console exporter: {e}")
320
+
321
+ # Set as global tracer provider
322
+ trace.set_tracer_provider(provider)
323
+
324
+ _tracer_provider = provider
325
+ _is_configured = True
326
+
327
+ return provider
316
328
 
317
329
 
318
330
  def get_tracer_provider() -> Optional[TracerProvider]:
@@ -326,11 +338,15 @@ def is_configured() -> bool:
326
338
 
327
339
 
328
340
  def shutdown_collector():
329
- """Shutdown the tracer provider and flush all spans"""
341
+ """Shutdown the tracer provider and flush all spans.
342
+
343
+ Thread-safe: Uses lock to protect shutdown operation.
344
+ """
330
345
  global _tracer_provider, _is_configured
331
346
 
332
- if _tracer_provider:
333
- _tracer_provider.shutdown()
334
- _tracer_provider = None
335
- _is_configured = False
336
- print("✅ Tracer provider shutdown")
347
+ with _collector_lock:
348
+ if _tracer_provider:
349
+ _tracer_provider.shutdown()
350
+ _tracer_provider = None
351
+ _is_configured = False
352
+ print("✅ Tracer provider shutdown")
@@ -10,6 +10,8 @@ Supports:
10
10
  - OpenAI (GPT-4, GPT-3.5, etc.)
11
11
  - Anthropic (Claude models)
12
12
  - Extensible for other vendors
13
+
14
+ Note: All adapters now use centralized pricing from kalibr.pricing module.
13
15
  """
14
16
 
15
17
  import json
@@ -17,6 +19,8 @@ import os
17
19
  from abc import ABC, abstractmethod
18
20
  from typing import Dict, Optional
19
21
 
22
+ from kalibr.pricing import get_pricing, normalize_model_name
23
+
20
24
 
21
25
  class BaseCostAdapter(ABC):
22
26
  """Base class for vendor cost adapters."""
@@ -42,43 +46,27 @@ class BaseCostAdapter(ABC):
42
46
 
43
47
 
44
48
  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
- }
49
+ """Cost adapter for OpenAI models.
50
+
51
+ Uses centralized pricing from kalibr.pricing module.
52
+ """
71
53
 
72
54
  def get_vendor_name(self) -> str:
73
55
  return "openai"
74
56
 
75
57
  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"])
58
+ """Compute cost for OpenAI models.
59
+
60
+ Args:
61
+ model_name: Model identifier (e.g., "gpt-4o", "gpt-4")
62
+ tokens_in: Input token count
63
+ tokens_out: Output token count
64
+
65
+ Returns:
66
+ Cost in USD (rounded to 6 decimal places)
67
+ """
68
+ # Get pricing from centralized module
69
+ pricing, _ = get_pricing("openai", model_name)
82
70
 
83
71
  # Calculate cost (pricing is per 1M tokens)
84
72
  input_cost = (tokens_in / 1_000_000) * pricing["input"]
@@ -86,64 +74,29 @@ class OpenAICostAdapter(BaseCostAdapter):
86
74
 
87
75
  return round(input_cost + output_cost, 6)
88
76
 
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
77
 
113
78
  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
- }
79
+ """Cost adapter for Anthropic Claude models.
80
+
81
+ Uses centralized pricing from kalibr.pricing module.
82
+ """
136
83
 
137
84
  def get_vendor_name(self) -> str:
138
85
  return "anthropic"
139
86
 
140
87
  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"])
88
+ """Compute cost for Anthropic models.
89
+
90
+ Args:
91
+ model_name: Model identifier (e.g., "claude-3-opus", "claude-3-5-sonnet")
92
+ tokens_in: Input token count
93
+ tokens_out: Output token count
94
+
95
+ Returns:
96
+ Cost in USD (rounded to 6 decimal places)
97
+ """
98
+ # Get pricing from centralized module
99
+ pricing, _ = get_pricing("anthropic", model_name)
147
100
 
148
101
  # Calculate cost (pricing is per 1M tokens)
149
102
  input_cost = (tokens_in / 1_000_000) * pricing["input"]
@@ -151,27 +104,6 @@ class AnthropicCostAdapter(BaseCostAdapter):
151
104
 
152
105
  return round(input_cost + output_cost, 6)
153
106
 
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
107
 
176
108
  class CostAdapterFactory:
177
109
  """Factory to get appropriate cost adapter for a vendor."""
@@ -3,8 +3,11 @@ Anthropic SDK Instrumentation
3
3
 
4
4
  Monkey-patches the Anthropic SDK to automatically emit OpenTelemetry spans
5
5
  for all message API calls.
6
+
7
+ Thread-safe singleton pattern using double-checked locking.
6
8
  """
7
9
 
10
+ import threading
8
11
  import time
9
12
  from functools import wraps
10
13
  from typing import Any, Dict, Optional
@@ -15,50 +18,34 @@ from .base import BaseCostAdapter, BaseInstrumentation
15
18
 
16
19
 
17
20
  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()
21
+ """Cost calculation adapter for Anthropic models.
22
+
23
+ Uses centralized pricing from kalibr.pricing module.
24
+ """
42
25
 
43
- # Try exact match first
44
- pricing = self.get_pricing(base_model)
26
+ def get_vendor_name(self) -> str:
27
+ """Return vendor name for Anthropic."""
28
+ return "anthropic"
45
29
 
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}
30
+ def calculate_cost(self, model: str, usage: Dict[str, int]) -> float:
31
+ """Calculate cost in USD for an Anthropic API call.
32
+
33
+ Args:
34
+ model: Model identifier (e.g., "claude-3-opus", "claude-3-5-sonnet-20240620")
35
+ usage: Token usage dict with input_tokens and output_tokens
36
+
37
+ Returns:
38
+ Cost in USD (rounded to 6 decimal places)
39
+ """
40
+ # Get pricing from centralized module (handles normalization)
41
+ pricing = self.get_pricing_for_model(model)
56
42
 
57
43
  input_tokens = usage.get("input_tokens", 0)
58
44
  output_tokens = usage.get("output_tokens", 0)
59
45
 
60
- input_cost = (input_tokens / 1000) * pricing["input"]
61
- output_cost = (output_tokens / 1000) * pricing["output"]
46
+ # Calculate cost (pricing is per 1M tokens)
47
+ input_cost = (input_tokens / 1_000_000) * pricing["input"]
48
+ output_cost = (output_tokens / 1_000_000) * pricing["output"]
62
49
 
63
50
  return round(input_cost + output_cost, 6)
64
51
 
@@ -262,13 +249,20 @@ class AnthropicInstrumentation(BaseInstrumentation):
262
249
 
263
250
  # Singleton instance
264
251
  _anthropic_instrumentation = None
252
+ _anthropic_lock = threading.Lock()
265
253
 
266
254
 
267
255
  def get_instrumentation() -> AnthropicInstrumentation:
268
- """Get or create the Anthropic instrumentation singleton"""
256
+ """Get or create the Anthropic instrumentation singleton.
257
+
258
+ Thread-safe singleton pattern using double-checked locking.
259
+ """
269
260
  global _anthropic_instrumentation
270
261
  if _anthropic_instrumentation is None:
271
- _anthropic_instrumentation = AnthropicInstrumentation()
262
+ with _anthropic_lock:
263
+ # Double-check inside lock to prevent race condition
264
+ if _anthropic_instrumentation is None:
265
+ _anthropic_instrumentation = AnthropicInstrumentation()
272
266
  return _anthropic_instrumentation
273
267
 
274
268