How to Enforce a Token Quota Per Team in an AI Gateway
enforcing a token quota per team means the gateway, not the model, decides how many tokens a team may spend this month. Four pieces do it — resolve the team and read its entitlement, count what the next call would cost, refuse at the check when the counter is past the ceiling, and warn the owner before it is.
Short answer: enforcing a token quota per team means the gateway, not the model, decides how many tokens a team may spend this month. Four pieces do it — resolve the team and read its entitlement, count what the next call would cost, refuse at the check when the counter is past the ceiling, and warn the owner before it is. The refusal names the limit and the plan behind it, the alerts fire at 80 and 100 percent, and the client, not the gateway, is what makes a denial final.
Key takeaways
- The ceiling belongs to the team, not to the key. Resolve the team first and read one value, so every key, agent and MCP server under it shares the same budget.
- Four pieces: entitlement, counter, decision, notification. Remove any one and the other three describe a limit nobody ever hits.
- A retryable 429 and a quota refusal are different answers. One forgives a retry storm a minute later; the other accumulates it against the month.
- Refuse with a sentence a human can act on. Name the limit, the plan it came from and the remedy, or the reader learns nothing from the refusal.
- Warn at 80 and 100 percent, and store the preference per team. The threshold is a two-value choice, not a free slider.
- Do this first: put the check in front of one team's agent loop, read its audit rows for a week, then make the answer authoritative for everyone else.
What is an AI gateway: four pieces that make a quota real
# dashboard-calibration/dashboard_calibration/guards.py — source lines 22–35 (assert_calibration_team)
def assert_calibration_team(
*,
team_name: str,
account_kind: str,
force: bool,
) -> None:
if force:
return
if team_name != CALIBRATION_TEAM_NAME:
print(f"Refusing: team_name={team_name!r}", file=sys.stderr)
raise SystemExit(2)
if account_kind != "INTERNAL_TEST":
print(f"Refusing: account_kind={account_kind!r}", file=sys.stderr)
raise SystemExit(2)
An AI gateway is the layer that sees a request while it still knows which team sent it — and that is why a token quota can be enforced there and almost nowhere else. The decision needs four things at once, and a missing piece is not a smaller quota, it is a quota nobody can enforce:
| Piece | What it decides | Where it has to live |
|---|---|---|
| Entitlement | the ceiling this team may spend this month | one row, read on every check |
| Counter | what the next call would cost | in front of the model and tool calls |
| Decision | allow, or refuse and name the binding constraint | at the API boundary |
| Notification | who hears about it at 80 percent, and who at 100 | a per-team preference |
The excerpt is the fourth piece's sibling discipline — a guard, not a counter. It refuses to proceed
unless the team name and account kind match the calibration tenant, prints the offending value to
standard error, and exits with code 2; force is the one documented override. Four properties make
it worth copying into a quota path: it fails closed, it names the offending value instead of a
generic refusal, its single override is a parameter rather than an environment variable someone sets
once, and it is testable without a production tenant.
Per-service checks fail structurally: a service sees its own calls, nobody sees the team's total. Ten services with a 100k-token limit are a 1M-token team with ten ways to be surprised, and the first honest number arrives on the invoice.
Why a 429 is not a quota: retry storms, windows and the month
Rate limits and quotas are both refusals, and treating them as the same refusal is the most common way a per-team ceiling stops working. A rate limiter answers too many requests (HTTP 429) and means come back later: the window rolls, the client backs off, the request succeeds. A quota answers this team has spent its month and means come back when the limit changes. A correct client retries the first and must not retry the second.
That difference decides three implementation choices.
- Make the two refusals distinguishable in the body, not only in the status code. If both are a bare 429, every client library in your fleet applies backoff to a monthly ceiling — burning the accounting path with traffic that cannot succeed.
- Count retries once, not once per attempt. Twenty retries of one failed tool call are invisible to a per-minute limiter and expensive against a monthly counter; count where the spend is committed, and let the retry carry the original's idempotency key.
- Enforce where the identity already exists — the layer that resolved the team from the request, not a client library that can be uninstalled.
A monthly number also has a boundary problem not a client library that can be uninstalled.
The rate-limiting half of that pair - per-minute windows, burst absorption and the queues behind them - is worked through in concurrency control in AI backend systems.
A monthly number also has a boundary problem a per-minute window never has: decide whether the period is a calendar month, a rolling 30 days or the billing cycle, write that decision next to the counter, and make the dashboard read the same boundary. Two definitions of "this month" is how a check and a chart disagree by a month's worth of tokens and both look right.
AI agent memory: the state a quota check has to keep
# lib/settings/quota-email-prefs.ts — source lines 3–10 (quotaEmailModeFromPrefs)
function quotaEmailModeFromPrefs(
q80: boolean,
q100: boolean,
): QuotaEmailMode {
if (!q80 && !q100) return "off";
if (!q80 && q100) return "limit_only";
return "both";
}
A per-team quota is three pieces of state, and only one of them is interesting:
- the entitlement — the ceiling, in the team's own row, so the check never consults a console;
- the counter — tokens committed in the current period, keyed by team and period;
- the preference — whether the owner is warned at 80 percent, at 100 percent, or never.
The excerpt is the third piece, and it is two functions doing the same job in opposite directions: a
stored mode (off, limit_only, both) projected into two booleans, and those booleans collapsed
back into the mode. Two decisions are worth stealing. The stored form is one small enum rather than
two free flags, so limit only and both cannot drift apart. And the fallback is the loudest option:
an unrecognised mode warns at both thresholds rather than silently disabling notifications.
Where the state lives matters more than its shape. Keep the counter server-side, keyed by team and period: an agent that counts its own tokens is an estimate from a party with an interest in the answer, and it loses the count on restart, so an agent's session memory and the quota counter are different objects with different lifetimes. Keep the ceiling on the team, not the key — a per-key limit multiplies the budget by however many keys exist, which is the number you were trying to bound. And expect the counter store to be the first suspect when a team says a refusal looks wrong: an unreachable or reset counter is how a ceiling quietly becomes advice.
Sizing that counter, and deciding what may be cached in front of it, is part of the wider cost design worked through in Build a Low-Cost AI Backend Architecture.
MCP server tutorial: read the team's caps before the first call
# app/api/settings/team/gateway-policy/route.ts — source lines 22–41 (teamCaps)
async function teamCaps(teamId: string) {
const team = await prisma.team.findUniqueOrThrow({
where: { id: teamId },
select: {
plan: true,
features: true,
billingExempt: true,
accountKind: true,
},
});
const features = team.features as Record<string, unknown> | null;
return {
team,
caps: resolvePlanCapabilities(
team.plan,
features,
isBillingExemptTeam(team),
),
};
}
Four steps, in this order, and the excerpt is the second one.
- Get the team id from the request. In an MCP or API request it comes from the authenticated credential; in a dashboard request it comes from the session's team cookie. Either way it is resolved, not accepted from a body field.
- Read the plan, the feature overrides and the account kind in one query. The excerpt selects four columns and asks for the row that must exist — a lookup that can return nothing and be read as "no cap" is the failure mode to avoid.
- Resolve the capabilities the plan grants. The plan is the default;
featuresis a JSON column that can override it, which is how one team gets an early-access capability or a raised ceiling without a code change; and a billing-exempt tenant resolves through the same function, so the rest of the code never special-cases it. - Hand back the team and its caps, and let the check compare. The entitlement read returns data; it does not decide. Keeping the two apart is what lets you test the entitlement layer against the plan table without spending a token.
The step worth rehearsing is the third: the caps object is test data. Resolve the caps for one team and compare them with what the pricing page says that plan includes. A mismatch there — a feature flag that quietly raises a ceiling, an account kind that skips billing — is a quota that will behave differently from the one you drew on the whiteboard.
What is RAG architecture: where the check sits in the request path
# dashboard-calibration/dashboard_calibration/team_lookup.py — source lines 11–37 (resolve_team)
async def resolve_team(*, team_id: str | None = None) -> dict[str, Any]:
conn = await asyncpg.connect(resolve_database_url())
try:
if team_id:
row = await conn.fetchrow(
"""
SELECT id, name, plan::text AS plan, account_kind::text AS account_kind
FROM teams WHERE id = $1
""",
team_id,
)
else:
row = await conn.fetchrow(
"""
SELECT id, name, plan::text AS plan, account_kind::text AS account_kind
FROM teams WHERE name = $1
""",
CALIBRATION_TEAM_NAME,
)
if row is None:
raise SystemExit(
"Calibration team not found. "
"Run: pnpm tsx dashboard-calibration/seed/create-tenant.ts"
)
return dict(row)
finally:
await conn.close()
A retrieval-augmented loop is the most token-hungry shape in most agent stacks: embed the query, retrieve, stuff the context into the model call. Every step bills the same team, which is why the check sits where identity is resolved once and inherited by everything downstream.
The excerpt is that resolution written plainly: a team looked up by id, or by name when no id is given, selecting four columns — id, name, plan, account kind. Two properties transfer to a request path. The read is one query, so it can sit in front of every model call without becoming the latency budget. And the miss is a hard stop with the remedy in the message: the function refuses with the command that seeds the tenant rather than returning an empty result that a later comparison reads as "unlimited".
Two rules follow for the hot path. Resolve from the credential that carried the request, never from the body — a client that can name its own team can name a cheaper one, so per-request overrides belong behind an admin role and in an audit row when they are used. And cache the resolution for the life of one request, not the process, because a process-level cache turns a plan change into a stale quota no operator can explain.
Retrieval is what makes spend visible; the levers on the spend itself are capping what it pulls in and compressing the context before the model call, plus token optimization techniques for AI apps. This page is what makes either lever survive a real team.
Model Context Protocol registry: metering every tool call
# app/[locale]/(marketing)/features/token-control/page.tsx — source lines 13–15 (TokenControlPage)
function TokenControlPage() {
return <FeaturePageLayout config={config} />;
}
The operator surface is where a quota stops being an abstraction, and the excerpt is the pattern to copy: the page is one component that renders a shared feature layout from a configuration object. The controls — limits, retention, alert preferences — are data; the screen is one renderer. When the quota rules change, the diff is a config change rather than a component.
That surface has to agree with the meter, and an MCP-native gateway has two billable paths rather than one. Model calls are the obvious one. The other is the tool call: a client asks for a tool by name from the server's registry — fetch, search, context gate, dedup, memory — and each of those calls runs work that consumes the same team budget. A tool that fans out into several internal steps is still one unit of work with several counted steps, and a client that retries a failed tool call is making the same request twice against the same counter.
Two checks keep the surface and the meter from diverging. Same table, same period, same timezone: if the dashboard aggregates usage rows while the check reads a counter, they will disagree by exactly the amount you cannot explain in a review. And show the constraint, not just the percentage — "92 percent of the team's monthly tokens" is actionable where "quota" is not.
The protocol side of that surface is deliberately boring, which is the point: MCP defines the message shape and the server registry, not the budget — whatever a client discovers through the protocol, the gateway still has to attribute the call to a team and count it.
Cloudflare AI Gateway pricing: what a plan cap covers
# lib/webhooks/quota.ts — source lines 21–23 (webhookQuotaExceededMessage)
function webhookQuotaExceededMessage(err: ExceedsPlanLimitError): string {
return `Webhook limit reached (${err.max} on ${err.plan}). Upgrade your plan to add more endpoints.`;
}
When a cap is reached the caller gets a sentence, and the whole function fits in the excerpt above: the limit was reached, here is the maximum, here is the plan, here is the remedy. Three things it does right — it names the number, so the reader can tell a rounding difference from a real overrun; it names the plan, so "upgrade" is checkable against a pricing page; and it states the remedy in the same breath as the refusal.
Managed gateways put their own version of that sentence in front of you: Cloudflare documents rate limiting for AI Gateway, while what a platform charges and what its plan caps include is a question for that platform's own pricing page. A plan cap is a commercial boundary, not a per-team budget, and the two are expensive to conflate:
| A plan cap on a platform | A per-team token quota | |
|---|---|---|
| Who it belongs to | your account with the provider | one team inside your organisation |
| What it protects | the vendor's capacity and your bill | one team's share of a shared budget |
| Where it is enforced | the provider's edge | your gateway, where the team is known |
| Who is told | the account owner | the team's owner, at 80 and 100 percent |
The message rule the excerpt encodes applies to all of them: keep one error class and one sentence per limit. A key limit, a webhook limit and a monthly token quota are three different refusals, and a shared "you have reached a limit, upgrade" tells two of those three callers something false.
A webhook that refuses on a quota has to be distinguishable from one that failed, and the plumbing around that decision - queues, deduplication and the retry posture - is what AI automation for SaaS operations covers.
AI gateway on Databricks: where the team's cap is read
# lib/settings/quota-email-prefs.ts — source lines 12–24 (prefsFromQuotaEmailMode)
function prefsFromQuotaEmailMode(mode: QuotaEmailMode): {
quota_email_80: boolean;
quota_email_100: boolean;
} {
switch (mode) {
case "off":
return { quota_email_80: false, quota_email_100: false };
case "limit_only":
return { quota_email_80: false, quota_email_100: true };
default:
return { quota_email_80: true, quota_email_100: true };
}
}
Platforms that ship governance for model traffic are another route to the same ground. Databricks documents its Unity Gateway — the governance layer built on Unity Catalog — as applying rate limits that "enforce consumption limits on model services and MCP services", configuring traffic splitting across providers, and managing budgets with per-user thresholds and hard caps. That answers what bounds consumption on their side of the boundary, not which of my teams may spend what this month — that entitlement is your organisation's, not the platform's account's.
The excerpt is the pattern for bridging the two, and it is deliberately small: one stored mode and two booleans, each deriving the other. Whatever a console owns, project it into a row your check can read — the cap, the period, where the value came from, and when it was last synchronised. The failure this prevents is quiet and specific: if the only copy of the number lives in a vendor console, a renamed budget, a changed plan or a console outage silently changes the ceiling, and your check cannot tell "under the limit" from "we could not find out".
Accounting and audit: the rows a quota decision leaves behind
Enforcement without a record is a guess with a status code. Every metered call should write two kinds of row, and they are different rows:
- a usage row — team, key, model or tool, tokens counted, period, and the decision that followed;
- a decision row — the refusal, the constraint that bound, and the limit in force at the time.
The second is the one teams skip, and it is the only evidence that a ceiling works: a refused call costs nothing, so it produces no usage, and without a decision row a month of working enforcement looks exactly like a month in which nobody tried. Write both, from the same place, with the same team id the check resolved.
Two properties make the record usable rather than merely present. Reconcile the counter against the rows — sum the usage rows per team monthly and compare with the counter the check reads, because a drifting counter is invisible until a team is refused a call it can afford or allowed one it cannot. And treat retention as a storage decision: seven, thirty, ninety or one hundred and eighty days depending on plan is also how long any prompt text your counter recorded stays in the table, so decide explicitly whether that field belongs there.
Both row types are also what a monitoring layer reads, and the team id, the period and the decision a refusal left behind are the dimensions an LLM observability view aggregates over. Read on their own, the usage rows are the per-team history behind a dashboard's ai token usage figure, which is why they have to carry the same team id and period the meter counts under.
What you could do instead of a gateway quota
Most teams already run one of these layers. The question is which one knows the team and the headroom before the call runs.
| What it bounds | Where the limit lives | What it leaves to you | |
|---|---|---|---|
| Per-service checks | whatever each service remembers | scattered across services | the shared record; one key or one loop drains the budget |
| Provider limits (OpenAI, Anthropic, Bedrock) | tokens per minute or per day, per account | in the provider's console | team-level slicing; a rolling window forgives retries |
| Edge quota policies (Apigee LLMTokenQuota, Traefik Hub) | tokens per key inside a reset window | in gateway configuration | a monthly per-team entitlement, denial semantics, a decision row |
| Hosted governance (Databricks Unity Gateway, Cloudflare AI Gateway) | consumption per service, per user or per account | in the platform's console | your own team entitlement as the system of record |
| A gateway with per-team entitlement and audit (SmartGate) | one team's monthly tokens, checked before the call and counted after it | one value in the team's row, read on every check | what a blocked caller does: the endpoint reports, the client enforces |
Pricing is not per token: Free is $0 with 2M tokens a month and 7 days of retention, Pro starts at $18 with 20M tokens and 30 days, Teams at $55 with a 100M pool and 90 days, Enterprise is contract with a 180-day window, all on the pricing page. Billing is pay for the platform, share only when you save: the share starts after $15 of measured savings and is capped near $36 a month on Pro.
For the reader who owns the bill rather than the enforcement, the same figures are framed as spend reporting, alerts and a per-tool breakdown in the FinOps lead view, and the unit arithmetic underneath any of those numbers — which tokens are billed at which rate, and what that makes one request cost — is worked through in AI token cost. The rates themselves sit one layer below that arithmetic: how inference cost builds up across models, and how to read an ai model pricing table without mistaking a list price for a bill.
How to get started
- Write the ceiling down per team. One column, one reader, one deliberate fallback — and a decision about whether an unreachable store fails open or closed.
- Count before you call, in the tokenizer family you deploy, and treat the provider's reported usage as the record of truth.
- Refuse at the boundary and make the refusal legible: one body naming the constraint, the limit and the plan, and a client that treats it as final rather than retryable.
- Keep the counter on the team, not on the key, or the number of keys silently becomes the multiplier on the budget.
- Alert at 80 and 100, and store the preference. A warning nobody wanted is noise; a refusal nobody was told about is an incident.
- Write the decision row from day one. You cannot tell working enforcement from no enforcement without it.
Start on the free tier — 2M tokens a month, the full MCP tool surface (smart_fetch, smart_search,
smart_context_gate, smart_dedup, smart_budget_guard, smart_memory, smart_pipe) and no card:
start free. The tool surface is in the
docs, the quota controls on
Token Control, and the plan limits on the
pricing page.
Frequently Asked Questions
Is a monthly token quota the same thing as a rate limit?
No. A rate limit bounds tokens or requests per minute and forgives the excess when the window rolls; a monthly quota accumulates it. Retry the first, never the second.
Where should the check run?
Wherever the request already knows which team sent it — the gateway, in front of the model and tool calls. A check that cannot attribute a call to a team is a global counter with a nicer name.
What should a refusal say?
The constraint that bound, the limit in force and the plan it came from. The webhook excerpt above names the maximum and the plan so the reader can check the pricing page unaided.
Do we need the warning at 80 percent if we have the hard stop at 100?
Yes, and they are different mechanisms. The alert changes behaviour while budget remains; the refusal is a wall. The 100 percent message tells an owner why their agents stopped.
What if a team has no entitlement stored?
Decide it explicitly. Either refuse calls until an operator sets a ceiling or apply a documented default — but write down which one, because a fallback generous enough to be invisible is also generous enough to be the incident.
Limitations and what this does not do
- This page is enforcement, not spend reduction. Nothing here compresses a prompt, routes to a cheaper model or caches a response; those are the companion pages linked above.
- The snippets are excerpts, not a complete quota system. The counter, the period boundary and the rule about counting retries once are described rather than quoted, because the pinned slices cover the entitlement, identity, notification and refusal surfaces.
- One planned section has no code excerpt — the
databricks unity ai gatewaysection is written from the vendor's own documentation instead of cut from a pinned symbol. - Not a billing system. A quota enforces a ceiling and records what happened; it does not prorate, refund or invoice, and any dollar figure derived from it is an estimate.
- Counting is an estimate. A local tokenizer close to the deployed family is the right shape for a pre-flight check, not for an invoice; the provider's usage report is the authority.
- The record is only as good as the decision row. Without it, the claims above hold for one month and cannot be reproduced for the next.
Enforcement and reduction are complements: once the ceiling is honest, the next win is to optimize ai agent execution cost so fewer calls reach it.
Sources
- Microsoft Foundry — Enforce token limits for models: https://learn.microsoft.com/en-us/azure/foundry/control-plane/how-to-enforce-limits-models
- Google Cloud Apigee — LLMTokenQuota policy: https://docs.cloud.google.com/apigee/docs/api-platform/reference/policies/llm-token-quota-policy
- Amazon Bedrock — Quotas: https://docs.aws.amazon.com/bedrock/latest/userguide/quotas.html
- Traefik Hub — Token Rate Limit and Quota: https://doc.traefik.io/traefik-hub/ai-gateway/middlewares/token-rate-limit
- Cloudflare — AI Gateway rate limiting: https://developers.cloudflare.com/ai-gateway/features/rate-limiting/
- Databricks — AI governance with Unity Gateway: https://docs.databricks.com/aws/en/ai-gateway/
- OpenAI — Rate limits: https://platform.openai.com/docs/guides/rate-limits
- Anthropic — Rate limits: https://docs.anthropic.com/en/api/rate-limits
- Model Context Protocol — specification: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization
- MDN — HTTP 429 Too Many Requests: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Status/429
- SmartGate — docs, pricing, token control: https://smartgate.network/docs · https://smartgate.network/pricing · https://smartgate.network/features/token-control