mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-09 22:31:41 +00:00
New add fallbacks modal
This commit is contained in:
parent
28bc83e394
commit
7e6fc6af2c
9 changed files with 605 additions and 401 deletions
|
|
@ -0,0 +1,169 @@
|
|||
/**
|
||||
* Parent component for adding fallbacks to the proxy router config
|
||||
* Handles value/onChange logic and form submission
|
||||
* Works with forms - reads from and writes to router_settings.fallbacks
|
||||
*/
|
||||
|
||||
import { Button as TremorButton } from "@tremor/react";
|
||||
import { Button, message } from "antd";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import NotificationManager from "../../../molecules/notifications_manager";
|
||||
import { fetchAvailableModels, ModelGroup } from "../../../playground/llm_calls/fetch_models";
|
||||
import { AddFallbacksModal } from "./AddFallbacksModal";
|
||||
import { FallbackGroup } from "./FallbackGroupConfig";
|
||||
import { FallbackSelectionForm } from "./FallbackSelectionForm";
|
||||
|
||||
export type FallbackEntry = { [modelName: string]: string[] };
|
||||
export type Fallbacks = FallbackEntry[];
|
||||
|
||||
interface AddFallbacksProps {
|
||||
models?: string[];
|
||||
accessToken: string;
|
||||
value?: Fallbacks; // Current fallbacks value from form
|
||||
onChange?: (fallbacks: Fallbacks) => Promise<void>; // Callback to update form value
|
||||
}
|
||||
|
||||
export default function AddFallbacks({
|
||||
models,
|
||||
accessToken,
|
||||
value = [],
|
||||
onChange,
|
||||
}: AddFallbacksProps) {
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
|
||||
const [modalKey, setModalKey] = useState(0); // Key to force remount of form when modal opens
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [groups, setGroups] = useState<FallbackGroup[]>([
|
||||
{
|
||||
id: "1",
|
||||
primaryModel: null,
|
||||
fallbackModels: [],
|
||||
},
|
||||
]);
|
||||
|
||||
// Reset groups state and increment modal key when modal opens
|
||||
useEffect(() => {
|
||||
if (isModalVisible) {
|
||||
setGroups([
|
||||
{
|
||||
id: "1",
|
||||
primaryModel: null,
|
||||
fallbackModels: [],
|
||||
},
|
||||
]);
|
||||
setModalKey((prev) => prev + 1); // Force remount of form
|
||||
}
|
||||
}, [isModalVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
const uniqueModels = await fetchAvailableModels(accessToken);
|
||||
console.log("Fetched models for fallbacks:", uniqueModels);
|
||||
setModelInfo(uniqueModels);
|
||||
} catch (error) {
|
||||
console.error("Error fetching model info for fallbacks:", error);
|
||||
}
|
||||
};
|
||||
if (isModalVisible) {
|
||||
loadModels();
|
||||
}
|
||||
}, [accessToken, isModalVisible]);
|
||||
|
||||
const availableModels = Array.from(new Set(modelInfo.map((option) => option.model_group))).sort();
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsModalVisible(false);
|
||||
// Reset to initial state
|
||||
setGroups([
|
||||
{
|
||||
id: "1",
|
||||
primaryModel: null,
|
||||
fallbackModels: [],
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const handleSaveAll = async () => {
|
||||
// Validation
|
||||
const invalidGroups = groups.filter(
|
||||
(g) => !g.primaryModel || g.fallbackModels.length === 0,
|
||||
);
|
||||
if (invalidGroups.length > 0) {
|
||||
message.error(
|
||||
`Please complete configuration for all groups. ${invalidGroups.length} group(s) incomplete.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create fallback objects in the format expected by the API
|
||||
const newFallbacks = groups.map((g) => ({
|
||||
[g.primaryModel!]: g.fallbackModels,
|
||||
}));
|
||||
|
||||
// Get current fallbacks from form value, or an empty array if it's null/undefined
|
||||
const currentFallbacks = value || [];
|
||||
|
||||
// Add new fallbacks to the current fallbacks
|
||||
const updatedFallbacks = [...currentFallbacks, ...newFallbacks];
|
||||
|
||||
// Call onChange to update the form value and wait for it to complete
|
||||
if (onChange) {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onChange(updatedFallbacks);
|
||||
NotificationManager.success(`${groups.length} fallback configuration(s) added successfully!`);
|
||||
handleCancel();
|
||||
} catch (error) {
|
||||
// Error handling is done in handleFallbacksChange, so we don't need to show another notification here
|
||||
console.error("Error saving fallbacks:", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
} else {
|
||||
NotificationManager.fromBackend("onChange callback not provided");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<TremorButton
|
||||
className="mx-auto"
|
||||
onClick={() => setIsModalVisible(true)}
|
||||
icon={() => <span className="mr-1">+</span>}
|
||||
>
|
||||
Add Fallbacks
|
||||
</TremorButton>
|
||||
<AddFallbacksModal open={isModalVisible} onCancel={handleCancel}>
|
||||
<FallbackSelectionForm
|
||||
key={modalKey}
|
||||
groups={groups}
|
||||
onGroupsChange={setGroups}
|
||||
availableModels={availableModels}
|
||||
maxFallbacks={5}
|
||||
maxGroups={5}
|
||||
/>
|
||||
{/* Footer with Cancel and Save buttons */}
|
||||
{groups.length > 0 && (
|
||||
<div className="flex items-center justify-end space-x-3 pt-6 mt-6 border-t border-gray-100">
|
||||
<Button
|
||||
type="default"
|
||||
onClick={handleCancel}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="default"
|
||||
onClick={handleSaveAll}
|
||||
disabled={groups.length === 0 || isSaving}
|
||||
loading={isSaving}
|
||||
>
|
||||
{isSaving ? "Saving Configuration..." : "Save All Configurations"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</AddFallbacksModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
/**
|
||||
* Modal wrapper for the fallback selection form
|
||||
* Handles modal visibility and layout, but delegates content to children
|
||||
*/
|
||||
|
||||
import { Modal } from "antd";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
interface AddFallbacksModalProps {
|
||||
open: boolean;
|
||||
onCancel: () => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AddFallbacksModal({
|
||||
open,
|
||||
onCancel,
|
||||
children,
|
||||
}: AddFallbacksModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<div className="pb-4 border-b border-gray-100">
|
||||
<div className="flex items-center gap-2 text-gray-800">
|
||||
<div className="p-2 bg-indigo-50 rounded-lg">
|
||||
<ArrowRight className="w-5 h-5 text-indigo-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold m-0">Configure Model Fallbacks</h2>
|
||||
<p className="text-sm text-gray-500 font-normal m-0">
|
||||
Manage multiple fallback chains for different models (up to 5 groups at a time)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
open={open}
|
||||
width={900}
|
||||
footer={null}
|
||||
onCancel={onCancel}
|
||||
maskClosable={false}
|
||||
className="top-8"
|
||||
styles={{
|
||||
body: { padding: "24px" },
|
||||
header: { padding: "24px 24px 0 24px", border: "none" },
|
||||
}}
|
||||
>
|
||||
<div className="mt-6">{children}</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
/**
|
||||
* Component for configuring a single fallback group
|
||||
* Handles primary model selection and fallback chain configuration
|
||||
*/
|
||||
|
||||
import { Select, Tooltip } from "antd";
|
||||
import { AlertCircle, ArrowDown, X } from "lucide-react";
|
||||
import React from "react";
|
||||
|
||||
export interface FallbackGroup {
|
||||
id: string;
|
||||
primaryModel: string | null;
|
||||
fallbackModels: string[];
|
||||
}
|
||||
|
||||
interface FallbackGroupConfigProps {
|
||||
group: FallbackGroup;
|
||||
onChange: (updatedGroup: FallbackGroup) => void;
|
||||
availableModels: string[];
|
||||
maxFallbacks: number;
|
||||
}
|
||||
|
||||
export function FallbackGroupConfig({
|
||||
group,
|
||||
onChange,
|
||||
availableModels,
|
||||
maxFallbacks,
|
||||
}: FallbackGroupConfigProps) {
|
||||
// Filter available options for fallbacks (exclude primary only, allow already selected to be shown for deselection)
|
||||
const availableFallbackOptions = availableModels.filter(
|
||||
(m) => m !== group.primaryModel,
|
||||
);
|
||||
|
||||
const handlePrimaryChange = (value: string) => {
|
||||
let newFallbacks = [...group.fallbackModels];
|
||||
// Remove from fallbacks if it was there
|
||||
if (newFallbacks.includes(value)) {
|
||||
newFallbacks = newFallbacks.filter((m) => m !== value);
|
||||
}
|
||||
onChange({
|
||||
...group,
|
||||
primaryModel: value,
|
||||
fallbackModels: newFallbacks,
|
||||
});
|
||||
};
|
||||
|
||||
const handleFallbackSelect = (values: string[]) => {
|
||||
// Limit to maxFallbacks
|
||||
const limitedValues = values.slice(0, maxFallbacks);
|
||||
|
||||
onChange({
|
||||
...group,
|
||||
fallbackModels: limitedValues,
|
||||
});
|
||||
};
|
||||
|
||||
const removeFallback = (indexToRemove: number) => {
|
||||
const newFallbacks = group.fallbackModels.filter((_, index) => index !== indexToRemove);
|
||||
onChange({
|
||||
...group,
|
||||
fallbackModels: newFallbacks,
|
||||
});
|
||||
};
|
||||
|
||||
const canAddMoreFallbacks = group.fallbackModels.length < maxFallbacks;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 py-4">
|
||||
{/* Primary Model Section */}
|
||||
<div className="relative">
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
Primary Model <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Select
|
||||
className="w-full h-12"
|
||||
size="large"
|
||||
placeholder="Select primary model"
|
||||
value={group.primaryModel}
|
||||
onChange={handlePrimaryChange}
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
options={availableModels.map((m) => ({ label: m, value: m }))}
|
||||
/>
|
||||
{!group.primaryModel && (
|
||||
<div className="mt-2 flex items-center gap-2 text-amber-600 text-xs bg-amber-50 p-2 rounded">
|
||||
<AlertCircle className="w-4 h-4" />
|
||||
<span>Select a model to begin configuring fallbacks</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Visual Connection */}
|
||||
<div className="flex items-center justify-center -my-4 z-10">
|
||||
<div className="bg-indigo-50 text-indigo-500 px-4 py-1 rounded-full text-xs font-bold border border-indigo-100 flex items-center gap-2 shadow-sm">
|
||||
<ArrowDown className="w-4 h-4" />
|
||||
IF FAILS, TRY...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fallback Models Section */}
|
||||
<div
|
||||
className={`transition-opacity duration-300 ${!group.primaryModel ? "opacity-50 pointer-events-none" : "opacity-100"}`}
|
||||
>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
Fallback Chain <span className="text-red-500">*</span>
|
||||
<span className="text-xs text-gray-500 font-normal ml-2">
|
||||
(Max {maxFallbacks} fallbacks at a time)
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<div className="bg-gray-50 rounded-xl p-4 border border-gray-200">
|
||||
{/* Add Fallback Input */}
|
||||
<div className="mb-4">
|
||||
<Select
|
||||
mode="multiple"
|
||||
className="w-full"
|
||||
size="large"
|
||||
placeholder={
|
||||
canAddMoreFallbacks
|
||||
? "Select fallback models to add..."
|
||||
: `Maximum ${maxFallbacks} fallbacks reached`
|
||||
}
|
||||
value={group.fallbackModels}
|
||||
onChange={handleFallbackSelect}
|
||||
disabled={!group.primaryModel}
|
||||
options={availableFallbackOptions.map((m) => ({
|
||||
label: m,
|
||||
value: m,
|
||||
}))}
|
||||
optionRender={(option, info) => {
|
||||
const isSelected = group.fallbackModels.includes(option.value as string);
|
||||
const orderIndex = isSelected
|
||||
? group.fallbackModels.indexOf(option.value as string) + 1
|
||||
: null;
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{isSelected && orderIndex !== null && (
|
||||
<span className="flex items-center justify-center w-5 h-5 rounded bg-indigo-100 text-indigo-600 text-xs font-bold">
|
||||
{orderIndex}
|
||||
</span>
|
||||
)}
|
||||
<span>{option.label}</span>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
maxTagCount="responsive"
|
||||
maxTagPlaceholder={(omittedValues) => (
|
||||
<Tooltip
|
||||
styles={{ root: { pointerEvents: "none" } }}
|
||||
title={omittedValues.map(({ value }) => value).join(", ")}
|
||||
>
|
||||
<span>+{omittedValues.length} more</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
showSearch
|
||||
filterOption={(input, option) =>
|
||||
(option?.label ?? "").toLowerCase().includes(input.toLowerCase())
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1 ml-1">
|
||||
{canAddMoreFallbacks
|
||||
? `Search and select multiple models. Selected models will appear below in order. (${group.fallbackModels.length}/${maxFallbacks} used)`
|
||||
: `Maximum ${maxFallbacks} fallbacks reached. Remove some to add more.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Fallback List */}
|
||||
<div className="space-y-2 min-h-[100px]">
|
||||
{group.fallbackModels.length === 0 ? (
|
||||
<div className="h-32 border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center text-gray-400">
|
||||
<span className="text-sm">No fallback models selected</span>
|
||||
<span className="text-xs mt-1">Add models from the dropdown above</span>
|
||||
</div>
|
||||
) : (
|
||||
group.fallbackModels.map((modelValue, index) => {
|
||||
return (
|
||||
<div
|
||||
key={`${modelValue}-${index}`}
|
||||
className="group flex items-center justify-between p-3 bg-white rounded-lg border border-gray-200 hover:border-indigo-300 hover:shadow-sm transition-all"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-6 h-6 rounded bg-gray-100 text-gray-400 group-hover:text-indigo-500 group-hover:bg-indigo-50">
|
||||
<span className="text-xs font-bold">{index + 1}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="font-medium text-gray-800">{modelValue}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeFallback(index)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-gray-400 hover:text-red-500 p-1"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* Form component for selecting and configuring fallback groups
|
||||
* Manages groups state internally, but does not handle submission
|
||||
* Decoupled from form submission logic
|
||||
*/
|
||||
|
||||
import { Button } from "@tremor/react";
|
||||
import { message, Tabs } from "antd";
|
||||
import { Plus } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { FallbackGroup, FallbackGroupConfig } from "./FallbackGroupConfig";
|
||||
|
||||
interface FallbackSelectionFormProps {
|
||||
groups: FallbackGroup[];
|
||||
onGroupsChange: (groups: FallbackGroup[]) => void;
|
||||
availableModels: string[];
|
||||
maxFallbacks?: number;
|
||||
maxGroups?: number;
|
||||
}
|
||||
|
||||
export function FallbackSelectionForm({
|
||||
groups,
|
||||
onGroupsChange,
|
||||
availableModels,
|
||||
maxFallbacks = 5,
|
||||
maxGroups = 5,
|
||||
}: FallbackSelectionFormProps) {
|
||||
const [activeKey, setActiveKey] = useState(groups.length > 0 ? groups[0].id : "1");
|
||||
|
||||
// Reset activeKey when groups change (e.g., when modal reopens)
|
||||
useEffect(() => {
|
||||
if (groups.length > 0) {
|
||||
// If current activeKey doesn't exist in groups, reset to first group
|
||||
const activeKeyExists = groups.some((g) => g.id === activeKey);
|
||||
if (!activeKeyExists) {
|
||||
setActiveKey(groups[0].id);
|
||||
}
|
||||
} else {
|
||||
// If groups is empty, reset activeKey
|
||||
setActiveKey("1");
|
||||
}
|
||||
}, [groups]);
|
||||
|
||||
const handleAddGroup = () => {
|
||||
if (groups.length >= maxGroups) {
|
||||
return;
|
||||
}
|
||||
const newId = Date.now().toString();
|
||||
const newGroups = [
|
||||
...groups,
|
||||
{
|
||||
id: newId,
|
||||
primaryModel: null,
|
||||
fallbackModels: [],
|
||||
},
|
||||
];
|
||||
onGroupsChange(newGroups);
|
||||
setActiveKey(newId);
|
||||
};
|
||||
|
||||
const handleRemoveGroup = (targetId: string) => {
|
||||
if (groups.length === 1) {
|
||||
message.warning("At least one group is required");
|
||||
return;
|
||||
}
|
||||
const newGroups = groups.filter((g) => g.id !== targetId);
|
||||
onGroupsChange(newGroups);
|
||||
if (activeKey === targetId && newGroups.length > 0) {
|
||||
setActiveKey(newGroups[newGroups.length - 1].id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGroupUpdate = (updatedGroup: FallbackGroup) => {
|
||||
const newGroups = groups.map((g) => (g.id === updatedGroup.id ? updatedGroup : g));
|
||||
onGroupsChange(newGroups);
|
||||
};
|
||||
|
||||
// Generate tab items
|
||||
const items = groups.map((group, index) => {
|
||||
const label = group.primaryModel
|
||||
? group.primaryModel
|
||||
: `Group ${index + 1}`;
|
||||
return {
|
||||
key: group.id,
|
||||
label: label,
|
||||
closable: groups.length > 1, // Only allow closing if there's more than 1 group
|
||||
children: (
|
||||
<FallbackGroupConfig
|
||||
group={group}
|
||||
onChange={handleGroupUpdate}
|
||||
availableModels={availableModels}
|
||||
maxFallbacks={maxFallbacks}
|
||||
/>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
if (groups.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12 bg-gray-50 rounded-lg border border-dashed border-gray-300">
|
||||
<p className="text-gray-500 mb-4">No fallback groups configured</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleAddGroup}
|
||||
icon={() => <Plus className="w-4 h-4" />}
|
||||
>
|
||||
Create First Group
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
type="editable-card"
|
||||
activeKey={activeKey}
|
||||
onChange={setActiveKey}
|
||||
onEdit={(targetKey, action) => {
|
||||
if (action === "add") handleAddGroup();
|
||||
else if (action === "remove" && groups.length > 1) {
|
||||
handleRemoveGroup(targetKey as string);
|
||||
}
|
||||
}}
|
||||
items={items}
|
||||
className="fallback-tabs"
|
||||
tabBarStyle={{
|
||||
marginBottom: 0,
|
||||
}}
|
||||
hideAdd={groups.length >= maxGroups}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,10 +3,10 @@ import { Icon, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow
|
|||
import { Tooltip } from "antd";
|
||||
import openai from "openai";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import AddFallbacks from "./add_fallbacks";
|
||||
import DeleteResourceModal from "./common_components/DeleteResourceModal";
|
||||
import NotificationsManager from "./molecules/notifications_manager";
|
||||
import { getCallbacksCall, setCallbacksCall } from "./networking";
|
||||
import DeleteResourceModal from "../../../common_components/DeleteResourceModal";
|
||||
import NotificationsManager from "../../../molecules/notifications_manager";
|
||||
import { getCallbacksCall, setCallbacksCall } from "../../../networking";
|
||||
import AddFallbacks from "./AddFallbacks";
|
||||
|
||||
type FallbackEntry = { [modelName: string]: string[] };
|
||||
type Fallbacks = FallbackEntry[];
|
||||
|
|
@ -21,7 +21,7 @@ interface FallbacksProps {
|
|||
async function testFallbackModelResponse(selectedModel: string, accessToken: string) {
|
||||
const isLocal = process.env.NODE_ENV === "development";
|
||||
if (isLocal != true) {
|
||||
console.log = function () {};
|
||||
console.log = function () { };
|
||||
}
|
||||
const proxyBaseUrl = isLocal ? "http://localhost:4000" : window.location.origin;
|
||||
const client = new openai.OpenAI({
|
||||
|
|
@ -142,13 +142,48 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID, mo
|
|||
return null;
|
||||
}
|
||||
|
||||
const handleFallbacksChange = async (fallbacks: Fallbacks): Promise<void> => {
|
||||
if (!accessToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedSettings = {
|
||||
...routerSettings,
|
||||
fallbacks: fallbacks,
|
||||
};
|
||||
|
||||
const payload = {
|
||||
router_settings: updatedSettings,
|
||||
};
|
||||
|
||||
try {
|
||||
await setCallbacksCall(accessToken, payload);
|
||||
// Update UI only after successful API call
|
||||
setRouterSettings(updatedSettings);
|
||||
} catch (error) {
|
||||
// Revert on error by refetching from server
|
||||
NotificationsManager.fromBackend("Failed to update router settings: " + error);
|
||||
if (accessToken && userRole && userID) {
|
||||
getCallbacksCall(accessToken, userID, userRole).then((data) => {
|
||||
let router_settings = data.router_settings;
|
||||
if ("model_group_retry_policy" in router_settings) {
|
||||
delete router_settings["model_group_retry_policy"];
|
||||
}
|
||||
setRouterSettings(router_settings);
|
||||
});
|
||||
}
|
||||
// Re-throw error so caller can handle it
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<AddFallbacks
|
||||
models={modelData?.data ? modelData.data.map((data: any) => data.model_name) : []}
|
||||
accessToken={accessToken}
|
||||
routerSettings={routerSettings}
|
||||
setRouterSettings={setRouterSettings}
|
||||
accessToken={accessToken || ""}
|
||||
value={routerSettings.fallbacks || []}
|
||||
onChange={handleFallbacksChange}
|
||||
/>
|
||||
<Table>
|
||||
<TableHead>
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import AddFallbacks from "./add_fallbacks";
|
||||
|
||||
vi.mock("./networking", () => ({
|
||||
setCallbacksCall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./playground/llm_calls/fetch_models", () => ({
|
||||
fetchAvailableModels: vi.fn(() =>
|
||||
Promise.resolve([
|
||||
{ model_group: "gpt-4" },
|
||||
{ model_group: "gpt-3.5-turbo" },
|
||||
{ model_group: "claude-3-opus" },
|
||||
{ model_group: "claude-3-sonnet" },
|
||||
]),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("./molecules/notifications_manager", () => ({
|
||||
default: {
|
||||
success: vi.fn(),
|
||||
fromBackend: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("AddFallbacks", () => {
|
||||
const mockAccessToken = "test-token";
|
||||
const mockRouterSettings = { fallbacks: [] };
|
||||
const mockSetRouterSettings = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should render the component", () => {
|
||||
render(
|
||||
<AddFallbacks
|
||||
accessToken={mockAccessToken}
|
||||
routerSettings={mockRouterSettings}
|
||||
setRouterSettings={mockSetRouterSettings}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /Add Fallbacks/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,250 +0,0 @@
|
|||
/**
|
||||
* Modal to add fallbacks to the proxy router config
|
||||
*/
|
||||
|
||||
import { Button } from "@tremor/react";
|
||||
import { Form, Modal, Select } from "antd";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import NotificationManager from "./molecules/notifications_manager";
|
||||
import { setCallbacksCall } from "./networking";
|
||||
import { fetchAvailableModels, ModelGroup } from "./playground/llm_calls/fetch_models";
|
||||
|
||||
interface AddFallbacksProps {
|
||||
models?: string[];
|
||||
accessToken: string;
|
||||
routerSettings: { [key: string]: any };
|
||||
setRouterSettings: React.Dispatch<React.SetStateAction<{ [key: string]: any }>>;
|
||||
}
|
||||
|
||||
const AddFallbacks: React.FC<AddFallbacksProps> = ({ models, accessToken, routerSettings, setRouterSettings }) => {
|
||||
const [form] = Form.useForm();
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [selectedModel, setSelectedModel] = useState("");
|
||||
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
|
||||
const [selectedFallbacks, setSelectedFallbacks] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
const uniqueModels = await fetchAvailableModels(accessToken);
|
||||
console.log("Fetched models for fallbacks:", uniqueModels);
|
||||
setModelInfo(uniqueModels);
|
||||
} catch (error) {
|
||||
console.error("Error fetching model info for fallbacks:", error);
|
||||
}
|
||||
};
|
||||
loadModels();
|
||||
}, [accessToken]);
|
||||
const handleOk = () => {
|
||||
setIsModalVisible(false);
|
||||
form.resetFields();
|
||||
setSelectedFallbacks([]);
|
||||
setSelectedModel("");
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsModalVisible(false);
|
||||
form.resetFields();
|
||||
setSelectedFallbacks([]);
|
||||
setSelectedModel("");
|
||||
};
|
||||
|
||||
const updateFallbacks = (formValues: Record<string, any>) => {
|
||||
// Print the received value
|
||||
console.log(formValues);
|
||||
|
||||
// Extract model_name and models from formValues
|
||||
const { model_name, models } = formValues;
|
||||
|
||||
// Create new fallback
|
||||
const newFallback = { [model_name]: models };
|
||||
|
||||
// Get current fallbacks, or an empty array if it's null
|
||||
const currentFallbacks = routerSettings.fallbacks || [];
|
||||
|
||||
// Add new fallback to the current fallbacks
|
||||
const updatedFallbacks = [...currentFallbacks, newFallback];
|
||||
|
||||
// Create a new routerSettings object with updated fallbacks
|
||||
const updatedRouterSettings = { ...routerSettings, fallbacks: updatedFallbacks };
|
||||
|
||||
// Print updated routerSettings
|
||||
console.log(updatedRouterSettings);
|
||||
|
||||
const payload = {
|
||||
router_settings: updatedRouterSettings,
|
||||
};
|
||||
|
||||
try {
|
||||
setCallbacksCall(accessToken, payload);
|
||||
// Update routerSettings state
|
||||
setRouterSettings(updatedRouterSettings);
|
||||
} catch (error) {
|
||||
NotificationManager.fromBackend("Failed to update router settings: " + error);
|
||||
}
|
||||
|
||||
NotificationManager.success("router settings updated successfully");
|
||||
|
||||
setIsModalVisible(false);
|
||||
form.resetFields();
|
||||
setSelectedFallbacks([]);
|
||||
setSelectedModel("");
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Button className="mx-auto" onClick={() => setIsModalVisible(true)} icon={() => <span className="mr-1">+</span>}>
|
||||
Add Fallbacks
|
||||
</Button>
|
||||
<Modal
|
||||
title={
|
||||
<div className="pb-4 border-b border-gray-100">
|
||||
<h2 className="text-xl font-semibold text-gray-900">Add Fallbacks</h2>
|
||||
</div>
|
||||
}
|
||||
open={isModalVisible}
|
||||
width={900}
|
||||
footer={null}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
className="top-8"
|
||||
styles={{
|
||||
body: { padding: "24px" },
|
||||
header: { padding: "24px 24px 0 24px", border: "none" },
|
||||
}}
|
||||
>
|
||||
<div className="mt-6">
|
||||
<div className="mb-6">
|
||||
<p className="text-gray-600">
|
||||
Configure fallback models to improve reliability. When the primary model fails or is unavailable, requests
|
||||
will automatically route to the specified fallback models in order.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Form form={form} onFinish={updateFallbacks} layout="vertical" className="space-y-6">
|
||||
<div className="grid grid-cols-1 gap-6">
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Primary Model <span className="text-red-500">*</span>
|
||||
</span>
|
||||
}
|
||||
name="model_name"
|
||||
rules={[{ required: true, message: "Please select the primary model that needs fallbacks" }]}
|
||||
className="!mb-0"
|
||||
>
|
||||
<Select
|
||||
placeholder="Select the model that needs fallback protection"
|
||||
value={selectedModel || undefined}
|
||||
onChange={(value: string) => {
|
||||
setSelectedModel(value);
|
||||
// Remove the selected model from fallbacks if it was selected
|
||||
const updatedFallbacks = selectedFallbacks.filter((model) => model !== value);
|
||||
setSelectedFallbacks(updatedFallbacks);
|
||||
form.setFieldValue("models", updatedFallbacks);
|
||||
form.setFieldValue("model_name", value);
|
||||
}}
|
||||
showSearch
|
||||
allowClear
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{Array.from(new Set(modelInfo.map((option) => option.model_group))).map(
|
||||
(model: string, index: number) => (
|
||||
<Select.Option key={index} value={model}>
|
||||
{model}
|
||||
</Select.Option>
|
||||
),
|
||||
)}
|
||||
</Select>
|
||||
<p className="text-sm text-gray-500 mt-1">This is the primary model that users will request</p>
|
||||
</Form.Item>
|
||||
|
||||
<div className="border-t border-gray-200 my-6"></div>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
Fallback Models (select multiple) <span className="text-red-500">*</span>
|
||||
</span>
|
||||
}
|
||||
name="models"
|
||||
rules={[{ required: true, message: "Please select at least one fallback model" }]}
|
||||
className="!mb-0"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{/* Show selected models in order */}
|
||||
{selectedFallbacks.length > 0 && (
|
||||
<div className="border border-gray-200 rounded-lg p-3 bg-gray-50">
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Fallback Order:</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedFallbacks.map((model, index) => (
|
||||
<div
|
||||
key={model}
|
||||
className="flex items-center bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm"
|
||||
>
|
||||
<span className="font-medium mr-2">{index + 1}.</span>
|
||||
<span>{model}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const newFallbacks = selectedFallbacks.filter((m) => m !== model);
|
||||
setSelectedFallbacks(newFallbacks);
|
||||
form.setFieldValue("models", newFallbacks);
|
||||
}}
|
||||
className="ml-2 text-blue-600 hover:text-blue-800"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model selector */}
|
||||
<Select
|
||||
placeholder="Add a fallback model"
|
||||
value={undefined}
|
||||
onChange={(value: string) => {
|
||||
if (value && !selectedFallbacks.includes(value)) {
|
||||
const newFallbacks = [...selectedFallbacks, value];
|
||||
setSelectedFallbacks(newFallbacks);
|
||||
form.setFieldValue("models", newFallbacks);
|
||||
}
|
||||
}}
|
||||
showSearch
|
||||
allowClear
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
{Array.from(new Set(modelInfo.map((option) => option.model_group)))
|
||||
.filter((data: string) => data !== selectedModel && !selectedFallbacks.includes(data))
|
||||
.sort()
|
||||
.map((model: string) => (
|
||||
<Select.Option key={model} value={model}>
|
||||
{model}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
<strong>Order matters:</strong> Models will be tried in the order shown above (1st, 2nd, 3rd, etc.)
|
||||
</p>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end space-x-3 pt-6 border-t border-gray-100">
|
||||
<Button variant="secondary" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary" type="submit">
|
||||
Add Fallbacks
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddFallbacks;
|
||||
|
|
@ -1,95 +0,0 @@
|
|||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import Fallbacks from "./fallbacks";
|
||||
import { getCallbacksCall, setCallbacksCall } from "./networking";
|
||||
|
||||
vi.mock("./networking", () => ({
|
||||
getCallbacksCall: vi.fn(),
|
||||
setCallbacksCall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./add_fallbacks", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>Mock Add Fallbacks</div>,
|
||||
}));
|
||||
|
||||
vi.mock("openai", () => ({
|
||||
default: {
|
||||
OpenAI: vi.fn().mockImplementation(() => ({
|
||||
chat: {
|
||||
completions: {
|
||||
create: vi.fn().mockResolvedValue({
|
||||
model: "test-model",
|
||||
}),
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
}));
|
||||
|
||||
describe("Fallbacks", () => {
|
||||
const defaultProps = {
|
||||
accessToken: "token",
|
||||
userRole: "admin",
|
||||
userID: "user-123",
|
||||
modelData: { data: [] },
|
||||
};
|
||||
const mockGetCallbacksCall = vi.mocked(getCallbacksCall);
|
||||
const mockSetCallbacksCall = vi.mocked(setCallbacksCall);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockGetCallbacksCall.mockResolvedValue({
|
||||
router_settings: {
|
||||
fallbacks: [],
|
||||
},
|
||||
});
|
||||
mockSetCallbacksCall.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("should render", async () => {
|
||||
render(<Fallbacks {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("columnheader", { name: "Model Name" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should render fallback data when callback data is returned from network call", async () => {
|
||||
const mockFallbackData = {
|
||||
router_settings: {
|
||||
fallbacks: [{ "xai/grok-2": ["xai/grok-4", "gpt-4"] }, { "gpt-3.5-turbo": ["gpt-4"] }],
|
||||
},
|
||||
};
|
||||
|
||||
mockGetCallbacksCall.mockResolvedValue(mockFallbackData);
|
||||
|
||||
render(<Fallbacks {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("xai/grok-2")).toBeInTheDocument();
|
||||
expect(screen.getByText("xai/grok-4, gpt-4")).toBeInTheDocument();
|
||||
expect(screen.getByText("gpt-3.5-turbo")).toBeInTheDocument();
|
||||
expect(screen.getByText("gpt-4")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(mockGetCallbacksCall).toHaveBeenCalledWith(
|
||||
defaultProps.accessToken,
|
||||
defaultProps.userID,
|
||||
defaultProps.userRole,
|
||||
);
|
||||
});
|
||||
|
||||
it("should render AddFallbacks component", async () => {
|
||||
render(<Fallbacks {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Mock Add Fallbacks")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not render when access token is not provided", () => {
|
||||
const { container } = render(<Fallbacks {...defaultProps} accessToken={null} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -22,7 +22,7 @@ import { InputNumber } from "antd";
|
|||
import { TrashIcon, CheckCircleIcon } from "@heroicons/react/outline";
|
||||
|
||||
import RouterSettings from "./router_settings";
|
||||
import Fallbacks from "./fallbacks";
|
||||
import Fallbacks from "./Settings/RouterSettings/Fallbacks/Fallbacks";
|
||||
interface GeneralSettingsPageProps {
|
||||
accessToken: string | null;
|
||||
userRole: string | null;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue