Blog

Per-key spending limits: what LiteLLM, OpenRouter and Portkey enforce — and the four leaks a cap doesn't close

How LiteLLM, OpenRouter and Portkey cap spend per API key — layer, timing, status code, reset — and the four leaks a cap doesn't close, read from the docs.

Leo Kaka22 min read
Matrix of three LLM routers, LiteLLM, OpenRouter and Portkey, against four budget dimensions: the per-key cap parameter, the shared wallet, when the check runs, and the HTTP status returned when the cap is hit

Yes, all three do it. LiteLLM caps a virtual key (the key the proxy issues to your team, in front of the real provider key) with max_budget and a reset window, OpenRouter caps a key with limit plus limit_reset, and Portkey caps an API key by cost or by tokens. All three also have a shared wallet, under different names: a LiteLLM team, an OpenRouter organization credit pool or workspace budget, a Portkey workspace.

Two facts to settle before the tables. Shape: LiteLLM is a proxy you run yourself, and every budget on its page “is enforced against spend read from the database”, so no Postgres means no budgets; OpenRouter is a hosted account paid from prepaid credits; Portkey is a hosted gateway with a self-hosted enterprise option. Price of entry: LiteLLM’s key, team and user budgets carry no Enterprise mark, its per-model budget does; OpenRouter’s per-key limit has no plan gate on the pages checked, its workspace budgets and spend alerts are Enterprise, and an organization “can only have 10 members”; Portkey’s budgets are “Available on Enterprise plan and select Pro customers”. The tier table below has the links.

The differences that matter are in which layer holds the number, when the check runs relative to the provider call, what the caller receives when the cap is hit, and when the counter goes back to zero. Rate limits (RPM/TPM) are a separate axis and not covered here. Helicone does something adjacent, a cost-based rate limit set per request via a Helicone-RateLimit-Policy header with u=cents; it is a throttle, not a key attribute, so it stays out of the tables. If you only need the settings, the last section is a checklist.

Everything here was read from each product’s own documentation on 2026-08-25, with wording quoted and links on every cell. Where the docs say nothing, the cell says so. Where two pages from the same vendor disagree, both are quoted; adjudicating that is their job. LiteLLM is open source, so its gaps were filled from its source code; OpenRouter and Portkey are closed, so “not documented” there means exactly that and nothing more. For the record, we are building a router ourselves and the metering layer is where we spend our days, which is why this is a reading exercise rather than a comparison row.

Which layer holds the budget: key, user, team, workspace, organization

“Per-key budget” is the entry point, but none of the three stop there, and the layer you pick decides who can route around the cap by creating another key.

LayerLiteLLMOpenRouterPortkey
API keymax_budget + budget_duration on /key/generate; multi-window budget_limitslimit + limit_reset on the key, via dashboard or Management APICost (min $1) or tokens on the API key
Team / workspaceTeam max_budget + budget_durationWorkspace budgets: daily / weekly / monthly / lifetimeWorkspace budget, cost or tokens
Organization / accountOrganization budget; global litellm_settings.max_budgetOrganization shared credit pool (“An organization can only have 10 members. Contact support if you need more.”)Integration budget across all workspaces; an integration is Portkey’s name for a connected provider account
User / memberInternal user max_budget; team-member max_budget_in_teamGuardrail budget, a per-member policy objectNot documented as a first-class layer; policies grouped by metadata.*
Per model, per keymodel_max_budgetNot documented (checked 2026-08-25)Usage-limit policy with model condition
End customer (no key issued)max_end_user_budget_id, applied to the user fieldNot documentedPolicy with metadata._user group-by
Who can mint a key, and does the cap followAnyone allowed on /key/generate; upperbound_key_generate_params forces a cap on every new key; a no-team key inherits its owner’s max_budget (virtual keys)“All organization members can create API keys”; members “can only view and manage API keys they created”, admins see all; the per-member backstop is a guardrail budgetNot documented on the budget pages checked (2026-08-25)

The same layers, read for price of entry; only what the pages state is written down.

