From 465480af41200231505926f97eb9d1aca73230cd Mon Sep 17 00:00:00 2001 From: DanBrima <40828002+DanBrima@users.noreply.github.com> Date: Mon, 17 Aug 2026 19:44:57 +0000 Subject: [PATCH 01/10] feat(prometheus): expose team-scoped rate limit gauges Configured and remaining rate limits were only observable at virtual key scope (litellm_remaining_api_key_*_for_model) and deployment scope (litellm_deployment_{tpm,rpm}_limit). At team scope the only gauges were dollar-denominated budgets, so there was no way to alert on a team approaching the model_tpm_limit / model_rpm_limit set on its team object. The v3 rate limiter already computes current_limit and limit_remaining for its model_per_team descriptor and publishes them as x-ratelimit-model_per_team-{remaining,limit}-{requests,tokens}, which land in the standard logging payload. Read those existing values in async_log_success_event and set four new gauges labeled by team, team_alias and model. No enforcement change and no extra Redis round trips. Scope is the per-model team limits only. Every gauge carries a model label, and a model-agnostic team-wide limit has no single model to attribute to. A team with no limit configured for the requested model produces no header, and therefore no series. --- litellm/integrations/prometheus.py | 168 +++++++++++- litellm/types/integrations/prometheus.py | 18 +- ...test_prometheus_team_rate_limit_metrics.py | 256 ++++++++++++++++++ 3 files changed, 427 insertions(+), 15 deletions(-) create mode 100644 tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py index a9056aaf4e1..42a9148066f 100644 --- a/litellm/integrations/prometheus.py +++ b/litellm/integrations/prometheus.py @@ -9,7 +9,7 @@ import os import sys from collections.abc import Awaitable, Callable, Mapping, Sequence from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Any, Final, Literal, cast +from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, cast import litellm from litellm._logging import print_verbose, verbose_logger @@ -114,6 +114,31 @@ def _get_budget_metrics_per_request_timeout() -> float: return parsed +class _LabeledGauge(Protocol): + """Structural type shared by ``prometheus_client.Gauge`` and the no-op / label-excluding wrappers above.""" + + def labels(self, **labels: str) -> _LabeledGauge: ... + + def set(self, value: float) -> None: ... + + +_TEAM_RATE_LIMIT_GAUGE_SPECS: Final[ + tuple[ + tuple[ + DEFINED_PROMETHEUS_METRICS, + Literal["remaining", "limit"], + Literal["requests", "tokens"], + ], + ..., + ] +] = ( + ("litellm_remaining_team_requests_for_model", "remaining", "requests"), + ("litellm_remaining_team_tokens_for_model", "remaining", "tokens"), + ("litellm_team_rpm_limit", "limit", "requests"), + ("litellm_team_tpm_limit", "limit", "tokens"), +) + + class PrometheusLogger(CustomLogger): # Class variables or attributes @@ -387,6 +412,34 @@ class PrometheusLogger(CustomLogger): labelnames=self.get_labels_for_metric("litellm_remaining_api_key_tokens_for_model"), ) + ######################################## + # LiteLLM Team rate limit metrics + ######################################## + + self.litellm_remaining_team_requests_for_model = self._gauge_factory( + "litellm_remaining_team_requests_for_model", + "Remaining Requests team can make for model (model based rpm limit on team)", + labelnames=self.get_labels_for_metric("litellm_remaining_team_requests_for_model"), + ) + + self.litellm_remaining_team_tokens_for_model = self._gauge_factory( + "litellm_remaining_team_tokens_for_model", + "Remaining Tokens team can make for model (model based tpm limit on team)", + labelnames=self.get_labels_for_metric("litellm_remaining_team_tokens_for_model"), + ) + + self.litellm_team_rpm_limit = self._gauge_factory( + "litellm_team_rpm_limit", + "Configured RPM limit for team + model (model based rpm limit on team)", + labelnames=self.get_labels_for_metric("litellm_team_rpm_limit"), + ) + + self.litellm_team_tpm_limit = self._gauge_factory( + "litellm_team_tpm_limit", + "Configured TPM limit for team + model (model based tpm limit on team)", + labelnames=self.get_labels_for_metric("litellm_team_tpm_limit"), + ) + ######################################## # LLM API Deployment Metrics / analytics ######################################## @@ -1385,6 +1438,14 @@ class PrometheusLogger(CustomLogger): model_id=enum_values.model_id, ) + # set team rpm/tpm metrics for the requested model + self._set_team_rate_limit_metrics( + user_api_team=user_api_team, + user_api_team_alias=user_api_team_alias, + model_group=standard_logging_payload["model_group"], + standard_logging_payload=standard_logging_payload, + ) + # set latency metrics self._set_latency_metrics( kwargs=kwargs, @@ -1895,18 +1956,21 @@ class PrometheusLogger(CustomLogger): ) @staticmethod - def _get_remaining_from_v3_rate_limit_headers( + def _get_v3_rate_limit_header( standard_logging_payload: StandardLoggingPayload | None, + descriptor_key: Literal["model_per_key", "model_per_team"], + value_type: Literal["remaining", "limit"], rate_limit_type: Literal["requests", "tokens"], ) -> int | None: """ - Read the per-(key, model) remaining value emitted by the v3 rate - limiter (``parallel_request_limiter_v3.py``), which writes - ``x-ratelimit-model_per_key-remaining-{requests,tokens}`` into - ``standard_logging_object.hidden_params.additional_headers`` instead - of the ``litellm-key-remaining-*`` metadata keys the legacy limiter - sets. The header carries no model group; it always refers to this - request's model group, which is what the gauges are labeled with. + Read a per-(scope, model) value emitted by the v3 rate limiter + (``parallel_request_limiter_v3.py``), which writes + ``x-ratelimit-{descriptor_key}-{remaining,limit}-{requests,tokens}`` + into ``standard_logging_object.hidden_params.additional_headers`` + instead of the ``litellm-key-remaining-*`` metadata keys the legacy + limiter sets. The header carries no model group; it always refers to + this request's model group, which is what the gauges are labeled + with. A scope with no configured limit produces no header at all. Values are written in-process as plain ints (never HTTP-serialized strings), so anything else is rejected rather than coerced. """ @@ -1918,7 +1982,7 @@ class PrometheusLogger(CustomLogger): additional_headers: Final = hidden_params.get("additional_headers") if additional_headers is None: return None - value: Final = dict(additional_headers).get(f"x-ratelimit-model_per_key-remaining-{rate_limit_type}") + value: Final = dict(additional_headers).get(f"x-ratelimit-{descriptor_key}-{value_type}-{rate_limit_type}") if isinstance(value, bool) or not isinstance(value, int): return None return value @@ -1944,15 +2008,21 @@ class PrometheusLogger(CustomLogger): remaining_requests = metadata.get(remaining_requests_variable_name) if remaining_requests is None: - remaining_requests = self._get_remaining_from_v3_rate_limit_headers( - standard_logging_payload=standard_logging_payload, rate_limit_type="requests" + remaining_requests = self._get_v3_rate_limit_header( + standard_logging_payload=standard_logging_payload, + descriptor_key="model_per_key", + value_type="remaining", + rate_limit_type="requests", ) if remaining_requests is None: remaining_requests = sys.maxsize remaining_tokens = metadata.get(remaining_tokens_variable_name) if remaining_tokens is None: - remaining_tokens = self._get_remaining_from_v3_rate_limit_headers( - standard_logging_payload=standard_logging_payload, rate_limit_type="tokens" + remaining_tokens = self._get_v3_rate_limit_header( + standard_logging_payload=standard_logging_payload, + descriptor_key="model_per_key", + value_type="remaining", + rate_limit_type="tokens", ) if remaining_tokens is None: remaining_tokens = sys.maxsize @@ -1983,6 +2053,76 @@ class PrometheusLogger(CustomLogger): ) self.litellm_remaining_api_key_tokens_for_model.labels(**tokens_labels).set(remaining_tokens) + def _set_team_rate_limit_metrics( + self, + user_api_team: str | None, + user_api_team_alias: str | None, + model_group: str | None, + standard_logging_payload: StandardLoggingPayload | None, + ) -> None: + """ + Emit the per-(team, model) rate limit gauges from the values the v3 + rate limiter already computed for its ``model_per_team`` descriptor + and shipped to the client as ``x-ratelimit-model_per_team-*`` + headers. A team with no per-model limit configured for the requested + model produces no header, and therefore no series, which matches how + the per-key gauges behave. + """ + if user_api_team is None: + return + + configured: Final = tuple( + (metric_name, value) + for metric_name, value_type, rate_limit_type in _TEAM_RATE_LIMIT_GAUGE_SPECS + if ( + value := self._get_v3_rate_limit_header( + standard_logging_payload=standard_logging_payload, + descriptor_key="model_per_team", + value_type=value_type, + rate_limit_type=rate_limit_type, + ) + ) + is not None + ) + if not configured: + return + + enum_values: Final = UserAPIKeyLabelValues( + team=user_api_team, + team_alias=user_api_team_alias, + model=model_group, + custom_metadata_labels=get_custom_labels_from_metadata( + metadata=_get_combined_custom_metadata_from_standard_logging_payload( + standard_logging_payload=standard_logging_payload + ) + ), + ) + label_context: Final = PrometheusLabelFactoryContext(enum_values) + + for metric_name, value in configured: + self._set_team_rate_limit_gauge( + gauge=getattr(self, metric_name), + metric_name=metric_name, + value=value, + enum_values=enum_values, + label_context=label_context, + ) + + def _set_team_rate_limit_gauge( + self, + gauge: _LabeledGauge, + metric_name: DEFINED_PROMETHEUS_METRICS, + value: int, + enum_values: UserAPIKeyLabelValues, + label_context: PrometheusLabelFactoryContext, + ) -> None: + labels: Final = prometheus_label_factory( + supported_enum_labels=self.get_labels_for_metric(metric_name), + enum_values=enum_values, + label_context=label_context, + ) + gauge.labels(**labels).set(value) + def _set_latency_metrics( self, kwargs: dict, diff --git a/litellm/types/integrations/prometheus.py b/litellm/types/integrations/prometheus.py index ebec5df55fa..9fe51f026e9 100644 --- a/litellm/types/integrations/prometheus.py +++ b/litellm/types/integrations/prometheus.py @@ -1,5 +1,5 @@ import re -from collections.abc import Mapping +from collections.abc import Mapping, Sequence from dataclasses import MISSING, dataclass, field, fields from enum import Enum from types import MappingProxyType @@ -257,6 +257,10 @@ DEFINED_PROMETHEUS_METRICS = Literal[ "litellm_deployment_rpm_limit", "litellm_remaining_api_key_requests_for_model", "litellm_remaining_api_key_tokens_for_model", + "litellm_remaining_team_requests_for_model", + "litellm_remaining_team_tokens_for_model", + "litellm_team_rpm_limit", + "litellm_team_tpm_limit", "litellm_llm_api_failed_requests_metric", "litellm_callback_logging_failures_metric", "litellm_in_flight_requests", @@ -670,6 +674,18 @@ class PrometheusMetricLabels: UserAPIKeyLabelNames.MODEL_ID.value, ] + litellm_remaining_team_requests_for_model: ClassVar[Sequence[str]] = [ + UserAPIKeyLabelNames.TEAM.value, + UserAPIKeyLabelNames.TEAM_ALIAS.value, + UserAPIKeyLabelNames.v1_LITELLM_MODEL_NAME.value, + ] + + litellm_remaining_team_tokens_for_model = litellm_remaining_team_requests_for_model + + litellm_team_rpm_limit = litellm_remaining_team_requests_for_model + + litellm_team_tpm_limit = litellm_remaining_team_requests_for_model + litellm_llm_api_failed_requests_metric = [ UserAPIKeyLabelNames.END_USER.value, UserAPIKeyLabelNames.API_KEY_HASH.value, diff --git a/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py b/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py new file mode 100644 index 00000000000..820c4b75ea1 --- /dev/null +++ b/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py @@ -0,0 +1,256 @@ +""" +Tests for the team-scoped rate limit Prometheus gauges. + +LiteLLM exposed configured/remaining rate limits at virtual key scope +(``litellm_remaining_api_key_*_for_model``) and deployment scope +(``litellm_deployment_{tpm,rpm}_limit``) but not at team scope, so there was +no way to alert on a team approaching the ``model_tpm_limit`` / +``model_rpm_limit`` configured on its team object. + +The v3 rate limiter already computes those numbers for its ``model_per_team`` +descriptor and ships them to clients as +``x-ratelimit-model_per_team-{remaining,limit}-{requests,tokens}``. These +tests cover routing those already-computed values to Prometheus. +""" + +from typing import get_args +from unittest.mock import MagicMock, patch + +import pytest + +from litellm.integrations.prometheus import PrometheusLogger +from litellm.proxy.hooks.parallel_request_limiter_v3 import ( + _PROXY_MaxParallelRequestsHandler_v3, +) +from litellm.types.integrations.prometheus import ( + DEFINED_PROMETHEUS_METRICS, + PrometheusMetricLabels, + UserAPIKeyLabelNames, +) + +TEAM_RATE_LIMIT_METRICS = ( + "litellm_remaining_team_requests_for_model", + "litellm_remaining_team_tokens_for_model", + "litellm_team_rpm_limit", + "litellm_team_tpm_limit", +) + + +def _logger_with_mock_team_gauges(labels_are_real: bool = False) -> PrometheusLogger: + with patch("litellm.integrations.prometheus.PrometheusLogger.__init__", return_value=None): + logger = PrometheusLogger() + for metric_name in TEAM_RATE_LIMIT_METRICS: + setattr(logger, metric_name, MagicMock()) + if labels_are_real: + logger.get_labels_for_metric = MagicMock(side_effect=PrometheusMetricLabels.get_labels) + else: + logger.get_labels_for_metric = MagicMock(return_value=[]) + return logger + + +def _payload_with_headers(additional_headers: dict) -> dict: + return { + "metadata": {}, + "hidden_params": {"additional_headers": additional_headers}, + } + + +def _set_team_metrics(logger: PrometheusLogger, standard_logging_payload: dict) -> None: + logger._set_team_rate_limit_metrics( + user_api_team="team-abc", + user_api_team_alias="research", + model_group="gpt-4o-mini", + standard_logging_payload=standard_logging_payload, + ) + + +def _assert_set_once(logger: PrometheusLogger, metric_name: str, value: int) -> None: + getattr(logger, metric_name).labels.return_value.set.assert_called_once_with(value) + + +ALL_TEAM_HEADERS = { + "x-ratelimit-model_per_team-remaining-requests": 42, + "x-ratelimit-model_per_team-remaining-tokens": 900, + "x-ratelimit-model_per_team-limit-requests": 100, + "x-ratelimit-model_per_team-limit-tokens": 1000, +} + + +def test_team_metrics_are_defined_with_team_and_model_labels(): + defined_metrics = get_args(DEFINED_PROMETHEUS_METRICS) + expected_labels = [ + UserAPIKeyLabelNames.TEAM.value, + UserAPIKeyLabelNames.TEAM_ALIAS.value, + UserAPIKeyLabelNames.v1_LITELLM_MODEL_NAME.value, + ] + + for metric_name in TEAM_RATE_LIMIT_METRICS: + assert metric_name in defined_metrics + labels = PrometheusMetricLabels.get_labels(metric_name) + for expected_label in expected_labels: + assert expected_label in labels + + +def test_every_logger_owned_metric_resolves_labels(): + """ + ``PrometheusMetricLabels.get_labels`` resolves a metric name to a label + list via ``getattr``, so a metric added to the literal without a matching + label attribute fails at logger construction time in production rather + than at lint time. + + ``litellm_in_flight_requests`` is excluded because it is a label-free + gauge registered by the in-flight middleware, not by ``PrometheusLogger``; + it appears in the literal only so ``prometheus_metrics_config`` can name it. + """ + for metric_name in get_args(DEFINED_PROMETHEUS_METRICS): + if metric_name == "litellm_in_flight_requests": + continue + assert isinstance(PrometheusMetricLabels.get_labels(metric_name), list) + + +def test_sets_every_team_gauge_from_v3_headers(): + logger = _logger_with_mock_team_gauges() + + _set_team_metrics(logger, _payload_with_headers(dict(ALL_TEAM_HEADERS))) + + _assert_set_once(logger, "litellm_remaining_team_requests_for_model", 42) + _assert_set_once(logger, "litellm_remaining_team_tokens_for_model", 900) + _assert_set_once(logger, "litellm_team_rpm_limit", 100) + _assert_set_once(logger, "litellm_team_tpm_limit", 1000) + + +def test_labels_carry_team_and_requested_model(): + logger = _logger_with_mock_team_gauges(labels_are_real=True) + + _set_team_metrics(logger, _payload_with_headers(dict(ALL_TEAM_HEADERS))) + + for metric_name in TEAM_RATE_LIMIT_METRICS: + labels_kwargs = getattr(logger, metric_name).labels.call_args.kwargs + assert labels_kwargs["team"] == "team-abc" + assert labels_kwargs["team_alias"] == "research" + assert labels_kwargs["model"] == "gpt-4o-mini" + + +def test_emits_nothing_when_team_has_no_configured_limits(): + """A team without per-model limits gets no descriptor, so no header, so no series.""" + logger = _logger_with_mock_team_gauges() + + _set_team_metrics( + logger, + _payload_with_headers( + { + "x-ratelimit-model_per_key-remaining-requests": 42, + "x-ratelimit-model_per_key-limit-requests": 100, + } + ), + ) + + for metric_name in TEAM_RATE_LIMIT_METRICS: + getattr(logger, metric_name).labels.assert_not_called() + + +def test_emits_only_the_dimension_the_team_configured(): + """A team with only an RPM limit must not get a fabricated TPM series.""" + logger = _logger_with_mock_team_gauges() + + _set_team_metrics( + logger, + _payload_with_headers( + { + "x-ratelimit-model_per_team-remaining-requests": 7, + "x-ratelimit-model_per_team-limit-requests": 60, + } + ), + ) + + _assert_set_once(logger, "litellm_remaining_team_requests_for_model", 7) + _assert_set_once(logger, "litellm_team_rpm_limit", 60) + logger.litellm_remaining_team_tokens_for_model.labels.assert_not_called() + logger.litellm_team_tpm_limit.labels.assert_not_called() + + +def test_emits_zero_remaining_rather_than_skipping_it(): + """An exhausted team is the case operators alert on, so 0 must be a real sample.""" + logger = _logger_with_mock_team_gauges() + + _set_team_metrics( + logger, + _payload_with_headers( + { + "x-ratelimit-model_per_team-remaining-requests": 0, + "x-ratelimit-model_per_team-remaining-tokens": 0, + } + ), + ) + + _assert_set_once(logger, "litellm_remaining_team_requests_for_model", 0) + _assert_set_once(logger, "litellm_remaining_team_tokens_for_model", 0) + + +def test_emits_nothing_for_a_request_with_no_team(): + logger = _logger_with_mock_team_gauges() + + logger._set_team_rate_limit_metrics( + user_api_team=None, + user_api_team_alias=None, + model_group="gpt-4o-mini", + standard_logging_payload=_payload_with_headers(dict(ALL_TEAM_HEADERS)), + ) + + for metric_name in TEAM_RATE_LIMIT_METRICS: + getattr(logger, metric_name).labels.assert_not_called() + + +@pytest.mark.parametrize("bad_value", ["100", None, True, 12.5]) +def test_ignores_non_int_header_values(bad_value): + logger = _logger_with_mock_team_gauges() + + _set_team_metrics( + logger, + _payload_with_headers({"x-ratelimit-model_per_team-remaining-requests": bad_value}), + ) + + logger.litellm_remaining_team_requests_for_model.labels.assert_not_called() + + +def test_raises_nothing_when_payload_has_no_hidden_params(): + logger = _logger_with_mock_team_gauges() + + _set_team_metrics(logger, {"metadata": {}}) + + for metric_name in TEAM_RATE_LIMIT_METRICS: + getattr(logger, metric_name).labels.assert_not_called() + + +def test_limiter_publishes_team_headers_in_the_shape_the_gauges_read(): + """ + Pins the producer/consumer contract: the gauges read header names the v3 + limiter builds from ``descriptor_key`` + ``rate_limit_type``, so a change + to that format would otherwise silently stop the team series. + """ + headers = _PROXY_MaxParallelRequestsHandler_v3._merge_ratelimit_statuses_into_additional_headers( + additional_headers={}, + statuses=[ + { + "code": "OK", + "current_limit": 100, + "limit_remaining": 42, + "rate_limit_type": "requests", + "descriptor_key": "model_per_team", + }, + { + "code": "OK", + "current_limit": 1000, + "limit_remaining": 900, + "rate_limit_type": "tokens", + "descriptor_key": "model_per_team", + }, + ], + ) + + assert headers == { + "x-ratelimit-model_per_team-remaining-requests": 42, + "x-ratelimit-model_per_team-limit-requests": 100, + "x-ratelimit-model_per_team-remaining-tokens": 900, + "x-ratelimit-model_per_team-limit-tokens": 1000, + } From 8ead29498c08cdbb55e5df79b38fbc0755f8f7d1 Mon Sep 17 00:00:00 2001 From: DanBrima <40828002+DanBrima@users.noreply.github.com> Date: Tue, 18 Aug 2026 04:34:38 +0000 Subject: [PATCH 02/10] fix(prometheus): drop stale team rate limit series when a limit is removed Prometheus keeps a child series for the life of the process once emitted. The gauges only wrote a value when the v3 limiter reported one, so a team whose model_rpm_limit / model_tpm_limit was removed kept publishing the last remaining and limit values it ever saw, and headroom alerts kept evaluating against a limit no longer being enforced. Drop the child series instead of returning early when the header is absent. NoOpMetric and the label-excluding wrapper gain remove() so the drop still applies when metrics are disabled or labels are filtered. Always reaching the gauge attributes means async_log_success_event now touches them on every team request, so the partially initialized logger in test_prometheus_client_ip_user_agent stubs the new method alongside the sibling rate limit one it already stubbed. --- litellm/integrations/prometheus.py | 60 ++++++++++++------- litellm/types/integrations/prometheus.py | 3 + .../test_prometheus_client_ip_user_agent.py | 1 + ...test_prometheus_team_rate_limit_metrics.py | 30 ++++++++++ 4 files changed, 72 insertions(+), 22 deletions(-) diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py index 42a9148066f..59b395cceb7 100644 --- a/litellm/integrations/prometheus.py +++ b/litellm/integrations/prometheus.py @@ -95,6 +95,13 @@ class _ExcludedLabelMetric: ) return self._metric.labels(*kept_values) if kept_values else self._metric + def remove(self, *labelvalues: str) -> None: + kept_values: Final = tuple( + value for name, value in zip(self._original_labelnames, labelvalues) if name not in self._excluded_labels + ) + if kept_values: + self._metric.remove(*kept_values) + def _get_budget_metrics_per_request_timeout() -> float: raw: Final = os.getenv("PROMETHEUS_BUDGET_METRICS_PER_REQUEST_TIMEOUT") @@ -2071,22 +2078,6 @@ class PrometheusLogger(CustomLogger): if user_api_team is None: return - configured: Final = tuple( - (metric_name, value) - for metric_name, value_type, rate_limit_type in _TEAM_RATE_LIMIT_GAUGE_SPECS - if ( - value := self._get_v3_rate_limit_header( - standard_logging_payload=standard_logging_payload, - descriptor_key="model_per_team", - value_type=value_type, - rate_limit_type=rate_limit_type, - ) - ) - is not None - ) - if not configured: - return - enum_values: Final = UserAPIKeyLabelValues( team=user_api_team, team_alias=user_api_team_alias, @@ -2099,29 +2090,54 @@ class PrometheusLogger(CustomLogger): ) label_context: Final = PrometheusLabelFactoryContext(enum_values) - for metric_name, value in configured: - self._set_team_rate_limit_gauge( + for metric_name, value_type, rate_limit_type in _TEAM_RATE_LIMIT_GAUGE_SPECS: + self._sync_team_rate_limit_gauge( gauge=getattr(self, metric_name), metric_name=metric_name, - value=value, + value=self._get_v3_rate_limit_header( + standard_logging_payload=standard_logging_payload, + descriptor_key="model_per_team", + value_type=value_type, + rate_limit_type=rate_limit_type, + ), enum_values=enum_values, label_context=label_context, ) - def _set_team_rate_limit_gauge( + def _sync_team_rate_limit_gauge( self, gauge: _LabeledGauge, metric_name: DEFINED_PROMETHEUS_METRICS, - value: int, + value: int | None, enum_values: UserAPIKeyLabelValues, label_context: PrometheusLabelFactoryContext, ) -> None: + """ + Set the gauge, or drop its child series when this team has no limit + configured for this model. Prometheus keeps a child series for the + life of the process once emitted, so without the drop a team whose + limit is removed would keep publishing the last remaining/limit + values it ever saw, and alerts would evaluate against a number no + longer being enforced. + """ labels: Final = prometheus_label_factory( supported_enum_labels=self.get_labels_for_metric(metric_name), enum_values=enum_values, label_context=label_context, ) - gauge.labels(**labels).set(value) + if value is not None: + gauge.labels(**labels).set(value) + return + + remove: Final = getattr(gauge, "remove", None) + if remove is None: + return + try: + remove(*(labels[name] for name in self.get_labels_for_metric(metric_name))) + except KeyError: + # No child series for this labelset, which is the common case: + # the team never had a limit for this model. + pass def _set_latency_metrics( self, diff --git a/litellm/types/integrations/prometheus.py b/litellm/types/integrations/prometheus.py index 9fe51f026e9..0b1288de3f1 100644 --- a/litellm/types/integrations/prometheus.py +++ b/litellm/types/integrations/prometheus.py @@ -981,6 +981,9 @@ class NoOpMetric: def labels(self, *args, **kwargs): return self + def remove(self, *labelvalues: object) -> None: + pass + def inc(self, *args, **kwargs) -> None: pass diff --git a/tests/test_litellm/integrations/test_prometheus_client_ip_user_agent.py b/tests/test_litellm/integrations/test_prometheus_client_ip_user_agent.py index 029b097cb75..08612110ca5 100644 --- a/tests/test_litellm/integrations/test_prometheus_client_ip_user_agent.py +++ b/tests/test_litellm/integrations/test_prometheus_client_ip_user_agent.py @@ -93,6 +93,7 @@ async def test_async_post_call_success_hook_includes_client_ip_user_agent(): logger._increment_token_metrics = MagicMock() logger._increment_remaining_budget_metrics = AsyncMock() logger._set_virtual_key_rate_limit_metrics = MagicMock() + logger._set_team_rate_limit_metrics = MagicMock() logger._set_latency_metrics = MagicMock() logger.set_llm_deployment_success_metrics = MagicMock() logger._increment_cache_metrics = MagicMock() diff --git a/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py b/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py index 820c4b75ea1..84a1a7b7405 100644 --- a/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py +++ b/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py @@ -149,6 +149,36 @@ def test_emits_nothing_when_team_has_no_configured_limits(): getattr(logger, metric_name).labels.assert_not_called() +def test_drops_stale_series_when_a_team_limit_is_removed(): + """ + Prometheus keeps a child series for the life of the process once emitted, + so a team whose limit is removed would otherwise keep publishing the last + values it saw and alerts would fire on a limit nobody enforces. + """ + logger = _logger_with_mock_team_gauges(labels_are_real=True) + + _set_team_metrics(logger, _payload_with_headers(dict(ALL_TEAM_HEADERS))) + _assert_set_once(logger, "litellm_remaining_team_requests_for_model", 42) + + _set_team_metrics(logger, _payload_with_headers({})) + + for metric_name in TEAM_RATE_LIMIT_METRICS: + gauge = getattr(logger, metric_name) + gauge.remove.assert_called_once_with("team-abc", "research", "gpt-4o-mini") + + +def test_survives_removing_a_series_that_was_never_emitted(): + """The common case: a team that never had a limit for this model.""" + logger = _logger_with_mock_team_gauges() + for metric_name in TEAM_RATE_LIMIT_METRICS: + getattr(logger, metric_name).remove.side_effect = KeyError("not present") + + _set_team_metrics(logger, _payload_with_headers({})) + + for metric_name in TEAM_RATE_LIMIT_METRICS: + getattr(logger, metric_name).labels.assert_not_called() + + def test_emits_only_the_dimension_the_team_configured(): """A team with only an RPM limit must not get a fabricated TPM series.""" logger = _logger_with_mock_team_gauges() From 666bf1bf6aa2bfbd5a5d25ab0bb176ca79546786 Mon Sep 17 00:00:00 2001 From: DanBrima <40828002+DanBrima@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:06:15 +0000 Subject: [PATCH 03/10] test(prometheus): cover team gauge removal against real prometheus clients The removal paths were only exercised through MagicMock gauges, so prometheus_client's own child-series bookkeeping was never involved and Codecov reported the wrapper remove() methods as uncovered. Drive real Gauge objects on a private CollectorRegistry instead: set a team series, drop it, and assert the sample is gone from the registry. Also pin the label-exclusion behaviour, including the degenerate case where every label is excluded and the gauge collapses to a single unlabeled sample that has no child series to remove. The getattr guard around remove() is gone: every gauge type reaching it now implements remove(), so the branch was unreachable. --- litellm/integrations/prometheus.py | 7 +- ...test_prometheus_team_rate_limit_metrics.py | 78 ++++++++++++++++++- 2 files changed, 80 insertions(+), 5 deletions(-) diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py index 59b395cceb7..848dba2d32a 100644 --- a/litellm/integrations/prometheus.py +++ b/litellm/integrations/prometheus.py @@ -128,6 +128,8 @@ class _LabeledGauge(Protocol): def set(self, value: float) -> None: ... + def remove(self, *labelvalues: str) -> None: ... + _TEAM_RATE_LIMIT_GAUGE_SPECS: Final[ tuple[ @@ -2129,11 +2131,8 @@ class PrometheusLogger(CustomLogger): gauge.labels(**labels).set(value) return - remove: Final = getattr(gauge, "remove", None) - if remove is None: - return try: - remove(*(labels[name] for name in self.get_labels_for_metric(metric_name))) + gauge.remove(*(labels[name] for name in self.get_labels_for_metric(metric_name))) except KeyError: # No child series for this labelset, which is the common case: # the team never had a limit for this model. diff --git a/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py b/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py index 84a1a7b7405..13688e97611 100644 --- a/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py +++ b/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py @@ -18,16 +18,22 @@ from unittest.mock import MagicMock, patch import pytest -from litellm.integrations.prometheus import PrometheusLogger +from prometheus_client import CollectorRegistry, Gauge + +from litellm.integrations.prometheus import PrometheusLogger, _ExcludedLabelMetric from litellm.proxy.hooks.parallel_request_limiter_v3 import ( _PROXY_MaxParallelRequestsHandler_v3, ) from litellm.types.integrations.prometheus import ( DEFINED_PROMETHEUS_METRICS, + NoOpMetric, PrometheusMetricLabels, UserAPIKeyLabelNames, ) +TEAM_LABELS = {"team": "team-abc", "team_alias": "research", "model": "gpt-4o-mini"} +ORIGINAL_LABELNAMES = ("team", "team_alias", "model") + TEAM_RATE_LIMIT_METRICS = ( "litellm_remaining_team_requests_for_model", "litellm_remaining_team_tokens_for_model", @@ -284,3 +290,73 @@ def test_limiter_publishes_team_headers_in_the_shape_the_gauges_read(): "x-ratelimit-model_per_team-remaining-tokens": 900, "x-ratelimit-model_per_team-limit-tokens": 1000, } + + +def _logger_with_real_gauge(metric_name: str, gauge: Gauge) -> PrometheusLogger: + logger = _logger_with_mock_team_gauges(labels_are_real=True) + setattr(logger, metric_name, gauge) + return logger + + +def test_removes_a_real_prometheus_child_series_when_the_limit_disappears(): + """ + Mock gauges cannot prove the drop works, since prometheus_client owns the + child-series bookkeeping. This drives the real Gauge end to end. + """ + registry = CollectorRegistry() + gauge = Gauge("litellm_team_rpm_limit", "doc", labelnames=list(ORIGINAL_LABELNAMES), registry=registry) + logger = _logger_with_real_gauge("litellm_team_rpm_limit", gauge) + + _set_team_metrics(logger, _payload_with_headers({"x-ratelimit-model_per_team-limit-requests": 60})) + assert registry.get_sample_value("litellm_team_rpm_limit", TEAM_LABELS) == 60 + + _set_team_metrics(logger, _payload_with_headers({})) + assert registry.get_sample_value("litellm_team_rpm_limit", TEAM_LABELS) is None + + +def test_removing_a_never_emitted_real_series_raises_nothing(): + registry = CollectorRegistry() + gauge = Gauge("litellm_team_tpm_limit", "doc", labelnames=list(ORIGINAL_LABELNAMES), registry=registry) + logger = _logger_with_real_gauge("litellm_team_tpm_limit", gauge) + + _set_team_metrics(logger, _payload_with_headers({})) + + assert registry.get_sample_value("litellm_team_tpm_limit", TEAM_LABELS) is None + + +def test_excluded_label_wrapper_sets_and_removes_using_the_kept_labels(): + registry = CollectorRegistry() + real = Gauge("litellm_team_rpm_limit", "doc", labelnames=["team", "model"], registry=registry) + wrapper = _ExcludedLabelMetric(real, ORIGINAL_LABELNAMES, frozenset({"team_alias"})) + kept = {"team": "team-abc", "model": "gpt-4o-mini"} + + wrapper.labels(**TEAM_LABELS).set(60) + assert registry.get_sample_value("litellm_team_rpm_limit", kept) == 60 + + wrapper.remove(*(TEAM_LABELS[name] for name in ORIGINAL_LABELNAMES)) + assert registry.get_sample_value("litellm_team_rpm_limit", kept) is None + + +def test_excluded_label_wrapper_cannot_remove_when_every_label_is_excluded(): + """ + With every label excluded the gauge collapses to a single unlabeled sample, + which has no child series for prometheus_client to remove. Such a metric + cannot represent per-team state at all, so there is no correct value to + drop it to; this pins the behaviour rather than papering over it. + """ + registry = CollectorRegistry() + real = Gauge("litellm_team_rpm_limit", "doc", registry=registry) + wrapper = _ExcludedLabelMetric(real, ORIGINAL_LABELNAMES, frozenset(ORIGINAL_LABELNAMES)) + + wrapper.labels(**TEAM_LABELS).set(60) + assert registry.get_sample_value("litellm_team_rpm_limit", {}) == 60 + + wrapper.remove(*(TEAM_LABELS[name] for name in ORIGINAL_LABELNAMES)) + assert registry.get_sample_value("litellm_team_rpm_limit", {}) == 60 + + +def test_noop_metric_remove_is_inert(): + metric = NoOpMetric() + + metric.labels(**TEAM_LABELS).set(60) + metric.remove(*TEAM_LABELS.values()) From 6aac068555a017248ac98d9fa0a04881663c4be0 Mon Sep 17 00:00:00 2001 From: DanBrima <40828002+DanBrima@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:22:58 +0000 Subject: [PATCH 04/10] fix(prometheus): retire superseded team rate limit series Two ways a team gauge could keep publishing values nobody enforces. Renaming a team changes the team_alias label, which starts a new child series and leaves the old one holding the values it had at rename time, double counting the team on any sum over team. Before setting a series, retire any series for the same team and model under a different alias. Excluding the team label collapses the gauge to a single sample shared by every team, attributing a limit to nobody and leaving nothing that can be retired. Emit nothing in that configuration rather than a number that silently belongs to whichever team wrote it last. Team tests now resolve real label sets instead of an empty list, which is what the metrics are actually constructed with. --- litellm/integrations/prometheus.py | 47 ++++++++++- ...test_prometheus_team_rate_limit_metrics.py | 78 +++++++++++++++++-- 2 files changed, 115 insertions(+), 10 deletions(-) diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py index 848dba2d32a..ac008e3714a 100644 --- a/litellm/integrations/prometheus.py +++ b/litellm/integrations/prometheus.py @@ -2122,22 +2122,65 @@ class PrometheusLogger(CustomLogger): values it ever saw, and alerts would evaluate against a number no longer being enforced. """ + labelnames: Final = self.get_labels_for_metric(metric_name) + if UserAPIKeyLabelNames.TEAM.value not in labelnames: + # Without a team label the gauge collapses to one sample shared by + # every team, which cannot attribute a limit to anyone and cannot + # be retired. Publishing nothing beats publishing a number that + # silently belongs to whichever team wrote it last. + return + labels: Final = prometheus_label_factory( - supported_enum_labels=self.get_labels_for_metric(metric_name), + supported_enum_labels=labelnames, enum_values=enum_values, label_context=label_context, ) if value is not None: + self._drop_superseded_team_series(gauge=gauge, labelnames=labelnames, labels=labels) gauge.labels(**labels).set(value) return try: - gauge.remove(*(labels[name] for name in self.get_labels_for_metric(metric_name))) + gauge.remove(*(labels.get(name, "") for name in labelnames)) except KeyError: # No child series for this labelset, which is the common case: # the team never had a limit for this model. pass + def _drop_superseded_team_series( + self, + gauge: _LabeledGauge, + labelnames: Sequence[str], + labels: Mapping[str, str], + ) -> None: + """ + Retire child series that describe this same team and model under a + different alias. Renaming a team changes ``team_alias``, which starts a + new series, and the old one would otherwise keep publishing the values + it held at rename time, double counting the team on any sum over + ``team``. + """ + collect: Final = getattr(gauge, "collect", None) + if collect is None: + return + + team_label: Final = UserAPIKeyLabelNames.TEAM.value + alias_label: Final = UserAPIKeyLabelNames.TEAM_ALIAS.value + model_label: Final = UserAPIKeyLabelNames.v1_LITELLM_MODEL_NAME.value + superseded: Final = tuple( + tuple(sample.labels.get(name, "") for name in labelnames) + for metric in collect() + for sample in metric.samples + if sample.labels.get(team_label) == labels.get(team_label) + and sample.labels.get(model_label) == labels.get(model_label) + and sample.labels.get(alias_label) != labels.get(alias_label) + ) + for label_values in superseded: + try: + gauge.remove(*label_values) + except KeyError: + pass + def _set_latency_metrics( self, kwargs: dict, diff --git a/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py b/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py index 13688e97611..041ad02c1c1 100644 --- a/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py +++ b/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py @@ -42,15 +42,12 @@ TEAM_RATE_LIMIT_METRICS = ( ) -def _logger_with_mock_team_gauges(labels_are_real: bool = False) -> PrometheusLogger: +def _logger_with_mock_team_gauges() -> PrometheusLogger: with patch("litellm.integrations.prometheus.PrometheusLogger.__init__", return_value=None): logger = PrometheusLogger() for metric_name in TEAM_RATE_LIMIT_METRICS: setattr(logger, metric_name, MagicMock()) - if labels_are_real: - logger.get_labels_for_metric = MagicMock(side_effect=PrometheusMetricLabels.get_labels) - else: - logger.get_labels_for_metric = MagicMock(return_value=[]) + logger.get_labels_for_metric = MagicMock(side_effect=PrometheusMetricLabels.get_labels) return logger @@ -126,7 +123,7 @@ def test_sets_every_team_gauge_from_v3_headers(): def test_labels_carry_team_and_requested_model(): - logger = _logger_with_mock_team_gauges(labels_are_real=True) + logger = _logger_with_mock_team_gauges() _set_team_metrics(logger, _payload_with_headers(dict(ALL_TEAM_HEADERS))) @@ -161,7 +158,7 @@ def test_drops_stale_series_when_a_team_limit_is_removed(): so a team whose limit is removed would otherwise keep publishing the last values it saw and alerts would fire on a limit nobody enforces. """ - logger = _logger_with_mock_team_gauges(labels_are_real=True) + logger = _logger_with_mock_team_gauges() _set_team_metrics(logger, _payload_with_headers(dict(ALL_TEAM_HEADERS))) _assert_set_once(logger, "litellm_remaining_team_requests_for_model", 42) @@ -293,7 +290,7 @@ def test_limiter_publishes_team_headers_in_the_shape_the_gauges_read(): def _logger_with_real_gauge(metric_name: str, gauge: Gauge) -> PrometheusLogger: - logger = _logger_with_mock_team_gauges(labels_are_real=True) + logger = _logger_with_mock_team_gauges() setattr(logger, metric_name, gauge) return logger @@ -360,3 +357,68 @@ def test_noop_metric_remove_is_inert(): metric.labels(**TEAM_LABELS).set(60) metric.remove(*TEAM_LABELS.values()) + + +def test_retires_the_old_series_when_a_team_is_renamed(): + """ + A rename changes team_alias, which starts a new series. The old one would + otherwise keep publishing the values it held at rename time, double + counting the team on any sum over `team`. + """ + registry = CollectorRegistry() + gauge = Gauge("litellm_team_rpm_limit", "doc", labelnames=list(ORIGINAL_LABELNAMES), registry=registry) + logger = _logger_with_real_gauge("litellm_team_rpm_limit", gauge) + headers = {"x-ratelimit-model_per_team-limit-requests": 60} + + _set_team_metrics(logger, _payload_with_headers(headers)) + assert registry.get_sample_value("litellm_team_rpm_limit", TEAM_LABELS) == 60 + + logger._set_team_rate_limit_metrics( + user_api_team="team-abc", + user_api_team_alias="ml-research", + model_group="gpt-4o-mini", + standard_logging_payload=_payload_with_headers(headers), + ) + + renamed = {**TEAM_LABELS, "team_alias": "ml-research"} + assert registry.get_sample_value("litellm_team_rpm_limit", renamed) == 60 + assert registry.get_sample_value("litellm_team_rpm_limit", TEAM_LABELS) is None + + +def test_keeps_other_teams_when_one_team_is_renamed(): + registry = CollectorRegistry() + gauge = Gauge("litellm_team_rpm_limit", "doc", labelnames=list(ORIGINAL_LABELNAMES), registry=registry) + logger = _logger_with_real_gauge("litellm_team_rpm_limit", gauge) + headers = {"x-ratelimit-model_per_team-limit-requests": 60} + + logger._set_team_rate_limit_metrics( + user_api_team="team-other", + user_api_team_alias="platform", + model_group="gpt-4o-mini", + standard_logging_payload=_payload_with_headers(headers), + ) + _set_team_metrics(logger, _payload_with_headers(headers)) + logger._set_team_rate_limit_metrics( + user_api_team="team-abc", + user_api_team_alias="ml-research", + model_group="gpt-4o-mini", + standard_logging_payload=_payload_with_headers(headers), + ) + + other = {"team": "team-other", "team_alias": "platform", "model": "gpt-4o-mini"} + assert registry.get_sample_value("litellm_team_rpm_limit", other) == 60 + + +def test_emits_nothing_when_the_team_label_is_excluded(): + """ + Without a team label the gauge collapses to one sample shared by every + team, which attributes a limit to nobody and cannot be retired. + """ + logger = _logger_with_mock_team_gauges() + logger.get_labels_for_metric = MagicMock(return_value=["model"]) + + _set_team_metrics(logger, _payload_with_headers(dict(ALL_TEAM_HEADERS))) + + for metric_name in TEAM_RATE_LIMIT_METRICS: + getattr(logger, metric_name).labels.assert_not_called() + getattr(logger, metric_name).remove.assert_not_called() From 38c2875b10c42edca109ab26421ccf9223c364ed Mon Sep 17 00:00:00 2001 From: DanBrima <40828002+DanBrima@users.noreply.github.com> Date: Tue, 18 Aug 2026 05:43:15 +0000 Subject: [PATCH 05/10] test(prometheus): pin that team gauges are never label-exclusion wrapped Alias cleanup reads the gauge's existing children through collect(), which the label-exclusion wrapper does not expose. That wrapper is unreachable for these metrics: get_labels_for_metric strips excluded labels before the labelnames reach the metric factory, and the factory only wraps when the labelnames it receives still intersect exclude_labels. The reasoning spans two distant pieces of code, so pin the invariant with a test rather than leaving a future refactor free to break the link. --- ...test_prometheus_team_rate_limit_metrics.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py b/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py index 041ad02c1c1..0a59b720133 100644 --- a/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py +++ b/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py @@ -422,3 +422,23 @@ def test_emits_nothing_when_the_team_label_is_excluded(): for metric_name in TEAM_RATE_LIMIT_METRICS: getattr(logger, metric_name).labels.assert_not_called() getattr(logger, metric_name).remove.assert_not_called() + + +def test_excluded_labels_never_reach_team_gauge_labelnames(): + """ + `exclude_labels` is applied inside `get_labels_for_metric`, so the + labelnames a team gauge is constructed with never contain an excluded + label. The factory only wraps a metric when its labelnames still intersect + `exclude_labels`, so these gauges are always real prometheus_client + Gauges and always expose `collect` for alias cleanup. + """ + with patch("litellm.integrations.prometheus.PrometheusLogger.__init__", return_value=None): + logger = PrometheusLogger() + logger.exclude_labels = frozenset({"model", "team_alias"}) + logger.label_filters = {} + logger._cached_metric_labels = {} + + for metric_name in TEAM_RATE_LIMIT_METRICS: + labelnames = logger.get_labels_for_metric(metric_name) + assert not frozenset(labelnames) & logger.exclude_labels + assert "team" in labelnames From a36e0d4cba37457fea79557e1c2ac7258cb6c609 Mon Sep 17 00:00:00 2001 From: DanBrima <40828002+DanBrima@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:29:24 +0000 Subject: [PATCH 06/10] fix(prometheus): pass gauge labels positionally after upstream merge Merging litellm_internal_staging put the codebase over two ceilings that this branch is responsible for one violation of each. The _LabeledGauge protocol declared labels(**labels), which LIT008 bans outright, and the keyword call also cost a reportGeneralTypeIssues. Both go away by passing label values positionally, which prometheus_client accepts and which the protocol's remove() already did, so the two calls are now consistent and the ordered values are computed once. Also assert observable output in the no-op metric test instead of relying on it not raising. --- litellm/integrations/prometheus.py | 7 ++++--- .../test_prometheus_team_rate_limit_metrics.py | 15 +++++++++------ 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py index 8a99daef02a..ce5ed7ee9dd 100644 --- a/litellm/integrations/prometheus.py +++ b/litellm/integrations/prometheus.py @@ -160,7 +160,7 @@ def _get_budget_metrics_per_request_timeout() -> float: class _LabeledGauge(Protocol): """Structural type shared by ``prometheus_client.Gauge`` and the no-op / label-excluding wrappers above.""" - def labels(self, **labels: str) -> _LabeledGauge: ... + def labels(self, *labelvalues: str) -> _LabeledGauge: ... def set(self, value: float) -> None: ... @@ -2171,13 +2171,14 @@ class PrometheusLogger(CustomLogger): enum_values=enum_values, label_context=label_context, ) + label_values: Final = tuple(labels.get(name, "") for name in labelnames) if value is not None: self._drop_superseded_team_series(gauge=gauge, labelnames=labelnames, labels=labels) - gauge.labels(**labels).set(value) + gauge.labels(*label_values).set(value) return try: - gauge.remove(*(labels.get(name, "") for name in labelnames)) + gauge.remove(*label_values) except KeyError: # No child series for this labelset, which is the common case: # the team never had a limit for this model. diff --git a/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py b/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py index 0a59b720133..cbbcd9bba66 100644 --- a/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py +++ b/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py @@ -128,10 +128,9 @@ def test_labels_carry_team_and_requested_model(): _set_team_metrics(logger, _payload_with_headers(dict(ALL_TEAM_HEADERS))) for metric_name in TEAM_RATE_LIMIT_METRICS: - labels_kwargs = getattr(logger, metric_name).labels.call_args.kwargs - assert labels_kwargs["team"] == "team-abc" - assert labels_kwargs["team_alias"] == "research" - assert labels_kwargs["model"] == "gpt-4o-mini" + labelnames = PrometheusMetricLabels.get_labels(metric_name) + label_values = getattr(logger, metric_name).labels.call_args.args + assert dict(zip(labelnames, label_values, strict=True)) == TEAM_LABELS def test_emits_nothing_when_team_has_no_configured_limits(): @@ -353,10 +352,14 @@ def test_excluded_label_wrapper_cannot_remove_when_every_label_is_excluded(): def test_noop_metric_remove_is_inert(): + """A disabled metric answers every call without recording or raising.""" metric = NoOpMetric() - metric.labels(**TEAM_LABELS).set(60) - metric.remove(*TEAM_LABELS.values()) + child = metric.labels(*TEAM_LABELS.values()) + + assert child is metric + assert child.set(60) is None + assert metric.remove(*TEAM_LABELS.values()) is None def test_retires_the_old_series_when_a_team_is_renamed(): From 9883474d7215fcc2b6e1360ede27006a3f7e7d5e Mon Sep 17 00:00:00 2001 From: DanBrima <40828002+DanBrima@users.noreply.github.com> Date: Sat, 22 Aug 2026 10:26:17 +0000 Subject: [PATCH 07/10] fix(prometheus): sweep superseded aliases on the removal path too A team can be renamed and then have its per-model limit removed before it sends another limited request. The removal path only knows the current labelset, so the pre-rename series was never retired and kept publishing a limit nobody enforces. Hoist the superseded-alias sweep above the branch so it runs whether the gauge is being set or dropped, which also removes a duplicated call site. --- litellm/integrations/prometheus.py | 6 ++++- ...test_prometheus_team_rate_limit_metrics.py | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py index ce5ed7ee9dd..4425bfcecd9 100644 --- a/litellm/integrations/prometheus.py +++ b/litellm/integrations/prometheus.py @@ -2157,6 +2157,10 @@ class PrometheusLogger(CustomLogger): limit is removed would keep publishing the last remaining/limit values it ever saw, and alerts would evaluate against a number no longer being enforced. + + Superseded aliases are swept on both paths, because a team can be + renamed and then have its limit removed before it sends another + limited request, which would otherwise strand the pre-rename series. """ labelnames: Final = self.get_labels_for_metric(metric_name) if UserAPIKeyLabelNames.TEAM.value not in labelnames: @@ -2172,8 +2176,8 @@ class PrometheusLogger(CustomLogger): label_context=label_context, ) label_values: Final = tuple(labels.get(name, "") for name in labelnames) + self._drop_superseded_team_series(gauge=gauge, labelnames=labelnames, labels=labels) if value is not None: - self._drop_superseded_team_series(gauge=gauge, labelnames=labelnames, labels=labels) gauge.labels(*label_values).set(value) return diff --git a/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py b/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py index cbbcd9bba66..fbee2ba58c4 100644 --- a/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py +++ b/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py @@ -445,3 +445,29 @@ def test_excluded_labels_never_reach_team_gauge_labelnames(): labelnames = logger.get_labels_for_metric(metric_name) assert not frozenset(labelnames) & logger.exclude_labels assert "team" in labelnames + + +def test_retires_the_old_alias_when_the_limit_is_removed_after_a_rename(): + """ + A team can be renamed and then have its limit removed before it sends + another limited request. The removal path only knows the current + labelset, so without sweeping superseded aliases on that path too, the + pre-rename series would stay published forever. + """ + registry = CollectorRegistry() + gauge = Gauge("litellm_team_rpm_limit", "doc", labelnames=list(ORIGINAL_LABELNAMES), registry=registry) + logger = _logger_with_real_gauge("litellm_team_rpm_limit", gauge) + + _set_team_metrics(logger, _payload_with_headers({"x-ratelimit-model_per_team-limit-requests": 60})) + assert registry.get_sample_value("litellm_team_rpm_limit", TEAM_LABELS) == 60 + + logger._set_team_rate_limit_metrics( + user_api_team="team-abc", + user_api_team_alias="ml-research", + model_group="gpt-4o-mini", + standard_logging_payload=_payload_with_headers({}), + ) + + assert registry.get_sample_value("litellm_team_rpm_limit", TEAM_LABELS) is None + renamed = {**TEAM_LABELS, "team_alias": "ml-research"} + assert registry.get_sample_value("litellm_team_rpm_limit", renamed) is None From 359aa247742b079a842bb8d762480938665d9110 Mon Sep 17 00:00:00 2001 From: DanBrima <40828002+DanBrima@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:17:50 +0000 Subject: [PATCH 08/10] perf(prometheus): track the last team labelset instead of scanning Retiring a renamed team's old series scanned the metric's children on every team request, once per gauge. That cost work proportional to the total number of team series ever emitted, and any authenticated caller could amplify it with ordinary traffic. Remember the last labelset emitted per (metric, team, model) and retire that one directly, which is O(1) and drops the registry scan entirely. The map is also the reason the gauges no longer need collect(), so the label-exclusion wrapper is no longer involved in cleanup at all. --- litellm/integrations/prometheus.py | 69 ++++++++++++------- ...test_prometheus_team_rate_limit_metrics.py | 1 + 2 files changed, 46 insertions(+), 24 deletions(-) diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py index 4425bfcecd9..f3a55a5204d 100644 --- a/litellm/integrations/prometheus.py +++ b/litellm/integrations/prometheus.py @@ -7,7 +7,7 @@ import asyncio import math import os import sys -from collections.abc import Awaitable, Callable, Mapping, Sequence +from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any, Final, Literal, Protocol, TypeVar, cast @@ -228,6 +228,11 @@ class PrometheusLogger(CustomLogger): _custom_buckets: Final = litellm.prometheus_latency_buckets self.latency_buckets = tuple(_custom_buckets) if _custom_buckets is not None else LATENCY_BUCKETS self._bounded_prometheus_series_tracker = BoundedPrometheusSeriesTracker() + # Last labelset emitted per (metric, team, model), so a renamed team's + # previous series can be retired without scanning the registry. + self._team_series_label_values: MutableMapping[ # mutable-ok: per-process emission state, rewritten as teams are renamed + tuple[str, str, str], tuple[str, ...] + ] = {} # Create metric factory functions self._counter_factory = self._create_metric_factory(Counter) @@ -2176,11 +2181,14 @@ class PrometheusLogger(CustomLogger): label_context=label_context, ) label_values: Final = tuple(labels.get(name, "") for name in labelnames) - self._drop_superseded_team_series(gauge=gauge, labelnames=labelnames, labels=labels) + self._drop_superseded_team_series( + gauge=gauge, metric_name=metric_name, labels=labels, label_values=label_values + ) if value is not None: gauge.labels(*label_values).set(value) return + self._forget_team_series(metric_name=metric_name, labels=labels) try: gauge.remove(*label_values) except KeyError: @@ -2191,36 +2199,49 @@ class PrometheusLogger(CustomLogger): def _drop_superseded_team_series( self, gauge: _LabeledGauge, - labelnames: Sequence[str], + metric_name: DEFINED_PROMETHEUS_METRICS, labels: Mapping[str, str], + label_values: tuple[str, ...], ) -> None: """ - Retire child series that describe this same team and model under a - different alias. Renaming a team changes ``team_alias``, which starts a - new series, and the old one would otherwise keep publishing the values - it held at rename time, double counting the team on any sum over - ``team``. - """ - collect: Final = getattr(gauge, "collect", None) - if collect is None: - return + Retire the series this team and model last published under a different + alias. Renaming a team changes ``team_alias``, which starts a new + series, and the old one would otherwise keep publishing the values it + held at rename time, double counting the team on any sum over ``team``. - team_label: Final = UserAPIKeyLabelNames.TEAM.value - alias_label: Final = UserAPIKeyLabelNames.TEAM_ALIAS.value - model_label: Final = UserAPIKeyLabelNames.v1_LITELLM_MODEL_NAME.value - superseded: Final = tuple( - tuple(sample.labels.get(name, "") for name in labelnames) - for metric in collect() - for sample in metric.samples - if sample.labels.get(team_label) == labels.get(team_label) - and sample.labels.get(model_label) == labels.get(model_label) - and sample.labels.get(alias_label) != labels.get(alias_label) + The previously emitted labelset is remembered per (metric, team, model) + rather than found by scanning the registry. A scan would cost every + team request work proportional to the total number of team series ever + emitted, which any authenticated caller could amplify by sending + ordinary traffic. + """ + identity: Final = ( + metric_name, + labels.get(UserAPIKeyLabelNames.TEAM.value, ""), + labels.get(UserAPIKeyLabelNames.v1_LITELLM_MODEL_NAME.value, ""), ) - for label_values in superseded: + previous: Final = self._team_series_label_values.get(identity) + if previous is not None and previous != label_values: try: - gauge.remove(*label_values) + gauge.remove(*previous) except KeyError: pass + self._team_series_label_values[identity] = label_values + + def _forget_team_series( + self, + metric_name: DEFINED_PROMETHEUS_METRICS, + labels: Mapping[str, str], + ) -> None: + """Stop tracking a (metric, team, model) whose series has been dropped.""" + self._team_series_label_values.pop( + ( + metric_name, + labels.get(UserAPIKeyLabelNames.TEAM.value, ""), + labels.get(UserAPIKeyLabelNames.v1_LITELLM_MODEL_NAME.value, ""), + ), + None, + ) def _set_latency_metrics( self, diff --git a/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py b/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py index fbee2ba58c4..63d70a6def5 100644 --- a/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py +++ b/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py @@ -48,6 +48,7 @@ def _logger_with_mock_team_gauges() -> PrometheusLogger: for metric_name in TEAM_RATE_LIMIT_METRICS: setattr(logger, metric_name, MagicMock()) logger.get_labels_for_metric = MagicMock(side_effect=PrometheusMetricLabels.get_labels) + logger._team_series_label_values = {} return logger From 4e1d1fac2e8be3d463ff2cda136182598510f144 Mon Sep 17 00:00:00 2001 From: DanBrima <40828002+DanBrima@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:44:56 +0000 Subject: [PATCH 09/10] test(prometheus): cover retiring a tracked series that is already gone The tracked labelset can outlive the child series it names, for instance when a cardinality cap evicts it. Pin that retiring it does not break the emission that triggered the retirement, which was the last uncovered branch in this change. --- ...test_prometheus_team_rate_limit_metrics.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py b/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py index 63d70a6def5..a653865b6dc 100644 --- a/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py +++ b/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py @@ -472,3 +472,29 @@ def test_retires_the_old_alias_when_the_limit_is_removed_after_a_rename(): assert registry.get_sample_value("litellm_team_rpm_limit", TEAM_LABELS) is None renamed = {**TEAM_LABELS, "team_alias": "ml-research"} assert registry.get_sample_value("litellm_team_rpm_limit", renamed) is None + + +def test_rename_survives_a_tracked_series_that_is_already_gone(): + """ + The tracked labelset can outlive the child series it names, for instance + when a cardinality cap evicts it. Retiring it must not break the emission + that triggered the retirement. + """ + registry = CollectorRegistry() + gauge = Gauge("litellm_team_rpm_limit", "doc", labelnames=list(ORIGINAL_LABELNAMES), registry=registry) + logger = _logger_with_real_gauge("litellm_team_rpm_limit", gauge) + headers = {"x-ratelimit-model_per_team-limit-requests": 60} + + _set_team_metrics(logger, _payload_with_headers(headers)) + gauge.remove(*(TEAM_LABELS[name] for name in ORIGINAL_LABELNAMES)) + assert registry.get_sample_value("litellm_team_rpm_limit", TEAM_LABELS) is None + + logger._set_team_rate_limit_metrics( + user_api_team="team-abc", + user_api_team_alias="ml-research", + model_group="gpt-4o-mini", + standard_logging_payload=_payload_with_headers(headers), + ) + + renamed = {**TEAM_LABELS, "team_alias": "ml-research"} + assert registry.get_sample_value("litellm_team_rpm_limit", renamed) == 60 From 64fb3fa24ee2f67bb68dd9f840bb08ae85c22fa0 Mon Sep 17 00:00:00 2001 From: DanBrima <40828002+DanBrima@users.noreply.github.com> Date: Sat, 22 Aug 2026 14:50:30 +0000 Subject: [PATCH 10/10] fix(prometheus): skip series retirement under multiprocess collection prometheus_client refuses to remove a labelset when PROMETHEUS_MULTIPROC_DIR is set and warns instead, because each worker owns its own mmap file and cannot retire a series another worker wrote. LiteLLM enables that mode automatically for multi-worker deployments. Retirement was therefore inert there while still calling remove() on every team request without a limit, which only produced library warnings. Gate it on single-process collection, where it is tested to work, and leave the emission path unchanged so the gauges still populate under either mode. --- litellm/integrations/prometheus.py | 22 ++++++++-- ...test_prometheus_team_rate_limit_metrics.py | 40 +++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/litellm/integrations/prometheus.py b/litellm/integrations/prometheus.py index f3a55a5204d..6934b384977 100644 --- a/litellm/integrations/prometheus.py +++ b/litellm/integrations/prometheus.py @@ -184,6 +184,17 @@ _TEAM_RATE_LIMIT_GAUGE_SPECS: Final[ ) +def _series_retirement_supported() -> bool: + """ + ``prometheus_client`` refuses to remove a labelset in multiprocess mode and + warns when asked, because each worker owns its own mmap file and cannot + retire a series another worker wrote. Retirement is therefore a + single-process capability, and attempting it under multiprocess collection + would only emit warnings while leaving the sample in place. + """ + return not ("PROMETHEUS_MULTIPROC_DIR" in os.environ or "prometheus_multiproc_dir" in os.environ) + + class PrometheusLogger(CustomLogger): # Class variables or attributes @@ -2181,13 +2192,18 @@ class PrometheusLogger(CustomLogger): label_context=label_context, ) label_values: Final = tuple(labels.get(name, "") for name in labelnames) - self._drop_superseded_team_series( - gauge=gauge, metric_name=metric_name, labels=labels, label_values=label_values - ) + can_retire: Final = _series_retirement_supported() + if can_retire: + self._drop_superseded_team_series( + gauge=gauge, metric_name=metric_name, labels=labels, label_values=label_values + ) if value is not None: gauge.labels(*label_values).set(value) return + if not can_retire: + return + self._forget_team_series(metric_name=metric_name, labels=labels) try: gauge.remove(*label_values) diff --git a/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py b/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py index a653865b6dc..c244651bef6 100644 --- a/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py +++ b/tests/test_litellm/integrations/test_prometheus_team_rate_limit_metrics.py @@ -42,6 +42,16 @@ TEAM_RATE_LIMIT_METRICS = ( ) +@pytest.fixture(autouse=True) +def _single_process_collection(monkeypatch): + """ + Series retirement is only possible outside multiprocess collection, so pin + the mode rather than depending on whatever the ambient environment has set. + """ + monkeypatch.delenv("PROMETHEUS_MULTIPROC_DIR", raising=False) + monkeypatch.delenv("prometheus_multiproc_dir", raising=False) + + def _logger_with_mock_team_gauges() -> PrometheusLogger: with patch("litellm.integrations.prometheus.PrometheusLogger.__init__", return_value=None): logger = PrometheusLogger() @@ -498,3 +508,33 @@ def test_rename_survives_a_tracked_series_that_is_already_gone(): renamed = {**TEAM_LABELS, "team_alias": "ml-research"} assert registry.get_sample_value("litellm_team_rpm_limit", renamed) == 60 + + +def test_does_not_attempt_retirement_under_multiprocess_collection(monkeypatch): + """ + prometheus_client refuses to remove a labelset when PROMETHEUS_MULTIPROC_DIR + is set, warning instead, because a worker cannot retire a series another + worker wrote. Attempting it on every team request would emit warnings while + leaving the sample in place, so the gauges are set and nothing is retired. + """ + monkeypatch.setenv("PROMETHEUS_MULTIPROC_DIR", "/tmp/does-not-need-to-exist") + logger = _logger_with_mock_team_gauges() + + _set_team_metrics(logger, _payload_with_headers(dict(ALL_TEAM_HEADERS))) + _set_team_metrics(logger, _payload_with_headers({})) + + _assert_set_once(logger, "litellm_remaining_team_requests_for_model", 42) + for metric_name in TEAM_RATE_LIMIT_METRICS: + getattr(logger, metric_name).remove.assert_not_called() + + +def test_retires_series_when_collection_is_single_process(monkeypatch): + monkeypatch.delenv("PROMETHEUS_MULTIPROC_DIR", raising=False) + monkeypatch.delenv("prometheus_multiproc_dir", raising=False) + logger = _logger_with_mock_team_gauges() + + _set_team_metrics(logger, _payload_with_headers(dict(ALL_TEAM_HEADERS))) + _set_team_metrics(logger, _payload_with_headers({})) + + for metric_name in TEAM_RATE_LIMIT_METRICS: + getattr(logger, metric_name).remove.assert_called_once_with("team-abc", "research", "gpt-4o-mini")