mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
fixes issue where empty premium fields were blocking key edit
This commit is contained in:
parent
10d6d72ae3
commit
aca8ae7962
7 changed files with 363 additions and 11 deletions
|
|
@ -43,7 +43,7 @@ def _set_object_metadata_field(
|
|||
value: Value to set for the field
|
||||
"""
|
||||
if field_name in LiteLLM_ManagementEndpoint_MetadataFields_Premium:
|
||||
_premium_user_check()
|
||||
_premium_user_check(field_name)
|
||||
object_data.metadata = object_data.metadata or {}
|
||||
object_data.metadata[field_name] = value
|
||||
|
||||
|
|
|
|||
|
|
@ -903,7 +903,7 @@ def prepare_metadata_fields(
|
|||
if k in LiteLLM_ManagementEndpoint_MetadataFields_Premium:
|
||||
from litellm.proxy.utils import _premium_user_check
|
||||
|
||||
_premium_user_check()
|
||||
_premium_user_check(k)
|
||||
casted_metadata[k] = v
|
||||
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -3571,17 +3571,22 @@ def handle_exception_on_proxy(e: Exception) -> ProxyException:
|
|||
)
|
||||
|
||||
|
||||
def _premium_user_check():
|
||||
def _premium_user_check(feature:str=None):
|
||||
"""
|
||||
Raises an HTTPException if the user is not a premium user
|
||||
"""
|
||||
from litellm.proxy.proxy_server import premium_user
|
||||
|
||||
if feature:
|
||||
detail_msg = f"This feature is only available for LiteLLM Enterprise users: {feature}. {CommonProxyErrors.not_premium_user.value}"
|
||||
else:
|
||||
detail_msg = f"This feature is only available for LiteLLM Enterprise users. {CommonProxyErrors.not_premium_user.value}"
|
||||
|
||||
if not premium_user:
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={
|
||||
"error": f"This feature is only available for LiteLLM Enterprise users. {CommonProxyErrors.not_premium_user.value}"
|
||||
"error": detail_msg
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -9,13 +9,15 @@ interface GuardrailSelectorProps {
|
|||
value?: string[];
|
||||
className?: string;
|
||||
accessToken: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const GuardrailSelector: React.FC<GuardrailSelectorProps> = ({
|
||||
onChange,
|
||||
value,
|
||||
className,
|
||||
accessToken
|
||||
accessToken,
|
||||
disabled
|
||||
}) => {
|
||||
const [guardrails, setGuardrails] = useState<Guardrail[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
|
@ -51,7 +53,8 @@ const GuardrailSelector: React.FC<GuardrailSelectorProps> = ({
|
|||
<div>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="Select guardrails"
|
||||
disabled={disabled}
|
||||
placeholder={disabled ? "Setting guardrails is a premium feature." : "Select guardrails"}
|
||||
onChange={handleGuardrailChange}
|
||||
value={value}
|
||||
loading={loading}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,338 @@
|
|||
// KeyInfoView.premium-guard.test.tsx
|
||||
import React from "react"
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest"
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
|
||||
|
||||
// ---- Hoisted shared mocks (safe to use inside vi.mock factories) ----
|
||||
const { keyUpdateCallMock, keyDeleteCallMock } = vi.hoisted(() => {
|
||||
return {
|
||||
keyUpdateCallMock: vi.fn().mockResolvedValue({}),
|
||||
keyDeleteCallMock: vi.fn().mockResolvedValue({}),
|
||||
}
|
||||
})
|
||||
|
||||
// ---- Module mocks ----
|
||||
|
||||
// Networking: wire the hoisted fns so we can assert calls later
|
||||
vi.mock("../networking", () => {
|
||||
return {
|
||||
keyUpdateCall: (...args: any[]) => keyUpdateCallMock(...args),
|
||||
keyDeleteCall: (...args: any[]) => keyDeleteCallMock(...args),
|
||||
}
|
||||
})
|
||||
|
||||
// Notifications
|
||||
vi.mock("../molecules/notifications_manager", () => {
|
||||
const Notifications = {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
fromBackend: vi.fn(),
|
||||
}
|
||||
return { default: Notifications }
|
||||
})
|
||||
|
||||
// Roles: ensure 'admin' has write access
|
||||
vi.mock("../../utils/roles", () => ({
|
||||
rolesWithWriteAccess: ["admin"],
|
||||
}))
|
||||
|
||||
// Helpers used in rendering
|
||||
vi.mock("@/utils/dataUtils", () => ({
|
||||
copyToClipboard: async () => true,
|
||||
formatNumberWithCommas: (n: any) => String(n),
|
||||
}))
|
||||
vi.mock("../key_info_utils", () => ({
|
||||
extractLoggingSettings: () => ({}),
|
||||
formatMetadataForDisplay: (m: any) => JSON.stringify(m, null, 2),
|
||||
}))
|
||||
vi.mock("../callback_info_helpers", () => ({
|
||||
callback_map: {},
|
||||
mapInternalToDisplayNames: (x: any) => x,
|
||||
mapDisplayToInternalNames: (x: any) => x,
|
||||
}))
|
||||
vi.mock("../shared/errorUtils", () => ({
|
||||
parseErrorMessage: (e: any) => String(e),
|
||||
}))
|
||||
|
||||
// Tremor components -> async factory, local React import, and named passthroughs
|
||||
vi.mock("@tremor/react", async () => {
|
||||
const React = await import("react")
|
||||
|
||||
const makeNamedPassthrough = (tag: any, name: string) => {
|
||||
function Named(props: any) {
|
||||
const { children, ...rest } = props
|
||||
return React.createElement(tag, rest, children)
|
||||
}
|
||||
;(Named as any).displayName = name
|
||||
return Named
|
||||
}
|
||||
|
||||
const Card = makeNamedPassthrough("div", "Card")
|
||||
const Text = makeNamedPassthrough("span", "Text")
|
||||
const Grid = makeNamedPassthrough("div", "Grid")
|
||||
const Col = makeNamedPassthrough("div", "Col")
|
||||
const TabGroup = makeNamedPassthrough("div", "TabGroup")
|
||||
const TabList = makeNamedPassthrough("div", "TabList")
|
||||
const TabPanels = makeNamedPassthrough("div", "TabPanels")
|
||||
const TabPanel = makeNamedPassthrough("div", "TabPanel")
|
||||
const Title = makeNamedPassthrough("h1", "Title")
|
||||
const Badge = makeNamedPassthrough("span", "Badge")
|
||||
|
||||
function Button(props: any) {
|
||||
const { children, onClick, ...rest } = props
|
||||
return React.createElement("button", { onClick, ...rest }, children)
|
||||
}
|
||||
;(Button as any).displayName = "Button"
|
||||
|
||||
function Tab(props: any) {
|
||||
const { children, ...rest } = props
|
||||
return React.createElement("button", { ...rest }, children)
|
||||
}
|
||||
;(Tab as any).displayName = "Tab"
|
||||
|
||||
function TextInput(props: any) {
|
||||
return React.createElement("input", { ...props })
|
||||
}
|
||||
;(TextInput as any).displayName = "TextInput"
|
||||
|
||||
function TremorSelect(props: any) {
|
||||
return React.createElement("select", { ...props })
|
||||
}
|
||||
;(TremorSelect as any).displayName = "TremorSelect"
|
||||
|
||||
return {
|
||||
Card,
|
||||
Text,
|
||||
Button,
|
||||
Grid,
|
||||
Col,
|
||||
Tab,
|
||||
TabList,
|
||||
TabGroup,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Title,
|
||||
Badge,
|
||||
TextInput,
|
||||
Select: TremorSelect,
|
||||
}
|
||||
})
|
||||
|
||||
// antd bits -> async factory & local React
|
||||
vi.mock("antd", async () => {
|
||||
const React = await import("react")
|
||||
|
||||
const Form = { useForm: () => [{}] }
|
||||
|
||||
function Input(props: any) {
|
||||
return React.createElement("input", { ...props })
|
||||
}
|
||||
;(Input as any).displayName = "AntdInput"
|
||||
|
||||
function InputNumber(props: any) {
|
||||
return React.createElement("input", { ...props })
|
||||
}
|
||||
;(InputNumber as any).displayName = "AntdInputNumber"
|
||||
|
||||
function Select(props: any) {
|
||||
return React.createElement("select", { ...props })
|
||||
}
|
||||
;(Select as any).displayName = "AntdSelect"
|
||||
|
||||
function Tooltip({ children }: any) {
|
||||
return React.createElement(React.Fragment, null, children)
|
||||
}
|
||||
;(Tooltip as any).displayName = "AntdTooltip"
|
||||
|
||||
function Button(props: any) {
|
||||
const { children, onClick, ...rest } = props
|
||||
return React.createElement("button", { onClick, ...rest }, children)
|
||||
}
|
||||
;(Button as any).displayName = "AntdButton"
|
||||
|
||||
return { Form, Input, InputNumber, Select, Tooltip, Button }
|
||||
})
|
||||
|
||||
// Icons -> async factory & local React
|
||||
vi.mock("@heroicons/react/outline", async () => {
|
||||
const React = await import("react")
|
||||
function ArrowLeftIcon() {
|
||||
return React.createElement("span")
|
||||
}
|
||||
;(ArrowLeftIcon as any).displayName = "ArrowLeftIcon"
|
||||
function TrashIcon() {
|
||||
return React.createElement("span")
|
||||
}
|
||||
;(TrashIcon as any).displayName = "TrashIcon"
|
||||
function RefreshIcon() {
|
||||
return React.createElement("span")
|
||||
}
|
||||
;(RefreshIcon as any).displayName = "RefreshIcon"
|
||||
return { ArrowLeftIcon, TrashIcon, RefreshIcon }
|
||||
})
|
||||
|
||||
vi.mock("lucide-react", async () => {
|
||||
const React = await import("react")
|
||||
function CopyIcon() {
|
||||
return React.createElement("span")
|
||||
}
|
||||
;(CopyIcon as any).displayName = "CopyIcon"
|
||||
function CheckIcon() {
|
||||
return React.createElement("span")
|
||||
}
|
||||
;(CheckIcon as any).displayName = "CheckIcon"
|
||||
return { CopyIcon, CheckIcon }
|
||||
})
|
||||
|
||||
// Heavy children -> async factories & local React
|
||||
vi.mock("../organisms/regenerate_key_modal", async () => {
|
||||
const React = await import("react")
|
||||
function RegenerateKeyModal() {
|
||||
return null
|
||||
}
|
||||
;(RegenerateKeyModal as any).displayName = "RegenerateKeyModal"
|
||||
return { RegenerateKeyModal }
|
||||
})
|
||||
vi.mock("../object_permissions_view", async () => {
|
||||
const React = await import("react")
|
||||
function ObjectPermissionsView() {
|
||||
return null
|
||||
}
|
||||
;(ObjectPermissionsView as any).displayName = "ObjectPermissionsView"
|
||||
return { __esModule: true, default: ObjectPermissionsView }
|
||||
})
|
||||
vi.mock("../logging_settings_view", async () => {
|
||||
const React = await import("react")
|
||||
function LoggingSettingsView() {
|
||||
return null
|
||||
}
|
||||
;(LoggingSettingsView as any).displayName = "LoggingSettingsView"
|
||||
return { __esModule: true, default: LoggingSettingsView }
|
||||
})
|
||||
vi.mock("../common_components/AutoRotationView", async () => {
|
||||
const React = await import("react")
|
||||
function AutoRotationView() {
|
||||
return null
|
||||
}
|
||||
;(AutoRotationView as any).displayName = "AutoRotationView"
|
||||
return { __esModule: true, default: AutoRotationView }
|
||||
})
|
||||
|
||||
// KeyEditView mock: triggers onSubmit with our injected form values
|
||||
vi.mock("./key_edit_view", async () => {
|
||||
const React = await import("react")
|
||||
function KeyEditView(props: any) {
|
||||
return React.createElement(
|
||||
"div",
|
||||
null,
|
||||
React.createElement(
|
||||
"button",
|
||||
{
|
||||
onClick: () =>
|
||||
props.onSubmit((globalThis as any).__TEST_FORM_VALUES ?? {}),
|
||||
},
|
||||
"Mock Submit"
|
||||
)
|
||||
)
|
||||
}
|
||||
;(KeyEditView as any).displayName = "KeyEditViewMock"
|
||||
return { KeyEditView }
|
||||
})
|
||||
|
||||
// ---- SUT import AFTER mocks ----
|
||||
import KeyInfoView from "./key_info_view"
|
||||
|
||||
// ---- Test data helpers ----
|
||||
const baseKeyData = {
|
||||
token_id: "tok_123",
|
||||
token: "tok_123",
|
||||
key_alias: "My API Key",
|
||||
key_name: "sk-xxxx",
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
spend: 0,
|
||||
max_budget: null,
|
||||
tpm_limit: null,
|
||||
rpm_limit: null,
|
||||
models: [] as string[],
|
||||
metadata: {} as Record<string, any>,
|
||||
object_permission: {} as Record<string, any>,
|
||||
auto_rotate: false,
|
||||
rotation_interval: null as any,
|
||||
last_rotation_at: null as any,
|
||||
key_rotation_at: null as any,
|
||||
next_rotation_at: null as any,
|
||||
}
|
||||
|
||||
const renderView = (premiumUser: boolean) =>
|
||||
render(
|
||||
<KeyInfoView
|
||||
keyId="tok_123"
|
||||
onClose={() => {}}
|
||||
keyData={baseKeyData as any}
|
||||
onKeyDataUpdate={() => {}}
|
||||
accessToken="access_abc"
|
||||
userID="user_1"
|
||||
userRole="admin"
|
||||
teams={[]}
|
||||
premiumUser={premiumUser}
|
||||
setAccessToken={() => {}}
|
||||
/>
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
;(globalThis as any).__TEST_FORM_VALUES = undefined
|
||||
})
|
||||
|
||||
// ---- Tests ----
|
||||
describe("KeyInfoView handleKeyUpdate premium guard", () => {
|
||||
it("removes guardrails & prompts for non-premium users and prevents metadata.guardrails", async () => {
|
||||
renderView(false) // premiumUser = false
|
||||
|
||||
fireEvent.click(screen.getByText("Edit Settings"))
|
||||
|
||||
;(globalThis as any).__TEST_FORM_VALUES = {
|
||||
token: "tok_123",
|
||||
guardrails: ["gr-1", "gr-2"],
|
||||
prompts: ["fast", "safe"],
|
||||
metadata: {}, // object form (not JSON string)
|
||||
}
|
||||
|
||||
fireEvent.click(screen.getByText("Mock Submit"))
|
||||
|
||||
await waitFor(() => expect(keyUpdateCallMock).toHaveBeenCalled())
|
||||
|
||||
const [sentAccessToken, sentPayload] = keyUpdateCallMock.mock.calls[0]
|
||||
expect(sentAccessToken).toBe("access_abc")
|
||||
|
||||
expect("guardrails" in sentPayload).toBe(false)
|
||||
expect("prompts" in sentPayload).toBe(false)
|
||||
expect(sentPayload.metadata?.guardrails).toBeUndefined()
|
||||
expect(sentPayload.key).toBe("tok_123")
|
||||
})
|
||||
|
||||
it("preserves guardrails & prompts for premium users and includes metadata.guardrails", async () => {
|
||||
renderView(true) // premiumUser = true
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
|
|
@ -137,8 +137,8 @@ export function KeyEditView({
|
|||
token: keyData.token || keyData.token_id,
|
||||
budget_duration: getBudgetDuration(keyData.budget_duration),
|
||||
metadata: formatMetadataForDisplay(keyData.metadata),
|
||||
guardrails: keyData.metadata?.guardrails || [],
|
||||
prompts: keyData.metadata?.prompts || [],
|
||||
guardrails: keyData.metadata?.guardrails,
|
||||
prompts: keyData.metadata?.prompts,
|
||||
vector_stores: keyData.object_permission?.vector_stores || [],
|
||||
mcp_servers_and_groups: {
|
||||
servers: keyData.object_permission?.mcp_servers || [],
|
||||
|
|
@ -158,8 +158,8 @@ export function KeyEditView({
|
|||
token: keyData.token || keyData.token_id,
|
||||
budget_duration: getBudgetDuration(keyData.budget_duration),
|
||||
metadata: formatMetadataForDisplay(keyData.metadata),
|
||||
guardrails: keyData.metadata?.guardrails || [],
|
||||
prompts: keyData.metadata?.prompts || [],
|
||||
guardrails: keyData.metadata?.guardrails,
|
||||
prompts: keyData.metadata?.prompts,
|
||||
vector_stores: keyData.object_permission?.vector_stores || [],
|
||||
mcp_servers_and_groups: {
|
||||
servers: keyData.object_permission?.mcp_servers || [],
|
||||
|
|
@ -240,7 +240,7 @@ export function KeyEditView({
|
|||
|
||||
<Form.Item label="Guardrails" name="guardrails">
|
||||
{ accessToken &&
|
||||
<GuardrailSelector onChange={(v) => {form.setFieldValue("guardrails", v)}} accessToken={accessToken} />
|
||||
<GuardrailSelector onChange={(v) => {form.setFieldValue("guardrails", v)}} accessToken={accessToken} disabled={!premiumUser}/>
|
||||
}
|
||||
</Form.Item>
|
||||
|
||||
|
|
|
|||
|
|
@ -109,6 +109,12 @@ export default function KeyInfoView({
|
|||
const currentKey = formValues.token
|
||||
formValues.key = currentKey
|
||||
|
||||
// Guard premium features
|
||||
if (!premiumUser) {
|
||||
delete formValues.guardrails;
|
||||
delete formValues.prompts;
|
||||
}
|
||||
|
||||
// Handle object_permission updates
|
||||
if (formValues.vector_stores !== undefined) {
|
||||
formValues.object_permission = {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue