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 ecc15453e84..3787b5954fc 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
@@ -202,11 +202,16 @@ describe("AddAutoRouterTab", () => {
});
// react-query keeps the last successful list when a later refetch fails, so a background
- // refetch error must not treat an already-verified, still-cached preset as unverifiable: the
- // caller never sees a stale error message, and a preset they already picked stays submittable.
+ // refetch error must not treat an already-verified, still-cached preset as unverifiable in the
+ // UI: the dropdown stays selectable and the caller never sees a stale error just from a passive
+ // hiccup. Submit still forces its own fresh check (separately tested below); here that fresh
+ // check succeeds, representing the hiccup having been transient.
it("keeps a selected preset submit-reachable when a background refetch fails but cached models remain valid", async () => {
const user = userEvent.setup();
- mockFetchAvailableModels.mockResolvedValueOnce(ALL_FAMILY_MODELS).mockRejectedValue(new Error("boom"));
+ mockFetchAvailableModels
+ .mockResolvedValueOnce(ALL_FAMILY_MODELS)
+ .mockRejectedValueOnce(new Error("boom"))
+ .mockResolvedValueOnce(ALL_FAMILY_MODELS);
renderWithProviders();
openTemplateDropdown();
@@ -216,6 +221,10 @@ describe("AddAutoRouterTab", () => {
await act(async () => {
await testQueryClient.refetchQueries({ queryKey: ["availableModels", "autoRouter", "token"] });
});
+ // The passive refetch failure must not have re-disabled the already-selected preset's option.
+ openTemplateDropdown();
+ expect(isOptionDisabled(optionByLabel("Anthropic Family")!)).toBe(false);
+ openTemplateDropdown();
await user.type(screen.getByPlaceholderText(/smart_router/i), "resilient-router");
await user.click(screen.getByRole("button", { name: /add auto router/i }));
@@ -226,6 +235,60 @@ describe("AddAutoRouterTab", () => {
);
});
+ // The backend does not re-check a router's referenced model names against the caller's access,
+ // so submit is the only place that can catch a genuinely stale preset: force a fresh fetch right
+ // before creating the router rather than trusting the cache, and block if that fresh check can't
+ // confirm availability (a real outage, or the caller's access having actually narrowed).
+ it("blocks submit when a fresh re-check at submit time cannot confirm the preset's models", async () => {
+ const user = userEvent.setup();
+ mockFetchAvailableModels.mockResolvedValueOnce(ALL_FAMILY_MODELS).mockRejectedValue(new Error("boom"));
+
+ renderWithProviders();
+ openTemplateDropdown();
+ await waitFor(() => expect(isOptionDisabled(optionByLabel("Anthropic Family")!)).toBe(false));
+ fireEvent.click(optionByLabel("Anthropic Family")!);
+
+ await user.type(screen.getByPlaceholderText(/smart_router/i), "unconfirmed-router");
+ await user.click(screen.getByRole("button", { name: /add auto router/i }));
+
+ await waitFor(() =>
+ expect(NotificationManager.fromBackend).toHaveBeenCalledWith(
+ "This template's models are no longer available. Please reselect a template or switch to Custom.",
+ ),
+ );
+ expect(mockHandleAddAutoRouterSubmit).not.toHaveBeenCalled();
+ });
+
+ // The fresh re-check is a real network round trip, not instant, so the submit button must show
+ // its own loading state (matching the Test Connection button's existing convention) rather than
+ // silently doing nothing until it settles. This also proves the button can't be clicked again
+ // mid-check.
+ it("shows a loading state on the submit button while the submit-time re-check is in flight", async () => {
+ const user = userEvent.setup();
+ let resolveRecheck: (models: ModelGroup[]) => void = () => undefined;
+ mockFetchAvailableModels
+ .mockResolvedValueOnce(ALL_FAMILY_MODELS)
+ .mockReturnValueOnce(new Promise((resolve) => (resolveRecheck = resolve)));
+
+ renderWithProviders();
+ openTemplateDropdown();
+ await waitFor(() => expect(isOptionDisabled(optionByLabel("Anthropic Family")!)).toBe(false));
+ fireEvent.click(optionByLabel("Anthropic Family")!);
+
+ await user.type(screen.getByPlaceholderText(/smart_router/i), "loading-state-router");
+ const submitButton = screen.getByRole("button", { name: /add auto router/i });
+ await user.click(submitButton);
+
+ await waitFor(() => expect(submitButton).toHaveClass("ant-btn-loading"));
+ await user.click(submitButton);
+ expect(mockFetchAvailableModels).toHaveBeenCalledTimes(2);
+
+ resolveRecheck(ALL_FAMILY_MODELS);
+
+ await waitFor(() => expect(submitButton).not.toHaveClass("ant-btn-loading"));
+ expect(mockHandleAddAutoRouterSubmit).toHaveBeenCalledTimes(1);
+ });
+
// The headline behavior: selecting a preset must pre-fill the tier config so the created
// router carries the preset's models. Real tier validation runs here (getMissingTiersError is
// not stubbed), so if selection stopped pre-filling, the empty tiers would either block the
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 ea29e04d372..071afecb8d0 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
@@ -92,6 +92,7 @@ const AddAutoRouterTab: React.FC = ({
const [isTestModalVisible, setIsTestModalVisible] = useState(false);
const [isTestingConnection, setIsTestingConnection] = useState(false);
+ const [isSubmittingRouter, setIsSubmittingRouter] = useState(false);
const [connectionTestId, setConnectionTestId] = useState(0);
const [testTargets, setTestTargets] = useState([]);
@@ -191,25 +192,34 @@ const AddAutoRouterTab: React.FC = ({
setEscalationKeywords(config.escalation_keywords ?? DEFAULT_ESCALATION_KEYWORDS);
};
- const submitRecommendedRouter = (name: string) => {
+ // The dropdown and handlePresetChange trust the cached model list, so a caller's access can
+ // narrow without ever being reflected here (nothing invalidates a preset already applied, and a
+ // background refetch failure keeps trusting the stale cache by design - see modelsUnverifiable
+ // above). The backend does not re-check a router's referenced model names against the caller's
+ // access either, so this is the only place that can catch it: force a fresh fetch right before
+ // creating the router, rather than trusting whatever's cached.
+ const verifyPresetStillAvailable = async (presetKey: string): Promise => {
+ const preset = getPresetByKey(presetKey);
+ if (!preset) return false;
+ const { data: freshModels, isError: freshError } = await refetchModels();
+ if (freshError) return false;
+ const freshSet = new Set((freshModels ?? []).map((m) => m.model_group));
+ return getMissingModelsInPreset(preset, freshSet).length === 0;
+ };
+
+ const submitRecommendedRouter = async (name: string) => {
if (!selectedPreset) {
setShowValidationErrors(true);
NotificationManager.fromBackend("Please select a template, or choose Custom Configuration");
return;
}
- // handlePresetChange only ever applies a preset that was available at selection time; that
- // guarantee can go stale by submit time (e.g. the caller's model access narrowed since), so
- // re-verify here rather than trust state gathered earlier.
- if (selectedPreset !== "custom") {
- const preset = getPresetByKey(selectedPreset);
- if (!preset || presetAvailability(preset).kind !== "available") {
- setShowValidationErrors(true);
- NotificationManager.fromBackend(
- "This template's models are no longer available. Please reselect a template or switch to Custom.",
- );
- return;
- }
+ if (selectedPreset !== "custom" && !(await verifyPresetStillAvailable(selectedPreset))) {
+ setShowValidationErrors(true);
+ NotificationManager.fromBackend(
+ "This template's models are no longer available. Please reselect a template or switch to Custom.",
+ );
+ return;
}
const {
@@ -256,48 +266,48 @@ const AddAutoRouterTab: React.FC = ({
auto_router_default_model: defaultModel,
});
- form
- .validateFields(requiresTeamScope ? ["auto_router_name", "team_id"] : ["auto_router_name"])
- .then((values) => {
- const complexityRouterConfigParams = {
- tiers,
- classifierType,
- classifierLlmConfig,
- classifierContextWindowSize,
- classifierContextPerTurnChars,
- classifierContextIncludeAssistantTurns,
- sessionAffinity,
- customTechnicalKeywords,
- keywordTierRules,
- semanticMatchingEnabled,
- embeddingModel,
- matchThreshold,
- escalationKeywords,
- adaptive,
- adaptiveWeights,
- tierDistancePenalty,
- adaptiveEligible,
- returnRawModelName,
- };
+ try {
+ const values = await form.validateFields(
+ requiresTeamScope ? ["auto_router_name", "team_id"] : ["auto_router_name"],
+ );
+ const complexityRouterConfigParams = {
+ tiers,
+ classifierType,
+ classifierLlmConfig,
+ classifierContextWindowSize,
+ classifierContextPerTurnChars,
+ classifierContextIncludeAssistantTurns,
+ sessionAffinity,
+ customTechnicalKeywords,
+ keywordTierRules,
+ semanticMatchingEnabled,
+ embeddingModel,
+ matchThreshold,
+ escalationKeywords,
+ adaptive,
+ adaptiveWeights,
+ tierDistancePenalty,
+ adaptiveEligible,
+ returnRawModelName,
+ };
- const submitValues = {
- ...values,
- auto_router_name: name,
- auto_router_default_model: defaultModel,
- model_type: "complexity_router",
- complexity_router_config: buildComplexityRouterConfig(complexityRouterConfigParams),
- model_access_group: form.getFieldValue("model_access_group"),
- };
+ const submitValues = {
+ ...values,
+ auto_router_name: name,
+ auto_router_default_model: defaultModel,
+ model_type: "complexity_router",
+ complexity_router_config: buildComplexityRouterConfig(complexityRouterConfigParams),
+ model_access_group: form.getFieldValue("model_access_group"),
+ };
- handleAddAutoRouterSubmit(submitValues, accessToken, form, handleOk);
- })
- .catch((error) => {
- console.error("Validation failed:", error);
- NotificationManager.fromBackend("Please fill in all required fields");
- });
+ await handleAddAutoRouterSubmit(submitValues, accessToken, form, handleOk);
+ } catch (error) {
+ console.error("Validation failed:", error);
+ NotificationManager.fromBackend("Please fill in all required fields");
+ }
};
- const handleAutoRouterSubmit = () => {
+ const handleAutoRouterSubmit = async () => {
const name = form.getFieldValue("auto_router_name");
if (!name) {
setShowValidationErrors(true);
@@ -306,7 +316,12 @@ const AddAutoRouterTab: React.FC = ({
return;
}
- submitRecommendedRouter(name);
+ setIsSubmittingRouter(true);
+ try {
+ await submitRecommendedRouter(name);
+ } finally {
+ setIsSubmittingRouter(false);
+ }
};
const handleTestConnection = () => {
@@ -483,8 +498,9 @@ const AddAutoRouterTab: React.FC = ({