diff --git a/ui/litellm-dashboard/eslint-suppressions.json b/ui/litellm-dashboard/eslint-suppressions.json index ec1e3ac05ba..28b4a1e0469 100644 --- a/ui/litellm-dashboard/eslint-suppressions.json +++ b/ui/litellm-dashboard/eslint-suppressions.json @@ -241,11 +241,6 @@ "count": 1 } }, - "src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx": { - "no-restricted-imports": { - "count": 1 - } - }, "src/app/(dashboard)/cost-optimization/_components/PromptCompressionTab.tsx": { "no-restricted-imports": { "count": 1 @@ -1804,9 +1799,6 @@ "count": 1 }, "no-restricted-imports": { - "count": 3 - }, - "prefer-const": { "count": 2 } }, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx index c4cfa98b2a1..f0b75358445 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.test.tsx @@ -1,7 +1,7 @@ import { renderWithProviders, screen, within } from "../../../../../tests/test-utils"; import userEvent from "@testing-library/user-event"; import { vi } from "vitest"; -import GeneralSettings from "./general_settings"; +import { GeneralConfigTab } from "./general_settings"; import { deleteConfigFieldSetting, getGeneralSettingsCall, updateConfigFieldSetting } from "@/components/networking"; vi.mock("@/components/networking", () => ({ @@ -10,10 +10,6 @@ vi.mock("@/components/networking", () => ({ deleteConfigFieldSetting: vi.fn().mockResolvedValue({}), })); -vi.mock("@/components/router_settings", () => ({ default: () => null })); -vi.mock("@/components/Settings/RouterSettings/Fallbacks/Fallbacks", () => ({ default: () => null })); -vi.mock("@/components/routing_groups", () => ({ default: () => null })); - // Mirrors the /config/list ordering: the two prompt-caching rows sit between the // General-tab rows in the unfiltered response but are filtered out of the General // tab's table, so any index-based lookup into the unfiltered array reads the wrong @@ -63,7 +59,7 @@ const settingsRow = async (fieldName: string) => { return row as HTMLElement; }; -describe("GeneralSettings General tab", () => { +describe("GeneralConfigTab", () => { beforeEach(() => { vi.mocked(getGeneralSettingsCall).mockResolvedValue([...SETTINGS_FIXTURE.map((s) => ({ ...s }))]); vi.mocked(updateConfigFieldSetting).mockClear(); @@ -72,9 +68,8 @@ describe("GeneralSettings General tab", () => { it("updates max_ui_session_budget with its own value, not the value at its filtered index", async () => { const user = userEvent.setup(); - renderWithProviders(); + renderWithProviders(); - await user.click(screen.getByText("General")); const row = await settingsRow("max_ui_session_budget"); await user.click(within(row).getByRole("button", { name: /update/i })); @@ -84,9 +79,8 @@ describe("GeneralSettings General tab", () => { it("reset shows the field's default value instead of an empty input", async () => { const user = userEvent.setup(); - renderWithProviders(); + renderWithProviders(); - await user.click(screen.getByText("General")); const row = await settingsRow("max_ui_session_budget"); expect(within(row).getByRole("spinbutton")).toHaveValue("7.50"); @@ -98,4 +92,12 @@ describe("GeneralSettings General tab", () => { expect(deleteConfigFieldSetting).toHaveBeenCalledWith("token", "max_ui_session_budget"); expect(within(row).getByRole("spinbutton")).toHaveValue("1.00"); }); + + it("hides prompt-caching-tab rows from the General table", async () => { + renderWithProviders(); + + await settingsRow("max_ui_session_budget"); + expect(screen.queryByText("enable_anthropic_prompt_caching")).not.toBeInTheDocument(); + expect(screen.queryByText("anthropic_prompt_caching_ttl")).not.toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx index fa3447e0cbf..42af400f230 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx @@ -13,26 +13,15 @@ import { Icon, Switch, } from "@tremor/react"; -import { TabPanel, TabPanels, TabGroup, TabList, Tab } from "@tremor/react"; import { getGeneralSettingsCall, updateConfigFieldSetting, deleteConfigFieldSetting } from "@/components/networking"; import { InputNumber, Select as AntdSelect } from "antd"; import { TrashIcon } from "@heroicons/react/outline"; import { StatusBadge } from "@/components/shared/table_cells"; -import RouterSettings from "@/components/router_settings"; -import Fallbacks from "@/components/Settings/RouterSettings/Fallbacks/Fallbacks"; -import RoutingGroups from "@/components/routing_groups"; - const PROMPT_CACHING_TAB = "prompt_caching"; const ENABLE_ANTHROPIC_PROMPT_CACHING = "enable_anthropic_prompt_caching"; const ANTHROPIC_PROMPT_CACHING_TTL = "anthropic_prompt_caching_ttl"; -interface GeneralSettingsPageProps { - accessToken: string | null; - userRole: string | null; - userID: string | null; -} - export interface generalSettingsItem { field_name: string; field_type: string; @@ -162,154 +151,105 @@ export const PromptCachingPanel: React.FC<{ ); }; -const GeneralSettings: React.FC = ({ accessToken, userRole, userID }) => { +const useGeneralSettings = (accessToken: string) => { const [generalSettings, setGeneralSettings] = useState([]); useEffect(() => { - if (!accessToken) { - return; - } getGeneralSettingsCall(accessToken).then((data) => { - let general_settings = data; - setGeneralSettings(general_settings); + setGeneralSettings(data); }); }, [accessToken]); const handleInputChange = (fieldName: string, newValue: any) => { - // Update the value in the state - const updatedSettings = generalSettings.map((setting) => - setting.field_name === fieldName ? { ...setting, field_value: newValue } : setting, + setGeneralSettings((prev) => + prev.map((setting) => (setting.field_name === fieldName ? { ...setting, field_value: newValue } : setting)), ); - setGeneralSettings(updatedSettings); }; const handleUpdateField = (fieldName: string) => { - if (!accessToken) { + const fieldValue = generalSettings.find((setting) => setting.field_name === fieldName)?.field_value; + if (fieldValue == null) { return; } - - let fieldValue = generalSettings.find((setting) => setting.field_name === fieldName)?.field_value; - - if (fieldValue == null || fieldValue == undefined) { - return; - } - try { - updateConfigFieldSetting(accessToken, fieldName, fieldValue); - // update value in state - - const updatedSettings = generalSettings.map((setting) => - setting.field_name === fieldName ? { ...setting, stored_in_db: true } : setting, - ); - setGeneralSettings(updatedSettings); - } catch (error) { - // do something - } + updateConfigFieldSetting(accessToken, fieldName, fieldValue); + setGeneralSettings((prev) => + prev.map((setting) => (setting.field_name === fieldName ? { ...setting, stored_in_db: true } : setting)), + ); }; const handleResetField = (fieldName: string) => { - if (!accessToken) { - return; - } - - try { - deleteConfigFieldSetting(accessToken, fieldName); - // update value in state - - const updatedSettings = generalSettings.map((setting) => + deleteConfigFieldSetting(accessToken, fieldName); + setGeneralSettings((prev) => + prev.map((setting) => setting.field_name === fieldName ? { ...setting, stored_in_db: null, field_value: setting.field_default_value ?? null } : setting, - ); - setGeneralSettings(updatedSettings); - } catch (error) { - // do something - } + ), + ); }; - if (!accessToken) { - return null; - } - - return ( - - - - Loadbalancing - Routing Groups - Fallbacks - Prompt Caching - General - - - - - - - - - - - - - - - - - - - - Setting - Value - Status - Action - - - - {generalSettings - .filter((value) => value.field_type !== "TypedDictionary" && value.field_tab !== PROMPT_CACHING_TAB) - .map((value, index) => ( - - - {value.field_name} - - {value.field_description} - - - - - - - {value.stored_in_db == true ? ( - - ) : value.stored_in_db == false ? ( - - ) : ( - - )} - - - handleUpdateField(value.field_name)}>Update - handleResetField(value.field_name)}> - Reset - - - - ))} - - - - - - - - ); + return { generalSettings, handleInputChange, handleUpdateField, handleResetField }; }; -export default GeneralSettings; +export const PromptCachingSettingsTab: React.FC<{ accessToken: string }> = ({ accessToken }) => { + const { generalSettings, handleInputChange } = useGeneralSettings(accessToken); + return ; +}; + +export const GeneralConfigTab: React.FC<{ accessToken: string }> = ({ accessToken }) => { + const { generalSettings, handleInputChange, handleUpdateField, handleResetField } = useGeneralSettings(accessToken); + + return ( + + + + + Setting + Value + Status + Action + + + + {generalSettings + .filter((value) => value.field_type !== "TypedDictionary" && value.field_tab !== PROMPT_CACHING_TAB) + .map((value, index) => ( + + + {value.field_name} + + {value.field_description} + + + + + + + {value.stored_in_db == true ? ( + + ) : value.stored_in_db == false ? ( + + ) : ( + + )} + + + handleUpdateField(value.field_name)}>Update + handleResetField(value.field_name)}> + Reset + + + + ))} + + + + ); +}; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/fallbacks/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/fallbacks/page.tsx new file mode 100644 index 00000000000..04bc917d015 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/fallbacks/page.tsx @@ -0,0 +1,9 @@ +"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 ; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/general/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/general/page.tsx new file mode 100644 index 00000000000..d94056add4a --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/general/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { GeneralConfigTab } from "@/app/(dashboard)/router-settings/_components/general_settings"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +export default function GeneralConfigPage() { + const { accessToken } = useAuthorized(); + if (!accessToken) { + return null; + } + return ; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/layout.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/layout.test.tsx new file mode 100644 index 00000000000..0fda48b9d5a --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/layout.test.tsx @@ -0,0 +1,81 @@ +/* @vitest-environment jsdom */ +import { act, render } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import RouterSettingsLayout from "./layout"; + +const { mockPush, navState } = vi.hoisted(() => ({ + mockPush: vi.fn(), + navState: { pathname: "/router-settings" }, +})); +vi.mock("next/navigation", () => ({ + usePathname: () => navState.pathname, + useRouter: () => ({ push: mockPush }), +})); + +vi.mock("@/components/networking", () => ({ serverRootPath: "" })); + +const renderLayout = () => + render( + + CHILD + , + ); + +describe("RouterSettingsLayout", () => { + beforeEach(() => { + navState.pathname = "/router-settings"; + mockPush.mockClear(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (global as any).ResizeObserver = class { + observe() {} + unobserve() {} + disconnect() {} + }; + }); + + it("renders the tab bar and the active tab's page content", () => { + const { getByRole, getByTestId } = renderLayout(); + for (const name of ["Loadbalancing", "Routing Groups", "Fallbacks", "Prompt Caching", "General"]) { + expect(getByRole("tab", { name })).toBeInTheDocument(); + } + expect(getByTestId("tab-content")).toHaveTextContent("CHILD"); + }); + + it("marks the base route's Loadbalancing tab active", () => { + const { getByRole } = renderLayout(); + expect(getByRole("tab", { name: "Loadbalancing" })).toHaveAttribute("aria-selected", "true"); + expect(getByRole("tab", { name: "General" })).toHaveAttribute("aria-selected", "false"); + }); + + it("navigates to a tab's path when its tab is clicked", async () => { + const { getByRole } = renderLayout(); + await act(async () => { + getByRole("tab", { name: "Prompt Caching" }).click(); + }); + expect(mockPush).toHaveBeenCalledWith(expect.stringMatching(/\/router-settings\/prompt-caching\/$/)); + }); + + it("routes the base tab back to the router-settings root (no slug)", async () => { + navState.pathname = "/router-settings/general"; + const { getByRole } = renderLayout(); + await act(async () => { + getByRole("tab", { name: "Loadbalancing" }).click(); + }); + expect(mockPush).toHaveBeenCalledWith(expect.stringMatching(/\/router-settings\/$/)); + }); + + it("redirects to the base router-settings path when the tab slug is unknown", async () => { + const replaceMock = vi.fn(); + const originalLocation = window.location; + Object.defineProperty(window, "location", { + configurable: true, + value: { replace: replaceMock, assign: vi.fn(), href: "http://localhost/", pathname: "/", search: "" }, + }); + navState.pathname = "/router-settings/bogus"; + await act(async () => { + renderLayout(); + }); + expect(replaceMock).toHaveBeenCalledWith(expect.stringMatching(/\/router-settings\/$/)); + Object.defineProperty(window, "location", { configurable: true, value: originalLocation }); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/layout.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/layout.tsx new file mode 100644 index 00000000000..6f5cfef99d3 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/layout.tsx @@ -0,0 +1,64 @@ +"use client"; + +import type { ReactNode } from "react"; +import { useEffect } from "react"; +import { usePathname, useRouter } from "next/navigation"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + routerSettingsTabHref, + slugFromPathname, + type RouterSettingsTabSlug, +} from "@/app/(dashboard)/router-settings/tabRoutes"; + +const BASE_TAB_KEY = "loadbalancing"; + +const ORDERED_KEYS: Array<"" | RouterSettingsTabSlug> = [ + "", + "routing-groups", + "fallbacks", + "prompt-caching", + "general", +]; + +const TAB_LABELS: Record<"" | RouterSettingsTabSlug, string> = { + "": "Loadbalancing", + "routing-groups": "Routing Groups", + fallbacks: "Fallbacks", + "prompt-caching": "Prompt Caching", + general: "General", +}; + +export default function RouterSettingsLayout({ children }: { children: ReactNode }) { + const pathname = usePathname(); + const router = useRouter(); + + const activeSlug = slugFromPathname(pathname); + const isKnownSlug = ORDERED_KEYS.some((slug) => slug === activeSlug); + const activeKey = isKnownSlug ? activeSlug || BASE_TAB_KEY : BASE_TAB_KEY; + + useEffect(() => { + if (activeSlug !== "" && !isKnownSlug) { + window.location.replace(routerSettingsTabHref("")); + } + }, [activeSlug, isKnownSlug]); + + return ( + + router.push(routerSettingsTabHref(key === BASE_TAB_KEY ? "" : key))} + className="px-8 pt-4" + > + + {ORDERED_KEYS.map((slug) => ( + + {TAB_LABELS[slug]} + + ))} + + + + {children} + + ); +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/page.tsx index 90f41ac58a4..869be8548b3 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/page.tsx @@ -1,9 +1,9 @@ "use client"; -import GeneralSettings from "./_components/general_settings"; +import RouterSettings from "@/components/router_settings"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; export default function RouterSettingsPage() { const { accessToken, userRole, userId } = useAuthorized(); - return ; + return ; } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/prompt-caching/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/prompt-caching/page.tsx new file mode 100644 index 00000000000..1a7d689e085 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/prompt-caching/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { PromptCachingSettingsTab } from "@/app/(dashboard)/router-settings/_components/general_settings"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; + +export default function PromptCachingPage() { + const { accessToken } = useAuthorized(); + if (!accessToken) { + return null; + } + return ; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/routing-groups/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/routing-groups/page.tsx new file mode 100644 index 00000000000..e972c84204f --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/routing-groups/page.tsx @@ -0,0 +1,7 @@ +"use client"; + +import RoutingGroups from "@/components/routing_groups"; + +export default function RoutingGroupsPage() { + return ; +} diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/tabRoutes.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/tabRoutes.test.ts new file mode 100644 index 00000000000..e4d1880c5e4 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/tabRoutes.test.ts @@ -0,0 +1,38 @@ +/* @vitest-environment jsdom */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/components/networking", () => ({ serverRootPath: "" })); + +import { ROUTER_SETTINGS_TAB_SLUGS, routerSettingsTabHref, slugFromPathname } from "./tabRoutes"; + +describe("slugFromPathname", () => { + it("returns empty string for the base path with or without a trailing slash", () => { + expect(slugFromPathname("/router-settings")).toBe(""); + expect(slugFromPathname("/router-settings/")).toBe(""); + }); + + it("extracts the tab slug from dev and proxy-mounted (/ui) paths", () => { + expect(slugFromPathname("/router-settings/fallbacks")).toBe("fallbacks"); + expect(slugFromPathname("/ui/router-settings/prompt-caching/")).toBe("prompt-caching"); + }); + + it("returns the raw segment for an unknown tab so the layout can redirect to base", () => { + expect(slugFromPathname("/ui/router-settings/bogus")).toBe("bogus"); + }); + + it("returns empty string when the router-settings base segment is not in the path", () => { + expect(slugFromPathname("/teams")).toBe(""); + }); +}); + +describe("routerSettingsTabHref", () => { + it("builds the trailing-slash base href for the empty slug", () => { + expect(routerSettingsTabHref("")).toBe("/ui/router-settings/"); + }); + + it("builds a trailing-slash href for every tab slug (required by static export)", () => { + for (const slug of ROUTER_SETTINGS_TAB_SLUGS) { + expect(routerSettingsTabHref(slug)).toBe(`/ui/router-settings/${slug}/`); + } + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/tabRoutes.ts b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/tabRoutes.ts new file mode 100644 index 00000000000..9761d6f43ab --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/tabRoutes.ts @@ -0,0 +1,21 @@ +import { migratedHref } from "@/utils/migratedPages"; + +export const ROUTER_SETTINGS_BASE_SEGMENT = "router-settings"; + +export const ROUTER_SETTINGS_TAB_SLUGS = ["routing-groups", "fallbacks", "prompt-caching", "general"] as const; + +export type RouterSettingsTabSlug = (typeof ROUTER_SETTINGS_TAB_SLUGS)[number]; + +export function routerSettingsTabHref(slug: string): string { + const base = migratedHref(ROUTER_SETTINGS_BASE_SEGMENT); + return slug ? `${base}/${slug}/` : `${base}/`; +} + +export function slugFromPathname(pathname: string): string { + const parts = pathname.split("/").filter(Boolean); + const idx = parts.indexOf(ROUTER_SETTINGS_BASE_SEGMENT); + if (idx === -1) { + return ""; + } + return parts[idx + 1] ?? ""; +}
- {value.field_description} -
+ {value.field_description} +