diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 645346811d0..255e37186df 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -124,6 +124,14 @@ class LitellmUserRoles(str, enum.Enum): } return ui_labels.get(self.value, "") + @property + def is_internal_user_role(self) -> bool: + """returns true if this role is an `internal_user` or `internal_user_viewer` role""" + return self.value in [ + self.INTERNAL_USER, + self.INTERNAL_USER_VIEW_ONLY, + ] + class LitellmTableNames(str, enum.Enum): """ @@ -2609,3 +2617,32 @@ class SpecialManagementEndpointEnums(enum.Enum): class TransformRequestBody(BaseModel): call_type: CallTypes request_body: dict + + +class DefaultInternalUserParams(LiteLLMPydanticObjectBase): + """ + Default parameters to apply when a new user signs in via SSO or is created on the /user/new API endpoint + """ + + user_role: Optional[ + Literal[ + LitellmUserRoles.INTERNAL_USER, + LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, + LitellmUserRoles.PROXY_ADMIN, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + ] + ] = Field( + default=LitellmUserRoles.INTERNAL_USER, + description="Default role assigned to new users created", + ) + max_budget: Optional[float] = Field( + default=None, + description="Default maximum budget (in USD) for new users created", + ) + budget_duration: Optional[str] = Field( + default=None, + description="Default budget duration for new users (e.g. 'daily', 'weekly', 'monthly')", + ) + models: Optional[List[str]] = Field( + default=None, description="Default list of models that new users can access" + ) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index a638f4c7438..1cc31a8849b 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -86,7 +86,7 @@ def _update_internal_new_user_params(data_json: dict, data: NewUserRequest) -> d ) is_internal_user = False - if data.user_role == LitellmUserRoles.INTERNAL_USER: + if data.user_role and data.user_role.is_internal_user_role: is_internal_user = True if litellm.default_internal_user_params: for key, value in litellm.default_internal_user_params.items(): diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 8f91f9bbc99..504ba9537aa 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -3,6 +3,7 @@ from typing import List from fastapi import APIRouter, Depends, HTTPException +import litellm from litellm._logging import verbose_proxy_logger from litellm.proxy._types import * from litellm.proxy.auth.user_api_key_auth import user_api_key_auth @@ -108,3 +109,94 @@ async def delete_allowed_ip(ip_address: IPAddress): await proxy_config.save_config(new_config=config) return {"message": f"IP {ip_address.ip} deleted successfully", "status": "success"} + + +@router.get( + "/get/internal_user_settings", + tags=["SSO Settings"], + dependencies=[Depends(user_api_key_auth)], +) +async def get_sso_settings(): + """ + Get all SSO settings from the litellm_settings configuration. + Returns a structured object with values and descriptions for UI display. + """ + from pydantic import TypeAdapter + + from litellm.proxy.proxy_server import proxy_config + + # Load existing config + config = await proxy_config.get_config() + litellm_settings = config.get("litellm_settings", {}) or {} + default_internal_user_params = ( + litellm_settings.get("default_internal_user_params", {}) or {} + ) + + # Create the settings object first + sso_settings = DefaultInternalUserParams(**(default_internal_user_params)) + # Get the schema for UISSOSettings + schema = TypeAdapter(DefaultInternalUserParams).json_schema(by_alias=True) + + # Convert to dict for response + settings_dict = sso_settings.model_dump() + + # Add descriptions to the response + result = { + "values": settings_dict, + "schema": {"description": schema.get("description", ""), "properties": {}}, + } + + # Add property descriptions + for field_name, field_info in schema["properties"].items(): + result["schema"]["properties"][field_name] = { + "description": field_info.get("description", ""), + "type": field_info.get("type", "string"), + } + + # Add nested object descriptions + for def_name, def_schema in schema.get("definitions", {}).items(): + result["schema"][def_name] = { + "description": def_schema.get("description", ""), + "properties": { + prop_name: {"description": prop_info.get("description", "")} + for prop_name, prop_info in def_schema.get("properties", {}).items() + }, + } + + return result + + +@router.patch( + "/update/internal_user_settings", + tags=["SSO Settings"], + dependencies=[Depends(user_api_key_auth)], +) +async def update_internal_user_settings(settings: DefaultInternalUserParams): + """ + Update the default internal user parameters for SSO users. + These settings will be applied to new users who sign in via SSO. + """ + from litellm.proxy.proxy_server import proxy_config + + # Update the in-memory settings + litellm.default_internal_user_params = settings.model_dump(exclude_none=True) + + # Load existing config + config = await proxy_config.get_config() + + # Update config with new settings + if "litellm_settings" not in config: + config["litellm_settings"] = {} + + config["litellm_settings"]["default_internal_user_params"] = settings.model_dump( + exclude_none=True + ) + + # Save the updated config + await proxy_config.save_config(new_config=config) + + return { + "message": "Internal user settings updated successfully", + "status": "success", + "settings": litellm.default_internal_user_params, + } diff --git a/tests/litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py new file mode 100644 index 00000000000..e2c37048d3b --- /dev/null +++ b/tests/litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -0,0 +1,140 @@ +import json +import os +import sys + +import pytest +from fastapi.testclient import TestClient + +sys.path.insert( + 0, os.path.abspath("../../..") +) # Adds the parent directory to the system path + +from litellm.proxy._types import DefaultInternalUserParams, LitellmUserRoles +from litellm.proxy.proxy_server import app + +client = TestClient(app) + + +@pytest.fixture +def mock_proxy_config(monkeypatch): + """Mock the proxy_config to avoid actual file operations during tests""" + mock_config = { + "litellm_settings": { + "default_internal_user_params": { + "user_role": LitellmUserRoles.INTERNAL_USER, + "max_budget": 100.0, + "budget_duration": "30d", + "models": ["gpt-3.5-turbo", "gpt-4"], + } + } + } + + async def mock_get_config(): + return mock_config + + # Add a counter to track save_config calls + save_config_call_count = 0 + + async def mock_save_config(new_config=None): + nonlocal mock_config, save_config_call_count + save_config_call_count += 1 + if new_config: + mock_config = new_config + return mock_config + + from litellm.proxy.proxy_server import proxy_config + + monkeypatch.setattr(proxy_config, "get_config", mock_get_config) + monkeypatch.setattr(proxy_config, "save_config", mock_save_config) + + # Return both the config and the call counter + return {"config": mock_config, "save_call_count": lambda: save_config_call_count} + + +@pytest.fixture +def mock_auth(monkeypatch): + """Mock the authentication to bypass auth checks""" + + async def mock_user_api_key_auth(): + return {"user_id": "test_user"} + + from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ( + user_api_key_auth, + ) + + monkeypatch.setattr( + "litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints.user_api_key_auth", + mock_user_api_key_auth, + ) + + +class TestProxySettingEndpoints: + + def test_get_internal_user_settings(self, mock_proxy_config, mock_auth): + """Test getting the internal user settings""" + response = client.get("/get/internal_user_settings") + + assert response.status_code == 200 + data = response.json() + + # Check structure of response + assert "values" in data + assert "schema" in data + + # Check values match our mock config + values = data["values"] + mock_params = mock_proxy_config["config"]["litellm_settings"][ + "default_internal_user_params" + ] + assert values["user_role"] == mock_params["user_role"] + assert values["max_budget"] == mock_params["max_budget"] + assert values["budget_duration"] == mock_params["budget_duration"] + assert values["models"] == mock_params["models"] + + # Check schema contains descriptions + assert "properties" in data["schema"] + assert "user_role" in data["schema"]["properties"] + assert "description" in data["schema"]["properties"]["user_role"] + + def test_update_internal_user_settings( + self, mock_proxy_config, mock_auth, monkeypatch + ): + """Test updating the internal user settings""" + # Mock litellm.default_internal_user_params + import litellm + + monkeypatch.setattr(litellm, "default_internal_user_params", {}) + + # New settings to update + new_settings = { + "user_role": LitellmUserRoles.PROXY_ADMIN, + "max_budget": 200.0, + "budget_duration": "7d", + "models": ["gpt-4", "claude-3"], + } + + response = client.patch("/update/internal_user_settings", json=new_settings) + + assert response.status_code == 200 + data = response.json() + + # Check response structure + assert data["status"] == "success" + assert "settings" in data + + # Verify settings were updated + settings = data["settings"] + assert settings["user_role"] == new_settings["user_role"] + assert settings["max_budget"] == new_settings["max_budget"] + assert settings["budget_duration"] == new_settings["budget_duration"] + assert settings["models"] == new_settings["models"] + + # Verify the config was updated + updated_config = mock_proxy_config["config"]["litellm_settings"][ + "default_internal_user_params" + ] + assert updated_config["user_role"] == new_settings["user_role"] + assert updated_config["max_budget"] == new_settings["max_budget"] + + # Verify save_config was called exactly once + assert mock_proxy_config["save_call_count"]() == 1 diff --git a/ui/litellm-dashboard/src/components/SSOSettings.tsx b/ui/litellm-dashboard/src/components/SSOSettings.tsx new file mode 100644 index 00000000000..426fafafa89 --- /dev/null +++ b/ui/litellm-dashboard/src/components/SSOSettings.tsx @@ -0,0 +1,331 @@ +import React, { useState, useEffect } from "react"; +import { Card, Title, Text, Divider, Button, TextInput } from "@tremor/react"; +import { Typography, Spin, message, Switch, Select, Form } from "antd"; +import { getInternalUserSettings, updateInternalUserSettings, modelAvailableCall } from "./networking"; +import BudgetDurationDropdown, { getBudgetDurationLabel } from "./common_components/budget_duration_dropdown"; +import { getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key"; + +interface SSOSettingsProps { + accessToken: string | null; + possibleUIRoles?: Record> | null; + userID?: string; +} + +const SSOSettings: React.FC = ({ accessToken, possibleUIRoles }) => { + const [loading, setLoading] = useState(true); + const [settings, setSettings] = useState(null); + const [isEditing, setIsEditing] = useState(false); + const [editedValues, setEditedValues] = useState({}); + const [saving, setSaving] = useState(false); + const [availableModels, setAvailableModels] = useState([]); + const { Paragraph } = Typography; + const { Option } = Select; + + useEffect(() => { + const fetchSSOSettings = async () => { + if (!accessToken) { + setLoading(false); + return; + } + + try { + const data = await getInternalUserSettings(accessToken); + setSettings(data); + setEditedValues(data.values || {}); + + // Fetch available models + if (accessToken) { + try { + const modelResponse = await modelAvailableCall(accessToken, null, null); + if (modelResponse && modelResponse.data) { + const modelNames = modelResponse.data.map((model: { id: string }) => model.id); + setAvailableModels(modelNames); + } + } catch (error) { + console.error("Error fetching available models:", error); + } + } + } catch (error) { + console.error("Error fetching SSO settings:", error); + message.error("Failed to fetch SSO settings"); + } finally { + setLoading(false); + } + }; + + fetchSSOSettings(); + }, [accessToken]); + + const handleSaveSettings = async () => { + if (!accessToken) return; + + setSaving(true); + try { + const updatedSettings = await updateInternalUserSettings(accessToken, editedValues); + setSettings({...settings, values: updatedSettings.settings}); + setIsEditing(false); + } catch (error) { + console.error("Error updating SSO settings:", error); + message.error("Failed to update settings"); + } finally { + setSaving(false); + } + }; + + const handleTextInputChange = (key: string, value: any) => { + setEditedValues((prev: Record) => ({ + ...prev, + [key]: value + })); + }; + + const renderEditableField = (key: string, property: any, value: any) => { + const type = property.type; + + if (key === "user_role" && possibleUIRoles) { + return ( + + ); + } else if (key === "budget_duration") { + return ( + handleTextInputChange(key, value)} + className="mt-2" + /> + ); + } else if (type === "boolean") { + return ( +
+ handleTextInputChange(key, checked)} + /> +
+ ); + } else if (type === "array" && property.items?.enum) { + return ( + + ); + } else if (key === "models") { + return ( + + ); + } else if (type === "string" && property.enum) { + return ( + + ); + } else { + return ( + handleTextInputChange(key, e.target.value)} + placeholder={property.description || ""} + className="mt-2" + /> + ); + } + }; + + const renderValue = (key: string, value: any): JSX.Element => { + if (value === null || value === undefined) return Not set; + + if (key === "user_role" && possibleUIRoles && possibleUIRoles[value]) { + const { ui_label, description } = possibleUIRoles[value]; + return ( +
+ {ui_label} + {description &&

{description}

} +
+ ); + } + + if (key === "budget_duration") { + return {getBudgetDurationLabel(value)}; + } + + if (typeof value === "boolean") { + return {value ? "Enabled" : "Disabled"}; + } + + if (key === "models" && Array.isArray(value)) { + if (value.length === 0) return None; + + return ( +
+ {value.map((model, index) => ( + + {getModelDisplayName(model)} + + ))} +
+ ); + } + + if (typeof value === "object") { + if (Array.isArray(value)) { + if (value.length === 0) return None; + + return ( +
+ {value.map((item, index) => ( + + {typeof item === "object" ? JSON.stringify(item) : String(item)} + + ))} +
+ ); + } + + return ( +
+          {JSON.stringify(value, null, 2)}
+        
+ ); + } + + return {String(value)}; + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (!settings) { + return ( + + No settings available or you do not have permission to view them. + + ); + } + + // Dynamically render settings based on the schema + const renderSettings = () => { + const { values, schema } = settings; + + if (!schema || !schema.properties) { + return No schema information available; + } + + return Object.entries(schema.properties).map(([key, property]: [string, any]) => { + const value = values[key]; + const displayName = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); + + return ( +
+ {displayName} + + {property.description || "No description available"} + + + {isEditing ? ( +
+ {renderEditableField(key, property, value)} +
+ ) : ( +
+ {renderValue(key, value)} +
+ )} +
+ ); + }); + }; + + return ( + +
+ {!loading && settings && ( + isEditing ? ( +
+ + +
+ ) : ( + + ) + )} +
+ + {settings?.schema?.description && ( + {settings.schema.description} + )} + + +
+ {renderSettings()} +
+
+ ); +}; + +export default SSOSettings; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/common_components/budget_duration_dropdown.tsx b/ui/litellm-dashboard/src/components/common_components/budget_duration_dropdown.tsx new file mode 100644 index 00000000000..9171e13381c --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/budget_duration_dropdown.tsx @@ -0,0 +1,46 @@ +import React from "react"; +import { Select } from "antd"; + +const { Option } = Select; + +interface BudgetDurationDropdownProps { + value?: string | null; + onChange: (value: string) => void; + className?: string; + style?: React.CSSProperties; +} + +const BudgetDurationDropdown: React.FC = ({ + value, + onChange, + className = "", + style = {} +}) => { + return ( + + ); +}; + +export const getBudgetDurationLabel = (value: string | null | undefined): string => { + if (!value) return "Not set"; + + const budgetDurationMap: Record = { + "24h": "daily", + "7d": "weekly", + "30d": "monthly" + }; + + return budgetDurationMap[value] || value; +}; + +export default BudgetDurationDropdown; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/create_key_button.tsx b/ui/litellm-dashboard/src/components/create_key_button.tsx index 0f93f61ff77..cc0a3899289 100644 --- a/ui/litellm-dashboard/src/components/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/create_key_button.tsx @@ -40,6 +40,7 @@ import { Tooltip } from 'antd'; import Createuser from "./create_user_button"; import debounce from 'lodash/debounce'; import { rolesWithWriteAccess } from '../utils/roles'; +import BudgetDurationDropdown from "./common_components/budget_duration_dropdown"; @@ -572,11 +573,7 @@ const CreateKey: React.FC = ({ name="budget_duration" help={`Team Reset Budget: ${team?.budget_duration !== null && team?.budget_duration !== undefined ? team?.budget_duration : "None"}`} > - + form.setFieldValue('budget_duration', value)} /> = ({ visible, possibleUIRoles, label="User Budget (USD)" name="max_budget" tooltip="(float) - Maximum budget of this user" - help="Ignored if the key has a team_id; team budget applies there." + help="Maximum budget of this user." > diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 70e84dc9167..1079b5b2787 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -3970,7 +3970,73 @@ export const uiSpendLogDetailsCall = async ( return data; } catch (error) { console.error("Failed to fetch log details:", error); + throw error; + } +}; + +export const getInternalUserSettings = async (accessToken: string) => { + try { + // Construct base URL + let url = proxyBaseUrl + ? `${proxyBaseUrl}/get/internal_user_settings` + : `/get/internal_user_settings`; + + console.log("Fetching SSO settings from:", url); + + 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("Fetched SSO settings:", data); + return data; + } catch (error) { + console.error("Failed to fetch SSO settings:", error); throw error; } }; + +export const updateInternalUserSettings = async (accessToken: string, settings: Record) => { + try { + // Construct base URL + let url = proxyBaseUrl + ? `${proxyBaseUrl}/update/internal_user_settings` + : `/update/internal_user_settings`; + + console.log("Updating internal user settings:", settings); + + const response = await fetch(url, { + method: "PATCH", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(settings), + }); + + 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("Updated internal user settings:", data); + message.success("Internal user settings updated successfully"); + return data; + } catch (error) { + console.error("Failed to update internal user settings:", error); + throw error; + } +}; diff --git a/ui/litellm-dashboard/src/components/view_users.tsx b/ui/litellm-dashboard/src/components/view_users.tsx index d97acbce6d1..d776cae2be0 100644 --- a/ui/litellm-dashboard/src/components/view_users.tsx +++ b/ui/litellm-dashboard/src/components/view_users.tsx @@ -49,6 +49,7 @@ import { columns } from "./view_users/columns"; import { UserDataTable } from "./view_users/table"; import { UserInfo } from "./view_users/types"; import BulkCreateUsers from "./bulk_create_users_button"; +import SSOSettings from "./SSOSettings"; interface ViewUserDashboardProps { accessToken: string | null; @@ -106,6 +107,7 @@ const ViewUserDashboard: React.FC = ({ >({}); const defaultPageSize = 25; const [searchTerm, setSearchTerm] = useState(""); + const [activeTab, setActiveTab] = useState("users"); // check if window is not undefined if (typeof window !== "undefined") { @@ -285,56 +287,71 @@ const ViewUserDashboard: React.FC = ({ -
-
-
-
- - Showing{" "} - {userListResponse && userListResponse.users && userListResponse.users.length > 0 - ? (userListResponse.page - 1) * userListResponse.page_size + 1 - : 0}{" "} - -{" "} - {userListResponse && userListResponse.users - ? Math.min( - userListResponse.page * userListResponse.page_size, - userListResponse.total - ) - : 0}{" "} - of {userListResponse ? userListResponse.total : 0} results - -
- - - Page {userListResponse ? userListResponse.page : "-"} of{" "} - {userListResponse ? userListResponse.total_pages : "-"} - - + setActiveTab(index === 0 ? "users" : "settings")}> + + Users + Default User Settings + + + + +
+
+
+
+ + Showing{" "} + {userListResponse && userListResponse.users && userListResponse.users.length > 0 + ? (userListResponse.page - 1) * userListResponse.page_size + 1 + : 0}{" "} + -{" "} + {userListResponse && userListResponse.users + ? Math.min( + userListResponse.page * userListResponse.page_size, + userListResponse.total + ) + : 0}{" "} + of {userListResponse ? userListResponse.total : 0} results + +
+ + + Page {userListResponse ? userListResponse.page : "-"} of{" "} + {userListResponse ? userListResponse.total_pages : "-"} + + +
+
+
+
-
-
- -
+ + + + + + + {/* Existing Modals */}