fix(spend_tracking): attribute router-rejected requests to the model group provider

A request for a configured model group that the router rejects before picking a deployment (all deployments in cooldown, no healthy deployment) never gets a custom_llm_provider in its logging kwargs. The spend log payload persisted an empty provider, the daily spend tables carried it through, and the Admin UI Usage page rendered those requests under unknown even though every model in the group has a provider

get_logging_payload now takes the proxy router and, when the logged provider is missing, infers it from the model group's deployments. It only attributes when every deployment in the group resolves to the same provider; mixed groups, unknown groups and a missing router leave the value empty as before. Explicitly logged providers keep precedence

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
yassin 2026-09-16 23:10:47 +00:00
parent 9357491f91
commit acd4f0eb04
4 changed files with 141 additions and 3 deletions

View file

@ -299,6 +299,7 @@ class DBSpendUpdateWriter:
response_obj=completion_response,
start_time=start_time,
end_time=end_time,
llm_router=get_llm_router(),
)
payload["spend"] = response_cost or 0.0
if isinstance(payload["startTime"], datetime):

View file

@ -5,7 +5,7 @@ from collections.abc import Mapping, Sequence
from datetime import datetime, timezone
from datetime import datetime as dt
from types import MappingProxyType
from typing import Final, Literal, Protocol, cast, runtime_checkable
from typing import TYPE_CHECKING, Final, Literal, Protocol, cast, runtime_checkable
from pydantic import BaseModel
@ -43,6 +43,7 @@ from litellm.proxy._types import SpendLogsMetadata, SpendLogsPayload, SpendLogsR
from litellm.proxy.route_llm_request import ProxyModelNotFoundError
from litellm.proxy.spend_tracking.spend_log_error_logger import spend_log_error
from litellm.proxy.utils import PrismaClient, hash_token
from litellm.types.router import DeploymentTypedDict, LiteLLM_Params
from litellm.types.utils import (
PROMPT_CARRYING_GUARDRAIL_FIELDS,
CallTypes,
@ -57,6 +58,9 @@ from litellm.types.utils import (
)
from litellm.utils import get_end_user_id_for_cost_tracking
if TYPE_CHECKING:
from litellm.router import Router
def _get_max_string_length_prompt_in_db() -> int:
"""
@ -339,12 +343,36 @@ def _sl_attribution_fallback(
return standard_logging_payload.get(field) or ""
def _deployment_provider(deployment: DeploymentTypedDict) -> str | None:
litellm_params: Final = LiteLLM_Params.model_validate(deployment["litellm_params"])
try:
_, provider, _, _ = litellm.get_llm_provider(
model=litellm_params.model, custom_llm_provider=litellm_params.custom_llm_provider
)
except litellm.exceptions.BadRequestError:
return None
return provider or None
def _model_group_provider(model_group: str, llm_router: "Router | None") -> str | None:
if llm_router is None or not model_group:
return None
providers: Final = frozenset(
provider
for deployment in llm_router.get_model_list(model_name=model_group) or ()
if (provider := _deployment_provider(deployment)) is not None
)
return next(iter(providers)) if len(providers) == 1 else None
def _looks_like_model_name(model: str) -> bool:
candidate: Final = model.removeprefix(MCP_SPEND_LOG_MODEL_PREFIX)
return len(candidate) <= MAX_SPEND_LOG_MODEL_NAME_LENGTH and not any(char.isspace() for char in candidate)
def get_logging_payload(kwargs, response_obj, start_time, end_time) -> SpendLogsPayload:
def get_logging_payload(
kwargs, response_obj, start_time, end_time, llm_router: "Router | None" = None
) -> SpendLogsPayload:
if kwargs is None:
kwargs = {}
@ -443,7 +471,7 @@ def get_logging_payload(kwargs, response_obj, start_time, end_time) -> SpendLogs
custom_llm_provider: Final = (
kwargs.get("custom_llm_provider")
or _sl_attribution_fallback(standard_logging_payload, "custom_llm_provider")
or None
or _model_group_provider(_model_group, llm_router)
)
raw_model: Final = cast(str, kwargs.get("model") or "")
resolved_model: Final = (

View file

@ -76,6 +76,49 @@ async def test_daily_spend_tracking_with_disabled_spend_logs():
assert call_args["payload"]["custom_llm_provider"] == "openai"
@pytest.mark.asyncio
async def test_update_database_attributes_router_rejected_failure_to_model_group_provider():
db_writer = DBSpendUpdateWriter()
db_writer._insert_spend_log_to_db = AsyncMock()
db_writer.add_spend_log_transaction_to_daily_user_transaction = AsyncMock()
llm_router: Final = litellm.Router(
model_list=[
{"model_name": "openai-outage", "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-a"}},
{"model_name": "openai-outage", "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-b"}},
]
)
with (
patch("litellm.proxy.proxy_server.disable_spend_logs", True), # test-quality-ok: update_database reads this proxy_server module global at call time; no injection seam
patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), # test-quality-ok: update_database reads this proxy_server module global at call time; no injection seam
patch("litellm.proxy.proxy_server.user_api_key_cache", MagicMock()), # test-quality-ok: update_database reads this proxy_server module global at call time; no injection seam
patch("litellm.proxy.proxy_server.litellm_proxy_budget_name", "test-budget"), # test-quality-ok: update_database reads this proxy_server module global at call time; no injection seam
patch("litellm.proxy.proxy_server.llm_router", llm_router), # test-quality-ok: get_llm_router reads this proxy_server module global at call time; no injection seam
):
await db_writer.update_database(
token="test-token",
user_id="test-user",
end_user_id=None,
team_id=None,
org_id=None,
kwargs={
"model": "openai-outage",
"litellm_params": {
"metadata": {"user_api_key": "test-token", "model_group": "openai-outage", "status": "failure"}
},
},
completion_response={},
start_time=datetime.now(timezone.utc),
end_time=datetime.now(timezone.utc),
response_cost=0.0,
)
await asyncio.sleep(0)
payload: Final = db_writer.add_spend_log_transaction_to_daily_user_transaction.call_args[1]["payload"]
assert payload["model_group"] == "openai-outage"
assert payload["custom_llm_provider"] == "openai"
def _tool_call_response(*names: str) -> object:
from types import SimpleNamespace

View file

@ -4003,6 +4003,72 @@ def test_get_logging_payload_failed_request_without_standard_logging_payload_lea
assert payload["custom_llm_provider"] == ""
def _router_rejected_failure_payload(model_group: str, llm_router: litellm.Router | None) -> SpendLogsPayload:
return get_logging_payload(
kwargs={
"model": model_group,
"litellm_params": {
"metadata": {"user_api_key": "test-key", "model_group": model_group, "status": "failure"}
},
},
response_obj={},
start_time=datetime.datetime.now(timezone.utc),
end_time=datetime.datetime.now(timezone.utc),
llm_router=llm_router,
)
def _openai_and_anthropic_router() -> litellm.Router:
return litellm.Router(
model_list=[
{"model_name": "openai-group", "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-a"}},
{"model_name": "openai-group", "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-b"}},
{"model_name": "mixed-group", "litellm_params": {"model": "openai/gpt-4o-mini", "api_key": "sk-a"}},
{
"model_name": "mixed-group",
"litellm_params": {"model": "anthropic/claude-haiku-4-5", "api_key": "sk-c"},
},
]
)
@pytest.mark.parametrize(
"model_group,expected_provider",
[("openai-group", "openai"), ("mixed-group", ""), ("not-in-router", "")],
)
def test_get_logging_payload_router_rejected_request_takes_provider_from_model_group(
model_group: str, expected_provider: str
):
payload = _router_rejected_failure_payload(model_group, _openai_and_anthropic_router())
assert payload["model_group"] == model_group
assert payload["custom_llm_provider"] == expected_provider
def test_get_logging_payload_router_rejected_request_without_router_leaves_provider_empty():
assert _router_rejected_failure_payload("openai-group", None)["custom_llm_provider"] == ""
def test_get_logging_payload_logged_provider_wins_over_model_group_provider():
payload = get_logging_payload(
kwargs={
"model": "openai-group",
"litellm_params": {"metadata": {"user_api_key": "test-key", "model_group": "openai-group"}},
"standard_logging_object": {
**_make_failed_request_standard_logging_payload(),
"model_group": "openai-group",
"custom_llm_provider": "azure",
},
},
response_obj={},
start_time=datetime.datetime.now(timezone.utc),
end_time=datetime.datetime.now(timezone.utc),
llm_router=_openai_and_anthropic_router(),
)
assert payload["custom_llm_provider"] == "azure"
class _ModelRouterSpendLogKwargs(TypedDict):
model: ReadOnly[str]
litellm_params: ReadOnly[dict[str, dict[str, str]]]