diff --git a/ui/litellm-dashboard/src/components/add_model/CustomTierPromptEditor.test.tsx b/ui/litellm-dashboard/src/components/add_model/CustomTierPromptEditor.test.tsx index 4d45801f7e1..9cce7f969b1 100644 --- a/ui/litellm-dashboard/src/components/add_model/CustomTierPromptEditor.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/CustomTierPromptEditor.test.tsx @@ -78,6 +78,32 @@ describe("CustomTierPromptEditor", () => { ); }); + it("ignores a stale response that resolves after a newer one", async () => { + let resolveFirst: (text: string) => void = () => {}; + getAutoRouterCustomTierPromptCall + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockResolvedValueOnce("assembled from the edited draft"); + renderEditor(); + fireEvent.click(screen.getByRole("button", { name: "Edit prompt" })); + await vi.waitFor(() => expect(getAutoRouterCustomTierPromptCall).toHaveBeenCalledTimes(1)); + + fireEvent.change(screen.getByLabelText("Classifier opening instructions"), { target: { value: "edited" } }); + expect(await screen.findByLabelText("Assembled classifier prompt")).toHaveTextContent( + "assembled from the edited draft", + ); + + resolveFirst("assembled from the stale draft"); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(screen.getByLabelText("Assembled classifier prompt")).toHaveTextContent( + "assembled from the edited draft", + ); + }); + it("keeps the editor usable when the preview cannot be fetched", async () => { getAutoRouterCustomTierPromptCall.mockRejectedValue(new Error("boom")); renderEditor(); diff --git a/ui/litellm-dashboard/src/components/add_model/CustomTierPromptEditor.tsx b/ui/litellm-dashboard/src/components/add_model/CustomTierPromptEditor.tsx index 99cf0b76306..2ae3fcfe347 100644 --- a/ui/litellm-dashboard/src/components/add_model/CustomTierPromptEditor.tsx +++ b/ui/litellm-dashboard/src/components/add_model/CustomTierPromptEditor.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { getAutoRouterCustomTierPromptCall } from "@/components/networking"; import { Button } from "@/components/ui/button"; @@ -33,30 +33,27 @@ const CustomTierPromptEditor: React.FC = ({ >({ status: "loading" }); const isOverridden = Boolean(classificationPrompt?.trim()); - // Debounced so the preview follows the draft without a request per keystroke. Nothing is saved - // from here, so a failed fetch leaves the preview empty rather than blocking the edit. - const refreshPreview = useCallback(async () => { - if (!accessToken) return; - try { - const text = await getAutoRouterCustomTierPromptCall( - accessToken, - contextWindowSize, - tierDefinitionsFromRows(tierRows), - draft, - ); - setPreview({ status: "ready", text }); - } catch { - // Distinct from loading: a role that may not call the preview, or a prompt the write gate - // would reject, otherwise leaves the panel claiming it is still fetching, forever. - setPreview({ status: "error" }); - } - }, [accessToken, contextWindowSize, tierRows, draft]); - useEffect(() => { - if (!isOpen) return; - const timer = setTimeout(refreshPreview, 300); - return () => clearTimeout(timer); - }, [isOpen, refreshPreview]); + if (!isOpen || !accessToken) return; + let stale = false; + const timer = setTimeout(async () => { + try { + const text = await getAutoRouterCustomTierPromptCall( + accessToken, + contextWindowSize, + tierDefinitionsFromRows(tierRows), + draft, + ); + if (!stale) setPreview({ status: "ready", text }); + } catch { + if (!stale) setPreview({ status: "error" }); + } + }, 300); + return () => { + stale = true; + clearTimeout(timer); + }; + }, [isOpen, accessToken, contextWindowSize, tierRows, draft]); const openEditor = () => { setDraft(classificationPrompt ?? "");