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:
Bytechoreographer 2026-04-29 20:50:17 +08:00
parent 6e6b2ca2d8
commit 08b8ecab5c
4 changed files with 196 additions and 21 deletions

View file

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

View file

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

View file

@ -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(() => {

View file

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