FeatureLiteLLMOpenRouterPortkey
Per-key budgetNo Enterprise mark; database requiredNo plan gate stated on the limits page“Available on Enterprise plan and select Pro customers” (API key page)
Per-member budgetNo Enterprise mark (max_budget_in_team, internal user)Guardrail budgets; no plan gate stated on the guardrails pagePolicies only: “Enterprise Self Hosting plan only”, Gateway 1.17.0+ (policies)
Team walletNo Enterprise mark (team budget)Organization credit pool, 10 members; workspace budgets “available on the Enterprise plan” (workspace budgets)Workspace budget: Enterprise and select Pro (workspace page)
Per-model budgetEnterprise (model_max_budget)Not documentedPolicies: Enterprise self-hosted
Spend alertsNo Enterprise mark on the alerting page (soft_budget, webhooks)“Enterprise private preview”, email-only (notifications)Key-level email alerts on the same tier as the budget; policy alerts “are not sent today”

Three rules inside those tables are worth reading twice.

LiteLLM: “If a key belongs to a team, only the team (and team-member) budgets are enforced; the key owner’s personal budget does not apply.” So a personal max_budget on a user is not a ceiling over their team keys. Use max_budget_in_team for that.

OpenRouter: guardrail budgets “are enforced per-user and per-key, not shared across all users with that guardrail”, and “Individual API key budgets still apply. The lower limit wins.” Two keys with $20/day each under a member with a $20/day guardrail get blocked at $20 combined, not $40. That is the behaviour you want; it is also the one that surprises people.

OpenRouter again, on team size: the shared credit pool is an organization, and “An organization can only have 10 members. Contact support if you need more.” A 40-person team is past that line before it has set a single cap; the last section lists the three roads.

Portkey’s five levels stack the same way: the use-cases page puts it as “Every request passes through each applicable check before reaching the provider.” A key inside a workspace inside an integration has three counters.

Portkey's Add Budget form on an API key: a Cost / Tokens radio pair, a Budget Limit field whose placeholder reads Min $1, an Alert Threshold field, a Periodic Reset dropdown showing No Periodic Reset, plus Periodic Reset in Days and Set Next Usage Reset At fields
One key's budget in Portkey: the cap is either a dollar amount or a token count, the floor is $1, and the alert threshold is a separate number you have to fill in yourself.Source: Portkey Docs — Budget and rate limits on an API key.

When the check happens: before the request, after the response, or both

This is the axis nobody’s marketing page mentions, and it is the one that decides how far past the cap you can actually go.

LiteLLM runs a check before the request and a correction after it. The mechanism is called budget reservation and it is on by default:

LiteLLM estimates the request’s maximum cost from the request body and the model’s pricing. It temporarily reserves that amount against the applicable budget. If the reservation would exceed the budget, LiteLLM rejects the request before sending it to the provider. After the response is priced, LiteLLM replaces the reservation with the actual cost.

The estimate uses max_tokens when you send one; otherwise, the docs say, “it uses the model’s configured limits”. The source is more specific: budget_reservation.py reserves min(16384, max_output_tokens) output tokens through a constant named DEFAULT_MAX_OUTPUT_TOKENS_FALLBACK. The actual spend is written by a success callback after the response (proxy_track_cost_callback.py), and the hot-path counter lives in Redis with the database reconciled in the background.

OpenRouter checks before the request against spend already recorded. The workspace budgets FAQ states plainly what that leaves open:

Budget checks run before the request is routed to a provider. In-flight requests that were already dispatched will complete, so actual spend may slightly exceed the budget limit.

The help centre says the same for per-key limits: “Because the check runs per request, a burst of simultaneous requests can slightly overshoot the limit” (article 51680687417499).

Portkey checks before the request and says so in one line: “All checks happen before a request reaches the provider. A blocked request costs nothing.” Whether the check reserves an estimate or reads recorded spend is not documented (checked 2026-08-25), so the concurrency behaviour is unknown rather than known-bad.

Timeline of a budgeted request: the cap is checked, the request is dispatched, the response returns, the cost is priced, the counter is updated; concurrent requests all pass the first step before any reaches the last
Fig. 1 — the window between check and counter update is where concurrent requests overshoot. A pre-request estimate narrows it; a post-response ledger alone does not.

What you get when the cap is hit: 400, 402, 403, 412, or a quiet reroute

Behaviour on exceedLiteLLMOpenRouterPortkey
Hard reject400 / 401 / 429 depending on the docs example: 400 in the per-model key and team examples, 401 for an end user, 429 for a user’s per-model budget; the BudgetExceededError class defaults to 429402 Payment Required for a key limit or empty balance; 403 Forbidden for a workspace or guardrail budget412 Precondition Failed for budget, token or request caps; 429 is reserved for rate limits, 401 for an expired key
Soft alert, no blocksoft_budget on the key; webhook events threshold_crossed at 85% and 95%, projected_limit_exceeded (alerting)Per-key spend alert at 80% and 100% by default, “email-only for now”, Enterprise private preview (notifications)Alert threshold in USD or tokens, email to org admins, owners and the key creator; for policies, “Email and other proactive alerts for threshold crossings are work in progress; they are not sent today” (budget policies)
Fallback to a cheaper modelbudget_fallbacks per key, v1.92.x+: “the request is silently rerouted … Spend is attributed to the fallback model, not the exhausted one”Not documented as a budget response; provider.max_price refuses expensive endpoints per request insteadNot documented
Throttle instead of blockthrottle_on_budget_exceeded exists in source, not on the docs page checkedNot documentedNot documented
Free models still allowed“Budget checks are skipped entirely for zero-cost models” (both cost fields explicitly 0)A negative balance blocks free models too (limits)Not documented

In the table, ✗ means the docs describe no such behaviour or say nothing; ⚠ means it exists with a condition attached.

The status-code spread is the practical problem. If your client maps 402 to “top up”, 403 to “permission”, 412 to “retry with a different precondition” and 429 to “back off and retry”, then three of those four codes will be handled by code written for something else. LiteLLM’s own examples disagree with each other on the code and on the type (budget_exceeded in the per-model examples, auth_error in the team and end-user ones), and its exception class defaults to 429, which is the one you least want a budget rejection to wear: a retry loop will keep knocking, politely, forever. Whatever router you run, match on the error message, not the number and not the type.

Reset semantics: rolling durations, UTC midnights, and one weekday the docs disagree on

Reset rules decide whether “$100 a month” means a calendar month or thirty days from the day someone created the key.

LiteLLM has two spellings. A single budget_duration is relative: it accepts “seconds (”30s”), minutes (“30m”), hours (“30h”), days (“30d”)”, the budget “is reset at the end of specified duration”, and the next reset sits on the key as budget_reset_at, applied by a scheduler: “By default, the server checks for resets every 10 minutes, to minimize DB calls.” The same strings inside budget_limits align to the calendar instead:

curl 'http://0.0.0.0:4000/key/generate' \
  --header 'Authorization: Bearer <your-master-key>' \
  --header 'Content-Type: application/json' \
  --data-raw '{
    "budget_limits": [
      {"budget_duration": "24h", "max_budget": 10},
      {"budget_duration": "30d", "max_budget": 100}
    ]
  }'

Per the docs table: 1h resets every hour, 24h daily at midnight UTC, 7d every Sunday at midnight UTC, 30d on the 1st of every month at midnight UTC. So "budget_duration": "30d" on its own is thirty days from the key’s creation and then from each reset; the same "30d" inside budget_limits is the first of the month. Two spellings, two calendars, on the same page. Pick one per deployment and write it down.

OpenRouter’s per-key limit_reset is daily, weekly, monthly or null, and per the help centre “Resets run automatically at midnight UTC (weeks are Monday–Sunday)”. Workspace budgets add lifetime and a rule keys do not have: the limits must be strictly decreasing as the interval narrows, lifetime > monthly > weekly > daily, enforced with a 400 if you get it wrong. The key below also opts BYOK (bring-your-own-key) traffic into the cap; more on that in Leak 2.

{
  "name": "Customer Instance Key",
  "limit": 50,
  "limit_reset": "monthly",
  "include_byok_in_limit": true
}

