mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
preliminary virtual-keys refactor
simplifies handleCreate function for CreateKeyModal moved hooks, components, files where I want them moved virtual-key page to (console) for common layout added layout for refactored console file structure, sidebar wired teams to refactored components fixing type issue wired new top level hook useAuthorized Adds routing to unrefactored pages Update leftnav.tsx Update useAuthorized.ts useAuthorized in virtual-keys/page and deprecation notices prelim VirtualKeyViewPage, removed old comment Delete page.tsx
This commit is contained in:
parent
ddb90c9ad7
commit
03804cb1d2
30 changed files with 3941 additions and 35 deletions
291
ui/litellm-dashboard/src/app/(console)/components/Sidebar.tsx
Normal file
291
ui/litellm-dashboard/src/app/(console)/components/Sidebar.tsx
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
import React from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
import { Layout, Menu, ConfigProvider } from "antd";
|
||||
import {
|
||||
KeyOutlined,
|
||||
PlayCircleOutlined,
|
||||
BlockOutlined,
|
||||
BarChartOutlined,
|
||||
TeamOutlined,
|
||||
BankOutlined,
|
||||
UserOutlined,
|
||||
SettingOutlined,
|
||||
ApiOutlined,
|
||||
AppstoreOutlined,
|
||||
DatabaseOutlined,
|
||||
FileTextOutlined,
|
||||
LineChartOutlined,
|
||||
SafetyOutlined,
|
||||
ExperimentOutlined,
|
||||
ToolOutlined,
|
||||
TagsOutlined,
|
||||
BgColorsOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { all_admin_roles, internalUserRoles, isAdminRole, rolesWithWriteAccess } from "@/utils/roles";
|
||||
import UsageIndicator from "@/components/usage_indicator";
|
||||
|
||||
const { Sider } = Layout;
|
||||
|
||||
interface SidebarProps {
|
||||
accessToken: string | null;
|
||||
userRole: string;
|
||||
/** Used to highlight a menu item when pathname doesn't match (e.g., on non-routed pages) */
|
||||
defaultSelectedKey?: string;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
key: string;
|
||||
page: string;
|
||||
label: string;
|
||||
roles?: string[];
|
||||
children?: MenuItem[];
|
||||
icon?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ accessToken, userRole, defaultSelectedKey, collapsed = false }) => {
|
||||
const menuItems: MenuItem[] = [
|
||||
{ key: "1", page: "api-keys", label: "Virtual Keys", icon: <KeyOutlined style={{ fontSize: 18 }} /> },
|
||||
{
|
||||
key: "3",
|
||||
page: "llm-playground",
|
||||
label: "Test Key",
|
||||
icon: <PlayCircleOutlined style={{ fontSize: 18 }} />,
|
||||
roles: rolesWithWriteAccess,
|
||||
},
|
||||
{
|
||||
key: "2",
|
||||
page: "models",
|
||||
label: "Models + Endpoints",
|
||||
icon: <BlockOutlined style={{ fontSize: 18 }} />,
|
||||
roles: rolesWithWriteAccess,
|
||||
},
|
||||
{
|
||||
key: "12",
|
||||
page: "new_usage",
|
||||
label: "Usage",
|
||||
icon: <BarChartOutlined style={{ fontSize: 18 }} />,
|
||||
roles: [...all_admin_roles, ...internalUserRoles],
|
||||
},
|
||||
{ key: "6", page: "teams", label: "Teams", icon: <TeamOutlined style={{ fontSize: 18 }} /> },
|
||||
{
|
||||
key: "17",
|
||||
page: "organizations",
|
||||
label: "Organizations",
|
||||
icon: <BankOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "5",
|
||||
page: "users",
|
||||
label: "Internal Users",
|
||||
icon: <UserOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{ key: "14", page: "api_ref", label: "API Reference", icon: <ApiOutlined style={{ fontSize: 18 }} /> },
|
||||
{ key: "16", page: "model-hub-table", label: "Model Hub", icon: <AppstoreOutlined style={{ fontSize: 18 }} /> },
|
||||
{ key: "15", page: "logs", label: "Logs", icon: <LineChartOutlined style={{ fontSize: 18 }} /> },
|
||||
{
|
||||
key: "11",
|
||||
page: "guardrails",
|
||||
label: "Guardrails",
|
||||
icon: <SafetyOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "26",
|
||||
page: "tools",
|
||||
label: "Tools",
|
||||
icon: <ToolOutlined style={{ fontSize: 18 }} />,
|
||||
children: [
|
||||
{ key: "18", page: "mcp-servers", label: "MCP Servers", icon: <ToolOutlined style={{ fontSize: 18 }} /> },
|
||||
{
|
||||
key: "21",
|
||||
page: "vector-stores",
|
||||
label: "Vector Stores",
|
||||
icon: <DatabaseOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "experimental",
|
||||
page: "experimental",
|
||||
label: "Experimental",
|
||||
icon: <ExperimentOutlined style={{ fontSize: 18 }} />,
|
||||
children: [
|
||||
{
|
||||
key: "9",
|
||||
page: "caching",
|
||||
label: "Caching",
|
||||
icon: <DatabaseOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "25",
|
||||
page: "prompts",
|
||||
label: "Prompts",
|
||||
icon: <FileTextOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "10",
|
||||
page: "budgets",
|
||||
label: "Budgets",
|
||||
icon: <BankOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "20",
|
||||
page: "transform-request",
|
||||
label: "API Playground",
|
||||
icon: <ApiOutlined style={{ fontSize: 18 }} />,
|
||||
roles: [...all_admin_roles, ...internalUserRoles],
|
||||
},
|
||||
{
|
||||
key: "19",
|
||||
page: "tag-management",
|
||||
label: "Tag Management",
|
||||
icon: <TagsOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{ key: "4", page: "usage", label: "Old Usage", icon: <BarChartOutlined style={{ fontSize: 18 }} /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "settings",
|
||||
page: "settings",
|
||||
label: "Settings",
|
||||
icon: <SettingOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
children: [
|
||||
{
|
||||
key: "11",
|
||||
page: "general-settings",
|
||||
label: "Router Settings",
|
||||
icon: <SettingOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "8",
|
||||
page: "settings",
|
||||
label: "Logging & Alerts",
|
||||
icon: <SettingOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "13",
|
||||
page: "admin-panel",
|
||||
label: "Admin Settings",
|
||||
icon: <SettingOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{
|
||||
key: "14",
|
||||
page: "ui-theme",
|
||||
label: "UI Theme",
|
||||
icon: <BgColorsOutlined style={{ fontSize: 18 }} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Role filtering (preserves original visibility behavior)
|
||||
const filteredMenuItems: MenuItem[] = menuItems
|
||||
.filter((item) => !item.roles || item.roles.includes(userRole))
|
||||
.map((item) => ({
|
||||
...item,
|
||||
children: item.children?.filter((child) => !child.roles || child.roles.includes(userRole)),
|
||||
}));
|
||||
|
||||
// Highlight selection based on pathname or ?page=
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const pageParam = searchParams.get("page") || undefined;
|
||||
|
||||
const findMenuItemKey = (page: string): string => {
|
||||
const top = filteredMenuItems.find((i) => i.page === page);
|
||||
if (top) return top.key;
|
||||
for (const i of filteredMenuItems) {
|
||||
const child = i.children?.find((c) => c.page === page);
|
||||
if (child) return child.key;
|
||||
}
|
||||
return "1";
|
||||
};
|
||||
|
||||
const selectedMenuKey =
|
||||
pathname === "/virtual-keys"
|
||||
? "1"
|
||||
: pageParam
|
||||
? findMenuItemKey(pageParam)
|
||||
: defaultSelectedKey
|
||||
? findMenuItemKey(defaultSelectedKey)
|
||||
: "1";
|
||||
|
||||
// Root-only routing helper: always replace everything after the domain
|
||||
const rootWithPage = (p: string) => ({ pathname: "/", query: { page: p } });
|
||||
|
||||
// Convert to AntD Menu items:
|
||||
// - "Virtual Keys" still routes to /virtual-keys
|
||||
// - All other items (and children) route to "/?page=<page>"
|
||||
const antdItems = filteredMenuItems.map((item) => {
|
||||
const isVirtualKeys = item.key === "1";
|
||||
const label = isVirtualKeys ? (
|
||||
<Link href="/virtual-keys">Virtual Keys</Link>
|
||||
) : (
|
||||
<Link href={rootWithPage(item.page)}>{item.label}</Link>
|
||||
);
|
||||
|
||||
return {
|
||||
key: item.key,
|
||||
icon: item.icon,
|
||||
label,
|
||||
children: item.children?.map((child) => ({
|
||||
key: child.key,
|
||||
icon: child.icon,
|
||||
label: <Link href={rootWithPage(child.page)}>{child.label}</Link>,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: "100vh" }}>
|
||||
<Sider
|
||||
theme="light"
|
||||
width={220}
|
||||
collapsed={collapsed}
|
||||
collapsedWidth={80}
|
||||
collapsible
|
||||
trigger={null}
|
||||
style={{
|
||||
transition: "all 0.3s cubic-bezier(0.4, 0, 0.2, 1)",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
Menu: { iconSize: 18, fontSize: 14 },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[selectedMenuKey]}
|
||||
defaultOpenKeys={collapsed ? [] : ["llm-tools"]} /* kept to match original look */
|
||||
inlineCollapsed={collapsed}
|
||||
className="custom-sidebar-menu"
|
||||
style={{ borderRight: 0, backgroundColor: "transparent", fontSize: 14 }}
|
||||
items={antdItems as any}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
|
||||
{isAdminRole(userRole) && !collapsed && <UsageIndicator accessToken={accessToken} width={220} />}
|
||||
</Sider>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default Sidebar;
|
||||
|
|
@ -0,0 +1,345 @@
|
|||
// TODO: refactor
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button, Modal, Form, Input, message, Select, InputNumber, Select as Select2 } from "antd";
|
||||
import {
|
||||
Button as Button2,
|
||||
Text,
|
||||
TextInput,
|
||||
SelectItem,
|
||||
Accordion,
|
||||
AccordionHeader,
|
||||
AccordionBody,
|
||||
Title,
|
||||
} from "@tremor/react";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
const { Option } = Select;
|
||||
import { Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import OnboardingModal, { InvitationLink } from "@/components/onboarding_link";
|
||||
import {
|
||||
getProxyBaseUrl,
|
||||
getProxyUISettings,
|
||||
invitationCreateCall,
|
||||
modelAvailableCall,
|
||||
Team,
|
||||
userCreateCall,
|
||||
} from "@/components/networking";
|
||||
import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key";
|
||||
import BulkCreateUsersButton from "@/components/bulk_create_users_button";
|
||||
import { fetchTeams } from "@/app/(console)/virtual-keys/networking";
|
||||
import useTeams from "@/app/(console)/virtual-keys/hooks/useTeams";
|
||||
import useAuthorized from "@/app/(console)/hooks/useAuthorized";
|
||||
|
||||
// Helper function to generate UUID compatible across all environments
|
||||
const generateUUID = (): string => {
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
// Fallback UUID generation for environments without crypto.randomUUID
|
||||
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c == "x" ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
};
|
||||
|
||||
interface CreateUserModalProps {
|
||||
possibleUIRoles: null | Record<string, Record<string, string>>;
|
||||
onUserCreated?: (userId: string) => void;
|
||||
isEmbedded?: boolean;
|
||||
}
|
||||
|
||||
interface UISettings {
|
||||
PROXY_BASE_URL: string | null;
|
||||
PROXY_LOGOUT_URL: string | null;
|
||||
DEFAULT_TEAM_DISABLED: boolean;
|
||||
SSO_ENABLED: boolean;
|
||||
}
|
||||
|
||||
const CreateUserModal: React.FC<CreateUserModalProps> = ({ possibleUIRoles, onUserCreated, isEmbedded = false }) => {
|
||||
const { userId: userID, accessToken } = useAuthorized();
|
||||
const queryClient = useQueryClient();
|
||||
const [uiSettings, setUISettings] = useState<UISettings | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [apiuser, setApiuser] = useState<boolean>(false);
|
||||
const [userModels, setUserModels] = useState<string[]>([]);
|
||||
const [isInvitationLinkModalVisible, setIsInvitationLinkModalVisible] = useState(false);
|
||||
const [invitationLinkData, setInvitationLinkData] = useState<InvitationLink | null>(null);
|
||||
const [baseUrl, setBaseUrl] = useState<string | null>(null);
|
||||
const teams = useTeams();
|
||||
|
||||
// get all models
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const userRole = "any"; // You may need to get the user role dynamically
|
||||
const modelDataResponse = await modelAvailableCall(accessToken, userID, userRole);
|
||||
// Assuming modelDataResponse.data contains an array of model objects with a 'model_name' property
|
||||
const availableModels = [];
|
||||
for (let i = 0; i < modelDataResponse.data.length; i++) {
|
||||
const model = modelDataResponse.data[i];
|
||||
availableModels.push(model.id);
|
||||
}
|
||||
console.log("Model data response:", modelDataResponse.data);
|
||||
console.log("Available models:", availableModels);
|
||||
|
||||
// Assuming modelDataResponse.data contains an array of model names
|
||||
setUserModels(availableModels);
|
||||
|
||||
// get ui settings
|
||||
const uiSettingsResponse = await getProxyUISettings(accessToken);
|
||||
console.log("uiSettingsResponse:", uiSettingsResponse);
|
||||
|
||||
setUISettings(uiSettingsResponse);
|
||||
} catch (error) {
|
||||
console.error("Error fetching model data:", error);
|
||||
}
|
||||
};
|
||||
|
||||
setBaseUrl(getProxyBaseUrl());
|
||||
|
||||
fetchData(); // Call the function to fetch model data when the component mounts
|
||||
}, []); // Empty dependency array to run only once
|
||||
|
||||
const handleOk = () => {
|
||||
setIsModalVisible(false);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsModalVisible(false);
|
||||
setApiuser(false);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const handleCreate = async (formValues: { user_id: string; models?: string[]; user_role: string }) => {
|
||||
try {
|
||||
NotificationsManager.info("Making API Call");
|
||||
if (!isEmbedded) {
|
||||
setIsModalVisible(true);
|
||||
}
|
||||
if ((!formValues.models || formValues.models.length === 0) && formValues.user_role !== "proxy_admin") {
|
||||
console.log("formValues.user_role", formValues.user_role);
|
||||
// If models is empty or undefined, set it to "no-default-models"
|
||||
formValues.models = ["no-default-models"];
|
||||
}
|
||||
console.log("formValues in create user:", formValues);
|
||||
const response = await userCreateCall(accessToken, null, formValues);
|
||||
await queryClient.invalidateQueries({ queryKey: ["userList"] });
|
||||
console.log("user create Response:", response);
|
||||
setApiuser(true);
|
||||
const user_id = response.data?.user_id || response.user_id;
|
||||
|
||||
// Call the callback if provided (for embedded mode)
|
||||
if (onUserCreated && isEmbedded) {
|
||||
onUserCreated(user_id);
|
||||
form.resetFields();
|
||||
return; // Skip the invitation flow when embedded
|
||||
}
|
||||
|
||||
// only do invite link flow if sso is not enabled
|
||||
if (!uiSettings?.SSO_ENABLED) {
|
||||
invitationCreateCall(accessToken, user_id).then((data) => {
|
||||
data.has_user_setup_sso = false;
|
||||
setInvitationLinkData(data);
|
||||
setIsInvitationLinkModalVisible(true);
|
||||
});
|
||||
} else {
|
||||
// create an InvitationLink Object for this user for the SSO flow
|
||||
// for SSO the invite link is the proxy base url since the User just needs to login
|
||||
const invitationLink: InvitationLink = {
|
||||
id: generateUUID(), // Generate a unique ID
|
||||
user_id: user_id,
|
||||
is_accepted: false,
|
||||
accepted_at: null,
|
||||
expires_at: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // Set expiry to 7 days from now
|
||||
created_at: new Date(),
|
||||
created_by: userID, // Assuming userID is the current user creating the invitation
|
||||
updated_at: new Date(),
|
||||
updated_by: userID,
|
||||
has_user_setup_sso: true,
|
||||
};
|
||||
setInvitationLinkData(invitationLink);
|
||||
setIsInvitationLinkModalVisible(true);
|
||||
}
|
||||
|
||||
NotificationsManager.success("API user Created");
|
||||
form.resetFields();
|
||||
localStorage.removeItem("userData" + userID);
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.detail || error?.message || "Error creating the user";
|
||||
NotificationsManager.fromBackend(errorMessage);
|
||||
console.error("Error creating the user:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Modify the return statement to handle embedded mode
|
||||
if (isEmbedded) {
|
||||
return (
|
||||
<Form form={form} onFinish={handleCreate} labelCol={{ span: 8 }} wrapperCol={{ span: 16 }} labelAlign="left">
|
||||
<Form.Item label="User Email" name="user_email">
|
||||
<TextInput placeholder="" />
|
||||
</Form.Item>
|
||||
<Form.Item label="User Role" name="user_role">
|
||||
<Select2>
|
||||
{possibleUIRoles &&
|
||||
Object.entries(possibleUIRoles).map(([role, { ui_label, description }]) => (
|
||||
<SelectItem key={role} value={role} title={ui_label}>
|
||||
<div className="flex">
|
||||
{ui_label}{" "}
|
||||
<p className="ml-2" style={{ color: "gray", fontSize: "12px" }}>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select2>
|
||||
</Form.Item>
|
||||
<Form.Item label="Team ID" name="team_id">
|
||||
<Select placeholder="Select Team ID" style={{ width: "100%" }}>
|
||||
{teams ? (
|
||||
teams.map((team: any) => (
|
||||
<Option key={team.team_id} value={team.team_id}>
|
||||
{team.team_alias}
|
||||
</Option>
|
||||
))
|
||||
) : (
|
||||
<Option key="default" value={null}>
|
||||
Default Team
|
||||
</Option>
|
||||
)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Metadata" name="metadata">
|
||||
<Input.TextArea rows={4} placeholder="Enter metadata as JSON" />
|
||||
</Form.Item>
|
||||
|
||||
<div style={{ textAlign: "right", marginTop: "10px" }}>
|
||||
<Button htmlType="submit">Create User</Button>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
// Original return for standalone mode
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button2 className="mb-0" onClick={() => setIsModalVisible(true)}>
|
||||
+ Invite User
|
||||
</Button2>
|
||||
<BulkCreateUsersButton accessToken={accessToken} teams={teams} possibleUIRoles={possibleUIRoles} />
|
||||
<Modal
|
||||
title="Invite User"
|
||||
visible={isModalVisible}
|
||||
width={800}
|
||||
footer={null}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
>
|
||||
<Text className="mb-1">Create a User who can own keys</Text>
|
||||
<Form form={form} onFinish={handleCreate} labelCol={{ span: 8 }} wrapperCol={{ span: 16 }} labelAlign="left">
|
||||
<Form.Item label="User Email" name="user_email">
|
||||
<TextInput placeholder="" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Global Proxy Role{" "}
|
||||
<Tooltip title="This is the role that the user will globally on the proxy. This role is independent of any team/org specific roles.">
|
||||
<InfoCircleOutlined />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="user_role"
|
||||
>
|
||||
<Select2>
|
||||
{possibleUIRoles &&
|
||||
Object.entries(possibleUIRoles).map(([role, { ui_label, description }]) => (
|
||||
<SelectItem key={role} value={role} title={ui_label}>
|
||||
<div className="flex">
|
||||
{ui_label}{" "}
|
||||
<p className="ml-2" style={{ color: "gray", fontSize: "12px" }}>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select2>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Team ID"
|
||||
className="gap-2"
|
||||
name="team_id"
|
||||
help="If selected, user will be added as a 'user' role to the team."
|
||||
>
|
||||
<Select placeholder="Select Team ID" style={{ width: "100%" }}>
|
||||
{teams ? (
|
||||
teams.map((team: any) => (
|
||||
<Option key={team.team_id} value={team.team_id}>
|
||||
{team.team_alias}
|
||||
</Option>
|
||||
))
|
||||
) : (
|
||||
<Option key="default" value={null}>
|
||||
Default Team
|
||||
</Option>
|
||||
)}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="Metadata" name="metadata">
|
||||
<Input.TextArea rows={4} placeholder="Enter metadata as JSON" />
|
||||
</Form.Item>
|
||||
<Accordion>
|
||||
<AccordionHeader>
|
||||
<Title>Personal Key Creation</Title>
|
||||
</AccordionHeader>
|
||||
<AccordionBody>
|
||||
<Form.Item
|
||||
className="gap-2"
|
||||
label={
|
||||
<span>
|
||||
Models{" "}
|
||||
<Tooltip title="Models user has access to, outside of team scope.">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="models"
|
||||
help="Models user has access to, outside of team scope."
|
||||
>
|
||||
<Select2 mode="multiple" placeholder="Select models" style={{ width: "100%" }}>
|
||||
<Select2.Option key="all-proxy-models" value="all-proxy-models">
|
||||
All Proxy Models
|
||||
</Select2.Option>
|
||||
{userModels.map((model) => (
|
||||
<Select2.Option key={model} value={model}>
|
||||
{getModelDisplayName(model)}
|
||||
</Select2.Option>
|
||||
))}
|
||||
</Select2>
|
||||
</Form.Item>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
<div style={{ textAlign: "right", marginTop: "10px" }}>
|
||||
<Button htmlType="submit">Create User</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
{apiuser && (
|
||||
<OnboardingModal
|
||||
isInvitationLinkModalVisible={isInvitationLinkModalVisible}
|
||||
setIsInvitationLinkModalVisible={setIsInvitationLinkModalVisible}
|
||||
baseUrl={baseUrl || ""}
|
||||
invitationLinkData={invitationLinkData}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateUserModal;
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import { Router } from "next/router";
|
||||
import { getCookie } from "@/utils/cookieUtils";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
|
||||
const useAuthorized = () => {
|
||||
const token = getCookie("token");
|
||||
const router = useRouter();
|
||||
|
||||
if (!token) {
|
||||
router.replace("/sso/key/generate");
|
||||
}
|
||||
|
||||
const decoded = jwtDecode(token as string) as { [key: string]: any };
|
||||
const accessToken = decoded.key;
|
||||
const userId = decoded.user_id;
|
||||
const userRole = decoded.user_role;
|
||||
const premiumUser = decoded.premium_user;
|
||||
|
||||
return { accessToken, userId, userRole, premiumUser };
|
||||
};
|
||||
|
||||
export default useAuthorized;
|
||||
46
ui/litellm-dashboard/src/app/(console)/layout.tsx
Normal file
46
ui/litellm-dashboard/src/app/(console)/layout.tsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import Navbar from "@/components/navbar";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
import Sidebar from "@/app/(console)/components/Sidebar";
|
||||
import useAuthorized from "@/app/(console)/hooks/useAuthorized";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { accessToken, userRole } = useAuthorized();
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = React.useState(false);
|
||||
|
||||
const toggleSidebar = () => setSidebarCollapsed((v) => !v);
|
||||
|
||||
return (
|
||||
<ThemeProvider accessToken={""}>
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<Navbar
|
||||
isPublicPage={false}
|
||||
sidebarCollapsed={sidebarCollapsed}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
userID={null}
|
||||
userEmail={null}
|
||||
userRole={null}
|
||||
premiumUser={false}
|
||||
proxySettings={undefined}
|
||||
setProxySettings={function (value: any): void {
|
||||
throw new Error("Function not implemented.");
|
||||
}}
|
||||
accessToken={null}
|
||||
/>
|
||||
<div className="flex flex-1 overflow-auto">
|
||||
<div className="mt-2">
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
accessToken={accessToken}
|
||||
userRole={userRole}
|
||||
defaultSelectedKey={""}
|
||||
/>
|
||||
</div>
|
||||
<main className="flex-1">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { Button } from "@tremor/react";
|
||||
import { Modal, Form } from "antd";
|
||||
import { getPossibleUserRoles, Organization } from "@/components/networking";
|
||||
import { rolesWithWriteAccess } from "@/utils/roles";
|
||||
import { Team } from "@/components/key_team_helpers/key_list";
|
||||
import CreateKeyModal from "@/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyModal";
|
||||
import CreateUserModal from "@/app/(console)/components/modals/CreateUserModal";
|
||||
import useAuthorized from "@/app/(console)/hooks/useAuthorized";
|
||||
|
||||
interface CreateKeyProps {
|
||||
team: Team | null;
|
||||
userRole: string | null;
|
||||
data: any[] | null;
|
||||
addKey: (data: any) => void;
|
||||
}
|
||||
|
||||
interface User {
|
||||
user_id: string;
|
||||
user_email: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
interface UserOption {
|
||||
label: string;
|
||||
value: string;
|
||||
user: User;
|
||||
}
|
||||
const CreateKey: React.FC<CreateKeyProps> = ({ team, userRole, data, addKey }) => {
|
||||
const { accessToken } = useAuthorized();
|
||||
const [form] = Form.useForm();
|
||||
const [isModalVisible, setIsModalVisible] = useState(false);
|
||||
const [isCreateUserModalVisible, setIsCreateUserModalVisible] = useState(false);
|
||||
const [newlyCreatedUserId, setNewlyCreatedUserId] = useState<string | null>(null);
|
||||
const [possibleUIRoles, setPossibleUIRoles] = useState<Record<string, Record<string, string>>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const fetchPossibleRoles = async () => {
|
||||
try {
|
||||
if (accessToken) {
|
||||
// Check if roles are cached in session storage
|
||||
const cachedRoles = sessionStorage.getItem("possibleUserRoles");
|
||||
if (cachedRoles) {
|
||||
setPossibleUIRoles(JSON.parse(cachedRoles));
|
||||
} else {
|
||||
const availableUserRoles = await getPossibleUserRoles(accessToken);
|
||||
sessionStorage.setItem("possibleUserRoles", JSON.stringify(availableUserRoles));
|
||||
setPossibleUIRoles(availableUserRoles);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error fetching possible user roles:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchPossibleRoles();
|
||||
}, [accessToken]);
|
||||
|
||||
// Add a callback function to handle user creation
|
||||
const handleUserCreated = (userId: string) => {
|
||||
setNewlyCreatedUserId(userId);
|
||||
form.setFieldsValue({ user_id: userId });
|
||||
setIsCreateUserModalVisible(false);
|
||||
};
|
||||
|
||||
const handleUserSelect = (_value: string, option: UserOption): void => {
|
||||
const selectedUser = option.user;
|
||||
form.setFieldsValue({
|
||||
user_id: selectedUser.user_id,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{userRole && rolesWithWriteAccess.includes(userRole) && (
|
||||
<Button className="mx-auto" onClick={() => setIsModalVisible(true)}>
|
||||
+ Create New Key
|
||||
</Button>
|
||||
)}
|
||||
<CreateKeyModal
|
||||
isModalVisible={isModalVisible}
|
||||
form={form}
|
||||
handleUserSelect={handleUserSelect}
|
||||
setIsCreateUserModalVisible={setIsCreateUserModalVisible}
|
||||
team={team}
|
||||
setIsModalVisible={setIsModalVisible}
|
||||
data={data}
|
||||
addKey={addKey}
|
||||
/>
|
||||
|
||||
{isCreateUserModalVisible && (
|
||||
<Modal
|
||||
title="Create New User"
|
||||
open={isCreateUserModalVisible}
|
||||
onCancel={() => setIsCreateUserModalVisible(false)}
|
||||
footer={null}
|
||||
width={800}
|
||||
>
|
||||
<CreateUserModal possibleUIRoles={possibleUIRoles} onUserCreated={handleUserCreated} isEmbedded={true} />
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateKey;
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import { TextInput, Title } from "@tremor/react";
|
||||
import { Form, FormInstance, Select, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key";
|
||||
import React from "react";
|
||||
|
||||
export interface KeyDetailsSectionProps {
|
||||
form: FormInstance;
|
||||
keyOwner: string;
|
||||
modelsToPick: string[];
|
||||
keyType: string;
|
||||
setKeyType: (keyType: string) => void;
|
||||
}
|
||||
|
||||
const { Option } = Select;
|
||||
|
||||
const KeyDetailsSection = ({ form, keyOwner, keyType, modelsToPick, setKeyType }: KeyDetailsSectionProps) => {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<Title className="mb-4">Key Details</Title>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
{keyOwner === "you" || keyOwner === "another_user" ? "Key Name" : "Service Account ID"}{" "}
|
||||
<Tooltip
|
||||
title={
|
||||
keyOwner === "you" || keyOwner === "another_user"
|
||||
? "A descriptive name to identify this key"
|
||||
: "Unique identifier for this service account"
|
||||
}
|
||||
>
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="key_alias"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: `Please input a ${keyOwner === "you" ? "key name" : "service account ID"}`,
|
||||
},
|
||||
]}
|
||||
help="required"
|
||||
>
|
||||
<TextInput placeholder="" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Models{" "}
|
||||
<Tooltip title="Select which models this key can access. Choose 'All Team Models' to grant access to all models available to the team">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="models"
|
||||
rules={
|
||||
keyType === "management" || keyType === "read_only"
|
||||
? []
|
||||
: [{ required: true, message: "Please select a model" }]
|
||||
}
|
||||
help={
|
||||
keyType === "management" || keyType === "read_only"
|
||||
? "Models field is disabled for this key type"
|
||||
: "required"
|
||||
}
|
||||
className="mt-4"
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
placeholder="Select models"
|
||||
style={{ width: "100%" }}
|
||||
disabled={keyType === "management" || keyType === "read_only"}
|
||||
onChange={(values) => {
|
||||
if (values.includes("all-team-models")) {
|
||||
form.setFieldsValue({ models: ["all-team-models"] });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Option key="all-team-models" value="all-team-models">
|
||||
All Team Models
|
||||
</Option>
|
||||
{modelsToPick.map((model: string) => (
|
||||
<Option key={model} value={model}>
|
||||
{getModelDisplayName(model)}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Key Type{" "}
|
||||
<Tooltip title="Select the type of key to determine what routes and operations this key can access">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="key_type"
|
||||
initialValue="default"
|
||||
className="mt-4"
|
||||
>
|
||||
<Select
|
||||
defaultValue="default"
|
||||
placeholder="Select key type"
|
||||
style={{ width: "100%" }}
|
||||
optionLabelProp="label"
|
||||
onChange={(value) => {
|
||||
setKeyType(value);
|
||||
// Clear models field and disable if management or read_only
|
||||
if (value === "management" || value === "read_only") {
|
||||
form.setFieldsValue({ models: [] });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Option value="default" label="Default">
|
||||
<div style={{ padding: "4px 0" }}>
|
||||
<div style={{ fontWeight: 500 }}>Default</div>
|
||||
<div style={{ fontSize: "11px", color: "#6b7280", marginTop: "2px" }}>
|
||||
Can call LLM API + Management routes
|
||||
</div>
|
||||
</div>
|
||||
</Option>
|
||||
<Option value="llm_api" label="LLM API">
|
||||
<div style={{ padding: "4px 0" }}>
|
||||
<div style={{ fontWeight: 500 }}>LLM API</div>
|
||||
<div style={{ fontSize: "11px", color: "#6b7280", marginTop: "2px" }}>
|
||||
Can call only LLM API routes (chat/completions, embeddings, etc.)
|
||||
</div>
|
||||
</div>
|
||||
</Option>
|
||||
<Option value="management" label="Management">
|
||||
<div style={{ padding: "4px 0" }}>
|
||||
<div style={{ fontWeight: 500 }}>Management</div>
|
||||
<div style={{ fontSize: "11px", color: "#6b7280", marginTop: "2px" }}>
|
||||
Can call only management routes (user/team/key management)
|
||||
</div>
|
||||
</div>
|
||||
</Option>
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyDetailsSection;
|
||||
|
|
@ -0,0 +1,458 @@
|
|||
import { Accordion, AccordionBody, AccordionHeader, Text, Title } from "@tremor/react";
|
||||
import { Form, FormInstance, Input, Select, Tooltip } from "antd";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import NumericalInput from "@/components/shared/numerical_input";
|
||||
import BudgetDurationDropdown from "@/components/common_components/budget_duration_dropdown";
|
||||
import RateLimitTypeFormItem from "@/components/common_components/RateLimitTypeFormItem";
|
||||
import VectorStoreSelector from "@/components/vector_store_management/VectorStoreSelector";
|
||||
import MCPServerSelector from "@/components/mcp_server_management/MCPServerSelector";
|
||||
import PremiumLoggingSettings from "@/components/common_components/PremiumLoggingSettings";
|
||||
import ModelAliasManager from "@/components/common_components/ModelAliasManager";
|
||||
import KeyLifecycleSettings from "@/components/common_components/KeyLifecycleSettings";
|
||||
import { proxyBaseUrl } from "@/components/networking";
|
||||
import SchemaFormFields from "@/components/common_components/check_openapi_schema";
|
||||
import { Team } from "@/components/key_team_helpers/key_list";
|
||||
import React from "react";
|
||||
import { DefaultOptionType } from "rc-select/lib/Select";
|
||||
import { ModelAliases } from "@/app/(console)/virtual-keys/components/CreateKeyModal/types";
|
||||
|
||||
export interface OptionalSettingsSectionProps {
|
||||
form: FormInstance;
|
||||
team: Team | null;
|
||||
premiumUser: boolean;
|
||||
guardrails: string[];
|
||||
prompts: string[];
|
||||
accessToken: string;
|
||||
predefinedTags: DefaultOptionType[];
|
||||
loggingSettings: any;
|
||||
setLoggingSettings: (settings: any) => void;
|
||||
disabledCallbacks: string[];
|
||||
setDisabledCallbacks: (disabledCallbacks: string[]) => void;
|
||||
modelAliases: ModelAliases;
|
||||
setModelAliases: (aliases: ModelAliases) => void;
|
||||
autoRotationEnabled: boolean;
|
||||
setAutoRotationEnabled: (enabled: boolean) => void;
|
||||
rotationInterval: string;
|
||||
setRotationInterval: (interval: string) => void;
|
||||
}
|
||||
|
||||
// TODO: break apart this component into smaller sections
|
||||
const OptionalSettingsSection = ({
|
||||
form,
|
||||
team,
|
||||
premiumUser,
|
||||
guardrails,
|
||||
prompts,
|
||||
accessToken,
|
||||
predefinedTags,
|
||||
loggingSettings,
|
||||
setLoggingSettings,
|
||||
disabledCallbacks,
|
||||
setDisabledCallbacks,
|
||||
modelAliases,
|
||||
setModelAliases,
|
||||
autoRotationEnabled,
|
||||
setAutoRotationEnabled,
|
||||
rotationInterval,
|
||||
setRotationInterval,
|
||||
}: OptionalSettingsSectionProps) => {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<Accordion className="mt-4 mb-4">
|
||||
<AccordionHeader>
|
||||
<Title className="m-0">Optional Settings</Title>
|
||||
</AccordionHeader>
|
||||
<AccordionBody>
|
||||
<Form.Item
|
||||
className="mt-4"
|
||||
label={
|
||||
<span>
|
||||
Max Budget (USD){" "}
|
||||
<Tooltip title="Maximum amount in USD this key can spend. When reached, the key will be blocked from making further requests">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="max_budget"
|
||||
help={`Budget cannot exceed team max budget: $${team?.max_budget !== null && team?.max_budget !== undefined ? team?.max_budget : "unlimited"}`}
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
if (value && team && team.max_budget !== null && value > team.max_budget) {
|
||||
throw new Error(
|
||||
`Budget cannot exceed team max budget: $${formatNumberWithCommas(team.max_budget, 4)}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<NumericalInput step={0.01} precision={2} width={200} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="mt-4"
|
||||
label={
|
||||
<span>
|
||||
Reset Budget{" "}
|
||||
<Tooltip title="How often the budget should reset. For example, setting 'daily' will reset the budget every 24 hours">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="budget_duration"
|
||||
help={`Team Reset Budget: ${team?.budget_duration !== null && team?.budget_duration !== undefined ? team?.budget_duration : "None"}`}
|
||||
>
|
||||
<BudgetDurationDropdown onChange={(value) => form.setFieldValue("budget_duration", value)} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
className="mt-4"
|
||||
label={
|
||||
<span>
|
||||
Tokens per minute Limit (TPM){" "}
|
||||
<Tooltip title="Maximum number of tokens this key can process per minute. Helps control usage and costs">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="tpm_limit"
|
||||
help={`TPM cannot exceed team TPM limit: ${team?.tpm_limit !== null && team?.tpm_limit !== undefined ? team?.tpm_limit : "unlimited"}`}
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
if (value && team && team.tpm_limit !== null && value > team.tpm_limit) {
|
||||
throw new Error(`TPM limit cannot exceed team TPM limit: ${team.tpm_limit}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<NumericalInput step={1} width={400} />
|
||||
</Form.Item>
|
||||
<RateLimitTypeFormItem
|
||||
type="tpm"
|
||||
name="tpm_limit_type"
|
||||
className="mt-4"
|
||||
initialValue={null}
|
||||
form={form}
|
||||
showDetailedDescriptions={true}
|
||||
/>
|
||||
<Form.Item
|
||||
className="mt-4"
|
||||
label={
|
||||
<span>
|
||||
Requests per minute Limit (RPM){" "}
|
||||
<Tooltip title="Maximum number of API requests this key can make per minute. Helps prevent abuse and manage load">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="rpm_limit"
|
||||
help={`RPM cannot exceed team RPM limit: ${team?.rpm_limit !== null && team?.rpm_limit !== undefined ? team?.rpm_limit : "unlimited"}`}
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
if (value && team && team.rpm_limit !== null && value > team.rpm_limit) {
|
||||
throw new Error(`RPM limit cannot exceed team RPM limit: ${team.rpm_limit}`);
|
||||
}
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<NumericalInput step={1} width={400} />
|
||||
</Form.Item>
|
||||
<RateLimitTypeFormItem
|
||||
type="rpm"
|
||||
name="rpm_limit_type"
|
||||
className="mt-4"
|
||||
initialValue={null}
|
||||
form={form}
|
||||
showDetailedDescriptions={true}
|
||||
/>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Guardrails{" "}
|
||||
<Tooltip title="Apply safety guardrails to this key to filter content or enforce policies">
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/guardrails/quick_start"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()} // Prevent accordion from collapsing when clicking link
|
||||
>
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="guardrails"
|
||||
className="mt-4"
|
||||
help={
|
||||
premiumUser
|
||||
? "Select existing guardrails or enter new ones"
|
||||
: "Premium feature - Upgrade to set guardrails by key"
|
||||
}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
style={{ width: "100%" }}
|
||||
disabled={!premiumUser}
|
||||
placeholder={
|
||||
!premiumUser ? "Premium feature - Upgrade to set guardrails by key" : "Select or enter guardrails"
|
||||
}
|
||||
options={guardrails.map((name) => ({ value: name, label: name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Prompts{" "}
|
||||
<Tooltip title="Allow this key to use specific prompt templates">
|
||||
<a
|
||||
href="https://docs.litellm.ai/docs/proxy/prompt_management"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()} // Prevent accordion from collapsing when clicking link
|
||||
>
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</a>
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="prompts"
|
||||
className="mt-4"
|
||||
help={
|
||||
premiumUser
|
||||
? "Select existing prompts or enter new ones"
|
||||
: "Premium feature - Upgrade to set prompts by key"
|
||||
}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
style={{ width: "100%" }}
|
||||
disabled={!premiumUser}
|
||||
placeholder={!premiumUser ? "Premium feature - Upgrade to set prompts by key" : "Select or enter prompts"}
|
||||
options={prompts.map((name) => ({ value: name, label: name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Allowed Vector Stores{" "}
|
||||
<Tooltip title="Select which vector stores this key can access. If none selected, the key will have access to all available vector stores">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="allowed_vector_store_ids"
|
||||
className="mt-4"
|
||||
help="Select vector stores this key can access. Leave empty for access to all vector stores"
|
||||
>
|
||||
<VectorStoreSelector
|
||||
onChange={(values: string[]) => form.setFieldValue("allowed_vector_store_ids", values)}
|
||||
value={form.getFieldValue("allowed_vector_store_ids")}
|
||||
accessToken={accessToken}
|
||||
placeholder="Select vector stores (optional)"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Allowed MCP Servers{" "}
|
||||
<Tooltip title="Select which MCP servers or access groups this key can access. ">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="allowed_mcp_servers_and_groups"
|
||||
className="mt-4"
|
||||
help="Select MCP servers or access groups this key can access. "
|
||||
>
|
||||
<MCPServerSelector
|
||||
onChange={(val: any) => form.setFieldValue("allowed_mcp_servers_and_groups", val)}
|
||||
value={form.getFieldValue("allowed_mcp_servers_and_groups")}
|
||||
accessToken={accessToken}
|
||||
placeholder="Select MCP servers or access groups (optional)"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Metadata{" "}
|
||||
<Tooltip title="JSON object with additional information about this key. Used for tracking or custom logic">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="metadata"
|
||||
className="mt-4"
|
||||
>
|
||||
<Input.TextArea rows={4} placeholder="Enter metadata as JSON" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Tags{" "}
|
||||
<Tooltip title="Tags for tracking spend and/or doing tag-based routing. Used for analytics and filtering">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="tags"
|
||||
className="mt-4"
|
||||
help={`Tags for tracking spend and/or doing tag-based routing.`}
|
||||
>
|
||||
<Select
|
||||
mode="tags"
|
||||
style={{ width: "100%" }}
|
||||
placeholder="Enter tags"
|
||||
tokenSeparators={[","]}
|
||||
options={predefinedTags}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{premiumUser ? (
|
||||
<Accordion className="mt-4 mb-4">
|
||||
<AccordionHeader>
|
||||
<b>Logging Settings</b>
|
||||
</AccordionHeader>
|
||||
<AccordionBody>
|
||||
<div className="mt-4">
|
||||
<PremiumLoggingSettings
|
||||
value={loggingSettings}
|
||||
onChange={setLoggingSettings}
|
||||
premiumUser={true}
|
||||
disabledCallbacks={disabledCallbacks}
|
||||
onDisabledCallbacksChange={setDisabledCallbacks}
|
||||
/>
|
||||
</div>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
) : (
|
||||
<Tooltip
|
||||
title={
|
||||
<span>
|
||||
Key-level logging settings is an enterprise feature, get in touch -
|
||||
<a href="https://www.litellm.ai/enterprise" target="_blank">
|
||||
https://www.litellm.ai/enterprise
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
<div style={{ position: "relative" }}>
|
||||
<div style={{ opacity: 0.5 }}>
|
||||
<Accordion className="mt-4 mb-4">
|
||||
<AccordionHeader>
|
||||
<b>Logging Settings</b>
|
||||
</AccordionHeader>
|
||||
<AccordionBody>
|
||||
<div className="mt-4">
|
||||
<PremiumLoggingSettings
|
||||
value={loggingSettings}
|
||||
onChange={setLoggingSettings}
|
||||
premiumUser={false}
|
||||
disabledCallbacks={disabledCallbacks}
|
||||
onDisabledCallbacksChange={setDisabledCallbacks}
|
||||
/>
|
||||
</div>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
</div>
|
||||
<div style={{ position: "absolute", inset: 0, cursor: "not-allowed" }} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Accordion className="mt-4 mb-4">
|
||||
<AccordionHeader>
|
||||
<b>Model Aliases</b>
|
||||
</AccordionHeader>
|
||||
<AccordionBody>
|
||||
<div className="mt-4">
|
||||
<Text className="text-sm text-gray-600 mb-4">
|
||||
Create custom aliases for models that can be used in API calls. This allows you to create shortcuts
|
||||
for specific models.
|
||||
</Text>
|
||||
<ModelAliasManager
|
||||
accessToken={accessToken}
|
||||
initialModelAliases={modelAliases}
|
||||
onAliasUpdate={setModelAliases}
|
||||
showExampleConfig={false}
|
||||
/>
|
||||
</div>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
|
||||
<Accordion className="mt-4 mb-4">
|
||||
<AccordionHeader>
|
||||
<b>Key Lifecycle</b>
|
||||
</AccordionHeader>
|
||||
<AccordionBody>
|
||||
<div className="mt-4">
|
||||
<KeyLifecycleSettings
|
||||
form={form}
|
||||
autoRotationEnabled={autoRotationEnabled}
|
||||
onAutoRotationChange={setAutoRotationEnabled}
|
||||
rotationInterval={rotationInterval}
|
||||
onRotationIntervalChange={setRotationInterval}
|
||||
/>
|
||||
</div>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
<Accordion className="mt-4 mb-4">
|
||||
<AccordionHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<b>Advanced Settings</b>
|
||||
<Tooltip
|
||||
title={
|
||||
<span>
|
||||
Learn more about advanced settings in our{" "}
|
||||
<a
|
||||
href={
|
||||
proxyBaseUrl
|
||||
? `${proxyBaseUrl}/#/key%20management/generate_key_fn_key_generate_post`
|
||||
: `/#/key%20management/generate_key_fn_key_generate_post`
|
||||
}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
documentation
|
||||
</a>
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<InfoCircleOutlined className="text-gray-400 hover:text-gray-300 cursor-help" />
|
||||
</Tooltip>
|
||||
</div>
|
||||
</AccordionHeader>
|
||||
<AccordionBody>
|
||||
<SchemaFormFields
|
||||
schemaComponent="GenerateKeyRequest"
|
||||
form={form}
|
||||
excludedFields={[
|
||||
"key_alias",
|
||||
"team_id",
|
||||
"models",
|
||||
"duration",
|
||||
"metadata",
|
||||
"tags",
|
||||
"guardrails",
|
||||
"max_budget",
|
||||
"budget_duration",
|
||||
"tpm_limit",
|
||||
"rpm_limit",
|
||||
]}
|
||||
/>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
</AccordionBody>
|
||||
</Accordion>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OptionalSettingsSection;
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
import { Button as Button2, Form, Radio, Select, Tooltip } from "antd";
|
||||
import { Title } from "@tremor/react";
|
||||
import { InfoCircleOutlined } from "@ant-design/icons";
|
||||
import TeamDropdown from "@/components/common_components/team_dropdown";
|
||||
import React from "react";
|
||||
import { Team } from "@/components/key_team_helpers/key_list";
|
||||
import { UserOption } from "@/app/(console)/virtual-keys/components/CreateKeyModal/types";
|
||||
|
||||
interface OwnershipSectionProps {
|
||||
team: Team | null;
|
||||
teams: Team[] | null;
|
||||
userRole: string | null;
|
||||
userOptions: UserOption[];
|
||||
keyOwner: string;
|
||||
setKeyOwner: React.Dispatch<React.SetStateAction<string>>;
|
||||
handleUserSearch: (q: string) => void;
|
||||
userSearchLoading: boolean;
|
||||
handleUserSelect: (q: string, option: UserOption) => void;
|
||||
setSelectedCreateKeyTeam: React.Dispatch<React.SetStateAction<Team | null>>;
|
||||
setIsCreateUserModalVisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
const OwnershipSection = ({
|
||||
team,
|
||||
teams,
|
||||
userRole,
|
||||
userOptions,
|
||||
keyOwner,
|
||||
setKeyOwner,
|
||||
handleUserSearch,
|
||||
userSearchLoading,
|
||||
handleUserSelect,
|
||||
setSelectedCreateKeyTeam,
|
||||
setIsCreateUserModalVisible,
|
||||
}: OwnershipSectionProps) => {
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<Title className="mb-4">Key Ownership</Title>
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Owned By{" "}
|
||||
<Tooltip title="Select who will own this API key">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
className="mb-4"
|
||||
>
|
||||
<Radio.Group onChange={(e) => setKeyOwner(e.target.value)} value={keyOwner}>
|
||||
<Radio value="you">You</Radio>
|
||||
<Radio value="service_account">Service Account</Radio>
|
||||
{userRole === "Admin" && <Radio value="another_user">Another User</Radio>}
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
|
||||
{keyOwner === "another_user" && (
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
User ID{" "}
|
||||
<Tooltip title="The user who will own this key and be responsible for its usage">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="user_id"
|
||||
className="mt-4"
|
||||
rules={[
|
||||
{
|
||||
required: keyOwner === "another_user",
|
||||
message: `Please input the user ID of the user you are assigning the key to`,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<div>
|
||||
<div style={{ display: "flex", marginBottom: "8px" }}>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Type email to search for users"
|
||||
filterOption={false}
|
||||
onSearch={handleUserSearch}
|
||||
onSelect={(value, option) => handleUserSelect(value, option as UserOption)}
|
||||
options={userOptions}
|
||||
loading={userSearchLoading}
|
||||
allowClear
|
||||
style={{ width: "100%" }}
|
||||
notFoundContent={userSearchLoading ? "Searching..." : "No users found"}
|
||||
/>
|
||||
<Button2 onClick={() => setIsCreateUserModalVisible(true)} style={{ marginLeft: "8px" }}>
|
||||
Create User
|
||||
</Button2>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">Search by email to find users</div>
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item
|
||||
label={
|
||||
<span>
|
||||
Team{" "}
|
||||
<Tooltip title="The team this key belongs to, which determines available models and budget limits">
|
||||
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
}
|
||||
name="team_id"
|
||||
initialValue={team ? team.team_id : null}
|
||||
className="mt-4"
|
||||
rules={[
|
||||
{
|
||||
required: keyOwner === "service_account",
|
||||
message: "Please select a team for the service account",
|
||||
},
|
||||
]}
|
||||
help={keyOwner === "service_account" ? "required" : ""}
|
||||
>
|
||||
<TeamDropdown
|
||||
teams={teams}
|
||||
onChange={(teamId) => {
|
||||
const selectedTeam = teams?.find((t) => t.team_id === teamId) || null;
|
||||
setSelectedCreateKeyTeam(selectedTeam);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OwnershipSection;
|
||||
|
|
@ -0,0 +1,233 @@
|
|||
import { Button as Button2, Form, FormInstance, Modal } from "antd";
|
||||
import { Text } from "@tremor/react";
|
||||
import { keyCreateCall, keyCreateServiceAccountCall } from "@/components/networking";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Team } from "@/components/key_team_helpers/key_list";
|
||||
import SaveKeyModal from "@/app/(console)/virtual-keys/components/SaveKeyModal";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import {
|
||||
useGuardrailsAndPrompts,
|
||||
useMcpAccessGroups,
|
||||
useTeamModels,
|
||||
useUserModels,
|
||||
useUserSearch,
|
||||
} from "@/app/(console)/virtual-keys/components/CreateKeyModal/hooks";
|
||||
import OwnershipSection from "@/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OwnershipSection";
|
||||
import KeyDetailsSection from "@/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/KeyDetailsSection";
|
||||
import OptionalSettingsSection from "@/app/(console)/virtual-keys/components/CreateKeyModal/CreateKeyForm/OptionalSettingsSection";
|
||||
import { ModelAliases } from "@/app/(console)/virtual-keys/components/CreateKeyModal/types";
|
||||
import { getPredefinedTags, prepareFormValues } from "@/app/(console)/virtual-keys/components/CreateKeyModal/utils";
|
||||
import { fetchTeams } from "@/app/(console)/virtual-keys/networking";
|
||||
import useTeams from "@/app/(console)/virtual-keys/hooks/useTeams";
|
||||
import useAuthorized from "@/app/(console)/hooks/useAuthorized";
|
||||
|
||||
export interface CreateKeyModalProps {
|
||||
isModalVisible: boolean;
|
||||
setIsModalVisible: (isModalVisible: boolean) => void;
|
||||
form: FormInstance;
|
||||
handleUserSelect: (_value: string, option: UserOption) => void;
|
||||
setIsCreateUserModalVisible: (visible: boolean) => void;
|
||||
team: Team | null;
|
||||
data: any[] | null;
|
||||
addKey: (data: any) => void;
|
||||
}
|
||||
|
||||
interface User {
|
||||
user_id: string;
|
||||
user_email: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
interface UserOption {
|
||||
label: string;
|
||||
value: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
const CreateKeyModal = ({
|
||||
isModalVisible,
|
||||
setIsModalVisible,
|
||||
form,
|
||||
handleUserSelect,
|
||||
setIsCreateUserModalVisible,
|
||||
team,
|
||||
data,
|
||||
addKey,
|
||||
}: CreateKeyModalProps) => {
|
||||
const { userId: userID, userRole, accessToken, premiumUser } = useAuthorized();
|
||||
const [apiKey, setApiKey] = useState<string | null>(null);
|
||||
const [keyOwner, setKeyOwner] = useState("you");
|
||||
const [keyType, setKeyType] = useState<string>("default");
|
||||
|
||||
const [selectedCreateKeyTeam, setSelectedCreateKeyTeam] = useState<Team | null>(team);
|
||||
|
||||
const [softBudget, setSoftBudget] = useState<string | null>(null);
|
||||
const [loggingSettings, setLoggingSettings] = useState<any[]>([]);
|
||||
const [disabledCallbacks, setDisabledCallbacks] = useState<string[]>([]);
|
||||
const [autoRotationEnabled, setAutoRotationEnabled] = useState<boolean>(false);
|
||||
const [rotationInterval, setRotationInterval] = useState<string>("30d");
|
||||
const [modelAliases, setModelAliases] = useState<ModelAliases>({});
|
||||
const predefinedTags = getPredefinedTags(data);
|
||||
|
||||
const { options: userOptions, loading: userSearchLoading, onSearch: handleUserSearch } = useUserSearch();
|
||||
const { guardrails, prompts } = useGuardrailsAndPrompts();
|
||||
const teams = useTeams();
|
||||
const mcpAccessGroups = useMcpAccessGroups();
|
||||
const userModels = useUserModels();
|
||||
const modelsToPick = useTeamModels(selectedCreateKeyTeam);
|
||||
|
||||
const isTeamSelectionRequired = modelsToPick.includes("no-default-models");
|
||||
const isFormDisabled = isTeamSelectionRequired && !selectedCreateKeyTeam;
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsModalVisible(false);
|
||||
setApiKey(null);
|
||||
setSelectedCreateKeyTeam(null);
|
||||
form.resetFields();
|
||||
setLoggingSettings([]);
|
||||
setDisabledCallbacks([]);
|
||||
setKeyType("default");
|
||||
setModelAliases({});
|
||||
setAutoRotationEnabled(false);
|
||||
setRotationInterval("30d");
|
||||
};
|
||||
|
||||
const handleOk = () => {
|
||||
setIsModalVisible(false);
|
||||
form.resetFields();
|
||||
setLoggingSettings([]);
|
||||
setDisabledCallbacks([]);
|
||||
setKeyType("default");
|
||||
setModelAliases({});
|
||||
setAutoRotationEnabled(false);
|
||||
setRotationInterval("30d");
|
||||
};
|
||||
|
||||
const handleCreate = async (formValues: Record<string, any>) => {
|
||||
try {
|
||||
const newKeyAlias = formValues?.key_alias ?? "";
|
||||
const newKeyTeamId = formValues?.team_id ?? null;
|
||||
|
||||
const existingKeyAliases = data?.filter((k) => k.team_id === newKeyTeamId).map((k) => k.key_alias) ?? [];
|
||||
|
||||
if (existingKeyAliases.includes(newKeyAlias)) {
|
||||
throw new Error(
|
||||
`Key alias ${newKeyAlias} already exists for team with ID ${newKeyTeamId}, please provide another key alias`,
|
||||
);
|
||||
}
|
||||
|
||||
NotificationsManager.info("Making API Call");
|
||||
setIsModalVisible(true);
|
||||
|
||||
const prepared = prepareFormValues(formValues, {
|
||||
keyOwner,
|
||||
userID,
|
||||
loggingSettings,
|
||||
disabledCallbacks,
|
||||
autoRotationEnabled,
|
||||
rotationInterval,
|
||||
modelAliases,
|
||||
});
|
||||
|
||||
let response;
|
||||
if (keyOwner === "service_account") {
|
||||
response = await keyCreateServiceAccountCall(accessToken, prepared);
|
||||
} else {
|
||||
response = await keyCreateCall(accessToken, userID, prepared);
|
||||
}
|
||||
|
||||
// TODO: change logic to trigger API call in parent component
|
||||
addKey(response);
|
||||
|
||||
setApiKey(response["key"]);
|
||||
setSoftBudget(response["soft_budget"]);
|
||||
NotificationsManager.success("API Key Created");
|
||||
form.resetFields();
|
||||
localStorage.removeItem("userData" + userID);
|
||||
} catch (error) {
|
||||
console.log("error in create key:", error);
|
||||
NotificationsManager.fromBackend(`Error creating the key: ${error}`);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldValue("models", []);
|
||||
}, [selectedCreateKeyTeam, accessToken, userID, userRole, form]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Modal open={isModalVisible} width={1000} footer={null} onOk={handleOk} onCancel={handleCancel}>
|
||||
<Form form={form} onFinish={handleCreate} labelCol={{ span: 8 }} wrapperCol={{ span: 16 }} labelAlign="left">
|
||||
<OwnershipSection
|
||||
team={team}
|
||||
teams={teams}
|
||||
userRole={userRole}
|
||||
userOptions={userOptions}
|
||||
keyOwner={keyOwner}
|
||||
setKeyOwner={setKeyOwner}
|
||||
handleUserSearch={handleUserSearch}
|
||||
userSearchLoading={userSearchLoading}
|
||||
handleUserSelect={handleUserSelect}
|
||||
setSelectedCreateKeyTeam={setSelectedCreateKeyTeam}
|
||||
setIsCreateUserModalVisible={setIsCreateUserModalVisible}
|
||||
/>
|
||||
|
||||
{/* Show message when team selection is required */}
|
||||
{isFormDisabled && (
|
||||
<div className="mb-8 p-4 bg-blue-50 border border-blue-200 rounded-md">
|
||||
<Text className="text-blue-800 text-sm">
|
||||
Please select a team to continue configuring your API key. If you do not see any teams, please contact
|
||||
your Proxy Admin to either provide you with access to models or to add you to a team.
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Section 2: Key Details */}
|
||||
{!isFormDisabled && (
|
||||
<KeyDetailsSection
|
||||
form={form}
|
||||
keyOwner={keyOwner}
|
||||
modelsToPick={modelsToPick}
|
||||
keyType={keyType}
|
||||
setKeyType={setKeyType}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Section 3: Optional Settings */}
|
||||
{!isFormDisabled && (
|
||||
<OptionalSettingsSection
|
||||
form={form}
|
||||
team={team}
|
||||
premiumUser={premiumUser}
|
||||
guardrails={guardrails}
|
||||
prompts={prompts}
|
||||
accessToken={accessToken}
|
||||
predefinedTags={predefinedTags}
|
||||
loggingSettings={loggingSettings}
|
||||
setLoggingSettings={setLoggingSettings}
|
||||
disabledCallbacks={disabledCallbacks}
|
||||
setDisabledCallbacks={setDisabledCallbacks}
|
||||
modelAliases={modelAliases}
|
||||
setModelAliases={setModelAliases}
|
||||
autoRotationEnabled={autoRotationEnabled}
|
||||
setAutoRotationEnabled={setAutoRotationEnabled}
|
||||
rotationInterval={rotationInterval}
|
||||
setRotationInterval={setRotationInterval}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ textAlign: "right", marginTop: "10px" }}>
|
||||
<Button2 htmlType="submit" disabled={isFormDisabled} style={{ opacity: isFormDisabled ? 0.5 : 1 }}>
|
||||
Create Key
|
||||
</Button2>
|
||||
</div>
|
||||
</Form>
|
||||
</Modal>
|
||||
{apiKey && (
|
||||
<SaveKeyModal apiKey={apiKey} isModalVisible={isModalVisible} handleOk={handleOk} handleCancel={handleCancel} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateKeyModal;
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export * from "./useGuardrailsAndPrompts";
|
||||
export * from "./useMcpAccessGroups";
|
||||
export * from "./useUserModels";
|
||||
export * from "./useTeamModels";
|
||||
export * from "./useUserSearch";
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { fetchGuardrails, fetchPrompts } from "@/app/(console)/virtual-keys/components/CreateKeyModal/networking";
|
||||
import useAuthorized from "@/app/(console)/hooks/useAuthorized";
|
||||
|
||||
export const useGuardrailsAndPrompts = () => {
|
||||
const [guardrails, setGuardrails] = useState<string[]>([]);
|
||||
const [prompts, setPrompts] = useState<string[]>([]);
|
||||
const { accessToken } = useAuthorized();
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) {
|
||||
setGuardrails([]);
|
||||
setPrompts([]);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
const [g, p] = await Promise.all([fetchGuardrails(accessToken), fetchPrompts(accessToken)]);
|
||||
setGuardrails(g || []);
|
||||
setPrompts(p || []);
|
||||
})();
|
||||
}, [accessToken]);
|
||||
|
||||
return { guardrails, prompts };
|
||||
};
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { getMCPAccessGroups } from "@/app/(console)/virtual-keys/components/CreateKeyModal/networking";
|
||||
import useAuthorized from "@/app/(console)/hooks/useAuthorized";
|
||||
|
||||
export const useMcpAccessGroups = () => {
|
||||
const [mcpAccessGroups, setMcpAccessGroups] = useState<string[]>([]);
|
||||
const { accessToken } = useAuthorized();
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) {
|
||||
setMcpAccessGroups([]);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
const groups = await getMCPAccessGroups(accessToken);
|
||||
setMcpAccessGroups(groups || []);
|
||||
})();
|
||||
}, [accessToken]);
|
||||
|
||||
return mcpAccessGroups;
|
||||
};
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { Team } from "@/components/key_team_helpers/key_list";
|
||||
import { fetchTeamModels } from "@/app/(console)/virtual-keys/components/CreateKeyModal/networking";
|
||||
import useAuthorized from "@/app/(console)/hooks/useAuthorized";
|
||||
|
||||
export const useTeamModels = (selectedTeam: Team | null) => {
|
||||
const { userId: userID, userRole, accessToken } = useAuthorized();
|
||||
const [modelsToPick, setModelsToPick] = useState<string[]>([]);
|
||||
|
||||
// turn array into a stable dep so it only re-runs when contents change
|
||||
const selectedTeamModelsKey = useMemo(() => (selectedTeam?.models ?? []).join("|"), [selectedTeam?.models]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userID || !userRole || !accessToken) {
|
||||
setModelsToPick([]);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
const fetched = await fetchTeamModels(userID, userRole, accessToken, selectedTeam?.team_id ?? null);
|
||||
const union = Array.from(new Set([...(selectedTeam?.models ?? []), ...(fetched || [])]));
|
||||
setModelsToPick(union);
|
||||
})();
|
||||
}, [userID, userRole, accessToken, selectedTeam?.team_id, selectedTeamModelsKey, selectedTeam?.models]);
|
||||
|
||||
return modelsToPick;
|
||||
};
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { getUserModelNames } from "@/app/(console)/virtual-keys/components/CreateKeyModal/networking";
|
||||
import useAuthorized from "@/app/(console)/hooks/useAuthorized";
|
||||
|
||||
export const useUserModels = () => {
|
||||
const { userId: userID, userRole, accessToken } = useAuthorized();
|
||||
const [userModels, setUserModels] = useState<string[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userID || !userRole || !accessToken) {
|
||||
setUserModels([]);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
const modelNames = await getUserModelNames(userID, userRole, accessToken);
|
||||
setUserModels(modelNames || []);
|
||||
})();
|
||||
}, [userID, userRole, accessToken]);
|
||||
|
||||
return userModels;
|
||||
};
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import { debounce } from "lodash";
|
||||
import { searchUserOptionsByEmail } from "@/app/(console)/virtual-keys/components/CreateKeyModal/networking";
|
||||
import useAuthorized from "@/app/(console)/hooks/useAuthorized";
|
||||
|
||||
type User = { user_id: string; user_email: string; role?: string };
|
||||
export interface UserOption {
|
||||
label: string;
|
||||
value: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export const useUserSearch = (delay = 300) => {
|
||||
const { accessToken } = useAuthorized();
|
||||
const [options, setOptions] = useState<UserOption[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const doSearch = async (raw: string) => {
|
||||
const q = raw?.trim();
|
||||
if (!q || !accessToken) {
|
||||
setOptions([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await searchUserOptionsByEmail(accessToken, q);
|
||||
setOptions(res || []);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSearch = useMemo(() => debounce(doSearch, delay), [accessToken, delay]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => onSearch.cancel();
|
||||
}, [onSearch]);
|
||||
|
||||
return { options, loading, onSearch };
|
||||
};
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import {
|
||||
fetchMCPAccessGroups,
|
||||
getGuardrailsList,
|
||||
getPromptsList,
|
||||
modelAvailableCall,
|
||||
User,
|
||||
userFilterUICall,
|
||||
} from "@/components/networking";
|
||||
import { ModelAvailableResponse, UserOption } from "@/app/(console)/virtual-keys/components/CreateKeyModal/types";
|
||||
|
||||
export const fetchGuardrails = async (accessToken: string) => {
|
||||
try {
|
||||
const response = await getGuardrailsList(accessToken);
|
||||
return response.guardrails.map((g: { guardrail_name: string }) => g.guardrail_name);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch guardrails:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const fetchPrompts = async (accessToken: string) => {
|
||||
try {
|
||||
const response = await getPromptsList(accessToken);
|
||||
return response.prompts.map((prompt) => prompt.prompt_id);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch prompts:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const searchUserOptionsByEmail = async (accessToken: string, emailQuery: string): Promise<UserOption[]> => {
|
||||
const params = new URLSearchParams();
|
||||
params.append("user_email", emailQuery);
|
||||
|
||||
const response = await userFilterUICall(accessToken, params);
|
||||
const users: User[] = response;
|
||||
|
||||
return users.map((user) => ({
|
||||
label: `${user.user_email} (${user.user_id})`,
|
||||
value: user.user_id,
|
||||
user,
|
||||
}));
|
||||
};
|
||||
|
||||
export const getUserModelNames = async (userID: string, userRole: string, accessToken: string): Promise<string[]> => {
|
||||
const res: ModelAvailableResponse = await modelAvailableCall(accessToken, userID, userRole);
|
||||
return (res?.data ?? []).map((m) => m.id);
|
||||
};
|
||||
|
||||
export const fetchTeamModels = async (
|
||||
userID: string,
|
||||
userRole: string,
|
||||
accessToken: string,
|
||||
teamID: string | null,
|
||||
): Promise<string[]> => {
|
||||
try {
|
||||
if (userID === null || userRole === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (accessToken !== null) {
|
||||
const model_available = await modelAvailableCall(accessToken, userID, userRole, true, teamID, true);
|
||||
let available_model_names = model_available["data"].map((element: { id: string }) => element.id);
|
||||
console.log("available_model_names:", available_model_names);
|
||||
return available_model_names;
|
||||
}
|
||||
return [];
|
||||
} catch (error) {
|
||||
console.error("Error fetching user models:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const getMCPAccessGroups = async (accessToken: string): Promise<string[]> => {
|
||||
try {
|
||||
if (accessToken == null) {
|
||||
return [];
|
||||
}
|
||||
return await fetchMCPAccessGroups(accessToken);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch MCP access groups:", error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
export interface User {
|
||||
user_id: string;
|
||||
user_email: string;
|
||||
role?: string;
|
||||
}
|
||||
|
||||
export interface UserOption {
|
||||
label: string;
|
||||
value: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export type Model = { id: string };
|
||||
|
||||
export type ModelAvailableResponse = { data: Model[] };
|
||||
|
||||
export type ModelAliases = { [key: string]: string };
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
import { mapDisplayToInternalNames } from "@/components/callback_info_helpers";
|
||||
import { ModelAliases } from "@/app/(console)/virtual-keys/components/CreateKeyModal/types";
|
||||
|
||||
export const getPredefinedTags = (data: any[] | null) => {
|
||||
let allTags = [];
|
||||
|
||||
console.log("data:", JSON.stringify(data));
|
||||
|
||||
if (data) {
|
||||
for (let key of data) {
|
||||
if (key["metadata"] && key["metadata"]["tags"]) {
|
||||
allTags.push(...key["metadata"]["tags"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueTags = Array.from(new Set(allTags)).map((tag) => ({
|
||||
value: tag,
|
||||
label: tag,
|
||||
}));
|
||||
|
||||
console.log("uniqueTags:", uniqueTags);
|
||||
return uniqueTags;
|
||||
};
|
||||
|
||||
type AnyObj = Record<string, any>;
|
||||
|
||||
type PrepareOptions = {
|
||||
keyOwner: string;
|
||||
userID: string;
|
||||
loggingSettings: any[];
|
||||
disabledCallbacks: string[];
|
||||
autoRotationEnabled: boolean;
|
||||
rotationInterval: string;
|
||||
modelAliases: ModelAliases;
|
||||
};
|
||||
|
||||
export function safeParseMetadata(metadataStr: string | undefined): AnyObj {
|
||||
try {
|
||||
return JSON.parse(metadataStr || "{}");
|
||||
} catch (error) {
|
||||
console.error("Error parsing metadata:", error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function withOwnerAssignments(
|
||||
values: AnyObj,
|
||||
{ keyOwner, userID }: Pick<PrepareOptions, "keyOwner" | "userID">,
|
||||
): AnyObj {
|
||||
// If owned by "you", set user_id
|
||||
const base = keyOwner === "you" ? { ...values, user_id: userID } : { ...values };
|
||||
return base;
|
||||
}
|
||||
|
||||
export function enrichMetadata(
|
||||
metadata: AnyObj,
|
||||
values: AnyObj,
|
||||
{
|
||||
keyOwner,
|
||||
loggingSettings,
|
||||
disabledCallbacks,
|
||||
}: Pick<PrepareOptions, "keyOwner" | "loggingSettings" | "disabledCallbacks">,
|
||||
): AnyObj {
|
||||
let next = { ...metadata };
|
||||
|
||||
// If it's a service account, add the service_account_id to the metadata
|
||||
if (keyOwner === "service_account") {
|
||||
next = { ...next, service_account_id: values.key_alias };
|
||||
}
|
||||
|
||||
// Add logging settings to the metadata
|
||||
if (Array.isArray(loggingSettings) && loggingSettings.length > 0) {
|
||||
next = { ...next, logging: loggingSettings.filter((config: any) => config?.callback_name) };
|
||||
}
|
||||
|
||||
// Add disabled callbacks to the metadata
|
||||
if (Array.isArray(disabledCallbacks) && disabledCallbacks.length > 0) {
|
||||
const mappedDisabledCallbacks = mapDisplayToInternalNames(disabledCallbacks);
|
||||
next = { ...next, litellm_disabled_callbacks: mappedDisabledCallbacks };
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function withAutoRotation(
|
||||
values: AnyObj,
|
||||
{ autoRotationEnabled, rotationInterval }: Pick<PrepareOptions, "autoRotationEnabled" | "rotationInterval">,
|
||||
): AnyObj {
|
||||
if (!autoRotationEnabled) return { ...values };
|
||||
return { ...values, auto_rotate: true, rotation_interval: rotationInterval };
|
||||
}
|
||||
|
||||
export function withDurationNoop(values: AnyObj): AnyObj {
|
||||
// Preserve the original no-op logic: if duration exists, set it to itself
|
||||
if (values.duration) {
|
||||
return { ...values, duration: values.duration };
|
||||
}
|
||||
return { ...values };
|
||||
}
|
||||
|
||||
export function mergeObjectPermission(values: AnyObj, patch: AnyObj): AnyObj {
|
||||
const object_permission = { ...(values.object_permission || {}), ...patch };
|
||||
return { ...values, object_permission };
|
||||
}
|
||||
|
||||
export function withVectorStores(values: AnyObj): AnyObj {
|
||||
const { allowed_vector_store_ids, ...rest } = values;
|
||||
if (Array.isArray(allowed_vector_store_ids) && allowed_vector_store_ids.length > 0) {
|
||||
const patched = mergeObjectPermission(rest, { vector_stores: allowed_vector_store_ids });
|
||||
return patched; // original field removed by destructuring
|
||||
}
|
||||
return { ...values };
|
||||
}
|
||||
|
||||
export function withMcpServersAndGroups(values: AnyObj): AnyObj {
|
||||
const { allowed_mcp_servers_and_groups, ...rest } = values;
|
||||
const servers = allowed_mcp_servers_and_groups?.servers;
|
||||
const accessGroups = allowed_mcp_servers_and_groups?.accessGroups;
|
||||
|
||||
const hasServers = Array.isArray(servers) && servers.length > 0;
|
||||
const hasAccessGroups = Array.isArray(accessGroups) && accessGroups.length > 0;
|
||||
|
||||
if (!hasServers && !hasAccessGroups) return { ...values };
|
||||
|
||||
let patched = { ...rest };
|
||||
if (hasServers) patched = mergeObjectPermission(patched, { mcp_servers: servers });
|
||||
if (hasAccessGroups) patched = mergeObjectPermission(patched, { mcp_access_groups: accessGroups });
|
||||
|
||||
// original field removed by destructuring
|
||||
return patched;
|
||||
}
|
||||
|
||||
export function withMcpAccessGroups(values: AnyObj): AnyObj {
|
||||
const { allowed_mcp_access_groups, ...rest } = values;
|
||||
if (Array.isArray(allowed_mcp_access_groups) && allowed_mcp_access_groups.length > 0) {
|
||||
const patched = mergeObjectPermission(rest, { mcp_access_groups: allowed_mcp_access_groups });
|
||||
return patched; // original field removed by destructuring
|
||||
}
|
||||
return { ...values };
|
||||
}
|
||||
|
||||
function withAliases(values: AnyObj, modelAliases: ModelAliases): AnyObj {
|
||||
if (modelAliases && Object.keys(modelAliases).length > 0) {
|
||||
return { ...values, aliases: JSON.stringify(modelAliases) };
|
||||
}
|
||||
return { ...values };
|
||||
}
|
||||
|
||||
function withMetadataString(values: AnyObj, metadata: AnyObj): AnyObj {
|
||||
return { ...values, metadata: JSON.stringify(metadata) };
|
||||
}
|
||||
|
||||
export function prepareFormValues(initialValues: AnyObj, opts: PrepareOptions): AnyObj {
|
||||
// Step 1: assign user id if needed
|
||||
let values = withOwnerAssignments(initialValues, { keyOwner: opts.keyOwner, userID: opts.userID });
|
||||
|
||||
// Step 2: metadata parse & enrichment
|
||||
const parsedMetadata = safeParseMetadata(values.metadata);
|
||||
const enrichedMetadata = enrichMetadata(parsedMetadata, values, {
|
||||
keyOwner: opts.keyOwner,
|
||||
loggingSettings: opts.loggingSettings,
|
||||
disabledCallbacks: opts.disabledCallbacks,
|
||||
});
|
||||
|
||||
// Step 3: auto-rotation
|
||||
values = withAutoRotation(values, {
|
||||
autoRotationEnabled: opts.autoRotationEnabled,
|
||||
rotationInterval: opts.rotationInterval,
|
||||
});
|
||||
|
||||
// Step 4: duration (no-op preservation)
|
||||
values = withDurationNoop(values);
|
||||
|
||||
// Step 5: commit metadata string
|
||||
values = withMetadataString(values, enrichedMetadata);
|
||||
|
||||
// Step 6: object_permission transformations
|
||||
values = withVectorStores(values);
|
||||
values = withMcpServersAndGroups(values);
|
||||
values = withMcpAccessGroups(values);
|
||||
|
||||
// Step 7: aliases
|
||||
values = withAliases(values, opts.modelAliases);
|
||||
|
||||
return values;
|
||||
}
|
||||
|
|
@ -0,0 +1,697 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import {
|
||||
Card,
|
||||
Text,
|
||||
Button,
|
||||
Grid,
|
||||
Tab,
|
||||
TabList,
|
||||
TabGroup,
|
||||
TabPanel,
|
||||
TabPanels,
|
||||
Title,
|
||||
Badge,
|
||||
} from "@tremor/react";
|
||||
import { ArrowLeftIcon, TrashIcon, RefreshIcon } from "@heroicons/react/outline";
|
||||
import { Form, Tooltip, Button as AntdButton } from "antd";
|
||||
import { copyToClipboard as utilCopyToClipboard, formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { CopyIcon, CheckIcon } from "lucide-react";
|
||||
import { KeyResponse } from "@/components/key_team_helpers/key_list"
|
||||
import { mapDisplayToInternalNames, mapInternalToDisplayNames } from "@/components/callback_info_helpers"
|
||||
import NotificationManager from "@/components/molecules/notifications_manager"
|
||||
import { keyDeleteCall, keyUpdateCall } from "@/components/networking"
|
||||
import { parseErrorMessage } from "@/components/shared/errorUtils"
|
||||
import { rolesWithWriteAccess } from "@/utils/roles"
|
||||
import { RegenerateKeyModal } from "@/components/organisms/regenerate_key_modal"
|
||||
import ObjectPermissionsView from "@/components/object_permissions_view"
|
||||
import LoggingSettingsView from "@/components/logging_settings_view"
|
||||
import { extractLoggingSettings, formatMetadataForDisplay } from "@/components/key_info_utils"
|
||||
import AutoRotationView from "@/components/common_components/AutoRotationView"
|
||||
import { KeyEditView } from "@/components/templates/key_edit_view"
|
||||
|
||||
|
||||
interface KeyInfoViewProps {
|
||||
keyId: string;
|
||||
onClose: () => void;
|
||||
keyData: KeyResponse | undefined;
|
||||
onKeyDataUpdate?: (data: Partial<KeyResponse>) => void;
|
||||
onDelete?: () => void;
|
||||
accessToken: string | null;
|
||||
userID: string | null;
|
||||
userRole: string | null;
|
||||
teams: any[] | null;
|
||||
premiumUser: boolean;
|
||||
setAccessToken?: (token: string) => void;
|
||||
backButtonText?: string;
|
||||
}
|
||||
|
||||
const KeyInfoView = ({
|
||||
keyId,
|
||||
onClose,
|
||||
keyData,
|
||||
accessToken,
|
||||
userID,
|
||||
userRole,
|
||||
teams,
|
||||
onKeyDataUpdate,
|
||||
onDelete,
|
||||
premiumUser,
|
||||
setAccessToken,
|
||||
backButtonText = "Back to Keys",
|
||||
}: KeyInfoViewProps) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
const [deleteConfirmInput, setDeleteConfirmInput] = useState("");
|
||||
const [isRegenerateModalOpen, setIsRegenerateModalOpen] = useState(false);
|
||||
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Add local state to maintain key data and track regeneration
|
||||
const [currentKeyData, setCurrentKeyData] = useState<KeyResponse | undefined>(keyData);
|
||||
const [lastRegeneratedAt, setLastRegeneratedAt] = useState<Date | null>(null);
|
||||
const [isRecentlyRegenerated, setIsRecentlyRegenerated] = useState(false);
|
||||
|
||||
// Update local state when keyData prop changes (but don't reset to undefined)
|
||||
useEffect(() => {
|
||||
if (keyData) {
|
||||
setCurrentKeyData(keyData);
|
||||
}
|
||||
}, [keyData]);
|
||||
|
||||
// Reset recent regeneration indicator after 5 seconds
|
||||
useEffect(() => {
|
||||
if (isRecentlyRegenerated) {
|
||||
const timer = setTimeout(() => {
|
||||
setIsRecentlyRegenerated(false);
|
||||
}, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isRecentlyRegenerated]);
|
||||
|
||||
// Use currentKeyData instead of keyData throughout the component
|
||||
if (!currentKeyData) {
|
||||
return (
|
||||
<div className="p-4">
|
||||
<Button icon={ArrowLeftIcon} variant="light" onClick={onClose} className="mb-4">
|
||||
{backButtonText}
|
||||
</Button>
|
||||
<Text>Key not found</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleKeyUpdate = async (formValues: Record<string, any>) => {
|
||||
try {
|
||||
if (!accessToken) return;
|
||||
|
||||
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 = {
|
||||
...currentKeyData.object_permission,
|
||||
vector_stores: formValues.vector_stores || [],
|
||||
};
|
||||
// Remove vector_stores from the top level as it should be in object_permission
|
||||
delete formValues.vector_stores;
|
||||
}
|
||||
|
||||
if (formValues.mcp_servers_and_groups !== undefined) {
|
||||
const { servers, accessGroups } = formValues.mcp_servers_and_groups || { servers: [], accessGroups: [] };
|
||||
formValues.object_permission = {
|
||||
...currentKeyData.object_permission,
|
||||
mcp_servers: servers || [],
|
||||
mcp_access_groups: accessGroups || [],
|
||||
};
|
||||
// Remove mcp_servers_and_groups from the top level as it should be in object_permission
|
||||
delete formValues.mcp_servers_and_groups;
|
||||
}
|
||||
|
||||
// Convert metadata back to an object if it exists and is a string
|
||||
if (formValues.metadata && typeof formValues.metadata === "string") {
|
||||
try {
|
||||
const parsedMetadata = JSON.parse(formValues.metadata);
|
||||
formValues.metadata = {
|
||||
...parsedMetadata,
|
||||
...(formValues.guardrails?.length > 0 ? { guardrails: formValues.guardrails } : {}),
|
||||
...(formValues.logging_settings ? { logging: formValues.logging_settings } : {}),
|
||||
...(formValues.disabled_callbacks?.length > 0
|
||||
? {
|
||||
litellm_disabled_callbacks: mapDisplayToInternalNames(formValues.disabled_callbacks),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error parsing metadata JSON:", error);
|
||||
NotificationManager.error("Invalid metadata JSON");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
formValues.metadata = {
|
||||
...(formValues.metadata || {}),
|
||||
...(formValues.guardrails?.length > 0 ? { guardrails: formValues.guardrails } : {}),
|
||||
...(formValues.logging_settings ? { logging: formValues.logging_settings } : {}),
|
||||
...(formValues.disabled_callbacks?.length > 0
|
||||
? {
|
||||
litellm_disabled_callbacks: mapDisplayToInternalNames(formValues.disabled_callbacks),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
delete formValues.logging_settings;
|
||||
|
||||
// Convert budget_duration to API format
|
||||
if (formValues.budget_duration) {
|
||||
const durationMap: Record<string, string> = {
|
||||
daily: "24h",
|
||||
weekly: "7d",
|
||||
monthly: "30d",
|
||||
};
|
||||
formValues.budget_duration = durationMap[formValues.budget_duration];
|
||||
}
|
||||
|
||||
const newKeyValues = await keyUpdateCall(accessToken, formValues);
|
||||
|
||||
// Update local state
|
||||
setCurrentKeyData((prevData) => (prevData ? { ...prevData, ...newKeyValues } : undefined));
|
||||
|
||||
if (onKeyDataUpdate) {
|
||||
onKeyDataUpdate(newKeyValues);
|
||||
}
|
||||
NotificationManager.success("Key updated successfully");
|
||||
setIsEditing(false);
|
||||
// Refresh key data here if needed
|
||||
} catch (error) {
|
||||
NotificationManager.fromBackend(parseErrorMessage(error));
|
||||
console.error("Error updating key:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
if (!accessToken) return;
|
||||
await keyDeleteCall(accessToken as string, currentKeyData.token || currentKeyData.token_id);
|
||||
NotificationManager.success("Key deleted successfully");
|
||||
if (onDelete) {
|
||||
onDelete();
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Error deleting the key:", error);
|
||||
NotificationManager.fromBackend(error);
|
||||
}
|
||||
// Reset the confirmation input
|
||||
setDeleteConfirmInput("");
|
||||
};
|
||||
|
||||
const copyToClipboard = async (text: string, key: string) => {
|
||||
const success = await utilCopyToClipboard(text);
|
||||
if (success) {
|
||||
setCopiedStates((prev) => ({ ...prev, [key]: true }));
|
||||
setTimeout(() => {
|
||||
setCopiedStates((prev) => ({ ...prev, [key]: false }));
|
||||
}, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegenerateKeyUpdate = (updatedKeyData: Partial<KeyResponse>) => {
|
||||
// Update local state immediately with ALL the new data
|
||||
setCurrentKeyData((prevData) => {
|
||||
if (!prevData) return undefined;
|
||||
const newData = {
|
||||
...prevData,
|
||||
...updatedKeyData, // This should include the new token (key-id)
|
||||
// Update the created_at to show when it was regenerated
|
||||
created_at: new Date().toLocaleString(),
|
||||
};
|
||||
return newData;
|
||||
});
|
||||
|
||||
// Track regeneration timestamp
|
||||
setLastRegeneratedAt(new Date());
|
||||
setIsRecentlyRegenerated(true);
|
||||
|
||||
if (onKeyDataUpdate) {
|
||||
onKeyDataUpdate({
|
||||
...updatedKeyData,
|
||||
created_at: new Date().toLocaleString(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Update the formatTimestamp function to use the desired date format
|
||||
const formatTimestamp = (timestamp: string | Date) => {
|
||||
const date = new Date(timestamp);
|
||||
const dateStr = date.toLocaleDateString("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
});
|
||||
const timeStr = date.toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
hour12: true,
|
||||
});
|
||||
return `${dateStr} at ${timeStr}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full h-screen p-4">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<Button icon={ArrowLeftIcon} variant="light" onClick={onClose} className="mb-4">
|
||||
{backButtonText}
|
||||
</Button>
|
||||
<Title>{currentKeyData.key_alias || "API Key"}</Title>
|
||||
|
||||
<div className="flex items-center cursor-pointer mb-2 space-y-6">
|
||||
<div>
|
||||
<Text className="text-xs text-gray-400 uppercase tracking-wide mt-2">Key ID</Text>
|
||||
<Text className="text-gray-500 font-mono text-sm">{currentKeyData.token_id || currentKeyData.token}</Text>
|
||||
</div>
|
||||
<AntdButton
|
||||
type="text"
|
||||
size="small"
|
||||
icon={copiedStates["key-id"] ? <CheckIcon size={12} /> : <CopyIcon size={12} />}
|
||||
onClick={() => copyToClipboard(currentKeyData.token_id || currentKeyData.token, "key-id")}
|
||||
className={`ml-2 transition-all duration-200${
|
||||
copiedStates["key-id"]
|
||||
? "text-green-600 bg-green-50 border-green-200"
|
||||
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Add timestamp and regeneration indicator */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Text className="text-sm text-gray-500">
|
||||
{currentKeyData.updated_at && currentKeyData.updated_at !== currentKeyData.created_at
|
||||
? `Updated: ${formatTimestamp(currentKeyData.updated_at)}`
|
||||
: `Created: ${formatTimestamp(currentKeyData.created_at)}`}
|
||||
</Text>
|
||||
|
||||
{isRecentlyRegenerated && (
|
||||
<Badge color="green" size="xs" className="animate-pulse">
|
||||
Recently Regenerated
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{lastRegeneratedAt && (
|
||||
<Badge color="blue" size="xs">
|
||||
Regenerated
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{userRole && rolesWithWriteAccess.includes(userRole) && (
|
||||
<div className="flex gap-2">
|
||||
<Tooltip
|
||||
title={!premiumUser ? "This is a LiteLLM Enterprise feature, and requires a valid key to use." : ""}
|
||||
>
|
||||
<span className="inline-block">
|
||||
<Button
|
||||
icon={RefreshIcon}
|
||||
variant="secondary"
|
||||
onClick={() => setIsRegenerateModalOpen(true)}
|
||||
className="flex items-center"
|
||||
disabled={!premiumUser}
|
||||
>
|
||||
Regenerate Key
|
||||
</Button>
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Button
|
||||
icon={TrashIcon}
|
||||
variant="secondary"
|
||||
onClick={() => setIsDeleteModalOpen(true)}
|
||||
className="flex items-center"
|
||||
>
|
||||
Delete Key
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add RegenerateKeyModal */}
|
||||
<RegenerateKeyModal
|
||||
selectedToken={currentKeyData}
|
||||
visible={isRegenerateModalOpen}
|
||||
onClose={() => setIsRegenerateModalOpen(false)}
|
||||
accessToken={accessToken}
|
||||
premiumUser={premiumUser}
|
||||
setAccessToken={setAccessToken}
|
||||
onKeyUpdate={handleRegenerateKeyUpdate}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{isDeleteModalOpen &&
|
||||
(() => {
|
||||
const keyName = currentKeyData?.key_alias || currentKeyData?.token_id || "API Key";
|
||||
const isValid = deleteConfirmInput === keyName;
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl min-h-[380px] py-6 overflow-hidden transform transition-all flex flex-col justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h3 className="text-lg font-semibold text-gray-900">Delete Key</h3>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setDeleteConfirmInput("");
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-500 focus:outline-none"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-6 py-4">
|
||||
<div className="flex items-start gap-3 p-4 bg-red-50 border border-red-100 rounded-md mb-5">
|
||||
<div className="text-red-500 mt-0.5">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.082 16.5c-.77.833.192 2.5 1.732 2.5z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-base font-medium text-red-600">
|
||||
Warning: You are about to delete this API key.
|
||||
</p>
|
||||
<p className="text-base text-red-600 mt-2">
|
||||
This action is irreversible and will immediately revoke access for any applications using this
|
||||
key.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-base text-gray-600 mb-5">Are you sure you want to delete this API key?</p>
|
||||
<div className="mb-5">
|
||||
<label className="block text-base font-medium text-gray-700 mb-2">
|
||||
{`Type `}
|
||||
<span className="underline">{keyName}</span>
|
||||
{` to confirm deletion:`}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={deleteConfirmInput}
|
||||
onChange={(e) => setDeleteConfirmInput(e.target.value)}
|
||||
placeholder="Enter key name exactly"
|
||||
className="w-full px-4 py-3 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 text-base"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 bg-gray-50 flex justify-end gap-4">
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsDeleteModalOpen(false);
|
||||
setDeleteConfirmInput("");
|
||||
}}
|
||||
className="px-5 py-3 bg-white border border-gray-300 rounded-md text-base font-medium text-gray-700 hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={!isValid}
|
||||
className={`px-5 py-3 rounded-md text-base font-medium text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500 ${isValid ? "bg-red-600 hover:bg-red-700" : "bg-red-300 cursor-not-allowed"}`}
|
||||
>
|
||||
Delete Key
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
<TabGroup>
|
||||
<TabList className="mb-4">
|
||||
<Tab>Overview</Tab>
|
||||
<Tab>Settings</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanels>
|
||||
{/* Overview Panel */}
|
||||
<TabPanel>
|
||||
<Grid numItems={1} numItemsSm={2} numItemsLg={3} className="gap-6">
|
||||
<Card>
|
||||
<Text>Spend</Text>
|
||||
<div className="mt-2">
|
||||
<Title>${formatNumberWithCommas(currentKeyData.spend, 4)}</Title>
|
||||
<Text>
|
||||
of{" "}
|
||||
{currentKeyData.max_budget !== null
|
||||
? `$${formatNumberWithCommas(currentKeyData.max_budget)}`
|
||||
: "Unlimited"}
|
||||
</Text>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Text>Rate Limits</Text>
|
||||
<div className="mt-2">
|
||||
<Text>TPM: {currentKeyData.tpm_limit !== null ? currentKeyData.tpm_limit : "Unlimited"}</Text>
|
||||
<Text>RPM: {currentKeyData.rpm_limit !== null ? currentKeyData.rpm_limit : "Unlimited"}</Text>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Text>Models</Text>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{currentKeyData.models && currentKeyData.models.length > 0 ? (
|
||||
currentKeyData.models.map((model, index) => (
|
||||
<Badge key={index} color="red">
|
||||
{model}
|
||||
</Badge>
|
||||
))
|
||||
) : (
|
||||
<Text>No models specified</Text>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<ObjectPermissionsView
|
||||
objectPermission={currentKeyData.object_permission}
|
||||
variant="inline"
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<LoggingSettingsView
|
||||
loggingConfigs={extractLoggingSettings(currentKeyData.metadata)}
|
||||
disabledCallbacks={
|
||||
Array.isArray(currentKeyData.metadata?.litellm_disabled_callbacks)
|
||||
? mapInternalToDisplayNames(currentKeyData.metadata.litellm_disabled_callbacks)
|
||||
: []
|
||||
}
|
||||
variant="card"
|
||||
/>
|
||||
|
||||
<AutoRotationView
|
||||
autoRotate={currentKeyData.auto_rotate}
|
||||
rotationInterval={currentKeyData.rotation_interval}
|
||||
lastRotationAt={currentKeyData.last_rotation_at}
|
||||
keyRotationAt={currentKeyData.key_rotation_at}
|
||||
nextRotationAt={currentKeyData.next_rotation_at}
|
||||
variant="card"
|
||||
/>
|
||||
</Grid>
|
||||
</TabPanel>
|
||||
|
||||
{/* Settings Panel */}
|
||||
<TabPanel>
|
||||
<Card className="overflow-y-auto max-h-[65vh]">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<Title>Key Settings</Title>
|
||||
{!isEditing && userRole && rolesWithWriteAccess.includes(userRole) && (
|
||||
<Button variant="light" onClick={() => setIsEditing(true)}>
|
||||
Edit Settings
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<KeyEditView
|
||||
keyData={currentKeyData}
|
||||
onCancel={() => setIsEditing(false)}
|
||||
onSubmit={handleKeyUpdate}
|
||||
teams={teams}
|
||||
accessToken={accessToken}
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
premiumUser={premiumUser}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text className="font-medium">Key ID</Text>
|
||||
<Text className="font-mono">{currentKeyData.token_id || currentKeyData.token}</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Key Alias</Text>
|
||||
<Text>{currentKeyData.key_alias || "Not Set"}</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Secret Key</Text>
|
||||
<Text className="font-mono">{currentKeyData.key_name}</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Team ID</Text>
|
||||
<Text>{currentKeyData.team_id || "Not Set"}</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Organization</Text>
|
||||
<Text>{currentKeyData.organization_id || "Not Set"}</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Created</Text>
|
||||
<Text>{formatTimestamp(currentKeyData.created_at)}</Text>
|
||||
</div>
|
||||
|
||||
{lastRegeneratedAt && (
|
||||
<div>
|
||||
<Text className="font-medium">Last Regenerated</Text>
|
||||
<div className="flex items-center gap-2">
|
||||
<Text>{formatTimestamp(lastRegeneratedAt)}</Text>
|
||||
<Badge color="green" size="xs">
|
||||
Recent
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Expires</Text>
|
||||
<Text>{currentKeyData.expires ? formatTimestamp(currentKeyData.expires) : "Never"}</Text>
|
||||
</div>
|
||||
|
||||
<AutoRotationView
|
||||
autoRotate={currentKeyData.auto_rotate}
|
||||
rotationInterval={currentKeyData.rotation_interval}
|
||||
lastRotationAt={currentKeyData.last_rotation_at}
|
||||
keyRotationAt={currentKeyData.key_rotation_at}
|
||||
nextRotationAt={currentKeyData.next_rotation_at}
|
||||
variant="inline"
|
||||
className="pt-4 border-t border-gray-200"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Spend</Text>
|
||||
<Text>${formatNumberWithCommas(currentKeyData.spend, 4)} USD</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Budget</Text>
|
||||
<Text>
|
||||
{currentKeyData.max_budget !== null
|
||||
? `$${formatNumberWithCommas(currentKeyData.max_budget, 2)}`
|
||||
: "Unlimited"}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Prompts</Text>
|
||||
<Text>
|
||||
{Array.isArray(currentKeyData.metadata?.prompts) && currentKeyData.metadata.prompts.length > 0
|
||||
? currentKeyData.metadata.prompts.map((prompt, index) => (
|
||||
<span key={index} className="px-2 mr-2 py-1 bg-blue-100 rounded text-xs">
|
||||
{prompt}
|
||||
</span>
|
||||
))
|
||||
: "No prompts specified"}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Models</Text>
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{currentKeyData.models && currentKeyData.models.length > 0 ? (
|
||||
currentKeyData.models.map((model, index) => (
|
||||
<span key={index} className="px-2 py-1 bg-blue-100 rounded text-xs">
|
||||
{model}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<Text>No models specified</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Rate Limits</Text>
|
||||
<Text>TPM: {currentKeyData.tpm_limit !== null ? currentKeyData.tpm_limit : "Unlimited"}</Text>
|
||||
<Text>RPM: {currentKeyData.rpm_limit !== null ? currentKeyData.rpm_limit : "Unlimited"}</Text>
|
||||
<Text>
|
||||
Max Parallel Requests:{" "}
|
||||
{currentKeyData.max_parallel_requests !== null
|
||||
? currentKeyData.max_parallel_requests
|
||||
: "Unlimited"}
|
||||
</Text>
|
||||
<Text>
|
||||
Model TPM Limits:{" "}
|
||||
{currentKeyData.metadata?.model_tpm_limit
|
||||
? JSON.stringify(currentKeyData.metadata.model_tpm_limit)
|
||||
: "Unlimited"}
|
||||
</Text>
|
||||
<Text>
|
||||
Model RPM Limits:{" "}
|
||||
{currentKeyData.metadata?.model_rpm_limit
|
||||
? JSON.stringify(currentKeyData.metadata.model_rpm_limit)
|
||||
: "Unlimited"}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text className="font-medium">Metadata</Text>
|
||||
<pre className="bg-gray-100 p-2 rounded text-xs overflow-auto mt-1">
|
||||
{formatMetadataForDisplay(currentKeyData.metadata)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<ObjectPermissionsView
|
||||
objectPermission={currentKeyData.object_permission}
|
||||
variant="inline"
|
||||
className="pt-4 border-t border-gray-200"
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
|
||||
<LoggingSettingsView
|
||||
loggingConfigs={extractLoggingSettings(currentKeyData.metadata)}
|
||||
disabledCallbacks={
|
||||
Array.isArray(currentKeyData.metadata?.litellm_disabled_callbacks)
|
||||
? mapInternalToDisplayNames(currentKeyData.metadata.litellm_disabled_callbacks)
|
||||
: []
|
||||
}
|
||||
variant="inline"
|
||||
className="pt-4 border-t border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</TabPanel>
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default KeyInfoView;
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import { Button, Col, Grid, Text, Title } from "@tremor/react";
|
||||
import { CopyToClipboard } from "react-copy-to-clipboard";
|
||||
import { Modal } from "antd";
|
||||
import React from "react";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
|
||||
export interface SaveKeyModalProps {
|
||||
apiKey: string;
|
||||
isModalVisible: boolean;
|
||||
handleOk: () => void;
|
||||
handleCancel: () => void;
|
||||
}
|
||||
|
||||
const SaveKeyModal = ({ apiKey, isModalVisible, handleOk, handleCancel }: SaveKeyModalProps) => {
|
||||
const handleCopy = () => {
|
||||
NotificationsManager.success("API Key copied to clipboard");
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={isModalVisible} onOk={handleOk} onCancel={handleCancel} footer={null}>
|
||||
<Grid numItems={1} className="gap-2 w-full">
|
||||
<Title>Save your Key</Title>
|
||||
<Col numColSpan={1}>
|
||||
<p>
|
||||
Please save this secret key somewhere safe and accessible. For security reasons,{" "}
|
||||
<b>you will not be able to view it again</b> through your LiteLLM account. If you lose this secret key, you
|
||||
will need to generate a new one.
|
||||
</p>
|
||||
</Col>
|
||||
<Col numColSpan={1}>
|
||||
{apiKey != null ? (
|
||||
<div>
|
||||
<Text className="mt-3">API Key:</Text>
|
||||
<div
|
||||
style={{
|
||||
background: "#f8f8f8",
|
||||
padding: "10px",
|
||||
borderRadius: "5px",
|
||||
marginBottom: "10px",
|
||||
}}
|
||||
>
|
||||
<pre style={{ wordWrap: "break-word", whiteSpace: "normal" }}>{apiKey}</pre>
|
||||
</div>
|
||||
|
||||
<CopyToClipboard text={apiKey} onCopy={handleCopy}>
|
||||
<Button className="mt-3">Copy API Key</Button>
|
||||
</CopyToClipboard>
|
||||
</div>
|
||||
) : (
|
||||
<Text>Key being created, this might take 30s</Text>
|
||||
)}
|
||||
</Col>
|
||||
</Grid>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SaveKeyModal;
|
||||
|
|
@ -0,0 +1,666 @@
|
|||
// TODO: refactor
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { Button } from "@tremor/react";
|
||||
import { Tooltip } from "antd";
|
||||
import { updateExistingKeys } from "@/utils/dataUtils";
|
||||
import { flexRender, getCoreRowModel, getSortedRowModel, SortingState, useReactTable } from "@tanstack/react-table";
|
||||
import { Table, TableHead, TableHeaderCell, TableBody, TableRow, TableCell, Icon } from "@tremor/react";
|
||||
import { SwitchVerticalIcon, ChevronUpIcon, ChevronDownIcon, ChevronRightIcon } from "@heroicons/react/outline";
|
||||
import { Badge, Text } from "@tremor/react";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
import { KeyResponse, Team } from "@/components/key_team_helpers/key_list";
|
||||
import { Organization, userListCall } from "@/components/networking";
|
||||
import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key";
|
||||
import FilterComponent, { FilterOption } from "@/components/molecules/filter";
|
||||
import KeyInfoView from "@/components/templates/key_info_view";
|
||||
import { useFilterLogic } from "@/components/key_team_helpers/filter_logic";
|
||||
import useTeams from "@/app/(console)/virtual-keys/hooks/useTeams";
|
||||
import useAuthorized from "@/app/(console)/hooks/useAuthorized";
|
||||
|
||||
interface AllKeysTableProps {
|
||||
keys: KeyResponse[];
|
||||
setKeys: (keys: KeyResponse[] | ((prev: KeyResponse[]) => KeyResponse[])) => void;
|
||||
isLoading?: boolean;
|
||||
pagination: {
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
totalCount: number;
|
||||
};
|
||||
onPageChange: (page: number) => void;
|
||||
pageSize?: number;
|
||||
organizations: Organization[] | null;
|
||||
refresh?: () => void;
|
||||
onSortChange?: (sortBy: string, sortOrder: "asc" | "desc") => void;
|
||||
currentSort?: {
|
||||
sortBy: string;
|
||||
sortOrder: "asc" | "desc";
|
||||
};
|
||||
setAccessToken?: (token: string) => void;
|
||||
}
|
||||
|
||||
interface UserResponse {
|
||||
user_id: string;
|
||||
user_email: string;
|
||||
user_role: string;
|
||||
}
|
||||
|
||||
const VirtualKeysTable = ({
|
||||
keys,
|
||||
setKeys,
|
||||
isLoading = false,
|
||||
pagination,
|
||||
onPageChange,
|
||||
pageSize = 50,
|
||||
organizations,
|
||||
refresh,
|
||||
onSortChange,
|
||||
currentSort,
|
||||
setAccessToken,
|
||||
}: AllKeysTableProps) => {
|
||||
const { userId: userID, userRole, accessToken, premiumUser } = useAuthorized();
|
||||
const teams = useTeams();
|
||||
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null);
|
||||
const [userList, setUserList] = useState<UserResponse[]>([]);
|
||||
const [sorting, setSorting] = React.useState<SortingState>(() => {
|
||||
if (currentSort) {
|
||||
return [
|
||||
{
|
||||
id: currentSort.sortBy,
|
||||
desc: currentSort.sortOrder === "desc",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
id: "created_at",
|
||||
desc: true,
|
||||
},
|
||||
];
|
||||
});
|
||||
const [expandedAccordions, setExpandedAccordions] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Use the filter logic hook
|
||||
|
||||
const { filters, filteredKeys, allKeyAliases, allTeams, allOrganizations, handleFilterChange, handleFilterReset } =
|
||||
useFilterLogic({
|
||||
keys,
|
||||
teams,
|
||||
organizations,
|
||||
accessToken,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (accessToken) {
|
||||
const user_IDs = keys.map((key) => key.user_id).filter((id) => id !== null);
|
||||
const fetchUserList = async () => {
|
||||
const userListData = await userListCall(accessToken, user_IDs, 1, 100);
|
||||
setUserList(userListData.users);
|
||||
};
|
||||
fetchUserList();
|
||||
}
|
||||
}, [accessToken, keys]);
|
||||
|
||||
// Add a useEffect to call refresh when a key is created
|
||||
useEffect(() => {
|
||||
if (refresh) {
|
||||
const handleStorageChange = () => {
|
||||
refresh();
|
||||
};
|
||||
|
||||
// Listen for storage events that might indicate a key was created
|
||||
window.addEventListener("storage", handleStorageChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("storage", handleStorageChange);
|
||||
};
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
const columns: ColumnDef<KeyResponse>[] = [
|
||||
{
|
||||
id: "expander",
|
||||
header: () => null,
|
||||
cell: ({ row }) =>
|
||||
row.getCanExpand() ? (
|
||||
<button onClick={row.getToggleExpandedHandler()} style={{ cursor: "pointer" }}>
|
||||
{row.getIsExpanded() ? "▼" : "▶"}
|
||||
</button>
|
||||
) : null,
|
||||
},
|
||||
{
|
||||
id: "token",
|
||||
accessorKey: "token",
|
||||
header: "Key ID",
|
||||
cell: (info) => (
|
||||
<div className="overflow-hidden">
|
||||
<Tooltip title={info.getValue() as string}>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
className="font-mono text-blue-500 bg-blue-50 hover:bg-blue-100 text-xs font-normal px-2 py-0.5 text-left overflow-hidden truncate max-w-[200px]"
|
||||
onClick={() => setSelectedKeyId(info.getValue() as string)}
|
||||
>
|
||||
{info.getValue() ? `${(info.getValue() as string).slice(0, 7)}...` : "-"}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "key_alias",
|
||||
accessorKey: "key_alias",
|
||||
header: "Key Alias",
|
||||
cell: (info) => {
|
||||
const value = info.getValue() as string;
|
||||
return (
|
||||
<Tooltip title={value}>{value ? (value.length > 20 ? `${value.slice(0, 20)}...` : value) : "-"}</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "key_name",
|
||||
accessorKey: "key_name",
|
||||
header: "Secret Key",
|
||||
cell: (info) => <span className="font-mono text-xs">{info.getValue() as string}</span>,
|
||||
},
|
||||
{
|
||||
id: "team_alias",
|
||||
accessorKey: "team_id",
|
||||
header: "Team Alias",
|
||||
cell: ({ row, getValue }) => {
|
||||
const teamId = getValue() as string;
|
||||
const team = teams?.find((t) => t.team_id === teamId);
|
||||
return team?.team_alias || "Unknown";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "team_id",
|
||||
accessorKey: "team_id",
|
||||
header: "Team ID",
|
||||
cell: (info) => (
|
||||
<Tooltip title={info.getValue() as string}>
|
||||
{info.getValue() ? `${(info.getValue() as string).slice(0, 7)}...` : "-"}
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "organization_id",
|
||||
accessorKey: "organization_id",
|
||||
header: "Organization ID",
|
||||
cell: (info) => (info.getValue() ? info.renderValue() : "-"),
|
||||
},
|
||||
{
|
||||
id: "user_email",
|
||||
accessorKey: "user_id",
|
||||
header: "User Email",
|
||||
cell: (info) => {
|
||||
const userId = info.getValue() as string;
|
||||
const user = userList.find((u) => u.user_id === userId);
|
||||
return user?.user_email ? (
|
||||
<Tooltip title={user?.user_email}>
|
||||
<span>{user?.user_email.slice(0, 20)}...</span>
|
||||
</Tooltip>
|
||||
) : (
|
||||
"-"
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "user_id",
|
||||
accessorKey: "user_id",
|
||||
header: "User ID",
|
||||
cell: (info) => {
|
||||
const userId = info.getValue() as string | null;
|
||||
if (userId && userId.length > 15) {
|
||||
return (
|
||||
<Tooltip title={userId}>
|
||||
<span>{userId.slice(0, 7)}...</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return userId ? userId : "-";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "created_at",
|
||||
accessorKey: "created_at",
|
||||
header: "Created At",
|
||||
cell: (info) => {
|
||||
const value = info.getValue();
|
||||
return value ? new Date(value as string).toLocaleDateString() : "-";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "created_by",
|
||||
accessorKey: "created_by",
|
||||
header: "Created By",
|
||||
cell: (info) => {
|
||||
const value = info.getValue() as string | null;
|
||||
if (value && value.length > 15) {
|
||||
return (
|
||||
<Tooltip title={value}>
|
||||
<span>{value.slice(0, 7)}...</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "updated_at",
|
||||
accessorKey: "updated_at",
|
||||
header: "Updated At",
|
||||
cell: (info) => {
|
||||
const value = info.getValue();
|
||||
return value ? new Date(value as string).toLocaleDateString() : "Never";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "expires",
|
||||
accessorKey: "expires",
|
||||
header: "Expires",
|
||||
cell: (info) => {
|
||||
const value = info.getValue();
|
||||
return value ? new Date(value as string).toLocaleDateString() : "Never";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "spend",
|
||||
accessorKey: "spend",
|
||||
header: "Spend (USD)",
|
||||
cell: (info) => formatNumberWithCommas(info.getValue() as number, 4),
|
||||
},
|
||||
{
|
||||
id: "max_budget",
|
||||
accessorKey: "max_budget",
|
||||
header: "Budget (USD)",
|
||||
cell: (info) => {
|
||||
const maxBudget = info.getValue() as number | null;
|
||||
if (maxBudget === null) {
|
||||
return "Unlimited";
|
||||
}
|
||||
return `$${formatNumberWithCommas(maxBudget)}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "budget_reset_at",
|
||||
accessorKey: "budget_reset_at",
|
||||
header: "Budget Reset",
|
||||
cell: (info) => {
|
||||
const value = info.getValue();
|
||||
return value ? new Date(value as string).toLocaleString() : "Never";
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
accessorKey: "models",
|
||||
header: "Models",
|
||||
cell: (info) => {
|
||||
const models = info.getValue() as string[];
|
||||
return (
|
||||
<div className="flex flex-col py-2">
|
||||
{Array.isArray(models) ? (
|
||||
<div className="flex flex-col">
|
||||
{models.length === 0 ? (
|
||||
<Badge size={"xs"} className="mb-1" color="red">
|
||||
<Text>All Proxy Models</Text>
|
||||
</Badge>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start">
|
||||
{models.length > 3 && (
|
||||
<div>
|
||||
<Icon
|
||||
icon={expandedAccordions[info.row.id] ? ChevronDownIcon : ChevronRightIcon}
|
||||
className="cursor-pointer"
|
||||
size="xs"
|
||||
onClick={() => {
|
||||
setExpandedAccordions((prev) => ({
|
||||
...prev,
|
||||
[info.row.id]: !prev[info.row.id],
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{models.slice(0, 3).map((model, index) =>
|
||||
model === "all-proxy-models" ? (
|
||||
<Badge key={index} size={"xs"} color="red">
|
||||
<Text>All Proxy Models</Text>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge key={index} size={"xs"} color="blue">
|
||||
<Text>
|
||||
{model.length > 30
|
||||
? `${getModelDisplayName(model).slice(0, 30)}...`
|
||||
: getModelDisplayName(model)}
|
||||
</Text>
|
||||
</Badge>
|
||||
),
|
||||
)}
|
||||
{models.length > 3 && !expandedAccordions[info.row.id] && (
|
||||
<Badge size={"xs"} color="gray" className="cursor-pointer">
|
||||
<Text>
|
||||
+{models.length - 3} {models.length - 3 === 1 ? "more model" : "more models"}
|
||||
</Text>
|
||||
</Badge>
|
||||
)}
|
||||
{expandedAccordions[info.row.id] && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{models.slice(3).map((model, index) =>
|
||||
model === "all-proxy-models" ? (
|
||||
<Badge key={index + 3} size={"xs"} color="red">
|
||||
<Text>All Proxy Models</Text>
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge key={index + 3} size={"xs"} color="blue">
|
||||
<Text>
|
||||
{model.length > 30
|
||||
? `${getModelDisplayName(model).slice(0, 30)}...`
|
||||
: getModelDisplayName(model)}
|
||||
</Text>
|
||||
</Badge>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "rate_limits",
|
||||
header: "Rate Limits",
|
||||
cell: ({ row }) => {
|
||||
const key = row.original;
|
||||
return (
|
||||
<div>
|
||||
<div>TPM: {key.tpm_limit !== null ? key.tpm_limit : "Unlimited"}</div>
|
||||
<div>RPM: {key.rpm_limit !== null ? key.rpm_limit : "Unlimited"}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const filterOptions: FilterOption[] = [
|
||||
{
|
||||
name: "Team ID",
|
||||
label: "Team ID",
|
||||
isSearchable: true,
|
||||
searchFn: async (searchText: string) => {
|
||||
if (!allTeams || allTeams.length === 0) return [];
|
||||
|
||||
const filteredTeams = allTeams.filter(
|
||||
(team) =>
|
||||
team.team_id.toLowerCase().includes(searchText.toLowerCase()) ||
|
||||
(team.team_alias && team.team_alias.toLowerCase().includes(searchText.toLowerCase())),
|
||||
);
|
||||
|
||||
return filteredTeams.map((team) => ({
|
||||
label: `${team.team_alias || team.team_id} (${team.team_id})`,
|
||||
value: team.team_id,
|
||||
}));
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Organization ID",
|
||||
label: "Organization ID",
|
||||
isSearchable: true,
|
||||
searchFn: async (searchText: string) => {
|
||||
if (!allOrganizations || allOrganizations.length === 0) return [];
|
||||
|
||||
const filteredOrgs = allOrganizations.filter(
|
||||
(org) => org.organization_id?.toLowerCase().includes(searchText.toLowerCase()) ?? false,
|
||||
);
|
||||
|
||||
return filteredOrgs
|
||||
.filter((org) => org.organization_id !== null && org.organization_id !== undefined)
|
||||
.map((org) => ({
|
||||
label: `${org.organization_id || "Unknown"} (${org.organization_id})`,
|
||||
value: org.organization_id as string,
|
||||
}));
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Key Alias",
|
||||
label: "Key Alias",
|
||||
isSearchable: true,
|
||||
searchFn: async (searchText) => {
|
||||
const filteredKeyAliases = allKeyAliases.filter((key) => {
|
||||
return key.toLowerCase().includes(searchText.toLowerCase());
|
||||
});
|
||||
|
||||
return filteredKeyAliases.map((key) => {
|
||||
return {
|
||||
label: key,
|
||||
value: key,
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "User ID",
|
||||
label: "User ID",
|
||||
isSearchable: false,
|
||||
},
|
||||
{
|
||||
name: "Key Hash",
|
||||
label: "Key Hash",
|
||||
isSearchable: false,
|
||||
},
|
||||
];
|
||||
|
||||
console.log(`keys: ${JSON.stringify(keys)}`);
|
||||
|
||||
const table = useReactTable({
|
||||
data: filteredKeys,
|
||||
columns: columns.filter((col) => col.id !== "expander"),
|
||||
state: {
|
||||
sorting,
|
||||
},
|
||||
onSortingChange: (updaterOrValue) => {
|
||||
const newSorting = typeof updaterOrValue === "function" ? updaterOrValue(sorting) : updaterOrValue;
|
||||
console.log(`newSorting: ${JSON.stringify(newSorting)}`);
|
||||
setSorting(newSorting);
|
||||
if (newSorting && newSorting.length > 0) {
|
||||
const sortState = newSorting[0];
|
||||
const sortBy = sortState.id;
|
||||
const sortOrder = sortState.desc ? "desc" : "asc";
|
||||
console.log(`sortBy: ${sortBy}, sortOrder: ${sortOrder}`);
|
||||
handleFilterChange({
|
||||
...filters,
|
||||
"Sort By": sortBy,
|
||||
"Sort Order": sortOrder,
|
||||
});
|
||||
onSortChange?.(sortBy, sortOrder);
|
||||
}
|
||||
},
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
enableSorting: true,
|
||||
manualSorting: false,
|
||||
});
|
||||
|
||||
// Update local sorting state when currentSort prop changes
|
||||
React.useEffect(() => {
|
||||
if (currentSort) {
|
||||
setSorting([
|
||||
{
|
||||
id: currentSort.sortBy,
|
||||
desc: currentSort.sortOrder === "desc",
|
||||
},
|
||||
]);
|
||||
}
|
||||
}, [currentSort]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full overflow-hidden">
|
||||
{selectedKeyId ? (
|
||||
<KeyInfoView
|
||||
keyId={selectedKeyId}
|
||||
onClose={() => setSelectedKeyId(null)}
|
||||
keyData={filteredKeys.find((k) => k.token === selectedKeyId)}
|
||||
onKeyDataUpdate={(updatedKeyData) => {
|
||||
setKeys((keys) =>
|
||||
keys.map((key) => {
|
||||
if (key.token === updatedKeyData.token) {
|
||||
return updateExistingKeys(key, updatedKeyData);
|
||||
}
|
||||
return key;
|
||||
}),
|
||||
);
|
||||
if (refresh) refresh(); // Minimal fix: refresh the full key list after an update
|
||||
}}
|
||||
onDelete={() => {
|
||||
setKeys((keys) => keys.filter((key) => key.token !== selectedKeyId));
|
||||
if (refresh) refresh(); // Minimal fix: refresh the full key list after a delete
|
||||
}}
|
||||
accessToken={accessToken}
|
||||
userID={userID}
|
||||
userRole={userRole}
|
||||
teams={allTeams}
|
||||
premiumUser={premiumUser}
|
||||
setAccessToken={setAccessToken}
|
||||
/>
|
||||
) : (
|
||||
<div className="border-b py-4 flex-1 overflow-hidden">
|
||||
<div className="w-full mb-6">
|
||||
<FilterComponent
|
||||
options={filterOptions}
|
||||
onApplyFilters={handleFilterChange}
|
||||
initialValues={filters}
|
||||
onResetFilters={handleFilterReset}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between w-full mb-4">
|
||||
<span className="inline-flex text-sm text-gray-700">
|
||||
Showing{" "}
|
||||
{isLoading
|
||||
? "..."
|
||||
: `${(pagination.currentPage - 1) * pageSize + 1} - ${Math.min(pagination.currentPage * pageSize, pagination.totalCount)}`}{" "}
|
||||
of {isLoading ? "..." : pagination.totalCount} results
|
||||
</span>
|
||||
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span className="text-sm text-gray-700">
|
||||
Page {isLoading ? "..." : pagination.currentPage} of {isLoading ? "..." : pagination.totalPages}
|
||||
</span>
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(pagination.currentPage - 1)}
|
||||
disabled={isLoading || pagination.currentPage === 1}
|
||||
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onPageChange(pagination.currentPage + 1)}
|
||||
disabled={isLoading || pagination.currentPage === pagination.totalPages}
|
||||
className="px-3 py-1 text-sm border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-[75vh] overflow-auto">
|
||||
<div className="rounded-lg custom-border relative">
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="[&_td]:py-0.5 [&_th]:py-1">
|
||||
<TableHead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHeaderCell
|
||||
key={header.id}
|
||||
className={`py-1 h-8 ${
|
||||
header.id === "actions"
|
||||
? "sticky right-0 bg-white shadow-[-4px_0_8px_-6px_rgba(0,0,0,0.1)]"
|
||||
: ""
|
||||
}`}
|
||||
onClick={header.column.getToggleSortingHandler()}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</div>
|
||||
{header.id !== "actions" && (
|
||||
<div className="w-4">
|
||||
{header.column.getIsSorted() ? (
|
||||
{
|
||||
asc: <ChevronUpIcon className="h-4 w-4 text-blue-500" />,
|
||||
desc: <ChevronDownIcon className="h-4 w-4 text-blue-500" />,
|
||||
}[header.column.getIsSorted() as string]
|
||||
) : (
|
||||
<SwitchVerticalIcon className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableHeaderCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-8 text-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>🚅 Loading keys...</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredKeys.length > 0 ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} className="h-8">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={{
|
||||
maxWidth: "8-x",
|
||||
whiteSpace: "pre-wrap",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
className={`py-0.5 max-h-8 overflow-hidden text-ellipsis whitespace-nowrap ${cell.column.id === "models" && (cell.getValue() as string[]).length > 3 ? "px-0" : ""}`}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={columns.length} className="h-8 text-center">
|
||||
<div className="text-center text-gray-500">
|
||||
<p>No keys found</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VirtualKeysTable;
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
import { useCallback, useEffect, useState, useRef } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { debounce } from "lodash";
|
||||
import { KeyResponse, Team } from "@/components/key_team_helpers/key_list";
|
||||
import { keyListCall, Organization } from "@/components/networking";
|
||||
import { defaultPageSize } from "@/components/constants";
|
||||
import { fetchAllKeyAliases, fetchAllOrganizations, fetchAllTeams } from "@/components/key_team_helpers/filter_helpers";
|
||||
|
||||
export interface FilterState {
|
||||
"Team ID": string;
|
||||
"Organization ID": string;
|
||||
"Key Alias": string;
|
||||
[key: string]: string;
|
||||
"User ID": string;
|
||||
"Sort By": string;
|
||||
"Sort Order": string;
|
||||
}
|
||||
|
||||
export function useFilterLogic({
|
||||
keys,
|
||||
teams,
|
||||
organizations,
|
||||
accessToken,
|
||||
}: {
|
||||
keys: KeyResponse[];
|
||||
teams: Team[] | null;
|
||||
organizations: Organization[] | null;
|
||||
accessToken: string | null;
|
||||
}) {
|
||||
const defaultFilters: FilterState = {
|
||||
"Team ID": "",
|
||||
"Organization ID": "",
|
||||
"Key Alias": "",
|
||||
"User ID": "",
|
||||
"Sort By": "created_at",
|
||||
"Sort Order": "desc",
|
||||
};
|
||||
const [filters, setFilters] = useState<FilterState>(defaultFilters);
|
||||
const [allTeams, setAllTeams] = useState<Team[]>(teams || []);
|
||||
const [allOrganizations, setAllOrganizations] = useState<Organization[]>(organizations || []);
|
||||
const [filteredKeys, setFilteredKeys] = useState<KeyResponse[]>(keys);
|
||||
const lastSearchTimestamp = useRef(0);
|
||||
const debouncedSearch = useCallback(
|
||||
debounce(async (filters: FilterState) => {
|
||||
if (!accessToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTimestamp = Date.now();
|
||||
lastSearchTimestamp.current = currentTimestamp;
|
||||
|
||||
try {
|
||||
// Make the API call using userListCall with all filter parameters
|
||||
const data = await keyListCall(
|
||||
accessToken,
|
||||
filters["Organization ID"] || null,
|
||||
filters["Team ID"] || null,
|
||||
filters["Key Alias"] || null,
|
||||
filters["User ID"] || null,
|
||||
filters["Key Hash"] || null,
|
||||
1, // Reset to first page when searching
|
||||
defaultPageSize,
|
||||
filters["Sort By"] || null,
|
||||
filters["Sort Order"] || null,
|
||||
);
|
||||
|
||||
// Only update state if this is the most recent search
|
||||
if (currentTimestamp === lastSearchTimestamp.current) {
|
||||
if (data) {
|
||||
setFilteredKeys(data.keys);
|
||||
console.log("called from debouncedSearch filters:", JSON.stringify(filters));
|
||||
console.log("called from debouncedSearch data:", JSON.stringify(data));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error searching users:", error);
|
||||
}
|
||||
}, 300),
|
||||
[accessToken],
|
||||
);
|
||||
// Apply filters to keys whenever keys or filters change
|
||||
useEffect(() => {
|
||||
if (!keys) {
|
||||
setFilteredKeys([]);
|
||||
return;
|
||||
}
|
||||
|
||||
let result = [...keys];
|
||||
|
||||
// Apply Team ID filter
|
||||
if (filters["Team ID"]) {
|
||||
result = result.filter((key) => key.team_id === filters["Team ID"]);
|
||||
}
|
||||
|
||||
// Apply Organization ID filter
|
||||
if (filters["Organization ID"]) {
|
||||
result = result.filter((key) => key.organization_id === filters["Organization ID"]);
|
||||
}
|
||||
|
||||
setFilteredKeys(result);
|
||||
}, [keys, filters]);
|
||||
|
||||
// Fetch all data for filters when component mounts
|
||||
useEffect(() => {
|
||||
const loadAllFilterData = async () => {
|
||||
// Load all teams - no organization filter needed here
|
||||
const teamsData = await fetchAllTeams(accessToken);
|
||||
if (teamsData.length > 0) {
|
||||
setAllTeams(teamsData);
|
||||
}
|
||||
|
||||
// Load all organizations
|
||||
const orgsData = await fetchAllOrganizations(accessToken);
|
||||
if (orgsData.length > 0) {
|
||||
setAllOrganizations(orgsData);
|
||||
}
|
||||
};
|
||||
|
||||
if (accessToken) {
|
||||
loadAllFilterData();
|
||||
}
|
||||
}, [accessToken]);
|
||||
|
||||
const queryAllKeysQuery = useQuery({
|
||||
queryKey: ["allKeys"],
|
||||
queryFn: async () => {
|
||||
if (!accessToken) throw new Error("Access token required");
|
||||
return await fetchAllKeyAliases(accessToken);
|
||||
},
|
||||
enabled: !!accessToken,
|
||||
});
|
||||
const allKeyAliases = queryAllKeysQuery.data || [];
|
||||
|
||||
// Update teams and organizations when props change
|
||||
useEffect(() => {
|
||||
if (teams && teams.length > 0) {
|
||||
setAllTeams((prevTeams) => {
|
||||
// Only update if we don't already have a larger set of teams
|
||||
return prevTeams.length < teams.length ? teams : prevTeams;
|
||||
});
|
||||
}
|
||||
}, [teams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (organizations && organizations.length > 0) {
|
||||
setAllOrganizations((prevOrgs) => {
|
||||
// Only update if we don't already have a larger set of organizations
|
||||
return prevOrgs.length < organizations.length ? organizations : prevOrgs;
|
||||
});
|
||||
}
|
||||
}, [organizations]);
|
||||
|
||||
const handleFilterChange = (newFilters: Record<string, string>) => {
|
||||
// Update filters state
|
||||
setFilters({
|
||||
"Team ID": newFilters["Team ID"] || "",
|
||||
"Organization ID": newFilters["Organization ID"] || "",
|
||||
"Key Alias": newFilters["Key Alias"] || "",
|
||||
"User ID": newFilters["User ID"] || "",
|
||||
"Sort By": newFilters["Sort By"] || "created_at",
|
||||
"Sort Order": newFilters["Sort Order"] || "desc",
|
||||
});
|
||||
|
||||
// Fetch keys based on new filters
|
||||
const updatedFilters = {
|
||||
...filters,
|
||||
...newFilters,
|
||||
};
|
||||
debouncedSearch(updatedFilters);
|
||||
};
|
||||
|
||||
const handleFilterReset = () => {
|
||||
// Reset filters state
|
||||
setFilters(defaultFilters);
|
||||
|
||||
// Reset selections
|
||||
debouncedSearch(defaultFilters);
|
||||
};
|
||||
|
||||
return {
|
||||
filters,
|
||||
filteredKeys,
|
||||
allKeyAliases,
|
||||
allTeams,
|
||||
allOrganizations,
|
||||
handleFilterChange,
|
||||
handleFilterReset,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { fetchTeams } from "@/app/(console)/virtual-keys/networking";
|
||||
import { Team } from "@/components/key_team_helpers/key_list";
|
||||
import useAuthorized from "@/app/(console)/hooks/useAuthorized";
|
||||
|
||||
const useTeams = () => {
|
||||
const [teams, setTeams] = useState<Team[]>([]);
|
||||
const { accessToken, userId: userID, userRole } = useAuthorized();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const fetched = await fetchTeams(accessToken, userID, userRole, null);
|
||||
setTeams(fetched);
|
||||
})();
|
||||
}, [accessToken, userID, userRole]);
|
||||
|
||||
return teams;
|
||||
};
|
||||
|
||||
export default useTeams;
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
import { Organization, teamListCall } from "@/components/networking";
|
||||
|
||||
export const fetchTeams = async (
|
||||
accessToken: string,
|
||||
userID: string | null,
|
||||
userRole: string | null,
|
||||
currentOrg: Organization | null,
|
||||
) => {
|
||||
let givenTeams;
|
||||
if (userRole != "Admin" && userRole != "Admin Viewer") {
|
||||
givenTeams = await teamListCall(accessToken, currentOrg?.organization_id || null, userID);
|
||||
} else {
|
||||
givenTeams = await teamListCall(accessToken, currentOrg?.organization_id || null);
|
||||
}
|
||||
|
||||
return givenTeams;
|
||||
};
|
||||
52
ui/litellm-dashboard/src/app/(console)/virtual-keys/page.tsx
Normal file
52
ui/litellm-dashboard/src/app/(console)/virtual-keys/page.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
"use client";
|
||||
|
||||
import VirtualKeysTable from "@/app/(console)/virtual-keys/components/VirtualKeysTable/VirtualKeysTable";
|
||||
import { getCookie } from "@/utils/cookieUtils";
|
||||
import { jwtDecode } from "jwt-decode";
|
||||
import { useState } from "react";
|
||||
import useKeyList from "@/components/key_team_helpers/key_list";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Col, Grid } from "@tremor/react";
|
||||
import CreateKey from "@/app/(console)/virtual-keys/components/CreateKey";
|
||||
import useAuthorized from "@/app/(console)/hooks/useAuthorized"
|
||||
|
||||
const VirtualKeysPage = () => {
|
||||
const {accessToken, userRole} = useAuthorized();
|
||||
const [createClicked, setCreateClicked] = useState<boolean>(false);
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const { keys, isLoading, error, pagination, refresh, setKeys } = useKeyList({
|
||||
selectedKeyAlias: null,
|
||||
currentOrg: null,
|
||||
accessToken: accessToken || "",
|
||||
createClicked,
|
||||
});
|
||||
|
||||
const addKey = (data: any) => {
|
||||
setKeys((prevData) => (prevData ? [...prevData, data] : [data]));
|
||||
setCreateClicked(() => !createClicked);
|
||||
};
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<div className="w-full mx-4 h-[75vh]">
|
||||
<Grid numItems={1} className="gap-2 p-8 w-full mt-2">
|
||||
<Col numColSpan={1} className="flex flex-col gap-2">
|
||||
<CreateKey team={null} userRole={userRole} data={null} addKey={addKey} />
|
||||
<VirtualKeysTable
|
||||
keys={keys}
|
||||
setKeys={setKeys}
|
||||
pagination={pagination}
|
||||
onPageChange={() => {}}
|
||||
organizations={null}
|
||||
/>
|
||||
</Col>
|
||||
</Grid>
|
||||
</div>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default VirtualKeysPage;
|
||||
|
|
@ -234,7 +234,6 @@ export default function CreateKeyPage() {
|
|||
teams={teams}
|
||||
keys={keys}
|
||||
setUserRole={setUserRole}
|
||||
userEmail={userEmail}
|
||||
setUserEmail={setUserEmail}
|
||||
setTeams={setTeams}
|
||||
setKeys={setKeys}
|
||||
|
|
@ -275,7 +274,6 @@ export default function CreateKeyPage() {
|
|||
teams={teams}
|
||||
keys={keys}
|
||||
setUserRole={setUserRole}
|
||||
userEmail={userEmail}
|
||||
setUserEmail={setUserEmail}
|
||||
setTeams={setTeams}
|
||||
setKeys={setKeys}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
import { Layout, Menu } from "antd";
|
||||
import Link from "next/link";
|
||||
import { List } from "postcss/lib/list";
|
||||
import { Text, Button } from "@tremor/react";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
KeyOutlined,
|
||||
PlayCircleOutlined,
|
||||
|
|
@ -16,29 +12,17 @@ import {
|
|||
AppstoreOutlined,
|
||||
DatabaseOutlined,
|
||||
FileTextOutlined,
|
||||
LineOutlined,
|
||||
LineChartOutlined,
|
||||
SafetyOutlined,
|
||||
ExperimentOutlined,
|
||||
ThunderboltOutlined,
|
||||
LockOutlined,
|
||||
ToolOutlined,
|
||||
TagsOutlined,
|
||||
BgColorsOutlined,
|
||||
MenuFoldOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import {
|
||||
old_admin_roles,
|
||||
v2_admin_role_names,
|
||||
all_admin_roles,
|
||||
rolesAllowedToSeeUsage,
|
||||
rolesWithWriteAccess,
|
||||
internalUserRoles,
|
||||
isAdminRole,
|
||||
} from "../utils/roles";
|
||||
import { all_admin_roles, rolesWithWriteAccess, internalUserRoles, isAdminRole } from "../utils/roles";
|
||||
import UsageIndicator from "./usage_indicator";
|
||||
import { ConfigProvider } from "antd";
|
||||
import { useRouter } from "next/navigation";
|
||||
const { Sider } = Layout;
|
||||
|
||||
// Define the props type
|
||||
|
|
@ -61,6 +45,7 @@ interface MenuItem {
|
|||
}
|
||||
|
||||
const Sidebar: React.FC<SidebarProps> = ({ accessToken, setPage, userRole, defaultSelectedKey, collapsed = false }) => {
|
||||
const router = useRouter();
|
||||
// Note: If a menu item does not have a role, it is visible to all roles.
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
|
|
@ -252,6 +237,19 @@ const Sidebar: React.FC<SidebarProps> = ({ accessToken, setPage, userRole, defau
|
|||
return true;
|
||||
});
|
||||
|
||||
const navigateToPage = (page: string) => {
|
||||
if (page === "api-keys") {
|
||||
// Go to clean route and DO NOT call setPage to avoid any parent sync that re-adds ?page=api-keys
|
||||
router.replace("/virtual-keys");
|
||||
return;
|
||||
}
|
||||
const newSearchParams = new URLSearchParams(window.location.search);
|
||||
newSearchParams.set("page", page);
|
||||
// Use absolute root to replace everything after the domain
|
||||
router.replace(`/?${newSearchParams.toString()}`);
|
||||
setPage(page);
|
||||
};
|
||||
|
||||
return (
|
||||
<Layout style={{ minHeight: "100vh" }}>
|
||||
<Sider
|
||||
|
|
@ -295,21 +293,9 @@ const Sidebar: React.FC<SidebarProps> = ({ accessToken, setPage, userRole, defau
|
|||
key: child.key,
|
||||
icon: child.icon,
|
||||
label: child.label,
|
||||
onClick: () => {
|
||||
const newSearchParams = new URLSearchParams(window.location.search);
|
||||
newSearchParams.set("page", child.page);
|
||||
window.history.pushState(null, "", `?${newSearchParams.toString()}`);
|
||||
setPage(child.page);
|
||||
},
|
||||
onClick: () => navigateToPage(child.page),
|
||||
})),
|
||||
onClick: !item.children
|
||||
? () => {
|
||||
const newSearchParams = new URLSearchParams(window.location.search);
|
||||
newSearchParams.set("page", item.page);
|
||||
window.history.pushState(null, "", `?${newSearchParams.toString()}`);
|
||||
setPage(item.page);
|
||||
}
|
||||
: undefined,
|
||||
onClick: !item.children ? () => navigateToPage(item.page) : undefined,
|
||||
}))}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
|
|
|
|||
|
|
@ -131,6 +131,14 @@ export const fetchUserModels = async (
|
|||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* @deprecated
|
||||
* This component is being DEPRECATED in favor of src/app/(console)/virtual-keys/components/CreateKey.tsx
|
||||
* Please contribute to the new refactor.
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
const CreateKey: React.FC<CreateKeyProps> = ({
|
||||
userID,
|
||||
team,
|
||||
|
|
|
|||
|
|
@ -135,6 +135,13 @@ interface CombinedLimits {
|
|||
[key: string]: CombinedLimit; // Index signature allowing string keys
|
||||
}
|
||||
|
||||
/**
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* @deprecated
|
||||
* This component is being DEPRECATED in favor of src/app/(console)/virtual-keys/components/VirtualKeysTable/
|
||||
* Please contribute to the new refactor.
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
*/
|
||||
const ViewKeyTable: React.FC<ViewKeyTableProps> = ({
|
||||
userID,
|
||||
userRole,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ export const all_admin_roles = [...old_admin_roles, ...v2_admin_role_names];
|
|||
|
||||
export const internalUserRoles = ["Internal User", "Internal Viewer"];
|
||||
export const rolesAllowedToSeeUsage = ["Admin", "Admin Viewer", "Internal User", "Internal Viewer"];
|
||||
export const rolesWithWriteAccess = ["Internal User", "Admin"];
|
||||
export const rolesWithWriteAccess = ["Internal User", "Admin", "proxy_admin"];
|
||||
|
||||
// Helper function to check if a role is in all_admin_roles
|
||||
export const isAdminRole = (role: string): boolean => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue