mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-29 01:42:19 +00:00
fix: resolve BerriAI/litellm#37434 - [Bug]: inflation/drift in /tag/daily/activity for custom-header tag metrics
Signed-off-by: pjdurden <prajjwalchittori1@gmail.com>
This commit is contained in:
parent
ca0b951a43
commit
952dbce6ca
9 changed files with 408 additions and 46 deletions
|
|
@ -784,6 +784,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
# Tag usage endpoints scope internal users to tags produced by
|
||||
# their own keys in tag_management_endpoints.py.
|
||||
"/tag/daily/activity",
|
||||
"/tag/daily/activity/aggregated",
|
||||
"/tag/list",
|
||||
"/v1/models/{model_id}",
|
||||
"/models/{model_id}",
|
||||
|
|
@ -808,6 +809,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
# Tag usage endpoints scope internal viewers to tags produced by
|
||||
# their own keys in tag_management_endpoints.py.
|
||||
"/tag/daily/activity",
|
||||
"/tag/daily/activity/aggregated",
|
||||
"/tag/list",
|
||||
]
|
||||
)
|
||||
|
|
@ -895,6 +897,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/team/daily/activity",
|
||||
"/team/daily/activity/aggregated",
|
||||
"/tag/daily/activity",
|
||||
"/tag/daily/activity/aggregated",
|
||||
"/tag/list",
|
||||
"/audit",
|
||||
"/audit/{id}",
|
||||
|
|
|
|||
|
|
@ -482,6 +482,26 @@ async def get_api_key_metadata(
|
|||
return result
|
||||
|
||||
|
||||
MAX_AGGREGATED_RANGE_DAYS: Final = 400
|
||||
|
||||
|
||||
def aggregated_date_range_error(start_date: str | None, end_date: str | None) -> str | None:
|
||||
"""The aggregated endpoints have no pagination to bound their work, so malformed
|
||||
dates and ranges wider than the UI ever requests are rejected before querying."""
|
||||
if start_date is None or end_date is None:
|
||||
return "Please provide start_date and end_date"
|
||||
try:
|
||||
parsed_start: Final = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||
parsed_end: Final = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return "start_date and end_date must be valid YYYY-MM-DD dates"
|
||||
if parsed_end < parsed_start:
|
||||
return "end_date must be on or after start_date"
|
||||
if (parsed_end - parsed_start).days > MAX_AGGREGATED_RANGE_DAYS:
|
||||
return f"Date range must be at most {MAX_AGGREGATED_RANGE_DAYS} days"
|
||||
return None
|
||||
|
||||
|
||||
def _adjust_dates_for_timezone(
|
||||
start_date: str,
|
||||
end_date: str,
|
||||
|
|
|
|||
|
|
@ -27,7 +27,9 @@ from litellm.proxy.common_utils.user_api_key_cache import (
|
|||
)
|
||||
from litellm.proxy.management_endpoints.common_daily_activity import (
|
||||
SpendAnalyticsPaginatedResponse,
|
||||
aggregated_date_range_error,
|
||||
get_daily_activity,
|
||||
get_daily_activity_aggregated,
|
||||
)
|
||||
from litellm.proxy.management_helpers.utils import handle_budget_for_entity
|
||||
from litellm.repositories.model_repository import ModelRepository
|
||||
|
|
@ -57,6 +59,7 @@ if TYPE_CHECKING:
|
|||
from litellm.types.router import Deployment
|
||||
|
||||
router: Final = APIRouter()
|
||||
_TAG_MANAGEMENT_TAGS: Final = ["tag management"]
|
||||
|
||||
|
||||
class _TagRecord(Protocol):
|
||||
|
|
@ -245,7 +248,7 @@ async def get_deployments_by_model(model: str, llm_router: "Router") -> list["De
|
|||
|
||||
@router.post(
|
||||
"/tag/new",
|
||||
tags=["tag management"],
|
||||
tags=_TAG_MANAGEMENT_TAGS,
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def new_tag(
|
||||
|
|
@ -393,7 +396,7 @@ async def _add_tag_to_deployment(deployment: "Deployment", tag: str):
|
|||
|
||||
@router.post(
|
||||
"/tag/update",
|
||||
tags=["tag management"],
|
||||
tags=_TAG_MANAGEMENT_TAGS,
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def update_tag(
|
||||
|
|
@ -484,7 +487,7 @@ async def update_tag(
|
|||
|
||||
@router.post(
|
||||
"/tag/info",
|
||||
tags=["tag management"],
|
||||
tags=_TAG_MANAGEMENT_TAGS,
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def info_tag(
|
||||
|
|
@ -573,7 +576,7 @@ def _validate_tag_list_date_range(start_date: str | None, end_date: str | None)
|
|||
|
||||
@router.get(
|
||||
"/tag/list",
|
||||
tags=["tag management"],
|
||||
tags=_TAG_MANAGEMENT_TAGS,
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def list_tags(
|
||||
|
|
@ -684,7 +687,7 @@ async def list_tags(
|
|||
|
||||
@router.post(
|
||||
"/tag/delete",
|
||||
tags=["tag management"],
|
||||
tags=_TAG_MANAGEMENT_TAGS,
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def delete_tag(
|
||||
|
|
@ -721,7 +724,7 @@ async def delete_tag(
|
|||
@router.get(
|
||||
"/tag/daily/activity",
|
||||
response_model=SpendAnalyticsPaginatedResponse,
|
||||
tags=["tag management"],
|
||||
tags=_TAG_MANAGEMENT_TAGS,
|
||||
dependencies=[Depends(user_api_key_auth)],
|
||||
)
|
||||
async def get_tag_daily_activity(
|
||||
|
|
@ -785,3 +788,69 @@ async def get_tag_daily_activity(
|
|||
# individual tags, making this trade-off acceptable.
|
||||
metadata_metrics_func=None,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/tag/daily/activity/aggregated",
|
||||
response_model=SpendAnalyticsPaginatedResponse,
|
||||
tags=_TAG_MANAGEMENT_TAGS,
|
||||
)
|
||||
async def get_tag_daily_activity_aggregated(
|
||||
tags: str | None = None,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
model: str | None = None,
|
||||
api_key: str | None = None,
|
||||
timezone: int | None = None,
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
):
|
||||
"""
|
||||
Daily activity for tags over the whole range in one response, with per-tag breakdown.
|
||||
|
||||
One SQL GROUPING SETS pass reads the range from a single snapshot, so callers never
|
||||
walk pages of LiteLLM_DailyTagSpend and never re-count rows that the tag spend
|
||||
scheduler shifted across a page boundary mid-walk. Same response shape as the
|
||||
paginated endpoint with page metadata pinned to a single page.
|
||||
|
||||
Args:
|
||||
tags (Optional[str]): Comma-separated list of tags to filter by. If not provided, returns data for all tags.
|
||||
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).
|
||||
model (Optional[str]): Filter by model name.
|
||||
api_key (Optional[str]): Filter by API key.
|
||||
timezone (Optional[int]): Timezone offset in minutes from UTC, matching JavaScript's Date.getTimezoneOffset() convention.
|
||||
|
||||
Returns:
|
||||
SpendAnalyticsPaginatedResponse: Response containing all daily activity data for the range.
|
||||
"""
|
||||
from litellm.proxy.proxy_server import prisma_client
|
||||
|
||||
if prisma_client is None:
|
||||
raise HTTPException(status_code=500, detail="Database not connected")
|
||||
|
||||
range_error: Final = aggregated_date_range_error(start_date, end_date)
|
||||
if range_error is not None:
|
||||
raise HTTPException(status_code=400, detail=range_error)
|
||||
|
||||
tag_list: Final = tags.split(",") if tags else None
|
||||
scoped_api_key_filter: Final = await _get_tag_daily_activity_api_key_filter(
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
requested_api_key=api_key,
|
||||
)
|
||||
if scoped_api_key_filter == []:
|
||||
return SpendAnalyticsPaginatedResponse(results=[])
|
||||
|
||||
return await get_daily_activity_aggregated(
|
||||
prisma_client=prisma_client,
|
||||
table_name="litellm_dailytagspend",
|
||||
entity_id_field="tag",
|
||||
entity_id=tag_list,
|
||||
entity_metadata_field=None,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
model=model,
|
||||
api_key=scoped_api_key_filter,
|
||||
timezone_offset_minutes=timezone,
|
||||
include_entity_breakdown=True,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ from litellm.proxy.common_utils.callback_utils import encrypt_callback_vars
|
|||
from litellm.proxy.common_utils.json_merge_patch import apply_json_merge_patch
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
from litellm.proxy.management_endpoints.common_daily_activity import (
|
||||
aggregated_date_range_error,
|
||||
get_daily_activity_aggregated,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
|
|
@ -6116,26 +6117,6 @@ async def get_team_daily_activity(
|
|||
)
|
||||
|
||||
|
||||
_MAX_AGGREGATED_RANGE_DAYS: Final = 400
|
||||
|
||||
|
||||
def _aggregated_date_range_error(start_date: str | None, end_date: str | None) -> str | None:
|
||||
"""The aggregated endpoint has no pagination to bound its work, so malformed
|
||||
dates and ranges wider than the UI ever requests are rejected before querying."""
|
||||
if start_date is None or end_date is None:
|
||||
return "Please provide start_date and end_date"
|
||||
try:
|
||||
parsed_start: Final = datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||
parsed_end: Final = datetime.strptime(end_date, "%Y-%m-%d").replace(tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return "start_date and end_date must be valid YYYY-MM-DD dates"
|
||||
if parsed_end < parsed_start:
|
||||
return "end_date must be on or after start_date"
|
||||
if (parsed_end - parsed_start).days > _MAX_AGGREGATED_RANGE_DAYS:
|
||||
return f"Date range must be at most {_MAX_AGGREGATED_RANGE_DAYS} days"
|
||||
return None
|
||||
|
||||
|
||||
@router.get(
|
||||
"/team/daily/activity/aggregated",
|
||||
response_model=SpendAnalyticsPaginatedResponse,
|
||||
|
|
@ -6178,7 +6159,7 @@ async def get_team_daily_activity_aggregated(
|
|||
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)
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -1330,3 +1330,162 @@ async def test_add_tag_to_deployment_model_not_found():
|
|||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert "not found in database" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
def _grouping_set_row(*, group_level: int, date: str | None = None, spend: float = 0.0, **overrides: object) -> dict:
|
||||
"""One already-aggregated rollup row as ``LiteLLM_DailyTagSpend``'s GROUPING SETS query emits it."""
|
||||
return {
|
||||
"date": date,
|
||||
"api_key": None,
|
||||
"model": None,
|
||||
"model_group": None,
|
||||
"custom_llm_provider": None,
|
||||
"mcp_namespaced_tool_name": None,
|
||||
"endpoint": None,
|
||||
"group_level": group_level,
|
||||
"spend": spend,
|
||||
"ptu_flat_cost": 0.0,
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"cache_read_input_tokens": 0,
|
||||
"cache_creation_input_tokens": 0,
|
||||
"compression_saved_tokens": 0,
|
||||
"compression_savings_spend": 0.0,
|
||||
"prompt_caching_savings_spend": 0.0,
|
||||
"gateway_injected_caching_savings_spend": 0.0,
|
||||
"autorouter_savings_spend": 0.0,
|
||||
"api_requests": 0,
|
||||
"successful_requests": 0,
|
||||
"failed_requests": 0,
|
||||
**overrides,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_daily_activity_aggregated_returns_range_totals_without_paginating():
|
||||
"""Regression for #37434.
|
||||
|
||||
``/tag/daily/activity`` paginates raw ``LiteLLM_DailyTagSpend`` rows, so a caller
|
||||
after range totals has to walk every page and add them up. The tag spend scheduler
|
||||
inserts rows while that walk is in flight, shifting rows past the caller's offset so
|
||||
they get served twice and the summed total drifts upward between identical reads.
|
||||
The aggregated endpoint sums in the database against a single snapshot, so its
|
||||
``metadata`` already carries the whole range and there are no pages to reassemble.
|
||||
"""
|
||||
from litellm.proxy.management_endpoints.tag_management_endpoints import (
|
||||
get_tag_daily_activity_aggregated,
|
||||
)
|
||||
|
||||
mock_user_auth = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
rollup_rows = [
|
||||
_grouping_set_row(group_level=127, spend=7.5, api_requests=3),
|
||||
_grouping_set_row(group_level=63, date="2025-01-01", spend=5.0, api_requests=2),
|
||||
_grouping_set_row(group_level=63, date="2025-01-02", spend=2.5, api_requests=1),
|
||||
]
|
||||
entity_rows = [
|
||||
{**_grouping_set_row(group_level=0, date="2025-01-01", spend=5.0), "entity_id": "prod", "api_key_rolled": 1},
|
||||
{**_grouping_set_row(group_level=0, date="2025-01-02", spend=2.5), "entity_id": "prod", "api_key_rolled": 1},
|
||||
]
|
||||
|
||||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
|
||||
mock_db = Mock()
|
||||
mock_prisma.db = mock_db
|
||||
mock_db.query_raw = AsyncMock(side_effect=[rollup_rows, entity_rows])
|
||||
|
||||
result = await get_tag_daily_activity_aggregated(
|
||||
tags="prod",
|
||||
start_date="2025-01-01",
|
||||
end_date="2025-01-02",
|
||||
user_api_key_dict=mock_user_auth,
|
||||
)
|
||||
|
||||
mock_db.litellm_dailytagspend.find_many.assert_not_called()
|
||||
mock_db.litellm_dailytagspend.count.assert_not_called()
|
||||
|
||||
queries = [call.args[0] for call in mock_db.query_raw.call_args_list]
|
||||
assert len(queries) == 2
|
||||
assert all('"LiteLLM_DailyTagSpend"' in query for query in queries)
|
||||
assert all("LIMIT" not in query and "OFFSET" not in query for query in queries)
|
||||
|
||||
assert result.metadata.total_spend == 7.5
|
||||
assert result.metadata.total_api_requests == 3
|
||||
assert result.metadata.page == 1
|
||||
assert result.metadata.total_pages == 1
|
||||
assert result.metadata.has_more is False
|
||||
assert [day.date.isoformat() for day in result.results] == ["2025-01-02", "2025-01-01"]
|
||||
assert result.results[0].breakdown.entities["prod"].metrics.spend == 2.5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_internal_user_tag_daily_activity_aggregated_is_scoped_to_their_keys():
|
||||
"""The aggregated variant must apply the same per-key scoping as the paginated one,
|
||||
so an internal user cannot read proxy-wide tag spend through the new route."""
|
||||
from litellm.proxy.management_endpoints.tag_management_endpoints import (
|
||||
get_tag_daily_activity_aggregated,
|
||||
)
|
||||
|
||||
mock_user_auth = UserAPIKeyAuth(
|
||||
user_id="internal-user-123",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER_VIEW_ONLY,
|
||||
)
|
||||
|
||||
with (
|
||||
patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma,
|
||||
patch(
|
||||
"litellm.proxy.management_endpoints.tag_management_endpoints.get_daily_activity_aggregated",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_aggregated,
|
||||
):
|
||||
mock_db = Mock()
|
||||
mock_prisma.db = mock_db
|
||||
|
||||
owned_key_record = Mock()
|
||||
owned_key_record.token = "owned-key"
|
||||
mock_db.litellm_verificationtoken = FakeVerificationTokenTable([owned_key_record])
|
||||
mock_aggregated.return_value = "aggregated-response"
|
||||
|
||||
result = await get_tag_daily_activity_aggregated(
|
||||
start_date="2025-01-01",
|
||||
end_date="2025-01-31",
|
||||
user_api_key_dict=mock_user_auth,
|
||||
)
|
||||
|
||||
assert result == "aggregated-response"
|
||||
assert mock_aggregated.await_args.kwargs["api_key"] == ["owned-key"]
|
||||
assert mock_aggregated.await_args.kwargs["table_name"] == "litellm_dailytagspend"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"start_date, end_date, expected_detail_fragment",
|
||||
[
|
||||
(None, "2025-01-31", "Please provide start_date and end_date"),
|
||||
("not-a-date", "2025-01-31", "valid YYYY-MM-DD dates"),
|
||||
("2025-02-01", "2025-01-31", "end_date must be on or after start_date"),
|
||||
("2020-01-01", "2026-12-31", "at most 400 days"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_daily_activity_aggregated_rejects_unbounded_ranges(start_date, end_date, expected_detail_fragment):
|
||||
"""Nothing paginates this endpoint, so the range guard is the only bound on its scan."""
|
||||
from litellm.proxy.management_endpoints.tag_management_endpoints import (
|
||||
get_tag_daily_activity_aggregated,
|
||||
)
|
||||
|
||||
mock_user_auth = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
|
||||
with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma:
|
||||
mock_db = Mock()
|
||||
mock_prisma.db = mock_db
|
||||
mock_db.query_raw = AsyncMock()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await get_tag_daily_activity_aggregated(
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
user_api_key_dict=mock_user_auth,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert expected_detail_fragment in exc_info.value.detail
|
||||
mock_db.query_raw.assert_not_awaited()
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ beforeAll(() => {
|
|||
// Mock the networking module
|
||||
vi.mock("@/components/networking", () => ({
|
||||
tagDailyActivityCall: vi.fn(),
|
||||
tagDailyActivityAggregatedCall: vi.fn(),
|
||||
teamDailyActivityCall: vi.fn(),
|
||||
teamDailyActivityAggregatedCall: vi.fn(),
|
||||
organizationDailyActivityCall: vi.fn(),
|
||||
|
|
@ -93,6 +94,7 @@ vi.mock("@/app/(dashboard)/hooks/useTeams", () => ({
|
|||
|
||||
describe("EntityUsage", () => {
|
||||
const mockTagDailyActivityCall = vi.mocked(networking.tagDailyActivityCall);
|
||||
const mockTagDailyActivityAggregatedCall = vi.mocked(networking.tagDailyActivityAggregatedCall);
|
||||
const mockTeamDailyActivityCall = vi.mocked(networking.teamDailyActivityCall);
|
||||
const mockTeamDailyActivityAggregatedCall = vi.mocked(networking.teamDailyActivityAggregatedCall);
|
||||
const mockOrganizationDailyActivityCall = vi.mocked(networking.organizationDailyActivityCall);
|
||||
|
|
@ -395,6 +397,7 @@ describe("EntityUsage", () => {
|
|||
|
||||
beforeEach(() => {
|
||||
mockTagDailyActivityCall.mockClear();
|
||||
mockTagDailyActivityAggregatedCall.mockClear();
|
||||
mockTeamDailyActivityCall.mockClear();
|
||||
mockTeamDailyActivityAggregatedCall.mockClear();
|
||||
mockOrganizationDailyActivityCall.mockClear();
|
||||
|
|
@ -402,6 +405,7 @@ describe("EntityUsage", () => {
|
|||
mockAgentDailyActivityCall.mockClear();
|
||||
mockUserDailyActivityCall.mockClear();
|
||||
mockTagDailyActivityCall.mockResolvedValue(mockSpendData);
|
||||
mockTagDailyActivityAggregatedCall.mockResolvedValue(mockSpendData);
|
||||
mockTeamDailyActivityCall.mockResolvedValue(mockSpendData);
|
||||
mockTeamDailyActivityAggregatedCall.mockResolvedValue(mockSpendData);
|
||||
mockOrganizationDailyActivityCall.mockResolvedValue(mockSpendData);
|
||||
|
|
@ -421,7 +425,7 @@ describe("EntityUsage", () => {
|
|||
render(<EntityUsage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Tag Spend Overview")).toBeInTheDocument();
|
||||
|
|
@ -515,7 +519,7 @@ describe("EntityUsage", () => {
|
|||
render(<EntityUsage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Tag Spend Overview")).toBeInTheDocument();
|
||||
|
|
@ -564,7 +568,7 @@ describe("EntityUsage", () => {
|
|||
render(<EntityUsage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
|
|
@ -616,12 +620,12 @@ describe("EntityUsage", () => {
|
|||
},
|
||||
};
|
||||
|
||||
mockTagDailyActivityCall.mockResolvedValue(emptyData);
|
||||
mockTagDailyActivityAggregatedCall.mockResolvedValue(emptyData);
|
||||
|
||||
render(<EntityUsage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(await screen.findByText("Tag Spend Overview")).toBeInTheDocument();
|
||||
|
|
@ -634,7 +638,7 @@ describe("EntityUsage", () => {
|
|||
render(<EntityUsage {...defaultProps} entityType="tag" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Model Activity")).toBeInTheDocument();
|
||||
|
|
@ -654,7 +658,7 @@ describe("EntityUsage", () => {
|
|||
render(<EntityUsage {...defaultProps} entityType="tag" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.getByText("Top Public Model Names")).toBeInTheDocument();
|
||||
|
|
@ -664,7 +668,7 @@ describe("EntityUsage", () => {
|
|||
const { container } = render(<EntityUsage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
|
|
@ -705,7 +709,7 @@ describe("EntityUsage", () => {
|
|||
render(<EntityUsage {...defaultProps} entityList={customEntityList} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -719,7 +723,7 @@ describe("EntityUsage", () => {
|
|||
render(<EntityUsage {...defaultProps} entityList={customEntityList} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -731,7 +735,7 @@ describe("EntityUsage", () => {
|
|||
render(<EntityUsage {...defaultProps} entityList={null} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -753,7 +757,7 @@ describe("EntityUsage", () => {
|
|||
render(<EntityUsage {...defaultProps} entityType="tag" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.queryByText("Agent Activity")).not.toBeInTheDocument();
|
||||
|
|
@ -773,7 +777,7 @@ describe("EntityUsage", () => {
|
|||
render(<EntityUsage {...defaultProps} entityType="tag" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(screen.queryByText("Top Agents Driving Spend")).not.toBeInTheDocument();
|
||||
|
|
@ -797,7 +801,7 @@ describe("EntityUsage", () => {
|
|||
render(<EntityUsage {...defaultProps} entityType="tag" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(mockAgentDailyActivityCall).not.toHaveBeenCalled();
|
||||
|
|
@ -839,12 +843,12 @@ describe("EntityUsage", () => {
|
|||
],
|
||||
};
|
||||
|
||||
mockTagDailyActivityCall.mockResolvedValue(spendDataWithoutAlias);
|
||||
mockTagDailyActivityAggregatedCall.mockResolvedValue(spendDataWithoutAlias);
|
||||
|
||||
render(<EntityUsage {...defaultProps} entityList={null} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -856,7 +860,7 @@ describe("EntityUsage", () => {
|
|||
const { container } = render(<EntityUsage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
|
@ -976,7 +980,7 @@ describe("EntityUsage", () => {
|
|||
render(<EntityUsage {...defaultProps} entityType="tag" userRole="Internal User" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.getByText("Tag Spend Overview")).toBeInTheDocument();
|
||||
});
|
||||
|
|
@ -997,7 +1001,7 @@ describe("EntityUsage", () => {
|
|||
},
|
||||
],
|
||||
};
|
||||
mockTagDailyActivityCall.mockResolvedValue(spendDataUnknownProvider);
|
||||
mockTagDailyActivityAggregatedCall.mockResolvedValue(spendDataUnknownProvider);
|
||||
|
||||
render(<EntityUsage {...defaultProps} />);
|
||||
|
||||
|
|
@ -1056,6 +1060,33 @@ describe("EntityUsage", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("uses the aggregated tag endpoint and never drains paginated pages for tags", async () => {
|
||||
render(<EntityUsage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
|
||||
});
|
||||
expect(mockTagDailyActivityCall).not.toHaveBeenCalled();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("$100.50").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the paginated tag endpoint when the aggregated call fails", async () => {
|
||||
mockTagDailyActivityAggregatedCall.mockRejectedValue(new Error("aggregated unavailable"));
|
||||
|
||||
render(<EntityUsage {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockTagDailyActivityCall).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("$100.50").length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the paginated team endpoint when the aggregated call fails", async () => {
|
||||
mockTeamDailyActivityAggregatedCall.mockRejectedValue(new Error("aggregated unavailable"));
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import {
|
|||
agentDailyActivityCall,
|
||||
customerDailyActivityCall,
|
||||
organizationDailyActivityCall,
|
||||
tagDailyActivityAggregatedCall,
|
||||
tagDailyActivityCall,
|
||||
teamDailyActivityAggregatedCall,
|
||||
teamDailyActivityCall,
|
||||
|
|
@ -100,6 +101,7 @@ const ENTITY_FETCH_FNS: Record<EntityType, (...args: any[]) => Promise<any>> = {
|
|||
// Single-shot endpoints returning the whole range in one response; entity types
|
||||
// without one fall back to page-draining the paginated endpoint.
|
||||
const ENTITY_AGGREGATED_FETCH_FNS: Partial<Record<EntityType, (...args: any[]) => Promise<any>>> = {
|
||||
tag: tagDailyActivityAggregatedCall,
|
||||
team: teamDailyActivityAggregatedCall,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1451,6 +1451,31 @@ export const tagDailyActivityCall = async (
|
|||
});
|
||||
};
|
||||
|
||||
export const tagDailyActivityAggregatedCall = async (
|
||||
accessToken: string,
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
tags: string[] | null = null,
|
||||
) => {
|
||||
/**
|
||||
* Get aggregated daily tag activity with per-tag breakdown (no pagination)
|
||||
*/
|
||||
try {
|
||||
return await apiClient.get(`/tag/daily/activity/aggregated`, {
|
||||
accessToken,
|
||||
query: {
|
||||
start_date: formatDate(startTime),
|
||||
end_date: formatDate(endTime),
|
||||
timezone: new Date().getTimezoneOffset().toString(),
|
||||
tags: tags && tags.length > 0 ? tags.join(",") : undefined,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch aggregated tag daily activity:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const teamDailyActivityCall = async (
|
||||
accessToken: string,
|
||||
startTime: Date,
|
||||
|
|
|
|||
72
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
72
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -14138,6 +14138,42 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/tag/daily/activity/aggregated": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get Tag Daily Activity Aggregated
|
||||
* @description Daily activity for tags over the whole range in one response, with per-tag breakdown.
|
||||
*
|
||||
* One SQL GROUPING SETS pass reads the range from a single snapshot, so callers never
|
||||
* walk pages of LiteLLM_DailyTagSpend and never re-count rows that the tag spend
|
||||
* scheduler shifted across a page boundary mid-walk. Same response shape as the
|
||||
* paginated endpoint with page metadata pinned to a single page.
|
||||
*
|
||||
* Args:
|
||||
* tags (Optional[str]): Comma-separated list of tags to filter by. If not provided, returns data for all tags.
|
||||
* 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).
|
||||
* model (Optional[str]): Filter by model name.
|
||||
* api_key (Optional[str]): Filter by API key.
|
||||
* timezone (Optional[int]): Timezone offset in minutes from UTC, matching JavaScript's Date.getTimezoneOffset() convention.
|
||||
*
|
||||
* Returns:
|
||||
* SpendAnalyticsPaginatedResponse: Response containing all daily activity data for the range.
|
||||
*/
|
||||
get: operations["get_tag_daily_activity_aggregated_tag_daily_activity_aggregated_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/tag/dau": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -55692,6 +55728,42 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
get_tag_daily_activity_aggregated_tag_daily_activity_aggregated_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
tags?: string | null;
|
||||
start_date?: string | null;
|
||||
end_date?: string | null;
|
||||
model?: string | null;
|
||||
api_key?: 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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
get_daily_active_users_tag_dau_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue