# Sırdaş Gateway: redact PII before your app calls an LLM

> **Since 0.5.0.** `npx -y @sirdaspdf/gateway@0.5.0`

A local proxy that sits between your application and the OpenAI or Anthropic
API. It replaces names, SSNs, MRNs, addresses, emails, account and card numbers
with placeholders such as `[PERSON_1]` on your machine, forwards the request
with your own API key, and puts the real values back into the answer.

```
your app ──► Sırdaş Gateway (127.0.0.1) ──► OpenAI / Anthropic
              redacts the request             sees [PERSON_1], [SSN_1]
your app ◄── restores the real values ◄────── answers with placeholders
```

## What changes in your code

One line: the base URL. Your key, your model and your prompts stay as they are.

```python
from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:8787/openai/v1")

from anthropic import Anthropic
client = Anthropic(base_url="http://127.0.0.1:8787/anthropic")
```

Supported: OpenAI `chat/completions`, `responses` and `embeddings`; Anthropic
`messages`. Streaming works on all of them: a placeholder split across two
chunks (`[PERS` + `ON_1]`) is held back until it is complete.

## In-process: middleware for the Vercel AI SDK

No separate process: `wrapLanguageModel` redacts the prompt in your own
process and restores the real values in the answer, streaming included.

```ts
import { generateText, wrapLanguageModel } from "ai";
import { openai } from "@ai-sdk/openai";
import { sirdasMiddleware } from "@sirdaspdf/gateway/ai-sdk";

const model = wrapLanguageModel({
  model: openai("gpt-5"),
  middleware: sirdasMiddleware({ block: ["SSN"] }),
});
const { text } = await generateText({ model, prompt: "..." });
```

Text, reasoning, tool calls and tool results are redacted with one map per
call. An attached file cannot be redacted here: it is rejected before the call
(`files: "pass"` lets it through untouched; convert PDFs to text with
`sirdas markdown` instead). `block` refuses the call when a type appears, even
redacted. `onRedact` receives counts by type, never values. Tested against
`ai@7` (default, `specificationVersion: "v4"`) and `ai@6`
(`specificationVersion: "v3"`) with their mock models.

## Attached PDFs

An inline PDF (OpenAI `file` part, Anthropic `document` block) is converted to
text **on your machine** and redacted with the rest of the request. The PDF
bytes never reach the provider.

## What it refuses, and why

A privacy gateway that forwards what it cannot read is not one. These return
`422` and **nothing is sent**:

| Request contains | Why | What to do |
|---|---|---|
| An image or audio | It cannot be redacted | Remove it, or set `SIRDAS_GATEWAY_ALLOW_IMAGES=1` to pass it through unredacted, deliberately |
| A scanned PDF | No text layer to redact | Run OCR locally first (`sirdas ocr`) |
| A PDF it cannot open | Usually password-protected | `sirdas unlock` with the password you know |
| An unknown route | Unredacted traffic | Use one of the supported routes |

## What is logged

One JSON line per request on stderr: route, model, status, time and the
**count** of each placeholder type. Never a value. Every response carries
`x-sirdas-redacted: 4` and `x-sirdas-redacted-types: PERSON:1,SSN:1,…`.

## Configuration

| Variable | Default | |
|---|---|---|
| `SIRDAS_GATEWAY_PORT` | `8787` | |
| `SIRDAS_GATEWAY_HOST` | `127.0.0.1` | `0.0.0.0` would let anyone on the network use your key through it |
| `OPENAI_UPSTREAM`, `ANTHROPIC_UPSTREAM` | official APIs | Azure OpenAI, another proxy, a local model |
| `SIRDAS_LABELS` | `en` | `es` for `[NOMBRE_1]`, `auto` to follow the text |
| `SIRDAS_GATEWAY_POLICY` | none | Per-key rules, see below |
| `SIRDAS_GATEWAY_QUIET` | off | Silence the per-request log line |

A Docker image (`ghcr.io/brayan15p/sirdas-gateway`) ships with the release; run
it with `-p 127.0.0.1:8787:8787` so the port is only reachable from the host.

## Per-key policy

One gateway, several applications, different rules. `SIRDAS_GATEWAY_POLICY`
points at a JSON file that says what each API key may do. Keys are listed by
their **sha256**, never in clear: the file is safe to commit and review.

```json
{
  "version": 1,
  "name": "acme",
  "keys": [
    { "name": "support bot", "sha256": "3f1c…", "upstreams": ["anthropic"], "models": ["claude-haiku*"], "block": ["SSN", "MEDICAL_RECORD"] },
    { "name": "underwriting", "sha256": "9ab0…" }
  ]
}
```

| Field | Effect |
|---|---|
| `upstreams` | `openai`, `anthropic`, or both (default) |
| `models` | Allowed models; a trailing `*` is a prefix |
| `block` | Placeholder types that make the request fail with `422` instead of leaving redacted (`SSN`, `MEDICAL_RECORD`, `PERSON`, …) |
| `allowImages` | Per-key override of `SIRDAS_GATEWAY_ALLOW_IMAGES` |
| `default` | Rule for keys not listed. Without it, unlisted keys get `403` |

```bash
printf %s "$ANTHROPIC_API_KEY" | sirdas-gateway hash-key
```

A malformed policy (unknown field, unknown type, a key pasted in clear) stops
the gateway from starting: it never runs with fewer rules than you wrote. A
refused request is not forwarded, and the log line names the rule, not the key.

## Deploy it on your infrastructure

`deploy/docker-compose.yml` (in the package and the repository) runs the image
with the port published only to the host, the policy mounted read-only, a
read-only container filesystem and no extra privileges. A test fails if any of
those lines changes.

```bash
cp policy.example.json policy.json        # one entry per API key, by sha256
printf %s "$OPENAI_API_KEY" | docker run --rm -i ghcr.io/brayan15p/sirdas-gateway:0.5.0 hash-key
docker compose up -d
```

The gateway is a control only when it is the only way out: keep the real API
keys in the apps that go through it, and block direct egress to the providers
at your network.

## Limits, stated before you find them

- Redaction is **rule-based**. It finds labelled names («Patient Name:»,
  «Account holder:») and every later mention of them, but misses a name that
  never appears next to a label.
- Against the 18 HIPAA Safe Harbor identifiers it covers 16 fully or partly;
  it does not handle biometric identifiers or face photos, and several are
  only caught next to a label (names, licences, other unique numbers). It is
  not a HIPAA compliance certification, and Sırdaş holds no SOC 2 or ISO 27001
  certification.
- The model still sees the structure of the document with placeholders. If the
  document itself is the secret, the right answer is a local model:
  `OPENAI_UPSTREAM` accepts any compatible endpoint.
- This is the only Sırdaş package that uses the network, and only towards the
  upstream you configure. The CLI and the MCP server never do.
