Metadata-Version: 2.5
Name: pii-gateway-sdk
Version: 0.1.0
Summary: OBVELO — mask personal data before it reaches a model provider, restore it in the answer. EU-hosted gateway, no US processor in the path.
Project-URL: Homepage, https://obvelo.com
Project-URL: Repository, https://github.com/Luminote-P-S-A/pii-gateway
Project-URL: Issues, https://github.com/Luminote-P-S-A/pii-gateway/issues
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: anonymization,anthropic,eu-ai-act,gdpr,langchain,openai,pii,privacy
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Security
Requires-Python: >=3.9
Requires-Dist: httpx>=0.24
Provides-Extra: anthropic
Requires-Dist: anthropic>=0.25; extra == 'anthropic'
Provides-Extra: dev
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=7; extra == 'dev'
Provides-Extra: openai
Requires-Dist: openai>=1.0; extra == 'openai'
Description-Content-Type: text/markdown

# OBVELO SDK for Python

Mask personal data before it reaches a model provider. Restore it in the
answer. The OBVELO gateway runs in the EU; no US processor is in the path.

## Install from the downloaded file

The SDK is distributed as a file from the OBVELO website, not from PyPI.
Check the file against the SHA-256 printed beside the download link, then
install it into your environment:

```
sha256sum pii_gateway_sdk-0.1.0-py3-none-any.whl
pip install ./pii_gateway_sdk-0.1.0-py3-none-any.whl
```

Its one dependency, `httpx`, comes from your usual package index. With the
optional wrappers: `pip install "./pii_gateway_sdk-0.1.0-py3-none-any.whl[openai]"`
(or `[anthropic]`). In a `requirements.txt`, name the file:
`pii-gateway-sdk @ file:///path/to/pii_gateway_sdk-0.1.0-py3-none-any.whl`.
The source archive, `pii_gateway_sdk-0.1.0.tar.gz`, installs the same way.

The distribution inside the file is named `pii-gateway-sdk` and the module
you import is `pii_gateway`.

One dependency (`httpx`), Python 3.9+. Synchronous and asynchronous, with the
same surface.

