mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-12 23:01:41 +00:00
fix(ui): show and edit key-level router settings on a virtual key
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
7e80e094c4
commit
b44b9635e3
9 changed files with 292 additions and 1 deletions
|
|
@ -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(
|
||||
<RouterSettingsSummary
|
||||
routerSettings={{
|
||||
fallbacks: [{ "gpt-4": ["gpt-4o", "claude-sonnet"] }, { "gpt-4o": ["gpt-4o-mini"] }],
|
||||
num_retries: 3,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(<RouterSettingsSummary routerSettings={{ fallbacks: null, num_retries: null }} />);
|
||||
|
||||
expect(screen.getByText("No router settings configured")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
import { Badge } from "@/components/ui/badge";
|
||||
import { hasRouterSettings } from "./routerSettingsPayload";
|
||||
|
||||
interface RouterSettingsSummaryProps {
|
||||
routerSettings: Record<string, unknown> | 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 <div className="text-gray-400">{emptyText}</div>;
|
||||
}
|
||||
|
||||
const settings = routerSettings as Record<string, unknown>;
|
||||
const fallbacks = fallbackEntries(settings.fallbacks);
|
||||
|
||||
return (
|
||||
<div className="space-y-1 text-sm">
|
||||
{settings.routing_strategy != null && (
|
||||
<div>
|
||||
Routing Strategy: <Badge variant="secondary">{String(settings.routing_strategy)}</Badge>
|
||||
</div>
|
||||
)}
|
||||
{settings.num_retries != null && <div>Number of Retries: {String(settings.num_retries)}</div>}
|
||||
{settings.allowed_fails != null && <div>Allowed Failures: {String(settings.allowed_fails)}</div>}
|
||||
{settings.cooldown_time != null && <div>Cooldown Time: {String(settings.cooldown_time)}s</div>}
|
||||
{settings.timeout != null && <div>Timeout: {String(settings.timeout)}s</div>}
|
||||
{settings.retry_after != null && <div>Retry After: {String(settings.retry_after)}s</div>}
|
||||
{Boolean(settings.enable_tag_filtering) && <div>Tag Filtering: Enabled</div>}
|
||||
{fallbacks.length > 0 && (
|
||||
<div>
|
||||
<div>Fallbacks:</div>
|
||||
<div className="mt-1 space-y-1">
|
||||
{fallbacks.map(([model, targets]) => (
|
||||
<div key={model} className="text-xs text-gray-600">
|
||||
<span className="font-medium">{model}</span>
|
||||
<span className="mx-1 text-gray-400">-></span>
|
||||
{Array.isArray(targets) ? targets.join(", ") : String(targets)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, unknown> | 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<string, unknown> | null | undefined,
|
||||
): RouterSettings | undefined => {
|
||||
if (!edited) return undefined;
|
||||
const editedRecord: Record<string, unknown> = edited;
|
||||
return hasRouterSettings(editedRecord) || hasRouterSettings(stored) ? edited : undefined;
|
||||
};
|
||||
|
|
@ -95,6 +95,7 @@ export interface KeyResponse {
|
|||
object_permission?: ObjectPermission | null;
|
||||
access_group_ids?: string[];
|
||||
budget_fallbacks?: Record<string, string[]>;
|
||||
router_settings?: Record<string, any> | null;
|
||||
budget_limits?: Array<{ budget_duration: string; max_budget: number; reset_at?: string }>;
|
||||
auto_rotate?: boolean;
|
||||
rotation_interval?: string;
|
||||
|
|
|
|||
|
|
@ -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<string, unknown> } | undefined,
|
||||
editedValue: null as Record<string, unknown> | null,
|
||||
}));
|
||||
|
||||
vi.mock("../common_components/RouterSettingsAccordion", async () => {
|
||||
const { forwardRef, useImperativeHandle } = await import("react");
|
||||
return {
|
||||
default: forwardRef(({ value }: { value?: { router_settings: Record<string, unknown> } }, ref) => {
|
||||
routerSettingsMocks.receivedValue = value;
|
||||
useImperativeHandle(ref, () => ({
|
||||
getValue: () => ({ router_settings: routerSettingsMocks.editedValue ?? value?.router_settings ?? {} }),
|
||||
}));
|
||||
return <div data-testid="router-settings-accordion" />;
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
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<string, any>) => Promise<void>) =>
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={{ ...MOCK_KEY_DATA, router_settings: STORED_ROUTER_SETTINGS }}
|
||||
onCancel={() => {}}
|
||||
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(
|
||||
<KeyEditView
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import PolicySelector from "@/components/policies/PolicySelector";
|
|||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { TextInput, Button as TremorButton } from "@tremor/react";
|
||||
import { Form, Input, Select, Switch, Tooltip } from "antd";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { hasCapability } from "../../utils/capabilities";
|
||||
import { isProxyAdminRole, rolesWithWriteAccess } from "../../utils/roles";
|
||||
import AgentSelector from "../agent_management/AgentSelector";
|
||||
|
|
@ -17,6 +17,8 @@ import KeyLifecycleSettings from "../common_components/KeyLifecycleSettings";
|
|||
import PassThroughRoutesSelector from "../common_components/PassThroughRoutesSelector";
|
||||
import RateLimitTypeFormItem from "../common_components/RateLimitTypeFormItem";
|
||||
import OrganizationDropdown from "../common_components/OrganizationDropdown";
|
||||
import RouterSettingsAccordion, { RouterSettingsAccordionRef } from "../common_components/RouterSettingsAccordion";
|
||||
import { routerSettingsUpdate } from "../common_components/routerSettingsPayload";
|
||||
import { extractLoggingSettings, formatMetadataForDisplay, stripTagsFromMetadata } from "../key_info_utils";
|
||||
import { estimateFields, estimateRules, estimateTooltips, withNormalizedEstimates } from "./estimatedOutputTokens";
|
||||
import { canonicalBudgetDuration, keyTypeFromRoutes } from "./keyEditFieldNormalizers";
|
||||
|
|
@ -91,6 +93,7 @@ export function KeyEditView({
|
|||
const [budgetFallbacks, setBudgetFallbacks] = useState<Record<string, string[]>>(
|
||||
keyData.budget_fallbacks && typeof keyData.budget_fallbacks === "object" ? keyData.budget_fallbacks : {},
|
||||
);
|
||||
const routerSettingsRef = useRef<RouterSettingsAccordionRef>(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({
|
|||
<Input value={projectDisplay ?? ""} disabled />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item label="Router Settings">
|
||||
<RouterSettingsAccordion
|
||||
ref={routerSettingsRef}
|
||||
accessToken={accessToken || ""}
|
||||
teamId={keyData.team_id}
|
||||
value={keyData.router_settings ? { router_settings: keyData.router_settings } : undefined}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Logging Settings" name="logging_settings">
|
||||
<EditLoggingSettings
|
||||
value={form.getFieldValue("logging_settings")}
|
||||
|
|
|
|||
|
|
@ -189,6 +189,25 @@ describe("KeyInfoView", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("should render the key's saved router fallbacks", async () => {
|
||||
vi.mocked(useAuthorized).mockReturnValue(baseUseAuthorizedMock);
|
||||
|
||||
renderWithProviders(
|
||||
<KeyInfoView
|
||||
keyData={{ ...MOCK_KEY_DATA, router_settings: { num_retries: 2, fallbacks: [{ "gpt-4": ["gpt-4o"] }] } }}
|
||||
onClose={() => {}}
|
||||
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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{hasRouterSettings(currentKeyData.router_settings) && (
|
||||
<div>
|
||||
<Text className="font-medium">Router Settings</Text>
|
||||
<div className="mt-1">
|
||||
<RouterSettingsSummary routerSettings={currentKeyData.router_settings} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Tags</Text>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue