Add all /key/generate api params to UI + add metadata fields on team AND org add/update (#8667)

* feat(create_key_button.tsx): initial commit using openapi.json to ensure all values via api are supported on ui for `/key/generate`

Closes https://github.com/BerriAI/litellm/issues/7763

* style(create_key_button.tsx): put openapi settings inside 'advanced setting' accordion

* fix(check_openapi_schema.tsx): style improvements for advanced settings

* style(create_key_button.tsx): add tooltip explaining what the settings mean

* fix(team_info.tsx): render metadata field on team update

allow updating a team's metadata

* fix(networking.tsx): add 'metadata' field to create team form

* refactor: cleanup dead codeblock

* fix(organization_endpoints.py): fix metadata param support on `/organization/new`

* feat(organization_endpoints.py): support updating metadata for organization on api + ui

* test: mark flaky test
This commit is contained in:
Krish Dholakia 2025-02-19 21:13:06 -08:00 committed by GitHub
parent 40d1576292
commit cc77138b37
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 392 additions and 209 deletions

View file

@ -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):

View file

@ -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},
)

View file

@ -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():
"""

View file

@ -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<void>
};
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<SchemaFormFieldsProps> = ({
schemaComponent,
excludedFields = [],
form,
overrideLabels = {},
overrideTooltips = {},
customValidation = {},
defaultValues = {}
}) => {
const [schemaProperties, setSchemaProperties] = useState<OpenAPISchema | null>(null);
const [error, setError] = useState<string | null>(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 ? (
<span>
{label}{' '}
<Tooltip title={tooltip}>
<InfoCircleOutlined style={{ marginLeft: '4px' }} />
</Tooltip>
</span>
) : label;
let inputComponent;
if (isJSONField(key, property)) {
inputComponent = (
<Input.TextArea
rows={4}
placeholder="Enter as JSON"
className="font-mono"
/>
);
} else if (property.enum) {
inputComponent = (
<Select>
{property.enum.map(value => (
<Select.Option key={value} value={value}>
{value}
</Select.Option>
))}
</Select>
);
} else if (type === 'number' || type === 'integer') {
inputComponent = (
<InputNumber
style={{ width: '100%' }}
precision={type === 'integer' ? 0 : undefined}
/>
);
} else if (key === 'duration') {
inputComponent = (
<TextInput
placeholder="eg: 30s, 30h, 30d"
/>
);
} else {
inputComponent = (
<TextInput
placeholder={tooltip || ''}
/>
);
}
return (
<Form.Item
key={key}
label={formLabel}
name={key}
className="mt-8"
rules={rules}
initialValue={defaultValues[key]}
help={
<div className="text-xs text-gray-500">
{getFieldHelp(key, property, type)}
</div>
}
>
{inputComponent}
</Form.Item>
);
};
if (error) {
return <div className="text-red-500">Error: {error}</div>;
}
if (!schemaProperties?.properties) {
return null;
}
return (
<div>
{Object.entries(schemaProperties.properties)
.filter(([key]) => !excludedFields.includes(key))
.map(([key, property]) => renderFormItem(key, property))}
</div>
);
};
export default SchemaFormFields;

View file

@ -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<CreateKeyProps> = ({
options={predefinedTags}
/>
</Form.Item>
<Accordion className="mt-20 mb-8">
<AccordionHeader>
<div className="flex items-center gap-2">
<b>Advanced Settings</b>
<Tooltip title={
<span>
Learn more about advanced settings in our{' '}
<a
href={proxyBaseUrl ? `${proxyBaseUrl}/#/key%20management/generate_key_fn_key_generate_post`: `/#/key%20management/generate_key_fn_key_generate_post`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300"
>
documentation
</a>
</span>
}>
<InfoCircleOutlined className="text-gray-400 hover:text-gray-300 cursor-help" />
</Tooltip>
</div>
</AccordionHeader>
<AccordionBody>
<SchemaFormFields
schemaComponent="GenerateKeyRequest"
form={form}
excludedFields={['key_alias', 'team_id', 'models', 'duration', 'metadata', 'tags', 'guardrails', "max_budget", "budget_duration", "tpm_limit", "rpm_limit"]}
/>
</AccordionBody>
</Accordion>
</AccordionBody>
</Accordion>
</>

View file

@ -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<string, any> // 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, {

View file

@ -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<FormData>;
submitButtonText?: string;
modelSelectionType?: 'single' | 'multiple';
}
// OrganizationForm.tsx
import React, { useState } from 'react';
const onSubmit = async (formValues: Record<string, any>, 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<OrganizationFormProps> = ({
title = "Create Organization",
onCancel,
accessToken,
availableModels = [],
initialValues = {},
submitButtonText = "Create",
modelSelectionType = "multiple",
}) => {
const [form] = Form.useForm();
const [isModalVisible, setIsModalVisible] = useState<boolean>(false);
const [formData, setFormData] = useState<FormData>({
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<string, any>) => {
if (accessToken == null) {
return;
}
await onSubmit(formValues, accessToken, setIsModalVisible);
setIsModalVisible(false);
};
const handleCancel = (): void => {
setIsModalVisible(false);
if (onCancel) onCancel();
};
return (
<div className="w-full">
<Button
onClick={() => setIsModalVisible(true)}
className="mx-auto"
type="button"
>
+ Create New {title}
</Button>
<Modal
title={`Create ${title}`}
visible={isModalVisible}
width={800}
footer={null}
onCancel={handleCancel}
>
<Form
form={form}
onFinish={handleSubmit}
labelCol={{ span: 8 }}
wrapperCol={{ span: 16 }}
labelAlign="left"
>
<>
<Form.Item
label={`${title} Name`}
name="organization_alias"
rules={[
{ required: true, message: `Please input a ${title} name` },
]}
>
<TextInput placeholder="" />
</Form.Item>
<Form.Item label="Models" name="models">
<Select2
mode="multiple"
placeholder="Select models"
style={{ width: "100%" }}
>
<Select2.Option
key="all-proxy-models"
value="all-proxy-models"
>
All Proxy Models
</Select2.Option>
{availableModels.map((model) => (
<Select2.Option key={model} value={model}>
{model}
</Select2.Option>
))}
</Select2>
</Form.Item>
<Form.Item label="Max Budget (USD)" name="max_budget">
<InputNumber step={0.01} precision={2} width={200} />
</Form.Item>
<Form.Item
className="mt-8"
label="Reset Budget"
name="budget_duration"
>
<Select2 defaultValue={null} placeholder="n/a">
<Select2.Option value="24h">daily</Select2.Option>
<Select2.Option value="7d">weekly</Select2.Option>
<Select2.Option value="30d">monthly</Select2.Option>
</Select2>
</Form.Item>
<Form.Item
label="Tokens per minute Limit (TPM)"
name="tpm_limit"
>
<InputNumber step={1} width={400} />
</Form.Item>
<Form.Item
label="Requests per minute Limit (RPM)"
name="rpm_limit"
>
<InputNumber step={1} width={400} />
</Form.Item>
</>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button2 htmlType="submit">{submitButtonText}</Button2>
</div>
</Form>
</Modal>
</div>
);
};
export default OrganizationForm;

View file

@ -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<OrganizationInfoProps> = ({
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<OrganizationInfoProps> = ({
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<OrganizationInfoProps> = ({
<InputNumber step={1} style={{ width: "100%" }} />
</Form.Item>
<Form.Item label="Metadata" name="metadata">
<Input.TextArea rows={4} />
</Form.Item>
<div className="flex justify-end gap-2 mt-6">
<Button onClick={() => setIsEditing(false)}>
Cancel

View file

@ -19,6 +19,7 @@ import {
TabPanels,
TabPanel,
} from "@tremor/react";
import { Input } from "antd";
import { Modal, Form, InputNumber, Tooltip, Select as Select2 } from "antd";
import { InfoCircleOutlined } from '@ant-design/icons';
import { PencilAltIcon, TrashIcon, RefreshIcon } from "@heroicons/react/outline";
@ -102,6 +103,8 @@ const OrganizationsTable: React.FC<OrganizationsTableProps> = ({
try {
if (!accessToken) return;
console.log(`values in organizations new create call: ${JSON.stringify(values)}`);
await organizationCreateCall(accessToken, values);
setIsOrgModalVisible(false);
form.resetFields();
@ -334,6 +337,10 @@ const OrganizationsTable: React.FC<OrganizationsTableProps> = ({
<InputNumber step={1} width={400} />
</Form.Item>
<Form.Item label="Metadata" name="metadata">
<Input.TextArea rows={4} />
</Form.Item>
<div style={{ textAlign: "right", marginTop: "10px" }}>
<Button type="submit">Create Organization</Button>
</div>

View file

@ -348,7 +348,8 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
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<TeamInfoProps> = ({
placeholder="Select or enter guardrails"
/>
</Form.Item>
<Form.Item label="Metadata" name="metadata">
<Input.TextArea rows={10} />
</Form.Item>
<div className="flex justify-end gap-2 mt-6">
<Button onClick={() => setIsEditing(false)}>

View file

@ -267,15 +267,9 @@ const Teams: React.FC<TeamProps> = ({
} else {
formValues.organization_id = organizationId.trim();
}
// Create metadata object with guardrails if they exist
formValues.metadata = {
...(formValues.guardrails ? { guardrails: formValues.guardrails } : {})
};
// Remove guardrails from top level since it's now in metadata
delete formValues.guardrails;
if (existingTeamAliases.includes(newTeamAlias)) {
throw new Error(
`Team alias ${newTeamAlias} already exists, please pick another alias`
@ -702,6 +696,7 @@ const Teams: React.FC<TeamProps> = ({
>
<InputNumber step={1} width={400} />
</Form.Item>
<Accordion className="mt-20 mb-8">
<AccordionHeader>
<b>Additional Settings</b>
@ -718,6 +713,9 @@ const Teams: React.FC<TeamProps> = ({
}}
/>
</Form.Item>
<Form.Item label="Metadata" name="metadata" help="Additional team metadata. Enter metadata as JSON object.">
<Input.TextArea rows={4} />
</Form.Item>
<Form.Item
label={
<span>