{"id":284,"date":"2026-09-22T17:46:20","date_gmt":"2026-09-22T09:46:20","guid":{"rendered":"https:\/\/wp.qoraapi.com\/ai-cost-guardrails-budgets\/"},"modified":"2026-09-22T17:49:36","modified_gmt":"2026-09-22T09:49:36","slug":"ai-cost-guardrails-budgets","status":"publish","type":"post","link":"https:\/\/qoraapi.com\/blog\/ai-cost-guardrails-budgets\/","title":{"rendered":"Preventing Runaway AI Spend: Budget Caps, Kill Switches, and Anomaly Alerts"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">The bills that hurt are never the ones you planned for. A workload running at 200 USD a day does not become a 20,000 USD day because a model got more expensive; it becomes one because something looped, retried, or was invoked ten thousand times by a code path nobody was watching. Guardrails are a different problem from cost optimisation: optimisation lowers the baseline, while budget caps, kill switches and anomaly alerts bound the worst case.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Optimisation is not a control<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Caching, model routing and prompt trimming reduce the price of a normal request. None of them stops an abnormal one. A 70 percent cache hit rate is a great number until an agent loop generates a unique prompt on every iteration and pays full price forever. Model routing does nothing when the runaway path legitimately needs the frontier model.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">&#8220;Our cost per request is down 40 percent&#8221; and &#8220;we cannot spend more than 5,000 USD this month&#8221; are unrelated claims. The first is a <a href=\"https:\/\/qoraapi.com\/blog\/reduce-ai-api-costs\/\">cost reduction exercise<\/a>; the second is admission control, and it must be enforced in the request path rather than observed afterwards in a dashboard.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Six failure modes that actually cause runaway bills<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Runaway spend comes from a handful of structural bugs, each with an early signature and a cheap control.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>An agent loop that never terminates.<\/strong> The model calls a tool, the tool returns something unusable, and the model calls the tool again. Nothing is broken in the traditional sense, so nothing alerts, and the cost curve is worse than linear because context grows each turn. Worked example: a 2,000-token system prompt plus 1,500 tokens per turn means input at turn <em>n<\/em> is 2,000 + 1,500(n-1). Over 200 turns that is 200 &times; 2,000 + 1,500 &times; (200 &times; 199 \/ 2) &asymp; 30.25M input tokens, which at 3 USD per million is 90.75 USD for one conversation. A job fanning out 5,000 of them is 453,750 USD. The missing control is a maximum-turn and per-run token budget.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>A retry storm.<\/strong> A retry wrapper with a short timeout, a reset backoff counter, or missing jitter can multiply one logical request into hundreds. Fifty workers retrying ten times a second against a failing call is 500 requests per second; at 0.02 USD each, roughly 36,000 USD per hour. Errors are cheap only if they fail fast \u2014 a call that burns input tokens before returning a 500 costs full price every attempt. Idempotency keys and a retry budget fix it; see the <a href=\"https:\/\/qoraapi.com\/blog\/ai-api-rate-limits-429-errors\/\">retry and 429 guide<\/a>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>A user-triggered bulk action with no per-user cap.<\/strong> Someone selects &#8220;all 400,000 records&#8221; in a UI that fans out one model call per record. The org budget is fine; the individual account is not.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>A runaway eval or backfill job.<\/strong> A scheduled job re-scores the last 90 days of traffic against a new prompt. It never appears in latency dashboards and runs at 02:00. Backfills cause more surprising months than anything else: one-off code, written quickly, with no budget.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>A webhook redelivery loop.<\/strong> Your handler returns a 500 after the model call succeeds, the provider redelivers, and your handler calls the model again. Every redelivery is paid inference, and the signature is a burst of identical idempotency keys.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>A prompt that grows the context window every turn.<\/strong> The slow burn: each turn appends, nothing is evicted, and per-request cost creeps up a few percent per turn. No per-request limit ever trips.<\/p>\n\n\n\n<figure class=\"wp-block-table is-style-stripes\"><table class=\"has-fixed-layout\"><thead><tr><th>Failure mode<\/th><th>Early signal<\/th><th>Control that stops it<\/th><\/tr><\/thead><tbody><tr><td>Non-terminating agent loop<\/td><td>Turns per run climbing; tokens per run rising superlinearly<\/td><td>Max-turn cap, per-run token budget, spend-rate alert<\/td><\/tr><tr><td>Retry storm<\/td><td>Retries per logical request; requests per second per client; 5xx ratio<\/td><td>Retry token bucket, exponential backoff with full jitter, idempotency keys<\/td><\/tr><tr><td>Uncapped bulk action<\/td><td>Requests per user per minute; spend per user<\/td><td>Per-user daily cap, job-size ceiling, async queue with confirmation<\/td><\/tr><tr><td>Runaway eval or backfill<\/td><td>Off-hours spend; spend grouped by feature tag<\/td><td>Dedicated budget per feature, dry-run sample, hard job ceiling<\/td><\/tr><tr><td>Webhook redelivery loop<\/td><td>Duplicate idempotency keys; requests from one source per minute<\/td><td>Idempotency keys with a dedupe window, per-source rate cap<\/td><\/tr><tr><td>Growing context window<\/td><td>Tokens per request z-score; input-to-output ratio<\/td><td>Context compaction, sliding window, per-request token ceiling<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">Budget hierarchy: put each control at the level that owns the failure<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A single org-level budget is a smoke detector in a warehouse. It tells you the building is on fire and nothing about which aisle. Each level should exist to catch a failure the level above cannot localise.<\/p>\n\n\n\n<figure class=\"wp-block-table is-style-stripes\"><table class=\"has-fixed-layout\"><thead><tr><th>Level<\/th><th>Typical control<\/th><th>Why it belongs here<\/th><\/tr><\/thead><tbody><tr><td>Org<\/td><td>Monthly hard ceiling, global kill switch, billing alerts<\/td><td>Last line of defence; the number finance signs off on<\/td><\/tr><tr><td>Team<\/td><td>Monthly budget with a named owner<\/td><td>Accountability without blocking unrelated teams<\/td><\/tr><tr><td>Environment<\/td><td>Separate budgets; development and staging capped hard<\/td><td>Stops an experiment or load test eating production headroom<\/td><\/tr><tr><td>User<\/td><td>Daily cap plus per-minute rate limit<\/td><td>Catches one account&#8217;s loop or bulk action while everyone else works<\/td><\/tr><tr><td>Feature<\/td><td>Budget per feature tag, e.g. <code>summarise_v3<\/code><\/td><td>Catches a bad deploy of one code path before it becomes an org incident<\/td><\/tr><tr><td>Request<\/td><td><code>max_tokens<\/code> ceiling, context limit, pre-flight reservation<\/td><td>Bounds the worst possible single call<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Per-user caps catch what org caps structurally cannot. When an org budget trips, every user is blocked, including the 99.9 percent who behaved correctly, and the response starts with &#8220;who did this&#8221; \u2014 an investigation that takes hours when requests are untagged. A per-user cap turns the same event into one account hitting a limit. Enforce at the narrowest scope that covers the blast radius, and keep the wider scope as a backstop.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Soft caps, hard caps, and the 70\/90\/100 ladder<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A hard cap with no warning is bad engineering: the first signal a customer gets is total failure, at the worst possible time, with no chance to react. A soft cap with no enforcement is not a control, just a notification people learn to filter. You need a ladder that both warns and enforces, applied per scope.<\/p>\n\n\n\n<ul class=\"wp-block-list\"><li><strong>70 percent \u2014 warn.<\/strong> Alert the owning team, not the on-call rotation. Nothing is blocked. Increase trace sampling for this scope and put the top spenders by feature in the alert body.<\/li><li><strong>90 percent \u2014 degrade.<\/strong> Shed cost automatically: route eligible traffic to a cheaper model, disable optional enrichment, tighten <code>max_tokens<\/code>, stop accepting batch work. Users still get answers; they get smaller ones.<\/li><li><strong>100 percent \u2014 block.<\/strong> Reject new requests for that scope with a machine-readable error carrying the scope, the reset time, and a link to request an increase. In-flight requests finish; killing them mid-stream wastes paid tokens.<\/li><li><strong>120 percent \u2014 kill.<\/strong> If spend still passes 120 percent of a hard limit, something bypassed the reservation path. Trip the kill switch and page a human: reaching this tier is a bug, not a budget decision.<\/li><\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">One knob per scope, one owner, one place to look when behaviour changes.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Pre-flight estimation, atomic reservation, and reconciliation<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">You cannot enforce a budget you only learn about after the call. Every request needs a cost estimate before dispatch, a reservation against the relevant scopes, and a settlement once real usage is known.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Estimating before you call<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Use the model&#8217;s real tokeniser when the request is large enough to matter, and a heuristic when it is not. For most chat traffic a character-based estimate lands within about 15 percent, which is fine for reservation as long as you reconcile: roughly four characters per token for Latin text, one and a half to two for CJK, plus 20 to 30 tokens of chat framing. Then price it using the <em>output cap<\/em>, not a hopeful average: <code>estimate = input_tokens &times; price_in + max_tokens &times; price_out<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Reserving against <code>max_tokens<\/code> deliberately over-reserves, which is the correct default: over-reservation costs headroom, under-reservation costs the guarantee. If callers set <code>max_tokens<\/code> to 4096 and use 300, tighten the callers rather than weakening the guard.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The concurrency race<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The naive implementation reads the remaining budget, compares it to the estimate, and writes the new total. Two requests arriving in the same millisecond both read 1.00 USD of headroom, both estimate 0.80 USD, both pass, and both spend \u2014 a 60 percent overrun from two concurrent calls, unbounded at real concurrency. This lost-update race is why homegrown guards fail precisely when traffic spikes.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Two fixes exist. An atomic counter performs the check and the increment in one operation, via a Redis Lua script or a conditional SQL update. A reservation ledger writes a row per request in a <code>reserved<\/code> state and moves it to <code>settled<\/code> afterwards. The counter is faster; the ledger gives attribution and post-hoc reconciliation. Ship both if you can, with the ledger as source of truth.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Where to enforce: application, gateway, or both<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Application-level checks are the only place with enough context to know which user, feature and logical operation a request belongs to, and the easiest layer to bypass. Every new service, notebook, cron job and script is a fresh chance to call the provider without the guard.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A gateway sits in front of every provider call by construction and cannot be bypassed by new code as long as the credentials live behind it. That is the operational reason never to distribute raw provider keys: issue gateway keys with per-key budgets. The gateway knows the model, the token counts and the account, so it enforces org and tenant ceilings, rate limits and kill switches without caller cooperation.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Its blind spot is intent: it does not know this call is the 40th turn of a loop, or that it belongs to a backfill that should never exceed 200 USD. So the application should enforce semantic caps and pass a pre-flight estimate, while the gateway enforces the hard ceiling, the rate limit and the kill switch, and rejects requests arriving without a valid estimate header. That is what a gateway such as <a href=\"https:\/\/qoraapi.com\/\">Qora API<\/a> is built for: one key across providers with per-key budgets and unified metering.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Kill switches that are fast, safe, and rehearsed<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A kill switch is a manual, deliberate stop. Unlike a circuit breaker, which is automatic and trips on error rate, a kill switch trips on money. It needs three properties.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Scoped.<\/strong> Support at least global, per-tenant, per-model and per-feature scopes. Stopping one tenant is a routine action; stopping everything is a company-level decision. If the only switch you have is global, nobody will pull it, which means it does not exist. The switch should stop <em>new<\/em> requests and let in-flight ones finish \u2014 except streaming calls, whose cost is unbounded until they end.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Fast.<\/strong> Read the flag from a cache with a short TTL, in the request path \u2014 not from a config deploy, not from a database query. A switch that takes four minutes to propagate arrives after the spend. Target seconds from flip to effect, and measure it.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Testable in production without a real incident.<\/strong> This is the property everyone skips and the one that decides whether the switch works when you need it. Run it in shadow mode \u2014 flip it for one internal tenant or one percent of traffic \u2014 and confirm callers get the expected error, retries do not amplify the rejection, and the alert fires. Do it on a schedule, because an untested kill switch is a boolean never evaluated under load.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Anomaly detection that catches the burn before the invoice<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">A per-request threshold misses slow burns by construction, and a daily alert arrives after the money is gone. Evaluate on minute-level aggregates, and prefer rules you actually ship.<\/p>\n\n\n\n<ul class=\"wp-block-list\"><li><strong>Spend rate versus trailing baseline.<\/strong> Compare spend in the last ten minutes against the median of the same bucket over the previous seven days. Alert when the ratio exceeds five <em>and<\/em> the value clears a floor, so 0.02 USD to 0.10 USD does not page anyone at 4am.<\/li><li><strong>Tokens per request z-score.<\/strong> Track mean and standard deviation of input tokens per feature over a rolling window; alert at mean plus three standard deviations. This catches the growing context window, which no cost-per-request rule sees.<\/li><li><strong>Requests per user per minute.<\/strong> Humans do not make 400 requests a minute; loops and bulk actions do.<\/li><li><strong>Cache hit-rate collapse.<\/strong> A drop from 60 percent to 5 percent means a cache-key bug or a prompt change that invalidated every entry. Both cost money and are invisible in latency metrics.<\/li><li><strong>Model-mix shift.<\/strong> Alert when the share of requests on your most expensive model moves by more than a few points. A routing bug sending everything to the frontier model is silent and expensive.<\/li><\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Alert on the derivative, not the level: a scope that always spends 500 USD a day should not page anyone for spending 500 USD a day. Put the top three spenders by tag in the alert body, because &#8220;spend is up 6x, and 92 percent of it is <code>tenant_4471<\/code>&#8221; ends an investigation that &#8220;spend is up 6x&#8221; starts. These are the aggregates described in the <a href=\"https:\/\/qoraapi.com\/blog\/llm-observability\/\">observability stack<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Graceful degradation beats hard failure<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Blocking is the last resort. Most budget pressure can be absorbed by changing what the system does rather than whether it responds.<\/p>\n\n\n\n<figure class=\"wp-block-table is-style-stripes\"><table class=\"has-fixed-layout\"><thead><tr><th>Degradation<\/th><th>User impact<\/th><th>When to use<\/th><\/tr><\/thead><tbody><tr><td>Fall back to a cheaper model<\/td><td>Lower quality on hard inputs, still correct on easy ones<\/td><td>Classification, extraction, summarisation, and any task where a cheap model passes your eval set<\/td><\/tr><tr><td>Disable an optional step<\/td><td>Less rich answer; nothing promised is missing<\/td><td>Enrichment such as reranking, second-opinion passes, speculative tool calls<\/td><\/tr><tr><td>Shorten the context<\/td><td>Long-range detail may be lost; recent turns preserved<\/td><td>When context is the cost driver and recency is what users rely on<\/td><\/tr><tr><td>Queue for later<\/td><td>Latency moves from seconds to hours<\/td><td>Non-interactive work: evals, backfills, report generation, embedding refreshes<\/td><\/tr><tr><td>Return a partial answer with a notice<\/td><td>Incomplete but honest and immediate<\/td><td>When the user prefers something now over everything later<\/td><\/tr><tr><td>Reject with a clear error<\/td><td>Work blocked until the budget resets or is raised<\/td><td>Only when the alternative is unbudgeted spend<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The routing decision must be precomputed, not improvised at 90 percent budget. Which tasks have an acceptable cheap-model fallback is a quality question that belongs in an eval run, not an incident. The <a href=\"https:\/\/qoraapi.com\/blog\/choose-right-ai-model-routing\/\">model routing guide<\/a> covers how to establish that mapping.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Attribution: tag every request or debug forever<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Without tags, a spend spike is an unbounded investigation: a number, a timestamp, and hours of grepping logs for the code path that changed. With tags it is one query. Every request should carry tenant, user, feature, environment, model, and a trace identifier propagated through retries and tool calls, so a storm of 400 provider calls collapses into one logical request.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The trace identifier is the piece teams most often omit and the one that makes retry storms legible: without it, a storm looks like a traffic increase. The feature tag is the other high-value dimension because it maps onto a deploy, and <code>GROUP BY feature ORDER BY usd DESC<\/code> answers &#8220;what shipped&#8221; in seconds. The metering model behind this is described in the <a href=\"https:\/\/qoraapi.com\/blog\/ai-usage-metering-billing\/\">usage metering post<\/a>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">A concrete implementation: reserve, then reconcile<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The guard below estimates cost from the prompt and the output cap, reserves atomically against a scope, and reconciles with actual usage when the call returns. Atomicity comes from a Redis Lua script, so the read-check-write sequence cannot interleave.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import uuid\nfrom dataclasses import dataclass\nfrom decimal import Decimal\n\nimport redis\n\n# Check and increment happen in one atomic step. Without this, two concurrent\n# requests both read the same headroom and both pass.\nRESERVE_LUA = \"\"\"\nlocal key = KEYS[1]\nlocal amount = tonumber(ARGV[1])\nlocal limit = tonumber(ARGV[2])\nlocal ttl = tonumber(ARGV[3])\nlocal current = tonumber(redis.call('GET', key) or '0')\nif current + amount &gt; limit then\n  return {'-1', tostring(limit - current)}\nend\nlocal next_total = current + amount\nredis.call('SET', key, next_total, 'EX', ttl)\nreturn {tostring(next_total), tostring(limit - next_total)}\n\"\"\"\n\n@dataclass(frozen=True)\nclass Reservation:\n    request_id: str\n    scope: str\n    period: str\n    amount_usd: Decimal\n\nclass BudgetExceeded(Exception):\n    \"\"\"Raised when a scope has no headroom left for the estimated cost.\"\"\"\n\nclass BudgetGuard:\n    def __init__(self, client: redis.Redis, limits: dict):\n        self.client = client\n        self.limits = limits          # {(scope, period): Decimal}\n        self.reserve_script = client.register_script(RESERVE_LUA)\n\n    @staticmethod\n    def estimate_usd(prompt: str, max_output_tokens: int,\n                     price_in_per_m: Decimal, price_out_per_m: Decimal) -&gt; Decimal:\n        # Heuristic tokeniser: ~4 chars\/token for Latin, ~1.7 for CJK,\n        # plus 24 tokens of chat framing. Reconcile afterwards, always.\n        ascii_chars = sum(1 for ch in prompt if ord(ch) &lt; 128)\n        wide_chars = len(prompt) - ascii_chars\n        input_tokens = ascii_chars \/\/ 4 + int(wide_chars \/ 1.7) + 24\n        return (input_tokens * price_in_per_m\n                + max_output_tokens * price_out_per_m) \/ Decimal(1_000_000)\n\n    def reserve(self, scope: str, period: str, amount_usd: Decimal,\n                seconds_left: int) -&gt; Reservation:\n        limit = self.limits[(scope, period)]\n        key = \"budget:{}:{}\".format(period, scope)\n        total, remaining = self.reserve_script(\n            keys=[key],\n            args=[str(amount_usd), str(limit), int(seconds_left)],\n        )\n        if float(total) == -1:\n            raise BudgetExceeded(\n                \"{} \/ {} has {:.4f} USD headroom left\".format(scope, period, float(remaining))\n            )\n        return Reservation(uuid.uuid4().hex, scope, period, amount_usd)\n\n    def reconcile(self, reservation: Reservation, actual_usd: Decimal) -&gt; None:\n        # Refund unused headroom, or charge the overrun. Redis preserves the TTL.\n        key = \"budget:{}:{}\".format(reservation.period, reservation.scope)\n        delta = actual_usd - reservation.amount_usd\n        if delta != 0:\n            self.client.incrbyfloat(key, str(delta))\n\n# Call site: reserve against every scope that applies, innermost first.\nguard = BudgetGuard(redis.Redis(), limits={\n    (\"org\", \"month\"): Decimal(\"5000\"),\n    (\"user:u_8812\", \"day\"): Decimal(\"25\"),\n})\n\nestimate = guard.estimate_usd(prompt, 800, Decimal(\"3.00\"), Decimal(\"15.00\"))\nreservation = guard.reserve(\"user:u_8812\", \"day\", estimate, seconds_left=43200)\ntry:\n    response = call_model(prompt, max_output_tokens=800)\nfinally:\n    # On failure actual usage is zero, so the reservation is refunded in full.\n    guard.reconcile(reservation, Decimal(str(response.usage.cost_usd)))<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The ledger is the durable half: the counter in front of it is a cache, the ledger is what you reconcile against the provider invoice. Note the <code>FOR UPDATE<\/code> on the limit row, which serialises concurrent reservations for the same scope and makes the conditional insert safe under load.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>CREATE TABLE budget_reservations (\n  request_id    uuid          PRIMARY KEY,\n  trace_id      uuid          NOT NULL,\n  tenant_id     text          NOT NULL,\n  feature       text          NOT NULL,\n  model         text          NOT NULL,\n  scope         text          NOT NULL,\n  scope_id      text          NOT NULL,\n  period        text          NOT NULL,\n  period_start  timestamptz   NOT NULL,\n  reserved_usd  numeric(14,6) NOT NULL,\n  settled_usd   numeric(14,6),\n  state         text          NOT NULL DEFAULT 'reserved'\n                  CHECK (state IN ('reserved', 'settled', 'released')),\n  created_at    timestamptz   NOT NULL DEFAULT now(),\n  settled_at    timestamptz\n);\n\nCREATE INDEX budget_reservations_scope_idx\n  ON budget_reservations (scope, scope_id, period, period_start);\nCREATE INDEX budget_reservations_trace_idx  ON budget_reservations (trace_id);\nCREATE INDEX budget_reservations_tenant_idx ON budget_reservations (tenant_id, created_at DESC);\n\n-- Reserve. The row lock serialises concurrent reservations for this scope,\n-- so two requests cannot both observe the same headroom.\nBEGIN;\n\nSELECT limit_usd FROM budget_limits\nWHERE scope = :scope AND scope_id = :scope_id AND period = :period\nFOR UPDATE;\n\nINSERT INTO budget_reservations (\n  request_id, trace_id, tenant_id, feature, model,\n  scope, scope_id, period, period_start, reserved_usd\n)\nSELECT :request_id, :trace_id, :tenant_id, :feature, :model,\n       :scope, :scope_id, :period, :period_start, :estimate_usd\nFROM (\n  SELECT :limit_usd - COALESCE(sum(COALESCE(settled_usd, reserved_usd)), 0) AS headroom\n  FROM budget_reservations\n  WHERE scope = :scope AND scope_id = :scope_id\n    AND period = :period AND period_start = :period_start\n    AND state &lt;&gt; 'released'\n) AS h\nWHERE h.headroom &gt;= :estimate_usd\nRETURNING request_id;\n\nCOMMIT;\n-- Zero rows returned means the budget was exhausted: reject with HTTP 402 or 429.\n\n-- Settle with real usage from the provider response.\nUPDATE budget_reservations\nSET state = 'settled', settled_usd = :actual_usd, settled_at = now()\nWHERE request_id = :request_id AND state = 'reserved';\n\n-- Attribution: what is spending, right now, for this tenant.\nSELECT feature, model,\n       count(*)                                   AS calls,\n       sum(COALESCE(settled_usd, reserved_usd))   AS usd\nFROM budget_reservations\nWHERE tenant_id = :tenant_id\n  AND created_at &gt;= now() - interval '15 minutes'\nGROUP BY feature, model\nORDER BY usd DESC\nLIMIT 10;<\/code><\/pre>\n\n\n\n<h2 class=\"wp-block-heading\">The minimum viable control set for week one<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">With one week, I would ship five things and deliberately skip the rest.<\/p>\n\n\n\n<ol class=\"wp-block-list\"><li><strong>Tag every request<\/strong> with tenant, user, feature and trace id. Nothing else works without this.<\/li><li><strong>Reserve and reconcile against an atomic counter<\/strong> at the org and per-user scopes. Two scopes, one script, one call site.<\/li><li><strong>Set the 70\/90\/100 ladder<\/strong> with a real degradation path at 90 percent. If the 90 percent tier does nothing, you have not shipped a ladder.<\/li><li><strong>Ship one global and one per-tenant kill switch<\/strong>, read from a cached flag, with a documented flip procedure and a shadow-mode test you have actually run.<\/li><li><strong>Add three alerts<\/strong>: spend rate versus trailing baseline, tokens per request z-score, and requests per user per minute \u2014 minute-level evaluation, top spenders in the alert body.<\/li><\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Defer per-feature budgets until the taxonomy stabilises, the 120 percent tier until you trust reservations, and ML-based anomaly detection entirely: rules catch the failure modes above, and an untrusted alert is an ignored alert.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Frequently asked questions<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">Should the budget be enforced in the application or at the gateway?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Both, with different jobs. The application owns the semantic caps only it can see \u2014 per-user daily limits, per-feature budgets, maximum turns per agent run \u2014 and passes a pre-flight estimate downstream. The gateway owns the ceilings that must hold regardless of which code path is calling. Application-only enforcement fails the moment someone adds a service that calls the provider directly, and it fails silently. If you ship one first, ship the gateway: it is the only layer that cannot be bypassed.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How accurate does pre-flight estimation need to be?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Accurate enough that the reservation is a useful bound. A character-based heuristic lands within roughly 15 percent for typical chat traffic, and reservation over-reserves anyway because it charges <code>max_tokens<\/code> rather than expected output. Reconciliation keeps the system honest: reserved amounts are provisional, settled amounts are truth, and the counter is corrected by the delta. Estimates consistently off by more than 30 percent point at your callers&#8217; <code>max_tokens<\/code> settings, not at the guard.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Why not just set a hard cap and be done with it?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Because the first thing users experience is total failure with no warning, usually at an inconvenient hour. A hard cap with no ladder also fails badly at the org level: one runaway job exhausts the shared budget and every other tenant is blocked, turning a single bad deploy into a platform-wide outage. The ladder exists so the system changes behaviour before it stops responding.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">How do I know my kill switch will actually work?<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">By using it in production when nothing is wrong. Flip it for an internal tenant or one percent of traffic on a schedule, verify callers receive the expected error code and that retries do not amplify the rejection into a storm, then time the interval from flip to effect and treat it as an SLO. A kill switch never exercised under production load is an untested branch in the most important code path you own.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Conclusion<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Runaway AI spend is a bounded problem with a small set of known causes. The failure modes are structural \u2014 loops without termination, retries without budgets, bulk actions without per-user caps, jobs without ceilings \u2014 and each has a signature that appears in aggregates long before it appears on an invoice. What makes guardrails work is coverage, not sophistication: tag everything, reserve atomically before dispatching, reconcile after, enforce at the narrowest scope that covers the blast radius, and keep a gateway as the line that cannot be bypassed.<\/p>\n\n\n\n\n<h3 class=\"wp-block-heading\">Related reading<\/h3>\n\n\n<ul class=\"wp-block-list\"><li><a href=\"https:\/\/qoraapi.com\/blog\/reduce-ai-api-costs\/\">How to Reduce AI API Costs: A Practical Guide for Developers<\/a><\/li><li><a href=\"https:\/\/qoraapi.com\/blog\/ai-usage-metering-billing\/\">Metering and Billing AI Usage Per User: A Practical SaaS Guide<\/a><\/li><li><a href=\"https:\/\/qoraapi.com\/blog\/prompt-caching-guide\/\">Prompt Caching Explained: How to Cut Costs on Repeated Context<\/a><\/li><li><a href=\"https:\/\/qoraapi.com\/blog\/semantic-caching-ai-api\/\">Semantic Caching for AI APIs: Cut Latency and Cost by Up to 60%<\/a><\/li><\/ul>\n\n","protected":false},"excerpt":{"rendered":"<p>Agent loops, retry storms and uncapped bulk jobs cause five-figure bills. Design a budget hierarchy, atomic reserve-and-reconcile enforcement, rehearsed kill switches and anomaly rules that fire early.<\/p>\n","protected":false},"author":1,"featured_media":283,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[3],"tags":[5,6,9,7],"class_list":["post-284","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-ai-api","tag-ai-api","tag-api-gateway","tag-developer-tools","tag-developers"],"_links":{"self":[{"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/posts\/284","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/comments?post=284"}],"version-history":[{"count":1,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/posts\/284\/revisions"}],"predecessor-version":[{"id":311,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/posts\/284\/revisions\/311"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/media\/283"}],"wp:attachment":[{"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/media?parent=284"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/categories?post=284"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/qoraapi.com\/blog\/wp-json\/wp\/v2\/tags?post=284"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}