Skip to content

Vercel AI SDK middleware (covers Mastra)

Vercel AI SDK v4+ (ai) is the dominant TS-side LLM router. Mastra Agents call generateText / streamText from ai underneath, so a single middleware covers both ecosystems. SpendGuard ships as createSpendGuardMiddleware — a stock LanguageModelV1Middleware you drop onto any @ai-sdk/* provider via wrapLanguageModel({ model, middleware }). No model subclassing, no proxy fork. Mastra users import the same factory under its Mastra-idiomatic name through the @spendguard/vercel-ai/mastra subpath 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 /mastra subpath alias remains published and functional for that path), while model-router-string Agents have no wrapLanguageModel injection point and are covered by @spendguard/mastra’s SpendGuardProcessor at the agent-step boundary. See the dated 2026-06-10 amendment in D06 design.md §9.

Terminal window
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:

Terminal window
pnpm add @ai-sdk/openai # or @ai-sdk/anthropic, @ai-sdk/google, ...

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:

  1. The middleware’s transformParams({ params }) derives a stable (runId, idempotencyKey) pair from the params reference, projects a coarse pre-call BudgetClaim, and calls client.reserve({ trigger: "LLM_CALL_PRE", ... }).
  2. On DecisionDenied (DENY / STOP / APPROVAL_REQUIRED), the middleware rethrows — generateText halts BEFORE the inner doGenerate() HTTP call fires.
  3. On success, the inflight (decisionId, reservationId) is stashed on a WeakMap<LanguageModelV1CallOptions, StashEntry> keyed by the params reference itself. The provider’s doGenerate() fires.
  4. wrapGenerate extracts the provider’s reported (promptTokens, completionTokens) from result.usage (accepting both AI SDK v4 canonical camelCase AND OpenAI-passthrough snake_case shapes) and emits a SUCCESS commit via client.commitEstimated(...).
  5. For streaming (streamText), wrapStream instruments the ReadableStream<LanguageModelV1StreamPart> with a TransformStream that forwards every part downstream unmodified, watches for the terminal finish part, and asynchronously emits a SUCCESS commit when the consumer drains the stream. Mid-stream errors emit a FAILURE commit.

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.

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.

OptionTypeRequiredDescription
clientSpendGuardClientYESConfigured substrate client from @spendguard/sdk. The adapter does NOT own the client lifecycle — the consumer calls connect() / handshake() / close().
tenantIdstringYESTenant 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.
budgetIdstringNOBudget 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.

wrapStream returns the inner { stream, rawCall, rawResponse, warnings } unmodified except for stream. The replaced stream is a ReadableStream<LanguageModelV1StreamPart> produced by a TransformStream that:

  1. Forwards every LanguageModelV1StreamPart downstream byte-for-byte — consumers see the original stream unchanged.
  2. Watches each part for the terminal finish event and snapshots its usage: {promptTokens, completionTokens} payload.
  3. On the stream’s flush() (consumer drained), asynchronously emits a SUCCESS commit against the captured usage tuple.
  4. On a stream-side error or upstream throw, emits a FAILURE commit with the error message and propagates the error downstream.
  5. A single terminal boolean flag guards against the finish/error race so exactly one of SUCCESS / FAILURE fires.

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.

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 LanguageModelV1Middleware shape — not v5 yet. D06’s spec was written against AI SDK v5 (LanguageModelV2). The current ai@^4 peer wires v1; the middleware targets that surface natively. The v5 migration is additive when AI SDK v5 lands as the default peer.
  • transformParams throw-to-halt is load-bearing. Without the rethrow path in the middleware, wrapLanguageModel swallows the decision-denied signal and the budget gate never blocks the LLM call. The middleware throws DecisionDenied (and its DecisionStopped / ApprovalRequired subclasses) directly from transformParams so 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 Python pydantic_ai.py::SpendGuardModel v0.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:

Terminal window
make demo-up DEMO_MODE=vercel_ai_mastra

The 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. transformParams fires PRE, client.reserve() returns ALLOW, the wrapped model’s doGenerate() HTTP call fires, counter +1, wrapGenerate emits a SUCCESS commit with real token usage.
  • DENYspendguard_estimate_override=2000000000 blows past the seeded 1B hard-cap. The sidecar contract evaluator emits SPENDGUARD_DENY, transformParams throws DecisionDenied, generateText halts BEFORE the upstream HTTP call. Counting-stub counter UNCHANGED (proves the gate fires pre-call).
  • STREAMstreamText consumed via result.textStream async iterator. PRE fires once at stream open, POST commits once at stream end against real usage.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.

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 emitted SPENDGUARD_DENY. The middleware rethrows, generateText halts, and the upstream provider HTTP call never fires. Check the sidecar logs for the rule that matched; the decision row in the ledger carries decision_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 to generateText — it MUST be the wrapped one, not the raw openai("...") / anthropic("...") reference. Reproduce by logging middleware.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 by deploy/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 swallows SidecarUnavailable on the reserve path (degraded mode — LLM call proceeds without a gate). Construction-time client.handshake() failures are surfaced to the caller directly.

  • @spendguard/vercel-ai/mastra import resolves to undefined — Make sure your bundler honours the package’s exports map. The /mastra subpath is declared in package.json#exports and resolves via Node’s ESM resolution. Older bundlers (esbuild < 0.20, Webpack 4) may need explicit alias entries.

  • generateText: typed error not DecisionDenied — You imported DecisionDenied from @spendguard/sdk directly while the wrapped model’s middleware threw from @spendguard/vercel-ai. Class identity IS preserved (re-export, not subclass), so err instanceof DecisionDenied works regardless of which import you use. If the check fails, suspect a duplicate @spendguard/sdk in your node_modules tree.

  • Token counts on commit don’t match what the provider reports. The middleware’s extractUsageFromGenerate accepts 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 ships 0 for the missing fields — check the provider’s raw response with rawResponse?.headers.