docs / get started
Framework integrations
Drop-in budget enforcement for the Vercel AI SDK, LangChain (JS and Python) and CrewAI.
The integrations wrap the same three calls you would make by hand: check() before a model call, record() after it, and startRun() for a per-run cap. Your framework still makes the provider call. noburn never proxies it, and it never receives prompt or completion text. The integrations only use the prompt's length to estimate input tokens.
Every integration behaves the same way:
- Allowed: the call goes through, and the real token usage is recorded once it finishes. For streams, usage comes from the final usage chunk.
- Blocked: a
NoburnBlockedErroris raised before the request reaches the provider. (With the AI SDK'sstreamText/streamObjectthe error goes to theonErrorcallback instead of being thrown; see Streaming.) ItsblockReason(block_reasonin Python) holds the server's reason, for examplebudget_exceeded,user_budget_exceeded,run_budget_exceededorpolicy_rule. - noburn unreachable: the call goes through.
check()fails open by default, so setonError: 'block'/on_error="block"on the guard if you want it to fail closed. - Invalid key: a
NoburnAuthErroris raised. No other error comes from the integration.
Nothing extra gets installed. Both SDKs have zero runtime dependencies, and the integrations work with the framework you already have.
Vercel AI SDK
npm install @noburn/sdk ainoburnMiddleware works with wrapLanguageModel. It supports generateText, streamText, generateObject, streamObject and agents, because they all call the wrapped model.
import { generateText, streamText, wrapLanguageModel } from 'ai';
import { openai } from '@ai-sdk/openai';
import { NoburnGuard } from '@noburn/sdk';
import { noburnMiddleware, NoburnBlockedError } from '@noburn/sdk/ai-sdk';
const guard = new NoburnGuard({
apiKey: process.env.NOBURN_API_KEY!,
projectId: process.env.NOBURN_PROJECT_ID!,
});
const model = wrapLanguageModel({
model: openai('gpt-4o-mini'),
middleware: noburnMiddleware(guard, { endUserId: 'user_123' }),
});
export async function POST(req: Request) {
try {
const { text } = await generateText({ model, prompt: await req.text() });
return Response.json({ text });
} catch (err) {
if (err instanceof NoburnBlockedError) {
// err.blockReason === 'user_budget_exceeded' | 'budget_exceeded' | ...
return Response.json({ error: err.blockReason }, { status: 402 });
}
throw err;
}
}Streaming
Streams are metered from the final usage chunk. When a streamed call is blocked, streamText doesn't throw from textStream or fullStream. The AI SDK sends the NoburnBlockedError to onError and the stream ends with no output, so handle the block there:
export async function POST(req: Request) {
let blockReason: string | null = null;
const result = streamText({
model,
prompt: await req.text(),
onError: ({ error }) => {
if (error instanceof NoburnBlockedError) {
blockReason = error.blockReason; // e.g. 'user_budget_exceeded'
} else {
console.error(error);
}
},
});
return result.toTextStreamResponse();
}If you need to answer with a 402 before any stream starts, call guard.check() yourself before streamText. The middleware still meters the call.
Per-request attribution
Pass a function to work out the end user, run and policy context for each call, for example from a request-scoped store:
const model = wrapLanguageModel({
model: openai('gpt-4o'),
middleware: noburnMiddleware(guard, (call) => ({
endUserId: currentUser().id,
context: { feature: 'support-bot', type: call.type }, // 'generate' | 'stream'
})),
});| Option | Default | Description |
|---|---|---|
endUserId, runId, context | — | Static attribution for every call |
resolve(call) | — | Works out the attribution for each call. You can pass it in place of the options object. |
estimateTokens(params) | ~4 chars/token | Your own input-token estimate (for example tiktoken) |
estimatedTokensOut | maxOutputTokens, else 256 | Output-token estimate used by check() |
modelId | model id without provider/ | The model id reported to noburn (a string or a function) |
The middleware follows the AI SDK 7 middleware spec (specificationVersion: 'v4'). Its types are structural, so ai is never a dependency of @noburn/sdk.
LangChain (JavaScript)
import { ChatOpenAI } from '@langchain/openai';
import { NoburnGuard } from '@noburn/sdk';
import { NoburnCallbackHandler, NoburnBlockedError } from '@noburn/sdk/langchain';
const guard = new NoburnGuard({
apiKey: process.env.NOBURN_API_KEY!,
projectId: process.env.NOBURN_PROJECT_ID!,
});
const llm = new ChatOpenAI({
model: 'gpt-4o-mini',
callbacks: [new NoburnCallbackHandler(guard, { endUserId: 'user_123' })],
});
await llm.invoke('Hello'); // throws NoburnBlockedError when over budgetThe handler sets raiseError and awaitHandlers, so LangChain waits for the budget check and passes the block back to you before it sends the provider request. endUserId and runId can be functions, which are evaluated on every call.
LangChain (Python)
pip install "noburn[langchain]" # or: pip install noburn langchain-corefrom langchain_openai import ChatOpenAI
from noburn import NoburnGuard, NoburnBlockedError
from noburn.integrations.langchain import NoburnCallbackHandler
guard = NoburnGuard(api_key="sk-nb-...", project_id="...")
handler = NoburnCallbackHandler(guard, end_user_id="user_123")
llm = ChatOpenAI(model="gpt-4o-mini", callbacks=[handler])
try:
llm.invoke("Hello")
except NoburnBlockedError as e:
print("blocked:", e.block_reason)on_llm_start and on_chat_model_start run the check. on_llm_end records usage from llm_output["token_usage"] or from the messages' usage_metadata. The handler works with sync, async and streaming calls, and with chains and agents (pass config={"callbacks": [handler]}). Importing noburn never imports LangChain.
| Argument | Default | Description |
|---|---|---|
end_user_id, run_id | None | A string, or a zero-argument callable evaluated per call |
context | None | Policy context for rule matching |
model | — | Used when LangChain doesn't expose the model id |
estimated_tokens_out | max_tokens, else 256 | Output-token estimate used by check() |
estimate_tokens | ~4 chars/token | callable(text) -> int. The text is never stored. |
CrewAI
Requires crewai>=1.15, which needs Python 3.10 or newer.
pip install "noburn[crewai]" # or: pip install noburn "crewai>=1.15"NoburnCrewGuard opens a fresh noburn run for each crew execution, so all of the crew's spend counts against a single hard cap:
from crewai import Agent, Crew, Task
from noburn import NoburnGuard, NoburnBlockedError
from noburn.integrations.crewai import NoburnCrewGuard
guard = NoburnGuard(api_key="sk-nb-...", project_id="...")
crew = Crew(agents=[researcher, writer], tasks=[research, write])
crew_guard = NoburnCrewGuard(guard, budget_cap_usd=0.50, end_user_id="user_123")
try:
result = crew_guard.kickoff(crew, inputs={"topic": "AI agents"})
except NoburnBlockedError as e:
print("crew stopped:", e.block_reason)
finally:
print(crew_guard.summary) # status, spend_usd, budget_cap_usd, ...kickoff() starts a run, runs the crew and ends the run, even if the crew raises. While the crew runs, the guard registers CrewAI model-call hooks (PRE_MODEL_CALL and POST_MODEL_CALL) scoped to that one execution:
- Before every LLM call, for every agent and whatever
step_callbacks they have, it callscheck()against the run. Once the run, project or end-user budget is used up, the call is aborted before it reaches the provider. - The error it raises is both a
NoburnBlockedErrorand CrewAI'sHookAborted. CrewAI treats it as a deny, so the task isn't retried and no more provider calls are made. - Every allowed LLM call is recorded exactly once, using the change in the LLM's
get_token_usage_summary()since the call started. Most calls are recorded as soon as they finish. CrewAI runs no post-call hook for native tool-call turns (when the model's response is a tool call rather than text). Those turns are recorded just before that agent's next LLM call, and before its budget check, so they count against the cap. A tool-call turn that has no next call is recorded when the crew finishes. A provider call that raises before reporting usage isn't recorded. - Nothing is registered globally, and nothing outlives the execution. The next
kickoff()gets a new run, and a crew run outside the guard isn't metered.
Other forms:
from noburn.integrations.crewai import noburn_kickoff
result = noburn_kickoff(guard, crew, budget_cap_usd=0.50)
with NoburnCrewGuard(guard, budget_cap_usd=0.50): # same enforcement, your own kickoff call
crew.kickoff()Limits to know about:
- Parallel tasks: with
async_execution=Truetasks, several LLM calls can pass their check at the same time. The cap can then be exceeded by the calls already in flight when it's reached. Sequential crews stop at the cap. - One guard per concurrent crew: a
NoburnCrewGuardguards one execution at a time. Running two crews at once through the same instance raisesRuntimeError, so create oneNoburnCrewGuardper crew you run concurrently. They can share the sameNoburnGuard. - Shared LLM objects: usage is read from per-LLM totals. If agents on different threads share one LLM instance at the same moment, the split between individual calls can be off. The run's total is still right.
If noburn is unreachable when the crew starts, the crew runs without a per-run cap (with a RuntimeWarning), but project and end-user caps still apply. An invalid key raises NoburnAuthError.