Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ui/litellm-dashboard/e2e_tests/fixtures/migratedPages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const MIGRATED_E2E_PAGES: Record<string, string> = {
agents: "agents",
"router-settings": "router-settings",
users: "users",
organizations: "organizations",
};

export const MIGRATED_E2E_SEGMENTS: string[] = [...new Set(Object.values(MIGRATED_E2E_PAGES))];
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
useModelHub,
useModelsInfo,
useSelectedTeamModels,
useUserModels,
type AllProxyModelsResponse,
type PaginatedModelInfoResponse,
type ProxyModel,
Expand Down Expand Up @@ -75,7 +76,7 @@
React.createElement(QueryClientProvider, { client: queryClient }, children);

it("should render without crashing", () => {
(modelInfoCall as any).mockResolvedValue(mockPaginatedModelInfoResponse);

Check warning on line 79 in ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

const { result } = renderHook(() => useModelsInfo(), { wrapper });

Expand All @@ -83,7 +84,7 @@
});

it("should return models data when query is successful", async () => {
(modelInfoCall as any).mockResolvedValue(mockPaginatedModelInfoResponse);

Check warning on line 87 in ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

const { result } = renderHook(() => useModelsInfo(), { wrapper });

Expand Down Expand Up @@ -113,7 +114,7 @@
});

it("should use custom page and size parameters", async () => {
(modelInfoCall as any).mockResolvedValue(mockPaginatedModelInfoResponse);

Check warning on line 117 in ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

const { result } = renderHook(() => useModelsInfo(2, 25), { wrapper });

Expand All @@ -139,7 +140,7 @@
const errorMessage = "Failed to fetch models";
const testError = new Error(errorMessage);

(modelInfoCall as any).mockRejectedValue(testError);

Check warning on line 143 in ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

const { result } = renderHook(() => useModelsInfo(), { wrapper });

Expand Down Expand Up @@ -266,7 +267,7 @@
React.createElement(QueryClientProvider, { client: queryClient }, children);

it("should render without crashing", () => {
(modelHubCall as any).mockResolvedValue({ data: [] });

Check warning on line 270 in ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

const { result } = renderHook(() => useModelHub(), { wrapper });

Expand All @@ -275,7 +276,7 @@

it("should return model hub data when query is successful", async () => {
const mockHubData = { data: [{ id: "hub-1", name: "Test Hub" }] };
(modelHubCall as any).mockResolvedValue(mockHubData);

Check warning on line 279 in ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

const { result } = renderHook(() => useModelHub(), { wrapper });

Expand All @@ -297,7 +298,7 @@
const errorMessage = "Failed to fetch model hub";
const testError = new Error(errorMessage);

(modelHubCall as any).mockRejectedValue(testError);

Check warning on line 301 in ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

const { result } = renderHook(() => useModelHub(), { wrapper });

Expand Down Expand Up @@ -364,7 +365,7 @@
React.createElement(QueryClientProvider, { client: queryClient }, children);

it("should render without crashing", () => {
(modelAvailableCall as any).mockResolvedValue(mockAllProxyModelsResponse);

Check warning on line 368 in ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

const { result } = renderHook(() => useAllProxyModels(), { wrapper });

Expand All @@ -372,7 +373,7 @@
});

it("should return all proxy models data when query is successful", async () => {
(modelAvailableCall as any).mockResolvedValue(mockAllProxyModelsResponse);

Check warning on line 376 in ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

const { result } = renderHook(() => useAllProxyModels(), { wrapper });

Expand Down Expand Up @@ -403,7 +404,7 @@
const errorMessage = "Failed to fetch proxy models";
const testError = new Error(errorMessage);

(modelAvailableCall as any).mockRejectedValue(testError);

Check warning on line 407 in ui/litellm-dashboard/src/app/(dashboard)/hooks/models/useModels.test.ts

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type

const { result } = renderHook(() => useAllProxyModels(), { wrapper });

Expand Down Expand Up @@ -480,6 +481,70 @@
});
});

describe("useUserModels", () => {
let queryClient: QueryClient;

beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});

vi.clearAllMocks();

mockUseAuthorized.mockReturnValue({
accessToken: "test-access-token",
userId: "test-user-id",
userRole: "Admin",
token: "test-token",
userEmail: "test@example.com",
premiumUser: false,
disabledPersonalKeyCreation: null,
showSSOBanner: false,
});
});

const wrapper = ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);

it("maps the available-models response to a list of model ids", async () => {
(modelAvailableCall as any).mockResolvedValue({
data: [{ id: "gpt-4" }, { id: "claude-3-opus" }],
});

const { result } = renderHook(() => useUserModels(), { wrapper });

await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});

expect(result.current.data).toEqual(["gpt-4", "claude-3-opus"]);
expect(modelAvailableCall).toHaveBeenCalledWith("test-access-token", "test-user-id", "Admin");
expect(modelAvailableCall).toHaveBeenCalledTimes(1);
});

