@octabits-io/disclosure
Pseudonymisation protects personal data. It does nothing for the content that actually hurts when it reaches a model vendor: which system runs in which zone, which finding is still open, what a customer’s contract says. This package puts a disclosure gate in front of every model call. The call site declares what the content is, each model destination declares where it runs, and one fixed rule decides:
| Class | Content | May reach |
|---|---|---|
public |
published material, standards text, generic questions | any destination |
internal |
your records, but nothing that names what must stay in | a vendor only redacted, inside the gate; a self-hosted model as it is |
confidential |
your systems, zones, findings, anything a call declares nothing for | a self-hosted model only |
A call that declares no class is confidential, so a forgotten declaration
fails closed. No model decides the class of the content it is about to
receive. A detector may take a second look and raise a call’s class —
never lower it — for the place a declaration cannot cover: free text a person
typed, declared public because the feature is, carrying an IBAN or a sick
note. Every call — sent, failed or denied — is recorded as the SHA-256
digest of what was sent and never the payload itself, or the audit trail
would be the leak. And a check fails your lint when a model is reachable
without the gate.
The root needs zod and nothing else, so a settings page can show the same
policy the gate enforces, and it carries guardModelCall, the gate around any
function that sends a payload to a model. ./ai-sdk binds it to the Vercel AI
SDK (ai and @ai-sdk/provider optional peers); ./postgres and ./drizzle keep the
records (drizzle-orm an optional peer for the column set). No node:*: Node,
Bun, Deno, edge runtimes.
npm i @octabits-io/disclosure zod # add ai for ./ai-sdk
The gate on an AI SDK model
import { guardLanguageModel, isDisclosureDeniedError } from '@octabits-io/disclosure/ai-sdk';
import { generateText } from 'ai';
// Where you build models, and only there.
const assessmentModel = guardLanguageModel(vendorModel, {
destination: { id: 'mistral-eu', region: 'eu' },
class: 'internal',
redact: (text) => pseudonymise(text), // runs inside the gate, on every text the call carries
audit: { sink: (record) => disclosureLog.append(record) },
});
const { text } = await generateText({ model: assessmentModel, prompt }); // the call site does not change
try {
await generateText({ model: guardLanguageModel(vendorModel, { destination, audit }), prompt });
} catch (error) {
if (isDisclosureDeniedError(error)) {
// error.reason === 'confidential_leaves_the_house' — no class declared, nothing was sent
}
}
guardEmbeddingModel is the same gate for embed and embedMany: the values
are the payload, redacted one by one when they must be.
What every call goes through:
- Decide.
evaluateDisclosurefrom the declared class and the destination. A denial is recorded and thrown asDisclosureDeniedError(key: 'disclosure_denied',reason,class,destinationId) before the model is called. - Redact when the decision says so —
internalcontent for a destination outside the house. Yourredactruns over the system prompt, every message text, tool inputs and tool results. Without aredactthose calls are denied (redaction_required); a file or raw bytes cannot be redacted as text and are denied too (unredactable_content). With a detector that counts personal data, a redaction that replaced fewer occurrences than it counted is denied (redaction_incomplete): what a detector saw and no redactor recognised would leave in clear. - Digest, call, record. The payload as sent is digested, the model is
called, and the record goes to your sink with
outcome: 'sent'or'failed'(a thrown call may still have left the house). A sink that throws goes toaudit.onSinkErrorand never changes the call.
Wrap outermost — around the refusal and usage wrappers you already have, and
around octaflow’s circuit breaker — so the gate sees the call first:
guardLanguageModel(withCircuitBreaker(createRefusalAwareModel(createInstrumentedModel(model, usage))), options).
The breaker inside the gate means a denial never reaches it and is never
counted as a vendor fault; a breaker outside the gate would still not trip on
a denial, but its notice would be the gate’s to give.
The gate on any other model call
Not every model is an AI SDK model: a vendor’s own client, a classifier or a
reranker behind fetch, a typed-decision API that returns a choice and a
probability rather than text. guardModelCall is the same gate around the
function that sends the payload:
import { guardModelCall } from '@octabits-io/disclosure';
// Where you build models, and only there.
const decide = guardModelCall((request: DecisionRequest) => vendor.decide(request), {
model: { provider: 'vendor', modelId: 'decision-1' }, // what the record names
destination: { id: 'vendor-eu', region: 'eu' },
class: 'internal',
redact: (text) => pseudonymise(text),
structuralKeys: ['schema'], // your question and its choices: not redacted, still digested
audit: { sink: log.sink({ scopeKey: orgId, correlationId: runId }) },
});
const answer = await decide(request); // Result<Decision, DisclosureDenied>
if (!answer.ok) {
// answer.error.reason === 'redaction_required' | 'confidential_leaves_the_house' | …; send was never called
}
The decision, the redaction, the digest and the record are the ones above.
The payload is the one argument: send receives it as it may leave (redacted
when the decision says so) and must send nothing else — whatever it adds from
a closure is outside the digest and outside the redaction. The redactor runs
over every string in the payload except under structuralKeys, which your code
writes (a request’s schema, its enum of choices, ids); a key named there that
carries content leaves unredacted, so name only what your code builds. Bytes
or a file in content that must be redacted are denied, in the AI SDK’s shape
or a vendor’s (media_type, mime_type or mimeType beside data or url),
and so is a file inlined as a base64 data: URL. The payload is plain data and
bytes: an object whose content is not in its own keys (a Blob, FormData, a
Map, a stream) or one that refers to itself has no digest, so the call throws
a TypeError before anything is decided, recorded or sent. A denial is a
value, recorded, and send is never called; a send that throws is recorded
as failed and rethrown. operation on the record defaults to 'generate'.
Destinations
{ id: 'local-llm', region: 'self-hosted' } // inside the house: receives every class
{ id: 'mistral-eu', region: 'eu' } // a vendor: public, and internal redacted
{ id: 'openai-us', region: 'third-country' } // the same rule; the region is for the record
{ id: 'shared-gpu', region: 'self-hosted', maxClass: 'internal' } // a lower ceiling than the region allows
maxClass can only narrow. A vendor destination claiming confidential is
refused when the guard is built, as is an unknown region or class — those are
programming errors and throw.
Routing by class
A host with more than one model — a gateway’s aliases, a vendor and a model in the house — names the class at the call site and lets the routes pick the model:
import { routeLanguageModels } from '@octabits-io/disclosure/ai-sdk';
const models = routeLanguageModels({
routes: {
public: { model: gateway.languageModel('approval-public'), destination: { id: 'approval-public', region: 'eu' } },
confidential: { model: gateway.languageModel('approval-confidential'), destination: { id: 'approval-confidential', region: 'self-hosted' } },
},
redact,
audit: { sink },
});
await generateText({ model: models.languageModel('internal'), prompt });
A call takes the route of its own class. A class without one takes the nearest
route of a more sensitive class — above, internal goes to the confidential
route, inside the house. With nothing above it, it takes the nearest route
below, and the gate there refuses it and records why. The routes are checked
when they are built: a route whose destination would refuse its own class — a
confidential route outside the house, an internal one outside it with no
redact — throws. resolveDisclosureRoute and checkDisclosureRoutes are the
same rules as plain functions at the root, for a settings page.
A class per field
A host that sends structured data knows what each field is, and can say it once, on its Zod schema, instead of at every call:
import { disclosureClassOf, disclosureFieldClasses } from '@octabits-io/disclosure';
const Ticket = z.object({
product: z.string().meta({ disclosure: 'public' }),
customer: z.object({ name: z.string(), email: z.string() }).meta({ disclosure: 'internal' }),
notes: z.string().meta({ disclosure: 'internal' }).optional(),
diagnosis: z.string().meta({ disclosure: 'confidential' }).optional(),
});
const cls = disclosureClassOf(Ticket, ticket); // the most sensitive field it carries
await generateText({ model: models.languageModel(cls), prompt: render(ticket) });
- The record’s class is the most sensitive class among the fields it
carries. A field that is
undefinedornullcarries nothing and counts for nothing; a record with no field carrying content ispublic. - A field without a class is
confidential, as a call without one is: a field added to the schema and forgotten in the annotation fails closed. So is a key the schema does not name (a loose object). - A class on an object or an array covers everything below it; without one,
the walk goes into its fields and items. Wrappers —
optional,nullable,default— are looked through. disclosureFieldClasses(schema, value)lists every field that counted, with its path (customer.email,lines[0].price), its class and whether it was declared: what a settings page or a test shows to explain a record’s class.- The field classes decide the declared class; they do not reach into the gate. A detector on the guard still reads the whole call — the gate cannot tell which text of a rendered prompt came from which field.
Detection: raise a class, never lower it
The declared class holds for code paths. It does not hold for text people
type: a chat or a form declared internal, and an admin password pasted into
it. A detector reads the texts a call carries and returns the class they need
at least; the gate decides on the more sensitive of the declared class and the
detector’s raise.
import { combineDetectors, createPatternDetector } from '@octabits-io/disclosure';
const model = guardLanguageModel(base, {
destination: { id: 'mistral-eu', region: 'eu' },
class: 'internal', redact,
detect: combineDetectors(createPatternDetector(), modelDetector), // a detector over a self-hosted model is the host's
audit: { sink },
});
- It can only make a call stricter. A detector that misses something
leaves the call as declared — as without a detector. One that sees too much
costs a redaction or a denial, never a leak. A detector cannot answer
public; an answer that is notinternalorconfidentialis a failure. - It fails closed. A detector that throws, does not answer within
detectTimeoutMs(15 s by default; it is aborted through the signal it was given) or answers something unreadable makes the callconfidential.onDetectErrorhears why. - It runs only where a raise can change the decision. Content already
confidential, a self-hosted destination that takes every class, a call already denied: the detector does not run, and the record saysskipped. On a vendor route it runs on every call, so its latency is the price of sending content out of the house. - It reads what the redactor reads, before the redactor: the system prompt, messages, tool inputs and results, embedding values — not the structural keys, not files or bytes.
- It must run in the house. It sees the payload before the gate decides where it may go: a detector on a vendor’s endpoint has already sent what it was meant to judge.
- It is counted, never quoted. It returns finding types and counts; the
record keeps those, and a finding type that looks like found text (anything
beyond letters, digits and
_ . : -, or longer than 64 characters) fails the detection instead of reaching the log.
createPatternDetector() finds structured identifiers by their shape and
their check digits — IBAN (mod 97), the German tax ID and health insurance
number, phone numbers, e-mail addresses — with no dependency, in microseconds,
and raises to internal. It finds no names, no health data, no passwords:
that takes a model. combineDetectors(...detectors) runs several over the
same texts at once and takes the highest raise; one that fails fails the
whole. A detector is any object with an id, an optional version and
detect(texts, { signal }) — the record names both, so a result can be traced
to the detector and the prompt that produced it. A result may say how many
occurrences of personal data it found (identifiers; the pattern, model and
Presidio detectors do); combined detectors report the highest count, not the
sum, and where the gate redacts it denies a call whose redaction replaced
fewer.
A model you run as the detector
Names, health data, a password, a vulnerability: that takes a language model,
and ./ai-sdk turns one into a detector. It must run in the house — building
it with a destination that is not self-hosted throws.
import { combineDetectors, createPatternDetector } from '@octabits-io/disclosure';
import { detectorFromModel, guardLanguageModel } from '@octabits-io/disclosure/ai-sdk';
const detect = combineDetectors(
createPatternDetector(),
detectorFromModel(local.languageModel('qwen3.6-35b-a3b'), {
destination: { id: 'detector', region: 'self-hosted' },
providerOptions: { local: { chat_template_kwargs: { enable_thinking: false } } }, // a thinking model, turned down
}),
);
const model = guardLanguageModel(vendorModel, { destination, class: 'internal', redact, detect, audit: { sink } });
- The model is asked, with
detectorPrompt(), for personal identifiers and for sensitive content — GDPR Art. 9/10 data, credentials, network facts, vulnerabilities, prices, salaries, procurement — as kinds and counts, never quotes. A sensitive finding raises toconfidential, an identifier tointernal. - The record names the detector as the model (
<provider>/<modelId>, orid) and its version as the prompt (DETECTOR_PROMPT_VERSION): a result depends on both. - An answer that cannot be read — prose, a cut-off list, a reasoning model’s
answer without its final channel — throws, and the gate makes the call
confidential. Reasoning left in an answer (
<think>, the harmony analysis channel) is skipped. A finding type outside the kinds the prompt names is recorded asidentifierorsensitive, so nothing the model wrote reaches the record. - Pass the model unguarded: its call stays in the house and must not run
through the gate it serves. A call longer than
maxChars(24 000) is read in parts. detectorPrompt,detectorInputandparseDetectorAnswerare at the root, for a host whose model runtime is not the AI SDK.
Which model is a measurement, not a default. On the German set in
evals/pii-detection-de (72 prompts, one laptop), Qwen3.6 35B-A3B leaves 1
prompt too low at 0.65 s, Mistral Small 3.2 24B 3 at 4.8 s; of ten models
measured there with a quoting prompt, the weakest left 47. Size and maker
predict little. Measure the model
you would run, on your own prompts, before you trust it: the repository has
the test set and run_detector.mjs, which runs exactly this prompt.
GLiNER behind Presidio’s analyzer API
A model misses names now and then — a lower-case müller, a bare Ronny — and
GLiNER, a small span model, misses what a model finds. Together they left no
German text of the test sets too low; on long business text, GLiNER also
raised every harmless one (below). GLiNER has no HTTP interface of its own;
Presidio’s analyzer can host it, and infra/single-host in the repository
ships a smaller service that speaks the same API — GLiNER in Rust on ONNX
Runtime, with no Python in the image, answering span for span what Presidio
hosting GLiNER answers on every text of the eval. ./presidio is a detector
and a redactor over that REST API (fetch, no dependency), whichever of the
two answers it:
import { createPresidioDetector } from '@octabits-io/disclosure/presidio';
const detect = combineDetectors(
createPatternDetector(),
detectorFromModel(localModel, { destination: inside }),
createPresidioDetector({ url: 'http://presidio-analyzer:3000', destination: inside, version: 'presidio-gliner-1' }),
);
- Each text of a call is one
/analyzerequest (languagedefaultde). An entity type insensitiveEntitiesraises toconfidential, any other tointernal; the record keeps the entity types with counts. - The analyzer is configured, not this package. The configuration it was
measured with is
evals/pii-detection-de/presidio-gliner.yamlin the repository: no NLP engine, GLiNER as the only recognizer, told the same categories as the model, each label mapped to an entity type —PRESIDIO_SENSITIVE_ENTITIESare its sensitive ones. Presidio’s own pattern recognizers add little beside a model andcreatePatternDetector, and more false alarms. - What it costs: on short prompts GLiNER flags about one harmless German
prompt in five, where the models alone flag none. On long business text —
a pasted e-mail thread, a policy, release notes — it reads ordinary nouns as
its sensitive labels (“Lieferung” as procurement, public rates as prices,
at scores as high as its true findings) and raised every harmless long text
measured to
confidential. Friction, not a leak, but where vendor routes should carry pasted documents it closes them; detect with the patterns and a model, and use GLiNER to redact. Some 60 ms per short prompt, 0.5 s per long text, on a laptop CPU. - Building it with a destination that is not
self-hostedthrows, and so does a URL that is nothttp(s). A failed request or an answer that is not a list of results throws, and the gate makes the call confidential.
A detector bounds even a model you would not otherwise trust: it can only fail to raise — the call then goes out as declared, as with no detector — or raise too far, which costs friction. It cannot make a call more permissive, and served with no route out it has nowhere to send what it reads.
Inside a LiteLLM proxy
Where the prompts come from no code of yours — people in a chat client, tools
pointed at a company model proxy — the gate sits in the proxy. ./litellm is a
fetch handler for LiteLLM’s Generic Guardrail API: LiteLLM posts each request’s
texts to it before sending them on, and every request is decided like a
guarded model call.
import { createLiteLlmGuardrailHandler } from '@octabits-io/disclosure/litellm';
const guardrail = createLiteLlmGuardrailHandler({
destinations: [{ id: 'mistral-eu', region: 'eu' }, { id: 'local', region: 'self-hosted' }],
models: { 'chat-eu': 'mistral-eu', 'chat-local': 'local' }, // what LiteLLM's model_list routes each alias to
classOf: ({ teamAlias }) => (teamAlias === 'marketing' ? 'internal' : undefined), // undeclared → confidential
redact, detect, token: process.env.GUARDRAIL_TOKEN,
audit: { sinkFor: (info) => log.sink({ correlationId: info.callId }) },
});
// Serve it at <api_base>/beta/litellm_basic_guardrail_api — e.g. toNodeHandler(guardrail) from @octabits-io/server/node.
# LiteLLM config.yaml — one guardrail, on every request
guardrails:
- guardrail_name: disclosure
litellm_params:
guardrail: generic_guardrail_api
mode: pre_call
default_on: true
api_base: http://disclosure-guardrail:3000
api_key: os.environ/GUARDRAIL_TOKEN
# fail_on_error: true and unreachable_fallback: fail_closed are the defaults — keep them
- The answer: denied →
BLOCKEDwith the reason, which the client gets as an error; anything redacted →GUARDRAIL_INTERVENEDwith the texts as they may leave, which LiteLLM sends in place of the originals; otherwiseNONE. - The class comes from who asks:
classOfgets the virtual key, its team, user and organisation as LiteLLM reports them. A requestclassOfdeclares nothing for isconfidential— blocked on every route outside the house, passed to a self-hosted model. - The destination comes from the alias. LiteLLM names the model alias in
each request;
modelssays where the proxy sends each one, because the guardrail cannot see the routing. An aliasmodelsdoes not name is blocked. What the proxy does after the guardrail answered — a fallback — the guardrail never sees: hold the two files together withcheckLiteLlmConfig, below. (Guardrails per model are a LiteLLM Enterprise feature; with them, each guardrail entry can name its destination inadditional_provider_specific_params.destination, andmodelsis left out.) - What LiteLLM cannot take back rewritten — images, tool calls — is denied when the decision says redact. A detector reads the texts.
- It fails closed. A wrong token (
x-api-key, as LiteLLM sends itsapi_key), a body that is not a guardrail request, apost_callentry — a model’s answer is not a disclosure — and a redactor that throws are answered with an error status, which LiteLLM, failing closed, turns into a blocked request. - Call the detector’s model directly, not through the proxy: through it, the detector’s own call comes back to the guardrail.
- Check what LiteLLM keeps. Its own logs and spend tracking can store the original prompt; the disclosure record never does, but it cannot make the proxy’s logs forget.
- The request shape is LiteLLM’s and not a documented contract; the handler is held against LiteLLM 1.103.0 in a Testcontainers suite, on a Postgres with keys and teams, as in production.
liteLlmClassOf builds classOf from data — keys by alias, teams by alias or
id, organisations by id; the most specific entry wins, and a request none of
them names is undeclared:
classOf: liteLlmClassOf({ teams: { marketing: 'internal', docs: 'public' }, keys: { 'press-bot': 'public' } }),
Pseudonyms the answer is read back with
A bare [PERSON] for every name makes two people one, and the answer comes
back with placeholders in it. With pseudonymize: true the redactors write
through a table per call instead: each value its own numbered placeholder —
[PERSON_1], [PERSON_2], [EMAIL_1], the same one wherever it occurs — and
a second LiteLLM entry sends each answer back, which the handler restores from
that call’s table before LiteLLM hands it to the client.
guardrails:
- guardrail_name: disclosure # decides, as above
litellm_params: { guardrail: generic_guardrail_api, mode: pre_call, default_on: true, api_base: http://disclosure-guardrail:3000, api_key: os.environ/GUARDRAIL_TOKEN }
- guardrail_name: disclosure-restore # reads the answer back
litellm_params: { guardrail: generic_guardrail_api, mode: post_call, default_on: true, api_base: http://disclosure-guardrail:3000, api_key: os.environ/GUARDRAIL_TOKEN }
- Numbers hold across a conversation without being stored. They are given
in the order values first occur, and a chat resends its whole history in the
same order every turn: the person who was
[PERSON_1]stays[PERSON_1]. - The table never leaves the house and is kept in memory, by
litellm_call_id, until the answer comes back (ttlMs, default ten minutes;maxCalls, default 10 000). Behind several guardrail replicas, route a call’s request and answer to the same one. - A streamed answer keeps its placeholders. LiteLLM runs a
post_callguardrail on a stream after the client has it — proven against 1.103 by the integration suite. Turn streaming off for the models that leave the house (Open WebUI: Stream Chat Response off on the model, which wins over the chat’s and the account’s). - It fails toward placeholders, never toward a leak: an expired table, a
restart, a stream — the reader sees
[PERSON_1], the vendor never saw more. - The redactors take the table from the call’s
RedactionContext(createPatternRedactor,createPresidioRedactorandcombineRedactorsdo); one of your own that ignores the context replaces as it always did.createPseudonymTableis exported for a gate of your own.
checkLiteLlmConfig(…, { pseudonymize: true }) wants an entry that runs
post_call; without pseudonymize, such an entry is an error, because the
guardrail refuses answers and LiteLLM fails every call on that. In the
service’s file it is redact.pseudonymize: true (or { ttlSeconds, maxCalls }).
Routes the gate chooses: “Auto”
A person who picks the model has to know what their text is. With routes,
the gate picks: a request for auto goes to the first alias the gate lets it
reach — auto: [chat-eu, chat-local] sends an internal text to the vendor,
pseudonymised, and a password to the model in the house, answered instead of
refused.
LiteLLM cannot route on a guardrail’s answer — the Generic Guardrail API takes
back texts and images, never a model or a tag — and its own auto router and tag
routing decide after the guardrail ran for the alias asked for: proven against
1.103, a password sent to auto reached the vendor, and the guardrail saw
auto. So the package ships a LiteLLM plugin, plugins/litellm/disclosure_router.py,
that runs before the guardrail, asks it at POST /route and rewrites the
request’s model; LiteLLM routes on that, and the guardrail entry then decides
for the alias chosen, reusing the decision /route made for the call — one
detection, one record per destination tried.
# LiteLLM config.yaml — the router first: guardrails run in the order listed
guardrails:
- guardrail_name: disclosure-router
litellm_params: { guardrail: disclosure_router.DisclosureRouter, mode: pre_call, default_on: true }
- guardrail_name: disclosure
litellm_params: { guardrail: generic_guardrail_api, mode: pre_call, default_on: true, api_base: http://disclosure-guardrail:3000, api_key: os.environ/GUARDRAIL_TOKEN }
# plus DISCLOSURE_ROUTE_URL=http://disclosure-guardrail:3000/route and GUARDRAIL_TOKEN in LiteLLM's environment,
# and disclosure_router.py next to config.yaml (it ships in this package under plugins/litellm/)
- A refused destination is recorded: the password above leaves two
records,
deniedfor the vendor andsentfor the house. - The wrong order fails closed: a guardrail that sees
autoblocks it — a route alias has no destination of its own.checkLiteLlmConfig(…, { routes })warns about a router listed after the guardrail or missing. - The class still comes from the person’s key and team: the plugin passes them on, and the guardrail entry reads them from LiteLLM as before.
- A route alias may also be served in
model_list, so clients list it; its deployment should be in the house, since it is never reached while the router runs first.
What the guardrail cannot see: the proxy’s routing and its keys
LiteLLM runs the guardrail once, for the alias a client asked for. A
fallback — chat-local is down, the proxy tries chat-eu — sends the same
text on, and the guardrail decided for chat-local: in the house, so the
text passed unredacted. Held against LiteLLM 1.103 in the integration suite,
and it happens two ways:
- a fallback in the proxy’s file (
fallbacks,context_window_fallbacks,content_policy_fallbacks,default_fallbacks, underrouter_settingsorlitellm_settings); - a fallback the client names in its own request —
{"model": "chat-local", "fallbacks": ["chat-eu"]}— with nothing in the file at all. The guardrail is not told.
One switch closes both: metadata.disable_fallbacks: true on every key.
LiteLLM reads it from the key and lets no request override it, and it stops
the configured fallbacks too. Two checks hold the rest:
import { checkLiteLlmAccess, checkLiteLlmConfig } from '@octabits-io/disclosure/litellm';
const problems = checkLiteLlmConfig(parse(readFileSync('config.yaml', 'utf8')), { destinations, models });
// + checkLiteLlmAccess({ keys, teams, guardrailName: 'disclosure' }) over GET /key/list and /team/list
checkLiteLlmConfig — the proxy’s config.yaml against the guardrail’s
declaration. Errors, where a request can reach a destination nobody decided
for, or the guardrail may not run:
- a deployment of an alias declared self-hosted that goes outside the house: an
api_basethat is not loopback, a private address, a single-label name (a Compose service) or.internal/.local/.lan/.home.arpa/.svc— or noapi_baseon a hosted provider (insideHostsnames your own domains); - a fallback to a destination that may receive less, or is farther away (into the house is a warning: the record would name the wrong destination);
- an alias served and not declared, a wildcard
model_name, amodel_group_aliasto another destination; - client-side credentials (
allow_client_side_credentials,configurable_clientside_auth_params): a client that setsapi_basepicks the host; - the guardrail entry not
generic_guardrail_api, notpre_call(during_callsends while it decides), notdefault_on, or failing open.
Warnings: logging callbacks and store_prompts_in_spend_logs, which read the
original text; no master_key, so no request has a team to be declared by.
checkLiteLlmAccess — what lives in LiteLLM’s database, not the file: every
key that is not blocked has disable_fallbacks: true, and no key or team
opts out of the guardrail (disable_global_guardrails,
opted_out_global_guardrails). A request cannot opt out on its own; LiteLLM
reads those flags from the key and the team only.
Who sent it: the actor
The record names a destination and a class, and for a proxy the class comes
from who asked. liteLlmActorOf(info, level) turns what LiteLLM reports into
the log’s actor — team:support, key:open-webui, user:ada@example.com —
at the level you choose, falling back to the next coarser one:
teamnames the basis of the declared class, and no person;useris the end user a client names in a header LiteLLM maps (general_settings.user_header_mappings, e.g. Open WebUI’sX-OpenWebUI-User-EmailwithENABLE_FORWARD_USER_INFO_HEADERS=true).
A key or a user usually is a person. The log is then personal data, and in a workplace a record of what each employee sent is monitoring — in Germany a matter for the works council (§ 87(1) no. 6 BetrVG). Settle that before you record more than the team.
As a service: litellm-guardrail
For an operator who runs LiteLLM and writes no code, the package’s second
command serves the handler, configured by a file. infra/single-host in the
repository runs it as the gateway profile, beside LiteLLM and GLiNER behind
Presidio’s analyzer API, and carries a commented guardrail.yaml.
GUARDRAIL_TOKEN=… DATABASE_URL=postgres://… litellm-guardrail --config guardrail.yaml --port 8080
destinations: [{ id: local, region: self-hosted }, { id: vendor-eu, region: eu }]
models: { chat-local: local, chat-eu: vendor-eu } # every alias in LiteLLM's model_list
classes: { teams: { marketing: internal, docs: public } } # anyone else is confidential
inside: local # where the detectors and the redactor run
detect:
patterns: true
model: { baseURL: http://model:11434/v1, model: qwen3.6-35b-a3b }
# presidio: { url: http://gliner:3000 } # GLiNER as a detector too: see above
redact:
patterns: true # first: what the patterns find, an IBAN by its shape
presidio: { url: http://gliner:3000, entities: [PERSON, LOCATION, DATE_OF_BIRTH] }
POST /beta/litellm_basic_guardrail_apiis the guardrail;GET /health/liveandGET /health/readyare for the orchestrator. Ready means the log answers and the detector model has answered once: the command asks it at start and every 10 s until it does, because a cold model’s first answers can outlastdetect.timeoutMsand every request that waited for one would be made confidential.- The file is checked at start, every problem listed: an alias to an
undeclared destination, a class that is none, an unknown field, a detector
or redactor without a self-hosted
inside. --litellm config.yamlholds the proxy’s file to this one before it starts (checkLiteLlmConfig): an error stops the service, a warning is logged. Mount the proxy’s file read-only beside its own.litellm-guardrail check --config guardrail.yaml --litellm config.yaml [--keys-from http://litellm:4000]runs the same and exits 1 on an error; with--keys-fromandLITELLM_MASTER_KEYit also reads every key and team from the admin API (checkLiteLlmAccess). Run it after a change to either file, and after keys are made.audit.actor—team(the default),key,userornone— is what each record names as its actor; see Who sent it above before you chooseuser.- Each record goes to
disclosure_log(audit.table) with LiteLLM’s call id ascorrelationIdand the actor; the table is created on start, and a table an earlier version created gets the columns added since, so give the service a database of its own.--audit stdoutwrites JSON lines instead, for a first boot. trace: { url }POSTs each decision’s trace (above) to a URL as JSON — a monitor in the house; off unless set. It is sent without waiting: a monitor that is slow or gone never holds up a request, and a trace that cannot be delivered is logged and dropped.- Secrets come from the environment:
GUARDRAIL_TOKEN(required),DATABASE_URL,DETECTOR_API_KEY. - It loads optional peers as it needs them:
yaml,pg, and@ai-sdk/openai-compatiblewithaifor a detector model.
Redactors
createPatternRedactor() replaces what the pattern detector counts — so a
call the patterns raised leaves without them — and takes an IBAN by its shape
alone, since a mistyped account number is still one: [IBAN], [TAX_ID_DE],
[HEALTH_INSURANCE_ID_DE], [EMAIL], [PHONE]. createPresidioRedactor in
./presidio replaces every entity the analyzer finds with its type
([PERSON]), overlapping entities once, and leaves alone what an earlier
redactor put in; Presidio’s offsets count code points, and it cuts where
Presidio meant. A request that fails throws, and the gate denies the call.
combineRedactors runs them in order — the exact one first, so what it
replaced is not there for the next one to miss:
const redact = combineRedactors(
createPatternRedactor(),
createPresidioRedactor({ url: 'http://presidio-analyzer:3000', destination: inside, entities: ['PERSON', 'LOCATION', 'DATE_OF_BIRTH'] }),
);
Limit Presidio’s redaction to personal data with entities: content in a
sensitive category makes the call confidential, which is denied, never
redacted. With GLiNER as the recognizer, expect some pronouns and role nouns
to go too (“ich” → [PERSON]) — readable, not pretty. A redactor only
replaces what it finds, so the gate holds the redactions against what the
detectors counted and denies the call when they fall short
(redaction_incomplete) — a passport number or a date of birth 3.7.85 that
the model found and neither the patterns nor GLiNER recognised is blocked,
not sent. The patterns run first so that the identifiers they know are never
the reason.
The record
{
destinationId: 'mistral-eu', region: 'eu', provider: 'mistral.chat', modelId: 'mistral-large-latest',
operation: 'generate', // 'generate' | 'stream' | 'embed' | 'tool-result' (an MCP tool's result, @octabits-io/server/mcp)
class: 'internal', classDeclared: true,
outcome: 'sent', // 'sent' | 'failed' | 'denied'
reason: undefined, // the denial reason, for 'denied'
payloadDigest: 'sha256:9f2c…', payloadBytes: 1824,
redacted: true, redactions: 3,
detection: { // present when the guard has a detector
detectorId: 'patterns+qwen', detectorVersion: '1+prompt-1',
status: 'raised', // 'clear' | 'raised' | 'skipped' | 'failed'
raisedFrom: 'public', // the class before the raise
findings: [{ type: 'iban', count: 1 }],
},
at: new Date(),
}
class is the class the decision was made on, after any raise;
classDeclared still says whether the call site declared one.
disclosureRecordSchema is the Zod schema of it. The digest is over a
canonical JSON encoding (canonicalJson: sorted keys, bytes as base64), so the
same content always yields the same digest — keep the content you disclosed
elsewhere and digestPayload proves later that this is what was sent. The
sink is any function; the disclosure log below is the one that keeps records
where an audit can read them back.
The trace: how a call was decided, for someone who has to understand it
The record says what was decided and never quotes the text. Someone tuning
the detectors in a pilot, or explaining a decision to a security officer,
needs the other half: which detector found what, where in the text, what a
detector model actually answered, what each redactor replaced, and how long
each step took. trace on any guard (and on the LiteLLM handler) hears that,
once per call, when the gate has decided:
const model = guardLanguageModel(vendorModel, { destination, class: 'internal', redact, detect, audit,
trace: (trace) => console.log(JSON.stringify(trace)) });
{
destinationId: 'vendor-eu', region: 'eu', modelId: 'mistral-large-latest',
declared: 'internal', classDeclared: true, class: 'confidential',
outcome: 'denied', reason: 'confidential_leaves_the_house', ms: 1240,
detection: {
detectorId: 'patterns+detector.chat/qwen', status: 'raised', ms: 1236,
detectors: [
{ detectorId: 'patterns', started: true, ms: 0, result: { findings: [{ type: 'email', count: 1 }], … },
matches: [{ type: 'email', text: 'ada@example.com', textIndex: 1, start: 13, end: 28 }] },
{ detectorId: 'detector.chat/qwen', started: true, ms: 1236, result: { raise: 'confidential', … },
answer: '{"findings":[{"type":"credentials","kind":"sensitive","count":1}]}' },
],
},
// redaction: { ms, redactions, redactors: [{ redactorId: 'presidio', textIndex, ms, matches: [{ type: 'PERSON', text: 'Ada Krüger', placeholder: '[PERSON_1]', score: 0.94 }] }] }
}
- A trace quotes the text it found. That is its use, and why it is never
part of the record: hand it only to something in the house — a console
during a pilot, a monitor beside the gateway — and keep it no longer than it
is looked at. Nothing is traced unless
traceis set. - Each part of a combined detector reports itself, with its own time. A
part with
startedand nomshad not answered when the gate stopped waiting — the reason the call was made confidential;detection.errorsays why detection failed. A skipped detection says why (skipped). - The detectors and redactors here fill it in: the patterns and Presidio
with the matched span, offset and score; a model detector with its answer
before it was read (at most
MAX_TRACE_ANSWERcharacters). A detector of your own getscontext.traceand may report the same; a redactor gets it on itsRedactionContext. - What the trace callback throws is ignored: it never changes a call.
- The LiteLLM handler’s trace (
LiteLlmTrace) adds the request:path(routeorguardrail), the call id, the alias, the key, team and user, the candidate a route tried, andreusedwhen the guardrail entry took the decision/routemade.
The disclosure log
A table of every gate decision, with the scope it belongs to and a
correlationId — your id for what the call served, a workflow run or a
proposal — so an audit goes from a reviewed proposal to every model call behind
it. The root holds the contract and an in-memory store; ./postgres is the
store over a structural executor (pg, PGlite, postgres.js) plus
disclosureLogDdl(); ./drizzle is disclosureLogColumns, the table for
drizzle-kit (drizzle-orm optional peer). There is no Drizzle store: a log is
one insert and one select, so the SQL store is the store — hand it your pool.
import { createPostgresDisclosureLogStore } from '@octabits-io/disclosure/postgres';
const log = createPostgresDisclosureLogStore({ db: pool, scope: { column: 'org_id' } }); // your scope column
const model = guardLanguageModel(base, {
destination, class: 'internal', redact,
audit: { sink: log.sink({ scopeKey: orgId, correlationId: runId }), onSinkError: (e) => logger.error(e) },
});
const page = await log.list({ scopeKey: orgId, correlationId: runId }); // newest first
const period = await log.list({ scopeKey: orgId, since: quarterStart, until: quarterEnd, before: page.value.nextBefore });
record(record, context?)validates againstdisclosureRecordSchemaand writes one row —disclosure_log_invalidwhen it does not match, nothing written.sink(context)is the same write as a guard sink; it throws on a failed write, so the guard’sonSinkErrorhears of it and the call is never changed.- Scope:
{ column, value }stamps and filters byvalue;{ column }stamps each record’sscopeKey(disclosure_log_unscopedwithout one) and reads across scopes unlesslistgets ascopeKey— or a row-level-security policy on the column does the filtering. Omitscopein a single-scope deployment. The scope column, its foreign key and its policy are yours. actor(optional, in the context besidecorrelationId) names who the call came from, for a host where nothing the correlation id points at keeps it — a proxy’s team, key or user. Where a run or a proposal already names its actor, leave it out.listfilters bycorrelationId,actor,destinationId,outcomeand a half-opensince/untilperiod; pages of 50 by default (at most 500), newest first, withnextBeforeas the keyset cursor.- A log created before detection gains its five columns from
disclosureLogDetectionDdl(), and one created before the actor gains it fromdisclosureLogActorDdl()(ALTER TABLE … ADD COLUMN IF NOT EXISTS, both idempotent); a Drizzle host generates the same fromdisclosureLogColumns. - A guarded model built once for the process has no per-request context to
hand
sink(): write throughlog.record(record, currentContext())from a sink of your own, with the context from your request scope.
Nothing reaches a model around the gate
The gate is a control only if there is no other way to a model. disclosure-check
fails when a provider import exists outside the files you name as your guarded
factory:
{ "scripts": { "lint": "disclosure-check --guarded src/ai/models.ts" } }
A model import is any @ai-sdk/* provider (not @ai-sdk/provider or
@ai-sdk/provider-utils) and ai/test’s mock models, static or dynamic.
--module <regex> adds your own — a local runtime such as node-llama-cpp is
a destination too, and so is the client module a guardModelCall wraps: name
it with --module and the file that builds the guard with --guarded. Two more ways reach a model with no provider import, and
both are findings: the SDK’s own gateway / createGateway imported from
ai, and a string model id (model: 'openai/gpt-4o') inside an SDK call,
which the SDK resolves through its global default provider — Vercel’s gateway
unless the host set one. A host that wants string ids sets
globalThis.AI_SDK_DEFAULT_PROVIDER to a provider whose models come out of
the guarded factory, and passes --allow-string-models. Directories default to
src; test files are skipped unless --include-tests; a --guarded path that
does not exist fails the check, so the allowlist cannot go stale.
findUnguardedModelImports({ files, guarded }) is the same check as a
function, exported from the command’s module. It is a lint over source text,
not a proof: a model id held in a variable, or a provider reached through a
namespace import, is not seen.
The policy without a model runtime
import { evaluateDisclosure, DISCLOSURE_CLASSES } from '@octabits-io/disclosure';
evaluateDisclosure({ class: 'internal', destination: { id: 'mistral-eu', region: 'eu' }, canRedact: true });
// → ok({ class: 'internal', classDeclared: true, redact: true })
Exports
| Path | What |
|---|---|
. (any model call) |
guardModelCall, and the ModelCallGuardOptions, GuardedModelCall, DisclosureGuardOptions, Redactor and RedactedText types |
. |
DISCLOSURE_CLASSES, DISCLOSURE_REGIONS, DEFAULT_DISCLOSURE_CLASS, evaluateDisclosure, isMoreSensitive, disclosureDenied, disclosureDestinationSchema, disclosureRecordSchema, DISCLOSURE_DENIAL_REASONS, DISCLOSURE_OPERATIONS, DISCLOSURE_OUTCOMES, digestPayload, canonicalJson, and their types |
. (detection) |
detectorPrompt, detectorInput, parseDetectorAnswer, UnreadableDetectorAnswer, DETECTOR_PROMPT_VERSION, DETECTOR_FINDING_TYPES, createPatternDetector, combineDetectors, createPatternRedactor, combineRedactors, DETECTION_RAISES, DETECTION_STATUSES, PATTERN_FINDING_TYPES, DEFAULT_DETECT_TIMEOUT_MS, MAX_DETECTION_FINDINGS, detectionResultSchema, detectionFindingSchema, detectionFindingTypeSchema, disclosureDetectionSchema, and the Detector, DetectionContext, DetectionResult, DetectionRaise, DetectionFinding, DetectionStatus, DisclosureDetection, PatternFindingType and PatternRedactorOptions types |
. (fields) |
disclosureClassOf, disclosureFieldClasses, DISCLOSURE_META_KEY, the DisclosureFieldClass type |
. (routes) |
resolveDisclosureRoute, checkDisclosureRoutes, and the DisclosureRoutes, ResolvedDisclosureRoute, DisclosureRouteProblem and DisclosureRoutesInvalid types |
. (the trace) |
MAX_TRACE_TEXT, MAX_TRACE_ANSWER, MAX_TRACE_MATCHES, and the DisclosureTrace, DetectorTraceEvent, RedactorTraceEvent and TraceMatch types |
. (the log) |
createInMemoryDisclosureLogStore, createDisclosureLogStore, toDisclosureLogRow, toDisclosureLogQuery, disclosureLogFailure, DISCLOSURE_LOG_DEFAULT_LIMIT, DISCLOSURE_LOG_MAX_LIMIT, and the DisclosureLog* types |
./ai-sdk |
guardLanguageModel, guardEmbeddingModel, routeLanguageModels, detectorFromModel and its ModelDetectorOptions, DisclosureDeniedError, isDisclosureDeniedError, the LanguageModelRoute, ClassRoutedModelsOptions and ClassRoutedModels types, and the DisclosureGuardOptions, Redactor and RedactedText types (the root’s, re-exported) |
./presidio |
createPresidioDetector, createPresidioRedactor, PRESIDIO_SENSITIVE_ENTITIES, the PresidioDetectorOptions and PresidioRedactorOptions types |
./litellm |
createLiteLlmGuardrailHandler, liteLlmClassOf, liteLlmClassMapSchema, checkLiteLlmConfig, checkLiteLlmAccess, liteLlmActorOf, LITELLM_ACTOR_LEVELS, the LiteLlmGuardrailOptions, LiteLlmRequestInfo, LiteLlmClassMap, LiteLlmConfigProblem, CheckLiteLlmConfigOptions, LiteLlmKeyInfo, LiteLlmTeamInfo, LiteLlmActorLevel and LiteLlmTrace types |
./postgres |
createPostgresDisclosureLogStore, disclosureLogDdl, disclosureLogDetectionDdl, disclosureLogActorDdl, SqlExecutor |
./drizzle |
disclosureLogColumns |
disclosure-check |
the command (bin) |
litellm-guardrail |
the LiteLLM guardrail as a service (bin), configured by a file |
What it does not do
- Classify. The call site declares the class; the gate enforces it. A classifier model deciding what may leave would be the thing the gate guards against. A detector raises a class; it never sets one.
- Redact. It runs your redaction, where no caller can skip it — over the
system prompt, the messages, tool inputs and results, and the provider
options (a vendor’s own fields carry content as plain text; they are in the
digest too). A tool’s schema is not content and is not redacted.
@octabits-io/pii’s pseudonyms are one redaction; a list of your own system names is another. - Cover tool definitions and response schemas. Those are written by your code, not carried in from content, and pass as they are.
- Record who called. The record names the destination, not the caller. The log keeps the scope and a correlation id beside it; the actor belongs to what the correlation id points at — the run or the proposal, and the ledger entry that applied it.
Stability
0.x: minors may break and say so; patches never do. The three classes, the rule between them and the record’s fields are the contract.
From @octabits-io/disclosure@0.8.1 in the platform repository.