Humans and agents on the same gateway: why `priority` does not prioritise, the 429 that brings down the whole pool, and the two ways to separate traffic
Contents
Fourth article in the operational track of the control layer. The pair with Langfuse covered observability, the proxy’s day 2 covered availability and virtual keys covered governance. Here comes the new tenant: agents, which speak the same protocol as humans and behave in a different way. Everything that follows is verified against LiteLLM 1.100.1 and vLLM 0.29.0, both from the first ten days of September 2026.
TL;DR
The day a team connects an agent to the corporate gateway, the platform changes regime without anyone touching a YAML. These are the seven conclusions.
LiteLLM’s priority parameter prioritises nothing under normal conditions. It is still marked as beta and its queue is only consulted when all the deployments in the group are in cooldown. With free capacity, the only thing it produces is a response header. On top of that, the success branch does not pop the item off the queue, so the list grows indefinitely; and on /chat/completions the value 0, which is the one used by all the documentation examples, is falsy in Python and disables queuing.
Separating traffic is done with two pools, not with priorities. The non-forgeable way is model_group_alias at key or team level: the client always asks for qwen-30b and the credential decides whether that resolves to qwen-30b-interactivo or to qwen-30b-agentes. The way that does not touch clients is tag_regex against the User-Agent, with a pattern like ^User-Agent: claude-code\/, which serves to classify load and not to control access, because that header is set by the client.
The useful admission control arrives in the 1.101 branch, with max_in_flight_requests_per_worker and a per-process queue that returns 503 with retry-after. It rejects before authenticating, so what it discards does not appear attributed to any key.
max_parallel_requests is only applied at virtual key level. The team, user, end customer and organisation endpoints accept the field and store it in the database, and nobody reads it. global_max_parallel_requests is a no-op unless the old limiter is reactivated, and general_settings.max_parallel_requests is read by no code path.
vLLM with --scheduling-policy priority does not push ahead anyone who is already generating. The only thing that changes is who it evicts when KV cache runs short. If the agents have filled max_num_seqs, a chat with the highest priority waits just the same. And the two knobs that in 2025 allowed short prompts to get ahead of long ones, max_num_partial_prefills and max_long_partial_prefills, disappeared from the code in 0.27.
The retry product reaches 45 calls per agent turn. Three from the client SDK times three from the router times up to five fallback groups. The proxy sets max_retries=0 on the SDK it uses itself, but it cannot touch the client’s. The timeout is per attempt, not per request, and LiteLLM’s Timeout comes out as a 408, which is retryable.
A single 429 takes a deployment out of the pool for five seconds, without going through the allowed_fails threshold. With an agentic burst against a fleet of four replicas, the whole pool enters cooldown and the gateway returns a 429 of its own to everybody, humans included.
You are here: the gateway layer with two classes of traffic
In the seven-layer stack this still lives in the gateway layer, with one difference compared to the previous three articles. There the variable was load: more requests, more teams, more budget to share out. Here the variable is the shape of the load.
A gateway sized for three hundred engineers using a chat works. The same gateway, with thirty of those engineers running coding agents, serves a fraction of the human traffic with the previous latency and the rest degrades. The GPUs stay just as busy, the utilisation metrics stay good, and the support team gets complaints from people waiting nine seconds for a paragraph.
The analogy: the registry desk and the courier with two hundred files
A registry office serves people in order of arrival. Citizens come in one at a time, present a piece of paper, and leave. The system is fair and the queue moves.
One Tuesday a courier turns up with a trolley of two hundred files. He joins the queue like anyone else, because rules are rules, and when his turn comes he occupies the desk for forty minutes. Behind him are twelve people with one piece of paper each. Nobody has broken any rule. The courier has the same right as everyone else and uses the same procedure. What fails is that the office was designed assuming a certain size of transaction and another one three orders of magnitude bigger has turned up.
The real solutions for an office are three, and all three have an exact equivalent in the platform. Open a desk for bulk deliveries, which is separating pools. Put up a capacity sign and send people back later, which is admission control with its 503. And say that files are handed in during the afternoon, which is the part that on an inference platform can almost never be applied, because the agent does not negotiate schedules.
What does not work in the office is giving the citizen a priority number when the courier is already at the desk. That number does not interrupt the transaction in progress. It is, literally, what priority does in the two layers of the platform, and it is worth keeping in mind while reading the next section.
The two kinds of traffic, measured
Before configuring anything, you have to be able to state the difference in numbers, because every later adjustment comes out of it.
| Trait | Human chat | Agentic loop |
|---|---|---|
| Input per request | hundreds of tokens | tens of thousands, and it grows with each turn |
| Output per request | hundreds of tokens | from two tokens (a tool call) to several thousand |
| Concurrency per user | 1 | 3 to 20 requests in flight |
| The metric that hurts | time to first token | total turn throughput |
| Tolerance for waiting | seconds | minutes |
| Cancelled requests | rare | routine, by the agent’s own decision |
| Retries | the browser’s | the SDK’s, plus the loop’s |
| Witness when it fails | the user | nobody |
Two rows deserve comment because they contradict intuition.
The first is the metric row. An agent does not perceive time to first token, so on a shared engine it is the natural candidate to give up that metric. The operational conclusion is that the agent pool can be configured for aggressive throughput, with large batches and deep queues, while the interactive pool is configured for the opposite.
The second is the cancellations row. An agent that launches three speculative continuations and discards two generates two aborted streams per turn. At the gateway, that touches spend accounting, the release of concurrency counters and the open connection to the engine. LiteLLM 1.100 handles it reasonably well, and it did not six months ago.
What does not work: priority in LiteLLM
The first reaction of any operator is to look for a priority field. LiteLLM has one, documents it on its Request Prioritization page, and that page carries the beta label and the sentence saying it is for testing. The label is correct and has to be taken literally, because behind it there are four things.
The queue is only consulted when the whole group is in cooldown. The scheduler’s poll() method returns true if there is any healthy deployment, without touching the queue. With available capacity, which is the normal situation, the request goes straight through and priority orders nothing. The only thing that changes is that the response carries the x-litellm-request-prioritization-used header.
The queue does not empty. In that success branch there is no heappop and no call to the cleanup, which only happens in the expiry branch. The list under the key scheduler:queue:{model} grows without bound, is serialised whole into Redis on every insertion, and there is no maximum size and no discard policy. When the group does enter cooldown, the head of the heap is occupied by fossil entries from requests that finished hours ago, and new ones compare their identifier against that head, never match, and wait until the timeout runs out.
On /chat/completions, the highest priority disables the feature. The code reads the value with a kwargs.get("priority") or self.default_priority, and in Python 0 or None is None. Since in this scheduler the lowest number is the highest priority, priority: 0, which is the value in the documentation’s own examples, goes down the path without queuing. On /completions the check is done with is not None and it works; the two routes do not behave the same.
And default_priority in router_settings breaks the request. If the client does not send a priority but the global setting is in place, the value is truthy, the scheduling branch is entered and a function is invoked that requires the positional argument nobody has injected. The setting has no associated test and does not appear in the router_settings reference.
There is also a /queue/chat/completions, marked as experimental and hidden from the OpenAPI schema, which reads data["priority"] with no default value: a request without that field raises an exception that comes out as a 400 authentication error, with a message that has no relation to the cause.
There is a priority feature that is solid, and it is a different thing. The v3 dynamic limiter shares out token and request per minute quota between classes with weights, through litellm.priority_reservation with values like {"premium": 0.75, "standard": 0.25}. It shares out capacity, it does not order a queue: below the saturation threshold it lends free capacity and above it applies the weights. It never makes anyone wait, either it passes or it returns a 429. It requires Postgres and an enterprise licence, and it is still marked as beta.
What does work: two pools
Effective separation is done earlier, by deciding which set of replicas each class of traffic goes to. LiteLLM offers two mechanisms with very different security properties.
By credential: model_group_alias at key or team level
Router settings are resolved in the order key, team, global. Among those that accept that override is model_group_alias, and there lies the piece:
model_list:
- model_name: qwen-30b-interactivo
litellm_params:
model: hosted_vllm/Qwen3-30B
api_base: http://vllm-chat.inferencia.svc:8000/v1
model_info:
id: chat-01
max_input_tokens: 32768
- model_name: qwen-30b-agentes
litellm_params:
model: hosted_vllm/Qwen3-30B
api_base: http://vllm-agentes.inferencia.svc:8000/v1
model_info:
id: agentes-01
max_input_tokens: 131072
And on the agent team’s key:
curl -X POST "$PROXY/key/generate" \
-H "Authorization: Bearer $MASTER_KEY" \
-d '{
"team_id": "plataforma-agentes",
"models": ["qwen-30b"],
"router_settings": {
"model_group_alias": {"qwen-30b": "qwen-30b-agentes"}
}
}'
The client asks for qwen-30b and knows nothing about any of it. The resolution is tied to the credential, so the caller cannot get around it by changing a header. The same list allows routing_strategy, fallbacks, context_window_fallbacks, retry_policy, cooldown_time and allowed_fails to be set per key or team, which gives different reliability policies for each class of traffic on the same proxy instance.
It should not be confused with access groups (model_info.access_groups), which are permissions and not routing: they decide whether a key can invoke a model name, not which replica the request goes to.
By User-Agent: tag_regex
When separate credentials cannot be issued, because the agentic client uses the same personal key as that person’s chat, LiteLLM classifies by regular expression against the request headers:
model_list:
- model_name: qwen-30b
litellm_params:
model: hosted_vllm/Qwen3-30B
api_base: http://vllm-agentes.inferencia.svc:8000/v1
tag_regex: ["^User-Agent: claude-code\\/", "^User-Agent: .*codex"]
model_info: {id: agentes-01}
- model_name: qwen-30b
litellm_params:
model: hosted_vllm/Qwen3-30B
api_base: http://vllm-chat.inferencia.svc:8000/v1
tags: ["default"]
model_info: {id: chat-01}
router_settings:
enable_tag_filtering: true
tag_filtering_match_any: true
The proxy composes strings of the form User-Agent: <value> and tests them with re.search. Exact tag matching takes precedence over the regex. The documentation itself carries a warning that has to be respected: the User-Agent is written by the client, so this is traffic classification and not a security boundary. As load sharing it is enough, because an agent that lies about its identity to sneak into the interactive pool is a governance problem, not a routing one.
The behaviour when no deployment carries the requested tag has a nuance that comes as a surprise in production. If the tag is unknown to the group, the request falls through to the pool marked with tags: ["default"]. If the tag exists in the group but no healthy deployment carries it at that moment, the request fails with the tag configuration error, even though a default pool is available. To get deterministic fallthrough to the default pool you have to set model_info.allow_fail_open: true.
A different strategy per pool with routing_groups
router_settings:
routing_strategy: simple-shuffle
routing_groups:
- group_name: interactivo
models: [qwen-30b-interactivo]
routing_strategy: simple-shuffle
- group_name: agentes
models: [qwen-30b-agentes]
routing_strategy: simple-shuffle
The recommendation to leave simple-shuffle on both is not laziness, and sustaining it requires looking at what the alternatives do on a homogeneous vLLM fleet.
least-busy keeps a complete dictionary under a single key and updates it with non-atomic reads and writes, without using INCR, so with several proxy replicas increments are lost. It has no TTL, so a request that never completes leaves the counter raised forever and that replica is permanently excluded. Counters can go negative. And when the minimum corresponds to an identifier that is no longer in the healthy list, it falls back to random.choice without warning. The pattern of requests that do not complete is exactly that of an agent that cancels.
latency-based-routing measures seconds per output token, not wall-clock latency. A chat turn of twenty tokens looks extremely slow by that metric and an agent generation of two thousand tokens looks extremely fast, so mixing both kinds of traffic in the same group poisons the signal. It also seeds new deployments with zero latency, and with the buffer defaulting to zero, a freshly started replica takes all the traffic until its first success callback.
usage-based-routing-v2 uses minute windows that reset all at once, and it accounts for tokens when the request completes, so long generations in flight are invisible to the router. With long outputs, it systematically underestimates the real load.
cost-based-routing sorts by the sum of the input and output unit prices and takes the first, with no random tie-break. With replicas of the same model at the same price, all the traffic always goes to the same place. And a model that is not in the price map gets a default cost of 5.0 per token, a figure designed to deprioritise it.
Admission control: the new piece in the 1.101 branch
When the agent pool saturates, what you want is to reject fast and with a code the client understands, not to accumulate requests inside the proxy until memory runs out. That arrives with per-worker admission control, added in the 1.101 branch, in release candidate as this is written.
general_settings:
max_in_flight_requests_per_worker: 64
max_queued_requests_per_worker: 64
admission_queue_timeout_seconds: 1.0
It is a semaphore with a per-uvicorn-process queue, without Redis. When the queue fills or the wait expires it returns 503 with retry-after: 1 and a body with "type": "overloaded_error". It is observed through /health/backlog, which exposes requests in flight, admitted, queued and rejected, and through the Prometheus litellm_admission_* metrics, whose rejection counter distinguishes queue_full from queue_timeout.
Two properties have to go into the runbook. The middleware rejects before authenticating, so what is discarded is not attributed to any key in the spend records, and the real number of requests rejected per team cannot be reconstructed from the spend table. And the limits are read on the first request: changing them requires a restart.
Concurrency per key, and the three settings that do nothing
max_parallel_requests is the right brake for a specific agent, and its configuration surface is full of dead ends.
The only level where it is applied is the virtual key. The v3 limiter, active by default since 1.94, has descriptors for user, team, team member, end customer, organisation, model per key, tag and agent, and all of them carry only request and token counters per window. The only reference to max_parallel_requests is the one in the key descriptor. The old limiter says so in a comment: supporting it for model, user and team is pending.
At the same time, /team/new, /user/new, /customer/new and the organisation endpoints accept the field, validate it and write it to Postgres, and the documentation shows it in their examples. Nobody reads it afterwards. A team with max_parallel_requests: 20 in its row has no concurrency limit at all.
global_max_parallel_requests is injected into the request metadata and is only consulted by the old limiter, which is disabled unless LEGACY_MULTI_INSTANCE_RATE_LIMITING=true is set. The server settings documentation still describes it as a global limit coordinated through Redis. And general_settings.max_parallel_requests does not appear in any execution path: its only two appearances are the copy from the database and the type schema of the interface.
What does work, in detail:
curl -X POST "$PROXY/key/generate" -H "Authorization: Bearer $MASTER_KEY" \
-d '{"team_id":"plataforma-agentes","max_parallel_requests":8,"rpm_limit":600}'
Counting is done with a sorted set in Redis scored by Redis’s own clock, with Lua scripts to acquire and release. Each slot expires after 3,600 seconds, which is at once the maximum request duration the meter can track and the time a leaked slot takes to heal by itself. Release is hooked to success, to failure and to client disconnection during streaming, which was the classic leak with clients that cancel.
One live leak remains and it affects exactly the case in this article: every tool call through the MCP gateway acquires a slot and does not release it. The MCP route invokes the pre-call hook, which acquires, and none of the release functions is called from that module. With an agent that makes twenty tool calls per turn and a key with eight slots, the 429 arrives on the first turn and lasts an hour.
The order of evaluation matters for interpreting 429s. First the window counters, requests and tokens per minute, are checked, and only if they pass is the concurrency slot acquired. The code comment explains why: the other way round, every rate limit rejection would leave an orphan slot. In practice, with a low rpm you always see the requests type 429, and the max_parallel_requests one only appears when the relationship between concurrency and duration makes it binding first. The message distinguishes the two cases with the Limit type: field and the rate_limit_type header.
A minor detail with consequences on the client: the response to a concurrency rejection carries retry-after and reset_at computed from the window size, sixty seconds by default, when a concurrency meter has no window. A well-behaved client that respects that header will wait a minute when it might have had a free slot in two seconds.
Session affinity: the KV cache that gets thrown away
An agent turn resends the whole conversation context, system prompt and tools included. If that request lands on a different replica from the previous turn’s, vLLM’s prefix cache does not have the prefix and the prefill is recomputed in full. With twenty turns and four replicas balanced blindly, the long prefill is paid for fifteen times over.
LiteLLM has two mechanisms for this and they are not equally good.
The prompt_caching check computes a SHA-256 of the cacheable prefix, defined as everything up to the last block with cache_control, and pins the deployment with that hash. It has two problems. The TTL is hardcoded to 300 seconds in two places in the code, with no way to configure it, something which is reported and open; with one-hour caches, affinity is lost after five minutes of inactivity. And the key is an exact hash, so a client that moves the cut point forward on every turn, which is exactly what agents do to extend the cached prefix, generates a new key every time.
The session_affinity check is the one to use:
router_settings:
optional_pre_call_checks: ["session_affinity"]
deployment_affinity_ttl_seconds: 3600
It pins the deployment with an atomic Lua script in Redis, with a configurable TTL that defaults to one hour and that is refreshed on every request, so it bounds the idle time between turns and not the duration of the conversation. If the pinned deployment enters cooldown, the request goes out through the normal strategy and keeps the pin so it can come back when it heals.
The session identifier comes from a precedence chain that starts with x-litellm-trace-id, continues with x-litellm-session-id and then accepts any header of the form x-<something>-session-id whose value looks like an identifier. That includes the session header Claude Code already sends, with nothing configured on the client. Whatever matches populates both the session identifier and the trace identifier, which links this to the correlation from the first article in the track.
One dangerous interaction when combining it with tags: affinity is evaluated before tag-based routing. If the pin reduces the list to a deployment that does not satisfy the request’s tag, the request fails instead of falling through to the default pool, unless allow_fail_open has been enabled. Retagging a pool with live sessions breaks them.
And a clarification that saves a pointless search: LiteLLM does not do prefix routing. There is no prefix tree and nothing that directs the request to the vLLM replica that already has that prefix warm. What there is, is affinity by session or by key. For the other thing you need a specific router underneath the gateway, and that architecture has its own article pending.
The engine: what vLLM can and cannot do
Everything above shares out requests between engines. Inside one engine, the question is what happens when a twenty-token request and a sixty-thousand-token one live side by side.
Priority exists and does less than it looks like. --scheduling-policy priority turns the waiting queue into a heap ordered by priority, arrival time and identifier, with the lowest number first. The request can carry the priority field in the body, and there is also the X-Vllm-Priority header, which takes precedence over the body and is not documented anywhere.
What priority does not do is push ahead of whoever is already generating. In the running queue loop, when KV cache runs short, the policy only changes who is chosen as the victim of eviction: with priority the one with the worst priority and arrival time is evicted, and with fcfs the last in the queue. In the waiting queue loop, if there are no blocks available the iteration is cut short, without evicting anyone. And the max_num_seqs cap is a hard cut. If the agents have filled that cap, a chat with maximum priority waits just the same. There has been an open issue since April and an unmerged pull request about this.
That nuance about the victim is not negligible, because with fcfs the victims are the most recently admitted, that is, the chats that have just come in. Switching the priority policy on already improves the situation even if it pushes nobody ahead.
Eviction is always by recomputation. The swap-to-host-memory mode and the enumeration that controlled it disappeared from the code; all that is left is freeing blocks and returning the request to the queue with the computed token counter at zero. An evicted request loses all its prefill, and the prefix cache recovers part of it, but the perceived time to first token restarts.
Two admission settings help and do not appear in the narrative documentation. watermark reserves a fraction of free KV blocks on admission, with the declared purpose of avoiding repeated eviction, and it ships disabled. And scheduler_reserve_full_isl, active by default, checks that the full input length fits in the cache before admitting, instead of looking only at the first chunk.
Chunked prefill is active by default and prioritises decode. The scheduler walks the whole running request loop before touching the waiting queue, so a long agent prefill does not freeze a chat’s generation in progress. That protects the inter-token latency of what has already been admitted, and it does not protect the time to first token of what is waiting.
Here there is a breaking change that forces a review of the manifests. max_num_partial_prefills and max_long_partial_prefills were removed in 0.27. They were precisely the mechanism that allowed short prompts to get ahead of long ones. A deployment with those flags in its args does not start. long_prefill_token_threshold still exists, and its meaning changed: today it is a per-request chunk size cap, not a classification of long requests.
The partial replacement arrives in 0.29 with max_num_queued_tokens and max_num_queued_reqs, admission valves that return 503 when the pending prefill work exceeds the target. The code itself frames it as a quality of service mechanism for time to first token, with the formula of setting it to the latency target multiplied by prefill throughput. They are blind to the class of traffic: they reject a chat just the same as an agent.
And the default value of --max-num-batched-tokens depends on the hardware and on the start-up mode. With vllm serve on H100 or H200 it is 8,192, on cards of 160 GiB or more it is 16,384, and on the rest 2,048. The 2,048 that appears in the scheduler configuration only applies to tests.
The metrics for diagnosing coexistence, with the exact names from 0.29:
| Metric | What for |
|---|---|
vllm:num_requests_waiting | queue depth, the autoscaling signal |
vllm:num_requests_waiting_by_reason | separates waiting for capacity from waiting in a blocked state |
vllm:num_preemptions_total | evictions, the signal that the two classes are treading on each other |
vllm:kv_cache_usage_perc | cache pressure, the cause of the evictions |
vllm:request_queue_time_seconds | what a chat waits with the agents inside |
vllm:prefix_cache_hits_total and _queries_total | measured in tokens, not in requests |
A warning for anyone with old alerts. The log warning about evicted sequences that appears in the optimisation documentation belongs to the previous version of the engine and no longer exists in the code. Preemptions are only visible as a counter. An alert based on searching for that string in the logs has not fired for months, and not because there are no evictions.
On isolation between classes inside one engine, the short answer is that it does not exist. There are two queues and neither is per class of traffic. The conclusion, which is the one that holds up this article’s architecture: effective isolation means separate fleets, and on top of them the gateway deciding who goes to which.
Retries: the product nobody calculates
A human who gets an error sees it and decides. An agentic loop retries, and beneath it there are two more layers that also retry.
| Layer | Default |
|---|---|
| Client SDK | 2 retries, 3 attempts |
| LiteLLM router | 2 retries, 3 attempts |
| Fallbacks | up to 5 groups |
The product is about 45 calls to the engine for one agent turn, before counting the loop’s own retry. LiteLLM does what it can: when the call comes from the router, it forces max_retries=0 on the SDK it itself uses against the engine. What it cannot touch is the client’s SDK. The only layer that breaks the product is that one, and it is broken by setting max_retries=0 on the client and delegating the retry to the gateway.
The retryable errors are 408, 409, 429 and anything 500 or above. And there lies the problem with long reasoning: litellm.Timeout is a subclass of OpenAI’s timeout exception and comes out with code 408, which is retryable, so the most expensive request of all, the one that expired after ten minutes of generating, is launched again in full. Twice by the router, and as many times again by the client.
On timeouts you have to be precise because the code comment is: the configured timeout is per attempt, not per request. A request_timeout: 600 with two retries and two fallback groups gives a worst case of almost two hours before the client receives anything. The default value is still 6,000 seconds, as was already covered in the day 2 article.
The retry storm has two specific causes and both are default settings.
The first: a single 429 takes the deployment out of the pool, without going through the allowed_fails threshold. The cooldown logic has an explicit branch which, on a 429 in a group with more than one deployment, returns immediate cooldown. With an agentic burst against four replicas, all four leave the pool at once and the gateway answers everybody with its own 429 of “no deployments available”. The default cooldown is five seconds, which in a sustained burst gets renewed.
The second: backoff is zero while the pool looks healthy. The function that computes the wait returns 0 if the healthy deployment list is not empty. Exponential backoff with jitter, capped at eight seconds, only kicks in when there is nothing healthy left. Under partial degradation, the proxy hammers away without waiting and only slows down once everything has already fallen over.
On top of that, the engine’s Retry-After header is only respected if its value is between 1 and 60 seconds. A longer limit window is ignored and replaced by the proxy’s own backoff.
The settings that cut the problem off:
router_settings:
num_retries: 1
retry_after: 2 # real floor; the default 0 is hammering
cooldown_time: 30
allowed_fails_policy:
RateLimitErrorAllowedFails: 3 # stops a 429 taking the deployment out
retry_policy:
TimeoutErrorRetries: 0 # do not relaunch the expensive request
AuthenticationErrorRetries: 0
litellm_settings:
request_timeout: 900 # per attempt
drop_params: false
That last setting deserves an explanation, because it is a silent way of breaking an agent. With drop_params: true, LiteLLM removes from the body any parameter the destination provider does not support, and in that branch it writes no warning at all. The candidates for disappearing are tools, tool_choice, response_format and parallel_tool_calls. An agent that has tools taken away receives free text where it expected a function call, and interprets it as the model having decided not to use tools.
Worse still is prompt-based emulation. For a non-OpenAI-compatible provider that does not support tools, LiteLLM does not fail: it turns the tools into text inside the prompt, forces JSON format, discards tool_choice, and on the way switches on a process-global variable that makes any other tool-less provider in the same process go to emulation instead of erroring.
With that in mind, the warning about fallbacks is clear: LiteLLM does not check capabilities before falling back. The function that knows whether a model supports function calling exists and is only used in an informational endpoint. The pre-call checks filter by context window, request limit and region, and nothing else. A fallback list that goes from a model with tools to one without them degrades the agent silently. There are guards for provider-scoped resources, files and batches, so the absence of the capability guard is an omission and not a design oversight.
A month’s budget in twenty minutes
A runaway agent consumes in minutes what a team consumes in weeks, and the operational question is how long the gateway takes to find out.
The answer has changed, and the old mental model, the one where spend consolidates every minute and until then there is no brake, is no longer correct. Since 1.84 there are two new pieces. An optimistic reservation before the call, which estimates the maximum cost of the request and reserves it atomically in the counter before heading out to the engine, reconciling it with the real cost on completion. And a counter increment after the call that is explicitly awaited, with the comment that this way the counter is up to date before the next request goes through authentication. Budget checks read that counter, with Redis first and seeding from the database when cold.
Batch writing to Postgres still exists, every 10 to 15 seconds, and it affects persistence and what is shown in the interface, not the cut-off. The production documentation’s recommendation to raise that interval to 60 does not relax the control, and it does delay by a minute what /key/info shows, something which frequently generates operational panic. And the budget rescheduler’s ten minutes do not consolidate spend: they fire the job that resets expired budgets.
The overspend window then comes down to the cost of the requests in flight, provided there is Redis. Without Redis, the counters are per pod, and with N replicas you can spend up to N times the budget. That is the configuration failure that matters and it is the same Redis that the day 2 article declared non-optional for other reasons.
Two settings that do not appear in the public documentation, only in the data model descriptions, and that decide behaviour at the worst moment:
general_settings:
fail_closed_budget_enforcement: true # false by default: if the counter fails, it lets things through
# disable_budget_reservation: do not set it to true
With the default value, if the counter backend does not respond, the proxy fails open and carries on serving. For an agent in a loop that is exactly the opposite of what you want.
Time for the honest warning: this route is still unstable. There are open issues in both directions, one where the key and user budget limit stops being applied while spend is recorded, and another where a 429 is returned for stale spend in the reservation counters while the key query shows spend below the limit. Project budgets are not on the atomic reservation route and concurrency overshoots them. The practical conclusion is not to trust the hard cut-off to the budget alone.
On the manual cut-off, there is an asymmetry to know about before you need it. /key/block invalidates the local cache, the Redis one, and notifies all workers by publish, so with Redis the effect is almost immediate. /customer/block does the same. /team/block only writes to the database, without invalidating or notifying, so it takes however long the management object cache TTL takes, sixty seconds by default. To cut a team off dead you have to block its keys one by one, or lower user_api_key_cache_ttl. And /user/block no longer exists, even though the docstring of the function that replaced it still shows that route in its example.
On alerts, two numbers. The default thresholds warn at 85 % and 95 % of the budget. And deduplication of those alerts is 24 hours per event and identifier, so crossing 85 % generates one warning and then silence for a day. For agentic traffic, where 85 % and 100 % can be twenty minutes apart, the second warning arrives when it is no longer any use.
Attribution: who the user is when the caller is an agent
With agents, the question of who the spend is charged to has two answers and both are needed: the agent that executes and the human on whose behalf it acts.
The chain that extracts the end customer identifier tries the standard headers first, then the configurable header mapping, and only then the user field of the body, the litellm_metadata.user of the Anthropic format, the metadata.user_id and the safety_identifier. The last four are body fields and are therefore written by the client. The code itself warns about it: for untrusted callers the identifier has to be set from headers or from a middleware on the server, to avoid impersonation.
Out of that comes the correct pattern, which is the inverse of the one people put together out of inertia: the virtual key identifies the agent and is the non-forgeable part, and the header or the user field identify the human and serve for attribution, not for control. An agent that lies about the human behind it is still bounded by the budget and the limits of its key.
The spend table stores user, end_user, team_id, organization_id, request_tags, session_id and agent_id, with daily aggregation per agent, so the detail is available once it is sent. The x-litellm-agent-id header is what populates that last field.
A note about the MCP gateway, which on a platform with agents ends up being the other entry door. Tool calls do appear in the spend records, with call types of their own and a metadata block that stores tool name, arguments, result and server. What is missing is token counting: the cost is 0.0 by default and is only populated if the administrator configures a price per query, per server or per tool. It is the same pattern of silent zero cost that already came up with unpriced models in the first article in the track.
And the permission setting to review on day one: access control for MCP servers is open by default. If no level of the hierarchy defines a list, the request reaches every configured server. general_settings.require_key_mcp_access_defined: true inverts that inheritance, and it is not the default value.
A reference config.yaml
All of the above, together, on a fleet split into two pools:
model_list:
- model_name: qwen-30b-interactivo
litellm_params:
model: hosted_vllm/Qwen3-30B
api_base: http://vllm-chat.inferencia.svc:8000/v1
input_cost_per_token: 0.00000018 # without this, silent zero cost
output_cost_per_token: 0.00000072
model_info:
id: chat-01
max_input_tokens: 32768 # without this there is no context filter
- model_name: qwen-30b-agentes
litellm_params:
model: hosted_vllm/Qwen3-30B
api_base: http://vllm-agentes.inferencia.svc:8000/v1
input_cost_per_token: 0.00000018
output_cost_per_token: 0.00000072
tag_regex: ["^User-Agent: claude-code\\/"]
model_info:
id: agentes-01
max_input_tokens: 131072
allow_fail_open: true
router_settings:
routing_strategy: simple-shuffle
enable_pre_call_checks: true
enable_tag_filtering: true
optional_pre_call_checks: ["session_affinity"]
deployment_affinity_ttl_seconds: 3600
num_retries: 1
retry_after: 2
cooldown_time: 30
allowed_fails_policy:
RateLimitErrorAllowedFails: 3
retry_policy:
TimeoutErrorRetries: 0
AuthenticationErrorRetries: 0
general_settings:
fail_closed_budget_enforcement: true
require_key_mcp_access_defined: true
user_api_key_cache_ttl: 10
proxy_batch_write_at: 10
# 1.101 branch onwards:
max_in_flight_requests_per_worker: 64
max_queued_requests_per_worker: 64
admission_queue_timeout_seconds: 1.0
litellm_settings:
request_timeout: 900 # per attempt
drop_params: false
cache: true # Redis, not optional
And the start-up of the two engines, with the deliberate asymmetry:
# interactive pool: protect the first token
vllm serve Qwen/Qwen3-30B \
--scheduling-policy priority \
--max-num-seqs 64 \
--max-model-len 32768 \
--max-num-batched-tokens 4096
# agent pool: throughput and deep queues
vllm serve Qwen/Qwen3-30B \
--max-num-seqs 256 \
--max-model-len 131072 \
--max-num-batched-tokens 8192
Checklist
- Issue separate keys for agents, with
model_group_aliasat key or team level. It is the only separation the client cannot evade. - Add
tag_regexover theUser-Agentas a safety net for agents that use personal credentials, with adefaultpool andallow_fail_open: true. - Leave
simple-shuffleon both pools. The other four strategies have verifiable defects on homogeneous replicas. - Enable
session_affinitywith a one-hour TTL. Do not useprompt_cachingfor this while its TTL stays hardcoded. - Declare
max_input_tokenson every deployment. Without that value, the context window filter is not applied and it fails silently for models that are not in the price map. - Declare per-token prices on every self-hosted model, or the agents’ spend will be zero and their budget will never run out.
max_parallel_requestson the agent’s key, never on its team. On the team it is stored and not applied.- Lower
num_retriesto 1, put a floor onretry_after, raisecooldown_timeand disable retry on timeout. On the client,max_retries=0. fail_closed_budget_enforcement: trueand mandatory Redis. Without Redis, N replicas are N times the budget.- Start the interactive engine with
--scheduling-policy priorityeven though it pushes nobody ahead: it changes who it evicts. - Alert on
vllm:num_preemptions_totalandvllm:num_requests_waiting_by_reason, not on log strings that are no longer emitted. - Prepare the cut-off: block by key, never by team, and verify the real propagation time in your own deployment.
Traps and things that are not what they seem
priority: 0on/chat/completionsdisables prioritisation. It is the value in the documentation examples and it is falsy in Python.max_parallel_requestson a team is stored and not applied. The field exists in the API, in the database and in the documentation; the limiter only reads it from the key descriptor.global_max_parallel_requestsis a no-op with the default limiter, and the server settings documentation still describes it as active.- Every MCP call leaks a concurrency slot, which only heals when its hour expires. With agents calling tools in a loop, the 429 appears early and lasts a long time.
- The TTL of prompt cache affinity is 300 hardcoded seconds. For agent conversations, session affinity is the right option.
- Affinity is evaluated before tag-based routing. A pin to a deployment that stops satisfying the tag makes the request fail instead of falling through to the default pool.
- LiteLLM’s timeout is per attempt. The worst case is the timeout multiplied by attempts and by fallback groups.
- A 429 takes the deployment out of the pool without going through
allowed_fails, and backoff is zero while anything healthy remains. - The engine’s
Retry-Afteris ignored if it asks for more than 60 seconds. drop_params: trueremovestoolsandresponse_formatwithout leaving a warning, and the fallback does not check whether the destination model supports function calling./team/blockdoes not invalidate the cache. It takes the full TTL;/key/blockdoes propagate by publish in Redis.- Access to MCP servers is open by default if no level defines a list.
max_num_partial_prefillsandmax_long_partial_prefillsno longer exist in vLLM. A manifest with those flags does not start from 0.27 onwards.- vLLM’s priority does not push ahead of running requests, and with
max_num_seqsfull it pushes nobody ahead. - The eviction log warning that appears in the vLLM documentation is no longer emitted. Only the Prometheus counter is left.
Closing
The coexistence of humans and agents on the same platform is not solved with a priority field, and that is the conclusion that takes the most effort to accept because the field exists and looks made for this. Neither the gateway nor the engine has a usable notion of class of service today: one orders a queue that is almost never consulted, the other picks who to evict. What they do have is the ability to direct each request to a different place, and that is where the win is.
The architecture that comes out of all of the above fits in one sentence. Two vLLM fleets with opposite parameters, a credential that decides which one each request goes to, session affinity so as not to throw away the prefill work, concurrency limits on the agent’s key and not on its team, retries trimmed back on the three layers where they multiply, and budgets that fail closed.
The rest is measurement. If vllm:num_preemptions_total rises in the interactive pool, the two classes are treading on each other despite the separation. If chat queue time grows while total throughput holds, the partition is badly sized. And if the agents’ spend comes out as zero, the problem is not coexistence but undeclared prices, which is where this track started.
See also
- Virtual keys, budgets and limits in LiteLLM, the full hierarchy and the v3 limiter that this article’s limits rest on.
- LiteLLM on day 2: high availability, the Redis and the Postgres taken for granted here, and retries seen from the gateway side.
- LiteLLM and Langfuse: the operational pair, the trace correlation that this article’s session identifier feeds, and the zero cost of unpriced models.
- FinOps and GPU multi-tenancy with LiteLLM, where the figures that go into the budgets defended here come from.
- The contractor with the master key: isolating AI agents, the other side of the problem, the agent as a security risk rather than a class of traffic.
- The second cost vector of AI agents, what a loop that fails halfway costs, and why durable execution changes that sum.
- Prefix routing: what LiteLLM does not do, the continuation of this article’s session affinity, and the correction of what the gateway can do with the KV cache.
- LiteLLM’s MCP gateway, the agents’ other entry door, including the correction about the default access this article took to be open.
- The LLM inference router, the router’s four functions and the prefix-aware routing LiteLLM does not do.
- Prefill optimisations in vLLM, the work that is recomputed in full when a request is evicted.
- The vLLM scheduler step, the loop the priority policy and chunked prefill operate on.
- When MCP grows: authentication with Keycloak, the identity of the other entry door, the one agents use to call tools.
- Sizing for agents, the question before this one: how big the fleet and the gateway have to be, with the agentic load measurements published in 2026.
- The gateway does not live alone, the identity of the agent and of the human seen from outside the proxy.
Sources
- LiteLLM, [BETA] Request Prioritization: https://docs.litellm.ai/docs/scheduler.
- LiteLLM, Router - Load Balancing (estrategias, afinidad de sesión, comprobaciones previas): https://docs.litellm.ai/docs/routing.
- LiteLLM, Tag Based Routing (incluye
tag_regexy el ejemplo con clientes agénticos): https://docs.litellm.ai/docs/proxy/tag_routing. - LiteLLM, Router Settings for Keys and Teams (resolución clave, equipo, global): https://docs.litellm.ai/docs/proxy/keys_teams_router_settings.
- LiteLLM, Server Tuning (control de admisión por worker): https://docs.litellm.ai/docs/proxy/server_tuning.
- LiteLLM, Dynamic TPM/RPM Allocation (reserva de prioridad, beta y enterprise): https://docs.litellm.ai/docs/proxy/dynamic_rate_limit.
- LiteLLM, Budgets, Rate Limits: https://docs.litellm.ai/docs/proxy/users.
- LiteLLM, Production Best Practices: https://docs.litellm.ai/docs/proxy/prod.
- LiteLLM, Reliability (fallbacks, ventana de contexto, enfriamientos): https://docs.litellm.ai/docs/proxy/reliability.
- LiteLLM, código:
litellm/scheduler.py,litellm/router_strategy/tag_based_routing.py,litellm/router_utils/pre_call_checks/deployment_affinity_check.py,litellm/proxy/hooks/parallel_request_limiter_v3.py,litellm/proxy/middleware/admission_control_middleware.py,litellm/router_utils/cooldown_handlers.py: https://github.com/BerriAI/litellm. - LiteLLM, incidencia 28427, TTL de la afinidad por caché de prompts fijado a cinco minutos: https://github.com/BerriAI/litellm/issues/28427.
- LiteLLM, incidencia 34534, fuga de slot de concurrencia en cada llamada MCP: https://github.com/BerriAI/litellm/issues/34534.
- LiteLLM, incidencia 26672, presupuesto de clave y usuario no aplicado: https://github.com/BerriAI/litellm/issues/26672.
- LiteLLM, incidencia 872, comprobación previa de soporte de llamada a funciones, cerrada sin implementar: https://github.com/BerriAI/litellm/issues/872.
- vLLM, Optimization and Tuning (prefill troceado, prioridad de decodificación): https://docs.vllm.ai/en/v0.29.0/configuration/optimization.html.
- vLLM, Metrics design (nombres, convención
_total, autoescalado como problema abierto): https://docs.vllm.ai/en/v0.29.0/design/metrics.html. - vLLM, Data Parallel Deployment (caché KV independiente por réplica): https://docs.vllm.ai/en/v0.29.0/serving/data_parallel_deployment.html.
- vLLM, código:
vllm/v1/core/sched/scheduler.py,vllm/v1/core/sched/request_queue.py,vllm/config/scheduler.py,vllm/engine/arg_utils.py,vllm/v1/metrics/loggers.py: https://github.com/vllm-project/vllm. - vLLM, incidencia 40004, la prioridad no desaloja peticiones en ejecución: https://github.com/vllm-project/vllm/issues/40004.