From 8ddf69f694515641d89e09984ebf4d3c75dd397b Mon Sep 17 00:00:00 2001 From: yassin Date: Wed, 23 Sep 2026 23:53:08 +0000 Subject: [PATCH 01/13] feat(usage): search keys beyond the top-N usage subset The Key Activity panel on the Usage page only searched the top-spend keys the aggregated endpoint loads. A new /user/daily/activity/aggregated/search route matches verification tokens by hash, alias, or user id and aggregates spend for the matches; the panel debounces to it when the loaded set is truncated. Entity panels stay local-only. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/_types.py | 2 + .../internal_user_endpoints.py | 150 ++++++++++++++-- .../internal_user_endpoints.py | 9 + .../proxy/auth/test_route_checks.py | 1 + .../test_internal_user_endpoints.py | 163 ++++++++++++++++++ .../_components/components/UsagePageView.tsx | 16 +- .../components/KeyActivityPanel.test.tsx | 42 +++++ .../UsagePage/components/KeyActivityPanel.tsx | 69 +++++++- .../src/components/networking.tsx | 33 ++++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 65 +++++++ 10 files changed, 528 insertions(+), 22 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 54574ed64e3d..db5eb39148e1 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -699,6 +699,7 @@ class LiteLLMRoutes(enum.Enum): "/user/list", "/user/daily/activity", "/user/daily/activity/aggregated", + "/user/daily/activity/aggregated/search", # team "/team/new", "/team/update", @@ -901,6 +902,7 @@ class LiteLLMRoutes(enum.Enum): "/model/delete", "/user/daily/activity", "/user/daily/activity/aggregated", + "/user/daily/activity/aggregated/search", # Endpoint restricts results to organizations the caller is ORG_ADMIN # of; a caller who administers none gets an empty result set. "/organization/daily/activity", diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 59d8dd821d85..e43fc82a3b13 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -28,6 +28,7 @@ import litellm from litellm._logging import verbose_proxy_logger from litellm._uuid import uuid +from litellm.constants import USAGE_TOP_API_KEYS_LIMIT from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler from litellm.proxy._types import * from litellm.proxy.auth.auth_checks import ( @@ -87,11 +88,13 @@ VerificationTokenRepository, ) from litellm.types.proxy.management_endpoints.common_daily_activity import ( + DailySpendMetadata, SpendAnalyticsPaginatedResponse, ) from litellm.types.proxy.management_endpoints.internal_user_endpoints import ( BulkUpdateUserRequest, BulkUpdateUserResponse, + KeyActivitySearchWhere, UserListResponse, UserSearchWhere, UserUpdateResult, @@ -2991,6 +2994,27 @@ async def get_user_daily_activity( ) +def _resolve_user_daily_activity_entity_id( + user_api_key_dict: UserAPIKeyAuth, + user_id: str | None, +) -> str | None: + is_admin: Final = _user_has_admin_view(user_api_key_dict) + + if is_admin: + return user_id + + caller_user_id: Final = require_caller_user_id_for_non_admin(user_api_key_dict) + effective_user_id: Final = user_id if user_id is not None else caller_user_id + if effective_user_id != caller_user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ # mutable-ok: FastAPI detail payload shape + "error": "Non-admin users can only view their own spend data." + }, + ) + return effective_user_id + + @router.get( "/user/daily/activity/aggregated", tags=["Budget & Spend Tracking", "Internal User management"], @@ -3057,20 +3081,7 @@ async def get_user_daily_activity_aggregated( ) try: - is_admin: Final = _user_has_admin_view(user_api_key_dict) - - if is_admin: - entity_id = user_id # None means global view, otherwise filter by user - else: - caller_user_id: Final = require_caller_user_id_for_non_admin(user_api_key_dict) - if user_id is None: - user_id = caller_user_id - if user_id != caller_user_id: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail={"error": "Non-admin users can only view their own spend data."}, - ) - entity_id = user_id + entity_id: Final = _resolve_user_daily_activity_entity_id(user_api_key_dict, user_id) return await get_daily_activity_aggregated( prisma_client=prisma_client, @@ -3094,3 +3105,114 @@ async def get_user_daily_activity_aggregated( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail={"error": f"Failed to fetch analytics: {e}"}, ) + + +@router.get( + "/user/daily/activity/aggregated/search", + tags=["Budget & Spend Tracking", "Internal User management"], # mutable-ok: FastAPI route tags shape + dependencies=[Depends(user_api_key_auth)], # mutable-ok: FastAPI route dependencies shape + response_model=SpendAnalyticsPaginatedResponse, +) +@management_endpoint_wrapper +async def search_user_daily_activity_keys( + search: str = fastapi.Query( + ..., + min_length=1, + description="Matches keys whose hash equals the value, or whose key alias or user ID contains it (case-insensitive)", + ), + start_date: str | None = fastapi.Query( + default=None, + description="Start date in YYYY-MM-DD format", + ), + end_date: str | None = fastapi.Query( + default=None, + description="End date in YYYY-MM-DD format", + ), + user_id: str | None = fastapi.Query( + default=None, + description="Filter by specific user ID. Admins can filter by any user or omit for global view. Non-admins must provide their own user_id.", + ), + timezone: int | None = fastapi.Query( + default=None, + description="Timezone offset in minutes from UTC (e.g., 480 for PST). " + "Matches JavaScript's Date.getTimezoneOffset() convention.", + ), + include_current_utc_day: bool = fastapi.Query( + default=False, + description="When the range ends on the caller's current local day, extend it to " + "today's UTC bucket so spend written after the caller's local midnight (in UTC " + "terms) is included. Requires the timezone parameter. Historical ranges are " + "never extended.", + ), + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), # noqa: B008 # FastAPI dependency injection +) -> SpendAnalyticsPaginatedResponse: + """ + Search verification tokens by exact token hash or by a case-insensitive substring of + the key alias or owning user ID, then return the aggregated daily activity for the + matches. Lets the Usage page surface keys that fell outside the top-spend subset + the aggregated endpoint loads. + """ + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise HTTPException( + status_code=500, + detail={ # mutable-ok: FastAPI detail payload shape + "error": CommonProxyErrors.db_not_connected_error.value + }, + ) + + if start_date is None or end_date is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": "Please provide start_date and end_date"}, # mutable-ok: FastAPI detail payload shape + ) + + try: + entity_id: Final = _resolve_user_daily_activity_entity_id(user_api_key_dict, user_id) + + where: Final[KeyActivitySearchWhere] = { + "OR": ( + {"token": search}, + {"key_alias": {"contains": search, "mode": "insensitive"}}, + {"user_id": {"contains": search, "mode": "insensitive"}}, + ) + } + matched_keys: Final = await VerificationTokenRepository(prisma_client).table.find_many( + where=where, + take=USAGE_TOP_API_KEYS_LIMIT, + order={"spend": "desc"}, # mutable-ok: prisma serializes order, keep it a plain dict + ) + tokens: Final = [key.token for key in matched_keys] # mutable-ok: api_key filter union expects a list + + if not tokens: + return SpendAnalyticsPaginatedResponse( + results=[], # mutable-ok: response model field shape + metadata=DailySpendMetadata( + api_key_limit=USAGE_TOP_API_KEYS_LIMIT, + total_api_keys=0, + ), + ) + + return await get_daily_activity_aggregated( + prisma_client=prisma_client, + table_name="litellm_dailyuserspend", + entity_id_field="user_id", + entity_id=entity_id, + entity_metadata_field=None, + start_date=start_date, + end_date=end_date, + model=None, + api_key=tokens, + timezone_offset_minutes=timezone, + include_current_utc_day=include_current_utc_day, + ) + + except HTTPException: + raise + except Exception as e: + verbose_proxy_logger.exception("/user/daily/activity/aggregated/search: Exception occured - %s", e) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"error": f"Failed to fetch analytics: {e}"}, # mutable-ok: FastAPI detail payload shape + ) diff --git a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py index 43e3899d5237..701538877154 100644 --- a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py @@ -28,6 +28,15 @@ class UserSearchWhere(TypedDict): OR: ReadOnly[tuple[Mapping[Literal["user_id", "user_email"], InsensitiveContains], ...]] +class KeyActivitySearchWhere(TypedDict): + """Prisma filter behind `/user/daily/activity/aggregated/search`: exact token hash, or key alias + or user id containing the term, case-insensitive.""" + + OR: ReadOnly[ + tuple[Mapping[Literal["token"], str] | Mapping[Literal["key_alias", "user_id"], InsensitiveContains], ...] + ] + + class UserListResponse(BaseModel): """ Response model for the user list endpoint diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index e5179387f828..571e066e9474 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -3517,6 +3517,7 @@ def test_internal_user_still_blocked_from_another_users_info(): [ "/user/daily/activity", "/user/daily/activity/aggregated", + "/user/daily/activity/aggregated/search", ], ) @pytest.mark.parametrize( diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index c663e63414c6..aba80523b7ea 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -2659,6 +2659,169 @@ async def test_get_user_daily_activity_aggregated_non_admin_cannot_view_other_us assert mock_get_daily_agg.call_args.kwargs["entity_id"] == "regular-user-123" +@pytest.mark.asyncio +async def test_search_user_daily_activity_keys_passes_matched_tokens_to_aggregation(monkeypatch): + """The search endpoint resolves matching verification tokens by hash, alias, or + user id, then aggregates daily spend for exactly those tokens. This is what lets + the Usage page find keys outside the top-spend subset the aggregated endpoint caps.""" + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock + + from litellm.constants import USAGE_TOP_API_KEYS_LIMIT + from litellm.proxy.management_endpoints.internal_user_endpoints import ( + search_user_daily_activity_keys, + ) + + mock_prisma_client = MagicMock() + mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock( + return_value=[SimpleNamespace(token="tok-a"), SimpleNamespace(token="tok-b")] + ) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + mock_response = MagicMock() + mock_get_daily_agg = AsyncMock(return_value=mock_response) + monkeypatch.setattr( + "litellm.proxy.management_endpoints.internal_user_endpoints.get_daily_activity_aggregated", + mock_get_daily_agg, + ) + + admin_key_dict = UserAPIKeyAuth( + user_id="admin-user-001", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + + result = await search_user_daily_activity_keys( + search="gamma", + start_date="2025-02-01", + end_date="2025-02-28", + user_id=None, + timezone=480, + include_current_utc_day=False, + user_api_key_dict=admin_key_dict, + ) + + assert result is mock_response + + find_many_kwargs = mock_prisma_client.db.litellm_verificationtoken.find_many.call_args.kwargs + assert find_many_kwargs["take"] == USAGE_TOP_API_KEYS_LIMIT + assert find_many_kwargs["where"]["OR"] == ( + {"token": "gamma"}, + {"key_alias": {"contains": "gamma", "mode": "insensitive"}}, + {"user_id": {"contains": "gamma", "mode": "insensitive"}}, + ) + + mock_get_daily_agg.assert_called_once_with( + prisma_client=mock_prisma_client, + table_name="litellm_dailyuserspend", + entity_id_field="user_id", + entity_id=None, + entity_metadata_field=None, + start_date="2025-02-01", + end_date="2025-02-28", + model=None, + api_key=["tok-a", "tok-b"], + timezone_offset_minutes=480, + include_current_utc_day=False, + ) + + +@pytest.mark.asyncio +async def test_search_user_daily_activity_keys_no_match_returns_empty_without_aggregating(monkeypatch): + from unittest.mock import AsyncMock, MagicMock + + from litellm.constants import USAGE_TOP_API_KEYS_LIMIT + from litellm.proxy.management_endpoints.internal_user_endpoints import ( + search_user_daily_activity_keys, + ) + + mock_prisma_client = MagicMock() + mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + mock_get_daily_agg = AsyncMock() + monkeypatch.setattr( + "litellm.proxy.management_endpoints.internal_user_endpoints.get_daily_activity_aggregated", + mock_get_daily_agg, + ) + + admin_key_dict = UserAPIKeyAuth( + user_id="admin-user-001", + user_role=LitellmUserRoles.PROXY_ADMIN, + ) + + result = await search_user_daily_activity_keys( + search="nothing-matches", + start_date="2025-02-01", + end_date="2025-02-28", + user_id=None, + timezone=None, + include_current_utc_day=False, + user_api_key_dict=admin_key_dict, + ) + + assert result.results == [] + assert result.metadata.api_key_limit == USAGE_TOP_API_KEYS_LIMIT + assert result.metadata.total_api_keys == 0 + mock_get_daily_agg.assert_not_called() + + +@pytest.mark.asyncio +async def test_search_user_daily_activity_keys_non_admin_scoped_to_caller(monkeypatch): + """Same scoping contract as the aggregated route: a non-admin with no user_id + is scoped to their own rows, and any other user_id is a 403.""" + from types import SimpleNamespace + from unittest.mock import AsyncMock, MagicMock + + from fastapi import HTTPException + + from litellm.proxy.management_endpoints.internal_user_endpoints import ( + search_user_daily_activity_keys, + ) + + mock_prisma_client = MagicMock() + mock_prisma_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[SimpleNamespace(token="tok-a")]) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + non_admin_key_dict = UserAPIKeyAuth( + user_id="user-1", + user_role=LitellmUserRoles.INTERNAL_USER, + ) + + mock_response = MagicMock() + mock_get_daily_agg = AsyncMock(return_value=mock_response) + monkeypatch.setattr( + "litellm.proxy.management_endpoints.internal_user_endpoints.get_daily_activity_aggregated", + mock_get_daily_agg, + ) + + result = await search_user_daily_activity_keys( + search="gamma", + start_date="2025-02-01", + end_date="2025-02-28", + user_id=None, + timezone=None, + include_current_utc_day=False, + user_api_key_dict=non_admin_key_dict, + ) + + assert result is mock_response + assert mock_get_daily_agg.call_args.kwargs["entity_id"] == "user-1" + + with pytest.raises(HTTPException) as exc_info: + await search_user_daily_activity_keys( + search="gamma", + start_date="2025-02-01", + end_date="2025-02-28", + user_id="user-2", + timezone=None, + include_current_utc_day=False, + user_api_key_dict=non_admin_key_dict, + ) + + assert exc_info.value.status_code == 403 + assert "Non-admin users can only view their own spend data" in str(exc_info.value.detail) + + @pytest.mark.asyncio async def test_delete_user_cleans_up_created_by_invitation_links(mocker): """ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx index 228d8acf1460..e0171d974236 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.tsx @@ -39,6 +39,7 @@ import { tagListCall, userDailyActivityAggregatedCall, userDailyActivityCall, + userDailyActivityKeySearchCall, } from "@/components/networking"; import AdvancedDatePicker from "@/components/shared/advanced_date_picker"; import { ChartLoader } from "@/components/shared/chart_loader"; @@ -437,6 +438,15 @@ const UsagePage: React.FC = ({ teams, organizations }) => { [userSpendData, modelViewType, teams], ); const keyMetrics = useMemo(() => processActivityData(userSpendData, "api_keys", teams), [userSpendData, teams]); + const searchKeys = useCallback( + (q: string) => { + if (!accessToken || !startTime || !endTime) return Promise.resolve({}); + return userDailyActivityKeySearchCall(accessToken, startTime, endTime, q, effectiveUserId).then((data) => + processActivityData(data, "api_keys", teams), + ); + }, + [accessToken, startTime, endTime, effectiveUserId, teams], + ); const mcpServerMetrics = useMemo( () => processActivityData(userSpendData, "mcp_servers", teams), [userSpendData, teams], @@ -865,7 +875,11 @@ const UsagePage: React.FC = ({ teams, organizations }) => { - + diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx index 830139143e94..a3d48834a469 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx @@ -78,4 +78,46 @@ describe("KeyActivityPanel", () => { render(); expect(screen.queryByRole("note")).not.toBeInTheDocument(); }); + + it("finds keys outside the loaded top-spend subset via server search", async () => { + const searchKeys = vi + .fn<(query: string) => Promise>>() + .mockResolvedValue({ "hash-gamma": activity("gamma-low-key", "gamma@example.com", "user-gamma") }); + render( + , + ); + + fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "gamma" } }); + + expect(await screen.findByText("hash-gamma")).toBeInTheDocument(); + expect(searchKeys).toHaveBeenCalledWith("gamma"); + expect(screen.getByText("Showing 1 of 3 keys")).toBeInTheDocument(); + }); + + it("never calls the server search when every key is already loaded", async () => { + const searchKeys = vi + .fn<(query: string) => Promise>>() + .mockResolvedValue({ "hash-gamma": activity("gamma-low-key", "gamma@example.com", "user-gamma") }); + render(); + + fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "gamma" } }); + + expect(await screen.findByText('No keys match "gamma" in this date range')).toBeInTheDocument(); + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(searchKeys).not.toHaveBeenCalled(); + }); + + it("reports a failed server search but keeps the local matches", async () => { + const searchKeys = vi + .fn<(query: string) => Promise>>() + .mockRejectedValue(new Error("boom")); + render( + , + ); + + fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "alice" } }); + + expect(await screen.findByRole("alert")).toHaveTextContent("Key search failed"); + expect(screen.getByTestId("rendered-keys")).toHaveTextContent("hash-alice"); + }); }); diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx index 8b2141f85285..2883726e20b5 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx @@ -1,5 +1,5 @@ import { Search, X } from "lucide-react"; -import React, { useMemo, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { ActivityMetrics } from "@/components/activity_metrics"; import type { ApiKeyTruncation } from "@/components/EntityUsageExport/exportBlockedReason"; @@ -12,18 +12,63 @@ interface KeyActivityPanelProps { keyMetrics: Record; hidePromptCachingMetrics?: boolean; apiKeyTruncation?: ApiKeyTruncation; + searchKeys?: (query: string) => Promise>; } +type RemoteSearch = + | { status: "idle" } + | { status: "loading"; query: string } + | { status: "done"; query: string; keys: Record } + | { status: "error"; query: string }; + +const REMOTE_SEARCH_DEBOUNCE_MS = 300; + const KeyActivityPanel: React.FC = ({ keyMetrics, hidePromptCachingMetrics = false, apiKeyTruncation, + searchKeys, }) => { const [query, setQuery] = useState(""); + const [remote, setRemote] = useState({ status: "idle" }); const filtered = useMemo(() => filterKeyActivity(keyMetrics, query), [keyMetrics, query]); + const trimmedQuery = query.trim(); + const remoteEnabled = searchKeys !== undefined && apiKeyTruncation !== undefined && trimmedQuery !== ""; + + useEffect(() => { + if (!remoteEnabled) return; + let cancelled = false; + const timer = setTimeout(() => { + setRemote({ status: "loading", query: trimmedQuery }); + searchKeys(trimmedQuery) + .then((keys) => { + if (!cancelled) setRemote({ status: "done", query: trimmedQuery, keys }); + }) + .catch(() => { + if (!cancelled) setRemote({ status: "error", query: trimmedQuery }); + }); + }, REMOTE_SEARCH_DEBOUNCE_MS); + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [remoteEnabled, trimmedQuery, searchKeys]); + + const remoteCurrent = remoteEnabled && "query" in remote && remote.query === trimmedQuery; + const remoteLoading = remoteEnabled && !remoteCurrent; + const remoteFailed = remoteCurrent && remote.status === "error"; + + const extraRemoteKeys = useMemo(() => { + const remoteKeys = remoteCurrent && remote.status === "done" ? remote.keys : {}; + return Object.fromEntries(Object.entries(remoteKeys).filter(([hash]) => !(hash in keyMetrics))); + }, [remoteCurrent, remote, keyMetrics]); + const displayed = useMemo(() => ({ ...extraRemoteKeys, ...filtered }), [extraRemoteKeys, filtered]); + const totalKeys = Object.keys(keyMetrics).length; - const shownKeys = Object.keys(filtered).length; - const isFiltering = query.trim() !== ""; + const shownKeys = Object.keys(displayed).length; + const totalShown = totalKeys + Object.keys(extraRemoteKeys).length; + const isFiltering = trimmedQuery !== ""; + const noMatches = isFiltering && !remoteLoading && totalKeys > 0 && shownKeys === 0; return (
@@ -47,8 +92,18 @@ const KeyActivityPanel: React.FC = ({ )} - Showing {shownKeys.toLocaleString()} of {totalKeys.toLocaleString()} keys + Showing {shownKeys.toLocaleString()} of {totalShown.toLocaleString()} keys + {remoteLoading && ( + + Searching all keys... + + )} + {remoteFailed && ( + + Key search failed + + )} {apiKeyTruncation !== undefined && ( Only the {apiKeyTruncation.limit.toLocaleString()} highest-spend keys of{" "} @@ -56,12 +111,12 @@ const KeyActivityPanel: React.FC = ({ )}
- {isFiltering && totalKeys > 0 && shownKeys === 0 ? ( + {noMatches ? (

- No keys match "{query.trim()}" in this date range + No keys match "{trimmedQuery}" in this date range

) : ( - + )} ); diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index c2f4fa806349..993da2800e8f 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -2556,6 +2556,39 @@ export const userDailyActivityAggregatedCall = async ( } }; +export const userDailyActivityKeySearchCall = async ( + accessToken: string, + startTime: Date, + endTime: Date, + ...options: [search: string, userId?: string | null] +) => { + /** + * Search keys beyond the top-spend subset the aggregated endpoint loads + */ + const [search, userId = null] = options; + try { + const formatDate = (date: Date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + }; + return await apiClient.get(`/user/daily/activity/aggregated/search`, { + accessToken, + query: { + start_date: formatDate(startTime), + end_date: formatDate(endTime), + timezone: new Date().getTimezoneOffset().toString(), + search, + user_id: userId || undefined, + }, + }); + } catch (error) { + console.error("Failed to search user daily activity keys:", error); + throw error; + } +}; + export const gatewayDailyActivityCall = async (accessToken: string, startTime: Date, endTime: Date) => { /** * Get gateway request counts (SGR) recorded by the proxy middleware. diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index bdfd4aec3163..61a260be0fd4 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -17326,6 +17326,29 @@ export interface paths { patch?: never; trace?: never; }; + "/user/daily/activity/aggregated/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Search User Daily Activity Keys + * @description Search verification tokens by exact token hash or by a case-insensitive substring of + * the key alias or owning user ID, then return the aggregated daily activity for the + * matches. Lets the Usage page surface keys that fell outside the top-spend subset + * the aggregated endpoint loads. + */ + get: operations["search_user_daily_activity_keys_user_daily_activity_aggregated_search_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/user/delete": { parameters: { query?: never; @@ -64443,6 +64466,48 @@ export interface operations { }; }; }; + search_user_daily_activity_keys_user_daily_activity_aggregated_search_get: { + parameters: { + query: { + /** @description Matches keys whose hash equals the value, or whose key alias or user ID contains it (case-insensitive) */ + search: string; + /** @description Start date in YYYY-MM-DD format */ + start_date?: string | null; + /** @description End date in YYYY-MM-DD format */ + end_date?: string | null; + /** @description Filter by specific user ID. Admins can filter by any user or omit for global view. Non-admins must provide their own user_id. */ + user_id?: string | null; + /** @description Timezone offset in minutes from UTC (e.g., 480 for PST). Matches JavaScript's Date.getTimezoneOffset() convention. */ + timezone?: number | null; + /** @description When the range ends on the caller's current local day, extend it to today's UTC bucket so spend written after the caller's local midnight (in UTC terms) is included. Requires the timezone parameter. Historical ranges are never extended. */ + include_current_utc_day?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SpendAnalyticsPaginatedResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; delete_user_user_delete_post: { parameters: { query?: never; From 379a69a4c80be2811145a63deef0ac8e4e3da9c9 Mon Sep 17 00:00:00 2001 From: yassin Date: Thu, 24 Sep 2026 00:01:15 +0000 Subject: [PATCH 02/13] chore(terraform): allowlist usage key search endpoint in coverage audit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- terraform/provider/tools/endpointaudit/coverage_allowlist.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt index f8277c83a643..d0f9c31ecafe 100644 --- a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt +++ b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt @@ -32,6 +32,7 @@ GET /team/spend/by_user GET /team/spend/report GET /user/daily/activity GET /user/daily/activity/aggregated +GET /user/daily/activity/aggregated/search GET /user/spend/report # Admin UI helper endpoints; serve UI forms and caller-scoped views, not desired state From 3e74d6d61337a1e3ff5ab7ba87134950c30f2358 Mon Sep 17 00:00:00 2001 From: yassin Date: Thu, 24 Sep 2026 00:08:08 +0000 Subject: [PATCH 03/13] fix(usage): scope key search to the resolved user and reset stale remote results Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../internal_user_endpoints.py | 17 ++++++++------ .../internal_user_endpoints.py | 3 ++- .../test_internal_user_endpoints.py | 3 +++ .../components/KeyActivityPanel.test.tsx | 22 +++++++++++++++++++ .../UsagePage/components/KeyActivityPanel.tsx | 22 +++++++++++-------- .../src/components/networking.tsx | 3 --- 6 files changed, 50 insertions(+), 20 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index e43fc82a3b13..7b25348aa53c 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -3171,13 +3171,16 @@ async def search_user_daily_activity_keys( try: entity_id: Final = _resolve_user_daily_activity_entity_id(user_api_key_dict, user_id) - where: Final[KeyActivitySearchWhere] = { - "OR": ( - {"token": search}, - {"key_alias": {"contains": search, "mode": "insensitive"}}, - {"user_id": {"contains": search, "mode": "insensitive"}}, - ) - } + search_or: Final = ( + {"token": search}, # mutable-ok: prisma serializes where clauses, keep plain dicts + {"key_alias": {"contains": search, "mode": "insensitive"}}, # mutable-ok: prisma where clause leaf + {"user_id": {"contains": search, "mode": "insensitive"}}, # mutable-ok: prisma where clause leaf + ) + where: Final[KeyActivitySearchWhere] = ( + {"OR": search_or} # mutable-ok: prisma where clause root + if entity_id is None + else {"user_id": entity_id, "OR": search_or} # mutable-ok: prisma where clause root + ) matched_keys: Final = await VerificationTokenRepository(prisma_client).table.find_many( where=where, take=USAGE_TOP_API_KEYS_LIMIT, diff --git a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py index 701538877154..05cbd4507a27 100644 --- a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py @@ -2,7 +2,7 @@ from typing import Any, Final, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator -from typing_extensions import ReadOnly, TypedDict +from typing_extensions import NotRequired, ReadOnly, TypedDict from litellm.proxy._types import ( LiteLLM_UserTableWithKeyCount, @@ -32,6 +32,7 @@ class KeyActivitySearchWhere(TypedDict): """Prisma filter behind `/user/daily/activity/aggregated/search`: exact token hash, or key alias or user id containing the term, case-insensitive.""" + user_id: NotRequired[ReadOnly[str]] OR: ReadOnly[ tuple[Mapping[Literal["token"], str] | Mapping[Literal["key_alias", "user_id"], InsensitiveContains], ...] ] diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index aba80523b7ea..8260aec9326a 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -2709,6 +2709,7 @@ async def test_search_user_daily_activity_keys_passes_matched_tokens_to_aggregat {"key_alias": {"contains": "gamma", "mode": "insensitive"}}, {"user_id": {"contains": "gamma", "mode": "insensitive"}}, ) + assert "user_id" not in find_many_kwargs["where"] mock_get_daily_agg.assert_called_once_with( prisma_client=mock_prisma_client, @@ -2806,6 +2807,8 @@ async def test_search_user_daily_activity_keys_non_admin_scoped_to_caller(monkey assert result is mock_response assert mock_get_daily_agg.call_args.kwargs["entity_id"] == "user-1" + find_many_kwargs = mock_prisma_client.db.litellm_verificationtoken.find_many.call_args.kwargs + assert find_many_kwargs["where"]["user_id"] == "user-1" with pytest.raises(HTTPException) as exc_info: await search_user_daily_activity_keys( diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx index a3d48834a469..f8a7d07633b4 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.test.tsx @@ -107,6 +107,28 @@ describe("KeyActivityPanel", () => { expect(searchKeys).not.toHaveBeenCalled(); }); + it("drops stale server results as soon as the search callback is rebuilt", async () => { + const searchKeysA = vi + .fn<(query: string) => Promise>>() + .mockResolvedValue({ "hash-gamma": activity("gamma-low-key", "gamma@example.com", "user-gamma") }); + const searchKeysB = vi + .fn<(query: string) => Promise>>() + .mockReturnValue(new Promise(() => {})); + const { rerender } = render( + , + ); + + fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "gamma" } }); + expect(await screen.findByText("hash-gamma")).toBeInTheDocument(); + + rerender( + , + ); + + expect(screen.getByRole("status")).toHaveTextContent("Searching all keys"); + expect(screen.queryByText("hash-gamma")).not.toBeInTheDocument(); + }); + it("reports a failed server search but keeps the local matches", async () => { const searchKeys = vi .fn<(query: string) => Promise>>() diff --git a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx index 2883726e20b5..3467ba61b220 100644 --- a/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx +++ b/ui/litellm-dashboard/src/components/UsagePage/components/KeyActivityPanel.tsx @@ -12,14 +12,16 @@ interface KeyActivityPanelProps { keyMetrics: Record; hidePromptCachingMetrics?: boolean; apiKeyTruncation?: ApiKeyTruncation; - searchKeys?: (query: string) => Promise>; + searchKeys?: SearchKeys; } +type SearchKeys = (query: string) => Promise>; + type RemoteSearch = | { status: "idle" } - | { status: "loading"; query: string } - | { status: "done"; query: string; keys: Record } - | { status: "error"; query: string }; + | { status: "loading"; query: string; searchKeys: SearchKeys } + | { status: "done"; query: string; searchKeys: SearchKeys; keys: Record } + | { status: "error"; query: string; searchKeys: SearchKeys }; const REMOTE_SEARCH_DEBOUNCE_MS = 300; @@ -39,13 +41,13 @@ const KeyActivityPanel: React.FC = ({ if (!remoteEnabled) return; let cancelled = false; const timer = setTimeout(() => { - setRemote({ status: "loading", query: trimmedQuery }); + setRemote({ status: "loading", query: trimmedQuery, searchKeys }); searchKeys(trimmedQuery) .then((keys) => { - if (!cancelled) setRemote({ status: "done", query: trimmedQuery, keys }); + if (!cancelled) setRemote({ status: "done", query: trimmedQuery, searchKeys, keys }); }) .catch(() => { - if (!cancelled) setRemote({ status: "error", query: trimmedQuery }); + if (!cancelled) setRemote({ status: "error", query: trimmedQuery, searchKeys }); }); }, REMOTE_SEARCH_DEBOUNCE_MS); return () => { @@ -54,8 +56,10 @@ const KeyActivityPanel: React.FC = ({ }; }, [remoteEnabled, trimmedQuery, searchKeys]); - const remoteCurrent = remoteEnabled && "query" in remote && remote.query === trimmedQuery; - const remoteLoading = remoteEnabled && !remoteCurrent; + const remoteMatchesSearch = + "searchKeys" in remote && remote.searchKeys === searchKeys && remote.query === trimmedQuery; + const remoteCurrent = remoteEnabled && remoteMatchesSearch; + const remoteLoading = remoteEnabled && (remote.status === "loading" || !remoteCurrent); const remoteFailed = remoteCurrent && remote.status === "error"; const extraRemoteKeys = useMemo(() => { diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 993da2800e8f..e6923a26d4c5 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -2562,9 +2562,6 @@ export const userDailyActivityKeySearchCall = async ( endTime: Date, ...options: [search: string, userId?: string | null] ) => { - /** - * Search keys beyond the top-spend subset the aggregated endpoint loads - */ const [search, userId = null] = options; try { const formatDate = (date: Date) => { From 046ff3f5e1a8bd26a3d63de258a28f5b2e6ca687 Mon Sep 17 00:00:00 2001 From: yassin Date: Thu, 24 Sep 2026 02:14:00 +0000 Subject: [PATCH 04/13] feat(usage): search team keys beyond the top-N in the Team usage view Adds GET /team/daily/activity/aggregated/search, which resolves a search term against the verification tokens the caller may see (team membership and own-key scoping applied inside the Prisma where before take) and aggregates litellm_dailyteamspend for the matched hashes. The Team usage view passes a searchKeys callback to KeyActivityPanel so keys outside the loaded top-N are found server side Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/_types.py | 5 + .../management_endpoints/team_endpoints.py | 108 +++++++++++ .../management_endpoints/team_endpoints.py | 15 ++ .../endpointaudit/coverage_allowlist.txt | 1 + .../test_team_daily_activity_key_search.py | 89 +++++++++ .../proxy/auth/test_route_checks.py | 42 +++++ .../test_team_endpoints.py | 170 ++++++++++++++++++ .../components/EntityUsage/EntityUsage.tsx | 14 +- .../src/components/networking.tsx | 25 +++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 74 +++++++- 10 files changed, 541 insertions(+), 2 deletions(-) create mode 100644 tests/integration/spend/test_team_daily_activity_key_search.py diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index db5eb39148e1..12367784e8c7 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -307,6 +307,7 @@ class KeyManagementRoutes(str, enum.Enum): # team usage routes TEAM_DAILY_ACTIVITY = "/team/daily/activity" TEAM_DAILY_ACTIVITY_AGGREGATED = "/team/daily/activity/aggregated" + TEAM_DAILY_ACTIVITY_AGGREGATED_SEARCH = "/team/daily/activity/aggregated/search" # team spend-log viewing SPEND_LOGS = "/spend/logs" @@ -673,6 +674,7 @@ class LiteLLMRoutes(enum.Enum): KeyManagementRoutes.TEAM_KEY_BULK_UPDATE.value, KeyManagementRoutes.TEAM_DAILY_ACTIVITY.value, KeyManagementRoutes.TEAM_DAILY_ACTIVITY_AGGREGATED.value, + KeyManagementRoutes.TEAM_DAILY_ACTIVITY_AGGREGATED_SEARCH.value, KeyManagementRoutes.SPEND_LOGS.value, KeyManagementRoutes.SPEND_LOGS_V2.value, KeyManagementRoutes.KEY_RESET_SPEND.value, @@ -717,6 +719,7 @@ class LiteLLMRoutes(enum.Enum): "/team/permissions_bulk_update", "/team/daily/activity", "/team/daily/activity/aggregated", + "/team/daily/activity/aggregated/search", "/team/spend/by_user", # gateway request counts (SGR); deployment-wide, admin-only "/gateway/daily/activity", @@ -887,6 +890,7 @@ class LiteLLMRoutes(enum.Enum): "/team/permissions_update", "/team/daily/activity", "/team/daily/activity/aggregated", + "/team/daily/activity/aggregated/search", "/team/spend/by_user", "/team/{team_id}/members/me", # POST/GET the team's logging callbacks, and DELETE one of them. Every @@ -986,6 +990,7 @@ class LiteLLMRoutes(enum.Enum): "/user/daily/activity", "/team/daily/activity", "/team/daily/activity/aggregated", + "/team/daily/activity/aggregated/search", "/tag/daily/activity", "/tag/list", "/audit", diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index d092fc2fbb70..b026e716535f 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -40,6 +40,7 @@ import litellm from litellm._logging import verbose_proxy_logger from litellm._uuid import uuid +from litellm.constants import USAGE_TOP_API_KEYS_LIMIT from litellm.integrations.prometheus import PrometheusLogger from litellm.litellm_core_utils.safe_json_dumps import safe_dumps from litellm.proxy._types import ( @@ -195,6 +196,7 @@ from litellm.router import Router from litellm.types.proxy.auth.auth_checks import UserNotFoundError from litellm.types.proxy.management_endpoints.common_daily_activity import ( + DailySpendMetadata, SpendAnalyticsPaginatedResponse, ) from litellm.types.proxy.management_endpoints.team_endpoints import ( @@ -204,6 +206,7 @@ BulkUpdateTeamMemberPermissionsResponse, GetTeamMemberPermissionsResponse, TeamIdSearchMatch, + TeamKeyActivitySearchWhere, TeamListItem, TeamListResponse, TeamMemberAddResult, @@ -6793,6 +6796,111 @@ async def get_team_daily_activity_aggregated( ) +def _team_key_search_where(*, search: str, scope: _TeamDailyActivityScope) -> TeamKeyActivitySearchWhere: + """Caller scoping lives inside the same Prisma where as the search term so `take` + never trims visible matches in favour of keys the caller is not allowed to see.""" + search_or: Final = ( + {"token": search}, + {"key_alias": {"contains": search, "mode": "insensitive"}}, + {"user_id": {"contains": search, "mode": "insensitive"}}, + ) + own_keys: Final = tuple(scope.api_key_filter) if isinstance(scope.api_key_filter, list) else None + if scope.team_ids is None and own_keys is None: + return {"OR": search_or} + if scope.team_ids is None and own_keys is not None: + return {"token": {"in": own_keys}, "OR": search_or} + if scope.team_ids is not None and own_keys is None: + return {"team_id": {"in": tuple(scope.team_ids)}, "OR": search_or} + assert scope.team_ids is not None and own_keys is not None + return {"team_id": {"in": tuple(scope.team_ids)}, "token": {"in": own_keys}, "OR": search_or} + + +@router.get( + "/team/daily/activity/aggregated/search", + response_model=SpendAnalyticsPaginatedResponse, + tags=["team management"], +) +async def search_team_daily_activity_keys( + user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + search: str = fastapi.Query( + ..., + min_length=1, + description="Exact token hash, or a case-insensitive substring of the key alias or owning user id", + ), + team_ids: str | None = None, + start_date: str | None = None, + end_date: str | None = None, + exclude_team_ids: str | None = None, + timezone: int | None = None, +) -> SpendAnalyticsPaginatedResponse: + """ + Aggregated daily team activity for the keys matching a search term. + + `/team/daily/activity/aggregated` only returns the top USAGE_TOP_API_KEYS_LIMIT keys by spend, so + a key outside that subset cannot be found by filtering the loaded page. This endpoint resolves + the term against every verification token the caller may see (same team and key scoping as the + aggregated endpoint) and aggregates litellm_dailyteamspend for just those hashes. + + Args: + search (str): Exact token hash, or case-insensitive substring of key_alias or user_id. + team_ids (Optional[str]): Comma-separated list of team IDs to filter by. + start_date (Optional[str]): Start date for the activity period (YYYY-MM-DD). + end_date (Optional[str]): End date for the activity period (YYYY-MM-DD). + exclude_team_ids (Optional[str]): Comma-separated list of team IDs to exclude. + timezone (Optional[int]): Timezone offset in minutes from UTC, matching JavaScript's Date.getTimezoneOffset(). + Returns: + SpendAnalyticsPaginatedResponse: Aggregated activity restricted to the matching keys. + """ + 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: + raise _daily_activity_error(status_code=400, message=range_error) + + scope: Final = await _resolve_team_daily_activity_scope( + team_ids=team_ids, + exclude_team_ids=exclude_team_ids, + 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, + ) + matched_keys: Final = await _tokens_db(prisma_client).find_many( + where=_team_key_search_where(search=search, scope=scope), + take=USAGE_TOP_API_KEYS_LIMIT, + order={"spend": "desc"}, + ) + tokens: Final = [key.token for key in matched_keys] # mutable-ok: get_daily_activity_aggregated takes list[str] + if not tokens: + return SpendAnalyticsPaginatedResponse( + results=[], + metadata=DailySpendMetadata(api_key_limit=USAGE_TOP_API_KEYS_LIMIT, total_api_keys=0), + ) + + return await get_daily_activity_aggregated( + prisma_client=prisma_client, + table_name="litellm_dailyteamspend", + entity_id_field="team_id", + entity_id=scope.team_ids, + entity_metadata_field=scope.team_alias_metadata, + start_date=start_date, + end_date=end_date, + model=None, + api_key=tokens, + exclude_entity_ids=scope.exclude_team_ids, + 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 "" diff --git a/litellm/types/proxy/management_endpoints/team_endpoints.py b/litellm/types/proxy/management_endpoints/team_endpoints.py index 4524c47ec389..a25bca283c99 100644 --- a/litellm/types/proxy/management_endpoints/team_endpoints.py +++ b/litellm/types/proxy/management_endpoints/team_endpoints.py @@ -1,6 +1,8 @@ +from collections.abc import Mapping, Sequence from typing import Any, Final, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from typing_extensions import NotRequired, ReadOnly, TypedDict from litellm.proxy._types import ( KeyManagementRoutes, @@ -11,10 +13,23 @@ MemberDeleteRequest, ) from litellm.proxy.common_utils.timezone_utils import budget_duration_error +from litellm.types.proxy.management_endpoints.internal_user_endpoints import InsensitiveContains from litellm.types.proxy.management_endpoints.management_v1 import ResourceResponse TeamIdSearchMatch = Literal["exact", "prefix"] + +class TeamKeyActivitySearchWhere(TypedDict): + """Prisma filter behind `/team/daily/activity/aggregated/search`: exact token hash, or key alias + or user id containing the term, case-insensitive, narrowed to the teams and keys the caller may see.""" + + team_id: NotRequired[ReadOnly[Mapping[Literal["in"], Sequence[str]]]] + token: NotRequired[ReadOnly[Mapping[Literal["in"], Sequence[str]]]] + OR: ReadOnly[ + tuple[Mapping[Literal["token"], str] | Mapping[Literal["key_alias", "user_id"], InsensitiveContains], ...] + ] + + MAX_BULK_TEAM_MEMBER_DELETES: Final = 500 MAX_BULK_TEAM_MEMBER_BUDGET_UPDATES: Final = 500 diff --git a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt index d0f9c31ecafe..b0f28a9c7406 100644 --- a/terraform/provider/tools/endpointaudit/coverage_allowlist.txt +++ b/terraform/provider/tools/endpointaudit/coverage_allowlist.txt @@ -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/daily/activity/aggregated/search GET /team/spend/by_user GET /team/spend/report GET /user/daily/activity diff --git a/tests/integration/spend/test_team_daily_activity_key_search.py b/tests/integration/spend/test_team_daily_activity_key_search.py new file mode 100644 index 000000000000..c106f9727a9d --- /dev/null +++ b/tests/integration/spend/test_team_daily_activity_key_search.py @@ -0,0 +1,89 @@ +import uuid +from datetime import datetime, timedelta, timezone +from hashlib import sha256 +from typing import Final + +import pytest +from integration._support.client import Gateway, eventually, object_value +from integration._support.database import read_rows +from pydantic import JsonValue + +_SEARCH_PATH: Final = "/team/daily/activity/aggregated/search" + + +def _range_around_today() -> dict[str, str]: + today: Final = datetime.now(timezone.utc) + return { + "start_date": (today - timedelta(days=1)).strftime("%Y-%m-%d"), + "end_date": (today + timedelta(days=1)).strftime("%Y-%m-%d"), + "timezone": "0", + } + + +def _team_key_breakdown(body: dict[str, JsonValue], team: str) -> dict[str, JsonValue]: + results: Final = body["results"] + assert isinstance(results, list) and len(results) == 1, body + entities: Final = object_value(object_value(object_value(results[0])["breakdown"])["entities"]) + return object_value(object_value(entities[team])["api_key_breakdown"]) + + +def test_team_key_search_returns_only_the_matching_key_spend_by_alias_and_by_hash(gateway: Gateway) -> None: + with gateway.scenario() as scenario: + model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002) + team: Final = scenario.team(models=[model]) + needle_alias: Final = f"needle-{uuid.uuid4().hex}" + needle: Final = scenario.key(team_id=team, models=[model], key_alias=needle_alias) + other: Final = scenario.key(team_id=team, models=[model], key_alias=f"other-{uuid.uuid4().hex}") + needle_digest: Final = sha256(needle.encode()).hexdigest() + other_digest: Final = sha256(other.encode()).hexdigest() + for key in (needle, other): + reply: Final = gateway.chat(model, key=key, text=f"key search {uuid.uuid4().hex}") + assert object_value(reply["usage"])["total_tokens"] == 40, reply + daily: Final = eventually( + lambda: read_rows('SELECT api_key, spend FROM "LiteLLM_DailyTeamSpend" WHERE team_id=%s', (team,)), + lambda values: sorted(row["api_key"] for row in values) == sorted((needle_digest, other_digest)), + seconds=70, + ) + assert all(float(row["spend"]) == pytest.approx(0.06) for row in daily), daily + for search in (needle_alias.upper(), needle_digest): + response: Final = gateway.request( + "GET", _SEARCH_PATH, params={"team_ids": team, "search": search, **_range_around_today()} + ) + assert response.status_code == 200, response.text + body: Final = object_value(response.json()) + assert object_value(body["metadata"])["total_spend"] == pytest.approx(0.06), response.text + per_key: Final = _team_key_breakdown(body, team) + assert set(per_key) == {needle_digest}, response.text + assert object_value(object_value(per_key[needle_digest])["metrics"])["spend"] == pytest.approx(0.06) + + +def test_team_key_search_is_scoped_to_the_teams_the_caller_belongs_to(gateway: Gateway) -> None: + with gateway.scenario() as scenario: + model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002) + team: Final = scenario.team(models=[model]) + needle_alias: Final = f"needle-{uuid.uuid4().hex}" + needle: Final = scenario.key(team_id=team, models=[model], key_alias=needle_alias) + needle_digest: Final = sha256(needle.encode()).hexdigest() + reply: Final = gateway.chat(model, key=needle, text=f"key search {uuid.uuid4().hex}") + assert object_value(reply["usage"])["total_tokens"] == 40, reply + eventually( + lambda: read_rows('SELECT api_key FROM "LiteLLM_DailyTeamSpend" WHERE team_id=%s', (team,)), + lambda values: [row["api_key"] for row in values] == [needle_digest], + seconds=70, + ) + outsider: Final = scenario.user(user_role="internal_user") + outsider_team: Final = scenario.team(models=[model], members_with_roles=[{"user_id": outsider, "role": "user"}]) + outsider_key: Final = scenario.key(user_id=outsider, team_id=outsider_team, models=[model]) + params: Final = {"search": needle_alias, **_range_around_today()} + admin_view: Final = gateway.request("GET", _SEARCH_PATH, params={"team_ids": team, **params}) + assert admin_view.status_code == 200, admin_view.text + assert set(_team_key_breakdown(object_value(admin_view.json()), team)) == {needle_digest}, admin_view.text + own_teams_view: Final = gateway.request("GET", _SEARCH_PATH, params=params, key=outsider_key) + assert own_teams_view.status_code == 200, own_teams_view.text + own_teams_body: Final = object_value(own_teams_view.json()) + assert own_teams_body["results"] == [], own_teams_view.text + assert object_value(own_teams_body["metadata"])["total_api_keys"] == 0, own_teams_view.text + foreign_team_view: Final = gateway.request( + "GET", _SEARCH_PATH, params={"team_ids": team, **params}, key=outsider_key + ) + assert foreign_team_view.status_code == 404, foreign_team_view.text diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index 571e066e9474..36521441cc5d 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -3600,6 +3600,48 @@ def test_user_daily_activity_aggregated_not_covered_by_prefix_match(): ) +@pytest.mark.parametrize( + "route", + [ + "/team/daily/activity", + "/team/daily/activity/aggregated", + "/team/daily/activity/aggregated/search", + ], +) +@pytest.mark.parametrize( + "user_role", + [ + LitellmUserRoles.INTERNAL_USER.value, + LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value, + ], +) +def test_team_daily_activity_routes_reachable_by_non_admin(route, user_role): + """The Team Usage dashboard calls all three team daily-activity routes, and + each handler self-scopes to the caller's teams and own keys + (_resolve_team_daily_activity_scope). self_managed_routes is the only list + granting them to a non-admin, and check_route_access is exact-match, so each + sub-path needs its own entry: dropping one 401s the dashboard before the + handler ever runs. + """ + 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 = {} + + 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={}, + ) + + @pytest.mark.parametrize( "user_role", [ diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 7a9b6b669460..a9f1d63c0669 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -14569,6 +14569,176 @@ async def test_get_team_daily_activity_aggregated_rejects_bad_ranges( mock_aggregated.assert_not_called() +def _key_search_team_setup(mock_db_client, user_id: str, team_id: str): + mock_user_info = LiteLLM_UserTable( + user_id=user_id, + teams=[team_id], + max_budget=1000.0, + spend=0.0, + user_email="test@example.com", + user_role="internal_user", + ) + mock_team = MagicMock(spec=LiteLLM_TeamTable) + mock_team.team_id = team_id + mock_team.team_alias = "Test Team" + mock_team.members_with_roles = [Member(user_id=user_id, role="user")] + mock_team.model_dump.return_value = { + "team_id": team_id, + "team_alias": "Test Team", + "members_with_roles": [{"user_id": user_id, "role": "user"}], + } + mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[mock_team]) + return mock_user_info + + +@pytest.mark.asyncio +async def test_search_team_daily_activity_keys_scopes_where_before_take(mock_db_client): + """A member's search must put the team and own-key scoping inside the same + Prisma where as the term, because `take` trims rows before Python sees them: + scoped outside the where, the top-N slice could be spent entirely on keys + the caller is not allowed to see.""" + from litellm.constants import USAGE_TOP_API_KEYS_LIMIT + from litellm.proxy.management_endpoints.team_endpoints import ( + search_team_daily_activity_keys, + ) + + user_id = "test_user_123" + team_id = "test_team_456" + user_api_key_dict = UserAPIKeyAuth(user_id=user_id, user_role=LitellmUserRoles.INTERNAL_USER) + mock_user_info = _key_search_team_setup(mock_db_client, user_id, team_id) + + user_key_1 = MagicMock() + user_key_1.token = "user_key_1" + matched = MagicMock() + matched.token = "user_key_1" + mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock(side_effect=[[user_key_1], [matched]]) + + with patch( + "litellm.proxy.management_endpoints.team_endpoints.get_user_object", + new_callable=AsyncMock, + ) as mock_get_user_object: + mock_get_user_object.return_value = mock_user_info + + with patch( + "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity_aggregated", + new_callable=AsyncMock, + ) as mock_aggregated: + mock_aggregated.return_value = MagicMock() + + await search_team_daily_activity_keys( + user_api_key_dict=user_api_key_dict, + search="Needle", + team_ids=team_id, + start_date="2024-01-01", + end_date="2024-01-31", + exclude_team_ids=None, + timezone=480, + ) + + token_calls = mock_db_client.db.litellm_verificationtoken.find_many.call_args_list + assert len(token_calls) == 2 + search_kwargs = token_calls[1][1] + assert search_kwargs["where"] == { + "team_id": {"in": (team_id,)}, + "token": {"in": ("user_key_1",)}, + "OR": ( + {"token": "Needle"}, + {"key_alias": {"contains": "Needle", "mode": "insensitive"}}, + {"user_id": {"contains": "Needle", "mode": "insensitive"}}, + ), + } + assert search_kwargs["take"] == USAGE_TOP_API_KEYS_LIMIT + assert search_kwargs["order"] == {"spend": "desc"} + + call_kwargs = mock_aggregated.call_args[1] + assert call_kwargs["api_key"] == ["user_key_1"] + assert call_kwargs["entity_id"] == [team_id] + assert call_kwargs["table_name"] == "litellm_dailyteamspend" + assert call_kwargs["include_entity_breakdown"] is True + assert call_kwargs["timezone_offset_minutes"] == 480 + assert call_kwargs["model"] is None + assert call_kwargs["entity_metadata_field"] == {team_id: {"team_alias": "Test Team"}} + + +@pytest.mark.asyncio +async def test_search_team_daily_activity_keys_admin_unscoped_where(mock_db_client): + """An admin's search has no caller scoping, so the where is the bare OR over + token, key alias and user id; every matched hash is passed through to the + aggregation.""" + from litellm.constants import USAGE_TOP_API_KEYS_LIMIT + from litellm.proxy.management_endpoints.team_endpoints import ( + search_team_daily_activity_keys, + ) + + match_1 = MagicMock() + match_1.token = "h1" + match_2 = MagicMock() + match_2.token = "h2" + mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[]) + mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[match_1, match_2]) + + with patch( + "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity_aggregated", + new_callable=AsyncMock, + ) as mock_aggregated: + mock_aggregated.return_value = MagicMock() + + await search_team_daily_activity_keys( + user_api_key_dict=UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN), + search="Needle", + team_ids=None, + start_date="2024-01-01", + end_date="2024-01-31", + exclude_team_ids=None, + timezone=None, + ) + + search_kwargs = mock_db_client.db.litellm_verificationtoken.find_many.call_args[1] + assert search_kwargs["where"] == { + "OR": ( + {"token": "Needle"}, + {"key_alias": {"contains": "Needle", "mode": "insensitive"}}, + {"user_id": {"contains": "Needle", "mode": "insensitive"}}, + ) + } + assert search_kwargs["take"] == USAGE_TOP_API_KEYS_LIMIT + assert mock_aggregated.call_args[1]["api_key"] == ["h1", "h2"] + + +@pytest.mark.asyncio +async def test_search_team_daily_activity_keys_no_match_returns_empty_without_aggregating( + mock_db_client, +): + """A term matching no key still owes the caller the standard metadata shape + (api_key_limit, total_api_keys), and the aggregated query must not run.""" + from litellm.constants import USAGE_TOP_API_KEYS_LIMIT + from litellm.proxy.management_endpoints.team_endpoints import ( + search_team_daily_activity_keys, + ) + + mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[]) + mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[]) + + with patch( + "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity_aggregated", + new_callable=AsyncMock, + ) as mock_aggregated: + result = await search_team_daily_activity_keys( + user_api_key_dict=UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN), + search="Needle", + team_ids=None, + start_date="2024-01-01", + end_date="2024-01-31", + exclude_team_ids=None, + timezone=None, + ) + + assert result.results == [] + assert result.metadata.total_api_keys == 0 + assert result.metadata.api_key_limit == USAGE_TOP_API_KEYS_LIMIT + mock_aggregated.assert_not_called() + + def _wire_new_team_prisma(mock_db_client): mock_db_client.jsonify_team_object = lambda db_data: db_data mock_db_client.get_data = AsyncMock(return_value=None) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx index 27460b211086..16dc41c3ba8d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.tsx @@ -20,7 +20,7 @@ import type { ColumnDef } from "@tanstack/react-table"; import PaginationStatusAlerts from "@/components/shared/PaginationStatusAlerts"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import React, { type ReactNode, useMemo, useState } from "react"; +import React, { type ReactNode, useCallback, useMemo, useState } from "react"; import TeamMultiSelect from "@/components/common_components/team_multi_select"; import UserDropdown from "@/components/common_components/UserDropdown"; import { ActivityMetrics, processActivityData } from "@/components/activity_metrics"; @@ -34,6 +34,7 @@ import { tagDailyActivityCall, teamDailyActivityAggregatedCall, teamDailyActivityCall, + teamDailyActivityKeySearchCall, userDailyActivityCall, } from "@/components/networking"; import { Logo } from "@/components/molecules/logo/Logo"; @@ -182,6 +183,16 @@ const EntityUsage: React.FC = ({ const modelBreakdownKey = modelViewType === "groups" ? "model_groups" : "models"; const modelMetrics = processActivityData(spendData, modelBreakdownKey, teams || []); const keyMetrics = processActivityData(spendData, "api_keys", teams || []); + const searchTeamKeys = useCallback( + (query: string) => { + if (!accessToken || !startTime || !endTime) return Promise.resolve({}); + const teamIds = Array.isArray(entityFilterArg) ? entityFilterArg : null; + return teamDailyActivityKeySearchCall(accessToken, startTime, endTime, query, teamIds).then((data) => + processActivityData(data, "api_keys", teams || []), + ); + }, + [accessToken, startTime, endTime, entityFilterArg, teams], + ); const agentMetrics = showAgentBreakdown ? processActivityData(agentSpendData, "entities", teams || []) : {}; const getAllTags = () => { @@ -667,6 +678,7 @@ const EntityUsage: React.FC = ({ keyMetrics={keyMetrics} hidePromptCachingMetrics={entityType === "agent"} apiKeyTruncation={apiKeyTruncation} + searchKeys={entityType === "team" ? searchTeamKeys : undefined} /> ), }, diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index e6923a26d4c5..a2a1f745a007 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1467,6 +1467,31 @@ export const teamDailyActivityAggregatedCall = async ( } }; +export const teamDailyActivityKeySearchCall = async ( + accessToken: string, + startTime: Date, + endTime: Date, + search: string, + teamIds: string[] | null = null, +) => { + try { + return await apiClient.get(`/team/daily/activity/aggregated/search`, { + accessToken, + query: { + start_date: formatDate(startTime), + end_date: formatDate(endTime), + timezone: new Date().getTimezoneOffset().toString(), + search, + team_ids: teamIds && teamIds.length > 0 ? teamIds.join(",") : undefined, + exclude_team_ids: "litellm-dashboard", + }, + }); + } catch (error) { + console.error("Failed to search team daily activity keys:", error); + throw error; + } +}; + export type TeamUserSpendResponse = components["schemas"]["TeamUserSpendResponse"]; export const teamSpendByUserCall = async ( diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 61a260be0fd4..3b321ca88b6c 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -15645,6 +15645,41 @@ export interface paths { patch?: never; trace?: never; }; + "/team/daily/activity/aggregated/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Search Team Daily Activity Keys + * @description Aggregated daily team activity for the keys matching a search term. + * + * `/team/daily/activity/aggregated` only returns the top USAGE_TOP_API_KEYS_LIMIT keys by spend, so + * a key outside that subset cannot be found by filtering the loaded page. This endpoint resolves + * the term against every verification token the caller may see (same team and key scoping as the + * aggregated endpoint) and aggregates litellm_dailyteamspend for just those hashes. + * + * Args: + * search (str): Exact token hash, or case-insensitive substring of key_alias or user_id. + * team_ids (Optional[str]): Comma-separated list of team IDs to filter by. + * start_date (Optional[str]): Start date for the activity period (YYYY-MM-DD). + * end_date (Optional[str]): End date for the activity period (YYYY-MM-DD). + * exclude_team_ids (Optional[str]): Comma-separated list of team IDs to exclude. + * timezone (Optional[int]): Timezone offset in minutes from UTC, matching JavaScript's Date.getTimezoneOffset(). + * Returns: + * SpendAnalyticsPaginatedResponse: Aggregated activity restricted to the matching keys. + */ + get: operations["search_team_daily_activity_keys_team_daily_activity_aggregated_search_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/team/delete": { parameters: { query?: never; @@ -30010,7 +30045,7 @@ export interface components { * @description Enum for key management routes * @enum {string} */ - KeyManagementRoutes: "/key/generate" | "/key/update" | "/key/delete" | "/key/regenerate" | "/key/service-account/generate" | "/key/{key_id}/regenerate" | "/key/block" | "/key/unblock" | "/key/bulk_update" | "/team/key/bulk_update" | "/key/{key_id}/reset_spend" | "/key/access_group_assignment" | "/auto_router/manage" | "/key/info" | "/key/health" | "/key/list" | "/key/aliases" | "/team/daily/activity" | "/team/daily/activity/aggregated" | "/spend/logs" | "/spend/logs/v2"; + KeyManagementRoutes: "/key/generate" | "/key/update" | "/key/delete" | "/key/regenerate" | "/key/service-account/generate" | "/key/{key_id}/regenerate" | "/key/block" | "/key/unblock" | "/key/bulk_update" | "/team/key/bulk_update" | "/key/{key_id}/reset_spend" | "/key/access_group_assignment" | "/auto_router/manage" | "/key/info" | "/key/health" | "/key/list" | "/key/aliases" | "/team/daily/activity" | "/team/daily/activity/aggregated" | "/team/daily/activity/aggregated/search" | "/spend/logs" | "/spend/logs/v2"; /** * KeyManagementSystem * @enum {string} @@ -62336,6 +62371,43 @@ export interface operations { }; }; }; + search_team_daily_activity_keys_team_daily_activity_aggregated_search_get: { + parameters: { + query: { + /** @description Exact token hash, or a case-insensitive substring of the key alias or owning user id */ + search: string; + team_ids?: string | null; + start_date?: string | null; + end_date?: string | null; + exclude_team_ids?: string | null; + timezone?: number | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SpendAnalyticsPaginatedResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; delete_team_team_delete_post: { parameters: { query?: never; From 29a48eea8e0555907333d8923993861f2385987f Mon Sep 17 00:00:00 2001 From: yassin Date: Thu, 24 Sep 2026 02:29:10 +0000 Subject: [PATCH 05/13] fix(usage): suppress LIT002 on prisma where literals and keep key search call under max-params Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../management_endpoints/team_endpoints.py | 20 +++++++++---------- .../src/components/networking.tsx | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index b026e716535f..e7d12e7f47ac 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -6800,25 +6800,25 @@ def _team_key_search_where(*, search: str, scope: _TeamDailyActivityScope) -> Te """Caller scoping lives inside the same Prisma where as the search term so `take` never trims visible matches in favour of keys the caller is not allowed to see.""" search_or: Final = ( - {"token": search}, - {"key_alias": {"contains": search, "mode": "insensitive"}}, - {"user_id": {"contains": search, "mode": "insensitive"}}, + {"token": search}, # mutable-ok: prisma where clause leaf + {"key_alias": {"contains": search, "mode": "insensitive"}}, # mutable-ok: prisma where clause leaf + {"user_id": {"contains": search, "mode": "insensitive"}}, # mutable-ok: prisma where clause leaf ) own_keys: Final = tuple(scope.api_key_filter) if isinstance(scope.api_key_filter, list) else None if scope.team_ids is None and own_keys is None: - return {"OR": search_or} + return {"OR": search_or} # mutable-ok: prisma where clause root if scope.team_ids is None and own_keys is not None: - return {"token": {"in": own_keys}, "OR": search_or} + return {"token": {"in": own_keys}, "OR": search_or} # mutable-ok: prisma where clause root if scope.team_ids is not None and own_keys is None: - return {"team_id": {"in": tuple(scope.team_ids)}, "OR": search_or} + return {"team_id": {"in": tuple(scope.team_ids)}, "OR": search_or} # mutable-ok: prisma where clause root assert scope.team_ids is not None and own_keys is not None - return {"team_id": {"in": tuple(scope.team_ids)}, "token": {"in": own_keys}, "OR": search_or} + return {"team_id": {"in": tuple(scope.team_ids)}, "token": {"in": own_keys}, "OR": search_or} # mutable-ok: prisma where clause root @router.get( "/team/daily/activity/aggregated/search", response_model=SpendAnalyticsPaginatedResponse, - tags=["team management"], + tags=["team management"], # mutable-ok: FastAPI route tags shape ) async def search_team_daily_activity_keys( user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], @@ -6876,12 +6876,12 @@ async def search_team_daily_activity_keys( matched_keys: Final = await _tokens_db(prisma_client).find_many( where=_team_key_search_where(search=search, scope=scope), take=USAGE_TOP_API_KEYS_LIMIT, - order={"spend": "desc"}, + order={"spend": "desc"}, # mutable-ok: prisma serializes order, keep it a plain dict ) tokens: Final = [key.token for key in matched_keys] # mutable-ok: get_daily_activity_aggregated takes list[str] if not tokens: return SpendAnalyticsPaginatedResponse( - results=[], + results=[], # mutable-ok: response model field shape metadata=DailySpendMetadata(api_key_limit=USAGE_TOP_API_KEYS_LIMIT, total_api_keys=0), ) diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index a2a1f745a007..edaf56a8a164 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -1471,9 +1471,9 @@ export const teamDailyActivityKeySearchCall = async ( accessToken: string, startTime: Date, endTime: Date, - search: string, - teamIds: string[] | null = null, + ...options: [search: string, teamIds?: string[] | null] ) => { + const [search, teamIds = null] = options; try { return await apiClient.get(`/team/daily/activity/aggregated/search`, { accessToken, From fda522f7a89d7fa65f2d8a99769916c858de8fec Mon Sep 17 00:00:00 2001 From: yassin Date: Thu, 24 Sep 2026 02:31:27 +0000 Subject: [PATCH 06/13] style(usage): wrap long prisma where literal to satisfy ruff format Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/management_endpoints/team_endpoints.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index e7d12e7f47ac..ae1664e60dd5 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -6812,7 +6812,11 @@ def _team_key_search_where(*, search: str, scope: _TeamDailyActivityScope) -> Te if scope.team_ids is not None and own_keys is None: return {"team_id": {"in": tuple(scope.team_ids)}, "OR": search_or} # mutable-ok: prisma where clause root assert scope.team_ids is not None and own_keys is not None - return {"team_id": {"in": tuple(scope.team_ids)}, "token": {"in": own_keys}, "OR": search_or} # mutable-ok: prisma where clause root + return { + "team_id": {"in": tuple(scope.team_ids)}, + "token": {"in": own_keys}, + "OR": search_or, + } # mutable-ok: prisma where clause root @router.get( From 775891358603ba57633fbabfa5140f3439dbb822 Mon Sep 17 00:00:00 2001 From: yassin Date: Thu, 24 Sep 2026 02:36:22 +0000 Subject: [PATCH 07/13] style(usage): pin LIT002 suppressions to each literal line in wrapped where Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/management_endpoints/team_endpoints.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index ae1664e60dd5..0406cd4438c1 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -6812,11 +6812,11 @@ def _team_key_search_where(*, search: str, scope: _TeamDailyActivityScope) -> Te if scope.team_ids is not None and own_keys is None: return {"team_id": {"in": tuple(scope.team_ids)}, "OR": search_or} # mutable-ok: prisma where clause root assert scope.team_ids is not None and own_keys is not None - return { - "team_id": {"in": tuple(scope.team_ids)}, - "token": {"in": own_keys}, + return { # mutable-ok: prisma where clause root + "team_id": {"in": tuple(scope.team_ids)}, # mutable-ok: prisma where clause leaf + "token": {"in": own_keys}, # mutable-ok: prisma where clause leaf "OR": search_or, - } # mutable-ok: prisma where clause root + } @router.get( From c9ee12e43f6c7b3c68f98464f1193c01ddaa49c4 Mon Sep 17 00:00:00 2001 From: yassin Date: Thu, 24 Sep 2026 02:41:18 +0000 Subject: [PATCH 08/13] test(usage): assert reachability outcome for team daily activity routes Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../proxy/auth/test_route_checks.py | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index 36521441cc5d..7bb79a115dd9 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -3632,14 +3632,21 @@ def test_team_daily_activity_routes_reachable_by_non_admin(route, user_role): request = MagicMock(spec=Request) request.query_params = {} - 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={}, - ) + def outcome() -> 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() == "allowed" @pytest.mark.parametrize( From a08e57c6f3b10f879f8d5159cceeefc3637daa71 Mon Sep 17 00:00:00 2001 From: yassin Date: Thu, 24 Sep 2026 02:49:43 +0000 Subject: [PATCH 09/13] test(usage): cover team key search route in daily activity behavior matrix Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../management/test_team_daily_activity.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/tests/proxy_behavior/management/test_team_daily_activity.py b/tests/proxy_behavior/management/test_team_daily_activity.py index d84cc4c94af5..9bbc8fdde290 100644 --- a/tests/proxy_behavior/management/test_team_daily_activity.py +++ b/tests/proxy_behavior/management/test_team_daily_activity.py @@ -5,8 +5,9 @@ pytestmark = pytest.mark.asyncio(loop_scope="session") -# GET /team/daily/activity and its /aggregated variant (same shared scope -# resolver, so the matrix must hold for both). A proxy admin (admin view) sees +# GET /team/daily/activity, its /aggregated variant, and the key-search +# variant (same shared scope resolver, so the matrix must hold for all +# three). A proxy admin (admin view) sees # activity for any team. A non-admin is scoped to user_info.teams: a bare query # defaults to its own teams (200), and an explicit team_ids filter naming a # team it does not belong to is 404 (the VERIA-43 fix). Org admins have no @@ -43,8 +44,12 @@ def _expected(actor: Actor, team: str) -> int: @pytest.mark.parametrize( "endpoint", - ("/team/daily/activity", "/team/daily/activity/aggregated"), - ids=("paginated", "aggregated"), + ( + "/team/daily/activity", + "/team/daily/activity/aggregated", + "/team/daily/activity/aggregated/search", + ), + ids=("paginated", "aggregated", "search"), ) @pytest.mark.parametrize( "actor,team,expected_status", @@ -54,7 +59,7 @@ def _expected(actor: Actor, team: str) -> int: async def test_team_daily_activity_matrix( actor: Actor, team: str, expected_status: int, endpoint: str, proxy_client, world ): - query = _DATES + query = _DATES + ("&search=x" if endpoint.endswith("/search") else "") if team == "alpha": query += f"&team_ids={world.team_alpha_id}" elif team == "beta": From a4686cdfbca83eea668aad85131cea47309ac967 Mon Sep 17 00:00:00 2001 From: yassin Date: Thu, 24 Sep 2026 04:10:51 +0000 Subject: [PATCH 10/13] fix(usage): exclude filtered teams inside the key search where before take Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../management_endpoints/team_endpoints.py | 25 ++++++++--- .../management_endpoints/team_endpoints.py | 8 +++- .../test_team_daily_activity_key_search.py | 39 +++++++++++++++++ .../test_team_endpoints.py | 42 +++++++++++++++++++ 4 files changed, 107 insertions(+), 7 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 0406cd4438c1..599cbc2138a3 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -205,6 +205,7 @@ BulkUpdateTeamMemberPermissionsRequest, BulkUpdateTeamMemberPermissionsResponse, GetTeamMemberPermissionsResponse, + TeamIdSearchFilter, TeamIdSearchMatch, TeamKeyActivitySearchWhere, TeamListItem, @@ -6805,15 +6806,27 @@ def _team_key_search_where(*, search: str, scope: _TeamDailyActivityScope) -> Te {"user_id": {"contains": search, "mode": "insensitive"}}, # mutable-ok: prisma where clause leaf ) own_keys: Final = tuple(scope.api_key_filter) if isinstance(scope.api_key_filter, list) else None - if scope.team_ids is None and own_keys is None: + team_filter: Final[TeamIdSearchFilter | None] = ( + { # mutable-ok: prisma where clause leaf + "in": tuple(scope.team_ids), + "notIn": tuple(scope.exclude_team_ids), + } + if scope.team_ids is not None and scope.exclude_team_ids is not None + else {"in": tuple(scope.team_ids)} # mutable-ok: prisma where clause leaf + if scope.team_ids is not None + else {"notIn": tuple(scope.exclude_team_ids)} # mutable-ok: prisma where clause leaf + if scope.exclude_team_ids is not None + else None + ) + if team_filter is None and own_keys is None: return {"OR": search_or} # mutable-ok: prisma where clause root - if scope.team_ids is None and own_keys is not None: + if team_filter is None and own_keys is not None: return {"token": {"in": own_keys}, "OR": search_or} # mutable-ok: prisma where clause root - if scope.team_ids is not None and own_keys is None: - return {"team_id": {"in": tuple(scope.team_ids)}, "OR": search_or} # mutable-ok: prisma where clause root - assert scope.team_ids is not None and own_keys is not None + if team_filter is not None and own_keys is None: + return {"team_id": team_filter, "OR": search_or} # mutable-ok: prisma where clause root + assert team_filter is not None and own_keys is not None return { # mutable-ok: prisma where clause root - "team_id": {"in": tuple(scope.team_ids)}, # mutable-ok: prisma where clause leaf + "team_id": team_filter, "token": {"in": own_keys}, # mutable-ok: prisma where clause leaf "OR": search_or, } diff --git a/litellm/types/proxy/management_endpoints/team_endpoints.py b/litellm/types/proxy/management_endpoints/team_endpoints.py index a25bca283c99..144ab55d32cb 100644 --- a/litellm/types/proxy/management_endpoints/team_endpoints.py +++ b/litellm/types/proxy/management_endpoints/team_endpoints.py @@ -19,11 +19,17 @@ TeamIdSearchMatch = Literal["exact", "prefix"] +TeamIdSearchFilter = TypedDict( + "TeamIdSearchFilter", + {"in": NotRequired[ReadOnly[Sequence[str]]], "notIn": NotRequired[ReadOnly[Sequence[str]]]}, +) + + class TeamKeyActivitySearchWhere(TypedDict): """Prisma filter behind `/team/daily/activity/aggregated/search`: exact token hash, or key alias or user id containing the term, case-insensitive, narrowed to the teams and keys the caller may see.""" - team_id: NotRequired[ReadOnly[Mapping[Literal["in"], Sequence[str]]]] + team_id: NotRequired[ReadOnly[TeamIdSearchFilter]] token: NotRequired[ReadOnly[Mapping[Literal["in"], Sequence[str]]]] OR: ReadOnly[ tuple[Mapping[Literal["token"], str] | Mapping[Literal["key_alias", "user_id"], InsensitiveContains], ...] diff --git a/tests/integration/spend/test_team_daily_activity_key_search.py b/tests/integration/spend/test_team_daily_activity_key_search.py index c106f9727a9d..2b0395cf4ba2 100644 --- a/tests/integration/spend/test_team_daily_activity_key_search.py +++ b/tests/integration/spend/test_team_daily_activity_key_search.py @@ -87,3 +87,42 @@ def test_team_key_search_is_scoped_to_the_teams_the_caller_belongs_to(gateway: G "GET", _SEARCH_PATH, params={"team_ids": team, **params}, key=outsider_key ) assert foreign_team_view.status_code == 404, foreign_team_view.text + + +def test_team_key_search_excludes_teams_inside_the_where(gateway: Gateway) -> None: + """The dashboard always sends exclude_team_ids; a matching key in an excluded + team with higher spend must not consume a take slot nor appear in the result.""" + with gateway.scenario() as scenario: + model: Final = scenario.model(input_cost_per_token=0.001, output_cost_per_token=0.002) + team_keep: Final = scenario.team(models=[model]) + team_drop: Final = scenario.team(models=[model]) + shared_alias: Final = f"needle-{uuid.uuid4().hex}" + keep: Final = scenario.key(team_id=team_keep, models=[model], key_alias=f"{shared_alias}-keep") + drop: Final = scenario.key(team_id=team_drop, models=[model], key_alias=f"{shared_alias}-drop") + keep_digest: Final = sha256(keep.encode()).hexdigest() + drop_digest: Final = sha256(drop.encode()).hexdigest() + for _ in range(2): + reply: Final = gateway.chat(model, key=drop, text=f"key search {uuid.uuid4().hex}") + assert object_value(reply["usage"])["total_tokens"] == 40, reply + reply = gateway.chat(model, key=keep, text=f"key search {uuid.uuid4().hex}") + assert object_value(reply["usage"])["total_tokens"] == 40, reply + eventually( + lambda: read_rows( + 'SELECT api_key, spend FROM "LiteLLM_DailyTeamSpend" WHERE team_id IN (%s, %s)', + (team_keep, team_drop), + ), + lambda values: sorted(row["api_key"] for row in values) == sorted((keep_digest, drop_digest)), + seconds=70, + ) + response: Final = gateway.request( + "GET", + _SEARCH_PATH, + params={"search": shared_alias, "exclude_team_ids": team_drop, **_range_around_today()}, + ) + assert response.status_code == 200, response.text + body: Final = object_value(response.json()) + results: Final = body["results"] + assert isinstance(results, list) and len(results) == 1, body + entities: Final = object_value(object_value(object_value(results[0])["breakdown"])["entities"]) + assert set(entities) == {team_keep}, response.text + assert set(_team_key_breakdown(body, team_keep)) == {keep_digest}, response.text diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index a9f1d63c0669..1ffd6064450a 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -14739,6 +14739,48 @@ async def test_search_team_daily_activity_keys_no_match_returns_empty_without_ag mock_aggregated.assert_not_called() +@pytest.mark.asyncio +async def test_search_team_daily_activity_keys_excludes_teams_in_where(mock_db_client): + """The dashboard always sends exclude_team_ids=litellm-dashboard; if that + filter stayed out of the where, matching keys in excluded teams could fill + the take=N slice and push visible matches out.""" + from litellm.proxy.management_endpoints.team_endpoints import ( + search_team_daily_activity_keys, + ) + + matched = MagicMock() + matched.token = "h1" + mock_db_client.db.litellm_teamtable.find_many = AsyncMock(return_value=[]) + mock_db_client.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[matched]) + + with patch( + "litellm.proxy.management_endpoints.team_endpoints.get_daily_activity_aggregated", + new_callable=AsyncMock, + ) as mock_aggregated: + mock_aggregated.return_value = MagicMock() + + await search_team_daily_activity_keys( + user_api_key_dict=UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN), + search="Needle", + team_ids=None, + start_date="2024-01-01", + end_date="2024-01-31", + exclude_team_ids="litellm-dashboard", + timezone=None, + ) + + search_kwargs = mock_db_client.db.litellm_verificationtoken.find_many.call_args[1] + assert search_kwargs["where"] == { + "team_id": {"notIn": ("litellm-dashboard",)}, + "OR": ( + {"token": "Needle"}, + {"key_alias": {"contains": "Needle", "mode": "insensitive"}}, + {"user_id": {"contains": "Needle", "mode": "insensitive"}}, + ), + } + assert mock_aggregated.call_args[1]["exclude_entity_ids"] == ["litellm-dashboard"] + + def _wire_new_team_prisma(mock_db_client): mock_db_client.jsonify_team_object = lambda db_data: db_data mock_db_client.get_data = AsyncMock(return_value=None) From cef26890d3aedf9e22021b8996df810945cd883c Mon Sep 17 00:00:00 2001 From: yassin Date: Thu, 24 Sep 2026 04:15:20 +0000 Subject: [PATCH 11/13] style(usage): suppress LIT002 on functional TypedDict field map Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/types/proxy/management_endpoints/team_endpoints.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/litellm/types/proxy/management_endpoints/team_endpoints.py b/litellm/types/proxy/management_endpoints/team_endpoints.py index 144ab55d32cb..aac2703e918e 100644 --- a/litellm/types/proxy/management_endpoints/team_endpoints.py +++ b/litellm/types/proxy/management_endpoints/team_endpoints.py @@ -21,7 +21,10 @@ TeamIdSearchFilter = TypedDict( "TeamIdSearchFilter", - {"in": NotRequired[ReadOnly[Sequence[str]]], "notIn": NotRequired[ReadOnly[Sequence[str]]]}, + { # mutable-ok: functional TypedDict field map + "in": NotRequired[ReadOnly[Sequence[str]]], + "notIn": NotRequired[ReadOnly[Sequence[str]]], + }, ) From 5c19c01248879da920f065d008d5f36df1965f54 Mon Sep 17 00:00:00 2001 From: yassin Date: Thu, 24 Sep 2026 04:44:01 +0000 Subject: [PATCH 12/13] docs(usage): trim team key search endpoint docstring to the route summary Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../management_endpoints/team_endpoints.py | 20 ++----------------- 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 599cbc2138a3..0daad8619220 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -6850,24 +6850,8 @@ async def search_team_daily_activity_keys( exclude_team_ids: str | None = None, timezone: int | None = None, ) -> SpendAnalyticsPaginatedResponse: - """ - Aggregated daily team activity for the keys matching a search term. - - `/team/daily/activity/aggregated` only returns the top USAGE_TOP_API_KEYS_LIMIT keys by spend, so - a key outside that subset cannot be found by filtering the loaded page. This endpoint resolves - the term against every verification token the caller may see (same team and key scoping as the - aggregated endpoint) and aggregates litellm_dailyteamspend for just those hashes. - - Args: - search (str): Exact token hash, or case-insensitive substring of key_alias or user_id. - team_ids (Optional[str]): Comma-separated list of team IDs to filter by. - start_date (Optional[str]): Start date for the activity period (YYYY-MM-DD). - end_date (Optional[str]): End date for the activity period (YYYY-MM-DD). - exclude_team_ids (Optional[str]): Comma-separated list of team IDs to exclude. - timezone (Optional[int]): Timezone offset in minutes from UTC, matching JavaScript's Date.getTimezoneOffset(). - Returns: - SpendAnalyticsPaginatedResponse: Aggregated activity restricted to the matching keys. - """ + """Aggregated daily team activity for the keys matching `search`, across every key the caller may + see rather than only the top USAGE_TOP_API_KEYS_LIMIT keys by spend.""" from litellm.proxy.proxy_server import ( prisma_client, proxy_logging_obj, From ffc9cb4f234468ad321f6a48928f951e14899986 Mon Sep 17 00:00:00 2001 From: yassin Date: Thu, 24 Sep 2026 04:47:44 +0000 Subject: [PATCH 13/13] chore(usage): regenerate dashboard api types after docstring trim Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ui/litellm-dashboard/src/lib/http/schema.d.ts | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 3b321ca88b6c..a8b2c3301f64 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -15654,22 +15654,8 @@ export interface paths { }; /** * Search Team Daily Activity Keys - * @description Aggregated daily team activity for the keys matching a search term. - * - * `/team/daily/activity/aggregated` only returns the top USAGE_TOP_API_KEYS_LIMIT keys by spend, so - * a key outside that subset cannot be found by filtering the loaded page. This endpoint resolves - * the term against every verification token the caller may see (same team and key scoping as the - * aggregated endpoint) and aggregates litellm_dailyteamspend for just those hashes. - * - * Args: - * search (str): Exact token hash, or case-insensitive substring of key_alias or user_id. - * team_ids (Optional[str]): Comma-separated list of team IDs to filter by. - * start_date (Optional[str]): Start date for the activity period (YYYY-MM-DD). - * end_date (Optional[str]): End date for the activity period (YYYY-MM-DD). - * exclude_team_ids (Optional[str]): Comma-separated list of team IDs to exclude. - * timezone (Optional[int]): Timezone offset in minutes from UTC, matching JavaScript's Date.getTimezoneOffset(). - * Returns: - * SpendAnalyticsPaginatedResponse: Aggregated activity restricted to the matching keys. + * @description Aggregated daily team activity for the keys matching `search`, across every key the caller may + * see rather than only the top USAGE_TOP_API_KEYS_LIMIT keys by spend. */ get: operations["search_team_daily_activity_keys_team_daily_activity_aggregated_search_get"]; put?: never;