it("should not execute query when accessToken is missing", () => {
mockUseAuthorized.mockReturnValue({
accessToken: null,
userId: "test-user-id",
userRole: "Admin",
token: null,
userEmail: "test@example.com",
premiumUser: false,
disabledPersonalKeyCreation: null,
showSSOBanner: false,
});

const { result } = renderHook(() => useUserModels(), { wrapper });

expect(result.current.isFetched).toBe(false);
expect(modelAvailableCall).not.toHaveBeenCalled();
});
});

describe("useSelectedTeamModels", () => {
let queryClient: QueryClient;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useQuery, useInfiniteQuery } from "@tanstack/react-query";
import { useQuery, useInfiniteQuery, UseQueryResult } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import { modelInfoCall, modelHubCall, modelAvailableCall } from "@/components/networking";
import useAuthorized from "../useAuthorized";
Expand Down Expand Up @@ -27,6 +27,7 @@ const modelHubKeys = createQueryKeys("modelHub");
const allProxyModelsKeys = createQueryKeys("allProxyModels");
const selectedTeamModelsKeys = createQueryKeys("selectedTeamModels");
const infiniteModelKeys = createQueryKeys("infiniteModels");
const userModelsKeys = createQueryKeys("userModels");

export const useModelsInfo = (
page: number = 1,
Expand Down Expand Up @@ -76,6 +77,18 @@ export const useAllProxyModels = () => {
});
};

export const useUserModels = (): UseQueryResult<string[]> => {
const { accessToken, userId, userRole } = useAuthorized();
return useQuery<string[]>({
queryKey: userModelsKeys.list({}),
queryFn: async () => {
const response = await modelAvailableCall(accessToken!, userId!, userRole!);
return response["data"].map((model: { id: string }) => model.id);
},
enabled: Boolean(accessToken && userId && userRole),
});
};
Comment thread
ryan-crabbe-berri marked this conversation as resolved.

