diff --git a/src/lib/config/concepts-nav.ts b/src/lib/config/concepts-nav.ts index 9201155..8abd222 100644 --- a/src/lib/config/concepts-nav.ts +++ b/src/lib/config/concepts-nav.ts @@ -36,6 +36,10 @@ export const conceptsNav: DocsNavSection[] = [ items: [ { label: 'Circuit Breakers', href: `${base}/concepts/reliability/circuit-breakers` }, { label: 'Rate Limiting', href: `${base}/concepts/reliability/rate-limiting` }, + { + label: 'Tenant Rate Limiting', + href: `${base}/concepts/reliability/tenant-rate-limiting` + }, { label: 'Priority Scheduling', href: `${base}/concepts/reliability/priority-scheduling` }, { label: 'Retries', href: `${base}/concepts/reliability/retries` }, { label: 'Health Checks', href: `${base}/concepts/reliability/health-checks` }, diff --git a/src/lib/config/reference-nav.ts b/src/lib/config/reference-nav.ts index d3d71b7..f117826 100644 --- a/src/lib/config/reference-nav.ts +++ b/src/lib/config/reference-nav.ts @@ -17,6 +17,7 @@ export const referenceNav: DocsNavSection[] = [ label: 'Guides', items: [ { label: 'Configuration', href: `${base}/reference/configuration` }, + { label: 'Tenant Rate Limiting', href: `${base}/reference/tenant-rate-limiting` }, { label: 'Priority Scheduler', href: `${base}/reference/priority-scheduler` }, { label: 'Metrics', href: `${base}/reference/metrics` }, { label: 'Internal MCP Servers', href: `${base}/reference/mcp-internal-servers` } diff --git a/src/lib/content/concepts/reliability/index.md b/src/lib/content/concepts/reliability/index.md index 99976e8..83b0bb2 100644 --- a/src/lib/content/concepts/reliability/index.md +++ b/src/lib/content/concepts/reliability/index.md @@ -7,6 +7,7 @@ SMG provides several mechanisms to ensure high availability and stability. * **[Circuit Breakers](./circuit-breakers.md)**: Automatically detect failing workers and temporarily stop sending traffic to them until they recover. * **[Retries & Backoff](./retries.md)**: Configurable retry logic with exponential backoff to handle transient network issues or worker busy states. * **[Rate Limiting](./rate-limiting.md)**: Protect your workers from being overwhelmed by controlling the concurrency and request rate. +* **[Tenant Rate Limiting](./tenant-rate-limiting.md)**: Cap per-tenant LLM token and request consumption per minute, independent of worker concurrency. * **[Priority Scheduling](./priority-scheduling.md)**: Admit higher-priority traffic first with reserved slots, per-class queues, and TTFT-aware preemption. * **[Health Checks](./health-checks.md)**: Active and passive monitoring of worker health to remove unhealthy nodes from the rotation. * **[Graceful Shutdown](./graceful-shutdown.md)**: Ensure in-flight requests complete before the server stops. diff --git a/src/lib/content/concepts/reliability/priority-scheduling.md b/src/lib/content/concepts/reliability/priority-scheduling.md index b0954fb..85a6566 100644 --- a/src/lib/content/concepts/reliability/priority-scheduling.md +++ b/src/lib/content/concepts/reliability/priority-scheduling.md @@ -150,6 +150,16 @@ The legacy concurrency-limit path the scheduler falls back to.
+### :material-swap-horizontal: Tenant Rate Limiting + +Another opt-in, per-tenant-policy layer — this one meters tokens instead of ordering by class. + +[Tenant Rate Limiting →](tenant-rate-limiting.md) + +
+ +
+ ### :material-electric-switch: Circuit Breakers Isolate failing workers to prevent cascade failures. diff --git a/src/lib/content/concepts/reliability/rate-limiting.md b/src/lib/content/concepts/reliability/rate-limiting.md index 8cd6aae..283b98e 100644 --- a/src/lib/content/concepts/reliability/rate-limiting.md +++ b/src/lib/content/concepts/reliability/rate-limiting.md @@ -319,6 +319,16 @@ class AdaptiveClient:
+### :material-swap-horizontal: Tenant Rate Limiting + +Cap per-tenant LLM token and request consumption per minute — a different axis than this page's worker concurrency limits. + +[Tenant Rate Limiting →](tenant-rate-limiting.md) + +
+ +
+ ### :material-electric-switch: Circuit Breakers Isolate failing workers to prevent cascade failures. diff --git a/src/lib/content/concepts/reliability/tenant-rate-limiting.md b/src/lib/content/concepts/reliability/tenant-rate-limiting.md new file mode 100644 index 0000000..3f097c5 --- /dev/null +++ b/src/lib/content/concepts/reliability/tenant-rate-limiting.md @@ -0,0 +1,147 @@ +--- +title: Tenant Rate Limiting +--- + +# Tenant Rate Limiting + +[Rate Limiting](rate-limiting.md) protects **workers** from too much concurrent traffic. Tenant rate limiting protects **budgets**: it caps how many LLM tokens and requests a given tenant can consume per minute, independent of how busy the workers are. The two are orthogonal and can run together — a request can be admitted by the concurrency limiter and still be denied because its tenant is over budget, or vice versa. + +Think of it like reserving a hotel room online: the site holds the room — and an estimated price — the moment you book, before you have stayed a single night. When you check out, the front desk settles the bill against what you actually used: extra nights cost more, an early checkout refunds the difference. If you never check in at all, the hold quietly expires without ever being charged. Tenant rate limiting works the same way: SMG reserves an estimated number of tokens against a tenant's budget *before* it ever contacts a worker, then settles that reservation against the real, backend-reported token count once the response is known. + +--- + +## Why token-based, not just concurrency-based? + +The existing [concurrency limiter](rate-limiting.md) answers "how many requests can be in flight at once?" — a good proxy for protecting GPU memory, but a poor proxy for *cost*. A tenant sending one request with a 100,000-token prompt and a tenant sending a hundred one-line chat messages can look identical to a concurrency limiter while being wildly different in actual LLM spend. Tenant rate limiting answers a different question: "how many tokens (and requests) has this tenant actually consumed this minute?" — measured in the unit that maps to real inference cost, not connection count. + +--- + +## Reserve, then settle + +The core mechanic is a two-phase commit against a tenant's budget: + +1. **Reserve.** Before dispatching to a worker, the gateway estimates the request's input token count (from its own tokenizer) and debits that estimate from the tenant's bucket. If the bucket cannot afford it, the request is denied immediately — no worker is ever contacted. +2. **Settle.** Once the response is known — the backend's real, reported `prompt_tokens` plus `completion_tokens` — the gateway trues up the reservation: the difference between the estimate and the real total is applied as a signed delta. A response that used *more* than estimated pushes the bucket further down (even temporarily negative, i.e. into debt); a response that used less refunds the difference. + +Settling always uses the backend's own reported usage, never the gateway's own estimate — the estimate only exists to gate admission before any real cost has been incurred. + +!!! note "Two independent counters, reserved together" + Each tenant (and optional per-model rule, see below) tracks **tokens per minute** and **requests per minute** as two separate counters. A reservation debits both — the estimated tokens from the token counter, a flat `1` from the request counter — and is denied if *either* counter can't afford it. Only the token counter is trued up at settle time; the request counter's flat debit never changes. + +### What happens if a response never completes? + +Not every reservation makes it to settlement — a request can fail before dispatch even happens, get preempted, or have its client disconnect mid-stream. Every one of these paths is covered so a reservation never leaks the tenant's budget forever, and never gets resolved twice: + +- **A non-2xx final response** (retries exhausted, a request that fails before ever reaching a worker) closes the reservation, keeping the reserved estimate as the final charge — there is no better number to true up against. +- **Preemption or cancellation** before any response exists is caught by the reservation's own RAII cleanup: if nothing else has resolved it by the time its holder is dropped, it self-abandons (keeping the reserved estimate) rather than leaking. +- **A streaming client that disconnects mid-response** is caught the same way — the reservation is attached to the response body's lifetime, so its cleanup fires when the body is dropped, whether that's a clean end-of-stream or a client hang-up. +- **A streaming response that reaches a clean end-of-stream without ever reporting authoritative usage** (no `Complete` frame from the backend) settles as "no better number available" rather than truing up to zero — a zero-usage settle would incorrectly refund the entire reservation for a request that plainly did generate output. + +In every case, resolution is idempotent: whichever of settle, close, or abandon reaches a given reservation first wins, and every path after that is a safe no-op. + +--- + +## Reserved once, even across retries + +SMG's gRPC pipeline retries a failed dispatch (a worker timeout, a `5xx`) by rerunning the *entire* pipeline for that attempt — including tokenization and worker selection. Naively, that would mean re-reserving tokens on every retry attempt for what is, from the tenant's point of view, a single logical request. + +Instead, the reservation is made **once**, by whichever attempt reaches the reserve step first, and cached for the lifetime of that logical request. Every later retry attempt sees the cached outcome and skips straight through — no repeat reservation, no repeat denial check against the backend. A rate-limit denial is also never itself treated as retryable: retrying immediately against the same exhausted budget would just defeat the wait time the gateway already told the client about. + +The model a reservation is scoped to is pinned the same way, for the same reason: retries dispatch against the exact canonical model the first attempt resolved, even if the underlying alias mapping changes mid-retry. Without that, a request could be reserved against one model's budget and settled against another's. + +--- + +## Tenant and per-model policy + +A policy is **tenant-global limits**, plus optional **per-model rules** layered on top: + +- Every tenant gets a `tokens_per_minute` / `requests_per_minute` pair — either from an explicit entry keyed by that tenant, or from the config's `default_policy` if it has none. +- A tenant can additionally define per-model rules, each matching an exact model ID or a prefix, with their own `tokens_per_minute` / `requests_per_minute`. +- At most **one** model rule applies per request — an exact match wins over a prefix match, and the longest prefix wins among competing prefixes. Rules never stack with each other. +- When a model rule does apply, a reservation debits **both** the tenant-global bucket and the matching rule's bucket, and is only admitted if both can afford it. + +Tenant identity uses the same tenant key SMG already resolves elsewhere in the request path (`auth:`, `header:`, `ip:
`, or `anonymous`) — there is no separate identity system to configure. + +See the [reference page](../../reference/tenant-rate-limiting.md) for the exact YAML shape and validation rules. + +--- + +## `n>1` and streaming are counted correctly + +A few accounting details that were specifically fixed to avoid over- or under-charging a tenant: + +- **A shared prompt is charged once, not per choice.** When a request asks for `n>1` completions, every choice shares the same input prompt. The reservation — and the settled usage — uses the *maximum* reported prompt (and cached-token) count across choices, not the sum; only completion tokens, which really are distinct per choice, are summed. +- **A streaming response settles only once every expected choice has actually finished.** A clean end-of-stream partway through an `n>1` request (some choices completed, others didn't) is not treated as full, trustworthy usage — it closes the reservation at the estimate instead of settling with an understated real count. + +--- + +## Fail-open by design + +Two situations are deliberately handled by *not* enforcing the limit, rather than by blocking traffic: + +- **Startup:** an unparsable or invalid rate-limit YAML is logged at `ERROR` and the gateway starts anyway, without a rate limiter — a broken config file must never take the data plane down. +- **Missing tenant identity:** if a request somehow reaches the reserve stage without a resolved tenant identity (it shouldn't, once tenant-resolution middleware is wired), the gateway logs a warning and skips reservation rather than blocking the request on missing context. + +--- + +## Response codes + +A denied reservation returns **429** with the gateway's standard JSON error envelope (`X-SMG-Error-Code: tenant_rate_limit_exceeded`). When the wait is finite, the response also carries `Retry-After: `. When the request's estimated cost exceeds the tenant's *total* capacity — meaning it could never be admitted no matter how long the client waits — `Retry-After` is omitted rather than sent as an effectively-infinite number. + +--- + +## Current scope + +Tenant rate limiting is wired into SMG's **gRPC router only**, covering the Chat, Generate, Completion, and Messages endpoints (Harmony-mode chat is covered too — it shares the same entry point as regular chat). It is **not yet wired into**: the Responses endpoint, embeddings, classify, audio transcriptions, or any of the HTTP-passthrough / external-provider routers. + +Enforcement is also **per gateway instance**: a tenant's true limit across several independent SMG instances is roughly the configured value multiplied by the instance count. A distributed backend for exact cluster-wide enforcement is a possible future extension behind the same interface, not something this version provides. + +There are no Prometheus metrics for tenant rate-limit decisions yet — admissions, denials, and settlement deltas aren't currently observable beyond request-level logging and the `429` responses themselves. + +--- + +## What's Next? + +
+ +
+ +### :material-file-document-outline: Tenant Rate Limiting Reference + +CLI flags, the full YAML schema, and exact response codes. + +[Tenant Rate Limiting Reference →](../../reference/tenant-rate-limiting.md) + +
+ +
+ +### :material-tray-full: Rate Limiting + +The concurrency-based limiter this feature complements, not replaces. + +[Rate Limiting →](rate-limiting.md) + +
+ +
+ +### :material-priority-high: Priority Scheduling + +Another opt-in, per-tenant-policy admission layer — this one orders requests by class instead of metering tokens. + +[Priority Scheduling →](priority-scheduling.md) + +
+ +
+ +### :material-refresh: Retries + +How the gRPC pipeline retries a failed dispatch — the mechanism tenant rate limiting has to stay correct across. + +[Retries →](retries.md) + +
+ +
diff --git a/src/lib/content/reference/tenant-rate-limiting.md b/src/lib/content/reference/tenant-rate-limiting.md new file mode 100644 index 0000000..a92d810 --- /dev/null +++ b/src/lib/content/reference/tenant-rate-limiting.md @@ -0,0 +1,178 @@ +--- +title: Tenant Rate Limiting Reference +--- + +# Tenant Rate Limiting Reference + +Precise contract for per-tenant token/request rate limiting: every configuration knob, the YAML policy schema, and the exact response shape. For how it works conceptually — the reserve/settle model, retry handling, `n>1` accounting — see [Tenant Rate Limiting](../concepts/reliability/tenant-rate-limiting.md). + +Tenant rate limiting is **disabled by default** and, when enabled, only enforced on the **gRPC router's** Chat, Generate, Completion, and Messages endpoints (Harmony-mode chat included). It does not apply to the Responses endpoint, embeddings, classify, audio transcriptions, or the HTTP/external-provider routers. + +--- + +## Enabling it + +```bash +smg \ + --worker-urls grpc://w1:9000 grpc://w2:9000 \ + --tenant-rate-limit-enabled \ + --tenant-rate-limit-config /etc/smg/tenant-rate-limit.yaml +``` + +### CLI flags + +| Flag | Default | Description | +|------|---------|--------------| +| `--tenant-rate-limit-enabled` | `false` | Master switch. When unset, no rate limiter is constructed and every request skips reservation entirely. | +| `--tenant-rate-limit-config` | unset | Path to the tenant-rate-limit YAML. Required when `--tenant-rate-limit-enabled` is set. | + +!!! warning "Fail-safe startup" + If the config path is missing, unreadable, or fails validation, the gateway logs the failure at `ERROR` and starts **without** a rate limiter rather than aborting — a broken policy file must never take the data plane down. Every request then behaves exactly as it did before this feature existed. + +--- + +## YAML configuration + +```yaml +default_policy: + tokens_per_minute: 100000 + requests_per_minute: 600 + +tenants: + - tenant_key: "auth:team-red" + tokens_per_minute: 500000 + requests_per_minute: 3000 + model_rules: + - rule_id: gpt4-cap + matcher: + type: exact + value: gpt-4 + tokens_per_minute: 50000 + requests_per_minute: 300 + - rule_id: legacy-models + matcher: + type: prefix + value: "legacy-" + tokens_per_minute: 10000 + requests_per_minute: 100 + + - tenant_key: "anonymous" + tokens_per_minute: 5000 + requests_per_minute: 60 +``` + +A tenant not listed under `tenants` uses `default_policy`. Tenant keys are the same canonical keys SMG resolves elsewhere in the request path — `auth:`, `header:`, `ip:
`, or `anonymous` — there's no separate tenant-identity system for this feature. + +### `default_policy` / `tenants[]` fields + +| Field | Type | Meaning | +|-------|------|---------| +| `tenant_key` | string | Canonical tenant key. Must be **absent** on `default_policy` and **present** on every entry under `tenants`. | +| `tokens_per_minute` | integer, `> 0` | Token-bucket capacity *and* full-minute refill rate for this scope. The bucket starts full, so the first request(s) can burst up to this value immediately. | +| `requests_per_minute` | integer, `> 0` | Same shape as `tokens_per_minute`, but for request count. Each admitted reservation debits exactly `1`, regardless of how many tokens it used. | +| `model_rules` | list, optional | Per-model overrides layered on top of this scope's own limits. See below. | + +### `model_rules[]` fields + +| Field | Type | Meaning | +|-------|------|---------| +| `rule_id` | string, `[A-Za-z0-9._-]+` | Stable identifier, unique within the tenant (or `default_policy`). | +| `matcher.type` | `exact` \| `prefix` | How `matcher.value` is compared against the request's model ID. | +| `matcher.value` | string | The model ID (`exact`) or model ID prefix (`prefix`) this rule applies to. No surrounding whitespace. | +| `tokens_per_minute` | integer, `> 0` | Independent token bucket for this rule. | +| `requests_per_minute` | integer, `> 0` | Independent request bucket for this rule. | + +**At most one model rule applies per request.** An exact match wins over any prefix match; the longest matching prefix wins among competing prefixes. Rules never stack with each other — only with the tenant-global limits. When a rule applies, a reservation must be affordable in **both** the tenant-global scope and the rule's scope, or it's denied. + +### Validation + +Checked once, at load, before the gateway ever serves traffic on this config: + +- `default_policy` must **not** set `tenant_key`; every entry under `tenants` **must**. +- Tenant keys must be non-empty, have no surrounding whitespace, be unique across `tenants`, and be a canonical serving-path tenant key (`auth:`, `header:`, `ip:`-prefixed, or exactly `anonymous`) — a bare ID copy-pasted without its prefix is rejected rather than silently never matching. +- `tokens_per_minute` and `requests_per_minute` must be `> 0`, on every scope (`default_policy`, each tenant, each model rule). +- `rule_id` must match `[A-Za-z0-9._-]+`, and be unique within its tenant (or `default_policy`). +- `matcher.value` must be non-empty and have no surrounding whitespace. +- No two rules within the same tenant may share the same `exact` value, or the same `prefix` value (an `exact` and a `prefix` rule *may* share the same literal string — they're different match kinds). +- Unknown YAML fields anywhere in the document are rejected rather than silently ignored (so a typo like `tenant:` instead of `tenants:` fails loudly instead of compiling to an empty override list). + +Any validation failure is the same as an unparsable file: logged at `ERROR`, gateway starts without a rate limiter. + +--- + +## Response codes + +| Status | Condition | `X-SMG-Error-Code` | Extra headers | +|--------|-----------|---------------------|----------------| +| **429** Too Many Requests | Reservation denied — the tenant (or matching model rule) doesn't have enough budget right now | `tenant_rate_limit_exceeded` | `Retry-After: ` — present only for a finite wait | + +The response body is the gateway's standard JSON error envelope: + +```json +{ + "error": { + "type": "Too Many Requests", + "code": "tenant_rate_limit_exceeded", + "message": "Tenant rate limit exceeded for this request", + "param": null + } +} +``` + +!!! tip "When `Retry-After` is absent" + If the request's estimated token cost exceeds the scope's *total* capacity — meaning no amount of waiting would ever admit it — the gateway omits `Retry-After` instead of sending an effectively-infinite wait time. Every other denial (the budget is just temporarily exhausted) carries a real `Retry-After` value. + +--- + +## How admission is computed + +Each affected scope (tenant-global, and the matching model rule if any) is a continuous-refill bucket with two independent counters: + +- **Capacity** = the configured `tokens_per_minute` / `requests_per_minute` value itself. The bucket starts full. +- **Refill rate** = capacity ÷ 60, applied continuously (not in discrete per-minute resets). +- **Reserve** debits the estimated input-token count from the token counter and a flat `1` from the request counter, only if *both* counters can currently afford it. A request is denied if *either* can't. +- **Settle** applies a signed delta — `(real input tokens + real completion tokens) − estimated tokens` — to the token counter only. A response that used more than estimated can push the counter temporarily negative (debt); the request counter is never trued up, since it was always exactly `1`. + +--- + +## Observability + +There are currently **no Prometheus metrics** for tenant rate-limit decisions — admissions, denials, and settlement deltas are not exposed as counters or histograms today. Denials are visible only via the `429` responses themselves and standard request logging. + +--- + +## See also + +
+ +
+ +### :material-swap-horizontal: Tenant Rate Limiting Concept + +The reserve/settle model, retry handling, and `n>1` accounting. + +[Tenant Rate Limiting →](../concepts/reliability/tenant-rate-limiting.md) + +
+ +
+ +### :material-tray-full: Rate Limiting Reference + +The concurrency-based limiter this feature complements. + +[Rate Limiting →](../concepts/reliability/rate-limiting.md) + +
+ +
+ +### :material-cog: Configuration Reference + +All other gateway CLI flags and configuration options. + +[Configuration →](configuration.md) + +
+ +