mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
feat(team): report per-user spend within a team for JWT traffic (#39771)
* feat(team): report per-user spend within a team for JWT traffic Add GET /team/spend/by_user, which groups raw spend logs by (team_id, user) so JWT/SSO requests with no virtual key are attributed to the user inside each selected team. Team admins see every member, plain members see only their own row. The Team Usage page gets a Spend Per User Within Team card with CSV export backed by the same endpoint. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * test(team): cover /team/spend/by_user in behavior suite, tf audit allowlist and EntityUsage unit test Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(team): drop explanatory docstrings from /team/spend/by_user and regen schema.d.ts Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --------- Co-authored-by: yassin <yassin@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
f74bc9427b
commit
dd01abc439
14 changed files with 845 additions and 0 deletions
|
|
@ -670,6 +670,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/team/permissions_bulk_update",
|
||||
"/team/daily/activity",
|
||||
"/team/daily/activity/aggregated",
|
||||
"/team/spend/by_user",
|
||||
# gateway request counts (SGR); deployment-wide, admin-only
|
||||
"/gateway/daily/activity",
|
||||
# model
|
||||
|
|
@ -832,6 +833,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/team/permissions_update",
|
||||
"/team/daily/activity",
|
||||
"/team/daily/activity/aggregated",
|
||||
"/team/spend/by_user",
|
||||
"/team/{team_id}/members/me",
|
||||
"/model/new",
|
||||
"/model/update",
|
||||
|
|
|
|||
|
|
@ -170,6 +170,8 @@ from litellm.types.proxy.management_endpoints.team_endpoints import (
|
|||
TeamMemberAddResult,
|
||||
TeamMemberInfoResponse,
|
||||
TeamMetadataSchemaResponse,
|
||||
TeamUserSpendResponse,
|
||||
TeamUserSpendRow,
|
||||
UpdateTeamMemberPermissionsRequest,
|
||||
)
|
||||
|
||||
|
|
@ -6231,3 +6233,124 @@ async def get_team_daily_activity_aggregated(
|
|||
timezone_offset_minutes=timezone,
|
||||
include_entity_breakdown=True,
|
||||
)
|
||||
|
||||
|
||||
def _team_user_spend_sql(*, team_count: int, restrict_to_user: bool) -> str:
|
||||
team_placeholders: Final = ", ".join(f"${i}" for i in range(3, 3 + team_count))
|
||||
user_clause: Final = f' AND sl."user" = ${3 + team_count}' if restrict_to_user else ""
|
||||
return f"""
|
||||
SELECT
|
||||
sl.team_id,
|
||||
sl."user" AS user_id,
|
||||
u.user_email,
|
||||
u.user_alias,
|
||||
SUM(sl.spend)::float AS spend,
|
||||
SUM(sl.prompt_tokens)::bigint AS prompt_tokens,
|
||||
SUM(sl.completion_tokens)::bigint AS completion_tokens,
|
||||
SUM(sl.total_tokens)::bigint AS total_tokens,
|
||||
COUNT(*)::bigint AS api_requests,
|
||||
COUNT(*) FILTER (WHERE sl.status IS DISTINCT FROM 'failure')::bigint AS successful_requests,
|
||||
COUNT(*) FILTER (WHERE sl.status = 'failure')::bigint AS failed_requests
|
||||
FROM "LiteLLM_SpendLogs" sl
|
||||
LEFT JOIN "LiteLLM_UserTable" u ON u.user_id = sl."user"
|
||||
WHERE sl."startTime" >= $1::timestamp
|
||||
AND sl."startTime" < $2::timestamp + INTERVAL '1 day'
|
||||
AND sl.team_id IN ({team_placeholders}){user_clause}
|
||||
GROUP BY sl.team_id, sl."user", u.user_email, u.user_alias
|
||||
ORDER BY spend DESC, sl.team_id, sl."user"
|
||||
"""
|
||||
|
||||
|
||||
class _TeamUserSpendDbRow(TypedDict):
|
||||
team_id: ReadOnly[str]
|
||||
user_id: ReadOnly[str | None]
|
||||
user_email: ReadOnly[str | None]
|
||||
user_alias: ReadOnly[str | None]
|
||||
spend: ReadOnly[float]
|
||||
prompt_tokens: ReadOnly[int]
|
||||
completion_tokens: ReadOnly[int]
|
||||
total_tokens: ReadOnly[int]
|
||||
api_requests: ReadOnly[int]
|
||||
successful_requests: ReadOnly[int]
|
||||
failed_requests: ReadOnly[int]
|
||||
|
||||
|
||||
@router.get(
|
||||
"/team/spend/by_user",
|
||||
response_model=TeamUserSpendResponse,
|
||||
tags=["team management"], # mutable-ok: fastapi route tags must be a list
|
||||
)
|
||||
async def get_team_spend_by_user(
|
||||
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
|
||||
team_ids: str | None = None,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
) -> TeamUserSpendResponse:
|
||||
"""
|
||||
Spend per user within the given teams, attributed per request from spend logs.
|
||||
|
||||
Proxy admins may query any team. Team admins and members holding the
|
||||
`/team/daily/activity` permission see every user of the requested teams;
|
||||
other members only see their own row.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import (
|
||||
prisma_client,
|
||||
proxy_logging_obj,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
if prisma_client is None:
|
||||
raise _daily_activity_error(status_code=500, message=CommonProxyErrors.db_not_connected_error.value)
|
||||
|
||||
range_error: Final = _aggregated_date_range_error(start_date, end_date)
|
||||
if range_error is not None or start_date is None or end_date is None:
|
||||
raise _daily_activity_error(status_code=400, message=range_error or "Please provide start_date and end_date")
|
||||
|
||||
if not team_ids:
|
||||
raise _daily_activity_error(status_code=400, message="Please provide team_ids")
|
||||
|
||||
scope: Final = await _resolve_team_daily_activity_scope(
|
||||
team_ids=team_ids,
|
||||
exclude_team_ids=None,
|
||||
api_key=None,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
scoped_team_ids: Final = tuple(scope.team_ids or ())
|
||||
if not scoped_team_ids:
|
||||
return TeamUserSpendResponse(start_date=start_date, end_date=end_date, results=())
|
||||
|
||||
own_user_only: Final = scope.api_key_filter is not None
|
||||
user_param: Final = (user_api_key_dict.user_id or "",) if own_user_only else ()
|
||||
rows: Final[Sequence[_TeamUserSpendDbRow]] = await prisma_client.db.query_raw(
|
||||
_team_user_spend_sql(team_count=len(scoped_team_ids), restrict_to_user=own_user_only),
|
||||
start_date,
|
||||
end_date,
|
||||
*scoped_team_ids,
|
||||
*user_param,
|
||||
)
|
||||
results: Final = tuple(
|
||||
TeamUserSpendRow(
|
||||
team_id=row["team_id"],
|
||||
team_alias=_team_alias_or_none(scope.team_alias_metadata.get(row["team_id"])),
|
||||
user_id=row["user_id"] or "",
|
||||
user_email=row["user_email"],
|
||||
user_alias=row["user_alias"],
|
||||
spend=row["spend"],
|
||||
prompt_tokens=row["prompt_tokens"],
|
||||
completion_tokens=row["completion_tokens"],
|
||||
total_tokens=row["total_tokens"],
|
||||
api_requests=row["api_requests"],
|
||||
successful_requests=row["successful_requests"],
|
||||
failed_requests=row["failed_requests"],
|
||||
)
|
||||
for row in rows
|
||||
)
|
||||
return TeamUserSpendResponse(start_date=start_date, end_date=end_date, results=results)
|
||||
|
||||
|
||||
def _team_alias_or_none(metadata: Mapping[str, object] | None) -> str | None:
|
||||
alias: Final = metadata.get("team_alias") if metadata is not None else None
|
||||
return alias if isinstance(alias, str) else None
|
||||
|
|
|
|||
|
|
@ -143,3 +143,24 @@ class TeamMetadataSchemaResponse(BaseModel):
|
|||
"""Response for GET /team/metadata_schema; ``fields`` is empty when no schema is configured."""
|
||||
|
||||
fields: tuple[TeamMetadataFieldSchema, ...]
|
||||
|
||||
|
||||
class TeamUserSpendRow(BaseModel):
|
||||
team_id: str
|
||||
team_alias: str | None = None
|
||||
user_id: str
|
||||
user_email: str | None = None
|
||||
user_alias: str | None = None
|
||||
spend: float = 0.0
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
api_requests: int = 0
|
||||
successful_requests: int = 0
|
||||
failed_requests: int = 0
|
||||
|
||||
|
||||
class TeamUserSpendResponse(BaseModel):
|
||||
start_date: str
|
||||
end_date: str
|
||||
results: tuple[TeamUserSpendRow, ...]
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ GET /tag/user-agent/per-user-analytics
|
|||
GET /tag/wau
|
||||
GET /team/daily/activity
|
||||
GET /team/daily/activity/aggregated
|
||||
GET /team/spend/by_user
|
||||
GET /team/spend/report
|
||||
GET /user/daily/activity
|
||||
GET /user/daily/activity/aggregated
|
||||
|
|
|
|||
58
tests/proxy_behavior/management/test_team_spend_by_user.py
Normal file
58
tests/proxy_behavior/management/test_team_spend_by_user.py
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import pytest
|
||||
|
||||
from .actors import Actor
|
||||
|
||||
pytestmark = pytest.mark.asyncio(loop_scope="session")
|
||||
|
||||
|
||||
# GET /team/spend/by_user shares the team-scope resolver with
|
||||
# /team/daily/activity, so the membership matrix must hold here too. team_ids
|
||||
# is mandatory on this route (a per-user rollup with no team is meaningless),
|
||||
# so the bare query is 400 for everyone instead of defaulting to own teams.
|
||||
_MEMBERS = {
|
||||
"alpha": {
|
||||
Actor.TEAM_ADMIN,
|
||||
Actor.INTERNAL_USER,
|
||||
Actor.OWNER,
|
||||
Actor.UNRELATED_SAME_ORG,
|
||||
Actor.SERVICE_ACCOUNT,
|
||||
},
|
||||
"beta": {Actor.CROSS_ORG_USER},
|
||||
}
|
||||
|
||||
|
||||
def _expected(actor: Actor, team: str) -> int:
|
||||
if team == "none":
|
||||
return 400
|
||||
if actor == Actor.PROXY_ADMIN:
|
||||
return 200
|
||||
return 200 if actor in _MEMBERS.get(team, set()) else 404
|
||||
|
||||
|
||||
_CASES = [
|
||||
(f"{team}/{actor.value}", actor, team, _expected(actor, team))
|
||||
for team in ("none", "alpha", "beta")
|
||||
for actor in Actor
|
||||
]
|
||||
|
||||
_DATES = "start_date=2024-01-01&end_date=2024-12-31"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"actor,team,expected_status",
|
||||
[(a, t, s) for (_id, a, t, s) in _CASES],
|
||||
ids=[c[0] for c in _CASES],
|
||||
)
|
||||
async def test_team_spend_by_user_matrix(actor: Actor, team: str, expected_status: int, proxy_client, world):
|
||||
team_id = {"alpha": world.team_alpha_id, "beta": world.team_beta_id}.get(team)
|
||||
query = _DATES if team_id is None else f"{_DATES}&team_ids={team_id}"
|
||||
|
||||
resp = await proxy_client.get(
|
||||
f"/team/spend/by_user?{query}",
|
||||
headers={"Authorization": f"Bearer {world.keys[actor].cleartext}"},
|
||||
)
|
||||
assert resp.status_code == expected_status, f"{actor.value} -> {team}: {resp.status_code} {resp.text}"
|
||||
if expected_status == 200:
|
||||
body = resp.json()
|
||||
assert (body["start_date"], body["end_date"]) == ("2024-01-01", "2024-12-31")
|
||||
assert all(row["team_id"] == team_id for row in body["results"])
|
||||
|
|
@ -3312,6 +3312,41 @@ def test_user_daily_activity_routes_reachable_by_non_admin(route, user_role):
|
|||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user_role",
|
||||
[
|
||||
LitellmUserRoles.INTERNAL_USER.value,
|
||||
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
|
||||
],
|
||||
)
|
||||
def test_team_spend_by_user_reachable_by_non_admin(user_role):
|
||||
user_obj = LiteLLM_UserTable(
|
||||
user_id="test_user",
|
||||
user_email="test@example.com",
|
||||
user_role=user_role,
|
||||
)
|
||||
valid_token = UserAPIKeyAuth(user_id="test_user", user_role=user_role)
|
||||
request = MagicMock(spec=Request)
|
||||
request.query_params = {}
|
||||
|
||||
def outcome(route: str) -> str:
|
||||
try:
|
||||
RouteChecks.non_proxy_admin_allowed_routes_check(
|
||||
user_obj=user_obj,
|
||||
_user_role=user_role,
|
||||
route=route,
|
||||
request=request,
|
||||
valid_token=valid_token,
|
||||
request_data={},
|
||||
)
|
||||
except Exception as exc:
|
||||
return f"denied: {exc}"
|
||||
return "allowed"
|
||||
|
||||
assert outcome("/team/spend/by_user") == "allowed"
|
||||
assert outcome("/team/spend/by_key").startswith("denied: Only proxy admin")
|
||||
|
||||
|
||||
def test_user_daily_activity_aggregated_not_covered_by_prefix_match():
|
||||
"""check_route_access is exact-match plus explicit wildcards, so listing the
|
||||
parent /user/daily/activity does not implicitly cover the /aggregated
|
||||
|
|
|
|||
|
|
@ -13598,3 +13598,175 @@ async def test_team_member_update_skips_invalidation_when_no_budget_fields_sent(
|
|||
|
||||
assert await real_cache.async_get_cache(key="team-1_member-1") == "still-fresh-membership"
|
||||
assert real_spend_counter_cache.in_memory_cache.get_cache(key="spend:team_member:member-1:team-1") == 1.5
|
||||
|
||||
|
||||
def _team_spend_by_user_team(team_id: str, team_alias: str, member: Member, permissions: list[str]) -> MagicMock:
|
||||
team = MagicMock(spec=LiteLLM_TeamTable)
|
||||
team.team_id = team_id
|
||||
team.team_alias = team_alias
|
||||
team.members_with_roles = [member]
|
||||
team.team_member_permissions = permissions
|
||||
team.model_dump.return_value = {
|
||||
"team_id": team_id,
|
||||
"team_alias": team_alias,
|
||||
"members_with_roles": [{"user_id": member.user_id, "role": member.role}],
|
||||
"team_member_permissions": permissions,
|
||||
}
|
||||
return team
|
||||
|
||||
|
||||
def _team_spend_by_user_caller(user_id: str, teams: list[str]) -> LiteLLM_UserTable:
|
||||
return LiteLLM_UserTable(
|
||||
user_id=user_id, user_email=f"{user_id}@example.com", teams=teams, user_role="internal_user"
|
||||
)
|
||||
|
||||
|
||||
def _team_spend_by_user_db_row(team_id: str, user_id: str, spend: float, requests: int) -> dict:
|
||||
return {
|
||||
"team_id": team_id,
|
||||
"user_id": user_id,
|
||||
"user_email": f"{user_id}@example.com",
|
||||
"user_alias": None,
|
||||
"spend": spend,
|
||||
"prompt_tokens": 10 * requests,
|
||||
"completion_tokens": 5 * requests,
|
||||
"total_tokens": 15 * requests,
|
||||
"api_requests": requests,
|
||||
"successful_requests": requests - 1,
|
||||
"failed_requests": 1,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_team_spend_by_user_admin_groups_spend_logs_by_team_and_user(mock_db_client):
|
||||
from litellm.proxy.management_endpoints.team_endpoints import get_team_spend_by_user
|
||||
|
||||
admin = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
alpha = _team_spend_by_user_team("team-alpha", "Team Alpha", Member(user_id="alice", role="admin"), [])
|
||||
beta = _team_spend_by_user_team("team-beta", "Team Beta", Member(user_id="alice", role="user"), [])
|
||||
mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[alpha, beta])
|
||||
mock_db_client.db.query_raw = AsyncMock(
|
||||
return_value=[
|
||||
_team_spend_by_user_db_row("team-alpha", "alice", 0.5, 3),
|
||||
_team_spend_by_user_db_row("team-alpha", "bob", 0.25, 2),
|
||||
_team_spend_by_user_db_row("team-beta", "alice", 0.1, 1),
|
||||
]
|
||||
)
|
||||
|
||||
response = await get_team_spend_by_user(
|
||||
user_api_key_dict=admin,
|
||||
team_ids="team-alpha,team-beta",
|
||||
start_date="2026-09-01",
|
||||
end_date="2026-09-04",
|
||||
)
|
||||
|
||||
sql, *params = mock_db_client.db.query_raw.call_args.args
|
||||
assert params == ["2026-09-01", "2026-09-04", "team-alpha", "team-beta"]
|
||||
assert 'FROM "LiteLLM_SpendLogs" sl' in sql
|
||||
assert 'sl."startTime" >= $1::timestamp' in sql
|
||||
assert "sl.\"startTime\" < $2::timestamp + INTERVAL '1 day'" in sql
|
||||
assert "sl.team_id IN ($3, $4)" in sql
|
||||
assert 'GROUP BY sl.team_id, sl."user"' in sql
|
||||
assert 'sl."user" = $' not in sql
|
||||
|
||||
assert response.start_date == "2026-09-01"
|
||||
assert response.end_date == "2026-09-04"
|
||||
assert [(r.team_id, r.team_alias, r.user_id, r.user_email, r.spend, r.api_requests) for r in response.results] == [
|
||||
("team-alpha", "Team Alpha", "alice", "alice@example.com", 0.5, 3),
|
||||
("team-alpha", "Team Alpha", "bob", "bob@example.com", 0.25, 2),
|
||||
("team-beta", "Team Beta", "alice", "alice@example.com", 0.1, 1),
|
||||
]
|
||||
assert (response.results[0].successful_requests, response.results[0].failed_requests) == (2, 1)
|
||||
assert (response.results[0].prompt_tokens, response.results[0].completion_tokens) == (30, 15)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_team_spend_by_user_team_admin_sees_every_member(mock_db_client):
|
||||
from litellm.proxy.management_endpoints.team_endpoints import get_team_spend_by_user
|
||||
|
||||
caller = UserAPIKeyAuth(user_id="alice", user_role=LitellmUserRoles.INTERNAL_USER)
|
||||
alpha = _team_spend_by_user_team("team-alpha", "Team Alpha", Member(user_id="alice", role="admin"), [])
|
||||
mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[alpha])
|
||||
mock_db_client.db.query_raw = AsyncMock(return_value=[])
|
||||
mock_db_client.db.litellm_usertable.find_unique = AsyncMock(
|
||||
return_value=_team_spend_by_user_caller("alice", ["team-alpha"])
|
||||
)
|
||||
|
||||
await get_team_spend_by_user(
|
||||
user_api_key_dict=caller, team_ids="team-alpha", start_date="2026-09-01", end_date="2026-09-04"
|
||||
)
|
||||
|
||||
sql, *params = mock_db_client.db.query_raw.call_args.args
|
||||
assert params == ["2026-09-01", "2026-09-04", "team-alpha"]
|
||||
assert 'sl."user" = $' not in sql
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_team_spend_by_user_plain_member_only_sees_own_row(mock_db_client):
|
||||
from litellm.proxy.management_endpoints.team_endpoints import get_team_spend_by_user
|
||||
|
||||
caller = UserAPIKeyAuth(user_id="bob", user_role=LitellmUserRoles.INTERNAL_USER)
|
||||
alpha = _team_spend_by_user_team("team-alpha", "Team Alpha", Member(user_id="bob", role="user"), ["/key/info"])
|
||||
mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[alpha])
|
||||
mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
|
||||
mock_db_client.db.query_raw = AsyncMock(return_value=[_team_spend_by_user_db_row("team-alpha", "bob", 0.25, 2)])
|
||||
mock_db_client.db.litellm_usertable.find_unique = AsyncMock(
|
||||
return_value=_team_spend_by_user_caller("bob", ["team-alpha"])
|
||||
)
|
||||
|
||||
response = await get_team_spend_by_user(
|
||||
user_api_key_dict=caller, team_ids="team-alpha", start_date="2026-09-01", end_date="2026-09-04"
|
||||
)
|
||||
|
||||
sql, *params = mock_db_client.db.query_raw.call_args.args
|
||||
assert params == ["2026-09-01", "2026-09-04", "team-alpha", "bob"]
|
||||
assert "sl.team_id IN ($3)" in sql
|
||||
assert 'AND sl."user" = $4' in sql
|
||||
assert [(r.user_id, r.spend) for r in response.results] == [("bob", 0.25)]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_team_spend_by_user_member_of_other_team_gets_404(mock_db_client):
|
||||
from litellm.proxy.management_endpoints.team_endpoints import get_team_spend_by_user
|
||||
|
||||
caller = UserAPIKeyAuth(user_id="bob", user_role=LitellmUserRoles.INTERNAL_USER)
|
||||
mock_db_client.db.query_raw = AsyncMock(return_value=[])
|
||||
mock_db_client.db.litellm_usertable.find_unique = AsyncMock(
|
||||
return_value=_team_spend_by_user_caller("bob", ["team-alpha"])
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_team_spend_by_user(
|
||||
user_api_key_dict=caller, team_ids="team-beta", start_date="2026-09-01", end_date="2026-09-04"
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 404
|
||||
mock_db_client.db.query_raw.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"team_ids,start_date,end_date,expected_error",
|
||||
[
|
||||
(None, "2026-09-01", "2026-09-04", "team_ids"),
|
||||
("", "2026-09-01", "2026-09-04", "team_ids"),
|
||||
("team-alpha", None, "2026-09-04", "start_date and end_date"),
|
||||
("team-alpha", "2026-09-04", "2026-09-01", "on or after"),
|
||||
("team-alpha", "2020-01-01", "2026-12-31", "at most 400 days"),
|
||||
("team-alpha", "nope", "2026-09-04", "valid YYYY-MM-DD"),
|
||||
],
|
||||
)
|
||||
async def test_get_team_spend_by_user_rejects_bad_input(mock_db_client, team_ids, start_date, end_date, expected_error):
|
||||
from litellm.proxy.management_endpoints.team_endpoints import get_team_spend_by_user
|
||||
|
||||
mock_db_client.db.query_raw = AsyncMock(return_value=[])
|
||||
admin = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_team_spend_by_user(
|
||||
user_api_key_dict=admin, team_ids=team_ids, start_date=start_date, end_date=end_date
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert expected_error in str(exc_info.value.detail)
|
||||
mock_db_client.db.query_raw.assert_not_called()
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import userEvent from "@testing-library/user-event";
|
|||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers";
|
||||
import useTeams from "@/app/(dashboard)/hooks/useTeams";
|
||||
import * as networking from "@/components/networking";
|
||||
import EntityUsage from "./EntityUsage";
|
||||
|
||||
|
|
@ -60,6 +61,10 @@ vi.mock("./TopModelView", () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
vi.mock("./TeamUserSpendCard", () => ({
|
||||
default: ({ teamIds }: { teamIds: string[] }) => <div>{`team-user-spend:${teamIds.join("|")}`}</div>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/EntityUsageExport/EntityUsageExportModal", () => ({
|
||||
default: () => <div>Entity Usage Export Modal</div>,
|
||||
}));
|
||||
|
|
@ -460,6 +465,26 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("feeds the per-user spend card every visible team except the dashboard team, only for teams", async () => {
|
||||
const mockUseTeams = vi.mocked(useTeams);
|
||||
const teamsResult = (teams: { team_id: string }[]) =>
|
||||
({ teams, setTeams: vi.fn() }) as unknown as ReturnType<typeof useTeams>;
|
||||
mockUseTeams.mockReturnValue(
|
||||
teamsResult([{ team_id: "team-alpha" }, { team_id: "litellm-dashboard" }, { team_id: "team-beta" }]),
|
||||
);
|
||||
|
||||
render(<EntityUsage {...defaultProps} entityType="team" />);
|
||||
expect(await screen.findByText("team-user-spend:team-alpha|team-beta")).toBeInTheDocument();
|
||||
|
||||
cleanup();
|
||||
mockUseTeams.mockReturnValue(teamsResult([]));
|
||||
render(<EntityUsage {...defaultProps} entityType="tag" />);
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.queryByText(/^team-user-spend:/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render with organization entity type and call organization API", async () => {
|
||||
render(<EntityUsage {...defaultProps} entityType="organization" />);
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import EndpointUsage from "../EndpointUsage/EndpointUsage";
|
|||
import ModelViewToggle, { ModelViewType } from "../ModelViewToggle";
|
||||
import TopKeyView from "@/components/UsagePage/components/EntityUsage/TopKeyView";
|
||||
import TopModelView from "./TopModelView";
|
||||
import TeamUserSpendCard from "./TeamUserSpendCard";
|
||||
|
||||
interface EntityMetrics {
|
||||
metrics: {
|
||||
|
|
@ -275,6 +276,13 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
|
||||
const capitalizedEntityLabel = entityType.charAt(0).toUpperCase() + entityType.slice(1);
|
||||
const showFlatCost = entityType === "team" && hasFlatCost(spendData.metadata);
|
||||
const userSpendTeamIds = useMemo(
|
||||
() =>
|
||||
selectedTags.length > 0
|
||||
? selectedTags
|
||||
: (teams ?? []).map((team) => team.team_id).filter((id) => id !== "litellm-dashboard"),
|
||||
[selectedTags, teams],
|
||||
);
|
||||
const providerSpend = useMemo(() => getProviderSpend(spendData.results), [spendData.results]);
|
||||
const entityBreakdownColumns = useMemo<ColumnDef<EntityMetricWithMetadata>[]>(
|
||||
() => [
|
||||
|
|
@ -530,6 +538,17 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
|
|||
</ShadcnCard>
|
||||
</div>
|
||||
|
||||
{entityType === "team" && (
|
||||
<div className="col-span-2">
|
||||
<TeamUserSpendCard
|
||||
accessToken={accessToken}
|
||||
startTime={startTime}
|
||||
endTime={endTime}
|
||||
teamIds={userSpendTeamIds}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top API Keys */}
|
||||
<div>
|
||||
<ShadcnCard>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { Download } from "lucide-react";
|
||||
import React, { useMemo } from "react";
|
||||
|
||||
import { teamSpendByUserCall } from "@/components/networking";
|
||||
import { DataTable } from "@/components/shared/DataTable";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card as ShadcnCard, CardContent } from "@/components/ui/card";
|
||||
|
||||
import {
|
||||
buildTeamUserSpendCsv,
|
||||
downloadCsv,
|
||||
sortBySpendDesc,
|
||||
teamLabel,
|
||||
teamUserSpendCsvFileName,
|
||||
teamUserSpendRowId,
|
||||
userLabel,
|
||||
type TeamUserSpendRow,
|
||||
} from "./teamUserSpend";
|
||||
|
||||
interface TeamUserSpendCardProps {
|
||||
accessToken: string | null;
|
||||
startTime: Date | null;
|
||||
endTime: Date | null;
|
||||
teamIds: string[];
|
||||
}
|
||||
|
||||
const columns: ColumnDef<TeamUserSpendRow>[] = [
|
||||
{ header: "Team", accessorFn: teamLabel, id: "team", cell: ({ row }) => teamLabel(row.original) },
|
||||
{ header: "User", accessorFn: userLabel, id: "user", cell: ({ row }) => userLabel(row.original) },
|
||||
{
|
||||
header: "Spend",
|
||||
accessorKey: "spend",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={4} />,
|
||||
},
|
||||
{
|
||||
header: "Requests",
|
||||
accessorKey: "api_requests",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => row.original.api_requests.toLocaleString(),
|
||||
},
|
||||
{
|
||||
header: "Successful",
|
||||
accessorKey: "successful_requests",
|
||||
meta: { numeric: true, className: "text-success" },
|
||||
cell: ({ row }) => row.original.successful_requests.toLocaleString(),
|
||||
},
|
||||
{
|
||||
header: "Failed",
|
||||
accessorKey: "failed_requests",
|
||||
meta: { numeric: true, className: "text-destructive" },
|
||||
cell: ({ row }) => row.original.failed_requests.toLocaleString(),
|
||||
},
|
||||
{
|
||||
header: "Tokens",
|
||||
accessorKey: "total_tokens",
|
||||
meta: { numeric: true },
|
||||
cell: ({ row }) => row.original.total_tokens.toLocaleString(),
|
||||
},
|
||||
];
|
||||
|
||||
const TeamUserSpendCard: React.FC<TeamUserSpendCardProps> = ({ accessToken, startTime, endTime, teamIds }) => {
|
||||
const hasTeams = teamIds.length > 0;
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["teamSpendByUser", startTime?.toISOString(), endTime?.toISOString(), teamIds],
|
||||
queryFn: () =>
|
||||
accessToken && startTime && endTime ? teamSpendByUserCall(accessToken, startTime, endTime, teamIds) : null,
|
||||
enabled: Boolean(accessToken && startTime && endTime) && hasTeams,
|
||||
});
|
||||
const rows = useMemo(() => sortBySpendDesc(data?.results ?? []), [data]);
|
||||
|
||||
return (
|
||||
<ShadcnCard>
|
||||
<CardContent className="flex flex-col space-y-4">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex flex-col space-y-2">
|
||||
<h3 className="text-lg font-medium text-foreground">Spend Per User Within Team</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Attributed per request from spend logs, so it includes JWT/SSO traffic that does not use a virtual key
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!data || rows.length === 0}
|
||||
onClick={() => data && downloadCsv(buildTeamUserSpendCsv(data), teamUserSpendCsvFileName(data))}
|
||||
>
|
||||
<Download />
|
||||
Download CSV
|
||||
</Button>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
getRowId={teamUserSpendRowId}
|
||||
isLoading={isLoading}
|
||||
maxBodyHeight={320}
|
||||
noDataMessage={teamIds.length === 0 ? "Select a team to see spend per user" : "No user spend in this range"}
|
||||
size="compact"
|
||||
/>
|
||||
</CardContent>
|
||||
</ShadcnCard>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamUserSpendCard;
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { TeamUserSpendResponse } from "@/components/networking";
|
||||
|
||||
import {
|
||||
buildTeamUserSpendCsv,
|
||||
sortBySpendDesc,
|
||||
teamUserSpendCsvFileName,
|
||||
teamUserSpendRowId,
|
||||
userLabel,
|
||||
type TeamUserSpendRow,
|
||||
} from "./teamUserSpend";
|
||||
|
||||
const row = (overrides: Partial<TeamUserSpendRow>): TeamUserSpendRow => ({
|
||||
team_id: "team-alpha",
|
||||
team_alias: "Team Alpha",
|
||||
user_id: "alice@example.com",
|
||||
user_email: "alice@example.com",
|
||||
user_alias: null,
|
||||
spend: 0.5,
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 5,
|
||||
total_tokens: 15,
|
||||
api_requests: 3,
|
||||
successful_requests: 2,
|
||||
failed_requests: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const aliceInBeta: Partial<TeamUserSpendRow> = {
|
||||
team_id: "team-beta",
|
||||
team_alias: "Team Beta",
|
||||
spend: 0.1,
|
||||
api_requests: 1,
|
||||
};
|
||||
const bobInAlpha: Partial<TeamUserSpendRow> = {
|
||||
user_id: "bob",
|
||||
user_email: null,
|
||||
user_alias: "Bob",
|
||||
spend: 0.25,
|
||||
api_requests: 2,
|
||||
};
|
||||
|
||||
const response: TeamUserSpendResponse = {
|
||||
start_date: "2026-09-01",
|
||||
end_date: "2026-09-04",
|
||||
results: [row(aliceInBeta), row({}), row(bobInAlpha)],
|
||||
};
|
||||
|
||||
describe("teamUserSpend", () => {
|
||||
it("keeps the same user as separate rows per team", () => {
|
||||
const ids = response.results.map(teamUserSpendRowId);
|
||||
expect(new Set(ids).size).toBe(3);
|
||||
expect(ids[0]).not.toBe(ids[1]);
|
||||
});
|
||||
|
||||
it("labels a user by email, then alias, then id, then a placeholder", () => {
|
||||
expect(userLabel(row({}))).toBe("alice@example.com");
|
||||
expect(userLabel(row({ user_email: null, user_alias: "Bob", user_id: "u1" }))).toBe("Bob");
|
||||
expect(userLabel(row({ user_email: null, user_alias: null, user_id: "u1" }))).toBe("u1");
|
||||
expect(userLabel(row({ user_email: null, user_alias: null, user_id: "" }))).toBe("(no user)");
|
||||
});
|
||||
|
||||
it("sorts by spend descending without mutating the input", () => {
|
||||
const before = [...response.results];
|
||||
expect(sortBySpendDesc(response.results).map((r) => r.spend)).toEqual([0.5, 0.25, 0.1]);
|
||||
expect(response.results).toEqual(before);
|
||||
});
|
||||
|
||||
it("writes one CSV line per (team, user) with the team kept on every line", () => {
|
||||
const lines = buildTeamUserSpendCsv(response).split(/\r?\n/);
|
||||
expect(lines[0]).toBe(
|
||||
"Start Date,End Date,Team,Team ID,User,User ID,User Email,Spend (USD),Requests,Successful,Failed,Prompt Tokens,Completion Tokens,Total Tokens",
|
||||
);
|
||||
expect(lines.slice(1)).toEqual([
|
||||
"2026-09-01,2026-09-04,Team Alpha,team-alpha,alice@example.com,alice@example.com,alice@example.com,0.5,3,2,1,10,5,15",
|
||||
"2026-09-01,2026-09-04,Team Alpha,team-alpha,Bob,bob,,0.25,2,2,1,10,5,15",
|
||||
"2026-09-01,2026-09-04,Team Beta,team-beta,alice@example.com,alice@example.com,alice@example.com,0.1,1,2,1,10,5,15",
|
||||
]);
|
||||
});
|
||||
|
||||
it("neutralises spreadsheet formulas in user-controlled cells", () => {
|
||||
const csv = buildTeamUserSpendCsv({
|
||||
...response,
|
||||
results: [row({ user_alias: null, user_email: "=HYPERLINK(1)" })],
|
||||
});
|
||||
expect(csv).toContain("'=HYPERLINK(1)");
|
||||
});
|
||||
|
||||
it("names the file after the exported range", () => {
|
||||
expect(teamUserSpendCsvFileName(response)).toBe("team_user_spend_2026-09-01_to_2026-09-04.csv");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
import Papa from "papaparse";
|
||||
|
||||
import type { TeamUserSpendResponse } from "@/components/networking";
|
||||
|
||||
export type TeamUserSpendRow = TeamUserSpendResponse["results"][number];
|
||||
|
||||
export const NO_USER_LABEL = "(no user)";
|
||||
|
||||
export const userLabel = (row: TeamUserSpendRow): string => {
|
||||
const identity = row.user_email || row.user_alias;
|
||||
return identity || row.user_id || NO_USER_LABEL;
|
||||
};
|
||||
|
||||
export const teamLabel = (row: TeamUserSpendRow): string => row.team_alias || row.team_id;
|
||||
|
||||
export const teamUserSpendRowId = (row: TeamUserSpendRow): string => `${row.team_id}\u0000${row.user_id}`;
|
||||
|
||||
export const sortBySpendDesc = (rows: readonly TeamUserSpendRow[]): TeamUserSpendRow[] =>
|
||||
[...rows].sort((a, b) => b.spend - a.spend || teamLabel(a).localeCompare(teamLabel(b)));
|
||||
|
||||
export const buildTeamUserSpendCsv = (response: TeamUserSpendResponse): string =>
|
||||
Papa.unparse(
|
||||
sortBySpendDesc(response.results).map((row) => ({
|
||||
"Start Date": response.start_date,
|
||||
"End Date": response.end_date,
|
||||
Team: teamLabel(row),
|
||||
"Team ID": row.team_id,
|
||||
User: userLabel(row),
|
||||
"User ID": row.user_id,
|
||||
"User Email": row.user_email ?? "",
|
||||
"Spend (USD)": row.spend,
|
||||
Requests: row.api_requests,
|
||||
Successful: row.successful_requests,
|
||||
Failed: row.failed_requests,
|
||||
"Prompt Tokens": row.prompt_tokens,
|
||||
"Completion Tokens": row.completion_tokens,
|
||||
"Total Tokens": row.total_tokens,
|
||||
})),
|
||||
{ escapeFormulae: true },
|
||||
);
|
||||
|
||||
export const teamUserSpendCsvFileName = (response: TeamUserSpendResponse): string =>
|
||||
`team_user_spend_${response.start_date}_to_${response.end_date}.csv`;
|
||||
|
||||
export const downloadCsv = (csv: string, fileName: string): void => {
|
||||
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
};
|
||||
|
|
@ -81,6 +81,7 @@ import { EmailEventSettingsResponse, EmailEventSettingsUpdateRequest } from "./e
|
|||
import type { SkillRegisterRequest } from "./claude_code_plugins/types";
|
||||
import type { ModelBudgetUsage, ModelMaxBudget } from "./key_team_helpers/ModelMaxBudgetEditor";
|
||||
import type { ObjectPermission } from "./object_permission_types";
|
||||
import type { components } from "@/lib/http/schema";
|
||||
import { jsonFields } from "./common_components/check_openapi_schema";
|
||||
import type { MCPUserEnvVarsStatus } from "./mcp_tools/types";
|
||||
import type {
|
||||
|
|
@ -1535,6 +1536,23 @@ export const teamDailyActivityAggregatedCall = async (
|
|||
}
|
||||
};
|
||||
|
||||
export type TeamUserSpendResponse = components["schemas"]["TeamUserSpendResponse"];
|
||||
|
||||
export const teamSpendByUserCall = async (
|
||||
accessToken: string,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
teamIds: string[],
|
||||
): Promise<TeamUserSpendResponse> =>
|
||||
apiClient.get<TeamUserSpendResponse>(`/team/spend/by_user`, {
|
||||
accessToken,
|
||||
query: {
|
||||
start_date: formatDate(startTime),
|
||||
end_date: formatDate(endTime),
|
||||
team_ids: teamIds.join(","),
|
||||
},
|
||||
});
|
||||
|
||||
export const organizationDailyActivityCall = async (
|
||||
accessToken: string,
|
||||
startTime: Date,
|
||||
|
|
|
|||
114
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
114
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -15531,6 +15531,30 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/team/spend/by_user": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get Team Spend By User
|
||||
* @description Spend per user within the given teams, attributed per request from spend logs.
|
||||
*
|
||||
* Proxy admins may query any team. Team admins and members holding the
|
||||
* `/team/daily/activity` permission see every user of the requested teams;
|
||||
* other members only see their own row.
|
||||
*/
|
||||
get: operations["get_team_spend_by_user_team_spend_by_user_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/team/spend/report": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -36769,6 +36793,63 @@ export interface components {
|
|||
/** Team Id */
|
||||
team_id: string;
|
||||
};
|
||||
/** TeamUserSpendResponse */
|
||||
TeamUserSpendResponse: {
|
||||
/** End Date */
|
||||
end_date: string;
|
||||
/** Results */
|
||||
results: components["schemas"]["TeamUserSpendRow"][];
|
||||
/** Start Date */
|
||||
start_date: string;
|
||||
};
|
||||
/** TeamUserSpendRow */
|
||||
TeamUserSpendRow: {
|
||||
/**
|
||||
* Api Requests
|
||||
* @default 0
|
||||
*/
|
||||
api_requests: number;
|
||||
/**
|
||||
* Completion Tokens
|
||||
* @default 0
|
||||
*/
|
||||
completion_tokens: number;
|
||||
/**
|
||||
* Failed Requests
|
||||
* @default 0
|
||||
*/
|
||||
failed_requests: number;
|
||||
/**
|
||||
* Prompt Tokens
|
||||
* @default 0
|
||||
*/
|
||||
prompt_tokens: number;
|
||||
/**
|
||||
* Spend
|
||||
* @default 0
|
||||
*/
|
||||
spend: number;
|
||||
/**
|
||||
* Successful Requests
|
||||
* @default 0
|
||||
*/
|
||||
successful_requests: number;
|
||||
/** Team Alias */
|
||||
team_alias?: string | null;
|
||||
/** Team Id */
|
||||
team_id: string;
|
||||
/**
|
||||
* Total Tokens
|
||||
* @default 0
|
||||
*/
|
||||
total_tokens: number;
|
||||
/** User Alias */
|
||||
user_alias?: string | null;
|
||||
/** User Email */
|
||||
user_email?: string | null;
|
||||
/** User Id */
|
||||
user_id: string;
|
||||
};
|
||||
/**
|
||||
* TestCustomCodeGuardrailRequest
|
||||
* @description Request model for testing custom code guardrails.
|
||||
|
|
@ -58550,6 +58631,39 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
get_team_spend_by_user_team_spend_by_user_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
team_ids?: string | null;
|
||||
start_date?: string | null;
|
||||
end_date?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["TeamUserSpendResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_team_spend_report_team_spend_report_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue