mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
Merge pull request #2 from sarvika/litellm_project_filter_usage_page
fix: UI/Linting fixes resolved
This commit is contained in:
commit
fd6972beda
9 changed files with 91 additions and 59 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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>);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue