[Feat] UI - Allow Adding LiteLLM Auto Router on UI (#12960)
* add router.json * test_router_auto_router * async_pre_routing_hook * fixes for auto router * add async_pre_routing_hook * add LiteLLMRouterEncoder * update test auto_router_embedding_model * add auto_router_embedding_model * add AutoRouter * fix async_pre_routing_hook * update async_pre_routing_hook * fix auto router * fix router.json * working router init * working embedding encoder * working auto router * test_router_auto_router * test auto router * add semantic-router as optional for litellm * add extras * semantic_router==0.1.10 * ruff fix * use aiohttp==3.10.11 * python-dotenv==1.0.1 * test auto router * test_router_auto_router * semantic_router * test_is_auto_router_deployment * fix check * fix docker build step * add semantic_router * UI - Add auto router on litellm * working utterances config * fix route config builder * kind of working add automodel router * move loc of add deployment * fixes for AutoRouter * add auto_router_config in types.py * fixes for init_auto_router_deployment * fix adding auto router models * working auto-router with dB * Revert "add semantic_router" This reverts commit 537b67288798731a119d811f643b682086377ee9. * TestAutoRouter * fix linting * add semantic router to docker * test fix * fix router config builder * remove export button
This commit is contained in:
parent
e63b163578
commit
106a298f0a
@ -65,6 +65,9 @@ COPY --from=builder /wheels/ /wheels/
|
||||
# Install the built wheel using pip; again using a wildcard if it's the only file
|
||||
RUN pip install *.whl /wheels/* --no-index --find-links=/wheels/ && rm -f *.whl && rm -rf /wheels
|
||||
|
||||
# Install semantic_router without dependencies
|
||||
RUN pip install semantic_router --no-deps
|
||||
|
||||
# Generate prisma client
|
||||
RUN prisma generate
|
||||
RUN chmod +x docker/entrypoint.sh
|
||||
|
||||
@ -57,6 +57,9 @@ COPY --from=builder /wheels/ /wheels/
|
||||
# Install the built wheel using pip; again using a wildcard if it's the only file
|
||||
RUN pip install *.whl /wheels/* --no-index --find-links=/wheels/ && rm -f *.whl && rm -rf /wheels
|
||||
|
||||
# Install semantic_router without dependencies
|
||||
RUN pip install semantic_router --no-deps
|
||||
|
||||
# ensure pyjwt is used, not jwt
|
||||
RUN pip uninstall jwt -y
|
||||
RUN pip uninstall PyJWT -y
|
||||
|
||||
@ -46,6 +46,9 @@ RUN pip install *.whl /wheels/* --no-index --find-links=/wheels/ \
|
||||
&& rm -f *.whl \
|
||||
&& rm -rf /wheels
|
||||
|
||||
# Install semantic_router without dependencies
|
||||
RUN pip install semantic_router --no-deps
|
||||
|
||||
# Ensure correct JWT library is used (pyjwt not jwt)
|
||||
RUN pip uninstall jwt -y && \
|
||||
pip uninstall PyJWT -y && \
|
||||
|
||||
@ -4703,11 +4703,6 @@ class Router:
|
||||
)
|
||||
|
||||
|
||||
#########################################################
|
||||
# Check if this is an auto-router deployment
|
||||
#########################################################
|
||||
if self._is_auto_router_deployment(litellm_params=litellm_params):
|
||||
self.init_auto_router_deployment(deployment=deployment)
|
||||
|
||||
## OLD MODEL REGISTRATION ## Kept to prevent breaking changes
|
||||
_model_name = deployment.litellm_params.model
|
||||
@ -4764,9 +4759,10 @@ class Router:
|
||||
This will initialize the auto-router and add it to the auto-routers dictionary.
|
||||
"""
|
||||
from litellm.router_strategy.auto_router.auto_router import AutoRouter
|
||||
router_config_path: Optional[str] = deployment.litellm_params.auto_router_config_path
|
||||
if router_config_path is None:
|
||||
raise ValueError("auto_router_config_path is required for auto-router deployments. Please set it in the litellm_params")
|
||||
auto_router_config_path: Optional[str] = deployment.litellm_params.auto_router_config_path
|
||||
auto_router_config: Optional[str] = deployment.litellm_params.auto_router_config
|
||||
if auto_router_config_path is None and auto_router_config is None:
|
||||
raise ValueError("auto_router_config_path or auto_router_config is required for auto-router deployments. Please set it in the litellm_params")
|
||||
|
||||
default_model: Optional[str] = deployment.litellm_params.auto_router_default_model
|
||||
if default_model is None:
|
||||
@ -4778,7 +4774,8 @@ class Router:
|
||||
|
||||
autor_router: AutoRouter = AutoRouter(
|
||||
model_name=deployment.model_name,
|
||||
router_config_path=router_config_path,
|
||||
auto_router_config_path=auto_router_config_path,
|
||||
auto_router_config=auto_router_config,
|
||||
default_model=default_model,
|
||||
embedding_model=embedding_model,
|
||||
litellm_router_instance=self,
|
||||
@ -4950,6 +4947,12 @@ class Router:
|
||||
model=deployment.litellm_params.model,
|
||||
)
|
||||
|
||||
#########################################################
|
||||
# Check if this is an auto-router deployment
|
||||
#########################################################
|
||||
if self._is_auto_router_deployment(litellm_params=deployment.litellm_params):
|
||||
self.init_auto_router_deployment(deployment=deployment)
|
||||
|
||||
return deployment
|
||||
|
||||
def _initialize_deployment_for_pass_through(
|
||||
|
||||
@ -7,11 +7,14 @@ from litellm._logging import verbose_router_logger
|
||||
from litellm.integrations.custom_logger import CustomLogger
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from semantic_router.routers.base import Route
|
||||
|
||||
from litellm.router import Router
|
||||
from litellm.types.router import PreRoutingHookResponse
|
||||
else:
|
||||
Router = Any
|
||||
PreRoutingHookResponse = Any
|
||||
Route = Any
|
||||
|
||||
|
||||
class AutoRouter(CustomLogger):
|
||||
@ -19,30 +22,63 @@ class AutoRouter(CustomLogger):
|
||||
def __init__(
|
||||
self,
|
||||
model_name: str,
|
||||
router_config_path: str,
|
||||
default_model: str,
|
||||
embedding_model: str,
|
||||
litellm_router_instance: "Router",
|
||||
auto_router_config_path: Optional[str] = None,
|
||||
auto_router_config: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Auto-Router class that uses a semantic router to route requests to the appropriate model.
|
||||
|
||||
Args:
|
||||
model_name: The name of the model to use for the auto-router. eg. if model = "auto-router1" then us this router.
|
||||
router_config_path: The path to the router config file.
|
||||
auto_router_config_path: The path to the router config file.
|
||||
auto_router_config: The config to use for the auto-router. You can either use this or auto_router_config_path, not both.
|
||||
default_model: The default model to use if no route is found.
|
||||
embedding_model: The embedding model to use for the auto-router.
|
||||
litellm_router_instance: The instance of the LiteLLM Router.
|
||||
"""
|
||||
from semantic_router.routers import SemanticRouter
|
||||
|
||||
self.router_config_path = router_config_path
|
||||
self.auto_router_config_path: Optional[str] = auto_router_config_path
|
||||
self.auto_router_config: Optional[str] = auto_router_config
|
||||
self.auto_sync_value = self.DEFAULT_AUTO_SYNC_VALUE
|
||||
self.loaded_router: SemanticRouter = SemanticRouter.from_json(self.router_config_path)
|
||||
self.loaded_routes: List[Route] = self._load_semantic_routing_routes()
|
||||
self.routelayer: Optional[SemanticRouter] = None
|
||||
self.default_model = default_model
|
||||
self.embedding_model: str = embedding_model
|
||||
self.litellm_router_instance: "Router" = litellm_router_instance
|
||||
|
||||
def _load_semantic_routing_routes(self) -> List[Route]:
|
||||
from semantic_router.routers import SemanticRouter
|
||||
if self.auto_router_config_path:
|
||||
return SemanticRouter.from_json(self.auto_router_config_path).routes
|
||||
elif self.auto_router_config:
|
||||
return self._load_auto_router_routes_from_config_json()
|
||||
else:
|
||||
raise ValueError("No router config provided")
|
||||
|
||||
|
||||
def _load_auto_router_routes_from_config_json(self) -> List[Route]:
|
||||
import json
|
||||
|
||||
from semantic_router.routers.base import Route
|
||||
|
||||
if self.auto_router_config is None:
|
||||
raise ValueError("No auto router config provided")
|
||||
auto_router_routes: List[Route] = []
|
||||
loaded_config = json.loads(self.auto_router_config)
|
||||
for route in loaded_config.get("routes", []):
|
||||
auto_router_routes.append(
|
||||
Route(
|
||||
name=route.get("name"),
|
||||
description=route.get("description"),
|
||||
utterances=route.get("utterances", []),
|
||||
score_threshold=route.get("score_threshold")
|
||||
)
|
||||
)
|
||||
return auto_router_routes
|
||||
|
||||
|
||||
async def async_pre_routing_hook(
|
||||
@ -74,7 +110,7 @@ class AutoRouter(CustomLogger):
|
||||
# Create the route layer
|
||||
#######################
|
||||
self.routelayer = SemanticRouter(
|
||||
routes=self.loaded_router.routes,
|
||||
routes=self.loaded_routes,
|
||||
encoder=LiteLLMRouterEncoder(
|
||||
litellm_router_instance=self.litellm_router_instance,
|
||||
model_name=self.embedding_model,
|
||||
|
||||
@ -212,6 +212,7 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams):
|
||||
|
||||
# auto-router params
|
||||
auto_router_config_path: Optional[str] = None
|
||||
auto_router_config: Optional[str] = None
|
||||
auto_router_default_model: Optional[str] = None
|
||||
auto_router_embedding_model: Optional[str] = None
|
||||
|
||||
@ -261,6 +262,7 @@ class GenericLiteLLMParams(CredentialLiteLLMParams, CustomPricingLiteLLMParams):
|
||||
mock_response: Optional[Union[str, ModelResponse, Exception, Any]] = None,
|
||||
# auto-router params
|
||||
auto_router_config_path: Optional[str] = None,
|
||||
auto_router_config: Optional[str] = None,
|
||||
auto_router_default_model: Optional[str] = None,
|
||||
auto_router_embedding_model: Optional[str] = None,
|
||||
**params,
|
||||
|
||||
@ -1263,55 +1263,6 @@ def test_is_auto_router_deployment(model_list):
|
||||
assert router._is_auto_router_deployment(litellm_params_contains) is False
|
||||
|
||||
|
||||
def test_init_auto_router_deployment_missing_params(model_list):
|
||||
"""Test if the 'init_auto_router_deployment' function raises ValueError when required parameters are missing"""
|
||||
router = Router(model_list=model_list)
|
||||
|
||||
# Test case 1: Missing auto_router_config_path
|
||||
litellm_params = LiteLLM_Params(
|
||||
model="auto_router/test",
|
||||
auto_router_default_model="gpt-3.5-turbo",
|
||||
auto_router_embedding_model="text-embedding-ada-002"
|
||||
)
|
||||
deployment = Deployment(
|
||||
model_name="test-auto-router",
|
||||
litellm_params=litellm_params,
|
||||
model_info={"id": "test-id"}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="auto_router_config_path is required"):
|
||||
router.init_auto_router_deployment(deployment)
|
||||
|
||||
# Test case 2: Missing auto_router_default_model
|
||||
litellm_params = LiteLLM_Params(
|
||||
model="auto_router/test",
|
||||
auto_router_config_path="/path/to/config",
|
||||
auto_router_embedding_model="text-embedding-ada-002"
|
||||
)
|
||||
deployment = Deployment(
|
||||
model_name="test-auto-router",
|
||||
litellm_params=litellm_params,
|
||||
model_info={"id": "test-id"}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="auto_router_default_model is required"):
|
||||
router.init_auto_router_deployment(deployment)
|
||||
|
||||
# Test case 3: Missing auto_router_embedding_model
|
||||
litellm_params = LiteLLM_Params(
|
||||
model="auto_router/test",
|
||||
auto_router_config_path="/path/to/config",
|
||||
auto_router_default_model="gpt-3.5-turbo"
|
||||
)
|
||||
deployment = Deployment(
|
||||
model_name="test-auto-router",
|
||||
litellm_params=litellm_params,
|
||||
model_info={"id": "test-id"}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="auto_router_embedding_model is required"):
|
||||
router.init_auto_router_deployment(deployment)
|
||||
|
||||
|
||||
@patch('litellm.router_strategy.auto_router.auto_router.AutoRouter')
|
||||
def test_init_auto_router_deployment_success(mock_auto_router, model_list):
|
||||
|
||||
@ -56,14 +56,14 @@ class TestAutoRouter:
|
||||
# Act
|
||||
auto_router = AutoRouter(
|
||||
model_name=model_name,
|
||||
router_config_path=router_config_path,
|
||||
auto_router_config_path=router_config_path,
|
||||
default_model=default_model,
|
||||
embedding_model=embedding_model,
|
||||
litellm_router_instance=mock_router_instance,
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert auto_router.router_config_path == router_config_path
|
||||
assert auto_router.auto_router_config_path == router_config_path
|
||||
assert auto_router.auto_sync_value == AutoRouter.DEFAULT_AUTO_SYNC_VALUE
|
||||
assert auto_router.default_model == default_model
|
||||
assert auto_router.embedding_model == embedding_model
|
||||
@ -93,7 +93,7 @@ class TestAutoRouter:
|
||||
|
||||
auto_router = AutoRouter(
|
||||
model_name="test-auto-router",
|
||||
router_config_path="test/path/router.json",
|
||||
auto_router_config_path="test/path/router.json",
|
||||
default_model="gpt-4o-mini",
|
||||
embedding_model="text-embedding-model",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
@ -136,7 +136,7 @@ class TestAutoRouter:
|
||||
|
||||
auto_router = AutoRouter(
|
||||
model_name="test-auto-router",
|
||||
router_config_path="test/path/router.json",
|
||||
auto_router_config_path="test/path/router.json",
|
||||
default_model="gpt-4o-mini",
|
||||
embedding_model="text-embedding-model",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
@ -163,7 +163,7 @@ class TestAutoRouter:
|
||||
with patch('semantic_router.routers.SemanticRouter'):
|
||||
auto_router = AutoRouter(
|
||||
model_name="test-auto-router",
|
||||
router_config_path="test/path/router.json",
|
||||
auto_router_config_path="test/path/router.json",
|
||||
default_model="gpt-4o-mini",
|
||||
embedding_model="text-embedding-model",
|
||||
litellm_router_instance=mock_router_instance,
|
||||
|
||||
@ -0,0 +1,367 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Card, Form, Button, Tooltip, Typography, Select as AntdSelect, Modal, Upload, message } from "antd";
|
||||
import type { FormInstance } from "antd";
|
||||
import type { UploadProps } from "antd/es/upload";
|
||||
import { UploadOutlined } from "@ant-design/icons";
|
||||
import { Text, TextInput } from "@tremor/react";
|
||||
import { Row, Col } from "antd";
|
||||
import { CredentialItem, modelAvailableCall } from "../networking";
|
||||
import ConnectionErrorDisplay from "./model_connection_test";
|
||||
import { all_admin_roles } from "@/utils/roles";
|
||||
import { handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit";
|
||||
import { fetchAvailableModels, ModelGroup } from "../chat_ui/llm_calls/fetch_models";
|
||||
import RouterConfigBuilder from "./router_config_builder";
|
||||
|
||||
interface AddAutoRouterTabProps {
|
||||
form: FormInstance;
|
||||
handleOk: () => void;
|
||||
accessToken: string;
|
||||
userRole: string;
|
||||
}
|
||||
|
||||
const { Title, Link } = Typography;
|
||||
|
||||
const AddAutoRouterTab: React.FC<AddAutoRouterTabProps> = ({
|
||||
form,
|
||||
handleOk,
|
||||
accessToken,
|
||||
userRole,
|
||||
}) => {
|
||||
// State for connection testing
|
||||
const [isResultModalVisible, setIsResultModalVisible] = useState<boolean>(false);
|
||||
const [isTestingConnection, setIsTestingConnection] = useState<boolean>(false);
|
||||
const [connectionTestId, setConnectionTestId] = useState<string>("");
|
||||
|
||||
|
||||
|
||||
const [modelAccessGroups, setModelAccessGroups] = useState<string[]>([]);
|
||||
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
|
||||
const [showCustomDefaultModel, setShowCustomDefaultModel] = useState<boolean>(false);
|
||||
const [showCustomEmbeddingModel, setShowCustomEmbeddingModel] = useState<boolean>(false);
|
||||
const [routerConfig, setRouterConfig] = useState<any>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchModelAccessGroups = async () => {
|
||||
const response = await modelAvailableCall(accessToken, "", "", false, null, true, true);
|
||||
setModelAccessGroups(response["data"].map((model: any) => model["id"]));
|
||||
};
|
||||
fetchModelAccessGroups();
|
||||
}, [accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
const uniqueModels = await fetchAvailableModels(accessToken);
|
||||
console.log("Fetched models for auto router:", uniqueModels);
|
||||
setModelInfo(uniqueModels);
|
||||
} catch (error) {
|
||||
console.error("Error fetching model info for auto router:", error);
|
||||
}
|
||||
};
|
||||
loadModels();
|
||||
}, [accessToken]);
|
||||
|
||||
const isAdmin = all_admin_roles.includes(userRole);
|
||||
|
||||
// Test connection when button is clicked
|
||||
const handleTestConnection = async () => {
|
||||
setIsTestingConnection(true);
|
||||
setConnectionTestId(`test-${Date.now()}`);
|
||||
setIsResultModalVisible(true);
|
||||
};
|
||||
|
||||
// Auto router specific form submit handler
|
||||
const handleAutoRouterSubmit = () => {
|
||||
console.log("Auto router submit triggered!");
|
||||
console.log("Router config:", routerConfig);
|
||||
const currentFormValues = form.getFieldsValue();
|
||||
console.log("Form values:", currentFormValues);
|
||||
|
||||
// Check basic required fields first
|
||||
if (!currentFormValues.auto_router_name) {
|
||||
message.error("Please enter an Auto Router Name");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentFormValues.auto_router_default_model) {
|
||||
message.error("Please select a Default Model");
|
||||
return;
|
||||
}
|
||||
|
||||
// Set auto router specific form values that are required by the regular model form
|
||||
form.setFieldsValue({
|
||||
custom_llm_provider: 'auto_router',
|
||||
model: currentFormValues.auto_router_name,
|
||||
// api_key is not needed for auto router, but form expects it
|
||||
api_key: 'not_required_for_auto_router'
|
||||
});
|
||||
|
||||
// Custom validation for router config
|
||||
if (!routerConfig || !routerConfig.routes || routerConfig.routes.length === 0) {
|
||||
message.error("Please configure at least one route for the auto router");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if all routes have required fields
|
||||
const invalidRoutes = routerConfig.routes.filter((route: any) =>
|
||||
!route.name || !route.description || route.utterances.length === 0
|
||||
);
|
||||
|
||||
if (invalidRoutes.length > 0) {
|
||||
message.error("Please ensure all routes have a target model, description, and at least one utterance");
|
||||
return;
|
||||
}
|
||||
|
||||
form
|
||||
.validateFields()
|
||||
.then((values) => {
|
||||
console.log("Form validation passed, submitting with values:", values);
|
||||
// Add the router config to form values
|
||||
const submitValues = {
|
||||
...values,
|
||||
auto_router_config: routerConfig,
|
||||
};
|
||||
console.log("Final submit values:", submitValues);
|
||||
handleAddAutoRouterSubmit(submitValues, accessToken, form, handleOk);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Validation failed:", error);
|
||||
|
||||
// Extract specific field errors
|
||||
const fieldErrors = error.errorFields || [];
|
||||
if (fieldErrors.length > 0) {
|
||||
const missingFields = fieldErrors.map((field: any) => {
|
||||
const fieldName = field.name[0];
|
||||
const friendlyNames: { [key: string]: string } = {
|
||||
'auto_router_name': 'Auto Router Name',
|
||||
'auto_router_default_model': 'Default Model',
|
||||
'auto_router_embedding_model': 'Embedding Model'
|
||||
};
|
||||
return friendlyNames[fieldName] || fieldName;
|
||||
});
|
||||
message.error(`Please fill in the following required fields: ${missingFields.join(', ')}`);
|
||||
} else {
|
||||
message.error("Please fill in all required fields");
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title level={2}>Add Auto Router</Title>
|
||||
<Text className="text-gray-600 mb-6">
|
||||
Create an auto router with intelligent routing logic that automatically selects the best model based on user input patterns and semantic matching.
|
||||
</Text>
|
||||
|
||||
<Card>
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleAutoRouterSubmit}
|
||||
labelCol={{ span: 10 }}
|
||||
wrapperCol={{ span: 16 }}
|
||||
labelAlign="left"
|
||||
>
|
||||
{/* Auto Router Name */}
|
||||
<Form.Item
|
||||
rules={[{ required: true, message: "Auto router name is required" }]}
|
||||
label="Auto Router Name"
|
||||
name="auto_router_name"
|
||||
tooltip="Unique name for this auto router configuration"
|
||||
labelCol={{ span: 10 }}
|
||||
labelAlign="left"
|
||||
>
|
||||
<TextInput placeholder="e.g., auto_router_1, smart_routing" />
|
||||
</Form.Item>
|
||||
|
||||
{/* Router Configuration Builder */}
|
||||
<div className="w-full mb-4">
|
||||
<RouterConfigBuilder
|
||||
modelInfo={modelInfo}
|
||||
value={routerConfig}
|
||||
onChange={(config) => {
|
||||
setRouterConfig(config);
|
||||
form.setFieldValue('auto_router_config', config);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Auto Router Default Model */}
|
||||
<Form.Item
|
||||
rules={[{ required: true, message: "Default model is required" }]}
|
||||
label="Default Model"
|
||||
name="auto_router_default_model"
|
||||
tooltip="Fallback model to use when auto routing logic cannot determine the best model"
|
||||
labelCol={{ span: 10 }}
|
||||
labelAlign="left"
|
||||
>
|
||||
<AntdSelect
|
||||
placeholder="Select a default model"
|
||||
onChange={(value) => {
|
||||
setShowCustomDefaultModel(value === 'custom');
|
||||
}}
|
||||
options={[
|
||||
...Array.from(new Set(modelInfo.map(option => option.model_group)))
|
||||
.map((model_group) => ({
|
||||
value: model_group,
|
||||
label: model_group,
|
||||
})),
|
||||
{ value: 'custom', label: 'Enter custom model name' }
|
||||
]}
|
||||
style={{ width: "100%" }}
|
||||
showSearch={true}
|
||||
/>
|
||||
</Form.Item>
|
||||
{showCustomDefaultModel && (
|
||||
<Form.Item
|
||||
label="Custom Default Model"
|
||||
name="custom_default_model"
|
||||
labelCol={{ span: 10 }}
|
||||
labelAlign="left"
|
||||
>
|
||||
<TextInput
|
||||
placeholder="Enter custom model name"
|
||||
onChange={(e) => {
|
||||
form.setFieldValue('auto_router_default_model', e.target.value);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
{/* Auto Router Embedding Model */}
|
||||
<Form.Item
|
||||
label="Embedding Model"
|
||||
name="auto_router_embedding_model"
|
||||
tooltip="Optional: Embedding model to use for semantic routing decisions"
|
||||
labelCol={{ span: 10 }}
|
||||
labelAlign="left"
|
||||
>
|
||||
<AntdSelect
|
||||
value={form.getFieldValue('auto_router_embedding_model')}
|
||||
placeholder="Select an embedding model (optional)"
|
||||
onChange={(value) => {
|
||||
setShowCustomEmbeddingModel(value === 'custom');
|
||||
form.setFieldValue('auto_router_embedding_model', value);
|
||||
}}
|
||||
options={[
|
||||
...Array.from(new Set(modelInfo.map(option => option.model_group)))
|
||||
.map((model_group) => ({
|
||||
value: model_group,
|
||||
label: model_group,
|
||||
})),
|
||||
{ value: 'custom', label: 'Enter custom model name' }
|
||||
]}
|
||||
style={{ width: "100%" }}
|
||||
showSearch={true}
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
{showCustomEmbeddingModel && (
|
||||
<Form.Item
|
||||
label="Custom Embedding Model"
|
||||
name="custom_embedding_model"
|
||||
labelCol={{ span: 10 }}
|
||||
labelAlign="left"
|
||||
>
|
||||
<TextInput
|
||||
placeholder="Enter custom embedding model name"
|
||||
onChange={(e) => {
|
||||
form.setFieldValue('auto_router_embedding_model', e.target.value);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<div className="flex items-center my-4">
|
||||
<div className="flex-grow border-t border-gray-200"></div>
|
||||
<span className="px-4 text-gray-500 text-sm">Additional Settings</span>
|
||||
<div className="flex-grow border-t border-gray-200"></div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
{/* Model Access Groups - Admin only */}
|
||||
{isAdmin && (
|
||||
<Form.Item
|
||||
label="Model Access Group"
|
||||
name="model_access_group"
|
||||
className="mb-4"
|
||||
tooltip="Use model access groups to control who can access this auto router"
|
||||
>
|
||||
<AntdSelect
|
||||
mode="tags"
|
||||
showSearch
|
||||
placeholder="Select existing groups or type to create new ones"
|
||||
optionFilterProp="children"
|
||||
tokenSeparators={[',']}
|
||||
options={modelAccessGroups.map((group) => ({
|
||||
value: group,
|
||||
label: group
|
||||
}))}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Tooltip title="Get help on our github">
|
||||
<Typography.Link href="https://github.com/BerriAI/litellm/issues">
|
||||
Need Help?
|
||||
</Typography.Link>
|
||||
</Tooltip>
|
||||
<div className="space-x-2">
|
||||
<Button onClick={handleTestConnection} loading={isTestingConnection}>Test Connect</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
console.log("Add Auto Router button clicked!");
|
||||
console.log("Current router config:", routerConfig);
|
||||
console.log("Current form values:", form.getFieldsValue());
|
||||
handleAutoRouterSubmit();
|
||||
}}
|
||||
>
|
||||
Add Auto Router
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
{/* Test Connection Results Modal */}
|
||||
<Modal
|
||||
title="Connection Test Results"
|
||||
open={isResultModalVisible}
|
||||
onCancel={() => {
|
||||
setIsResultModalVisible(false);
|
||||
setIsTestingConnection(false);
|
||||
}}
|
||||
footer={[
|
||||
<Button key="close" onClick={() => {
|
||||
setIsResultModalVisible(false);
|
||||
setIsTestingConnection(false);
|
||||
}}>
|
||||
Close
|
||||
</Button>
|
||||
]}
|
||||
width={700}
|
||||
>
|
||||
{/* Only render the ConnectionErrorDisplay when modal is visible and we have a test ID */}
|
||||
{isResultModalVisible && (
|
||||
<ConnectionErrorDisplay
|
||||
key={connectionTestId}
|
||||
formValues={form.getFieldsValue()}
|
||||
accessToken={accessToken}
|
||||
testMode="chat"
|
||||
modelName={form.getFieldValue('auto_router_name')}
|
||||
onClose={() => {
|
||||
setIsResultModalVisible(false);
|
||||
setIsTestingConnection(false);
|
||||
}}
|
||||
onTestComplete={() => setIsTestingConnection(false)}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddAutoRouterTab;
|
||||
@ -9,4 +9,14 @@ export const TEST_MODES = [
|
||||
{ value: "rerank", label: "Rerank - /rerank" },
|
||||
{ value: "realtime", label: "Realtime - /realtime"},
|
||||
{ value: "batch", label: "Batch - /batch"}
|
||||
];
|
||||
|
||||
// Define the available auto router routing strategies
|
||||
export const AUTO_ROUTER_MODES = [
|
||||
{ value: "simple-shuffle", label: "Simple Shuffle - Random selection from available models" },
|
||||
{ value: "least-busy", label: "Least Busy - Route to model with lowest current load" },
|
||||
{ value: "latency-based", label: "Latency Based - Route to model with best response time" },
|
||||
{ value: "cost-based", label: "Cost Based - Route to most cost-effective model" },
|
||||
{ value: "usage-based", label: "Usage Based - Route based on historical usage patterns" },
|
||||
{ value: "custom", label: "Custom - Use custom routing logic defined in config" }
|
||||
];
|
||||
@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Card, Form, Button, Tooltip, Typography, Select as AntdSelect, Modal } from "antd";
|
||||
import type { FormInstance } from "antd";
|
||||
import type { UploadProps } from "antd/es/upload";
|
||||
import { TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react";
|
||||
import LiteLLMModelNameField from "./litellm_model_name";
|
||||
import ConditionalPublicModelName from "./conditional_public_model_name";
|
||||
import ProviderSpecificFields from "./provider_specific_fields";
|
||||
@ -15,6 +16,8 @@ import { Row, Col } from "antd";
|
||||
import { Text, TextInput, Switch } from "@tremor/react";
|
||||
import TeamDropdown from "../common_components/team_dropdown";
|
||||
import { all_admin_roles } from "@/utils/roles";
|
||||
import AddAutoRouterTab from "./add_auto_router_tab";
|
||||
import { handleAddAutoRouterSubmit } from "./handle_add_auto_router_submit";
|
||||
|
||||
interface AddModelTabProps {
|
||||
form: FormInstance;
|
||||
@ -85,10 +88,28 @@ const AddModelTab: React.FC<AddModelTabProps> = ({
|
||||
|
||||
const isAdmin = all_admin_roles.includes(userRole);
|
||||
|
||||
const handleAutoRouterOk = () => {
|
||||
form
|
||||
.validateFields()
|
||||
.then((values) => {
|
||||
handleAddAutoRouterSubmit(values, accessToken, form, handleOk);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Validation failed:", error);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Title level={2}>Add new model</Title>
|
||||
<Card>
|
||||
<TabGroup className="w-full">
|
||||
<TabList className="mb-4">
|
||||
<Tab>Add Model</Tab>
|
||||
<Tab>Add Auto Router</Tab>
|
||||
</TabList>
|
||||
<TabPanels>
|
||||
<TabPanel>
|
||||
<Card>
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleOk}
|
||||
@ -328,7 +349,18 @@ const AddModelTab: React.FC<AddModelTabProps> = ({
|
||||
</div>
|
||||
</>
|
||||
</Form>
|
||||
</Card>
|
||||
</Card>
|
||||
</TabPanel>
|
||||
<TabPanel>
|
||||
<AddAutoRouterTab
|
||||
form={form}
|
||||
handleOk={handleAutoRouterOk}
|
||||
accessToken={accessToken}
|
||||
userRole={userRole}
|
||||
/>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
|
||||
{/* Test Connection Results Modal */}
|
||||
<Modal
|
||||
|
||||
@ -0,0 +1,65 @@
|
||||
import { message } from "antd";
|
||||
import { modelCreateCall, Model } from "../networking";
|
||||
|
||||
export const handleAddAutoRouterSubmit = async (
|
||||
values: any,
|
||||
accessToken: string,
|
||||
form: any,
|
||||
callback?: () => void,
|
||||
) => {
|
||||
try {
|
||||
console.log("=== AUTO ROUTER SUBMIT HANDLER CALLED ===");
|
||||
console.log("handling auto router submit for formValues:", values);
|
||||
console.log("Access token:", accessToken ? "Present" : "Missing");
|
||||
console.log("Form:", form ? "Present" : "Missing");
|
||||
console.log("Callback:", callback ? "Present" : "Missing");
|
||||
|
||||
// Create auto router configuration
|
||||
const autoRouterConfig: any = {
|
||||
model_name: values.auto_router_name,
|
||||
litellm_params: {
|
||||
model: `auto_router/${values.auto_router_name}`,
|
||||
auto_router_config: JSON.stringify(values.auto_router_config), // Convert JSON object to string as expected by backend
|
||||
auto_router_default_model: values.auto_router_default_model,
|
||||
},
|
||||
model_info: {},
|
||||
};
|
||||
|
||||
// Add optional embedding model if provided
|
||||
if (values.auto_router_embedding_model && values.auto_router_embedding_model !== 'custom') {
|
||||
autoRouterConfig.litellm_params.auto_router_embedding_model = values.auto_router_embedding_model;
|
||||
} else if (values.custom_embedding_model) {
|
||||
autoRouterConfig.litellm_params.auto_router_embedding_model = values.custom_embedding_model;
|
||||
}
|
||||
|
||||
// Add team information if provided
|
||||
if (values.team_id) {
|
||||
autoRouterConfig.model_info.team_id = values.team_id;
|
||||
}
|
||||
|
||||
// Add model access groups if provided
|
||||
if (values.model_access_group && values.model_access_group.length > 0) {
|
||||
autoRouterConfig.model_info.access_groups = values.model_access_group;
|
||||
}
|
||||
|
||||
console.log("Auto router configuration to be created:", autoRouterConfig);
|
||||
console.log("Auto router config (stringified):", autoRouterConfig.litellm_params.auto_router_config);
|
||||
|
||||
// Create the auto router using the same model creation endpoint
|
||||
console.log("Calling modelCreateCall with:", { accessToken: accessToken ? "Present" : "Missing", config: autoRouterConfig });
|
||||
const response: any = await modelCreateCall(accessToken, autoRouterConfig as Model);
|
||||
console.log(`response for auto router create call:`, response);
|
||||
|
||||
message.success("Auto router added successfully!");
|
||||
|
||||
// Call the callback function if provided (usually to refresh the model list)
|
||||
callback && callback();
|
||||
|
||||
// Reset the form
|
||||
form.resetFields();
|
||||
|
||||
} catch (error) {
|
||||
console.error("Failed to add auto router:", error);
|
||||
message.error("Failed to add auto router: " + error, 10);
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,274 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Card, Form, Button, Input, InputNumber, Select as AntdSelect, Space, Tooltip, Collapse } from "antd";
|
||||
import { PlusOutlined, DeleteOutlined, InfoCircleOutlined, DownOutlined } from "@ant-design/icons";
|
||||
import { Text, TextInput } from "@tremor/react";
|
||||
import { ModelGroup } from "../chat_ui/llm_calls/fetch_models";
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Panel } = Collapse;
|
||||
|
||||
interface Route {
|
||||
id: string;
|
||||
model: string;
|
||||
utterances: string[];
|
||||
description: string;
|
||||
score_threshold: number;
|
||||
}
|
||||
|
||||
interface RouterConfigBuilderProps {
|
||||
modelInfo: ModelGroup[];
|
||||
value?: any;
|
||||
onChange?: (config: any) => void;
|
||||
}
|
||||
|
||||
const RouterConfigBuilder: React.FC<RouterConfigBuilderProps> = ({
|
||||
modelInfo,
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const [routes, setRoutes] = useState<Route[]>(value?.routes || []);
|
||||
const [showJsonPreview, setShowJsonPreview] = useState<boolean>(false);
|
||||
const [expandedRoutes, setExpandedRoutes] = useState<string[]>([]);
|
||||
|
||||
// Initialize expanded routes for existing routes on mount
|
||||
useEffect(() => {
|
||||
if (value?.routes && value.routes.length > 0 && expandedRoutes.length === 0) {
|
||||
const existingRouteIds = value.routes.map((route: any) => route.id || `route-${Math.random()}`);
|
||||
setExpandedRoutes(existingRouteIds);
|
||||
}
|
||||
}, [value?.routes, expandedRoutes.length]);
|
||||
|
||||
// Handle adding a new route
|
||||
const addRoute = () => {
|
||||
const newRouteId = `route-${Date.now()}`;
|
||||
const newRoute: Route = {
|
||||
id: newRouteId,
|
||||
model: "",
|
||||
utterances: [],
|
||||
description: "",
|
||||
score_threshold: 0.5,
|
||||
};
|
||||
const updatedRoutes = [...routes, newRoute];
|
||||
setRoutes(updatedRoutes);
|
||||
updateConfig(updatedRoutes);
|
||||
// Automatically expand the new route
|
||||
setExpandedRoutes(prev => [...prev, newRouteId]);
|
||||
};
|
||||
|
||||
// Handle removing a route
|
||||
const removeRoute = (routeId: string) => {
|
||||
const updatedRoutes = routes.filter(route => route.id !== routeId);
|
||||
setRoutes(updatedRoutes);
|
||||
updateConfig(updatedRoutes);
|
||||
// Remove from expanded routes as well
|
||||
setExpandedRoutes(prev => prev.filter(id => id !== routeId));
|
||||
};
|
||||
|
||||
// Handle updating a route
|
||||
const updateRoute = (routeId: string, field: keyof Route, value: any) => {
|
||||
const updatedRoutes = routes.map(route =>
|
||||
route.id === routeId ? { ...route, [field]: value } : route
|
||||
);
|
||||
setRoutes(updatedRoutes);
|
||||
updateConfig(updatedRoutes);
|
||||
};
|
||||
|
||||
// Update the overall configuration
|
||||
const updateConfig = (updatedRoutes: Route[]) => {
|
||||
const config = {
|
||||
routes: updatedRoutes.map(route => ({
|
||||
name: route.model,
|
||||
utterances: route.utterances,
|
||||
description: route.description,
|
||||
score_threshold: route.score_threshold,
|
||||
})),
|
||||
};
|
||||
onChange?.(config);
|
||||
};
|
||||
|
||||
// Handle utterances change (convert textarea string to array)
|
||||
const handleUtterancesChange = (routeId: string, utterancesText: string) => {
|
||||
const utterancesArray = utterancesText
|
||||
.split('\n')
|
||||
.map(line => line.trim()) // Only trims leading/trailing whitespace, preserves internal spaces
|
||||
.filter(line => line.length > 0);
|
||||
updateRoute(routeId, 'utterances', utterancesArray);
|
||||
};
|
||||
|
||||
// Prepare model options for dropdowns
|
||||
const modelOptions = modelInfo.map(model => ({
|
||||
value: model.model_group,
|
||||
label: model.model_group,
|
||||
}));
|
||||
|
||||
const generateConfig = () => {
|
||||
return {
|
||||
routes: routes.map(route => ({
|
||||
name: route.model,
|
||||
utterances: route.utterances,
|
||||
description: route.description,
|
||||
score_threshold: route.score_threshold,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-none">
|
||||
{/* Routes Configuration Header */}
|
||||
<div className="flex justify-between items-center mb-6 w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<Text className="text-lg font-semibold">Routes Configuration</Text>
|
||||
<Tooltip title="Configure routing logic to automatically select the best model based on user input patterns">
|
||||
<InfoCircleOutlined className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={addRoute}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
Add Route
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Routes */}
|
||||
{routes.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500 bg-gray-50 rounded-lg border-2 border-dashed border-gray-200 mb-6">
|
||||
<Text>No routes configured. Click “Add Route” to get started.</Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3 mb-6 w-full">
|
||||
{routes.map((route, index) => (
|
||||
<Card
|
||||
key={route.id}
|
||||
className="border border-gray-200 shadow-sm w-full"
|
||||
bodyStyle={{ padding: 0 }}
|
||||
>
|
||||
<Collapse
|
||||
ghost
|
||||
expandIcon={({ isActive }) => <DownOutlined rotate={isActive ? 180 : 0} />}
|
||||
activeKey={expandedRoutes}
|
||||
onChange={(keys) => setExpandedRoutes(Array.isArray(keys) ? keys : [keys].filter(Boolean))}
|
||||
items={[
|
||||
{
|
||||
key: route.id,
|
||||
label: (
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<Text className="font-medium text-base">
|
||||
Route {index + 1}: {route.model || 'Unnamed'}
|
||||
</Text>
|
||||
<Button
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeRoute(route.id);
|
||||
}}
|
||||
className="mr-2"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
children: (
|
||||
<div className="px-6 pb-6 w-full">
|
||||
{/* Model Selection */}
|
||||
<div className="mb-4 w-full">
|
||||
<Text className="text-sm font-medium mb-2 block">Model</Text>
|
||||
<AntdSelect
|
||||
value={route.model}
|
||||
onChange={(value) => updateRoute(route.id, 'model', value)}
|
||||
placeholder="Select model"
|
||||
showSearch
|
||||
style={{ width: '100%' }}
|
||||
options={modelOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-4 w-full">
|
||||
<Text className="text-sm font-medium mb-2 block">Description</Text>
|
||||
<TextArea
|
||||
value={route.description}
|
||||
onChange={(e) => updateRoute(route.id, 'description', e.target.value)}
|
||||
placeholder="Describe when this route should be used..."
|
||||
rows={2}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Score Threshold */}
|
||||
<div className="mb-4 w-full">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Text className="text-sm font-medium">Score Threshold</Text>
|
||||
<Tooltip title="Minimum similarity score to route to this model (0-1)">
|
||||
<InfoCircleOutlined className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<InputNumber
|
||||
value={route.score_threshold}
|
||||
onChange={(value) => updateRoute(route.id, 'score_threshold', value || 0)}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
style={{ width: '100%' }}
|
||||
placeholder="0.5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Example Utterances */}
|
||||
<div className="w-full">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Text className="text-sm font-medium">Example Utterances</Text>
|
||||
<Tooltip title="Training examples for this route. Type an utterance and press Enter to add it.">
|
||||
<InfoCircleOutlined className="text-gray-400" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Text className="text-xs text-gray-500 mb-2">Type an utterance and press Enter to add it. You can also paste multiple lines.</Text>
|
||||
<AntdSelect
|
||||
mode="tags"
|
||||
value={route.utterances}
|
||||
onChange={(utterances) => updateRoute(route.id, 'utterances', utterances)}
|
||||
placeholder="Type an utterance and press Enter..."
|
||||
style={{ width: '100%' }}
|
||||
tokenSeparators={['\n']}
|
||||
maxTagCount="responsive"
|
||||
allowClear
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* JSON Preview */}
|
||||
<div className="border-t pt-6 w-full">
|
||||
<div className="flex justify-between items-center mb-4 w-full">
|
||||
<Text className="text-lg font-semibold">JSON Preview</Text>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => setShowJsonPreview(!showJsonPreview)}
|
||||
className="text-blue-600 p-0"
|
||||
>
|
||||
{showJsonPreview ? 'Hide' : 'Show'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showJsonPreview && (
|
||||
<Card className="bg-gray-50 w-full">
|
||||
<pre className="text-sm overflow-auto max-h-64 w-full">
|
||||
{JSON.stringify(generateConfig(), null, 2)}
|
||||
</pre>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RouterConfigBuilder;
|
||||
@ -21,6 +21,7 @@ import {
|
||||
} from "./networking";
|
||||
|
||||
import { handleAddModelSubmit } from "./add_model/handle_add_model_submit";
|
||||
|
||||
import CredentialsPanel from "@/components/model_add/credentials";
|
||||
import { getDisplayModelName } from "./view_model/model_name_display";
|
||||
import {
|
||||
@ -67,6 +68,7 @@ import {
|
||||
} from "./provider_info_helpers";
|
||||
import ModelInfoView from "./model_info_view";
|
||||
import AddModelTab from "./add_model/add_model_tab";
|
||||
|
||||
import { ModelDataTable } from "./model_dashboard/table";
|
||||
import { columns } from "./model_dashboard/columns";
|
||||
import HealthCheckComponent from "./model_dashboard/HealthCheckComponent";
|
||||
@ -967,6 +969,8 @@ const ModelDashboard: React.FC<ModelDashboardProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
|
||||
console.log(`selectedProvider: ${selectedProvider}`);
|
||||
console.log(`providerModels.length: ${providerModels.length}`);
|
||||
|
||||
|
||||
@ -393,7 +393,7 @@ const NewUsagePage: React.FC<NewUsagePageProps> = ({
|
||||
return (
|
||||
<div style={{ width: "100%" }} className="p-8 relative">
|
||||
{/* Export Data Button - Positioned in top right corner */}
|
||||
{all_admin_roles.includes(userRole || "") && (
|
||||
{/* {all_admin_roles.includes(userRole || "") && (
|
||||
<div className="absolute top-4 right-4 z-10">
|
||||
<button
|
||||
onClick={() => setIsCloudZeroModalOpen(true)}
|
||||
@ -428,7 +428,7 @@ const NewUsagePage: React.FC<NewUsagePageProps> = ({
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
)} */}
|
||||
|
||||
<TabGroup>
|
||||
<TabList variant="solid" className="mt-1">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user