znyx-inference 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.
- znyx_inference-1.0.0/PKG-INFO +81 -0
- znyx_inference-1.0.0/README.md +41 -0
- znyx_inference-1.0.0/pyproject.toml +75 -0
- znyx_inference-1.0.0/setup.cfg +4 -0
- znyx_inference-1.0.0/src/znyx_inference/__init__.py +11 -0
- znyx_inference-1.0.0/src/znyx_inference/batching.py +112 -0
- znyx_inference-1.0.0/src/znyx_inference/cache.py +62 -0
- znyx_inference-1.0.0/src/znyx_inference/config.py +78 -0
- znyx_inference-1.0.0/src/znyx_inference/contract.py +83 -0
- znyx_inference-1.0.0/src/znyx_inference/main.py +129 -0
- znyx_inference-1.0.0/src/znyx_inference/registry.py +115 -0
- znyx_inference-1.0.0/src/znyx_inference/runners/__init__.py +12 -0
- znyx_inference-1.0.0/src/znyx_inference/runners/_artifacts.py +64 -0
- znyx_inference-1.0.0/src/znyx_inference/runners/_fetch.py +165 -0
- znyx_inference-1.0.0/src/znyx_inference/runners/_heavy.py +178 -0
- znyx_inference-1.0.0/src/znyx_inference/runners/base.py +105 -0
- znyx_inference-1.0.0/src/znyx_inference/runners/classifier.py +42 -0
- znyx_inference-1.0.0/src/znyx_inference/runners/embedding.py +52 -0
- znyx_inference-1.0.0/src/znyx_inference/runners/guard_llm.py +65 -0
- znyx_inference-1.0.0/src/znyx_inference/runners/language.py +59 -0
- znyx_inference-1.0.0/src/znyx_inference/runners/ner.py +70 -0
- znyx_inference-1.0.0/src/znyx_inference/runners/nli.py +61 -0
- znyx_inference-1.0.0/src/znyx_inference.egg-info/PKG-INFO +81 -0
- znyx_inference-1.0.0/src/znyx_inference.egg-info/SOURCES.txt +25 -0
- znyx_inference-1.0.0/src/znyx_inference.egg-info/dependency_links.txt +1 -0
- znyx_inference-1.0.0/src/znyx_inference.egg-info/requires.txt +20 -0
- znyx_inference-1.0.0/src/znyx_inference.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: znyx-inference
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Znyx inference sidecar: an optional HTTP service that serves ML models for model-backed guardrail detection. Boots dependency-free on a stub runner; add the [onnx] extra to serve real weights on CPU.
|
|
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,inference,ml-serving,ai-security
|
|
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
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: znyx-core>=1.0.0
|
|
24
|
+
Requires-Dist: fastapi>=0.109.0
|
|
25
|
+
Requires-Dist: uvicorn[standard]>=0.27.0
|
|
26
|
+
Requires-Dist: pydantic>=2.5.0
|
|
27
|
+
Requires-Dist: httpx>=0.26.0
|
|
28
|
+
Provides-Extra: onnx
|
|
29
|
+
Requires-Dist: onnxruntime<2.0,>=1.17; extra == "onnx"
|
|
30
|
+
Requires-Dist: tokenizers<1.0,>=0.19; extra == "onnx"
|
|
31
|
+
Requires-Dist: numpy>=1.24; extra == "onnx"
|
|
32
|
+
Provides-Extra: export
|
|
33
|
+
Requires-Dist: torch<3.0,>=2.2; extra == "export"
|
|
34
|
+
Requires-Dist: transformers<6.0,>=4.40; extra == "export"
|
|
35
|
+
Requires-Dist: optimum[exporters,onnxruntime]<3.0,>=1.17; extra == "export"
|
|
36
|
+
Requires-Dist: huggingface-hub<2.0,>=0.23; extra == "export"
|
|
37
|
+
Provides-Extra: torch
|
|
38
|
+
Requires-Dist: torch<3.0,>=2.2; extra == "torch"
|
|
39
|
+
Requires-Dist: transformers<6.0,>=4.40; extra == "torch"
|
|
40
|
+
|
|
41
|
+
# znyx-inference
|
|
42
|
+
|
|
43
|
+
The optional Znyx inference sidecar: an HTTP service that serves ML models for
|
|
44
|
+
model-backed guardrail detection. The [`znyx-runtime`](https://github.com/zitrino-oss/znyx-runtime)
|
|
45
|
+
calls it over HTTP to escalate from rules to ML. Without it, the runtime still
|
|
46
|
+
works fully on its deterministic rules path.
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
# boots dependency-free on a stub runner (no ML stack needed):
|
|
50
|
+
pip install znyx-inference
|
|
51
|
+
|
|
52
|
+
# to serve real model weights on CPU — lean, torch-free (onnxruntime + tokenizers):
|
|
53
|
+
pip install "znyx-inference[onnx]"
|
|
54
|
+
|
|
55
|
+
# offline only: export + quantize a checkpoint to a pinned ONNX artifact (heavy: torch + optimum):
|
|
56
|
+
pip install "znyx-inference[export]"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Weights are never bundled
|
|
60
|
+
|
|
61
|
+
This package ships **code only, no model weights.** You fetch the models you want
|
|
62
|
+
explicitly, sha256-pin them, and mount them. Nothing is downloaded implicitly on
|
|
63
|
+
the serving path. See [MODELS.md](MODELS.md) for the curated model list, licenses,
|
|
64
|
+
and the fetch-and-pin workflow, including which models carry special licenses.
|
|
65
|
+
|
|
66
|
+
## How it fits
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
your app -> znyx-runtime (rules) --HTTP--> znyx-inference (ML) -> local, pinned weights
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The sidecar loads only local, sha256-verified weights, fails closed, and never
|
|
73
|
+
phones home. If a model is not present it stays on the stub runner and the runtime
|
|
74
|
+
falls back to rules per the policy's fallback mode.
|
|
75
|
+
|
|
76
|
+
See the [repository README](https://github.com/zitrino-oss/znyx-runtime) for the
|
|
77
|
+
full architecture.
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
Apache-2.0. Model weights you fetch are governed by their own licenses (see MODELS.md).
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# znyx-inference
|
|
2
|
+
|
|
3
|
+
The optional Znyx inference sidecar: an HTTP service that serves ML models for
|
|
4
|
+
model-backed guardrail detection. The [`znyx-runtime`](https://github.com/zitrino-oss/znyx-runtime)
|
|
5
|
+
calls it over HTTP to escalate from rules to ML. Without it, the runtime still
|
|
6
|
+
works fully on its deterministic rules path.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
# boots dependency-free on a stub runner (no ML stack needed):
|
|
10
|
+
pip install znyx-inference
|
|
11
|
+
|
|
12
|
+
# to serve real model weights on CPU — lean, torch-free (onnxruntime + tokenizers):
|
|
13
|
+
pip install "znyx-inference[onnx]"
|
|
14
|
+
|
|
15
|
+
# offline only: export + quantize a checkpoint to a pinned ONNX artifact (heavy: torch + optimum):
|
|
16
|
+
pip install "znyx-inference[export]"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Weights are never bundled
|
|
20
|
+
|
|
21
|
+
This package ships **code only, no model weights.** You fetch the models you want
|
|
22
|
+
explicitly, sha256-pin them, and mount them. Nothing is downloaded implicitly on
|
|
23
|
+
the serving path. See [MODELS.md](MODELS.md) for the curated model list, licenses,
|
|
24
|
+
and the fetch-and-pin workflow, including which models carry special licenses.
|
|
25
|
+
|
|
26
|
+
## How it fits
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
your app -> znyx-runtime (rules) --HTTP--> znyx-inference (ML) -> local, pinned weights
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The sidecar loads only local, sha256-verified weights, fails closed, and never
|
|
33
|
+
phones home. If a model is not present it stays on the stub runner and the runtime
|
|
34
|
+
falls back to rules per the policy's fallback mode.
|
|
35
|
+
|
|
36
|
+
See the [repository README](https://github.com/zitrino-oss/znyx-runtime) for the
|
|
37
|
+
full architecture.
|
|
38
|
+
|
|
39
|
+
## License
|
|
40
|
+
|
|
41
|
+
Apache-2.0. Model weights you fetch are governed by their own licenses (see MODELS.md).
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "znyx-inference"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Znyx inference sidecar: an optional HTTP service that serves ML models for model-backed guardrail detection. Boots dependency-free on a stub runner; add the [onnx] extra to serve real weights on CPU."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
authors = [{ name = "Zitrino" }]
|
|
13
|
+
keywords = ["llm", "guardrails", "inference", "ml-serving", "ai-security"]
|
|
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
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
25
|
+
]
|
|
26
|
+
# Core is intentionally light: the sidecar boots on a dependency-free stub runner
|
|
27
|
+
# with no ML stack installed. The heavy model runtimes are an optional extra.
|
|
28
|
+
dependencies = [
|
|
29
|
+
"znyx-core>=1.0.0",
|
|
30
|
+
"fastapi>=0.109.0",
|
|
31
|
+
"uvicorn[standard]>=0.27.0",
|
|
32
|
+
"pydantic>=2.5.0",
|
|
33
|
+
"httpx>=0.26.0",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[project.optional-dependencies]
|
|
37
|
+
# --- [onnx] — the DEFAULT serving extra. CPU-only, torch-free. This is what the
|
|
38
|
+
# inference Docker image installs. Serves sha256-pinned local ONNX artifacts via
|
|
39
|
+
# onnxruntime + the standalone Rust `tokenizers` (no transformers, no torch, no CUDA).
|
|
40
|
+
# Weights are never bundled; export + pin them offline with the [export] extra (see MODELS.md).
|
|
41
|
+
# pip install znyx-inference[onnx]
|
|
42
|
+
onnx = [
|
|
43
|
+
"onnxruntime>=1.17,<2.0",
|
|
44
|
+
"tokenizers>=0.19,<1.0",
|
|
45
|
+
"numpy>=1.24",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
# --- [export] — OFFLINE, operator/CI only. Never installed in the serving image. Pulls a
|
|
49
|
+
# vetted checkpoint and exports + dynamically-quantizes it to a pinned ONNX artifact
|
|
50
|
+
# (scripts/fetch_inference_model.py). This is the ONLY place torch lives in the normal flow.
|
|
51
|
+
# pip install znyx-inference[export]
|
|
52
|
+
export = [
|
|
53
|
+
"torch>=2.2,<3.0",
|
|
54
|
+
"transformers>=4.40,<6.0",
|
|
55
|
+
"optimum[exporters,onnxruntime]>=1.17,<3.0",
|
|
56
|
+
"huggingface-hub>=0.23,<2.0",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
# --- [torch] — OPT-IN escape hatch. Adds the eager PyTorch stack so the generative
|
|
60
|
+
# guard_llm runner (Llama-Guard-style causal LM) can serve, and so un-exported HF weights
|
|
61
|
+
# can be run directly. Heavy (pulls the torch CPU/GPU wheel); most detection is covered by
|
|
62
|
+
# the CPU-ONNX runners above, so install this only if you need generative guard.
|
|
63
|
+
# pip install znyx-inference[torch]
|
|
64
|
+
torch = [
|
|
65
|
+
"torch>=2.2,<3.0",
|
|
66
|
+
"transformers>=4.40,<6.0",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
[project.urls]
|
|
70
|
+
Homepage = "https://github.com/zitrino-oss/znyx-runtime"
|
|
71
|
+
Source = "https://github.com/zitrino-oss/znyx-runtime"
|
|
72
|
+
Issues = "https://github.com/zitrino-oss/znyx-runtime/issues"
|
|
73
|
+
|
|
74
|
+
[tool.setuptools.packages.find]
|
|
75
|
+
where = ["src"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""ZNYX Inference Service — an OPTIONAL, separately-deployable sidecar that hosts
|
|
2
|
+
local transformer/embedding/NLI/guard-LLM models behind a stable scoring contract
|
|
3
|
+
spoken by the extended RemoteDetector transport.
|
|
4
|
+
|
|
5
|
+
Hard rule: the core OSS runtime and control plane gain ZERO heavy dependencies. The
|
|
6
|
+
heavy stack (torch/transformers/sentence-transformers/onnxruntime) lives only in this
|
|
7
|
+
package's runners, lazy-imported at load time, and only in the inference Docker image
|
|
8
|
+
(requirements-inference.txt). With no heavy deps installed the service still runs on the
|
|
9
|
+
dependency-free StubRunner, so the contract / cache / batching / registry are testable
|
|
10
|
+
anywhere.
|
|
11
|
+
"""
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Dynamic batching: a bounded-latency queue that coalesces concurrent
|
|
2
|
+
/v1/infer calls into batches handed to a runner. A single worker drains the queue so
|
|
3
|
+
model inference is serialized (no re-entrancy), batches form up to ``max_batch_size``
|
|
4
|
+
within a ``max_wait_ms`` window, and the service stays within a per-task latency budget —
|
|
5
|
+
returning 429 (Saturated) instead of blocking past it. Dependency-free (asyncio only).
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from typing import List
|
|
11
|
+
|
|
12
|
+
from znyx_inference.runners.base import InferOutput, Runner
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Saturated(Exception):
|
|
16
|
+
"""The queue is full or a request exceeded the latency budget → caller returns 429."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BatchProcessor:
|
|
20
|
+
def __init__(self, runner: Runner, *, max_batch_size: int = 16,
|
|
21
|
+
max_wait_ms: int = 10, max_queue: int = 256, budget_ms: int = 2000):
|
|
22
|
+
self.runner = runner
|
|
23
|
+
self.max_batch_size = max(1, max_batch_size)
|
|
24
|
+
self.max_wait_ms = max(0, max_wait_ms)
|
|
25
|
+
self.max_queue = max(1, max_queue)
|
|
26
|
+
self.budget_ms = max(1, budget_ms)
|
|
27
|
+
self._queue: "asyncio.Queue[tuple]" = asyncio.Queue()
|
|
28
|
+
self._worker: asyncio.Task | None = None
|
|
29
|
+
self._running = False
|
|
30
|
+
# observable metrics (acceptance: batching + saturation are visible)
|
|
31
|
+
self.batches_processed = 0
|
|
32
|
+
self.items_processed = 0
|
|
33
|
+
self.rejected = 0
|
|
34
|
+
|
|
35
|
+
async def start(self) -> None:
|
|
36
|
+
if self._worker is None:
|
|
37
|
+
self._running = True
|
|
38
|
+
self._worker = asyncio.create_task(self._run())
|
|
39
|
+
|
|
40
|
+
async def stop(self) -> None:
|
|
41
|
+
self._running = False
|
|
42
|
+
if self._worker is not None:
|
|
43
|
+
self._worker.cancel()
|
|
44
|
+
try:
|
|
45
|
+
await self._worker
|
|
46
|
+
except asyncio.CancelledError:
|
|
47
|
+
pass
|
|
48
|
+
self._worker = None
|
|
49
|
+
|
|
50
|
+
async def submit(self, text: str) -> InferOutput:
|
|
51
|
+
"""Enqueue one item and await its result. Raises Saturated (→429) when the queue
|
|
52
|
+
is full or the latency budget is exceeded — never blocks past the budget."""
|
|
53
|
+
if self._queue.qsize() >= self.max_queue:
|
|
54
|
+
self.rejected += 1
|
|
55
|
+
raise Saturated("inference queue saturated")
|
|
56
|
+
loop = asyncio.get_running_loop()
|
|
57
|
+
fut: asyncio.Future = loop.create_future()
|
|
58
|
+
await self._queue.put((text, fut))
|
|
59
|
+
try:
|
|
60
|
+
return await asyncio.wait_for(fut, timeout=self.budget_ms / 1000.0)
|
|
61
|
+
except asyncio.TimeoutError:
|
|
62
|
+
self.rejected += 1
|
|
63
|
+
raise Saturated("inference exceeded latency budget")
|
|
64
|
+
|
|
65
|
+
async def _collect_batch(self) -> List[tuple]:
|
|
66
|
+
first = await self._queue.get()
|
|
67
|
+
batch = [first]
|
|
68
|
+
loop = asyncio.get_running_loop()
|
|
69
|
+
deadline = loop.time() + self.max_wait_ms / 1000.0
|
|
70
|
+
while len(batch) < self.max_batch_size:
|
|
71
|
+
remaining = deadline - loop.time()
|
|
72
|
+
if remaining <= 0:
|
|
73
|
+
break
|
|
74
|
+
try:
|
|
75
|
+
batch.append(await asyncio.wait_for(self._queue.get(), timeout=remaining))
|
|
76
|
+
except asyncio.TimeoutError:
|
|
77
|
+
break
|
|
78
|
+
return batch
|
|
79
|
+
|
|
80
|
+
async def _run(self) -> None:
|
|
81
|
+
while self._running:
|
|
82
|
+
try:
|
|
83
|
+
batch = await asyncio.wait_for(self._collect_batch(), timeout=0.5)
|
|
84
|
+
except asyncio.TimeoutError:
|
|
85
|
+
continue
|
|
86
|
+
except asyncio.CancelledError:
|
|
87
|
+
break
|
|
88
|
+
texts = [t for t, _ in batch]
|
|
89
|
+
try:
|
|
90
|
+
# Runner inference is sync/CPU-bound → offload so the loop keeps serving.
|
|
91
|
+
outputs = await asyncio.to_thread(self.runner.infer_batch, texts)
|
|
92
|
+
except Exception as exc: # noqa: BLE001 — propagate to every waiter in the batch
|
|
93
|
+
for _, fut in batch:
|
|
94
|
+
if not fut.done():
|
|
95
|
+
fut.set_exception(exc)
|
|
96
|
+
continue
|
|
97
|
+
self.batches_processed += 1
|
|
98
|
+
self.items_processed += len(batch)
|
|
99
|
+
for (_, fut), out in zip(batch, outputs):
|
|
100
|
+
if not fut.done():
|
|
101
|
+
fut.set_result(out)
|
|
102
|
+
|
|
103
|
+
def stats(self) -> dict:
|
|
104
|
+
return {
|
|
105
|
+
"batches_processed": self.batches_processed,
|
|
106
|
+
"items_processed": self.items_processed,
|
|
107
|
+
"rejected": self.rejected,
|
|
108
|
+
"queue_depth": self._queue.qsize(),
|
|
109
|
+
"max_batch_size": self.max_batch_size,
|
|
110
|
+
"avg_batch_size": (round(self.items_processed / self.batches_processed, 2)
|
|
111
|
+
if self.batches_processed else 0.0),
|
|
112
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Content-hash decision cache: normalize → sha256 → LRU of recent
|
|
2
|
+
results. Two identical normalized inputs to the same model hit the cache. Dependency-free.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import re
|
|
7
|
+
import threading
|
|
8
|
+
from collections import OrderedDict
|
|
9
|
+
from hashlib import sha256
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
_WS = re.compile(r"\s+")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def content_key(model_version: str, text: str) -> str:
|
|
16
|
+
"""Stable cache key: model_version + sha256 of the normalized text. Normalization
|
|
17
|
+
(lowercase + whitespace-collapse) means trivially-different inputs still hit."""
|
|
18
|
+
norm = _WS.sub(" ", (text or "").strip().lower())
|
|
19
|
+
digest = sha256(norm.encode("utf-8")).hexdigest()
|
|
20
|
+
return f"{model_version}:{digest}"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class ContentHashCache:
|
|
24
|
+
"""Thread-safe LRU keyed by (model_version, content-hash). Tracks hit/miss counters
|
|
25
|
+
for the observable cache-hit metric the acceptance test asserts."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, maxsize: int = 4096):
|
|
28
|
+
self.maxsize = max(1, maxsize)
|
|
29
|
+
self._store: "OrderedDict[str, object]" = OrderedDict()
|
|
30
|
+
self._lock = threading.Lock()
|
|
31
|
+
self.hits = 0
|
|
32
|
+
self.misses = 0
|
|
33
|
+
|
|
34
|
+
def get(self, key: str) -> Optional[object]:
|
|
35
|
+
with self._lock:
|
|
36
|
+
if key in self._store:
|
|
37
|
+
self._store.move_to_end(key)
|
|
38
|
+
self.hits += 1
|
|
39
|
+
return self._store[key]
|
|
40
|
+
self.misses += 1
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
def put(self, key: str, value: object) -> None:
|
|
44
|
+
with self._lock:
|
|
45
|
+
self._store[key] = value
|
|
46
|
+
self._store.move_to_end(key)
|
|
47
|
+
while len(self._store) > self.maxsize:
|
|
48
|
+
self._store.popitem(last=False)
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def size(self) -> int:
|
|
52
|
+
return len(self._store)
|
|
53
|
+
|
|
54
|
+
def stats(self) -> dict:
|
|
55
|
+
total = self.hits + self.misses
|
|
56
|
+
return {
|
|
57
|
+
"hits": self.hits,
|
|
58
|
+
"misses": self.misses,
|
|
59
|
+
"size": self.size,
|
|
60
|
+
"maxsize": self.maxsize,
|
|
61
|
+
"hit_rate": round(self.hits / total, 4) if total else 0.0,
|
|
62
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Inference service configuration. Env-driven; safe dependency-free defaults so
|
|
2
|
+
the service boots on the StubRunner with no ML stack installed.
|
|
3
|
+
|
|
4
|
+
Per-task spec keys:
|
|
5
|
+
runner: stub | classifier | embedding | nli | guard_llm
|
|
6
|
+
model_id / revision / sha256: pinned artifact identity (heavy runners; verified on load)
|
|
7
|
+
threshold: decision threshold
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict
|
|
16
|
+
|
|
17
|
+
# Default task set runs on the dependency-free StubRunner.
|
|
18
|
+
_DEFAULT_TASK_SPECS: Dict[str, Dict[str, Any]] = {
|
|
19
|
+
"prompt_injection": {"runner": "stub", "threshold": 0.5},
|
|
20
|
+
"toxicity": {"runner": "stub", "threshold": 0.5},
|
|
21
|
+
"jailbreak": {"runner": "stub", "threshold": 0.5},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _int(name: str, default: int) -> int:
|
|
26
|
+
try:
|
|
27
|
+
return int(os.getenv(name, str(default)))
|
|
28
|
+
except (TypeError, ValueError):
|
|
29
|
+
return default
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class InferenceConfig:
|
|
34
|
+
task_specs: Dict[str, Dict[str, Any]] = field(default_factory=lambda: dict(_DEFAULT_TASK_SPECS))
|
|
35
|
+
max_batch_size: int = 16
|
|
36
|
+
max_wait_ms: int = 10
|
|
37
|
+
max_queue: int = 256
|
|
38
|
+
budget_ms: int = 2000
|
|
39
|
+
cache_maxsize: int = 4096
|
|
40
|
+
# Artifacts dir + no-download posture for heavy runners (warnbox).
|
|
41
|
+
model_artifacts_dir: str = ""
|
|
42
|
+
require_local_files: bool = True # never pull weights from the network at startup
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def from_env(cls) -> "InferenceConfig":
|
|
46
|
+
cfg = cls(
|
|
47
|
+
max_batch_size=_int("ZNYX_INFERENCE_MAX_BATCH", 16),
|
|
48
|
+
max_wait_ms=_int("ZNYX_INFERENCE_MAX_WAIT_MS", 10),
|
|
49
|
+
max_queue=_int("ZNYX_INFERENCE_MAX_QUEUE", 256),
|
|
50
|
+
budget_ms=_int("ZNYX_INFERENCE_BUDGET_MS", 2000),
|
|
51
|
+
cache_maxsize=_int("ZNYX_INFERENCE_CACHE_SIZE", 4096),
|
|
52
|
+
model_artifacts_dir=os.getenv("ZNYX_INFERENCE_ARTIFACTS_DIR", ""),
|
|
53
|
+
require_local_files=os.getenv("ZNYX_INFERENCE_REQUIRE_LOCAL_FILES", "true").lower()
|
|
54
|
+
not in ("0", "false", "no"),
|
|
55
|
+
)
|
|
56
|
+
# Optional JSON override of the task→spec map (e.g. to wire heavy runners).
|
|
57
|
+
raw = os.getenv("ZNYX_INFERENCE_TASKS")
|
|
58
|
+
if raw:
|
|
59
|
+
try:
|
|
60
|
+
parsed = json.loads(raw)
|
|
61
|
+
if isinstance(parsed, dict) and parsed:
|
|
62
|
+
cfg.task_specs = parsed
|
|
63
|
+
return cfg
|
|
64
|
+
except (ValueError, TypeError):
|
|
65
|
+
pass
|
|
66
|
+
# Otherwise, an opt-in named profile. ZNYX_INFERENCE_PROFILE=heavy loads the
|
|
67
|
+
# canonical ML task catalog (classifier/nli/embedding/guard_llm pins). The boot
|
|
68
|
+
# default stays "stub" so the service runs with zero ML deps unless asked.
|
|
69
|
+
if os.getenv("ZNYX_INFERENCE_PROFILE", "stub").lower() == "heavy":
|
|
70
|
+
from znyx_core.engine.ml_catalog import inference_task_specs
|
|
71
|
+
specs = inference_task_specs()
|
|
72
|
+
if specs:
|
|
73
|
+
cfg.task_specs = specs
|
|
74
|
+
return cfg
|
|
75
|
+
|
|
76
|
+
def artifact_path(self, *parts: str) -> str:
|
|
77
|
+
base = self.model_artifacts_dir or str(Path.home() / ".znyx" / "models")
|
|
78
|
+
return str(Path(base, *parts))
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""The inference scoring contract — the request + response shapes the
|
|
2
|
+
extended RemoteDetector speaks. Kept dependency-free (pydantic only)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, Field, field_validator, model_validator
|
|
8
|
+
|
|
9
|
+
# Canonical decision set — the inference contract speaks the same vocabulary as the
|
|
10
|
+
# rest of ZNYX (single source of truth), so a runner can't emit an unknown decision.
|
|
11
|
+
from znyx_core.core.models import Decision
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class InferRequest(BaseModel):
|
|
15
|
+
"""One scoring request. Provide EXACTLY ONE non-empty input form: ``text`` for a
|
|
16
|
+
single item OR ``texts`` for an explicit batch (a 422 otherwise)."""
|
|
17
|
+
text: Optional[str] = None
|
|
18
|
+
texts: Optional[List[str]] = None
|
|
19
|
+
model_id: Optional[str] = None # optional pin assertion (must match the loaded model)
|
|
20
|
+
revision: Optional[str] = None
|
|
21
|
+
|
|
22
|
+
@model_validator(mode="after")
|
|
23
|
+
def _validate(self):
|
|
24
|
+
has_text, has_texts = self.text is not None, self.texts is not None
|
|
25
|
+
if has_text == has_texts:
|
|
26
|
+
raise ValueError("provide exactly one of 'text' or 'texts'")
|
|
27
|
+
if has_text and not (self.text or "").strip():
|
|
28
|
+
raise ValueError("'text' must be non-empty")
|
|
29
|
+
if has_texts and (not self.texts or any(not (t or "").strip() for t in self.texts)):
|
|
30
|
+
raise ValueError("'texts' must be a non-empty list of non-empty strings")
|
|
31
|
+
# A revision pin is meaningless without the model it pins — reject it (else a
|
|
32
|
+
# revision-only pin would be silently ignored, scoring against the wrong model).
|
|
33
|
+
if self.revision is not None and not self.model_id:
|
|
34
|
+
raise ValueError("'revision' pin requires 'model_id'")
|
|
35
|
+
return self
|
|
36
|
+
|
|
37
|
+
def items(self) -> List[str]:
|
|
38
|
+
if self.texts is not None:
|
|
39
|
+
return list(self.texts)
|
|
40
|
+
return [self.text or ""]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class InferResult(BaseModel):
|
|
44
|
+
"""The confidence contract for a single scored item."""
|
|
45
|
+
decision: Decision # canonical: ALLOW|WARN|BLOCK|REDACT|TRANSFORM
|
|
46
|
+
risk_score: int = Field(ge=0, le=100)
|
|
47
|
+
confidence: Optional[float] = Field(default=None, ge=0.0, le=1.0)
|
|
48
|
+
label_scores: Optional[Dict[str, float]] = None
|
|
49
|
+
calibrated_score: Optional[float] = Field(default=None, ge=0.0, le=1.0)
|
|
50
|
+
threshold: Optional[float] = None
|
|
51
|
+
model_version: str # model_id@revision (stable)
|
|
52
|
+
|
|
53
|
+
@field_validator("label_scores")
|
|
54
|
+
@classmethod
|
|
55
|
+
def _label_scores_in_unit_range(cls, v):
|
|
56
|
+
if v is not None:
|
|
57
|
+
for key, val in v.items():
|
|
58
|
+
if not (0.0 <= float(val) <= 1.0):
|
|
59
|
+
raise ValueError(f"label score for '{key}' must be in [0, 1]")
|
|
60
|
+
return v
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class InferResponse(InferResult):
|
|
64
|
+
"""Single-item response = the contract + the service-measured latency."""
|
|
65
|
+
latency_ms: int = 0
|
|
66
|
+
cached: bool = False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class BatchInferResponse(BaseModel):
|
|
70
|
+
results: List[InferResult]
|
|
71
|
+
latency_ms: int = 0
|
|
72
|
+
model_version: str
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ModelInfo(BaseModel):
|
|
76
|
+
task: str
|
|
77
|
+
model_version: str
|
|
78
|
+
runner: str # "stub" | "classifier" | "embedding" | "nli" | "guard_llm"
|
|
79
|
+
available: bool # False when heavy deps / artifacts are missing
|
|
80
|
+
model_id: Optional[str] = None
|
|
81
|
+
revision: Optional[str] = None
|
|
82
|
+
sha256: Optional[str] = None
|
|
83
|
+
detail: Optional[str] = None
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""ZNYX Inference Service — optional sidecar FastAPI app.
|
|
2
|
+
|
|
3
|
+
Endpoints:
|
|
4
|
+
POST /v1/infer/{task} → the confidence contract (cached + batched)
|
|
5
|
+
GET /healthz → liveness
|
|
6
|
+
GET /v1/models → registered models + availability (feeds the model registry)
|
|
7
|
+
GET /v1/stats → cache + batcher metrics (observability for cache-hit/batching)
|
|
8
|
+
|
|
9
|
+
Boots on the dependency-free StubRunner with no ML stack installed.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import time
|
|
15
|
+
from contextlib import asynccontextmanager
|
|
16
|
+
|
|
17
|
+
from fastapi import FastAPI, HTTPException, Request
|
|
18
|
+
|
|
19
|
+
from znyx_inference.batching import Saturated
|
|
20
|
+
from znyx_inference.cache import ContentHashCache, content_key
|
|
21
|
+
from znyx_inference.config import InferenceConfig
|
|
22
|
+
from znyx_inference.contract import (
|
|
23
|
+
BatchInferResponse,
|
|
24
|
+
InferRequest,
|
|
25
|
+
InferResponse,
|
|
26
|
+
InferResult,
|
|
27
|
+
)
|
|
28
|
+
from znyx_inference.registry import RunnerRegistry
|
|
29
|
+
|
|
30
|
+
logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@asynccontextmanager
|
|
34
|
+
async def lifespan(app: FastAPI):
|
|
35
|
+
config = InferenceConfig.from_env()
|
|
36
|
+
registry = RunnerRegistry(config)
|
|
37
|
+
await registry.start_all()
|
|
38
|
+
app.state.config = config
|
|
39
|
+
app.state.registry = registry
|
|
40
|
+
app.state.cache = ContentHashCache(maxsize=config.cache_maxsize)
|
|
41
|
+
available = [m.task for m in registry.list_models() if m.available]
|
|
42
|
+
logger.info("ZNYX Inference ready — available tasks: %s", available)
|
|
43
|
+
try:
|
|
44
|
+
yield
|
|
45
|
+
finally:
|
|
46
|
+
await registry.stop_all()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
app = FastAPI(title="ZNYX Inference Service", version="1.0.0", lifespan=lifespan)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _result(out, model_version: str) -> InferResult:
|
|
53
|
+
return InferResult(
|
|
54
|
+
decision=out.decision, risk_score=out.risk_score, confidence=out.confidence,
|
|
55
|
+
label_scores=out.label_scores, calibrated_score=out.calibrated_score,
|
|
56
|
+
threshold=out.threshold, model_version=model_version,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _assert_pin(req: InferRequest, model_version: str) -> None:
|
|
61
|
+
"""If the caller pinned model_id/revision, the loaded model MUST match (model
|
|
62
|
+
pinning) — otherwise the caller could be silently scored by the wrong model. The
|
|
63
|
+
loaded identity is ``model_id@revision``; compare each pinned component against it."""
|
|
64
|
+
if not req.model_id:
|
|
65
|
+
return
|
|
66
|
+
loaded_id, _, loaded_rev = model_version.partition("@")
|
|
67
|
+
if req.model_id != loaded_id or (req.revision is not None and req.revision != loaded_rev):
|
|
68
|
+
want = req.model_id + (f"@{req.revision}" if req.revision else "")
|
|
69
|
+
raise HTTPException(status_code=409, detail=(
|
|
70
|
+
f"model pin mismatch: requested {want}, loaded {model_version}"))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@app.get("/healthz")
|
|
74
|
+
async def healthz():
|
|
75
|
+
return {"status": "ok"}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.get("/v1/models")
|
|
79
|
+
async def list_models(request: Request):
|
|
80
|
+
return {"models": [m.model_dump() for m in request.app.state.registry.list_models()]}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@app.get("/v1/stats")
|
|
84
|
+
async def stats(request: Request):
|
|
85
|
+
return {
|
|
86
|
+
"cache": request.app.state.cache.stats(),
|
|
87
|
+
"batchers": request.app.state.registry.batcher_stats(),
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@app.post("/v1/infer/{task}")
|
|
92
|
+
async def infer(task: str, req: InferRequest, request: Request):
|
|
93
|
+
registry: RunnerRegistry = request.app.state.registry
|
|
94
|
+
cache: ContentHashCache = request.app.state.cache
|
|
95
|
+
batcher = registry.get(task)
|
|
96
|
+
if batcher is None:
|
|
97
|
+
raise HTTPException(status_code=503, detail=f"task '{task}' unavailable")
|
|
98
|
+
model_version = registry.model_version(task) or "unknown"
|
|
99
|
+
_assert_pin(req, model_version)
|
|
100
|
+
|
|
101
|
+
texts = req.items()
|
|
102
|
+
t0 = time.perf_counter()
|
|
103
|
+
|
|
104
|
+
# Explicit multi-item batch → no per-item cache, return BatchInferResponse.
|
|
105
|
+
if req.texts is not None:
|
|
106
|
+
try:
|
|
107
|
+
outs = [await batcher.submit(t) for t in texts]
|
|
108
|
+
except Saturated as exc:
|
|
109
|
+
raise HTTPException(status_code=429, detail=str(exc))
|
|
110
|
+
return BatchInferResponse(
|
|
111
|
+
results=[_result(o, model_version) for o in outs],
|
|
112
|
+
latency_ms=int((time.perf_counter() - t0) * 1000),
|
|
113
|
+
model_version=model_version,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# Single item → content-hash cache then batch.
|
|
117
|
+
text = texts[0]
|
|
118
|
+
key = content_key(model_version, text)
|
|
119
|
+
cached = cache.get(key)
|
|
120
|
+
if cached is not None:
|
|
121
|
+
resp = _result(cached, model_version).model_dump()
|
|
122
|
+
return InferResponse(**resp, latency_ms=int((time.perf_counter() - t0) * 1000), cached=True)
|
|
123
|
+
try:
|
|
124
|
+
out = await batcher.submit(text)
|
|
125
|
+
except Saturated as exc:
|
|
126
|
+
raise HTTPException(status_code=429, detail=str(exc))
|
|
127
|
+
cache.put(key, out)
|
|
128
|
+
resp = _result(out, model_version).model_dump()
|
|
129
|
+
return InferResponse(**resp, latency_ms=int((time.perf_counter() - t0) * 1000), cached=False)
|