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.
Files changed (27) hide show
  1. znyx_inference-1.0.0/PKG-INFO +81 -0
  2. znyx_inference-1.0.0/README.md +41 -0
  3. znyx_inference-1.0.0/pyproject.toml +75 -0
  4. znyx_inference-1.0.0/setup.cfg +4 -0
  5. znyx_inference-1.0.0/src/znyx_inference/__init__.py +11 -0
  6. znyx_inference-1.0.0/src/znyx_inference/batching.py +112 -0
  7. znyx_inference-1.0.0/src/znyx_inference/cache.py +62 -0
  8. znyx_inference-1.0.0/src/znyx_inference/config.py +78 -0
  9. znyx_inference-1.0.0/src/znyx_inference/contract.py +83 -0
  10. znyx_inference-1.0.0/src/znyx_inference/main.py +129 -0
  11. znyx_inference-1.0.0/src/znyx_inference/registry.py +115 -0
  12. znyx_inference-1.0.0/src/znyx_inference/runners/__init__.py +12 -0
  13. znyx_inference-1.0.0/src/znyx_inference/runners/_artifacts.py +64 -0
  14. znyx_inference-1.0.0/src/znyx_inference/runners/_fetch.py +165 -0
  15. znyx_inference-1.0.0/src/znyx_inference/runners/_heavy.py +178 -0
  16. znyx_inference-1.0.0/src/znyx_inference/runners/base.py +105 -0
  17. znyx_inference-1.0.0/src/znyx_inference/runners/classifier.py +42 -0
  18. znyx_inference-1.0.0/src/znyx_inference/runners/embedding.py +52 -0
  19. znyx_inference-1.0.0/src/znyx_inference/runners/guard_llm.py +65 -0
  20. znyx_inference-1.0.0/src/znyx_inference/runners/language.py +59 -0
  21. znyx_inference-1.0.0/src/znyx_inference/runners/ner.py +70 -0
  22. znyx_inference-1.0.0/src/znyx_inference/runners/nli.py +61 -0
  23. znyx_inference-1.0.0/src/znyx_inference.egg-info/PKG-INFO +81 -0
  24. znyx_inference-1.0.0/src/znyx_inference.egg-info/SOURCES.txt +25 -0
  25. znyx_inference-1.0.0/src/znyx_inference.egg-info/dependency_links.txt +1 -0
  26. znyx_inference-1.0.0/src/znyx_inference.egg-info/requires.txt +20 -0
  27. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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)