mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
fix(ui): tag filter dropdown on Usage page respects selected date range
- GET /tag/list now accepts optional start_date/end_date query params (YYYY-MM-DD). When provided, dynamic tags are filtered to those with spend in the given window; stored tags are always returned. - _validate_tag_list_date_range enforces that both params are supplied together, format is valid, and start <= end. - tagListCall in networking.tsx gains optional startTime/endTime args; builds YYYY-MM-DD query params via a local formatYmd helper. - UsagePageView replaces the static mount-only useEffect with one whose deps include startTime and endTime, so the tag dropdown re-fetches whenever the date range changes. - Two new unit tests cover the date-filtered and unfiltered code paths in test_tag_management_endpoints.py. Co-Authored-By: Claude Sonnet 4 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
6e6b2ca2d8
commit
08b8ecab5c
4 changed files with 196 additions and 21 deletions
|
|
@ -12,9 +12,10 @@ All /tag management endpoints
|
|||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.proxy._types import UserAPIKeyAuth
|
||||
|
|
@ -395,6 +396,32 @@ async def info_tag(
|
|||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
def _validate_tag_list_date_range(
|
||||
start_date: Optional[str], end_date: Optional[str]
|
||||
) -> None:
|
||||
"""Require both dates together, and enforce YYYY-MM-DD format with start <= end."""
|
||||
if (start_date is None) != (end_date is None):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="start_date and end_date must be provided together",
|
||||
)
|
||||
if start_date is None:
|
||||
return
|
||||
try:
|
||||
start = datetime.strptime(start_date, "%Y-%m-%d")
|
||||
end = datetime.strptime(end_date, "%Y-%m-%d") # type: ignore[arg-type]
|
||||
except ValueError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid date format, expected YYYY-MM-DD: {e}",
|
||||
)
|
||||
if start > end:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="start_date must be on or before end_date",
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tag/list",
|
||||
tags=["tag management"],
|
||||
|
|
@ -402,6 +429,18 @@ async def info_tag(
|
|||
)
|
||||
async def list_tags(
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
start_date: Optional[str] = Query(
|
||||
None,
|
||||
description=(
|
||||
"Optional start date (YYYY-MM-DD). When provided together with "
|
||||
"end_date, dynamic tags are limited to those active in the window. "
|
||||
"Stored tags are always returned."
|
||||
),
|
||||
),
|
||||
end_date: Optional[str] = Query(
|
||||
None,
|
||||
description="Optional end date (YYYY-MM-DD). Must be given with start_date.",
|
||||
),
|
||||
):
|
||||
"""
|
||||
List all available tags with their budget information.
|
||||
|
|
@ -411,6 +450,8 @@ async def list_tags(
|
|||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail="Database not connected")
|
||||
|
||||
_validate_tag_list_date_range(start_date, end_date)
|
||||
|
||||
try:
|
||||
## QUERY STORED TAGS ##
|
||||
tag_records = await prisma_client.db.litellm_tagtable.find_many(
|
||||
|
|
@ -453,9 +494,13 @@ async def list_tags(
|
|||
# Prisma's distinct fetches all columns for all rows and deduplicates
|
||||
# in application code, which is extremely slow on large tables.
|
||||
# See: https://www.prisma.io/docs/orm/prisma-client/queries/aggregation-grouping-summarizing#distinct-under-the-hood
|
||||
dynamic_tag_where: Dict[str, Any] = {"tag": {"not": None}}
|
||||
if start_date is not None and end_date is not None:
|
||||
dynamic_tag_where["date"] = {"gte": start_date, "lte": end_date}
|
||||
|
||||
dynamic_tag_rows = await prisma_client.db.litellm_dailytagspend.group_by(
|
||||
by=["tag"],
|
||||
where={"tag": {"not": None}},
|
||||
where=dynamic_tag_where,
|
||||
min={"created_at": True},
|
||||
max={"updated_at": True},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -380,6 +380,116 @@ async def test_list_tags_no_dynamic_tags():
|
|||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tags_with_date_range_filters_dynamic_tags():
|
||||
"""
|
||||
/tag/list?start_date=...&end_date=... should push the date window into
|
||||
the dailytagspend group_by WHERE clause so large tables don't get scanned.
|
||||
"""
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
|
||||
mock_user_auth = UserAPIKeyAuth(
|
||||
user_id="test-user-123",
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
)
|
||||
app.dependency_overrides[user_api_key_auth] = lambda: mock_user_auth
|
||||
|
||||
try:
|
||||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
|
||||
mock_db = Mock()
|
||||
mock_prisma.db = mock_db
|
||||
mock_db.litellm_tagtable.find_many = AsyncMock(return_value=[])
|
||||
group_by_mock = AsyncMock(return_value=[])
|
||||
mock_db.litellm_dailytagspend.group_by = group_by_mock
|
||||
|
||||
headers = {"Authorization": "Bearer sk-1234"}
|
||||
response = client.get(
|
||||
"/tag/list?start_date=2026-04-01&end_date=2026-04-29",
|
||||
headers=headers,
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
group_by_mock.assert_awaited_once()
|
||||
where = group_by_mock.await_args.kwargs["where"]
|
||||
assert where["tag"] == {"not": None}
|
||||
assert where["date"] == {"gte": "2026-04-01", "lte": "2026-04-29"}
|
||||
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tags_without_date_range_omits_date_filter():
|
||||
"""When no date range is passed, the WHERE clause must not carry a date key."""
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
|
||||
mock_user_auth = UserAPIKeyAuth(
|
||||
user_id="test-user-123",
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
)
|
||||
app.dependency_overrides[user_api_key_auth] = lambda: mock_user_auth
|
||||
|
||||
try:
|
||||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
|
||||
mock_db = Mock()
|
||||
mock_prisma.db = mock_db
|
||||
mock_db.litellm_tagtable.find_many = AsyncMock(return_value=[])
|
||||
group_by_mock = AsyncMock(return_value=[])
|
||||
mock_db.litellm_dailytagspend.group_by = group_by_mock
|
||||
|
||||
headers = {"Authorization": "Bearer sk-1234"}
|
||||
response = client.get("/tag/list", headers=headers)
|
||||
|
||||
assert response.status_code == 200
|
||||
where = group_by_mock.await_args.kwargs["where"]
|
||||
assert "date" not in where
|
||||
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"query, expected_detail_fragment",
|
||||
[
|
||||
("?start_date=2026-04-01", "must be provided together"),
|
||||
("?end_date=2026-04-29", "must be provided together"),
|
||||
("?start_date=2026-04-29&end_date=2026-04-01", "on or before end_date"),
|
||||
("?start_date=not-a-date&end_date=2026-04-29", "YYYY-MM-DD"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tags_rejects_invalid_date_range(query, expected_detail_fragment):
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
|
||||
mock_user_auth = UserAPIKeyAuth(
|
||||
user_id="test-user-123",
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
)
|
||||
app.dependency_overrides[user_api_key_auth] = lambda: mock_user_auth
|
||||
|
||||
try:
|
||||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
|
||||
mock_db = Mock()
|
||||
mock_prisma.db = mock_db
|
||||
mock_db.litellm_tagtable.find_many = AsyncMock(return_value=[])
|
||||
mock_db.litellm_dailytagspend.group_by = AsyncMock(return_value=[])
|
||||
|
||||
headers = {"Authorization": "Bearer sk-1234"}
|
||||
response = client.get(f"/tag/list{query}", headers=headers)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert expected_detail_fragment in response.json()["detail"]
|
||||
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_deployments_by_model_id():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -145,23 +145,6 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
const [topKeysLimit, setTopKeysLimit] = useState<number>(5);
|
||||
const [topModelsLimit, setTopModelsLimit] = useState<number>(5);
|
||||
const [showTokenBreakdown, setShowTokenBreakdown] = useState(false);
|
||||
const getAllTags = async () => {
|
||||
if (!accessToken) {
|
||||
return;
|
||||
}
|
||||
const tags = await tagListCall(accessToken);
|
||||
setAllTags(
|
||||
Object.values(tags).map((tag: Tag) => ({
|
||||
label: tag.name,
|
||||
value: tag.name,
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getAllTags();
|
||||
}, [accessToken]);
|
||||
|
||||
// Sync selectedUserId when auth state settles (isAdmin/userID may be null on initial render)
|
||||
useEffect(() => {
|
||||
if (!isAdmin && userID) {
|
||||
|
|
@ -175,6 +158,24 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
|
|||
const startTime = useMemo(() => (dateValue.from ? new Date(dateValue.from) : null), [dateValue.from]);
|
||||
const endTime = useMemo(() => (dateValue.to ? new Date(dateValue.to) : null), [dateValue.to]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const tags = await tagListCall(accessToken, startTime, endTime);
|
||||
if (cancelled) return;
|
||||
setAllTags(
|
||||
Object.values(tags).map((tag: Tag) => ({
|
||||
label: tag.name,
|
||||
value: tag.name,
|
||||
})),
|
||||
);
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [accessToken, startTime, endTime]);
|
||||
|
||||
// Try aggregated endpoint first, fall back to paginated on failure
|
||||
const aggregatedFetchIdRef = useRef(0);
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -7288,10 +7288,29 @@ export const tagInfoCall = async (accessToken: string, tagNames: string[]): Prom
|
|||
}
|
||||
};
|
||||
|
||||
export const tagListCall = async (accessToken: string): Promise<TagListResponse> => {
|
||||
const formatYmd = (value: Date): string => {
|
||||
const year = value.getFullYear();
|
||||
const month = String(value.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(value.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
export const tagListCall = async (
|
||||
accessToken: string,
|
||||
startTime?: Date | null,
|
||||
endTime?: Date | null,
|
||||
): Promise<TagListResponse> => {
|
||||
try {
|
||||
let url = proxyBaseUrl ? `${proxyBaseUrl}/tag/list` : `/tag/list`;
|
||||
|
||||
if (startTime && endTime) {
|
||||
const params = new URLSearchParams({
|
||||
start_date: formatYmd(startTime),
|
||||
end_date: formatYmd(endTime),
|
||||
});
|
||||
url = `${url}?${params.toString()}`;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue