kalibr 1.2.6__py3-none-any.whl → 1.2.9__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 CHANGED
@@ -1,4 +1,4 @@
1
- """Kalibr SDK v1.2.0 - LLM Observability & Tracing Framework
1
+ """Kalibr SDK v1.2.7 - LLM Observability & Tracing Framework
2
2
 
3
3
  Features:
4
4
  - **Auto-Instrumentation**: Zero-config tracing of OpenAI, Anthropic, Google SDK calls
@@ -36,7 +36,7 @@ CLI Usage:
36
36
  kalibr version # Show version
37
37
  """
38
38
 
39
- __version__ = "1.2.0"
39
+ __version__ = "1.2.7"
40
40
 
41
41
  # Auto-instrument LLM SDKs on import (can be disabled via env var)
42
42
  import os
kalibr/cli/capsule_cmd.py CHANGED
@@ -23,7 +23,7 @@ def capsule(
23
23
  None,
24
24
  "--api-url",
25
25
  "-u",
26
- help="Kalibr API base URL (default: from env KALIBR_API_URL or https://api.kalibr.systems)",
26
+ help="Kalibr API base URL (default: from env KALIBR_API_URL or https://kalibr-backend.fly.dev)",
27
27
  envvar="KALIBR_API_URL",
28
28
  ),
29
29
  output: Optional[Path] = typer.Option(
@@ -63,10 +63,10 @@ def capsule(
63
63
  kalibr capsule abc-123-def --export --output capsule.json
64
64
 
65
65
  # Specify custom API URL
66
- kalibr capsule abc-123-def -u https://api.kalibr.systems
66
+ kalibr capsule abc-123-def -u https://kalibr-backend.fly.dev
67
67
  """
68
68
  # Determine API base URL
69
- base_url = api_url or "https://api.kalibr.systems"
69
+ base_url = api_url or "https://kalibr-backend.fly.dev"
70
70
  base_url = base_url.rstrip("/")
71
71
 
72
72
  # Build endpoint URL
kalibr/cli/run.py CHANGED
@@ -47,7 +47,7 @@ def run(
47
47
  kalibr run weather.py --runtime fly.io
48
48
 
49
49
  # Custom backend
50
- kalibr run weather.py --backend-url https://api.kalibr.systems
50
+ kalibr run weather.py --backend-url https://kalibr-backend.fly.dev
51
51
  """
52
52
  # Validate file exists
53
53
  agent_path = Path(file_path).resolve()
@@ -56,7 +56,7 @@ def run(
56
56
  raise typer.Exit(1)
57
57
 
58
58
  # Configure backend
59
- backend = backend_url or os.getenv("KALIBR_BACKEND_URL", "https://api.kalibr.systems")
59
+ backend = backend_url or os.getenv("KALIBR_BACKEND_URL", "https://kalibr-backend.fly.dev")
60
60
  api_key = os.getenv("KALIBR_API_KEY")
61
61
  if not api_key:
62
62
  console.print("[yellow]⚠️ KALIBR_API_KEY not set. Set it for trace authentication.[/yellow]")
kalibr/client.py CHANGED
@@ -70,7 +70,7 @@ class KalibrClient:
70
70
 
71
71
  self.api_key = api_key or env_config.get("auth_token", "")
72
72
  self.endpoint = endpoint or env_config.get(
73
- "api_endpoint", "https://api.kalibr.systems/api/v1/traces"
73
+ "api_endpoint", "https://kalibr-backend.fly.dev/api/v1/traces"
74
74
  )
75
75
  self.tenant_id = tenant_id or env_config.get("tenant_id", "default")
76
76
  self.environment = environment or env_config.get("environment", "prod")
kalibr/collector.py CHANGED
@@ -3,14 +3,21 @@ OpenTelemetry Collector Setup
3
3
 
4
4
  Configures OpenTelemetry tracer provider with multiple exporters:
5
5
  1. OTLP exporter for sending to OpenTelemetry collectors
6
- 2. File exporter for local JSONL fallback
6
+ 2. Kalibr HTTP exporter for sending to Kalibr backend
7
+ 3. File exporter for local JSONL fallback
8
+
9
+ Thread-safe singleton pattern for collector setup.
7
10
  """
8
11
 
9
12
  import json
10
13
  import os
14
+ import threading
15
+ from datetime import datetime, timezone
11
16
  from pathlib import Path
12
17
  from typing import Optional
13
18
 
19
+ import requests
20
+
14
21
  from opentelemetry import trace
15
22
  from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
16
23
  from opentelemetry.sdk.resources import SERVICE_NAME, Resource
@@ -21,6 +28,7 @@ from opentelemetry.sdk.trace.export import (
21
28
  SpanExporter,
22
29
  SpanExportResult,
23
30
  )
31
+ from opentelemetry.trace import StatusCode
24
32
 
25
33
  try:
26
34
  from opentelemetry.sdk.trace import ReadableSpan
@@ -81,8 +89,156 @@ class FileSpanExporter(SpanExporter):
81
89
  }
