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:
devin-ai-integration[bot] 2026-09-04 12:00:47 -07:00 committed by GitHub
parent f74bc9427b
commit dd01abc439
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 845 additions and 0 deletions

View file

@ -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",

View file

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

View file

@ -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, ...]

View file

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

View 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"])

View file

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

View file

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

View file

@ -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" />);

View file

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

View file

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

View file

@ -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");
});
});

View file

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

View file

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

View file

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