joelbarmettlerUZH/gheim

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

gheim.ch/

README

gheim

PII round-trip for LLM APIs. Anonymise before the request, de-anonymise the stream on the way back.

gheim.ch PyPI npm HF model HF dataset ghcr Apache 2.0 GitHub stars


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 ships

whatwherehow
🌐Live demogheim.chruns the model in your browser
🐍Python SDKpackages/gheim-py · PyPIuv add "gheim[openai]"
🟦JS / TS SDKpackages/gheim-js · npmnpm install gheim openai
🐳Detection serverserverdocker pull ghcr.io/joelbarmettlerUZH/gheim-server
🤗Modelgheim-ch-560m · model card560M, Apache 2.0, ONNX int8
📚Datasetgheim-ch-pii-171k · dataset card171k chunks, 5 langs, CC BY 4.0

Quick start

Python · drop-in OpenAI client

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.

JavaScript / TypeScript

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.

Self-host the detection server

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.

The Swiss-tuned model

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 F1char F1
gheim-ch-560m (this)0.9160.958
openai/privacy-filter (1.4B MoE, zero-shot)0.4430.610
Microsoft Presidio Analyzer0.4340.562
Davlan/xlm-roberta-base-ner-hrl (PER cell)n/a0.728 PER
spaCy de/fr/it_core_news_lg (PER cell)n/a0.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.

The dataset

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.

License

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.

Citation

@software{barmettler2026gheim,
  author = {Barmettler, Joel},
  title  = {gheim: PII Round-Trip for LLM APIs},
  year   = {2026},
  url    = {https://github.com/joelbarmettlerUZH/gheim}
}

Contributors

claude

5 commits

joelbarmettlerUZH/gheim

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

gheim.ch/

README

gheim

PII round-trip for LLM APIs. Anonymise before the request, de-anonymise the stream on the way back.

gheim.ch PyPI npm HF model HF dataset ghcr Apache 2.0 GitHub stars


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 ships

whatwherehow
🌐Live demogheim.chruns the model in your browser
🐍Python SDKpackages/gheim-py · PyPIuv add "gheim[openai]"
🟦JS / TS SDKpackages/gheim-js · npmnpm install gheim openai
🐳Detection serverserverdocker pull ghcr.io/joelbarmettlerUZH/gheim-server
🤗Modelgheim-ch-560m · model card560M, Apache 2.0, ONNX int8
📚Datasetgheim-ch-pii-171k · dataset card171k chunks, 5 langs, CC BY 4.0

Quick start

Python · drop-in OpenAI client

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.

JavaScript / TypeScript

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.

Self-host the detection server

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.

The Swiss-tuned model

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 F1char F1
gheim-ch-560m (this)0.9160.958
openai/privacy-filter (1.4B MoE, zero-shot)0.4430.610
Microsoft Presidio Analyzer0.4340.562
Davlan/xlm-roberta-base-ner-hrl (PER cell)n/a0.728 PER
spaCy de/fr/it_core_news_lg (PER cell)n/a0.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.

The dataset

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.

License

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.

Citation

@software{barmettler2026gheim,
  author = {Barmettler, Joel},
  title  = {gheim: PII Round-Trip for LLM APIs},
  year   = {2026},
  url    = {https://github.com/joelbarmettlerUZH/gheim}
}

Contributors

claude

5 commits

Languages

Python

73.7%

TypeScript

16.7%

TeX

5.4%

Vue

3.0%