znyx-runtime 1.0.0__tar.gz

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,57 @@
1
+ Metadata-Version: 2.4
2
+ Name: znyx-runtime
3
+ Version: 1.0.0
4
+ Summary: Znyx guardrails runtime: a lightweight FastAPI service that evaluates LLM traffic against policies. Rules-only out of the box; gains ML when pointed at a Znyx inference sidecar.
5
+ Author: Zitrino
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/zitrino-oss/znyx-runtime
8
+ Project-URL: Source, https://github.com/zitrino-oss/znyx-runtime
9
+ Project-URL: Issues, https://github.com/zitrino-oss/znyx-runtime/issues
10
+ Keywords: llm,guardrails,safety,ai-security,fastapi,policy
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Framework :: FastAPI
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: Apache Software License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Security
20
+ Requires-Python: >=3.11
21
+ Description-Content-Type: text/markdown
22
+ Requires-Dist: znyx-core>=1.0.0
23
+ Requires-Dist: fastapi>=0.109.0
24
+ Requires-Dist: uvicorn[standard]>=0.27.0
25
+ Requires-Dist: pydantic>=2.5.0
26
+ Requires-Dist: pydantic-settings>=2.1.0
27
+ Requires-Dist: python-multipart>=0.0.30
28
+ Requires-Dist: python-dotenv>=1.0.0
29
+
30
+ # znyx-runtime
31
+
32
+ A lightweight, dependency-minimal FastAPI service that evaluates LLM traffic
33
+ against guardrail policies. Rules-only out of the box; it gains ML detection when
34
+ pointed at a Znyx inference sidecar over HTTP.
35
+
36
+ ```bash
37
+ pip install znyx-runtime
38
+ znyx-runtime serve --port 8080
39
+ ```
40
+
41
+ Or with Docker:
42
+
43
+ ```bash
44
+ docker run -p 8080:8080 znyx/runtime
45
+ ```
46
+
47
+ The runtime is deliberately thin: FastAPI, uvicorn, httpx, and
48
+ [`znyx-core`](https://github.com/zitrino-oss/znyx-runtime) (the detection engine).
49
+ No database, no heavy ML libraries. Point it at a sidecar endpoint to enable
50
+ model-backed detectors; without one, it runs the deterministic rules path.
51
+
52
+ See the [repository README](https://github.com/zitrino-oss/znyx-runtime) for
53
+ configuration, deployment manifests, and the evaluate API.
54
+
55
+ ## License
56
+
57
+ Apache-2.0
@@ -0,0 +1,28 @@
1
+ # znyx-runtime
2
+
3
+ A lightweight, dependency-minimal FastAPI service that evaluates LLM traffic
4
+ against guardrail policies. Rules-only out of the box; it gains ML detection when
5
+ pointed at a Znyx inference sidecar over HTTP.
6
+
7
+ ```bash
8
+ pip install znyx-runtime
9
+ znyx-runtime serve --port 8080
10
+ ```
11
+
12
+ Or with Docker:
13
+
14
+ ```bash
15
+ docker run -p 8080:8080 znyx/runtime
16
+ ```
17
+
18
+ The runtime is deliberately thin: FastAPI, uvicorn, httpx, and
19
+ [`znyx-core`](https://github.com/zitrino-oss/znyx-runtime) (the detection engine).
20
+ No database, no heavy ML libraries. Point it at a sidecar endpoint to enable
21
+ model-backed detectors; without one, it runs the deterministic rules path.
22
+
23
+ See the [repository README](https://github.com/zitrino-oss/znyx-runtime) for
24
+ configuration, deployment manifests, and the evaluate API.
25
+
26
+ ## License
27
+
28
+ Apache-2.0
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "znyx-runtime"
7
+ version = "1.0.0"
8
+ description = "Znyx guardrails runtime: a lightweight FastAPI service that evaluates LLM traffic against policies. Rules-only out of the box; gains ML when pointed at a Znyx inference sidecar."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "Apache-2.0" }
12
+ authors = [{ name = "Zitrino" }]
13
+ keywords = ["llm", "guardrails", "safety", "ai-security", "fastapi", "policy"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Framework :: FastAPI",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: Apache Software License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Security",
24
+ ]
25
+ dependencies = [
26
+ "znyx-core>=1.0.0",
27
+ "fastapi>=0.109.0",
28
+ "uvicorn[standard]>=0.27.0",
29
+ "pydantic>=2.5.0",
30
+ "pydantic-settings>=2.1.0",
31
+ "python-multipart>=0.0.30",
32
+ "python-dotenv>=1.0.0",
33
+ ]
34
+
35
+ [project.scripts]
36
+ znyx-runtime = "znyx_runtime.cli:main"
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/zitrino-oss/znyx-runtime"
40
+ Source = "https://github.com/zitrino-oss/znyx-runtime"
41
+ Issues = "https://github.com/zitrino-oss/znyx-runtime/issues"
42
+
43
+ [tool.setuptools.packages.find]
44
+ where = ["src"]
45
+
46
+ [tool.setuptools.package-data]
47
+ znyx_runtime = ["**/*.yaml", "**/*.yml", "**/*.json"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
File without changes
@@ -0,0 +1,284 @@
1
+ """
2
+ Runtime API routes - evaluation endpoints only.
3
+ No DB dependency, no admin routes.
4
+ """
5
+ import logging
6
+ import os
7
+ from typing import Optional
8
+
9
+ from fastapi import APIRouter, Depends, HTTPException, Header
10
+
11
+ from znyx_core.core.models import (
12
+ EvaluationRequest, EvaluationResponse, HealthResponse, ToolEvaluationRequest,
13
+ RetrievalEvaluationRequest, AgentPlanEvaluationRequest, AgentStepEvaluationRequest,
14
+ MemoryWriteEvaluationRequest,
15
+ )
16
+
17
+ logger = logging.getLogger(__name__)
18
+ router = APIRouter()
19
+
20
+
21
+ def _is_prod() -> bool:
22
+ from znyx_core.utils.env import is_production
23
+ return is_production()
24
+
25
+
26
+ def _runtime_auth_required() -> bool:
27
+ """Default: on. Only explicitly disabled when RUNTIME_REQUIRE_AUTH=false,
28
+ and only honoured outside production — production always requires auth."""
29
+ raw = os.getenv("RUNTIME_REQUIRE_AUTH", "true").strip().lower()
30
+ if _is_prod():
31
+ return True
32
+ return raw not in ("0", "false", "no", "off")
33
+
34
+
35
+ async def _optional_runtime_auth(
36
+ x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
37
+ authorization: Optional[str] = Header(default=None),
38
+ ) -> None:
39
+ """Enforce runtime auth. Default-on; only disablable in non-production."""
40
+ if not _runtime_auth_required():
41
+ return
42
+
43
+ expected = os.getenv("RUNTIME_API_KEY", "").strip()
44
+ if not expected:
45
+ raise HTTPException(
46
+ status_code=500,
47
+ detail="Server misconfiguration: RUNTIME_REQUIRE_AUTH is enabled but RUNTIME_API_KEY is not set.",
48
+ )
49
+
50
+ # Accept either X-API-Key or Authorization: Bearer
51
+ token = x_api_key
52
+ if not token and authorization and authorization.startswith("Bearer "):
53
+ token = authorization[7:]
54
+
55
+ if not token:
56
+ raise HTTPException(
57
+ status_code=401,
58
+ detail="Authentication required. Set X-API-Key or Authorization: Bearer header.",
59
+ )
60
+
61
+ # Constant-time compare to prevent timing attacks.
62
+ import hmac as _hmac
63
+
64
+ if not _hmac.compare_digest(token, expected):
65
+ raise HTTPException(status_code=401, detail="Invalid authentication token")
66
+
67
+
68
+ def _get_evaluator():
69
+ from znyx_runtime.main import evaluator
70
+ return evaluator
71
+
72
+
73
+ def _get_bundle_manager():
74
+ from znyx_runtime.main import bundle_manager
75
+ return bundle_manager
76
+
77
+
78
+ def _get_heartbeat():
79
+ from znyx_runtime.main import heartbeat
80
+ return heartbeat
81
+
82
+
83
+ def _get_runtime_judge():
84
+ from znyx_runtime.main import runtime_judge
85
+ return runtime_judge
86
+
87
+
88
+ def _runtime_judge_ctx(policy, scoped):
89
+ """Build the per-request judge context for a runtime evaluation, or None
90
+ when judges aren't used / judge audit is off. Stamps the request's tenant as org_scope
91
+ so the CP drain can attribute the spooled rows."""
92
+ rj = _get_runtime_judge()
93
+ if rj is None:
94
+ return None
95
+ return rj.context_for(policy, env=getattr(scoped, "env", None) or "prod",
96
+ org_scope=getattr(scoped, "tenant_id", None))
97
+
98
+
99
+ # Public aliases consumed by stream_routes and any other runtime-scoped code.
100
+ # These intentionally shadow the private underscore names so callers can do:
101
+ # from znyx_runtime.api.routes import verify_runtime_auth, get_evaluator
102
+ verify_runtime_auth = _optional_runtime_auth
103
+ get_evaluator = _get_evaluator
104
+ get_heartbeat = _get_heartbeat
105
+
106
+
107
+ @router.post("/v1/evaluate/input", response_model=EvaluationResponse, dependencies=[Depends(_optional_runtime_auth)])
108
+ async def evaluate_input(request: EvaluationRequest) -> EvaluationResponse:
109
+ """Evaluate input text before sending to LLM."""
110
+ try:
111
+ bm = _get_bundle_manager()
112
+ if bm.effective_env:
113
+ request = request.model_copy(update={"env": bm.effective_env})
114
+ policy = bm.get_policy(
115
+ tenant_id=request.tenant_id,
116
+ app_id=request.app_id,
117
+ agent_id=request.agent_id,
118
+ env=request.env,
119
+ )
120
+ ev = _get_evaluator()
121
+ result = await ev.evaluate(request, context="input", policy=policy,
122
+ judge_ctx=_runtime_judge_ctx(policy, request))
123
+ hb = _get_heartbeat()
124
+ if hb:
125
+ hb.increment_eval_count()
126
+ return result
127
+ except RuntimeError as e:
128
+ logger.error(f"Policy unavailable: {e}")
129
+ raise HTTPException(status_code=503, detail="Policy unavailable (fail-closed)")
130
+ except Exception:
131
+ logger.exception("Evaluation failed")
132
+ raise HTTPException(status_code=500, detail="Evaluation failed")
133
+
134
+
135
+ @router.post("/v1/evaluate/output", response_model=EvaluationResponse, dependencies=[Depends(_optional_runtime_auth)])
136
+ async def evaluate_output(request: EvaluationRequest) -> EvaluationResponse:
137
+ """Evaluate output text from LLM before returning to user."""
138
+ try:
139
+ bm = _get_bundle_manager()
140
+ if bm.effective_env:
141
+ request = request.model_copy(update={"env": bm.effective_env})
142
+ policy = bm.get_policy(
143
+ tenant_id=request.tenant_id,
144
+ app_id=request.app_id,
145
+ agent_id=request.agent_id,
146
+ env=request.env,
147
+ )
148
+ ev = _get_evaluator()
149
+ result = await ev.evaluate(request, context="output", policy=policy,
150
+ judge_ctx=_runtime_judge_ctx(policy, request))
151
+ hb = _get_heartbeat()
152
+ if hb:
153
+ hb.increment_eval_count()
154
+ return result
155
+ except RuntimeError as e:
156
+ logger.error(f"Policy unavailable: {e}")
157
+ raise HTTPException(status_code=503, detail="Policy unavailable (fail-closed)")
158
+ except Exception:
159
+ logger.exception("Evaluation failed")
160
+ raise HTTPException(status_code=500, detail="Evaluation failed")
161
+
162
+
163
+ @router.post("/v1/evaluate/tool", response_model=EvaluationResponse, dependencies=[Depends(_optional_runtime_auth)])
164
+ async def evaluate_tool(request: ToolEvaluationRequest) -> EvaluationResponse:
165
+ """Evaluate tool invocation against governance policies."""
166
+ try:
167
+ bm = _get_bundle_manager()
168
+ if bm.effective_env:
169
+ request = request.model_copy(update={"env": bm.effective_env})
170
+ policy = bm.get_policy(
171
+ tenant_id=request.tenant_id,
172
+ app_id=request.app_id,
173
+ agent_id=request.agent_id,
174
+ env=request.env,
175
+ )
176
+ ev = _get_evaluator()
177
+ result = await ev.evaluate_tool(request, policy=policy,
178
+ judge_ctx=_runtime_judge_ctx(policy, request))
179
+ hb = _get_heartbeat()
180
+ if hb:
181
+ hb.increment_eval_count()
182
+ return result
183
+ except RuntimeError as e:
184
+ logger.error(f"Policy unavailable: {e}")
185
+ raise HTTPException(status_code=503, detail="Policy unavailable (fail-closed)")
186
+ except Exception:
187
+ logger.exception("Tool evaluation failed")
188
+ raise HTTPException(status_code=500, detail="Tool evaluation failed")
189
+
190
+
191
+ # ── New-stage evaluate endpoints (generalized stage dispatch) ──────
192
+
193
+ async def _evaluate_stage_runtime(scoped, stage: str) -> EvaluationResponse:
194
+ """Shared runtime handler for the per-stage endpoints: resolve the bundle
195
+ policy for the request scope and dispatch through the generalized stage pipeline."""
196
+ bm = _get_bundle_manager()
197
+ if bm.effective_env:
198
+ scoped = scoped.model_copy(update={"env": bm.effective_env})
199
+ policy = bm.get_policy(
200
+ tenant_id=scoped.tenant_id,
201
+ app_id=scoped.app_id,
202
+ agent_id=scoped.agent_id,
203
+ env=scoped.env,
204
+ )
205
+ ev = _get_evaluator()
206
+ result = await ev.evaluate_stage(scoped, stage, policy=policy,
207
+ judge_ctx=_runtime_judge_ctx(policy, scoped))
208
+ hb = _get_heartbeat()
209
+ if hb:
210
+ hb.increment_eval_count()
211
+ return result
212
+
213
+
214
+ @router.post("/v1/evaluate/retrieval", response_model=EvaluationResponse, dependencies=[Depends(_optional_runtime_auth)])
215
+ async def evaluate_retrieval(request: RetrievalEvaluationRequest) -> EvaluationResponse:
216
+ """Evaluate retrieved RAG chunks for indirect prompt injection before they enter context (LLM01)."""
217
+ try:
218
+ return await _evaluate_stage_runtime(request, "retrieval")
219
+ except RuntimeError:
220
+ raise HTTPException(status_code=503, detail="Policy unavailable (fail-closed)")
221
+ except Exception:
222
+ logger.exception("Retrieval evaluation failed")
223
+ raise HTTPException(status_code=500, detail="Evaluation failed")
224
+
225
+
226
+ @router.post("/v1/evaluate/agent-plan", response_model=EvaluationResponse, dependencies=[Depends(_optional_runtime_auth)])
227
+ async def evaluate_agent_plan(request: AgentPlanEvaluationRequest) -> EvaluationResponse:
228
+ """Evaluate a proposed multi-step agent plan for excessive agency (LLM06)."""
229
+ try:
230
+ return await _evaluate_stage_runtime(request, "agent_plan")
231
+ except RuntimeError:
232
+ raise HTTPException(status_code=503, detail="Policy unavailable (fail-closed)")
233
+ except Exception:
234
+ logger.exception("Agent-plan evaluation failed")
235
+ raise HTTPException(status_code=500, detail="Evaluation failed")
236
+
237
+
238
+ @router.post("/v1/evaluate/agent-step", response_model=EvaluationResponse, dependencies=[Depends(_optional_runtime_auth)])
239
+ async def evaluate_agent_step(request: AgentStepEvaluationRequest) -> EvaluationResponse:
240
+ """Evaluate a single agent-loop iteration for budget/depth caps (LLM10)."""
241
+ try:
242
+ return await _evaluate_stage_runtime(request, "agent_loop")
243
+ except RuntimeError:
244
+ raise HTTPException(status_code=503, detail="Policy unavailable (fail-closed)")
245
+ except Exception:
246
+ logger.exception("Agent-step evaluation failed")
247
+ raise HTTPException(status_code=500, detail="Evaluation failed")
248
+
249
+
250
+ @router.post("/v1/evaluate/memory-write", response_model=EvaluationResponse, dependencies=[Depends(_optional_runtime_auth)])
251
+ async def evaluate_memory_write(request: MemoryWriteEvaluationRequest) -> EvaluationResponse:
252
+ """Evaluate text being written to agent memory for persistent injection (LLM01)."""
253
+ try:
254
+ return await _evaluate_stage_runtime(request, "memory_write")
255
+ except RuntimeError:
256
+ raise HTTPException(status_code=503, detail="Policy unavailable (fail-closed)")
257
+ except Exception:
258
+ logger.exception("Memory-write evaluation failed")
259
+ raise HTTPException(status_code=500, detail="Evaluation failed")
260
+
261
+
262
+ @router.get("/healthz", response_model=HealthResponse)
263
+ async def health_check() -> HealthResponse:
264
+ """Health check - always returns 200."""
265
+ return HealthResponse(status="ok")
266
+
267
+
268
+ @router.get("/readyz", response_model=HealthResponse)
269
+ async def readiness_check() -> HealthResponse:
270
+ """Readiness check - returns 200 only if a policy is loaded."""
271
+ bm = _get_bundle_manager()
272
+ if bm.is_ready:
273
+ return HealthResponse(status="ready")
274
+ raise HTTPException(status_code=503, detail="Not ready - no policy loaded")
275
+
276
+
277
+ @router.get("/v1/bundle/status")
278
+ async def bundle_status():
279
+ """Return metadata about the currently loaded policy bundle."""
280
+ bm = _get_bundle_manager()
281
+ return {
282
+ "ready": bm.is_ready,
283
+ **bm.bundle_info,
284
+ }
@@ -0,0 +1,101 @@
1
+ """Streaming guardrails SSE endpoint - evaluate LLM output in real-time."""
2
+ import json
3
+ import logging
4
+ from typing import Any, Dict, List, Optional
5
+ from fastapi import APIRouter, Depends
6
+ from fastapi.responses import StreamingResponse
7
+ from pydantic import BaseModel, Field
8
+
9
+ from znyx_runtime.api.routes import verify_runtime_auth, get_evaluator, get_heartbeat
10
+
11
+ logger = logging.getLogger(__name__)
12
+ router = APIRouter(tags=["Runtime API"])
13
+
14
+
15
+ class StreamEvaluateRequest(BaseModel):
16
+ """Request body for streaming evaluation."""
17
+ request_id: str = "stream-0"
18
+ tenant_id: str = "default"
19
+ app_id: str = "default"
20
+ context: str = Field(default="output", description="input or output")
21
+ chunks: List[str] = Field(..., description="Text chunks to evaluate in order")
22
+ policy: Optional[Dict[str, Any]] = Field(default=None, description="Inline policy (optional)")
23
+ window_size: int = Field(default=200, ge=50, le=2000)
24
+ overlap: int = Field(default=40, ge=0, le=500)
25
+
26
+
27
+ @router.post(
28
+ "/v1/evaluate/stream",
29
+ operation_id="runtime.evaluateStream",
30
+ summary="Evaluate streaming LLM output as it arrives",
31
+ )
32
+ async def evaluate_stream(
33
+ body: StreamEvaluateRequest,
34
+ _auth=Depends(verify_runtime_auth),
35
+ evaluator=Depends(get_evaluator),
36
+ ):
37
+ """Evaluate text chunks via Server-Sent Events.
38
+
39
+ Accepts a list of text chunks and streams back SSE events as each window
40
+ is evaluated. Events:
41
+ - ``chunk``: forwarded text chunk
42
+ - ``guardrail``: window evaluation result (non-blocking)
43
+ - ``block``: window triggered a BLOCK decision
44
+ - ``done``: final summary with aggregate metrics
45
+ """
46
+ from znyx_core.engine.streaming import StreamingEvaluator
47
+ from znyx_core.core.models import EvaluationRequest
48
+
49
+ # Resolve policy
50
+ policy = body.policy
51
+ if policy is None and evaluator.policy_resolver:
52
+ policy = evaluator.policy_resolver.resolve(
53
+ tenant_id=body.tenant_id,
54
+ app_id=body.app_id,
55
+ agent_id="default",
56
+ env="prod",
57
+ )
58
+ if policy is None:
59
+ policy = {}
60
+
61
+ request = EvaluationRequest(
62
+ request_id=body.request_id,
63
+ tenant_id=body.tenant_id,
64
+ app_id=body.app_id,
65
+ text="", # text comes in chunks
66
+ )
67
+
68
+ stream_eval = StreamingEvaluator(
69
+ policy=policy,
70
+ context=body.context,
71
+ window_size=body.window_size,
72
+ overlap=body.overlap,
73
+ request=request,
74
+ )
75
+
76
+ async def event_generator():
77
+ for chunk in body.chunks:
78
+ events = stream_eval.push(chunk)
79
+ for event in events:
80
+ yield f"event: {event['event']}\ndata: {json.dumps(event['data'])}\n\n"
81
+ if event["event"] == "block":
82
+ # Still send done after block
83
+ break
84
+ if stream_eval.is_blocked:
85
+ break
86
+
87
+ summary = stream_eval.flush()
88
+ yield f"event: {summary['event']}\ndata: {json.dumps(summary['data'])}\n\n"
89
+ hb = get_heartbeat()
90
+ if hb:
91
+ hb.increment_eval_count()
92
+
93
+ return StreamingResponse(
94
+ event_generator(),
95
+ media_type="text/event-stream",
96
+ headers={
97
+ "Cache-Control": "no-cache",
98
+ "Connection": "keep-alive",
99
+ "X-Accel-Buffering": "no",
100
+ },
101
+ )