AI budget
Per-tenant AI spend caps, cutoff modes, and call-rate limits that make catastrophic cost-drain impossible by construction.
AI budget
The AI budget is the strict cost gate every AI call passes before Pact invokes a model provider. It exists so that a prompt injection, a compromised session, or a runaway loop can never quietly drain your AI credits — anything over the cap is refused at the door, before the provider is called and before a ledger row is written.
The control lives at Admin → Security → AI budget and is backed by
core.ai.budget, enforced from core.ai.client.AIClient via
check_budget_or_raise(...) on every call.
This is real and enforced
Budget checks run inside the AI client for every feature — not as an
advisory dashboard number. When a cap is breached the call raises
AIBudgetExceededError and a row is written to
ai_budget_blocks so you can see exactly what was refused and why.
The five layers
Checked in order on every call:
- Per-tenant monthly spend cap — the hard ceiling. Sums
ai_usage.cost_centsfor the current calendar month againsttenant_ai_budget.monthly_cap_cents. - Per-tenant daily spend cap — optional, scoped to today (UTC). Limits blast radius without lowering the monthly cap.
- Per-user daily spend sub-cap — optional. Stops one rogue user from draining the whole tenant pool.
- Per-tenant calls-per-minute — in-memory sliding window, default 60. Catches loops before they burn spend.
- Per-user calls-per-minute — default 20, within a tenant. A voice-relay utterance counts once here however many model calls it fans out into (router, classifier, answer, suggestions …): the limit reads "20 utterances a minute", which is what it was meant to catch — one user looping, not a conversation's own fan-out. Layer 4 still counts every call.
Layers 4 and 5 are intentionally in-memory (per replica) rather than Redis: the spend cap in the database is the strict gate, and the rate limit is a soft gate that catches runaway loops early. This avoids the failure mode where a Redis blip would let cost-drain succeed.
Cutoff modes
The monthly and daily caps share a cutoff_mode (VALID_CUTOFF_MODES):
hard_block(default) — refuse every call once the cap is hit.soft_block— refuse the expensive tiers (default/complex) but keep allowing the cheapextract(Haiku) tier, so critical classification paths stay alive while a runaway elsewhere is investigated.warn— log and write an audit event, but allow the call. For trusted internal tenants who only want telemetry.
Default budget
Tenants without an explicit tenant_ai_budget row inherit DEFAULT_BUDGET:
- $20.00 / month (
monthly_cap_cents = 2000) hard cap - No daily cap, no per-user sub-cap
- 60 tenant calls/minute, 20 per user
alert_threshold_pct = 80— surfaces an alert at 80% of the caphard_blockcutoff
Admins raise caps or switch to warning-only mode by creating an explicit row.
API
All routes are admin/owner only and tenant-scoped from the auth context:
GET /v1/admin/security/ai-budget # config + live spend + EOM projection + Ask limits
PUT /v1/admin/security/ai-budget # upsert config
GET /v1/admin/security/ai-budget/blocks # recent block events
The GET also returns a linear end-of-month spend projection and a per-feature
cost breakdown (from the ai_usage ledger). Every block is auditable via
ai_budget_blocks (alembic 0149_tenant_ai_budget).
Filed under Security on purpose
Budget caps live under /admin/security, not a standalone billing
page, because they cap the blast radius of prompt-injection and chat-jacking
attacks. An admin doing a security review finds them alongside rate limits,
network policy, and the audit log.
Ask limits
The Ask panel has three ceilings of its own (core.ask.guardrails), checked on
every question in addition to the spend caps above. The Ask limits card on
this page shows them as the limiter applies them:
- Questions per workspace per day: a rolling 24-hour window. Pact's
default is 500. A workspace can have its own limit
(
tenants.limits_json.ask_questions_per_day), which Pact sets on request. Workspace admins cannot change it here. If a stored value is out of range, the limiter ignores it and the card says so. "Look up" on an empty Ask answer draws from the same window and the same limit. - Per person per minute: 30, the same for every workspace.
- Model spend per answer: 5¢, checked twice. Before any model runs, Ask estimates what the answer will cost from this workspace's recent model costs (the 90th percentile of the last 20 runs of each model step in 30 days, or a fixed budget per step until there are 5) and refuses a question estimated over 5¢. Nothing is spent on it, and the answer says what it would have cost and how to ask more narrowly. Questions Ask answers in SQL without a model are never refused for cost. An answer whose actual cost still comes in over 5¢ is not served. Both kinds of refusal show on the Ask trail as "Refused (cost)".
The card also shows how many questions were asked in the last 24 hours, read from the limiter's own window without using up a question. When Pact runs the in-process counter, each server keeps its own count, and the card says the number covers one server. A count that could not be read is shown as unread, never as 0. When the limit is reached, a new question gets a "limit reached" answer that says when a slot frees up.
Kill switch
The same admin surface exposes the AI kill switch (core.ai.kill_switch) —
the outermost break-glass control. It can disable AI for a single tenant, and an
auto-trigger engages it when suspicious activity spikes (3+ injection blocks
in 5 minutes), writing an auto_engaged row to ai_kill_switch_events.
Recovery is always manual. A burst of rate-limit refusals alerts but never
disables the workspace: each refused call was already refused, spend is
bounded by the caps above, and one voice call fans out into several AI calls,
so a busy user must not be able to switch AI off for everyone. See
AI defense for the full detection pipeline.