#41610·litellm

[Bug]: enforce_model_rate_limits — deployment TPM is never enforced for streaming requests (hidden_params.litellm_model_name is None)

Author: 1twJaderCostaCreated Sep 17, 2026Updated Sep 17, 2026
Labelsbugproxyllm translation

Check for existing issues

  • I have searched the existing issues and checked that my issue is not a duplicate.

What happened?

What happened?

With optional_pre_call_checks: ["enforce_model_rate_limits"], the per-deployment TPM limit (litellm_params.tpm) is enforced correctly for non-streaming requests but is never enforced for streaming requests. RPM is unaffected.

Root cause: in ModelRateLimitingCheck.async_log_success_event (litellm/router_utils/pre_call_checks/model_rate_limit_check.py), the TPM counter is only incremented when standard_logging_object["hidden_params"]["litellm_model_name"] is truthy:

model = standard_logging_object.get("hidden_params", {}).get("litellm_model_name")
total_tokens = standard_logging_object.get("total_tokens", 0)
if not model or not total_tokens:
    return
tpm_key = f"{model_id}:{model}:tpm:{current_minute}"

For streaming responses that field comes back None, so the method returns early, the TPM key is never created, the counter stays at 0 and the limit can never be reached.

Note the asymmetry: the check itself (async_pre_call_check) does run for streaming — a streaming request is correctly rejected if the counter was already primed by a non-streaming request. Only the write side is missing. The Router's own counter (RouterCacheEnum.TPMglobal_router:{id}:{model}:tpm:{minute}) does increment on streaming, because it takes the model name from the local deployment_name variable instead of from the logging payload.

Evidence — the logging payload differs between modes

A CustomLogger registered on the same Router, same model, same prompt:

mode total_tokens hidden_params.litellm_model_name model_id
non-streaming 17 'openai/<model>' 'probe'
streaming 8 None 'probe'

total_tokens and model_id are both present for streaming. Only the model name is missing.

Minimal reproduction

Suggested fix

In ModelRateLimitingCheck.async_log_success_event / log_success_event, fall back to the deployment's model name when hidden_params.litellm_model_name is missing, e.g. resolve it from model_id via the router, or reuse the same value the pre-call check used to build the key (deployment["litellm_params"]["model"]). Populating hidden_params.litellm_model_name for streaming responses would fix it at the source and likely help other consumers too.

Related

#17705 reports the same user-visible symptom (deployment tpm not applied while rpm works) and was closed as not planned. This report adds the underlying cause and a minimal reproduction.

Relevant log output

Not an exception — the limit silently never triggers. The logging payload diff above is the signal.

Are you a ML Ops Team?

Yes

What LiteLLM version are you on?

v1.92.0 — the relevant code in main at the time of writing is unchanged (same cache key, same local_only=True read, same if not model or not total_tokens: return guard), so this is expected to reproduce on current versions as well

User Flow

Single process, single Router, tpm: 100, fresh minute, stream_options={"include_usage": True}:

non-streaming (works):

# result limiter counter
1–3 200 45 → 90 → 135
4–6 RateLimitError 429 135 (limit 100 reached)

streaming (not enforced):

# result cache keys present
1 200 global_router:probe:openai/<model>:tpm:<min> = 43
2 200 global_router:... = 86
3 200 global_router:... = 129
4 200 global_router:... = 172
5 200 global_router:... = 215

215 tokens consumed against a limit of 100, with no rejection. The limiter's own key (probe:openai/<model>:tpm:<min>) is never created in the streaming run, while it exists in the non-streaming run.

This rules out the usual suspects: it is a single process (not a multi-replica counter split), include_usage is set, the tokens are accounted for (the Router counter increments by the exact amount), and the same limiter rejects correctly in non-streaming mode.

Proof the bug occurs

import asyncio
import litellm
from litellm import Router
from litellm.integrations.custom_logger import CustomLogger

API_BASE = "<your OpenAI-compatible base url>"
API_KEY = "<key or 'no-key-needed'>"
MODEL = "openai/<model served by that endpoint>"

payloads = []


class Probe(CustomLogger):
    async def async_log_success_event(self, kwargs, response_obj, start_time, end_time):
        slo = kwargs.get("standard_logging_object") or {}
        payloads.append(
            {
                "total_tokens": slo.get("total_tokens"),
                "litellm_model_name": (slo.get("hidden_params") or {}).get("litellm_model_name"),
                "model_id": slo.get("model_id"),
            }
        )


litellm.callbacks.append(Probe())

router = Router(
    model_list=[
        {
            "model_name": "g",
            "litellm_params": {"model": MODEL, "api_base": API_BASE, "api_key": API_KEY, "tpm": 100},
            "model_info": {"id": "probe"},
        }
    ],
    optional_pre_call_checks=["enforce_model_rate_limits"],
)


def tpm_keys():
    cache = getattr(router.cache.in_memory_cache, "cache_dict", {}) or {}
    return {k: v for k, v in cache.items() if ":tpm:" in k}


async def call(i, stream):
    kwargs = dict(
        model="g",
        messages=[{"role": "user", "content": f"write one short sentence about the number {i}"}],
        max_tokens=25,
    )
    if stream:
        kwargs.update(stream=True, stream_options={"include_usage": True})
    try:
        resp = await router.acompletion(**kwargs)
        if stream:
            async for _ in resp:
                pass
        return "200"
    except Exception as exc:
        return type(exc).__name__


async def main():
    for mode in (False, True):
        print(f"--- {'streaming' if mode else 'non-streaming'} (tpm=100) ---")
        for i in range(1, 7):
            status = await call(i, mode)
            await asyncio.sleep(2)
            print(f"  {i}: {status:18} keys={tpm_keys()}")
        print(f"  last logging payload: {payloads[-1]}")


asyncio.run(main())

Expected: the streaming loop starts rejecting with RateLimitError once ~100 tokens are consumed. Actual: every streaming call succeeds, the limiter key is never created, and the last payload shows litellm_model_name: None.

What part of LiteLLM is this about?

Proxy

What LiteLLM version are you on ?

v1.92.0

Twitter / LinkedIn details

No response