Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
3 changes: 3 additions & 0 deletions litellm/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,9 @@
# llm_provider stamped on proxy-side rate limit errors when the model resolves to no deployment
PROXY_LLM_PROVIDER_FALLBACK: Final = "litellm_proxy"

# litellm_params flag on failure logs for requests the proxy rejected before routing to a deployment
PROXY_REJECTED_BEFORE_ROUTING_KEY: Final = "proxy_rejected_before_routing"

# Generic fallback for unknown models
DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET: Final = int(
os.getenv("DEFAULT_REASONING_EFFORT_MINIMAL_THINKING_BUDGET", 128)
Expand Down
19 changes: 12 additions & 7 deletions litellm/integrations/prometheus.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@

import litellm
from litellm._logging import print_verbose, verbose_logger
from litellm.constants import PROXY_LLM_PROVIDER_FALLBACK
from litellm.constants import PROXY_LLM_PROVIDER_FALLBACK, PROXY_REJECTED_BEFORE_ROUTING_KEY
from litellm.exceptions import (
validate_rate_limit_category,
validate_rate_limit_type,
Expand Down Expand Up @@ -214,17 +214,22 @@ def _get_proxy_llm_router() -> Router | None:
return llm_router


def _bounded_requested_model_label(requested_model: str | None, router_originated: bool = False) -> str | None:
def _bounded_requested_model_label(requested_model: object, router_originated: bool = False) -> str | None:
"""
Bound ``requested_model`` label cardinality: names the router recognizes
(model names, deployment ids, aliases, routing groups, team public model
names) or matches via a global or team wildcard/pattern route keep their
own label value; any other client-supplied string collapses into the
single ``other`` bucket. With no proxy router to vouch for the string,
client-supplied values collapse to ``other`` while ``router_originated``
values (emitted by an SDK ``Router``'s own deployment failure and
fallback events, where the proxy router never exists) pass through.
single ``other`` bucket, as does any non-string request ``model`` value.
With no proxy router to vouch for the string, client-supplied values
collapse to ``other`` while ``router_originated`` values (emitted by an
SDK ``Router``'s own deployment failure and fallback events, where the
proxy router never exists) pass through.
"""
if requested_model is None:
return None
if not isinstance(requested_model, str):
return UNRECOGNIZED_REQUESTED_MODEL_LABEL
if not requested_model:
return requested_model
llm_router: Final = _get_proxy_llm_router()
Expand Down Expand Up @@ -2832,7 +2837,7 @@ def set_llm_deployment_failure_metrics(self, request_kwargs: dict):

# On LiteLLM-side rejects (no deployment picked), route request_kwargs["model"]
# into requested_model and leave deployment-scoped labels empty.
deployment_selected: Final = bool(model_id)
deployment_selected: Final = bool(model_id) and not _litellm_params.get(PROXY_REJECTED_BEFORE_ROUTING_KEY)
if deployment_selected:
label_litellm_model_name = litellm_model_name
label_model_id = model_id
Expand Down
101 changes: 101 additions & 0 deletions litellm/proxy/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
DEFAULT_MODEL_CREATED_AT_TIME,
LITELLM_LOGGING_NO_UPSTREAM_LLM_CALL,
MAX_TEAM_LIST_LIMIT,
PROXY_REJECTED_BEFORE_ROUTING_KEY,
REDIS_SPEND_LOGS_BUFFER_DEQUEUE_COUNT,
SPEND_LOG_QUEUE_MAX_BYTES,
SPEND_LOG_WRITE_BATCH_MAX_BYTES,
Expand Down Expand Up @@ -975,6 +976,92 @@ def _failure_usage_to_lift(
_EMPTY_LIFT: Final = MappingProxyType({})


def _stamp_deployment_attribution(
litellm_params: dict[str, object], model_group: str | None, team_id: str | None, dispatched: bool
) -> Mapping[str, object]:
"""Stamp provider and logging-metadata attribution onto ``litellm_params`` and return it.
``litellm_params["model_info"]`` stays unset: the router's cooldown and per-deployment rpm
callbacks key off it and must not count a proxy-side reject against the deployment. A failure
after the provider handoff keeps the metadata the router stamped; a request that never reached a
provider is flagged ``PROXY_REJECTED_BEFORE_ROUTING_KEY`` (deployment metrics key off it) whatever
its metadata says, since ``metadata.model_info`` can be caller supplied."""
attribution: Final = _deployment_attribution_for_model_group(model_group, team_id)
if "custom_llm_provider" in attribution:
litellm_params["custom_llm_provider"] = attribution["custom_llm_provider"]
if dispatched:
return attribution
litellm_params[PROXY_REJECTED_BEFORE_ROUTING_KEY] = True
if "model_info" not in attribution:
return attribution
if litellm_params.get("metadata") is None:
litellm_params["metadata"] = {} # mutable-ok: legacy logging payload is populated in place
metadata: Final = litellm_params["metadata"]
if not isinstance(metadata, dict):
return attribution
metadata.setdefault("model_info", attribution["model_info"])
metadata.setdefault("deployment", attribution["deployment"])
return attribution


def _deployment_attribution_for_model_group(model_group: object, team_id: str | None) -> Mapping[str, object]:
"""Provider fields the router would have stamped had it reached a deployment:
``custom_llm_provider`` when every deployment in the group resolves to the same
provider, plus ``model_info`` and ``deployment`` when the group has exactly one.
``team_id`` picks the key's team deployments over a global group of the same public name."""
if not isinstance(model_group, str):
return _EMPTY_LIFT

from litellm.proxy.proxy_server import llm_router

if llm_router is None:
return _EMPTY_LIFT
deployments: Final = llm_router.get_model_list(model_name=model_group, team_id=team_id)
if not deployments:
return _EMPTY_LIFT

def _provider_for_deployment(deployment: Mapping[str, object]) -> str | None:
litellm_params: Final = cast( # cast-ok: router deployment parameters are mapping-shaped
Mapping[str, object], deployment["litellm_params"]
)
try:
provider: Final = litellm.get_llm_provider(
model=cast(str, litellm_params["model"]), # cast-ok: router deployment model is a string
custom_llm_provider=cast( # cast-ok: router deployment provider is optional
str | None, litellm_params.get("custom_llm_provider")
),
)[1]
return cast(str | None, provider) # cast-ok: provider resolver returns an optional provider string
except Exception: # noqa: BLE001 # get_llm_provider raises for unmapped models
return None

providers: Final = frozenset(_provider_for_deployment(deployment) for deployment in deployments)
shared_provider: Final = next(iter(providers)) if len(providers) == 1 else None
single_deployment: Final = deployments[0] if len(deployments) == 1 else None
single_deployment_params: Final = (
cast( # cast-ok: router deployment parameters are mapping-shaped
Mapping[str, object], single_deployment["litellm_params"]
)
if single_deployment is not None
else None
)
return MappingProxyType(
{
# mutable-ok: frozen immediately by the outer MappingProxyType
**({"custom_llm_provider": shared_provider} if shared_provider is not None else {}),
**(
{ # mutable-ok: frozen immediately by the outer MappingProxyType
"model_info": dict( # mutable-ok: preserve the router's mutable model-info payload
single_deployment.get("model_info") or {}
),
"deployment": single_deployment_params["model"],
}
if single_deployment is not None and single_deployment_params is not None
else {} # mutable-ok: frozen immediately by the outer MappingProxyType
),
}
)


def _call_type_for_route(route: str | None) -> str | None:
"""The route's call type when it maps to a single operation (its async and sync variants);
None for routes shared by several operations, since the method is not known here."""
Expand Down Expand Up @@ -3223,11 +3310,25 @@ async def _handle_logging_proxy_only_error(
elif k not in ("model", "user", "litellm_logging_obj"):
_optional_params[k] = v

attribution: Final = _stamp_deployment_attribution(
_litellm_params,
request_data.get("model"),
user_api_key_dict.team_id,
dispatched=litellm_logging_obj.model_call_details.get("first_api_call_start_time") is not None,
)

litellm_logging_obj.update_environment_variables(
model=request_data.get("model", ""),
user=request_data.get("user", ""),
optional_params=_optional_params,
litellm_params=_litellm_params,
**(
{ # mutable-ok: frozen immediately by keyword expansion
"custom_llm_provider": attribution["custom_llm_provider"]
}
if "custom_llm_provider" in attribution
else {} # mutable-ok: frozen immediately by keyword expansion
),
)

input: list | str | dict = ""
Expand Down
2 changes: 1 addition & 1 deletion tests/e2e/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ quota_management.<behavior>.<variant>.<assertion>
| isolates_per_model | isolates_per_member | isolates_per_group | enforced_across_keys
| routes_to_fallback | reseed_matches_db | reports_spend | logs_cost | zero_cost
| matches_sum_of_logs | loses_no_spend | attributes_spend | writes_own_rows
| writes_failure_row | returns_cost | keeps_total | joins_key | reports_alias_and_email
| writes_failure_row | attributes_provider | returns_cost | keeps_total | joins_key | reports_alias_and_email
| health_rows_keep_service_account | retrieve_batch_cost_joins_retrieving_key
| poller_batch_cost_joins_creating_key
e.g. quota_management.ratelimit.rpm.blocks_over_limit exercised_on=[chat_completions, messages]
Expand Down
1 change: 1 addition & 0 deletions tests/e2e/coverage_registry/quota_management.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
- {id: quota_management.spend_tracking.end_user.attributes_spend, module: quota_management, tier: P1, behavior: spend_tracking, variant: end_user, assertions: [attributes_spend], exercised_on: [chat_completions], source: "proxy/spend_tracking/spend_tracking_utils.py", rationale: "user= attribution lands the end-user id on the spend row"}
- {id: quota_management.spend_tracking.per_model.writes_own_rows, module: quota_management, tier: P2, behavior: spend_tracking, variant: per_model, assertions: [writes_own_rows], exercised_on: [chat_completions], source: "proxy/spend_tracking/spend_tracking_utils.py", rationale: "Each model on a shared key gets its own spend row"}
- {id: quota_management.spend_tracking.failure.writes_failure_row, module: quota_management, tier: P1, behavior: spend_tracking, variant: failure, assertions: [writes_failure_row], exercised_on: [chat_completions], source: "proxy/spend_tracking/spend_log_error_logger.py", rationale: "A failed call writes a failure-status spend row"}
- {id: quota_management.spend_tracking.failure.attributes_provider, module: quota_management, tier: P1, behavior: spend_tracking, variant: failure, assertions: [attributes_provider], exercised_on: [chat_completions], source: "proxy/utils.py", rationale: "A request rejected in pre_call_hook (rate limit, guardrail) still lands its single deployment's provider and model_id on the failure spend row"}
- {id: quota_management.spend_tracking.spend_calculate.returns_cost, module: quota_management, tier: P2, behavior: spend_tracking, variant: spend_calculate, assertions: [returns_cost], exercised_on: [spend_calculate], source: "proxy/spend_tracking/spend_management_endpoints.py", rationale: "/spend/calculate prices a hypothetical request at nonzero cost"}
- {id: quota_management.spend_tracking.pagination.keeps_total, module: quota_management, tier: P2, behavior: spend_tracking, variant: pagination, assertions: [keeps_total], exercised_on: [chat_completions], source: "proxy/spend_tracking/spend_management_endpoints.py", rationale: "Spend-logs v2 pagination caps page size without losing the total"}
- {id: quota_management.spend_tracking.cache_write.bills_cache_creation_rate, module: quota_management, tier: P1, behavior: spend_tracking, variant: cache_write, assertions: [bills_cache_creation_rate], exercised_on: [chat_completions], source: "litellm_core_utils/llm_cost_calc/utils.py", rationale: "OpenAI cache-write tokens land on the spend row as cache-creation tokens billed at the cache-creation rate, not silently at the input rate (#34046)"}
Expand Down
1 change: 1 addition & 0 deletions tests/e2e/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,7 @@ class SpendLogRow(BaseModel):
cache_hit: str | None = None
call_type: str | None = None
custom_llm_provider: str | None = None
model_id: str | None = None
team_id: str | None = None
user: str | None = None
end_user: str | None = None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@
from typing import Final

import pytest
from e2e_http import Success
from e2e_http import RateLimitedError, Success
from lifecycle import ResourceManager
from models import LiteLLMParamsBody, SpendLogs, SpendLogsParams
from models import KeyGenerateBody, LiteLLMParamsBody, SpendLogs, SpendLogsParams
from spend_e2e_client import SpendClient, SpendLogRow, is_ok, unique_marker, unwrap

pytestmark = pytest.mark.e2e
Expand All @@ -43,6 +43,7 @@ def _summarize(rows: list[SpendLogRow]) -> list[dict[str, object]]:
"cache_hit",
"call_type",
"custom_llm_provider",
"model_id",
"prompt_tokens",
"completion_tokens",
"total_tokens",
Expand Down Expand Up @@ -498,6 +499,43 @@ def test_failure_call_writes_failure_status_row(
assert (failure_row.spend or 0) == 0.0, "failed call must not be charged"


@pytest.mark.covers("quota_management.spend_tracking.failure.attributes_provider")
def test_pre_call_rejection_row_attributes_provider_and_model_id(
client: SpendClient, resources: ResourceManager
) -> None:
"""A request the proxy rejects before the router picks a deployment (here the
key's rpm limit, a pre_call_hook 429) never reaches the code that stamps the
deployment onto the log. The failure row must still carry the provider and
model_id of the model group's only deployment, so per-provider failure reports
can count it."""
model = f"e2e-spend-precall-{unique_marker()}"
model_id = client.proxy.create_model(
model, LiteLLMParamsBody(model="openai/gpt-5.5", api_key="os.environ/OPENAI_API_KEY")
)
resources.defer(lambda: client.proxy.delete_model(model_id))
key = client.proxy.generate_key(KeyGenerateBody(models=[model], rpm_limit=1))
resources.defer(lambda: client.proxy.delete_key(key))

unwrap(client.chat(key, model, f"reply with one word {unique_marker()}", max_tokens=8))
rejected = client.chat(key, model, f"over the rpm limit {unique_marker()}", max_tokens=8)
assert isinstance(rejected, RateLimitedError), (
f"the second call on an rpm_limit=1 key must be rejected with 429 before routing, got {rejected}"
)

rows = client.poll_logs_for_key(key, predicate=lambda rs: any(r.status == "failure" for r in rs))
success_row = _require_row(rows, lambda r: r.status == "success", "for the served call")
failure_row = _require_row(rows, lambda r: r.status == "failure", "for the rate-limited call")

assert failure_row.custom_llm_provider == success_row.custom_llm_provider, (
f"rejected call lost its provider: failure row {failure_row.custom_llm_provider!r} vs "
f"served row {success_row.custom_llm_provider!r}; {_summarize(rows)}"
)
assert failure_row.model_id == model_id, (
f"rejected call lost its deployment: failure row model_id {failure_row.model_id!r} vs "
f"registered {model_id!r}; {_summarize(rows)}"
)


@pytest.mark.covers("quota_management.spend_tracking.spend_calculate.returns_cost")
def test_spend_calculate_returns_nonzero_cost(client: SpendClient) -> None:
cost = client.calculate_spend(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
LIT-7701 attributes a pre_call_hook rejection's failure log to the model
group's single deployment (``model_id`` and ``custom_llm_provider``) and flags
it with ``PROXY_REJECTED_BEFORE_ROUTING_KEY``. The deployment health metrics
must keep treating such rejects as "no deployment picked": a key rate limit or
guardrail block never reached the deployment, so it must not flip
``litellm_deployment_state`` to partial outage or count as a deployment failure
response. A failure raised after the router picked a deployment (a post-call
guardrail block, a provider error) carries no flag and keeps its deployment labels.
"""

import pytest
from fastapi import HTTPException
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
from prometheus_client import REGISTRY

from litellm.constants import PROXY_REJECTED_BEFORE_ROUTING_KEY
from litellm.exceptions import GuardrailRaisedException
from litellm.integrations.prometheus import PrometheusLogger
from litellm.proxy._types import ProxyException
from litellm.proxy.common_utils.proxy_rate_limit_error import ProxyRateLimitError


@pytest.fixture(autouse=True)
def cleanup_prometheus_registry():
for collector in list(REGISTRY._collector_to_names.keys()):
try:
REGISTRY.unregister(collector)
except Exception:
pass

yield

for collector in list(REGISTRY._collector_to_names.keys()):
try:
REGISTRY.unregister(collector)
except Exception:
pass


def _attributed_failure_kwargs(exception: Exception, rejected_before_routing: bool) -> dict:
return {
"model": "openai/gpt-4.1",
"litellm_params": {
"custom_llm_provider": "openai",
"metadata": {"model_info": {"id": "dep-1"}, "model_group": "internal-model"},
**({PROXY_REJECTED_BEFORE_ROUTING_KEY: True} if rejected_before_routing else {}),
},
"standard_logging_object": {
"model_id": "dep-1",
"model_group": "internal-model",
"api_base": "https://api.openai.com",
"metadata": {},
},
"exception": exception,
}


def _model_id_values(metric) -> set[str]:
index = metric._labelnames.index("model_id")
return {sample_key[index] for sample_key in metric._metrics}


class _ProviderError(Exception):
status_code = 500


@pytest.mark.parametrize(
"rejection",
[
HTTPException(status_code=403, detail="guardrail blocked"),
ProxyException(message="budget exceeded", type="budget_exceeded", param=None, code=400),
ProxyRateLimitError(detail={"error": "key rpm limit"}),
GuardrailRaisedException(guardrail_name="pii", message="blocked", status_code=403),
],
ids=["http_exception", "proxy_exception", "proxy_rate_limit", "guardrail_raised"],
)
def test_attributed_proxy_reject_leaves_deployment_healthy(rejection: Exception):
logger = PrometheusLogger()

logger.set_llm_deployment_failure_metrics(_attributed_failure_kwargs(rejection, rejected_before_routing=True))

assert logger.litellm_deployment_state._metrics == {}
assert _model_id_values(logger.litellm_deployment_failure_responses) == {""}
assert _model_id_values(logger.litellm_deployment_total_requests) == {""}


@pytest.mark.parametrize(
"failure",
[
_ProviderError("upstream 500"),
GuardrailRaisedException(guardrail_name="pii", message="response blocked", status_code=400),
],
ids=["provider_error", "post_call_guardrail"],
)
def test_failure_after_routing_still_marks_deployment_partial_outage(failure: Exception):
logger = PrometheusLogger()

logger.set_llm_deployment_failure_metrics(_attributed_failure_kwargs(failure, rejected_before_routing=False))

assert _model_id_values(logger.litellm_deployment_state) == {"dep-1"}
assert _model_id_values(logger.litellm_deployment_failure_responses) == {"dep-1"}
Loading
Loading