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.
@@ -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"
@@ -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()
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ viktron_sdk