Budget Plugin
The budget plugin enforces a per-API-key USD spend cap using in-memory accumulation. It runs at both before_request (check accumulated spend against the limit) and after_request (price the completed request from token usage and add it to the store).
Factsโ
| Stages | before_request and after_request โ multi-stage, byte-identical config required at both |
| Reported type | ratelimit (plugin.TypeRateLimit) |
| Failure policy | Fails closed (a plugin error returns 500) |
| Denial status | 402 insufficient_quota โ not 429. Decided by plugin name in internal/apierror, checked before the generic ratelimit โ 429 branch, regardless of the reported type |
| Cap type | Soft cap, no reservation โ concurrent in-flight requests for the same key can collectively overshoot |
| Agentic loops | Re-checked on every MCP tool-loop turn (RunBeforeLoopTurn) โ can stop an overspending loop mid-request |
| Pricing source | The plugin's own configured rates (input_per_m_tokens / output_per_m_tokens / cache rates) โ not the model catalog. See Gotchas |
| External dependencies | None โ in-memory (sync.Map of per-store spend maps), per-process. Spend does not survive a restart |
Config keysโ
| Key | Type | Default | Required | Description |
|---|---|---|---|---|
store_id | string | "default" | No | Shared spend-store key. Instances with the same store_id share one set of counters โ how the before_request and after_request entries stay in sync. |
spend_limit_usd | float >= 0 | 0 (unlimited) | No | Max cumulative USD per API key. 0 disables the check. Setting it > 0 while every rate below is 0 is a load error โ cost would always compute to 0 and the cap could never be enforced. |
input_per_m_tokens | float | 0 | No | USD per 1M prompt tokens. |
output_per_m_tokens | float | 0 | No | USD per 1M completion tokens. ReasoningTokens are a subset of CompletionTokens โ already covered, never billed again. |
cache_read_per_m_tokens | float >= 0 (pointer โ unset is distinct from 0) | unset | No | USD per 1M cached prompt tokens. Unset โ 0: unset bills the whole prompt (including the cached subset) at input_per_m_tokens; 0.0 makes cached tokens free. When set, the cached subset comes off the input-rate count so it is billed once, not twice. |
cache_write_per_m_tokens | float >= 0 | unset (free) | No | USD per 1M cache-write tokens. CacheWriteTokens sit outside PromptTokens and bill only when this is set. |
max_keys | int >= 0 | 10000 | No | Max API keys tracked in memory per store. 0 = unlimited. At the cap, the lowest-spend key is evicted to make room and restarts at $0 โ under churn above max_keys distinct keys, a key's cap can be silently reset. |
Minimal configโ
Both stage entries must carry byte-identical config โ the gateway resolves them to one shared spend store by name + JSON-encoded config, and refuses to start if the two disagree.
plugins:
- name: budget
type: guardrail
stage: before_request
enabled: true
config:
store_id: default
spend_limit_usd: 10.0
input_per_m_tokens: 3.0
output_per_m_tokens: 15.0
max_keys: 10000
- name: budget
type: guardrail
stage: after_request
enabled: true
config:
store_id: default
spend_limit_usd: 10.0
input_per_m_tokens: 3.0
output_per_m_tokens: 15.0
max_keys: 10000
Gotchasโ
- 402, not 429, on exhaustion. Waiting does not restore a spend cap โ only cost roll-off (there is none, in-memory spend never decays) or an explicit reset clears it. A
429is retryable in every OpenAI SDK's default policy and would send a client into a backoff schedule that re-asks the same unanswerable question, roughly once a second, forever.402 insufficient_quotacarries noRetry-Afterand is outside every SDK's retry set, so the first refusal is the last request the SDK makes on its own. - Soft cap โ no reservation. The
before_requestcheck is read-only: it reads already-committed spend, places no hold, and lets the request proceed. A bounded number of concurrent requests for the same key can all pass the check simultaneously and collectively push spend past the limit once each completes. The overshoot is bounded by in-flight-request-count ร per-request cost, not unbounded. A hard cap (pre-authorization/reservation) is deliberately out of scope: a reservation that leaks on every error, cancellation, circuit-trip, or rejection would permanently pin a key at its cap. - Prices from operator-set rates, not the model catalog. Unlike the cost figures shown elsewhere in the gateway (the
ferro.cost.usdtrace attribute, catalog-based cost visibility โ see Cost tracking), the budget plugin has no catalog to read from: it multiplies token counts by whateverinput_per_m_tokens/output_per_m_tokens/ cache rates you configure. If those rates drift from your real per-model pricing, the spend this plugin tracks drifts from your real bill. - Cache tokens: unset โ zero. Leaving
cache_read_per_m_tokensunset bills the entire prompt โ cached subset included โ at the input rate (a visible over-report beats a silent under-report of an unpriced dimension). Set it to0.0explicitly to make cached tokens free. When set to any value,PromptTokens(which is inclusive ofCacheReadTokens) has the cached subset subtracted before the input rate applies, so cached tokens are never billed twice.CacheWriteTokensare outsidePromptTokensentirely and cost nothing unlesscache_write_per_m_tokensis set. - Two rates only โ other billing dimensions accrue $0. Only prompt and completion (plus the two optional cache rates) are priced. Most image providers report no token usage at all, so image-only traffic never touches a budget (only token-billed models like the
gpt-imagefamily accrue anything). Audio input (per-minute) and audio output (per-character) are not represented either โ the shortfall is always an under-report, never an over-report. - Per-turn enforcement on agentic loops. Inside an MCP tool loop, the gateway re-runs the
before_requestcheck on every turn and adds the request's own running spend (pctx.Measurements.CostUSD, whenHasCostis set) to what the store already has on record. Without that term, a key sitting at 99% of its cap on turn one would get an entire loop regardless of how many turns it ran or how large the context grew, because the store itself is only written once, after the whole request completes. This is the one place a single request can close its own cap mid-flight. - No API key, no tracking. The key is read from
Metadata["api_key"](the opaque credential id). A request with no key in metadata โ e.g. running withALLOW_UNAUTHENTICATED_PROXY=trueโ skips budget tracking entirely: it is neither rejected nor recorded against any key. - A cache hit records nothing.
after_requestis skipped whenSkipProvideris set (the response came fromresponse-cache, not a provider) โ there is no cost to record for a call that never left the process. This is also why the same prompt served from cache a hundred times does not consume a hundred times its cost from the budget. - In-memory only. Spend does not survive a process restart, and a store is per-process โ a multi-replica deployment gets independent counters per instance, not one shared budget.
ResetStore/ResetStoreKey(Go API, not an admin endpoint) exist for housekeeping, e.g. clearing spend on key rotation. ferrogw validatecatches the same errorsInitwould.spend_limit_usdset with every rate at0, or a negativespend_limit_usd/cache rate/max_keys, fails validation and startup โ it is not a silently-inert config.