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:
Achintya Rajan 2025-10-05 16:01:20 -07:00 committed by =
parent ddb90c9ad7
commit 03804cb1d2
30 changed files with 3941 additions and 35 deletions

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

View file

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

View file

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

View 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>
);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,5 @@
export * from "./useGuardrailsAndPrompts";
export * from "./useMcpAccessGroups";
export * from "./useUserModels";
export * from "./useTeamModels";
export * from "./useUserSearch";

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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