diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 23051c8eec..91cc80b8ca 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1114,6 +1114,7 @@ class NewOrganizationRequest(LiteLLM_BudgetTable): organization_alias: str models: List = [] budget_id: Optional[str] = None + metadata: Optional[dict] = None class OrganizationRequest(LiteLLMPydanticObjectBase): diff --git a/litellm/proxy/management_endpoints/organization_endpoints.py b/litellm/proxy/management_endpoints/organization_endpoints.py index 3b6c625abe..c202043fbe 100644 --- a/litellm/proxy/management_endpoints/organization_endpoints.py +++ b/litellm/proxy/management_endpoints/organization_endpoints.py @@ -62,13 +62,12 @@ async def new_organization( - soft_budget: *Optional[float]* - [Not Implemented Yet] Get a slack alert when this soft budget is reached. Don't block requests. - model_max_budget: *Optional[dict]* - Max budget for a specific model - budget_duration: *Optional[str]* - Frequency of reseting org budget - - metadata: *Optional[dict]* - Metadata for team, store information for team. Example metadata - {"extra_info": "some info"} + - metadata: *Optional[dict]* - Metadata for organization, store information for organization. Example metadata - {"extra_info": "some info"} - blocked: *bool* - Flag indicating if the org is blocked or not - will stop all calls from keys with this org_id. - tags: *Optional[List[str]]* - Tags for [tracking spend](https://litellm.vercel.app/docs/proxy/enterprise#tracking-spend-for-custom-tags) and/or doing [tag-based routing](https://litellm.vercel.app/docs/proxy/tag_routing). - organization_id: *Optional[str]* - The organization id of the team. Default is None. Create via `/organization/new`. - model_aliases: Optional[dict] - Model aliases for the team. [Docs](https://docs.litellm.ai/docs/proxy/team_based_routing#create-team-with-model-alias) - Case 1: Create new org **without** a budget_id ```bash @@ -103,6 +102,7 @@ async def new_organization( }' ``` """ + from litellm.proxy.proxy_server import litellm_proxy_admin_name, prisma_client if prisma_client is None: @@ -174,6 +174,9 @@ async def new_organization( new_organization_row = prisma_client.jsonify_object( organization_row.json(exclude_none=True) ) + verbose_proxy_logger.info( + f"new_organization_row: {json.dumps(new_organization_row, indent=2)}" + ) response = await prisma_client.db.litellm_organizationtable.create( data={ **new_organization_row, # type: ignore @@ -215,9 +218,13 @@ async def update_organization( if data.updated_by is None: data.updated_by = user_api_key_dict.user_id + updated_organization_row = prisma_client.jsonify_object( + data.model_dump(exclude_none=True) + ) + response = await prisma_client.db.litellm_organizationtable.update( where={"organization_id": data.organization_id}, - data=data.model_dump(exclude_none=True), + data=updated_organization_row, include={"members": True, "teams": True, "litellm_budget_table": True}, ) diff --git a/tests/local_testing/test_parallel_request_limiter.py b/tests/local_testing/test_parallel_request_limiter.py index b80dc22446..8b34e03454 100644 --- a/tests/local_testing/test_parallel_request_limiter.py +++ b/tests/local_testing/test_parallel_request_limiter.py @@ -410,6 +410,7 @@ async def test_success_call_hook(): ) +@pytest.mark.flaky(retries=6, delay=1) @pytest.mark.asyncio async def test_failure_call_hook(): """ diff --git a/ui/litellm-dashboard/src/components/common_components/check_openapi_schema.tsx b/ui/litellm-dashboard/src/components/common_components/check_openapi_schema.tsx new file mode 100644 index 0000000000..6f439508d7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/check_openapi_schema.tsx @@ -0,0 +1,258 @@ +import React, { useState, useEffect } from 'react'; +import { Form, Input, InputNumber, Select } from 'antd'; +import { TextInput } from "@tremor/react"; +import { InfoCircleOutlined } from '@ant-design/icons'; +import { Tooltip } from 'antd'; +import { getOpenAPISchema } from '../networking'; + +interface SchemaProperty { + type?: string; + title?: string; + description?: string; + anyOf?: Array<{ type: string }>; + enum?: string[]; + format?: string; +} + +interface OpenAPISchema { + properties: { + [key: string]: SchemaProperty; + }; + required?: string[]; +} + +interface SchemaFormFieldsProps { + schemaComponent: string; + excludedFields?: string[]; + form: any; + overrideLabels?: { [key: string]: string }; + overrideTooltips?: { [key: string]: string }; + customValidation?: { + [key: string]: (rule: any, value: any) => Promise + }; + defaultValues?: { [key: string]: any }; +} + +// Helper function to determine if a field should be treated as JSON +const isJSONField = (key: string, property: SchemaProperty): boolean => { + const jsonFields = ['metadata', 'config', 'enforced_params', 'aliases']; + return jsonFields.includes(key) || property.format === 'json'; +}; + +// Helper function to validate JSON input +const validateJSON = (value: string): boolean => { + if (!value) return true; + try { + JSON.parse(value); + return true; + } catch { + return false; + } +}; + +const getFieldHelp = (key: string, property: SchemaProperty, type: string): string => { + // Default help text based on type + const defaultHelp = { + string: 'Text input', + number: 'Numeric input', + integer: 'Whole number input', + boolean: 'True/False value', + }[type] || 'Text input'; + + // Specific field help text + const specificHelp: { [key: string]: string } = { + max_budget: 'Enter maximum budget in USD (e.g., 100.50)', + budget_duration: 'Select a time period for budget reset', + tpm_limit: 'Enter maximum tokens per minute (whole number)', + rpm_limit: 'Enter maximum requests per minute (whole number)', + duration: 'Enter duration (e.g., 30s, 24h, 7d)', + metadata: 'Enter JSON object with key-value pairs\nExample: {"team": "research", "project": "nlp"}', + config: 'Enter configuration as JSON object\nExample: {"setting": "value"}', + permissions: 'Enter comma-separated permission strings', + enforced_params: 'Enter parameters as JSON object\nExample: {"param": "value"}', + blocked: 'Enter true/false or specific block conditions', + aliases: 'Enter aliases as JSON object\nExample: {"alias1": "value1", "alias2": "value2"}', + models: 'Select one or more model names', + key_alias: 'Enter a unique identifier for this key', + tags: 'Enter comma-separated tag strings', + }; + + // Get specific help text or use default based on type + const helpText = specificHelp[key] || defaultHelp; + + // Add format requirements for special cases + if (isJSONField(key, property)) { + return `${helpText}\nMust be valid JSON format`; + } + + if (property.enum) { + return `Select from available options\nAllowed values: ${property.enum.join(', ')}`; + } + + return helpText; +}; + +const SchemaFormFields: React.FC = ({ + schemaComponent, + excludedFields = [], + form, + overrideLabels = {}, + overrideTooltips = {}, + customValidation = {}, + defaultValues = {} +}) => { + const [schemaProperties, setSchemaProperties] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchOpenAPISchema = async () => { + try { + const schema = await getOpenAPISchema(); + const componentSchema = schema.components.schemas[schemaComponent]; + + if (!componentSchema) { + throw new Error(`Schema component "${schemaComponent}" not found`); + } + + setSchemaProperties(componentSchema); + + const defaultFormValues: { [key: string]: any } = {}; + Object.keys(componentSchema.properties) + .filter(key => !excludedFields.includes(key) && defaultValues[key] !== undefined) + .forEach(key => { + defaultFormValues[key] = defaultValues[key]; + }); + + form.setFieldsValue(defaultFormValues); + + } catch (error) { + console.error('Schema fetch error:', error); + setError(error instanceof Error ? error.message : 'Failed to fetch schema'); + } + }; + + fetchOpenAPISchema(); + }, [schemaComponent, form, excludedFields]); + + const getPropertyType = (property: SchemaProperty): string => { + if (property.type) { + return property.type; + } + if (property.anyOf) { + const types = property.anyOf.map(t => t.type); + if (types.includes('number') || types.includes('integer')) return 'number'; + if (types.includes('string')) return 'string'; + } + return 'string'; + }; + + const renderFormItem = (key: string, property: SchemaProperty) => { + const type = getPropertyType(property); + const isRequired = schemaProperties?.required?.includes(key); + + const label = overrideLabels[key] || property.title || key; + const tooltip = overrideTooltips[key] || property.description; + + const rules = []; + if (isRequired) { + rules.push({ required: true, message: `${label} is required` }); + } + if (customValidation[key]) { + rules.push({ validator: customValidation[key] }); + } + if (isJSONField(key, property)) { + rules.push({ + validator: async (_: any, value: string) => { + if (value && !validateJSON(value)) { + throw new Error('Please enter valid JSON'); + } + } + }); + } + + const formLabel = tooltip ? ( + + {label}{' '} + + + + + ) : label; + + let inputComponent; + if (isJSONField(key, property)) { + inputComponent = ( + + ); + } else if (property.enum) { + inputComponent = ( + + ); + } else if (type === 'number' || type === 'integer') { + inputComponent = ( + + ); + } else if (key === 'duration') { + inputComponent = ( + + ); + } else { + inputComponent = ( + + ); + } + + return ( + + {getFieldHelp(key, property, type)} + + } + > + {inputComponent} + + ); + }; + + if (error) { + return
Error: {error}
; + } + + if (!schemaProperties?.properties) { + return null; + } + + return ( +
+ {Object.entries(schemaProperties.properties) + .filter(([key]) => !excludedFields.includes(key)) + .map(([key, property]) => renderFormItem(key, property))} +
+ ); +}; + +export default SchemaFormFields; \ 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 0464696cf7..47ceebbf94 100644 --- a/ui/litellm-dashboard/src/components/create_key_button.tsx +++ b/ui/litellm-dashboard/src/components/create_key_button.tsx @@ -1,5 +1,4 @@ "use client"; - import React, { useState, useEffect, useRef } from "react"; import { Button, TextInput, Grid, Col } from "@tremor/react"; import { @@ -24,11 +23,13 @@ import { Radio, } from "antd"; import { unfurlWildcardModelsInList, getModelDisplayName } from "./key_team_helpers/fetch_available_models_team_key"; +import SchemaFormFields from './common_components/check_openapi_schema'; import { keyCreateCall, slackBudgetAlertsHealthCheck, modelAvailableCall, getGuardrailsList, + proxyBaseUrl, } from "./networking"; import { Team } from "./key_team_helpers/key_list"; import TeamDropdown from "./common_components/team_dropdown"; @@ -474,6 +475,36 @@ const CreateKey: React.FC = ({ options={predefinedTags} /> + + +
+ + Advanced Settings + + Learn more about advanced settings in our{' '} + + documentation + + + }> + + +
+
+ + + +
diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index f4a1a40fbf..b58a74c196 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -4,7 +4,7 @@ import { message } from "antd"; const isLocal = process.env.NODE_ENV === "development"; -const proxyBaseUrl = isLocal ? "http://localhost:4000" : null; +export const proxyBaseUrl = isLocal ? "http://localhost:4000" : null; if (isLocal != true) { console.log = function() {}; } @@ -69,6 +69,12 @@ export function setGlobalLitellmHeaderName(headerName: string = "Authorization") globalLitellmHeaderName = headerName; } +export const getOpenAPISchema = async () => { + const url = proxyBaseUrl ? `${proxyBaseUrl}/openapi.json` : `/openapi.json`; + const response = await fetch(url); + const jsonData = await response.json(); + return jsonData; +} export const modelCostMap = async ( accessToken: string, @@ -882,6 +888,17 @@ export const organizationCreateCall = async ( try { console.log("Form Values in organizationCreateCall:", formValues); // Log the form values before making the API call + if (formValues.metadata) { + console.log("formValues.metadata:", formValues.metadata); + // if there's an exception JSON.parse, show it in the message + try { + formValues.metadata = JSON.parse(formValues.metadata); + } catch (error) { + console.error("Failed to parse metadata:", error); + throw new Error("Failed to parse metadata: " + error); + } + } + const url = proxyBaseUrl ? `${proxyBaseUrl}/organization/new` : `/organization/new`; const response = await fetch(url, { method: "POST", @@ -911,6 +928,42 @@ export const organizationCreateCall = async ( } }; +export const organizationUpdateCall = async ( + accessToken: string, + formValues: Record // Assuming formValues is an object +) => { + try { + console.log("Form Values in organizationUpdateCall:", formValues); // Log the form values before making the API call + + const url = proxyBaseUrl ? `${proxyBaseUrl}/organization/update` : `/organization/update`; + const response = await fetch(url, { + method: "PATCH", + headers: { + [globalLitellmHeaderName]: `Bearer ${accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + ...formValues, // Include formValues in the request body + }), + }); + + if (!response.ok) { + const errorData = await response.text(); + handleError(errorData); + console.error("Error response from the server:", errorData); + throw new Error("Network response was not ok"); + } + const data = await response.json(); + console.log("Update Team 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 organizationDeleteCall = async ( accessToken: string, organizationID: string @@ -2377,6 +2430,15 @@ export const teamCreateCall = async ( ) => { try { console.log("Form Values in teamCreateCall:", formValues); // Log the form values before making the API call + if (formValues.metadata) { + console.log("formValues.metadata:", formValues.metadata); + // if there's an exception JSON.parse, show it in the message + try { + formValues.metadata = JSON.parse(formValues.metadata); + } catch (error) { + throw new Error("Failed to parse metadata: " + error); + } + } const url = proxyBaseUrl ? `${proxyBaseUrl}/team/new` : `/team/new`; const response = await fetch(url, { diff --git a/ui/litellm-dashboard/src/components/organization/add_org.tsx b/ui/litellm-dashboard/src/components/organization/add_org.tsx deleted file mode 100644 index 958f2205dc..0000000000 --- a/ui/litellm-dashboard/src/components/organization/add_org.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { - Button as Button2, - Modal, - Form, - Select as Select2, - InputNumber, - message, -} from "antd"; - -import { - TextInput, - Button, -} from "@tremor/react"; - -import { organizationCreateCall } from "../networking"; - -// types.ts -export interface FormData { - name: string; - models: string[]; - maxBudget: number | null; - budgetDuration: string | null; - tpmLimit: number | null; - rpmLimit: number | null; -} - -export interface OrganizationFormProps { - title?: string; - onCancel?: () => void; - accessToken: string | null; - availableModels?: string[]; - initialValues?: Partial; - submitButtonText?: string; - modelSelectionType?: 'single' | 'multiple'; -} - -// OrganizationForm.tsx -import React, { useState } from 'react'; - -const onSubmit = async (formValues: Record, accessToken: string | null, setIsModalVisible: any) => { - if (accessToken == null) { - return; - } - try { - message.info("Creating Organization"); - console.log("formValues: " + JSON.stringify(formValues)); - const response: any = await organizationCreateCall(accessToken, formValues); - console.log(`response for organization create call: ${response}`); - message.success("Organization created"); - sessionStorage.removeItem('organizations'); - setIsModalVisible(false); - } catch (error) { - console.error("Error creating the organization:", error); - message.error("Error creating the organization: " + error, 20); - } - -} - -const OrganizationForm: React.FC = ({ - title = "Create Organization", - onCancel, - accessToken, - availableModels = [], - initialValues = {}, - submitButtonText = "Create", - modelSelectionType = "multiple", -}) => { - const [form] = Form.useForm(); - const [isModalVisible, setIsModalVisible] = useState(false); - const [formData, setFormData] = useState({ - name: initialValues.name || '', - models: initialValues.models || [], - maxBudget: initialValues.maxBudget || null, - budgetDuration: initialValues.budgetDuration || null, - tpmLimit: initialValues.tpmLimit || null, - rpmLimit: initialValues.rpmLimit || null - }); - - console.log(`availableModels: ${availableModels}`) - - const handleSubmit = async (formValues: Record) => { - if (accessToken == null) { - return; - } - await onSubmit(formValues, accessToken, setIsModalVisible); - setIsModalVisible(false); - }; - - const handleCancel = (): void => { - setIsModalVisible(false); - if (onCancel) onCancel(); - }; - - return ( -
- - - -
- <> - - - - - - - All Proxy Models - - {availableModels.map((model) => ( - - {model} - - ))} - - - - - - - - - daily - weekly - monthly - - - - - - - - - -
- {submitButtonText} -
-
-
-
- ); -}; - -export default OrganizationForm; \ No newline at end of file diff --git a/ui/litellm-dashboard/src/components/organization/organization_view.tsx b/ui/litellm-dashboard/src/components/organization/organization_view.tsx index 593e832ce1..00e9941976 100644 --- a/ui/litellm-dashboard/src/components/organization/organization_view.tsx +++ b/ui/litellm-dashboard/src/components/organization/organization_view.tsx @@ -23,7 +23,7 @@ import { Button, Form, Input, Select, message, InputNumber, Tooltip } from "antd import { InfoCircleOutlined } from '@ant-design/icons'; import { PencilAltIcon, TrashIcon } from "@heroicons/react/outline"; import { getModelDisplayName } from "../key_team_helpers/fetch_available_models_team_key"; -import { Member, Organization, organizationInfoCall, organizationMemberAddCall, organizationMemberUpdateCall, organizationMemberDeleteCall } from "../networking"; +import { Member, Organization, organizationInfoCall, organizationMemberAddCall, organizationMemberUpdateCall, organizationMemberDeleteCall, organizationUpdateCall } from "../networking"; import UserSearchModal from "../common_components/user_search_modal"; import MemberModal from "../team/edit_membership"; @@ -146,20 +146,12 @@ const OrganizationInfoView: React.FC = ({ rpm_limit: values.rpm_limit, max_budget: values.max_budget, budget_duration: values.budget_duration, - } + }, + metadata: values.metadata ? JSON.parse(values.metadata) : null, }; - const response = await fetch('/organization/update', { - method: 'POST', - headers: { - 'Authorization': `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(updateData), - }); + const response = await organizationUpdateCall(accessToken, updateData); - if (!response.ok) throw new Error('Failed to update organization'); - message.success("Organization settings updated successfully"); setIsEditing(false); fetchOrgInfo(); @@ -337,6 +329,7 @@ const OrganizationInfoView: React.FC = ({ rpm_limit: orgData.litellm_budget_table.rpm_limit, max_budget: orgData.litellm_budget_table.max_budget, budget_duration: orgData.litellm_budget_table.budget_duration, + metadata: orgData.metadata ? JSON.stringify(orgData.metadata, null, 2) : "", }} layout="vertical" > @@ -384,6 +377,10 @@ const OrganizationInfoView: React.FC = ({ + + + +
diff --git a/ui/litellm-dashboard/src/components/team/team_info.tsx b/ui/litellm-dashboard/src/components/team/team_info.tsx index ebd2b2e216..7de7396980 100644 --- a/ui/litellm-dashboard/src/components/team/team_info.tsx +++ b/ui/litellm-dashboard/src/components/team/team_info.tsx @@ -348,7 +348,8 @@ const TeamInfoView: React.FC = ({ rpm_limit: info.rpm_limit, max_budget: info.max_budget, budget_duration: info.budget_duration, - guardrails: info.metadata?.guardrails || [] + guardrails: info.metadata?.guardrails || [], + metadata: info.metadata ? JSON.stringify(info.metadata, null, 2) : "", }} layout="vertical" > @@ -420,6 +421,10 @@ const TeamInfoView: React.FC = ({ placeholder="Select or enter guardrails" /> + + + +