Per-Tenant Budgets¶
Give every user their own isolated LLM spend cap — same Redis backend, zero per-tenant infrastructure.
with budget(max_usd=0.10, tenant_id=user.id, name="api", backend=RedisBackend()) as b:
run_agent()
# Each user gets an independent $0.10 cap. No shared state. No cross-contamination.
When tenant_id is set, shekel namespaces all Redis state under shekel:tb:{name}:{tenant_id}. Two tenants with the same name never share counters.
Installation¶
Quick Start¶
from shekel import budget
from shekel.backends.redis import RedisBackend
backend = RedisBackend() # reads REDIS_URL from env
# Enforce a $0.10 monthly cap for user "user-42"
with budget(max_usd=0.10, tenant_id="user-42", name="api", backend=backend) as b:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
)
Required parameters
tenant_id requires both name and backend. Omitting either raises ValueError.
FastAPI SaaS Example¶
A production-ready endpoint that enforces per-user spend, returns HTTP 429 on exhaustion, and lets admins inspect quotas:
from fastapi import FastAPI, Depends, HTTPException, Request
from shekel import budget
from shekel.backends.redis import AsyncRedisBackend
from shekel.exceptions import BudgetExceededError
app = FastAPI()
backend = AsyncRedisBackend(url="redis://redis:6379/0")
MONTHLY_CAP_USD = 0.10 # $0.10 per user per 30 days
async def get_current_user(request: Request) -> str:
return request.headers["X-User-ID"] # your auth here
@app.post("/chat")
async def chat(prompt: str, user_id: str = Depends(get_current_user)):
try:
async with budget(
max_usd=MONTHLY_CAP_USD,
tenant_id=user_id,
name="api",
backend=backend,
window_seconds=86400 * 30, # 30-day rolling window (default)
) as b:
response = await client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
)
return {
"reply": response.choices[0].message.content,
"spent": b.spent,
}
except BudgetExceededError as e:
raise HTTPException(
status_code=429,
detail="Monthly spend limit reached.",
headers={"Retry-After": str(int(e.retry_after or 0))},
)
Async Usage¶
budget() with tenant_id works identically in async contexts — just use async with:
from shekel import budget
from shekel.backends.redis import AsyncRedisBackend
backend = AsyncRedisBackend()
async with budget(
max_usd=0.10,
tenant_id=user_id,
name="api",
backend=backend,
) as b:
await call_llm()
print(f"Tenant {user_id} spent: ${b.spent:.4f}")
Redis Key Scheme¶
Each tenant's state lives in its own Redis hash, completely isolated from other tenants:
| Key pattern | Example | Contains |
|---|---|---|
shekel:tb:{name}:{tenant_id} | shekel:tb:api:user-42 | usd:spent, usd:max, usd:window_s, usd:start, spec_hash |
Without tenant_id, the key is shekel:tb:{name} (shared across all callers). With tenant_id, an extra segment is appended so no two tenants ever touch the same hash.
Quota Management¶
RedisBackend (and AsyncRedisBackend) expose five admin methods for managing tenant quotas programmatically.
get_tenant_spend(name, tenant_id) → float¶
Return the current window spend for a tenant. Returns 0.0 if the tenant has never been seen.
spent = backend.get_tenant_spend(name="api", tenant_id="user-42")
print(f"User 42 has spent ${spent:.4f} this window")
get_tenant_limit(name, tenant_id) → float | None¶
Return the active spend limit for a tenant. Returns None if the tenant has no recorded limit.
limit = backend.get_tenant_limit(name="api", tenant_id="user-42")
if limit is not None:
print(f"User 42 limit: ${limit:.2f}")
set_tenant_limit(name, tenant_id, max_usd)¶
Override the spend limit for a tenant without resetting their accumulated spend. Useful for upgrades (free → pro) or admin adjustments.
# Upgrade user to a $1.00 monthly cap
backend.set_tenant_limit(name="api", tenant_id="user-42", max_usd=1.00)
After calling set_tenant_limit, subsequent budget(max_usd=1.00, tenant_id="user-42", ...) calls succeed. Passing the old limit raises BudgetConfigMismatchError — see Limit-change flow.
reset_tenant(name, tenant_id)¶
Zero out a tenant's accumulated spend while preserving their limit. Use this at the start of a new billing period.
list_tenants(name) → list[str]¶
Return all tenant IDs that have ever recorded spend for the given budget name.
tenants = backend.list_tenants(name="api")
for tid in tenants:
spent = backend.get_tenant_spend(name="api", tenant_id=tid)
limit = backend.get_tenant_limit(name="api", tenant_id=tid)
print(f"{tid}: ${spent:.4f} / ${limit:.2f}")
Async equivalents¶
All five methods are available as coroutines on AsyncRedisBackend:
spent = await backend.get_tenant_spend(name="api", tenant_id="user-42")
limit = await backend.get_tenant_limit(name="api", tenant_id="user-42")
await backend.set_tenant_limit(name="api", tenant_id="user-42", max_usd=1.00)
await backend.reset_tenant(name="api", tenant_id="user-42")
tenants = await backend.list_tenants(name="api")
shekel tenants CLI¶
The shekel tenants command inspects and manages tenant quotas from the command line — no code changes needed.
List tenants¶
Tenant Spent Limit % Used
user-1 $0.0821 $0.1000 82.1%
user-2 $0.0034 $0.1000 3.4%
org:user-3 $0.0990 $0.1000 99.0%
JSON output:
[
{"tenant_id": "user-1", "spent": 0.0821, "limit": 0.1},
{"tenant_id": "user-2", "spent": 0.0034, "limit": 0.1},
{"tenant_id": "org:user-3", "spent": 0.0990, "limit": 0.1}
]
Set a limit¶
Reset spend¶
Flag reference¶
| Flag | Description |
|---|---|
--name | Budget name (required for all subcommands) |
--tenant | Tenant ID (required for set-limit and reset) |
--max-usd | New spend limit in USD (required for set-limit) |
--redis-url | Redis URL (default: $REDIS_URL) |
--json | Output as JSON instead of a table |
Limit-Change Flow¶
When the tenant limit changes (e.g. a user upgrades), shekel detects the mismatch via a stored spec_hash and raises BudgetConfigMismatchError if you call budget() with the old limit.
Correct flow:
# 1. Admin raises the limit in Redis
backend.set_tenant_limit(name="api", tenant_id="user-1", max_usd=0.50)
# 2. Next request uses the new limit — no mismatch
with budget(max_usd=0.50, tenant_id="user-1", name="api", backend=backend):
call_llm()
Incorrect — still passing old limit:
backend.set_tenant_limit(name="api", tenant_id="user-1", max_usd=0.50)
# Passing old limit 0.10 → BudgetConfigMismatchError
with budget(max_usd=0.10, tenant_id="user-1", name="api", backend=backend):
call_llm()
The mismatch check is per-tenant — changing user-1's limit has no effect on user-2.
Error Reference¶
| Exception | When raised |
|---|---|
ValueError | tenant_id="" (empty string), or tenant_id set without backend, or tenant_id set without name |
BudgetExceededError | Tenant's spend cap is reached during a call |
BudgetConfigMismatchError | Same (name, tenant_id) called with a different max_usd than what's stored in Redis |
from shekel.exceptions import BudgetExceededError, BudgetConfigMismatchError
try:
with budget(max_usd=0.10, tenant_id=user_id, name="api", backend=backend):
call_llm()
except BudgetExceededError as e:
# Tenant is over their cap — retry_after tells them when the window resets
print(f"Limit reached. Retry in {e.retry_after:.0f}s")
except BudgetConfigMismatchError:
# Limit was changed in Redis but code still uses the old value
print("Budget config mismatch — check set_tenant_limit()")
tenant_id on the Budget Object¶
The tenant_id is accessible on the budget instance after the context exits:
with budget(max_usd=0.10, tenant_id="user-42", name="api", backend=backend) as b:
call_llm()
print(b.tenant_id) # "user-42"
print(b.spent) # e.g. 0.0023
b.summary() also surfaces the tenant:
Next Steps¶
- Distributed Budgets — shared caps across multiple workers using Redis
- Temporal Budgets — rolling-window rate limits
- API Reference — complete parameter and method reference