Portkey’s reset options are none, weekly or monthly on an API key; workspaces (via the Admin API) and policies add a custom periodic_reset_days from 1 to 365 with an optional next_usage_reset_at. On which day the week resets, its pages disagree: three app-level pages, the API key, workspace and provider budget pages, say “every Sunday at 12 AM UTC”; the self-hosted policies page says “every Monday at 12:00 AM UTC”. Both are quoted as written on 2026-08-25; policies run on the self-hosted Gateway, so these may be two implementations rather than one typo. If a weekly budget matters to you and it lives on a workspace or a policy, set periodic_reset_days: 7 with an explicit next_usage_reset_at and stop depending on the weekday. Keys have no such option.

The Periodic Reset dropdown on a Portkey API key expanded to its three options: No Periodic Reset, Reset Weekly, Reset Monthly, with the Set Next Usage Reset At field beside it
The whole reset vocabulary available on a key: three options, no custom interval. The day the week rolls over is not stated in this control — it is the one the docs disagree on.Source: Portkey Docs — Budget and rate limits on an API key.
portkey.admin.workspaces.update(
    workspace_id="WORKSPACE_ID",
    usage_limits=[{
        "type": "cost",
        "credit_limit": 500,
        "alert_threshold": 400,
        "periodic_reset_days": 7,
        "next_usage_reset_at": "2026-09-01T00:00:00Z",
    }],
)

One more Portkey rule with no equivalent elsewhere: on the provider (integration) level, “Once set, budget limits cannot be edited by any organization member”; to change one you “duplicate the existing provider and create a new one with the desired limit”. Budget limits there are also not retroactive: “The spend counter starts from zero only after you’ve set a budget limit for a key.”

Leak 1 — the concurrency window every pre-check leaves open

The cap is a comparison against a counter. Between the comparison and the counter update there is a window, and every request that enters the window sees the same counter value. Fire 50 requests at a key with $1 left and, in the simplest model, all 50 pass the check; the counter finds out later.

OpenRouter documents this twice, quoted above, and the help centre’s mitigation list is the honest version: keep Auto Top-Up off so prepaid credits are the real ceiling, set a per-key limit, cap max_tokens, and use provider.max_price. Their own words: “one very large request, or many requests fired at the same time, can push you a little past a limit before usage is recorded.” Note that the firmest ceiling in that list is the wallet, not the key.

LiteLLM’s reservation closes most of the window, with three edges. First, the estimate needs max_tokens; without it the reservation is min(16384, max_output_tokens) from the source quoted above, so a request that will emit 200 tokens holds 16,384 tokens’ worth of budget until the response is priced, and a burst of such requests can hit the cap on reservations alone. Second, “For routes without token pricing, such as some image and audio routes, LiteLLM cannot reserve a cost”, and a POST /batches submission “contains an input_file_id rather than the prompts in the file”, so the window reopens there. Third, the counter is Redis: “If Redis restarts and reloads an older snapshot, the counter can come back lower than the spend already recorded in the database … which can let a key keep spending past its max_budget until the counter is corrected.” The fix is fail_closed_budget_enforcement: true, priced in the same paragraph: roughly one database read per budgeted entity per cache window per worker, and a 503 “if current spend can be verified against neither Redis nor the database”. Postgres is not optional; Redis holds the hot-path counter, and the page’s own advice for when Redis “is unavailable or contains stale data” is that setting. With no database at all, litellm_settings.max_budget “fails open there rather than erroring”.

Portkey: not documented. The one sentence available says a blocked request costs nothing, which is true of every pre-check and says nothing about the requests that were not blocked.

Leak 2 — what counts as spend: cache tokens, reasoning tokens, unpriced models

A budget in dollars is only as good as the pricing function behind it, and each router’s pricing function has holes with different shapes.

Reasoning tokens. OpenRouter: “Reasoning tokens are considered output tokens and charged accordingly”, and for models with summarised thinking “usage is billed based on the tokens the model actually generates”, not the visible summary. So a per-key cap does count them, at output price. LiteLLM prices from its own model_prices_and_context_window.json; on the main branch as of 2026-08-25 it holds 3,174 model entries (excluding the file’s two reserved non-model keys, as LiteLLM’s own loader does), of which 2,632 carry a per-token input price and 57 carry an output_cost_per_reasoning_token. For the rest, the cost calculator falls back to the plain output rate, which is correct for most providers and wrong for any provider that bills reasoning separately.

