A circuit breaker for your LLM API bill — hard budget caps + per-feature cost attribution for OpenAI & Anthropic.
2
stars
108
commits
TypeScript
primary language
Sep 2, 2026
updated
A circuit breaker for your LLM API bill. One wrap, set a hard daily cap — runaway retry loops get blocked before they bill you. Plus per-feature cost attribution so you know what actually costs what.
Built for indie devs who've seen "a $40 bill from a $5 task." Works with the OpenAI / Anthropic / Gemini SDKs directly, or through the Vercel AI SDK (v5 + v7), LangChain.js, LlamaIndex.TS and Mastra adapters. Drop-in: your calls still go straight to the provider —
budget-guardjust counts and caps.
Free & open source (MIT). A hosted dashboard with cross-project spend + alerts is planned — but the SDK is, and stays, free.
npm i budget-guard
import OpenAI from 'openai';
import { guard } from 'budget-guard';
const openai = new OpenAI();
const ai = guard(openai.chat.completions, { project: 'my-app', dailyCapUSD: 50 });
// use it exactly like before — just add an optional feature tag
const res = await ai.create(
{ model: 'gpt-5.6', messages: [{ role: 'user', content: 'hi' }] },
{ feature: 'chat' },
);
If today's spend for my-app is already past $50, the next call throws BudgetExceededError before it bills. No more 3am surprise invoices.
That check runs against spend already settled, so calls still in flight aren't counted yet. If you fan calls out in parallel, add an estimator to make the cap hold — see Block before the call.
import Anthropic from '@anthropic-ai/sdk';
import { guard } from 'budget-guard';
const anthropic = new Anthropic();
const ai = guard(anthropic.messages, { project: 'my-app', dailyCapUSD: 50 });
await ai.create(
{ model: 'claude-opus-5', max_tokens: 1024, messages: [{ role: 'user', content: 'hi' }] },
{ feature: 'summarize' },
);
budget-guard auto-detects the usage shapes of OpenAI (incl. Azure, Mistral, DeepSeek, xAI), Anthropic, Google Gemini (usageMetadata), AWS Bedrock Converse and Cohere (billed_units). Cached and reasoning tokens are billed at their real per-class rates — including the provider quirks (xAI and Gemini report reasoning outside the output count; budget-guard adds it back so you're not silently under-counting). For anything else, pass your own extractor:
guard(client, opts, { usageOf: (res) => ({ input: res.in, output: res.out }) });
import { spendReport } from 'budget-guard';
await spendReport('my-app'); // async
// → { chat: 2.41, summarize: 0.88 } (today, in USD)
By default the ledger lives in memory (per process) — great for a single script, worker, or agent. Running multiple instances? Pass a shared store so they enforce one cap together and survive restarts:
import { createClient } from 'redis';
import { guard, redisStore } from 'budget-guard';
const redis = createClient();
await redis.connect();
const ai = guard(openai.chat.completions, {
project: 'my-app',
dailyCapUSD: 50,
store: redisStore(redis), // node-redis v4; keys auto-expire (~2 days)
});
store accepts anything implementing the tiny SpendStore interface (add / get / entries, plus optional addIfUnder for atomic reservations — redisStore implements that as a server-side Lua script), so you can back it with whatever you already run.
Cron jobs and short-lived scripts are where in-memory caps quietly fail — every run starts from $0. fileStore keeps the ledger in a single JSON file, so 100 runs a day share one cap:
import { guard } from 'budget-guard';
import { fileStore } from 'budget-guard/file';
const ai = guard(openai.chat.completions, {
project: 'nightly-job',
dailyCapUSD: 5,
store: fileStore('/var/tmp/my-app-spend.json'),
});
Writes are atomic (temp file + rename), parent directories are created, and a corrupted file throws instead of silently resetting your budget. Storage tiers: memory = one process, file = one machine, redis = a fleet. (Concurrent processes should use redisStore — the file store targets sequential runs.)
By default the cap is enforced on the next call after you cross it. Run calls one at a time and that costs you exactly one call of overshoot. Run them in parallel and it costs you every call already in flight: they all read the same under-cap balance and all go through — 100 concurrent $0.10 calls against a $5 cap settle at $10, not $5.
Give it an estimator and it blocks the offending call itself — the built-in one is a one-liner:
import { guard, estimator } from 'budget-guard';
const ai = guard(openai.chat.completions, {
project: 'my-app',
dailyCapUSD: 50,
estimateUsage: estimator(), // chars/4 heuristic — fine for a circuit breaker
});
estimator() reads prompt / system / messages for the input estimate and the declared max_tokens (or maxOutputTokens) for the output. It knows the new Claude tokenizer generation counts ~30% more tokens (Opus 4.7+, Sonnet 5+, Fable, Mythos — corrected automatically), and it adds tool-schema overhead when you pass tools. Want exact counts? Inject any tokenizer:
import { countTokens } from 'gpt-tokenizer';
estimateUsage: estimator({ countTokens });
Concurrency-safe — with the estimator. The reservation path needs all three of: estimateUsage, a store with addIfUnder (the built-in memory, file and redis stores all have it), and the default onCap: 'block'. Given those, the estimated cost is reserved atomically before the call and settled to the actual cost afterwards — so 100 parallel workers can't race past the cap together, and failed calls roll their reservation back. Drop any one of the three and you fall back to the unreserved check above, which makes no promise under concurrency.
Wrap any LlamaIndex LLM — the cap applies before each call and non-streaming
chat() is metered from the response:
import { guardLlamaIndex } from 'budget-guard';
const llm = guardLlamaIndex(openai({ model: 'gpt-5.6' }), { project: 'my-app', dailyCapUSD: 50 });
Settings.llm = llm; // or call llm.chat(...) directly
Usage is read from response.raw (works across providers). Streaming chat() is
metered too — each chunk's raw is watched for OpenAI / Anthropic / Gemini usage
shapes and the spend is settled when the stream ends. If the provider doesn't put
usage in the stream, you get a warning instead of a silent zero. Zero new
dependencies.
Attach the callback handler to any LangChain model or chain — the cap is enforced before each call, and cost is metered from the response:
import { BudgetGuardHandler } from 'budget-guard/langchain';
const handler = new BudgetGuardHandler({ project: 'my-app', dailyCapUSD: 50, model: 'gpt-5.6' });
await model.invoke(input, { callbacks: [handler] }); // over cap → throws before the call
Reads usage from usage_metadata (falling back to llmOutput.tokenUsage). Pass
model for reliable pricing (it's also auto-detected from the response when
present). Needs @langchain/core (an optional peer dependency).
guardOpenAI / guardAnthropic / guardGemini are thin wrappers that set
provider for you — so streaming is metered correctly without remembering the
option:
import { guardAnthropic } from 'budget-guard';
const ai = guardAnthropic(anthropic.messages, { project: 'my-app', dailyCapUSD: 50 });
// streaming already knows it's Anthropic — no `provider` to forget
Streaming calls are metered too — just pass stream: true as usual. budget-guard
passes every chunk straight through to you and reads the usage from the final
chunk, so the cost lands once, after the stream finishes:
const ai = guard(openai.chat.completions, { project: 'my-app', dailyCapUSD: 50 });
const stream = await ai.create({ model: 'gpt-5.6', stream: true }, { feature: 'chat' });
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content ?? '');
}
// cost is recorded once the loop finishes
For OpenAI it injects stream_options: { include_usage: true } for you (OpenAI
only sends usage on the final chunk when that flag is set). The cap is still
enforced before the call.
For Anthropic streaming, set provider: 'anthropic' — it reads usage from the
message_start / message_delta events and skips the OpenAI-only injection:
const ai = guard(anthropic.messages, {
project: 'my-app',
dailyCapUSD: 50,
provider: 'anthropic',
});
const stream = await ai.create({ model: 'claude-sonnet-4-6', stream: true, max_tokens: 1024 });
for await (const event of stream) { /* ... */ }
For Gemini streaming, set provider: 'gemini' — usage comes from each chunk's
usageMetadata (the last one carries the totals).
Using the AI SDK? Wrap any model with the middleware — no client to guard, the cap and per-feature metering apply automatically. Works with both AI SDK v5 and v7 (the usage shape is auto-detected per call — one entry point, nothing to configure):
import { wrapLanguageModel, generateText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { budgetGuardMiddleware } from 'budget-guard';
const model = wrapLanguageModel({
model: openai('gpt-5.6'),
middleware: budgetGuardMiddleware({ project: 'my-app', dailyCapUSD: 50, feature: 'chat' }),
});
await generateText({ model, prompt: 'hi' }); // over cap → throws before the model call
Meters both generateText and streamText (usage read from the stream's finish
part). Over-cap streamText blocks before the model call; the error arrives on the
SDK's standard error channel (onError, or await result.text) — textStream
swallows stream errors by default.
Mastra agents run on Vercel AI SDK models, so the middleware above already covers them — wrap the model before handing it to your agent, no Mastra-specific code:
import { wrapLanguageModel } from 'ai';
import { Agent } from '@mastra/core/agent';
import { budgetGuardMiddleware } from 'budget-guard';
const model = wrapLanguageModel({
model: openai('gpt-5.6'),
middleware: budgetGuardMiddleware({ project: 'my-app', dailyCapUSD: 50 }),
});
const agent = new Agent({ id: 'support', model, instructions: '…' });
// (or withMastra(model, { … }) if you use @mastra/ai-sdk directly)
The cap and metering apply to every model call the agent makes.
A hard cap stops the bleeding; onSpend lets you watch it. It fires on every
successful call with a SpendEvent, so you can pipe per-call cost straight into
your logs, traces, or a dashboard:
const ai = guard(openai.chat.completions, {
project: 'my-app',
dailyCapUSD: 50,
onSpend: (e) => {
// { project, feature, model, usd, dayTotalUsd }
console.log(JSON.stringify({ evt: 'llm_spend', ...e }));
},
onExceeded: ({ project, spentUsd, capUsd }) => {
metrics.increment('llm.cap_hit', { project }); // fires before block/warn
},
});
Keep the callback light — it runs synchronously just before the response is returned. Push heavy work (network, disk) onto a queue.
The most expensive LLM bug class isn't a big prompt — it's a retry loop quietly re-burning money all night. budget-guard tracks consecutive provider failures per (feature, model) and tells you when it looks like a storm:
const ai = guard(openai.chat.completions, {
project: 'my-app',
dailyCapUSD: 50,
retryStormThreshold: 5,
onRetryStorm: ({ feature, model, consecutiveFailures }) =>
alert(`retry storm: ${feature}/${model} failed ${consecutiveFailures}x in a row`),
});
ai.retryStats(); // → { totalRetries, retryStorms }
A success resets the streak and stamps retryCount on that call's SpendEvent, so your logs show "this $0.40 call took 7 attempts". Cap-blocked calls don't count — only calls that actually reached the provider.
Cap per month instead of per day, and reset on your billing time zone's calendar (not UTC):
const ai = guard(openai.chat.completions, {
project: 'my-app',
dailyCapUSD: 500, // the cap for the period
period: 'monthly', // 'daily' (default) | 'monthly'
timezone: 'America/New_York', // optional IANA zone; default UTC
});
An invalid timezone throws at construction. With a redisStore, set
ttlSeconds to cover a month (e.g. 60 * 60 * 24 * 40) so monthly counters don't
expire early. To read the current period's total, use periodKey:
import { spentTotal, periodKey } from 'budget-guard';
await spentTotal('my-app', store, periodKey(new Date(), 'monthly', 'America/New_York'));
budget-guard/testing ships the pieces you need to test caps deterministically — usage factories, a fixed clock for day/month boundaries, an operation-recording store:
import { buildOpenAIUsage, createFixedClock, FakeSpendStore } from 'budget-guard/testing';
const store = new FakeSpendStore();
const ai = guard(fakeClient, { project: 't', dailyCapUSD: 1, store },
{ now: createFixedClock('2026-01-31T23:59:00Z') });
// ...assert on store.ops: every add/get/addIfUnder, in order, with amounts
guard(client, {
project: 'my-app', // groups spend & shares one cap
dailyCapUSD: 50, // hard cap per period
period: 'daily', // 'daily' (default) | 'monthly'
timezone: 'UTC', // optional IANA zone for cap reset (default UTC)
onCap: 'block', // 'block' (throw) | 'warn' (log only). default 'block'
store: myStore, // optional SpendStore (default: in-memory, per-process)
estimateUsage: fn, // optional: block before a call would exceed the cap (see estimator())
onSpend: fn, // optional: SpendEvent per successful call (logs/traces)
onExceeded: fn, // optional: fires when the cap is hit (before block/warn)
retryStormThreshold: 5,// optional: consecutive-failure streak that fires onRetryStorm
onRetryStorm: fn, // optional: called once when the streak hits the threshold
provider: 'anthropic', // optional: 'openai' (default) | 'anthropic' | 'gemini' — for streaming
onMissingUsage: 'zero',// optional: 'throw' (default) | 'zero' — when a response has no usage
});
const ai = guard(openai.chat.completions, { project: 'my-app', dailyCapUSD: 50, onCap: 'warn' });
// over cap → logs a warning, still calls. Good for easing in.
Runnable, no API key needed — see examples/:
basic-cap.mjs — hard cap + per-feature spendReport()cost-observability.mjs — stream per-call cost via onSpendredis-fleet.mjs — one shared cap across a worker fleetprecall-block.mjs — estimator() + atomic reservation under 20 concurrent callscron-file-cap.mjs — one cap across separate script runs via fileStoreretry-storm.mjs — catching a retry loop that re-burns moneyestimator() is bring-your-own.onMissingUsage: 'zero' opts out per guard) — a budget tool shouldn't quietly stop counting.NOSCRIPT fallback).PRICES (USD per 1K tokens, incl. per-class cache rates and the per-provider reasoning-token convention) — PRs to keep them current are welcome.spendReport() is now async — add await. Everything else is backward compatible (no store = same in-process behavior).
MIT
90 commits
18 commits
TypeScript
100.0%
A circuit breaker for your LLM API bill — hard budget caps + per-feature cost attribution for OpenAI & Anthropic.
2
stars
108
commits
TypeScript
primary language
Sep 2, 2026
updated
A circuit breaker for your LLM API bill. One wrap, set a hard daily cap — runaway retry loops get blocked before they bill you. Plus per-feature cost attribution so you know what actually costs what.
Built for indie devs who've seen "a $40 bill from a $5 task." Works with the OpenAI / Anthropic / Gemini SDKs directly, or through the Vercel AI SDK (v5 + v7), LangChain.js, LlamaIndex.TS and Mastra adapters. Drop-in: your calls still go straight to the provider —
budget-guardjust counts and caps.
Free & open source (MIT). A hosted dashboard with cross-project spend + alerts is planned — but the SDK is, and stays, free.
npm i budget-guard
import OpenAI from 'openai';
import { guard } from 'budget-guard';
const openai = new OpenAI();
const ai = guard(openai.chat.completions, { project: 'my-app', dailyCapUSD: 50 });
// use it exactly like before — just add an optional feature tag
const res = await ai.create(
{ model: 'gpt-5.6', messages: [{ role: 'user', content: 'hi' }] },
{ feature: 'chat' },
);
If today's spend for my-app is already past $50, the next call throws BudgetExceededError before it bills. No more 3am surprise invoices.
That check runs against spend already settled, so calls still in flight aren't counted yet. If you fan calls out in parallel, add an estimator to make the cap hold — see Block before the call.
import Anthropic from '@anthropic-ai/sdk';
import { guard } from 'budget-guard';
const anthropic = new Anthropic();
const ai = guard(anthropic.messages, { project: 'my-app', dailyCapUSD: 50 });
await ai.create(
{ model: 'claude-opus-5', max_tokens: 1024, messages: [{ role: 'user', content: 'hi' }] },
{ feature: 'summarize' },
);
budget-guard auto-detects the usage shapes of OpenAI (incl. Azure, Mistral, DeepSeek, xAI), Anthropic, Google Gemini (usageMetadata), AWS Bedrock Converse and Cohere (billed_units). Cached and reasoning tokens are billed at their real per-class rates — including the provider quirks (xAI and Gemini report reasoning outside the output count; budget-guard adds it back so you're not silently under-counting). For anything else, pass your own extractor:
guard(client, opts, { usageOf: (res) => ({ input: res.in, output: res.out }) });
import { spendReport } from 'budget-guard';
await spendReport('my-app'); // async
// → { chat: 2.41, summarize: 0.88 } (today, in USD)
By default the ledger lives in memory (per process) — great for a single script, worker, or agent. Running multiple instances? Pass a shared store so they enforce one cap together and survive restarts:
import { createClient } from 'redis';
import { guard, redisStore } from 'budget-guard';
const redis = createClient();
await redis.connect();
const ai = guard(openai.chat.completions, {
project: 'my-app',
dailyCapUSD: 50,
store: redisStore(redis), // node-redis v4; keys auto-expire (~2 days)
});
store accepts anything implementing the tiny SpendStore interface (add / get / entries, plus optional addIfUnder for atomic reservations — redisStore implements that as a server-side Lua script), so you can back it with whatever you already run.
Cron jobs and short-lived scripts are where in-memory caps quietly fail — every run starts from $0. fileStore keeps the ledger in a single JSON file, so 100 runs a day share one cap:
import { guard } from 'budget-guard';
import { fileStore } from 'budget-guard/file';
const ai = guard(openai.chat.completions, {
project: 'nightly-job',
dailyCapUSD: 5,
store: fileStore('/var/tmp/my-app-spend.json'),
});
Writes are atomic (temp file + rename), parent directories are created, and a corrupted file throws instead of silently resetting your budget. Storage tiers: memory = one process, file = one machine, redis = a fleet. (Concurrent processes should use redisStore — the file store targets sequential runs.)
By default the cap is enforced on the next call after you cross it. Run calls one at a time and that costs you exactly one call of overshoot. Run them in parallel and it costs you every call already in flight: they all read the same under-cap balance and all go through — 100 concurrent $0.10 calls against a $5 cap settle at $10, not $5.
Give it an estimator and it blocks the offending call itself — the built-in one is a one-liner:
import { guard, estimator } from 'budget-guard';
const ai = guard(openai.chat.completions, {
project: 'my-app',
dailyCapUSD: 50,
estimateUsage: estimator(), // chars/4 heuristic — fine for a circuit breaker
});
estimator() reads prompt / system / messages for the input estimate and the declared max_tokens (or maxOutputTokens) for the output. It knows the new Claude tokenizer generation counts ~30% more tokens (Opus 4.7+, Sonnet 5+, Fable, Mythos — corrected automatically), and it adds tool-schema overhead when you pass tools. Want exact counts? Inject any tokenizer:
import { countTokens } from 'gpt-tokenizer';
estimateUsage: estimator({ countTokens });
Concurrency-safe — with the estimator. The reservation path needs all three of: estimateUsage, a store with addIfUnder (the built-in memory, file and redis stores all have it), and the default onCap: 'block'. Given those, the estimated cost is reserved atomically before the call and settled to the actual cost afterwards — so 100 parallel workers can't race past the cap together, and failed calls roll their reservation back. Drop any one of the three and you fall back to the unreserved check above, which makes no promise under concurrency.
Wrap any LlamaIndex LLM — the cap applies before each call and non-streaming
chat() is metered from the response:
import { guardLlamaIndex } from 'budget-guard';
const llm = guardLlamaIndex(openai({ model: 'gpt-5.6' }), { project: 'my-app', dailyCapUSD: 50 });
Settings.llm = llm; // or call llm.chat(...) directly
Usage is read from response.raw (works across providers). Streaming chat() is
metered too — each chunk's raw is watched for OpenAI / Anthropic / Gemini usage
shapes and the spend is settled when the stream ends. If the provider doesn't put
usage in the stream, you get a warning instead of a silent zero. Zero new
dependencies.
Attach the callback handler to any LangChain model or chain — the cap is enforced before each call, and cost is metered from the response:
import { BudgetGuardHandler } from 'budget-guard/langchain';
const handler = new BudgetGuardHandler({ project: 'my-app', dailyCapUSD: 50, model: 'gpt-5.6' });
await model.invoke(input, { callbacks: [handler] }); // over cap → throws before the call
Reads usage from usage_metadata (falling back to llmOutput.tokenUsage). Pass
model for reliable pricing (it's also auto-detected from the response when
present). Needs @langchain/core (an optional peer dependency).
guardOpenAI / guardAnthropic / guardGemini are thin wrappers that set
provider for you — so streaming is metered correctly without remembering the
option:
import { guardAnthropic } from 'budget-guard';
const ai = guardAnthropic(anthropic.messages, { project: 'my-app', dailyCapUSD: 50 });
// streaming already knows it's Anthropic — no `provider` to forget
Streaming calls are metered too — just pass stream: true as usual. budget-guard
passes every chunk straight through to you and reads the usage from the final
chunk, so the cost lands once, after the stream finishes:
const ai = guard(openai.chat.completions, { project: 'my-app', dailyCapUSD: 50 });
const stream = await ai.create({ model: 'gpt-5.6', stream: true }, { feature: 'chat' });
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content ?? '');
}
// cost is recorded once the loop finishes
For OpenAI it injects stream_options: { include_usage: true } for you (OpenAI
only sends usage on the final chunk when that flag is set). The cap is still
enforced before the call.
For Anthropic streaming, set provider: 'anthropic' — it reads usage from the
message_start / message_delta events and skips the OpenAI-only injection:
const ai = guard(anthropic.messages, {
project: 'my-app',
dailyCapUSD: 50,
provider: 'anthropic',
});
const stream = await ai.create({ model: 'claude-sonnet-4-6', stream: true, max_tokens: 1024 });
for await (const event of stream) { /* ... */ }
For Gemini streaming, set provider: 'gemini' — usage comes from each chunk's
usageMetadata (the last one carries the totals).
Using the AI SDK? Wrap any model with the middleware — no client to guard, the cap and per-feature metering apply automatically. Works with both AI SDK v5 and v7 (the usage shape is auto-detected per call — one entry point, nothing to configure):
import { wrapLanguageModel, generateText } from 'ai';
import { openai } from '@ai-sdk/openai';
import { budgetGuardMiddleware } from 'budget-guard';
const model = wrapLanguageModel({
model: openai('gpt-5.6'),
middleware: budgetGuardMiddleware({ project: 'my-app', dailyCapUSD: 50, feature: 'chat' }),
});
await generateText({ model, prompt: 'hi' }); // over cap → throws before the model call
Meters both generateText and streamText (usage read from the stream's finish
part). Over-cap streamText blocks before the model call; the error arrives on the
SDK's standard error channel (onError, or await result.text) — textStream
swallows stream errors by default.
Mastra agents run on Vercel AI SDK models, so the middleware above already covers them — wrap the model before handing it to your agent, no Mastra-specific code:
import { wrapLanguageModel } from 'ai';
import { Agent } from '@mastra/core/agent';
import { budgetGuardMiddleware } from 'budget-guard';
const model = wrapLanguageModel({
model: openai('gpt-5.6'),
middleware: budgetGuardMiddleware({ project: 'my-app', dailyCapUSD: 50 }),
});
const agent = new Agent({ id: 'support', model, instructions: '…' });
// (or withMastra(model, { … }) if you use @mastra/ai-sdk directly)
The cap and metering apply to every model call the agent makes.
A hard cap stops the bleeding; onSpend lets you watch it. It fires on every
successful call with a SpendEvent, so you can pipe per-call cost straight into
your logs, traces, or a dashboard:
const ai = guard(openai.chat.completions, {
project: 'my-app',
dailyCapUSD: 50,
onSpend: (e) => {
// { project, feature, model, usd, dayTotalUsd }
console.log(JSON.stringify({ evt: 'llm_spend', ...e }));
},
onExceeded: ({ project, spentUsd, capUsd }) => {
metrics.increment('llm.cap_hit', { project }); // fires before block/warn
},
});
Keep the callback light — it runs synchronously just before the response is returned. Push heavy work (network, disk) onto a queue.
The most expensive LLM bug class isn't a big prompt — it's a retry loop quietly re-burning money all night. budget-guard tracks consecutive provider failures per (feature, model) and tells you when it looks like a storm:
const ai = guard(openai.chat.completions, {
project: 'my-app',
dailyCapUSD: 50,
retryStormThreshold: 5,
onRetryStorm: ({ feature, model, consecutiveFailures }) =>
alert(`retry storm: ${feature}/${model} failed ${consecutiveFailures}x in a row`),
});
ai.retryStats(); // → { totalRetries, retryStorms }
A success resets the streak and stamps retryCount on that call's SpendEvent, so your logs show "this $0.40 call took 7 attempts". Cap-blocked calls don't count — only calls that actually reached the provider.
Cap per month instead of per day, and reset on your billing time zone's calendar (not UTC):
const ai = guard(openai.chat.completions, {
project: 'my-app',
dailyCapUSD: 500, // the cap for the period
period: 'monthly', // 'daily' (default) | 'monthly'
timezone: 'America/New_York', // optional IANA zone; default UTC
});
An invalid timezone throws at construction. With a redisStore, set
ttlSeconds to cover a month (e.g. 60 * 60 * 24 * 40) so monthly counters don't
expire early. To read the current period's total, use periodKey:
import { spentTotal, periodKey } from 'budget-guard';
await spentTotal('my-app', store, periodKey(new Date(), 'monthly', 'America/New_York'));
budget-guard/testing ships the pieces you need to test caps deterministically — usage factories, a fixed clock for day/month boundaries, an operation-recording store:
import { buildOpenAIUsage, createFixedClock, FakeSpendStore } from 'budget-guard/testing';
const store = new FakeSpendStore();
const ai = guard(fakeClient, { project: 't', dailyCapUSD: 1, store },
{ now: createFixedClock('2026-01-31T23:59:00Z') });
// ...assert on store.ops: every add/get/addIfUnder, in order, with amounts
guard(client, {
project: 'my-app', // groups spend & shares one cap
dailyCapUSD: 50, // hard cap per period
period: 'daily', // 'daily' (default) | 'monthly'
timezone: 'UTC', // optional IANA zone for cap reset (default UTC)
onCap: 'block', // 'block' (throw) | 'warn' (log only). default 'block'
store: myStore, // optional SpendStore (default: in-memory, per-process)
estimateUsage: fn, // optional: block before a call would exceed the cap (see estimator())
onSpend: fn, // optional: SpendEvent per successful call (logs/traces)
onExceeded: fn, // optional: fires when the cap is hit (before block/warn)
retryStormThreshold: 5,// optional: consecutive-failure streak that fires onRetryStorm
onRetryStorm: fn, // optional: called once when the streak hits the threshold
provider: 'anthropic', // optional: 'openai' (default) | 'anthropic' | 'gemini' — for streaming
onMissingUsage: 'zero',// optional: 'throw' (default) | 'zero' — when a response has no usage
});
const ai = guard(openai.chat.completions, { project: 'my-app', dailyCapUSD: 50, onCap: 'warn' });
// over cap → logs a warning, still calls. Good for easing in.
Runnable, no API key needed — see examples/:
basic-cap.mjs — hard cap + per-feature spendReport()cost-observability.mjs — stream per-call cost via onSpendredis-fleet.mjs — one shared cap across a worker fleetprecall-block.mjs — estimator() + atomic reservation under 20 concurrent callscron-file-cap.mjs — one cap across separate script runs via fileStoreretry-storm.mjs — catching a retry loop that re-burns moneyestimator() is bring-your-own.onMissingUsage: 'zero' opts out per guard) — a budget tool shouldn't quietly stop counting.NOSCRIPT fallback).PRICES (USD per 1K tokens, incl. per-class cache rates and the per-provider reasoning-token convention) — PRs to keep them current are welcome.spendReport() is now async — add await. Everything else is backward compatible (no store = same in-process behavior).
MIT
90 commits
18 commits
TypeScript
100.0%