viktron-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.
- viktron_sdk/__init__.py +7 -0
- viktron_sdk/analytics.py +117 -0
- viktron_sdk/guard.py +356 -0
- viktron_sdk/integrations/__init__.py +0 -0
- viktron_sdk/integrations/autogen.py +191 -0
- viktron_sdk/integrations/crewai.py +125 -0
- viktron_sdk/integrations/langchain.py +74 -0
- viktron_sdk/telemetry.py +175 -0
- viktron_sdk-0.2.0.dist-info/METADATA +107 -0
- viktron_sdk-0.2.0.dist-info/RECORD +12 -0
- viktron_sdk-0.2.0.dist-info/WHEEL +5 -0
- viktron_sdk-0.2.0.dist-info/top_level.txt +1 -0
viktron_sdk/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Viktron SDK — policy enforcement and telemetry for AI agents."""
|
|
2
|
+
from .telemetry import ViktronTelemetry
|
|
3
|
+
from .analytics import ViktronAnalytics
|
|
4
|
+
from .guard import ViktronGuard, ViktronPolicyViolation
|
|
5
|
+
|
|
6
|
+
__all__ = ["ViktronTelemetry", "ViktronAnalytics", "ViktronGuard", "ViktronPolicyViolation"]
|
|
7
|
+
__version__ = "0.2.0"
|
viktron_sdk/analytics.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Direct API client for AGENT IRL analytics and AI metrics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ViktronAnalytics:
|
|
12
|
+
"""Thin client for /api/saas analytics endpoints."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, auth_token: str, endpoint: str = "https://api.viktron.ai", timeout: float = 10.0):
|
|
15
|
+
self.auth_token = auth_token
|
|
16
|
+
self.endpoint = endpoint.rstrip("/")
|
|
17
|
+
self._client = httpx.Client(timeout=timeout)
|
|
18
|
+
|
|
19
|
+
def ingest_events(
|
|
20
|
+
self,
|
|
21
|
+
workspace_id: str,
|
|
22
|
+
events: list[dict[str, Any]],
|
|
23
|
+
batch_id: str | None = None,
|
|
24
|
+
idempotency_key: str | None = None,
|
|
25
|
+
) -> dict[str, Any]:
|
|
26
|
+
payload_events = []
|
|
27
|
+
for event in events:
|
|
28
|
+
item = dict(event)
|
|
29
|
+
item.setdefault("workspace_id", workspace_id)
|
|
30
|
+
item.setdefault("occurred_at", datetime.now(timezone.utc).isoformat())
|
|
31
|
+
payload_events.append(item)
|
|
32
|
+
|
|
33
|
+
headers = self._auth_headers()
|
|
34
|
+
if idempotency_key:
|
|
35
|
+
headers["x-idempotency-key"] = idempotency_key
|
|
36
|
+
|
|
37
|
+
response = self._client.post(
|
|
38
|
+
f"{self.endpoint}/api/saas/events/ingest",
|
|
39
|
+
json={"events": payload_events, "batch_id": batch_id},
|
|
40
|
+
headers=headers,
|
|
41
|
+
)
|
|
42
|
+
response.raise_for_status()
|
|
43
|
+
return response.json()
|
|
44
|
+
|
|
45
|
+
def record_llm_call(
|
|
46
|
+
self,
|
|
47
|
+
workspace_id: str,
|
|
48
|
+
*,
|
|
49
|
+
model: str,
|
|
50
|
+
provider: str,
|
|
51
|
+
input_tokens: int,
|
|
52
|
+
output_tokens: int,
|
|
53
|
+
latency_ms: int,
|
|
54
|
+
status: str = "ok",
|
|
55
|
+
mission_id: str | None = None,
|
|
56
|
+
session_id: str | None = None,
|
|
57
|
+
properties: dict[str, Any] | None = None,
|
|
58
|
+
) -> dict[str, Any]:
|
|
59
|
+
total_tokens = max(0, input_tokens) + max(0, output_tokens)
|
|
60
|
+
return self.ingest_events(
|
|
61
|
+
workspace_id=workspace_id,
|
|
62
|
+
events=[
|
|
63
|
+
{
|
|
64
|
+
"category": "llm",
|
|
65
|
+
"event": "llm_call",
|
|
66
|
+
"status": status,
|
|
67
|
+
"session_id": session_id,
|
|
68
|
+
"mission_id": mission_id,
|
|
69
|
+
"llm_provider": provider,
|
|
70
|
+
"llm_model": model,
|
|
71
|
+
"input_tokens": input_tokens,
|
|
72
|
+
"output_tokens": output_tokens,
|
|
73
|
+
"total_tokens": total_tokens,
|
|
74
|
+
"latency_ms": latency_ms,
|
|
75
|
+
"properties": properties or {},
|
|
76
|
+
}
|
|
77
|
+
],
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def get_llm_analytics(self, workspace_id: str, lookback_hours: int = 168) -> dict[str, Any]:
|
|
81
|
+
response = self._client.get(
|
|
82
|
+
f"{self.endpoint}/api/saas/ai/llm-analytics",
|
|
83
|
+
params={"workspace_id": workspace_id, "lookback_hours": lookback_hours},
|
|
84
|
+
headers=self._auth_headers(),
|
|
85
|
+
)
|
|
86
|
+
response.raise_for_status()
|
|
87
|
+
return response.json()
|
|
88
|
+
|
|
89
|
+
def get_mission_analytics(self, workspace_id: str, lookback_hours: int = 168) -> dict[str, Any]:
|
|
90
|
+
response = self._client.get(
|
|
91
|
+
f"{self.endpoint}/api/saas/ai/mission-analytics",
|
|
92
|
+
params={"workspace_id": workspace_id, "lookback_hours": lookback_hours},
|
|
93
|
+
headers=self._auth_headers(),
|
|
94
|
+
)
|
|
95
|
+
response.raise_for_status()
|
|
96
|
+
return response.json()
|
|
97
|
+
|
|
98
|
+
def get_tool_failure_rates(self, workspace_id: str, lookback_days: int = 30) -> dict[str, Any]:
|
|
99
|
+
response = self._client.get(
|
|
100
|
+
f"{self.endpoint}/api/saas/events/queries/agent-failure-rate-by-tool",
|
|
101
|
+
params={"workspace_id": workspace_id, "lookback_days": lookback_days},
|
|
102
|
+
headers=self._auth_headers(),
|
|
103
|
+
)
|
|
104
|
+
response.raise_for_status()
|
|
105
|
+
return response.json()
|
|
106
|
+
|
|
107
|
+
def close(self) -> None:
|
|
108
|
+
self._client.close()
|
|
109
|
+
|
|
110
|
+
def __enter__(self) -> "ViktronAnalytics":
|
|
111
|
+
return self
|
|
112
|
+
|
|
113
|
+
def __exit__(self, *_: Any) -> None:
|
|
114
|
+
self.close()
|
|
115
|
+
|
|
116
|
+
def _auth_headers(self) -> dict[str, str]:
|
|
117
|
+
return {"Authorization": f"Bearer {self.auth_token}"}
|
viktron_sdk/guard.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
"""ViktronGuard — LLM call interception proxy with policy enforcement and telemetry."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import inspect
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Callable, Optional
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ViktronPolicyViolation(Exception):
|
|
10
|
+
"""Raised when ViktronGuard blocks an action due to a policy rule.
|
|
11
|
+
|
|
12
|
+
Attributes
|
|
13
|
+
----------
|
|
14
|
+
policy_id : str | None
|
|
15
|
+
The ID of the matched policy rule, if available.
|
|
16
|
+
reason : str | None
|
|
17
|
+
Human-readable explanation of why the call was blocked.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
message: str,
|
|
23
|
+
policy_id: Optional[str] = None,
|
|
24
|
+
reason: Optional[str] = None,
|
|
25
|
+
):
|
|
26
|
+
super().__init__(message)
|
|
27
|
+
self.policy_id = policy_id
|
|
28
|
+
self.reason = reason
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Leaf method names we intercept at any proxy depth.
|
|
32
|
+
# Covers OpenAI `create`, Anthropic `create`, Cohere `generate`, etc.
|
|
33
|
+
_INTERCEPT_NAMES = frozenset({"create", "generate", "complete", "invoke", "run"})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
def _extract_prompt(kwargs: dict) -> str:
|
|
39
|
+
"""Best-effort extraction of the user prompt for policy evaluation."""
|
|
40
|
+
messages = kwargs.get("messages", [])
|
|
41
|
+
if messages and isinstance(messages, list):
|
|
42
|
+
for msg in reversed(messages):
|
|
43
|
+
if not isinstance(msg, dict):
|
|
44
|
+
continue
|
|
45
|
+
if msg.get("role") == "user":
|
|
46
|
+
content = msg.get("content", "")
|
|
47
|
+
if isinstance(content, str):
|
|
48
|
+
return content[:1000]
|
|
49
|
+
if isinstance(content, list):
|
|
50
|
+
for block in content:
|
|
51
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
52
|
+
return block.get("text", "")[:1000]
|
|
53
|
+
prompt = kwargs.get("prompt", "")
|
|
54
|
+
if isinstance(prompt, str):
|
|
55
|
+
return prompt[:1000]
|
|
56
|
+
return ""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _extract_tokens(result: Any) -> tuple[Optional[int], Optional[int]]:
|
|
60
|
+
"""Extract (input_tokens, output_tokens) from an LLM response object."""
|
|
61
|
+
usage = getattr(result, "usage", None)
|
|
62
|
+
if usage:
|
|
63
|
+
inp = getattr(usage, "prompt_tokens", None) or getattr(usage, "input_tokens", None)
|
|
64
|
+
out = getattr(usage, "completion_tokens", None) or getattr(usage, "output_tokens", None)
|
|
65
|
+
return inp, out
|
|
66
|
+
return None, None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ── Policy HTTP client ────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
class _PolicyClient:
|
|
72
|
+
"""Thin synchronous HTTP client for the /api/sdk/policy-check endpoint."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, api_key: str, endpoint: str, timeout: float, fail_open: bool):
|
|
75
|
+
self._api_key = api_key
|
|
76
|
+
self._endpoint = endpoint.rstrip("/")
|
|
77
|
+
self._timeout = timeout
|
|
78
|
+
self._fail_open = fail_open
|
|
79
|
+
self._http: Any = None # lazy-loaded httpx.Client
|
|
80
|
+
|
|
81
|
+
def _client(self) -> Any:
|
|
82
|
+
if self._http is None:
|
|
83
|
+
try:
|
|
84
|
+
import httpx # optional dep; guard falls back to fail-open if missing
|
|
85
|
+
self._http = httpx.Client(timeout=self._timeout)
|
|
86
|
+
except ImportError:
|
|
87
|
+
self._http = False
|
|
88
|
+
return self._http
|
|
89
|
+
|
|
90
|
+
def check(
|
|
91
|
+
self,
|
|
92
|
+
action_type: str,
|
|
93
|
+
agent_id: str,
|
|
94
|
+
model: str,
|
|
95
|
+
prompt_preview: str,
|
|
96
|
+
tool_name: str = "",
|
|
97
|
+
) -> tuple[bool, Optional[str], Optional[str]]:
|
|
98
|
+
"""Call policy-check. Returns (allowed, policy_id, reason).
|
|
99
|
+
|
|
100
|
+
On network/server error: honours fail_open.
|
|
101
|
+
"""
|
|
102
|
+
http = self._client()
|
|
103
|
+
if not http:
|
|
104
|
+
return True, None, None # httpx unavailable → fail open
|
|
105
|
+
|
|
106
|
+
payload = {
|
|
107
|
+
"action_type": action_type,
|
|
108
|
+
"agent_id": agent_id,
|
|
109
|
+
"model": model,
|
|
110
|
+
"prompt_preview": prompt_preview[:500],
|
|
111
|
+
"tool_name": tool_name,
|
|
112
|
+
}
|
|
113
|
+
try:
|
|
114
|
+
resp = http.post(
|
|
115
|
+
f"{self._endpoint}/api/sdk/policy-check",
|
|
116
|
+
json=payload,
|
|
117
|
+
headers={"Authorization": f"Bearer {self._api_key}"},
|
|
118
|
+
timeout=self._timeout,
|
|
119
|
+
)
|
|
120
|
+
if resp.status_code == 200:
|
|
121
|
+
data = resp.json()
|
|
122
|
+
return (
|
|
123
|
+
bool(data.get("allowed", True)),
|
|
124
|
+
data.get("policy_id"),
|
|
125
|
+
data.get("reason"),
|
|
126
|
+
)
|
|
127
|
+
# Non-200 from policy server
|
|
128
|
+
if self._fail_open:
|
|
129
|
+
return True, None, f"policy-server-http-{resp.status_code}"
|
|
130
|
+
raise ViktronPolicyViolation(f"Policy server returned {resp.status_code}")
|
|
131
|
+
except ViktronPolicyViolation:
|
|
132
|
+
raise
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
if self._fail_open:
|
|
135
|
+
return True, None, f"policy-error:{exc}"
|
|
136
|
+
raise ViktronPolicyViolation(f"Policy check failed: {exc}") from exc
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ── Interceptor ───────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
class _Interceptor:
|
|
142
|
+
"""Wraps a single callable to enforce policy + emit telemetry."""
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
fn: Callable,
|
|
147
|
+
path: str,
|
|
148
|
+
policy: _PolicyClient,
|
|
149
|
+
telemetry: Any,
|
|
150
|
+
agent_id: str,
|
|
151
|
+
):
|
|
152
|
+
self._fn = fn
|
|
153
|
+
self._path = path
|
|
154
|
+
self._policy = policy
|
|
155
|
+
self._tel = telemetry
|
|
156
|
+
self._agent_id = agent_id
|
|
157
|
+
|
|
158
|
+
# ── Internal helpers ──────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
def _run_policy(self, kwargs: dict) -> tuple[Optional[str], Optional[str]]:
|
|
161
|
+
allowed, policy_id, reason = self._policy.check(
|
|
162
|
+
action_type="llm_call",
|
|
163
|
+
agent_id=self._agent_id,
|
|
164
|
+
model=kwargs.get("model", ""),
|
|
165
|
+
prompt_preview=_extract_prompt(kwargs),
|
|
166
|
+
)
|
|
167
|
+
if not allowed:
|
|
168
|
+
if self._tel:
|
|
169
|
+
self._tel.record_event("guard.blocked", {
|
|
170
|
+
"agent_id": self._agent_id,
|
|
171
|
+
"policy_id": policy_id,
|
|
172
|
+
"reason": reason,
|
|
173
|
+
})
|
|
174
|
+
raise ViktronPolicyViolation(
|
|
175
|
+
f"Blocked by Viktron policy: {reason or 'policy violation'}",
|
|
176
|
+
policy_id=policy_id,
|
|
177
|
+
reason=reason,
|
|
178
|
+
)
|
|
179
|
+
return policy_id, reason
|
|
180
|
+
|
|
181
|
+
def _record(
|
|
182
|
+
self,
|
|
183
|
+
kwargs: dict,
|
|
184
|
+
result: Any,
|
|
185
|
+
duration_ms: int,
|
|
186
|
+
error: Optional[str],
|
|
187
|
+
policy_id: Optional[str],
|
|
188
|
+
) -> None:
|
|
189
|
+
if not self._tel:
|
|
190
|
+
return
|
|
191
|
+
inp, out = _extract_tokens(result) if result is not None else (None, None)
|
|
192
|
+
self._tel.record_event("llm.call", {
|
|
193
|
+
"agent_id": self._agent_id,
|
|
194
|
+
"model": kwargs.get("model", ""),
|
|
195
|
+
"duration_ms": duration_ms,
|
|
196
|
+
"tokens_in": inp,
|
|
197
|
+
"tokens_out": out,
|
|
198
|
+
"path": self._path,
|
|
199
|
+
"error": error,
|
|
200
|
+
"policy_id": policy_id,
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
# ── Public entry points ───────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
def __call__(self, *args: Any, **kwargs: Any) -> Any:
|
|
206
|
+
"""Synchronous intercept path."""
|
|
207
|
+
policy_id, _ = self._run_policy(kwargs)
|
|
208
|
+
start = time.perf_counter()
|
|
209
|
+
error: Optional[str] = None
|
|
210
|
+
result = None
|
|
211
|
+
try:
|
|
212
|
+
result = self._fn(*args, **kwargs)
|
|
213
|
+
return result
|
|
214
|
+
except ViktronPolicyViolation:
|
|
215
|
+
raise
|
|
216
|
+
except Exception as exc:
|
|
217
|
+
error = str(exc)
|
|
218
|
+
raise
|
|
219
|
+
finally:
|
|
220
|
+
self._record(kwargs, result, int((time.perf_counter() - start) * 1000), error, policy_id)
|
|
221
|
+
|
|
222
|
+
async def async_call(self, *args: Any, **kwargs: Any) -> Any:
|
|
223
|
+
"""Asynchronous intercept path (used when wrapping async clients)."""
|
|
224
|
+
policy_id, _ = self._run_policy(kwargs)
|
|
225
|
+
start = time.perf_counter()
|
|
226
|
+
error: Optional[str] = None
|
|
227
|
+
result = None
|
|
228
|
+
try:
|
|
229
|
+
result = await self._fn(*args, **kwargs)
|
|
230
|
+
return result
|
|
231
|
+
except ViktronPolicyViolation:
|
|
232
|
+
raise
|
|
233
|
+
except Exception as exc:
|
|
234
|
+
error = str(exc)
|
|
235
|
+
raise
|
|
236
|
+
finally:
|
|
237
|
+
self._record(kwargs, result, int((time.perf_counter() - start) * 1000), error, policy_id)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
# ── Recursive proxy ───────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
class _RecursiveProxy:
|
|
243
|
+
"""
|
|
244
|
+
Walks an object's attribute tree and intercepts calls to methods whose
|
|
245
|
+
names appear in _INTERCEPT_NAMES (e.g. ``client.chat.completions.create``).
|
|
246
|
+
|
|
247
|
+
Uses object.__setattr__/__getattribute__ to avoid infinite recursion
|
|
248
|
+
through our own __getattr__.
|
|
249
|
+
"""
|
|
250
|
+
|
|
251
|
+
__slots__ = ("_obj", "_factory")
|
|
252
|
+
|
|
253
|
+
def __init__(self, obj: Any, factory: Callable) -> None:
|
|
254
|
+
object.__setattr__(self, "_obj", obj)
|
|
255
|
+
object.__setattr__(self, "_factory", factory)
|
|
256
|
+
|
|
257
|
+
def __getattr__(self, name: str) -> Any:
|
|
258
|
+
obj = object.__getattribute__(self, "_obj")
|
|
259
|
+
factory = object.__getattribute__(self, "_factory")
|
|
260
|
+
attr = getattr(obj, name)
|
|
261
|
+
|
|
262
|
+
if name in _INTERCEPT_NAMES and callable(attr):
|
|
263
|
+
interceptor = factory(attr, name)
|
|
264
|
+
if inspect.iscoroutinefunction(attr):
|
|
265
|
+
async def _async_wrapper(*a: Any, **kw: Any) -> Any:
|
|
266
|
+
return await interceptor.async_call(*a, **kw)
|
|
267
|
+
return _async_wrapper
|
|
268
|
+
return interceptor
|
|
269
|
+
|
|
270
|
+
# Recurse into namespace objects (e.g. client.chat, client.messages)
|
|
271
|
+
if not inspect.isbuiltin(attr) and (hasattr(attr, "__dict__") or hasattr(type(attr), "__dict__")):
|
|
272
|
+
return _RecursiveProxy(attr, factory)
|
|
273
|
+
|
|
274
|
+
return attr
|
|
275
|
+
|
|
276
|
+
def __repr__(self) -> str:
|
|
277
|
+
obj = object.__getattribute__(self, "_obj")
|
|
278
|
+
return f"<ViktronGuardProxy wrapping {type(obj).__name__}>"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# ── Public API ────────────────────────────────────────────────────────────────
|
|
282
|
+
|
|
283
|
+
class ViktronGuard:
|
|
284
|
+
"""One-line LLM client wrapper with policy enforcement and telemetry.
|
|
285
|
+
|
|
286
|
+
Supports OpenAI (sync & async), Anthropic, and any client whose call
|
|
287
|
+
pattern matches ``client.<namespace>.create(messages=[...], model="...")``.
|
|
288
|
+
|
|
289
|
+
Example — synchronous OpenAI::
|
|
290
|
+
|
|
291
|
+
import openai
|
|
292
|
+
from viktron_sdk import ViktronGuard
|
|
293
|
+
|
|
294
|
+
client = ViktronGuard.wrap(
|
|
295
|
+
openai.OpenAI(),
|
|
296
|
+
api_key="vk_live_...",
|
|
297
|
+
agent_id="sales-agent-prod",
|
|
298
|
+
)
|
|
299
|
+
# All client.chat.completions.create() calls are now guarded.
|
|
300
|
+
response = client.chat.completions.create(
|
|
301
|
+
model="gpt-4o",
|
|
302
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
Example — async OpenAI::
|
|
306
|
+
|
|
307
|
+
client = ViktronGuard.wrap(openai.AsyncOpenAI(), api_key="vk_live_...", agent_id="my-agent")
|
|
308
|
+
response = await client.chat.completions.create(...)
|
|
309
|
+
|
|
310
|
+
Example — Anthropic::
|
|
311
|
+
|
|
312
|
+
import anthropic
|
|
313
|
+
client = ViktronGuard.wrap(anthropic.Anthropic(), api_key="vk_live_...", agent_id="my-agent")
|
|
314
|
+
msg = client.messages.create(model="claude-3-5-sonnet-20241022", ...)
|
|
315
|
+
|
|
316
|
+
Parameters
|
|
317
|
+
----------
|
|
318
|
+
client
|
|
319
|
+
An LLM provider client (OpenAI, AsyncOpenAI, Anthropic, Cohere, …).
|
|
320
|
+
api_key
|
|
321
|
+
Viktron API key (``vk_live_...`` or ``vk_test_...``).
|
|
322
|
+
agent_id
|
|
323
|
+
Stable identifier for this agent shown in the Viktron dashboard.
|
|
324
|
+
endpoint
|
|
325
|
+
Viktron API base URL (default: ``https://api.viktron.ai``).
|
|
326
|
+
fail_open
|
|
327
|
+
If ``True`` (default), allow LLM calls when Viktron is unreachable.
|
|
328
|
+
Set ``False`` for zero-trust enforcement.
|
|
329
|
+
timeout
|
|
330
|
+
Policy-check HTTP timeout in seconds (default: ``0.5``).
|
|
331
|
+
telemetry
|
|
332
|
+
Optional :class:`ViktronTelemetry` instance for usage observability.
|
|
333
|
+
"""
|
|
334
|
+
|
|
335
|
+
@staticmethod
|
|
336
|
+
def wrap(
|
|
337
|
+
client: Any,
|
|
338
|
+
api_key: str,
|
|
339
|
+
agent_id: str = "unknown",
|
|
340
|
+
endpoint: str = "https://api.viktron.ai",
|
|
341
|
+
fail_open: bool = True,
|
|
342
|
+
timeout: float = 0.5,
|
|
343
|
+
telemetry: Any = None,
|
|
344
|
+
) -> Any:
|
|
345
|
+
"""Return a proxied version of *client* with Viktron policy enforcement."""
|
|
346
|
+
policy = _PolicyClient(
|
|
347
|
+
api_key=api_key,
|
|
348
|
+
endpoint=endpoint,
|
|
349
|
+
timeout=timeout,
|
|
350
|
+
fail_open=fail_open,
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
def factory(fn: Callable, path: str) -> _Interceptor:
|
|
354
|
+
return _Interceptor(fn=fn, path=path, policy=policy, telemetry=telemetry, agent_id=agent_id)
|
|
355
|
+
|
|
356
|
+
return _RecursiveProxy(client, factory)
|
|
File without changes
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"""AutoGen integration for Viktron telemetry and policy enforcement.
|
|
2
|
+
|
|
3
|
+
Two patterns:
|
|
4
|
+
|
|
5
|
+
1. **LLM config hook** (AutoGen v0.2 / pyautogen)::
|
|
6
|
+
|
|
7
|
+
from viktron_sdk.integrations.autogen import viktron_llm_config
|
|
8
|
+
|
|
9
|
+
config = viktron_llm_config(
|
|
10
|
+
base_config={"model": "gpt-4o", "api_key": "sk-..."},
|
|
11
|
+
api_key="vk_live_...",
|
|
12
|
+
agent_id="autogen-planner",
|
|
13
|
+
)
|
|
14
|
+
assistant = AssistantAgent("planner", llm_config=config)
|
|
15
|
+
|
|
16
|
+
2. **Message hook** (AutoGen v0.4+ / autogen-agentchat)::
|
|
17
|
+
|
|
18
|
+
from viktron_sdk.integrations.autogen import ViktronAutoGenHook
|
|
19
|
+
|
|
20
|
+
hook = ViktronAutoGenHook(api_key="vk_live_...", agent_id="my-agent")
|
|
21
|
+
agent.register_hook("process_message_before_send", hook.before_send)
|
|
22
|
+
agent.register_hook("process_all_messages_before_reply", hook.before_reply)
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import time
|
|
27
|
+
from typing import Any, Callable, Optional
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ── AutoGen v0.2 / pyautogen LLM config wrapper ───────────────────────────────
|
|
31
|
+
|
|
32
|
+
def viktron_llm_config(
|
|
33
|
+
base_config: dict,
|
|
34
|
+
api_key: str,
|
|
35
|
+
agent_id: str = "autogen-agent",
|
|
36
|
+
endpoint: str = "https://api.viktron.ai",
|
|
37
|
+
fail_open: bool = True,
|
|
38
|
+
timeout: float = 0.5,
|
|
39
|
+
telemetry: Any = None,
|
|
40
|
+
) -> dict:
|
|
41
|
+
"""Return an AutoGen ``llm_config`` dict with Viktron policy enforcement.
|
|
42
|
+
|
|
43
|
+
Wraps the ``openai_client_cls`` so every LLM call passes through
|
|
44
|
+
ViktronGuard before reaching the provider.
|
|
45
|
+
|
|
46
|
+
Example::
|
|
47
|
+
|
|
48
|
+
config = viktron_llm_config(
|
|
49
|
+
base_config={
|
|
50
|
+
"model": "gpt-4o",
|
|
51
|
+
"api_key": os.environ["OPENAI_API_KEY"],
|
|
52
|
+
"temperature": 0.0,
|
|
53
|
+
},
|
|
54
|
+
api_key="vk_live_...",
|
|
55
|
+
agent_id="planner",
|
|
56
|
+
)
|
|
57
|
+
agent = AssistantAgent("planner", llm_config=config)
|
|
58
|
+
"""
|
|
59
|
+
try:
|
|
60
|
+
from viktron_sdk.guard import ViktronGuard # noqa: PLC0415
|
|
61
|
+
except ImportError:
|
|
62
|
+
return base_config # guard not available; return as-is
|
|
63
|
+
|
|
64
|
+
original_cls = base_config.get("openai_client_cls")
|
|
65
|
+
|
|
66
|
+
class _GuardedClientFactory:
|
|
67
|
+
"""Substitute openai_client_cls that wraps the real client after construction."""
|
|
68
|
+
|
|
69
|
+
def __new__(cls, *args: Any, **kwargs: Any) -> Any: # type: ignore[misc]
|
|
70
|
+
real_cls = original_cls or _default_openai_cls()
|
|
71
|
+
if real_cls is None:
|
|
72
|
+
raise RuntimeError("openai package is required for AutoGen LLM integration")
|
|
73
|
+
instance = real_cls(*args, **kwargs)
|
|
74
|
+
return ViktronGuard.wrap(
|
|
75
|
+
instance,
|
|
76
|
+
api_key=api_key,
|
|
77
|
+
agent_id=agent_id,
|
|
78
|
+
endpoint=endpoint,
|
|
79
|
+
fail_open=fail_open,
|
|
80
|
+
timeout=timeout,
|
|
81
|
+
telemetry=telemetry,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
merged = dict(base_config)
|
|
85
|
+
merged["openai_client_cls"] = _GuardedClientFactory
|
|
86
|
+
return merged
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _default_openai_cls() -> Any:
|
|
90
|
+
try:
|
|
91
|
+
import openai # noqa: PLC0415
|
|
92
|
+
return openai.OpenAI
|
|
93
|
+
except ImportError:
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ── AutoGen v0.4+ agent hook ──────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
class ViktronAutoGenHook:
|
|
100
|
+
"""Hook for AutoGen v0.4+ ``register_hook`` API.
|
|
101
|
+
|
|
102
|
+
Provides ``before_send`` and ``before_reply`` callbacks that record
|
|
103
|
+
messages and enforce policy.
|
|
104
|
+
|
|
105
|
+
Usage::
|
|
106
|
+
|
|
107
|
+
hook = ViktronAutoGenHook(api_key="vk_live_...", agent_id="planner")
|
|
108
|
+
agent.register_hook("process_message_before_send", hook.before_send)
|
|
109
|
+
agent.register_hook("process_all_messages_before_reply", hook.before_reply)
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
def __init__(
|
|
113
|
+
self,
|
|
114
|
+
api_key: str,
|
|
115
|
+
agent_id: str = "autogen-agent",
|
|
116
|
+
endpoint: str = "https://api.viktron.ai",
|
|
117
|
+
fail_open: bool = True,
|
|
118
|
+
timeout: float = 0.5,
|
|
119
|
+
telemetry: Any = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
self._api_key = api_key
|
|
122
|
+
self._agent_id = agent_id
|
|
123
|
+
self._endpoint = endpoint
|
|
124
|
+
self._fail_open = fail_open
|
|
125
|
+
self._timeout = timeout
|
|
126
|
+
self._tel = telemetry
|
|
127
|
+
self._policy: Any = None # lazy-loaded _PolicyClient
|
|
128
|
+
|
|
129
|
+
def _get_policy(self) -> Any:
|
|
130
|
+
if self._policy is None:
|
|
131
|
+
try:
|
|
132
|
+
from viktron_sdk.guard import _PolicyClient # noqa: PLC0415
|
|
133
|
+
self._policy = _PolicyClient(
|
|
134
|
+
api_key=self._api_key,
|
|
135
|
+
endpoint=self._endpoint,
|
|
136
|
+
timeout=self._timeout,
|
|
137
|
+
fail_open=self._fail_open,
|
|
138
|
+
)
|
|
139
|
+
except ImportError:
|
|
140
|
+
self._policy = False
|
|
141
|
+
return self._policy
|
|
142
|
+
|
|
143
|
+
def before_send(self, message: Any, recipient: Any, silent: bool) -> Any:
|
|
144
|
+
"""Called before an agent sends a message. Returns message unchanged or raises."""
|
|
145
|
+
policy = self._get_policy()
|
|
146
|
+
if not policy:
|
|
147
|
+
return message
|
|
148
|
+
|
|
149
|
+
content = ""
|
|
150
|
+
if isinstance(message, dict):
|
|
151
|
+
content = str(message.get("content", ""))
|
|
152
|
+
elif isinstance(message, str):
|
|
153
|
+
content = message
|
|
154
|
+
|
|
155
|
+
allowed, policy_id, reason = policy.check(
|
|
156
|
+
action_type="llm_call",
|
|
157
|
+
agent_id=self._agent_id,
|
|
158
|
+
model="",
|
|
159
|
+
prompt_preview=content[:500],
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if not allowed:
|
|
163
|
+
from viktron_sdk.guard import ViktronPolicyViolation # noqa: PLC0415
|
|
164
|
+
if self._tel:
|
|
165
|
+
self._tel.record_event("guard.blocked", {
|
|
166
|
+
"agent_id": self._agent_id,
|
|
167
|
+
"policy_id": policy_id,
|
|
168
|
+
"reason": reason,
|
|
169
|
+
})
|
|
170
|
+
raise ViktronPolicyViolation(
|
|
171
|
+
f"Blocked by Viktron policy: {reason or 'policy violation'}",
|
|
172
|
+
policy_id=policy_id,
|
|
173
|
+
reason=reason,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
if self._tel:
|
|
177
|
+
self._tel.record_event("autogen.message.sent", {
|
|
178
|
+
"agent_id": self._agent_id,
|
|
179
|
+
"content_preview": content[:300],
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
return message
|
|
183
|
+
|
|
184
|
+
def before_reply(self, messages: list, sender: Any, config: Any) -> tuple[bool, Any]:
|
|
185
|
+
"""Called before processing reply messages. Returns (False, None) to continue."""
|
|
186
|
+
if self._tel and messages:
|
|
187
|
+
self._tel.record_event("autogen.reply.start", {
|
|
188
|
+
"agent_id": self._agent_id,
|
|
189
|
+
"message_count": len(messages),
|
|
190
|
+
})
|
|
191
|
+
return False, None
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""CrewAI integration for Viktron telemetry and policy enforcement.
|
|
2
|
+
|
|
3
|
+
Two ways to instrument a CrewAI workflow:
|
|
4
|
+
|
|
5
|
+
1. **LLM guard** (recommended) — wraps the LLM before passing it to your agents::
|
|
6
|
+
|
|
7
|
+
from viktron_sdk import ViktronGuard
|
|
8
|
+
from langchain_openai import ChatOpenAI
|
|
9
|
+
|
|
10
|
+
llm = ViktronGuard.wrap(ChatOpenAI(model="gpt-4o"), api_key="vk_live_...", agent_id="my-crew")
|
|
11
|
+
agent = Agent(role="researcher", llm=llm, ...)
|
|
12
|
+
|
|
13
|
+
2. **Callback handler** — attaches to the crew for step-level telemetry without
|
|
14
|
+
wrapping the LLM (useful when you can't swap the LLM)::
|
|
15
|
+
|
|
16
|
+
from viktron_sdk import ViktronTelemetry
|
|
17
|
+
from viktron_sdk.integrations.crewai import ViktronCrewCallback
|
|
18
|
+
|
|
19
|
+
tel = ViktronTelemetry(api_key="vk_live_...", agent_slug="my-crew")
|
|
20
|
+
crew = Crew(agents=[...], tasks=[...], step_callback=ViktronCrewCallback(tel))
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import time
|
|
25
|
+
from typing import Any, Optional
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ViktronCrewCallback:
|
|
29
|
+
"""CrewAI ``step_callback`` / ``task_callback`` compatible handler.
|
|
30
|
+
|
|
31
|
+
Sends every agent step and task lifecycle event to Viktron telemetry.
|
|
32
|
+
|
|
33
|
+
Usage::
|
|
34
|
+
|
|
35
|
+
from viktron_sdk import ViktronTelemetry
|
|
36
|
+
from viktron_sdk.integrations.crewai import ViktronCrewCallback
|
|
37
|
+
|
|
38
|
+
tel = ViktronTelemetry(api_key="vk_live_...", agent_slug="sales-crew")
|
|
39
|
+
crew = Crew(
|
|
40
|
+
agents=[researcher, writer],
|
|
41
|
+
tasks=[research_task, write_task],
|
|
42
|
+
step_callback=ViktronCrewCallback(tel),
|
|
43
|
+
)
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, telemetry: Any) -> None:
|
|
47
|
+
self._tel = telemetry
|
|
48
|
+
self._task_starts: dict[str, float] = {}
|
|
49
|
+
|
|
50
|
+
# ── step_callback interface ───────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
def __call__(self, agent_output: Any) -> None:
|
|
53
|
+
"""Called by CrewAI after every agent step (step_callback)."""
|
|
54
|
+
try:
|
|
55
|
+
thought = getattr(agent_output, "thought", None)
|
|
56
|
+
action = getattr(agent_output, "tool", None) or getattr(agent_output, "action", None)
|
|
57
|
+
action_input = getattr(agent_output, "tool_input", None) or getattr(agent_output, "action_input", None)
|
|
58
|
+
output_text = getattr(agent_output, "result", None) or str(agent_output)[:500]
|
|
59
|
+
|
|
60
|
+
self._tel.record_event("crew.step", {
|
|
61
|
+
"thought_preview": str(thought or "")[:300],
|
|
62
|
+
"action": str(action or ""),
|
|
63
|
+
"action_input_preview": str(action_input or "")[:300],
|
|
64
|
+
"output_preview": str(output_text)[:300],
|
|
65
|
+
})
|
|
66
|
+
except Exception:
|
|
67
|
+
pass # Never break the crew run
|
|
68
|
+
|
|
69
|
+
# ── task_callback interface ───────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
def on_task_start(self, task: Any) -> None:
|
|
72
|
+
task_id = str(id(task))
|
|
73
|
+
self._task_starts[task_id] = time.time()
|
|
74
|
+
self._tel.record_event("crew.task.start", {
|
|
75
|
+
"task_description": str(getattr(task, "description", ""))[:200],
|
|
76
|
+
"task_id": task_id,
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
def on_task_end(self, task: Any, output: Any) -> None:
|
|
80
|
+
task_id = str(id(task))
|
|
81
|
+
started = self._task_starts.pop(task_id, time.time())
|
|
82
|
+
duration_ms = int((time.time() - started) * 1000)
|
|
83
|
+
self._tel.record_task(
|
|
84
|
+
task_id=task_id,
|
|
85
|
+
status="completed",
|
|
86
|
+
duration_ms=duration_ms,
|
|
87
|
+
)
|
|
88
|
+
self._tel.record_event("crew.task.end", {
|
|
89
|
+
"task_id": task_id,
|
|
90
|
+
"duration_ms": duration_ms,
|
|
91
|
+
"output_preview": str(output or "")[:300],
|
|
92
|
+
})
|
|
93
|
+
self._tel.flush()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def wrap_crew(crew: Any, api_key: str, agent_id: str = "crew", **guard_kwargs: Any) -> Any:
|
|
97
|
+
"""Convenience helper that wraps a CrewAI Crew's LLM with ViktronGuard.
|
|
98
|
+
|
|
99
|
+
Iterates over all agents on the crew and replaces their LLM with a
|
|
100
|
+
guarded version. Returns the crew unchanged (mutation in place).
|
|
101
|
+
|
|
102
|
+
Usage::
|
|
103
|
+
|
|
104
|
+
from viktron_sdk.integrations.crewai import wrap_crew
|
|
105
|
+
|
|
106
|
+
crew = wrap_crew(crew, api_key="vk_live_...", agent_id="sales-crew")
|
|
107
|
+
crew.kickoff()
|
|
108
|
+
"""
|
|
109
|
+
try:
|
|
110
|
+
from viktron_sdk.guard import ViktronGuard
|
|
111
|
+
except ImportError:
|
|
112
|
+
return crew # SDK not available; return as-is
|
|
113
|
+
|
|
114
|
+
agents = getattr(crew, "agents", [])
|
|
115
|
+
for agent in agents:
|
|
116
|
+
llm = getattr(agent, "llm", None)
|
|
117
|
+
if llm is not None:
|
|
118
|
+
agent.llm = ViktronGuard.wrap(
|
|
119
|
+
llm,
|
|
120
|
+
api_key=api_key,
|
|
121
|
+
agent_id=f"{agent_id}:{getattr(agent, 'role', 'agent')}",
|
|
122
|
+
**guard_kwargs,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
return crew
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""LangChain callback handler for ViktronTelemetry."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
from typing import Any
|
|
6
|
+
from uuid import UUID
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
10
|
+
from langchain_core.outputs import LLMResult
|
|
11
|
+
except ImportError:
|
|
12
|
+
raise ImportError(
|
|
13
|
+
"LangChain integration requires: pip install 'viktron-sdk[langchain]'"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from viktron_sdk.telemetry import ViktronTelemetry
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ViktronCallbackHandler(BaseCallbackHandler):
|
|
20
|
+
"""LangChain callback that sends all events to ViktronTelemetry.
|
|
21
|
+
|
|
22
|
+
Example:
|
|
23
|
+
handler = ViktronCallbackHandler(tel)
|
|
24
|
+
llm = ChatOpenAI(callbacks=[handler])
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, telemetry: ViktronTelemetry) -> None:
|
|
28
|
+
self._tel = telemetry
|
|
29
|
+
self._timers: dict[str, float] = {}
|
|
30
|
+
|
|
31
|
+
def on_chain_start(self, serialized: dict, inputs: dict, run_id: UUID, **kw: Any) -> None:
|
|
32
|
+
self._timers[str(run_id)] = time.time()
|
|
33
|
+
self._tel.record_event("chain.start", {
|
|
34
|
+
"chain": serialized.get("name", "unknown"),
|
|
35
|
+
"run_id": str(run_id),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
def on_chain_end(self, outputs: dict, run_id: UUID, **kw: Any) -> None:
|
|
39
|
+
elapsed = self._timers.pop(str(run_id), time.time())
|
|
40
|
+
self._tel.record_event("chain.end", {
|
|
41
|
+
"run_id": str(run_id),
|
|
42
|
+
"duration_ms": int((time.time() - elapsed) * 1000),
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
def on_llm_start(self, serialized: dict, prompts: list, run_id: UUID, **kw: Any) -> None:
|
|
46
|
+
self._timers[str(run_id)] = time.time()
|
|
47
|
+
|
|
48
|
+
def on_llm_end(self, response: LLMResult, run_id: UUID, **kw: Any) -> None:
|
|
49
|
+
elapsed = self._timers.pop(str(run_id), time.time())
|
|
50
|
+
usage = (response.llm_output or {}).get("token_usage", {})
|
|
51
|
+
self._tel.record_event("llm.end", {
|
|
52
|
+
"run_id": str(run_id),
|
|
53
|
+
"duration_ms": int((time.time() - elapsed) * 1000),
|
|
54
|
+
"prompt_tokens": usage.get("prompt_tokens"),
|
|
55
|
+
"completion_tokens": usage.get("completion_tokens"),
|
|
56
|
+
"total_tokens": usage.get("total_tokens"),
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
def on_tool_start(self, serialized: dict, input_str: str, run_id: UUID, **kw: Any) -> None:
|
|
60
|
+
self._timers[str(run_id)] = time.time()
|
|
61
|
+
self._tel.record_event("tool.start", {
|
|
62
|
+
"tool": serialized.get("name", "unknown"),
|
|
63
|
+
"run_id": str(run_id),
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
def on_tool_end(self, output: str, run_id: UUID, **kw: Any) -> None:
|
|
67
|
+
elapsed = self._timers.pop(str(run_id), time.time())
|
|
68
|
+
self._tel.record_event("tool.end", {
|
|
69
|
+
"run_id": str(run_id),
|
|
70
|
+
"duration_ms": int((time.time() - elapsed) * 1000),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
def on_agent_finish(self, finish: Any, run_id: UUID, **kw: Any) -> None:
|
|
74
|
+
self._tel.flush()
|
viktron_sdk/telemetry.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""ViktronTelemetry — send agent events to cloud.viktron.ai or self-hosted."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import uuid
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from typing import Any, Iterator
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ViktronTelemetry:
|
|
14
|
+
"""Framework-agnostic telemetry client.
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
from viktron_sdk import ViktronTelemetry
|
|
18
|
+
|
|
19
|
+
tel = ViktronTelemetry(api_key="vk_...", agent_slug="marketing")
|
|
20
|
+
|
|
21
|
+
# Record a task
|
|
22
|
+
tel.record_task("t-123", status="completed", duration_ms=4200, cost_usd=0.003)
|
|
23
|
+
|
|
24
|
+
# Use as context manager for a span
|
|
25
|
+
with tel.span("run_campaign") as span:
|
|
26
|
+
span.set_attribute("platform", "meta")
|
|
27
|
+
result = run_campaign(...)
|
|
28
|
+
span.set_output(result)
|
|
29
|
+
|
|
30
|
+
tel.close() # flush remaining events
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
api_key: str,
|
|
36
|
+
agent_slug: str = "unknown",
|
|
37
|
+
endpoint: str = "https://api.viktron.ai",
|
|
38
|
+
flush_interval: int = 10,
|
|
39
|
+
async_flush: bool = True,
|
|
40
|
+
):
|
|
41
|
+
self.api_key = api_key
|
|
42
|
+
self.agent_slug = agent_slug
|
|
43
|
+
self.endpoint = endpoint.rstrip("/")
|
|
44
|
+
self.flush_interval = flush_interval
|
|
45
|
+
self._session_id = str(uuid.uuid4())
|
|
46
|
+
self._buffer: list[dict] = []
|
|
47
|
+
self._lock = threading.Lock()
|
|
48
|
+
self._stopped = False
|
|
49
|
+
self._client = httpx.Client(timeout=5.0)
|
|
50
|
+
if async_flush:
|
|
51
|
+
self._flush_thread = threading.Thread(target=self._flush_loop, daemon=True)
|
|
52
|
+
self._flush_thread.start()
|
|
53
|
+
|
|
54
|
+
# ── Public API ─────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
def record_event(self, event_type: str, data: dict[str, Any] | None = None) -> None:
|
|
57
|
+
"""Record an arbitrary event. Buffered and auto-flushed every N events."""
|
|
58
|
+
with self._lock:
|
|
59
|
+
self._buffer.append({
|
|
60
|
+
"event_type": event_type,
|
|
61
|
+
"agent_slug": self.agent_slug,
|
|
62
|
+
"session_id": self._session_id,
|
|
63
|
+
"ts": time.time(),
|
|
64
|
+
"data": data or {},
|
|
65
|
+
})
|
|
66
|
+
should_flush = len(self._buffer) >= self.flush_interval
|
|
67
|
+
if should_flush:
|
|
68
|
+
self.flush()
|
|
69
|
+
|
|
70
|
+
def record_task(
|
|
71
|
+
self,
|
|
72
|
+
task_id: str,
|
|
73
|
+
status: str,
|
|
74
|
+
duration_ms: int | None = None,
|
|
75
|
+
tokens_used: int | None = None,
|
|
76
|
+
cost_usd: float | None = None,
|
|
77
|
+
error: str | None = None,
|
|
78
|
+
) -> None:
|
|
79
|
+
"""Record a task lifecycle event — primary billing and observability signal."""
|
|
80
|
+
self.record_event("task", {
|
|
81
|
+
"task_id": task_id,
|
|
82
|
+
"status": status,
|
|
83
|
+
"duration_ms": duration_ms,
|
|
84
|
+
"tokens_used": tokens_used,
|
|
85
|
+
"cost_usd": cost_usd,
|
|
86
|
+
"error": error,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
@contextmanager
|
|
90
|
+
def span(self, name: str, attributes: dict[str, Any] | None = None) -> Iterator[_Span]:
|
|
91
|
+
"""Context manager that records start/end of a named operation with timing."""
|
|
92
|
+
s = _Span(self, name, attributes or {})
|
|
93
|
+
s._start()
|
|
94
|
+
try:
|
|
95
|
+
yield s
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
s._error = str(exc)
|
|
98
|
+
raise
|
|
99
|
+
finally:
|
|
100
|
+
s._end()
|
|
101
|
+
|
|
102
|
+
def flush(self) -> bool:
|
|
103
|
+
"""Send buffered events to the endpoint. Returns True on success."""
|
|
104
|
+
with self._lock:
|
|
105
|
+
if not self._buffer:
|
|
106
|
+
return True
|
|
107
|
+
events = self._buffer.copy()
|
|
108
|
+
self._buffer.clear()
|
|
109
|
+
try:
|
|
110
|
+
resp = self._client.post(
|
|
111
|
+
f"{self.endpoint}/api/observability/ingest",
|
|
112
|
+
json={"events": events},
|
|
113
|
+
headers={"Authorization": f"Bearer {self.api_key}"},
|
|
114
|
+
timeout=5.0,
|
|
115
|
+
)
|
|
116
|
+
return resp.status_code < 400
|
|
117
|
+
except Exception:
|
|
118
|
+
with self._lock:
|
|
119
|
+
self._buffer = events + self._buffer
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
def _flush_loop(self) -> None:
|
|
123
|
+
while not self._stopped:
|
|
124
|
+
time.sleep(self.flush_interval)
|
|
125
|
+
if not self._stopped:
|
|
126
|
+
self.flush()
|
|
127
|
+
|
|
128
|
+
def close(self) -> None:
|
|
129
|
+
"""Flush and close the HTTP client."""
|
|
130
|
+
self._stopped = True
|
|
131
|
+
self.flush()
|
|
132
|
+
self._client.close()
|
|
133
|
+
|
|
134
|
+
# ── Context manager support ────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
def __enter__(self) -> "ViktronTelemetry":
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
def __exit__(self, *_: Any) -> None:
|
|
140
|
+
self.close()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class _Span:
|
|
144
|
+
"""Internal span object yielded by ViktronTelemetry.span()."""
|
|
145
|
+
|
|
146
|
+
def __init__(self, tel: ViktronTelemetry, name: str, attributes: dict[str, Any]):
|
|
147
|
+
self._tel = tel
|
|
148
|
+
self._name = name
|
|
149
|
+
self._attrs = dict(attributes)
|
|
150
|
+
self._start_time: float = 0.0
|
|
151
|
+
self._output: str | None = None
|
|
152
|
+
self._error: str | None = None
|
|
153
|
+
|
|
154
|
+
def set_attribute(self, key: str, value: Any) -> None:
|
|
155
|
+
"""Add or update a span attribute."""
|
|
156
|
+
self._attrs[key] = value
|
|
157
|
+
|
|
158
|
+
def set_output(self, value: Any) -> None:
|
|
159
|
+
"""Record a preview of the span output (truncated to 500 chars)."""
|
|
160
|
+
self._output = str(value)[:500]
|
|
161
|
+
|
|
162
|
+
# ── Internal ──────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
def _start(self) -> None:
|
|
165
|
+
self._start_time = time.time()
|
|
166
|
+
self._tel.record_event(f"{self._name}.start", self._attrs)
|
|
167
|
+
|
|
168
|
+
def _end(self) -> None:
|
|
169
|
+
duration_ms = int((time.time() - self._start_time) * 1000)
|
|
170
|
+
self._tel.record_event(f"{self._name}.end", {
|
|
171
|
+
**self._attrs,
|
|
172
|
+
"duration_ms": duration_ms,
|
|
173
|
+
"output_preview": self._output,
|
|
174
|
+
"error": self._error,
|
|
175
|
+
})
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: viktron-sdk
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Framework-agnostic governance SDK for AI agents — policy enforcement, telemetry, and observability
|
|
5
|
+
Author-email: Viktron AI <support@viktron.ai>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://viktron.ai
|
|
8
|
+
Project-URL: Documentation, https://viktron.ai/docs
|
|
9
|
+
Project-URL: Repository, https://github.com/vikasvardhanv/viktron-agentsALR
|
|
10
|
+
Keywords: ai,agents,governance,telemetry,observability,llm
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
Requires-Dist: httpx>=0.24.0
|
|
18
|
+
Provides-Extra: langchain
|
|
19
|
+
Requires-Dist: langchain-core>=0.1.0; extra == "langchain"
|
|
20
|
+
Provides-Extra: crewai
|
|
21
|
+
Requires-Dist: crewai>=0.1.0; extra == "crewai"
|
|
22
|
+
Provides-Extra: autogen
|
|
23
|
+
Requires-Dist: pyautogen>=0.2.0; extra == "autogen"
|
|
24
|
+
Provides-Extra: all
|
|
25
|
+
Requires-Dist: langchain-core>=0.1.0; extra == "all"
|
|
26
|
+
Requires-Dist: crewai>=0.1.0; extra == "all"
|
|
27
|
+
Requires-Dist: pyautogen>=0.2.0; extra == "all"
|
|
28
|
+
|
|
29
|
+
# Viktron SDK
|
|
30
|
+
|
|
31
|
+
Framework-agnostic governance SDK for AI agents — policy enforcement, telemetry, and real-time observability for production AI systems.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install viktron-sdk
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Quick Start
|
|
40
|
+
|
|
41
|
+
### Telemetry (send agent events to your Viktron dashboard)
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from viktron_sdk import ViktronTelemetry
|
|
45
|
+
|
|
46
|
+
tel = ViktronTelemetry(api_key="vk_live_...", agent_slug="my-agent")
|
|
47
|
+
|
|
48
|
+
# Record a task
|
|
49
|
+
tel.record_task("task-123", status="completed", duration_ms=4200, cost_usd=0.003)
|
|
50
|
+
|
|
51
|
+
# Use a context-managed span
|
|
52
|
+
with tel.span("run_campaign") as span:
|
|
53
|
+
span.set_attribute("platform", "meta")
|
|
54
|
+
result = run_campaign()
|
|
55
|
+
span.set_output(result)
|
|
56
|
+
|
|
57
|
+
tel.close() # flush remaining events
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Policy Guard (intercept LLM calls with governance rules)
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
import openai
|
|
64
|
+
from viktron_sdk import ViktronGuard
|
|
65
|
+
|
|
66
|
+
client = ViktronGuard.wrap(
|
|
67
|
+
openai.OpenAI(),
|
|
68
|
+
api_key="vk_live_...",
|
|
69
|
+
agent_id="sales-agent-prod",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# All create() calls are now policy-checked
|
|
73
|
+
response = client.chat.completions.create(
|
|
74
|
+
model="gpt-4o",
|
|
75
|
+
messages=[{"role": "user", "content": "Hello"}],
|
|
76
|
+
)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Works with OpenAI (sync & async), Anthropic, Cohere, and any client with `.create()` / `.generate()` call patterns.
|
|
80
|
+
|
|
81
|
+
### Framework Integrations
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
# LangChain
|
|
85
|
+
from viktron_sdk.integrations.langchain import ViktronCallbackHandler
|
|
86
|
+
handler = ViktronCallbackHandler(api_key="vk_live_...", agent_id="my-chain")
|
|
87
|
+
|
|
88
|
+
# CrewAI
|
|
89
|
+
from viktron_sdk.integrations.crewai import ViktronCrewAIObserver
|
|
90
|
+
observer = ViktronCrewAIObserver(api_key="vk_live_...", agent_id="my-crew")
|
|
91
|
+
|
|
92
|
+
# AutoGen
|
|
93
|
+
from viktron_sdk.integrations.autogen import ViktronAutoGenHook
|
|
94
|
+
hook = ViktronAutoGenHook(api_key="vk_live_...", agent_id="my-autogen")
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## API Keys
|
|
98
|
+
|
|
99
|
+
Generate your API key at [app.viktron.ai](https://app.viktron.ai) → Settings → API Keys.
|
|
100
|
+
|
|
101
|
+
Keys prefixed `vk_live_` are production; `vk_test_` are for testing.
|
|
102
|
+
|
|
103
|
+
## Links
|
|
104
|
+
|
|
105
|
+
- [Dashboard](https://app.viktron.ai)
|
|
106
|
+
- [Documentation](https://viktron.ai/docs)
|
|
107
|
+
- [GitHub](https://github.com/vikasvardhanv/viktron-agentsALR)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
viktron_sdk/__init__.py,sha256=1nYJZ5IH1p8dTCD8xkdKLTEVFQPjos_aAJkHbqs0Uh8,322
|
|
2
|
+
viktron_sdk/analytics.py,sha256=GhX_yBpQcQBPm6EEjLhc6Y3sl887B9m4DuDf6aGf7EU,4052
|
|
3
|
+
viktron_sdk/guard.py,sha256=HUyjJ7Rk24r0f3t8XN0W51DwgUbdiMjL0W0_QTyVQGs,13162
|
|
4
|
+
viktron_sdk/telemetry.py,sha256=CaQgUXuusYF1IhYGWO8xHa1STTrUWMteJNC3GMrEWfY,5997
|
|
5
|
+
viktron_sdk/integrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
viktron_sdk/integrations/autogen.py,sha256=1DpmwBmAjyFEoGOTcu1wVJqZwY-k2aoR_Ma6Byw9EfI,6544
|
|
7
|
+
viktron_sdk/integrations/crewai.py,sha256=QzLkUbXoO6IBY12Q85zA4uRWf1pmq54hKX_Guhs-s5M,4736
|
|
8
|
+
viktron_sdk/integrations/langchain.py,sha256=g32q4SPFRVvzUx4DfsCdsy08f-UfMB_s_QQexU0-A3M,2774
|
|
9
|
+
viktron_sdk-0.2.0.dist-info/METADATA,sha256=uK8BPv2oNz8MmLsFk8K7mEiqQ43JA9bfKNf0lgSad-k,3233
|
|
10
|
+
viktron_sdk-0.2.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
viktron_sdk-0.2.0.dist-info/top_level.txt,sha256=NRKby0lu3wr4_5ty0tOgTerhZoKQU6BIsIP4144nJhE,12
|
|
12
|
+
viktron_sdk-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
viktron_sdk
|