Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
104 changes: 104 additions & 0 deletions litellm/integrations/prometheus.py
Original file line number Diff line number Diff line change
Expand Up @@ -1226,6 +1226,17 @@ async def async_log_success_event(self, kwargs, response_obj, start_time, end_ti
label_context=label_context,
)

# Provider-agnostic fallback: providers like Bedrock and Vertex don't return
# x-ratelimit-remaining-* headers, so the gauges above only fire for OpenAI /
# Anthropic / Azure. When the proxy router has tpm/rpm configured for the
# model_group, derive remaining from configured-limit minus current usage so
# the same metric is populated for any provider.
await self._async_set_router_remaining_metrics(
standard_logging_payload=standard_logging_payload, # type: ignore
enum_values=enum_values,
label_context=label_context,
)

# cache metrics
self._increment_cache_metrics(
standard_logging_payload=standard_logging_payload, # type: ignore
Expand Down Expand Up @@ -2199,6 +2210,99 @@ def _set_deployment_tpm_rpm_limit_metrics(
)
self.litellm_deployment_rpm_limit.labels(**_labels).set(rpm)

async def _async_set_router_remaining_metrics(
self,
standard_logging_payload: StandardLoggingPayload,
enum_values: UserAPIKeyLabelValues,
label_context: Optional[PrometheusLabelFactoryContext] = None,
) -> None:
"""
Populate ``litellm_remaining_tokens_metric`` /
``litellm_remaining_requests_metric`` from the router's internal usage
counters when the upstream provider did not return
``x-ratelimit-remaining-*`` response headers.

OpenAI / Anthropic / Azure return remaining tokens/requests in response
headers, but Bedrock and Vertex AI do not. This fallback computes
``configured_limit - current_usage`` via
``Router.get_remaining_model_group_usage`` so the same gauges are
emitted for every provider when tpm/rpm is configured on the
deployment.
"""
try:
additional_headers = (
standard_logging_payload.get("hidden_params", {}) or {}
).get("additional_headers") or {}

already_have_tokens = (
additional_headers.get("x_ratelimit_remaining_tokens") is not None
)
already_have_requests = (
additional_headers.get("x_ratelimit_remaining_requests") is not None
)
if already_have_tokens and already_have_requests:
return

model_group = standard_logging_payload.get("model_group")
if not model_group:
return

try:
from litellm.proxy.proxy_server import llm_router
except ImportError:
llm_router = None
Comment thread
greptile-apps[bot] marked this conversation as resolved.

if llm_router is None:
return

try:
remaining_usage = await llm_router.get_remaining_model_group_usage(
model_group
)
Comment on lines +2258 to +2261

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Duplicate cache read per request

Router.set_response_headers already calls get_remaining_model_group_usage for providers that omit rate-limit headers (gated on the same header-absence condition). That call happens on the router's response path, and _async_set_router_remaining_metrics then issues a second identical async_batch_get_cache lookup for the same model_group within the same request's lifecycle. For deployments using a Redis-backed router cache this is two extra round-trips per Bedrock/Vertex request in the logging path. A possible mitigation is storing the router-derived values in standard_logging_payload["hidden_params"]["additional_headers"] before the logging callback fires, so this fallback can read them directly without a second cache lookup.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In practice no second cache read happens: Router.set_response_headers populates x-ratelimit-remaining-tokens/...-requests on response._hidden_params['additional_headers'], then StandardLoggingPayloadSetup.get_additional_headers normalises hyphens to underscores when constructing the StandardLoggingPayload (litellm_core_utils/litellm_logging.py:5018). The already_have_tokens/already_have_requests short-circuit at the top of _async_set_router_remaining_metrics therefore returns before issuing any router lookup whenever set_response_headers already filled the values. The router lookup only fires in the (rare) case where set_response_headers was not invoked at all (e.g. SDK path without a router-mediated response, or a non-BaseModel response that skipped the hidden_params setdefault), at which point this is the first and only lookup for the request.

except Exception as e:
verbose_logger.exception(
"Prometheus: get_remaining_model_group_usage failed for "
"model_group=%s: %s",
model_group,
e,
)
return
Comment thread
greptile-apps[bot] marked this conversation as resolved.

if not remaining_usage:
return

remaining_tokens = remaining_usage.get("x-ratelimit-remaining-tokens")
remaining_requests = remaining_usage.get("x-ratelimit-remaining-requests")

if not already_have_tokens and remaining_tokens is not None:
_labels = prometheus_label_factory(
supported_enum_labels=self.get_labels_for_metric(
metric_name="litellm_remaining_tokens_metric"
),
enum_values=enum_values,
label_context=label_context,
)
self.litellm_remaining_tokens_metric.labels(**_labels).set(
remaining_tokens
)

if not already_have_requests and remaining_requests is not None:
_labels = prometheus_label_factory(
supported_enum_labels=self.get_labels_for_metric(
metric_name="litellm_remaining_requests_metric"
),
enum_values=enum_values,
label_context=label_context,
)
self.litellm_remaining_requests_metric.labels(**_labels).set(
remaining_requests
)
except Exception as e:
verbose_logger.exception(
"Prometheus Error: _async_set_router_remaining_metrics. "
"Exception occured - {}".format(str(e))
)

def set_llm_deployment_success_metrics(
self,
request_kwargs: dict,
Expand Down
22 changes: 21 additions & 1 deletion litellm/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -8771,9 +8771,29 @@ async def set_response_headers(
model_group
)

# get_remaining_model_group_usage reads the router's TPM/RPM
# counter, which is incremented post-response by
# deployment_callback_on_success. So the values returned here
# are pre-decrement for the current request, while vendor
# headers (OpenAI/Anthropic/Azure) are post-decrement. Replay
# the in-flight increment so router-derived headers match
# vendor-derived semantics β€” for both the HTTP response sent
# to the client and the prometheus gauges that read these
# headers downstream (LIT-2719).
in_flight_tokens = 0
usage = getattr(response, "usage", None)
if usage is not None:
in_flight_tokens = getattr(usage, "total_tokens", 0) or 0
in_flight_delta = {
"x-ratelimit-remaining-tokens": in_flight_tokens,
"x-ratelimit-remaining-requests": 1,
}

for header, value in remaining_usage.items():
if value is not None:
additional_headers[header] = value
additional_headers[header] = value - in_flight_delta.get(
header, 0
)
return response

def _build_model_name_index(self, model_list: list) -> None:
Expand Down
73 changes: 73 additions & 0 deletions tests/router_unit_tests/test_router_helper_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -879,6 +879,79 @@ async def test_set_response_headers(model_list):
assert resp is None


@pytest.mark.asyncio
async def test_set_response_headers_subtracts_in_flight_delta(model_list):
"""
LIT-2719: router-derived `x-ratelimit-remaining-*` headers must be
post-decrement (match OpenAI/Anthropic vendor semantics) so the proxy's
HTTP response headers and the prometheus gauges that read them stay
comparable across providers.

Router's TPM/RPM counter is incremented post-response by
`deployment_callback_on_success`, so `get_remaining_model_group_usage`
sees pre-decrement values. `set_response_headers` must replay the
in-flight increment before writing the headers.
"""
from pydantic import BaseModel

class _Usage(BaseModel):
total_tokens: int = 42

class _Resp(BaseModel):
usage: _Usage = _Usage()
_hidden_params: dict = {}

router = Router(model_list=model_list)
router.get_remaining_model_group_usage = AsyncMock(
return_value={
"x-ratelimit-remaining-tokens": 1000,
"x-ratelimit-limit-tokens": 1000,
"x-ratelimit-remaining-requests": 100,
"x-ratelimit-limit-requests": 100,
}
)

resp = _Resp()
resp._hidden_params = {}
await router.set_response_headers(response=resp, model_group="gpt-3.5-turbo")

headers = resp._hidden_params["additional_headers"]
assert headers["x-ratelimit-remaining-tokens"] == 958
assert headers["x-ratelimit-remaining-requests"] == 99
# Limit headers pass through unmodified.
assert headers["x-ratelimit-limit-tokens"] == 1000
assert headers["x-ratelimit-limit-requests"] == 100


@pytest.mark.asyncio
async def test_set_response_headers_handles_missing_usage(model_list):
"""
Streaming chunks and some response shapes may lack a `usage` attribute or
populated `total_tokens`. The in-flight subtraction must default to 0
tokens (still subtract 1 from requests) and never raise.
"""
from pydantic import BaseModel

class _Resp(BaseModel):
_hidden_params: dict = {}

router = Router(model_list=model_list)
router.get_remaining_model_group_usage = AsyncMock(
return_value={
"x-ratelimit-remaining-tokens": 1000,
"x-ratelimit-remaining-requests": 100,
}
)

resp = _Resp()
resp._hidden_params = {}
await router.set_response_headers(response=resp, model_group="gpt-3.5-turbo")

headers = resp._hidden_params["additional_headers"]
assert headers["x-ratelimit-remaining-tokens"] == 1000
assert headers["x-ratelimit-remaining-requests"] == 99


def test_get_all_deployments(model_list):
"""Test if the 'get_all_deployments' function is working correctly"""
router = Router(model_list=model_list)
Expand Down
Loading
Loading