export const useSelectedTeamModels = (teamID: string | null) => {
const { accessToken, userId, userRole } = useAuthorized();
return useQuery<AllProxyModelsResponse>({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React, { ReactNode } from "react";
import { useOrganizations } from "./useOrganizations";
import { organizationListCall } from "@/components/networking";
import { organizationKeys, useOrganization, useOrganizations } from "./useOrganizations";
import { organizationInfoCall, organizationListCall } from "@/components/networking";
import type { Organization } from "@/components/networking";

// Mock the networking function
vi.mock("@/components/networking", () => ({
organizationListCall: vi.fn(),
organizationInfoCall: vi.fn(),
}));

// Mock useAuthorized hook - we can override this in individual tests
Expand Down Expand Up @@ -107,7 +108,7 @@ describe("useOrganizations", () => {

expect(result.current.data).toEqual(mockOrganizations);
expect(result.current.error).toBeNull();
expect(organizationListCall).toHaveBeenCalledWith("test-access-token");
expect(organizationListCall).toHaveBeenCalledWith("test-access-token", null, null);
expect(organizationListCall).toHaveBeenCalledTimes(1);
});

Expand All @@ -131,10 +132,47 @@ describe("useOrganizations", () => {

expect(result.current.error).toEqual(testError);
expect(result.current.data).toBeUndefined();
expect(organizationListCall).toHaveBeenCalledWith("test-access-token");
expect(organizationListCall).toHaveBeenCalledWith("test-access-token", null, null);
expect(organizationListCall).toHaveBeenCalledTimes(1);
});

it("passes org_id and org_alias filters to organizationListCall and caches separately from the unfiltered list", async () => {
(organizationListCall as any).mockResolvedValue(mockOrganizations);

const { result } = renderHook(() => useOrganizations({ org_id: "org-1", org_alias: "Test Organization 1" }), {
wrapper,
});

await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});

expect(organizationListCall).toHaveBeenCalledWith("test-access-token", "org-1", "Test Organization 1");

(organizationListCall as any).mockResolvedValue([]);
const { result: unfiltered } = renderHook(() => useOrganizations(), { wrapper });

await waitFor(() => {
expect(unfiltered.current.isSuccess).toBe(true);
});

expect(organizationListCall).toHaveBeenLastCalledWith("test-access-token", null, null);
expect(organizationListCall).toHaveBeenCalledTimes(2);
});

it("treats empty-string filters as no filters, writing to the unfiltered cache entry", async () => {
(organizationListCall as any).mockResolvedValue(mockOrganizations);

const { result } = renderHook(() => useOrganizations({ org_id: "", org_alias: "" }), { wrapper });

await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});

expect(organizationListCall).toHaveBeenCalledWith("test-access-token", null, null);
expect(queryClient.getQueryData(organizationKeys.list({}))).toEqual(mockOrganizations);
});

it("should not execute query when accessToken is missing", async () => {
// Mock missing accessToken
mockUseAuthorized.mockReturnValue({
Expand Down Expand Up @@ -243,7 +281,7 @@ describe("useOrganizations", () => {
expect(result.current.isLoading).toBe(false);
});

expect(organizationListCall).toHaveBeenCalledWith("test-access-token");
expect(organizationListCall).toHaveBeenCalledWith("test-access-token", null, null);
expect(organizationListCall).toHaveBeenCalledTimes(1);
});

Expand All @@ -260,7 +298,7 @@ describe("useOrganizations", () => {
});

expect(result.current.data).toEqual([]);
expect(organizationListCall).toHaveBeenCalledWith("test-access-token");
expect(organizationListCall).toHaveBeenCalledWith("test-access-token", null, null);
});

it("should handle network timeout error", async () => {
Expand All @@ -280,3 +318,58 @@ describe("useOrganizations", () => {
expect(result.current.data).toBeUndefined();
});
});

describe("useOrganization", () => {
let queryClient: QueryClient;

beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});

vi.clearAllMocks();

mockUseAuthorized.mockReturnValue({
accessToken: "test-access-token",
userId: "test-user-id",
userRole: "Admin",
token: "test-token",
userEmail: "test@example.com",
premiumUser: false,
disabledPersonalKeyCreation: null,
showSSOBanner: false,
});
});

const wrapper = ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);

it("seeds initialData from a filtered list cache entry so the detail renders without a loading state", () => {
(organizationInfoCall as any).mockResolvedValue(mockOrganizations[1]);
// Only a filtered list was ever fetched; the unfiltered list({}) entry stays empty.
queryClient.setQueryData(organizationKeys.list({ filters: { org_id: "org-2" } }), [mockOrganizations[1]]);

const { result } = renderHook(() => useOrganization("org-2"), { wrapper });

// initialData found org-2 in the filtered cache, so data is present on the first render.
expect(result.current.data).toEqual(mockOrganizations[1]);
expect(result.current.isLoading).toBe(false);
});

it("falls through to the detail API call when no cached list contains the organization", async () => {
(organizationInfoCall as any).mockResolvedValue(mockOrganizations[0]);
queryClient.setQueryData(organizationKeys.list({ filters: { org_id: "org-2" } }), [mockOrganizations[1]]);

const { result } = renderHook(() => useOrganization("org-1"), { wrapper });

// org-1 is in no cached list, so there is no initialData and it loads via the detail API.
expect(result.current.data).toBeUndefined();

await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});

