Merge pull request #2 from sarvika/litellm_project_filter_usage_page

fix: UI/Linting fixes resolved
This commit is contained in:
Jay 2026-09-11 16:46:01 +05:30 committed by GitHub
commit fd6972beda
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 91 additions and 59 deletions

View file

@ -4263,7 +4263,7 @@ async def test_ui_view_spend_logs_with_error_message(client):
@pytest.mark.asyncio
async def test_ui_view_spend_logs_with_project_id(client):
async def test_ui_view_spend_logs_with_project_id(client, monkeypatch):
"""Test filtering spend logs by project_id"""
mock_spend_logs = [
{
@ -4302,30 +4302,27 @@ async def test_ui_view_spend_logs_with_project_id(client):
user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin_user"
)
monkeypatch.setattr(ps, "prisma_client", make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_project_id))
try:
with patch.object(
ps,
"prisma_client",
make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_project_id),
):
start_date, end_date = _default_date_range()
start_date, end_date = _default_date_range()
response = client.get(
"/spend/logs/ui",
params={
"project_id": "project-1",
"start_date": start_date,
"end_date": end_date,
},
headers={"Authorization": "Bearer sk-test"},
)
response = client.get(
"/spend/logs/ui",
params={
"project_id": "project-1",
"start_date": start_date,
"end_date": end_date,
},
headers={"Authorization": "Bearer sk-test"},
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert len(data["data"]) == 1
assert data["data"][0]["id"] == "log1"
assert data["data"][0]["metadata"]["user_api_key_project_id"] == "project-1"
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert len(data["data"]) == 1
assert data["data"][0]["id"] == "log1"
assert data["data"][0]["metadata"]["user_api_key_project_id"] == "project-1"
finally:
app.dependency_overrides.pop(ps.user_api_key_auth, None)

View file

@ -4,6 +4,16 @@ import { createQueryKeys } from "../common/queryKeysFactory";
const uiSettingsKeys = createQueryKeys("uiSettings");
export interface UISettingsFieldSchema {
description?: string;
properties?: Record<string, { description?: string; type?: string }>;
}
export interface UISettingsData {
field_schema: UISettingsFieldSchema;
values: Record<string, unknown>;
}
/**
* UI settings, cached for an hour by default because they rarely change.
*
@ -14,11 +24,12 @@ const uiSettingsKeys = createQueryKeys("uiSettings");
* so a caller that needs to notice a change also has to poll.
*/
export const useUISettings = (options?: { staleTime?: number; refetchInterval?: number }) => {
return useQuery<Record<string, any>>({
const queryOptions = {
queryKey: uiSettingsKeys.list({}),
queryFn: async () => await getUiSettings(),
staleTime: options?.staleTime ?? 60 * 60 * 1000,
gcTime: 60 * 60 * 1000, // 1 hour - keep in cache for 1 hour
refetchInterval: options?.refetchInterval,
});
};
return useQuery<UISettingsData>(queryOptions);
};

View file

@ -35,7 +35,7 @@ import {
userDailyActivityCall,
} from "@/components/networking";
import { Logo } from "@/components/molecules/logo/Logo";
import { usePaginatedDailyActivity } from "../../hooks/usePaginatedDailyActivity";
import { usePaginatedDailyActivity, type FetchPageFn } from "../../hooks/usePaginatedDailyActivity";
import { EntityMetricWithMetadata } from "@/components/UsagePage/types";
import { valueFormatterSpend } from "@/components/UsagePage/utils/value_formatters";
import EndpointUsage from "../EndpointUsage/EndpointUsage";
@ -44,6 +44,15 @@ import TopKeyView from "@/components/UsagePage/components/EntityUsage/TopKeyView
import TopModelView from "./TopModelView";
import TeamUserSpendCard from "./TeamUserSpendCard";
/** The entity metadata shape actually probed by getEntityLabel: whichever of these
* fields the backend populated for a given entity type (team, user, ...). */
interface EntityBreakdownMetadata {
team_alias?: string;
user_email?: string;
user_alias?: string;
[key: string]: unknown;
}
interface EntityMetrics {
metrics: {
spend: number;
@ -56,7 +65,7 @@ interface EntityMetrics {
failed_requests: number;
api_requests: number;
};
metadata: Record<string, any>;
metadata: EntityBreakdownMetadata;
}
interface EntitySpendData {
@ -88,7 +97,7 @@ interface EntityUsageProps {
isOrgAdmin?: boolean;
}
const ENTITY_FETCH_FNS: Record<EntityType, (...args: any[]) => Promise<any>> = {
const ENTITY_FETCH_FNS: Record<EntityType, FetchPageFn> = {
tag: tagDailyActivityCall,
team: teamDailyActivityCall,
organization: organizationDailyActivityCall,
@ -99,7 +108,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>>> = {
const ENTITY_AGGREGATED_FETCH_FNS: Partial<Record<EntityType, FetchPageFn>> = {
team: teamDailyActivityAggregatedCall,
};
@ -141,18 +150,19 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
const hasRequestWindow = !!accessToken && !!startTime && !!endTime;
const enabled = hasRequestWindow && canViewEntity;
const entityPaginatedActivityOptions = {
fetchFn,
args: [accessToken, startTime, endTime, entityFilterArg],
enabled,
aggregatedFetchFn,
};
const {
data: spendDataRaw,
isFetchingMore,
progress,
cancelled,
cancel,
} = usePaginatedDailyActivity({
fetchFn,
args: [accessToken, startTime, endTime, entityFilterArg],
enabled,
aggregatedFetchFn,
});
} = usePaginatedDailyActivity(entityPaginatedActivityOptions);
const spendData = spendDataRaw as unknown as EntitySpendData;
@ -181,7 +191,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
}
};
const getEntityLabel = (entity: string, metadata?: Record<string, any>): string => {
const getEntityLabel = (entity: string, metadata?: EntityBreakdownMetadata): string => {
if (entityList) {
const entityItem = entityList.find((item) => item.value === entity);
if (entityItem) {
@ -226,7 +236,7 @@ const EntityUsage: React.FC<EntityUsageProps> = ({
cache_creation_input_tokens: 0,
},
metadata: {
alias: getEntityLabel(entity, data.metadata as any),
alias: getEntityLabel(entity, data.metadata as EntityBreakdownMetadata),
id: entity,
},
};

View file

@ -20,13 +20,15 @@ const row = (overrides: Partial<ProjectDailySpendRow> = {}): ProjectDailySpendRo
describe("summarizeProjectUsage", () => {
it("returns all-zero totals for no rows", () => {
expect(summarizeProjectUsage([])).toEqual({
const zeroTotals = {
total_spend: 0,
total_api_requests: 0,
total_successful_requests: 0,
total_failed_requests: 0,
total_tokens: 0,
});
};
expect(summarizeProjectUsage([])).toEqual(zeroTotals);
});
it("sums spend, requests, and tokens across every row regardless of project or date", () => {
@ -40,14 +42,15 @@ describe("summarizeProjectUsage", () => {
total_tokens: 40,
};
const rows = [row({ spend: 1.5 }), row(projectBetaRow)];
expect(summarizeProjectUsage(rows)).toEqual({
const expectedTotals = {
total_spend: 3.75,
total_api_requests: 7,
total_successful_requests: 6,
total_failed_requests: 1,
total_tokens: 55,
});
};
expect(summarizeProjectUsage(rows)).toEqual(expectedTotals);
});
});

View file

@ -66,6 +66,14 @@ const groupByProjectId = (rows: ProjectDailySpendRow[]): ProjectDailySpendRow[][
return [...groups.values()];
};
const EMPTY_PROJECT_GROUP_TOTALS = {
spend: 0,
requests: 0,
successful_requests: 0,
failed_requests: 0,
tokens: 0,
};
const summarizeProjectGroup = (rows: ProjectDailySpendRow[]): ProjectSpendRow => {
const [{ project_id, project_alias }] = rows;
const totals = rows.reduce(
@ -76,7 +84,7 @@ const summarizeProjectGroup = (rows: ProjectDailySpendRow[]): ProjectSpendRow =>
failed_requests: acc.failed_requests + row.failed_requests,
tokens: acc.tokens + row.total_tokens,
}),
{ spend: 0, requests: 0, successful_requests: 0, failed_requests: 0, tokens: 0 },
EMPTY_PROJECT_GROUP_TOTALS,
);
return { project_id, project_alias: project_alias || project_id, ...totals };
};

View file

@ -124,9 +124,9 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const [isGlobalExportModalOpen, setIsGlobalExportModalOpen] = useState(false);
const [isAiChatOpen, setIsAiChatOpen] = useState(false);
const [selectedUsageView, setUsageView] = useState<UsageOption>("global");
const stillHasAccessToSelectedView =
(selectedUsageView !== "organization" || canViewOrganizationUsage) &&
(selectedUsageView !== "project" || canViewProjectUsage);
const hasOrganizationAccessIfSelected = selectedUsageView !== "organization" || canViewOrganizationUsage;
const hasProjectAccessIfSelected = selectedUsageView !== "project" || canViewProjectUsage;
const stillHasAccessToSelectedView = hasOrganizationAccessIfSelected && hasProjectAccessIfSelected;
const usageView: UsageOption = stillHasAccessToSelectedView ? selectedUsageView : "global";
const [showCredentialBanner, setShowCredentialBanner] = useState(true);
@ -238,10 +238,12 @@ const UsagePage: React.FC<UsagePageProps> = ({ teams, organizations }) => {
const aggregatedFailed = selectForRange(aggregatedFailure, currentAggregatedRangeKey) === true;
// Paginated fallback — only enabled when aggregated endpoint fails
const hasRequestWindow = !!accessToken && !!startTime && !!endTime;
const hasPaginatedFallbackRequestWindow = aggregatedFailed && hasRequestWindow;
const paginatedResult = usePaginatedDailyActivity({
fetchFn: userDailyActivityCall,
args: [accessToken, startTime, endTime, effectiveUserId],
enabled: aggregatedFailed && !!accessToken && !!startTime && !!endTime,
enabled: hasPaginatedFallbackRequestWindow,
});
// Derive userSpendData from whichever source is active

View file

@ -71,15 +71,18 @@ describe("UsageViewSelect", () => {
},
);
it.each(["Organization Usage", "Agent Usage (A2A)", "Project Usage"])("should hide %s from an internal user", async (optionName) => {
const user = userEvent.setup();
const { container } = render(
<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" canViewTagUsage={true} />,
);
it.each(["Organization Usage", "Agent Usage (A2A)", "Project Usage"])(
"should hide %s from an internal user",
async (optionName) => {
const user = userEvent.setup();
const { container } = render(
<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" canViewTagUsage={true} />,
);
await openMenu(user);
expect(offers(container, optionName)).toBe(false);
});
await openMenu(user);
expect(offers(container, optionName)).toBe(false);
},
);
// An org admin's session role is "Internal User" — org-admin-ness lives in the
// membership table — so the two rows above cannot tell them apart from a plain

View file

@ -32,12 +32,12 @@ const SUMMABLE_METADATA_KEYS = [
"total_flat_cost",
] as const;
interface DailyActivityResponse {
export interface DailyActivityResponse {
results: DailyData[];
metadata: Record<string, any>;
}
type FetchPageFn = (...args: any[]) => Promise<DailyActivityResponse>;
export type FetchPageFn = (...args: any[]) => Promise<DailyActivityResponse>;
interface UsePaginatedDailyActivityParams {
/** The API call function (e.g., userDailyActivityCall). */

View file

@ -87,9 +87,7 @@ describe("RequestLogsFilters", () => {
vi.mocked(useInfiniteSpendLogEndUsers).mockReturnValue(
emptyInfiniteQuery as unknown as ReturnType<typeof useInfiniteSpendLogEndUsers>,
);
vi.mocked(useProjects).mockReturnValue({ data: [], isLoading: false } as unknown as ReturnType<
typeof useProjects
>);
vi.mocked(useProjects).mockReturnValue({ data: [], isLoading: false } as unknown as ReturnType<typeof useProjects>);
vi.mocked(useUISettings).mockReturnValue({
data: { values: { enable_projects_ui: true } },
} as unknown as ReturnType<typeof useUISettings>);