Vercel AI SDK middleware (covers Mastra)
Vercel AI SDK v4+ (
ai) is the dominant TS-side LLM router. Mastra Agents callgenerateText/streamTextfromaiunderneath, so a single middleware covers both ecosystems. SpendGuard ships ascreateSpendGuardMiddleware— a stockLanguageModelV1Middlewareyou drop onto any@ai-sdk/*provider viawrapLanguageModel({ model, middleware }). No model subclassing, no proxy fork. Mastra users import the same factory under its Mastra-idiomatic name through the@spendguard/vercel-ai/mastrasubpath alias.
Using Mastra? See
@spendguard/mastra— the recommended Mastra integration (deliverable D38). Mastra owns its own agent loop since v0.14.0; this page’s coverage is scoped to explicit AI SDK model instances handed to Mastra (the/mastrasubpath alias remains published and functional for that path), while model-router-string Agents have nowrapLanguageModelinjection point and are covered by@spendguard/mastra’sSpendGuardProcessorat the agent-step boundary. See the dated 2026-06-10 amendment inD06 design.md§9.
Install
Section titled “Install”pnpm add @spendguard/sdk @spendguard/vercel-ai ai@spendguard/sdk, ai (Vercel AI SDK), and zod are declared as peer
dependencies so the adapter pins none of them — your project’s lockfile
wins. Node 20.10+ is required (the substrate uses await using + stable
AsyncLocalStorage).
For real provider HTTP, add the official @ai-sdk/* provider you target:
pnpm add @ai-sdk/openai # or @ai-sdk/anthropic, @ai-sdk/google, ...Quick start
Section titled “Quick start”Minimal end-to-end wire — connect a SpendGuardClient to the sidecar UDS,
build the middleware, hand it to wrapLanguageModel, and use the wrapped
model with generateText:
import { generateText, wrapLanguageModel } from "ai";import { openai } from "@ai-sdk/openai";import { SpendGuardClient } from "@spendguard/sdk";import { createSpendGuardMiddleware } from "@spendguard/vercel-ai";
const client = new SpendGuardClient({ socketPath: "/var/run/spendguard/adapter.sock", tenantId: "00000000-0000-4000-8000-000000000001", runtimeKind: "vercel-ai-js",});await client.connect();await client.handshake();
const middleware = createSpendGuardMiddleware({ client, tenantId: "00000000-0000-4000-8000-000000000001", budgetId: "44444444-4444-4444-8444-444444444444",});
const model = wrapLanguageModel({ model: openai("gpt-4o-mini"), middleware,});
try { const { text } = await generateText({ model, prompt: "hello vercel ai", }); console.log(text);} finally { await client.close();}What happens on each generateText / streamText call:
- The middleware’s
transformParams({ params })derives a stable(runId, idempotencyKey)pair from theparamsreference, projects a coarse pre-callBudgetClaim, and callsclient.reserve({ trigger: "LLM_CALL_PRE", ... }). - On
DecisionDenied(DENY / STOP / APPROVAL_REQUIRED), the middleware rethrows —generateTexthalts BEFORE the innerdoGenerate()HTTP call fires. - On success, the inflight
(decisionId, reservationId)is stashed on aWeakMap<LanguageModelV1CallOptions, StashEntry>keyed by the params reference itself. The provider’sdoGenerate()fires. wrapGenerateextracts the provider’s reported(promptTokens, completionTokens)fromresult.usage(accepting both AI SDK v4 canonical camelCase AND OpenAI-passthrough snake_case shapes) and emits aSUCCESScommit viaclient.commitEstimated(...).- For streaming (
streamText),wrapStreaminstruments theReadableStream<LanguageModelV1StreamPart>with aTransformStreamthat forwards every part downstream unmodified, watches for the terminalfinishpart, and asynchronously emits aSUCCESScommit when the consumer drains the stream. Mid-stream errors emit aFAILUREcommit.
Mastra Agents — same middleware, alias import
Section titled “Mastra Agents — same middleware, alias import”Mastra Agents call generateText / streamText from ai underneath, so
the same LanguageModelV1Middleware covers both ecosystems byte-for-byte.
Mastra consumers import the factory under its Mastra-idiomatic name via
the /mastra subpath alias:
import { Agent } from "@mastra/core";import { wrapLanguageModel } from "ai";import { openai } from "@ai-sdk/openai";import { createSpendGuardLanguageMiddleware } from "@spendguard/vercel-ai/mastra";
const middleware = createSpendGuardLanguageMiddleware({ client, tenantId: "tenant-prod", budgetId: "...",});
const guardedModel = wrapLanguageModel({ model: openai("gpt-4o-mini"), middleware,});
const agent = new Agent({ name: "my-budget-aware-agent", model: guardedModel,});
// agent.generate(...) and agent.stream(...) now reserve before each// LLM call and commit after; deny + approval flows propagate as typed errors.The factory at @spendguard/vercel-ai/mastra is a function-reference
alias — strict equality (===) holds with the root export. One factory,
two import paths.
Configuration
Section titled “Configuration”The middleware options surface supports unitId pass-through (set
SPENDGUARD_UNIT_ID in the runner environment and pass it as the unitId
option). The richer windowInstanceId / pricing / claimEstimator fields
the design anticipates are deferred to a later slice.
| Option | Type | Required | Description |
|---|---|---|---|
client | SpendGuardClient | YES | Configured substrate client from @spendguard/sdk. The adapter does NOT own the client lifecycle — the consumer calls connect() / handshake() / close(). |
tenantId | string | YES | Tenant UUID the call is billed to. Forwarded to the substrate as the reserve() claim scope and as the first field of the idempotency-key canonical tuple. |
budgetId | string | NO | Budget UUID used as the projected claim’s scopeId. When unset, the middleware falls back to tenantId as the scopeId. |
A future minor (v0.x) extends this surface field-for-field with the
design.md §4 superset (windowInstanceId, unit, pricing,
claimEstimator, route, callSignature, runIdProvider,
providerEventIdExtractor). The extensions are additive optional fields
— no breaking change to the v0.1.0 shape.
Streaming semantics
Section titled “Streaming semantics”wrapStream returns the inner { stream, rawCall, rawResponse, warnings }
unmodified except for stream. The replaced stream is a
ReadableStream<LanguageModelV1StreamPart> produced by a
TransformStream that:
- Forwards every
LanguageModelV1StreamPartdownstream byte-for-byte — consumers see the original stream unchanged. - Watches each part for the terminal
finishevent and snapshots itsusage: {promptTokens, completionTokens}payload. - On the stream’s
flush()(consumer drained), asynchronously emits aSUCCESScommit against the captured usage tuple. - On a stream-side error or upstream throw, emits a
FAILUREcommit with the error message and propagates the error downstream. - A single
terminalboolean flag guards against the finish/error race so exactly one ofSUCCESS/FAILUREfires.
Commit-side failures (e.g. sidecar UNAVAILABLE post-finish) do NOT corrupt the stream — the substrate’s TTL reconciler closes any orphaned reservation via the audit chain.
Limitations
Section titled “Limitations”SpendGuard’s Vercel AI SDK middleware is pre-call + end-of-stream commit only. The middleware gates BEFORE the upstream provider HTTP call and commits AFTER the stream completes. A few important things to know before you ship it in front of long streaming responses or rely on D06’s full design surface:
- Vercel AI SDK v4
LanguageModelV1Middlewareshape — not v5 yet. D06’s spec was written against AI SDK v5 (LanguageModelV2). The currentai@^4peer wires v1; the middleware targets that surface natively. The v5 migration is additive when AI SDK v5 lands as the default peer. transformParamsthrow-to-halt is load-bearing. Without the rethrow path in the middleware,wrapLanguageModelswallows the decision-denied signal and the budget gate never blocks the LLM call. The middleware throwsDecisionDenied(and itsDecisionStopped/ApprovalRequiredsubclasses) directly fromtransformParamsso the AI SDK caller sees the typed error.- No mid-stream cap on streamed responses. SpendGuard reserves the
predicted budget at pre-call time. The commit happens at end-of-stream
against the real
usage. The middleware never tears down a mid-flight stream to halt token emission — overruns land in the audit chain and reflect at commit time, but the tokens were already emitted. - DEGRADE patch application surfaces as
MutationApplyFailed. Matches the Pythonpydantic_ai.py::SpendGuardModelv0.5.1 stance. Built-in claim mutation lands in a later slice. - No tool-call mid-loop gating. Each tool call is its own AI SDK event; the substrate handles per-call PRE/POST. Cross-tool budget enforcement is the contract layer’s job, not the middleware’s.
A bundled docker-compose demo proves the full ALLOW + DENY + STREAM
matrix end-to-end against a real generateText / streamText +
sidecar + counting-stub provider, including the Mastra alias parity
check at boot:
make demo-up DEMO_MODE=vercel_ai_mastraThe mode boots postgres + sidecar + vercel-ai-mastra-runner + counting-stub, then runs three invocations from the Node example at
examples/vercel-ai-mastra/:
- ALLOW — small message within budget.
transformParamsfires PRE,client.reserve()returns ALLOW, the wrapped model’sdoGenerate()HTTP call fires, counter +1,wrapGenerateemits aSUCCESScommit with real token usage. - DENY —
spendguard_estimate_override=2000000000blows past the seeded 1B hard-cap. The sidecar contract evaluator emitsSPENDGUARD_DENY,transformParamsthrowsDecisionDenied,generateTexthalts BEFORE the upstream HTTP call. Counting-stub counter UNCHANGED (proves the gate fires pre-call). - STREAM —
streamTextconsumed viaresult.textStreamasync iterator. PRE fires once at stream open, POST commits once at stream end against realusage.completionTokens.
Success line on a clean run:
[demo] vercel_ai_mastra ALL 3 steps PASS (ALLOW + DENY + STREAM)Full details and the verify-SQL gates are in
deploy/demo/vercel_ai_mastra/README.md.
Troubleshooting
Section titled “Troubleshooting”Common boot-time and first-call errors. The substrate’s typed exceptions
are re-exported from @spendguard/vercel-ai so you can pattern-match on
DecisionDenied / SidecarUnavailable without taking a second import.
-
DecisionDenied: reserve() denied by contract— Expected behaviour when the budget is exhausted or a contract rule emittedSPENDGUARD_DENY. The middleware rethrows,generateTexthalts, and the upstream provider HTTP call never fires. Check the sidecar logs for the rule that matched; the decision row in the ledger carriesdecision_context.contract_rule. -
Budget gate not blocking — provider call fires anyway. Almost always caused by the middleware not being threaded into
wrapLanguageModel. Inspect the model you pass togenerateText— it MUST be the wrapped one, not the rawopenai("...")/anthropic("...")reference. Reproduce by loggingmiddleware.transformParams.toString()— it should not be the identity function. -
SidecarUnavailable: handshake timeout— The sidecar’s UDS path (SPENDGUARD_SIDECAR_UDS, default/var/run/spendguard/adapter.sock) is not reachable. Check that the sidecar container is up, the socket file exists, and your Node process has read/write permission on it. In the demo overlay this is wired bydeploy/demo/vercel_ai_mastra/docker-compose.yaml; outside the demo, mount the same socket path into your Node container’s/var/run/spendguard/. The middleware swallowsSidecarUnavailableon the reserve path (degraded mode — LLM call proceeds without a gate). Construction-timeclient.handshake()failures are surfaced to the caller directly. -
@spendguard/vercel-ai/mastraimport resolves toundefined— Make sure your bundler honours the package’sexportsmap. The/mastrasubpath is declared inpackage.json#exportsand resolves via Node’s ESM resolution. Older bundlers (esbuild < 0.20, Webpack 4) may need explicit alias entries. -
generateText: typed error not DecisionDenied— You importedDecisionDeniedfrom@spendguard/sdkdirectly while the wrapped model’s middleware threw from@spendguard/vercel-ai. Class identity IS preserved (re-export, not subclass), soerr instanceof DecisionDeniedworks regardless of which import you use. If the check fails, suspect a duplicate@spendguard/sdkin yournode_modulestree. -
Token counts on commit don’t match what the provider reports. The middleware’s
extractUsageFromGenerateaccepts both AI SDK v4 canonical camelCase ({promptTokens, completionTokens}) AND OpenAI-passthrough snake_case ({prompt_tokens, completion_tokens}) shapes. If neither shape is present (rare; defensive fallback), the commit ships0for the missing fields — check the provider’s raw response withrawResponse?.headers.
Related
Section titled “Related”- Quickstart — full SpendGuard stack up in 5 minutes
- LangChain.js callback handler — LangChain.js sibling of this middleware (callback-handler shape; LangChain idiom)
- Pydantic-AI — Python sibling
- Contract YAML reference — author allow/stop rules
- Other adapter integrations: LangChain & LangGraph (Python) · OpenAI Agents SDK · Microsoft AGT · LiteLLM proxy guardrail