expect(organizationInfoCall).toHaveBeenCalledWith("test-access-token", "org-1");
expect(result.current.data).toEqual(mockOrganizations[0]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,23 @@ import { useQuery, useQueryClient, UseQueryResult } from "@tanstack/react-query"
import { createQueryKeys } from "../common/queryKeysFactory";

export const organizationKeys = createQueryKeys("organizations");
export const useOrganizations = (): UseQueryResult<Organization[]> => {

export interface OrganizationListFilters {
org_id?: string | null;
org_alias?: string | null;
}

export const useOrganizations = (filters?: OrganizationListFilters): UseQueryResult<Organization[]> => {
const { accessToken, userId, userRole } = useAuthorized();
const orgId = filters?.org_id || null;
const orgAlias = filters?.org_alias || null;
return useQuery<Organization[]>({
queryKey: organizationKeys.list({}),
queryFn: async () => await organizationListCall(accessToken!),
queryKey: organizationKeys.list(
orgId || orgAlias
? { filters: { ...(orgId && { org_id: orgId }), ...(orgAlias && { org_alias: orgAlias }) } }
: {},
),
queryFn: async () => await organizationListCall(accessToken!, orgId, orgAlias),
enabled: Boolean(accessToken && userId && userRole),
});
};
Expand All @@ -31,9 +43,10 @@ export const useOrganization = (organizationID?: string) => {
initialData: () => {
if (!organizationID) return undefined;

const organizations = queryClient.getQueryData<Organization[]>(organizationKeys.list({}));

return organizations?.find((organization: Organization) => organization.organization_id === organizationID);
return queryClient
.getQueriesData<Organization[]>({ queryKey: organizationKeys.lists() })
.flatMap(([, organizations]) => organizations ?? [])
.find((organization) => organization.organization_id === organizationID);
},
});
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
"use client";

import OrganizationsTable from "@/components/organizations";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";

export default function OrganizationsPage() {
const { accessToken, userRole, premiumUser } = useAuthorized();
return <OrganizationsTable userRole={userRole ?? ""} accessToken={accessToken} premiumUser={premiumUser ?? false} />;
}
17 changes: 2 additions & 15 deletions ui/litellm-dashboard/src/app/(dashboard)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import LoadingScreen from "@/components/common_components/LoadingScreen";
import { Team } from "@/components/key_team_helpers/key_list";
import { Organization, proxyBaseUrl, getInProductNudgesCall } from "@/components/networking";
import OldTeams from "@/components/OldTeams";
import { fetchUserModels, CreateKeyPrefillData } from "@/components/organisms/create_key_button";
import Organizations, { fetchOrganizations } from "@/components/organizations";
import { CreateKeyPrefillData } from "@/components/organisms/create_key_button";
import { fetchOrganizations } from "@/components/organizations";
import PassThroughSettings from "@/components/pass_through_settings";
import { SurveyPrompt, SurveyModal, ClaudeCodePrompt, ClaudeCodeModal } from "@/components/survey";
import Usage from "@/components/usage";
Expand All @@ -32,7 +32,6 @@ function CreateKeyPageContent() {
const [teams, setTeams] = useState<Team[] | null>(null);
const [keys, setKeys] = useState<null | any[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [userModels, setUserModels] = useState<string[]>([]);

const router = useRouter();
const searchParams = useSearchParams()!;
Expand Down Expand Up @@ -172,9 +171,6 @@ function CreateKeyPageContent() {
}, [token]);

useEffect(() => {
if (accessToken && userID && userRole) {
fetchUserModels(userID, userRole, accessToken, setUserModels);
}
if (accessToken && userID && userRole) {
v2TeamListCall(accessToken, 1, 100, {
userID: userRole !== "Admin" && userRole !== "Admin Viewer" ? userID : null,
Expand Down Expand Up @@ -333,15 +329,6 @@ function CreateKeyPageContent() {
premiumUser={premiumUser}
searchParams={searchParams}
/>
) : page == "organizations" ? (
<Organizations
organizations={organizations}
setOrganizations={setOrganizations}
userModels={userModels}
accessToken={accessToken}
userRole={userRole}
premiumUser={premiumUser}
/>
) : page == "pass-through-settings" ? (
<PassThroughSettings
userID={userID}
Expand Down
Loading
Loading