@pleach/gateway
@pleach/gateway — multi-tenant model routing, observability, and cost attribution layered over the family-locked matrix.
The gate in the hedge — every call walks through here, in family,
on budget, attributable to one tenant. @pleach/gateway is the
multi-tenant routing SKU layered over the @pleach/core
model-family substrate. One client per tenant accepts a
(family, callClass, model, prompt) call, picks a transport, walks
the in-family cascade on transient failure, emits one cost event per
successful call, and never silently widens across families.
The substrate's in-family cascade behavior — what locks at session
start and how pickNextInFamily walks the column — lives in
Family-locked routing. The
model resolution matrix defines the
(family × callClass) cells the cascade walks. This page is the
multi-tenant routing client layered on top: per-tenant scoping,
operator allowlist, BYOK fingerprinting, and the per-call cost event.
The package is intentionally thin. The routing math, family matrix,
walk order, and BYOK transport resolution all live in
@pleach/core's model-family module;
gateway re-exports CallClass, ProviderFamily, and
RoutingDecision so consumers don't need to import from both places,
but it doesn't reimplement any of them.
Not a proxy
@pleach/gateway routes, refuses over-budget calls, attributes
cost, and fails over — the same verbs a governed-proxy control
plane (Portkey, Cloudflare AI Gateway, and the enterprise
control planes above them) ships. The difference is where it
sits. A proxy fences traffic with a one-URL swap, but routes
100% of your model calls through a vendor and holds the audit
trail on its side. Gateway is an in-process routing client: it
never opens a socket of its own, consumes a GatewayTransport
the host supplies, and runs inside your process against
transports you own. The out-of-family or over-budget call is
denied before it touches the wire, and the cost event lands in a
sink you own — no vendor in the data path. That's altitude, not
timing: both enforce at runtime; only the substrate governs at
execution-graph altitude instead of the HTTP boundary. See
the comparison
for the full axis.
Install
npm install @pleach/gatewaypnpm add @pleach/gatewayyarn add @pleach/gatewaybun add @pleach/gatewayimport {
GatewayClient,
asTenantId,
fingerprintByokKey,
GatewayFamilyDeniedError,
GatewayFamilyExhaustedError,
GatewayTransportMissingError,
type CostEvent,
type GatewayResponse,
type GatewayTransport,
} from "@pleach/gateway";The package re-exports CallClass, ProviderFamily, and
RoutingDecision from @pleach/core as well, so a consumer that
talks only to the gateway doesn't need a second import line for the
substrate types.
GatewayClient
One client per tenant is the canonical pattern. tenantId is
required at construction; the constructor throws if it's missing or
empty. Every cost event the client emits carries that tenant id, and
the operator-supplied allowedFamilies allowlist is scoped to the
client instance.
const gateway = new GatewayClient({
tenantId: asTenantId("acme-corp"),
transports: new Map([
["anthropic", anthropicTransport],
["openai", openaiTransport],
]),
allowedFamilies: new Set(["anthropic", "openai"]),
costEventEmitter: {
emit(event) {
eventLog.append(event);
},
},
});
const response = await gateway.route({
family: "anthropic",
callClass: "synthesize",
model: "claude-sonnet-4-6",
prompt: "Summarize the meeting notes …",
});asTenantId(raw) is the helper that turns a plain string into the
branded TenantId the constructor expects. It validates the input is
non-empty so the silent-isolation case (an unset env var
interpolated as "") becomes a load-bearing throw at init rather
than a months-later billing incident.
Constructor options
| Option | Required | What it does |
|---|---|---|
tenantId | yes | Branded TenantId — every cost event the client emits carries it. |
transports | effectively | Map<family, GatewayTransport>. Omitted → every route() call throws GatewayTransportMissingError. |
allowedFamilies | no | Set<ProviderFamily>. Calls with a family outside the set throw GatewayFamilyDeniedError before any transport invocation. |
byokKey | no | Client-scoped BYOK key. Header-only; never persisted. Per-call route({ byokKey }) overrides it. |
costEventEmitter | no | Sink for CostEvent. Defaults to a no-op so tests and dry-runs don't NPE; production must wire a real sink. |
route()
Returns a GatewayResponse on success. The response carries the
final modelInvoked, the usage token counts, a cost block with
the family and rate snapshot, the full routingDecision, the
cascadeWalks history (empty when the first rung succeeded), and a
familyExhausted boolean.
close()
Marks the client closed; subsequent route() calls reject. Phase A
has no underlying resources to release — transports are
caller-supplied — so close() is purely a state flag today.
The transport seam
GatewayClient doesn't ship concrete HTTP clients. It consumes a
narrow GatewayTransport interface and a Map<family, transport>
the host supplies at construction.
interface GatewayTransport {
readonly kind:
| "openrouter"
| "native-anthropic"
| "native-openai"
| "native-google"
| "bedrock"
| "azure"
| "vertex";
invoke(req: GatewayTransportRequest): Promise<GatewayTransportResponse>;
}The contract is intentionally minimal — the host wraps its existing
provider client (OpenRouter HTTP, the Anthropic SDK, an in-house
Bedrock adapter) in a GatewayTransport and hands the map to the
constructor. Gateway never opens a socket of its own.
A transport map keyed by family is the Phase A shape. Region-aware
routing ships separately through @pleach/gateway/region (see
Region routing); (family, callClass) transport
arbitration remains future work.
The chat-completion transport seam
Experimental. GatewayChatTransport and the concrete
createOpenAiCompatibleTransport land incrementally; the
signature is not frozen. The
npm README is the
source of truth — if a shape here disagrees, the README wins.
GatewayChatTransport is a distinct seam from the Phase-A
GatewayTransport above. Phase-A transports are callClass-shaped
and cost-less, serving the low-level GatewayClient.route() path.
GatewayChatTransport is the runtime-native shape
GatewayRuntime.routeChatCompletion() dispatches through: messages
in, content + usage out.
interface GatewayChatTransport {
route(request: GatewayChatTransportRequest): Promise<GatewayChatTransportResult>;
}The request carries the already-resolved route — provider,
family, model, messages, tenantId, an optional region, and
an optional abortSignal. The result carries content, a usage
pair (promptTokens + completionTokens), an optional modelInvoked
(the concrete id the transport actually hit), and an optional
costUsd hint.
costUsd is a hint, not a requirement. The gateway is the single
source of truth for cost attribution, so a failover counts only the
successful transport's cost, once, at the gateway. A transport that
already computed a cost may surface it; absent that, the runtime
resolves cost from usage against the gateway-owned rate card.
createOpenAiCompatibleTransport is the first concrete
GatewayChatTransport. Point its baseURL at any endpoint speaking
the OpenAI Chat Completions dialect — OpenAI, OpenRouter, Together,
Groq, a self-hosted vLLM — and supply an auth header callback. The
OpenAI request encoding and response decoding are reused from the
shape adapter, not reimplemented; the factory only maps the two
contracts and threads the per-call abortSignal. It deliberately
does not expose requestEncoder/responseDecoder overrides — its
name sells the OpenAI dialect, so a non-OpenAI wire format uses the
lower-level HTTP transport directly instead.
The cloud-IAM transports — Bedrock, Azure OpenAI, Vertex — implement
the same GatewayChatTransport contract over an IAM-credential
adapter in later cuts. See @pleach/transport-bedrock,
@pleach/transport-azure-openai, and
@pleach/transport-vertex for the
per-cloud placement.
Per-call cost event
Every successful route() emits one event to the configured sink.
The cadence is per-call, not batched: accuracy and reproducibility
for compliance attestation outweigh the batching savings. A sink
that wants its own batching can implement it internally; the
gateway's contract with consumers is one event per call.
interface CostEvent {
readonly type: "domain.gateway.cost.recorded";
readonly tenantId: TenantId;
readonly family: ProviderFamily;
readonly callClass: CallClass;
readonly modelInvoked: string;
readonly costUsd: number;
readonly promptTokens: number;
readonly completionTokens: number;
readonly byokActive: boolean;
readonly byokKeyHash?: string;
readonly routingDecision: RoutingDecision;
readonly timestamp: string;
}The full routingDecision shape ships inside the payload so a
downstream consumer — typically @pleach/compliance's attestation
runtime — has the provenance to attest the call without a round-trip
to the audit ledger. raw_provider_cost_usd and markup_pct live
inside the decision separately from the marked costUsd, so a
consumer that needs the pre-markup figure has it directly.
response.cost.usd is raw_provider_cost × (1 + markupPct). The
20% flat markup is sourced from MODEL_FAMILY_MATRIX; the gateway
doesn't compute it independently.
BYOK is header-only
A BYOK key passed to the constructor or to an individual route()
call is forwarded to the transport via the request payload and
never written anywhere else. The gateway computes a 16-character
sha256 fingerprint of the key for three purposes:
- The cache fingerprint key, so the same BYOK gets cache reuse without ever comparing raw keys.
- The
byok_key_idfield on the routing decision for audit attribution. - The
byokKeyHashfield on the emitted cost event.
import { fingerprintByokKey, isSameByokKey } from "@pleach/gateway";
fingerprintByokKey("sk-ant-api03-abc…");
// → "8f3a2b1c4d5e6f78"
isSameByokKey(a, b);
// → true iff fingerprintByokKey(a) === fingerprintByokKey(b)The 16-character hex slice is opaque — enough entropy to de-dup
within a tenant, not enough to reverse. brandedFingerprintByokKey
returns the same string under a branded ByokKeyFingerprint type
for code that wants to distinguish gateway hashes from arbitrary
hex strings at the type layer.
The caller is responsible for never logging the raw key. Once it's fingerprinted, gateway forgets the plain value.
Family-strict cascade on 503
On a transient transport failure (status 502 / 503 / 504, rate-limit
429, timeout, abort), GatewayClient walks the in-family rung
ladder via pickNextInFamily(family, currentModel, attempted) from
the substrate. The walk follows the same order the runtime uses —
synthesize → reasoning → utility → converse — skipping any rung
already tried this call. Each attempt produces a CascadeWalk entry
on the response.
When every rung fails, the gateway throws
GatewayFamilyExhaustedError with the attempted-model list
attached. It does not widen across families. That's the
gateway-side mirror of the runtime-side Family-Strict Cascade
Pivot:
cross-family is a consumer decision, made explicitly, not a silent
behavior of the routing layer.
try {
const response = await gateway.route({ family: "anthropic", … });
} catch (err) {
if (err instanceof GatewayFamilyExhaustedError) {
// err.family — "anthropic"
// err.attemptedModels — ["claude-sonnet-4-6", "claude-haiku-4-5", …]
// Consumer chooses: widen to "openai", surface to user, or fail.
}
}The graceful familyExhausted: true flag on the response is
reserved for a future shape where a transport reports a
degraded-but-non-failed rung; Phase A throws on hard exhaustion to
keep the error contract honest.
The cascade is bounded at 16 steps so a pathological transport can't infinite-loop the gateway.
allowedFamilies governance
allowedFamilies is the operator-facing governance hook. When set,
route() checks the requested family against the allowlist before
any transport invocation and throws GatewayFamilyDeniedError on
denial. The throw is synchronous-within-the-promise — denied calls
never touch the wire, never count against rate budgets, and never
emit a cost event.
const gateway = new GatewayClient({
tenantId: asTenantId("acme-corp"),
transports: transportMap,
allowedFamilies: new Set(["anthropic", "openai"]),
});
await gateway.route({ family: "deepseek", … });
// → throws GatewayFamilyDeniedError
// err.family — "deepseek"
// err.allowedFamilies — Set { "anthropic", "openai" }Use it to enforce per-tenant family policy ("acme-corp is on anthropic-only"), to block a family during a rollback, or to gate a preview family behind an explicit operator opt-in. The mechanism is structural — a tenant on the allowlist cannot reach a family that's not on it, regardless of what the model id string says.
Errors
Three error classes, three failure modes. All thrown via promise
rejection from route(); the constructor's TypeError for invalid
tenantId is the fourth.
| Error | When | Thrown before transport? |
|---|---|---|
GatewayFamilyDeniedError | family is not in allowedFamilies. | Yes — governance check is first. |
GatewayTransportMissingError | No transport configured for the resolved family. | Yes — configuration check is second. |
GatewayFamilyExhaustedError | Cascade walked every in-family rung and all failed. | No — at least one rung was attempted. |
GatewayFamilyDeniedError and GatewayTransportMissingError are
configuration failures and never reach the wire.
GatewayFamilyExhaustedError is a cascade outcome and carries the
attempted-model list so the consumer can decide what (if anything)
to widen to.
Tenant scoping
tenantId is required at construction and stamped on every emitted
CostEvent. The gateway's tenant-scope contract aligns with the
substrate's tenant facet and the
multi-tenant deployment pattern — same field,
same partition key. The gateway's cost numbers come from the
transport's reported usage, not from reading the core audit
ledger; the gateway never queries the ledger. A consumer that
wants both surfaces partitioned the same way (GROUP BY tenantId)
gets that because both stamp the same opaque tenantId, not
because the gateway joins through the ledger.
The tenantId field is opaque the same way the audit row's is.
A GatewayClient per end customer is the SaaS pattern; a
GatewayClient per cost center is the internal-Enterprise pattern
(one Anthropic Workspace or OpenAI Project sits behind the gateway,
and the cost event partitions spend across teams). The cost-event
sink reads the same GROUP BY tenantId either way.
One GatewayClient instance per tenant is the canonical usage
pattern. Per-tenant API keys (rotating credentials scoped to the
tenant, separate from BYOK) are deferred to a later phase.
Phase A status
What ships in the Phase A cut:
GatewayClientwithroute()andclose().- Per-call
domain.gateway.cost.recordedevent emission with fullRoutingDecisionpayload. - BYOK fingerprinting (
fingerprintByokKey,isSameByokKey,brandedFingerprintByokKey). - Family-strict cascade-on-503 via
pickNextInFamily. - Operator
allowedFamiliesallowlist governance. - Three typed error classes.
- Re-exports of
CallClass,ProviderFamily, andRoutingDecisionfrom@pleach/core. createGatewayRuntime()contract +routeChatCompletion()body.routeChatCompletion()validates the input shape (tenantId/family/messages) and theallowedProvidersgovernance hook, resolves the family default provider, and returns aRouteChatCompletionOutput. When a transport is wired for the resolved provider (via theGatewayRuntimeConfig.transportsmap — e.g.createOpenAiCompatibleTransport), it invokes that transport and returns realcontent+usage(costUsdstays0until the rate-card slice lands). When no transport is wired it returns a lossless stub-shape response so callers can build against the contract before wiring a wire transport.routeEmbedding(),getProviderHealth(), andgetTrafficStats()continue to throw the slice-1 sentinel.
What's not in Phase A — lands in subsequent passes:
- The full concrete-transport set.
createOpenAiCompatibleTransportships today as the first concreteGatewayChatTransport(OpenAI-dialect wire shape, exported from@pleach/gateway/transport) — wire it intoGatewayRuntimeConfig.transportsandrouteChatCompletion()dispatches through it. The bundled native-Anthropic / native-OpenAI / native-Google adapters and the Bedrock / Azure / Vertex concrete transports with region-pin routing arrive in subsequent Phase B slices. - OTEL
llm.invocationspan emission. The C7 telemetry rung that records every gateway call as an OTEL span lands onceruntime.otelis consumable from@pleach/core's exported surface. Until then, callers thread their own span context if they need it. - Native-vs-openrouter transport arbitration. Choosing between a
native provider transport and an OpenRouter transport per
(family, callClass)is v1.x work. Phase A's cascade is family-strict and walk-order-strict; there's no policy knob between them. (Note: cross-provider failover and region routing primitives now ship as experimental subpaths — see Failover and Region routing — but they are opt-in wrappers, not part of the defaultroute()path.)
Tenant resolution
The base @pleach/gateway/auth subpath ships the TenantResolver
contract plus createInMemoryTenantResolver for tests. Four
production adapters resolve a tenantId from a request, each on its
own subpath so the base stays dependency-free:
| Adapter | Subpath | Resolves from |
|---|---|---|
createJwtTenantResolver | @pleach/gateway/auth/adapters/jwt | a verified JWT claim (jwksUri or publicKey) |
createClerkTenantResolver | @pleach/gateway/auth/adapters/clerk | a Clerk organization/session |
createOktaTenantResolver | @pleach/gateway/auth/adapters/okta | an Okta token introspection |
createApiKeyTenantResolver | @pleach/gateway/auth/adapters/apiKey | an API-key Map or async lookup |
import { createJwtTenantResolver } from "@pleach/gateway/auth/adapters/jwt"
const resolver = createJwtTenantResolver({
jwksUri: "https://issuer.example.com/.well-known/jwks.json",
tenantClaim: "org_id",
})
const { tenantId } = await resolver.resolve({ headers })Failover
Experimental (status: experimental). The @pleach/gateway/failover
signatures are not frozen; the
npm README wins on
disagreement.
@pleach/gateway/failover wraps an operator-supplied provider chain
in a retry loop. It's vendor-neutral — you supply the chain and the
transient-error classifier is purely structural (no provider SDKs).
createFailoverPolicy({ chain, maxAttempts?, backoff? })— a chain cursor.maxAttemptsdefaults to and clamps atchain.length;backoffdefaults todefaultLinearBackoff.createFailoverMiddleware({ policy, onExhausted?, sleep?, isTransient? })— wraps a call inrunWithFailover(fn). ThrowsFailoverExhaustedErrorand emits a structuralFailoverExhaustedevent when the chain runs out.
import {
createFailoverPolicy,
createFailoverMiddleware,
} from "@pleach/gateway/failover"
const mw = createFailoverMiddleware({
policy: createFailoverPolicy({ chain: ["primary", "secondary"] }),
})
const res = await mw.runWithFailover(() => callProvider())Region routing
Experimental (status: experimental). Vendor-neutral — no AWS /
Azure / GCP imports. Region resolution flows through a structural
RegionResolver you implement against any backing store.
@pleach/gateway/region augments a RoutingDecision with a region
and enforces region allowlists:
createRegionRouter({ resolver })— augments a route with its resolved region.createStaticRegionResolver({ region })— reference single-region resolver.createRegionAwareCascade(...)— filters cascade candidates to a region allowlist.createRegionDrPolicy({ primary, secondaries, mode? })— primary→secondary DR ordering;modedefaults to"active-passive". Rejects withRegionNotPermittedErrorwhen no permitted region remains.
Identity providers
Experimental (status: experimental). The base subpath is
vendor-neutral — no @aws-sdk/*, @azure/identity, or
google-auth-library peers. Concrete adapters live on per-vendor
subpaths so you only pay for what you import.
@pleach/gateway/identity resolves short-lived federated credentials
per tenant. Multi-cloud deployments route LLM traffic without copying
long-lived API keys into the gateway process. The base subpath ships
the contract plus a credential cache:
IdentityProvider— the resolution seam. Resolves credentials for anIdentityContext.IdentityContext— carriestenantId,targetFamily, and an optionalregion.ResolvedCredentials—kind: "static"(a stored key) orkind: "federated"(a bearer token withexpiresAt).createTokenCache(...)— LRU + TTL cache that evicts onexpiresAtbefore the upstream endpoint rejects a stale token.
Reference adapters resolve against a vendor client you inject — the adapter never imports the SDK:
| Subpath | Factory | Backing flow |
|---|---|---|
@pleach/gateway/identity/providers/iam | createIamIdentityProvider | AWS STS AssumeRole |
@pleach/gateway/identity/providers/azure | createAzureManagedIdentityProvider | Azure Managed Identity |
@pleach/gateway/identity/providers/gcp | createGcpWorkloadIdentityProvider | GCP Workload Identity Federation |
Each factory consumes a duck-typed client matching the vendor SDK shape
(STSClient.send, TokenCredential.getToken,
GoogleAuth.getAccessToken). Federated resolution suits regulated,
multi-cloud tenants — see the government use case.
Related SKUs
@pleach/core— the family-locked matrix the gateway routes against.pickNextInFamily,MODEL_FAMILY_MATRIX, and theCallClass/ProviderFamily/RoutingDecisiontypes all live there; gateway re-exports them for convenience.@pleach/compliance— downstream consumer ofdomain.gateway.cost.recordedevents. Reads the fullRoutingDecisionpayload for attestation provenance without a round-trip to the audit ledger.@pleach/observe— wires the gateway's cost events into OpenTelemetry / Datadog / Honeycomb spans.
For the full SKU map see Which SKU do I need?.
Where to go next
Family-locked routing
The in-substrate cascade behavior the gateway respects — what locks at session start and how `pickNextInFamily` walks the column.
Model resolution matrix
The `(family × callClass)` matrix gateway routes through, the downgrade ladder, and the family-strict cross-family policy.
Multi-tenant deployments
Where `tenantId` lives in the substrate — RLS, fingerprint, audit row, OTEL attribute.
@pleach/compliance
The downstream consumer that reads gateway's cost events for attestation provenance without a round-trip to the ledger.