diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx index 6af1e8d0441..10b9b864b50 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx @@ -9,6 +9,7 @@ import PromptCompressionTab from "./PromptCompressionTab"; import PromptCachingTab from "./PromptCachingTab"; import AutoRouterBenchmarksTab from "./AutoRouterBenchmarksTab"; import { useDailyActivityRange } from "./useDailyActivityRange"; +import { hasCapability } from "@/utils/capabilities"; interface CostOptimizationViewProps { accessToken: string | null; @@ -33,7 +34,13 @@ const CostOptimizationView: React.FC = ({ accessToken { key: "caching", label: "Prompt Caching", - children: , + children: ( + + ), }, { key: "autorouter-usage", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx index a18109e8133..d7afb00251a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.test.tsx @@ -1,5 +1,5 @@ import { render, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const mockGetGeneralSettingsCall = vi.fn(); @@ -23,21 +23,42 @@ vi.mock("./CacheLeakageCard", () => ({ import PromptCachingTab from "./PromptCachingTab"; +const activityFixture = () => ({ + dateValue: {}, + onDateChange: vi.fn(), + results: [], + loading: false, + isFetchingMore: false, +}); + describe("PromptCachingTab", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + it("renders the cache leakage table alongside the caching settings", async () => { mockGetGeneralSettingsCall.mockResolvedValue([]); - const activity = { - dateValue: {}, - onDateChange: vi.fn(), - results: [], - loading: false, - isFetchingMore: false, - }; - const { getByTestId } = render(); + const activity = activityFixture(); + const { getByTestId } = render( + , + ); expect(getByTestId("caching-settings")).toBeInTheDocument(); expect(getByTestId("cache-leakage-card")).toBeInTheDocument(); await waitFor(() => expect(mockCacheLeakageCard).toHaveBeenCalledWith(expect.objectContaining({ activity }))); }); + + it("keeps the cache leakage table but skips /config/list when the caller cannot read proxy config", async () => { + mockGetGeneralSettingsCall.mockResolvedValue([]); + + const { getByTestId, queryByTestId } = render( + , + ); + + expect(queryByTestId("caching-settings")).not.toBeInTheDocument(); + expect(getByTestId("cache-leakage-card")).toBeInTheDocument(); + await waitFor(() => expect(mockCacheLeakageCard).toHaveBeenCalled()); + expect(mockGetGeneralSettingsCall).not.toHaveBeenCalled(); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.tsx index 952e9f653ac..a15b25fd5d5 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/PromptCachingTab.tsx @@ -14,13 +14,14 @@ import { DailyActivityRange } from "./useDailyActivityRange"; interface PromptCachingTabProps { accessToken: string | null; activity: DailyActivityRange; + canViewProxyConfig: boolean; } -const PromptCachingTab: React.FC = ({ accessToken, activity }) => { +const PromptCachingTab: React.FC = ({ accessToken, activity, canViewProxyConfig }) => { const [settings, setSettings] = useState([]); const loadSettings = useCallback(() => { - if (!accessToken) { + if (!accessToken || !canViewProxyConfig) { return; } getGeneralSettingsCall(accessToken) @@ -29,7 +30,7 @@ const PromptCachingTab: React.FC = ({ accessToken, activi console.error("Failed to load prompt caching settings:", error); NotificationsManager.fromBackend("Failed to load prompt caching settings"); }); - }, [accessToken]); + }, [accessToken, canViewProxyConfig]); useEffect(() => { loadSettings(); @@ -47,7 +48,9 @@ const PromptCachingTab: React.FC = ({ accessToken, activi return (
- + {canViewProxyConfig && ( + + )}
); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/credentials/useCredentials.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/credentials/useCredentials.test.ts index ee903628f08..987f9e9d66d 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/credentials/useCredentials.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/credentials/useCredentials.test.ts @@ -176,6 +176,52 @@ describe("useCredentials", () => { expect(result.current.data).toBeUndefined(); }); + it.each(["Internal User", "Internal Viewer", "Org Admin", "internal_user", "org_admin"])( + "should not call GET /credentials for %s", + async (userRole) => { + mockUseAuthorized.mockReturnValue({ + accessToken: "test-access-token", + userRole, + userId: "test-user-id", + token: "test-token", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + + const { result } = renderHook(() => useCredentials(), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.isFetched).toBe(false); + expect(credentialListCall).not.toHaveBeenCalled(); + }, + ); + + it.each(["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer"])( + "should call GET /credentials for %s", + async (userRole) => { + (credentialListCall as any).mockResolvedValue(mockCredentialsResponse); + mockUseAuthorized.mockReturnValue({ + accessToken: "test-access-token", + userRole, + userId: "test-user-id", + token: "test-token", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + + const { result } = renderHook(() => useCredentials(), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + expect(credentialListCall).toHaveBeenCalledWith("test-access-token"); + }, + ); + it("should execute query when accessToken is present", async () => { // Mock successful API call (credentialListCall as any).mockResolvedValue(mockCredentialsResponse); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/credentials/useCredentials.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/credentials/useCredentials.ts index e3266de4fbc..14bb8598623 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/credentials/useCredentials.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/credentials/useCredentials.ts @@ -2,14 +2,16 @@ import { credentialListCall, CredentialsResponse } from "@/components/networking import { useQuery } from "@tanstack/react-query"; import { createQueryKeys } from "../common/queryKeysFactory"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import useCan from "@/app/(dashboard)/hooks/useCan"; const credentialsKeys = createQueryKeys("credentials"); export const useCredentials = () => { const { accessToken } = useAuthorized(); + const canViewCredentials = useCan("viewCredentials"); return useQuery({ queryKey: credentialsKeys.list({}), queryFn: async () => await credentialListCall(accessToken!), - enabled: Boolean(accessToken), + enabled: Boolean(accessToken) && canViewCredentials, }); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxyConfig/useProxyConfig.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxyConfig/useProxyConfig.test.ts index 4ba80df5c6c..28cac2b71af 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxyConfig/useProxyConfig.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxyConfig/useProxyConfig.test.ts @@ -156,6 +156,58 @@ describe("useProxyConfig", () => { expect(result.current.isLoading).toBe(true); }); + it.each(["Internal User", "Internal Viewer", "Org Admin", "internal_user", "org_admin"])( + "should not call GET /config/list for %s", + (userRole) => { + mockUseAuthorized.mockReturnValue({ + accessToken: mockAccessToken, + userId: "test-user-id", + userRole, + token: "test-token", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + + const { result } = renderHook(() => useProxyConfig(ConfigType.GENERAL_SETTINGS), { wrapper }); + + expect(result.current.isLoading).toBe(false); + expect(result.current.isFetched).toBe(false); + expect(fetchSpy).not.toHaveBeenCalled(); + }, + ); + + it.each(["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer"])( + "should call GET /config/list for %s", + async (userRole) => { + (fetchSpy as any).mockResolvedValue({ + ok: true, + json: async () => mockProxyConfigResponse, + }); + mockUseAuthorized.mockReturnValue({ + accessToken: mockAccessToken, + userId: "test-user-id", + userRole, + token: "test-token", + userEmail: "test@example.com", + premiumUser: false, + disabledPersonalKeyCreation: null, + showSSOBanner: false, + }); + + const { result } = renderHook(() => useProxyConfig(ConfigType.GENERAL_SETTINGS), { wrapper }); + + await waitFor(() => { + expect(result.current.isSuccess).toBe(true); + }); + expect(fetchSpy).toHaveBeenCalledWith( + `${mockProxyBaseUrl}/config/list?config_type=${ConfigType.GENERAL_SETTINGS}`, + expect.objectContaining({ method: "GET" }), + ); + }, + ); + it("should return proxy config data when query is successful", async () => { (fetchSpy as any).mockResolvedValue({ ok: true, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxyConfig/useProxyConfig.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxyConfig/useProxyConfig.ts index 485c7cc1f92..d96727357ba 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxyConfig/useProxyConfig.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/proxyConfig/useProxyConfig.ts @@ -1,6 +1,7 @@ import { useQuery, useMutation, UseMutationResult, useQueryClient } from "@tanstack/react-query"; import { createQueryKeys } from "../common/queryKeysFactory"; import useAuthorized from "../useAuthorized"; +import useCan from "../useCan"; import { proxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking"; /** @@ -147,6 +148,7 @@ export const deleteProxyConfigFieldCall = async ( */ export const useProxyConfig = (configType: ConfigType) => { const { accessToken } = useAuthorized(); + const canViewProxyConfig = useCan("viewProxyConfig"); return useQuery({ queryKey: proxyConfigKeys.list({ filters: { @@ -154,7 +156,7 @@ export const useProxyConfig = (configType: ConfigType) => { }, }), queryFn: async () => await getProxyConfigCall(accessToken!, configType), - enabled: Boolean(accessToken), + enabled: Boolean(accessToken) && canViewProxyConfig, }); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx index 659a618c8f6..012bdea5c76 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.test.tsx @@ -314,6 +314,26 @@ describe("AllModelsTab", () => { expect(screen.getByTestId("model-settings-modal")).toBeInTheDocument(); }); + it.each(["Internal User", "Internal Viewer", "Org Admin"])( + "hides the model settings trigger from %s, who cannot read /config/list", + (userRole) => { + vi.spyOn(useAuthorizedModule, "default").mockReturnValue({ ...MOCK_AUTHORIZED, userRole }); + + render(); + + expect(screen.queryByTestId("models-settings-trigger")).not.toBeInTheDocument(); + expect(screen.queryByTestId("model-settings-modal")).not.toBeInTheDocument(); + }, + ); + + it.each(["Admin", "Admin Viewer"])("keeps the model settings trigger for %s", (userRole) => { + vi.spyOn(useAuthorizedModule, "default").mockReturnValue({ ...MOCK_AUTHORIZED, userRole }); + + render(); + + expect(screen.getByTestId("models-settings-trigger")).toBeInTheDocument(); + }); + it("opens the model detail view from the model ID cell", async () => { const user = userEvent.setup(); render(); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx index 1d206f81030..55b0d79fdf3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTab.tsx @@ -3,6 +3,7 @@ import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import useCan from "@/app/(dashboard)/hooks/useCan"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; import ModelSettingsModal from "@/components/model_dashboard/ModelSettingsModal/ModelSettingsModal"; import { ModelData } from "@/components/model_dashboard/types"; @@ -48,6 +49,7 @@ const AllModelsTab = ({ }: AllModelsTabProps) => { const { data: modelCostMapData, isLoading: isLoadingModelCostMap } = useModelCostMap(); const { accessToken, userId, userRole } = useAuthorized(); + const canViewProxyConfig = useCan("viewProxyConfig"); const { data: teams, isLoading: isLoadingTeams } = useTeams(); const queryClient = useQueryClient(); @@ -283,7 +285,7 @@ const AllModelsTab = ({ isLoadingTeams={isLoadingTeams} viewMode={modelViewMode} onViewModeChange={setModelViewMode} - onOpenModelSettings={handleOpenModelSettings} + onOpenModelSettings={canViewProxyConfig ? handleOpenModelSettings : null} availableModelGroups={availableModelGroups} availableModelAccessGroups={availableModelAccessGroups} userRole={userRole} @@ -351,11 +353,13 @@ const AllModelsTab = ({ onOk={handleDeleteModel} confirmLoading={deleteLoading} /> - setIsModelSettingsModalVisible(false)} - onSuccess={() => setIsModelSettingsModalVisible(false)} - /> + {canViewProxyConfig && ( + setIsModelSettingsModalVisible(false)} + onSuccess={() => setIsModelSettingsModalVisible(false)} + /> + )} ); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx index 8af24b91dd8..2eed02dd937 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AllModelsTable.tsx @@ -68,7 +68,7 @@ interface AllModelsTableProps { isLoadingTeams: boolean; viewMode: ModelViewMode; onViewModeChange: (viewMode: ModelViewMode) => void; - onOpenModelSettings: () => void; + onOpenModelSettings: (() => void) | null; availableModelGroups: string[]; availableModelAccessGroups: string[]; userRole: string; @@ -249,18 +249,22 @@ export function AllModelsTable({ - + {onOpenModelSettings !== null && ( + <> + - + + + )} ({ + default: () => mockUseAuthorized(), +})); + +const storedCredentials: CredentialItem[] = [ + { + credential_name: "openai-key", + credential_values: {}, + credential_info: { custom_llm_provider: "openai" }, + }, +]; + +const mockUseCredentials = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/credentials/useCredentials", () => ({ + useCredentials: () => mockUseCredentials(), +})); + +vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({ + useModelCostMap: () => ({ data: {}, isLoading: false, error: null }), +})); + +vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ + useTeams: () => ({ data: [], isLoading: false, error: null }), +})); + +vi.mock("@tanstack/react-query", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useQueryClient: () => ({ invalidateQueries: vi.fn() }) }; +}); + +const addModelFormProps = vi.fn(); +vi.mock("@/components/add_model/AddModelForm", () => ({ + __esModule: true, + default: (props: { credentials: CredentialItem[] | null }) => { + addModelFormProps(props); + return
; + }, +})); + +import AddModelPanel from "./AddModelPanel"; + +const credentialsPassedToForm = (): CredentialItem[] | null => + addModelFormProps.mock.calls.at(-1)?.[0].credentials ?? null; + +describe("AddModelPanel", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUseCredentials.mockReturnValue({ data: { credentials: storedCredentials } }); + }); + + const renderAs = (userRole: string) => { + mockUseAuthorized.mockReturnValue({ accessToken: "sk-test", userRole, userId: "user-1" }); + render(); + }; + + it.each(["Internal User", "Internal Viewer", "Org Admin"])( + "hides the credential picker from %s by passing null", + (userRole) => { + renderAs(userRole); + + expect(credentialsPassedToForm()).toBeNull(); + }, + ); + + it.each(["Admin", "Admin Viewer"])("passes the fetched credentials through for %s", (userRole) => { + renderAs(userRole); + + expect(credentialsPassedToForm()).toEqual(storedCredentials); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx index 4d7bcc921af..c760be43cdc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AddModelPanel.tsx @@ -11,10 +11,12 @@ import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap" import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import useCan from "@/app/(dashboard)/hooks/useCan"; import { vertexCredentialsUploadProps } from "@/app/(dashboard)/models-and-endpoints/vertexCredentialsUpload"; export default function AddModelPanel() { const { accessToken } = useAuthorized(); + const canViewCredentials = useCan("viewCredentials"); const [form] = Form.useForm(); const queryClient = useQueryClient(); const { data: modelCostMapData } = useModelCostMap(); @@ -51,7 +53,7 @@ export default function AddModelPanel() { showAdvancedSettings={showAdvancedSettings} setShowAdvancedSettings={setShowAdvancedSettings} teams={teams ?? null} - credentials={credentialsResponse?.credentials || []} + credentials={canViewCredentials ? credentialsResponse?.credentials ?? [] : null} /> ); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx index 67ba469f68d..186ffa4ebdc 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/VectorStoreForm.tsx @@ -19,7 +19,8 @@ interface VectorStoreFormProps { onCancel: () => void; onSuccess: () => void; accessToken: string | null; - credentials: CredentialItem[]; + /** `null` when the caller may not read stored credentials, which hides the reuse picker entirely. */ + credentials: CredentialItem[] | null; } const VectorStoreForm: React.FC = ({ @@ -353,32 +354,34 @@ const VectorStoreForm: React.FC = ({ - - Existing Credentials{" "} - - - - - } - name="litellm_credential_name" - > - (option?.label ?? "").toLowerCase().includes(input.toLowerCase())} + options={[ + { value: null, label: "None" }, + ...credentials.map((credential) => ({ + value: credential.credential_name, + label: credential.credential_name, + })), + ]} + allowClear + /> + + )} ({ __esModule: true, default: () => null }) vi.mock("./TestVectorStoreTab", () => ({ __esModule: true, default: () => null })); const mockVectorStoreListCall = vi.mocked(vectorStoreListCall); +const mockCredentialListCall = vi.mocked(credentialListCall); const openManageTab = async (user: ReturnType) => { await user.click(screen.getByRole("tab", { name: "Manage Vector Stores" })); @@ -60,3 +61,33 @@ describe("VectorStoreManagement loading state", () => { expect(mockVectorStoreListCall).toHaveBeenCalledWith("sk-test"); }); }); + +describe("VectorStoreManagement credential access", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockVectorStoreListCall.mockResolvedValue({ data: [] }); + mockCredentialListCall.mockResolvedValue({ credentials: [] }); + }); + + it.each(["Internal User", "Internal Viewer", "Org Admin"])( + "should not call GET /credentials for %s", + async (userRole) => { + const user = userEvent.setup(); + render(); + await openManageTab(user); + + expect(await screen.findByText("table-loaded")).toBeInTheDocument(); + expect(mockVectorStoreListCall).toHaveBeenCalledWith("sk-test"); + expect(mockCredentialListCall).not.toHaveBeenCalled(); + }, + ); + + it.each(["Admin", "Admin Viewer"])("should call GET /credentials for %s", async (userRole) => { + const user = userEvent.setup(); + render(); + await openManageTab(user); + + expect(await screen.findByText("table-loaded")).toBeInTheDocument(); + expect(mockCredentialListCall).toHaveBeenCalledWith("sk-test"); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.tsx index 8afa49ea148..9b14235ba15 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/index.tsx @@ -14,6 +14,7 @@ import VectorStoreInfoView from "./vector_store_info"; import CreateVectorStore from "./CreateVectorStore"; import TestVectorStoreTab from "./TestVectorStoreTab"; import { isAdminRole } from "@/utils/roles"; +import { hasCapability } from "@/utils/capabilities"; import NotificationsManager from "@/components/molecules/notifications_manager"; import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -26,6 +27,7 @@ interface VectorStoreProps { } const VectorStoreManagement: React.FC = ({ accessToken, userID, userRole }) => { + const canViewCredentials = hasCapability(userRole, "viewCredentials"); const [vectorStores, setVectorStores] = useState([]); const [isLoadingVectorStores, setIsLoadingVectorStores] = useState(true); const [isCreateModalVisible, setIsCreateModalVisible] = useState(false); @@ -55,7 +57,7 @@ const VectorStoreManagement: React.FC = ({ accessToken, userID }; const fetchCredentials = async () => { - if (!accessToken) return; + if (!accessToken || !canViewCredentials) return; try { const response = await credentialListCall(accessToken); setCredentials(response.credentials || []); @@ -132,6 +134,7 @@ const VectorStoreManagement: React.FC = ({ accessToken, userID onClose={handleCloseInfo} accessToken={accessToken} is_admin={isAdminRole(userRole || "")} + canViewCredentials={canViewCredentials} editVectorStore={editVectorStore} />
@@ -196,7 +199,7 @@ const VectorStoreManagement: React.FC = ({ accessToken, userID onCancel={() => setIsCreateModalVisible(false)} onSuccess={handleCreateSuccess} accessToken={accessToken} - credentials={credentials} + credentials={canViewCredentials ? credentials : null} /> {/* Delete Confirmation Modal */} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx index e5646037d14..f2836faf1ea 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/vector-stores/_components/vector_store_info.tsx @@ -21,6 +21,7 @@ interface VectorStoreInfoViewProps { onClose: () => void; accessToken: string | null; is_admin: boolean; + canViewCredentials: boolean; editVectorStore: boolean; } @@ -29,6 +30,7 @@ const VectorStoreInfoView: React.FC = ({ onClose, accessToken, is_admin, + canViewCredentials, editVectorStore, }) => { const [form] = Form.useForm(); @@ -69,7 +71,7 @@ const VectorStoreInfoView: React.FC = ({ }; const fetchCredentials = async () => { - if (!accessToken) return; + if (!accessToken || !canViewCredentials) return; try { const response = await credentialListCall(accessToken); setCredentials(response.credentials || []); @@ -192,37 +194,41 @@ const VectorStoreInfoView: React.FC = ({ - {/* Credentials */} -
- - Either select existing credentials OR enter provider credentials below - -
+ {canViewCredentials && ( + <> + {/* Credentials */} +
+ + Either select existing credentials OR enter provider credentials below + +
- - - (option?.label ?? "").toLowerCase().includes(input.toLowerCase()) - } - options={[ - { value: null, label: "None" }, - ...credentials.map((credential) => ({ - value: credential.credential_name, - label: credential.credential_name, - })), - ]} - allowClear - /> - + + + (option?.label ?? "").toLowerCase().includes(input.toLowerCase()) + } + options={[ + { value: null, label: "None" }, + ...credentials.map((credential) => ({ + value: credential.credential_name, + label: credential.credential_name, + })), + ]} + allowClear + /> + -
-
- OR -
-
+
+
+ OR +
+
+ + )} { expect(screen.queryByRole("switch")).not.toBeInTheDocument(); }); + it("should offer the existing-credentials picker when the caller can read credentials", async () => { + const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized")); + mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser("proxy_admin", "user-1", true)); + + renderWithProviders(); + + expect(await screen.findByText("Existing Credentials")).toBeInTheDocument(); + expect(screen.getByText(/Either select existing credentials/i)).toBeInTheDocument(); + }); + + it("should drop the existing-credentials picker, keeping provider fields, when credentials are unreadable", async () => { + const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized")); + mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser("proxy_admin", "user-1", true)); + + renderWithProviders(); + + expect(await screen.findByText("Provider")).toBeInTheDocument(); + expect(screen.queryByText("Existing Credentials")).not.toBeInTheDocument(); + expect(screen.queryByText(/Either select existing credentials/i)).not.toBeInTheDocument(); + }); + it("should display the provider field and the Test Connect / Add Model buttons", async () => { const mockUseAuthorized = vi.mocked(await import("@/app/(dashboard)/hooks/useAuthorized")); mockUseAuthorized.default.mockReturnValue(mockAuthorizedUser("proxy_admin", "user-1", true)); diff --git a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx index 307f3665e83..1f9dfbbe78c 100644 --- a/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx +++ b/ui/litellm-dashboard/src/components/add_model/AddModelForm.tsx @@ -33,7 +33,8 @@ interface AddModelFormProps { showAdvancedSettings: boolean; setShowAdvancedSettings: (show: boolean) => void; teams: Team[] | null; - credentials: CredentialItem[]; + /** `null` when the caller may not read stored credentials, which hides the reuse picker entirely. */ + credentials: CredentialItem[] | null; } const { Title, Link } = Typography; @@ -228,55 +229,63 @@ const AddModelForm: React.FC = ({ - {/* Credentials */} -
- - Either select existing credentials OR enter new provider credentials below - -
+ {credentials === null ? ( + + ) : ( + <> + {/* Credentials */} +
+ + Either select existing credentials OR enter new provider credentials below + +
- - (option?.label ?? "").toLowerCase().includes(input.toLowerCase())} - options={[ - { value: null, label: "None" }, - ...credentials.map((credential) => ({ - value: credential.credential_name, - label: credential.credential_name, - })), - ]} - allowClear - /> - + + + (option?.label ?? "").toLowerCase().includes(input.toLowerCase()) + } + options={[ + { value: null, label: "None" }, + ...credentials.map((credential) => ({ + value: credential.credential_name, + label: credential.credential_name, + })), + ]} + allowClear + /> + - - prevValues.litellm_credential_name !== currentValues.litellm_credential_name || - prevValues.provider !== currentValues.provider - } - > - {({ getFieldValue }) => { - const credentialName = getFieldValue("litellm_credential_name"); - // Only show provider specific fields if no credentials selected - if (!credentialName) { - return ( - <> -
-
- OR -
-
- - - ); - } - return null; - }} -
+ + prevValues.litellm_credential_name !== currentValues.litellm_credential_name || + prevValues.provider !== currentValues.provider + } + > + {({ getFieldValue }) => { + const credentialName = getFieldValue("litellm_credential_name"); + // Only show provider specific fields if no credentials selected + if (!credentialName) { + return ( + <> +
+
+ OR +
+
+ + + ); + } + return null; + }} +
+ + )}
Additional Model Info Settings diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index f795076ff03..c97cbd76512 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -3,8 +3,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../../tests/test-utils"; import Sidebar, { menuGroups, getBreadcrumb } from "./leftnav"; -vi.mock("../utils/roles", () => { +vi.mock("../utils/roles", async (importOriginal) => { + const actual = await importOriginal(); return { + ...actual, all_admin_roles: ["admin", "admin_viewer"], internalUserRoles: ["internal"], rolesWithWriteAccess: ["admin", "internal"], diff --git a/ui/litellm-dashboard/src/utils/capabilities.test.ts b/ui/litellm-dashboard/src/utils/capabilities.test.ts index 98752456166..f6270e84e13 100644 --- a/ui/litellm-dashboard/src/utils/capabilities.test.ts +++ b/ui/litellm-dashboard/src/utils/capabilities.test.ts @@ -23,8 +23,20 @@ const ADMIN_ONLY_CAPABILITIES: Capability[] = [ "viewPrompts", "viewOrganizationUsage", "viewAgentUsage", + "viewProxyConfig", + "viewCredentials", ]; +// `/config/list` sits in `admin_viewer_routes`, which `org_admin_allowed_routes` +// includes, so an org admin looks allowed on paper. At runtime `_user_is_org_admin` +// needs an `organization_id` in the request payload, which a bare GET never carries, +// so the proxy answers 401 for org admins on both of these routes. +const PROXY_ADMIN_ONLY_CAPABILITIES: Capability[] = ["viewProxyConfig", "viewCredentials"]; + +// A team admin carries no distinct session role; the dashboard sees their user_role. +const TEAM_ADMIN_SESSION_ROLE = "Internal User"; +const ORG_ADMIN_ROLES = ["Org Admin", "org_admin"]; + describe("hasCapability", () => { describe.each(ADMIN_ONLY_CAPABILITIES)("%s", (capability) => { it.each(ADMIN_ROLES)("should grant it to %s", (role) => { @@ -37,6 +49,24 @@ describe("hasCapability", () => { }); }); +describe.each(PROXY_ADMIN_ONLY_CAPABILITIES)("hasCapability - %s", (capability) => { + it.each(ORG_ADMIN_ROLES)("should deny it to an org admin (%s)", (role) => { + expect(hasCapability(role, capability)).toBe(false); + }); + + it("should deny it to a team admin", () => { + expect(hasCapability(TEAM_ADMIN_SESSION_ROLE, capability)).toBe(false); + }); + + it.each(["internal_user", "internal_user_viewer", "Internal Viewer"])("should deny it to %s", (role) => { + expect(hasCapability(role, capability)).toBe(false); + }); + + it.each(["Admin", "proxy_admin", "Admin Viewer", "proxy_admin_viewer"])("should grant it to %s", (role) => { + expect(hasCapability(role, capability)).toBe(true); + }); +}); + describe("rolesWithCapability", () => { it("should return a copy so callers cannot mutate the capability map", () => { const roles = rolesWithCapability("viewToolPolicies"); diff --git a/ui/litellm-dashboard/src/utils/capabilities.ts b/ui/litellm-dashboard/src/utils/capabilities.ts index 6be75ba402e..6046932f8f2 100644 --- a/ui/litellm-dashboard/src/utils/capabilities.ts +++ b/ui/litellm-dashboard/src/utils/capabilities.ts @@ -1,4 +1,6 @@ -import { all_admin_roles } from "./roles"; +import { all_admin_roles, old_admin_roles } from "./roles"; + +const proxyAdminOnlyRoles = [...old_admin_roles, "proxy_admin", "proxy_admin_viewer"]; const CAPABILITY_ROLES = { viewToolPolicies: all_admin_roles, @@ -8,6 +10,8 @@ const CAPABILITY_ROLES = { viewPrompts: all_admin_roles, viewOrganizationUsage: all_admin_roles, viewAgentUsage: all_admin_roles, + viewProxyConfig: proxyAdminOnlyRoles, + viewCredentials: proxyAdminOnlyRoles, } as const satisfies Record; export type Capability = keyof typeof CAPABILITY_ROLES;