PII round-trip for LLM APIs. Anonymise before the request, de-anonymise the stream on the way back.
4
stars
131
commits
Python
primary language
Jul 11, 2026
updated
Swiss companies want to use GPT, Claude, and Gemini. Their lawyers do not. Every prompt with a customer name, a Swiss address, or an IBAN is a compliance problem the moment it crosses the Atlantic.
gheim solves it locally. A small on-device model finds the PII, swaps it for
stable sentinels, sends the redacted text to any LLM, and restores the
originals as the response streams back. The user sees Joel. OpenAI only ever
sees <PERSON_1>.
The round-trip is four steps: (1) detect PII spans on the input, (2) swap
each span for a stable sentinel like <PERSON_1> or <ACCOUNT_1>, (3)
send the redacted text to any LLM, (4) substitute the originals back into
the response on the way out, including across SSE token boundaries via a
bounded hold-back buffer.
| what | where | how | |
|---|---|---|---|
| 🌐 | Live demo | gheim.ch | runs the model in your browser |
| 🐍 | Python SDK | packages/gheim-py · PyPI | uv add "gheim[openai]" |
| 🟦 | JS / TS SDK | packages/gheim-js · npm | npm install gheim openai |
| 🐳 | Detection server | server | docker pull ghcr.io/joelbarmettlerUZH/gheim-server |
| 🤗 | Model | gheim-ch-560m · model card | 560M, Apache 2.0, ONNX int8 |
| 📚 | Dataset | gheim-ch-pii-171k · dataset card | 171k chunks, 5 langs, CC BY 4.0 |
uv add "gheim[local,openai]"
from gheim.openai import OpenAI # same constructor as openai.OpenAI
client = OpenAI() # detector defaults to joelbarmettler/gheim-ch-560m
r = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Hi, my name is Joel."}],
)
# r.choices[0].message.content contains "Joel".
# OpenAI only ever saw "<PERSON_1>".
Custom endpoint or key (e.g. OpenRouter, local vLLM) — pass straight through:
client = OpenAI(api_key="sk-or-...", base_url="https://openrouter.ai/api/v1")
Streaming, async (AsyncOpenAI), tool calls, and 9 other text-carrying
endpoints (responses, embeddings, moderations, audio.*, images.*)
are wrapped automatically. See the
Python SDK README for the full surface.
npm install gheim openai @huggingface/transformers
import { OpenAI } from "gheim/openai";
const client = new OpenAI(); // detector defaults to gheim-ch-560m
const r = await client.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: "Hi, my name is Joel." }],
});
apiKey and baseURL work at the top level (mirrors the official
openai client). Dual ESM + CJS, full .d.ts, Node 18+, Bun 1.1+,
modern browsers (auto WebGPU + WASM fallback). See the
JS SDK README.
docker run -p 8080:8080 -e GHEIM_API_KEYS=your-key \
ghcr.io/joelbarmettlerUZH/gheim-server:latest
from gheim import RemoteDetector
det = RemoteDetector(base_url="http://your-host:8080", api_key="your-key")
Model weights are baked into the image, HF_HUB_OFFLINE=1 at runtime;
works in air-gapped environments. See the server README.
joelbarmettler/gheim-ch-560m is the default detector when running locally:
a fine-tune of xlm-roberta-large on 171k Swiss-domain chunks across the
four official Swiss languages (de_CH, fr_CH, it_CH, rm) plus English.
| strict-span F1 | char F1 | |
|---|---|---|
gheim-ch-560m (this) | 0.916 | 0.958 |
openai/privacy-filter (1.4B MoE, zero-shot) | 0.443 | 0.610 |
| Microsoft Presidio Analyzer | 0.434 | 0.562 |
Davlan/xlm-roberta-base-ner-hrl (PER cell) | n/a | 0.728 PER |
spaCy de/fr/it_core_news_lg (PER cell) | n/a | 0.621 PER |
All seven contestants scored on the same 15,861-chunk held-out test split.
Full comparison matrix and methodology in MODEL_CARD.md;
raw numbers in eval/positioning_matrix.json.
The model also ships as onnx/model_quantized.onnx (int8, 552 MB) so the
demo at gheim.ch runs entirely in the browser via
@huggingface/transformers.
joelbarmettler/gheim-ch-pii-171k is the training corpus released with the
model. 171,336 chunks across de_CH / fr_CH / it_CH / rm / en, 8 PII
categories, BIOES-tagged. Annotations are machine-generated (LLM labelling
plus checksum-validated regex augmentation, with synthetic gap-fill for
sparse cells); a 4-way subagent labelling on a 176-chunk sample yielded
F1 ≈ 0.66 against majority vote, an upper bound on label noise. See
DATASET_CARD.md for the curation pipeline, per-cell
distribution, and reuse instructions.
Apache 2.0; see LICENSE. The published model weights and the
training dataset are licensed Apache 2.0 and CC BY 4.0 respectively;
attribution to the upstream swiss-ai/apertus-pretrain-* corpora is
required when reusing the dataset.
@software{barmettler2026gheim,
author = {Barmettler, Joel},
title = {gheim: PII Round-Trip for LLM APIs},
year = {2026},
url = {https://github.com/joelbarmettlerUZH/gheim}
}
126 commits
5 commits
Python
73.7%
TypeScript
16.7%
TeX
5.4%
Vue
3.0%
PII round-trip for LLM APIs. Anonymise before the request, de-anonymise the stream on the way back.
4
stars
131
commits
Python
primary language
Jul 11, 2026
updated
Swiss companies want to use GPT, Claude, and Gemini. Their lawyers do not. Every prompt with a customer name, a Swiss address, or an IBAN is a compliance problem the moment it crosses the Atlantic.
gheim solves it locally. A small on-device model finds the PII, swaps it for
stable sentinels, sends the redacted text to any LLM, and restores the
originals as the response streams back. The user sees Joel. OpenAI only ever
sees <PERSON_1>.
The round-trip is four steps: (1) detect PII spans on the input, (2) swap
each span for a stable sentinel like <PERSON_1> or <ACCOUNT_1>, (3)
send the redacted text to any LLM, (4) substitute the originals back into
the response on the way out, including across SSE token boundaries via a
bounded hold-back buffer.
| what | where | how | |
|---|---|---|---|
| 🌐 | Live demo | gheim.ch | runs the model in your browser |
| 🐍 | Python SDK | packages/gheim-py · PyPI | uv add "gheim[openai]" |
| 🟦 | JS / TS SDK | packages/gheim-js · npm | npm install gheim openai |
| 🐳 | Detection server | server | docker pull ghcr.io/joelbarmettlerUZH/gheim-server |
| 🤗 | Model | gheim-ch-560m · model card | 560M, Apache 2.0, ONNX int8 |
| 📚 | Dataset | gheim-ch-pii-171k · dataset card | 171k chunks, 5 langs, CC BY 4.0 |
uv add "gheim[local,openai]"
from gheim.openai import OpenAI # same constructor as openai.OpenAI
client = OpenAI() # detector defaults to joelbarmettler/gheim-ch-560m
r = client.chat.completions.create(
model="gpt-4o",
messages=[{"role": "user", "content": "Hi, my name is Joel."}],
)
# r.choices[0].message.content contains "Joel".
# OpenAI only ever saw "<PERSON_1>".
Custom endpoint or key (e.g. OpenRouter, local vLLM) — pass straight through:
client = OpenAI(api_key="sk-or-...", base_url="https://openrouter.ai/api/v1")
Streaming, async (AsyncOpenAI), tool calls, and 9 other text-carrying
endpoints (responses, embeddings, moderations, audio.*, images.*)
are wrapped automatically. See the
Python SDK README for the full surface.
npm install gheim openai @huggingface/transformers
import { OpenAI } from "gheim/openai";
const client = new OpenAI(); // detector defaults to gheim-ch-560m
const r = await client.chat.completions.create({
model: "gpt-4o",
messages: [{ role: "user", content: "Hi, my name is Joel." }],
});
apiKey and baseURL work at the top level (mirrors the official
openai client). Dual ESM + CJS, full .d.ts, Node 18+, Bun 1.1+,
modern browsers (auto WebGPU + WASM fallback). See the
JS SDK README.
docker run -p 8080:8080 -e GHEIM_API_KEYS=your-key \
ghcr.io/joelbarmettlerUZH/gheim-server:latest
from gheim import RemoteDetector
det = RemoteDetector(base_url="http://your-host:8080", api_key="your-key")
Model weights are baked into the image, HF_HUB_OFFLINE=1 at runtime;
works in air-gapped environments. See the server README.
joelbarmettler/gheim-ch-560m is the default detector when running locally:
a fine-tune of xlm-roberta-large on 171k Swiss-domain chunks across the
four official Swiss languages (de_CH, fr_CH, it_CH, rm) plus English.
| strict-span F1 | char F1 | |
|---|---|---|
gheim-ch-560m (this) | 0.916 | 0.958 |
openai/privacy-filter (1.4B MoE, zero-shot) | 0.443 | 0.610 |
| Microsoft Presidio Analyzer | 0.434 | 0.562 |
Davlan/xlm-roberta-base-ner-hrl (PER cell) | n/a | 0.728 PER |
spaCy de/fr/it_core_news_lg (PER cell) | n/a | 0.621 PER |
All seven contestants scored on the same 15,861-chunk held-out test split.
Full comparison matrix and methodology in MODEL_CARD.md;
raw numbers in eval/positioning_matrix.json.
The model also ships as onnx/model_quantized.onnx (int8, 552 MB) so the
demo at gheim.ch runs entirely in the browser via
@huggingface/transformers.
joelbarmettler/gheim-ch-pii-171k is the training corpus released with the
model. 171,336 chunks across de_CH / fr_CH / it_CH / rm / en, 8 PII
categories, BIOES-tagged. Annotations are machine-generated (LLM labelling
plus checksum-validated regex augmentation, with synthetic gap-fill for
sparse cells); a 4-way subagent labelling on a 176-chunk sample yielded
F1 ≈ 0.66 against majority vote, an upper bound on label noise. See
DATASET_CARD.md for the curation pipeline, per-cell
distribution, and reuse instructions.
Apache 2.0; see LICENSE. The published model weights and the
training dataset are licensed Apache 2.0 and CC BY 4.0 respectively;
attribution to the upstream swiss-ai/apertus-pretrain-* corpora is
required when reusing the dataset.
@software{barmettler2026gheim,
author = {Barmettler, Joel},
title = {gheim: PII Round-Trip for LLM APIs},
year = {2026},
url = {https://github.com/joelbarmettlerUZH/gheim}
}
126 commits
5 commits
Python
73.7%
TypeScript
16.7%
TeX
5.4%
Vue
3.0%