mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-05 02:41:56 +00:00
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:
parent
9357491f91
commit
acd4f0eb04
4 changed files with 141 additions and 3 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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]]]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue