diff --git a/litellm/litellm_core_utils/litellm_logging.py b/litellm/litellm_core_utils/litellm_logging.py index eb2e7b8c240..1701b71df79 100644 --- a/litellm/litellm_core_utils/litellm_logging.py +++ b/litellm/litellm_core_utils/litellm_logging.py @@ -7319,6 +7319,18 @@ def get_standard_logging_object_payload( # failure payloads stay None like every response type that carries its own _hidden_params timing_metrics: Final = getattr(logging_obj, "response_timing_metrics", None) or {} clean_hidden_params["litellm_overhead_time_ms"] = timing_metrics.get("litellm_overhead_time_ms") + if status == "success" and clean_hidden_params["litellm_model_name"] is None and "model" in kwargs: + # /v1/messages dict results and the bridge stream wrappers cannot carry + # _hidden_params; backfill the deployment model from the call details (the + # same value update_response_metadata writes on the object path) so + # deployment TPM/RPM accounting keys match the pre-call check (#45702) + backfill_model: Final[str | None] = ( + cast( # cast-ok: kwargs model is the deployment model string for this call + str | None, kwargs["model"] + ) + ) + if isinstance(backfill_model, str) and backfill_model: + clean_hidden_params["litellm_model_name"] = backfill_model model_cost_information: Final = StandardLoggingPayloadSetup.get_model_cost_information( base_model=base_model, diff --git a/tests/unit/litellm_core_utils/test_messages_dict_litellm_model_name.py b/tests/unit/litellm_core_utils/test_messages_dict_litellm_model_name.py new file mode 100644 index 00000000000..74f9cb80d36 --- /dev/null +++ b/tests/unit/litellm_core_utils/test_messages_dict_litellm_model_name.py @@ -0,0 +1,128 @@ +""" +Regression tests for BerriAI/litellm#45702: a native /v1/messages result is a +plain dict, so it carries no _hidden_params and the standard logging payload left +hidden_params["litellm_model_name"] as None. Deployment-level TPM/RPM accounting +(model_rate_limit_check, lowest_tpm_rpm_v2) keys its post-call increment on +:, so every such call was silently skipped and +enforce_model_rate_limits / usage-based-routing-v2 never saw the usage. +""" + +import datetime +from typing import Final +from unittest.mock import AsyncMock, MagicMock + +import pytest + +import litellm +from litellm.litellm_core_utils.litellm_logging import ( + Logging, + get_standard_logging_object_payload, +) +from litellm.router_utils.pre_call_checks.model_rate_limit_check import ( + ModelRateLimitingCheck, +) + +DEPLOYMENT_MODEL: Final = "anthropic/claude-opus-4-8" +DEPLOYMENT_ID: Final = "dep-anth" + + +def _call_kwargs() -> dict: + # model_call_details keeps the full deployment model (provider prefix included); + # Logging.model is the provider-stripped name, which must NOT be used here + return { + "model": DEPLOYMENT_MODEL, + "litellm_params": { + "model": DEPLOYMENT_MODEL, + "metadata": {"model_group": "m-group", "model_info": {"id": DEPLOYMENT_ID}}, + }, + "call_type": "anthropic_messages", + "litellm_call_id": "call-45702", + "stream": False, + } + + +def _anthropic_result() -> dict: + # the native /v1/messages response reaches the payload builder as a plain dict + return { + "id": "msg_45702", + "type": "message", + "role": "assistant", + "model": "claude-opus-4-8", + "content": [{"type": "text", "text": "hi"}], + "usage": {"input_tokens": 1, "output_tokens": 1}, + } + + +def _logging_obj() -> Logging: + return Logging( + model=DEPLOYMENT_MODEL, + messages=[{"role": "user", "content": "hi"}], + stream=False, + call_type="anthropic_messages", + start_time=datetime.datetime.now(), + litellm_call_id="call-45702", + function_id="fid-45702", + ) + + +def _payload(status: str = "success") -> dict: + now: Final = datetime.datetime.now() + return get_standard_logging_object_payload( + kwargs=_call_kwargs(), + init_response_obj=_anthropic_result(), + start_time=now, + end_time=now, + logging_obj=_logging_obj(), + status=status, + ) + + +def test_messages_plain_dict_result_backfills_litellm_model_name() -> None: + payload: Final = _payload() + assert payload["hidden_params"]["litellm_model_name"] == DEPLOYMENT_MODEL + assert payload["model_id"] == DEPLOYMENT_ID + + +def test_messages_plain_dict_failure_payload_stays_unmarked() -> None: + # failure payloads carry no model name on the object path either; keep it that way + payload: Final = _payload(status="failure") + assert payload["hidden_params"]["litellm_model_name"] is None + + +def test_object_result_hidden_params_win_over_backfill() -> None: + response: Final = litellm.ModelResponse() + response._hidden_params["litellm_model_name"] = "anthropic/claude-opus-4-8" + kwargs: Final = _call_kwargs() + kwargs["model"] = "m-group" + now: Final = datetime.datetime.now() + payload: Final = get_standard_logging_object_payload( + kwargs=kwargs, + init_response_obj=response, + start_time=now, + end_time=now, + logging_obj=_logging_obj(), + status="success", + ) + assert payload["hidden_params"]["litellm_model_name"] == "anthropic/claude-opus-4-8" + + +@pytest.mark.asyncio +async def test_backfilled_name_keys_the_deployment_tpm_increment() -> None: + # the pre-call check keys on :; the post-call + # increment must land on the same key - a provider-stripped name would miss it + mock_cache: Final = MagicMock() + mock_cache.async_increment_cache = AsyncMock() + check: Final = ModelRateLimitingCheck(dual_cache=mock_cache) + + await check.async_log_success_event({"standard_logging_object": _payload()}, None, None, None) + + mock_cache.async_increment_cache.assert_called_once() + _, kwarg_params = mock_cache.async_increment_cache.call_args + deployment: Final = { + "litellm_params": {"model": DEPLOYMENT_MODEL}, + "model_info": {"id": DEPLOYMENT_ID}, + } + tpm_key, _ = check._get_cache_keys( # pyright: ignore[reportPrivateUsage] # same seam the pre-call check uses + deployment, litellm.utils.get_utc_datetime().strftime("%H-%M") + ) + assert kwarg_params["key"] == tpm_key