OpenAI Agents SDK (TypeScript) — withSpendGuard(model)
@openai/agents(TypeScript) 是 OpenAI 官方的 JS agent runtime。它的Model介面(OpenAIChatCompletionsModel、OpenAIResponsesModel)在結構上 跟 Python SDK 一模一樣,所以 Python 那套 wrapper 做法(subclassModel、用reserve → call → commitEstimated把getResponse()包起來)可以乾淨地搬過來。 SpendGuard 出貨的形式是withSpendGuard(inner, opts)— 一個現成的 factory, 你把它套在任何@openai/agentsModel上,再交給new Agent({ model })就好, 不需要自己去 subclass model。不過如果你的 codebase 偏好instanceof檢查或 subclass factory,也還有一個對等的SpendGuardAgentsModelclass 形式可用。
pnpm add @spendguard/sdk @spendguard/openai-agents @openai/agents@spendguard/sdk 和 @openai/agents 都宣告成 peer dependencies,所以 adapter 兩個
都不會 pin 死版本 — 由你專案的 lockfile 說了算。需要 Node 20.10+(底層 substrate 用到
await using 加上穩定版的 AsyncLocalStorage)。
如果要打真的 OpenAI HTTP,標準的 @openai/agents-openai provider 會提供你要包起來的
OpenAIChatCompletionsModel / OpenAIResponsesModel:
pnpm add @openai/agents-openai最精簡的端到端接線 — 把 SpendGuardClient 連到 sidecar 的 UDS、組出包好的 model、
交給 new Agent({ model }),再透過 Runner.run(agent, ..., runContext) 跑起來:
import { Agent, Runner } from "@openai/agents";import { OpenAIChatCompletionsModel, OpenAIProvider } from "@openai/agents-openai";import { SpendGuardClient, newUuid7 } from "@spendguard/sdk";import { withSpendGuard, runContext } from "@spendguard/openai-agents";
const client = new SpendGuardClient({ socketPath: "/var/run/spendguard/adapter.sock", tenantId: "00000000-0000-4000-8000-000000000001", runtimeKind: "openai-agents-ts",});await client.connect();await client.handshake();
const provider = new OpenAIProvider({ apiKey: process.env.OPENAI_API_KEY });const inner = new OpenAIChatCompletionsModel(provider.openaiClient, "gpt-4o-mini");const guarded = withSpendGuard(inner, { client, tenantId: "00000000-0000-4000-8000-000000000001", budgetId: "44444444-4444-4444-8444-444444444444",});
const agent = new Agent({ name: "my-budget-aware-agent", instructions: "Reply concisely.", model: guarded,});
try { const runId = newUuid7(); const result = await runContext({ runId }, () => Runner.run(agent, "Say hello in three words."), ); console.log("Runner.run OK", { runId, output: result.finalOutput });} finally { await client.close();}每一次 Runner.run(...) 呼叫實際發生的事情:
withSpendGuardfactory 的getResponse(request)會從(request.input, request.systemInstructions)推出一個穩定的 BLAKE2b-128 簽章, 透過 substrate 的deriveUuidFromSignature(...)鑄出一組決定性的(decisionId, llmCallId),再用deriveIdempotencyKey({ tenantId, sessionId: runId, runId, stepId, llmCallId, trigger: "LLM_CALL_PRE" })組出 idempotency key。- 接著
client.reserve({ trigger: "LLM_CALL_PRE", projectedClaims, … })帶著一個 per-model 的 baselineBudgetClaim發出去(從MODEL_BASELINE_TOKENS表查;查不到 的 model 一律退回 800 tokens)。 - 碰到
DecisionDenied/DecisionStopped/ApprovalRequired時,adapter 會把例外 重新拋出 —Runner.run(...)會在 inner Model 的getResponse(...)HTTP 呼叫發出 之前就停住。Reviewer gate 1.3 有強制執行:任何非 CONTINUE 的路徑上,inner.callCount都會維持在0。 - 走到
CONTINUE時,會原封不動地拿同一個 request 去呼叫 inner Model(不改任何 欄位)。Runner 照常去編排 tool calls / handoffs。 - inner response 回來之後,bracket 會去讀
usage.totalTokens(為了跨 provider 的穩健, AI SDK v4 的標準 camelCase 跟 snake_case 兩種形狀都接受),再透過client.commitEstimated(...)送出一個SUCCESScommit。如果是 provider error, commit 會帶outcome="PROVIDER_ERROR"送出,然後把那個 error 重新拋出。
Class 形式 — SpendGuardAgentsModel
Section titled “Class 形式 — SpendGuardAgentsModel”Class 形式是給那些偏好 subclass factory、或需要 instanceof 檢查的 codebase 用的
對等介面。兩種介面都會委派到同一個 bracketedGetResponse(...) 共用核心,所以 bracket
的行為在兩者之間絕對不會走鐘 — 這是 review-standards §1.2 的 reviewer gate。
import { SpendGuardAgentsModel, runContext } from "@spendguard/openai-agents";
const guarded = new SpendGuardAgentsModel({ inner, client, tenantId: "tenant-prod", budgetId: "budget-team-7",});共用的 runContext() — 跨框架共享一條 trace
Section titled “共用的 runContext() — 跨框架共享一條 trace”一個多框架的 agent(LangChain.js + Vercel AI + Agents SDK 跑在同一個 Node process 裡)
理應只有一條 trace。SpendGuard 用單一個 AsyncLocalStorage 來撐起 runContext(),
key 是 Symbol.for("@spendguard/run-context/v1") — 每個 adapter(D04 / D06 / D08 /
D29)都從 global registry 匯入同一個 Symbol,所以不管是哪個框架驅動的,單一個
runContext({ runId }, …) scope 都能貫穿底下每一個巢狀的 LLM 呼叫。
const runId = newUuid7();await runContext({ runId }, async () => { // LangChain.js call — handler.handleChatModelStart reads the SAME runId. await chat.invoke([new HumanMessage("hello")]); // Vercel AI call — middleware.transformParams reads the SAME runId. await generateText({ model: wrappedAi, prompt: "hi" }); // OpenAI Agents call — withSpendGuard reads the SAME runId. await Runner.run(agent, "Say hi");});命名提醒:
@openai/agents自己也匯出了一個叫RunContext的 type,用來描述 OpenAI runner 串過各個 tool 的 per-run 狀態。為了避免撞名,挑一個 alias 掉:import type { RunContext as SpendGuardRunContext } from "@spendguard/openai-agents";import { RunContext } from "@openai/agents";
v0.1.0 的 options 介面是刻意收窄的。設計上預期會有更豐富的
unitId / windowInstanceId / pricing / claimEstimator 形狀,目前先延後(見下方
限制)— 在 substrate 把 UnitRef 拓寬之前,adapter 會投射出合理的 defaults。
| 選項 | 型別 | 必填 | 說明 |
|---|---|---|---|
client | SpendGuardClient | YES | 從 @spendguard/sdk 設定好的 substrate client。adapter 不負責 client 的生命週期 — 由使用方自己去呼叫 connect() / handshake() / close()。 |
tenantId | string | YES | 這次呼叫要記帳到哪個 tenant UUID。會轉發給 substrate 當作 reserve() 的 claim scope,同時也是 idempotency-key 標準 tuple 的第一個欄位。 |
budgetId | string | NO | 用來當投射 claim 的 scopeId 的 budget UUID。沒設的話,adapter 會退回用 tenantId 當 scopeId。 |
未來的某個 minor(v0.x)會等 substrate 把 UnitRef 拓寬之後,照 design.md §4 的
superset(windowInstanceId、unit、pricing、claimEstimator)逐欄把這個介面擴出去。
這些擴充都是 additive 的 optional 欄位 — 不會對 v0.1.0 的形狀造成破壞性變更。
預設 claim 投射(MODEL_BASELINE_TOKENS)
Section titled “預設 claim 投射(MODEL_BASELINE_TOKENS)”沒有給 claimEstimator 時(也就是 v0.1.0 的介面),bracket 會投射出單一個 per-model
的 baseline BudgetClaim:
| Model | Baseline (tokens) |
| --------------- | ----------------- |
| gpt-4o-mini | 500 |
| gpt-4o | 1500 |
| gpt-4.1-mini | 500 |
| gpt-4.1 | 1500 |
| o1 | 3000 |
| o3-mini | 1500 |
| o3 | 3000 |
| unknown | 800 |
這些數值跟 design.md §11 那張字面表是 byte-identical 的。TS adapter 在 v0.1.x 出的是
字面數字版;per-model 的 tokenizer dispatch(Strategy A — 也就是 Python 兄弟版的 v0.5.x
擴充)會在未來某個 TS minor 以 additive optional 的方式登場。在過渡期間,使用者自己提供的
claimEstimator(也就是未來的 v0.x options 介面)就是那個逃生口。
SpendGuard 的 OpenAI Agents TS adapter 只做 pre-call reserve + post-call commit。
bracket 在 inner OpenAI HTTP 呼叫之前做 gate,在 inner.getResponse(...) 回來
之後做 commit。在你把它擺到串流量很大的工作負載前面、或是想倚賴完整設計介面之前,
有幾件重要的事情得先搞清楚:
- 逐 chunk 的串流 gating 不在
v0.1.x範圍內。withSpendGuard(...)的getStreamedResponse(...)是直接 pass-through、沒有 PRE/POST gating — 串流會原封不動地轉發出去。等 substrate 的LLM_STREAM_DELTAtrigger 出貨後,POST_D08 /v0.2才會加上逐 chunk 的 gating。目前每一個串流呼叫都會 繞過 reserve / commit 這個 bracket;如果你的長時間串流需要 mid-stream 強制執行, 那就改用 non-streaming 的 Agent.run() 一輪一輪跑。 - DEGRADE patch 套用會以
MutationApplyFailed的形式冒出來。 DEGRADE-mutation-apply 這條路徑在 v0.1.x 是反範圍的(design.md §3 non-goals)。 substrate 的 DEGRADE outcome 會以 CONTINUE 的形式流過去 — bracket 不會去改寫 inner request。內建的 claim mutation 會在後面的 slice 才登場。這跟 Pythonopenai_agents.py裡SpendGuardAgentsModelv0.5.1 的立場一致。 - 對 substrate 拓寬
UnitRef有相依關係。 照 design.md §4 的 superset,v0.1.0 的 options 介面省略了windowInstanceId、unit、pricing和claimEstimator。 TS substrate 的 publicUnitRef目前還沒把unit_id暴露出來 (sdk/typescript/src/client.ts::mapUnitRef寫死成空的);未來某個 hardening slice 會把 SDK 端的拓寬跟 adapter 的接線一起處理掉。 - 串流回應上沒有 mid-stream 的上限。 SpendGuard 在 pre-call 時就預留好預測的 budget,
commit 則發生在呼叫結束時、對著真實的
usage.totalTokens做。adapter 永遠不會為了 攔住 token 繼續吐出而去拆掉一條飛行中的串流 — 超量會落到稽核鏈裡、在 commit 時反映出來, 但那些 token 早就已經吐出去了。 raiseError語義:SpendGuard substrate 的 error 會原樣往上傳。client.reserve(...)拋出的DecisionDenied/DecisionStopped/ApprovalRequired不會被攔下來 — 它們會穿過Runner.run(...)一路傳到 caller。 在 reserve 這條路徑上,bracket 同樣不會吞掉SidecarUnavailable(未來的degrade=auto模式在 v0.1.x 是被鎖死的 — design.md §3 non-goals);要不要因為 sidecar 掛掉就讓整個 run 停下來,由 Runner 的 caller 自己決定。- 不支援 browser(D05 §6 UDS-only)。adapter 只跑在有
AsyncLocalStorage的 Node 20.10+ 上。
內附的 docker-compose demo 會拿一個真的 @openai/agents Agent + Runner.run +
sidecar + counting-stub provider,端到端證明完整的 ALLOW + DENY + STREAM 矩陣:
make demo-up DEMO_MODE=openai_agents_ts這個模式會把 postgres + sidecar + openai-agents-runner + counting-stub 開起來,
然後從
examples/openai-agents-ts-composite/
這個 Node 範例跑三次呼叫:
- ALLOW — 一則在 budget 內的小訊息。
withSpendGuard的getResponse(request)發出 PRE,client.reserve()回 ALLOW,包好的 Model 的 HTTP 呼叫發出去,counter+1, bracket 帶著真實的 token 用量送出一個SUCCESScommit。 - DENY —
Agent.modelSettings.extraBody.spendguard_estimate_override = "2000000000"直接衝破預先種入的 1B 硬上限。sidecar 的 contract evaluator 送出SPENDGUARD_DENY,bracket 拋出DecisionDenied,Runner.run(...)在上游 HTTP 呼叫 之前就停住。counting-stub 的 counter 不變(證明 gate 是在 pre-call 觸發的)。 - STREAM — 一個 pass-through 的串流呼叫,後面再接一次 non-streaming 的
Runner.run(...),確認 bracket 的紀律有撐住。counter+1。
跑乾淨時的成功行(已鎖定 — CI 會 grep 這個一字不差的拼法):
[demo] openai_agents_ts ALL 3 steps PASS (ALLOW + DENY + STREAM)完整細節與 verify-SQL gates 都在
deploy/demo/openai_agents_ts/README.md。
獨立的 --mock 模式(不需要 sidecar)會拿同一個 factory,搭配一個 in-process 的
SpendGuardClient double 來跑:
cd examples/openai-agents-ts-compositepnpm installnode demo.mjs --mockmock 模式會明確驗證「DENY ⇒ inner Model 絕對不會被呼叫」這條不變式(review-standards §1.6 reviewer gate 1.6),一旦違反就以非零 exit code 退出。
常見的開機期與第一次呼叫的錯誤。substrate 的 typed exceptions 有從
@spendguard/openai-agents 重新匯出,所以你可以直接對 DecisionDenied /
SidecarUnavailable 做 pattern-match,不用再多 import 一次。
-
DecisionDenied: reserve() denied by contract— 這是 budget 用完、或某條 contract rule 送出SPENDGUARD_DENY時的預期行為。bracket 會重新拋出,Runner.run(...)停住, 上游的 OpenAI HTTP 呼叫永遠不會發出。去翻 sidecar log 看是哪條 rule 命中;ledger 裡那筆 decision row 會帶著decision_context.contract_rule。 -
Budget gate 沒擋住 — OpenAI 呼叫還是照樣發出去了。 幾乎都是因為
withSpendGuard(...)沒有真的接進Agent({ model })那個 slot。檢查你傳給new Agent(...)的 model — 它一定要是包好的那個,不能是原始的OpenAIChatCompletionsModel(...)reference。 -
@spendguard/openai-agents called outside an active runContext().— 每一個透過withSpendGuard(...)驅動的Runner.run(...)都一定要包在await runContext({ runId }, () => Runner.run(agent, input))裡。錯誤訊息本身就附了 你該套上去的那段確切 wrap snippet。 -
SidecarUnavailable: handshake timeout— sidecar 的 UDS 路徑 (SPENDGUARD_SIDECAR_UDS,預設/var/run/spendguard/adapter.sock)連不上。 確認 sidecar container 有起來、socket 檔案存在,而且你的 Node process 對它有讀寫權限。 在 demo overlay 裡這是靠deploy/demo/openai_agents_ts/docker-compose.yaml接好的; 到 demo 之外,把同一個 socket 路徑 mount 進你 Node container 的/var/run/spendguard/。 -
RunContextimport 跟@openai/agents的RunContext撞名 — import 時把其中一個 alias 掉:import type { RunContext as SpendGuardRunContext } from "@spendguard/openai-agents";import { RunContext } from "@openai/agents"; -
commit 上的 token 數跟 provider 回報的對不起來。 bracket 的
extractUsage(response)同時接受Usage-class 的 camelCase ({inputTokens, outputTokens, totalTokens})跟 snake_case ({prompt_tokens, completion_tokens, total_tokens})兩種形狀 — Agents SDK 對 OpenAI provider 出的是 Usage class,但是把原始 OpenAI HTTP 形狀原封不動傳過來的 custom provider 也照樣支援。如果兩種形狀都沒有(罕見;防禦性的 fallback),commit 會把缺的欄位以0送出 — 用response.providerData去檢查 provider 的原始回應。 -
Class identity 檢查失敗:
err instanceof DecisionDenied是 false — 你直接從@spendguard/sdk匯入DecisionDenied,但包好的 model 的 bracket 是從@spendguard/openai-agents拋出來的。Class identity 其實是有保留的(是 re-export, 不是 subclass),所以不管你用哪一個 import,這個檢查都會成立。如果還是失敗,那就懷疑 你的node_modules樹裡有重複的@spendguard/sdk。
- Quickstart — 5 分鐘把整套 SpendGuard stack 拉起來。
- Vercel AI SDK (covers Mastra) — 這個 adapter 的 Vercel AI 中介軟體兄弟版。
- LangChain.js callback handler — LangChain.js 的 callback-handler 兄弟版。
- Pydantic-AI — Python 兄弟版。
- Contract YAML reference — 撰寫 allow/stop rules。
- 其他 adapter 整合: LangChain & LangGraph (Python) · OpenAI Agents SDK (Python) · Microsoft AGT · LiteLLM proxy guardrail