sovergate 0.1.0a1__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.
sovergate/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .client import init, instrument, shutdown
2
+
3
+ __all__ = ["init", "instrument", "shutdown"]
4
+ __version__ = "0.1.0a1"
sovergate/client.py ADDED
@@ -0,0 +1,138 @@
1
+ """Public entry point — init() and instrument()."""
2
+
3
+ import logging
4
+ from typing import Any, Optional
5
+
6
+ from .sender import send
7
+ from .queue import LogQueue
8
+
9
+ logger = logging.getLogger("sovergate")
10
+
11
+ _state: dict = {
12
+ "api_key": None,
13
+ "base_url": "https://api.sovergate.com",
14
+ "pii_key": None,
15
+ "queue": None,
16
+ }
17
+
18
+
19
+ def init(
20
+ api_key: str,
21
+ base_url: str = "https://api.sovergate.com",
22
+ pii_key: Optional[str] = None,
23
+ debug: bool = False,
24
+ ) -> None:
25
+ """
26
+ Call once at app startup before any LLM calls.
27
+
28
+ Args:
29
+ api_key: Your sovergate SDK key (starts with sov_).
30
+ base_url: Override API endpoint (useful for self-hosted or local dev).
31
+ pii_key: Secret used to derive deterministic, irreversible PII tokens.
32
+ The same value always maps to the same token, so the platform
33
+ can count unique entities without ever holding raw PII. Keep it
34
+ secret and stable — it NEVER leaves your infrastructure. If
35
+ omitted, tokens are positional and not correlatable across logs.
36
+ debug: Enable verbose logging from the SDK.
37
+ """
38
+ try:
39
+ if debug:
40
+ _enable_debug_logging()
41
+
42
+ _state["api_key"] = api_key
43
+ _state["base_url"] = base_url.rstrip("/")
44
+ _state["pii_key"] = pii_key.encode("utf-8") if pii_key else None
45
+ if _state["pii_key"] is None:
46
+ logger.debug(
47
+ "sovergate: no pii_key set — PII tokens will be positional and not "
48
+ "correlatable across logs (unique-entity metrics unavailable)."
49
+ )
50
+
51
+ def _send(payload: dict) -> bool:
52
+ return send(api_key, payload, base_url=_state["base_url"])
53
+
54
+ q = LogQueue(send_fn=_send)
55
+ q.start()
56
+ _state["queue"] = q
57
+
58
+ logger.debug("sovergate initialised (base_url=%s)", _state["base_url"])
59
+ except Exception as e:
60
+ # Never let SDK setup break the host app; logging just stays disabled.
61
+ _state["queue"] = None
62
+ logger.warning("sovergate: init failed, logging disabled: %s", e)
63
+
64
+
65
+ def _enable_debug_logging() -> None:
66
+ """Verbose output for the SDK's own loggers only — never the app's root logger."""
67
+ logger.setLevel(logging.DEBUG)
68
+ if not logger.handlers:
69
+ handler = logging.StreamHandler()
70
+ handler.setFormatter(logging.Formatter("%(name)s %(levelname)s %(message)s"))
71
+ logger.addHandler(handler)
72
+
73
+
74
+ def instrument(library: Any) -> None:
75
+ """
76
+ Patch an LLM client library so all calls are automatically logged.
77
+
78
+ Supported:
79
+ - the openai module, or an OpenAI / AsyncOpenAI client (chat.completions.create)
80
+ - an Anthropic / AsyncAnthropic client (messages.create)
81
+
82
+ Usage:
83
+ import openai
84
+ sovergate.instrument(openai)
85
+
86
+ import anthropic
87
+ client = anthropic.Anthropic()
88
+ sovergate.instrument(client)
89
+
90
+ The original library and its responses are completely unchanged.
91
+ Never raises: if instrumentation isn't possible, a warning is logged and
92
+ the library is left untouched.
93
+ """
94
+ if _state["queue"] is None:
95
+ logger.warning(
96
+ "sovergate: instrument() called before init(api_key=...) — LLM calls will not be logged."
97
+ )
98
+ return
99
+
100
+ try:
101
+ enqueue = _state["queue"].enqueue
102
+ pii_key = _state["pii_key"]
103
+ module_name = getattr(library, "__name__", "") or getattr(library, "__class__", type(library)).__name__
104
+
105
+ if module_name == "openai" or hasattr(library, "chat"):
106
+ from .patcher import patch_openai
107
+ patch_openai(library, enqueue, pii_key)
108
+ return
109
+
110
+ if module_name == "anthropic" and not hasattr(library, "messages"):
111
+ logger.warning(
112
+ "sovergate: pass an Anthropic client instance, not the anthropic module — "
113
+ "e.g. sovergate.instrument(anthropic.Anthropic()). LLM calls will not be logged."
114
+ )
115
+ return
116
+
117
+ if hasattr(library, "messages"):
118
+ from .patcher import patch_anthropic
119
+ patch_anthropic(library, enqueue, pii_key)
120
+ return
121
+
122
+ logger.warning(
123
+ "sovergate: unsupported library '%s' — LLM calls will not be logged. "
124
+ "Supported: openai, anthropic. Open an issue at "
125
+ "https://github.com/sovergate/sdk-python/issues",
126
+ module_name,
127
+ )
128
+ except Exception as e:
129
+ logger.warning("sovergate: instrument() failed, LLM calls will not be logged: %s", e)
130
+
131
+
132
+ def shutdown() -> None:
133
+ """Stop the background sender thread. Records still queued at that point are discarded."""
134
+ try:
135
+ if _state["queue"]:
136
+ _state["queue"].stop()
137
+ except Exception as e:
138
+ logger.debug("sovergate: shutdown error: %s", e)
sovergate/patcher.py ADDED
@@ -0,0 +1,141 @@
1
+ """Patches OpenAI / Anthropic clients so completed calls are logged after the fact."""
2
+
3
+ import inspect
4
+ import logging
5
+ import threading
6
+ import time
7
+ from datetime import datetime, timezone
8
+ from typing import Any, Callable, Optional
9
+
10
+ from .scrubber import scrub, scrub_messages
11
+
12
+ logger = logging.getLogger("sovergate.patcher")
13
+
14
+ _PATCHED = "__sovergate_patched__"
15
+
16
+ Enqueue = Callable[[dict], None]
17
+ BuildPayload = Callable[[dict, Any, str, int], dict]
18
+
19
+
20
+ def _utcnow() -> str:
21
+ return datetime.now(timezone.utc).isoformat()
22
+
23
+
24
+ def _log_in_background(build: BuildPayload, enqueue: Enqueue, kwargs: dict, response: Any,
25
+ request_at: str, latency_ms: int) -> None:
26
+ def run() -> None:
27
+ try:
28
+ enqueue(build(kwargs, response, request_at, latency_ms))
29
+ except Exception as e:
30
+ logger.debug("sovergate background log error: %s", e)
31
+
32
+ try:
33
+ threading.Thread(target=run, daemon=True, name="sovergate-log").start()
34
+ except Exception as e:
35
+ logger.debug("sovergate could not start log thread: %s", e)
36
+
37
+
38
+ def _wrap(target: Any, build: BuildPayload, enqueue: Enqueue) -> None:
39
+ """
40
+ Replace target.create with a wrapper that returns the original result unchanged
41
+ and logs a scrubbed copy in a daemon thread. Works for sync and async clients:
42
+ async SDK methods are sync wrappers returning a coroutine, so awaitability is
43
+ checked on the result rather than on the function.
44
+ """
45
+ original = target.create
46
+ if getattr(original, _PATCHED, False):
47
+ return
48
+
49
+ def patched_create(*args: Any, **kwargs: Any) -> Any:
50
+ request_at = _utcnow()
51
+ t0 = time.monotonic()
52
+
53
+ result = original(*args, **kwargs)
54
+
55
+ # Streams can only be consumed once and belong to the app; not logged yet.
56
+ if kwargs.get("stream") is True:
57
+ return result
58
+
59
+ def log(response: Any) -> None:
60
+ latency_ms = int((time.monotonic() - t0) * 1000)
61
+ _log_in_background(build, enqueue, kwargs, response, request_at, latency_ms)
62
+
63
+ if inspect.isawaitable(result):
64
+ async def await_and_log() -> Any:
65
+ response = await result
66
+ log(response)
67
+ return response
68
+
69
+ return await_and_log()
70
+
71
+ log(result)
72
+ return result # returned immediately — logging happens in the background
73
+
74
+ setattr(patched_create, _PATCHED, True)
75
+ patched_create.__wrapped__ = original # type: ignore[attr-defined]
76
+ target.create = patched_create
77
+
78
+
79
+ def patch_openai(openai_module: Any, enqueue: Enqueue, pii_key: Optional[bytes] = None) -> None:
80
+ """Wraps chat.completions.create on the openai module or an OpenAI / AsyncOpenAI client."""
81
+
82
+ def build(kwargs: dict, response: Any, request_at: str, latency_ms: int) -> dict:
83
+ scrubbed_messages, detections = scrub_messages(kwargs.get("messages", []), pii_key)
84
+
85
+ choice = response.choices[0] if response.choices else None
86
+ response_text = choice.message.content if choice else ""
87
+ scrubbed_response = scrub(response_text, pii_key).text if response_text else ""
88
+
89
+ usage = response.usage
90
+ return {
91
+ "model": response.model or kwargs.get("model", ""),
92
+ "messages": scrubbed_messages,
93
+ "response": scrubbed_response,
94
+ "prompt_tokens": usage.prompt_tokens if usage else 0,
95
+ "completion_tokens": usage.completion_tokens if usage else 0,
96
+ "latency_ms": latency_ms,
97
+ "timestamp": request_at,
98
+ "finish_reason": choice.finish_reason if choice else None,
99
+ "pii_detections": [
100
+ {"category": d.category, "placeholder": d.placeholder} for d in detections
101
+ ],
102
+ }
103
+
104
+ _wrap(openai_module.chat.completions, build, enqueue)
105
+ logger.debug("sovergate: patched chat.completions.create")
106
+
107
+
108
+ def patch_anthropic(anthropic_client: Any, enqueue: Enqueue, pii_key: Optional[bytes] = None) -> None:
109
+ """Wraps messages.create on an Anthropic / AsyncAnthropic client."""
110
+
111
+ def build(kwargs: dict, response: Any, request_at: str, latency_ms: int) -> dict:
112
+ all_messages = []
113
+ system = kwargs.get("system")
114
+ if system:
115
+ all_messages.append({"role": "system", "content": system})
116
+ all_messages.extend(kwargs.get("messages", []))
117
+
118
+ scrubbed_messages, detections = scrub_messages(all_messages, pii_key)
119
+
120
+ response_text = "".join(
121
+ block.text for block in (response.content or []) if getattr(block, "type", None) == "text"
122
+ )
123
+ scrubbed_response = scrub(response_text, pii_key).text if response_text else ""
124
+
125
+ usage = response.usage
126
+ return {
127
+ "model": response.model or kwargs.get("model", ""),
128
+ "messages": scrubbed_messages,
129
+ "response": scrubbed_response,
130
+ "prompt_tokens": usage.input_tokens if usage else 0,
131
+ "completion_tokens": usage.output_tokens if usage else 0,
132
+ "latency_ms": latency_ms,
133
+ "timestamp": request_at,
134
+ "finish_reason": response.stop_reason,
135
+ "pii_detections": [
136
+ {"category": d.category, "placeholder": d.placeholder} for d in detections
137
+ ],
138
+ }
139
+
140
+ _wrap(anthropic_client.messages, build, enqueue)
141
+ logger.debug("sovergate: patched messages.create")
sovergate/py.typed ADDED
File without changes
sovergate/queue.py ADDED
@@ -0,0 +1,68 @@
1
+ """Local queue with retry/backoff — survives transient API outages without blocking the app."""
2
+
3
+ import threading
4
+ import time
5
+ import logging
6
+ from collections import deque
7
+ from typing import Any, Callable
8
+
9
+ logger = logging.getLogger("sovergate.queue")
10
+
11
+ _MAX_QUEUE = 10_000
12
+ _RETRY_DELAYS = [1, 2, 4, 8, 16, 30, 60] # seconds between retries
13
+
14
+
15
+ class LogQueue:
16
+ def __init__(self, send_fn: Callable[[dict[str, Any]], bool]) -> None:
17
+ self._send = send_fn
18
+ self._queue: deque[dict[str, Any]] = deque(maxlen=_MAX_QUEUE)
19
+ self._lock = threading.Lock()
20
+ self._event = threading.Event()
21
+ self._running = False
22
+ self._thread: threading.Thread | None = None
23
+
24
+ def start(self) -> None:
25
+ self._running = True
26
+ self._thread = threading.Thread(target=self._worker, daemon=True, name="sovergate-queue")
27
+ self._thread.start()
28
+
29
+ def stop(self) -> None:
30
+ self._running = False
31
+ self._event.set()
32
+
33
+ def enqueue(self, payload: dict[str, Any]) -> None:
34
+ with self._lock:
35
+ self._queue.append(payload)
36
+ self._event.set()
37
+
38
+ def _worker(self) -> None:
39
+ retry_index = 0
40
+ while self._running:
41
+ with self._lock:
42
+ has_item = bool(self._queue)
43
+
44
+ if not has_item:
45
+ # Nothing to send — block until something is enqueued or stop() is called
46
+ self._event.wait()
47
+ self._event.clear()
48
+ continue
49
+
50
+ with self._lock:
51
+ payload = self._queue[0]
52
+
53
+ success = False
54
+ try:
55
+ success = self._send(payload)
56
+ except Exception as e:
57
+ logger.debug("sovergate queue send error: %s", e)
58
+
59
+ if success:
60
+ with self._lock:
61
+ self._queue.popleft()
62
+ retry_index = 0
63
+ else:
64
+ delay = _RETRY_DELAYS[min(retry_index, len(_RETRY_DELAYS) - 1)]
65
+ retry_index += 1
66
+ logger.debug("sovergate retrying in %ds", delay)
67
+ time.sleep(delay)
68
+ # loop continues — item stays in queue, we try again
sovergate/scrubber.py ADDED
@@ -0,0 +1,115 @@
1
+ """PII detection and scrubbing — runs locally, before any data leaves the customer's infra."""
2
+
3
+ import re
4
+ import hmac
5
+ import hashlib
6
+ from dataclasses import dataclass
7
+ from typing import Optional
8
+
9
+ # Hex chars of the HMAC digest shown in a token. 12 hex = 48 bits — enough to
10
+ # keep entity collisions negligible at realistic volumes while staying readable.
11
+ _TOKEN_HEX_LEN = 12
12
+
13
+
14
+ def _normalize(value: str) -> str:
15
+ """Fold trivial formatting differences so the same logical value (e.g. a
16
+ spaced vs. unspaced IBAN, or differently-cased email) yields one token."""
17
+ return re.sub(r"[\s\-]", "", value).lower()
18
+
19
+
20
+ def _hmac_token(key: bytes, base_placeholder: str, value: str) -> str:
21
+ digest = hmac.new(key, _normalize(value).encode("utf-8"), hashlib.sha256).hexdigest()
22
+ return f"[{base_placeholder}_{digest[:_TOKEN_HEX_LEN]}]"
23
+
24
+ # Each rule: (category, compiled regex, placeholder template)
25
+ _RULES: list[tuple[str, re.Pattern, str]] = [
26
+ ("email", re.compile(r'\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b'), "EMAIL_REDACTED"),
27
+ # IP before phone — phone regex would otherwise swallow dotted quads
28
+ ("ip", re.compile(r'\b(?:\d{1,3}\.){3}\d{1,3}\b'), "IP_REDACTED"),
29
+ # IBAN: tolerate the conventional 4-char grouping (e.g. "DE89 3704 0044 ...")
30
+ # so a spaced IBAN can't leak its country/check digits to the phone rule.
31
+ ("iban", re.compile(r'\b[A-Z]{2}\d{2}(?:[ ]?[A-Z0-9]){11,30}\b'), "IBAN_REDACTED"),
32
+ # Credit card before phone — captures the FULL grouped PAN (13–20 digits in
33
+ # groups of 4) so the phone rule's length cap can't leave a trailing group.
34
+ # Emitted as id_number until a first-class credit_card category exists in the
35
+ # ingest DTO + DB CHECK constraint. See follow-up.
36
+ ("id_number", re.compile(r'\b\d{4}(?:[ -]?\d{4}){2,4}\b'), "CARD_REDACTED"),
37
+ # Phone: require at least 7 digits (not just any digit-like chars)
38
+ ("phone", re.compile(r'\b(?:\+?\d[\d\s\-().]{5,14}\d)\b'), "PHONE_REDACTED"),
39
+ ("dob", re.compile(r'\b(?:\d{1,2}[\/\-\.]\d{1,2}[\/\-\.]\d{2,4}|\d{4}[\/\-\.]\d{1,2}[\/\-\.]\d{1,2})\b'), "DOB_REDACTED"),
40
+ ("id_number", re.compile(r'\b[A-Z]{1,3}\d{6,9}\b'), "ID_REDACTED"),
41
+ # Names last — broad heuristic, apply only inside common name patterns
42
+ ("name", re.compile(r'\b(?:Mr|Mrs|Ms|Dr|Prof)\.?\s+[A-Z][a-z]+(?: [A-Z][a-z]+)*\b'), "NAME_REDACTED"),
43
+ ]
44
+
45
+
46
+ @dataclass
47
+ class Detection:
48
+ category: str
49
+ placeholder: str
50
+
51
+
52
+ @dataclass
53
+ class ScrubResult:
54
+ text: str
55
+ detections: list[Detection]
56
+
57
+
58
+ def scrub(text: str, key: Optional[bytes] = None) -> ScrubResult:
59
+ """Replace PII in text with placeholders. Returns scrubbed text + detection list.
60
+
61
+ If ``key`` is given, each value is replaced with a deterministic, irreversible
62
+ HMAC token — the same value yields the same token across every log, which lets
63
+ the platform count unique entities without ever holding the raw PII. The key
64
+ is the customer's secret and is never sent to our servers. Without a key, we
65
+ fall back to per-text positional numbering (tokens are not correlatable).
66
+ """
67
+ if not text:
68
+ return ScrubResult(text=text, detections=[])
69
+
70
+ detections: list[Detection] = []
71
+ counters: dict[str, int] = {}
72
+
73
+ for category, pattern, base_placeholder in _RULES:
74
+ def replace(m: re.Match, cat: str = category, ph: str = base_placeholder) -> str:
75
+ if key is not None:
76
+ placeholder = _hmac_token(key, ph, m.group(0))
77
+ else:
78
+ counters[cat] = counters.get(cat, 0) + 1
79
+ placeholder = f"[{ph}_{counters[cat]}]"
80
+ detections.append(Detection(category=cat, placeholder=placeholder))
81
+ return placeholder
82
+
83
+ text = pattern.sub(replace, text)
84
+
85
+ return ScrubResult(text=text, detections=detections)
86
+
87
+
88
+ def scrub_messages(
89
+ messages: list[dict], key: Optional[bytes] = None
90
+ ) -> tuple[list[dict], list[Detection]]:
91
+ """Scrub a list of OpenAI-style message dicts. Returns scrubbed messages + all detections."""
92
+ all_detections: list[Detection] = []
93
+ scrubbed: list[dict] = []
94
+
95
+ for msg in messages:
96
+ content = msg.get("content", "")
97
+ if isinstance(content, str):
98
+ result = scrub(content, key)
99
+ scrubbed.append({**msg, "content": result.text})
100
+ all_detections.extend(result.detections)
101
+ elif isinstance(content, list):
102
+ # multi-modal content blocks
103
+ new_blocks = []
104
+ for block in content:
105
+ if block.get("type") == "text":
106
+ result = scrub(block.get("text", ""), key)
107
+ new_blocks.append({**block, "text": result.text})
108
+ all_detections.extend(result.detections)
109
+ else:
110
+ new_blocks.append(block)
111
+ scrubbed.append({**msg, "content": new_blocks})
112
+ else:
113
+ scrubbed.append(msg)
114
+
115
+ return scrubbed, all_detections
sovergate/sender.py ADDED
@@ -0,0 +1,38 @@
1
+ """Async HTTP sender — fire-and-forget, 2s timeout, never raises."""
2
+
3
+ import json
4
+ import logging
5
+ import urllib.request
6
+ import urllib.error
7
+ from typing import Any
8
+
9
+ logger = logging.getLogger("sovergate.sender")
10
+
11
+ _BASE_URL = "https://api.sovergate.com"
12
+ _TIMEOUT = 2 # seconds — never exceed this
13
+
14
+
15
+ def send(api_key: str, payload: dict[str, Any], base_url: str = _BASE_URL) -> bool:
16
+ """
17
+ POST payload to /ingest. Returns True on success, False on any failure.
18
+ Never raises — the caller's app must not be affected by our failures.
19
+ """
20
+ try:
21
+ body = json.dumps(payload).encode("utf-8")
22
+ req = urllib.request.Request(
23
+ url=f"{base_url}/ingest",
24
+ data=body,
25
+ method="POST",
26
+ headers={
27
+ "Authorization": f"Bearer {api_key}",
28
+ "Content-Type": "application/json",
29
+ },
30
+ )
31
+ with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp:
32
+ return resp.status < 400
33
+ except urllib.error.HTTPError as e:
34
+ logger.debug("sovergate ingest HTTP %s: %s", e.code, e.reason)
35
+ return False
36
+ except Exception as e:
37
+ logger.debug("sovergate ingest error: %s", e)
38
+ return False
@@ -0,0 +1,185 @@
1
+ Metadata-Version: 2.5
2
+ Name: sovergate
3
+ Version: 0.1.0a1
4
+ Summary: EU AI Act compliance SDK — automatic, non-invasive audit logging for LLM calls
5
+ Project-URL: Homepage, https://sovergate.com
6
+ Project-URL: Repository, https://github.com/Sovergate/sdk-python
7
+ Project-URL: Issues, https://github.com/Sovergate/sdk-python/issues
8
+ Author: Roberto Postolovski
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: anthropic,audit,compliance,eu-ai-act,gdpr,llm,observability,openai,pii
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Security
22
+ Classifier: Topic :: System :: Logging
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.10
25
+ Provides-Extra: dev
26
+ Requires-Dist: anthropic>=2.0; extra == 'dev'
27
+ Requires-Dist: httpx2; extra == 'dev'
28
+ Requires-Dist: httpx>=0.27; extra == 'dev'
29
+ Requires-Dist: openai>=1.0; extra == 'dev'
30
+ Requires-Dist: pytest>=8.0; extra == 'dev'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # sovergate
34
+
35
+ Python SDK for [Sovergate](https://sovergate.com): EU AI Act Article 12 audit
36
+ logging for LLM calls, with PII scrubbed inside your own infrastructure.
37
+
38
+ > **Early access.** The SDK is published as an alpha and the hosted ingest
39
+ > service is not generally available yet. APIs may change before 1.0.
40
+
41
+ ## How it works
42
+
43
+ Your app keeps calling OpenAI or Anthropic directly. Sovergate does not proxy
44
+ or intercept the request. After a call completes, the SDK takes a copy,
45
+ scrubs personal data **locally**, and sends the scrubbed copy to Sovergate
46
+ from a background thread.
47
+
48
+ ```
49
+ Your app ─────────────────────────────► OpenAI / Anthropic
50
+ │
51
+ └──► scrub PII locally ──► Sovergate (background, fire and forget)
52
+ ```
53
+
54
+ ## Install
55
+
56
+ ```bash
57
+ pip install --pre sovergate
58
+ ```
59
+
60
+ `--pre` is needed while the SDK is in alpha. Requires Python 3.10+. No runtime
61
+ dependencies.
62
+
63
+ ## Quick start
64
+
65
+ ```python
66
+ import openai
67
+ import sovergate
68
+
69
+ sovergate.init(api_key="sov_prod_xxxx")
70
+ sovergate.instrument(openai)
71
+
72
+ # Your existing code is unchanged
73
+ response = openai.chat.completions.create(
74
+ model="gpt-4o",
75
+ messages=[{"role": "user", "content": "Hello"}],
76
+ )
77
+ # The response is returned immediately; logging happens in the background
78
+ ```
79
+
80
+ You can also instrument a client instance, sync or async:
81
+
82
+ ```python
83
+ from openai import AsyncOpenAI
84
+
85
+ client = AsyncOpenAI()
86
+ sovergate.instrument(client)
87
+ ```
88
+
89
+ ### Anthropic
90
+
91
+ Pass a client instance (the `anthropic` module itself has no client to patch):
92
+
93
+ ```python
94
+ import anthropic
95
+ import sovergate
96
+
97
+ client = anthropic.Anthropic() # or anthropic.AsyncAnthropic()
98
+ sovergate.init(api_key="sov_prod_xxxx")
99
+ sovergate.instrument(client)
100
+ ```
101
+
102
+ ## Stable PII tokens (recommended)
103
+
104
+ By default, detected PII is replaced with positional placeholders
105
+ (`[EMAIL_REDACTED_1]`), which are not correlatable between logs. Pass a
106
+ `pii_key` to replace values with deterministic, irreversible HMAC-SHA256 tokens
107
+ instead. The same value always maps to the same token, so Sovergate can count
108
+ unique people, IBANs and so on without ever receiving the raw values.
109
+
110
+ ```python
111
+ sovergate.init(api_key="sov_prod_xxxx", pii_key="<your-secret>")
112
+ ```
113
+
114
+ The `pii_key` is your secret. Keep it stable, and never send it to us: it stays
115
+ on your infrastructure, and without it the tokens cannot be reversed.
116
+
117
+ ## Options
118
+
119
+ ```python
120
+ sovergate.init(
121
+ api_key="sov_prod_xxxx", # required
122
+ base_url="https://...", # default: https://api.sovergate.com
123
+ pii_key="<your-secret>", # optional, see above
124
+ debug=True, # verbose output from the "sovergate" logger only
125
+ )
126
+ ```
127
+
128
+ ## What is sent to Sovergate
129
+
130
+ For each completed call, one JSON record:
131
+
132
+ | Field | Content |
133
+ |---|---|
134
+ | `model` | Model name returned by the provider |
135
+ | `messages` | Your prompt messages (and Anthropic `system` prompt), **after** PII scrubbing |
136
+ | `response` | The model's text output, **after** PII scrubbing |
137
+ | `prompt_tokens`, `completion_tokens` | Token counts from the provider |
138
+ | `latency_ms`, `timestamp` | Call duration and start time |
139
+ | `finish_reason` | Why the model stopped |
140
+ | `pii_detections` | Category and placeholder of each scrubbed item, never the original value |
141
+
142
+ Never sent: the raw prompt or response, your `pii_key`, your LLM provider API
143
+ key, or request headers.
144
+
145
+ ### What the scrubber detects
146
+
147
+ Regex-based, applied to every text part of the prompt and response: email
148
+ addresses, IP addresses, IBANs (including the spaced format), card numbers,
149
+ phone numbers, dates of birth, ID numbers, and names that follow a title (Mr,
150
+ Mrs, Ms, Dr, Prof). Other personal data, such as names without a title, is
151
+ **not** detected yet, so keep data minimisation in mind when you build prompts.
152
+ The code is in [`sovergate/scrubber.py`](sovergate/scrubber.py).
153
+
154
+ ## Guarantees
155
+
156
+ - Never blocks your call: logging runs in a background daemon thread
157
+ - Never raises: if setup fails, the SDK logs a warning on the `sovergate`
158
+ logger and stays out of the way
159
+ - Returns the provider's response unchanged; `with_raw_response` keeps working
160
+ - Never changes your app's logging configuration, even with `debug=True`
161
+ - 2 second timeout on every HTTP call to Sovergate
162
+ - If Sovergate is unreachable, records are queued in memory (up to 10,000) and
163
+ retried with exponential backoff
164
+
165
+ ## Current limitations
166
+
167
+ - Streaming calls (`stream=True`) are passed through untouched but not logged yet.
168
+ - Calls made through `with_raw_response` are not logged.
169
+ - The retry queue is in memory, so queued records are lost if the process exits.
170
+
171
+ ## Development
172
+
173
+ ```bash
174
+ python -m venv .venv && source .venv/bin/activate
175
+ pip install -e ".[dev]"
176
+ pytest
177
+ ```
178
+
179
+ ## Security
180
+
181
+ Please report vulnerabilities privately. See [SECURITY.md](SECURITY.md).
182
+
183
+ ## License
184
+
185
+ [MIT](LICENSE)
@@ -0,0 +1,11 @@
1
+ sovergate/__init__.py,sha256=II8IbizprpBr6bWxEw4QJzYSwqt7xOPCzRDDTUJ-CqA,117
2
+ sovergate/client.py,sha256=PjTbJam3aC6peBOQqmokGz-D8bCjJEKiwVwX6i_hnhY,4866
3
+ sovergate/patcher.py,sha256=WFcGg2AYN-18aAOidI5wjjbRfwl4S3sQMZuywiTmkaU,5391
4
+ sovergate/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ sovergate/queue.py,sha256=uMmxKSIr2XLdzYGID4m9czyPhkVGQLzHx2DW3MHPjaU,2188
6
+ sovergate/scrubber.py,sha256=nW76ho1FbhG1jpOMB8xfEpKyTtM-alNJ29QfIjZNtGQ,4985
7
+ sovergate/sender.py,sha256=TRC_ZKF0RsckL_dMfNoZFfaAp1sq3V8rsyeERFWg4LU,1231
8
+ sovergate-0.1.0a1.dist-info/METADATA,sha256=xcsuwzl0ovVYS-As47ADNvv9Nboc9sKmoCEjR5XR_6g,6189
9
+ sovergate-0.1.0a1.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
10
+ sovergate-0.1.0a1.dist-info/licenses/LICENSE,sha256=WTZWv7-Yhn7hxEtx60bKB5QfVAlJK_6FL6sj2RqmvIw,1076
11
+ sovergate-0.1.0a1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Roberto Postolovski
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.