82
90
 
83
91
 
92
+ class KalibrHTTPSpanExporter(SpanExporter):
93
+ """Export spans to Kalibr backend via HTTP POST"""
94
+
95
+ DEFAULT_URL = "https://kalibr-backend.fly.dev/api/ingest"
96
+
97
+ def __init__(
98
+ self,
99
+ url: Optional[str] = None,
100
+ api_key: Optional[str] = None,
101
+ tenant_id: Optional[str] = None,
102
+ ):
103
+ """Initialize the Kalibr HTTP exporter.
104
+
105
+ Args:
106
+ url: Kalibr collector URL (default: from KALIBR_COLLECTOR_URL env var)
107
+ api_key: API key (default: from KALIBR_API_KEY env var)
108
+ tenant_id: Tenant ID (default: from KALIBR_TENANT_ID env var)
109
+ """
110
+ self.url = url or os.getenv("KALIBR_COLLECTOR_URL", self.DEFAULT_URL)
111
+ self.api_key = api_key or os.getenv("KALIBR_API_KEY")
112
+ self.tenant_id = tenant_id or os.getenv("KALIBR_TENANT_ID", "default")
113
+ self.environment = os.getenv("KALIBR_ENVIRONMENT", "production")
114
+
115
+ def export(self, spans) -> SpanExportResult:
116
+ """Export spans to Kalibr backend"""
117
+ if not self.api_key:
118
+ print("[Kalibr SDK] ⚠️ KALIBR_API_KEY not set, spans will not be sent to backend")
119
+ return SpanExportResult.SUCCESS
120
+
121
+ try:
122
+ events = [self._convert_span(span) for span in spans]
123
+
124
+ headers = {
125
+ "X-API-Key": self.api_key,
126
+ "X-Tenant-ID": self.tenant_id,
127
+ "Content-Type": "application/json",
128
+ }
129
+
130
+ payload = {"events": events}
131
+
132
+ response = requests.post(
133
+ self.url,
134
+ headers=headers,
135
+ json=payload,
136
+ timeout=30,
137
+ )
138
+
139
+ if not response.ok:
140
+ print(
141
+ f"[Kalibr SDK] ❌ Backend rejected spans: {response.status_code} - {response.text}"
142
+ )
143
+ return SpanExportResult.FAILURE
144
+
145
+ return SpanExportResult.SUCCESS
146
+
147
+ except Exception as e:
148
+ print(f"[Kalibr SDK] ❌ Failed to export spans to backend: {e}")
149
+ return SpanExportResult.FAILURE
150
+
151
+ def shutdown(self):
152
+ """Shutdown the exporter"""
153
+ pass
154
+
155
+ def _nanos_to_iso(self, nanos: int) -> str:
156
+ """Convert nanoseconds since epoch to ISO format timestamp"""
157
+ if nanos is None:
158
+ return datetime.now(timezone.utc).isoformat()
159
+ seconds = nanos / 1_000_000_000
160
+ dt = datetime.fromtimestamp(seconds, tz=timezone.utc)
161
+ return dt.isoformat()
162
+
163
+ def _get_attr(self, span, *keys, default=None):
164
+ """Get attribute value from span, trying multiple keys"""
165
+ attrs = dict(span.attributes) if span.attributes else {}
166
+ for key in keys:
167
+ if key in attrs:
168
+ return attrs[key]
169
+ return default
170
+
171
+ def _convert_span(self, span) -> dict:
172
+ """Convert OTel span to Kalibr event format"""
173
+
174
+ # Calculate duration from span times (nanoseconds to milliseconds)
175
+ duration_ms = 0
176
+ if span.start_time and span.end_time:
177
+ duration_ms = int((span.end_time - span.start_time) / 1_000_000)
178
+
179
+ # Determine status
180
+ is_error = (
181
+ hasattr(span.status, "status_code") and span.status.status_code == StatusCode.ERROR
182
+ )
183
+ status = "error" if is_error else "success"
184
+
185
+ # Extract provider and model
186
+ provider = self._get_attr(span, "llm.vendor", "llm.system", "gen_ai.system", default="")
187
+ model_id = self._get_attr(
188
+ span, "llm.request.model", "llm.response.model", "gen_ai.request.model", default=""
189
+ )
190
+
191
+ # Extract token counts
192
+ input_tokens = self._get_attr(
193
+ span, "llm.usage.prompt_tokens", "gen_ai.usage.prompt_tokens", default=0
194
+ )
195
+ output_tokens = self._get_attr(
196
+ span, "llm.usage.completion_tokens", "gen_ai.usage.completion_tokens", default=0
197
+ )
198
+ total_tokens = self._get_attr(
199
+ span, "llm.usage.total_tokens", "gen_ai.usage.total_tokens", default=0
200
+ )
201
+
202
+ # If total_tokens not provided, calculate it
203
+ if not total_tokens and (input_tokens or output_tokens):
204
+ total_tokens = (input_tokens or 0) + (output_tokens or 0)
205
+
206
+ # Build event payload
207
+ event = {
208
+ "schema_version": "1.0",
209
+ "trace_id": format(span.context.trace_id, "032x"),
210
+ "span_id": format(span.context.span_id, "016x"),
211
+ "parent_id": format(span.parent.span_id, "016x") if span.parent else None,
212
+ "tenant_id": self.tenant_id,
213
+ "provider": provider,
214
+ "model_id": model_id,
215
+ "model_name": model_id,
216
+ "operation": span.name,
217
+ "endpoint": span.name,
218
+ "input_tokens": input_tokens or 0,
219
+ "output_tokens": output_tokens or 0,
220
+ "total_tokens": total_tokens or 0,
221
+ "cost_usd": self._get_attr(span, "llm.cost_usd", "gen_ai.usage.cost", default=0.0),
222
+ "latency_ms": self._get_attr(span, "llm.latency_ms", default=duration_ms),
223
+ "duration_ms": duration_ms,
224
+ "status": status,
225
+ "error_type": self._get_attr(span, "error.type", default=None) if is_error else None,
226
+ "error_message": (
227
+ self._get_attr(span, "error.message", default=None) if is_error else None
228
+ ),
229
+ "timestamp": self._nanos_to_iso(span.end_time),
230
+ "ts_start": self._nanos_to_iso(span.start_time),
231
+ "ts_end": self._nanos_to_iso(span.end_time),
232
+ "goal": self._get_attr(span, "kalibr.goal", default=""),
233
+ "environment": self.environment,
234
+ }
235
+
236
+ return event
237
+
238
+
84
239
  _tracer_provider: Optional[TracerProvider] = None
