fix(usage): restore per-user spend breakdown (#49093)

This commit is contained in:
ishaan-j-berri 2026-10-31 11:27:13 -07:00
parent aea0e4f2d4
commit f37bc11874
4 changed files with 252 additions and 5 deletions

View file

@ -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))

View file

@ -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(
{

View file

@ -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": {

View file

@ -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: