tracify-sdk 0.2.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.
tracify/__init__.py
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import time
|
|
3
|
+
import json
|
|
4
|
+
import uuid
|
|
5
|
+
import functools
|
|
6
|
+
import asyncio
|
|
7
|
+
import threading
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Callable, Dict, List, Optional, Union
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
import httpx
|
|
15
|
+
_HAS_HTTPX = True
|
|
16
|
+
except ImportError:
|
|
17
|
+
_HAS_HTTPX = False
|
|
18
|
+
|
|
19
|
+
import requests as _requests_fallback
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class LatencyBudgetExceeded(Exception):
|
|
23
|
+
"""Raised when a call exceeds the latency budget and is aborted."""
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class CancellationToken:
|
|
28
|
+
"""
|
|
29
|
+
Cooperative cancellation token passed to the user's call function.
|
|
30
|
+
|
|
31
|
+
When the latency budget expires, the SDK sets this token as cancelled.
|
|
32
|
+
The user's call function should check ``token.is_cancelled`` periodically
|
|
33
|
+
and raise or return early if true.
|
|
34
|
+
|
|
35
|
+
Example::
|
|
36
|
+
|
|
37
|
+
def my_llm_call(model: str, token: CancellationToken) -> dict:
|
|
38
|
+
# Check before making the HTTP call
|
|
39
|
+
if token.is_cancelled:
|
|
40
|
+
raise LatencyBudgetExceeded("Cancelled")
|
|
41
|
+
response = httpx.post(..., timeout=30)
|
|
42
|
+
return response.json()
|
|
43
|
+
|
|
44
|
+
For true cancellation of in-flight HTTP requests, the user should use
|
|
45
|
+
``token`` with an httpx client::
|
|
46
|
+
|
|
47
|
+
def my_llm_call(model: str, token: CancellationToken) -> dict:
|
|
48
|
+
with httpx.Client(timeout=30) as client:
|
|
49
|
+
# httpx will close the connection if the client is garbage collected
|
|
50
|
+
# after the timeout fires and this function returns
|
|
51
|
+
return client.post(...).json()
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self):
|
|
55
|
+
self._cancelled = threading.Event()
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def is_cancelled(self) -> bool:
|
|
59
|
+
return self._cancelled.is_set()
|
|
60
|
+
|
|
61
|
+
def cancel(self):
|
|
62
|
+
self._cancelled.set()
|
|
63
|
+
|
|
64
|
+
def check(self):
|
|
65
|
+
"""Raise LatencyBudgetExceeded if cancelled. Call this in long-running loops."""
|
|
66
|
+
if self._cancelled.is_set():
|
|
67
|
+
raise LatencyBudgetExceeded("Call cancelled by latency budget")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class RuntimePolicy:
|
|
72
|
+
enforcement_mode: str = "observe" # "observe" | "enforce"
|
|
73
|
+
max_cost_per_run: Optional[float] = None
|
|
74
|
+
max_cost_per_day: Optional[float] = None
|
|
75
|
+
fallback_chain: List[str] = field(default_factory=list)
|
|
76
|
+
max_attempts: int = 3
|
|
77
|
+
backoff_ms: float = 1000
|
|
78
|
+
backoff_multiplier: float = 2
|
|
79
|
+
retryable_errors: List[str] = field(default_factory=lambda: ["timeout", "5xx"])
|
|
80
|
+
latency_budget_ms: Optional[int] = None
|
|
81
|
+
|
|
82
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
83
|
+
return {
|
|
84
|
+
"enforcementMode": self.enforcement_mode,
|
|
85
|
+
"maxCostPerRun": self.max_cost_per_run,
|
|
86
|
+
"maxCostPerDay": self.max_cost_per_day,
|
|
87
|
+
"fallbackChain": self.fallback_chain,
|
|
88
|
+
"retryPolicy": {
|
|
89
|
+
"maxAttempts": self.max_attempts,
|
|
90
|
+
"backoffMs": self.backoff_ms,
|
|
91
|
+
"backoffMultiplier": self.backoff_multiplier,
|
|
92
|
+
"retryableErrors": self.retryable_errors,
|
|
93
|
+
},
|
|
94
|
+
"latencyBudgetMs": self.latency_budget_ms,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _is_retryable_error(error: Exception, retryable_errors: List[str]) -> bool:
|
|
99
|
+
if isinstance(error, LatencyBudgetExceeded):
|
|
100
|
+
return False
|
|
101
|
+
msg = str(error).lower()
|
|
102
|
+
status = getattr(error, "status_code", None) or getattr(error, "status", None) or 0
|
|
103
|
+
|
|
104
|
+
for code in retryable_errors:
|
|
105
|
+
if code == "timeout" and ("timeout" in msg or "timed out" in msg):
|
|
106
|
+
return True
|
|
107
|
+
if code == "5xx" and isinstance(status, int) and 500 <= status < 600:
|
|
108
|
+
return True
|
|
109
|
+
if code == "429" and (status == 429 or "rate limit" in msg or "too many requests" in msg):
|
|
110
|
+
return True
|
|
111
|
+
if code == "rate_limit" and (status == 429 or "rate limit" in msg or "throttl" in msg):
|
|
112
|
+
return True
|
|
113
|
+
if code == "overloaded" and ("overloaded" in msg or "capacity" in msg or "busy" in msg):
|
|
114
|
+
return True
|
|
115
|
+
return False
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _is_latency_abort(error: Exception) -> bool:
|
|
119
|
+
return isinstance(error, LatencyBudgetExceeded)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
_EXECUTOR = ThreadPoolExecutor(max_workers=4, thread_name_prefix="tracify-latency")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _run_with_timeout(
|
|
126
|
+
func: Callable[[CancellationToken], Any],
|
|
127
|
+
timeout_ms: Optional[int],
|
|
128
|
+
) -> Any:
|
|
129
|
+
"""
|
|
130
|
+
Run func(token) in a thread with a cooperative timeout.
|
|
131
|
+
|
|
132
|
+
The function receives a CancellationToken. When the timeout fires,
|
|
133
|
+
the token is set to cancelled. The function is responsible for checking
|
|
134
|
+
token.is_cancelled (or calling token.check()) and exiting cleanly.
|
|
135
|
+
|
|
136
|
+
If the function does not check the token, it will continue running in
|
|
137
|
+
the background after LatencyBudgetExceeded is raised. This is the same
|
|
138
|
+
behavior as TS's AbortController when the user's call ignores the signal,
|
|
139
|
+
but in Python the "background" thread is more visible.
|
|
140
|
+
|
|
141
|
+
Returns the function's result, or raises LatencyBudgetExceeded on timeout.
|
|
142
|
+
"""
|
|
143
|
+
if timeout_ms is None:
|
|
144
|
+
token = CancellationToken()
|
|
145
|
+
return func(token)
|
|
146
|
+
|
|
147
|
+
token = CancellationToken()
|
|
148
|
+
result = [None]
|
|
149
|
+
exception = [None]
|
|
150
|
+
|
|
151
|
+
def target():
|
|
152
|
+
try:
|
|
153
|
+
result[0] = func(token)
|
|
154
|
+
except Exception as e:
|
|
155
|
+
exception[0] = e
|
|
156
|
+
|
|
157
|
+
future = _EXECUTOR.submit(target)
|
|
158
|
+
try:
|
|
159
|
+
future.result(timeout=timeout_ms / 1000)
|
|
160
|
+
except FuturesTimeout:
|
|
161
|
+
token.cancel()
|
|
162
|
+
# Give the function a brief window to notice the cancellation
|
|
163
|
+
# and clean up (e.g., close HTTP connections).
|
|
164
|
+
try:
|
|
165
|
+
future.result(timeout=0.5)
|
|
166
|
+
except (FuturesTimeout, Exception):
|
|
167
|
+
pass
|
|
168
|
+
raise LatencyBudgetExceeded(
|
|
169
|
+
f"Call exceeded latency budget of {timeout_ms}ms. "
|
|
170
|
+
"The underlying call may still be running in the background."
|
|
171
|
+
)
|
|
172
|
+
except Exception as e:
|
|
173
|
+
if exception[0] is not None:
|
|
174
|
+
raise exception[0]
|
|
175
|
+
raise
|
|
176
|
+
|
|
177
|
+
if exception[0] is not None:
|
|
178
|
+
raise exception[0]
|
|
179
|
+
|
|
180
|
+
return result[0]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class TracifyClient:
|
|
184
|
+
REGION_HOSTS = {
|
|
185
|
+
"eu": "https://eu.cloud.tracify.tech",
|
|
186
|
+
"us": "https://us.cloud.tracify.tech",
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
def __init__(self, api_key: Optional[str] = None, host: Optional[str] = None, project_id: Optional[str] = None, region: Optional[str] = None):
|
|
190
|
+
self.api_key = api_key or os.environ.get("TRACIFY_API_KEY")
|
|
191
|
+
if not self.api_key:
|
|
192
|
+
raise ValueError("TRACIFY_API_KEY or TRACIFY_API_KEY must be provided or set as an environment variable")
|
|
193
|
+
selected_region = (region or os.environ.get("TRACIFY_REGION") or "eu").strip().lower()
|
|
194
|
+
if selected_region not in self.REGION_HOSTS:
|
|
195
|
+
raise ValueError("TRACIFY_REGION must be 'eu' or 'us'")
|
|
196
|
+
self.host = (host or os.environ.get("TRACIFY_HOST") or self.REGION_HOSTS[selected_region]).rstrip("/")
|
|
197
|
+
host_region = next((key for key, value in self.REGION_HOSTS.items() if value == self.host), None)
|
|
198
|
+
key_region = self._api_key_region(self.api_key)
|
|
199
|
+
if key_region and host_region and key_region != host_region:
|
|
200
|
+
raise ValueError(f"Tracify API key region mismatch: this key belongs to {key_region.upper()}, but the client is configured for {host_region.upper()}.")
|
|
201
|
+
self.project_id = project_id
|
|
202
|
+
self.ingest_url = f"{self.host}/api/ingest"
|
|
203
|
+
self.check_cost_url = f"{self.host}/api/orchestration/check-cost"
|
|
204
|
+
self._http = httpx.Client(timeout=10) if _HAS_HTTPX else None
|
|
205
|
+
self._last_fail_open = False
|
|
206
|
+
self._prompt_cache: Dict[str, Any] = {}
|
|
207
|
+
|
|
208
|
+
@staticmethod
|
|
209
|
+
def _api_key_region(api_key: str) -> Optional[str]:
|
|
210
|
+
if api_key.startswith("tracify_sk_live_eu_"):
|
|
211
|
+
return "eu"
|
|
212
|
+
if api_key.startswith("tracify_sk_live_us_"):
|
|
213
|
+
return "us"
|
|
214
|
+
if api_key.startswith("tracify_sk_live_"):
|
|
215
|
+
return "eu"
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
def _send_span(self, span: Dict[str, Any]):
|
|
219
|
+
try:
|
|
220
|
+
headers = {
|
|
221
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
222
|
+
"Content-Type": "application/json"
|
|
223
|
+
}
|
|
224
|
+
if self._http:
|
|
225
|
+
self._http.post(self.ingest_url, headers=headers, json=span)
|
|
226
|
+
else:
|
|
227
|
+
_requests_fallback.post(self.ingest_url, headers=headers, json=span, timeout=2)
|
|
228
|
+
except Exception as e:
|
|
229
|
+
print(f"Tracify Warning: Failed to ingest span: {e}")
|
|
230
|
+
|
|
231
|
+
def ingest(self, **kwargs):
|
|
232
|
+
span = {
|
|
233
|
+
"spanId": str(uuid.uuid4()),
|
|
234
|
+
"createdAt": datetime.utcnow().isoformat() + "Z",
|
|
235
|
+
"metadata": {},
|
|
236
|
+
**kwargs
|
|
237
|
+
}
|
|
238
|
+
self._send_span(span)
|
|
239
|
+
|
|
240
|
+
def get_prompt(self, name: str, environment: str = "production", cache_ttl_seconds: float = 60, fallback: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
241
|
+
"""Resolve a deployed prompt, using a short-lived cache and stale/fallback recovery."""
|
|
242
|
+
key = f"{name}:{environment}"
|
|
243
|
+
now = time.time()
|
|
244
|
+
cached = self._prompt_cache.get(key)
|
|
245
|
+
if cached and cached["expires_at"] > now:
|
|
246
|
+
return cached["value"]
|
|
247
|
+
try:
|
|
248
|
+
response = (self._http.get if self._http else _requests_fallback.get)(f"{self.host}/api/prompts/{name}", headers={"Authorization": f"Bearer {self.api_key}"}, params={"environment": environment}, timeout=10)
|
|
249
|
+
if response.status_code >= 400:
|
|
250
|
+
raise RuntimeError(f"Prompt resolution failed ({response.status_code}): {response.text[:300]}")
|
|
251
|
+
value = response.json()
|
|
252
|
+
self._prompt_cache[key] = {"value": value, "expires_at": now + cache_ttl_seconds}
|
|
253
|
+
return value
|
|
254
|
+
except Exception:
|
|
255
|
+
if cached:
|
|
256
|
+
return cached["value"]
|
|
257
|
+
if fallback is not None:
|
|
258
|
+
return fallback
|
|
259
|
+
raise
|
|
260
|
+
|
|
261
|
+
def _send_feedback(self, payload: Dict[str, Any]):
|
|
262
|
+
if not self.project_id:
|
|
263
|
+
print("Tracify Warning: project_id is required for feedback and score helpers")
|
|
264
|
+
return
|
|
265
|
+
try:
|
|
266
|
+
headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
|
|
267
|
+
if self._http:
|
|
268
|
+
self._http.post(f"{self.host}/api/feedback", headers=headers, json={"projectId": self.project_id, **payload}, timeout=5)
|
|
269
|
+
else:
|
|
270
|
+
_requests_fallback.post(f"{self.host}/api/feedback", headers=headers, json={"projectId": self.project_id, **payload}, timeout=5)
|
|
271
|
+
except Exception as e:
|
|
272
|
+
print(f"Tracify Warning: Failed to record evaluation signal: {e}")
|
|
273
|
+
|
|
274
|
+
def feedback(self, trace_id: str, value: Union[bool, float, int, str], kind: Optional[str] = None, span_id: Optional[str] = None, reason: Optional[str] = None, comment: Optional[str] = None, end_user_id: Optional[str] = None, dedupe_key: Optional[str] = None):
|
|
275
|
+
inferred_kind = kind or ("star" if isinstance(value, (int, float)) and not isinstance(value, bool) else "thumb" if isinstance(value, bool) else "text")
|
|
276
|
+
self._send_feedback({"traceId": trace_id, "spanId": span_id, "kind": inferred_kind, "value": value, "reason": reason, "comment": comment, "endUserId": end_user_id, "dedupeKey": dedupe_key})
|
|
277
|
+
|
|
278
|
+
def score(self, trace_id: str, name: str, value: Union[bool, float, int, str], data_type: Optional[str] = None, span_id: Optional[str] = None, comment: Optional[str] = None):
|
|
279
|
+
self._send_feedback({"traceId": trace_id, "spanId": span_id, "kind": "text", "name": name, "value": value, "dataType": data_type, "comment": comment})
|
|
280
|
+
|
|
281
|
+
def _check_cost(self, run_id: str, increment_usd: float) -> Dict[str, Any]:
|
|
282
|
+
"""Server-side cost check. Returns the server's decision."""
|
|
283
|
+
try:
|
|
284
|
+
headers = {
|
|
285
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
286
|
+
"Content-Type": "application/json"
|
|
287
|
+
}
|
|
288
|
+
if self._http:
|
|
289
|
+
resp = self._http.post(
|
|
290
|
+
self.check_cost_url,
|
|
291
|
+
headers=headers,
|
|
292
|
+
json={"runId": run_id, "incrementUsd": increment_usd},
|
|
293
|
+
)
|
|
294
|
+
else:
|
|
295
|
+
resp = _requests_fallback.post(
|
|
296
|
+
self.check_cost_url,
|
|
297
|
+
headers=headers,
|
|
298
|
+
json={"runId": run_id, "incrementUsd": increment_usd},
|
|
299
|
+
timeout=5,
|
|
300
|
+
)
|
|
301
|
+
if resp.status_code != 200:
|
|
302
|
+
self._last_fail_open = True
|
|
303
|
+
return {"allowed": True, "reason": "check_failed"}
|
|
304
|
+
self._last_fail_open = False
|
|
305
|
+
return resp.json()
|
|
306
|
+
except Exception:
|
|
307
|
+
self._last_fail_open = True
|
|
308
|
+
return {"allowed": True, "reason": "network_error"}
|
|
309
|
+
|
|
310
|
+
def orchestrate(
|
|
311
|
+
self,
|
|
312
|
+
policy: RuntimePolicy,
|
|
313
|
+
call: Callable[[str, CancellationToken], Any],
|
|
314
|
+
model: str,
|
|
315
|
+
input_data: Any = None,
|
|
316
|
+
calculate_cost: Optional[Callable[[Any, str], float]] = None,
|
|
317
|
+
run_id: Optional[str] = None,
|
|
318
|
+
) -> Any:
|
|
319
|
+
run_id = run_id or os.environ.get("TRACIFY_CURRENT_RUN_ID") or os.environ.get("TRACIFY_CURRENT_RUN_ID") or str(uuid.uuid4())
|
|
320
|
+
all_models = [model] + [m for m in policy.fallback_chain if m != model]
|
|
321
|
+
last_error: Optional[Exception] = None
|
|
322
|
+
|
|
323
|
+
for model_index, current_model in enumerate(all_models):
|
|
324
|
+
is_fallback = model_index > 0
|
|
325
|
+
fallback_reason = None
|
|
326
|
+
if is_fallback:
|
|
327
|
+
if last_error and _is_latency_abort(last_error):
|
|
328
|
+
fallback_reason = "latency_budget"
|
|
329
|
+
elif last_error:
|
|
330
|
+
fallback_reason = "provider_error"
|
|
331
|
+
else:
|
|
332
|
+
fallback_reason = "cost_ceiling"
|
|
333
|
+
|
|
334
|
+
max_attempts = 1 if is_fallback else (policy.max_attempts + 1)
|
|
335
|
+
|
|
336
|
+
for attempt in range(1, max_attempts + 1):
|
|
337
|
+
span_id = str(uuid.uuid4())
|
|
338
|
+
start_time = time.time()
|
|
339
|
+
|
|
340
|
+
# Server-side cost ceiling enforcement
|
|
341
|
+
if policy.enforcement_mode == "enforce":
|
|
342
|
+
estimated_cost = 0.01 if calculate_cost else 0
|
|
343
|
+
|
|
344
|
+
if policy.max_cost_per_run is not None:
|
|
345
|
+
check = self._check_cost(run_id, estimated_cost)
|
|
346
|
+
if not check.get("allowed") and check.get("reason") == "maxCostPerRun":
|
|
347
|
+
self.ingest(
|
|
348
|
+
spanId=span_id,
|
|
349
|
+
runId=run_id,
|
|
350
|
+
spanType="llm_call",
|
|
351
|
+
input=json.dumps(input_data) if input_data is not None else "",
|
|
352
|
+
output=json.dumps({"blocked": True, "reason": "cost_ceiling"}),
|
|
353
|
+
modelId=current_model,
|
|
354
|
+
costUsd=0,
|
|
355
|
+
latencyMs=0,
|
|
356
|
+
metadata={
|
|
357
|
+
"orchestrationBlocked": True,
|
|
358
|
+
"orchestrationReason": "cost_ceiling",
|
|
359
|
+
"orchestrationAttempt": attempt,
|
|
360
|
+
"orchestrationModel": current_model,
|
|
361
|
+
"orchestrationOriginalModel": model,
|
|
362
|
+
"orchestrationServerCost": check.get("currentCost"),
|
|
363
|
+
"orchestrationCeiling": check.get("ceiling"),
|
|
364
|
+
"orchestrationFailOpen": self._last_fail_open,
|
|
365
|
+
"orchestrationPolicy": json.dumps(policy.to_dict()),
|
|
366
|
+
},
|
|
367
|
+
)
|
|
368
|
+
if model_index < len(all_models) - 1:
|
|
369
|
+
last_error = Exception(f"Cost ceiling reached: ${check.get('currentCost', 0):.4f} >= ${check.get('ceiling')}")
|
|
370
|
+
break
|
|
371
|
+
raise Exception(f"Cost ceiling reached: ${check.get('currentCost', 0):.4f} >= ${check.get('ceiling')}")
|
|
372
|
+
|
|
373
|
+
if policy.max_cost_per_day is not None:
|
|
374
|
+
check = self._check_cost(run_id, estimated_cost)
|
|
375
|
+
if not check.get("allowed") and check.get("reason") == "maxCostPerDay":
|
|
376
|
+
self.ingest(
|
|
377
|
+
spanId=span_id,
|
|
378
|
+
runId=run_id,
|
|
379
|
+
spanType="llm_call",
|
|
380
|
+
input=json.dumps(input_data) if input_data is not None else "",
|
|
381
|
+
output=json.dumps({"blocked": True, "reason": "daily_cost_ceiling"}),
|
|
382
|
+
modelId=current_model,
|
|
383
|
+
costUsd=0,
|
|
384
|
+
latencyMs=0,
|
|
385
|
+
metadata={
|
|
386
|
+
"orchestrationBlocked": True,
|
|
387
|
+
"orchestrationReason": "daily_cost_ceiling",
|
|
388
|
+
"orchestrationAttempt": attempt,
|
|
389
|
+
"orchestrationModel": current_model,
|
|
390
|
+
"orchestrationOriginalModel": model,
|
|
391
|
+
"orchestrationServerCost": check.get("currentCost"),
|
|
392
|
+
"orchestrationCeiling": check.get("ceiling"),
|
|
393
|
+
"orchestrationFailOpen": self._last_fail_open,
|
|
394
|
+
"orchestrationPolicy": json.dumps(policy.to_dict()),
|
|
395
|
+
},
|
|
396
|
+
)
|
|
397
|
+
if model_index < len(all_models) - 1:
|
|
398
|
+
last_error = Exception(f"Daily cost ceiling reached: ${check.get('currentCost', 0):.4f} >= ${check.get('ceiling')}")
|
|
399
|
+
break
|
|
400
|
+
raise Exception(f"Daily cost ceiling reached: ${check.get('currentCost', 0):.4f} >= ${check.get('ceiling')}")
|
|
401
|
+
|
|
402
|
+
# Latency budget enforcement via cooperative cancellation
|
|
403
|
+
try:
|
|
404
|
+
timeout = policy.latency_budget_ms if policy.enforcement_mode == "enforce" else None
|
|
405
|
+
result = _run_with_timeout(
|
|
406
|
+
lambda token, m=current_model: call(m, token),
|
|
407
|
+
timeout,
|
|
408
|
+
)
|
|
409
|
+
latency_ms = int((time.time() - start_time) * 1000)
|
|
410
|
+
cost_usd = calculate_cost(result, current_model) if calculate_cost else 0
|
|
411
|
+
|
|
412
|
+
# Report actual cost to server
|
|
413
|
+
if policy.enforcement_mode == "enforce" and cost_usd > 0:
|
|
414
|
+
self._check_cost(run_id, cost_usd)
|
|
415
|
+
|
|
416
|
+
self.ingest(
|
|
417
|
+
spanId=span_id,
|
|
418
|
+
runId=run_id,
|
|
419
|
+
spanType="llm_call",
|
|
420
|
+
input=json.dumps(input_data) if input_data is not None else "",
|
|
421
|
+
output=json.dumps(result) if not isinstance(result, str) else result,
|
|
422
|
+
modelId=current_model,
|
|
423
|
+
costUsd=cost_usd,
|
|
424
|
+
latencyMs=latency_ms,
|
|
425
|
+
metadata={
|
|
426
|
+
"orchestrationAttempt": attempt,
|
|
427
|
+
"orchestrationModel": current_model,
|
|
428
|
+
"orchestrationOriginalModel": model,
|
|
429
|
+
"orchestrationIsFallback": is_fallback,
|
|
430
|
+
"orchestrationFallbackReason": fallback_reason,
|
|
431
|
+
"orchestrationFinal": True,
|
|
432
|
+
"orchestrationFailOpen": self._last_fail_open,
|
|
433
|
+
"orchestrationPolicy": json.dumps(policy.to_dict()),
|
|
434
|
+
},
|
|
435
|
+
)
|
|
436
|
+
return result
|
|
437
|
+
|
|
438
|
+
except Exception as error:
|
|
439
|
+
latency_ms = int((time.time() - start_time) * 1000)
|
|
440
|
+
last_error = error
|
|
441
|
+
|
|
442
|
+
retryable = _is_retryable_error(error, policy.retryable_errors)
|
|
443
|
+
is_last_attempt = attempt == max_attempts
|
|
444
|
+
was_latency_abort = _is_latency_abort(error)
|
|
445
|
+
|
|
446
|
+
self.ingest(
|
|
447
|
+
spanId=span_id,
|
|
448
|
+
runId=run_id,
|
|
449
|
+
spanType="llm_call" if (retryable and not is_last_attempt) else "error",
|
|
450
|
+
input=json.dumps(input_data) if input_data is not None else "",
|
|
451
|
+
output=str(error),
|
|
452
|
+
modelId=current_model,
|
|
453
|
+
costUsd=0,
|
|
454
|
+
latencyMs=latency_ms,
|
|
455
|
+
metadata={
|
|
456
|
+
"orchestrationAttempt": attempt,
|
|
457
|
+
"orchestrationModel": current_model,
|
|
458
|
+
"orchestrationOriginalModel": model,
|
|
459
|
+
"orchestrationIsFallback": is_fallback,
|
|
460
|
+
"orchestrationFallbackReason": fallback_reason,
|
|
461
|
+
"orchestrationError": True,
|
|
462
|
+
"orchestrationLatencyAbort": was_latency_abort,
|
|
463
|
+
"orchestrationRetryable": retryable,
|
|
464
|
+
"orchestrationWillRetry": retryable and not is_last_attempt,
|
|
465
|
+
"orchestrationFailOpen": self._last_fail_open,
|
|
466
|
+
"orchestrationPolicy": json.dumps(policy.to_dict()),
|
|
467
|
+
},
|
|
468
|
+
)
|
|
469
|
+
|
|
470
|
+
if retryable and not is_last_attempt:
|
|
471
|
+
delay = policy.backoff_ms * (policy.backoff_multiplier ** (attempt - 1))
|
|
472
|
+
time.sleep(delay / 1000)
|
|
473
|
+
continue
|
|
474
|
+
|
|
475
|
+
break
|
|
476
|
+
|
|
477
|
+
raise last_error or Exception("All fallback models exhausted")
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def trace_agent(client: Optional[TracifyClient] = None):
|
|
481
|
+
"""
|
|
482
|
+
Decorator to wrap an agent's main entry point.
|
|
483
|
+
Automatically handles runId generation and run_end span.
|
|
484
|
+
"""
|
|
485
|
+
def decorator(func: Callable):
|
|
486
|
+
@functools.wraps(func)
|
|
487
|
+
async def async_wrapper(*args, **kwargs):
|
|
488
|
+
nonlocal client
|
|
489
|
+
if client is None:
|
|
490
|
+
client = TracifyClient()
|
|
491
|
+
|
|
492
|
+
run_id = str(uuid.uuid4())
|
|
493
|
+
start_time = time.time()
|
|
494
|
+
|
|
495
|
+
os.environ["TRACIFY_CURRENT_RUN_ID"] = run_id
|
|
496
|
+
os.environ["TRACIFY_CURRENT_RUN_ID"] = run_id
|
|
497
|
+
|
|
498
|
+
try:
|
|
499
|
+
result = await func(*args, **kwargs)
|
|
500
|
+
latency_ms = int((time.time() - start_time) * 1000)
|
|
501
|
+
client.ingest(
|
|
502
|
+
runId=run_id,
|
|
503
|
+
spanType="run_end",
|
|
504
|
+
output=json.dumps(result) if result else "",
|
|
505
|
+
latencyMs=latency_ms,
|
|
506
|
+
costUsd=0,
|
|
507
|
+
)
|
|
508
|
+
return result
|
|
509
|
+
except Exception as e:
|
|
510
|
+
latency_ms = int((time.time() - start_time) * 1000)
|
|
511
|
+
client.ingest(
|
|
512
|
+
runId=run_id,
|
|
513
|
+
spanType="error",
|
|
514
|
+
output=str(e),
|
|
515
|
+
latencyMs=latency_ms,
|
|
516
|
+
costUsd=0,
|
|
517
|
+
)
|
|
518
|
+
raise e
|
|
519
|
+
|
|
520
|
+
@functools.wraps(func)
|
|
521
|
+
def sync_wrapper(*args, **kwargs):
|
|
522
|
+
nonlocal client
|
|
523
|
+
if client is None:
|
|
524
|
+
client = TracifyClient()
|
|
525
|
+
|
|
526
|
+
run_id = str(uuid.uuid4())
|
|
527
|
+
start_time = time.time()
|
|
528
|
+
os.environ["TRACIFY_CURRENT_RUN_ID"] = run_id
|
|
529
|
+
os.environ["TRACIFY_CURRENT_RUN_ID"] = run_id
|
|
530
|
+
|
|
531
|
+
try:
|
|
532
|
+
result = func(*args, **kwargs)
|
|
533
|
+
latency_ms = int((time.time() - start_time) * 1000)
|
|
534
|
+
client.ingest(
|
|
535
|
+
runId=run_id,
|
|
536
|
+
spanType="run_end",
|
|
537
|
+
output=json.dumps(result) if result else "",
|
|
538
|
+
latencyMs=latency_ms,
|
|
539
|
+
costUsd=0,
|
|
540
|
+
)
|
|
541
|
+
return result
|
|
542
|
+
except Exception as e:
|
|
543
|
+
latency_ms = int((time.time() - start_time) * 1000)
|
|
544
|
+
client.ingest(
|
|
545
|
+
runId=run_id,
|
|
546
|
+
spanType="error",
|
|
547
|
+
output=str(e),
|
|
548
|
+
latencyMs=latency_ms,
|
|
549
|
+
costUsd=0,
|
|
550
|
+
)
|
|
551
|
+
raise e
|
|
552
|
+
|
|
553
|
+
if asyncio.iscoroutinefunction(func):
|
|
554
|
+
return async_wrapper
|
|
555
|
+
return sync_wrapper
|
|
556
|
+
return decorator
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def llm_call(input_data: Any, output_data: Any, model_id: str, cost_usd: float, latency_ms: int, client: Optional[TracifyClient] = None, session_id: Optional[str] = None, end_user_id: Optional[str] = None, environment: Optional[str] = None, release: Optional[str] = None, tags: Optional[List[str]] = None, trace_name: Optional[str] = None):
|
|
560
|
+
if client is None: client = TracifyClient()
|
|
561
|
+
client.ingest(
|
|
562
|
+
runId=os.environ.get("TRACIFY_CURRENT_RUN_ID") or os.environ.get("TRACIFY_CURRENT_RUN_ID", "unknown"),
|
|
563
|
+
spanType="llm_call",
|
|
564
|
+
input=json.dumps(input_data) if not isinstance(input_data, str) else input_data,
|
|
565
|
+
output=json.dumps(output_data) if not isinstance(output_data, str) else output_data,
|
|
566
|
+
modelId=model_id,
|
|
567
|
+
costUsd=cost_usd,
|
|
568
|
+
latencyMs=latency_ms,
|
|
569
|
+
sessionId=session_id or "",
|
|
570
|
+
endUserId=end_user_id or "",
|
|
571
|
+
environment=environment or "",
|
|
572
|
+
release=release or "",
|
|
573
|
+
tags=tags or [],
|
|
574
|
+
traceName=trace_name or "",
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def tool_call(tool_name: str, input_data: Any, output_data: Any, latency_ms: int, client: Optional[TracifyClient] = None, session_id: Optional[str] = None, end_user_id: Optional[str] = None, environment: Optional[str] = None, release: Optional[str] = None, tags: Optional[List[str]] = None, trace_name: Optional[str] = None):
|
|
579
|
+
if client is None: client = TracifyClient()
|
|
580
|
+
client.ingest(
|
|
581
|
+
runId=os.environ.get("TRACIFY_CURRENT_RUN_ID") or os.environ.get("TRACIFY_CURRENT_RUN_ID", "unknown"),
|
|
582
|
+
spanType="tool_call",
|
|
583
|
+
toolName=tool_name,
|
|
584
|
+
input=json.dumps(input_data) if not isinstance(input_data, str) else input_data,
|
|
585
|
+
output=json.dumps(output_data) if not isinstance(output_data, str) else output_data,
|
|
586
|
+
latencyMs=latency_ms,
|
|
587
|
+
costUsd=0,
|
|
588
|
+
sessionId=session_id or "",
|
|
589
|
+
endUserId=end_user_id or "",
|
|
590
|
+
environment=environment or "",
|
|
591
|
+
release=release or "",
|
|
592
|
+
tags=tags or [],
|
|
593
|
+
traceName=trace_name or "",
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tracify-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Observability, runtime enforcement, and tracing for AI agents.
|
|
5
|
+
Author-email: Tracify Team <hello@tracify.tech>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://tracify.tech
|
|
8
|
+
Project-URL: Documentation, https://docs.tracify.tech
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/tracify/python-sdk/issues
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Requires-Python: >=3.7
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: requests>=2.25.0
|
|
15
|
+
Requires-Dist: httpx>=0.24.0
|
|
16
|
+
|
|
17
|
+
# Tracify Python SDK
|
|
18
|
+
|
|
19
|
+
Observability and tracing for AI agents. Gain full visibility into your agent's decision-making process, tool usage, and LLM costs.
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pip install tracify-sdk
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Quickstart
|
|
28
|
+
|
|
29
|
+
1. Get your API Key from the [Tracify Dashboard](https://tracify.tech).
|
|
30
|
+
2. Set it as an environment variable:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
export TRACIFY_API_KEY=your_sk_live_...
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
3. Instrument your agent:
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
from tracify import trace_agent, llm_call, tool_call
|
|
40
|
+
|
|
41
|
+
@trace_agent()
|
|
42
|
+
async def my_agent(query: str):
|
|
43
|
+
# LLM Call
|
|
44
|
+
llm_call(
|
|
45
|
+
input_data=query,
|
|
46
|
+
output_data="Thinking...",
|
|
47
|
+
model_id="gpt-4o",
|
|
48
|
+
cost_usd=0.002,
|
|
49
|
+
latency_ms=800
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Tool Use
|
|
53
|
+
tool_call(
|
|
54
|
+
tool_name="web_search",
|
|
55
|
+
input_data={"q": query},
|
|
56
|
+
output_data="Found results...",
|
|
57
|
+
latency_ms=1200
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return "Final Answer"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Features
|
|
64
|
+
|
|
65
|
+
- **@trace_agent Decorator:** Automatically captures run lifecycle and errors.
|
|
66
|
+
- **Granular Spans:** Manually record LLMs, tools, and custom decisions.
|
|
67
|
+
- **Cost Tracking:** Monitor your infrastructure spend in real-time.
|
|
68
|
+
- **Asynchronous & Synchronous Support:** Works with `async/await` or standard functions.
|
|
69
|
+
- **Session context:** Attach `session_id`, `end_user_id`, `environment`, `release`, and `tags` to connect related traces.
|
|
70
|
+
|
|
71
|
+
## Feedback and evaluation scores
|
|
72
|
+
|
|
73
|
+
Pass `project_id` when constructing the client to attach end-user feedback and typed scores to traces:
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
client = TracifyClient(project_id="your-project-id")
|
|
77
|
+
client.feedback(run_id, True, kind="thumb", comment="Helpful answer")
|
|
78
|
+
client.score(run_id, "answer_quality", 0.92, data_type="numeric")
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Feedback and scores use the same API key as ingestion and do not interrupt the agent when delivery fails.
|
|
82
|
+
|
|
83
|
+
## Prompt deployment
|
|
84
|
+
|
|
85
|
+
Resolve a deployed prompt without shipping a new application build:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
deployed = client.get_prompt("support-agent", "production")
|
|
89
|
+
client.ingest(spanType="llm", input=question, output=answer, promptVersionId=deployed["version"]["id"])
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Prompt responses are cached for 60 seconds by default. Use `cache_ttl_seconds=0` or provide a `fallback` for development and guaranteed-availability behavior.
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
tracify/__init__.py,sha256=2dWTQH4lODlwHDeOJzp_dbLMw_ZbNJkIGg4fW0jpkDc,26672
|
|
2
|
+
tracify_sdk-0.2.0.dist-info/METADATA,sha256=pgign8knE9eJ0nSW06dgsGe-Fk5rtfnJ5aC9ku5WJUc,2834
|
|
3
|
+
tracify_sdk-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
4
|
+
tracify_sdk-0.2.0.dist-info/top_level.txt,sha256=lYYW1kNrFaWyvmH6qxknVvTJrAPtHoj3b6yMz0zNGB4,8
|
|
5
|
+
tracify_sdk-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tracify
|