mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
Merge pull request #9328 from BerriAI/litellm_control_internal_user_settings_ui
[UI] - Allow controlling default internal user settings on ui
This commit is contained in:
commit
d42f84dd4e
10 changed files with 781 additions and 55 deletions
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
331
ui/litellm-dashboard/src/components/SSOSettings.tsx
Normal file
331
ui/litellm-dashboard/src/components/SSOSettings.tsx
Normal file
|
|
@ -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<string, Record<string, string>> | null;
|
||||
userID?: string;
|
||||
}
|
||||
|
||||
const SSOSettings: React.FC<SSOSettingsProps> = ({ accessToken, possibleUIRoles }) => {
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [settings, setSettings] = useState<any>(null);
|
||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||
const [editedValues, setEditedValues] = useState<any>({});
|
||||
const [saving, setSaving] = useState<boolean>(false);
|
||||
const [availableModels, setAvailableModels] = useState<string[]>([]);
|
||||
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<string, any>) => ({
|
||||
...prev,
|
||||
[key]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const renderEditableField = (key: string, property: any, value: any) => {
|
||||
const type = property.type;
|
||||
|
||||
if (key === "user_role" && possibleUIRoles) {
|
||||
return (
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={editedValues[key] || ""}
|
||||
onChange={(value) => handleTextInputChange(key, value)}
|
||||
className="mt-2"
|
||||
>
|
||||
{Object.entries(possibleUIRoles)
|
||||
.filter(([role]) => role.includes("internal_user"))
|
||||
.map(([role, { ui_label, description }]) => (
|
||||
<Option key={role} value={role}>
|
||||
<div className="flex items-center">
|
||||
<span>{ui_label}</span>
|
||||
<span className="ml-2 text-xs text-gray-500">{description}</span>
|
||||
</div>
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
} else if (key === "budget_duration") {
|
||||
return (
|
||||
<BudgetDurationDropdown
|
||||
value={editedValues[key] || null}
|
||||
onChange={(value) => handleTextInputChange(key, value)}
|
||||
className="mt-2"
|
||||
/>
|
||||
);
|
||||
} else if (type === "boolean") {
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<Switch
|
||||
checked={!!editedValues[key]}
|
||||
onChange={(checked) => handleTextInputChange(key, checked)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else if (type === "array" && property.items?.enum) {
|
||||
return (
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ width: '100%' }}
|
||||
value={editedValues[key] || []}
|
||||
onChange={(value) => handleTextInputChange(key, value)}
|
||||
className="mt-2"
|
||||
>
|
||||
{property.items.enum.map((option: string) => (
|
||||
<Option key={option} value={option}>{option}</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
} else if (key === "models") {
|
||||
return (
|
||||
<Select
|
||||
mode="multiple"
|
||||
style={{ width: '100%' }}
|
||||
value={editedValues[key] || []}
|
||||
onChange={(value) => handleTextInputChange(key, value)}
|
||||
className="mt-2"
|
||||
>
|
||||
{availableModels.map((model: string) => (
|
||||
<Option key={model} value={model}>
|
||||
{getModelDisplayName(model)}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
} else if (type === "string" && property.enum) {
|
||||
return (
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={editedValues[key] || ""}
|
||||
onChange={(value) => handleTextInputChange(key, value)}
|
||||
className="mt-2"
|
||||
>
|
||||
{property.enum.map((option: string) => (
|
||||
<Option key={option} value={option}>{option}</Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<TextInput
|
||||
value={editedValues[key] !== undefined ? String(editedValues[key]) : ""}
|
||||
onChange={(e) => 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 <span className="text-gray-400">Not set</span>;
|
||||
|
||||
if (key === "user_role" && possibleUIRoles && possibleUIRoles[value]) {
|
||||
const { ui_label, description } = possibleUIRoles[value];
|
||||
return (
|
||||
<div>
|
||||
<span className="font-medium">{ui_label}</span>
|
||||
{description && <p className="text-xs text-gray-500 mt-1">{description}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (key === "budget_duration") {
|
||||
return <span>{getBudgetDurationLabel(value)}</span>;
|
||||
}
|
||||
|
||||
if (typeof value === "boolean") {
|
||||
return <span>{value ? "Enabled" : "Disabled"}</span>;
|
||||
}
|
||||
|
||||
if (key === "models" && Array.isArray(value)) {
|
||||
if (value.length === 0) return <span className="text-gray-400">None</span>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{value.map((model, index) => (
|
||||
<span key={index} className="px-2 py-1 bg-blue-100 rounded text-xs">
|
||||
{getModelDisplayName(model)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof value === "object") {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return <span className="text-gray-400">None</span>;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{value.map((item, index) => (
|
||||
<span key={index} className="px-2 py-1 bg-blue-100 rounded text-xs">
|
||||
{typeof item === "object" ? JSON.stringify(item) : String(item)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<pre className="bg-gray-100 p-2 rounded text-xs overflow-auto mt-1">
|
||||
{JSON.stringify(value, null, 2)}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
return <span>{String(value)}</span>;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!settings) {
|
||||
return (
|
||||
<Card>
|
||||
<Text>No settings available or you do not have permission to view them.</Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// Dynamically render settings based on the schema
|
||||
const renderSettings = () => {
|
||||
const { values, schema } = settings;
|
||||
|
||||
if (!schema || !schema.properties) {
|
||||
return <Text>No schema information available</Text>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div key={key} className="mb-6 pb-6 border-b border-gray-200 last:border-0">
|
||||
<Text className="font-medium text-lg">{displayName}</Text>
|
||||
<Paragraph className="text-sm text-gray-500 mt-1">
|
||||
{property.description || "No description available"}
|
||||
</Paragraph>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="mt-2">
|
||||
{renderEditableField(key, property, value)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded">
|
||||
{renderValue(key, value)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex justify-end items-center mb-4">
|
||||
{!loading && settings && (
|
||||
isEditing ? (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setIsEditing(false);
|
||||
setEditedValues(settings.values || {});
|
||||
}}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSaveSettings}
|
||||
loading={saving}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
Edit Settings
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{settings?.schema?.description && (
|
||||
<Paragraph className="mb-4">{settings.schema.description}</Paragraph>
|
||||
)}
|
||||
<Divider />
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
{renderSettings()}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SSOSettings;
|
||||
|
|
@ -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<BudgetDurationDropdownProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
className = "",
|
||||
style = {}
|
||||
}) => {
|
||||
return (
|
||||
<Select
|
||||
style={{ width: '100%', ...style }}
|
||||
value={value || undefined}
|
||||
onChange={onChange}
|
||||
className={className}
|
||||
placeholder="n/a"
|
||||
>
|
||||
<Option value="24h">daily</Option>
|
||||
<Option value="7d">weekly</Option>
|
||||
<Option value="30d">monthly</Option>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export const getBudgetDurationLabel = (value: string | null | undefined): string => {
|
||||
if (!value) return "Not set";
|
||||
|
||||
const budgetDurationMap: Record<string, string> = {
|
||||
"24h": "daily",
|
||||
"7d": "weekly",
|
||||
"30d": "monthly"
|
||||
};
|
||||
|
||||
return budgetDurationMap[value] || value;
|
||||
};
|
||||
|
||||
export default BudgetDurationDropdown;
|
||||
|
|
@ -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<CreateKeyProps> = ({
|
|||
name="budget_duration"
|
||||
help={`Team Reset Budget: ${team?.budget_duration !== null && team?.budget_duration !== undefined ? team?.budget_duration : "None"}`}
|
||||
>
|
||||
<Select defaultValue={null} placeholder="n/a">
|
||||
<Select.Option value="24h">daily</Select.Option>
|
||||
<Select.Option value="7d">weekly</Select.Option>
|
||||
<Select.Option value="30d">monthly</Select.Option>
|
||||
</Select>
|
||||
<BudgetDurationDropdown onChange={(value) => form.setFieldValue('budget_duration', value)} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="mt-4"
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ const EditUserModal: React.FC<EditUserModalProps> = ({ 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."
|
||||
>
|
||||
<InputNumber min={0} step={1} />
|
||||
</Form.Item>
|
||||
|
|
|
|||
|
|
@ -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<string, any>) => {
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<ViewUserDashboardProps> = ({
|
|||
>({});
|
||||
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<ViewUserDashboardProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="border-b px-6 py-4">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between space-y-4 md:space-y-0">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-700">
|
||||
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
|
||||
</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={!userListResponse || currentPage <= 1}
|
||||
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-gray-700">
|
||||
Page {userListResponse ? userListResponse.page : "-"} of{" "}
|
||||
{userListResponse ? userListResponse.total_pages : "-"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => p + 1)}
|
||||
disabled={
|
||||
!userListResponse ||
|
||||
currentPage >= userListResponse.total_pages
|
||||
}
|
||||
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<TabGroup defaultIndex={0} onIndexChange={(index) => setActiveTab(index === 0 ? "users" : "settings")}>
|
||||
<TabList className="mb-4">
|
||||
<Tab>Users</Tab>
|
||||
<Tab>Default User Settings</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<div className="bg-white rounded-lg shadow">
|
||||
<div className="border-b px-6 py-4">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between space-y-4 md:space-y-0">
|
||||
<div className="flex items-center space-x-4">
|
||||
<span className="text-sm text-gray-700">
|
||||
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
|
||||
</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={!userListResponse || currentPage <= 1}
|
||||
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="text-sm text-gray-700">
|
||||
Page {userListResponse ? userListResponse.page : "-"} of{" "}
|
||||
{userListResponse ? userListResponse.total_pages : "-"}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setCurrentPage((p) => p + 1)}
|
||||
disabled={
|
||||
!userListResponse ||
|
||||
currentPage >= userListResponse.total_pages
|
||||
}
|
||||
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UserDataTable
|
||||
data={userData || []}
|
||||
columns={tableColumns}
|
||||
isLoading={!userData}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<UserDataTable
|
||||
data={userData || []}
|
||||
columns={tableColumns}
|
||||
isLoading={!userData}
|
||||
/>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
<SSOSettings accessToken={accessToken} possibleUIRoles={possibleUIRoles} userID={userID}/>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
|
||||
{/* Existing Modals */}
|
||||
<EditUserModal
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue