Vercel AI SDK 中间件(含 Mastra 覆盖)
Vercel AI SDK v4+(
ai)是 TS 这一侧的主流 LLM router。Mastra Agents 底层 也是调ai的generateText/streamText,所以一个中间件就把两套生态都 盖住了。SpendGuard 以createSpendGuardMiddleware形态发布 —— 一个标准的LanguageModelV1Middleware,通过wrapLanguageModel({ model, middleware })直接套到任意@ai-sdk/*provider 上。不用子类化 model,也不用 fork proxy。 Mastra 用户则通过@spendguard/vercel-ai/mastra子路径别名,以更贴合 Mastra 习惯的名字导入同一个 factory。
在用 Mastra?请看
@spendguard/mastra—— 那是推荐的 Mastra 集成方案(交付物 D38)。自 v0.14.0 起 Mastra 自己掌管 agent loop;本页覆盖的范围限定在显式交给 Mastra 的 AI SDK model 实例 (/mastra子路径别名对这条路径仍然发布且可用),而走 model-router-string 的 Agent 没有wrapLanguageModel的注入点,得靠@spendguard/mastra的SpendGuardProcessor在 agent-step 边界上拦。 详见D06 design.md§9 里 2026-06-10 那条带日期的修订说明。
pnpm add @spendguard/sdk @spendguard/vercel-ai ai@spendguard/sdk、ai(Vercel AI SDK)和 zod 都声明为 peer 依赖,适配器一个
都不 pin —— 以你项目 lockfile 里的版本为准。需要 Node 20.10+(底层用到了
await using 加上稳定版的 AsyncLocalStorage)。
要走真实 provider HTTP,再装上你要对接的官方 @ai-sdk/* provider:
pnpm add @ai-sdk/openai # or @ai-sdk/anthropic, @ai-sdk/google, ...最小的端到端接线 —— 把一个 SpendGuardClient 连到 sidecar 的 UDS,构建中间件,
交给 wrapLanguageModel,然后用包好的 model 跑 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();}每次 generateText / streamText 调用时具体发生了什么:
- 中间件的
transformParams({ params })从params引用上推导出一对稳定的(runId, idempotencyKey),投影出一个粗粒度的 pre-callBudgetClaim,然后 调client.reserve({ trigger: "LLM_CALL_PRE", ... })。 - 一旦命中
DecisionDenied(DENY / STOP / APPROVAL_REQUIRED),中间件直接 rethrow ——generateText在内层doGenerate()的 HTTP 调用发出之前就停下。 - 成功的话,正在飞行中的
(decisionId, reservationId)会被存进一个以 params 引用本身为 key 的WeakMap<LanguageModelV1CallOptions, StashEntry>。然后 provider 的doGenerate()才发出。 wrapGenerate从result.usage里取出 provider 报告的(promptTokens, completionTokens)(同时兼容 AI SDK v4 规范的 camelCase 和 OpenAI 透传的 snake_case 两种形状),通过client.commitEstimated(...)发一个SUCCESScommit。- 对流式(
streamText),wrapStream给ReadableStream<LanguageModelV1StreamPart>套上一个TransformStream: 原样往下游转发每一个 part,盯着终止的finishpart,等消费者把流读干后再 异步发出SUCCESScommit。流中途出错则发FAILUREcommit。
Mastra Agents —— 同一个中间件,换个别名导入
Section titled “Mastra Agents —— 同一个中间件,换个别名导入”Mastra Agents 底层调的就是 ai 的 generateText / streamText,所以同一个
LanguageModelV1Middleware 把两套生态盖得严丝合缝。Mastra 用户通过 /mastra
子路径别名,以更贴合 Mastra 习惯的名字导入这个 factory:
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.@spendguard/vercel-ai/mastra 处的 factory 就是个函数引用别名 —— 它跟 root
export 之间满足严格相等(===)。一个 factory,两条导入路径。
中间件的 options 表面支持 unitId 透传(在 runner 环境里设 SPENDGUARD_UNIT_ID,
再作为 unitId 选项传进来)。design 预留的那些更丰富的字段
—— windowInstanceId / pricing / claimEstimator —— 推迟到后续 slice。
| Option | Type | Required | Description |
|---|---|---|---|
client | SpendGuardClient | YES | 来自 @spendguard/sdk 的已配置底层 client。适配器不掌管 client 生命周期 —— 由调用方自己去调 connect() / handshake() / close()。 |
tenantId | string | YES | 这次调用要计费到的 tenant UUID。会作为 reserve() 的 claim 作用域转发给底层,同时作为 idempotency-key 规范化 tuple 的第一个字段。 |
budgetId | string | NO | 投影 claim 时用作 scopeId 的 budget UUID。不设的话,中间件回退到用 tenantId 当 scopeId。 |
之后某个 minor 版本(v0.x)会按 design.md §4 的超集逐字段扩展这个表面
(windowInstanceId、unit、pricing、claimEstimator、route、
callSignature、runIdProvider、providerEventIdExtractor)。这些扩展都是
additive 的可选字段 —— 对 v0.1.0 的形状没有 breaking change。
wrapStream 把内层的 { stream, rawCall, rawResponse, warnings } 原样返回,
只替换 stream 一项。替换后的 stream 是一个由 TransformStream 产出的
ReadableStream<LanguageModelV1StreamPart>,它做这几件事:
- 逐字节往下游转发每一个
LanguageModelV1StreamPart—— 消费者看到的原始流 一点不变。 - 盯着每个 part 找终止的
finish事件,快照它的usage: {promptTokens, completionTokens}载荷。 - 在流的
flush()(消费者读干)时,针对捕获到的 usage tuple 异步发出一个SUCCESScommit。 - 流这侧出错或上游抛异常时,带着错误信息发一个
FAILUREcommit,并把错误往 下游继续传。 - 单个
terminal布尔标志守住 finish/error 的竞态,保证SUCCESS/FAILURE有且只有一个会触发。
commit 这侧失败(比如 finish 之后 sidecar UNAVAILABLE)不会污染流 —— 底层的 TTL reconciler 会通过审计链把任何成孤儿的 reservation 收掉。
SpendGuard 的 Vercel AI SDK 中间件只做 pre-call 加 end-of-stream commit。 中间件在上游 provider HTTP 调用之前拦,在流跑完之后才 commit。在把它 摆到长流式响应前面,或者打算依赖 D06 完整 design 表面之前,有几个要点先搞清楚:
- 目标是 Vercel AI SDK v4 的
LanguageModelV1Middleware形状 —— 还不是 v5。 D06 的 spec 是对着 AI SDK v5(LanguageModelV2)写的。当前ai@^4peer 接的是 v1;中间件原生针对的就是 v1 这个表面。等 AI SDK v5 成为默认 peer 时,迁到 v5 是 additive 的。 transformParams抛异常即停,这一步是关键所在,缺它不可。 中间件里要是没有这条 rethrow 路径,wrapLanguageModel会把 decision-denied 信号吞掉,budget gate 就永远拦不住 LLM 调用。中间件直接从transformParams里抛DecisionDenied(以及它的DecisionStopped/ApprovalRequired子类),好让 AI SDK 调用方 看到带类型的错误。- 对流式响应没有 mid-stream cap。 SpendGuard 在 pre-call 时 reserve 预测出
的 budget,commit 发生在 end-of-stream、对着真实
usage。中间件不会为了 止住 token 输出去拆掉一个飞行中的流 —— 超额会落到审计链、在 commit 时体现, 但那些 token 早就已经吐出去了。 - DEGRADE 的 patch 应用失败会以
MutationApplyFailed暴露。 与 Pythonpydantic_ai.py::SpendGuardModelv0.5.1 的立场一致。内置的 claim mutation 放到后续 slice。 - 不做 tool-call 的 mid-loop gating。 每次 tool call 都是它自己的一个 AI SDK 事件;底层逐次处理 per-call 的 PRE/POST。跨 tool 的 budget 强制是 contract 层的活,不归中间件管。
随包带了一个 docker-compose demo,对着真实的 generateText / streamText +
sidecar + counting-stub provider 端到端跑通完整的 ALLOW + DENY + STREAM 矩阵,
启动时还会顺带做一次 Mastra 别名对等性检查:
make demo-up DEMO_MODE=vercel_ai_mastra这个模式会拉起 postgres + sidecar + vercel-ai-mastra-runner + counting-stub,然后从
examples/vercel-ai-mastra/
的 Node 示例里跑三次调用:
- ALLOW —— 预算内的小消息。
transformParams在 PRE 阶段触发,client.reserve()返回 ALLOW,包好的 model 的doGenerate()HTTP 调用发出, 计数器 +1,wrapGenerate用真实 token 用量发一个SUCCESScommit。 - DENY ——
spendguard_estimate_override=2000000000撞穿了 seed 进去的 1B 硬上限。sidecar contract evaluator 发出SPENDGUARD_DENY,transformParams抛DecisionDenied,generateText在上游 HTTP 调用之前就停下。counting-stub 计数器保持不变(证明 gate 是 pre-call 触发的)。 - STREAM ——
streamText通过result.textStream的 async iterator 消费。 PRE 在流打开时触发一次,POST 在流结束时对着真实的usage.completionTokenscommit 一次。
干净跑通时的成功行:
[demo] vercel_ai_mastra ALL 3 steps PASS (ALLOW + DENY + STREAM)完整细节和 verify-SQL 各道 gate 在
deploy/demo/vercel_ai_mastra/README.md。
常见的启动期和首次调用错误。底层那些带类型的异常会从 @spendguard/vercel-ai
里 re-export 出来,所以你可以直接 pattern-match DecisionDenied /
SidecarUnavailable,不用再多导一次包。
-
DecisionDenied: reserve() denied by contract—— 预算耗尽,或某条 contract 规则发出了SPENDGUARD_DENY时的预期行为。中间件 rethrow,generateText停下,上游 provider 的 HTTP 调用根本不发。去 sidecar 日志里 看是哪条规则命中了;ledger 里那行 decision 记录带着decision_context.contract_rule。 -
budget gate 没拦住 —— provider 调用照样发出去了。 几乎一定是因为没把中间件 串进
wrapLanguageModel。检查你传给generateText的那个 model —— 它必须是包好的那个,不是裸的openai("...")/anthropic("...")引用。 复现办法:打印middleware.transformParams.toString()—— 它不应该是 identity 函数。 -
SidecarUnavailable: handshake timeout—— sidecar 的 UDS 路径 (SPENDGUARD_SIDECAR_UDS,默认/var/run/spendguard/adapter.sock)够不着。 确认 sidecar 容器起来了、socket 文件存在、你的 Node 进程对它有读写权限。 在 demo overlay 里这是由deploy/demo/vercel_ai_mastra/docker-compose.yaml接好的;demo 之外,把同一个 socket 路径挂进你 Node 容器的/var/run/spendguard/。在 reserve 路径上,中间件 会吞掉SidecarUnavailable(降级模式 —— LLM 调用不带 gate 继续走)。而构造期client.handshake()的失败会直接抛给调用方。 -
@spendguard/vercel-ai/mastra导入解析成了undefined—— 确认你的 bundler 尊重这个包的exports映射。/mastra子路径声明在package.json#exports里,走 Node 的 ESM 解析。老一点的 bundler (esbuild < 0.20、Webpack 4)可能需要显式加 alias 条目。 -
generateText: typed error not DecisionDenied—— 你从@spendguard/sdk直接导了DecisionDenied,而包好的 model 的中间件是从@spendguard/vercel-ai抛的。类身份是保住的(re-export,不是子类),所以不管你用哪条导入,err instanceof DecisionDenied都成立。要是这个判断挂了,八成是你的node_modules树里有重复的@spendguard/sdk。 -
commit 时的 token 数和 provider 报的对不上。 中间件的
extractUsageFromGenerate同时接受 AI SDK v4 规范的 camelCase ({promptTokens, completionTokens})和 OpenAI 透传的 snake_case ({prompt_tokens, completion_tokens})两种形状。两种都不在(罕见;防御性兜底) 的话,commit 会给缺失字段发0—— 用rawResponse?.headers去看 provider 的 原始响应。
- 快速上手 —— 5 分钟拉起完整的 SpendGuard 栈
- LangChain.js callback handler —— 这个中间件的 LangChain.js 对应物(callback-handler 形状;LangChain 风格)
- Pydantic-AI —— Python 对应物
- Contract YAML 参考 —— 编写 allow/stop 规则
- 其他适配器集成: LangChain & LangGraph (Python) · OpenAI Agents SDK · Microsoft AGT · LiteLLM proxy guardrail