Cache reads and writes. OpenRouter exposes a cache_discount field per response and notes that “Some providers, like Anthropic, will have a negative discount on cache writes, but a positive discount … on cache reads”; both directions land in the key’s usage. Its own response cache is different: a hit “returns it immediately from cache with no billing (all billable usage counters are reported as 0)”. On LiteLLM this one errs on the safe side: the key runs out before the bill does. When an entry has no cache_read_input_token_cost, the same calculator prices cached input at the full input rate, so the provider’s discount never reaches the key’s ledger. The field is present on 794 of the 2,632 priced entries, and cache_creation_input_token_cost on 255; many of the rest belong to providers with no prompt cache to discount, so those numbers are a ceiling on the gap, not a count of broken entries. The docs’ own debugging page tells you to “compare token categories (including cache)” when the number does not match the bill.

Unpriced models. Portkey is explicit and it is the sharpest edge in this section: “If a specific request log shows 0 cents in the COST column, it means that Portkey does not currently track pricing for that model, and it will not count towards the providers’s [sic] budget limit.” A model the gateway cannot price is, under a cost budget, unlimited. Portkey’s answer is the token-based budget, “allowing you to control usage independent of cost fluctuations”. LiteLLM has the same hole in the opposite direction by design: a model with both cost fields explicitly 0 skips budget checks entirely, and the docs warn that unset fields do not count as zero.

BYOK (bring your own key). On OpenRouter, requests served with your own provider key are excluded from guardrail budgets and workspace budgets by default; the docs state that default only for those two, and the key-level include_byok_in_limit shows false in every example. The opt-in is include_byok_in_limit on the key and include_byok_in_budgets on the others, which then count “the amount OpenRouter would have charged had the request not used your own provider key”. A team that brings its own keys and forgets the toggle has a cap on the part of its spend that goes through OpenRouter’s credits and nothing on the part that does not.

Leak 3 — retries and fallbacks: who pays for the attempt that failed

A cap meters the request you sent. Routers send more than that.

OpenRouter’s help centre has an entire article titled Why Am I Still Being Charged When Using My Own Key (BYOK)?, and the main answer is provider fallback: if your key fails, “the router will fallback and try another provider that also offers” the model, and “This fallback request is fulfilled using your OpenRouter credits”. The “Always use for this provider” toggle does not stop this; it only stops OpenRouter from using its own key for the same provider. The request-level "provider": {"only": ["anthropic"]} does stop it, at the price of the request failing outright when Anthropic does.

LiteLLM’s budget_fallbacks is the budget-aware version of the same move: when a key’s per-model budget is exhausted “the request is silently rerouted to the first fallback that still has budget remaining”, and spend is attributed to the fallback model. That is the point of the feature, and it also means the caller asked for one model, paid for another, and got no error telling them so. On the failure path, the hook releases the reservation and then, unless disable_error_logs is set, writes a failure row against the key (source); its comment reads “A stream that broke mid-flight still billed the provider for the chunks already delivered … attribute the real partial spend to this failure row instead of zero”, and guardrail cost rides along. The blind spot is narrower than “failures are free”: a non-streaming request the provider billed but returned as an error, with no usage attached, lands in the ledger at zero. The docs do not describe either case; that reading is from the code.

Portkey: retry and fallback cost attribution is not documented (checked 2026-08-25).

Leak 4 — the price sheet your budget is denominated in

Every budget above is a number in USD. What that number buys depends on the price sheet the gateway is holding at the moment of the request, and each router holds a different one. LiteLLM fetches its sheet from the GitHub main branch when the process starts (get_model_cost_map.py), keeps a bundled copy as the fallback if the fetch fails, and uses your own file only if you set LITELLM_LOCAL_MODEL_COST_MAP=True. So by default you do not choose when a price change lands: whichever worker restarts next picks up whatever main says that day, and two workers started a week apart can price the same key from two sheets. Portkey prices from its own pricing support, which is why unpriced models fall through. OpenRouter charges “the same rate as you would directly with the provider”, no markup, at the listed per-endpoint price at request time, so a provider price change moves the number of requests a $100 key can make on the day it lands.

