feat(usage): search keys beyond the top-N usage subset (#42827)

This commit is contained in:
devin-ai-integration[bot] 2026-09-24 15:04:03 -05:00 • committed by GitHub
parent 1edc4ba580
commit 5a8ec13786
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 560 additions and 23 deletions

View file

@ -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",

View file

@ -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
)

View file

@ -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

View file

@ -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

View file

@ -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(

View file

@ -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):
"""

View file

@ -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} />

View file

@ -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");
});
});

View file

@ -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 &quot;{query.trim()}&quot; in this date range
No keys match &quot;{trimmedQuery}&quot; in this date range
</p>
) : (
<ActivityMetrics modelMetrics={filtered} hidePromptCachingMetrics={hidePromptCachingMetrics} />
<ActivityMetrics modelMetrics={displayed} hidePromptCachingMetrics={hidePromptCachingMetrics} />
)}
</div>
);

View file

@ -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.

View file

@ -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;