ylemis 0.1.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.
- ylemis-0.1.0/LICENSE +21 -0
- ylemis-0.1.0/PKG-INFO +106 -0
- ylemis-0.1.0/README.md +88 -0
- ylemis-0.1.0/pyproject.toml +29 -0
- ylemis-0.1.0/setup.cfg +4 -0
- ylemis-0.1.0/src/ylemis/__init__.py +20 -0
- ylemis-0.1.0/src/ylemis/_http.py +94 -0
- ylemis-0.1.0/src/ylemis/client.py +217 -0
- ylemis-0.1.0/src/ylemis/exceptions.py +73 -0
- ylemis-0.1.0/src/ylemis/models.py +197 -0
- ylemis-0.1.0/src/ylemis.egg-info/PKG-INFO +106 -0
- ylemis-0.1.0/src/ylemis.egg-info/SOURCES.txt +13 -0
- ylemis-0.1.0/src/ylemis.egg-info/dependency_links.txt +1 -0
- ylemis-0.1.0/src/ylemis.egg-info/top_level.txt +1 -0
- ylemis-0.1.0/tests/test_sdk.py +178 -0
ylemis-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ylemis (Pranshu)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
ylemis-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ylemis
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Ylemis Trust Platform: PII Shield, Injection Guard, GroundCheck — one client, one integration.
|
|
5
|
+
Author-email: Ylemis <pranshu.rs08@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://ylemis.com
|
|
8
|
+
Project-URL: Documentation, https://ylemis.com/docs
|
|
9
|
+
Keywords: pii,dpdp,guardrails,llm-security,india,aadhaar,prompt-injection,hallucination
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Security
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# ylemis — Python SDK for the Ylemis Trust Platform
|
|
20
|
+
|
|
21
|
+
One integration for all Ylemis guardrails: **PII Shield** (India-tuned PII detection/redaction,
|
|
22
|
+
98.68% F1, 0% false positives on the published benchmark), **Injection Guard**, and **GroundCheck**.
|
|
23
|
+
Zero dependencies — stdlib only.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install ylemis
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 30-second DPDP fix
|
|
30
|
+
|
|
31
|
+
You're piping customer data into an LLM. Aadhaar/PAN in a third-party model's logs is a
|
|
32
|
+
₹250-crore DPDP exposure. One line stops it at the door:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from ylemis import TrustEngine
|
|
36
|
+
|
|
37
|
+
engine = TrustEngine(keys={"pii-shield": "sk_live_..."})
|
|
38
|
+
|
|
39
|
+
safe_prompt = engine.check_input(user_text).redacted_text # PII never leaves your app
|
|
40
|
+
llm_response = call_your_llm(safe_prompt)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## The two hooks (full flow)
|
|
44
|
+
|
|
45
|
+
Guardrails belong at two points in the LLM lifecycle — before the prompt goes out, and after
|
|
46
|
+
the answer comes back:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
engine = TrustEngine(api_key="sk_live_...") # one key everywhere (or keys={...} per product)
|
|
50
|
+
|
|
51
|
+
inp = engine.check_input(prompt) # PII + injection, PRE-LLM
|
|
52
|
+
if inp.decision == "block":
|
|
53
|
+
... # your policy
|
|
54
|
+
response = call_your_llm(inp.redacted_text)
|
|
55
|
+
|
|
56
|
+
out = engine.check_output(response, docs=retrieved_docs) # PII + groundedness, POST-LLM
|
|
57
|
+
if out.decision == "allow":
|
|
58
|
+
return response
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`engine.scan(prompt=..., response=..., docs=...)` is batch/audit sugar over both hooks.
|
|
62
|
+
|
|
63
|
+
## Error handling — errors are honest
|
|
64
|
+
|
|
65
|
+
The API never masks infra errors as billing errors. The SDK encodes that contract as types:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from ylemis import QuotaExceeded, RateLimited, ServiceBusy, InvalidAPIKey
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
r = engine.pii.scan(text)
|
|
72
|
+
except QuotaExceeded: # 402 — genuinely out of quota, upgrade or wait for reset
|
|
73
|
+
...
|
|
74
|
+
except RateLimited: # 429 — slow down (see .retry_after)
|
|
75
|
+
...
|
|
76
|
+
except ServiceBusy: # 503 — transient; SDK already auto-retried per Retry-After
|
|
77
|
+
...
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Requests are metered only on success — a `ServiceBusy` retry never double-bills.
|
|
81
|
+
|
|
82
|
+
## Per-product access
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
engine.pii.scan(text, redaction_mode="mask") # mask | replace | drop | hash
|
|
86
|
+
engine.pii.redact(text)
|
|
87
|
+
engine.pii.usage()
|
|
88
|
+
engine.injection.score(text)
|
|
89
|
+
engine.groundcheck.check(response, source=[...])
|
|
90
|
+
engine.health() # no-auth liveness, all three services
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Products without a configured key are skipped by `check_*` (listed in `report.skipped`)
|
|
94
|
+
and raise `MissingKey` if called directly — so a PII-only key works fine today.
|
|
95
|
+
|
|
96
|
+
## Development status
|
|
97
|
+
|
|
98
|
+
All three adapters are exact, verified against the deployed services (2026-07-05),
|
|
99
|
+
including a live end-to-end run with a single key across all three products.
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
python -m unittest discover -s tests -v # offline, no keys needed
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The SDK is MIT-licensed client code. API access requires a Ylemis subscription and
|
|
106
|
+
key from [ylemis.com](https://ylemis.com).
|
ylemis-0.1.0/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# ylemis — Python SDK for the Ylemis Trust Platform
|
|
2
|
+
|
|
3
|
+
One integration for all Ylemis guardrails: **PII Shield** (India-tuned PII detection/redaction,
|
|
4
|
+
98.68% F1, 0% false positives on the published benchmark), **Injection Guard**, and **GroundCheck**.
|
|
5
|
+
Zero dependencies — stdlib only.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install ylemis
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 30-second DPDP fix
|
|
12
|
+
|
|
13
|
+
You're piping customer data into an LLM. Aadhaar/PAN in a third-party model's logs is a
|
|
14
|
+
₹250-crore DPDP exposure. One line stops it at the door:
|
|
15
|
+
|
|
16
|
+
```python
|
|
17
|
+
from ylemis import TrustEngine
|
|
18
|
+
|
|
19
|
+
engine = TrustEngine(keys={"pii-shield": "sk_live_..."})
|
|
20
|
+
|
|
21
|
+
safe_prompt = engine.check_input(user_text).redacted_text # PII never leaves your app
|
|
22
|
+
llm_response = call_your_llm(safe_prompt)
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## The two hooks (full flow)
|
|
26
|
+
|
|
27
|
+
Guardrails belong at two points in the LLM lifecycle — before the prompt goes out, and after
|
|
28
|
+
the answer comes back:
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
engine = TrustEngine(api_key="sk_live_...") # one key everywhere (or keys={...} per product)
|
|
32
|
+
|
|
33
|
+
inp = engine.check_input(prompt) # PII + injection, PRE-LLM
|
|
34
|
+
if inp.decision == "block":
|
|
35
|
+
... # your policy
|
|
36
|
+
response = call_your_llm(inp.redacted_text)
|
|
37
|
+
|
|
38
|
+
out = engine.check_output(response, docs=retrieved_docs) # PII + groundedness, POST-LLM
|
|
39
|
+
if out.decision == "allow":
|
|
40
|
+
return response
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`engine.scan(prompt=..., response=..., docs=...)` is batch/audit sugar over both hooks.
|
|
44
|
+
|
|
45
|
+
## Error handling — errors are honest
|
|
46
|
+
|
|
47
|
+
The API never masks infra errors as billing errors. The SDK encodes that contract as types:
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from ylemis import QuotaExceeded, RateLimited, ServiceBusy, InvalidAPIKey
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
r = engine.pii.scan(text)
|
|
54
|
+
except QuotaExceeded: # 402 — genuinely out of quota, upgrade or wait for reset
|
|
55
|
+
...
|
|
56
|
+
except RateLimited: # 429 — slow down (see .retry_after)
|
|
57
|
+
...
|
|
58
|
+
except ServiceBusy: # 503 — transient; SDK already auto-retried per Retry-After
|
|
59
|
+
...
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Requests are metered only on success — a `ServiceBusy` retry never double-bills.
|
|
63
|
+
|
|
64
|
+
## Per-product access
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
engine.pii.scan(text, redaction_mode="mask") # mask | replace | drop | hash
|
|
68
|
+
engine.pii.redact(text)
|
|
69
|
+
engine.pii.usage()
|
|
70
|
+
engine.injection.score(text)
|
|
71
|
+
engine.groundcheck.check(response, source=[...])
|
|
72
|
+
engine.health() # no-auth liveness, all three services
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Products without a configured key are skipped by `check_*` (listed in `report.skipped`)
|
|
76
|
+
and raise `MissingKey` if called directly — so a PII-only key works fine today.
|
|
77
|
+
|
|
78
|
+
## Development status
|
|
79
|
+
|
|
80
|
+
All three adapters are exact, verified against the deployed services (2026-07-05),
|
|
81
|
+
including a live end-to-end run with a single key across all three products.
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
python -m unittest discover -s tests -v # offline, no keys needed
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The SDK is MIT-licensed client code. API access requires a Ylemis subscription and
|
|
88
|
+
key from [ylemis.com](https://ylemis.com).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ylemis"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Official Python SDK for the Ylemis Trust Platform: PII Shield, Injection Guard, GroundCheck — one client, one integration."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" } # client code only — models/services stay proprietary; access requires an API key
|
|
12
|
+
authors = [{ name = "Ylemis", email = "pranshu.rs08@gmail.com" }]
|
|
13
|
+
keywords = ["pii", "dpdp", "guardrails", "llm-security", "india", "aadhaar", "prompt-injection", "hallucination"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Security",
|
|
19
|
+
]
|
|
20
|
+
# Zero runtime dependencies — stdlib only. This is deliberate: a guardrails SDK
|
|
21
|
+
# should not widen a customer's supply-chain surface.
|
|
22
|
+
dependencies = []
|
|
23
|
+
|
|
24
|
+
[project.urls]
|
|
25
|
+
Homepage = "https://ylemis.com"
|
|
26
|
+
Documentation = "https://ylemis.com/docs"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["src"]
|
ylemis-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Ylemis Trust Platform — official Python SDK (zero dependencies)."""
|
|
2
|
+
|
|
3
|
+
from .client import (GroundCheckClient, InjectionGuardClient, PIIShieldClient,
|
|
4
|
+
TrustEngine, DEFAULT_BASE_URL)
|
|
5
|
+
from .exceptions import (APIError, InvalidAPIKey, MissingKey, PayloadTooLarge,
|
|
6
|
+
QuotaExceeded, RateLimited, ServiceBusy, UpstreamTimeout,
|
|
7
|
+
YlemisError)
|
|
8
|
+
from .models import (CheckReport, Entity, GroundCheckResult, InjectionResult,
|
|
9
|
+
PIIResult, RedactResult, Usage, merge_decisions)
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"TrustEngine", "PIIShieldClient", "InjectionGuardClient", "GroundCheckClient",
|
|
15
|
+
"DEFAULT_BASE_URL",
|
|
16
|
+
"YlemisError", "MissingKey", "InvalidAPIKey", "QuotaExceeded", "PayloadTooLarge",
|
|
17
|
+
"RateLimited", "ServiceBusy", "UpstreamTimeout", "APIError",
|
|
18
|
+
"CheckReport", "PIIResult", "RedactResult", "Usage", "Entity",
|
|
19
|
+
"InjectionResult", "GroundCheckResult", "merge_decisions",
|
|
20
|
+
]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Stdlib-only HTTP transport with honest-error mapping and 503 auto-retry.
|
|
2
|
+
|
|
3
|
+
Retry policy: ONLY network errors and 503 ServiceBusy are retried (the API
|
|
4
|
+
guarantees 503 means the request was NOT metered — see store_pg.StoreUnavailable).
|
|
5
|
+
402/401/429 are never retried: they are truthful, actionable answers.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
import urllib.error
|
|
13
|
+
import urllib.request
|
|
14
|
+
|
|
15
|
+
from .exceptions import APIError, RateLimited, ServiceBusy, STATUS_MAP, YlemisError
|
|
16
|
+
|
|
17
|
+
_USER_AGENT = "ylemis-python/0.1.0"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _parse_retry_after(value: str | None, default: float = 1.0) -> float:
|
|
21
|
+
if not value:
|
|
22
|
+
return default
|
|
23
|
+
try:
|
|
24
|
+
return max(0.0, min(float(value), 10.0)) # cap: never sleep >10s per hop
|
|
25
|
+
except ValueError:
|
|
26
|
+
return default
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Transport:
|
|
30
|
+
def __init__(self, base_url: str, *, timeout: float = 15.0, max_retries: int = 2):
|
|
31
|
+
self.base_url = base_url.rstrip("/")
|
|
32
|
+
self.timeout = timeout
|
|
33
|
+
self.max_retries = max(0, int(max_retries))
|
|
34
|
+
|
|
35
|
+
# -- public ----------------------------------------------------------
|
|
36
|
+
def request(self, method: str, path: str, *, api_key: str | None = None,
|
|
37
|
+
payload: dict | None = None, product: str | None = None) -> dict:
|
|
38
|
+
attempt = 0
|
|
39
|
+
while True:
|
|
40
|
+
try:
|
|
41
|
+
return self._once(method, path, api_key=api_key, payload=payload,
|
|
42
|
+
product=product)
|
|
43
|
+
except ServiceBusy as exc:
|
|
44
|
+
if attempt >= self.max_retries:
|
|
45
|
+
raise
|
|
46
|
+
time.sleep(exc.retry_after or 1.0)
|
|
47
|
+
except YlemisError:
|
|
48
|
+
raise
|
|
49
|
+
except OSError as exc: # DNS/conn reset — one class of retryable
|
|
50
|
+
if attempt >= self.max_retries:
|
|
51
|
+
raise APIError(f"network error: {exc}", product=product) from exc
|
|
52
|
+
time.sleep(0.5 * (attempt + 1))
|
|
53
|
+
attempt += 1
|
|
54
|
+
|
|
55
|
+
# -- internals ---------------------------------------------------------
|
|
56
|
+
def _once(self, method: str, path: str, *, api_key: str | None,
|
|
57
|
+
payload: dict | None, product: str | None) -> dict:
|
|
58
|
+
url = f"{self.base_url}{path}"
|
|
59
|
+
headers = {"Accept": "application/json", "User-Agent": _USER_AGENT}
|
|
60
|
+
body = None
|
|
61
|
+
if payload is not None:
|
|
62
|
+
body = json.dumps(payload).encode("utf-8")
|
|
63
|
+
headers["Content-Type"] = "application/json"
|
|
64
|
+
if api_key:
|
|
65
|
+
headers["X-API-Key"] = api_key
|
|
66
|
+
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
67
|
+
try:
|
|
68
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
69
|
+
return self._decode(resp.read())
|
|
70
|
+
except urllib.error.HTTPError as err:
|
|
71
|
+
raise self._to_error(err, product) from None
|
|
72
|
+
|
|
73
|
+
@staticmethod
|
|
74
|
+
def _decode(raw: bytes) -> dict:
|
|
75
|
+
try:
|
|
76
|
+
data = json.loads(raw.decode("utf-8"))
|
|
77
|
+
except (ValueError, UnicodeDecodeError):
|
|
78
|
+
raise APIError("non-JSON response from API")
|
|
79
|
+
return data if isinstance(data, dict) else {"data": data}
|
|
80
|
+
|
|
81
|
+
@staticmethod
|
|
82
|
+
def _to_error(err: urllib.error.HTTPError, product: str | None) -> YlemisError:
|
|
83
|
+
status = err.code
|
|
84
|
+
try:
|
|
85
|
+
detail = json.loads(err.read().decode("utf-8"))
|
|
86
|
+
except Exception:
|
|
87
|
+
detail = None
|
|
88
|
+
message = (detail or {}).get("detail") if isinstance(detail, dict) else None
|
|
89
|
+
message = message or f"HTTP {status}"
|
|
90
|
+
cls = STATUS_MAP.get(status, APIError)
|
|
91
|
+
kwargs = {"status": status, "product": product, "detail": detail}
|
|
92
|
+
if cls in (ServiceBusy, RateLimited):
|
|
93
|
+
kwargs["retry_after"] = _parse_retry_after(err.headers.get("Retry-After"))
|
|
94
|
+
return cls(message, **kwargs)
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""Ylemis Trust Platform client.
|
|
2
|
+
|
|
3
|
+
Two hook points match the real LLM request lifecycle (input checks run BEFORE
|
|
4
|
+
the prompt reaches the model — that is the DPDP story), plus `.scan()` as
|
|
5
|
+
batch/audit sugar:
|
|
6
|
+
|
|
7
|
+
from ylemis import TrustEngine
|
|
8
|
+
engine = TrustEngine(keys={"pii-shield": "sk_live_..."}) # or api_key="..." for all
|
|
9
|
+
inp = engine.check_input(prompt) # PII + injection, pre-LLM
|
|
10
|
+
safe_prompt = inp.redacted_text # send THIS to the LLM
|
|
11
|
+
out = engine.check_output(response, docs=docs) # PII + groundedness, post-LLM
|
|
12
|
+
|
|
13
|
+
Keys: pass `api_key=` to use one key everywhere (forward-compatible with the
|
|
14
|
+
unified platform key), and/or `keys={product: key}` per product. Products with
|
|
15
|
+
no key are skipped by check_* (reported in `.skipped`) and raise MissingKey if
|
|
16
|
+
called directly.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
22
|
+
from typing import Dict, Iterable, Optional, Sequence
|
|
23
|
+
|
|
24
|
+
from ._http import Transport
|
|
25
|
+
from .exceptions import MissingKey
|
|
26
|
+
from .models import (CheckReport, GroundCheckResult, InjectionResult, PIIResult,
|
|
27
|
+
RedactResult, Usage, merge_decisions)
|
|
28
|
+
|
|
29
|
+
DEFAULT_BASE_URL = "https://api.ylemis.com"
|
|
30
|
+
PII, INJECTION, GROUNDCHECK = "pii-shield", "injection-guard", "groundcheck"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _ProductClient:
|
|
34
|
+
slug: str = ""
|
|
35
|
+
_health_path: str = "/health"
|
|
36
|
+
|
|
37
|
+
def __init__(self, transport: Transport, key: Optional[str]):
|
|
38
|
+
self._transport = transport
|
|
39
|
+
self._key = key
|
|
40
|
+
|
|
41
|
+
def _require_key(self) -> str:
|
|
42
|
+
if not self._key:
|
|
43
|
+
raise MissingKey(f"no API key configured for {self.slug}", product=self.slug)
|
|
44
|
+
return self._key
|
|
45
|
+
|
|
46
|
+
def _post(self, path: str, payload: dict) -> dict:
|
|
47
|
+
return self._transport.request("POST", f"/{self.slug}{path}",
|
|
48
|
+
api_key=self._require_key(), payload=payload,
|
|
49
|
+
product=self.slug)
|
|
50
|
+
|
|
51
|
+
def _get(self, path: str, *, auth: bool = True) -> dict:
|
|
52
|
+
return self._transport.request("GET", f"/{self.slug}{path}",
|
|
53
|
+
api_key=self._require_key() if auth else None,
|
|
54
|
+
product=self.slug)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def configured(self) -> bool:
|
|
58
|
+
return bool(self._key)
|
|
59
|
+
|
|
60
|
+
def health(self) -> dict:
|
|
61
|
+
"""No-auth liveness check."""
|
|
62
|
+
return self._get(self._health_path, auth=False)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class PIIShieldClient(_ProductClient):
|
|
66
|
+
"""Exact contract, verified against the deployed service source."""
|
|
67
|
+
|
|
68
|
+
slug = PII
|
|
69
|
+
|
|
70
|
+
def scan(self, text: str, *, redaction_mode: str = "mask") -> PIIResult:
|
|
71
|
+
return PIIResult.from_dict(self._post("/v1/scan", {"text": text, "redaction_mode": redaction_mode}))
|
|
72
|
+
|
|
73
|
+
def redact(self, text: str, *, redaction_mode: str = "mask") -> RedactResult:
|
|
74
|
+
return RedactResult.from_dict(self._post("/v1/redact", {"text": text, "redaction_mode": redaction_mode}))
|
|
75
|
+
|
|
76
|
+
def usage(self) -> Usage:
|
|
77
|
+
return Usage.from_dict(self._get("/v1/usage"))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class InjectionGuardClient(_ProductClient):
|
|
81
|
+
"""Exact contract (locked from the deployed service's API.md, 2026-07-04).
|
|
82
|
+
Limits: 100k chars/text; batch = max 32 texts, 200k chars total, metered per text."""
|
|
83
|
+
|
|
84
|
+
slug = INJECTION
|
|
85
|
+
|
|
86
|
+
def score(self, text: str, *, payload: Optional[dict] = None) -> InjectionResult:
|
|
87
|
+
return InjectionResult.from_dict(self._post("/v1/score", payload or {"text": text}))
|
|
88
|
+
|
|
89
|
+
def score_batch(self, texts: Sequence[str]) -> list[InjectionResult]:
|
|
90
|
+
data = self._post("/v1/batch", {"texts": list(texts)})
|
|
91
|
+
return [InjectionResult.from_dict(r) for r in data.get("results", [])]
|
|
92
|
+
|
|
93
|
+
def usage(self) -> Usage:
|
|
94
|
+
return Usage.from_dict(self._get("/v1/usage"))
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class GroundCheckClient(_ProductClient):
|
|
98
|
+
"""Exact contract (locked from the deployed service source, 2026-07-04).
|
|
99
|
+
Limits: source <=50k chars, answer <=10k, question <=2k; batch <=32 items."""
|
|
100
|
+
|
|
101
|
+
slug = GROUNDCHECK
|
|
102
|
+
_health_path = "/healthz"
|
|
103
|
+
|
|
104
|
+
def check(self, answer: str = "", source: "str | Sequence[str] | None" = None, *,
|
|
105
|
+
question: str = "", threshold: float = 0.5, granular: bool = False,
|
|
106
|
+
payload: Optional[dict] = None) -> GroundCheckResult:
|
|
107
|
+
"""Is `answer` supported by `source`? `source` may be one string or a
|
|
108
|
+
list of retrieved docs (joined with blank lines)."""
|
|
109
|
+
if payload is None:
|
|
110
|
+
if source is None:
|
|
111
|
+
raise ValueError("check() needs source= (the grounding text/docs)")
|
|
112
|
+
src = source if isinstance(source, str) else "\n\n".join(source)
|
|
113
|
+
payload = {"source": src, "answer": answer, "question": question,
|
|
114
|
+
"threshold": threshold, "granular": granular}
|
|
115
|
+
return GroundCheckResult.from_dict(self._post("/v1/check", payload))
|
|
116
|
+
|
|
117
|
+
def check_batch(self, items: Sequence[dict]) -> list[GroundCheckResult]:
|
|
118
|
+
"""items = list of {source, answer, question?, threshold?, granular?} (max 32)."""
|
|
119
|
+
data = self._post("/v1/check/batch", {"items": list(items)})
|
|
120
|
+
return [GroundCheckResult.from_dict(r) for r in data.get("results", [])]
|
|
121
|
+
|
|
122
|
+
def usage(self) -> Usage:
|
|
123
|
+
return Usage.from_dict(self._get("/v1/usage"))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class TrustEngine:
|
|
127
|
+
def __init__(self, api_key: Optional[str] = None, *,
|
|
128
|
+
keys: Optional[Dict[str, str]] = None,
|
|
129
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
130
|
+
timeout: float = 15.0, max_retries: int = 2):
|
|
131
|
+
resolved = {p: api_key for p in (PII, INJECTION, GROUNDCHECK)}
|
|
132
|
+
resolved.update(keys or {})
|
|
133
|
+
self._transport = Transport(base_url, timeout=timeout, max_retries=max_retries)
|
|
134
|
+
self.pii = PIIShieldClient(self._transport, resolved.get(PII))
|
|
135
|
+
self.injection = InjectionGuardClient(self._transport, resolved.get(INJECTION))
|
|
136
|
+
self.groundcheck = GroundCheckClient(self._transport, resolved.get(GROUNDCHECK))
|
|
137
|
+
|
|
138
|
+
def __repr__(self) -> str: # never leak keys
|
|
139
|
+
configured = [c.slug for c in (self.pii, self.injection, self.groundcheck) if c.configured]
|
|
140
|
+
return f"TrustEngine(products={configured})"
|
|
141
|
+
|
|
142
|
+
# -- hooks -------------------------------------------------------------
|
|
143
|
+
def check_input(self, prompt: str, *, checks: Iterable[str] = (PII, INJECTION),
|
|
144
|
+
redaction_mode: str = "mask") -> CheckReport:
|
|
145
|
+
"""Run pre-LLM guardrails on the outbound prompt. Use `.redacted_text`
|
|
146
|
+
as the safe prompt to actually send."""
|
|
147
|
+
checks = set(checks)
|
|
148
|
+
pii = inj = None
|
|
149
|
+
skipped: list[str] = []
|
|
150
|
+
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
151
|
+
futures = {}
|
|
152
|
+
if PII in checks:
|
|
153
|
+
if self.pii.configured:
|
|
154
|
+
futures[PII] = pool.submit(self.pii.scan, prompt, redaction_mode=redaction_mode)
|
|
155
|
+
else:
|
|
156
|
+
skipped.append(PII)
|
|
157
|
+
if INJECTION in checks:
|
|
158
|
+
if self.injection.configured:
|
|
159
|
+
futures[INJECTION] = pool.submit(self.injection.score, prompt)
|
|
160
|
+
else:
|
|
161
|
+
skipped.append(INJECTION)
|
|
162
|
+
pii = futures[PII].result() if PII in futures else None
|
|
163
|
+
inj = futures[INJECTION].result() if INJECTION in futures else None
|
|
164
|
+
decision = merge_decisions(pii.decision if pii else None, inj.decision if inj else None)
|
|
165
|
+
return CheckReport(decision=decision, pii=pii, injection=inj, skipped=skipped)
|
|
166
|
+
|
|
167
|
+
def check_output(self, response: str, docs: Optional[Sequence[str]] = None, *,
|
|
168
|
+
checks: Iterable[str] = (PII, GROUNDCHECK),
|
|
169
|
+
redaction_mode: str = "mask") -> CheckReport:
|
|
170
|
+
"""Run post-LLM guardrails on the model's answer (PII in the output;
|
|
171
|
+
groundedness against `docs` when provided)."""
|
|
172
|
+
checks = set(checks)
|
|
173
|
+
if docs is None:
|
|
174
|
+
checks.discard(GROUNDCHECK) # nothing to ground against
|
|
175
|
+
pii = gc = None
|
|
176
|
+
skipped: list[str] = []
|
|
177
|
+
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
178
|
+
futures = {}
|
|
179
|
+
if PII in checks:
|
|
180
|
+
if self.pii.configured:
|
|
181
|
+
futures[PII] = pool.submit(self.pii.scan, response, redaction_mode=redaction_mode)
|
|
182
|
+
else:
|
|
183
|
+
skipped.append(PII)
|
|
184
|
+
if GROUNDCHECK in checks:
|
|
185
|
+
if self.groundcheck.configured:
|
|
186
|
+
futures[GROUNDCHECK] = pool.submit(self.groundcheck.check, response, docs)
|
|
187
|
+
else:
|
|
188
|
+
skipped.append(GROUNDCHECK)
|
|
189
|
+
pii = futures[PII].result() if PII in futures else None
|
|
190
|
+
gc = futures[GROUNDCHECK].result() if GROUNDCHECK in futures else None
|
|
191
|
+
decision = merge_decisions(pii.decision if pii else None, gc.decision if gc else None)
|
|
192
|
+
return CheckReport(decision=decision, pii=pii, groundcheck=gc, skipped=skipped)
|
|
193
|
+
|
|
194
|
+
# -- audit sugar ---------------------------------------------------------
|
|
195
|
+
def scan(self, *, prompt: Optional[str] = None, response: Optional[str] = None,
|
|
196
|
+
docs: Optional[Sequence[str]] = None) -> CheckReport:
|
|
197
|
+
"""Offline/batch convenience: checks whatever you pass, merges decisions.
|
|
198
|
+
For live traffic prefer check_input()/check_output() at their hook points."""
|
|
199
|
+
reports = []
|
|
200
|
+
if prompt is not None:
|
|
201
|
+
reports.append(self.check_input(prompt))
|
|
202
|
+
if response is not None:
|
|
203
|
+
reports.append(self.check_output(response, docs))
|
|
204
|
+
if not reports:
|
|
205
|
+
raise ValueError("scan() needs prompt= and/or response=")
|
|
206
|
+
merged = CheckReport(
|
|
207
|
+
decision=merge_decisions(*(r.decision for r in reports)),
|
|
208
|
+
pii=next((r.pii for r in reports if r.pii), None),
|
|
209
|
+
injection=next((r.injection for r in reports if r.injection), None),
|
|
210
|
+
groundcheck=next((r.groundcheck for r in reports if r.groundcheck), None),
|
|
211
|
+
skipped=sorted({s for r in reports for s in r.skipped}),
|
|
212
|
+
)
|
|
213
|
+
return merged
|
|
214
|
+
|
|
215
|
+
def health(self) -> Dict[str, dict]:
|
|
216
|
+
"""No-auth liveness of all three services."""
|
|
217
|
+
return {c.slug: c.health() for c in (self.pii, self.injection, self.groundcheck)}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Typed errors encoding the Ylemis API error semantics.
|
|
2
|
+
|
|
3
|
+
Contract (NEXT_AGENT_HANDOFF_2026-07-04.md §5.6):
|
|
4
|
+
401 = bad/revoked key -> InvalidAPIKey
|
|
5
|
+
402 = GENUINE quota exhaustion -> QuotaExceeded (never masked infra errors)
|
|
6
|
+
413 = payload too large -> PayloadTooLarge
|
|
7
|
+
429 = rate limit / lockout -> RateLimited
|
|
8
|
+
503 = retryable infra hiccup -> ServiceBusy (transport auto-retries, honoring Retry-After)
|
|
9
|
+
504 = request timeout -> UpstreamTimeout
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class YlemisError(Exception):
|
|
16
|
+
"""Base for all SDK errors."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, message: str, *, status: int | None = None,
|
|
19
|
+
product: str | None = None, detail: object = None):
|
|
20
|
+
super().__init__(message)
|
|
21
|
+
self.status = status
|
|
22
|
+
self.product = product
|
|
23
|
+
self.detail = detail
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class MissingKey(YlemisError):
|
|
27
|
+
"""No API key configured for the product being called."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class InvalidAPIKey(YlemisError):
|
|
31
|
+
"""401 — key is missing, malformed, revoked, or scoped to another product."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class QuotaExceeded(YlemisError):
|
|
35
|
+
"""402 — the plan's quota for this billing period is genuinely exhausted."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class PayloadTooLarge(YlemisError):
|
|
39
|
+
"""413 — request body exceeds the service limit."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RateLimited(YlemisError):
|
|
43
|
+
"""429 — per-key/per-IP rate limit or temporary auth-failure lockout."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, message: str, *, retry_after: float | None = None, **kw):
|
|
46
|
+
super().__init__(message, **kw)
|
|
47
|
+
self.retry_after = retry_after
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ServiceBusy(YlemisError):
|
|
51
|
+
"""503 — transient infra/DB issue. The SDK already retried before raising this."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, message: str, *, retry_after: float | None = None, **kw):
|
|
54
|
+
super().__init__(message, **kw)
|
|
55
|
+
self.retry_after = retry_after
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class UpstreamTimeout(YlemisError):
|
|
59
|
+
"""504 — the service timed out processing the request."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class APIError(YlemisError):
|
|
63
|
+
"""Any other non-2xx response."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
STATUS_MAP = {
|
|
67
|
+
401: InvalidAPIKey,
|
|
68
|
+
402: QuotaExceeded,
|
|
69
|
+
413: PayloadTooLarge,
|
|
70
|
+
429: RateLimited,
|
|
71
|
+
503: ServiceBusy,
|
|
72
|
+
504: UpstreamTimeout,
|
|
73
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Result types. Every wrapper keeps the full server payload in `.raw`.
|
|
2
|
+
|
|
3
|
+
PII Shield shapes are exact (verified against the deployed service source).
|
|
4
|
+
Injection Guard / GroundCheck payloads are PROVISIONAL passthroughs until one
|
|
5
|
+
live probe locks their field names — decisions for them are derived defensively.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
Decision = str # "allow" | "review" | "block"
|
|
14
|
+
_DECISION_RANK = {"allow": 0, "review": 1, "block": 2}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def merge_decisions(*decisions: Optional[Decision]) -> Decision:
|
|
18
|
+
"""Most-restrictive-wins merge; unknown/None values are ignored."""
|
|
19
|
+
best = "allow"
|
|
20
|
+
for d in decisions:
|
|
21
|
+
if d in _DECISION_RANK and _DECISION_RANK[d] > _DECISION_RANK[best]:
|
|
22
|
+
best = d
|
|
23
|
+
return best
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class Entity:
|
|
28
|
+
"""One detected PII span (positions refer to the original text)."""
|
|
29
|
+
|
|
30
|
+
type: str
|
|
31
|
+
start: int
|
|
32
|
+
end: int
|
|
33
|
+
text_preview: str
|
|
34
|
+
confidence: float
|
|
35
|
+
severity: str
|
|
36
|
+
source: str = ""
|
|
37
|
+
method: str = ""
|
|
38
|
+
recognizer: str = ""
|
|
39
|
+
format_validated: bool = False
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def from_dict(cls, d: Dict[str, Any]) -> "Entity":
|
|
43
|
+
return cls(
|
|
44
|
+
type=d.get("type", ""), start=int(d.get("start", 0)), end=int(d.get("end", 0)),
|
|
45
|
+
text_preview=d.get("text_preview", ""), confidence=float(d.get("confidence", 0.0)),
|
|
46
|
+
severity=str(d.get("severity", "")), source=d.get("source", ""),
|
|
47
|
+
method=d.get("method", ""), recognizer=d.get("recognizer", ""),
|
|
48
|
+
format_validated=bool(d.get("format_validated", False)),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class PIIResult:
|
|
54
|
+
decision: Decision
|
|
55
|
+
risk_level: str
|
|
56
|
+
entities: List[Entity]
|
|
57
|
+
redacted_text: str
|
|
58
|
+
counts: Dict[str, int]
|
|
59
|
+
latency_ms: float
|
|
60
|
+
model_version: str
|
|
61
|
+
quota_remaining: Optional[int]
|
|
62
|
+
raw: Dict[str, Any] = field(repr=False, default_factory=dict)
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def has_pii(self) -> bool:
|
|
66
|
+
return bool(self.entities)
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_dict(cls, d: Dict[str, Any]) -> "PIIResult":
|
|
70
|
+
return cls(
|
|
71
|
+
decision=d.get("decision", "allow"),
|
|
72
|
+
risk_level=d.get("risk_level", ""),
|
|
73
|
+
entities=[Entity.from_dict(e) for e in d.get("entities", [])],
|
|
74
|
+
redacted_text=d.get("redacted_text", ""),
|
|
75
|
+
counts=dict(d.get("counts", {})),
|
|
76
|
+
latency_ms=float(d.get("latency_ms", 0.0)),
|
|
77
|
+
model_version=str(d.get("model_version", "")),
|
|
78
|
+
quota_remaining=d.get("quota_remaining"),
|
|
79
|
+
raw=d,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class RedactResult:
|
|
85
|
+
redacted_text: str
|
|
86
|
+
counts: Dict[str, int]
|
|
87
|
+
risk_level: str
|
|
88
|
+
quota_remaining: Optional[int]
|
|
89
|
+
model_version: str
|
|
90
|
+
raw: Dict[str, Any] = field(repr=False, default_factory=dict)
|
|
91
|
+
|
|
92
|
+
@classmethod
|
|
93
|
+
def from_dict(cls, d: Dict[str, Any]) -> "RedactResult":
|
|
94
|
+
return cls(
|
|
95
|
+
redacted_text=d.get("redacted_text", ""), counts=dict(d.get("counts", {})),
|
|
96
|
+
risk_level=d.get("risk_level", ""), quota_remaining=d.get("quota_remaining"),
|
|
97
|
+
model_version=str(d.get("model_version", "")), raw=d,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(frozen=True)
|
|
102
|
+
class Usage:
|
|
103
|
+
plan: str
|
|
104
|
+
quota: int # -1 = unlimited
|
|
105
|
+
used: int
|
|
106
|
+
unlimited: bool
|
|
107
|
+
raw: Dict[str, Any] = field(repr=False, default_factory=dict)
|
|
108
|
+
|
|
109
|
+
@classmethod
|
|
110
|
+
def from_dict(cls, d: Dict[str, Any]) -> "Usage":
|
|
111
|
+
return cls(plan=d.get("plan", ""), quota=int(d.get("quota", 0)),
|
|
112
|
+
used=int(d.get("used", 0)), unlimited=bool(d.get("unlimited", False)), raw=d)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _derive_decision(raw: Dict[str, Any]) -> Decision:
|
|
116
|
+
"""Defensive decision extraction for services whose schema isn't locked yet.
|
|
117
|
+
|
|
118
|
+
Order: explicit `decision` field > boolean block-ish flags > allow.
|
|
119
|
+
Deliberately does NOT invent numeric thresholds — that's server/policy work.
|
|
120
|
+
"""
|
|
121
|
+
d = raw.get("decision")
|
|
122
|
+
if d in _DECISION_RANK:
|
|
123
|
+
return d
|
|
124
|
+
for flag in ("blocked", "block", "flagged", "injection_detected", "is_injection"):
|
|
125
|
+
v = raw.get(flag)
|
|
126
|
+
if isinstance(v, bool):
|
|
127
|
+
return "block" if v else "allow"
|
|
128
|
+
verdict = raw.get("verdict") or raw.get("label")
|
|
129
|
+
if isinstance(verdict, str) and verdict.lower() in ("malicious", "injection", "unsafe", "ungrounded", "hallucinated"):
|
|
130
|
+
return "review"
|
|
131
|
+
return "allow"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class InjectionResult:
|
|
136
|
+
"""LOCKED to the deployed Injection Guard schema (verified against the
|
|
137
|
+
service's API.md pulled from the VPS, 2026-07-04)."""
|
|
138
|
+
|
|
139
|
+
decision: Decision # "allow" | "review" | "block" (server-native)
|
|
140
|
+
score: float = 0.0 # 0-1 probability of injection
|
|
141
|
+
reason: str = "" # "model" | "trivial_safe"
|
|
142
|
+
obfuscation: bool = False # homoglyph/base64/zero-width evasion detected
|
|
143
|
+
quota_remaining: Optional[int] = None
|
|
144
|
+
raw: Dict[str, Any] = field(repr=False, default_factory=dict)
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def from_dict(cls, d: Dict[str, Any]) -> "InjectionResult":
|
|
148
|
+
return cls(decision=_derive_decision(d), score=float(d.get("score", 0.0)),
|
|
149
|
+
reason=str(d.get("reason", "")),
|
|
150
|
+
obfuscation=bool(d.get("obfuscation", False)),
|
|
151
|
+
quota_remaining=d.get("quota_remaining"), raw=d)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@dataclass(frozen=True)
|
|
155
|
+
class GroundCheckResult:
|
|
156
|
+
"""LOCKED to the deployed GroundCheck Pro schema (app/schemas.py + engine.py,
|
|
157
|
+
verified 2026-07-04). `decision` is SDK-derived: hallucinated -> "review"
|
|
158
|
+
(grounding failure is a review signal, not a hard block, by default)."""
|
|
159
|
+
|
|
160
|
+
decision: Decision
|
|
161
|
+
label: str = "" # "grounded" | "hallucinated"
|
|
162
|
+
p_grounded: float = 0.0 # 0-1 probability the answer is supported
|
|
163
|
+
threshold: float = 0.5
|
|
164
|
+
engine: str = "" # "model" | "lexical" (degraded mode)
|
|
165
|
+
sentences: List[Dict[str, Any]] = field(default_factory=list) # granular=True only
|
|
166
|
+
quota_remaining: Optional[int] = None
|
|
167
|
+
raw: Dict[str, Any] = field(repr=False, default_factory=dict)
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def grounded(self) -> bool:
|
|
171
|
+
return self.label == "grounded"
|
|
172
|
+
|
|
173
|
+
@classmethod
|
|
174
|
+
def from_dict(cls, d: Dict[str, Any]) -> "GroundCheckResult":
|
|
175
|
+
label = str(d.get("label", ""))
|
|
176
|
+
return cls(decision="review" if label == "hallucinated" else "allow",
|
|
177
|
+
label=label, p_grounded=float(d.get("p_grounded", 0.0)),
|
|
178
|
+
threshold=float(d.get("threshold", 0.5)), engine=str(d.get("engine", "")),
|
|
179
|
+
sentences=list(d.get("sentences", [])),
|
|
180
|
+
quota_remaining=d.get("quota_remaining"), raw=d)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True)
|
|
184
|
+
class CheckReport:
|
|
185
|
+
"""Combined result of a multi-product check. `skipped` lists products that
|
|
186
|
+
had no API key configured and were therefore not consulted."""
|
|
187
|
+
|
|
188
|
+
decision: Decision
|
|
189
|
+
pii: Optional[PIIResult] = None
|
|
190
|
+
injection: Optional[InjectionResult] = None
|
|
191
|
+
groundcheck: Optional[GroundCheckResult] = None
|
|
192
|
+
skipped: List[str] = field(default_factory=list)
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def redacted_text(self) -> Optional[str]:
|
|
196
|
+
"""The PII-safe text (None if PII check was skipped)."""
|
|
197
|
+
return self.pii.redacted_text if self.pii else None
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ylemis
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Official Python SDK for the Ylemis Trust Platform: PII Shield, Injection Guard, GroundCheck — one client, one integration.
|
|
5
|
+
Author-email: Ylemis <pranshu.rs08@gmail.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://ylemis.com
|
|
8
|
+
Project-URL: Documentation, https://ylemis.com/docs
|
|
9
|
+
Keywords: pii,dpdp,guardrails,llm-security,india,aadhaar,prompt-injection,hallucination
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Security
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Dynamic: license-file
|
|
18
|
+
|
|
19
|
+
# ylemis — Python SDK for the Ylemis Trust Platform
|
|
20
|
+
|
|
21
|
+
One integration for all Ylemis guardrails: **PII Shield** (India-tuned PII detection/redaction,
|
|
22
|
+
98.68% F1, 0% false positives on the published benchmark), **Injection Guard**, and **GroundCheck**.
|
|
23
|
+
Zero dependencies — stdlib only.
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install ylemis
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## 30-second DPDP fix
|
|
30
|
+
|
|
31
|
+
You're piping customer data into an LLM. Aadhaar/PAN in a third-party model's logs is a
|
|
32
|
+
₹250-crore DPDP exposure. One line stops it at the door:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from ylemis import TrustEngine
|
|
36
|
+
|
|
37
|
+
engine = TrustEngine(keys={"pii-shield": "sk_live_..."})
|
|
38
|
+
|
|
39
|
+
safe_prompt = engine.check_input(user_text).redacted_text # PII never leaves your app
|
|
40
|
+
llm_response = call_your_llm(safe_prompt)
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## The two hooks (full flow)
|
|
44
|
+
|
|
45
|
+
Guardrails belong at two points in the LLM lifecycle — before the prompt goes out, and after
|
|
46
|
+
the answer comes back:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
engine = TrustEngine(api_key="sk_live_...") # one key everywhere (or keys={...} per product)
|
|
50
|
+
|
|
51
|
+
inp = engine.check_input(prompt) # PII + injection, PRE-LLM
|
|
52
|
+
if inp.decision == "block":
|
|
53
|
+
... # your policy
|
|
54
|
+
response = call_your_llm(inp.redacted_text)
|
|
55
|
+
|
|
56
|
+
out = engine.check_output(response, docs=retrieved_docs) # PII + groundedness, POST-LLM
|
|
57
|
+
if out.decision == "allow":
|
|
58
|
+
return response
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`engine.scan(prompt=..., response=..., docs=...)` is batch/audit sugar over both hooks.
|
|
62
|
+
|
|
63
|
+
## Error handling — errors are honest
|
|
64
|
+
|
|
65
|
+
The API never masks infra errors as billing errors. The SDK encodes that contract as types:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from ylemis import QuotaExceeded, RateLimited, ServiceBusy, InvalidAPIKey
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
r = engine.pii.scan(text)
|
|
72
|
+
except QuotaExceeded: # 402 — genuinely out of quota, upgrade or wait for reset
|
|
73
|
+
...
|
|
74
|
+
except RateLimited: # 429 — slow down (see .retry_after)
|
|
75
|
+
...
|
|
76
|
+
except ServiceBusy: # 503 — transient; SDK already auto-retried per Retry-After
|
|
77
|
+
...
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Requests are metered only on success — a `ServiceBusy` retry never double-bills.
|
|
81
|
+
|
|
82
|
+
## Per-product access
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
engine.pii.scan(text, redaction_mode="mask") # mask | replace | drop | hash
|
|
86
|
+
engine.pii.redact(text)
|
|
87
|
+
engine.pii.usage()
|
|
88
|
+
engine.injection.score(text)
|
|
89
|
+
engine.groundcheck.check(response, source=[...])
|
|
90
|
+
engine.health() # no-auth liveness, all three services
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Products without a configured key are skipped by `check_*` (listed in `report.skipped`)
|
|
94
|
+
and raise `MissingKey` if called directly — so a PII-only key works fine today.
|
|
95
|
+
|
|
96
|
+
## Development status
|
|
97
|
+
|
|
98
|
+
All three adapters are exact, verified against the deployed services (2026-07-05),
|
|
99
|
+
including a live end-to-end run with a single key across all three products.
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
python -m unittest discover -s tests -v # offline, no keys needed
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The SDK is MIT-licensed client code. API access requires a Ylemis subscription and
|
|
106
|
+
key from [ylemis.com](https://ylemis.com).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/ylemis/__init__.py
|
|
5
|
+
src/ylemis/_http.py
|
|
6
|
+
src/ylemis/client.py
|
|
7
|
+
src/ylemis/exceptions.py
|
|
8
|
+
src/ylemis/models.py
|
|
9
|
+
src/ylemis.egg-info/PKG-INFO
|
|
10
|
+
src/ylemis.egg-info/SOURCES.txt
|
|
11
|
+
src/ylemis.egg-info/dependency_links.txt
|
|
12
|
+
src/ylemis.egg-info/top_level.txt
|
|
13
|
+
tests/test_sdk.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ylemis
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Offline unit tests — a fake Transport is injected; no network, no keys."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import unittest
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
|
10
|
+
|
|
11
|
+
from ylemis import (InvalidAPIKey, MissingKey, QuotaExceeded, ServiceBusy,
|
|
12
|
+
TrustEngine, merge_decisions)
|
|
13
|
+
from ylemis._http import Transport
|
|
14
|
+
from ylemis.exceptions import STATUS_MAP
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
PII_SCAN_RESPONSE = {
|
|
18
|
+
"decision": "block",
|
|
19
|
+
"risk_level": "critical",
|
|
20
|
+
"entities": [{
|
|
21
|
+
"type": "AADHAAR", "start": 20, "end": 34, "text_preview": "XXXX-…-9012",
|
|
22
|
+
"confidence": 1.0, "source": "deterministic", "method": "deterministic",
|
|
23
|
+
"recognizer": "aadhaar", "format_validated": True, "severity": "critical",
|
|
24
|
+
}],
|
|
25
|
+
"redacted_text": "my aadhaar card is [AADHAAR]",
|
|
26
|
+
"counts": {"AADHAAR": 1},
|
|
27
|
+
"latency_ms": 9.4,
|
|
28
|
+
"model_version": "1.0",
|
|
29
|
+
"hybrid": True,
|
|
30
|
+
"quota_remaining": 41,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FakeTransport(Transport):
|
|
35
|
+
"""Records requests; replays canned responses/exceptions per (method, path)."""
|
|
36
|
+
|
|
37
|
+
def __init__(self):
|
|
38
|
+
super().__init__("https://api.test")
|
|
39
|
+
self.calls = []
|
|
40
|
+
self.responses = {}
|
|
41
|
+
|
|
42
|
+
def request(self, method, path, *, api_key=None, payload=None, product=None):
|
|
43
|
+
self.calls.append({"method": method, "path": path, "api_key": api_key,
|
|
44
|
+
"payload": payload, "product": product})
|
|
45
|
+
result = self.responses[(method, path)]
|
|
46
|
+
if isinstance(result, Exception):
|
|
47
|
+
raise result
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def engine_with_fake(**kw) -> tuple[TrustEngine, FakeTransport]:
|
|
52
|
+
engine = TrustEngine(**kw)
|
|
53
|
+
fake = FakeTransport()
|
|
54
|
+
engine._transport = fake
|
|
55
|
+
for client in (engine.pii, engine.injection, engine.groundcheck):
|
|
56
|
+
client._transport = fake
|
|
57
|
+
return engine, fake
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class TestKeyRouting(unittest.TestCase):
|
|
61
|
+
def test_single_key_used_for_all_products(self):
|
|
62
|
+
engine, fake = engine_with_fake(api_key="sk_live_one")
|
|
63
|
+
fake.responses[("POST", "/pii-shield/v1/scan")] = PII_SCAN_RESPONSE
|
|
64
|
+
fake.responses[("POST", "/injection-guard/v1/score")] = {"decision": "allow"}
|
|
65
|
+
engine.check_input("hello")
|
|
66
|
+
self.assertEqual({c["api_key"] for c in fake.calls}, {"sk_live_one"})
|
|
67
|
+
|
|
68
|
+
def test_per_product_keys_override(self):
|
|
69
|
+
engine, fake = engine_with_fake(api_key="sk_live_default",
|
|
70
|
+
keys={"injection-guard": "sk_live_inj"})
|
|
71
|
+
fake.responses[("POST", "/injection-guard/v1/score")] = {"decision": "allow"}
|
|
72
|
+
engine.injection.score("hi")
|
|
73
|
+
self.assertEqual(fake.calls[0]["api_key"], "sk_live_inj")
|
|
74
|
+
|
|
75
|
+
def test_missing_key_raises_and_check_skips(self):
|
|
76
|
+
engine, fake = engine_with_fake(keys={"pii-shield": "sk_live_p"})
|
|
77
|
+
with self.assertRaises(MissingKey):
|
|
78
|
+
engine.injection.score("hi")
|
|
79
|
+
fake.responses[("POST", "/pii-shield/v1/scan")] = PII_SCAN_RESPONSE
|
|
80
|
+
report = engine.check_input("hi") # injection not configured -> skipped
|
|
81
|
+
self.assertEqual(report.skipped, ["injection-guard"])
|
|
82
|
+
self.assertEqual(report.decision, "block") # PII decision still applies
|
|
83
|
+
|
|
84
|
+
def test_repr_never_leaks_keys(self):
|
|
85
|
+
engine, _ = engine_with_fake(api_key="sk_live_secret")
|
|
86
|
+
self.assertNotIn("sk_live_secret", repr(engine))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class TestPIIContract(unittest.TestCase):
|
|
90
|
+
def test_scan_parses_exact_shape(self):
|
|
91
|
+
engine, fake = engine_with_fake(api_key="k")
|
|
92
|
+
fake.responses[("POST", "/pii-shield/v1/scan")] = PII_SCAN_RESPONSE
|
|
93
|
+
r = engine.pii.scan("my aadhaar card is 2345 6789 9012")
|
|
94
|
+
self.assertTrue(r.has_pii)
|
|
95
|
+
self.assertEqual(r.entities[0].type, "AADHAAR")
|
|
96
|
+
self.assertTrue(r.entities[0].format_validated)
|
|
97
|
+
self.assertEqual(r.quota_remaining, 41)
|
|
98
|
+
self.assertEqual(r.raw["hybrid"], True)
|
|
99
|
+
self.assertEqual(fake.calls[0]["payload"],
|
|
100
|
+
{"text": "my aadhaar card is 2345 6789 9012", "redaction_mode": "mask"})
|
|
101
|
+
|
|
102
|
+
def test_usage(self):
|
|
103
|
+
engine, fake = engine_with_fake(api_key="k")
|
|
104
|
+
fake.responses[("GET", "/pii-shield/v1/usage")] = {
|
|
105
|
+
"plan": "pro", "quota": 100000, "used": 1234, "unlimited": False}
|
|
106
|
+
u = engine.pii.usage()
|
|
107
|
+
self.assertEqual((u.plan, u.used), ("pro", 1234))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TestDecisions(unittest.TestCase):
|
|
111
|
+
def test_merge_most_restrictive_wins(self):
|
|
112
|
+
self.assertEqual(merge_decisions("allow", "review", "block"), "block")
|
|
113
|
+
self.assertEqual(merge_decisions("allow", None, "review"), "review")
|
|
114
|
+
self.assertEqual(merge_decisions(), "allow")
|
|
115
|
+
|
|
116
|
+
def test_check_output_skips_groundcheck_without_docs(self):
|
|
117
|
+
engine, fake = engine_with_fake(api_key="k")
|
|
118
|
+
fake.responses[("POST", "/pii-shield/v1/scan")] = PII_SCAN_RESPONSE
|
|
119
|
+
report = engine.check_output("some model answer") # no docs
|
|
120
|
+
self.assertIsNone(report.groundcheck)
|
|
121
|
+
self.assertNotIn("groundcheck", report.skipped) # not skipped-for-key; simply N/A
|
|
122
|
+
|
|
123
|
+
def test_scan_sugar_merges_both_hooks(self):
|
|
124
|
+
engine, fake = engine_with_fake(api_key="k")
|
|
125
|
+
fake.responses[("POST", "/pii-shield/v1/scan")] = PII_SCAN_RESPONSE
|
|
126
|
+
fake.responses[("POST", "/injection-guard/v1/score")] = {"decision": "allow", "score": 0.01}
|
|
127
|
+
fake.responses[("POST", "/groundcheck/v1/check")] = {"label": "hallucinated", "p_grounded": 0.2}
|
|
128
|
+
report = engine.scan(prompt="p", response="r", docs=["d"])
|
|
129
|
+
self.assertEqual(report.decision, "block")
|
|
130
|
+
self.assertIsNotNone(report.groundcheck)
|
|
131
|
+
self.assertEqual(report.groundcheck.decision, "review")
|
|
132
|
+
|
|
133
|
+
def test_groundcheck_locked_request_shape(self):
|
|
134
|
+
engine, fake = engine_with_fake(api_key="k")
|
|
135
|
+
fake.responses[("POST", "/groundcheck/v1/check")] = {
|
|
136
|
+
"label": "grounded", "p_grounded": 0.97, "threshold": 0.5,
|
|
137
|
+
"engine": "model", "latency_ms": 12.0, "quota_remaining": 99}
|
|
138
|
+
r = engine.groundcheck.check("Paris is the capital.", ["Paris is the capital of France."])
|
|
139
|
+
self.assertTrue(r.grounded)
|
|
140
|
+
self.assertEqual(r.decision, "allow")
|
|
141
|
+
self.assertEqual(fake.calls[0]["payload"], {
|
|
142
|
+
"source": "Paris is the capital of France.", "answer": "Paris is the capital.",
|
|
143
|
+
"question": "", "threshold": 0.5, "granular": False})
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class TestErrorMapping(unittest.TestCase):
|
|
147
|
+
def test_status_map_covers_contract(self):
|
|
148
|
+
self.assertIs(STATUS_MAP[401], InvalidAPIKey)
|
|
149
|
+
self.assertIs(STATUS_MAP[402], QuotaExceeded)
|
|
150
|
+
self.assertIs(STATUS_MAP[503], ServiceBusy)
|
|
151
|
+
|
|
152
|
+
def test_quota_exceeded_surfaces(self):
|
|
153
|
+
engine, fake = engine_with_fake(api_key="k")
|
|
154
|
+
fake.responses[("POST", "/pii-shield/v1/scan")] = QuotaExceeded(
|
|
155
|
+
"quota exceeded", status=402, product="pii-shield")
|
|
156
|
+
with self.assertRaises(QuotaExceeded):
|
|
157
|
+
engine.pii.scan("text")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class TestProvisionalAdapters(unittest.TestCase):
|
|
161
|
+
def test_injection_decision_derivation(self):
|
|
162
|
+
engine, fake = engine_with_fake(api_key="k")
|
|
163
|
+
for raw, expected in [({"decision": "block"}, "block"),
|
|
164
|
+
({"blocked": True}, "block"),
|
|
165
|
+
({"label": "malicious"}, "review"),
|
|
166
|
+
({"score": 0.1}, "allow")]:
|
|
167
|
+
fake.responses[("POST", "/injection-guard/v1/score")] = raw
|
|
168
|
+
self.assertEqual(engine.injection.score("x").decision, expected, raw)
|
|
169
|
+
|
|
170
|
+
def test_groundcheck_payload_override(self):
|
|
171
|
+
engine, fake = engine_with_fake(api_key="k")
|
|
172
|
+
fake.responses[("POST", "/groundcheck/v1/check")] = {"decision": "allow"}
|
|
173
|
+
engine.groundcheck.check("ans", payload={"custom": "shape"})
|
|
174
|
+
self.assertEqual(fake.calls[0]["payload"], {"custom": "shape"})
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
unittest.main(verbosity=2)
|