mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-15 09:12:18 +00:00
feat(proxy): group_by=tag on the team daily activity report
GET /team/daily/activity/aggregated?team_ids=<team>&group_by=tag returns one team's spend per tag, keyed by tag in breakdown.entities, read from LiteLLM_DailyTagSpend filtered to that team. The response shape is unchanged, and without group_by the report is exactly as before. group_by=tag needs exactly one team in team_ids for every caller, else 400. It is checked before the permission lookup, which would otherwise fill in every team a non-admin belongs to. The team route's permissions are unchanged: a member without team-admin rights sees only their own keys, and another team is still a 404. The automatic User-Agent tags are left out of a team's tag breakdown, and PTU hours (a whole-team figure) are not attached to it. The keys, search, model_top_keys and export routes take the same parameter. The team filter binds after every existing WHERE filter, so no current query is renumbered. schema.d.ts is regenerated for the new parameter. LIT-8516
This commit is contained in:
parent
654f1918a4
commit
ec044cf68e
7 changed files with 176 additions and 4 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue