Gateway · Migration from `@pleach/core`
When to introduce `@pleach/gateway` over direct `@pleach/core` provider use — multi-tenant, BYOK, cost rollup, region failover, identity federation.
@pleach/core's model-family substrate is sufficient for
single-tenant agents that drive their own provider keys. The gateway
layers on top when the deployment shape changes — when calls need to
be attributed per tenant, BYOK per tenant, cost-rolled per tenant, or
routed through region / failover policies.
When to introduce the gateway
Add @pleach/gateway when any of the following is true:
- Multi-tenant. The same runtime serves multiple end customers (or multiple internal cost centers) and every call must be attributable to one of them.
- Per-tenant BYOK. Tenants supply their own provider keys for one or more families, and the platform routes via the tenant's key rather than a shared platform key.
- Cost rollup. A
GROUP BY tenant_idover emitted cost events drives billing, internal showback, or quota enforcement. - Region failover. Calls must be routed through a primary region with automatic failover to a DR region on regional outage.
- Identity federation. Provider keys are derived per-call from an OIDC / SAML / federated identity (rather than long-lived API keys).
When none of these apply, stay on @pleach/core. Don't bolt on the
gateway speculatively — it adds a per-call indirection and a
per-tenant client lifecycle that single-tenant agents don't need.
What stays the same
Your existing createPleachRuntime() calls do not change. The
runtime's model-family resolution, in-family cascade, and provider
seam contracts are unchanged. The gateway is a wrapper, not a
replacement.
The matrix, the cascade walk order, the (family × callClass) cells
— all of that lives in @pleach/core and the gateway re-exports
CallClass, ProviderFamily, and RoutingDecision so consumers
that talk only to the gateway don't need a second import line for
the substrate types.
The minimum migration
A single-tenant @pleach/core consumer typically looks like this:
import { createPleachRuntime } from "@pleach/core"
const runtime = createPleachRuntime({
providers: { /* … */ },
})
const { id: sessionId } = await runtime.sessions.create()
for await (const event of runtime.executeMessage(sessionId, "…")) {
// handle each StreamEvent
}The gateway-wrapped form keeps the runtime intact and introduces a
per-tenant GatewayClient that resolves (tenant → credential → region → transport) ahead of the runtime call:
import { createPleachRuntime } from "@pleach/core"
import {
GatewayClient,
asTenantId,
} from "@pleach/gateway"
import { createCredentialRoutingMiddleware } from "@pleach/gateway"
import { createPostgresCredentialStore } from "@pleach/gateway/byok/adapters/postgres"
import { createCostMiddleware, createPostgresCostEmitter } from "@pleach/gateway/cost"
// 1. One runtime, unchanged from your single-tenant setup.
const runtime = createPleachRuntime({ providers: { /* … */ } })
// 2. Per-tenant BYOK resolution.
const credentialStore = createPostgresCredentialStore({ pool })
const resolveCredential = createCredentialRoutingMiddleware({
store: credentialStore,
fallbackKey: process.env.PLATFORM_ANTHROPIC_KEY,
onMissing: "fallback",
})
// 3. Per-call cost emission.
const costEmitter = createPostgresCostEmitter({
pool,
table: "pleach_gateway_cost_events",
})
const recordCost = createCostMiddleware({
emitter: costEmitter,
computeCost: (route, response) => ({
type: "domain.gateway.cost.recorded",
tenantId: asTenantId(response.tenantId),
family: response.family,
callClass: response.callClass,
modelInvoked: response.modelInvoked,
costUsd: response.costUsd,
promptTokens: response.usage.promptTokens,
completionTokens: response.usage.completionTokens,
byokActive: response.byokActive,
routingDecision: response.routingDecision,
timestamp: new Date().toISOString(),
}),
})
// 4. One GatewayClient per tenant (SaaS pattern) or per cost center
// (internal-enterprise pattern). transports + allowedFamilies are
// the operator's governance hooks.
function buildGatewayForTenant(tenantId: string): GatewayClient {
return new GatewayClient({
tenantId: asTenantId(tenantId),
transports: tenantTransportMap,
allowedFamilies: tenantAllowedFamilies(tenantId),
costEventEmitter: { emit: (e) => costEmitter.emit(e) },
})
}The runtime continues to do the in-family cascade math, the plan-generation rung, the streaming pipeline — everything it did before. The gateway is a layer in front of the transport seam that swaps in the tenant's credential and accounts for the call after the fact.
Per-tenant client lifecycle
One GatewayClient per tenant is the canonical pattern. The constructor
captures tenantId, allowedFamilies, the transport map, and the
cost-event sink — all scoped to the instance. Don't share one client
across tenants; the cost attribution will collapse.
For high-tenant-count deployments, cache GatewayClient instances in
an LRU keyed by tenantId. close() on eviction is a state flag
today (Phase A has no underlying resources to release — transports
are caller-supplied) — but call it anyway so future versions can
release region-scoped pools or identity-cache entries.
Where the routing decision happens
In a single-tenant @pleach/core setup, the runtime picks the model
inside its provider seam:
runtime → provider seam → @pleach/core matrix → cascade walkWith the gateway in front:
GatewayClient.route()
→ governance gate (allowedFamilies allowlist)
→ credential gate (BYOK middleware: tenant store → fallback)
→ transport pick (Map<family, GatewayTransport>)
→ cascade walk (pickNextInFamily on 503 / timeout / 429)
→ cost event (one per successful call)The runtime's internal cascade math is reused — gateway calls
pickNextInFamily from the substrate directly. There is no
duplicated walk order, no second matrix.
The bundled route factory
A bundled createGatewayRoute factory ships today at
@pleach/gateway/next. It is a Web-standard
(req: Request) => Promise<Response> handler (no next peer
dependency — the /next subpath name is cosmetic, so it works in
App Router, Workers, Bun, and Hono) that wraps a fully-built
GatewayClient. It collapses the manual "resolve tenant → parse body →
call route() → shape a Response" wiring into one factory:
import { createGatewayRoute } from "@pleach/gateway/next"
// Multi-tenant: a GatewayClient is constructed per request with the
// resolved tenant id, so cost events + governance are tenant-scoped.
export const POST = createGatewayRoute({
clientOptions: {
transports, // Map<family, GatewayTransport>
allowedFamilies: new Set(["anthropic", "openai"]),
},
tenantId: (req) => req.headers.get("x-tenant-id") ?? "anonymous",
})For a single-tenant route, pass a pre-built client instead of
clientOptions + tenantId:
export const POST = createGatewayRoute({ client })The handler accepts a JSON body
({ family, callClass, model, prompt, byokKey? }) and returns the
resolved GatewayResponse as one application/json object. It is NOT
a streaming transport — GatewayClient.route() buffers the transport
call. Supply resolveRoute(req, body) to map a different request
schema onto GatewayRouteOptions. The route does NOT re-implement any
routing math or the in-family cascade — those live entirely inside
GatewayClient.route().
The explicit composition shown above remains available when you want every layer auditable by hand — exactly the property that matters for compliance attestation.
Cited source
packages/gateway/src/index.ts—GatewayClientPhase A.packages/gateway/src/byok/CredentialRoutingMiddleware.ts— BYOK middleware factory.packages/gateway/src/cost/CostMiddleware.ts— cost middleware factory.
Where to go next
BYOK credential routing
The store contract + three reference adapters (Postgres, Redis, external secret manager).
Cost events
The CostEmitter contract + four reference adapters (OTel, Postgres, Datadog, Honeycomb).
Multi-tenant deployments
Where tenantId lives in the substrate — RLS, fingerprint, audit row, OTel attribute.
`@pleach/gateway` overview
The Phase A GatewayClient — route(), cost events, family-strict cascade, transport seam.
Gateway · OpenTelemetry integration
OTel surface for `@pleach/gateway` — what's shipped today (cost counters), what's roadmap (tracer + spans).
@pleach/observe
Destination-flexible observability SDK for AI agents. Drop it into an existing agent loop (LangChain, CrewAI, OpenAI SDK, hand-rolled) and write typed audit rows to Postgres, Supabase, your OpenTelemetry collector, or an in-memory buffer — your storage, your control plane.