How much warning you get before that move is a contract clause, and we read four of them in Price-change notice: 14 days, 30 days, or nothing at all. The short version: 14 days, 30 days, 30 days with an immediate-effect carve-out, and no commitment at all. A per-key cap set on the first of the month is a forecast, and its error bar is that clause.

OpenRouter’s own pages show the same drift in miniature. The pricing page and the FAQ put the BYOK free allowance at “$25,000 of list price inference / month” on pay-as-you-go and $200,000 on Enterprise, “measured by list-price inference cost, not request count” (5% fee after, on top of the 5.5% fee on credit purchases); the help-centre article linked above, last updated 2026-06-14, says “The first 1,000,000 BYOK requests each calendar month are free of this fee.” Two pages, two units; the rule under your budget can be rewritten in a place you were not reading.

What to configure today, per product

Every recommendation here has its cost written next to it. None of them is free.

On LiteLLM (self-hosted; per-model budgets are Enterprise, the rest is not marked)

  • Run it with a database, or you have no budgets at all. Cost: a Postgres instance and the operational duty that comes with it.
  • Put every production key in a team and budget the team; add max_budget_in_team per member. Personal max_budget does not reach team keys.
  • Use budget_limits with a 24h and a 30d window rather than a single budget_duration, so one bad day cannot spend the month. Cost: two counters per key.
  • Send max_tokens on every request so the reservation estimates from your number, not the 16,384-token fallback. Cost: a client change and the occasional truncated answer.
  • Turn on fail_closed_budget_enforcement for keys whose cap is a hard promise. Cost: one database read per budgeted entity per cache window per worker, and a 503 when neither Redis nor the database can answer.
  • Set upperbound_key_generate_params so nobody can mint a key without a cap. The docs note that upperbounds “also act as defaults and cannot be opted out of”. Both settings live in config.yaml, in different sections:
general_settings:
  fail_closed_budget_enforcement: true
litellm_settings:
  upperbound_key_generate_params:
    max_budget: 100
    budget_duration: "30d"

On OpenRouter (hosted; workspace budgets and spend alerts are Enterprise)

  • Under 10 people: one organization, one credit pool, one key per person or service with limit and limit_reset, provisioned through the Management API so the cap is created with the key, not added later. Over 10: the org page says “Contact support if you need more”; the alternatives are Enterprise workspaces, or one account with many keys where any member can mint another and only admins see them all.
  • Keep Auto Top-Up off if the account balance is meant to be the ceiling. Cost: a request failure at zero instead of a card charge.
  • Set include_byok_in_limit on every key that might route through your own provider keys, and include_byok_in_budgets on workspaces and guardrails. Cost: BYOK spend now counts at list price toward caps you sized for credit spend.
  • Add provider.only on BYOK traffic whose fallback you are not willing to pay for. Cost: that traffic fails when the provider does.
  • Handle 402 and 403 as budget events in the client, not as payment or permission bugs.

On Portkey (hosted; budgets are Enterprise and select Pro, policies are Enterprise self-hosted)

  • Budget the integration (provider) first, as the ceiling, then workspaces, then keys; the provider budget cannot be edited once set, so size it with the duplication step in mind.
  • For anything that must not be unlimited, add a token budget beside the cost budget; the cost budget does not see models Portkey cannot price.
  • On workspaces and policies, use periodic_reset_days plus next_usage_reset_at instead of weekly, until the pages agree on the weekday. Keys only offer weekly or monthly.
  • Route 412 to your budget handler and 429 to your backoff handler; they are different events and Portkey keeps them apart for you.

Two ownership notes, stated and not interpreted. OpenRouter announced on 2026-08-19 that it is joining Stripe, with “nothing about your integration changes” and closing “in the coming weeks”. Portkey’s documentation now carries the footer “Portkey is now PRISMA AIRS AI Gateway” (as seen on the API key page). Either could change what these pages say. Re-read them before you copy the numbers into a runbook; the dates on each quote above are there for that reason.