Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .github/workflows/openapi.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: OpenAPI spec freshness

on:
pull_request:
paths:
- "api/**"
- "docs/api/**"
- ".github/workflows/openapi.yml"
push:
branches: [main]
paths:
- "api/**"
- "docs/api/**"
- ".github/workflows/openapi.yml"

permissions:
contents: read

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Clone OPF source (workspace member, gitignored locally)
run: git clone --depth 1 https://github.com/openai/privacy-filter privacy-filter

- name: Install uv
uses: astral-sh/setup-uv@v3
with:
enable-cache: true

- name: Set up Python
run: uv python install 3.12

- name: Install opf-api with docs extra
run: uv sync --package opf-api --extra docs

- name: Regenerate spec to a temp dir
run: uv run --package opf-api opf-api-export-openapi --out /tmp/fresh

- name: Diff against committed spec
run: |
set -e
fail=0
for f in openapi.json openapi.yaml; do
if ! diff -u "docs/api/$f" "/tmp/fresh/$f"; then
echo "::error file=docs/api/$f::$f is stale — regenerate with: uv run --package opf-api opf-api-export-openapi --out docs/api"
fail=1
fi
done
exit $fail
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
57 changes: 57 additions & 0 deletions .github/workflows/pages.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
name: Deploy docs to GitHub Pages

on:
push:
branches: [main]
paths:
- "docs/**"
- "api/**"
- ".github/workflows/pages.yml"
workflow_dispatch:

permissions:
contents: read
pages: write
id-token: write

concurrency:
group: pages
cancel-in-progress: false

jobs:
build-and-deploy:
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/checkout@v4

- name: Clone OPF source (workspace member, gitignored locally)
run: git clone --depth 1 https://github.com/openai/privacy-filter privacy-filter

- name: Install uv
uses: astral-sh/setup-uv@v3
with:
enable-cache: true

- name: Set up Python
run: uv python install 3.12

- name: Install opf-api with docs extra
run: uv sync --package opf-api --extra docs

- name: Regenerate OpenAPI spec into docs/api/
run: uv run --package opf-api opf-api-export-openapi --out docs/api

- name: Configure Pages
uses: actions/configure-pages@v5

- name: Upload docs/ as Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs

- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
154 changes: 14 additions & 140 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ PII-Masking-300k doesn't label these, so the harness doesn't grade them — but
- **Organization / work:** `EVENT`, `OCCUPATION`, `ORGANIZATION`, `ORGANIZATION_ID`, `PROJECT`
- **Other:** `DURATION`, `FILENAME`, `LANGUAGE`, `PRODUCT`, `VEHICLE_ID`

Source: `DeidentifyStringRequest.entity_types` enum in [detect.openapi.json](detect.openapi.json).
Source: `DeidentifyStringRequest.entity_types` enum in the Skyflow Detect API spec.

### Hit-rate tuning (historical context)

