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:
yassin 2026-08-12 05:26:22 +00:00
parent 7e80e094c4
commit b44b9635e3
9 changed files with 292 additions and 1 deletions

View file

@ -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();
});
});

View file

@ -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">-&gt;</span>
{Array.isArray(targets) ? targets.join(", ") : String(targets)}
</div>
))}
</div>
</div>
)}
</div>
);
}

View file

@ -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();
});
});

View file

@ -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;
};

View file

@ -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;

View file

@ -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

View file

@ -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")}

View file

@ -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);

View file

@ -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">