**Picking a language?** The SDK table in the OBVELO documentation
(<https://obvelo.com/docs/>) says what each of the three SDKs — TypeScript,
Python, .NET — carries.

## The one thing worth knowing

Two operations, and they happen in different places:

| | where it runs | what travels |
|---|---|---|
| `mask` | the gateway | your text, scattered |
| `restore` | your process | nothing |

The map — the list of `token -> real value` — is the most concentrated
personal data in the whole exchange. It never goes back over the network.
Restoration is string substitution against a map you already hold, so there is
nothing to send.

That is also why the map is returned to you rather than kept somewhere: how
long it lives is your decision, and a library that held onto it "for
convenience" would create a store of personal data inside your process that
nobody knows about.

## Quick start

```python
import os
from pii_gateway import PiiGateway

gateway = PiiGateway(os.environ["PII_GATEWAY_URL"], os.environ["PII_GATEWAY_KEY"])

masked = gateway.mask("Write to Jan Przykładowy at jan@acme.example about the Acme case.")
# masked.text: "Write to [Person 1] at [Email 1] about the [Organization 1] case."

answer = your_model(masked.text)

print(gateway.restore(answer, masked.map))
```

`restore` recognises both `[Person 1]` and the bare `Person 1`, because models
routinely echo the token without its brackets. Tokens are shortened to `[Person 1]` in
these examples; a real token also carries a per-request namespace, such as
`[Person BP8TMQYYSCFTFP312HD0C59GCN-1]`, so two conversations can never swap
values.

Asynchronously, the same:

```python
from pii_gateway import AsyncPiiGateway

async with AsyncPiiGateway(url, key) as gateway:
    masked = await gateway.mask(text)
```

## With the official `openai` package

```python
from openai import OpenAI
from pii_gateway import PiiGateway
from pii_gateway.openai import wrap_openai

client = wrap_openai(OpenAI(), gateway)

answer = client.chat.completions.create(
    model="gpt-4o",
    messages=[
        {"role": "system", "content": "You are a helpful assistant."},
        {"role": "user", "content": "Summarise what Jan Przykładowy from Acme asked for."},
    ],
)
# answer.choices[0].message.content already carries the real values
```

The whole conversation is masked in ONE call, so the same person keeps the
same token across every message — otherwise the model could not tell that the
person in the question and the person in the context are the same one.

Streaming works the same way, including a value that arrives split across
chunks. `chat.completions.create` and `responses.create` are both covered, in
the synchronous and the asynchronous client (`wrap_async_openai` with
`AsyncPiiGateway`).

## With the official `anthropic` package

```python
from anthropic import Anthropic
from pii_gateway.anthropic import wrap_anthropic

client = wrap_anthropic(Anthropic(), gateway)

answer = client.messages.create(
    model="claude-sonnet-4-5",
    max_tokens=1024,
    system="You are a helpful assistant.",
    messages=[{"role": "user", "content": "What did Jan Przykładowy ask for?"}],
)
```

The `system` prompt is masked as well. With Anthropic it is a separate
parameter, which makes it easy to forget that it is a prompt like any other
and carries whatever was put in it.

`messages.stream(...)` is covered too:

```python
with client.messages.stream(model=..., max_tokens=1024, messages=[...]) as stream:
    for piece in stream.text_stream:
        print(piece, end="")
    final = stream.get_final_message()
```

## Your own proper nouns

The gateway handles bare text. A dictionary raises recall on names that no
rule and no model could know, because they belong to your organisation:

```python
from pii_gateway import Dictionary

client = wrap_openai(OpenAI(), gateway, dictionary=Dictionary(
    people=["Jan Przykładowy"],
    organizations=["Acme"],
    # anything else that is a proper noun in YOUR world: a case reference,
    # a file number, an internal codename
    custom=[{"value": "KIO-2026-114", "label": "CaseRef"}],
))
```

The dictionary travels with the request and is never stored — the gateway is
stateless.

### The other direction: words that are NEVER personal data

`never` is the veto. The engine sees a capitalised word where a name would
stand and masks it; you are the only one who can say that "Kappa 3" is a
product, not a person. A phrase is kept whole — the designation is exempt,
not the word *Kappa* everywhere it appears:

```python
Dictionary(people=["Jan Przykładowy"], never=["Kappa 3", "Obvelo"])
```

## When the list arrives as a file

An export from a CRM, an ERP or a spreadsheet. `from_csv` reads it **in your
process** — there is no upload endpoint, and there will not be one, because
that would turn a stateless transformer into a service holding lists of names
at rest:

```python
from pii_gateway import from_csv

dictionary = from_csv(open("kontrahenci.csv", encoding="utf-8").read())
```

The separator, whether there is a header and which column is which are all
read from the file. Headers are recognised in every language the engine
covers, so `imię i nazwisko`, `société` and `ΠΕΛΑΤΗΣ` are understood without
anybody editing the export first. A file with no header it recognises is
treated as a list of people; pass `assume="organizations"` when it is not.

For a spreadsheet, parse it with whatever is already in the process and hand
over the rows:

```python
import openpyxl
from pii_gateway import from_rows

sheet = openpyxl.load_workbook("kontrahenci.xlsx").active
rows = [[str(c.value or "") for c in r] for r in sheet.iter_rows()]
dictionary = from_rows(rows, assume="organizations")
```

## When the list lives in a system, not a file

A CRM, an ERP, a workflow tool — the gateway needs rows, so anything that can
produce rows fits. Fetch them in your process, on your credentials, and hold
the result:

```python
from pii_gateway import Dictionary, DictionaryProvider

contractors = DictionaryProvider(
    lambda: Dictionary(organizations=erp.export_contractors()),
    ttl_seconds=900,
)

result = client.mask(text, dictionary=contractors.get())
```

**Narrow it, do not grow it.** The dictionary travels with every request, so
a list of fifty thousand contractors is uploaded on every call and matched
against a text that mentions three of them. Load the parties to THIS case.

Narrow by your own business key: the case, the customer, the ticket. Never by
testing which names appear in the text — Polish writes *Kowalskim*, the test
would not find *Kowalski*, the entry would be dropped, and the name would go
to the model in clear.

The gateway caps a dictionary at 20 000 entries, and it now says so:

```python
if result.dictionary and result.dictionary.truncated:
    print(f"{result.dictionary.dropped} entries did not fit — NOT masked")
```

Until 15.09.2026 that cut was silent. The provider refuses to cross the
ceiling rather than trimming, for the same reason: a helper that quietly
keeps the first 20 000 rows rebuilds the same defect one layer higher.

**We do not fetch it ourselves, and that is deliberate.** Pulling your
contractor list would mean holding credentials to your ERP and reading your
personal data on a schedule — we would become a processor with a copy of your
database, which is the opposite of what this product is.

## Inflection (Polish, Czech, Slovak and more)

These languages decline names: "Jan Przykładowski", "Jana Przykładowskiego"
and "Janowi Przykładowskiemu" are one man, and by default each surface is
its own token. With the inflection engine one person is ONE entity and the
token carries the grammatical case:

```python
masked = gateway.mask(text, inflection="tag")
# "Pismo trafiło do [Person 1|Gen]."
```

`restore` (and the stream restorer) understand the tags, tolerate the
echoes models produce (`|gen`, `| Ins`), and decline the name themselves
when the model asks for a case that never appeared in the input. A tag is
only attached when the case is certain — an ambiguous form keeps the bare
token, which restores to the canonical surface. The tenant's plan can set
both the mode and the language as defaults.

```python
# Czech or Slovak text: say which language — it is never guessed.
cz = gateway.mask(czech_text, inflection="tag", inflection_language="cs")
# "Faktura pro [Person 1|Acc] byla odeslána."
```

`inflection_language` is `pl` by default. The languages your gateway has an
analyzer for are listed by `POST /v1/whoami` (with your key) under
`catalogue.inflectionLanguages` — read them there rather than from a list in
a README, because the gateway gains languages without a new SDK. One
language per request, because Czech "Jana" and Polish "Jana" are the
same letters with a different case reading — a language with no analyzer
masks normally and simply carries no tags.

## The agentic loop

An agent's task spans many mask calls. Carry the map back and the same
person keeps the same token across the whole task — the gateway stores
nothing between calls:

```python
first = gateway.mask(question)
args = restore_object(tool_call_arguments, first.map)  # locally
second = gateway.mask(tool_output, prior_map=first.map)
# second.map is the merged map — use it for restore and the next call.
```

## What the wrappers do NOT cover

They mask CONTENT, not the shape of the request: tool names, the model name
and parameters go through as they are. Hiding those would break the call
without adding protection.

And they cover the calls that carry a conversation — for `openai`,
`chat.completions.create` and `responses.create`; for `anthropic`,
`messages.create` and `messages.stream`. Everything else on those clients
(embeddings, files, batches, the assistants API) passes through unchanged,
which also means unmasked. If you send personal data through one of those,
mask it yourself:

```python
masked = gateway.mask_object(tool_arguments)
```

## Errors

Every error is typed, and none of them carries your key or the gateway address:

| class | when |
|---|---|
| `UnauthorizedError` | the key is unknown or revoked |
| `RateLimitError` | too many requests; `retry_after_seconds` says how long |
| `QuotaExceededError` | the plan's limit is spent |
| `PayloadTooLargeError` | the text is over the instance limit |
| `SafetyNetError` | the gateway's safety net found unambiguous data left after masking — raised by the model wrappers before anything reaches the model |
| `GatewayUnavailableError` | the instance did not answer |

`RateLimitError` and `GatewayUnavailableError` are retried by the client
itself (twice by default, with backoff). The rest are raised immediately,
because retrying them changes nothing.

Plain `mask()` does not raise for the safety net: it returns the result with
`safety_net` set — a summary such as `iban x1` and the positions, never the
value. If you send masked text to a model yourself, check it first:

```python
result = pii.mask(text)
if result.safety_net is not None:
    raise RuntimeError(f"not sending: {result.safety_net.summary}")
```

## Streaming outside the wrappers

```python
restorer = gateway.stream_restorer(masked.map)
for piece in your_stream:
    sys.stdout.write(restorer.push(piece))
sys.stdout.write(restorer.flush())
```

`push` holds back the end of the buffer that might still be an unfinished
token, and `flush` releases the rest. Without the tail, a value split across
two chunks would reach the reader as a token.

## Development

```
pip install -e ".[dev]"
pytest
```

The downloadable files are built and checked by two scripts:

```
tools/build-package.sh <output-dir>             # wheel + sdist, version, SHA-256
tools/artifact-check.sh <output-dir>/<file>...  # installs each into a fresh venv
                                                # and masks/restores through a real gateway
```

`tests/test_parity.py` measures this package's restoration against a fixture
GENERATED FROM THE ENGINE:

```
deno run --node-modules-dir=none --allow-write --allow-read \
  packages/pii-sdk-python/tools/build_parity_cases.ts
```

The engine is written in TypeScript, so the Python restoration is a second
implementation of the same rules. The fixture is what keeps the two from
drifting — and when it fails, Python is the side that moves.
