Budget Enforcement
Prevent cost overruns with atomic budget reservation and real-time enforcement
Budget enforcement blocks requests once an API key's spend limit is reached, using an atomic reservation pattern that stays accurate under concurrent load.
Overview
The budget system provides:
- Per-key limits - Each API key can carry a
budget_limit_centsand abudget_period(dailyormonthly) - Atomic reservation - Reserve an estimated cost before the request, adjust to actual cost after completion
- Warning thresholds - Response headers, audit entries, and WebSocket events before the hard limit
- Time-series forecasting - Predict when budgets will be exhausted
Budgets are set per API key at creation time. There are no separate budget entities at the organization, team, project, or user level; scope a budget by choosing which owner the key belongs to.
How It Works
The atomic reservation pattern prevents overspend even with concurrent requests:
1. Request arrives
└─ Reserve estimated cost ($0.10 default) against the spend counter
└─ Check: current_spend + reservation > limit?
├─ Yes → Reject request (402 Payment Required)
└─ No → Continue
2. Forward to LLM provider
└─ Stream response
3. Request completes
└─ Calculate actual cost from token usage
└─ Adjust: replace estimate with actual cost
(adjustment = actual - estimated)
4. On failure/cancellation
└─ Refund: remove reservation entirelyThe check and the reservation happen in a single atomic cache operation, so 100 concurrent requests cannot slip past the limit together.
The reservation is capped at the key's own limit. A key whose budget is smaller than the configured estimate (for example a 5 cent budget with a 10 cent estimate) reserves at most its limit, so small budgets stay usable instead of rejecting every request.
Non-streaming requests settle immediately after the handler returns, using the cost computed for the response. Streaming requests settle from the streaming usage logger once the stream ends, including the partial-usage path when a client disconnects mid-stream. Exactly one side performs the adjustment.
Quick Start
Set a budget on an API key via the Admin API:
# Create an API key with a $100/month budget
curl -X POST http://localhost:8080/admin/v1/api-keys \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "production-api-key",
"owner": {
"type": "project",
"project_id": "550e8400-e29b-41d4-a716-446655440000"
},
"budget_limit_cents": 10000,
"budget_period": "monthly"
}'10000 = $100.00.Users can also set budgets on their own keys through the self-service endpoint POST /admin/v1/me/api-keys, which accepts the same budget_limit_cents and budget_period fields (the owner is set to the current user automatically).
Budget Configuration
API Key Level
Budgets are configured per API key at creation:
| Field | Type | Description |
|---|---|---|
budget_limit_cents | integer | Budget limit in cents (e.g., 10000 = $100.00) |
budget_period | string | "daily" or "monthly" |
The owner field is a tagged object that determines whose usage the key represents:
type | ID field | Scope |
|---|---|---|
organization | org_id | Shared organization-wide key |
team | team_id | Team workloads |
project | project_id | Project isolation |
user | user_id | Individual user |
service_account | service_account_id | Machine identity |
All IDs are UUIDs.
API keys are immutable: there is no PATCH or PUT endpoint. To change a budget, rotate the key
(POST /admin/v1/api-keys/{key_id}/rotate) or revoke and recreate it with new values.
Gateway Settings
The [limits.budgets] section in hadrian.toml accepts exactly two keys:
[limits.budgets]
# Warning threshold as a fraction of the limit (default 0.8 = 80%)
warning_threshold = 0.8
# Estimated cost per request in cents, reserved before the request runs (default 10)
estimated_cost_cents = 10| Key | Type | Default | Description |
|---|---|---|---|
warning_threshold | float | 0.8 | Fraction of the limit at which warnings trigger |
estimated_cost_cents | integer | 10 | Pre-request reservation, adjusted after completion |
Unknown keys in [limits.budgets] fail startup with a config error. There are no default budget
amounts, exceeded actions, or overage settings; the only enforcement behavior is block-at-limit,
and budget amounts live on each API key.
Warning Thresholds
When the spend counter reaches warning_threshold of the limit, the gateway:
- Publishes a
budget_threshold_reachedWebSocket event for real-time dashboards - Logs a
budget.warningaudit entry (deduplicated: once per API key per period) - Adds warning headers to the response
Warning Response Headers
| Header | Example | Description |
|---|---|---|
X-Budget-Warning | true | Flag indicating warning state |
X-Budget-Spend-Percentage | 0.85 | Fraction of the limit consumed |
X-Budget-Current-Spend-Cents | 850 | Current spend counter in cents |
X-Budget-Limit-Cents | 1000 | Budget limit in cents |
X-Budget-Period | monthly | Budget period |
X-Budget-Current-Spend-Cents reports the cache counter, which includes in-flight reservations,
including the current request's own reservation, not just settled spend. A fresh key's first
request can report the estimate (10 cents by default) as current spend before any cost has
settled.
Error Responses
When a budget is exceeded, the gateway returns 402 Payment Required (no Retry-After header):
{
"error": {
"type": "budget_error",
"message": "Budget limit exceeded for monthly period: current spend plus in-flight reservations (9995 cents) leave no room for this request's estimated cost within the 10000 cent limit",
"param": null,
"code": "budget_exceeded",
"request_id": "req_abc123"
}
}The current spend and limit are folded into the message text. The response body deliberately keeps the OpenAI error shape with no structured details object; the structured values are recorded in the budget.exceeded audit log entry instead.
Cache Requirements
Budget enforcement requires a cache backend ([cache] with type = "memory" or type = "redis"). Without a cache, budgets are silently not enforced: the limits check is skipped
entirely, requests succeed, and no error is returned. If you set budgets on API keys, verify that
a cache is configured.
Single-Node Deployment
In-memory cache is sufficient for a single node:
[cache]
type = "memory"Spend counters live only in the cache. With type = "memory", a restart zeroes all counters and
enforcement never reconciles against the usage_records table, so spend accumulated before the
restart is forgotten. Use Redis if budget durability matters.
Multi-Node Deployment
Redis is required for shared budget state across nodes:
[cache]
type = "redis"
url = "redis://localhost:6379"The atomic reservation pattern uses:
- In-memory: Compare-And-Swap (CAS) loops on
AtomicI64counters - Redis: Lua scripts for atomic check-and-reserve operations
Cache Key Format
Budget spend is tracked with keys like:
gw:spend:{<api_key_id>}:<period>:<date>Example: gw:spend:{550e8400-e29b-41d4-a716-446655440000}:daily:2026-07-25
The braces around the API key ID are literal: they are a Redis Cluster hash tag that keeps all keys for one API key on the same cluster slot. Monthly keys use a YYYY-MM date suffix.
Entries use a fixed full-period TTL from first write (24 hours for daily, 31 days for monthly) rather than expiring exactly at the period boundary. The date suffix in the key isolates periods, so a new period always starts at zero with a new key, and the fixed TTL prevents a long-running request from outliving its counter.
Audit Logging
Budget events are logged to the audit log:
| Action | Trigger |
|---|---|
budget.warning | Spend crosses the warning threshold (once per key per period) |
budget.exceeded | Request blocked at the budget limit (every blocked request) |
Both entries record the API key as the actor plus org_id and project_id where known, and a details object with limit_cents, current_spend_cents, period, request_path, and request_id (warnings also include spend_percentage).
Query them via the Admin API:
curl "http://localhost:8080/admin/v1/audit-logs?action=budget.exceeded" \
-H "Authorization: Bearer $ADMIN_TOKEN"WebSocket Events
Real-time budget events are published for dashboards. The same event type fires for warnings (with the actual percentage) and for exceeded budgets (with threshold_percent: 100):
interface BudgetThresholdReached {
event_type: "budget_threshold_reached";
timestamp: string;
budget_type: "daily" | "monthly";
threshold_percent: number; // 100 = exceeded
current_amount_microcents: number;
limit_microcents: number;
user_id: string | null;
org_id: string | null;
project_id: string | null;
}Time-Series Forecasting
The gateway includes time-series forecasting powered by augurs to predict budget exhaustion. Forecasts are available at GET /admin/v1/api-keys/{key_id}/usage/forecast, with equivalent endpoints for organizations, projects, users, and providers.
Algorithm
| Data Available | Method | Description |
|---|---|---|
| 14+ days | MSTL + AutoETS | Seasonal decomposition with weekly patterns |
| 7-13 days | AutoETS | Exponential smoothing without seasonality |
| < 7 days | None | Insufficient data for forecasting |
MSTL (Multiple Seasonal-Trend decomposition using Loess) captures weekly patterns like higher weekday usage vs. weekends.
Forecast Response
{
"current_spend_microcents": 5000000,
"budget_limit_microcents": 10000000,
"budget_period": "monthly",
"avg_daily_spend_microcents": 250000,
"std_dev_daily_spend_microcents": 50000,
"sample_days": 21,
"days_until_exhaustion": 20.0,
"projected_exhaustion_date": "2026-08-14",
"days_until_exhaustion_lower": 16.7,
"days_until_exhaustion_upper": 25.0,
"budget_utilization_percent": 50.0,
"projected_period_spend_microcents": 7750000,
"time_series_forecast": {
"dates": ["2026-07-26", "2026-07-27", "2026-07-28"],
"point_forecasts": [260000, 255000, 180000],
"lower_bounds": [200000, 195000, 120000],
"upper_bounds": [320000, 315000, 240000],
"confidence_level": 0.95,
"used_seasonal_decomposition": true
}
}Costs are stored in microcents (1/10,000 of a cent, so 1/1,000,000 of a dollar) for precision. 1,000,000 microcents = $1.00.
Forecast Fields
| Field | Description |
|---|---|
days_until_exhaustion | Estimated days until budget runs out |
projected_exhaustion_date | Calendar date of projected exhaustion |
days_until_exhaustion_lower | 95% confidence lower bound (faster exhaustion) |
days_until_exhaustion_upper | 95% confidence upper bound (slower exhaustion) |
budget_utilization_percent | Current period utilization |
projected_period_spend_microcents | Projected total spend by period end |
time_series_forecast | Multi-day point forecasts with intervals |
Pricing Configuration
Budget enforcement relies on accurate cost calculation. Pricing values are integer microcents per 1M tokens:
[pricing]
# Where the recorded cost comes from:
# "prefer_provider" - use provider-reported cost when present, else calculated (default)
# "calculated_only" - always calculate from token counts and configured pricing
# "provider_only" - only use provider-reported cost, record zero otherwise
cost_source = "prefer_provider"
[pricing.pricing.openai."gpt-4o-mini"]
input_per_1m_tokens = 150000 # $0.15 per 1M input tokens
output_per_1m_tokens = 600000 # $0.60 per 1M output tokensPricing can also be attached to a model directly in the provider config, which takes precedence:
[providers.openai.models."gpt-4o-mini"]
input_per_1m_tokens = 150000
output_per_1m_tokens = 600000Resolution order, highest priority first:
[providers.<name>.models."<model>"]per-provider model pricing[pricing.pricing.<provider>."<model>"]explicit pricing config- The bundled models.dev catalog, used automatically for known models
Models with no pricing from any source record zero cost, so their requests only consume budget transiently (the reservation is refunded at settlement).
Usage Analytics
The admin panel provides usage dashboards for monitoring spend and token consumption across the multi-tenancy hierarchy.
Admin Usage Page
The dedicated Usage page (/admin/usage) supports multi-dimensional filtering:
| Filter | Description |
|---|---|
| Organization | Required; scopes all data to a single org |
| Team | Narrow to a specific team within the org |
| Project | Narrow to a specific project |
| User | Narrow to a specific user |
| API Key | Narrow to a single API key |
Filters follow a priority order: API Key > User > Project > Team > Organization. The most specific filter wins.
The dashboard displays:
- Summary cards - Total cost, total requests, input/output tokens
- Cost over time - Daily spend line chart
- Cost by model - Breakdown pie chart
- Model details table - Per-model token and cost breakdown
Self-Service Usage
Non-admin users can view their own usage at /usage in the web UI. This page calls the self-service API endpoints (/admin/v1/me/usage/*) which require only standard authentication, not admin privileges.
Usage records and dashboards are the durable source of truth for reporting. The budget spend counter in the cache is a separate, enforcement-only value and the two are never reconciled.
Best Practices
- Set warning thresholds - Use 0.7-0.8 to get alerts before hitting limits
- Use Redis for production - Required for multi-node deployments and for counters that survive restarts
- Verify a cache is configured - Without one, budgets are silently not enforced
- Monitor forecasts - Review
days_until_exhaustionto proactively adjust budgets - Scope appropriately - Use project-owned keys for isolation, organization-owned keys for shared budgets
- Configure pricing for custom models - Models missing from the catalog record zero cost and never consume budget