kerneva-runtime-trust 0.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.
@@ -0,0 +1,43 @@
1
+ from .client import KernevaClient
2
+ from .context import (
3
+ current_agent_id,
4
+ current_customer_id,
5
+ current_metadata,
6
+ current_session_id,
7
+ new_session_id,
8
+ session,
9
+ )
10
+ from .exceptions import KernevaBlocked, KernevaError
11
+ from .models import (
12
+ AgentSummary,
13
+ EvaluationDetail,
14
+ EvaluationResult,
15
+ ExecutionOutcome,
16
+ HistoryEntry,
17
+ SignalEntry,
18
+ TraceEntry,
19
+ )
20
+ from .runtime_trust import ReviewContext, RuntimeTrustBlock, guard, with_runtime_trust
21
+
22
+ __all__ = [
23
+ "with_runtime_trust",
24
+ "guard",
25
+ "RuntimeTrustBlock",
26
+ "ReviewContext",
27
+ "session",
28
+ "new_session_id",
29
+ "current_agent_id",
30
+ "current_session_id",
31
+ "current_customer_id",
32
+ "current_metadata",
33
+ "KernevaClient",
34
+ "KernevaError",
35
+ "KernevaBlocked",
36
+ "EvaluationResult",
37
+ "ExecutionOutcome",
38
+ "EvaluationDetail",
39
+ "HistoryEntry",
40
+ "AgentSummary",
41
+ "SignalEntry",
42
+ "TraceEntry",
43
+ ]
@@ -0,0 +1,509 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ import time
5
+ import urllib.error
6
+ import urllib.request
7
+ from typing import Any, Dict, List, Literal, Optional
8
+ from urllib.parse import urlencode
9
+
10
+ from .exceptions import (
11
+ KernevaAuthError, KernevaBlocked, KernevaError,
12
+ KernevaFatalError, KernevaRetryableError, KernevaTimeoutError,
13
+ KernevaValidationError,
14
+ )
15
+ from .models import (
16
+ AgentSummary,
17
+ EvaluationDetail,
18
+ EvaluationResult,
19
+ HistoryEntry,
20
+ )
21
+
22
+ logger = logging.getLogger("kerneva_runtime_trust")
23
+
24
+ ExecutionStatus = Literal[
25
+ "completed",
26
+ "failed",
27
+ "blocked",
28
+ "abandoned",
29
+ "timed_out",
30
+ ]
31
+
32
+ _EXECUTION_TO_REVIEW: Dict[str, Dict[str, str]] = {
33
+ "completed": {"status": "resolved", "resolution": "completed"},
34
+ "failed": {"status": "resolved", "resolution": "failed"},
35
+ "blocked": {"status": "resolved", "resolution": "blocked_by_external"},
36
+ "abandoned": {"status": "dismissed", "resolution": "abandoned"},
37
+ "timed_out": {"status": "dismissed", "resolution": "timed_out"},
38
+ }
39
+
40
+ _DEFAULT_API_URL = "https://api.kerneva.com"
41
+
42
+ # Retry configuration
43
+ _RETRYABLE_STATUSES = {429, 503}
44
+ _MAX_RETRIES = 3
45
+ _BASE_DELAY = 1.0 # seconds
46
+ _MAX_DELAY = 10.0 # seconds
47
+
48
+
49
+ def _build_url(base: str, path: str, query: Optional[Dict[str, Any]] = None) -> str:
50
+ base = base.rstrip("/")
51
+ url = f"{base}{path}"
52
+ if query:
53
+ params = {k: v for k, v in query.items() if v is not None}
54
+ if params:
55
+ url = f"{url}?{urlencode(params)}"
56
+ return url
57
+
58
+
59
+ def _retry_delay(attempt: int) -> float:
60
+ """Exponential backoff with jitter."""
61
+ delay = min(_BASE_DELAY * (2 ** attempt), _MAX_DELAY)
62
+ # Add ±25% jitter
63
+ import random
64
+ jitter = delay * 0.25 * (2 * random.random() - 1)
65
+ return delay + jitter
66
+
67
+
68
+ class KernevaClient:
69
+ """Low-level client for the Kerneva API.
70
+
71
+ Usage:
72
+ client = KernevaClient(api_key="krv_test_...")
73
+ result = client.evaluate(
74
+ agent_id="bot-1",
75
+ session_id="ses-abc",
76
+ action_type="refund",
77
+ amount=150.0,
78
+ )
79
+ client.confirm_execution(result.evaluation_id, status="completed")
80
+ """
81
+
82
+ def __init__(
83
+ self,
84
+ api_key: Optional[str] = None,
85
+ base_url: Optional[str] = None,
86
+ timeout: int = 30,
87
+ endpoint_timeouts: Optional[dict[str, int]] = None,
88
+ ):
89
+ from .exceptions import KernevaConfigError
90
+
91
+ self.api_key = (
92
+ api_key
93
+ or os.environ.get("KERNEVA_API_KEY")
94
+ or os.environ.get("RUNTIME_TRUST_API_KEY", "")
95
+ )
96
+ if not self.api_key:
97
+ raise KernevaConfigError(
98
+ "api_key is required. Pass it explicitly or set KERNEVA_API_KEY"
99
+ )
100
+
101
+ self.base_url = (
102
+ base_url
103
+ or os.environ.get("KERNEVA_API_URL")
104
+ or os.environ.get("RUNTIME_TRUST_API_URL")
105
+ or _DEFAULT_API_URL
106
+ ).rstrip("/")
107
+
108
+ self.timeout = timeout
109
+ self.endpoint_timeouts = endpoint_timeouts or {}
110
+
111
+ # ------------------------------------------------------------------
112
+ # Internal request helpers
113
+ # ------------------------------------------------------------------
114
+
115
+ def _request(
116
+ self,
117
+ method: str,
118
+ path: str,
119
+ body: Optional[dict] = None,
120
+ query: Optional[Dict[str, Any]] = None,
121
+ idempotency_key: Optional[str] = None,
122
+ endpoint_timeout: Optional[int] = None,
123
+ ) -> Any:
124
+ url = _build_url(self.base_url, path, query)
125
+ data = json.dumps(body).encode() if body is not None else None
126
+ timeout = endpoint_timeout or self.timeout
127
+
128
+ headers = {
129
+ "Content-Type": "application/json",
130
+ "Authorization": f"Bearer {self.api_key}",
131
+ }
132
+ if idempotency_key:
133
+ headers["Idempotency-Key"] = idempotency_key
134
+
135
+ logger.debug(
136
+ "Sending %s %s (body=%s, query=%s, timeout=%ds)",
137
+ method, url,
138
+ json.dumps(body) if body else None,
139
+ query, timeout,
140
+ )
141
+
142
+ last_exc: Optional[Exception] = None
143
+ for attempt in range(_MAX_RETRIES + 1):
144
+ if attempt > 0:
145
+ delay = _retry_delay(attempt - 1)
146
+ logger.debug(
147
+ "Retry %d/%d after %.2fs for %s %s",
148
+ attempt, _MAX_RETRIES, delay, method, url,
149
+ )
150
+ time.sleep(delay)
151
+
152
+ try:
153
+ req = urllib.request.Request(
154
+ url, data=data, headers=headers, method=method,
155
+ )
156
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
157
+ response_data = json.loads(resp.read().decode())
158
+ logger.debug(
159
+ "Response %d for %s %s: %s",
160
+ resp.status, method, url,
161
+ json.dumps(response_data)[:500],
162
+ )
163
+ return response_data
164
+ except urllib.error.HTTPError as e:
165
+ body_text = e.read().decode(errors="replace")
166
+ logger.debug(
167
+ "HTTP error %d for %s %s: %s",
168
+ e.code, method, url, body_text,
169
+ )
170
+ if e.code == 401 or e.code == 403:
171
+ raise KernevaAuthError(
172
+ f"Authentication failed ({e.code}) for {method} {path}: {body_text}"
173
+ ) from e
174
+ if e.code == 422:
175
+ raise KernevaValidationError(
176
+ f"Validation error for {method} {path}: {body_text}"
177
+ ) from e
178
+ if e.code in _RETRYABLE_STATUSES and attempt < _MAX_RETRIES:
179
+ last_exc = KernevaRetryableError(
180
+ f"Retryable API error {e.code} for {method} {path}: {body_text}"
181
+ )
182
+ continue
183
+ if 500 <= e.code < 600 and e.code not in _RETRYABLE_STATUSES:
184
+ raise KernevaFatalError(
185
+ f"Non-retryable server error {e.code} for {method} {path}: {body_text}"
186
+ ) from e
187
+ raise KernevaError(
188
+ f"API error {e.code} for {method} {path}: {body_text}"
189
+ ) from e
190
+ except urllib.error.URLError as e:
191
+ logger.debug("URL error for %s %s: %s", method, url, e.reason)
192
+ raise KernevaRetryableError(
193
+ f"Connection error for {method} {url}: {e.reason}"
194
+ ) from e
195
+ except TimeoutError as e:
196
+ raise KernevaTimeoutError(
197
+ f"Request timed out after {timeout}s for {method} {url}"
198
+ ) from e
199
+ except OSError as e:
200
+ logger.debug("OS error for %s %s: %s", method, url, e)
201
+ raise KernevaError(
202
+ f"Request failed for {method} {url}: {e}"
203
+ ) from e
204
+
205
+ if last_exc:
206
+ raise last_exc
207
+
208
+ # ------------------------------------------------------------------
209
+ # Core API methods
210
+ # ------------------------------------------------------------------
211
+
212
+ def evaluate(
213
+ self,
214
+ agent_id: str,
215
+ session_id: str,
216
+ action_type: str,
217
+ amount: float,
218
+ metadata: Optional[Dict[str, Any]] = None,
219
+ idempotency_key: Optional[str] = None,
220
+ customer_id: Optional[str] = None,
221
+ ) -> EvaluationResult:
222
+ """Evaluate a single agent action.
223
+
224
+ Returns EvaluationResult. Raises KernevaBlocked if decision is BLOCK.
225
+ Pass an idempotency_key to safely retry without creating duplicate evaluations.
226
+
227
+ Pass customer_id to identify the *end-customer* this action concerns
228
+ (e.g. "cust_123"). It segments recent-activity lookups so one
229
+ end-customer's trajectory never bleeds into another's — omit it and all
230
+ end-customers collapse into a single trajectory, which produces spurious
231
+ escalation/clustering signals.
232
+
233
+ The caller should check result.decision and handle REVIEW/ALLOW
234
+ appropriately.
235
+ """
236
+ payload = {
237
+ "agent_id": agent_id,
238
+ "session_id": session_id,
239
+ "action_type": action_type,
240
+ "amount": amount,
241
+ "metadata": metadata or {},
242
+ }
243
+ if customer_id is not None:
244
+ payload["customer_id"] = customer_id
245
+ data = self._request(
246
+ "POST", "/evaluate", body=payload,
247
+ idempotency_key=idempotency_key,
248
+ endpoint_timeout=self.endpoint_timeouts.get("evaluate"),
249
+ )
250
+
251
+ if data.get("decision") == "BLOCK":
252
+ evaluation_id = data.get("evaluation_id", "")
253
+ raise KernevaBlocked(
254
+ f"Kerneva blocked {action_type} for ${amount:.2f}: "
255
+ f"{data.get('reason', 'No reason provided')}",
256
+ evaluation_id=evaluation_id,
257
+ )
258
+
259
+ return EvaluationResult(
260
+ decision=data.get("decision", "ALLOW"),
261
+ risk_score=float(data.get("risk_score", 0)),
262
+ trust_score=float(data.get("trust_score", 0)),
263
+ reason=data.get("reason", ""),
264
+ signals=data.get("signals", []),
265
+ failure_classes=data.get("failure_classes_detected", []),
266
+ evaluation_id=data.get("evaluation_id"),
267
+ observation_mode=bool(data.get("observation_mode", True)),
268
+ versions=data.get("versions", {}),
269
+ warnings=data.get("warnings") or [],
270
+ recommended_decision=data.get("recommended_decision"),
271
+ )
272
+
273
+ def confirm_execution(
274
+ self,
275
+ evaluation_id: str,
276
+ status: ExecutionStatus,
277
+ metadata: Optional[Dict[str, Any]] = None,
278
+ ) -> dict:
279
+ """Report the outcome of an evaluated action.
280
+
281
+ Maps SDK-level execution statuses to the backend review system:
282
+ completed -> resolved/completed
283
+ failed -> resolved/failed
284
+ blocked -> resolved/blocked_by_external
285
+ abandoned -> dismissed/abandoned
286
+ timed_out -> dismissed/timed_out
287
+ """
288
+ mapping = _EXECUTION_TO_REVIEW.get(status)
289
+ if not mapping:
290
+ raise KernevaError(
291
+ f"Invalid execution status: {status}. "
292
+ f"Must be one of: completed, failed, blocked, abandoned, timed_out"
293
+ )
294
+
295
+ payload = {
296
+ "status": mapping["status"],
297
+ "resolution": mapping["resolution"],
298
+ }
299
+ if metadata:
300
+ payload["impact"] = json.dumps(metadata)
301
+
302
+ return self._request(
303
+ "PUT",
304
+ f"/evaluations/{evaluation_id}/review",
305
+ body=payload,
306
+ )
307
+
308
+ def fetch_evaluation(self, evaluation_id: str) -> EvaluationDetail:
309
+ """Fetch the full trace for a single evaluation.
310
+
311
+ Returns EvaluationDetail containing all entries, signals, and
312
+ metadata for the evaluation.
313
+ """
314
+ data = self._request("GET", f"/trace/{evaluation_id}")
315
+ if isinstance(data, list):
316
+ return EvaluationDetail.from_api(data)
317
+ return EvaluationDetail.from_api([data])
318
+
319
+ def fetch_history(
320
+ self,
321
+ agent: Optional[str] = None,
322
+ decision: Optional[str] = None,
323
+ review_status: Optional[str] = None,
324
+ start: Optional[str] = None,
325
+ end: Optional[str] = None,
326
+ limit: int = 50,
327
+ cursor: Optional[str] = None,
328
+ offset: Optional[int] = None,
329
+ ) -> List[HistoryEntry]:
330
+ """Fetch investigation history with optional filters.
331
+
332
+ Supports cursor-based and offset-based pagination via the
333
+ *cursor* and *offset* parameters.
334
+
335
+ Returns a list of HistoryEntry objects representing recent
336
+ non-ALLOW evaluations.
337
+ """
338
+ query: Dict[str, Any] = {"limit": limit}
339
+ if agent:
340
+ query["agent"] = agent
341
+ if decision:
342
+ query["decision"] = decision
343
+ if review_status:
344
+ query["review_status"] = review_status
345
+ if start:
346
+ query["start"] = start
347
+ if end:
348
+ query["end"] = end
349
+ if cursor is not None:
350
+ query["cursor"] = cursor
351
+ if offset is not None:
352
+ query["offset"] = offset
353
+
354
+ data = self._request("GET", "/investigations", query=query)
355
+ entries: List[HistoryEntry] = []
356
+ for item in data if isinstance(data, list) else []:
357
+ entries.append(
358
+ HistoryEntry(
359
+ id=item.get("id", ""),
360
+ timestamp=item.get("timestamp", ""),
361
+ agent=item.get("agent", ""),
362
+ session_id=item.get("session_id", ""),
363
+ decision=item.get("decision", ""),
364
+ amount=float(item.get("amount", 0)),
365
+ trust_score=_safe_float(item.get("trust_score")),
366
+ risk_score=_safe_float(item.get("risk_score")),
367
+ failure_class=item.get("failure_class"),
368
+ review_status=item.get("review_status", "unreviewed"),
369
+ resolution=item.get("resolution"),
370
+ impact=item.get("impact"),
371
+ )
372
+ )
373
+ return entries
374
+
375
+ def list_agents(self) -> List[AgentSummary]:
376
+ """List all registered agents for this account."""
377
+ data = self._request("GET", "/agents")
378
+ agents: List[AgentSummary] = []
379
+ for item in data if isinstance(data, list) else []:
380
+ agents.append(
381
+ AgentSummary(
382
+ agent_id=item.get("agent_id", ""),
383
+ agent_name=item.get("agent_name", ""),
384
+ total_decisions=int(item.get("total_decisions", 0)),
385
+ review_count=int(item.get("review_count", 0)),
386
+ block_count=int(item.get("block_count", 0)),
387
+ avg_trust=_safe_float(item.get("avg_trust")),
388
+ )
389
+ )
390
+ return agents
391
+
392
+ def health(self) -> dict:
393
+ """Check API health.
394
+
395
+ Returns the raw health response dict, e.g. {"status": "ok"}.
396
+ Raises KernevaError if the API is unreachable.
397
+ """
398
+ return self._request("GET", "/health")
399
+
400
+ # ------------------------------------------------------------------
401
+ # Config endpoints
402
+ # ------------------------------------------------------------------
403
+
404
+ def get_config(self) -> dict:
405
+ """Fetch the current runtime configuration.
406
+
407
+ Returns the full configuration dict, e.g.:
408
+ {"thresholds": {"refund": 75.0}, "observation_mode": True}
409
+ """
410
+ return self._request("GET", "/config")
411
+
412
+ def update_config(self, config: dict) -> dict:
413
+ """Update the runtime configuration.
414
+
415
+ Args:
416
+ config: A dictionary of configuration values to set.
417
+ This replaces the entire configuration on the server.
418
+
419
+ Returns the updated configuration dict from the server.
420
+ """
421
+ return self._request("PUT", "/config", body=config)
422
+
423
+ # ------------------------------------------------------------------
424
+ # Async convenience wrappers
425
+ # ------------------------------------------------------------------
426
+
427
+ async def evaluate_async(
428
+ self,
429
+ agent_id: str,
430
+ session_id: str,
431
+ action_type: str,
432
+ amount: float,
433
+ metadata: Optional[Dict[str, Any]] = None,
434
+ customer_id: Optional[str] = None,
435
+ ) -> EvaluationResult:
436
+ """Async version of evaluate()."""
437
+ return self.evaluate(
438
+ agent_id=agent_id,
439
+ session_id=session_id,
440
+ action_type=action_type,
441
+ amount=amount,
442
+ metadata=metadata,
443
+ customer_id=customer_id,
444
+ )
445
+
446
+ async def confirm_execution_async(
447
+ self,
448
+ evaluation_id: str,
449
+ status: ExecutionStatus,
450
+ metadata: Optional[Dict[str, Any]] = None,
451
+ ) -> dict:
452
+ """Async version of confirm_execution()."""
453
+ return self.confirm_execution(
454
+ evaluation_id=evaluation_id,
455
+ status=status,
456
+ metadata=metadata,
457
+ )
458
+
459
+ async def fetch_evaluation_async(self, evaluation_id: str) -> EvaluationDetail:
460
+ """Async version of fetch_evaluation()."""
461
+ return self.fetch_evaluation(evaluation_id)
462
+
463
+ async def fetch_history_async(
464
+ self,
465
+ agent: Optional[str] = None,
466
+ decision: Optional[str] = None,
467
+ review_status: Optional[str] = None,
468
+ start: Optional[str] = None,
469
+ end: Optional[str] = None,
470
+ limit: int = 50,
471
+ cursor: Optional[str] = None,
472
+ offset: Optional[int] = None,
473
+ ) -> List[HistoryEntry]:
474
+ """Async version of fetch_history()."""
475
+ return self.fetch_history(
476
+ agent=agent,
477
+ decision=decision,
478
+ review_status=review_status,
479
+ start=start,
480
+ end=end,
481
+ limit=limit,
482
+ cursor=cursor,
483
+ offset=offset,
484
+ )
485
+
486
+ async def list_agents_async(self) -> List[AgentSummary]:
487
+ """Async version of list_agents()."""
488
+ return self.list_agents()
489
+
490
+ async def health_async(self) -> dict:
491
+ """Async version of health()."""
492
+ return self.health()
493
+
494
+ async def get_config_async(self) -> dict:
495
+ """Async version of get_config()."""
496
+ return self.get_config()
497
+
498
+ async def update_config_async(self, config: dict) -> dict:
499
+ """Async version of update_config()."""
500
+ return self.update_config(config)
501
+
502
+
503
+ def _safe_float(value: Any) -> Optional[float]:
504
+ if value is None:
505
+ return None
506
+ try:
507
+ return float(value)
508
+ except (TypeError, ValueError):
509
+ return None
@@ -0,0 +1,106 @@
1
+ """Ambient interaction context for the Kerneva SDK.
2
+
3
+ The engine's whole value is *longitudinal* — it reasons over an agent's
4
+ trajectory within a session and across a tenant's end-customers. Those three
5
+ identifiers (agent, session, end-customer) are business/temporal facts that a
6
+ single function call does not carry, so the SDK cannot reliably infer them by
7
+ reflection. Instead of forcing them into every function signature, this module
8
+ lets you declare them once for a logical interaction:
9
+
10
+ import kerneva_runtime_trust as kerneva
11
+
12
+ with kerneva.session(session_id=ticket_id, customer_id="cust_123",
13
+ agent_id="refund-bot"):
14
+ process_refund(amount=40) # shares one trajectory
15
+ process_refund(amount=75)
16
+
17
+ Anything guarded by ``@with_runtime_trust`` (aka ``@guard``) inside the block
18
+ picks these up automatically. ``ContextVar`` makes this correct under asyncio
19
+ and threads. If no session is active, each guarded call falls back to its own
20
+ fresh session id (so unrelated calls never collapse into one trajectory).
21
+ """
22
+
23
+ import contextlib
24
+ import contextvars
25
+ import uuid
26
+ from typing import Any, Dict, Iterator, Optional
27
+
28
+ _agent_id: "contextvars.ContextVar[Optional[str]]" = contextvars.ContextVar(
29
+ "kerneva_agent_id", default=None
30
+ )
31
+ _session_id: "contextvars.ContextVar[Optional[str]]" = contextvars.ContextVar(
32
+ "kerneva_session_id", default=None
33
+ )
34
+ _customer_id: "contextvars.ContextVar[Optional[str]]" = contextvars.ContextVar(
35
+ "kerneva_customer_id", default=None
36
+ )
37
+ _metadata: "contextvars.ContextVar[Dict[str, Any]]" = contextvars.ContextVar(
38
+ "kerneva_metadata", default={}
39
+ )
40
+
41
+
42
+ def new_session_id() -> str:
43
+ """A fresh, opaque session identifier."""
44
+ return str(uuid.uuid4())
45
+
46
+
47
+ def current_agent_id() -> Optional[str]:
48
+ return _agent_id.get()
49
+
50
+
51
+ def current_session_id() -> Optional[str]:
52
+ return _session_id.get()
53
+
54
+
55
+ def current_customer_id() -> Optional[str]:
56
+ return _customer_id.get()
57
+
58
+
59
+ def current_metadata() -> Dict[str, Any]:
60
+ return dict(_metadata.get())
61
+
62
+
63
+ @contextlib.contextmanager
64
+ def session(
65
+ session_id: Optional[str] = None,
66
+ *,
67
+ agent_id: Optional[str] = None,
68
+ customer_id: Optional[str] = None,
69
+ metadata: Optional[Dict[str, Any]] = None,
70
+ ) -> Iterator[str]:
71
+ """Bind an interaction context for the duration of the ``with`` block.
72
+
73
+ Args:
74
+ session_id: Identifier for this logical interaction (a support
75
+ conversation, one agent task-run, one request). If omitted, a fresh
76
+ id is generated so the block is still a single, bounded session.
77
+ agent_id: The logical AI actor making the calls (e.g. ``"refund-bot"``).
78
+ customer_id: The end-customer this interaction concerns. This is what
79
+ keeps one end-customer's trajectory from bleeding into another's.
80
+ metadata: Extra context merged into every evaluation inside the block
81
+ (shallow-merged over any enclosing session's metadata).
82
+
83
+ Yields:
84
+ The resolved ``session_id``.
85
+
86
+ Nesting is supported; on exit each value is restored to what it was before
87
+ the block, so contexts compose cleanly.
88
+ """
89
+ if session_id is None:
90
+ session_id = new_session_id()
91
+
92
+ tokens = [(_session_id, _session_id.set(session_id))]
93
+ if agent_id is not None:
94
+ tokens.append((_agent_id, _agent_id.set(agent_id)))
95
+ if customer_id is not None:
96
+ tokens.append((_customer_id, _customer_id.set(customer_id)))
97
+ if metadata is not None:
98
+ merged = {**_metadata.get(), **metadata}
99
+ tokens.append((_metadata, _metadata.set(merged)))
100
+
101
+ try:
102
+ yield session_id
103
+ finally:
104
+ # Restore in reverse order so nested contexts unwind correctly.
105
+ for var, token in reversed(tokens):
106
+ var.reset(token)
@@ -0,0 +1,38 @@
1
+ class KernevaError(Exception):
2
+ """Base exception for all Kerneva SDK errors."""
3
+
4
+
5
+ class KernevaRetryableError(KernevaError):
6
+ """Raised when a request failed but may succeed on retry (e.g., 429, 503)."""
7
+
8
+
9
+ class KernevaFatalError(KernevaError):
10
+ """Raised when a request failed with a non-retryable server error (e.g., 500, 502, 504)."""
11
+
12
+
13
+ class KernevaBlocked(KernevaError):
14
+ """Raised when Kerneva blocks an action (decision = BLOCK).
15
+
16
+ Attributes:
17
+ evaluation_id: The ID of the evaluation that was blocked, if available.
18
+ """
19
+
20
+ def __init__(self, message: str = "", evaluation_id: str = ""):
21
+ super().__init__(message)
22
+ self.evaluation_id = evaluation_id
23
+
24
+
25
+ class KernevaAuthError(KernevaError):
26
+ """Raised when authentication fails (401, 403)."""
27
+
28
+
29
+ class KernevaTimeoutError(KernevaRetryableError):
30
+ """Raised when a request times out."""
31
+
32
+
33
+ class KernevaValidationError(KernevaError):
34
+ """Raised when the API returns a validation error (422)."""
35
+
36
+
37
+ class KernevaConfigError(KernevaError):
38
+ """Raised when the SDK is misconfigured (e.g., missing API key)."""