85
240
  _is_configured = False
241
+ _collector_lock = threading.Lock()
86
242
 
87
243
 
88
244
  def setup_collector(
@@ -94,6 +250,8 @@ def setup_collector(
94
250
  """
95
251
  Setup OpenTelemetry collector with multiple exporters
96
252
 
253
+ Thread-safe: Uses double-checked locking to ensure single initialization.
254
+
97
255
  Args:
98
256
  service_name: Service name for the tracer provider
99
257
  otlp_endpoint: OTLP collector endpoint (e.g., "http://localhost:4317")
@@ -106,50 +264,67 @@ def setup_collector(
106
264
  """
107
265
  global _tracer_provider, _is_configured
108
266
 
267
+ # First check without lock (fast path)
109
268
  if _is_configured and _tracer_provider:
110
269
  return _tracer_provider
111
270
 
112
- # Create resource with service name
113
- resource = Resource(attributes={SERVICE_NAME: service_name})
114
-
115
- # Create tracer provider
116
- provider = TracerProvider(resource=resource)
117
-
118
- # Add OTLP exporter if endpoint is configured
119
- otlp_endpoint = otlp_endpoint or os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
120
- if otlp_endpoint:
121
- try:
122
- otlp_exporter = OTLPSpanExporter(endpoint=otlp_endpoint)
123
- provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
124
- print(f"✅ OTLP exporter configured: {otlp_endpoint}")
125
- except Exception as e:
126
- print(f"⚠️ Failed to configure OTLP exporter: {e}")
127
-
128
- # Add file exporter for local fallback
129
- if file_export:
130
- try:
131
- file_exporter = FileSpanExporter("/tmp/kalibr_otel_spans.jsonl")
132
- provider.add_span_processor(BatchSpanProcessor(file_exporter))
133
- print("✅ File exporter configured: /tmp/kalibr_otel_spans.jsonl")
134
- except Exception as e:
135
- print(f"⚠️ Failed to configure file exporter: {e}")
136
-
137
- # Add console exporter for debugging
138
- if console_export:
139
- try:
140
- console_exporter = ConsoleSpanExporter()
141
- provider.add_span_processor(BatchSpanProcessor(console_exporter))
142
- print(" Console exporter configured")
143
- except Exception as e:
144
- print(f"⚠️ Failed to configure console exporter: {e}")
145
-
146
- # Set as global tracer provider
147
- trace.set_tracer_provider(provider)
148
-
149
- _tracer_provider = provider
150
- _is_configured = True
151
-
152
- 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
153
328
 
154
329
 
155
330
  def get_tracer_provider() -> Optional[TracerProvider]:
@@ -163,11 +338,15 @@ def is_configured() -> bool:
163
338
 
164
339
 
165
340
  def shutdown_collector():
166
- """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
+ """
167
345
  global _tracer_provider, _is_configured
168
346
 
169
- if _tracer_provider:
170
- _tracer_provider.shutdown()
171
- _tracer_provider = None
172
- _is_configured = False
173
- 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")
kalibr/cost_adapter.py CHANGED
@@ -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."""