@demystify/ai
v0.1.0
Published
Tenant-scoped composition layer for the Demystify AI kit. One createAI({ tenantKey }) call returns surfaces already bound to that tenant — no method takes a tenant key, so none can take the wrong one. Attaches a per-tenant BYOK Credential to every backend
Maintainers
Readme
@demystify/ai
This package implements nothing.
It has no scorer, no parser, no redactor, no tokenizer, no scheduler, no retry policy, no cache and no transport. Every one of those already lives in a leaf package that does it better than a composition layer would, and none of them belongs here.
What it does own is one thing: a façade already bound to one tenant, so the five things a hand-wired stack has to remember cannot be forgotten.
import { createAI } from "@demystify/ai";
const ai = createAI({ tenantKey: "acme" }); // zero-config, keyless, offline
const outcome = await ai.operate(
{ name: "chat", estimateMicroUsd: 1_200, constraints: { requires: ["hindi"] } },
async (ctx) => {
const answer = await yourGateway.complete(prompt, ctx.backend); // ctx.backend carries the tenant's key
return { value: answer.text, costMicroUsd: answer.costMicroUsd };
},
);
if (!outcome.ok) console.warn(outcome.why); // a refusal always says whyThat one call did all of this, in this order, with no way to skip a step:
- opened a span tagged with the tenant,
- picked a driver that meets the constraints — or refused, naming every constraint and every rejected driver,
- checked the usage ceiling before the work, and did not run the body if it would cross,
- handed the body a
BackendContextwith the tenant's BYOK credential already attached, - recorded what it actually cost, in integer µUSD, after the work.
Why this exists at all
Wiring this stack by hand means threading one tenantKey into several
subsystems, attaching the tenant's Credential to the backend context, opening a
span, checking the budget before spending, and recording usage after. Forget any
one of them and nothing fails loudly. Forget the tenant key and the failure is a
cross-tenant data leak.
So the load-bearing property of this package is negative:
No method on the returned object takes a tenant key.
Not memory.remember, not runs.claim, not usage(), not operate(). A caller
who cannot pass a tenant key cannot pass the wrong one. It is enforced twice — in
the types, where every bound method's argument is Omit<…, "tenantKey"> derived
from the leaf's own input type, and at runtime, where the bound key is spread in
last so one that arrived through a variable is overwritten rather than obeyed.
test/tenant-binding.test.ts walks the entire returned surface and fails if a
tenant key ever becomes passable again.
Exactly what it wires
| Package | Relationship | What the façade adds |
|---|---|---|
| @demystify/memory | runtime dependency | ai.memory.*, tenant-bound. remember / recall / forget / erasureHistory lose their tenantKey parameter. Zero-config default: new Memory({ store: new InMemoryStore() }). |
| @demystify/agent-kernel | runtime dependency | ai.runs.*, tenant-bound. claim loses its tenantKey. Zero-config default: new AgentKernel({ store: new MemoryRunStore() }). |
| @demystify/skills | runtime dependency | ai.skills.*, fixed to this tenant's registry and spanned under its tag. Zero-config default: an empty registry. |
| @demystify/evals | accepted structurally | Pass a report.aggregate per driver id as evidence and driver choice ranks on measured per-tag results instead of a hardcoded preference. |
| @demystify/platform-observability | accepted structurally | spanExporterToMetrics(metrics) is a TelemetrySink — same field names, same method, no adapter. |
| @demystify/gateway-moat | shape mirrored | Credential and BackendContext are field-identical, so a credential from bearerCredential() and a context from this façade go straight into a backend. |
What it deliberately does NOT wire
@demystify/ai-guardrails, @demystify/grounding, @demystify/context and
@demystify/workflow are pure functions that take no tenant key and hold no
state. There is nothing to bind, nothing to scope and nothing to attach — a
wrapper would add a span and an import, and take away the ability to tree-shake.
Call them directly:
import { guardUntrusted } from "@demystify/ai-guardrails";
import { checkGrounding, INDIA_ACCOUNTING } from "@demystify/grounding";
import { createBudget } from "@demystify/context";
import { readySteps } from "@demystify/workflow";That is not a gap. A composition layer that re-exports every leaf becomes the
thing you cannot avoid installing, which is the bug this design exists to
prevent — see docs/federation/AI-KIT-ARCHITECTURE.md §0.
You can skip this package entirely
Everything here is wiring you can do yourself in about forty lines. Use the leaves directly if you want fewer moving parts, or if your app is single-tenant — in a single-tenant app the anti-leak property buys you nothing, and this package is mostly ceremony. It earns its place when one process serves many tenants.
The four capabilities
1. BYOK — a per-tenant credential, attached and never logged
import { bearerCredential } from "@demystify/gateway-moat/core";
const ai = createAI({ tenantKey: "acme", credential: bearerCredential(tenantKey) });
ai.backend(); // { tenantKey: "acme", credential } — frozen, ready for a backendThe credential is held in a closure, never as a property, so { ...ai },
Object.keys(ai), structuredClone and a debugger dump have nothing to find. It
never reaches a span attribute (attributes are scalars only, so an object cannot
be one), never reaches the run ledger, and never reaches a log line.
createAI refuses a credential that does not redact itself:
createAI({ tenantKey: "acme", credential: { scheme: "bearer", reveal: () => key,
toJSON: () => key, toString: () => key } });
// AIError [leaky_credential]: the supplied credential does not redact itself:
// toJSON() produced something other than "[REDACTED]". Build it with
// bearerCredential() / rawCredential() from the gateway's core, …2. Telemetry — one span per operation, tenant-tagged
No new tracing vocabulary. FinishedSpan and TelemetrySink are field-identical
to @demystify/platform-observability's FinishedSpan and SpanExporterLike:
import { createMetrics, spanExporterToMetrics } from "@demystify/platform-observability";
const metrics = createMetrics();
const ai = createAI({ tenantKey: "acme", telemetry: spanExporterToMetrics(metrics) });
// → dmstfy_span_duration_seconds{span="ai.op.chat",module="ai"}Telemetry fails open: a sink that throws is swallowed, because a metric that cannot record must never break the request it was observing. Safety fails closed: a non-scalar span attribute is rejected outright.
The default sink discards. No telemetry, no phone-home, until you ask.
3. Usage limits — checked before the spend, recorded after
const ai = createAI({ tenantKey: "acme", limits: createMemoryLimits({ ceilingMicroUsd: 5_000_000 }) });
const outcome = await ai.operate({ name: "chat", estimateMicroUsd: 9_000_000 }, run);
// { ok: false, refusal: "over_ceiling",
// why: 'an estimated 9000000 µUSD would take period "total" to … over the ceiling of 5000000 µUSD' }run is never called. The strongest evidence that a check happened before a
spend is that the work did not happen at all.
Two kinds of money, never mixed. AI cost is an integer count of micro-USD
(estimateMicroUsd, costMicroUsd). Book money is integer minor units with a
currency ({ amountMinor, currency } — what a run ceiling takes). Neither is
ever a float, and passing one where the other belongs throws:
ai.operate({ name: "chat", estimateMicroUsd: 1.5 }, run);
// AIError [invalid_amount]: estimateMicroUsd must be a non-negative integer count
// of micro-USD, got 1.5. AI cost is integer µUSD; book money is integer minor
// units with a currency. Never a float, never mixed.Swap createMemoryLimits() for any UsageLimits — a Postgres row, a metering
service — without touching a call site.
4. Pick and choose — by constraint, with the reason attached
const ai = createAI({
tenantKey: "acme",
drivers: [
{ id: "on-device", costMicroUsd: 0, provides: ["english", "hindi"], offline: true },
{ id: "hosted-lg", costMicroUsd: 900, provides: ["english", "hindi"] },
],
evidence: { // ← a @demystify/evals report.aggregate
"on-device": onDeviceReport.aggregate,
"hosted-lg": hostedReport.aggregate,
},
});
ai.choose({ requires: ["hindi"] });
// { chosen: true, driver: { id: "hosted-lg", … }, measuredScore: 0.93,
// why: 'chose "hosted-lg": measured mean score 0.9300 over ["hindi"] across 40 case(s),
// the best of 2 driver(s) that met the constraints (requires ["hindi"]).' }Two rules make this auditable rather than magic:
- A constraint filters; it never merely prefers. When nothing qualifies the
answer is a refusal carrying every constraint and every loser — never the
closest match, because "closest" to
requires: ["hindi"]is a model that answers in English. - The ranking is not invented here. With measured evidence, the winner is the
one with the best measured score on the tags actually required; without it, the
tie-break is cost and
whysays exactly that rather than implying a judgement was made. Scoring stays in@demystify/evals.
A Driver is a descriptor — an id, a cost, capability tags. It carries no
function, no endpoint and no key, so choosing one cannot itself call anything.
Your host maps the chosen id onto whatever actually sends.
Zero-config
createAI({ tenantKey }) works with no API key, no connection string, no
Redis, no Docker and no env var, on a machine with the network unplugged. The
defaults are in-memory throughout — the same discipline as
@demystify/platform-cache, and for the same reason: a keyless default has to be
a faithful rehearsal of production, not a stub that pretends.
Every part is swappable
createAI({
tenantKey,
credential, // any self-redacting Credential
memory, // any MemoryPort — e.g. Memory over PgMemoryStore
runs, // any RunsPort — e.g. AgentKernel over PgRunStore
skills, // any SkillsPort
limits, // any UsageLimits — Postgres, a metering service
telemetry, // any TelemetrySink
clock, // any Clock
drivers, evidence, period,
});No transport
This package opens no socket. It orchestrates packages that talk to things; it
talks to nothing itself. test/no-transport.test.ts fails the build on fetch,
WebSocket, node:http, node:net, child_process, dynamic import(),
eval, new Function, process.env, console, any URL, any provider name and
any auth-header literal — and asserts the source imports only the three
packages it declares as dependencies.
One assertion is inverted from the kernel's version of that suite, for the same
reason @demystify/evals inverted it: for the kernel, a callback is a
transport, so it forbids exports that accept callables. Here callables are the
product — ports in, a body function through. The inverse is asserted instead:
every outbound capability must be supplied by the host, and every default this
package can build on its own must be inert.
Limits and known gaps
- A body that throws records no spend. If a failed call still cost money, catch it inside the body and return the cost rather than letting it escape.
createMemoryLimitsis a reference adapter, not a metering product. It forgets everything when the process exits and counts one process only. Use a realUsageLimitsin production.- Driver choice is per call, not a failover chain. It picks one driver and
explains why. Retry and failover across drivers belong in the gateway's
Router, which already does alias chains and provider health. - No agent loop.
ai.runsis the kernel's plan-and-ledger, which structurally cannot send. The loop is the host's. ai.skillscarries no tenant key of its own — aSKILL.mdis not tenant data. Binding fixes the registry and adds the tenant tag to the span; if that is all you need, usecreateSkillRegistrydirectly.- Single process. Nothing here coordinates across machines.
Install
pnpm add @demystify/aiESM only. Node ≥ 22. TypeScript strict with exactOptionalPropertyTypes.
Tenancy is an opaque string — never parsed, never split, never interpreted.
Licence
MIT.
