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

View file

@ -247,6 +247,15 @@ def _check_stripped_model_group(model_group: str, fallback_key: str) -> bool:
return False
def _matches_provider_wildcard_key(model_group: str, fallback_key: str) -> bool:
provider, separator, rest = fallback_key.partition("/")
if separator != "/" or rest != "*" or not provider or "*" in provider:
return False
if model_group.startswith(f"{provider}/"):
return True
return "/" not in model_group and inferred_provider(model_group) == provider
def _provider_prefixed_model_group(model_group: str, fallback_keys: Sequence[str]) -> str | None:
if "/" in model_group or not any(key.endswith(f"/{model_group}") for key in fallback_keys):
return None
@ -548,9 +557,11 @@ def get_fallback_model_group(fallbacks: list[Any], model_group: str) -> tuple[li
- exact match
- stripped model group match
- provider-prefixed model group match
- provider wildcard match (e.g. "openai/*")
- generic fallback
"""
generic_fallback_idx: int | None = None
provider_wildcard_fallback_idx: int | None = None
stripped_model_fallback: list[str] | None = None
fallback_model_group: list[str] | None = None
fallback_keys: Final = tuple(next(iter(item)) for item in fallbacks if isinstance(item, dict) and item)
@ -564,20 +575,25 @@ def get_fallback_model_group(fallbacks: list[Any], model_group: str) -> tuple[li
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]
elif isinstance(fallback_key, str) and _matches_provider_wildcard_key(
model_group=model_group, fallback_key=fallback_key
):
provider_wildcard_fallback_idx = idx
elif fallback_key == "*": # check generic fallback
generic_fallback_idx = idx
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 generic_fallback_idx is not None:
fallback_model_group = fallbacks[generic_fallback_idx]["*"]
return fallback_model_group, generic_fallback_idx
if fallback_model_group is not None:
return fallback_model_group, generic_fallback_idx
if stripped_model_fallback is not None:
return stripped_model_fallback, generic_fallback_idx
if provider_wildcard_fallback_idx is not None:
return next(iter(fallbacks[provider_wildcard_fallback_idx].values())), generic_fallback_idx
if generic_fallback_idx is not None:
return fallbacks[generic_fallback_idx]["*"], generic_fallback_idx
return None, None
PROVIDER_SCOPED_RESOURCE_KEYS: Final = ("input_file_id", "training_file", "batch_id", "file_id", "fine_tuning_job_id")

View file

@ -1463,6 +1463,80 @@ def test_get_fallback_model_group_prefixed_match_skips_prefixed_model_group():
assert fallback_model_group is None
@pytest.mark.parametrize("model_group", ["openai/gpt-4o", "gpt-4o"])
def test_get_fallback_model_group_provider_wildcard_key_matches_every_model_of_that_provider(model_group: str):
fallbacks = [{"openai/*": ["anthropic/claude-sonnet-4-5"]}, {"*": ["gemini-2.5-flash"]}]
fallback_model_group, _ = get_fallback_model_group(fallbacks=fallbacks, model_group=model_group)
assert fallback_model_group == ["anthropic/claude-sonnet-4-5"]
@pytest.mark.parametrize("model_group", ["anthropic/claude-sonnet-4-5", "claude-sonnet-4-5"])
def test_get_fallback_model_group_provider_wildcard_key_skips_other_providers(model_group: str):
fallbacks = [{"openai/*": ["anthropic/claude-sonnet-4-5"]}, {"*": ["gemini-2.5-flash"]}]
fallback_model_group, _ = get_fallback_model_group(fallbacks=fallbacks, model_group=model_group)
assert fallback_model_group == ["gemini-2.5-flash"]
def test_get_fallback_model_group_exact_key_beats_provider_wildcard_key():
fallbacks = [{"openai/*": ["anthropic/claude-sonnet-4-5"]}, {"openai/gpt-4o": ["openai/gpt-4o-mini"]}]
fallback_model_group, _ = get_fallback_model_group(fallbacks=fallbacks, model_group="openai/gpt-4o")
assert fallback_model_group == ["openai/gpt-4o-mini"]
@pytest.mark.parametrize(
("model_group", "specific_key"),
[("gpt-4o", "openai/gpt-4o"), ("openai/gpt-4o", "gpt-4o")],
)
@pytest.mark.parametrize("specific_first", [True, False])
def test_get_fallback_model_group_per_model_key_beats_provider_wildcard_key_in_any_order(
model_group: str, specific_key: str, specific_first: bool
):
specific: Final = {specific_key: ["openai/gpt-4o-mini"]}
wildcard: Final = {"openai/*": ["anthropic/claude-sonnet-4-5"]}
fallbacks: Final = [specific, wildcard] if specific_first else [wildcard, specific]
fallback_model_group, _ = get_fallback_model_group(fallbacks=fallbacks, model_group=model_group)
assert fallback_model_group == ["openai/gpt-4o-mini"]
@pytest.mark.parametrize("fallback_key", ["*" * 100 + "Z", "openai/gpt-*", "*/*", "/*"])
def test_get_fallback_model_group_ignores_keys_that_are_not_a_single_provider_wildcard(fallback_key: str):
fallbacks: Final = [{fallback_key: ["anthropic/claude-sonnet-4-5"]}, {"*": ["gemini-2.5-flash"]}]
fallback_model_group, _ = get_fallback_model_group(fallbacks=fallbacks, model_group="openai/gpt-4o")
assert fallback_model_group == ["gemini-2.5-flash"]
def test_router_falls_back_from_provider_wildcard_key():
router = Router(
model_list=[
{
"model_name": "openai/gpt-4o",
"litellm_params": {"model": "openai/gpt-4o", "api_key": "fake", "mock_response": Exception("boom")},
},
{
"model_name": "anthropic/claude-sonnet-4-5",
"litellm_params": {"model": "anthropic/claude-sonnet-4-5", "api_key": "fake", "mock_response": "hi"},
},
],
fallbacks=[{"openai/*": ["anthropic/claude-sonnet-4-5"]}],
num_retries=0,
)
response = router.completion(model="openai/gpt-4o", messages=[{"role": "user", "content": "hi"}])
assert isinstance(response, litellm.ModelResponse)
assert response.choices[0].message.content == "hi"
def test_get_fallback_model_group_never_resolves_a_provider_without_a_prefixed_key(
monkeypatch: pytest.MonkeyPatch,
) -> None:

View file

@ -0,0 +1,13 @@
"use client";
import Fallbacks from "@/components/Settings/RouterSettings/Fallbacks/Fallbacks";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
export default function FallbacksPage() {
const { accessToken, userRole, userId } = useAuthorized();
return (
<div className="w-full px-8 py-6">
<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

@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { toast } from "@/lib/toast";
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
import { toModelProviders } from "./providerWildcards";
import { AddFallbacksModal } from "./AddFallbacksModal";
import { FallbackGroup } from "./FallbackGroupConfig";
import { FallbackSelectionForm } from "./FallbackSelectionForm";
@ -126,6 +127,7 @@ export default function AddFallbacks({ accessToken, value = [], onChange }: AddF
groups={groups}
onGroupsChange={setGroups}
availableModels={availableModels}
modelProviders={toModelProviders(modelInfo)}
maxFallbacks={10}
maxGroups={5}
/>

View file

@ -9,6 +9,7 @@ import { useQuery } from "@tanstack/react-query";
import { LoaderCircle, Pencil } from "lucide-react";
import React, { useMemo, useState } from "react";
import { fetchAvailableModels } from "@/components/llm_calls/fetch_models";
import { toModelProviders } from "./providerWildcards";
import { toast } from "@/lib/toast";
import { AddFallbacksModal } from "./AddFallbacksModal";
import { FallbackGroup, FallbackGroupConfig } from "./FallbackGroupConfig";
@ -56,6 +57,8 @@ export default function EditFallbacks({
[modelGroups],
);
const modelProviders = useMemo(() => toModelProviders(modelGroups), [modelGroups]);
const handleSave = async () => {
const primaryModel = group.primaryModel;
if (!primaryModel) {
@ -84,6 +87,7 @@ export default function EditFallbacks({
group={group}
onChange={setGroup}
availableModels={availableModels}
modelProviders={modelProviders}
maxFallbacks={maxFallbacks}
disablePrimaryModel
/>

View file

@ -0,0 +1,59 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it } from "vitest";
import { FallbackGroup, FallbackGroupConfig } from "./FallbackGroupConfig";
import { ModelProviders } from "./providerWildcards";
const availableModels = ["claude-sonnet-4-5", "anthropic/claude-opus-4-1", "openai/gpt-4o", "gpt-4o-mini"];
const modelProviders: ModelProviders = { "claude-sonnet-4-5": ["anthropic"], "gpt-4o-mini": ["openai"] };
function Harness({ initial, maxFallbacks = 10 }: { initial: FallbackGroup; maxFallbacks?: number }) {
const [group, setGroup] = useState(initial);
return (
<>
<FallbackGroupConfig
group={group}
onChange={setGroup}
availableModels={availableModels}
modelProviders={modelProviders}
maxFallbacks={maxFallbacks}
/>
<output data-testid="state">{JSON.stringify(group)}</output>
</>
);
}
const state = (): FallbackGroup => JSON.parse(screen.getByTestId("state").textContent ?? "{}");
describe("FallbackGroupConfig provider wildcards", () => {
it("lets the user pick All OpenAI models as the primary, found by typing openai/*", async () => {
const user = userEvent.setup();
render(<Harness initial={{ id: "1", primaryModel: null, fallbackModels: [] }} />);
await user.type(screen.getByRole("combobox", { name: /primary model/i }), "openai/*");
await user.click(await screen.findByRole("option", { name: /All OpenAI models/i }));
expect(state().primaryModel).toBe("openai/*");
});
it("expands All Anthropic models into every Anthropic model group in the fallback chain", async () => {
const user = userEvent.setup();
render(<Harness initial={{ id: "1", primaryModel: "openai/*", fallbackModels: [] }} />);
await user.click(screen.getByRole("combobox", { name: "Select fallback models to add..." }));
await user.click(await screen.findByRole("option", { name: /All Anthropic models/i }));
expect(state().fallbackModels).toEqual(["claude-sonnet-4-5", "anthropic/claude-opus-4-1"]);
});
it("does not offer the primary provider wildcard as its own fallback", async () => {
const user = userEvent.setup();
render(<Harness initial={{ id: "1", primaryModel: "openai/*", fallbackModels: [] }} />);
await user.click(screen.getByRole("combobox", { name: "Select fallback models to add..." }));
expect(await screen.findByRole("option", { name: /All Anthropic models/i })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: /All OpenAI models/i })).not.toBeInTheDocument();
});
});

View file

@ -7,6 +7,13 @@ import { MultiSelect } from "@/components/shared/MultiSelect";
import { SearchSelect } from "@/components/shared/SearchSelect";
import { AlertCircle, ArrowDown, X } from "lucide-react";
import React, { useId } from "react";
import {
expandFallbackSelection,
fallbackModelLabel,
ModelProviders,
providerWildcardValues,
toFallbackModelOption,
} from "./providerWildcards";
export interface FallbackGroup {
id: string;
@ -18,6 +25,7 @@ interface FallbackGroupConfigProps {
group: FallbackGroup;
onChange: (updatedGroup: FallbackGroup) => void;
availableModels: string[];
modelProviders?: ModelProviders;
maxFallbacks: number;
disablePrimaryModel?: boolean;
}
@ -26,11 +34,17 @@ export function FallbackGroupConfig({
group,
onChange,
availableModels,
modelProviders = {},
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);
const wildcardValues = providerWildcardValues(availableModels, modelProviders).filter(
(value) => !availableModels.includes(value),
);
const primaryOptions = [...wildcardValues, ...availableModels].map(toFallbackModelOption);
const fallbackOptions = [...wildcardValues, ...availableModels]
.filter((m) => m !== group.primaryModel)
.map(toFallbackModelOption);
const handlePrimaryChange = (value: string | null) => {
const newFallbacks = group.fallbackModels.filter((model) => model !== value);
@ -42,8 +56,10 @@ export function FallbackGroupConfig({
};
const handleFallbackSelect = (values: string[]) => {
// Limit to maxFallbacks
const limitedValues = values.slice(0, maxFallbacks);
const limitedValues = expandFallbackSelection(values, availableModels, modelProviders, {
primaryModel: group.primaryModel,
maxFallbacks,
});
onChange({
...group,
@ -71,10 +87,10 @@ export function FallbackGroupConfig({
</label>
<SearchSelect
inputId={primaryModelInputId}
options={availableModels.map((m) => ({ label: m, value: m }))}
options={primaryOptions}
value={group.primaryModel}
onValueChange={handlePrimaryChange}
placeholder="Select primary model"
placeholder="Select a model or All <provider> models"
emptyText="No models found"
disabled={disablePrimaryModel}
className="h-12"
@ -110,7 +126,7 @@ export function FallbackGroupConfig({
{/* Add Fallback Input */}
<div className="mb-4">
<MultiSelect
options={availableFallbackOptions.map((m) => ({ label: m, value: m }))}
options={fallbackOptions}
value={group.fallbackModels}
onValueChange={handleFallbackSelect}
placeholder={
@ -146,7 +162,7 @@ export function FallbackGroupConfig({
<span className="text-xs font-bold">{index + 1}</span>
</div>
<div>
<span className="font-medium text-foreground">{modelValue}</span>
<span className="font-medium text-foreground">{fallbackModelLabel(modelValue)}</span>
</div>
</div>

View file

@ -10,11 +10,13 @@ import { Plus, X } from "lucide-react";
import React, { useEffect, useState } from "react";
import { toast } from "@/lib/toast";
import { FallbackGroup, FallbackGroupConfig } from "./FallbackGroupConfig";
import { ModelProviders } from "./providerWildcards";
interface FallbackSelectionFormProps {
groups: FallbackGroup[];
onGroupsChange: (groups: FallbackGroup[]) => void;
availableModels: string[];
modelProviders?: ModelProviders;
maxFallbacks?: number;
maxGroups?: number;
}
@ -23,6 +25,7 @@ export function FallbackSelectionForm({
groups,
onGroupsChange,
availableModels,
modelProviders,
maxFallbacks = 10,
maxGroups = 5,
}: FallbackSelectionFormProps) {
@ -129,6 +132,7 @@ export function FallbackSelectionForm({
group={group}
onChange={handleGroupUpdate}
availableModels={availableModels}
modelProviders={modelProviders}
maxFallbacks={maxFallbacks}
/>
</TabsContent>

View file

@ -11,6 +11,7 @@ import { getCallbacksCall, setCallbacksCall } from "../../../networking";
import { isProxyAdminRole } from "@/utils/roles";
import AddFallbacks from "./AddFallbacks";
import EditFallbacks from "./EditFallbacks";
import { fallbackModelLabel, isProviderWildcard, wildcardProvider } from "./providerWildcards";
type FallbackEntry = { [modelName: string]: string[] };
type Fallbacks = FallbackEntry[];
@ -20,12 +21,17 @@ const modelCardClass =
const iconWrapperClass = "inline-flex shrink-0 items-center justify-center px-1.5 py-1.5";
const providerForModel = (modelName: string, getProviderFromModel?: (modelName: string) => string): string =>
isProviderWildcard(modelName) ? wildcardProvider(modelName) : getProviderFromModel?.(modelName) ?? modelName;
function renderModelNameCell(modelName: string, getProviderFromModel?: (modelName: string) => string): React.ReactNode {
const provider = getProviderFromModel?.(modelName) ?? modelName;
const provider = providerForModel(modelName, getProviderFromModel);
return (
<span className={modelCardClass}>
<ProviderLogo provider={provider} className="w-4 h-4 shrink-0" />
<span className="break-words">{modelName}</span>
<span className="break-words" title={modelName}>
{fallbackModelLabel(modelName)}
</span>
</span>
);
}
@ -39,11 +45,13 @@ function renderFallbacksChain(
if (list.length === 0) return null;
const ChainCard = ({ modelName }: { modelName: string }) => {
const provider = getProviderFromModel?.(modelName) ?? modelName;
const provider = providerForModel(modelName, getProviderFromModel);
return (
<span className={modelCardClass}>
<ProviderLogo provider={provider} className="w-4 h-4 shrink-0" />
<span className="break-words">{modelName}</span>
<span className="break-words" title={modelName}>
{fallbackModelLabel(modelName)}
</span>
</span>
);
};

View file

@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import {
expandFallbackSelection,
fallbackModelLabel,
ModelProviders,
providerWildcardValues,
toFallbackModelOption,
} from "./providerWildcards";
const models = ["claude-sonnet-4-5", "anthropic/claude-opus-4-1", "openai/gpt-4o", "gpt-4o-mini"];
const modelProviders: ModelProviders = { "claude-sonnet-4-5": ["anthropic"], "gpt-4o-mini": ["openai"] };
describe("providerWildcardValues", () => {
it("offers one wildcard per provider found in model metadata or a model name prefix", () => {
expect(providerWildcardValues(models, modelProviders)).toEqual(["anthropic/*", "openai/*"]);
});
});
describe("fallbackModelLabel", () => {
it("names provider wildcards and the global wildcard in plain words", () => {
expect(fallbackModelLabel("openai/*")).toBe("All OpenAI models");
expect(fallbackModelLabel("*")).toBe("All models");
expect(fallbackModelLabel("openai/gpt-4o")).toBe("openai/gpt-4o");
});
it("keeps the raw wildcard searchable as the sublabel", () => {
expect(toFallbackModelOption("openai/*")).toEqual({
label: "All OpenAI models",
value: "openai/*",
sublabel: "openai/*",
});
});
});
describe("expandFallbackSelection", () => {
it("expands a provider wildcard into every model group of that provider", () => {
expect(
expandFallbackSelection(["anthropic/*"], models, modelProviders, { primaryModel: "openai/*", maxFallbacks: 10 }),
).toEqual(["claude-sonnet-4-5", "anthropic/claude-opus-4-1"]);
});
it("drops duplicates and the primary model from the expanded chain", () => {
expect(
expandFallbackSelection(["gpt-4o-mini", "openai/*"], models, modelProviders, {
primaryModel: "openai/gpt-4o",
maxFallbacks: 10,
}),
).toEqual(["gpt-4o-mini"]);
});
it("keeps a wildcard that is itself a configured model group", () => {
expect(
expandFallbackSelection(["anthropic/*"], [...models, "anthropic/*"], modelProviders, {
primaryModel: null,
maxFallbacks: 10,
}),
).toEqual(["anthropic/*"]);
});
it("caps the expanded chain at the max fallback count", () => {
expect(
expandFallbackSelection(["anthropic/*"], models, modelProviders, {
primaryModel: "openai/gpt-4o",
maxFallbacks: 1,
}),
).toEqual(["claude-sonnet-4-5"]);
});
});

View file

@ -0,0 +1,74 @@
import type { ModelGroup } from "@/components/llm_calls/fetch_models";
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
const WILDCARD_SUFFIX = "/*";
export type ModelProviders = Readonly<Record<string, readonly string[]>>;
export interface FallbackModelOption {
label: string;
value: string;
sublabel?: string;
}
export const isProviderWildcard = (value: string): boolean =>
value.endsWith(WILDCARD_SUFFIX) && value.length > WILDCARD_SUFFIX.length;
export const wildcardProvider = (value: string): string => value.slice(0, -WILDCARD_SUFFIX.length);
export const fallbackModelLabel = (value: string): string => {
if (value === "*") {
return "All models";
}
if (!isProviderWildcard(value)) {
return value;
}
return `All ${getProviderLogoAndName(wildcardProvider(value)).displayName} models`;
};
export const toModelProviders = (models: readonly ModelGroup[]): ModelProviders =>
Object.fromEntries(models.map((model) => [model.model_group, model.providers ?? []]));
const providersOf = (model: string, modelProviders: ModelProviders): readonly string[] => {
const known = modelProviders[model] ?? [];
if (known.length > 0) {
return known;
}
const [prefix, ...rest] = model.split("/");
return rest.length > 0 && prefix ? [prefix] : [];
};
export const modelsForProvider = (
provider: string,
models: readonly string[],
modelProviders: ModelProviders,
): string[] =>
models.filter((model) => !isProviderWildcard(model) && providersOf(model, modelProviders).includes(provider));
export const providerWildcardValues = (models: readonly string[], modelProviders: ModelProviders): string[] => {
const providers = new Set(
models.filter((model) => !isProviderWildcard(model)).flatMap((model) => providersOf(model, modelProviders)),
);
return Array.from(providers)
.sort()
.map((provider) => `${provider}${WILDCARD_SUFFIX}`);
};
export const toFallbackModelOption = (value: string): FallbackModelOption =>
isProviderWildcard(value) ? { label: fallbackModelLabel(value), value, sublabel: value } : { label: value, value };
export const expandFallbackSelection = (
values: readonly string[],
models: readonly string[],
modelProviders: ModelProviders,
{ primaryModel, maxFallbacks }: { primaryModel: string | null; maxFallbacks: number },
): string[] => {
const expanded = values.flatMap((value) =>
isProviderWildcard(value) && !models.includes(value)
? modelsForProvider(wildcardProvider(value), models, modelProviders)
: [value],
);
return Array.from(new Set(expanded))
.filter((model) => model !== primaryModel)
.slice(0, maxFallbacks);
};

View file

@ -308,6 +308,10 @@ describe("Sidebar (leftnav)", () => {
expect(placementsOf("router-settings")).toEqual(["SETTINGS > settings"]);
});
it("puts Fallbacks at the top level of AI GATEWAY so it is one click away", () => {
expect(placementsOf("fallbacks")).toEqual(["AI GATEWAY"]);
});
it("has no duplicate keys among all menu items and their children", () => {
// React keys must be unique across the whole nav config, otherwise the
// active-item highlight and group expansion collide.

View file

@ -54,6 +54,7 @@ import {
Search,
Server,
Settings as SettingsIcon,
Shuffle,
Shield,
ShieldCheck,
Tags,
@ -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

@ -43,6 +43,7 @@ export const pageDescriptions: Record<string, string> = {
skills: "Browse and manage Claude Code skills",
usage: "View legacy usage dashboard",
"router-settings": "Configure routing and load balancing settings",
fallbacks: "Pick which models to try when a model fails",
"logging-and-alerts": "Set up logging and alert configurations",
"admin-panel": "Access admin panel and settings",
};