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 +4 -0
- sovergate/client.py +138 -0
- sovergate/patcher.py +141 -0
- sovergate/py.typed +0 -0
- sovergate/queue.py +68 -0
- sovergate/scrubber.py +115 -0
- sovergate/sender.py +38 -0
- sovergate-0.1.0a1.dist-info/METADATA +185 -0
- sovergate-0.1.0a1.dist-info/RECORD +11 -0
- sovergate-0.1.0a1.dist-info/WHEEL +4 -0
- sovergate-0.1.0a1.dist-info/licenses/LICENSE +21 -0
sovergate/__init__.py
ADDED
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,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.
|