Vercel AI SDK middleware(同時涵蓋 Mastra)
Vercel AI SDK v4+(
ai)是 TS 這邊主流的 LLM router。Mastra Agents 底層其實也是 在呼叫ai的generateText/streamText,所以一個 middleware 兩邊生態系一起 涵蓋。SpendGuard 以createSpendGuardMiddleware形式出貨 —— 就是一個標準的LanguageModelV1Middleware,透過wrapLanguageModel({ model, middleware })直接掛到任何@ai-sdk/*provider 上。不用 subclass model,也不用 fork proxy。 Mastra 使用者則是透過@spendguard/vercel-ai/mastra這個 subpath alias, 以 Mastra 慣用的名稱 import 同一個 factory。
在用 Mastra?請看
@spendguard/mastra—— 那才是推薦的 Mastra 整合(deliverable D38)。Mastra 從 v0.14.0 起就有自己的 agent loop;本頁所涵蓋的範圍僅限於明確交給 Mastra 的 AI SDK model 實例 (/mastrasubpath alias 對這條路徑仍維持發布且可用),至於走 model-router-string 的 Agents 因為沒有wrapLanguageModel的注入點,要靠@spendguard/mastra的SpendGuardProcessor在 agent-step 邊界去攔。詳見D06 design.md§9 那份標註 2026-06-10 的 amendment。
Install
Section titled “Install”pnpm add @spendguard/sdk @spendguard/vercel-ai ai@spendguard/sdk、ai(Vercel AI SDK)跟 zod 都宣告成 peer
dependencies,所以這個 adapter 一個版本都不釘 —— 由你專案的 lockfile
說了算。需要 Node 20.10+(底層 substrate 用到 await using 跟穩定版的
AsyncLocalStorage)。
要打真正的 provider HTTP,就把你要用的官方 @ai-sdk/* provider 加進來:
pnpm add @ai-sdk/openai # or @ai-sdk/anthropic, @ai-sdk/google, ...Quick start
Section titled “Quick start”最精簡的端到端接法 —— 把 SpendGuardClient 連到 sidecar 的 UDS,
建好 middleware,丟給 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 呼叫實際發生的事:
- middleware 的
transformParams({ params })會從params這個 reference 推導出一組穩定的(runId, idempotencyKey),投射出一筆粗略的 pre-callBudgetClaim,然後呼叫client.reserve({ trigger: "LLM_CALL_PRE", ... })。 - 一旦遇到
DecisionDenied(DENY / STOP / APPROVAL_REQUIRED),middleware 就把它 rethrow 出去 ——generateText會在內層doGenerate()的 HTTP 呼叫發出去之前就停下來。 - 成功的話,這筆 inflight 的
(decisionId, reservationId)會以 params 的 reference 本身為 key,存進一個WeakMap<LanguageModelV1CallOptions, StashEntry>。接著 provider 的doGenerate()才發出去。 wrapGenerate會從result.usage取出 provider 回報的(promptTokens, completionTokens)(v4 canonical camelCase 跟 OpenAI-passthrough snake_case 兩種形狀都收),然後透過client.commitEstimated(...)送出一筆SUCCESScommit。- 串流(
streamText)這邊,wrapStream會在ReadableStream<LanguageModelV1StreamPart>上面套一層TransformStream, 把每個 part 原封不動往下游轉,同時盯著終結的finishpart,等到 consumer 把 stream 抽乾時非同步送出一筆SUCCESScommit。串流中途出錯 則送出FAILUREcommit。
Mastra Agents —— 同一個 middleware,換個 alias import
Section titled “Mastra Agents —— 同一個 middleware,換個 alias import”Mastra Agents 底層也是在呼叫 ai 的 generateText / streamText,所以
同一個 LanguageModelV1Middleware 兩邊生態系是 byte-for-byte 一起涵蓋的。
Mastra 使用者透過 /mastra subpath alias,以 Mastra 慣用的名稱 import
這個 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 是個 function-reference
alias —— 跟 root export 是嚴格相等(===)的。一個 factory,兩條 import 路徑。
Configuration
Section titled “Configuration”middleware 的 options 介面支援 unitId pass-through(在 runner 環境裡設好
SPENDGUARD_UNIT_ID,再把它當成 unitId option 傳進去)。設計上預期的那組更完整的
windowInstanceId / pricing / claimEstimator 欄位則延到後面的 slice 再補。
| Option | Type | Required | Description |
|---|---|---|---|
client | SpendGuardClient | YES | 從 @spendguard/sdk 設定好的 substrate client。這個 adapter 不掌管 client 的生命週期 —— 由 consumer 自己呼叫 connect() / handshake() / close()。 |
tenantId | string | YES | 這次呼叫要計費到的 tenant UUID。會轉給 substrate 當作 reserve() 的 claim scope,也是 idempotency-key canonical tuple 的第一個欄位。 |
budgetId | string | NO | 當成投射出來那筆 claim 的 scopeId 用的 budget UUID。沒設的話,middleware 會退回用 tenantId 當 scopeId。 |
之後某個 minor 版(v0.x)會把這個介面照 design.md §4 那組 superset
逐欄補齊(windowInstanceId、unit、pricing、claimEstimator、route、
callSignature、runIdProvider、providerEventIdExtractor)。這些擴充都是
additive 的 optional 欄位 —— 對 v0.1.0 的形狀不會造成 breaking change。
Streaming semantics
Section titled “Streaming semantics”wrapStream 回傳的內層 { stream, rawCall, rawResponse, warnings }
除了 stream 以外原封不動。被換掉的那個 stream 是一個由
TransformStream 產出的 ReadableStream<LanguageModelV1StreamPart>,它會:
- 把每個
LanguageModelV1StreamPart都 byte-for-byte 往下游轉 —— consumer 看到的就是沒被動過的原始 stream。 - 盯著每個 part,看到終結的
finishevent 就把它的usage: {promptTokens, completionTokens}payload 快照下來。 - 等到 stream 的
flush()(consumer 抽乾了),就針對抓下來的那組 usage tuple 非同步送出一筆SUCCESScommit。 - 如果是 stream 這側出錯、或上游 throw,就送出一筆帶錯誤訊息的
FAILUREcommit,並把錯誤往下游傳出去。 - 用一個
terminalboolean 旗標擋住 finish/error 的 race,確保SUCCESS/FAILURE剛好只會觸發其中一個。
commit 這側就算失敗(例如 finish 之後 sidecar UNAVAILABLE)也不會弄壞 stream —— substrate 的 TTL reconciler 會透過 audit chain 把任何沒收掉的 reservation 收乾淨。
Limitations
Section titled “Limitations”SpendGuard 的 Vercel AI SDK middleware 只做 pre-call + 串流結束時 commit。 middleware 是在上游 provider 的 HTTP 呼叫之前攔,串流跑完之後才 commit。 在你把它擺到長串流回應前面、或想倚賴 D06 完整設計介面之前,有幾件事要先弄清楚:
- 走的是 Vercel AI SDK v4 的
LanguageModelV1Middleware形狀 —— 還不是 v5。 D06 的 spec 是對著 AI SDK v5(LanguageModelV2)寫的。目前的ai@^4peer 接的是 v1;這個 middleware 是原生對著那個介面做的。等 AI SDK v5 變成預設 peer 時,v5 的遷移會是 additive 的。 transformParams靠 throw 來 halt,這條路徑是整個機制的命脈。 middleware 裡 少了那條 rethrow 路徑,wrapLanguageModel會把 decision-denied 訊號吞掉, budget gate 就永遠擋不住 LLM 呼叫。所以 middleware 是直接從transformParams把DecisionDenied(以及它的DecisionStopped/ApprovalRequired子類別)throw 出來,讓 AI SDK 的呼叫端看得到 typed error。- 串流回應沒有 mid-stream cap。 SpendGuard 是在 pre-call 時 reserve
預測的 budget,commit 則發生在串流結束、對著真正的
usage做。middleware 不會為了攔住 token 輸出而把一個 mid-flight 的 stream 拆掉 —— 超用的部分會 進到 audit chain、在 commit 時反映出來,但那些 token 早就已經吐出去了。 - DEGRADE 的 patch 套用失敗會以
MutationApplyFailed形式冒出來。 跟 Pythonpydantic_ai.py::SpendGuardModelv0.5.1 的立場一致。內建的 claim mutation 留到後面的 slice 再做。 - tool-call 在 loop 中途不做 gating。 每個 tool call 都是它自己的一個 AI SDK event;substrate 是逐次呼叫處理 PRE/POST。跨 tool 的 budget enforcement 是 contract layer 的事,不歸 middleware 管。
附帶一套 docker-compose demo,會對著真正的 generateText / streamText +
sidecar + counting-stub provider 端到端把整個 ALLOW + DENY + STREAM
矩陣跑過一遍,啟動時也順帶做 Mastra alias 的 parity check:
make demo-up DEMO_MODE=vercel_ai_mastra這個 mode 會啟動 postgres + sidecar + vercel-ai-mastra-runner + counting-stub,接著從
examples/vercel-ai-mastra/
這支 Node 範例跑三次呼叫:
- ALLOW —— budget 之內的小訊息。
transformParams觸發 PRE,client.reserve()回 ALLOW,包好的 model 的doGenerate()HTTP 呼叫發出去, counter +1,wrapGenerate帶著真正的 token 用量送出一筆SUCCESScommit。 - DENY ——
spendguard_estimate_override=2000000000直接爆掉預設好的 1B hard-cap。sidecar 的 contract evaluator 送出SPENDGUARD_DENY,transformParamsthrow 出DecisionDenied,generateText在上游 HTTP 呼叫之前就停下來。counting-stub 的 counter 維持不變(證明 gate 是 在 pre-call 觸發的)。 - STREAM ——
streamText透過result.textStream這個 async iterator 消費。PRE 在 stream 開啟時觸發一次,POST 在 stream 結束時對著真正的usage.completionTokenscommit 一次。
乾淨跑完一輪的成功行:
[demo] vercel_ai_mastra ALL 3 steps PASS (ALLOW + DENY + STREAM)完整細節跟 verify-SQL gates 在
deploy/demo/vercel_ai_mastra/README.md。
Troubleshooting
Section titled “Troubleshooting”啟動時跟第一次呼叫常見的錯誤。substrate 的 typed exceptions 都從
@spendguard/vercel-ai 再 export 出來,所以你不用多 import 一次就能直接對
DecisionDenied / SidecarUnavailable 做 pattern-match。
-
DecisionDenied: reserve() denied by contract—— 這是預期行為, 發生在 budget 用完、或某條 contract rule 送出SPENDGUARD_DENY的時候。 middleware 會把它 rethrow,generateText停下來,上游 provider 的 HTTP 呼叫完全不會發出去。去翻 sidecar log 看是哪條 rule 命中;ledger 裡那筆 decision row 會帶著decision_context.contract_rule。 -
budget gate 沒擋住 —— provider 呼叫照樣發出去。 幾乎都是因為 middleware 根本沒被串進
wrapLanguageModel。檢查一下你丟給generateText的那個 model —— 它必須是包好的那個,而不是原始的openai("...")/anthropic("...")reference。把middleware.transformParams.toString()印出來重現一下 —— 它不應該是 identity function。 -
SidecarUnavailable: handshake timeout—— sidecar 的 UDS 路徑 (SPENDGUARD_SIDECAR_UDS,預設/var/run/spendguard/adapter.sock) 連不到。確認 sidecar container 有起來、socket 檔案存在、而且你的 Node process 對它有讀寫權限。在 demo overlay 裡這是由deploy/demo/vercel_ai_mastra/docker-compose.yaml接好的;在 demo 之外, 就把同一個 socket 路徑 mount 進你 Node container 的/var/run/spendguard/。middleware 在 reserve 路徑上會把SidecarUnavailable吞掉(降級模式 —— LLM 呼叫不經 gate 直接放行)。 建構期client.handshake()的失敗則會直接丟回給呼叫端。 -
@spendguard/vercel-ai/mastraimport 解析出來是undefined—— 確認你的 bundler 有遵守套件的exportsmap。/mastra這個 subpath 宣告 在package.json#exports,靠 Node 的 ESM resolution 解析。比較舊的 bundler(esbuild < 0.20、Webpack 4)可能要另外加 alias entry。 -
generateText: typed error not DecisionDenied—— 你直接從@spendguard/sdkimport 了DecisionDenied,但包好的 model 的 middleware 是從@spendguard/vercel-aithrow 的。class identity 是有保留的 (re-export,不是 subclass),所以不管你從哪邊 import,err instanceof DecisionDenied都會成立。如果這個檢查掛了,八成是你的node_modulestree 裡有重複的@spendguard/sdk。 -
commit 時的 token 數跟 provider 回報的對不起來。 middleware 的
extractUsageFromGenerate兩種形狀都收:AI SDK v4 canonical camelCase ({promptTokens, completionTokens})跟 OpenAI-passthrough snake_case ({prompt_tokens, completion_tokens})。如果兩種都不在(很少見,這是 防禦性的 fallback),commit 會把缺的欄位送0—— 用rawResponse?.headers去看 provider 的原始回應。
Related
Section titled “Related”- Quickstart —— 五分鐘把整套 SpendGuard stack 拉起來
- LangChain.js callback handler —— 這個 middleware 的 LangChain.js 姊妹版(callback-handler 形狀;走 LangChain 慣用法)
- Pydantic-AI —— Python 姊妹版
- Contract YAML reference —— 撰寫 allow/stop 規則
- 其他 adapter 整合: LangChain & LangGraph (Python) · OpenAI Agents SDK · Microsoft AGT · LiteLLM proxy guardrail