Developer documentation / Python
sether (Python)
Streaming PII redaction for AI applications. Tokenise sensitive data before it reaches any LLM, restore known replacements using the matching vault. The Python library follows @raeven-co/sether's detection approach and token format, with sync and async streaming. The full library, including decoy mode and restore, stays free and MIT licensed. Python 3.9+.
- v0.4.0 on PyPI
- Python 3.9+
- sync + async
- typed (py.typed)
- 1 core dependency
Full MIT library. Decoy and restore APIs included.
Overview
Sether sits between your application and any LLM API: OpenAI, Anthropic, Gemini, Bedrock, your own fine-tunes. It detects sensitive data, swaps each match for a random token like <EMAIL_…> before the request leaves your boundary, then restore_sync() swaps the originals back into the response. Your application code never has to branch on redacted text.
The SDK round trip
Same task. Fewer real details.
Write to alice@example.com.
Start with useful context and a supported email match.
Write to <EMAIL_…>.
To: alice@example.com.
Recognized replacement + matching retained vault.
restore_sync(). Vault details ↗Detection is deterministic (validated patterns — Luhn, mod-97, SSA blacklists — not ML guesses), streaming-safe (chunk-boundary correct, property-tested over random partitions), and local (no network calls; one core dependency, phonenumbers).
The port follows the TypeScript engine's detection and validation approach. Detector regexes use re.ASCII for compatible ASCII word boundaries and digits. Runtime behavior and integration coverage can differ; test the paths you deploy.
Install
pip install setherPython 3.9+. The phone detector uses phonenumbers (installed automatically). Integration wrappers are optional extras:
pip install "sether[openai]" # wrap_openai
pip install "sether[anthropic]" # wrap_anthropic
pip install "sether[httpx]" # wrap_httpx
pip install "sether[all]" # all of the aboveThe ASGI and WSGI middlewares have no extra dependency.
Quickstart
from sether import Sether
sether = Sether()
# Outgoing: redact before it reaches the LLM.
safe = sether.redact_sync("my email is alice@example.com")
# -> "my email is <EMAIL_…>"
# Incoming: restore before it reaches your user.
back = sether.restore_sync(safe)
# -> "my email is alice@example.com"The same Sether instance shares its vault between redact_sync() and restore_sync() — that is how the round-trip identity is preserved across streaming chunks. One instance per request/conversation is the standard pattern.
Streaming (sync & async)
For input that arrives in pieces (an LLM token stream), use the streaming API. It holds back safe_distance_bytes (default 256) at each chunk tail, so a pattern split across chunks (foo@ + bar.com) is still caught. Both a synchronous and an asynchronous form are provided:
sether = Sether()
# Synchronous: any iterable of text chunks -> iterator of redacted chunks.
redacted = "".join(sether.redact_stream(["my email is alice@", "example.com"]))
restored = "".join(sether.restore_stream([redacted]))
# Asynchronous: any async iterable (e.g. an LLM token stream).
async def forward(llm_stream):
async for piece in sether.aredact_stream(llm_stream):
await send_to_model(piece)
# and sether.arestore_stream(...) on the way backA long whitespace-free value (a JWT, an API key) is held back up to max(safe_distance_bytes * 4, 8192) bytes so it is never emitted partially. For values larger than that, raise safe_distance_bytes or use redact_sync on complete payloads.
Detectors (basic pack)
Sether() runs the basic pack by default. Pass an explicit list to narrow the scope:
from sether import Sether, email_detector, ssn_detector
sether = Sether(detectors=[email_detector, ssn_detector]) # only these two| Export | Token type | Method |
|---|---|---|
| email_detector | RFC 5321-style regex. ASCII local parts. | |
| phone_detector | PHONE | phonenumbers — international parsing. |
| credit_card_detector | CC | Bounded regex + Luhn check. |
| ssn_detector | SSN | Regex + SSA invalid-prefix rules. |
| ipv4_detector | IPV4 | Strict octet-bounded (0–255). |
| ipv6_detector | IPV6 | Candidate regex + in-tree validator. |
| iban_detector | IBAN | Regex + ISO 13616 mod-97 checksum. |
By default the phone detector only matches numbers written with a country code (+1 415 555 2671). For national-formatnumbers ((415) 555-2671, 0803 123 4567), build a region-aware detector:
from sether import Sether, basic_detectors, create_phone_detector
sether = Sether(detectors=[
*(d for d in basic_detectors if d.type != "PHONE"),
create_phone_detector(default_country="US"),
])Identity pack (opt-in)
Names, dates of birth, passport numbers, and addresses have no self-validating shape, so the identity pack uses label-anchored detection — it redacts a value only when it appears with the label that introduces it (Name:, DOB:, Passport No:, Address:), in many languages: Latin-script labels plus CJK, Cyrillic, and Arabic. Value capture is Unicode-aware.
from sether import Sether, basic_detectors, identity_detectors
sether = Sether(detectors=[*basic_detectors, *identity_detectors])Exports: name_detector, dob_detector, passport_detector, address_detector. Unlabelled names in free prose are the job of free-text NER (separate roadmap package).
Secrets pack (opt-in)
Catches leaked credentials before they reach a third-party model — useful when users paste config files or logs into AI features.
from sether import Sether, basic_detectors, secrets_detectors
sether = Sether(detectors=[*basic_detectors, *secrets_detectors])| Export | Token type | Method |
|---|---|---|
| aws_access_key_detector | AWS_KEY | AKIA / ASIA / AROA / AIDA keys. |
| openai_key_detector | OPENAI_KEY | sk- / sk-proj- API keys. |
| anthropic_key_detector | ANTHROPIC_KEY | sk-ant- API keys. |
| github_pat_detector | GITHUB_PAT | ghp_ and github_pat_ tokens. |
| slack_token_detector | SLACK_TOKEN | xox[baprs]- tokens. |
| stripe_key_detector | STRIPE_KEY | sk_live / pk_live / whsec_ keys. |
| jwt_detector | JWT | Three-part base64url tokens. |
| high_entropy_detector | HIGH_ENTROPY | Long high-entropy strings (≥3.5 bits/char). |
SSE / JSON-stream mode
OpenAI and Anthropic streaming responses come back as Server-Sent Events. The SSE-aware stream redacts payloads inside data: lines while preserving the data: / event: / id: framing and blank-line separators verbatim.
from sether import create_sse_redact_stream, basic_detectors, MemoryVault
vault = MemoryVault()
stream = create_sse_redact_stream(basic_detectors, vault)
out = stream.feed(sse_chunk) + stream.finish()
# or the iterator helpers: sse_redact_iter(chunks, detectors, vault)Integrations
Drop-in wrappers so you don't hand-wire the streams:
from sether import Sether
sether = Sether()
# httpx (sync or async client) — the fetch analog
import httpx
from sether import wrap_httpx
client = wrap_httpx(httpx.Client(), detectors=sether.detectors, vault=sether.vault)
# OpenAI / Anthropic SDKs — pass YOUR client; Sether never imports either SDK
from openai import OpenAI
from anthropic import Anthropic
from sether import wrap_openai, wrap_anthropic
openai = wrap_openai(OpenAI(), detectors=sether.detectors, vault=sether.vault)
claude = wrap_anthropic(Anthropic(), detectors=sether.detectors, vault=sether.vault)
# ASGI (FastAPI / Starlette)
from sether import SetherASGIMiddleware
app.add_middleware(SetherASGIMiddleware, detectors=sether.detectors, vault=sether.vault)
# WSGI (Flask)
from sether import SetherWSGIMiddleware
app.wsgi_app = SetherWSGIMiddleware(app.wsgi_app, detectors=sether.detectors, vault=sether.vault)The SDK wrappers are structurally typed — anything matching the chat.completions.create / messages.create shape works, sync or async, streaming or not.
JSON-safe restore (new in 0.4.0)
restore_sync, the restore streams and sether.restore_sync() are plain text substitution: each original goes back exactly as it was. That is right for prose and wrong for serialized JSON, such as tool-call arguments, where an original containing a quote can add keys. Restore JSON value by value instead:
from sether import restore_json, restore_json_value
# Serialized JSON in, serialized JSON out: each string value is restored and re-escaped.
safe_args = restore_json(tool_call.function.arguments, vault, strict=True)
# or, on the facade: sether.restore_json(tool_call.function.arguments, strict=True)
# Already parsed, e.g. an Anthropic tool_use block's `input`.
tool_input = restore_json_value(block.input, vault, strict=True)- Object keys are never restored. Invalid JSON raises json.JSONDecodeError, with no fallback to text replacement.
- strict=True raises UnresolvedTokenError when a token would be left in place: invented, expired, from another vault, case-altered or sitting in a key. The error lists tokens, never originals.
- Since 0.4.0 the SSE helpers, httpx, ASGI, WSGI and the SDK wrappers restore JSON value by value. Tool-call arguments stay tokenized unless you opt in with restore_tool_arguments.
- 0.4.0 is also the first PyPI release of the 0.3.0 features: the alias (decoy) engine, the multi-region phone detector and label-anchored secrets.
Audit events & regulation mappings
Each redaction can be described by a structured AuditEvent — detector type, token, value length (never the value), and the regulation references it evidences (GDPR Art. 28, SOC 2 CC6.7, HIPAA, PCI DSS, EU AI Act, NDPA) via DEFAULT_REGULATION_MAPPINGS. The JSON wire shape matches the TypeScript package (camelCase keys), so events are interchangeable across both.
from sether import AuditEvent, MemoryAuditSink, ConsoleAuditSink
sink = MemoryAuditSink() # accumulates for tests; ConsoleAuditSink writes JSONL to stderr
sink.write(AuditEvent(timestamp="…", detector="EMAIL", value_length=17, token="<EMAIL_x>"))
sink.events[0].to_dict() # -> {"timestamp": …, "detector": "EMAIL", "valueLength": 17, …}The vault
The vault is the token → original-value map. Default: MemoryVault (in-process, LRU + TTL). It implements a six-method protocol — set / get / has / delete / size / clear — so you can back it with Redis for multi-instance deployments. The vault is synchronous: restore substitutes tokens as bytes flow through and cannot await a lookup per token. The vault stays inside your infrastructure; tokens are useless without it.
Custom detectors
Anything with a type string and a detect(text) method works:
import re
from sether import DetectorMatch
class OrderIdDetector:
type = "ORDER_ID"
_re = re.compile(r"\bORD-\d{8}\b")
def detect(self, text):
return [DetectorMatch(m.start(), m.end(), m.group(0)) for m in self._re.finditer(text)]Parity with the TypeScript package
The port shares the TypeScript engine's detector approach (re.ASCII keeps \b / \dASCII-only), Luhn / mod-97 / SSA validation, overlap resolution (longest match wins), the same <TYPE_uuid> token format, and the same safe-distance and long-value streaming guards. The test suite covers detectors, vault, streaming (including a property-based chunk-partition round-trip), SSE, audit, and all five integrations.
Security
Found a vulnerability? Please don't open a public issue — email emorylebo@gmail.com or use GitHub private security advisories.

Sether