This commit is contained in:
Prajjwal Chittori 2026-09-23 14:52:32 +00:00 • committed by GitHub
commit 91452809cf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 408 additions and 46 deletions

View file

@ -843,6 +843,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}",
@ -867,6 +868,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",
]
)
@ -985,6 +987,7 @@ class LiteLLMRoutes(enum.Enum):
"/team/daily/activity",
"/team/daily/activity/aggregated",
"/tag/daily/activity",
"/tag/daily/activity/aggregated",
"/tag/list",
"/audit",
"/audit/{id}",

View file

@ -560,6 +560,26 @@ async def get_api_key_metadata(
return await attach_user_emails(prisma_client, combined)
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,

View file

@ -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(
@ -485,7 +488,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(
@ -574,7 +577,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(
@ -685,7 +688,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(
@ -722,7 +725,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(
@ -786,3 +789,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,
)

View file

@ -124,6 +124,7 @@ from litellm.proxy.hooks.model_max_budget_limiter import (
resolve_model_budget,
)
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 (
@ -6701,26 +6702,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,
@ -6763,7 +6744,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)

View file

@ -1513,3 +1513,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()

View file

@ -52,6 +52,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(),
@ -139,6 +140,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);
@ -441,6 +443,7 @@ describe("EntityUsage", () => {
beforeEach(() => {
mockTagDailyActivityCall.mockClear();
mockTagDailyActivityAggregatedCall.mockClear();
mockTeamDailyActivityCall.mockClear();
mockTeamDailyActivityAggregatedCall.mockClear();
mockOrganizationDailyActivityCall.mockClear();
@ -448,6 +451,7 @@ describe("EntityUsage", () => {
mockAgentDailyActivityCall.mockClear();
mockUserDailyActivityCall.mockClear();
mockTagDailyActivityCall.mockResolvedValue(mockSpendData);
mockTagDailyActivityAggregatedCall.mockResolvedValue(mockSpendData);
mockTeamDailyActivityCall.mockResolvedValue(mockSpendData);
mockTeamDailyActivityAggregatedCall.mockResolvedValue(mockSpendData);
mockOrganizationDailyActivityCall.mockResolvedValue(mockSpendData);
@ -516,7 +520,7 @@ describe("EntityUsage", () => {
render(<EntityUsage {...defaultProps} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect(screen.getByText("Tag Spend Overview")).toBeInTheDocument();
@ -630,7 +634,7 @@ describe("EntityUsage", () => {
render(<EntityUsage {...defaultProps} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect(screen.getByText("Tag Spend Overview")).toBeInTheDocument();
@ -696,7 +700,7 @@ describe("EntityUsage", () => {
render(<EntityUsage {...defaultProps} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
act(() => {
@ -748,12 +752,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();
@ -766,7 +770,7 @@ describe("EntityUsage", () => {
render(<EntityUsage {...defaultProps} entityType="tag" />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect(screen.getByText("Model Activity")).toBeInTheDocument();
@ -786,7 +790,7 @@ describe("EntityUsage", () => {
render(<EntityUsage {...defaultProps} entityType="tag" />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect(screen.getByText("Top Public Model Names")).toBeInTheDocument();
@ -796,7 +800,7 @@ describe("EntityUsage", () => {
const { container } = render(<EntityUsage {...defaultProps} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
act(() => {
@ -837,7 +841,7 @@ describe("EntityUsage", () => {
render(<EntityUsage {...defaultProps} entityList={customEntityList} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
await waitFor(() => {
@ -851,7 +855,7 @@ describe("EntityUsage", () => {
render(<EntityUsage {...defaultProps} entityList={customEntityList} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
await waitFor(() => {
@ -863,7 +867,7 @@ describe("EntityUsage", () => {
render(<EntityUsage {...defaultProps} entityList={null} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
await waitFor(() => {
@ -905,7 +909,7 @@ describe("EntityUsage", () => {
render(<EntityUsage {...defaultProps} entityType="tag" />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect(screen.queryByText("Agent Activity")).not.toBeInTheDocument();
@ -925,7 +929,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();
@ -949,7 +953,7 @@ describe("EntityUsage", () => {
render(<EntityUsage {...defaultProps} entityType="tag" />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
expect(mockAgentDailyActivityCall).not.toHaveBeenCalled();
@ -991,12 +995,12 @@ describe("EntityUsage", () => {
],
};
mockTagDailyActivityCall.mockResolvedValue(spendDataWithoutAlias);
mockTagDailyActivityAggregatedCall.mockResolvedValue(spendDataWithoutAlias);
render(<EntityUsage {...defaultProps} entityList={null} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
await waitFor(() => {
@ -1008,7 +1012,7 @@ describe("EntityUsage", () => {
const { container } = render(<EntityUsage {...defaultProps} />);
await waitFor(() => {
expect(mockTagDailyActivityCall).toHaveBeenCalled();
expect(mockTagDailyActivityAggregatedCall).toHaveBeenCalled();
});
await waitFor(() => {
@ -1128,7 +1132,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();
});
@ -1149,7 +1153,7 @@ describe("EntityUsage", () => {
},
],
};
mockTagDailyActivityCall.mockResolvedValue(spendDataUnknownProvider);
mockTagDailyActivityAggregatedCall.mockResolvedValue(spendDataUnknownProvider);
render(<EntityUsage {...defaultProps} />);
@ -1213,6 +1217,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"));

View file

@ -31,6 +31,7 @@ import {
agentDailyActivityCall,
customerDailyActivityCall,
organizationDailyActivityCall,
tagDailyActivityAggregatedCall,
tagDailyActivityCall,
teamDailyActivityAggregatedCall,
teamDailyActivityCall,
@ -105,6 +106,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,
};

View file

@ -1418,6 +1418,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,

View file

@ -15154,6 +15154,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;
@ -61777,6 +61813,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?: {