From 649fd3c9ea382f5ffc8218d20af91f0038bc0dcf Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:59:59 +0000 Subject: [PATCH 1/5] feat(fallbacks): surface fallbacks in nav, provider wildcards, and LiteAdmin - match fnmatch pattern keys (e.g. openai/*) in get_fallback_model_group with priority exact > stripped/prefixed > pattern > generic * - add provider wildcard options (All Models) to the Add Fallbacks primary picker; exclude wildcards from fallback chains - resolve provider logos in the fallbacks table for wildcard and prefixed keys, and render provider wildcards as friendly labels - add a top-level Fallbacks nav item and /fallbacks page - add fallbacks_list, fallback_set and fallback_delete LiteAdmin tools Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com> --- .../router_utils/fallback_event_handlers.py | 17 ++- .../test_fallback_event_handlers.py | 34 ++++++ .../unit/test_router/test_router_fallbacks.py | 36 ++++++ .../src/app/(dashboard)/fallbacks/page.tsx | 19 ++++ .../src/app/(dashboard)/legacyPageRoutes.ts | 1 + .../RouterSettings/Fallbacks/AddFallbacks.tsx | 3 +- .../Fallbacks/EditFallbacks.tsx | 10 +- .../Fallbacks/FallbackGroupConfig.tsx | 23 ++-- .../Fallbacks/FallbackSelectionForm.test.tsx | 30 ++--- .../Fallbacks/FallbackSelectionForm.tsx | 13 ++- .../Fallbacks/Fallbacks.test.tsx | 21 ++++ .../RouterSettings/Fallbacks/Fallbacks.tsx | 46 +++++--- .../Fallbacks/fallbackOptions.test.ts | 96 ++++++++++++++++ .../Fallbacks/fallbackOptions.ts | 106 ++++++++++++++++++ .../RouterSettingsAccordion.tsx | 4 +- .../src/components/leftnav.test.tsx | 1 + .../src/components/leftnav.tsx | 8 ++ .../src/components/liteadmin/agent.test.ts | 89 +++++++++++++++ .../src/components/liteadmin/operations.ts | 57 ++++++++++ .../src/components/liteadmin/toolResults.ts | 3 +- .../src/components/page_metadata.ts | 1 + 21 files changed, 563 insertions(+), 55 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/fallbacks/page.tsx create mode 100644 ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/fallbackOptions.test.ts create mode 100644 ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/fallbackOptions.ts diff --git a/litellm/router_utils/fallback_event_handlers.py b/litellm/router_utils/fallback_event_handlers.py index e1de57c91a0..1abee667297 100644 --- a/litellm/router_utils/fallback_event_handlers.py +++ b/litellm/router_utils/fallback_event_handlers.py @@ -1,3 +1,4 @@ +import fnmatch import hashlib import json from collections.abc import Callable, Mapping, Sequence @@ -5,7 +6,7 @@ from dataclasses import dataclass from datetime import datetime from enum import Enum from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Final +from typing import TYPE_CHECKING, Any, Final, cast # noqa: TID251 # narrows untyped fallback dict shapes import litellm from litellm._logging import verbose_router_logger @@ -548,32 +549,42 @@ def get_fallback_model_group(fallbacks: list[Any], model_group: str) -> tuple[li - exact match - stripped model group match - provider-prefixed model group match + - fnmatch pattern key match (e.g. "openai/*") - generic fallback """ generic_fallback_idx: int | None = None stripped_model_fallback: list[str] | None = None + pattern_model_fallback: list[str] | None = None # mutable-ok: resolved inside the loop like stripped_model_fallback # rebind-ok: one assignment inside the loop fallback_model_group: list[str] | None = None fallback_keys: Final = tuple(next(iter(item)) for item in fallbacks if isinstance(item, dict) and item) prefixed_model_group: Final = _provider_prefixed_model_group(model_group, fallback_keys) ## check for specific model group-specific fallbacks for idx, item in enumerate(fallbacks): if isinstance(item, dict): - fallback_key = next(iter(item)) + fallback_key = cast(str, next(iter(item))) # cast-ok: fallback entries are single-key dicts; the key is always a str if fallback_key == model_group: # check exact match fallback_model_group = item[model_group] break elif fallback_key == prefixed_model_group or _check_stripped_model_group( model_group=model_group, fallback_key=fallback_key ): # check generic fallback - stripped_model_fallback = item[fallback_key] + stripped_model_fallback = cast(list[str], item[fallback_key]) # cast-ok: fallback chains are lists of model names elif fallback_key == "*": # check generic fallback generic_fallback_idx = idx + elif ( + pattern_model_fallback is None + and "*" in fallback_key + and fnmatch.fnmatchcase(model_group, fallback_key) + ): + pattern_model_fallback = cast(list[str], item[fallback_key]) # cast-ok: fallback chains are lists of model names elif isinstance(item, str): fallback_model_group = [item] ## if none, check for generic fallback if fallback_model_group is None: if stripped_model_fallback is not None: fallback_model_group = stripped_model_fallback + elif pattern_model_fallback is not None: + fallback_model_group = pattern_model_fallback # rebind-ok: one assignment per priority branch, same as stripped and generic elif generic_fallback_idx is not None: fallback_model_group = fallbacks[generic_fallback_idx]["*"] diff --git a/tests/unit/router_utils/test_fallback_event_handlers.py b/tests/unit/router_utils/test_fallback_event_handlers.py index febb33aab7d..555e6e84eab 100644 --- a/tests/unit/router_utils/test_fallback_event_handlers.py +++ b/tests/unit/router_utils/test_fallback_event_handlers.py @@ -805,6 +805,40 @@ def test_get_fallback_model_group_does_not_mutate_fallbacks(): assert fallbacks == [{"gpt-3.5-turbo": ["claude-3-haiku"]}, "gpt-4o-mini"] +class TestGetFallbackModelGroupPatternKeys: + def test_provider_wildcard_key_matches_model_group(self): + fallbacks = [{"openai/*": ["claude-3-haiku"]}] + + assert get_fallback_model_group(fallbacks=fallbacks, model_group="openai/gpt-6-astra")[0] == [ + "claude-3-haiku" + ] + + def test_provider_wildcard_key_does_not_match_other_providers(self): + fallbacks = [{"openai/*": ["claude-3-haiku"]}] + + assert get_fallback_model_group(fallbacks=fallbacks, model_group="anthropic/claude-x")[0] is None + + def test_exact_key_beats_pattern_key(self): + fallbacks = [{"openai/*": ["generic-backup"]}, {"openai/gpt-4": ["specific-backup"]}] + + assert get_fallback_model_group(fallbacks=fallbacks, model_group="openai/gpt-4")[0] == ["specific-backup"] + + def test_pattern_key_beats_generic_star(self): + fallbacks = [{"*": ["star-backup"]}, {"openai/*": ["openai-backup"]}] + + assert get_fallback_model_group(fallbacks=fallbacks, model_group="openai/gpt-4")[0] == ["openai-backup"] + + def test_first_matching_pattern_key_in_list_order_wins(self): + fallbacks = [{"openai/*": ["first-backup"]}, {"*gpt*": ["second-backup"]}] + + assert get_fallback_model_group(fallbacks=fallbacks, model_group="openai/gpt-4")[0] == ["first-backup"] + + def test_generic_star_still_applies_when_no_pattern_matches(self): + fallbacks = [{"anthropic/*": ["anthropic-backup"]}, {"*": ["star-backup"]}] + + assert get_fallback_model_group(fallbacks=fallbacks, model_group="openai/gpt-4") == (["star-backup"], 1) + + class TestTriggerCooldownForFailedDeployment: def test_calls_set_cooldown_deployments_with_stamped_deployment_id(self): mock_router = MagicMock() diff --git a/tests/unit/test_router/test_router_fallbacks.py b/tests/unit/test_router/test_router_fallbacks.py index dc7ba5b4894..124f28c8baa 100644 --- a/tests/unit/test_router/test_router_fallbacks.py +++ b/tests/unit/test_router/test_router_fallbacks.py @@ -426,6 +426,42 @@ def test_router_fallbacks_with_wildcard_model_name(): assert response["choices"][0]["message"]["content"] == "Hi this is claude!" +def test_router_fallbacks_with_provider_wildcard_fallback_key(): + """ + A fallback key of "openai/*" must catch a failing request routed through a + wildcard "openai/*" deployment (model_group is the requested model name, + e.g. "openai/gpt-6-astra"). + """ + router = Router( + model_list=[ + { + "model_name": "openai/*", + "litellm_params": { + "model": "openai/*", + "api_key": os.getenv("OPENAI_API_KEY"), + }, + }, + { + "model_name": "claude-3-haiku", + "litellm_params": { + "model": "claude-haiku-4-5-20251001", + "api_key": os.getenv("ANTHROPIC_API_KEY"), + "mock_response": "Hi this is claude!", + }, + }, + ], + fallbacks=[{"openai/*": ["claude-3-haiku"]}], + ) + + response = router.completion( + model="openai/gpt-6-astra", + messages=[{"role": "user", "content": "Hey, how's it going?"}], + mock_testing_fallbacks=True, + ) + + assert response["choices"][0]["message"]["content"] == "Hi this is claude!" + + def test_get_fallback_model_group(): from litellm.router_utils.fallback_event_handlers import get_fallback_model_group diff --git a/ui/litellm-dashboard/src/app/(dashboard)/fallbacks/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/fallbacks/page.tsx new file mode 100644 index 00000000000..6ec2a98aa79 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/fallbacks/page.tsx @@ -0,0 +1,19 @@ +"use client"; + +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import Fallbacks from "@/components/Settings/RouterSettings/Fallbacks/Fallbacks"; + +export default function FallbacksPage() { + const { accessToken, userRole, userId } = useAuthorized(); + return ( +
+
+

Fallbacks

+

+ Automatically retry a different model when the primary fails +

+
+ +
+ ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts b/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts index 09cc4ef0a75..1c43f849b70 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/legacyPageRoutes.ts @@ -39,6 +39,7 @@ const LEGACY_PAGE_ROUTES: ReadonlyMap = new Map( "model-insights": "model-insights", agents: "agents", "router-settings": "router-settings", + fallbacks: "fallbacks", users: "users", teams: "teams", organizations: "organizations", diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx index aca47e6b5c3..b41aa374f69 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx @@ -63,7 +63,6 @@ export default function AddFallbacks({ accessToken, value = [], onChange }: AddF } }, [accessToken, isModalVisible]); - const availableModels = Array.from(new Set(modelInfo.map((option) => option.model_group))).sort(); const handleCancel = () => { setIsModalVisible(false); @@ -125,7 +124,7 @@ export default function AddFallbacks({ accessToken, value = [], onChange }: AddF key={modalKey} groups={groups} onGroupsChange={setGroups} - availableModels={availableModels} + modelGroups={modelInfo} maxFallbacks={10} maxGroups={5} /> diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.tsx index 3893a2f47ad..efb7f3bc927 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.tsx @@ -11,6 +11,7 @@ import React, { useMemo, useState } from "react"; import { fetchAvailableModels } from "@/components/llm_calls/fetch_models"; import { toast } from "@/lib/toast"; import { AddFallbacksModal } from "./AddFallbacksModal"; +import { fallbackChainOptions, primaryModelOptions } from "./fallbackOptions"; import { FallbackGroup, FallbackGroupConfig } from "./FallbackGroupConfig"; export type FallbackEntry = { [modelName: string]: string[] }; @@ -51,10 +52,8 @@ export default function EditFallbacks({ enabled: Boolean(accessToken), }); - const availableModels = useMemo( - () => Array.from(new Set(modelGroups.map((option) => option.model_group))).sort(), - [modelGroups], - ); + const primaryOptions = useMemo(() => primaryModelOptions(modelGroups), [modelGroups]); + const chainOptions = useMemo(() => fallbackChainOptions(modelGroups), [modelGroups]); const handleSave = async () => { const primaryModel = group.primaryModel; @@ -83,7 +82,8 @@ export default function EditFallbacks({ diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackGroupConfig.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackGroupConfig.tsx index 3897a9d9fa2..768fd0ab6a2 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackGroupConfig.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackGroupConfig.tsx @@ -4,9 +4,11 @@ */ import { MultiSelect } from "@/components/shared/MultiSelect"; -import { SearchSelect } from "@/components/shared/SearchSelect"; +import { SearchSelect, SearchSelectOption } from "@/components/shared/SearchSelect"; +import { ProviderLogo } from "@/components/molecules/models/ProviderLogo"; import { AlertCircle, ArrowDown, X } from "lucide-react"; import React, { useId } from "react"; +import { FallbackOption } from "./fallbackOptions"; export interface FallbackGroup { id: string; @@ -17,20 +19,27 @@ export interface FallbackGroup { interface FallbackGroupConfigProps { group: FallbackGroup; onChange: (updatedGroup: FallbackGroup) => void; - availableModels: string[]; + primaryOptions: FallbackOption[]; + fallbackOptions: FallbackOption[]; maxFallbacks: number; disablePrimaryModel?: boolean; } +const withWildcardIcons = (options: FallbackOption[]): SearchSelectOption[] => + options.map((option) => + option.value.endsWith("/*") ? { ...option, icon: } : option, + ); + export function FallbackGroupConfig({ group, onChange, - availableModels, + primaryOptions, + fallbackOptions, maxFallbacks, disablePrimaryModel = false, }: 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); + // Exclude the primary only, allow already selected to be shown for deselection + const availableFallbackOptions = fallbackOptions.filter((option) => option.value !== group.primaryModel); const handlePrimaryChange = (value: string | null) => { const newFallbacks = group.fallbackModels.filter((model) => model !== value); @@ -71,7 +80,7 @@ export function FallbackGroupConfig({ ({ label: m, value: m }))} + options={withWildcardIcons(primaryOptions)} value={group.primaryModel} onValueChange={handlePrimaryChange} placeholder="Select primary model" @@ -110,7 +119,7 @@ export function FallbackGroupConfig({ {/* Add Fallback Input */}
({ label: m, value: m }))} + options={availableFallbackOptions} value={group.fallbackModels} onValueChange={handleFallbackSelect} placeholder={ diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.test.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.test.tsx index 738040149f0..3745616c6dc 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.test.tsx @@ -6,7 +6,11 @@ import type { FallbackGroup } from "./FallbackGroupConfig"; import { toast } from "@/lib/toast"; const mockOnGroupsChange = vi.fn(); -const AVAILABLE_MODELS = ["gpt-4", "gpt-3.5-turbo", "claude-3-opus"]; +const AVAILABLE_MODEL_GROUPS = [ + { model_group: "gpt-4", providers: ["openai"] }, + { model_group: "gpt-3.5-turbo", providers: ["openai"] }, + { model_group: "claude-3-opus", providers: ["anthropic"] }, +]; describe("FallbackSelectionForm", () => { beforeEach(() => { @@ -16,14 +20,14 @@ describe("FallbackSelectionForm", () => { it("should render the component", () => { render( - , + , ); expect(screen.getByText(/no fallback groups configured/i)).toBeInTheDocument(); }); it("should show Create First Group button when no groups exist", () => { render( - , + , ); expect(screen.getByRole("button", { name: /create first group/i })).toBeInTheDocument(); }); @@ -31,7 +35,7 @@ describe("FallbackSelectionForm", () => { it("should call onGroupsChange when Create First Group is clicked", async () => { const user = userEvent.setup(); render( - , + , ); await user.click(screen.getByRole("button", { name: /create first group/i })); @@ -49,7 +53,7 @@ describe("FallbackSelectionForm", () => { it("should display tabs when groups exist", () => { const groups: FallbackGroup[] = [{ id: "1", primaryModel: null, fallbackModels: [] }]; render( - , + , ); expect(screen.getByRole("tab", { name: /group 1/i })).toBeInTheDocument(); }); @@ -57,7 +61,7 @@ describe("FallbackSelectionForm", () => { it("should display primary model as tab label when set", () => { const groups: FallbackGroup[] = [{ id: "1", primaryModel: "gpt-4", fallbackModels: [] }]; render( - , + , ); expect(screen.getByRole("tab", { name: "gpt-4" })).toBeInTheDocument(); }); @@ -66,7 +70,7 @@ describe("FallbackSelectionForm", () => { const user = userEvent.setup(); const groups: FallbackGroup[] = [{ id: "1", primaryModel: null, fallbackModels: [] }]; render( - , + , ); const addTabButton = screen.getByRole("button", { name: /add fallback group/i }); @@ -94,7 +98,7 @@ describe("FallbackSelectionForm", () => { , ); @@ -107,7 +111,7 @@ describe("FallbackSelectionForm", () => { , ); @@ -121,7 +125,7 @@ describe("FallbackSelectionForm", () => { { id: "2", primaryModel: "gpt-3.5-turbo", fallbackModels: [] }, ]; render( - , + , ); const removeButtons = screen.getAllByRole("button", { name: /^remove /i }); @@ -138,7 +142,7 @@ describe("FallbackSelectionForm", () => { it("should render FallbackGroupConfig for each group", () => { const groups: FallbackGroup[] = [{ id: "1", primaryModel: null, fallbackModels: [] }]; render( - , + , ); expect(screen.getByRole("combobox", { name: /primary model/i })).toHaveValue(""); expect(screen.getByText("Primary Model")).toBeInTheDocument(); @@ -147,7 +151,7 @@ describe("FallbackSelectionForm", () => { it("should display group with primary and fallback models in FallbackGroupConfig", () => { const groups: FallbackGroup[] = [{ id: "1", primaryModel: "gpt-4", fallbackModels: ["gpt-3.5-turbo"] }]; render( - , + , ); expect(screen.getByRole("tab", { name: "gpt-4" })).toBeInTheDocument(); expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0); @@ -165,7 +169,7 @@ describe("FallbackSelectionForm", () => { , ); diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.tsx index aba9312cb34..fdeb886ef04 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.tsx @@ -5,16 +5,18 @@ */ import { Button } from "@/components/ui/button"; +import { ModelGroup } from "@/components/llm_calls/fetch_models"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Plus, X } from "lucide-react"; -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { toast } from "@/lib/toast"; +import { fallbackChainOptions, primaryModelOptions } from "./fallbackOptions"; import { FallbackGroup, FallbackGroupConfig } from "./FallbackGroupConfig"; interface FallbackSelectionFormProps { groups: FallbackGroup[]; onGroupsChange: (groups: FallbackGroup[]) => void; - availableModels: string[]; + modelGroups: ModelGroup[]; maxFallbacks?: number; maxGroups?: number; } @@ -22,10 +24,12 @@ interface FallbackSelectionFormProps { export function FallbackSelectionForm({ groups, onGroupsChange, - availableModels, + modelGroups, maxFallbacks = 10, maxGroups = 5, }: FallbackSelectionFormProps) { + const primaryOptions = useMemo(() => primaryModelOptions(modelGroups), [modelGroups]); + const chainOptions = useMemo(() => fallbackChainOptions(modelGroups), [modelGroups]); const [activeKey, setActiveKey] = useState(groups.length > 0 ? groups[0].id : "1"); // Reset activeKey when groups change (e.g., when modal reopens) @@ -128,7 +132,8 @@ export function FallbackSelectionForm({ diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.test.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.test.tsx index cdcb1eefadb..02a85e150ba 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.test.tsx @@ -115,6 +115,27 @@ describe("Fallbacks", () => { ]); }); + it("should resolve a provider logo for wildcard and prefixed primary keys", async () => { + vi.mocked(networkingModule.getCallbacksCall).mockResolvedValueOnce({ + router_settings: { + fallbacks: [ + { "anthropic/*": ["gpt-4"] }, + { "openai/gpt-6-astra": ["gpt-4"] }, + { "bedrock/us.anthropic.claude-opus-4-8": ["gpt-4"] }, + ], + }, + }); + + const { container } = renderWithQueryClient(); + + await waitFor(() => { + expect(screen.getByText("All Anthropic Models")).toBeInTheDocument(); + }); + expect(screen.getByText("All Anthropic Models").parentElement).toHaveAttribute("title", "anthropic/*"); + const logos = container.querySelectorAll("img"); + expect(logos.length).toBeGreaterThanOrEqual(3); + }); + it("should render the component", async () => { renderWithQueryClient(); diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx index 2ccd1a70dc5..9d71f307fd6 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx @@ -1,4 +1,5 @@ import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap"; +import { useQuery } from "@tanstack/react-query"; import { ArrowRight, Pencil, Play, Trash2 } from "lucide-react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; @@ -9,6 +10,8 @@ import { ProviderLogo } from "../../../molecules/models/ProviderLogo"; import { toast } from "@/lib/toast"; import { getCallbacksCall, setCallbacksCall } from "../../../networking"; import { isProxyAdminRole } from "@/utils/roles"; +import { fetchAvailableModels } from "@/components/llm_calls/fetch_models"; +import { fallbackPrimaryLabel, resolveFallbackProvider } from "./fallbackOptions"; import AddFallbacks from "./AddFallbacks"; import EditFallbacks from "./EditFallbacks"; @@ -20,12 +23,14 @@ const modelCardClass = const iconWrapperClass = "inline-flex shrink-0 items-center justify-center px-1.5 py-1.5"; -function renderModelNameCell(modelName: string, getProviderFromModel?: (modelName: string) => string): React.ReactNode { - const provider = getProviderFromModel?.(modelName) ?? modelName; +export type ModelDisplayResolver = (modelName: string) => { provider: string; label: string; title?: string }; + +function renderModelNameCell(modelName: string, resolveModel?: ModelDisplayResolver): React.ReactNode { + const resolved = resolveModel?.(modelName) ?? { provider: modelName, label: modelName }; return ( - - - {modelName} + + + {resolved.label} ); } @@ -33,17 +38,17 @@ function renderModelNameCell(modelName: string, getProviderFromModel?: (modelNam function renderFallbacksChain( _primaryModel: string, fallbackModels: string[], - getProviderFromModel?: (modelName: string) => string, + resolveModel?: ModelDisplayResolver, ): React.ReactNode { const list = Array.isArray(fallbackModels) ? fallbackModels : []; if (list.length === 0) return null; const ChainCard = ({ modelName }: { modelName: string }) => { - const provider = getProviderFromModel?.(modelName) ?? modelName; + const resolved = resolveModel?.(modelName) ?? { provider: modelName, label: modelName }; return ( - - - {modelName} + + + {resolved.label} ); }; @@ -127,11 +132,18 @@ const Fallbacks: React.FC = ({ accessToken, userRole, userID }) const [fallbackToEdit, setFallbackToEdit] = useState(null); const { data: modelCostMapData } = useModelCostMap(); - const getProviderFromModel = (model: string): string => { - if (modelCostMapData != null && typeof modelCostMapData === "object" && model in modelCostMapData) { - return modelCostMapData[model]["litellm_provider"] ?? ""; - } - return ""; + const { data: modelGroups = [] } = useQuery({ + queryKey: ["availableModels", "fallbacks"], + queryFn: () => fetchAvailableModels(accessToken ?? ""), + enabled: Boolean(accessToken), + }); + const resolveModelDisplay = (model: string): { provider: string; label: string; title?: string } => { + const label = fallbackPrimaryLabel(model); + return { + provider: resolveFallbackProvider(model, modelCostMapData, modelGroups), + label, + ...(label !== model && { title: model }), + }; }; useEffect(() => { @@ -281,10 +293,10 @@ const Fallbacks: React.FC = ({ accessToken, userRole, userID }) Object.entries(item).map(([key, value]) => ( - {renderModelNameCell(key, getProviderFromModel)} + {renderModelNameCell(key, resolveModelDisplay)} - {renderFallbacksChain(key, Array.isArray(value) ? value : [], getProviderFromModel)} + {renderFallbacksChain(key, Array.isArray(value) ? value : [], resolveModelDisplay)} {canModify && ( diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/fallbackOptions.test.ts b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/fallbackOptions.test.ts new file mode 100644 index 00000000000..2b8766b382d --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/fallbackOptions.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vitest"; +import type { ModelGroup } from "@/components/llm_calls/fetch_models"; +import { + fallbackChainOptions, + fallbackPrimaryLabel, + primaryModelOptions, + resolveFallbackProvider, +} from "./fallbackOptions"; + +const modelGroups: ModelGroup[] = [ + { model_group: "gpt-4", providers: ["openai"] }, + { model_group: "claude-3-opus", providers: ["anthropic"] }, + { model_group: "bedrock/us.anthropic.claude-opus-4-8", providers: ["bedrock"] }, +]; + +describe("primaryModelOptions", () => { + it("lists provider wildcards first with friendly labels, then model groups", () => { + const options = primaryModelOptions(modelGroups); + + expect(options.slice(0, 3)).toEqual([ + { value: "anthropic/*", label: "All Anthropic Models" }, + { value: "bedrock/*", label: "All Amazon Bedrock Models" }, + { value: "openai/*", label: "All OpenAI Models" }, + ]); + expect(options.slice(3).map((option) => option.value)).toEqual([ + "bedrock/us.anthropic.claude-opus-4-8", + "claude-3-opus", + "gpt-4", + ]); + }); + + it("derives a provider wildcard from the model group prefix when providers are absent", () => { + const options = primaryModelOptions([{ model_group: "openai/gpt-6-astra" }]); + + expect(options[0]).toEqual({ value: "openai/*", label: "All OpenAI Models" }); + expect(options).toHaveLength(2); + }); + + it("ignores prefixes that are not known provider slugs", () => { + const options = primaryModelOptions([{ model_group: "my-router/custom-tier" }]); + + expect(options).toEqual([{ value: "my-router/custom-tier", label: "my-router/custom-tier" }]); + }); + + it("folds a model group literally named openai/* into the friendly option", () => { + const options = primaryModelOptions([{ model_group: "openai/*", providers: ["openai"] }]); + + expect(options).toEqual([{ value: "openai/*", label: "All OpenAI Models" }]); + }); +}); + +describe("fallbackChainOptions", () => { + it("excludes wildcard entries, which can never be fallback targets", () => { + const options = fallbackChainOptions([...modelGroups, { model_group: "openai/*", providers: ["openai"] }]); + + expect(options.map((option) => option.value)).toEqual([ + "bedrock/us.anthropic.claude-opus-4-8", + "claude-3-opus", + "gpt-4", + ]); + }); +}); + +describe("resolveFallbackProvider", () => { + it("prefers the cost map litellm_provider", () => { + expect( + resolveFallbackProvider("gpt-4", { "gpt-4": { litellm_provider: "azure" } }, modelGroups), + ).toBe("azure"); + }); + + it("falls back to the model group providers when the cost map misses", () => { + expect(resolveFallbackProvider("claude-3-opus", null, modelGroups)).toBe("anthropic"); + }); + + it("resolves provider wildcards and prefixed names from the prefix", () => { + expect(resolveFallbackProvider("anthropic/*", null, [])).toBe("anthropic"); + expect(resolveFallbackProvider("openai/gpt-6-astra", null, [])).toBe("openai"); + expect(resolveFallbackProvider("bedrock/us.anthropic.claude-opus-4-8", null, [])).toBe("bedrock"); + }); + + it("returns empty when nothing resolves", () => { + expect(resolveFallbackProvider("custom-group", null, [])).toBe(""); + }); +}); + +describe("fallbackPrimaryLabel", () => { + it("renders provider wildcards as All Models", () => { + expect(fallbackPrimaryLabel("openai/*")).toBe("All OpenAI Models"); + expect(fallbackPrimaryLabel("anthropic/*")).toBe("All Anthropic Models"); + }); + + it("keeps concrete names and unknown wildcards as-is", () => { + expect(fallbackPrimaryLabel("gpt-4")).toBe("gpt-4"); + expect(fallbackPrimaryLabel("custom/*")).toBe("custom/*"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/fallbackOptions.ts b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/fallbackOptions.ts new file mode 100644 index 00000000000..88181def618 --- /dev/null +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/fallbackOptions.ts @@ -0,0 +1,106 @@ +import { ModelGroup } from "@/components/llm_calls/fetch_models"; +import { getProviderLogoAndName, provider_map, resolveLitellmProviderSlug } from "@/components/provider_info_helpers"; + +export interface FallbackOption { + label: string; + value: string; +} + +const KNOWN_PROVIDER_SLUGS: ReadonlySet = new Set( + Object.values(provider_map).map((slug) => slug.toLowerCase()), +); + +const modelGroupProviderPrefix = (modelGroup: string): string | null => { + const slashIndex = modelGroup.indexOf("/"); + if (slashIndex <= 0) { + return null; + } + const prefix = modelGroup.slice(0, slashIndex).toLowerCase(); + return KNOWN_PROVIDER_SLUGS.has(prefix) ? prefix : null; +}; + +export const providerSlugsFor = (modelGroups: ModelGroup[]): string[] => { + const slugs = new Set(); + for (const modelGroup of modelGroups) { + for (const provider of modelGroup.providers ?? []) { + slugs.add(resolveLitellmProviderSlug(provider)); + } + const prefix = modelGroupProviderPrefix(modelGroup.model_group); + if (prefix) { + slugs.add(prefix); + } + } + return Array.from(slugs).sort((a, b) => a.localeCompare(b)); +}; + +export const providerWildcardOption = (slug: string): FallbackOption => { + const { displayName } = getProviderLogoAndName(slug); + return { value: `${slug}/*`, label: `All ${displayName} Models` }; +}; + +export const providerWildcardOptions = (modelGroups: ModelGroup[]): FallbackOption[] => + providerSlugsFor(modelGroups).map(providerWildcardOption); + +/** + * Primary picker options: one "All Models" wildcard option per + * provider first, then every existing model group. A model group literally + * named like a wildcard (e.g. "openai/*") is folded into its friendly option + * so it is not listed twice. + */ +export const primaryModelOptions = (modelGroups: ModelGroup[]): FallbackOption[] => { + const wildcardValues = new Set(providerWildcardOptions(modelGroups).map((option) => option.value)); + const groupOptions = Array.from(new Set(modelGroups.map((modelGroup) => modelGroup.model_group))) + .filter((name) => !wildcardValues.has(name)) + .sort((a, b) => a.localeCompare(b)) + .map((name) => ({ label: name, value: name })); + return [...providerWildcardOptions(modelGroups), ...groupOptions]; +}; + +/** + * Fallback-chain options: concrete model groups only. A wildcard entry can + * never be invoked as a fallback target, so anything containing "*" is out. + */ +export const fallbackChainOptions = (modelGroups: ModelGroup[]): FallbackOption[] => + Array.from(new Set(modelGroups.map((modelGroup) => modelGroup.model_group))) + .filter((name) => !name.includes("*")) + .sort((a, b) => a.localeCompare(b)) + .map((name) => ({ label: name, value: name })); + +/** + * Resolves the provider slug used to render a model's logo in the fallbacks + * table: the cost map's litellm_provider first, then the model-group info + * providers, then the prefix before the first "/" when it is a known + * provider slug. The last step is what lets keys like "openai/*" or + * "bedrock/us.anthropic.claude-opus-4-8" still get a logo. + */ +export const resolveFallbackProvider = ( + model: string, + modelCostMap: Record | null | undefined, + modelGroups: ModelGroup[], +): string => { + const fromCostMap = modelCostMap?.[model]?.litellm_provider; + if (typeof fromCostMap === "string" && fromCostMap !== "") { + return fromCostMap; + } + const provider = modelGroups.find((modelGroup) => modelGroup.model_group === model)?.providers?.[0]; + if (provider) { + return resolveLitellmProviderSlug(provider); + } + return modelGroupProviderPrefix(model) ?? ""; +}; + +/** + * Display label for a fallback primary key. "openai/*" reads as + * "All OpenAI Models" since the wildcard spelling means nothing to most + * people; everything else keeps its raw name. + */ +export const fallbackPrimaryLabel = (model: string): string => { + if (model.endsWith("/*")) { + const slug = model.slice(0, -2); + if (KNOWN_PROVIDER_SLUGS.has(slug.toLowerCase())) { + const { displayName } = getProviderLogoAndName(slug); + return `All ${displayName} Models`; + } + } + return model; +}; diff --git a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx index 7570806182d..f84593b0e34 100644 --- a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx +++ b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.tsx @@ -327,8 +327,6 @@ const RouterSettingsAccordion = forwardRef option.model_group))).sort(); - // Expose getValue method via ref useImperativeHandle(ref, () => ({ getValue: () => { @@ -363,7 +361,7 @@ const RouterSettingsAccordion = forwardRef diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index cdb74c73a80..2c97603cddb 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -250,6 +250,7 @@ describe("Sidebar (leftnav)", () => { "Virtual Keys", "Playground", "Models + Endpoints", + "Fallbacks", "Agentic", "MCP Servers", "Guardrails", diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 256fe578912..42b6801c5a7 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -52,6 +52,7 @@ import { ScrollText, Search, Server, + Shuffle, Settings as SettingsIcon, Shield, ShieldCheck, @@ -224,6 +225,13 @@ const menuGroups: MenuGroup[] = [ icon: , roles: rolesAllowedToViewWriteScopedPages, }, + { + key: "fallbacks", + page: "fallbacks", + label: "Fallbacks", + icon: , + roles: all_admin_roles, + }, { key: "agentic", page: "agentic", diff --git a/ui/litellm-dashboard/src/components/liteadmin/agent.test.ts b/ui/litellm-dashboard/src/components/liteadmin/agent.test.ts index 85c7e58eaf8..fe86f92819e 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/agent.test.ts +++ b/ui/litellm-dashboard/src/components/liteadmin/agent.test.ts @@ -666,3 +666,92 @@ describe("preferred LiteAdmin model", () => { ).toBeNull(); }); }); + +describe("router fallback operations", () => { + const routerSettings = () => ({ + routing_strategy: "least-busy", + fallbacks: [{ "gpt-4": ["claude-3-haiku"] }, { "openai/*": ["claude-3-haiku", "gpt-4"] }], + model_group_retry_policy: { "gpt-4": { AuthenticationErrorRetries: 0 } }, + }); + + beforeEach(() => { + managementFetch.mockImplementation(async (input) => { + const path = new URL(String(input)).pathname; + if (path.endsWith("/get/config/callbacks")) return json({ router_settings: routerSettings() }); + if (path.endsWith("/config/update")) return json({ status: "success" }); + throw new Error(`Unexpected request: ${path}`); + }); + }); + + const configUpdateBody = () => { + const call = managementFetch.mock.calls.find(([url]) => String(url).includes("/config/update")); + expect(call).toBeDefined(); + return JSON.parse(String(call?.[1]?.body)); + }; + + it("fallbacks_list reads router_settings.fallbacks without confirmation", async () => { + const input = options(); + const model = transport([completion([call("fallbacks_list", {})]), completion()]); + + await runLiteAdmin(input, model.client); + + expect(managementFetch).toHaveBeenCalledTimes(1); + expect(new URL(String(managementFetch.mock.calls[0][0])).pathname).toBe("/root/get/config/callbacks"); + expect(input.confirm).not.toHaveBeenCalled(); + }); + + it("fallback_set appends a new entry and saves full router_settings minus the retry policy", async () => { + const input = options(); + const model = transport([ + completion([call("fallback_set", { model: "anthropic/*", fallbacks: ["gpt-4"] })]), + completion(), + ]); + + await runLiteAdmin(input, model.client); + + expect(configUpdateBody()).toEqual({ + router_settings: { + routing_strategy: "least-busy", + fallbacks: [ + { "gpt-4": ["claude-3-haiku"] }, + { "openai/*": ["claude-3-haiku", "gpt-4"] }, + { "anthropic/*": ["gpt-4"] }, + ], + }, + }); + expect(input.confirm).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ name: "fallback_set", destructive: false }), + ); + }); + + it("fallback_set replaces the entry for an existing model in place", async () => { + const model = transport([ + completion([call("fallback_set", { model: "gpt-4", fallbacks: ["gpt-4o-mini"] })]), + completion(), + ]); + + await runLiteAdmin(options(), model.client); + + expect(configUpdateBody().router_settings.fallbacks).toEqual([ + { "gpt-4": ["gpt-4o-mini"] }, + { "openai/*": ["claude-3-haiku", "gpt-4"] }, + ]); + }); + + it("fallback_delete removes only that entry and keeps the rest of router_settings", async () => { + const input = options(); + const model = transport([completion([call("fallback_delete", { model: "openai/*" })]), completion()]); + + await runLiteAdmin(input, model.client); + + expect(configUpdateBody()).toEqual({ + router_settings: { + routing_strategy: "least-busy", + fallbacks: [{ "gpt-4": ["claude-3-haiku"] }], + }, + }); + expect(input.confirm).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ name: "fallback_delete", destructive: true }), + ); + }); +}); diff --git a/ui/litellm-dashboard/src/components/liteadmin/operations.ts b/ui/litellm-dashboard/src/components/liteadmin/operations.ts index 706b0c68be8..dcc57dc72b1 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/operations.ts +++ b/ui/litellm-dashboard/src/components/liteadmin/operations.ts @@ -104,6 +104,18 @@ const requestLogsFields = { model: optionalText, status_filter: optional(z.enum(["success", "failure"])), }; +const fallbackTarget = text.regex(/^[^*]+$/, "Wildcard entries cannot be fallback targets."); +const fallbackSetFields = { + model: text.describe( + "The primary model group, or a provider wildcard like openai/* meaning all models from that provider.", + ), + fallbacks: z.array(fallbackTarget).min(1).max(10), +}; +const fallbackDeleteFields = { model: text }; + +type RouterSettings = Record; +const fallbackEntries = (settings: RouterSettings): Record[] => + Array.isArray(settings.fallbacks) ? (settings.fallbacks as Record[]) : []; export function createLiteAdminOperations(context: OperationContext) { const active = () => { @@ -111,6 +123,14 @@ export function createLiteAdminOperations(context: OperationContext) { context.assertCurrent(); }; const auth = { accessToken: context.accessToken, signal: context.signal }; + const readRouterSettings = async (): Promise => { + const data = await apiClient.get<{ router_settings?: RouterSettings }>("/get/config/callbacks", auth); + const settings = { ...(data?.router_settings ?? {}) }; + delete settings.model_group_retry_policy; + return settings; + }; + const saveRouterSettings = (routerSettings: RouterSettings) => + apiClient.post("/config/update", { ...auth, body: { router_settings: routerSettings } }); const operation = (mode: "read" | "write" | "delete", kind: ResultKind, extraArguments?: Record) => >>( @@ -339,5 +359,42 @@ export function createLiteAdminOperations(context: OperationContext) { query: { ...a, start_date: `${a.start_date} 00:00:00`, end_date: `${a.end_date} 23:59:59` }, }), ), + operation("read", "fallback")( + "fallbacks_list", + "List configured router fallbacks", + object({}), + async () => ({ fallbacks: fallbackEntries(await readRouterSettings()) }), + ), + operation("write", "fallback")( + "fallback_set", + "Set the fallback chain for a primary model. The model may be a provider wildcard like openai/* meaning all models from that provider; wildcards are allowed as model but never inside fallbacks", + object(fallbackSetFields), + async (a) => { + const settings = await readRouterSettings(); + const current = fallbackEntries(settings); + const existingIndex = current.findIndex( + (entry) => entry !== null && typeof entry === "object" && a.model in entry, + ); + const updated = + existingIndex >= 0 + ? current.map((entry, index) => (index === existingIndex ? { [a.model]: a.fallbacks } : entry)) + : [...current, { [a.model]: a.fallbacks }]; + await saveRouterSettings({ ...settings, fallbacks: updated }); + return { fallbacks: updated }; + }, + ), + operation("delete", "fallback")( + "fallback_delete", + "Remove the fallback chain for a primary model", + object(fallbackDeleteFields), + async (a) => { + const settings = await readRouterSettings(); + const updated = fallbackEntries(settings) + .map((entry) => Object.fromEntries(Object.entries(entry).filter(([key]) => key !== a.model))) + .filter((entry) => Object.keys(entry).length > 0); + await saveRouterSettings({ ...settings, fallbacks: updated }); + return { fallbacks: updated }; + }, + ), ]; } diff --git a/ui/litellm-dashboard/src/components/liteadmin/toolResults.ts b/ui/litellm-dashboard/src/components/liteadmin/toolResults.ts index 355499ee0f7..90ec6fd3e0c 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/toolResults.ts +++ b/ui/litellm-dashboard/src/components/liteadmin/toolResults.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -export type ResultKind = "key" | "team" | "user" | "budget" | "spend" | "log"; +export type ResultKind = "key" | "team" | "user" | "budget" | "spend" | "log" | "fallback"; const limitedList = >(schema: Schema, limit = 50) => z.preprocess((value) => (Array.isArray(value) ? value.slice(0, limit) : value), z.array(schema)); @@ -150,6 +150,7 @@ export function projectToolResult(kind: ResultKind, value: unknown, secrets: rea ]), spend: limitedList(spend), log: z.object({ ...pagination, data: limitedList(log) }), + fallback: z.object({ fallbacks: limitedList(z.record(z.string(), limitedList(text))).nullish() }), }; const parsed = schemas[kind].safeParse(value); if (!parsed.success) diff --git a/ui/litellm-dashboard/src/components/page_metadata.ts b/ui/litellm-dashboard/src/components/page_metadata.ts index 8eb14fc2151..6bad0dc0209 100644 --- a/ui/litellm-dashboard/src/components/page_metadata.ts +++ b/ui/litellm-dashboard/src/components/page_metadata.ts @@ -8,6 +8,7 @@ export const pageDescriptions: Record = { "api-keys": "Manage virtual keys for API access and authentication", "llm-playground": "Interactive playground for testing LLM requests", models: "Configure and manage LLM models and endpoints", + fallbacks: "Configure which models to retry when the primary model fails", agents: "Create and manage AI agents", agentic: "Manage agentic resources: agents, workflow runs, and memory", workflows: "Track and inspect durable workflow run history", From cc4fc1b3a154cecedad8220f4135f13bed906fb0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:04:48 +0000 Subject: [PATCH 2/5] chore: drop comments from fallbackOptions and tighten fallback suppressions Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com> --- .../Fallbacks/fallbackOptions.ts | 22 ------------------- 1 file changed, 22 deletions(-) diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/fallbackOptions.ts b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/fallbackOptions.ts index 88181def618..e28173d0f6c 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/fallbackOptions.ts +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/fallbackOptions.ts @@ -41,12 +41,6 @@ export const providerWildcardOption = (slug: string): FallbackOption => { export const providerWildcardOptions = (modelGroups: ModelGroup[]): FallbackOption[] => providerSlugsFor(modelGroups).map(providerWildcardOption); -/** - * Primary picker options: one "All Models" wildcard option per - * provider first, then every existing model group. A model group literally - * named like a wildcard (e.g. "openai/*") is folded into its friendly option - * so it is not listed twice. - */ export const primaryModelOptions = (modelGroups: ModelGroup[]): FallbackOption[] => { const wildcardValues = new Set(providerWildcardOptions(modelGroups).map((option) => option.value)); const groupOptions = Array.from(new Set(modelGroups.map((modelGroup) => modelGroup.model_group))) @@ -56,23 +50,12 @@ export const primaryModelOptions = (modelGroups: ModelGroup[]): FallbackOption[] return [...providerWildcardOptions(modelGroups), ...groupOptions]; }; -/** - * Fallback-chain options: concrete model groups only. A wildcard entry can - * never be invoked as a fallback target, so anything containing "*" is out. - */ export const fallbackChainOptions = (modelGroups: ModelGroup[]): FallbackOption[] => Array.from(new Set(modelGroups.map((modelGroup) => modelGroup.model_group))) .filter((name) => !name.includes("*")) .sort((a, b) => a.localeCompare(b)) .map((name) => ({ label: name, value: name })); -/** - * Resolves the provider slug used to render a model's logo in the fallbacks - * table: the cost map's litellm_provider first, then the model-group info - * providers, then the prefix before the first "/" when it is a known - * provider slug. The last step is what lets keys like "openai/*" or - * "bedrock/us.anthropic.claude-opus-4-8" still get a logo. - */ export const resolveFallbackProvider = ( model: string, modelCostMap: Record | null | undefined, @@ -89,11 +72,6 @@ export const resolveFallbackProvider = ( return modelGroupProviderPrefix(model) ?? ""; }; -/** - * Display label for a fallback primary key. "openai/*" reads as - * "All OpenAI Models" since the wildcard spelling means nothing to most - * people; everything else keeps its raw name. - */ export const fallbackPrimaryLabel = (model: string): string => { if (model.endsWith("/*")) { const slug = model.slice(0, -2); From 0bd1fcd940a265a892255f520c29334a13f2b2d1 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:13:26 +0000 Subject: [PATCH 3/5] fix(ui): friendly fallback group labels, page padding, short LiteAdmin action title Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com> --- .../src/app/(dashboard)/fallbacks/page.tsx | 6 +-- .../Fallbacks/FallbackSelectionForm.test.tsx | 48 ++++++++++++++++--- .../Fallbacks/FallbackSelectionForm.tsx | 4 +- .../src/components/liteadmin/operations.ts | 17 +++---- 4 files changed, 55 insertions(+), 20 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/fallbacks/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/fallbacks/page.tsx index 6ec2a98aa79..b93091d1986 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/fallbacks/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/fallbacks/page.tsx @@ -6,12 +6,10 @@ import Fallbacks from "@/components/Settings/RouterSettings/Fallbacks/Fallbacks" export default function FallbacksPage() { const { accessToken, userRole, userId } = useAuthorized(); return ( -
+

Fallbacks

-

- Automatically retry a different model when the primary fails -

+

Automatically retry a different model when the primary fails

diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.test.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.test.tsx index 3745616c6dc..49ee62c50d9 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.test.tsx @@ -53,7 +53,11 @@ describe("FallbackSelectionForm", () => { it("should display tabs when groups exist", () => { const groups: FallbackGroup[] = [{ id: "1", primaryModel: null, fallbackModels: [] }]; render( - , + , ); expect(screen.getByRole("tab", { name: /group 1/i })).toBeInTheDocument(); }); @@ -61,16 +65,36 @@ describe("FallbackSelectionForm", () => { it("should display primary model as tab label when set", () => { const groups: FallbackGroup[] = [{ id: "1", primaryModel: "gpt-4", fallbackModels: [] }]; render( - , + , ); expect(screen.getByRole("tab", { name: "gpt-4" })).toBeInTheDocument(); }); + it("should display friendly label for provider wildcard primary", () => { + const groups: FallbackGroup[] = [{ id: "1", primaryModel: "openai/*", fallbackModels: [] }]; + render( + , + ); + expect(screen.getByRole("tab", { name: "All OpenAI Models" })).toBeInTheDocument(); + }); + it("should call onGroupsChange when add tab button is clicked", async () => { const user = userEvent.setup(); const groups: FallbackGroup[] = [{ id: "1", primaryModel: null, fallbackModels: [] }]; render( - , + , ); const addTabButton = screen.getByRole("button", { name: /add fallback group/i }); @@ -125,7 +149,11 @@ describe("FallbackSelectionForm", () => { { id: "2", primaryModel: "gpt-3.5-turbo", fallbackModels: [] }, ]; render( - , + , ); const removeButtons = screen.getAllByRole("button", { name: /^remove /i }); @@ -142,7 +170,11 @@ describe("FallbackSelectionForm", () => { it("should render FallbackGroupConfig for each group", () => { const groups: FallbackGroup[] = [{ id: "1", primaryModel: null, fallbackModels: [] }]; render( - , + , ); expect(screen.getByRole("combobox", { name: /primary model/i })).toHaveValue(""); expect(screen.getByText("Primary Model")).toBeInTheDocument(); @@ -151,7 +183,11 @@ describe("FallbackSelectionForm", () => { it("should display group with primary and fallback models in FallbackGroupConfig", () => { const groups: FallbackGroup[] = [{ id: "1", primaryModel: "gpt-4", fallbackModels: ["gpt-3.5-turbo"] }]; render( - , + , ); expect(screen.getByRole("tab", { name: "gpt-4" })).toBeInTheDocument(); expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0); diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.tsx index fdeb886ef04..17036c6c770 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/FallbackSelectionForm.tsx @@ -10,7 +10,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Plus, X } from "lucide-react"; import React, { useEffect, useMemo, useState } from "react"; import { toast } from "@/lib/toast"; -import { fallbackChainOptions, primaryModelOptions } from "./fallbackOptions"; +import { fallbackChainOptions, fallbackPrimaryLabel, primaryModelOptions } from "./fallbackOptions"; import { FallbackGroup, FallbackGroupConfig } from "./FallbackGroupConfig"; interface FallbackSelectionFormProps { @@ -81,7 +81,7 @@ export function FallbackSelectionForm({ }; const groupLabel = (group: FallbackGroup, index: number) => - group.primaryModel ? group.primaryModel : `Group ${index + 1}`; + group.primaryModel ? fallbackPrimaryLabel(group.primaryModel) : `Group ${index + 1}`; if (groups.length === 0) { return ( diff --git a/ui/litellm-dashboard/src/components/liteadmin/operations.ts b/ui/litellm-dashboard/src/components/liteadmin/operations.ts index dcc57dc72b1..fbb9571c696 100644 --- a/ui/litellm-dashboard/src/components/liteadmin/operations.ts +++ b/ui/litellm-dashboard/src/components/liteadmin/operations.ts @@ -109,7 +109,11 @@ const fallbackSetFields = { model: text.describe( "The primary model group, or a provider wildcard like openai/* meaning all models from that provider.", ), - fallbacks: z.array(fallbackTarget).min(1).max(10), + fallbacks: z + .array(fallbackTarget) + .min(1) + .max(10) + .describe("Concrete model groups to try in order. Never a wildcard."), }; const fallbackDeleteFields = { model: text }; @@ -359,15 +363,12 @@ export function createLiteAdminOperations(context: OperationContext) { query: { ...a, start_date: `${a.start_date} 00:00:00`, end_date: `${a.end_date} 23:59:59` }, }), ), - operation("read", "fallback")( - "fallbacks_list", - "List configured router fallbacks", - object({}), - async () => ({ fallbacks: fallbackEntries(await readRouterSettings()) }), - ), + operation("read", "fallback")("fallbacks_list", "List configured router fallbacks", object({}), async () => ({ + fallbacks: fallbackEntries(await readRouterSettings()), + })), operation("write", "fallback")( "fallback_set", - "Set the fallback chain for a primary model. The model may be a provider wildcard like openai/* meaning all models from that provider; wildcards are allowed as model but never inside fallbacks", + "Set the fallback chain for a model", object(fallbackSetFields), async (a) => { const settings = await readRouterSettings(); From e4c094da71fea434c09fc875edcecbf2ab02912c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 02:37:40 +0000 Subject: [PATCH 4/5] fix(ui): update RouterSettingsAccordion FallbackSelectionForm mock and apply ruff/prettier formatting Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com> --- .../router_utils/fallback_event_handlers.py | 20 ++++++++++++++----- .../RouterSettings/Fallbacks/AddFallbacks.tsx | 1 - .../Fallbacks/fallbackOptions.test.ts | 4 +--- .../RouterSettingsAccordion.test.tsx | 9 +++++++-- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/litellm/router_utils/fallback_event_handlers.py b/litellm/router_utils/fallback_event_handlers.py index 1abee667297..f25060b686b 100644 --- a/litellm/router_utils/fallback_event_handlers.py +++ b/litellm/router_utils/fallback_event_handlers.py @@ -554,21 +554,27 @@ def get_fallback_model_group(fallbacks: list[Any], model_group: str) -> tuple[li """ generic_fallback_idx: int | None = None stripped_model_fallback: list[str] | None = None - pattern_model_fallback: list[str] | None = None # mutable-ok: resolved inside the loop like stripped_model_fallback # rebind-ok: one assignment inside the loop + pattern_model_fallback: list[str] | None = ( + None # mutable-ok: resolved inside the loop like stripped_model_fallback # rebind-ok: one assignment inside the loop + ) fallback_model_group: list[str] | None = None fallback_keys: Final = tuple(next(iter(item)) for item in fallbacks if isinstance(item, dict) and item) prefixed_model_group: Final = _provider_prefixed_model_group(model_group, fallback_keys) ## check for specific model group-specific fallbacks for idx, item in enumerate(fallbacks): if isinstance(item, dict): - fallback_key = cast(str, next(iter(item))) # cast-ok: fallback entries are single-key dicts; the key is always a str + fallback_key = cast( + str, next(iter(item)) + ) # cast-ok: fallback entries are single-key dicts; the key is always a str if fallback_key == model_group: # check exact match fallback_model_group = item[model_group] break elif fallback_key == prefixed_model_group or _check_stripped_model_group( model_group=model_group, fallback_key=fallback_key ): # check generic fallback - stripped_model_fallback = cast(list[str], item[fallback_key]) # cast-ok: fallback chains are lists of model names + stripped_model_fallback = cast( + list[str], item[fallback_key] + ) # cast-ok: fallback chains are lists of model names elif fallback_key == "*": # check generic fallback generic_fallback_idx = idx elif ( @@ -576,7 +582,9 @@ def get_fallback_model_group(fallbacks: list[Any], model_group: str) -> tuple[li and "*" in fallback_key and fnmatch.fnmatchcase(model_group, fallback_key) ): - pattern_model_fallback = cast(list[str], item[fallback_key]) # cast-ok: fallback chains are lists of model names + pattern_model_fallback = cast( + list[str], item[fallback_key] + ) # cast-ok: fallback chains are lists of model names elif isinstance(item, str): fallback_model_group = [item] ## if none, check for generic fallback @@ -584,7 +592,9 @@ def get_fallback_model_group(fallbacks: list[Any], model_group: str) -> tuple[li if stripped_model_fallback is not None: fallback_model_group = stripped_model_fallback elif pattern_model_fallback is not None: - fallback_model_group = pattern_model_fallback # rebind-ok: one assignment per priority branch, same as stripped and generic + fallback_model_group = ( + pattern_model_fallback # rebind-ok: one assignment per priority branch, same as stripped and generic + ) elif generic_fallback_idx is not None: fallback_model_group = fallbacks[generic_fallback_idx]["*"] diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx index b41aa374f69..863143d3d76 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/AddFallbacks.tsx @@ -63,7 +63,6 @@ export default function AddFallbacks({ accessToken, value = [], onChange }: AddF } }, [accessToken, isModalVisible]); - const handleCancel = () => { setIsModalVisible(false); // Reset to initial state diff --git a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/fallbackOptions.test.ts b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/fallbackOptions.test.ts index 2b8766b382d..9f16691e8cd 100644 --- a/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/fallbackOptions.test.ts +++ b/ui/litellm-dashboard/src/components/Settings/RouterSettings/Fallbacks/fallbackOptions.test.ts @@ -63,9 +63,7 @@ describe("fallbackChainOptions", () => { describe("resolveFallbackProvider", () => { it("prefers the cost map litellm_provider", () => { - expect( - resolveFallbackProvider("gpt-4", { "gpt-4": { litellm_provider: "azure" } }, modelGroups), - ).toBe("azure"); + expect(resolveFallbackProvider("gpt-4", { "gpt-4": { litellm_provider: "azure" } }, modelGroups)).toBe("azure"); }); it("falls back to the model group providers when the cost map misses", () => { diff --git a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx index 6f819607e3f..cbb16892c63 100644 --- a/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx +++ b/ui/litellm-dashboard/src/components/common_components/RouterSettingsAccordion.test.tsx @@ -16,8 +16,13 @@ vi.mock("@/components/llm_calls/fetch_models", () => ({ })); vi.mock("../Settings/RouterSettings/Fallbacks/FallbackSelectionForm", () => ({ - FallbackSelectionForm: ({ availableModels }: { availableModels: string[] }) => ( -
{availableModels.join(",")}
+ FallbackSelectionForm: ({ modelGroups }: { modelGroups: { model_group: string }[] }) => ( +
+ {modelGroups + .map((modelGroup) => modelGroup.model_group) + .sort() + .join(",")} +
), })); From 677aec85633a829147a09834cdf4fe0c36bd3870 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Sat, 10 Oct 2026 03:00:21 +0000 Subject: [PATCH 5/5] fix(router): keep fallback suppression comments on one line for the discipline gate Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com> --- .../router_utils/fallback_event_handlers.py | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/litellm/router_utils/fallback_event_handlers.py b/litellm/router_utils/fallback_event_handlers.py index f25060b686b..f0e5e340041 100644 --- a/litellm/router_utils/fallback_event_handlers.py +++ b/litellm/router_utils/fallback_event_handlers.py @@ -554,27 +554,21 @@ def get_fallback_model_group(fallbacks: list[Any], model_group: str) -> tuple[li """ generic_fallback_idx: int | None = None stripped_model_fallback: list[str] | None = None - pattern_model_fallback: list[str] | None = ( - None # mutable-ok: resolved inside the loop like stripped_model_fallback # rebind-ok: one assignment inside the loop - ) + pattern_model_fallback: list[str] | None = None # mutable-ok: set in loop # rebind-ok: set in loop fallback_model_group: list[str] | None = None fallback_keys: Final = tuple(next(iter(item)) for item in fallbacks if isinstance(item, dict) and item) prefixed_model_group: Final = _provider_prefixed_model_group(model_group, fallback_keys) ## check for specific model group-specific fallbacks for idx, item in enumerate(fallbacks): if isinstance(item, dict): - fallback_key = cast( - str, next(iter(item)) - ) # cast-ok: fallback entries are single-key dicts; the key is always a str + fallback_key = cast(str, next(iter(item))) # cast-ok: fallback keys are str if fallback_key == model_group: # check exact match fallback_model_group = item[model_group] break elif fallback_key == prefixed_model_group or _check_stripped_model_group( model_group=model_group, fallback_key=fallback_key ): # check generic fallback - stripped_model_fallback = cast( - list[str], item[fallback_key] - ) # cast-ok: fallback chains are lists of model names + stripped_model_fallback = cast(list[str], item[fallback_key]) # cast-ok: chains are str lists elif fallback_key == "*": # check generic fallback generic_fallback_idx = idx elif ( @@ -582,9 +576,7 @@ def get_fallback_model_group(fallbacks: list[Any], model_group: str) -> tuple[li and "*" in fallback_key and fnmatch.fnmatchcase(model_group, fallback_key) ): - pattern_model_fallback = cast( - list[str], item[fallback_key] - ) # cast-ok: fallback chains are lists of model names + pattern_model_fallback = cast(list[str], item[fallback_key]) # cast-ok: chains are str lists elif isinstance(item, str): fallback_model_group = [item] ## if none, check for generic fallback @@ -592,9 +584,7 @@ def get_fallback_model_group(fallbacks: list[Any], model_group: str) -> tuple[li if stripped_model_fallback is not None: fallback_model_group = stripped_model_fallback elif pattern_model_fallback is not None: - fallback_model_group = ( - pattern_model_fallback # rebind-ok: one assignment per priority branch, same as stripped and generic - ) + fallback_model_group = pattern_model_fallback # rebind-ok: one branch assignment like stripped elif generic_fallback_idx is not None: fallback_model_group = fallbacks[generic_fallback_idx]["*"]