mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
fix(ui): allow internal users/team admins to select guardrails when creating keys (#22816)
* fix(proxy): add guardrails list routes for internal users * fix(ui): add guardrails fetch with v1/v2 fallback in networking * fix(ui): allow internal users/team admins to select guardrails in create key modal * fix(ui): show guardrails selector for internal users in key edit view * fix(ui): pass canEditGuardrails flag to key info view * test(ui): add tests for role-based guardrails access in key info view * test(ui): update key edit view test for guardrails
This commit is contained in:
parent
028dd3fddc
commit
09e1a06f47
7 changed files with 99 additions and 23 deletions
|
|
@ -622,6 +622,8 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/global/activity/model",
|
||||
"/v1/models/{model_id}",
|
||||
"/models/{model_id}",
|
||||
"/guardrails/list",
|
||||
"/v2/guardrails/list",
|
||||
]
|
||||
+ spend_tracking_routes
|
||||
+ key_management_routes
|
||||
|
|
|
|||
|
|
@ -5460,8 +5460,8 @@ export const testMCPSemanticFilter = async (accessToken: string, model: string,
|
|||
|
||||
export const getGuardrailsList = async (accessToken: string) => {
|
||||
try {
|
||||
const url = proxyBaseUrl ? `${proxyBaseUrl}/v2/guardrails/list` : `/v2/guardrails/list`;
|
||||
const response = await fetch(url, {
|
||||
const v2Url = proxyBaseUrl ? `${proxyBaseUrl}/v2/guardrails/list` : `/v2/guardrails/list`;
|
||||
const response = await fetch(v2Url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
|
|
@ -5470,17 +5470,35 @@ export const getGuardrailsList = async (accessToken: string) => {
|
|||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
const errorMessage = deriveErrorMessage(errorData);
|
||||
handleError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
throw new Error(`v2 guardrails/list returned ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error("Failed to get guardrails list:", error);
|
||||
throw error;
|
||||
console.log("v2/guardrails/list failed, falling back to v1:", error);
|
||||
try {
|
||||
const v1Url = proxyBaseUrl ? `${proxyBaseUrl}/guardrails/list` : `/guardrails/list`;
|
||||
const fallbackResponse = await fetch(v1Url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!fallbackResponse.ok) {
|
||||
const errorData = await fallbackResponse.json();
|
||||
const errorMessage = deriveErrorMessage(errorData);
|
||||
handleError(errorMessage);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return await fallbackResponse.json();
|
||||
} catch (fallbackError) {
|
||||
console.error("Failed to get guardrails list:", fallbackError);
|
||||
throw fallbackError;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -146,6 +146,7 @@ export const fetchUserModels = async (
|
|||
*/
|
||||
const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
|
||||
const { accessToken, userId: userID, userRole, premiumUser } = useAuthorized();
|
||||
const canEditGuardrails = premiumUser || (userRole != null && rolesWithWriteAccess.includes(userRole));
|
||||
const { data: projects, isLoading: isProjectsLoading } = useProjects();
|
||||
const { data: uiSettingsData } = useUISettings();
|
||||
const enableProjectsUI = Boolean(uiSettingsData?.values?.enable_projects_ui);
|
||||
|
|
@ -1000,7 +1001,7 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
|
|||
name="guardrails"
|
||||
className="mt-4"
|
||||
help={
|
||||
premiumUser
|
||||
canEditGuardrails
|
||||
? "Select existing guardrails or enter new ones"
|
||||
: "Premium feature - Upgrade to set guardrails by key"
|
||||
}
|
||||
|
|
@ -1008,9 +1009,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
|
|||
<Select
|
||||
mode="tags"
|
||||
style={{ width: "100%" }}
|
||||
disabled={!premiumUser}
|
||||
disabled={!canEditGuardrails}
|
||||
placeholder={
|
||||
!premiumUser
|
||||
!canEditGuardrails
|
||||
? "Premium feature - Upgrade to set guardrails by key"
|
||||
: "Select or enter guardrails"
|
||||
}
|
||||
|
|
@ -1037,12 +1038,12 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey }) => {
|
|||
className="mt-4"
|
||||
valuePropName="checked"
|
||||
help={
|
||||
premiumUser
|
||||
canEditGuardrails
|
||||
? "Bypass global guardrails for this key"
|
||||
: "Premium feature - Upgrade to disable global guardrails by key"
|
||||
}
|
||||
>
|
||||
<Switch disabled={!premiumUser} checkedChildren="Yes" unCheckedChildren="No" />
|
||||
<Switch disabled={!canEditGuardrails} checkedChildren="Yes" unCheckedChildren="No" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
|
|
|
|||
|
|
@ -259,6 +259,14 @@ vi.mock("@/app/(dashboard)/hooks/uiSettings/useUISettings", () => ({
|
|||
useUISettings: vi.fn().mockReturnValue({ data: { values: {} }, isLoading: false }),
|
||||
}));
|
||||
|
||||
// Mock useResetKeySpend hook (requires QueryClientProvider which is not available in this test)
|
||||
vi.mock("@/app/(dashboard)/hooks/keys/useResetKeySpend", () => ({
|
||||
useResetKeySpend: vi.fn().mockReturnValue({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
// KeyEditView mock: triggers onSubmit with our injected form values
|
||||
vi.mock("./key_edit_view", async () => {
|
||||
const React = await import("react");
|
||||
|
|
@ -334,9 +342,29 @@ beforeEach(() => {
|
|||
});
|
||||
|
||||
// ---- Tests ----
|
||||
describe("KeyInfoView handleKeyUpdate premium guard", () => {
|
||||
it("removes guardrails & prompts for non-premium users and prevents metadata.guardrails", async () => {
|
||||
renderView(false); // premiumUser = false
|
||||
describe("KeyInfoView handleKeyUpdate guardrails guard", () => {
|
||||
it("should remove guardrails & prompts for non-premium key owner without write access role", async () => {
|
||||
const keyDataWithOwner = { ...baseKeyData, user_id: "user_1" };
|
||||
mockUseAuthorized.mockReturnValue({
|
||||
accessToken: "access_abc",
|
||||
userId: "user_1",
|
||||
userRole: "viewer",
|
||||
premiumUser: false,
|
||||
token: "token_123",
|
||||
userEmail: "test@example.com",
|
||||
disabledPersonalKeyCreation: false,
|
||||
showSSOBanner: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<KeyInfoView
|
||||
keyId="tok_123"
|
||||
onClose={() => {}}
|
||||
keyData={keyDataWithOwner as any}
|
||||
onKeyDataUpdate={() => {}}
|
||||
teams={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText("Settings"));
|
||||
fireEvent.click(screen.getByText("Edit Settings"));
|
||||
|
|
@ -344,7 +372,7 @@ describe("KeyInfoView handleKeyUpdate premium guard", () => {
|
|||
token: "tok_123",
|
||||
guardrails: ["gr-1", "gr-2"],
|
||||
prompts: ["fast", "safe"],
|
||||
metadata: {}, // object form (not JSON string)
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
fireEvent.click(screen.getByText("Mock Submit"));
|
||||
|
|
@ -360,7 +388,31 @@ describe("KeyInfoView handleKeyUpdate premium guard", () => {
|
|||
expect(sentPayload.key).toBe("tok_123");
|
||||
});
|
||||
|
||||
it("preserves guardrails & prompts for premium users and includes metadata.guardrails", async () => {
|
||||
it("should preserve guardrails & prompts for non-premium users with write access role (e.g. Admin)", async () => {
|
||||
renderView(false); // premiumUser = false, userRole = "Admin"
|
||||
|
||||
fireEvent.click(screen.getByText("Settings"));
|
||||
fireEvent.click(screen.getByText("Edit Settings"));
|
||||
(globalThis as any).__TEST_FORM_VALUES = {
|
||||
token: "tok_123",
|
||||
guardrails: ["gr-1"],
|
||||
prompts: ["fast"],
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
fireEvent.click(screen.getByText("Mock Submit"));
|
||||
|
||||
await waitFor(() => expect(keyUpdateCallMock).toHaveBeenCalled());
|
||||
|
||||
const [, sentPayload] = keyUpdateCallMock.mock.calls[0];
|
||||
|
||||
expect(sentPayload.guardrails).toEqual(["gr-1"]);
|
||||
expect(sentPayload.prompts).toEqual(["fast"]);
|
||||
expect(sentPayload.metadata?.guardrails).toEqual(["gr-1"]);
|
||||
expect(sentPayload.key).toBe("tok_123");
|
||||
});
|
||||
|
||||
it("should preserve guardrails & prompts for premium users and includes metadata.guardrails", async () => {
|
||||
renderView(true); // premiumUser = true
|
||||
|
||||
fireEvent.click(screen.getByText("Settings"));
|
||||
|
|
|
|||
|
|
@ -374,7 +374,7 @@ describe("KeyEditView", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("should disable guardrails selector when user is not premium", async () => {
|
||||
it("should disable guardrails selector when user is not premium and has no write access role", async () => {
|
||||
renderWithProviders(
|
||||
<KeyEditView
|
||||
keyData={MOCK_KEY_DATA}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ 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 { rolesWithWriteAccess } from "../../utils/roles";
|
||||
import AgentSelector from "../agent_management/AgentSelector";
|
||||
import AccessGroupSelector from "../common_components/AccessGroupSelector";
|
||||
import { mapInternalToDisplayNames } from "../callback_info_helpers";
|
||||
|
|
@ -84,6 +85,7 @@ export function KeyEditView({
|
|||
userRole,
|
||||
premiumUser = false,
|
||||
}: KeyEditViewProps) {
|
||||
const canEditGuardrails = premiumUser || (userRole != null && rolesWithWriteAccess.includes(userRole));
|
||||
const [form] = Form.useForm();
|
||||
const [promptsList, setPromptsList] = useState<string[]>([]);
|
||||
const [tagsList, setTagsList] = useState<Record<string, Tag>>({});
|
||||
|
|
@ -443,7 +445,7 @@ export function KeyEditView({
|
|||
form.setFieldValue("guardrails", v);
|
||||
}}
|
||||
accessToken={accessToken}
|
||||
disabled={!premiumUser}
|
||||
disabled={!canEditGuardrails}
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
|
@ -460,7 +462,7 @@ export function KeyEditView({
|
|||
name="disable_global_guardrails"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch disabled={!premiumUser} checkedChildren="Yes" unCheckedChildren="No" />
|
||||
<Switch disabled={!canEditGuardrails} checkedChildren="Yes" unCheckedChildren="No" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { Badge, Button, Card, Grid, Tab, TabGroup, TabList, TabPanel, TabPanels,
|
|||
import { Form, Modal, Tag } from "antd";
|
||||
import { KeyInfoHeader } from "./KeyInfoHeader";
|
||||
import { useEffect, useState } from "react";
|
||||
import { isProxyAdminRole, isUserTeamAdminForSingleTeam } from "../../utils/roles";
|
||||
import { isProxyAdminRole, isUserTeamAdminForSingleTeam, rolesWithWriteAccess } from "../../utils/roles";
|
||||
import { mapDisplayToInternalNames, mapInternalToDisplayNames } from "../callback_info_helpers";
|
||||
import AutoRotationView from "../common_components/AutoRotationView";
|
||||
import DeleteResourceModal from "../common_components/DeleteResourceModal";
|
||||
|
|
@ -50,6 +50,7 @@ export default function KeyInfoView({
|
|||
backButtonText = "Back to Keys",
|
||||
}: KeyInfoViewProps) {
|
||||
const { accessToken, userId: userID, userRole, premiumUser } = useAuthorized();
|
||||
const canEditGuardrails = premiumUser || (userRole != null && rolesWithWriteAccess.includes(userRole));
|
||||
const { teams: teamsData } = useTeams();
|
||||
const { data: projects } = useProjects();
|
||||
const { data: uiSettingsData } = useUISettings();
|
||||
|
|
@ -140,7 +141,7 @@ export default function KeyInfoView({
|
|||
formValues.key = currentKey;
|
||||
|
||||
// Guard premium features
|
||||
if (!premiumUser) {
|
||||
if (!canEditGuardrails) {
|
||||
delete formValues.guardrails;
|
||||
delete formValues.prompts;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue