diff --git a/litellm/proxy/management_endpoints/daily_activity_routes.py b/litellm/proxy/management_endpoints/daily_activity_routes.py index 1a3445aae52..586b26721c5 100644 --- a/litellm/proxy/management_endpoints/daily_activity_routes.py +++ b/litellm/proxy/management_endpoints/daily_activity_routes.py @@ -34,6 +34,7 @@ from litellm.proxy.management_endpoints.daily_activity_scopes import ( EntityQuery, EntityScopeResolver, ResolvedScope, + group_by_tag_error, ) from litellm.proxy.management_endpoints.ptu_consumption import single_team_id, with_ptu_consumption from litellm.proxy.management_endpoints.team_endpoints import aggregated_date_range_error @@ -57,6 +58,7 @@ from litellm.types.proxy.management_endpoints.common_daily_activity import ( UserActivityRow, ) from litellm.types.repositories.daily_activity import ( + DailyActivityTable, ExportRow, ExportType, KeyMetadataRow, @@ -102,6 +104,10 @@ async def _resolved_scope( date_error: Final[InvalidDateRange | None] = _date_range_error(query, user_aggregated=user_aggregated) if date_error is not None: raise_public(date_error) + # A malformed request is a 400 before any permission lookup, the same as a bad date range. + group_by_error: Final[str | None] = group_by_tag_error(query) + if group_by_error is not None: + raise_public(InvalidDateRange(reason=group_by_error)) result: ResolvedScope | ScopeDenied = await resolver.resolve(user_api_key_dict, query, prisma_client) if isinstance(result, ScopeDenied): raise_public(result) @@ -285,7 +291,8 @@ def _register_aggregated_route(router: APIRouter, resolver: EntityScopeResolver, include_guardrail_breakdown=resolver.include_guardrail_breakdown, api_key_limit=api_key_limit, ) - if resolver.entity != "team": + # PTU hours are a whole-team figure, so a team's per-tag breakdown carries none. + if resolver.entity != "team" or resolved.scope.table is not DailyActivityTable.TEAM: return activity return with_ptu_consumption(activity, llm_router, single_team_id(resolved.scope.entity_ids)) except HTTPException: diff --git a/litellm/proxy/management_endpoints/daily_activity_scopes.py b/litellm/proxy/management_endpoints/daily_activity_scopes.py index 7ff73ac6e2e..38df6af2dd3 100644 --- a/litellm/proxy/management_endpoints/daily_activity_scopes.py +++ b/litellm/proxy/management_endpoints/daily_activity_scopes.py @@ -1,7 +1,7 @@ from collections.abc import Awaitable, Callable, Mapping, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from types import MappingProxyType -from typing import Final, Literal +from typing import Final, Literal, TypeAlias from fastapi import Query @@ -31,6 +31,7 @@ class EntityQuery: model: str | None timezone_offset_minutes: int | None include_current_utc_day: bool + group_by: "TeamGroupBy" = "team" @dataclass(frozen=True, slots=True) @@ -40,6 +41,8 @@ class ResolvedScope: Entity = Literal["user", "team", "tag", "organization", "customer", "agent"] +# How the Team view buckets its breakdown: by team (default), or by tag for one team's spend. +TeamGroupBy: TypeAlias = Literal["team", "tag"] EntityScopeResolution = ResolvedScope | ScopeDenied EntityScopeQuery = Callable[..., EntityQuery] EntityScopeResolve = Callable[[UserAPIKeyAuth, EntityQuery, PrismaClient], Awaitable[EntityScopeResolution]] @@ -129,7 +132,7 @@ async def _resolve_team( user_api_key_cache=user_api_key_cache, proxy_logging_obj=proxy_logging_obj, ) - return _build_scope( + team: Final = _build_scope( TEAM_RESOLVER, query, team_scope.team_ids, @@ -137,6 +140,25 @@ async def _resolve_team( team_scope.api_key_filter, team_scope.team_alias_metadata, ) + if query.group_by == "team": + return team + # group_by=tag: the same permitted team and key filter, read from the tag table and bucketed + # by tag. group_by_tag_error already held the request to exactly one named team, and the + # resolver returns a named team list as given (or 404s it), so this is that one team. + permitted: Final = tuple(team_scope.team_ids or ()) + if len(permitted) != 1: + raise ValueError(f"group_by=tag resolved to {len(permitted)} teams, expected exactly one") + return ResolvedScope( + scope=replace( + team.scope, + table=DailyActivityTable.TAG, + entity_id_field="tag", + entity_ids=None, + exclude_entity_ids=(), + team_id=permitted[0], + ), + entity_metadata=None, + ) async def _resolve_tag( @@ -259,6 +281,7 @@ def _team_query( api_key: str | None = None, exclude_team_ids: str | None = None, timezone: int | None = None, + group_by: TeamGroupBy = "team", ) -> EntityQuery: return EntityQuery( entity_ids=_query_ids(team_ids), @@ -269,9 +292,22 @@ def _team_query( model=model, timezone_offset_minutes=timezone, include_current_utc_day=False, + group_by=group_by, ) +def group_by_tag_error(query: EntityQuery) -> str | None: + """Why a group_by=tag request is malformed, else None. A team's tag breakdown is one team's + spend, so it needs exactly one team named in team_ids, whoever is asking: checked before any + permission lookup, since that lookup fills in every team a non-admin belongs to.""" + if query.group_by != "tag": + return None + named: Final = tuple(team_id for team_id in query.entity_ids or () if team_id) + if len(named) == 1: + return None + return f"group_by=tag needs exactly one team in team_ids, got {len(named)}" + + def _tag_query( start_date: str | None = None, end_date: str | None = None, diff --git a/litellm/repositories/daily_activity_sql.py b/litellm/repositories/daily_activity_sql.py index 4bb52767de6..101bb6b6ea4 100644 --- a/litellm/repositories/daily_activity_sql.py +++ b/litellm/repositories/daily_activity_sql.py @@ -75,6 +75,8 @@ 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)) + # Appended after every existing filter so a scope without a team binds exactly as before. + team_index: Final = api_keys_index + int(bool(scope.api_keys)) conditions: Final = ( f"date >= ${start_index}", f"date <= ${start_index + 1}", @@ -105,6 +107,7 @@ def build_where_clause(scope: DailyActivityScope, *, start_index: int = 1) -> tu if scope.api_keys else () ), + *((f"team_id = ${team_index}", _NOT_USER_AGENT_TAG) if scope.team_id is not None else ()), ) params: Final = ( adjusted_start, @@ -113,10 +116,18 @@ 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 ()), + *((scope.team_id,) if scope.team_id is not None else ()), ) return " AND ".join(conditions), params +# The proxy stamps a User-Agent tag on every request, so in a team's tag breakdown these would +# outnumber its real tags. Same rule as common_daily_activity._is_user_agent_tag. +_NOT_USER_AGENT_TAG: Final = ( + "NOT (LOWER(BTRIM(tag)) LIKE 'user-agent:%' OR LOWER(BTRIM(tag)) LIKE 'user agent:%')" +) + + 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" diff --git a/litellm/types/repositories/daily_activity.py b/litellm/types/repositories/daily_activity.py index b013ce43113..6df88ad1d47 100644 --- a/litellm/types/repositories/daily_activity.py +++ b/litellm/types/repositories/daily_activity.py @@ -39,10 +39,15 @@ class DailyActivityScope: model: str | None timezone_offset_minutes: int | None include_current_utc_day: bool = False + # One team's tag spend (the Team view's Spend Per Tag card). Only the tag table records the + # sending team, and a team-scoped tag read leaves out the automatic User-Agent tags. + team_id: str | None = None def __post_init__(self) -> None: if self.entity_id_field not in _ENTITY_FIELDS[self.table]: raise ValueError(f"Invalid entity_id_field {self.entity_id_field!r} for {self.table.value}") + if self.team_id is not None and self.table is not DailyActivityTable.TAG: + raise ValueError(f"team_id applies only to {DailyActivityTable.TAG.value}, not {self.table.value}") @dataclass(frozen=True, slots=True) diff --git a/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py b/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py index e487c00f25d..aa23f4bc052 100644 --- a/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py +++ b/tests/unit/proxy/management_endpoints/test_daily_activity_routes.py @@ -1270,6 +1270,89 @@ def test_team_scope_applies_membership_and_user_key_filter( assert repository.aggregated.call_args.kwargs["include_entity_breakdown"] is True +def test_team_group_by_tag_reads_one_teams_tag_rows_with_the_members_key_filter( + daily_activity_client: tuple[TestClient, _FakeRepository], +) -> None: + """group_by=tag keeps every team-route permission (membership, the member's own keys) and + only swaps the source to the tag table, scoped to that one team and bucketed by tag.""" + client, repository = daily_activity_client + response: Final = client.get( + "/team/daily/activity/aggregated", + params={**_entity_params("team_ids", "team-a"), "group_by": "tag"}, + headers={"x-user-role": LitellmUserRoles.INTERNAL_USER.value, "x-user-id": "user-a"}, + ) + assert response.status_code == 200, response.text + scope: Final = repository.aggregated.call_args.args[0] + assert (scope.table, scope.entity_id_field, scope.entity_ids, scope.team_id, scope.api_keys) == ( + DailyActivityTable.TAG, + "tag", + None, + "team-a", + ("key-alpha",), + ), scope + + +@pytest.mark.parametrize( + ("team_ids", "expected_count"), + [(None, 0), ("team-a,team-b", 2), ("team-a,,team-b", 2)], +) +@pytest.mark.parametrize("role", [LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.INTERNAL_USER]) +def test_team_group_by_tag_needs_exactly_one_named_team_for_every_caller( + daily_activity_client: tuple[TestClient, _FakeRepository], + team_ids: str | None, + expected_count: int, + role: LitellmUserRoles, +) -> None: + """Answered before any permission lookup, which would otherwise fill in every team a + non-admin belongs to and quietly turn "no team" into "all of my teams".""" + client, repository = daily_activity_client + response: Final = client.get( + "/team/daily/activity/aggregated", + params={**_DATE_PARAMS, "group_by": "tag", **({"team_ids": team_ids} if team_ids else {})}, + headers={"x-user-role": role.value, "x-user-id": "user-a"}, + ) + assert response.status_code == 400, response.text + assert response.json() == { + "detail": {"error": f"group_by=tag needs exactly one team in team_ids, got {expected_count}"} + } + repository.aggregated.assert_not_called() + + +def test_team_group_by_tag_carries_no_ptu_hours( + daily_activity_client: tuple[TestClient, _FakeRepository], + monkeypatch: pytest.MonkeyPatch, +) -> None: + """PTU hours are a whole-team figure; the same sized team that reports them by team reports + none per tag.""" + client, _ = daily_activity_client + monkeypatch.setenv("LITELLM_ENABLE_PTU_COST_ATTRIBUTION", "True") + _inject_llm_router(client, _ptu_sized_router("rare-group")) + + by_team: Final = client.get("/team/daily/activity/aggregated", params=_entity_params("team_ids", "team-a")) + by_tag: Final = client.get( + "/team/daily/activity/aggregated", params={**_entity_params("team_ids", "team-a"), "group_by": "tag"} + ) + + assert by_team.status_code == 200 and by_tag.status_code == 200, (by_team.text, by_tag.text) + ptu_hours: Final = (by_team.json()["metadata"]["total_ptu_hours"], by_tag.json()["metadata"]["total_ptu_hours"]) + assert ptu_hours[0] > 0 and ptu_hours[1] == 0, f"(by team, by tag) total_ptu_hours = {ptu_hours}" + + +def test_team_route_without_group_by_reads_the_team_table_as_before( + daily_activity_client: tuple[TestClient, _FakeRepository], +) -> None: + client, repository = daily_activity_client + response: Final = client.get("/team/daily/activity/aggregated", params=_entity_params("team_ids", "team-a")) + assert response.status_code == 200, response.text + scope: Final = repository.aggregated.call_args.args[0] + assert (scope.table, scope.entity_id_field, scope.entity_ids, scope.team_id) == ( + DailyActivityTable.TEAM, + "team_id", + ("team-a",), + None, + ), scope + + @pytest.mark.parametrize("api_key", ["key-beta", "unattributed", "raw:unattributed", "raw:raw:unattributed"]) def test_team_scope_does_not_allow_an_unowned_api_key( daily_activity_client: tuple[TestClient, _FakeRepository], diff --git a/tests/unit/repositories/test_daily_activity_sql.py b/tests/unit/repositories/test_daily_activity_sql.py index f8375b93dad..71001725f26 100644 --- a/tests/unit/repositories/test_daily_activity_sql.py +++ b/tests/unit/repositories/test_daily_activity_sql.py @@ -1,3 +1,4 @@ +from dataclasses import replace from datetime import datetime, timezone from typing import Final @@ -111,6 +112,30 @@ def test_where_clause_distinguishes_no_filter_from_empty_membership( assert params == expected_params +def test_where_clause_scopes_tag_rows_to_one_team_without_user_agent_tags() -> None: + """The team filter binds after every existing filter, so it never renumbers them, and the + User-Agent tags the proxy stamps on every request are left out of a team's tag breakdown.""" + scope = replace( + _scope(table=DailyActivityTable.TAG, entity_ids=None, api_keys=("key-1",), model="gpt-test"), + team_id="team-a", + ) + + sql, params = build_where_clause(scope) + + assert sql == ( + "date >= $1 AND date <= $2 AND model = $3 AND api_key = ANY($4::text[]) AND team_id = $5 " + "AND NOT (LOWER(BTRIM(tag)) LIKE 'user-agent:%' OR LOWER(BTRIM(tag)) LIKE 'user agent:%')" + ) + assert params == ("2026-01-01", "2026-01-31", "gpt-test", ["key-1"], "team-a") + + +@pytest.mark.parametrize("table", [table for table in DailyActivityTable if table is not DailyActivityTable.TAG]) +def test_scope_rejects_a_team_id_on_any_table_but_the_tag_table(table: DailyActivityTable) -> None: + """Only LiteLLM_DailyTagSpend records the sending team in a team_id column next to its own entity.""" + with pytest.raises(ValueError, match="team_id applies only to litellm_dailytagspend"): + replace(_scope(table=table), team_id="team-a") + + def test_key_page_sql_orders_exact_spend_and_binds_scope_before_page() -> None: query = build_key_page_sql(_scope(), offset=7, limit=3) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 66fe9b17a82..0ff60647044 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -74999,6 +74999,7 @@ export interface operations { api_key?: string | null; exclude_team_ids?: string | null; timezone?: number | null; + group_by?: "team" | "tag"; }; header?: never; path?: never; @@ -75038,6 +75039,7 @@ export interface operations { api_key?: string | null; exclude_team_ids?: string | null; timezone?: number | null; + group_by?: "team" | "tag"; }; header?: never; path?: never; @@ -75078,6 +75080,7 @@ export interface operations { api_key?: string | null; exclude_team_ids?: string | null; timezone?: number | null; + group_by?: "team" | "tag"; }; header?: never; path?: never; @@ -75117,6 +75120,7 @@ export interface operations { api_key?: string | null; exclude_team_ids?: string | null; timezone?: number | null; + group_by?: "team" | "tag"; }; header?: never; path?: never; @@ -75156,6 +75160,7 @@ export interface operations { api_key?: string | null; exclude_team_ids?: string | null; timezone?: number | null; + group_by?: "team" | "tag"; }; header?: never; path?: never;