mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-26 01:12:21 +00:00
feat(usage): search keys beyond the top-N usage subset (#42827)
This commit is contained in:
parent
1edc4ba580
commit
5a8ec13786
11 changed files with 560 additions and 23 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ from typing_extensions import ReadOnly, TypedDict
|
|||
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 @@ from litellm.repositories.verification_token_repository import (
|
|||
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,117 @@ 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)
|
||||
|
||||
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,
|
||||
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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ from collections.abc import Mapping, Sequence
|
|||
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,
|
||||
|
|
@ -28,6 +28,16 @@ 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."""
|
||||
|
||||
user_id: NotRequired[ReadOnly[str]]
|
||||
OR: ReadOnly[
|
||||
tuple[Mapping[Literal["token"], str] | Mapping[Literal["key_alias", "user_id"], InsensitiveContains], ...]
|
||||
]
|
||||
|
||||
|
||||
class UserListResponse(BaseModel):
|
||||
"""
|
||||
Response model for the user list endpoint
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -2659,6 +2659,172 @@ 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"}},
|
||||
)
|
||||
assert "user_id" not in find_many_kwargs["where"]
|
||||
|
||||
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"
|
||||
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(
|
||||
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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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<UsagePageProps> = ({ 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<UsagePageProps> = ({ teams, organizations }) => {
|
|||
<ActivityMetrics modelMetrics={modelMetrics} />
|
||||
</TabsContent>
|
||||
<TabsContent value="keys" keepMounted>
|
||||
<KeyActivityPanel keyMetrics={keyMetrics} apiKeyTruncation={spendFetchState.apiKeyTruncation} />
|
||||
<KeyActivityPanel
|
||||
keyMetrics={keyMetrics}
|
||||
apiKeyTruncation={spendFetchState.apiKeyTruncation}
|
||||
searchKeys={searchKeys}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="mcp" keepMounted>
|
||||
<ActivityMetrics modelMetrics={mcpServerMetrics} />
|
||||
|
|
|
|||
|
|
@ -78,4 +78,68 @@ describe("KeyActivityPanel", () => {
|
|||
render(<KeyActivityPanel keyMetrics={keyMetrics} />);
|
||||
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<Record<string, ModelActivityData>>>()
|
||||
.mockResolvedValue({ "hash-gamma": activity("gamma-low-key", "gamma@example.com", "user-gamma") });
|
||||
render(
|
||||
<KeyActivityPanel keyMetrics={keyMetrics} apiKeyTruncation={{ limit: 2, total: 3 }} searchKeys={searchKeys} />,
|
||||
);
|
||||
|
||||
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<Record<string, ModelActivityData>>>()
|
||||
.mockResolvedValue({ "hash-gamma": activity("gamma-low-key", "gamma@example.com", "user-gamma") });
|
||||
render(<KeyActivityPanel keyMetrics={keyMetrics} searchKeys={searchKeys} />);
|
||||
|
||||
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("drops stale server results as soon as the search callback is rebuilt", async () => {
|
||||
const searchKeysA = vi
|
||||
.fn<(query: string) => Promise<Record<string, ModelActivityData>>>()
|
||||
.mockResolvedValue({ "hash-gamma": activity("gamma-low-key", "gamma@example.com", "user-gamma") });
|
||||
const searchKeysB = vi
|
||||
.fn<(query: string) => Promise<Record<string, ModelActivityData>>>()
|
||||
.mockReturnValue(new Promise(() => {}));
|
||||
const { rerender } = render(
|
||||
<KeyActivityPanel keyMetrics={keyMetrics} apiKeyTruncation={{ limit: 2, total: 3 }} searchKeys={searchKeysA} />,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Search keys"), { target: { value: "gamma" } });
|
||||
expect(await screen.findByText("hash-gamma")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<KeyActivityPanel keyMetrics={keyMetrics} apiKeyTruncation={{ limit: 2, total: 3 }} searchKeys={searchKeysB} />,
|
||||
);
|
||||
|
||||
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<Record<string, ModelActivityData>>>()
|
||||
.mockRejectedValue(new Error("boom"));
|
||||
render(
|
||||
<KeyActivityPanel keyMetrics={keyMetrics} apiKeyTruncation={{ limit: 2, total: 3 }} searchKeys={searchKeys} />,
|
||||
);
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,67 @@ interface KeyActivityPanelProps {
|
|||
keyMetrics: Record<string, ModelActivityData>;
|
||||
hidePromptCachingMetrics?: boolean;
|
||||
apiKeyTruncation?: ApiKeyTruncation;
|
||||
searchKeys?: SearchKeys;
|
||||
}
|
||||
|
||||
type SearchKeys = (query: string) => Promise<Record<string, ModelActivityData>>;
|
||||
|
||||
type RemoteSearch =
|
||||
| { status: "idle" }
|
||||
| { status: "loading"; query: string; searchKeys: SearchKeys }
|
||||
| { status: "done"; query: string; searchKeys: SearchKeys; keys: Record<string, ModelActivityData> }
|
||||
| { status: "error"; query: string; searchKeys: SearchKeys };
|
||||
|
||||
const REMOTE_SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({
|
||||
keyMetrics,
|
||||
hidePromptCachingMetrics = false,
|
||||
apiKeyTruncation,
|
||||
searchKeys,
|
||||
}) => {
|
||||
const [query, setQuery] = useState("");
|
||||
const [remote, setRemote] = useState<RemoteSearch>({ 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 });
|
||||
searchKeys(trimmedQuery)
|
||||
.then((keys) => {
|
||||
if (!cancelled) setRemote({ status: "done", query: trimmedQuery, searchKeys, keys });
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setRemote({ status: "error", query: trimmedQuery, searchKeys });
|
||||
});
|
||||
}, REMOTE_SEARCH_DEBOUNCE_MS);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [remoteEnabled, trimmedQuery, searchKeys]);
|
||||
|
||||
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(() => {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
|
|
@ -47,8 +96,18 @@ const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({
|
|||
)}
|
||||
</InputGroup>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Showing {shownKeys.toLocaleString()} of {totalKeys.toLocaleString()} keys
|
||||
Showing {shownKeys.toLocaleString()} of {totalShown.toLocaleString()} keys
|
||||
</span>
|
||||
{remoteLoading && (
|
||||
<span role="status" className="text-sm text-muted-foreground">
|
||||
Searching all keys...
|
||||
</span>
|
||||
)}
|
||||
{remoteFailed && (
|
||||
<span role="alert" className="text-sm text-muted-foreground">
|
||||
Key search failed
|
||||
</span>
|
||||
)}
|
||||
{apiKeyTruncation !== undefined && (
|
||||
<span className="text-sm text-muted-foreground" role="note">
|
||||
Only the {apiKeyTruncation.limit.toLocaleString()} highest-spend keys of{" "}
|
||||
|
|
@ -56,12 +115,12 @@ const KeyActivityPanel: React.FC<KeyActivityPanelProps> = ({
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isFiltering && totalKeys > 0 && shownKeys === 0 ? (
|
||||
{noMatches ? (
|
||||
<p className="rounded-lg border p-6 text-center text-sm text-muted-foreground">
|
||||
No keys match "{query.trim()}" in this date range
|
||||
No keys match "{trimmedQuery}" in this date range
|
||||
</p>
|
||||
) : (
|
||||
<ActivityMetrics modelMetrics={filtered} hidePromptCachingMetrics={hidePromptCachingMetrics} />
|
||||
<ActivityMetrics modelMetrics={displayed} hidePromptCachingMetrics={hidePromptCachingMetrics} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2556,6 +2556,36 @@ export const userDailyActivityAggregatedCall = async (
|
|||
}
|
||||
};
|
||||
|
||||
export const userDailyActivityKeySearchCall = async (
|
||||
accessToken: string,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
...options: [search: string, userId?: string | null]
|
||||
) => {
|
||||
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.
|
||||
|
|
|
|||
65
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
65
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
@ -68731,6 +68754,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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue