This commit is contained in:
ishaan-berri 2026-10-11 15:02:23 +03:00 • committed by GitHub
commit b48c285da5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 582 additions and 59 deletions

View file

@ -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: 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 = next(iter(item))
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 = item[fallback_key]
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 (
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: chains are str lists
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 branch assignment like stripped
elif generic_fallback_idx is not None:
fallback_model_group = fallbacks[generic_fallback_idx]["*"]

View file

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

View file

@ -427,6 +427,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

View file

@ -0,0 +1,17 @@
"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 (
<div className="flex flex-col gap-4 px-8 py-6">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-bold">Fallbacks</h1>
<p className="text-sm text-muted-foreground">Automatically retry a different model when the primary fails</p>
</div>
<Fallbacks accessToken={accessToken} userRole={userRole} userID={userId} />
</div>
);
}

View file

@ -40,6 +40,7 @@ const LEGACY_PAGE_ROUTES: ReadonlyMap<string, string> = new Map(
"model-insights": "model-insights",
agents: "agents",
"router-settings": "router-settings",
fallbacks: "fallbacks",
users: "users",
teams: "teams",
organizations: "organizations",

View file

@ -63,8 +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);
// Reset to initial state
@ -125,7 +123,7 @@ export default function AddFallbacks({ accessToken, value = [], onChange }: AddF
key={modalKey}
groups={groups}
onGroupsChange={setGroups}
availableModels={availableModels}
modelGroups={modelInfo}
maxFallbacks={10}
maxGroups={5}
/>

View file

@ -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({
<FallbackGroupConfig
group={group}
onChange={setGroup}
availableModels={availableModels}
primaryOptions={primaryOptions}
fallbackOptions={chainOptions}
maxFallbacks={maxFallbacks}
disablePrimaryModel
/>

View file

@ -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: <ProviderLogo provider={option.value.slice(0, -2)} /> } : 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>
<SearchSelect
inputId={primaryModelInputId}
options={availableModels.map((m) => ({ 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 */}
<div className="mb-4">
<MultiSelect
options={availableFallbackOptions.map((m) => ({ label: m, value: m }))}
options={availableFallbackOptions}
value={group.fallbackModels}
onValueChange={handleFallbackSelect}
placeholder={

View file

@ -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(
<FallbackSelectionForm groups={[]} onGroupsChange={mockOnGroupsChange} availableModels={AVAILABLE_MODELS} />,
<FallbackSelectionForm groups={[]} onGroupsChange={mockOnGroupsChange} modelGroups={AVAILABLE_MODEL_GROUPS} />,
);
expect(screen.getByText(/no fallback groups configured/i)).toBeInTheDocument();
});
it("should show Create First Group button when no groups exist", () => {
render(
<FallbackSelectionForm groups={[]} onGroupsChange={mockOnGroupsChange} availableModels={AVAILABLE_MODELS} />,
<FallbackSelectionForm groups={[]} onGroupsChange={mockOnGroupsChange} modelGroups={AVAILABLE_MODEL_GROUPS} />,
);
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(
<FallbackSelectionForm groups={[]} onGroupsChange={mockOnGroupsChange} availableModels={AVAILABLE_MODELS} />,
<FallbackSelectionForm groups={[]} onGroupsChange={mockOnGroupsChange} modelGroups={AVAILABLE_MODEL_GROUPS} />,
);
await user.click(screen.getByRole("button", { name: /create first group/i }));
@ -49,7 +53,11 @@ describe("FallbackSelectionForm", () => {
it("should display tabs when groups exist", () => {
const groups: FallbackGroup[] = [{ id: "1", primaryModel: null, fallbackModels: [] }];
render(
<FallbackSelectionForm groups={groups} onGroupsChange={mockOnGroupsChange} availableModels={AVAILABLE_MODELS} />,
<FallbackSelectionForm
groups={groups}
onGroupsChange={mockOnGroupsChange}
modelGroups={AVAILABLE_MODEL_GROUPS}
/>,
);
expect(screen.getByRole("tab", { name: /group 1/i })).toBeInTheDocument();
});
@ -57,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(
<FallbackSelectionForm groups={groups} onGroupsChange={mockOnGroupsChange} availableModels={AVAILABLE_MODELS} />,
<FallbackSelectionForm
groups={groups}
onGroupsChange={mockOnGroupsChange}
modelGroups={AVAILABLE_MODEL_GROUPS}
/>,
);
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(
<FallbackSelectionForm
groups={groups}
onGroupsChange={mockOnGroupsChange}
modelGroups={AVAILABLE_MODEL_GROUPS}
/>,
);
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(
<FallbackSelectionForm groups={groups} onGroupsChange={mockOnGroupsChange} availableModels={AVAILABLE_MODELS} />,
<FallbackSelectionForm
groups={groups}
onGroupsChange={mockOnGroupsChange}
modelGroups={AVAILABLE_MODEL_GROUPS}
/>,
);
const addTabButton = screen.getByRole("button", { name: /add fallback group/i });
@ -94,7 +122,7 @@ describe("FallbackSelectionForm", () => {
<FallbackSelectionForm
groups={groups}
onGroupsChange={mockOnGroupsChange}
availableModels={AVAILABLE_MODELS}
modelGroups={AVAILABLE_MODEL_GROUPS}
maxGroups={5}
/>,
);
@ -107,7 +135,7 @@ describe("FallbackSelectionForm", () => {
<FallbackSelectionForm
groups={groups}
onGroupsChange={mockOnGroupsChange}
availableModels={AVAILABLE_MODELS}
modelGroups={AVAILABLE_MODEL_GROUPS}
maxGroups={3}
/>,
);
@ -121,7 +149,11 @@ describe("FallbackSelectionForm", () => {
{ id: "2", primaryModel: "gpt-3.5-turbo", fallbackModels: [] },
];
render(
<FallbackSelectionForm groups={groups} onGroupsChange={mockOnGroupsChange} availableModels={AVAILABLE_MODELS} />,
<FallbackSelectionForm
groups={groups}
onGroupsChange={mockOnGroupsChange}
modelGroups={AVAILABLE_MODEL_GROUPS}
/>,
);
const removeButtons = screen.getAllByRole("button", { name: /^remove /i });
@ -138,7 +170,11 @@ describe("FallbackSelectionForm", () => {
it("should render FallbackGroupConfig for each group", () => {
const groups: FallbackGroup[] = [{ id: "1", primaryModel: null, fallbackModels: [] }];
render(
<FallbackSelectionForm groups={groups} onGroupsChange={mockOnGroupsChange} availableModels={AVAILABLE_MODELS} />,
<FallbackSelectionForm
groups={groups}
onGroupsChange={mockOnGroupsChange}
modelGroups={AVAILABLE_MODEL_GROUPS}
/>,
);
expect(screen.getByRole("combobox", { name: /primary model/i })).toHaveValue("");
expect(screen.getByText("Primary Model")).toBeInTheDocument();
@ -147,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(
<FallbackSelectionForm groups={groups} onGroupsChange={mockOnGroupsChange} availableModels={AVAILABLE_MODELS} />,
<FallbackSelectionForm
groups={groups}
onGroupsChange={mockOnGroupsChange}
modelGroups={AVAILABLE_MODEL_GROUPS}
/>,
);
expect(screen.getByRole("tab", { name: "gpt-4" })).toBeInTheDocument();
expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0);
@ -165,7 +205,7 @@ describe("FallbackSelectionForm", () => {
<FallbackSelectionForm
groups={groups}
onGroupsChange={mockOnGroupsChange}
availableModels={AVAILABLE_MODELS}
modelGroups={AVAILABLE_MODEL_GROUPS}
maxGroups={5}
/>,
);

View file

@ -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, fallbackPrimaryLabel, 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)
@ -77,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 (
@ -128,7 +132,8 @@ export function FallbackSelectionForm({
<FallbackGroupConfig
group={group}
onChange={handleGroupUpdate}
availableModels={availableModels}
primaryOptions={primaryOptions}
fallbackOptions={chainOptions}
maxFallbacks={maxFallbacks}
/>
</TabsContent>

View file

@ -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(<Fallbacks {...defaultProps} />);
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(<Fallbacks {...defaultProps} />);

View file

@ -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 (
<span className={modelCardClass}>
<ProviderLogo provider={provider} className="w-4 h-4 shrink-0" />
<span className="break-words">{modelName}</span>
<span className={modelCardClass} title={resolved.title}>
<ProviderLogo provider={resolved.provider} className="w-4 h-4 shrink-0" />
<span className="break-words">{resolved.label}</span>
</span>
);
}
@ -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 (
<span className={modelCardClass}>
<ProviderLogo provider={provider} className="w-4 h-4 shrink-0" />
<span className="break-words">{modelName}</span>
<span className={modelCardClass} title={resolved.title}>
<ProviderLogo provider={resolved.provider} className="w-4 h-4 shrink-0" />
<span className="break-words">{resolved.label}</span>
</span>
);
};
@ -127,11 +132,18 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
const [fallbackToEdit, setFallbackToEdit] = useState<FallbackEntry | null>(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<FallbacksProps> = ({ accessToken, userRole, userID })
Object.entries(item).map(([key, value]) => (
<TableRow key={index.toString() + key}>
<TableCell className="align-top whitespace-normal">
{renderModelNameCell(key, getProviderFromModel)}
{renderModelNameCell(key, resolveModelDisplay)}
</TableCell>
<TableCell className="align-top whitespace-normal">
{renderFallbacksChain(key, Array.isArray(value) ? value : [], getProviderFromModel)}
{renderFallbacksChain(key, Array.isArray(value) ? value : [], resolveModelDisplay)}
</TableCell>
<TableCell className="align-top">
{canModify && (

View file

@ -0,0 +1,94 @@
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 <Provider> 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/*");
});
});

View file

@ -0,0 +1,84 @@
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<string> = 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<string>();
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);
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];
};
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 }));
export const resolveFallbackProvider = (
model: string,
modelCostMap: Record<string, { litellm_provider?: string }> | 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) ?? "";
};
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;
};

View file

@ -16,8 +16,13 @@ vi.mock("@/components/llm_calls/fetch_models", () => ({
}));
vi.mock("../Settings/RouterSettings/Fallbacks/FallbackSelectionForm", () => ({
FallbackSelectionForm: ({ availableModels }: { availableModels: string[] }) => (
<div data-testid="available-models">{availableModels.join(",")}</div>
FallbackSelectionForm: ({ modelGroups }: { modelGroups: { model_group: string }[] }) => (
<div data-testid="available-models">
{modelGroups
.map((modelGroup) => modelGroup.model_group)
.sort()
.join(",")}
</div>
),
}));

View file

@ -327,8 +327,6 @@ const RouterSettingsAccordion = forwardRef<RouterSettingsAccordionRef, RouterSet
setFallbacks(newFallbacks);
};
const availableModels = Array.from(new Set(modelInfo.map((option) => option.model_group))).sort();
// Expose getValue method via ref
useImperativeHandle(ref, () => ({
getValue: () => {
@ -363,7 +361,7 @@ const RouterSettingsAccordion = forwardRef<RouterSettingsAccordionRef, RouterSet
<FallbackSelectionForm
groups={fallbackGroups}
onGroupsChange={handleFallbackGroupsChange}
availableModels={availableModels}
modelGroups={modelInfo}
maxGroups={5}
/>
</TabsContent>

View file

@ -251,6 +251,7 @@ describe("Sidebar (leftnav)", () => {
"Virtual Keys",
"Playground",
"Models + Endpoints",
"Fallbacks",
"Agentic",
"MCP Servers",
"Guardrails",

View file

@ -53,6 +53,7 @@ import {
ScrollText,
Search,
Server,
Shuffle,
Settings as SettingsIcon,
Shield,
ShieldCheck,
@ -226,6 +227,13 @@ const menuGroups: MenuGroup[] = [
icon: <Network {...ICON} />,
roles: rolesAllowedToViewWriteScopedPages,
},
{
key: "fallbacks",
page: "fallbacks",
label: "Fallbacks",
icon: <Shuffle {...ICON} />,
roles: all_admin_roles,
},
{
key: "agentic",
page: "agentic",

View file

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

View file

@ -104,6 +104,22 @@ 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)
.describe("Concrete model groups to try in order. Never a wildcard."),
};
const fallbackDeleteFields = { model: text };
type RouterSettings = Record<string, unknown>;
const fallbackEntries = (settings: RouterSettings): Record<string, string[]>[] =>
Array.isArray(settings.fallbacks) ? (settings.fallbacks as Record<string, string[]>[]) : [];
export function createLiteAdminOperations(context: OperationContext) {
const active = () => {
@ -111,6 +127,14 @@ export function createLiteAdminOperations(context: OperationContext) {
context.assertCurrent();
};
const auth = { accessToken: context.accessToken, signal: context.signal };
const readRouterSettings = async (): Promise<RouterSettings> => {
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<unknown>("/config/update", { ...auth, body: { router_settings: routerSettings } });
const operation =
(mode: "read" | "write" | "delete", kind: ResultKind, extraArguments?: Record<string, unknown>) =>
<Schema extends z.ZodType<Record<string, unknown>>>(
@ -339,5 +363,39 @@ 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 model",
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 };
},
),
];
}

View file

@ -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 extends z.ZodType<unknown>>(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)

View file

@ -9,6 +9,7 @@ export const pageDescriptions: Record<string, string> = {
"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",