diff --git a/litellm/proxy/_experimental/out/onboarding.html b/litellm/proxy/_experimental/out/onboarding.html deleted file mode 100644 index 51290ebef106..000000000000 --- a/litellm/proxy/_experimental/out/onboarding.html +++ /dev/null @@ -1 +0,0 @@ -LiteLLM Dashboard \ No newline at end of file diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index c98e54061284..00ea8fb7f495 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -268,10 +268,11 @@ class LiteLLMRoutes(enum.Enum): "/v2/key/info", "/model_group/info", "/health", + "/key/list", ] # NOTE: ROUTES ONLY FOR MASTER KEY - only the Master Key should be able to Reset Spend - master_key_only_routes = ["/global/spend/reset", "/key/list"] + master_key_only_routes = ["/global/spend/reset"] management_routes = [ # key "/key/generate", @@ -280,6 +281,7 @@ class LiteLLMRoutes(enum.Enum): "/key/delete", "/key/info", "/key/health", + "/key/list", # user "/user/new", "/user/update", @@ -1348,7 +1350,7 @@ class LiteLLM_VerificationToken(LiteLLMPydanticObjectBase): key_alias: Optional[str] = None spend: float = 0.0 max_budget: Optional[float] = None - expires: Optional[str] = None + expires: Optional[Union[str, datetime]] = None models: List = [] aliases: Dict = {} config: Dict = {} diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index f0b8c84683c4..fff4c16b2dcd 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -1676,6 +1676,52 @@ async def regenerate_key_fn( raise handle_exception_on_proxy(e) +def validate_key_list_check( + complete_user_info: LiteLLM_UserTable, + user_id: Optional[str], + team_id: Optional[str], + organization_id: Optional[str], + key_alias: Optional[str], +): + if complete_user_info.user_role == LitellmUserRoles.PROXY_ADMIN.value: + return # proxy admin can see all keys + + # internal user can only see their own keys + if user_id: + if complete_user_info.user_id != user_id: + raise ProxyException( + message="You are not authorized to check another user's keys", + type=ProxyErrorTypes.bad_request_error, + param="user_id", + code=status.HTTP_403_FORBIDDEN, + ) + + if team_id: + if team_id not in complete_user_info.teams: + raise ProxyException( + message="You are not authorized to check this team's keys", + type=ProxyErrorTypes.bad_request_error, + param="team_id", + code=status.HTTP_403_FORBIDDEN, + ) + + if organization_id: + if ( + complete_user_info.organization_memberships is None + or organization_id + not in [ + membership.organization_id + for membership in complete_user_info.organization_memberships + ] + ): + raise ProxyException( + message="You are not authorized to check this organization's keys", + type=ProxyErrorTypes.bad_request_error, + param="organization_id", + code=status.HTTP_403_FORBIDDEN, + ) + + @router.get( "/key/list", tags=["key management"], @@ -1689,14 +1735,18 @@ async def list_keys( size: int = Query(10, description="Page size", ge=1, le=100), user_id: Optional[str] = Query(None, description="Filter keys by user ID"), team_id: Optional[str] = Query(None, description="Filter keys by team ID"), + organization_id: Optional[str] = Query( + None, description="Filter keys by organization ID" + ), key_alias: Optional[str] = Query(None, description="Filter keys by key alias"), + return_full_object: bool = Query(False, description="Return full key object"), ) -> KeyListResponseObject: """ - List all keys for a given user or team. + List all keys for a given user / team / organization. Returns: { - "keys": List[str], + "keys": List[str] or List[UserAPIKeyAuth], "total_count": int, "current_page": int, "total_pages": int, @@ -1706,7 +1756,14 @@ async def list_keys( from litellm.proxy.proxy_server import prisma_client # Check for unsupported parameters - supported_params = {"page", "size", "user_id", "team_id", "key_alias"} + supported_params = { + "page", + "size", + "user_id", + "team_id", + "key_alias", + "return_full_object", + } unsupported_params = set(request.query_params.keys()) - supported_params if unsupported_params: raise ProxyException( @@ -1722,6 +1779,47 @@ async def list_keys( verbose_proxy_logger.error("Database not connected") raise Exception("Database not connected") + if not user_api_key_dict.user_id: + raise ProxyException( + message="You are not authorized to access this endpoint. No 'user_id' is associated with your API key.", + type=ProxyErrorTypes.bad_request_error, + param="user_id", + code=status.HTTP_403_FORBIDDEN, + ) + + complete_user_info: Optional[BaseModel] = ( + await prisma_client.db.litellm_usertable.find_unique( + where={"user_id": user_api_key_dict.user_id}, + include={"organization_memberships": True}, + ) + ) + + if complete_user_info is None: + raise ProxyException( + message="You are not authorized to access this endpoint. No 'user_id' is associated with your API key.", + type=ProxyErrorTypes.bad_request_error, + param="user_id", + code=status.HTTP_403_FORBIDDEN, + ) + + complete_user_info_pydantic_obj = LiteLLM_UserTable( + **complete_user_info.model_dump() + ) + + validate_key_list_check( + complete_user_info=complete_user_info_pydantic_obj, + user_id=user_id, + team_id=team_id, + organization_id=organization_id, + key_alias=key_alias, + ) + + if user_id is None and complete_user_info_pydantic_obj.user_role != [ + LitellmUserRoles.PROXY_ADMIN.value, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, + ]: + user_id = user_api_key_dict.user_id + response = await _list_key_helper( prisma_client=prisma_client, page=page, @@ -1729,6 +1827,7 @@ async def list_keys( user_id=user_id, team_id=team_id, key_alias=key_alias, + return_full_object=return_full_object, ) verbose_proxy_logger.debug("Successfully prepared response") @@ -1736,6 +1835,7 @@ async def list_keys( return response except Exception as e: + verbose_proxy_logger.exception(f"Error in list_keys: {e}") if isinstance(e, HTTPException): raise ProxyException( message=getattr(e, "detail", f"error({str(e)})"), diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 3a089bf8067f..9fca59315d7e 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -4,7 +4,7 @@ import React, { Suspense, useEffect, useState } from "react"; import { useSearchParams } from "next/navigation"; import { jwtDecode } from "jwt-decode"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - +import { defaultOrg } from "@/components/common_components/default_org"; import Navbar from "@/components/navbar"; import UserDashboard from "@/components/user_dashboard"; import ModelDashboard from "@/components/model_dashboard"; @@ -78,7 +78,7 @@ export default function CreateKeyPage() { const [userEmail, setUserEmail] = useState(null); const [teams, setTeams] = useState(null); const [keys, setKeys] = useState(null); - const [currentOrg, setCurrentOrg] = useState(null); + const [currentOrg, setCurrentOrg] = useState(defaultOrg); const [organizations, setOrganizations] = useState([]); const [proxySettings, setProxySettings] = useState({ PROXY_BASE_URL: "", diff --git a/ui/litellm-dashboard/src/components/common_components/default_org.tsx b/ui/litellm-dashboard/src/components/common_components/default_org.tsx new file mode 100644 index 000000000000..a1c06a24395d --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/default_org.tsx @@ -0,0 +1,6 @@ +import { Organization } from "../networking"; + +export const defaultOrg = { + organization_id: null, + organization_alias: "Default Organization" +} as Organization \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx new file mode 100644 index 000000000000..8bb7cf98b36a --- /dev/null +++ b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx @@ -0,0 +1,144 @@ +import { useState, useEffect } from 'react'; +import { keyListCall, Organization } from '../networking'; +interface Team { +team_id: string; +team_alias: string; +} + +export interface KeyResponse { +token: string; +key_name: string; +key_alias: string; +spend: number; +max_budget: number; +expires: string; +models: string[]; +aliases: Record; +config: Record; +user_id: string; +team_id: string | null; +max_parallel_requests: number; +metadata: Record; +tpm_limit: number; +rpm_limit: number; +budget_duration: string; +budget_reset_at: string; +allowed_cache_controls: string[]; +permissions: Record; +model_spend: Record; +model_max_budget: Record; +soft_budget_cooldown: boolean; +blocked: boolean; +litellm_budget_table: Record; +org_id: string | null; +created_at: string; +updated_at: string; +team_spend: number; +team_alias: string; +team_tpm_limit: number; +team_rpm_limit: number; +team_max_budget: number; +team_models: string[]; +team_blocked: boolean; +soft_budget: number; +team_model_aliases: Record; +team_member_spend: number; +team_member?: { + user_id: string; + user_email: string; + role: 'admin' | 'user'; +}; +team_metadata: Record; +end_user_id: string; +end_user_tpm_limit: number; +end_user_rpm_limit: number; +end_user_max_budget: number; +last_refreshed_at: number; +api_key: string; +user_role: 'proxy_admin' | 'user'; +allowed_model_region?: 'eu' | 'us' | string; +parent_otel_span?: string; +rpm_limit_per_model: Record; +tpm_limit_per_model: Record; +user_tpm_limit: number; +user_rpm_limit: number; +user_email: string; +} + +interface KeyListResponse { +keys: KeyResponse[]; +total_count: number; +current_page: number; +total_pages: number; +} + +interface UseKeyListProps { +selectedTeam?: Team; +currentOrg: Organization | null; +accessToken: string; +} + +interface PaginationData { +currentPage: number; +totalPages: number; +totalCount: number; +} + +interface UseKeyListReturn { +keys: KeyResponse[]; +isLoading: boolean; +error: Error | null; +pagination: PaginationData; +refresh: (params?: Record) => Promise; +} + +const useKeyList = ({ selectedTeam, currentOrg, accessToken }: UseKeyListProps): UseKeyListReturn => { + const [keyData, setKeyData] = useState({ + keys: [], + total_count: 0, + current_page: 1, + total_pages: 0 + }); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchKeys = async (params: Record = {}): Promise => { + try { + console.log("calling fetchKeys"); + if (!currentOrg || !selectedTeam || !accessToken) { + console.log("currentOrg", currentOrg); + console.log("selectedTeam", selectedTeam); + console.log("accessToken", accessToken); + return + } + setIsLoading(true); + + const data = await keyListCall(accessToken, currentOrg.organization_id, selectedTeam.team_id); + console.log("data", data); + setKeyData(data); + setError(null); + } catch (err) { + setError(err instanceof Error ? err : new Error('An error occurred')); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchKeys(); + }, [selectedTeam, currentOrg]); + + return { + keys: keyData.keys, + isLoading, + error, + pagination: { + currentPage: keyData.current_page, + totalPages: keyData.total_pages, + totalCount: keyData.total_count + }, + refresh: fetchKeys + }; +}; + +export default useKeyList; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/navbar.tsx b/ui/litellm-dashboard/src/components/navbar.tsx index e304f931a34b..e02706ec4378 100644 --- a/ui/litellm-dashboard/src/components/navbar.tsx +++ b/ui/litellm-dashboard/src/components/navbar.tsx @@ -2,9 +2,8 @@ import Link from "next/link"; import React from "react"; import type { MenuProps } from "antd"; import { Dropdown } from "antd"; -import { CogIcon } from "@heroicons/react/outline"; import { Organization } from "@/components/networking"; - +import { defaultOrg } from "@/components/common_components/default_org"; interface NavbarProps { userID: string | null; userRole: string | null; @@ -75,10 +74,7 @@ const Navbar: React.FC = ({ Default Organization ), - onClick: () => onOrgChange({ - organization_id: null, - organization_alias: "Default Organization" - } as Organization) + onClick: () => onOrgChange(defaultOrg) }, ...organizations.filter(org => org.organization_id !== null).map(org => ({ key: org.organization_id ?? "default", diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index a59cf146007c..f385423979af 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -2075,6 +2075,58 @@ export const keyInfoCall = async (accessToken: String, keys: String[]) => { } }; +export const keyListCall = async ( + accessToken: String, + organizationID: string | null, + teamID: string | null, +) => { + /** + * Get all available teams on proxy + */ + try { + let url = proxyBaseUrl ? `${proxyBaseUrl}/key/list` : `/key/list`; + console.log("in keyListCall"); + const queryParams = new URLSearchParams(); + + if (teamID) { + queryParams.append('team_id', teamID.toString()); + } + + if (organizationID) { + queryParams.append('organization_id', organizationID.toString()); + } + + queryParams.append('return_full_object', 'true'); + + const queryString = queryParams.toString(); + if (queryString) { + url += `?${queryString}`; + } + + const response = await fetch(url, { + method: "GET", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + const errorData = await response.text(); + handleError(errorData); + throw new Error("Network response was not ok"); + } + + const data = await response.json(); + console.log("/team/list API Response:", data); + return data; + // Handle success - you might want to update some state or UI based on the created key + } catch (error) { + console.error("Failed to create key:", error); + throw error; + } +}; + export const spendUsersCall = async (accessToken: String, userID: String) => { try { const url = proxyBaseUrl ? `${proxyBaseUrl}/spend/users` : `/spend/users`; diff --git a/ui/litellm-dashboard/src/components/view_key_table.tsx b/ui/litellm-dashboard/src/components/view_key_table.tsx index ad90932cac1e..012782b8bf7f 100644 --- a/ui/litellm-dashboard/src/components/view_key_table.tsx +++ b/ui/litellm-dashboard/src/components/view_key_table.tsx @@ -69,8 +69,10 @@ import { import { CopyToClipboard } from "react-copy-to-clipboard"; import TextArea from "antd/es/input/TextArea"; - +import useKeyList from "./key_team_helpers/key_list"; +import { KeyResponse } from "./key_team_helpers/key_list"; const { Option } = Select; + const isLocal = process.env.NODE_ENV === "development"; const proxyBaseUrl = isLocal ? "http://localhost:4000" : null; if (isLocal != true) { @@ -87,7 +89,7 @@ interface EditKeyModalProps { interface ModelLimitModalProps { visible: boolean; onCancel: () => void; - token: ItemData; + token: KeyResponse; onSubmit: (updatedMetadata: any) => void; accessToken: string; } @@ -125,6 +127,19 @@ interface ItemData { // Add any other properties that exist in the item data } +interface ModelLimits { + [key: string]: number; // Index signature allowing string keys +} + +interface CombinedLimit { + tpm: number; + rpm: number; +} + +interface CombinedLimits { + [key: string]: CombinedLimit; // Index signature allowing string keys +} + const ViewKeyTable: React.FC = ({ userID, userRole, @@ -139,15 +154,22 @@ const ViewKeyTable: React.FC = ({ const [isButtonClicked, setIsButtonClicked] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [keyToDelete, setKeyToDelete] = useState(null); - const [selectedItem, setSelectedItem] = useState(null); + const [selectedItem, setSelectedItem] = useState(null); const [spendData, setSpendData] = useState< { day: string; spend: number }[] | null >(null); - const [predictedSpendString, setPredictedSpendString] = useState(""); + + const { keys, isLoading, error, refresh } = useKeyList({ + selectedTeam, + currentOrg, + accessToken + }); + + console.log("keys", keys); const [editModalVisible, setEditModalVisible] = useState(false); const [infoDialogVisible, setInfoDialogVisible] = useState(false); - const [selectedToken, setSelectedToken] = useState(null); + const [selectedToken, setSelectedToken] = useState(null); const [userModels, setUserModels] = useState([]); const initialKnownTeamIDs: Set = new Set(); const [modelLimitModalVisible, setModelLimitModalVisible] = useState(false); @@ -160,75 +182,6 @@ const ViewKeyTable: React.FC = ({ const [knownTeamIDs, setKnownTeamIDs] = useState(initialKnownTeamIDs); const [guardrailsList, setGuardrailsList] = useState([]); - // Function to check if user is admin of a team - const isUserTeamAdmin = (team: any) => { - if (!team.members_with_roles) return false; - return team.members_with_roles.some( - (member: any) => member.role === "admin" && member.user_id === userID - ); - }; - - // Combine all keys that user should have access to - const all_keys_to_display = React.useMemo(() => { - if (!data) return []; - - // Helper function for default team org check - const matchesDefaultTeamOrg = (key: any) => { - console.log(`Checking if key matches default team org: ${JSON.stringify(key)}, currentOrg: ${JSON.stringify(currentOrg)}`) - if (!currentOrg || currentOrg.organization_id === null) { - return !('organization_id' in key) || key.organization_id === null; - } - return key.organization_id === currentOrg.organization_id; - }; - - let allKeys: any[] = []; - - // Handle no team selected or Default Team case - if (!selectedTeam || selectedTeam.team_alias === "Default Team") { - - console.log(`inside personal keys`) - // Get personal keys (with org check) - const personalKeys = data.filter(key => - key.team_id == null && - matchesDefaultTeamOrg(key) - ); - - console.log(`personalKeys: ${JSON.stringify(personalKeys)}`) - - // Get admin team keys (no org check) - const adminTeamKeys = data.filter(key => { - const keyTeam = teams?.find(team => team.team_id === key.team_id); - return keyTeam && isUserTeamAdmin(keyTeam) && key.team_id !== "default-team"; - }); - - console.log(`adminTeamKeys: ${JSON.stringify(adminTeamKeys)}`) - - allKeys = [...personalKeys, ...adminTeamKeys]; - } - // Handle specific team selected - else { - const selectedTeamData = teams?.find(t => t.team_id === selectedTeam.team_id); - if (selectedTeamData) { - const teamKeys = data.filter(key => { - if (selectedTeamData.team_id === "default-team") { - return key.team_id == null && matchesDefaultTeamOrg(key); - } - return key.team_id === selectedTeamData.team_id; - }); - allKeys = teamKeys; - } - } - - // Final filtering and deduplication - return Array.from( - new Map( - allKeys - .filter(key => key.team_id !== "litellm-dashboard") - .map(key => [key.token, key]) - ).values() - ); - }, [data, teams, selectedTeam, currentOrg]); - useEffect(() => { const calculateNewExpiryTime = (duration: string | undefined) => { if (!duration) { @@ -298,7 +251,7 @@ const ViewKeyTable: React.FC = ({ fetchUserModels(); }, [accessToken, userID, userRole]); - const handleModelLimitClick = (token: ItemData) => { + const handleModelLimitClick = (token: KeyResponse) => { setSelectedToken(token); setModelLimitModalVisible(true); }; @@ -668,13 +621,12 @@ const ViewKeyTable: React.FC = ({ if (token.metadata) { const tpmLimits = token.metadata.model_tpm_limit || {}; const rpmLimits = token.metadata.model_rpm_limit || {}; - const combinedLimits: { [key: string]: { tpm: number; rpm: number } } = - {}; + const combinedLimits: CombinedLimits = {}; Object.keys({ ...tpmLimits, ...rpmLimits }).forEach((model) => { combinedLimits[model] = { - tpm: tpmLimits[model] || 0, - rpm: rpmLimits[model] || 0, + tpm: (tpmLimits as ModelLimits)[model] || 0, + rpm: (rpmLimits as ModelLimits)[model] || 0, }; }); @@ -1061,10 +1013,6 @@ const ViewKeyTable: React.FC = ({ } }; - if (data == null) { - return; - } - console.log("RERENDER TRIGGERED"); return (
@@ -1084,8 +1032,8 @@ const ViewKeyTable: React.FC = ({ - {all_keys_to_display && - all_keys_to_display.map((item) => { + {keys && + keys.map((item) => { console.log(item); // skip item if item.team_id == "litellm-dashboard" if (item.team_id === "litellm-dashboard") { @@ -1095,9 +1043,7 @@ const ViewKeyTable: React.FC = ({ /** * if selected team id is null -> show the keys with no team id or team id's that don't exist in db */ - console.log( - `item team id: ${item.team_id}, knownTeamIDs.has(item.team_id): ${knownTeamIDs.has(item.team_id)}, selectedTeam id: ${selectedTeam.team_id}` - ); + if ( selectedTeam.team_id == null && item.team_id !== null && @@ -1147,7 +1093,7 @@ const ViewKeyTable: React.FC = ({ {(() => { try { - return parseFloat(item.spend).toFixed(4); + return item.spend.toFixed(4); } catch (error) { return item.spend; } @@ -1324,9 +1270,7 @@ const ViewKeyTable: React.FC = ({

{(() => { try { - return parseFloat( - selectedToken.spend - ).toFixed(4); + return selectedToken.spend.toFixed(4); } catch (error) { return selectedToken.spend; } @@ -1334,7 +1278,7 @@ const ViewKeyTable: React.FC = ({

- +

Budget

@@ -1359,7 +1303,7 @@ const ViewKeyTable: React.FC = ({

- +

Expires