From f37bc118748eee485e4ce7a34cfa46c6d6954ea1 Mon Sep 17 00:00:00 2001 From: ishaan-j-berri Date: Sat, 31 Oct 2026 11:27:13 -0700 Subject: [PATCH] fix(usage): restore per-user spend breakdown (#49093) --- .../common_daily_activity.py | 17 +- .../daily_activity_scopes.py | 2 +- .../daily_activity_user_aggregated.json | 178 +++++++++++++++++- .../test_daily_activity_routes.py | 60 +++++- 4 files changed, 252 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/management_endpoints/common_daily_activity.py b/litellm/proxy/management_endpoints/common_daily_activity.py index 6e4ea0bd0cb..bccd5ae41ba 100644 --- a/litellm/proxy/management_endpoints/common_daily_activity.py +++ b/litellm/proxy/management_endpoints/common_daily_activity.py @@ -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)) diff --git a/litellm/proxy/management_endpoints/daily_activity_scopes.py b/litellm/proxy/management_endpoints/daily_activity_scopes.py index 7ff73ac6e2e..1eb6ab3210b 100644 --- a/litellm/proxy/management_endpoints/daily_activity_scopes.py +++ b/litellm/proxy/management_endpoints/daily_activity_scopes.py @@ -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( { diff --git a/tests/integration/spend/golden/daily_activity_user_aggregated.json b/tests/integration/spend/golden/daily_activity_user_aggregated.json index 185d333965a..3189c000035 100644 --- a/tests/integration/spend/golden/daily_activity_user_aggregated.json +++ b/tests/integration/spend/golden/daily_activity_user_aggregated.json @@ -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": { diff --git a/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py b/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py index e487c00f25d..d658e59b76c 100644 --- a/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py +++ b/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py @@ -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: