mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-01 07:03:02 +00:00
fix(usage): restore per-user spend breakdown (#49093)
This commit is contained in:
parent
aea0e4f2d4
commit
f37bc11874
4 changed files with 252 additions and 5 deletions
|
|
@ -1113,6 +1113,16 @@ async def _guardrail_spend_or_empty(
|
|||
return ()
|
||||
|
||||
|
||||
async def _user_entity_metadata(
|
||||
repository: DailyActivityRepository, entity_rows: Sequence[EntityRollupRow]
|
||||
) -> Mapping[str, dict[str, object]]: # mutable-ok: entity_metadata_field shape
|
||||
user_ids: Final = frozenset(row.entity_id for row in entity_rows if row.entity_id)
|
||||
users: Final = await repository.user_metadata(user_ids)
|
||||
return MappingProxyType(
|
||||
{user_id: {"user_email": user.user_email, "user_alias": user.user_alias} for user_id, user in users.items()}
|
||||
)
|
||||
|
||||
|
||||
async def get_daily_activity_aggregated(
|
||||
repository: DailyActivityRepository,
|
||||
scope: DailyActivityScope,
|
||||
|
|
@ -1157,12 +1167,17 @@ async def get_daily_activity_aggregated(
|
|||
if entity_api_keys
|
||||
else MappingProxyType({})
|
||||
)
|
||||
user_entity_metadata: Final = (
|
||||
await _user_entity_metadata(repository, entity_records)
|
||||
if scope.table is DailyActivityTable.USER
|
||||
else MappingProxyType({})
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
_fold_entity_rollups_sync,
|
||||
results=aggregated["results"],
|
||||
entity_rows=entity_records,
|
||||
api_key_metadata=entity_key_metadata,
|
||||
entity_metadata_field=entity_metadata_field,
|
||||
entity_metadata_field=MappingProxyType({**user_entity_metadata, **(entity_metadata_field or {})}),
|
||||
)
|
||||
results: Final = (
|
||||
list(await asyncio.to_thread(_with_guardrail_breakdown, aggregated["results"], guardrail_rows))
|
||||
|
|
|
|||
|
|
@ -363,7 +363,7 @@ USER_RESOLVER = EntityScopeResolver(
|
|||
tags=("Budget & Spend Tracking", "Internal User management"),
|
||||
query=_user_query,
|
||||
resolve=_resolve_user,
|
||||
include_entity_breakdown=False,
|
||||
include_entity_breakdown=True,
|
||||
include_guardrail_breakdown=True,
|
||||
operation_names=MappingProxyType(
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"metadata": {"entity_total_api_keys":null,
|
||||
"metadata": {"entity_total_api_keys":{"user-1": 5},
|
||||
"api_key_limit": 100,
|
||||
"has_more": false,
|
||||
"page": 1,
|
||||
|
|
@ -346,7 +346,181 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"entities": {},
|
||||
"entities": {
|
||||
"user-1": {
|
||||
"api_key_breakdown": {
|
||||
"key-a": {
|
||||
"metadata": {
|
||||
"key_alias": "alias-a",
|
||||
"key_exists": true,
|
||||
"team_id": "team-1",
|
||||
"user_email": "user@example.com",
|
||||
"user_id": "user-1"
|
||||
},
|
||||
"metrics": {
|
||||
"api_requests": 1,
|
||||
"autorouter_savings_spend": 0.0,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 1,
|
||||
"completion_tokens": 2,
|
||||
"compression_saved_tokens": 0,
|
||||
"compression_savings_spend": 0.0,
|
||||
"failed_requests": 0,
|
||||
"flat_cost": 0.0,
|
||||
"ptu_hours": 0.0,
|
||||
"gateway_injected_caching_savings_spend": 0.0,
|
||||
"prompt_caching_savings_spend": 0.0,
|
||||
"prompt_tokens": 2,
|
||||
"spend": 100.0,
|
||||
"successful_requests": 1,
|
||||
"timed_requests": 0,
|
||||
"total_response_time_ms": 0,
|
||||
"total_tokens": 4
|
||||
}
|
||||
},
|
||||
"key-b": {
|
||||
"metadata": {
|
||||
"key_alias": "alias-b",
|
||||
"key_exists": true,
|
||||
"team_id": "team-1",
|
||||
"user_email": "user@example.com",
|
||||
"user_id": "user-1"
|
||||
},
|
||||
"metrics": {
|
||||
"api_requests": 1,
|
||||
"autorouter_savings_spend": 0.0,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 1,
|
||||
"completion_tokens": 2,
|
||||
"compression_saved_tokens": 0,
|
||||
"compression_savings_spend": 0.0,
|
||||
"failed_requests": 0,
|
||||
"flat_cost": 0.0,
|
||||
"ptu_hours": 0.0,
|
||||
"gateway_injected_caching_savings_spend": 0.0,
|
||||
"prompt_caching_savings_spend": 0.0,
|
||||
"prompt_tokens": 3,
|
||||
"spend": 90.0,
|
||||
"successful_requests": 1,
|
||||
"timed_requests": 0,
|
||||
"total_response_time_ms": 0,
|
||||
"total_tokens": 5
|
||||
}
|
||||
},
|
||||
"key-c": {
|
||||
"metadata": {
|
||||
"key_alias": "alias-c",
|
||||
"key_exists": true,
|
||||
"team_id": "team-1",
|
||||
"user_email": "user@example.com",
|
||||
"user_id": "user-1"
|
||||
},
|
||||
"metrics": {
|
||||
"api_requests": 1,
|
||||
"autorouter_savings_spend": 0.0,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 1,
|
||||
"completion_tokens": 2,
|
||||
"compression_saved_tokens": 0,
|
||||
"compression_savings_spend": 0.0,
|
||||
"failed_requests": 0,
|
||||
"flat_cost": 0.0,
|
||||
"ptu_hours": 0.0,
|
||||
"gateway_injected_caching_savings_spend": 0.0,
|
||||
"prompt_caching_savings_spend": 0.0,
|
||||
"prompt_tokens": 4,
|
||||
"spend": 80.0,
|
||||
"successful_requests": 1,
|
||||
"timed_requests": 0,
|
||||
"total_response_time_ms": 0,
|
||||
"total_tokens": 6
|
||||
}
|
||||
},
|
||||
"key-cache": {
|
||||
"metadata": {
|
||||
"key_alias": "alias-cache",
|
||||
"key_exists": true,
|
||||
"team_id": "team-1",
|
||||
"user_email": "user@example.com",
|
||||
"user_id": "user-1"
|
||||
},
|
||||
"metrics": {
|
||||
"api_requests": 1,
|
||||
"autorouter_savings_spend": 0.0,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 1,
|
||||
"completion_tokens": 2,
|
||||
"compression_saved_tokens": 0,
|
||||
"compression_savings_spend": 0.0,
|
||||
"failed_requests": 0,
|
||||
"flat_cost": 0.0,
|
||||
"ptu_hours": 0.0,
|
||||
"gateway_injected_caching_savings_spend": 0.0,
|
||||
"prompt_caching_savings_spend": 0.0,
|
||||
"prompt_tokens": 1000,
|
||||
"spend": 2.0,
|
||||
"successful_requests": 1,
|
||||
"timed_requests": 0,
|
||||
"total_response_time_ms": 0,
|
||||
"total_tokens": 1002
|
||||
}
|
||||
},
|
||||
"key-target": {
|
||||
"metadata": {
|
||||
"key_alias": "deleted-target",
|
||||
"key_exists": false,
|
||||
"team_id": "team-1",
|
||||
"user_email": "user@example.com",
|
||||
"user_id": "user-1"
|
||||
},
|
||||
"metrics": {
|
||||
"api_requests": 1,
|
||||
"autorouter_savings_spend": 0.0,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 2,
|
||||
"completion_tokens": 2,
|
||||
"compression_saved_tokens": 0,
|
||||
"compression_savings_spend": 0.0,
|
||||
"failed_requests": 0,
|
||||
"flat_cost": 0.0,
|
||||
"ptu_hours": 0.0,
|
||||
"gateway_injected_caching_savings_spend": 0.0,
|
||||
"prompt_caching_savings_spend": 0.0,
|
||||
"prompt_tokens": 5,
|
||||
"spend": 1.0,
|
||||
"successful_requests": 1,
|
||||
"timed_requests": 0,
|
||||
"total_response_time_ms": 0,
|
||||
"total_tokens": 7
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"user_alias": null,
|
||||
"user_email": "user@example.com"
|
||||
},
|
||||
"metrics": {
|
||||
"api_requests": 5,
|
||||
"autorouter_savings_spend": 0.0,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"cache_read_input_tokens": 6,
|
||||
"completion_tokens": 10,
|
||||
"compression_saved_tokens": 0,
|
||||
"compression_savings_spend": 0.0,
|
||||
"failed_requests": 0,
|
||||
"flat_cost": 0.0,
|
||||
"ptu_hours": 0.0,
|
||||
"gateway_injected_caching_savings_spend": 0.0,
|
||||
"prompt_caching_savings_spend": 0.0,
|
||||
"prompt_tokens": 1014,
|
||||
"spend": 1273.0,
|
||||
"successful_requests": 5,
|
||||
"timed_requests": 0,
|
||||
"total_response_time_ms": 0,
|
||||
"total_tokens": 1024
|
||||
}
|
||||
}
|
||||
},
|
||||
"mcp_servers": {},
|
||||
"model_groups": {
|
||||
"model-cache": {
|
||||
|
|
|
|||
|
|
@ -23,7 +23,11 @@ from litellm.proxy.management_endpoints.daily_activity_routes import (
|
|||
router,
|
||||
)
|
||||
from litellm.router import Router
|
||||
from litellm.types.proxy.management_endpoints.common_daily_activity import KeySpendMetrics, SpendMetrics
|
||||
from litellm.types.proxy.management_endpoints.common_daily_activity import (
|
||||
KeySpendMetrics,
|
||||
SpendAnalyticsPaginatedResponse,
|
||||
SpendMetrics,
|
||||
)
|
||||
from litellm.types.repositories.daily_activity import (
|
||||
AggregatedRows,
|
||||
DailyActivityScope,
|
||||
|
|
@ -600,6 +604,7 @@ def daily_activity_client() -> Iterator[tuple[TestClient, _FakeRepository]]:
|
|||
|
||||
app.dependency_overrides[get_daily_activity_repository] = lambda: repository
|
||||
app.dependency_overrides[get_daily_activity_prisma_client] = lambda: prisma_client
|
||||
app.dependency_overrides[get_daily_activity_llm_router] = lambda: None
|
||||
app.dependency_overrides[user_api_key_auth] = resolve_auth
|
||||
with TestClient(app) as client:
|
||||
yield client, repository
|
||||
|
|
@ -910,6 +915,59 @@ def test_key_page_route_rejects_invalid_bounds(
|
|||
repository.key_page.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("headers", "expected_users"),
|
||||
(
|
||||
({}, {"2025-01-01": {"user-a": 12.5, "user-b": 100.0}, "2025-01-02": {"user-a": 2.0}}),
|
||||
(
|
||||
{"x-user-role": LitellmUserRoles.INTERNAL_USER.value, "x-user-id": "user-a"},
|
||||
{"2025-01-01": {"user-a": 12.5}, "2025-01-02": {"user-a": 2.0}},
|
||||
),
|
||||
),
|
||||
)
|
||||
def test_user_aggregate_returns_scoped_spend_per_user_with_labels(
|
||||
daily_activity_client: tuple[TestClient, _FakeRepository],
|
||||
headers: dict[str, str],
|
||||
expected_users: dict[str, dict[str, float]],
|
||||
) -> None:
|
||||
client, _ = daily_activity_client
|
||||
response: Final = client.get("/user/daily/activity/aggregated", params=_DATE_PARAMS, headers=headers)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body: Final = SpendAnalyticsPaginatedResponse.model_validate_json(response.content)
|
||||
assert {
|
||||
day.date.isoformat(): {user_id: entity.metrics.spend for user_id, entity in day.breakdown.entities.items()}
|
||||
for day in body.results
|
||||
} == expected_users, response.text
|
||||
labels: Final = {
|
||||
"user-a": {"user_email": "user@example.test", "user_alias": "Alias A"},
|
||||
"user-b": {"user_email": "other-user@example.test", "user_alias": None},
|
||||
}
|
||||
for day in body.results:
|
||||
assert {user_id: entity.metadata for user_id, entity in day.breakdown.entities.items()} == {
|
||||
user_id: labels[user_id] for user_id in expected_users[day.date.isoformat()]
|
||||
}, response.text
|
||||
assert sum(sum(users.values()) for users in expected_users.values()) == body.metadata.total_spend
|
||||
|
||||
|
||||
def test_user_aggregate_preserves_spend_when_user_metadata_is_missing(
|
||||
daily_activity_client: tuple[TestClient, _FakeRepository],
|
||||
) -> None:
|
||||
client, repository = daily_activity_client
|
||||
repository.user_metadata.return_value = {}
|
||||
repository.user_metadata.side_effect = None
|
||||
response: Final = client.get("/user/daily/activity/aggregated", params={**_DATE_PARAMS, "user_id": "user-a"})
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body: Final = SpendAnalyticsPaginatedResponse.model_validate_json(response.content)
|
||||
assert {
|
||||
day.date.isoformat(): {
|
||||
user_id: (entity.metrics.spend, entity.metadata) for user_id, entity in day.breakdown.entities.items()
|
||||
}
|
||||
for day in body.results
|
||||
} == {"2025-01-01": {"user-a": (12.5, {})}, "2025-01-02": {"user-a": (2.0, {})}}, response.text
|
||||
|
||||
|
||||
def test_user_page_route_ranks_users_by_spend_and_attaches_metadata(
|
||||
daily_activity_client: tuple[TestClient, _FakeRepository],
|
||||
) -> None:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue