kalibr 1.1.3a0__py3-none-any.whl → 1.3.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 +41 -3
- kalibr/cli/capsule_cmd.py +3 -3
- kalibr/cli/main.py +3 -3
- kalibr/cli/run.py +2 -2
- kalibr/client.py +1 -1
- kalibr/collector.py +227 -48
- kalibr/context.py +42 -0
- kalibr/cost_adapter.py +36 -104
- kalibr/instrumentation/anthropic_instr.py +34 -40
- kalibr/instrumentation/base.py +27 -9
- kalibr/instrumentation/google_instr.py +34 -39
- kalibr/instrumentation/openai_instr.py +34 -28
- kalibr/instrumentation/registry.py +38 -13
- kalibr/intelligence.py +662 -0
- kalibr/middleware/auto_tracer.py +1 -1
- kalibr/pricing.py +245 -0
- kalibr/router.py +499 -0
- kalibr/simple_tracer.py +16 -15
- kalibr/trace_capsule.py +19 -12
- kalibr/utils.py +2 -2
- kalibr-1.3.0.dist-info/LICENSE +190 -0
- kalibr-1.3.0.dist-info/METADATA +296 -0
- kalibr-1.3.0.dist-info/RECORD +52 -0
- {kalibr-1.1.3a0.dist-info → kalibr-1.3.0.dist-info}/WHEEL +1 -1
- kalibr_crewai/__init__.py +1 -1
- kalibr_crewai/callbacks.py +122 -14
- kalibr_crewai/instrumentor.py +196 -33
- kalibr_langchain/__init__.py +4 -2
- kalibr_langchain/callback.py +26 -0
- kalibr_langchain/chat_model.py +103 -0
- kalibr_openai_agents/__init__.py +1 -1
- kalibr-1.1.3a0.dist-info/METADATA +0 -236
- kalibr-1.1.3a0.dist-info/RECORD +0 -48
- kalibr-1.1.3a0.dist-info/licenses/LICENSE +0 -21
- {kalibr-1.1.3a0.dist-info → kalibr-1.3.0.dist-info}/entry_points.txt +0 -0
- {kalibr-1.1.3a0.dist-info → kalibr-1.3.0.dist-info}/top_level.txt +0 -0
kalibr/router.py
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Kalibr Router - Intelligent model routing with outcome learning.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import logging
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
9
|
+
|
|
10
|
+
from opentelemetry import trace as otel_trace
|
|
11
|
+
from opentelemetry.trace import SpanContext, TraceFlags, NonRecordingSpan, set_span_in_context
|
|
12
|
+
from opentelemetry.context import Context
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
# Type for paths - either string or dict
|
|
17
|
+
PathSpec = Union[str, Dict[str, Any]]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _create_context_with_trace_id(trace_id_hex: str) -> Optional[Context]:
|
|
21
|
+
"""Create an OTel context with a specific trace_id.
|
|
22
|
+
|
|
23
|
+
This allows child spans to inherit the intelligence service's trace_id,
|
|
24
|
+
enabling JOINs between outcomes and traces tables.
|
|
25
|
+
"""
|
|
26
|
+
try:
|
|
27
|
+
# Convert 32-char hex string to 128-bit int
|
|
28
|
+
trace_id_int = int(trace_id_hex, 16)
|
|
29
|
+
if trace_id_int == 0:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
# Create span context with our trace_id
|
|
33
|
+
span_context = SpanContext(
|
|
34
|
+
trace_id=trace_id_int,
|
|
35
|
+
span_id=0xDEADBEEF, # Placeholder, real span will have its own
|
|
36
|
+
is_remote=True, # Treat as remote parent so new span_id is generated
|
|
37
|
+
trace_flags=TraceFlags(TraceFlags.SAMPLED),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# Create a non-recording parent span and set in context
|
|
41
|
+
parent_span = NonRecordingSpan(span_context)
|
|
42
|
+
return set_span_in_context(parent_span)
|
|
43
|
+
except (ValueError, TypeError) as e:
|
|
44
|
+
logger.warning(f"Could not create OTel context with trace_id: {e}")
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Router:
|
|
49
|
+
"""
|
|
50
|
+
Routes LLM requests to the best model based on learned outcomes.
|
|
51
|
+
|
|
52
|
+
Example:
|
|
53
|
+
router = Router(
|
|
54
|
+
goal="summarize",
|
|
55
|
+
paths=["gpt-4o", "claude-3-sonnet"],
|
|
56
|
+
success_when=lambda out: len(out) > 100
|
|
57
|
+
)
|
|
58
|
+
response = router.completion(messages=[...])
|
|
59
|
+
|
|
60
|
+
Examples:
|
|
61
|
+
# Simple auto-reporting
|
|
62
|
+
router = Router(
|
|
63
|
+
goal="extract_email",
|
|
64
|
+
paths=["gpt-4o", "claude-sonnet-4"],
|
|
65
|
+
success_when=lambda out: "@" in out
|
|
66
|
+
)
|
|
67
|
+
response = router.completion(messages=[...])
|
|
68
|
+
# report() called automatically
|
|
69
|
+
|
|
70
|
+
# Manual reporting for complex validation
|
|
71
|
+
router = Router(
|
|
72
|
+
goal="book_meeting",
|
|
73
|
+
paths=["gpt-4o", "claude-sonnet-4"]
|
|
74
|
+
)
|
|
75
|
+
response = router.completion(messages=[...])
|
|
76
|
+
# ... complex validation logic ...
|
|
77
|
+
router.report(success=meeting_booked)
|
|
78
|
+
|
|
79
|
+
Warning:
|
|
80
|
+
Router is not thread-safe. For concurrent requests, create separate
|
|
81
|
+
Router instances per thread/task. For sequential requests in a single
|
|
82
|
+
thread, Router can be reused across multiple completion() calls.
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
goal: str,
|
|
88
|
+
paths: Optional[List[PathSpec]] = None,
|
|
89
|
+
success_when: Optional[Callable[[str], bool]] = None,
|
|
90
|
+
exploration_rate: Optional[float] = None,
|
|
91
|
+
auto_register: bool = True,
|
|
92
|
+
):
|
|
93
|
+
"""
|
|
94
|
+
Initialize router.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
goal: Name of the goal (e.g., "book_meeting", "summarize")
|
|
98
|
+
paths: List of models or path configs. Examples:
|
|
99
|
+
["gpt-4o", "claude-3-sonnet"]
|
|
100
|
+
[{"model": "gpt-4o", "tools": ["search"]}]
|
|
101
|
+
[{"model": "gpt-4o", "params": {"temperature": 0.7}}]
|
|
102
|
+
success_when: Optional function to auto-evaluate success from LLM output.
|
|
103
|
+
Takes the output string and returns True/False.
|
|
104
|
+
When provided, report() is called automatically after completion().
|
|
105
|
+
Use for simple validations (output length, contains key string).
|
|
106
|
+
For complex validation (API calls, multi-step checks), omit this
|
|
107
|
+
and call report() manually.
|
|
108
|
+
Examples:
|
|
109
|
+
success_when=lambda out: len(out) > 0 # Not empty
|
|
110
|
+
success_when=lambda out: "@" in out # Contains email
|
|
111
|
+
exploration_rate: Override exploration rate (0.0-1.0)
|
|
112
|
+
auto_register: If True, register paths on init
|
|
113
|
+
"""
|
|
114
|
+
self.goal = goal
|
|
115
|
+
|
|
116
|
+
# Validate required environment variables
|
|
117
|
+
api_key = os.environ.get('KALIBR_API_KEY')
|
|
118
|
+
tenant_id = os.environ.get('KALIBR_TENANT_ID')
|
|
119
|
+
|
|
120
|
+
if not api_key:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
"KALIBR_API_KEY environment variable not set.\n"
|
|
123
|
+
"Get your API key from: https://dashboard.kalibr.systems/settings\n"
|
|
124
|
+
"Then run: export KALIBR_API_KEY=your-key-here"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if not tenant_id:
|
|
128
|
+
raise ValueError(
|
|
129
|
+
"KALIBR_TENANT_ID environment variable not set.\n"
|
|
130
|
+
"Find your Tenant ID at: https://dashboard.kalibr.systems/settings\n"
|
|
131
|
+
"Then run: export KALIBR_TENANT_ID=your-tenant-id"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
self.success_when = success_when
|
|
135
|
+
self.exploration_rate = exploration_rate
|
|
136
|
+
self._last_trace_id: Optional[str] = None
|
|
137
|
+
self._last_model_id: Optional[str] = None
|
|
138
|
+
self._last_decision: Optional[dict] = None
|
|
139
|
+
self._outcome_reported = False
|
|
140
|
+
|
|
141
|
+
# Normalize paths to list of dicts
|
|
142
|
+
self._paths = self._normalize_paths(paths or ["gpt-4o"])
|
|
143
|
+
|
|
144
|
+
# Register paths if requested
|
|
145
|
+
if auto_register:
|
|
146
|
+
self._register_paths()
|
|
147
|
+
|
|
148
|
+
def _normalize_paths(self, paths: List[PathSpec]) -> List[Dict[str, Any]]:
|
|
149
|
+
"""Convert paths to consistent format."""
|
|
150
|
+
normalized = []
|
|
151
|
+
for p in paths:
|
|
152
|
+
if isinstance(p, str):
|
|
153
|
+
normalized.append({"model": p, "tools": None, "params": None})
|
|
154
|
+
elif isinstance(p, dict):
|
|
155
|
+
normalized.append({
|
|
156
|
+
"model": p.get("model") or p.get("model_id"),
|
|
157
|
+
"tools": p.get("tools") or p.get("tool_id"),
|
|
158
|
+
"params": p.get("params"),
|
|
159
|
+
})
|
|
160
|
+
else:
|
|
161
|
+
raise ValueError(f"Invalid path spec: {p}")
|
|
162
|
+
return normalized
|
|
163
|
+
|
|
164
|
+
def _register_paths(self):
|
|
165
|
+
"""Register paths with intelligence service."""
|
|
166
|
+
from kalibr.intelligence import register_path
|
|
167
|
+
|
|
168
|
+
for path in self._paths:
|
|
169
|
+
try:
|
|
170
|
+
register_path(
|
|
171
|
+
goal=self.goal,
|
|
172
|
+
model_id=path["model"],
|
|
173
|
+
tool_id=path["tools"][0] if isinstance(path["tools"], list) and path["tools"] else path["tools"],
|
|
174
|
+
params=path["params"],
|
|
175
|
+
)
|
|
176
|
+
except Exception as e:
|
|
177
|
+
# Log but don't fail - path might already exist
|
|
178
|
+
logger.debug(f"Path registration note: {e}")
|
|
179
|
+
|
|
180
|
+
def completion(
|
|
181
|
+
self,
|
|
182
|
+
messages: List[Dict[str, str]],
|
|
183
|
+
force_model: Optional[str] = None,
|
|
184
|
+
**kwargs
|
|
185
|
+
) -> Any:
|
|
186
|
+
"""
|
|
187
|
+
Make a completion request with intelligent routing.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
messages: OpenAI-format messages
|
|
191
|
+
force_model: Override routing and use this model
|
|
192
|
+
**kwargs: Additional args passed to provider
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
OpenAI-compatible ChatCompletion response with added attribute:
|
|
196
|
+
- kalibr_trace_id: Trace ID for explicit outcome reporting
|
|
197
|
+
"""
|
|
198
|
+
from kalibr.intelligence import decide
|
|
199
|
+
|
|
200
|
+
# Reset state for new request
|
|
201
|
+
self._outcome_reported = False
|
|
202
|
+
|
|
203
|
+
# Step 1: Get routing decision FIRST (before creating span)
|
|
204
|
+
decision = None
|
|
205
|
+
model_id = None
|
|
206
|
+
tool_id = None
|
|
207
|
+
params = {}
|
|
208
|
+
|
|
209
|
+
if force_model:
|
|
210
|
+
model_id = force_model
|
|
211
|
+
self._last_decision = {"model_id": model_id, "forced": True}
|
|
212
|
+
else:
|
|
213
|
+
try:
|
|
214
|
+
decision = decide(goal=self.goal)
|
|
215
|
+
model_id = decision.get("model_id") or self._paths[0]["model"]
|
|
216
|
+
tool_id = decision.get("tool_id")
|
|
217
|
+
params = decision.get("params") or {}
|
|
218
|
+
self._last_decision = decision
|
|
219
|
+
except Exception as e:
|
|
220
|
+
logger.warning(f"Routing failed, using fallback: {e}")
|
|
221
|
+
model_id = self._paths[0]["model"]
|
|
222
|
+
tool_id = self._paths[0].get("tools")
|
|
223
|
+
params = self._paths[0].get("params") or {}
|
|
224
|
+
self._last_decision = {"model_id": model_id, "fallback": True, "error": str(e)}
|
|
225
|
+
|
|
226
|
+
# Step 2: Determine trace_id
|
|
227
|
+
decision_trace_id = self._last_decision.get("trace_id") if self._last_decision else None
|
|
228
|
+
|
|
229
|
+
if decision_trace_id:
|
|
230
|
+
trace_id = decision_trace_id
|
|
231
|
+
else:
|
|
232
|
+
trace_id = uuid.uuid4().hex # Fallback: generate OTel-compatible format
|
|
233
|
+
|
|
234
|
+
self._last_trace_id = trace_id
|
|
235
|
+
self._last_model_id = model_id
|
|
236
|
+
|
|
237
|
+
# Step 3: Create OTel context with intelligence trace_id
|
|
238
|
+
otel_context = _create_context_with_trace_id(trace_id) if trace_id else None
|
|
239
|
+
|
|
240
|
+
# Step 4: Create span with custom context (child spans inherit trace_id)
|
|
241
|
+
tracer = otel_trace.get_tracer("kalibr.router")
|
|
242
|
+
|
|
243
|
+
with tracer.start_as_current_span(
|
|
244
|
+
"kalibr.router.completion",
|
|
245
|
+
context=otel_context,
|
|
246
|
+
attributes={
|
|
247
|
+
"kalibr.goal": self.goal,
|
|
248
|
+
"kalibr.trace_id": trace_id,
|
|
249
|
+
"kalibr.model_id": model_id,
|
|
250
|
+
}
|
|
251
|
+
) as router_span:
|
|
252
|
+
# Add decision attributes
|
|
253
|
+
if force_model:
|
|
254
|
+
router_span.set_attribute("kalibr.forced", True)
|
|
255
|
+
elif decision:
|
|
256
|
+
router_span.set_attribute("kalibr.path_id", decision.get("path_id", ""))
|
|
257
|
+
router_span.set_attribute("kalibr.reason", decision.get("reason", ""))
|
|
258
|
+
router_span.set_attribute("kalibr.exploration", decision.get("exploration", False))
|
|
259
|
+
router_span.set_attribute("kalibr.confidence", decision.get("confidence", 0.0))
|
|
260
|
+
else:
|
|
261
|
+
router_span.set_attribute("kalibr.fallback", True)
|
|
262
|
+
|
|
263
|
+
# Step 5: Dispatch to provider
|
|
264
|
+
try:
|
|
265
|
+
response = self._dispatch(model_id, messages, tool_id, **{**params, **kwargs})
|
|
266
|
+
|
|
267
|
+
# Auto-report if success_when provided
|
|
268
|
+
if self.success_when and not self._outcome_reported:
|
|
269
|
+
try:
|
|
270
|
+
output = response.choices[0].message.content or ""
|
|
271
|
+
success = self.success_when(output)
|
|
272
|
+
self.report(success=success)
|
|
273
|
+
except Exception as e:
|
|
274
|
+
logger.warning(f"Auto-outcome evaluation failed: {e}")
|
|
275
|
+
|
|
276
|
+
# Add trace_id to response for explicit linkage
|
|
277
|
+
response.kalibr_trace_id = trace_id
|
|
278
|
+
return response
|
|
279
|
+
|
|
280
|
+
except Exception as e:
|
|
281
|
+
router_span.set_attribute("error", True)
|
|
282
|
+
router_span.set_attribute("error.type", type(e).__name__)
|
|
283
|
+
|
|
284
|
+
# Auto-report failure
|
|
285
|
+
if not self._outcome_reported:
|
|
286
|
+
try:
|
|
287
|
+
self.report(success=False, reason=f"provider_error: {type(e).__name__}")
|
|
288
|
+
except:
|
|
289
|
+
pass
|
|
290
|
+
raise
|
|
291
|
+
|
|
292
|
+
def report(
|
|
293
|
+
self,
|
|
294
|
+
success: bool,
|
|
295
|
+
reason: Optional[str] = None,
|
|
296
|
+
score: Optional[float] = None,
|
|
297
|
+
trace_id: Optional[str] = None,
|
|
298
|
+
):
|
|
299
|
+
"""
|
|
300
|
+
Report outcome for the last completion.
|
|
301
|
+
|
|
302
|
+
Args:
|
|
303
|
+
success: Whether the task succeeded
|
|
304
|
+
reason: Optional failure reason
|
|
305
|
+
score: Optional quality score (0.0-1.0)
|
|
306
|
+
trace_id: Optional explicit trace ID (uses last completion's trace_id if not provided)
|
|
307
|
+
"""
|
|
308
|
+
if self._outcome_reported:
|
|
309
|
+
logger.warning("Outcome already reported for this completion. Each completion() requires a separate report() call.")
|
|
310
|
+
return
|
|
311
|
+
|
|
312
|
+
from kalibr.intelligence import report_outcome
|
|
313
|
+
|
|
314
|
+
trace_id = trace_id or self._last_trace_id
|
|
315
|
+
if not trace_id:
|
|
316
|
+
raise ValueError("Must call completion() before report(). No trace_id available.")
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
report_outcome(
|
|
320
|
+
trace_id=trace_id,
|
|
321
|
+
goal=self.goal,
|
|
322
|
+
success=success,
|
|
323
|
+
score=score,
|
|
324
|
+
failure_reason=reason,
|
|
325
|
+
model_id=self._last_model_id,
|
|
326
|
+
)
|
|
327
|
+
self._outcome_reported = True
|
|
328
|
+
except Exception as e:
|
|
329
|
+
logger.warning(f"Failed to report outcome: {e}")
|
|
330
|
+
|
|
331
|
+
def add_path(
|
|
332
|
+
self,
|
|
333
|
+
model: str,
|
|
334
|
+
tools: Optional[List[str]] = None,
|
|
335
|
+
params: Optional[Dict] = None,
|
|
336
|
+
):
|
|
337
|
+
"""Add a new path dynamically."""
|
|
338
|
+
from kalibr.intelligence import register_path
|
|
339
|
+
|
|
340
|
+
path = {"model": model, "tools": tools, "params": params}
|
|
341
|
+
self._paths.append(path)
|
|
342
|
+
|
|
343
|
+
register_path(
|
|
344
|
+
goal=self.goal,
|
|
345
|
+
model_id=model,
|
|
346
|
+
tool_id=tools[0] if tools else None,
|
|
347
|
+
params=params,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def _dispatch(
|
|
351
|
+
self,
|
|
352
|
+
model_id: str,
|
|
353
|
+
messages: List[Dict],
|
|
354
|
+
tools: Optional[Any] = None,
|
|
355
|
+
**kwargs
|
|
356
|
+
) -> Any:
|
|
357
|
+
"""Dispatch to the appropriate provider."""
|
|
358
|
+
if model_id.startswith(("gpt-", "o1-", "o3-")):
|
|
359
|
+
return self._call_openai(model_id, messages, tools, **kwargs)
|
|
360
|
+
elif model_id.startswith("claude-"):
|
|
361
|
+
return self._call_anthropic(model_id, messages, tools, **kwargs)
|
|
362
|
+
elif model_id.startswith(("gemini-", "models/gemini")):
|
|
363
|
+
return self._call_google(model_id, messages, tools, **kwargs)
|
|
364
|
+
else:
|
|
365
|
+
# Default to OpenAI-compatible
|
|
366
|
+
logger.info(f"Unknown model prefix '{model_id}', trying OpenAI")
|
|
367
|
+
return self._call_openai(model_id, messages, tools, **kwargs)
|
|
368
|
+
|
|
369
|
+
def _call_openai(self, model: str, messages: List[Dict], tools: Any, **kwargs) -> Any:
|
|
370
|
+
"""Call OpenAI API."""
|
|
371
|
+
try:
|
|
372
|
+
from openai import OpenAI
|
|
373
|
+
except ImportError:
|
|
374
|
+
raise ImportError("Install 'openai' package: pip install openai")
|
|
375
|
+
|
|
376
|
+
client = OpenAI()
|
|
377
|
+
|
|
378
|
+
call_kwargs = {"model": model, "messages": messages, **kwargs}
|
|
379
|
+
if tools:
|
|
380
|
+
call_kwargs["tools"] = tools
|
|
381
|
+
|
|
382
|
+
return client.chat.completions.create(**call_kwargs)
|
|
383
|
+
|
|
384
|
+
def _call_anthropic(self, model: str, messages: List[Dict], tools: Any, **kwargs) -> Any:
|
|
385
|
+
"""Call Anthropic API and convert response to OpenAI format."""
|
|
386
|
+
try:
|
|
387
|
+
from anthropic import Anthropic
|
|
388
|
+
except ImportError:
|
|
389
|
+
raise ImportError("Install 'anthropic' package: pip install anthropic")
|
|
390
|
+
|
|
391
|
+
client = Anthropic()
|
|
392
|
+
|
|
393
|
+
# Convert messages (handle system message)
|
|
394
|
+
system = None
|
|
395
|
+
anthropic_messages = []
|
|
396
|
+
for m in messages:
|
|
397
|
+
if m["role"] == "system":
|
|
398
|
+
system = m["content"]
|
|
399
|
+
else:
|
|
400
|
+
anthropic_messages.append({"role": m["role"], "content": m["content"]})
|
|
401
|
+
|
|
402
|
+
call_kwargs = {"model": model, "messages": anthropic_messages, "max_tokens": kwargs.pop("max_tokens", 4096)}
|
|
403
|
+
if system:
|
|
404
|
+
call_kwargs["system"] = system
|
|
405
|
+
if tools:
|
|
406
|
+
call_kwargs["tools"] = tools
|
|
407
|
+
call_kwargs.update(kwargs)
|
|
408
|
+
|
|
409
|
+
response = client.messages.create(**call_kwargs)
|
|
410
|
+
|
|
411
|
+
# Convert to OpenAI format
|
|
412
|
+
return self._anthropic_to_openai_response(response, model)
|
|
413
|
+
|
|
414
|
+
def _call_google(self, model: str, messages: List[Dict], tools: Any, **kwargs) -> Any:
|
|
415
|
+
"""Call Google API and convert response to OpenAI format."""
|
|
416
|
+
try:
|
|
417
|
+
import google.generativeai as genai
|
|
418
|
+
except ImportError:
|
|
419
|
+
raise ImportError("Install 'google-generativeai' package: pip install google-generativeai")
|
|
420
|
+
|
|
421
|
+
# Configure if API key available
|
|
422
|
+
api_key = os.environ.get("GOOGLE_API_KEY")
|
|
423
|
+
if api_key:
|
|
424
|
+
genai.configure(api_key=api_key)
|
|
425
|
+
|
|
426
|
+
# Convert messages to Google format
|
|
427
|
+
model_name = model.replace("models/", "") if model.startswith("models/") else model
|
|
428
|
+
gmodel = genai.GenerativeModel(model_name)
|
|
429
|
+
|
|
430
|
+
# Simple conversion - concatenate messages
|
|
431
|
+
prompt = "\n".join([f"{m['role']}: {m['content']}" for m in messages])
|
|
432
|
+
|
|
433
|
+
response = gmodel.generate_content(prompt)
|
|
434
|
+
|
|
435
|
+
# Convert to OpenAI format
|
|
436
|
+
return self._google_to_openai_response(response, model)
|
|
437
|
+
|
|
438
|
+
def _anthropic_to_openai_response(self, response: Any, model: str) -> Any:
|
|
439
|
+
"""Convert Anthropic response to OpenAI format."""
|
|
440
|
+
from types import SimpleNamespace
|
|
441
|
+
|
|
442
|
+
content = ""
|
|
443
|
+
if response.content:
|
|
444
|
+
content = response.content[0].text if hasattr(response.content[0], "text") else str(response.content[0])
|
|
445
|
+
|
|
446
|
+
return SimpleNamespace(
|
|
447
|
+
id=response.id,
|
|
448
|
+
model=model,
|
|
449
|
+
choices=[
|
|
450
|
+
SimpleNamespace(
|
|
451
|
+
index=0,
|
|
452
|
+
message=SimpleNamespace(
|
|
453
|
+
role="assistant",
|
|
454
|
+
content=content,
|
|
455
|
+
),
|
|
456
|
+
finish_reason=response.stop_reason,
|
|
457
|
+
)
|
|
458
|
+
],
|
|
459
|
+
usage=SimpleNamespace(
|
|
460
|
+
prompt_tokens=response.usage.input_tokens,
|
|
461
|
+
completion_tokens=response.usage.output_tokens,
|
|
462
|
+
total_tokens=response.usage.input_tokens + response.usage.output_tokens,
|
|
463
|
+
),
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
def _google_to_openai_response(self, response: Any, model: str) -> Any:
|
|
467
|
+
"""Convert Google response to OpenAI format."""
|
|
468
|
+
from types import SimpleNamespace
|
|
469
|
+
import uuid
|
|
470
|
+
|
|
471
|
+
content = response.text if hasattr(response, "text") else str(response)
|
|
472
|
+
|
|
473
|
+
return SimpleNamespace(
|
|
474
|
+
id=f"google-{uuid.uuid4().hex[:8]}",
|
|
475
|
+
model=model,
|
|
476
|
+
choices=[
|
|
477
|
+
SimpleNamespace(
|
|
478
|
+
index=0,
|
|
479
|
+
message=SimpleNamespace(
|
|
480
|
+
role="assistant",
|
|
481
|
+
content=content,
|
|
482
|
+
),
|
|
483
|
+
finish_reason="stop",
|
|
484
|
+
)
|
|
485
|
+
],
|
|
486
|
+
usage=SimpleNamespace(
|
|
487
|
+
prompt_tokens=getattr(response, "usage_metadata", {}).get("prompt_token_count", 0),
|
|
488
|
+
completion_tokens=getattr(response, "usage_metadata", {}).get("candidates_token_count", 0),
|
|
489
|
+
total_tokens=getattr(response, "usage_metadata", {}).get("total_token_count", 0),
|
|
490
|
+
),
|
|
491
|
+
)
|
|
492
|
+
|
|
493
|
+
def as_langchain(self):
|
|
494
|
+
"""Return a LangChain-compatible chat model."""
|
|
495
|
+
try:
|
|
496
|
+
from kalibr_langchain.chat_model import KalibrChatModel
|
|
497
|
+
return KalibrChatModel(router=self)
|
|
498
|
+
except ImportError:
|
|
499
|
+
raise ImportError("Install 'kalibr-langchain' package for LangChain integration")
|
kalibr/simple_tracer.py
CHANGED
|
@@ -19,6 +19,8 @@ Capsule Usage (automatic when middleware is active):
|
|
|
19
19
|
def process_request(request: Request, prompt: str):
|
|
20
20
|
# Capsule automatically updated with this hop
|
|
21
21
|
return llm_call(prompt)
|
|
22
|
+
|
|
23
|
+
Note: Uses centralized pricing from kalibr.pricing module.
|
|
22
24
|
"""
|
|
23
25
|
|
|
24
26
|
import json
|
|
@@ -31,6 +33,8 @@ from datetime import datetime, timezone
|
|
|
31
33
|
from functools import wraps
|
|
32
34
|
from typing import Callable, Optional
|
|
33
35
|
|
|
36
|
+
from kalibr.pricing import compute_cost
|
|
37
|
+
|
|
34
38
|
try:
|
|
35
39
|
import requests
|
|
36
40
|
except ImportError:
|
|
@@ -53,7 +57,7 @@ def send_event(payload: dict):
|
|
|
53
57
|
print("[Kalibr SDK] ❌ requests library not available")
|
|
54
58
|
return
|
|
55
59
|
|
|
56
|
-
url = os.getenv("KALIBR_COLLECTOR_URL", "https://
|
|
60
|
+
url = os.getenv("KALIBR_COLLECTOR_URL", "https://kalibr-backend.fly.dev/api/ingest")
|
|
57
61
|
api_key = os.getenv("KALIBR_API_KEY")
|
|
58
62
|
if not api_key:
|
|
59
63
|
print("[Kalibr SDK] ⚠️ KALIBR_API_KEY not set, traces will not be sent")
|
|
@@ -155,21 +159,18 @@ def trace(
|
|
|
155
159
|
actual_input_tokens = input_tokens or kwargs.get("input_tokens", 1000)
|
|
156
160
|
actual_output_tokens = output_tokens or kwargs.get("output_tokens", 500)
|
|
157
161
|
|
|
158
|
-
# Cost calculation
|
|
159
|
-
#
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
# Get unit price
|
|
168
|
-
provider_pricing = pricing_map.get(provider, {})
|
|
169
|
-
unit_price_usd = provider_pricing.get(model, 0.00002000) # Default $0.02/1M
|
|
162
|
+
# Cost calculation using centralized pricing
|
|
163
|
+
# This ensures consistency with all other cost adapters
|
|
164
|
+
total_cost_usd = compute_cost(
|
|
165
|
+
vendor=provider,
|
|
166
|
+
model_name=model,
|
|
167
|
+
input_tokens=actual_input_tokens,
|
|
168
|
+
output_tokens=actual_output_tokens,
|
|
169
|
+
)
|
|
170
170
|
|
|
171
|
-
# Calculate total cost
|
|
172
|
-
|
|
171
|
+
# Calculate unit price for backward compatibility (total cost / total tokens)
|
|
172
|
+
total_tokens = actual_input_tokens + actual_output_tokens
|
|
173
|
+
unit_price_usd = total_cost_usd / total_tokens if total_tokens > 0 else 0.0
|
|
173
174
|
|
|
174
175
|
# Build payload
|
|
175
176
|
payload = {
|
kalibr/trace_capsule.py
CHANGED
|
@@ -28,6 +28,7 @@ Usage:
|
|
|
28
28
|
"""
|
|
29
29
|
|
|
30
30
|
import json
|
|
31
|
+
import threading
|
|
31
32
|
import uuid
|
|
32
33
|
from datetime import datetime, timezone
|
|
33
34
|
from typing import Any, Dict, List, Optional
|
|
@@ -85,12 +86,16 @@ class TraceCapsule:
|
|
|
85
86
|
# Phase 3C: Context token propagation (keep as UUID for consistency)
|
|
86
87
|
self.context_token = context_token or str(uuid.uuid4())
|
|
87
88
|
self.parent_context_token = parent_context_token
|
|
89
|
+
# Thread-safety: Lock for protecting concurrent append_hop operations
|
|
90
|
+
self._lock = threading.Lock()
|
|
88
91
|
|
|
89
92
|
def append_hop(self, hop: Dict[str, Any]) -> None:
|
|
90
93
|
"""Append a new hop to the capsule.
|
|
91
94
|
|
|
92
95
|
Maintains a rolling window of last N hops to keep payload compact.
|
|
93
96
|
Updates aggregate metrics automatically.
|
|
97
|
+
|
|
98
|
+
Thread-safe: Uses internal lock to protect concurrent modifications.
|
|
94
99
|
|
|
95
100
|
Args:
|
|
96
101
|
hop: Dictionary containing hop metadata
|
|
@@ -111,22 +116,24 @@ class TraceCapsule:
|
|
|
111
116
|
"agent_name": "code-writer"
|
|
112
117
|
})
|
|
113
118
|
"""
|
|
114
|
-
#
|
|
115
|
-
|
|
119
|
+
# Thread-safe update of capsule state
|
|
120
|
+
with self._lock:
|
|
121
|
+
# Add hop_index
|
|
122
|
+
hop["hop_index"] = len(self.last_n_hops)
|
|
116
123
|
|
|
117
|
-
|
|
118
|
-
|
|
124
|
+
# Append to history
|
|
125
|
+
self.last_n_hops.append(hop)
|
|
119
126
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
127
|
+
# Maintain rolling window (keep last N hops)
|
|
128
|
+
if len(self.last_n_hops) > self.MAX_HOPS:
|
|
129
|
+
self.last_n_hops.pop(0)
|
|
123
130
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
131
|
+
# Update aggregates
|
|
132
|
+
self.aggregate_cost_usd += hop.get("cost_usd", 0.0)
|
|
133
|
+
self.aggregate_latency_ms += hop.get("duration_ms", 0.0)
|
|
127
134
|
|
|
128
|
-
|
|
129
|
-
|
|
135
|
+
# Update timestamp
|
|
136
|
+
self.timestamp = datetime.now(timezone.utc).isoformat()
|
|
130
137
|
|
|
131
138
|
def get_last_hop(self) -> Optional[Dict[str, Any]]:
|
|
132
139
|
"""Get the most recent hop.
|
kalibr/utils.py
CHANGED
|
@@ -38,8 +38,8 @@ def load_config_from_env() -> Dict[str, str]:
|
|
|
38
38
|
"workflow_id": os.getenv("KALIBR_WORKFLOW_ID", "default-workflow"),
|
|
39
39
|
"sandbox_id": os.getenv("SANDBOX_ID", "local"),
|
|
40
40
|
"runtime_env": os.getenv("RUNTIME_ENV", "local"),
|
|
41
|
-
"api_endpoint": os.getenv("KALIBR_API_ENDPOINT", "https://
|
|
42
|
-
"collector_url": os.getenv("KALIBR_COLLECTOR_URL", "https://
|
|
41
|
+
"api_endpoint": os.getenv("KALIBR_API_ENDPOINT", "https://kalibr-backend.fly.dev/api/v1/traces"),
|
|
42
|
+
"collector_url": os.getenv("KALIBR_COLLECTOR_URL", "https://kalibr-backend.fly.dev/api/ingest"),
|
|
43
43
|
}
|
|
44
44
|
return config
|
|
45
45
|
|