diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx
index e5db57336ca..5b1c6c2cf98 100644
--- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.test.tsx
@@ -305,6 +305,27 @@ describe("AddAutoRouterTab", () => {
expect(isOptionDisabled(optionByLabel("OpenAI Family")!)).toBe(true);
});
+ // A preset only prefills once; nothing re-checks selectedPreset afterward. So the config itself,
+ // not the preset it came from, has to stay checked against availableModelSet - here the caller's
+ // access narrows after a preset already filled in a model, and submitBlockedReason must catch it
+ // the same way it would catch an empty tier, not let a stale reference through.
+ it("blocks submit when a model already in the config is no longer in the caller's available list", async () => {
+ mockFetchAvailableModels
+ .mockResolvedValueOnce(ALL_FAMILY_MODELS)
+ .mockResolvedValueOnce(ALL_FAMILY_MODELS.filter((m) => m.model_group !== "claude-opus-5"));
+
+ const { rerender } = renderWithProviders(
+ ,
+ );
+ openTemplateDropdown();
+ await waitFor(() => expect(isOptionDisabled(optionByLabel("Anthropic Family")!)).toBe(false));
+ fireEvent.click(optionByLabel("Anthropic Family")!);
+
+ rerender();
+
+ await waitFor(() => expect(screen.getByRole("button", { name: /add auto router/i })).toBeDisabled());
+ });
+
// Prefill must preserve a preset's deliberately-falsy fields (a 0 match threshold, an empty
// escalation list). Using `||` instead of `??` would swap the 0 for the create-form default and
// re-enable escalation the preset meant to turn off, so this asserts the exact submitted values.
diff --git a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
index c595d407519..34e52c79739 100644
--- a/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
+++ b/ui/litellm-dashboard/src/components/add_model/add_auto_router_tab.tsx
@@ -27,7 +27,13 @@ import {
import { buildAutoRouterTestTargets, AutoRouterTestTarget } from "./build_auto_router_test_targets";
import AutoRouterConnectionTest from "./auto_router_connection_test";
import NotificationManager from "../molecules/notifications_manager";
-import { getAllPresets, getPresetByKey, getMissingModelsInPreset, AutoRouterPreset } from "@/lib/autorouter_presets";
+import {
+ getAllPresets,
+ getPresetByKey,
+ getMissingModelsInPreset,
+ getMissingModels,
+ AutoRouterPreset,
+} from "@/lib/autorouter_presets";
type PresetAvailability =
| { kind: "available" }
@@ -193,15 +199,25 @@ const AddAutoRouterTab: React.FC = ({
setEscalationKeywords(config.escalation_keywords ?? DEFAULT_ESCALATION_KEYWORDS);
};
+ // Checks the config actually being built, not which preset (if any) it came from: a model that
+ // was available when it entered a tier, whether via a preset or picked by hand, can have gone
+ // missing since (the caller's access narrowed, or a background refetch never caught it).
+ const missingReferencedModels = getMissingModels(
+ {
+ tiers: complexityRouterConfig.tiers,
+ classifier_llm_config: complexityRouterConfig.classifier_llm_config,
+ embedding_model: embeddingModel,
+ },
+ availableModelSet,
+ );
+
// Why the submit is unavailable, or null when it is available. The button reads this to disable
// itself and to say what is missing, so the two can never give different answers.
const submitBlockedReason =
- getMissingTiersError(complexityRouterConfig.tiers) ?? getKeywordTierRulesError(keywordTierRules);
+ getMissingTiersError(complexityRouterConfig.tiers) ??
+ getKeywordTierRulesError(keywordTierRules) ??
+ (missingReferencedModels.length > 0 ? `Model(s) no longer available: ${missingReferencedModels.join(", ")}` : null);
- // A preset only ever prefills complexityRouterConfig at selection time (handlePresetChange);
- // after that it's edited exactly like Custom, so there is nothing preset-specific left to verify
- // at submit. The tier selects already only ever offer models from modelInfo, so submitBlockedReason
- // covering the actual config is the whole check, regardless of how it got there.
const submitRecommendedRouter = async (name: string) => {
const {
tiers,
diff --git a/ui/litellm-dashboard/src/lib/autorouter_presets.ts b/ui/litellm-dashboard/src/lib/autorouter_presets.ts
index 4183f90bbd4..e95c431dbe6 100644
--- a/ui/litellm-dashboard/src/lib/autorouter_presets.ts
+++ b/ui/litellm-dashboard/src/lib/autorouter_presets.ts
@@ -22,11 +22,24 @@ export const getAllPresets = (): AutoRouterPreset[] => PRESETS;
export const getPresetByKey = (key: string): AutoRouterPreset | undefined => PRESETS.find((p) => p.key === key);
-export const getRequiredModelsInPreset = (preset: AutoRouterPreset): Set => {
- const { tiers, classifier_llm_config: classifier, embedding_model: embedding } = preset.complexity_router_config;
+// Generalized over ComplexityRouterConfigPayload (a preset's bundled config) so the same accessors
+// can check either a preset's own models or a caller's actually-built config - the two need to
+// agree, since a preset only prefills once and the config is edited freely after.
+export const getRequiredModels = (
+ config: Pick,
+): Set => {
+ const { tiers, classifier_llm_config: classifier, embedding_model: embedding } = config;
const models = [...tiers.SIMPLE, ...tiers.MEDIUM, ...tiers.COMPLEX, ...tiers.REASONING, classifier?.model, embedding];
return new Set(models.filter((model): model is string => model != null));
};
+export const getMissingModels = (
+ config: Pick,
+ availableModels: Set,
+): string[] => [...getRequiredModels(config)].filter((model) => !availableModels.has(model)).sort();
+
+export const getRequiredModelsInPreset = (preset: AutoRouterPreset): Set =>
+ getRequiredModels(preset.complexity_router_config);
+
export const getMissingModelsInPreset = (preset: AutoRouterPreset, availableModels: Set): string[] =>
- [...getRequiredModelsInPreset(preset)].filter((model) => !availableModels.has(model)).sort();
+ getMissingModels(preset.complexity_router_config, availableModels);