ylemis 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
ylemis/__init__.py 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
+ ]
ylemis/_http.py ADDED
@@ -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)
ylemis/client.py ADDED
@@ -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)}
ylemis/exceptions.py ADDED
@@ -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
+ }
ylemis/models.py ADDED
@@ -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,10 @@
1
+ ylemis/__init__.py,sha256=mckw2rbZAEC6QJ2HTPKv-vmB7ICMs6ZXb8RzYoRKu1Y,979
2
+ ylemis/_http.py,sha256=2AgEcyMQWhNqz8-yvLIY2hZTYu8f0-ZJxBo9xvsIuEI,3803
3
+ ylemis/client.py,sha256=OKy4_Lf1BwDXaFZxN1zhQCMp2l-GtgEc7IwFJmHYAO0,10175
4
+ ylemis/exceptions.py,sha256=DleKjSOfChFSgt4fbb3-KHCB25TLQwjbMy1Cir3WLlw,2177
5
+ ylemis/models.py,sha256=xVy2ybjCC3vRVvReko8WfDJhXnbeuC0OXVNTsJCE_VE,7276
6
+ ylemis-0.1.0.dist-info/licenses/LICENSE,sha256=Tj1q5-Cb41Rv1fExypUd7vTS3CkE21k9rIjPk3kFuRY,1073
7
+ ylemis-0.1.0.dist-info/METADATA,sha256=wIiufWB7Znf2AMXAQrhgd41VGMY8ui4uRnmU3WtLszE,3608
8
+ ylemis-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
9
+ ylemis-0.1.0.dist-info/top_level.txt,sha256=E3_1w3FtL62B392ddHbOPmBFSmYPZj9HuGz4xNVea3M,7
10
+ ylemis-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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.
@@ -0,0 +1 @@
1
+ ylemis