feat: add customer spend breakdown and filtering by current team

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
kenneth 2026-11-09 22:23:43 +00:00
parent 74cad3a2c3
commit be1dc08c6a
18 changed files with 410 additions and 5 deletions

View file

@ -3799,6 +3799,21 @@
},
"title": "Providers",
"type": "object"
},
"teams": {
"anyOf": [
{
"additionalProperties": {
"$ref": "#/components/schemas/MetricWithMetadata"
},
"type": "object"
},
{
"type": "null"
}
],
"description": "Customer spend by the key's current team, or last team for deleted keys. Keys without a known team are grouped under Unassigned",
"title": "Teams"
}
},
"title": "BreakdownMetrics",

View file

@ -47,6 +47,7 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import (
SpendMetrics,
)
from litellm.types.repositories.daily_activity import (
CustomerTeamRollupRow,
DailyActivityScope,
DailyActivityTable,
EntityRollupRow,
@ -967,6 +968,7 @@ async def get_daily_activity(
include_current_utc_day: bool = False,
resolve_entity_metadata: Callable[[Sequence[DailySpendRecord]], Awaitable[dict[str, dict[str, object]]]]
| None = None,
team_ids: tuple[str, ...] | None = None,
) -> SpendAnalyticsPaginatedResponse:
if prisma_client is None:
raise HTTPException(status_code=500, detail={"error": CommonProxyErrors.db_not_connected_error.value})
@ -981,7 +983,7 @@ async def get_daily_activity(
)
try:
scope: Final = daily_activity_scope(
base_scope: Final = daily_activity_scope(
table_name,
entity_id_field,
entity_id,
@ -993,6 +995,7 @@ async def get_daily_activity(
timezone_offset_minutes,
include_current_utc_day,
)
scope: Final = replace(base_scope, team_ids=team_ids)
repository: Final = daily_activity_repository(prisma_client)
page_data: Final = await repository.daily_rows(scope, page=page, page_size=page_size)
daily_spend_data: Final = page_data.rows
@ -1014,11 +1017,19 @@ async def get_daily_activity(
entity_id_field=entity_id_field,
entity_metadata_field=resolved_entity_metadata,
)
customer_results: Final = (
_with_customer_teams(
aggregated["results"],
await repository.customer_team_spend(scope, row_ids=tuple(row.id for row in daily_spend_data)),
)
if scope.table is DailyActivityTable.CUSTOMER
else aggregated["results"]
)
metadata_metrics = aggregated["totals"]
if metadata_metrics_func:
metadata_metrics = metadata_metrics_func(daily_spend_data)
return SpendAnalyticsPaginatedResponse(
results=aggregated["results"],
results=list(customer_results),
metadata=DailySpendMetadata(
total_spend=metadata_metrics.spend,
total_flat_cost=metadata_metrics.flat_cost,
@ -1143,6 +1154,30 @@ async def _user_entity_metadata(
)
def _with_customer_teams(
results: Sequence[DailySpendData], rows: Sequence[CustomerTeamRollupRow]
) -> tuple[DailySpendData, ...]:
return tuple(
day.model_copy(
update={
"breakdown": day.breakdown.model_copy(
update={
"teams": {
row.team_id: MetricWithMetadata(
metrics=_record_to_spend_metrics(row),
metadata={"team_alias": row.team_alias},
)
for row in rows
if row.date == day.date.isoformat()
}
}
)
}
)
for day in results
)
async def get_daily_activity_aggregated(
repository: DailyActivityRepository,
scope: DailyActivityScope,
@ -1190,13 +1225,18 @@ async def get_daily_activity_aggregated(
api_key_metadata=entity_key_metadata,
entity_metadata_field=MappingProxyType({**user_entity_metadata, **(entity_metadata_field or {})}),
)
results: Final = (
base_results: Final = (
list(await asyncio.to_thread(_with_guardrail_breakdown, aggregated["results"], guardrail_rows))
if guardrail_rows
else aggregated["results"]
)
results: Final = (
_with_customer_teams(base_results, await repository.customer_team_spend(scope))
if scope.table is DailyActivityTable.CUSTOMER
else base_results
)
return SpendAnalyticsPaginatedResponse(
results=results,
results=list(results),
metadata=DailySpendMetadata(
total_spend=aggregated["totals"].spend,
total_flat_cost=aggregated["totals"].flat_cost,

View file

@ -956,6 +956,7 @@ async def get_customer_daily_activity(
page: int = 1,
page_size: int = 10,
exclude_end_user_ids: str | None = None,
team_ids: str | None = None,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
@ -999,4 +1000,5 @@ async def get_customer_daily_activity(
api_key=api_key,
page=page,
page_size=page_size,
team_ids=tuple(team_ids.split(",")) if team_ids else None,
)

View file

@ -31,6 +31,7 @@ class EntityQuery:
model: str | None
timezone_offset_minutes: int | None
include_current_utc_day: bool
team_ids: tuple[str, ...] | None = None
@dataclass(frozen=True, slots=True)
@ -91,6 +92,7 @@ def _build_scope(
model=query.model,
timezone_offset_minutes=query.timezone_offset_minutes,
include_current_utc_day=query.include_current_utc_day,
team_ids=query.team_ids,
),
entity_metadata=entity_metadata,
)
@ -321,9 +323,11 @@ def _customer_query(
api_key: str | None = None,
exclude_end_user_ids: str | None = None,
timezone: int | None = None,
team_ids: str | None = None,
) -> EntityQuery:
return EntityQuery(
entity_ids=_query_ids(end_user_ids),
team_ids=_query_ids(team_ids),
exclude_entity_ids=_query_excluded_ids(exclude_end_user_ids),
api_key=api_key,
start_date=start_date,

View file

@ -18,6 +18,8 @@ from litellm.repositories.daily_activity_sql import (
adjust_dates_for_timezone,
build_aggregated_sql,
build_cache_leakage_keys_sql,
build_customer_filtered_keys_sql,
build_customer_team_rollup_sql,
build_entity_rollup_sql,
build_export_sql,
build_guardrail_spend_sql,
@ -29,6 +31,7 @@ from litellm.repositories.daily_activity_sql import (
from litellm.repositories.prisma_protocols import TableActions
from litellm.types.repositories.daily_activity import (
AggregatedRows,
CustomerTeamRollupRow,
DailyActivityProxyReads,
DailyActivityRow,
DailyActivityScope,
@ -102,6 +105,8 @@ class DailyActivityDatabase(Protocol):
_GROUPING_ADAPTER: Final = TypeAdapter(tuple[GroupingSetsRow, ...])
_CUSTOMER_TEAM_ADAPTER: Final = TypeAdapter(tuple[CustomerTeamRollupRow, ...])
_FILTERED_KEYS_ADAPTER: Final = TypeAdapter(tuple[dict[str, StrictStr], ...])
_ENTITY_ADAPTER: Final = TypeAdapter(tuple[EntityRollupRow, ...])
_GUARDRAIL_SPEND_ADAPTER: Final = TypeAdapter(tuple[GuardrailSpendRow, ...])
_KEY_SPEND_ADAPTER: Final = TypeAdapter(tuple[KeySpendRow, ...])
@ -341,6 +346,16 @@ class DailyActivityRepository:
if scope.exclude_entity_ids
else {}
)
filtered_keys: Final = (
tuple(
row["api_key"]
for row in _FILTERED_KEYS_ADAPTER.validate_python(
await self._query(build_customer_filtered_keys_sql(scope))
)
)
if scope.table is DailyActivityTable.CUSTOMER and scope.team_ids is not None
else scope.api_keys
)
conditions: Final = {
"date": {"gte": adjusted_start, "lte": adjusted_end},
**({scope.entity_id_field: {"in": list(scope.entity_ids)}} if scope.entity_ids is not None else {}),
@ -352,7 +367,7 @@ class DailyActivityRepository:
if scope.model
else {}
),
**({"api_key": {"in": list(scope.api_keys)}} if scope.api_keys is not None else {}),
**({"api_key": {"in": list(filtered_keys)}} if filtered_keys is not None else {}),
}
count, rows = await asyncio.gather(
table.count(where=conditions),
@ -364,3 +379,10 @@ class DailyActivityRepository:
),
)
return DailyRowsPage(total_count=count, rows=tuple(rows))
async def customer_team_spend(
self, scope: DailyActivityScope, *, row_ids: tuple[str, ...] | None = None
) -> tuple[CustomerTeamRollupRow, ...]:
return _CUSTOMER_TEAM_ADAPTER.validate_python(
await self._query(build_customer_team_rollup_sql(scope, row_ids=row_ids))
)

View file

@ -75,6 +75,7 @@ def build_where_clause(scope: DailyActivityScope, *, start_index: int = 1) -> tu
exclusion_index: Final = entity_index + int(has_entity_array)
model_index: Final = exclusion_index + int(bool(scope.exclude_entity_ids))
api_keys_index: Final = model_index + int(bool(scope.model))
team_index: Final = api_keys_index + int(bool(scope.api_keys))
conditions: Final = (
f"date >= ${start_index}",
f"date <= ${start_index + 1}",
@ -105,6 +106,11 @@ def build_where_clause(scope: DailyActivityScope, *, start_index: int = 1) -> tu
if scope.api_keys
else ()
),
*(
(f"{_customer_team_expression(scope)} = ANY(${team_index}::text[])",)
if scope.table is DailyActivityTable.CUSTOMER and scope.team_ids is not None
else ()
),
)
params: Final = (
adjusted_start,
@ -113,10 +119,46 @@ def build_where_clause(scope: DailyActivityScope, *, start_index: int = 1) -> tu
*((list(scope.exclude_entity_ids),) if scope.exclude_entity_ids else ()),
*((scope.model,) if scope.model else ()),
*((list(scope.api_keys),) if scope.api_keys else ()),
*((list(scope.team_ids),) if scope.table is DailyActivityTable.CUSTOMER and scope.team_ids is not None else ()),
)
return " AND ".join(conditions), params
def _customer_team_expression(scope: DailyActivityScope) -> str:
table: Final = PRISMA_TO_PG_TABLE[scope.table]
return (
"COALESCE(NULLIF((SELECT current_key.team_id FROM ("
'SELECT token, team_id, 0 AS priority, NULL::timestamp AS deleted_at FROM "LiteLLM_VerificationToken" '
"UNION ALL "
'SELECT token, team_id, 1 AS priority, deleted_at FROM "LiteLLM_DeletedVerificationToken"'
f') current_key WHERE current_key.token = "{table}".api_key '
"ORDER BY priority, deleted_at DESC LIMIT 1), ''), 'Unassigned')"
)
def build_customer_team_rollup_sql(scope: DailyActivityScope, *, row_ids: tuple[str, ...] | None = None) -> SqlQuery:
where_clause, params = build_where_clause(scope)
team: Final = _customer_team_expression(scope)
rows_filter: Final = f" AND id = ANY(${len(params) + 1}::text[])" if row_ids is not None else ""
return SqlQuery(
sql=f'''SELECT date, NULL::text AS api_key, customer_team_id AS team_id,
(SELECT team_alias FROM "LiteLLM_TeamTable" WHERE team_id = scoped.customer_team_id) AS team_alias,
{_rollup_metric_select(scope.table)}
FROM (SELECT *, {team} AS customer_team_id FROM "{PRISMA_TO_PG_TABLE[scope.table]}"
WHERE {where_clause}{rows_filter}) scoped
GROUP BY date, customer_team_id''',
params=(*params, *((list(row_ids),) if row_ids is not None else ())),
)
def build_customer_filtered_keys_sql(scope: DailyActivityScope) -> SqlQuery:
where_clause, params = build_where_clause(scope)
return SqlQuery(
sql=f'SELECT DISTINCT api_key FROM "{PRISMA_TO_PG_TABLE[scope.table]}" WHERE {where_clause}',
params=params,
)
def _ptu_flat_cost_select(table: DailyActivityTable, *, aggregate: bool = True) -> str:
if table is DailyActivityTable.TEAM:
return "SUM(ptu_flat_cost)::float AS ptu_flat_cost" if aggregate else "SUM(scoped.ptu_flat_cost)::float"

View file

@ -108,6 +108,12 @@ class BreakdownMetrics(LiteLLMBaseModel):
endpoints: dict[str, MetricWithMetadata] = Field(default_factory=dict) # endpoint -> {metrics, metadata}
api_keys: dict[str, KeyMetricWithMetadata] = Field(default_factory=dict) # api_key -> {metrics, metadata}
entities: dict[str, MetricWithMetadata] = Field(default_factory=dict) # entity -> {metrics, metadata}
teams: Mapping[str, MetricWithMetadata] | None = Field(
default=None,
exclude_if=lambda value: value is None,
description="Customer spend by the key's current team, or last team for deleted keys. "
"Keys without a known team are grouped under Unassigned",
)
guardrails: Mapping[str, GuardrailMetric] = Field(
default_factory=dict,
description="Spend by guardrail ID, already included in key and model spend. "

View file

@ -39,6 +39,7 @@ class DailyActivityScope:
model: str | None
timezone_offset_minutes: int | None
include_current_utc_day: bool = False
team_ids: tuple[str, ...] | None = None
def __post_init__(self) -> None:
if self.entity_id_field not in _ENTITY_FIELDS[self.table]:
@ -168,6 +169,12 @@ class EntityRollupRow(RollupMetricsRow):
distinct_api_keys: int | None
@dataclass(frozen=True, slots=True)
class CustomerTeamRollupRow(RollupMetricsRow):
team_id: str
team_alias: str | None
@dataclass(frozen=True, slots=True)
class GuardrailSpendRow:
date: str

View file

@ -0,0 +1,78 @@
import uuid
from datetime import datetime, timedelta, timezone
from typing import Final
import pytest
from integration._support.client import Gateway, eventually, string_value
from integration._support.database import read_rows
from litellm.types.proxy.management_endpoints.common_daily_activity import SpendAnalyticsPaginatedResponse
@pytest.mark.parametrize(
"route",
(
"/customer/daily/activity",
"/end_user/daily/activity",
"/customer/daily/activity/aggregated",
"/end_user/daily/activity/aggregated",
),
)
def test_customer_spend_by_current_team_and_team_filter(gateway: Gateway, route: str) -> None:
with gateway.scenario() as scenario:
model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002)
team_a: Final = scenario.team(team_alias="customer-team-a")
team_b: Final = scenario.team(team_alias="customer-team-b")
key_a: Final = scenario.key(models=[model], team_id=team_a)
key_b: Final = string_value(gateway.post("/key/generate", {"models": [model], "team_id": team_b})["key"])
scenario.cleanups.callback(gateway.request, "POST", "/key/delete", {"keys": [key_b]})
key_unassigned: Final = scenario.key(models=[model])
customer: Final = f"customer-{uuid.uuid4().hex}"
scenario.cleanups.callback(gateway.request, "POST", "/customer/delete", {"user_ids": [customer]})
for index, key in enumerate((key_a, key_b, key_unassigned)):
response: Final = gateway.request(
"POST",
"/v1/chat/completions",
{"model": model, "messages": [{"role": "user", "content": f"hello {index}"}], "user": customer},
key=key,
)
assert response.status_code == 200, response.text
eventually(
lambda: read_rows(
'SELECT SUM(api_requests)::int AS requests FROM "LiteLLM_DailyEndUserSpend" WHERE end_user_id=%s',
(customer,),
),
lambda rows: rows[0]["requests"] == 3,
seconds=70,
)
today: Final = datetime.now(timezone.utc).date()
start: Final = today - timedelta(days=1)
end: Final = today + timedelta(days=1)
query: Final = f"{route}?end_user_ids={customer}&start_date={start}&end_date={end}&page_size=100"
result: Final = gateway.request("GET", query)
assert result.status_code == 200, result.text
body: Final = SpendAnalyticsPaginatedResponse.model_validate_json(result.content)
assert body.metadata.total_spend == pytest.approx(3 * (20 * 0.001 + 20 * 0.002))
assert len(body.results) == 1
teams: Final = body.results[0].breakdown.teams
assert teams is not None
assert set(teams) == {team_a, team_b, "Unassigned"}
assert teams[team_a].metadata["team_alias"] == "customer-team-a"
assert teams[team_b].metadata["team_alias"] == "customer-team-b"
assert sum(bucket.metrics.spend for bucket in teams.values()) == pytest.approx(body.metadata.total_spend)
assert all(bucket.metrics.api_requests == 1 for bucket in teams.values())
assert all(bucket.metrics.total_tokens == 40 for bucket in teams.values())
filtered: Final = gateway.request("GET", f"{query}&team_ids={team_a}")
assert filtered.status_code == 200, filtered.text
filtered_body: Final = SpendAnalyticsPaginatedResponse.model_validate_json(filtered.content)
assert filtered_body.metadata.total_spend == pytest.approx(20 * 0.001 + 20 * 0.002)
assert set(filtered_body.results[0].breakdown.teams or {}) == {team_a}
empty: Final = gateway.request("GET", f"{query}&team_ids=missing-team")
assert empty.status_code == 200, empty.text
assert SpendAnalyticsPaginatedResponse.model_validate_json(empty.content).metadata.total_spend == 0
scenario.delete_key(key_b)
deleted: Final = gateway.request("GET", f"{query}&team_ids={team_b}")
assert deleted.status_code == 200, deleted.text
deleted_body: Final = SpendAnalyticsPaginatedResponse.model_validate_json(deleted.content)
assert deleted_body.metadata.total_spend == pytest.approx(20 * 0.001 + 20 * 0.002)
assert set(deleted_body.results[0].breakdown.teams or {}) == {team_b}

View file

@ -41,6 +41,7 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import (
)
from litellm.types.repositories.daily_activity import (
AggregatedRows,
CustomerTeamRollupRow,
DailyActivityScope,
EntityRollupRow,
GroupingSetsRow,
@ -3053,3 +3054,34 @@ def test_metric_merges_preserve_every_additive_field() -> None:
metrics: Final = SpendMetrics.model_validate(dict.fromkeys(SpendMetrics.model_fields, 2))
merged: Final = common_daily_activity_module.sum_spend_metrics((SpendMetrics(), metrics, metrics))
assert merged.model_dump() == dict.fromkeys(SpendMetrics.model_fields, 4)
@pytest.mark.parametrize("team_ids", (("team-a", "Unassigned"), ()))
def test_customer_teams_preserve_metrics_and_unassigned_bucket(team_ids: tuple[str, ...]) -> None:
from litellm.types.proxy.management_endpoints.common_daily_activity import DailySpendData
rows: Final = tuple(
CustomerTeamRollupRow(
date="2026-01-01",
api_key=None,
team_id=team_id,
team_alias="Team A" if team_id == "team-a" else None,
**{
field.name: 2
for field in fields(CustomerTeamRollupRow)
if field.name not in ("date", "api_key", "team_id", "team_alias")
},
)
for team_id in team_ids
)
day: Final = DailySpendData(date=date(2026, 1, 1), metrics=SpendMetrics())
results: Final = common_daily_activity_module._with_customer_teams((day,), rows)
teams: Final = results[0].breakdown.teams
assert teams is not None
assert tuple(teams) == team_ids
for team_id in team_ids:
assert teams[team_id].metrics.spend == 2
assert teams[team_id].metrics.api_requests == 2
assert teams[team_id].metrics.total_tokens == 4
assert teams[team_id].metadata == {"team_alias": "Team A" if team_id == "team-a" else None}
assert "teams" not in day.breakdown.model_dump()

View file

@ -256,6 +256,7 @@ class _FakeRepository:
self._rows: Final = rows
self.aggregated = AsyncMock(side_effect=self._aggregated)
self.guardrail_spend = AsyncMock(return_value=())
self.customer_team_spend = AsyncMock(return_value=())
self.key_page = AsyncMock(side_effect=self._key_page)
self.key_page_call: tuple[DailyActivityScope, int, int] | None = None
self.search_keys = AsyncMock(side_effect=self._search_keys)

View file

@ -1,3 +1,4 @@
from dataclasses import replace
from datetime import datetime, timezone
from typing import Final
@ -80,6 +81,21 @@ def test_where_clause_binds_each_filter_as_a_single_array_parameter() -> None:
)
@pytest.mark.parametrize("teams", (("team-a", "team-b"), (), ("Unassigned",)))
def test_customer_team_filter_is_separate_from_customer_and_key_filters(teams: tuple[str, ...]) -> None:
scope: Final = replace(
_scope(table=DailyActivityTable.CUSTOMER, api_keys=("key-a",), model="model-a"), team_ids=teams
)
sql, params = build_where_clause(scope)
assert '"end_user_id" = ANY($3::text[])' in sql
assert "api_key = ANY($5::text[])" in sql
assert "= ANY($6::text[])" in sql
assert '"LiteLLM_VerificationToken"' in sql
assert '"LiteLLM_DeletedVerificationToken"' in sql
assert "ORDER BY priority, deleted_at DESC LIMIT 1" in sql
assert params == ("2026-01-01", "2026-01-31", ["user-1"], "model-a", ["key-a"], list(teams))
def test_where_clause_exclusion_keeps_null_entity_rows() -> None:
scope = _scope(table=DailyActivityTable.TEAM, entity_ids=None, exclude_entity_ids=("litellm-dashboard",))

View file

@ -58,6 +58,7 @@ import { Panel, Segmented, Sparkline, Stat } from "../overview/Primitives";
import TopModelView from "./TopModelView";
import TeamUserSpendCard from "./TeamUserSpendCard";
import { ProviderSpendBreakdown } from "./SpendByProvider";
import SpendByCustomerTeam from "./SpendByCustomerTeam";
const BRAND = "#2b3fd6";
const FLAT_COST_SERIES: ParseKeys = "usage.entityUsage.chart.flatCostSeries";
@ -649,6 +650,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
<Panel icon={Server} title={t("usage.entityUsage.providerUsage")}>
<ProviderSpendBreakdown rows={providerSpend} />
{entityType === "customer" && <SpendByCustomerTeam response={spendDataRaw} />}
</Panel>
</div>
);

View file

@ -0,0 +1,46 @@
import { DataTable } from "@/components/shared/DataTable";
import { MoneyCell } from "@/components/shared/table_cells";
import type { ColumnDef } from "@tanstack/react-table";
import { useTranslation } from "react-i18next";
import i18n from "@/i18n/i18n";
import type { DailyActivityAggregatedResponse } from "@/components/UsagePage/dailyActivityApi";
import { customerTeamSpend, type CustomerTeamSpend } from "./customerTeamSpend";
const columns: ColumnDef<CustomerTeamSpend>[] = [
{
header: () => i18n.t("usage.teamUserSpend.columns.team"),
accessorKey: "teamAlias",
cell: ({ row }) => (
<div>
<div>{row.original.teamAlias}</div>
{row.original.teamAlias !== row.original.teamId && (
<div className="text-xs text-muted-foreground">{row.original.teamId}</div>
)}
</div>
),
},
{
header: () => i18n.t("usage.teamUserSpend.columns.spend"),
accessorKey: "spend",
cell: ({ row }) => <MoneyCell value={row.original.spend} decimals={6} />,
},
{ header: () => i18n.t("usage.teamUserSpend.columns.requests"), accessorKey: "requests" },
{ header: () => i18n.t("usage.teamUserSpend.columns.tokens"), accessorKey: "tokens" },
];
export default function SpendByCustomerTeam({ response }: { response: DailyActivityAggregatedResponse }) {
const { t } = useTranslation();
return (
<section className="grid gap-3">
<h3 className="font-semibold">{t("usage.customerTeamSpend.title")}</h3>
<p className="text-xs text-muted-foreground">{t("usage.customerTeamSpend.attributionNote")}</p>
<DataTable
columns={columns}
data={customerTeamSpend(response)}
getRowId={(row) => row.teamId}
noDataMessage={t("usage.customerTeamSpend.noData")}
size="compact"
/>
</section>
);
}

View file

@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import {
EMPTY_DAILY_ACTIVITY_METADATA,
type DailyActivityAggregatedResponse,
} from "@/components/UsagePage/dailyActivityApi";
import { customerTeamSpend } from "./customerTeamSpend";
describe("customerTeamSpend", () => {
it("sums spend, requests and tokens across days while keeping unassigned spend", () => {
const response: DailyActivityAggregatedResponse = {
metadata: EMPTY_DAILY_ACTIVITY_METADATA,
results: ["2026-01-01", "2026-01-02"].map((date) => ({
date,
metrics: {},
breakdown: {
teams: {
"team-a": {
metrics: { spend: 2, api_requests: 3, total_tokens: 40 },
metadata: { team_alias: "Team A" },
},
Unassigned: { metrics: { spend: 1, api_requests: 1, total_tokens: 10 }, metadata: {} },
},
},
})),
};
expect(customerTeamSpend(response)).toEqual([
{ teamId: "team-a", teamAlias: "Team A", spend: 4, requests: 6, tokens: 80 },
{ teamId: "Unassigned", teamAlias: "Unassigned", spend: 2, requests: 2, tokens: 20 },
]);
});
it("returns no rows for empty or older responses without a team breakdown", () => {
expect(customerTeamSpend({ metadata: EMPTY_DAILY_ACTIVITY_METADATA, results: [] })).toEqual([]);
expect(
customerTeamSpend({
metadata: EMPTY_DAILY_ACTIVITY_METADATA,
results: [{ date: "2026-01-01", metrics: {}, breakdown: {} }],
}),
).toEqual([]);
});
});

View file

@ -0,0 +1,27 @@
import type { DailyActivityAggregatedResponse } from "@/components/UsagePage/dailyActivityApi";
export interface CustomerTeamSpend {
teamId: string;
teamAlias: string;
spend: number;
requests: number;
tokens: number;
}
export function customerTeamSpend(response: DailyActivityAggregatedResponse): CustomerTeamSpend[] {
const entries = (response.results ?? []).flatMap((day) => Object.entries(day.breakdown?.teams ?? {}));
const ids = [...new Set(entries.map(([id]) => id))];
return ids
.map((teamId) => {
const buckets = entries.filter(([id]) => id === teamId).map(([, entry]) => entry);
const alias = buckets.find((entry) => typeof entry.metadata?.team_alias === "string")?.metadata?.team_alias;
return {
teamId,
teamAlias: typeof alias === "string" ? alias : teamId,
spend: buckets.reduce((total, entry) => total + (entry.metrics.spend ?? 0), 0),
requests: buckets.reduce((total, entry) => total + (entry.metrics.api_requests ?? 0), 0),
tokens: buckets.reduce((total, entry) => total + (entry.metrics.total_tokens ?? 0), 0),
};
})
.sort((a, b) => b.spend - a.spend || a.teamId.localeCompare(b.teamId));
}

View file

@ -15625,6 +15625,11 @@
"showUnknown": "Show Unknown",
"showUnknownHint": "Requests that failed to route to a provider"
},
"customerTeamSpend": {
"title": "Spend by team",
"attributionNote": "Uses each key's current team, or its last team when deleted. This is not historical team ownership.",
"noData": "No customer spend for this period"
},
"teamUserSpend": {
"columns": {
"team": "Team",

View file

@ -28757,6 +28757,13 @@ export interface components {
providers?: {
[key: string]: components["schemas"]["MetricWithMetadata"];
};
/**
* Teams
* @description Customer spend by the key's current team, or last team for deleted keys. Keys without a known team are grouped under Unassigned
*/
teams?: {
[key: string]: components["schemas"]["MetricWithMetadata"];
} | null;
};
/** BudgetConfig */
BudgetConfig: {
@ -59743,6 +59750,7 @@ export interface operations {
page?: number;
page_size?: number;
exclude_end_user_ids?: string | null;
team_ids?: string | null;
};
header?: never;
path?: never;
@ -59781,6 +59789,7 @@ export interface operations {
api_key?: string | null;
exclude_end_user_ids?: string | null;
timezone?: number | null;
team_ids?: string | null;
};
header?: never;
path?: never;
@ -59820,6 +59829,7 @@ export interface operations {
api_key?: string | null;
exclude_end_user_ids?: string | null;
timezone?: number | null;
team_ids?: string | null;
};
header?: never;
path?: never;
@ -59860,6 +59870,7 @@ export interface operations {
api_key?: string | null;
exclude_end_user_ids?: string | null;
timezone?: number | null;
team_ids?: string | null;
};
header?: never;
path?: never;
@ -59899,6 +59910,7 @@ export interface operations {
api_key?: string | null;
exclude_end_user_ids?: string | null;
timezone?: number | null;
team_ids?: string | null;
};
header?: never;
path?: never;
@ -59938,6 +59950,7 @@ export interface operations {
api_key?: string | null;
exclude_end_user_ids?: string | null;
timezone?: number | null;
team_ids?: string | null;
};
header?: never;
path?: never;
@ -60794,6 +60807,7 @@ export interface operations {
page?: number;
page_size?: number;
exclude_end_user_ids?: string | null;
team_ids?: string | null;
};
header?: never;
path?: never;
@ -60832,6 +60846,7 @@ export interface operations {
api_key?: string | null;
exclude_end_user_ids?: string | null;
timezone?: number | null;
team_ids?: string | null;
};
header?: never;
path?: never;
@ -60871,6 +60886,7 @@ export interface operations {
api_key?: string | null;
exclude_end_user_ids?: string | null;
timezone?: number | null;
team_ids?: string | null;
};
header?: never;
path?: never;
@ -60911,6 +60927,7 @@ export interface operations {
api_key?: string | null;
exclude_end_user_ids?: string | null;
timezone?: number | null;
team_ids?: string | null;
};
header?: never;
path?: never;
@ -60950,6 +60967,7 @@ export interface operations {
api_key?: string | null;
exclude_end_user_ids?: string | null;
timezone?: number | null;
team_ids?: string | null;
};
header?: never;
path?: never;
@ -60989,6 +61007,7 @@ export interface operations {
api_key?: string | null;
exclude_end_user_ids?: string | null;
timezone?: number | null;
team_ids?: string | null;
};
header?: never;
path?: never;