From 4119cd67a19ca46cd048260e6433c25a8345c251 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:48:28 +0000 Subject: [PATCH] fix(spend_tracking): derive failure call_type from the requested route An upstream failure never reaches the SDK, so nothing stamps call_type on the request data the failure hook logs from; use the route the caller hit, falling back to the request's Logging object for routes outside the map. --- .../proxy/hooks/proxy_track_cost_callback.py | 11 +++- .../hooks/test_proxy_track_cost_callback.py | 50 ++++++++++++++----- 2 files changed, 46 insertions(+), 15 deletions(-) diff --git a/litellm/proxy/hooks/proxy_track_cost_callback.py b/litellm/proxy/hooks/proxy_track_cost_callback.py index 97c1aada36a..cbe7d1d66da 100644 --- a/litellm/proxy/hooks/proxy_track_cost_callback.py +++ b/litellm/proxy/hooks/proxy_track_cost_callback.py @@ -6,6 +6,9 @@ from typing import Any, List, Optional, Union, cast import litellm from litellm._logging import verbose_proxy_logger from litellm.integrations.custom_logger import CustomLogger +from litellm.litellm_core_utils.api_route_to_call_types import ( + get_primary_call_type_for_route, +) from litellm.litellm_core_utils.core_helpers import ( _get_parent_otel_span_from_kwargs, get_litellm_metadata_from_kwargs, @@ -148,6 +151,12 @@ class _ProxyDBLogger(CustomLogger): request_data["litellm_params"]["proxy_server_request"] = ( request_data.get("proxy_server_request") or existing_litellm_params.get("proxy_server_request") or {} ) + # Failures that never reached the SDK carry no call_type, so the row lands + # without one and can't be attributed to the route the caller used. + route_call_type = get_primary_call_type_for_route(request_route) + if not request_data.get("call_type") and route_call_type is not None: + request_data["call_type"] = route_call_type.value + request_data[metadata_key] = spend_metadata request_data["litellm_params"]["metadata"] = spend_metadata if "litellm_metadata" in existing_litellm_params: @@ -174,8 +183,6 @@ class _ProxyDBLogger(CustomLogger): ) if request_data.get("litellm_trace_id") is None: request_data["litellm_trace_id"] = getattr(_litellm_logging_obj, "litellm_trace_id", None) - # Without this the spend log for a failure has a blank call_type, so the - # row can't be attributed to the route the caller actually used. if not request_data.get("call_type"): logged_call_type = _known_call_type(getattr(_litellm_logging_obj, "call_type", None)) if logged_call_type is not None: diff --git a/tests/test_litellm/proxy/hooks/test_proxy_track_cost_callback.py b/tests/test_litellm/proxy/hooks/test_proxy_track_cost_callback.py index b2268dfda48..42188701864 100644 --- a/tests/test_litellm/proxy/hooks/test_proxy_track_cost_callback.py +++ b/tests/test_litellm/proxy/hooks/test_proxy_track_cost_callback.py @@ -1401,27 +1401,51 @@ async def test_failure_spend_log_drops_caller_supplied_key_identity(): @pytest.mark.asyncio @pytest.mark.parametrize( - "logged_call_type, expected_call_type", + "request_route, expected_call_type", [ - ("aresponses", "aresponses"), - ("anthropic_messages", "anthropic_messages"), - ("/v1/responses", ""), + ("/v1/responses", "aresponses"), + ("/v1/messages", "anthropic_messages"), + ("/v1/chat/completions", "acompletion"), ], ) -async def test_failure_spend_log_call_type_comes_from_logging_object( - logged_call_type, expected_call_type -): - """Regression for #35068: failure rows were written with a blank call_type, so a - failed Responses request was indistinguishable from a failed chat completion. Route - strings left on the Logging object by proxy-only errors must not leak into the - column.""" +async def test_failure_spend_log_call_type_comes_from_the_requested_route(request_route, expected_call_type): + """Regression for #35068: an upstream failure never reaches the SDK, so nothing sets + call_type and the row was written blank; a failed Responses request was then + indistinguishable from a failed chat completion.""" + payload, _ = await _failure_spend_log_payload( + request_data={"model": "test-model", "litellm_metadata": {}}, + user_api_key_dict=UserAPIKeyAuth( + api_key="sk-test", + user_id="real-user", + team_id="real-team", + request_route=request_route, + ), + ) + + assert payload["call_type"] == expected_call_type + + +@pytest.mark.asyncio +async def test_failure_spend_log_call_type_falls_back_to_logging_object(): + """Routes outside the route map (e.g. pass-through) still know their call type from + the request's Logging object. Route strings left there by proxy-only errors must not + leak into the column.""" payload, _ = await _failure_spend_log_payload( request_data={ "model": "test-model", "litellm_metadata": {}, - "litellm_logging_obj": _StubLoggingObj(call_type=logged_call_type), + "litellm_logging_obj": _StubLoggingObj(call_type="aresponses"), }, user_api_key_dict=UserAPIKeyAuth(api_key="sk-test", user_id="real-user", team_id="real-team"), ) + assert payload["call_type"] == "aresponses" - assert payload["call_type"] == expected_call_type + payload, _ = await _failure_spend_log_payload( + request_data={ + "model": "test-model", + "litellm_metadata": {}, + "litellm_logging_obj": _StubLoggingObj(call_type="/v1/responses"), + }, + user_api_key_dict=UserAPIKeyAuth(api_key="sk-test", user_id="real-user", team_id="real-team"), + ) + assert payload["call_type"] == ""