Expand Down Expand Up @@ -268,35 +268,31 @@ The greedy per-category breakdown stays under the raw view (single per-label tab

## API server

The `api/` directory is a unified FastAPI service exposing all benchmark detectors behind one contract. Pick the backend with the `detector` field; canonical labels apply uniformly across all of them. Interactive docs at `/docs` (Swagger) and `/redoc`. Container build profiles in [api/Dockerfile](api/Dockerfile).
The `api/` directory is a unified FastAPI service exposing every benchmark detector behind one HTTP contract. Pick the backend with the `detector` field on each request; canonical labels apply uniformly across all of them. Container build profiles in [api/Dockerfile](api/Dockerfile).

Run locally:

```sh
DEFAULT_DETECTOR=opf EAGER_LOAD=opf uvicorn opf_api.main:app --reload
```

### Concepts
### API reference

**Detectors** — registered backends (lazy-loaded):
The full reference is generated from the live FastAPI app and published to GitHub Pages. The committed spec lives at [docs/api/openapi.json](docs/api/openapi.json) and is regenerated with:

| Name | Backend | Notes |
|--------------------------|---------------------------------------------------------|-------------------------------------------------------------|
| `opf` | OPF local model, ~2.8 GB | Trained categories only. |
| `skyflow` | HTTP proxy to Skyflow Detect API | Requires `SKYFLOW_VAULT_*` env. `proxy: true` in `/v1/detectors`. |
| `presidio` | Microsoft Presidio, English spaCy model | Registered only if `presidio-analyzer` is installed. |
| `presidio_multilang` | Presidio with all 6 spaCy languages | Each `<lang>_core_news_lg` model must be installed. |
| `gliner` | `urchade/gliner_multi_pii-v1`, multilingual | Registered only if `gliner` is installed. |
| `gliner_nvidia` | `nvidia/gliner-PII`, 570M params | Same vocabulary as `gliner`; GPU recommended. |
| `gliner_gretel_small` | `gretelai/gretel-gliner-bi-small-v1.0` | Uses Gretel's label space. |
| `gliner_gretel_large` | `gretelai/gretel-gliner-bi-large-v1.0` | Uses Gretel's label space. |
| `ai4privacy_modernbert` | ModernBERT-based multilingual anonymiser (~150M params) | Requires `transformers`. 8 languages (en, fr, de, es, it, nl, hi, te). |
```sh
uv run --package opf-api opf-api-export-openapi --out docs/api
```

Hit `GET /v1/detectors` to see what's currently registered in your deployment.
Three in-app reference UIs render the same spec — `/scalar` (Scalar), `/docs` (Swagger UI with try-it-out), `/redoc` (ReDoc). The narrative guides live under [docs/guides/](docs/guides/):

**Canonical labels (15)** — `PERSON`, `EMAIL`, `PHONE`, `ADDRESS`, `URL`, `DATE`, `ACCOUNT`, `SECRET`, `USERNAME`, `DEMOGRAPHIC`, `ORGANIZATION`, `OCCUPATION`, `MONEY`, `VEHICLE`, `PHYSICAL`. Every detector's raw output maps into this taxonomy. The `categories` request filter accepts any of these. Different detectors cover different subsets — `/v1/detectors` reports each detector's category list.
- [overview](docs/guides/overview.md) — what the API does and how to run it.
- [detectors](docs/guides/detectors.md) — the registered backends.
- [labels](docs/guides/labels.md) — the 15-label canonical taxonomy.
- [sanitize modes](docs/guides/sanitize-modes.md) — the four `/v1/sanitize` modes.
- [auth and env](docs/guides/auth-and-env.md) — env-var matrix and error codes.

#### Server env
### Server env

| Variable | Notes |
|--------------------------------|----------------------------------------------------------------------|
Expand All @@ -306,125 +302,3 @@ Hit `GET /v1/detectors` to see what's currently registered in your deployment.
| `OPF_DECODE_MODE` | `viterbi` or `argmax`. OPF-only. Default `viterbi`. |
| `SKYFLOW_VAULT_URL` / `_ID` / `_BEARER_TOKEN` | Required for the `skyflow` **detector**. |
| `SKYFLOW_TOKEN_VAULT_URL` / `_ID` / `_BEARER_TOKEN` | Required for `/v1/sanitize` `label_token` mode. See [docs/token-vault-setup.md](docs/token-vault-setup.md). |

---

### `POST /v1/detect`

Returns detected spans. No text rewriting.

Request:

```json
{
"text": "Email joe@example.com about the trip to Elgin, TX.",
"detector": "presidio", // optional, defaults to DEFAULT_DETECTOR
"categories": ["EMAIL"], // optional canonical filter
"decode_mode": "viterbi" // optional, OPF-only
}
```

Response (`200`):

```json
{
"schema_version": 1,
"detector": "presidio",
"text": "Email joe@example.com about the trip to Elgin, TX.",
"detected_spans": [
{"label": "EMAIL", "raw_label": "EMAIL_ADDRESS",
"start": 6, "end": 21, "text": "joe@example.com"}
],
"summary": {"span_count": 1, "by_label": {"EMAIL": 1}},
"warning": null
}
```

Errors: `400` unknown detector or unknown category. `502` detector backend failure.

---

### `POST /v1/sanitize`

Detect + rewrite each detected span under the chosen `mode`. Four modes, in increasing strength of identity preservation:

| `mode` | Looks like | What it preserves |
|----------------|--------------------|-------------------|
| `redact` | `********` | Nothing — fixed 8-character asterisk run regardless of span length. |
| `label` | `[EMAIL]` | Category only. Default mode. |
| `label_number` | `[EMAIL_1]` | Identity **within one request**. Per-label counter; duplicate `(label, text)` reuses its number. |
| `label_token` | `[EMAIL_MGaE1Bo]` | Identity **across requests and detectors** via a Skyflow vault. Deterministic — same plaintext → same 7-char token forever. |

Request:

```json
{
"text": "Email alice@x.com or call +1-415-555-0100.",
"detector": "presidio",
"mode": "label_token"
}
```

Response (`200`):

```json
{
"schema_version": 1,
"detector": "presidio",
"mode": "label_token",
"text": "Email alice@x.com or call +1-415-555-0100.",
"sanitized_text": "Email [EMAIL_MGaE1Bo] or call [PHONE_vRXiWKZ].",
"detected_spans": [
{"label": "EMAIL", "raw_label": "EMAIL_ADDRESS",
"start": 6, "end": 17, "text": "alice@x.com",
"replacement": "[EMAIL_MGaE1Bo]"},
{"label": "PHONE", "raw_label": "PHONE_NUMBER",
"start": 27, "end": 42, "text": "+1-415-555-0100",
"replacement": "[PHONE_vRXiWKZ]"}
],
"summary": {"span_count": 2, "by_label": {"EMAIL": 1, "PHONE": 1}},
"warning": null
}
```

Overlapping spans: the earlier-starting span wins; later overlaps are skipped in `sanitized_text` (still listed in `detected_spans`).

`label_token` requires `SKYFLOW_TOKEN_VAULT_URL` + `SKYFLOW_TOKEN_VAULT_ID` and a bearer (`SKYFLOW_TOKEN_BEARER_TOKEN`, or falls back to `SKYFLOW_BEARER_TOKEN`). The vault must be configured per [docs/token-vault-setup.md](docs/token-vault-setup.md) — one table with one `tok_<label>` column per canonical label, each `DETERMINISTIC_FPT` with regex `^[A-Za-z0-9]{7}$`.

Errors: `400` for unknown detector / unknown category / `label_token` without vault env. `502` for detector backend failure or vault call failure. Spans whose canonical label has no vault column fall back to `[LABEL]` for that span only.

---

### `GET /v1/detectors`

Lists registered detectors plus the categories each can produce.

```json
{
"default": "opf",
"detectors": [
{"name": "gliner", "categories": ["PERSON", "EMAIL", "..."], "loaded": false, "proxy": false},
{"name": "opf", "categories": ["PERSON", "EMAIL", "..."], "loaded": true, "proxy": false},
{"name": "skyflow", "categories": ["PERSON", "EMAIL", "..."], "loaded": false, "proxy": true}
]
}
```

`loaded` flips to `true` after first use (or at startup if listed in `EAGER_LOAD`). `proxy=true` means the detector calls an external service.

---

### `GET /v1/health`

Liveness + which detectors are loaded.

```json
{
"status": "ok",
"default_detector": "opf",
"loaded_detectors": ["opf"],
"schema_version": 1
}
```

Always `200` when the process is up; does not probe detector backends.
5 changes: 5 additions & 0 deletions api/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,17 @@ dependencies = [
"uvicorn[standard]>=0.30",
"pydantic>=2.6",
"httpx>=0.27",
"scalar-fastapi>=1.0,<2.0",
]

[project.optional-dependencies]
gliner = ["gliner>=0.2.13"]
presidio = ["presidio-analyzer>=2.2"]
all = ["gliner>=0.2.13", "presidio-analyzer>=2.2"]
docs = ["pyyaml>=6.0,<7.0"]

[project.scripts]
opf-api-export-openapi = "opf_api.scripts.export_openapi:main"

[build-system]
requires = ["hatchling"]
Expand Down
68 changes: 64 additions & 4 deletions api/src/opf_api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
os.environ.setdefault("OPF_MOE_TRITON", "0")

from fastapi import FastAPI # noqa: E402
from scalar_fastapi import get_scalar_api_reference # noqa: E402

from .registry import build_default_registry # noqa: E402
from .routes import router # noqa: E402
Expand Down Expand Up @@ -60,13 +61,72 @@ async def lifespan(app: FastAPI):
client.close()


API_DESCRIPTION = """\
Unified PII detection across **OPF**, **GLiNER**, **Presidio**, and **Skyflow**.
Pick a backend with the `detector` field; canonical labels apply uniformly across all of them.

## Endpoints

- `POST /v1/detect` — return spans only, no text rewriting.
- `POST /v1/sanitize` — detect and rewrite spans under one of four modes.
- `GET /v1/detectors` — list registered detectors and their category coverage.
- `GET /v1/health` — liveness probe; does not exercise detector backends.

## Versioning

Two version numbers appear in this API. They are independent:

- `info.version` (this spec) — tracks the OpenAPI contract.
- `schema_version` (response payload field) — tracks the request/response payload shape.
Currently `2`. Bumps when payload field names or semantics change. Clients should pin on this.

## Canonical labels

Every detector's raw output maps into a 15-label taxonomy:
`PERSON`, `EMAIL`, `PHONE`, `ADDRESS`, `URL`, `DATE`, `ACCOUNT`, `SECRET`, `USERNAME`,
`DEMOGRAPHIC`, `ORGANIZATION`, `OCCUPATION`, `MONEY`, `VEHICLE`, `PHYSICAL`.
Detectors vary in coverage — `GET /v1/detectors` reports each detector's category list.
"""

OPENAPI_TAGS = [
{
"name": "Detect",
"description": "Detection-only endpoint. Returns spans without rewriting the input.",
},
{
"name": "Sanitize",
"description": (
"Detection plus rewriting under one of four modes: "
"`redact`, `label`, `label_number`, `label_token`."
),
},
{
"name": "Meta",
"description": "Registry and liveness endpoints.",
},
]

app = FastAPI(
title="Privacy-detection API",
version="0.2.0",
summary="Unified PII detection across OPF, GLiNER, Presidio, and Skyflow.",
description=API_DESCRIPTION,
lifespan=lifespan,
description=(
"Unified PII detection across OPF, GLiNER, Presidio, and Skyflow. "
"Pick a backend with the `detector` field; canonical labels apply uniformly."
),
openapi_tags=OPENAPI_TAGS,
contact={
"name": "local-privacy",
"url": "https://github.com/jstjoe/local-privacy",
},
servers=[
{"url": "http://localhost:8000", "description": "Local dev"},
],
)
app.include_router(router, prefix="/v1")


@app.get("/scalar", include_in_schema=False)
async def scalar_html():
return get_scalar_api_reference(
openapi_url=app.openapi_url,
title=app.title,
)
Loading
Loading