diff --git a/ui/litellm-dashboard/src/components/common_components/RouterSettingsSummary.test.tsx b/ui/litellm-dashboard/src/components/common_components/RouterSettingsSummary.test.tsx new file mode 100644 index 00000000000..51eecc421a7 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/RouterSettingsSummary.test.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import RouterSettingsSummary from "./RouterSettingsSummary"; + +describe("RouterSettingsSummary", () => { + it("should list each configured fallback mapping", () => { + render( + , + ); + + expect(screen.getByText("gpt-4")).toBeInTheDocument(); + expect(screen.getByText("gpt-4o, claude-sonnet")).toBeInTheDocument(); + expect(screen.getByText("gpt-4o")).toBeInTheDocument(); + expect(screen.getByText("gpt-4o-mini")).toBeInTheDocument(); + expect(screen.getByText("Number of Retries: 3")).toBeInTheDocument(); + }); + + it("should show the empty state when every setting is null", () => { + render(); + + expect(screen.getByText("No router settings configured")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/RouterSettingsSummary.tsx b/ui/litellm-dashboard/src/components/common_components/RouterSettingsSummary.tsx new file mode 100644 index 00000000000..daaddc0cdfb --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/RouterSettingsSummary.tsx @@ -0,0 +1,56 @@ +import { Badge } from "@/components/ui/badge"; +import { hasRouterSettings } from "./routerSettingsPayload"; + +interface RouterSettingsSummaryProps { + routerSettings: Record | null | undefined; + emptyText?: string; +} + +const fallbackEntries = (fallbacks: unknown): Array<[string, string[]]> => { + if (!Array.isArray(fallbacks)) return []; + return fallbacks.flatMap((entry) => + entry && typeof entry === "object" ? (Object.entries(entry) as Array<[string, string[]]>) : [], + ); +}; + +export default function RouterSettingsSummary({ + routerSettings, + emptyText = "No router settings configured", +}: RouterSettingsSummaryProps) { + if (!hasRouterSettings(routerSettings)) { + return
{emptyText}
; + } + + const settings = routerSettings as Record; + const fallbacks = fallbackEntries(settings.fallbacks); + + return ( +
+ {settings.routing_strategy != null && ( +
+ Routing Strategy: {String(settings.routing_strategy)} +
+ )} + {settings.num_retries != null &&
Number of Retries: {String(settings.num_retries)}
} + {settings.allowed_fails != null &&
Allowed Failures: {String(settings.allowed_fails)}
} + {settings.cooldown_time != null &&
Cooldown Time: {String(settings.cooldown_time)}s
} + {settings.timeout != null &&
Timeout: {String(settings.timeout)}s
} + {settings.retry_after != null &&
Retry After: {String(settings.retry_after)}s
} + {Boolean(settings.enable_tag_filtering) &&
Tag Filtering: Enabled
} + {fallbacks.length > 0 && ( +
+
Fallbacks:
+
+ {fallbacks.map(([model, targets]) => ( +
+ {model} + -> + {Array.isArray(targets) ? targets.join(", ") : String(targets)} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/ui/litellm-dashboard/src/components/common_components/routerSettingsPayload.test.ts b/ui/litellm-dashboard/src/components/common_components/routerSettingsPayload.test.ts new file mode 100644 index 00000000000..37d694e5540 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/routerSettingsPayload.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { hasRouterSettings, routerSettingsUpdate } from "./routerSettingsPayload"; + +describe("hasRouterSettings", () => { + it("should treat unset, empty and all-null settings as absent", () => { + expect(hasRouterSettings(undefined)).toBe(false); + expect(hasRouterSettings(null)).toBe(false); + expect(hasRouterSettings({})).toBe(false); + expect( + hasRouterSettings({ num_retries: null, fallbacks: [], model_group_alias: {}, enable_tag_filtering: false }), + ).toBe(false); + }); + + it("should detect configured settings", () => { + expect(hasRouterSettings({ num_retries: 3 })).toBe(true); + expect(hasRouterSettings({ fallbacks: [{ "gpt-4": ["gpt-4o"] }] })).toBe(true); + expect(hasRouterSettings({ enable_tag_filtering: true })).toBe(true); + expect(hasRouterSettings({ num_retries: 0 })).toBe(true); + }); +}); + +describe("routerSettingsUpdate", () => { + const fallbacks = [{ "gpt-4": ["gpt-4o"] }]; + + it("should send the edited settings when the user configured something", () => { + expect(routerSettingsUpdate({ fallbacks }, null)).toEqual({ fallbacks }); + }); + + it("should send the edited settings unchanged so an unrelated edit keeps stored fallbacks", () => { + expect(routerSettingsUpdate({ fallbacks, num_retries: 2 }, { fallbacks, num_retries: 2 })).toEqual({ + fallbacks, + num_retries: 2, + }); + }); + + it("should send cleared settings so removing every fallback reaches the server", () => { + expect(routerSettingsUpdate({ fallbacks: null, num_retries: null }, { fallbacks })).toEqual({ + fallbacks: null, + num_retries: null, + }); + }); + + it("should leave the field off when nothing is stored and nothing was configured", () => { + expect(routerSettingsUpdate({ fallbacks: null, num_retries: null }, {})).toBeUndefined(); + expect(routerSettingsUpdate(undefined, { fallbacks })).toBeUndefined(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/common_components/routerSettingsPayload.ts b/ui/litellm-dashboard/src/components/common_components/routerSettingsPayload.ts new file mode 100644 index 00000000000..7caec880449 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/routerSettingsPayload.ts @@ -0,0 +1,28 @@ +import { RouterSettingsAccordionValue } from "./RouterSettingsAccordion"; + +export type RouterSettings = RouterSettingsAccordionValue["router_settings"]; + +const isMeaningfulRouterSetting = (value: unknown): boolean => { + if (value === null || value === undefined || value === "" || value === false) return false; + if (Array.isArray(value)) return value.length > 0; + if (typeof value === "object") return Object.keys(value).length > 0; + return true; +}; + +export const hasRouterSettings = (settings: Record | null | undefined): boolean => + settings != null && Object.values(settings).some(isMeaningfulRouterSetting); + +/** + * Router settings to put on a /key/update payload, or undefined to leave the field off. + * Clearing every field must reach the server, while a key that never had router settings + * must not start sending an all-null object, so the value is only sent when the editor + * holds something or there are stored settings to overwrite. + */ +export const routerSettingsUpdate = ( + edited: RouterSettings | null | undefined, + stored: Record | null | undefined, +): RouterSettings | undefined => { + if (!edited) return undefined; + const editedRecord: Record = edited; + return hasRouterSettings(editedRecord) || hasRouterSettings(stored) ? edited : undefined; +}; diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx index 920d1f4af5a..95c5ddbb3c6 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx +++ b/ui/litellm-dashboard/src/components/key_team_helpers/key_list.tsx @@ -95,6 +95,7 @@ export interface KeyResponse { object_permission?: ObjectPermission | null; access_group_ids?: string[]; budget_fallbacks?: Record; + router_settings?: Record | null; budget_limits?: Array<{ budget_duration: string; max_budget: number; reset_at?: string }>; auto_rotate?: boolean; rotation_interval?: string; diff --git a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx index fb8ab87e45f..93baf31aac8 100644 --- a/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_edit_view.test.tsx @@ -59,6 +59,24 @@ vi.mock("../organisms/create_key_button", () => ({ fetchTeamModels: vi.fn().mockResolvedValue(["team-model-1", "team-model-2"]), })); +const routerSettingsMocks = vi.hoisted(() => ({ + receivedValue: undefined as { router_settings: Record } | undefined, + editedValue: null as Record | null, +})); + +vi.mock("../common_components/RouterSettingsAccordion", async () => { + const { forwardRef, useImperativeHandle } = await import("react"); + return { + default: forwardRef(({ value }: { value?: { router_settings: Record } }, ref) => { + routerSettingsMocks.receivedValue = value; + useImperativeHandle(ref, () => ({ + getValue: () => ({ router_settings: routerSettingsMocks.editedValue ?? value?.router_settings ?? {} }), + })); + return
; + }), + }; +}); + vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ useOrganizations: vi.fn().mockReturnValue({ data: [ @@ -160,6 +178,69 @@ describe("KeyEditView", () => { last_rotation_at: undefined, key_rotation_at: undefined, }; + describe("router settings", () => { + const STORED_ROUTER_SETTINGS = { + num_retries: 2, + fallbacks: [{ "gpt-4": ["gpt-4o"] }], + }; + + const renderWithRouterSettings = (onSubmit: (values: Record) => Promise) => + renderWithProviders( + {}} + onSubmit={onSubmit} + accessToken="test-token" + userID="test-user" + userRole="proxy_admin" + premiumUser={true} + />, + ); + + beforeEach(() => { + routerSettingsMocks.receivedValue = undefined; + routerSettingsMocks.editedValue = null; + }); + + it("should load the key's stored router settings into the editor", async () => { + renderWithRouterSettings(async () => {}); + + await waitFor(() => { + expect(routerSettingsMocks.receivedValue).toEqual({ router_settings: STORED_ROUTER_SETTINGS }); + }); + }); + + it("should submit edited fallbacks", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + renderWithRouterSettings(onSubmit); + routerSettingsMocks.editedValue = { num_retries: 2, fallbacks: [{ "gpt-4": ["gpt-4o", "gpt-4o-mini"] }] }; + + fireEvent.click(screen.getByText("Save Changes")); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + router_settings: { num_retries: 2, fallbacks: [{ "gpt-4": ["gpt-4o", "gpt-4o-mini"] }] }, + }), + ); + }); + }); + + it("should submit cleared router settings so removing every fallback is persisted", async () => { + const onSubmit = vi.fn().mockResolvedValue(undefined); + renderWithRouterSettings(onSubmit); + routerSettingsMocks.editedValue = { num_retries: null, fallbacks: null }; + + fireEvent.click(screen.getByText("Save Changes")); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ router_settings: { num_retries: null, fallbacks: null } }), + ); + }); + }); + }); + it("should render", async () => { const { getByText } = renderWithProviders( >( keyData.budget_fallbacks && typeof keyData.budget_fallbacks === "object" ? keyData.budget_fallbacks : {}, ); + const routerSettingsRef = useRef(null); const { data: organizations, isLoading: isOrganizationsLoading } = useOrganizations(); const { data: projects } = useProjects(); const { data: uiSettingsData } = useUISettings(); @@ -286,6 +289,14 @@ export function KeyEditView({ values.budget_fallbacks = {}; } + const routerSettings = routerSettingsUpdate( + routerSettingsRef.current?.getValue()?.router_settings, + keyData.router_settings, + ); + if (routerSettings) { + values.router_settings = routerSettings; + } + await onSubmit(withNormalizedEstimates(values)); } finally { setIsKeySaving(false); @@ -799,6 +810,15 @@ export function KeyEditView({ )} + + + + { }); }); + it("should render the key's saved router fallbacks", async () => { + vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock); + + renderWithProviders( + {}} + keyId={"test-key-id"} + onKeyDataUpdate={() => {}} + teams={[]} + />, + ); + + expect(await screen.findByText("Router Settings")).toBeInTheDocument(); + expect(screen.getByText("gpt-4")).toBeInTheDocument(); + expect(screen.getByText("gpt-4o")).toBeInTheDocument(); + expect(screen.getByText("Number of Retries: 2")).toBeInTheDocument(); + }); + it("should render tags", async () => { vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock); diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index 6fd6547a995..f7720874968 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -13,6 +13,8 @@ import { isProxyAdminRole, isUserTeamAdminForSingleTeam, rolesWithWriteAccess } import { mapDisplayToInternalNames, mapInternalToDisplayNames } from "../callback_info_helpers"; import AutoRotationView from "../common_components/AutoRotationView"; import DeleteResourceModal from "../common_components/DeleteResourceModal"; +import RouterSettingsSummary from "../common_components/RouterSettingsSummary"; +import { hasRouterSettings } from "../common_components/routerSettingsPayload"; import { extractLoggingSettings, formatMetadataForDisplay, stripTagsFromMetadata } from "../key_info_utils"; import { KeyResponse } from "../key_team_helpers/key_list"; import LoggingSettingsView from "../logging_settings_view"; @@ -837,6 +839,15 @@ export default function KeyInfoView({
)} + {hasRouterSettings(currentKeyData.router_settings) && ( +
+ Router Settings +
+ +
+
+ )} +
Tags