mirror of
https://github.com/BerriAI/litellm.git
synced 2025-04-26 03:04:13 +00:00
649 lines
19 KiB
TypeScript
649 lines
19 KiB
TypeScript
"use client";
|
|
import React, { useEffect, useState, useMemo } from "react";
|
|
import {
|
|
keyDeleteCall,
|
|
modelAvailableCall,
|
|
getGuardrailsList,
|
|
Organization,
|
|
} from "./networking";
|
|
import { add } from "date-fns";
|
|
import {
|
|
InformationCircleIcon,
|
|
StatusOnlineIcon,
|
|
TrashIcon,
|
|
PencilAltIcon,
|
|
RefreshIcon,
|
|
} from "@heroicons/react/outline";
|
|
import {
|
|
keySpendLogsCall,
|
|
PredictedSpendLogsCall,
|
|
keyUpdateCall,
|
|
modelInfoCall,
|
|
regenerateKeyCall,
|
|
} from "./networking";
|
|
import {
|
|
Badge,
|
|
Card,
|
|
Table,
|
|
Grid,
|
|
Col,
|
|
Button,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeaderCell,
|
|
TableRow,
|
|
Dialog,
|
|
DialogPanel,
|
|
Text,
|
|
Title,
|
|
Subtitle,
|
|
Icon,
|
|
BarChart,
|
|
TextInput,
|
|
Textarea,
|
|
Select,
|
|
SelectItem,
|
|
} from "@tremor/react";
|
|
import { InfoCircleOutlined } from "@ant-design/icons";
|
|
import {
|
|
fetchAvailableModelsForTeamOrKey,
|
|
getModelDisplayName,
|
|
} from "./key_team_helpers/fetch_available_models_team_key";
|
|
import {
|
|
MultiSelect,
|
|
MultiSelectItem,
|
|
} from "@tremor/react";
|
|
import {
|
|
Button as Button2,
|
|
Modal,
|
|
Form,
|
|
Input,
|
|
Select as Select2,
|
|
InputNumber,
|
|
message,
|
|
Tooltip,
|
|
DatePicker,
|
|
} from "antd";
|
|
import { CopyToClipboard } from "react-copy-to-clipboard";
|
|
import TextArea from "antd/es/input/TextArea";
|
|
import useKeyList from "./key_team_helpers/key_list";
|
|
import { KeyResponse } from "./key_team_helpers/key_list";
|
|
import { AllKeysTable } from "./all_keys_table";
|
|
import { Team } from "./key_team_helpers/key_list";
|
|
import { Setter } from "@/types";
|
|
|
|
const isLocal = process.env.NODE_ENV === "development";
|
|
const proxyBaseUrl = isLocal ? "http://localhost:4000" : null;
|
|
if (isLocal != true) {
|
|
console.log = function () {};
|
|
}
|
|
|
|
interface EditKeyModalProps {
|
|
visible: boolean;
|
|
onCancel: () => void;
|
|
token: any; // Assuming TeamType is a type representing your team object
|
|
onSubmit: (data: FormData) => void; // Assuming FormData is the type of data to be submitted
|
|
}
|
|
|
|
interface ModelLimitModalProps {
|
|
visible: boolean;
|
|
onCancel: () => void;
|
|
token: KeyResponse;
|
|
onSubmit: (updatedMetadata: any) => void;
|
|
accessToken: string;
|
|
}
|
|
|
|
// Define the props type
|
|
interface ViewKeyTableProps {
|
|
userID: string;
|
|
userRole: string | null;
|
|
accessToken: string;
|
|
selectedTeam: any | null;
|
|
setSelectedTeam: React.Dispatch<React.SetStateAction<any | null>>;
|
|
data: any[] | null;
|
|
setData: React.Dispatch<React.SetStateAction<any[] | null>>;
|
|
teams: Team[] | null;
|
|
premiumUser: boolean;
|
|
currentOrg: Organization | null;
|
|
organizations: Organization[] | null;
|
|
setCurrentOrg: React.Dispatch<React.SetStateAction<Organization | null>>;
|
|
selectedKeyAlias: string | null;
|
|
setSelectedKeyAlias: Setter<string | null>;
|
|
}
|
|
|
|
interface ItemData {
|
|
key_alias: string | null;
|
|
key_name: string;
|
|
spend: string;
|
|
max_budget: string | null;
|
|
models: string[];
|
|
tpm_limit: string | null;
|
|
rpm_limit: string | null;
|
|
token: string;
|
|
token_id: string | null;
|
|
id: number;
|
|
team_id: string;
|
|
metadata: any;
|
|
user_id: string | null;
|
|
expires: any;
|
|
budget_duration: string | null;
|
|
budget_reset_at: string | null;
|
|
// Add any other properties that exist in the item data
|
|
}
|
|
|
|
interface ModelLimits {
|
|
[key: string]: number; // Index signature allowing string keys
|
|
}
|
|
|
|
interface CombinedLimit {
|
|
tpm: number;
|
|
rpm: number;
|
|
}
|
|
|
|
interface CombinedLimits {
|
|
[key: string]: CombinedLimit; // Index signature allowing string keys
|
|
}
|
|
|
|
const ViewKeyTable: React.FC<ViewKeyTableProps> = ({
|
|
userID,
|
|
userRole,
|
|
accessToken,
|
|
selectedTeam,
|
|
setSelectedTeam,
|
|
data,
|
|
setData,
|
|
teams,
|
|
premiumUser,
|
|
currentOrg,
|
|
organizations,
|
|
setCurrentOrg,
|
|
selectedKeyAlias,
|
|
setSelectedKeyAlias
|
|
}) => {
|
|
const [isButtonClicked, setIsButtonClicked] = useState(false);
|
|
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
|
const [keyToDelete, setKeyToDelete] = useState<string | null>(null);
|
|
const [selectedItem, setSelectedItem] = useState<KeyResponse | null>(null);
|
|
const [spendData, setSpendData] = useState<
|
|
{ day: string; spend: number }[] | null
|
|
>(null);
|
|
|
|
// NEW: Declare filter states for team and key alias.
|
|
const [teamFilter, setTeamFilter] = useState<string>(selectedTeam?.team_id || "");
|
|
const [keyAliasFilter, setKeyAliasFilter] = useState<string>("");
|
|
|
|
// Keep the team filter in sync with the incoming prop.
|
|
useEffect(() => {
|
|
setTeamFilter(selectedTeam?.team_id || "");
|
|
}, [selectedTeam]);
|
|
|
|
// Build a memoized filters object for the backend call.
|
|
|
|
// Pass filters into the hook so the API call includes these query parameters.
|
|
const { keys, isLoading, error, pagination, refresh, setKeys } = useKeyList({
|
|
selectedTeam,
|
|
currentOrg,
|
|
selectedKeyAlias,
|
|
accessToken,
|
|
});
|
|
|
|
// Make both refresh and addKey functions available globally
|
|
if (typeof window !== 'undefined') {
|
|
window.refreshKeysList = refresh;
|
|
window.addNewKeyToList = (newKey) => {
|
|
// Add the new key to the keys list without making an API call
|
|
setKeys((prevKeys) => [newKey, ...prevKeys]);
|
|
};
|
|
}
|
|
|
|
const handlePageChange = (newPage: number) => {
|
|
refresh({ page: newPage });
|
|
};
|
|
|
|
const [editModalVisible, setEditModalVisible] = useState(false);
|
|
const [infoDialogVisible, setInfoDialogVisible] = useState(false);
|
|
const [selectedToken, setSelectedToken] = useState<KeyResponse | null>(null);
|
|
const [userModels, setUserModels] = useState<string[]>([]);
|
|
const initialKnownTeamIDs: Set<string> = new Set();
|
|
const [modelLimitModalVisible, setModelLimitModalVisible] = useState(false);
|
|
const [regenerateDialogVisible, setRegenerateDialogVisible] = useState(false);
|
|
const [regeneratedKey, setRegeneratedKey] = useState<string | null>(null);
|
|
const [regenerateFormData, setRegenerateFormData] = useState<any>(null);
|
|
const [regenerateForm] = Form.useForm();
|
|
const [newExpiryTime, setNewExpiryTime] = useState<string | null>(null);
|
|
|
|
const [knownTeamIDs, setKnownTeamIDs] = useState(initialKnownTeamIDs);
|
|
const [guardrailsList, setGuardrailsList] = useState<string[]>([]);
|
|
|
|
useEffect(() => {
|
|
const calculateNewExpiryTime = (duration: string | undefined) => {
|
|
if (!duration) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
const now = new Date();
|
|
let newExpiry: Date;
|
|
|
|
if (duration.endsWith("s")) {
|
|
newExpiry = add(now, { seconds: parseInt(duration) });
|
|
} else if (duration.endsWith("h")) {
|
|
newExpiry = add(now, { hours: parseInt(duration) });
|
|
} else if (duration.endsWith("d")) {
|
|
newExpiry = add(now, { days: parseInt(duration) });
|
|
} else {
|
|
throw new Error("Invalid duration format");
|
|
}
|
|
|
|
return newExpiry.toLocaleString("en-US", {
|
|
year: "numeric",
|
|
month: "numeric",
|
|
day: "numeric",
|
|
hour: "numeric",
|
|
minute: "numeric",
|
|
second: "numeric",
|
|
hour12: true,
|
|
});
|
|
} catch (error) {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
console.log("in calculateNewExpiryTime for selectedToken", selectedToken);
|
|
|
|
// When a new duration is entered
|
|
if (regenerateFormData?.duration) {
|
|
setNewExpiryTime(calculateNewExpiryTime(regenerateFormData.duration));
|
|
} else {
|
|
setNewExpiryTime(null);
|
|
}
|
|
|
|
console.log("calculateNewExpiryTime:", newExpiryTime);
|
|
}, [selectedToken, regenerateFormData?.duration]);
|
|
|
|
useEffect(() => {
|
|
const fetchUserModels = async () => {
|
|
try {
|
|
if (userID === null || userRole === null || accessToken === null) {
|
|
return;
|
|
}
|
|
|
|
const models = await fetchAvailableModelsForTeamOrKey(
|
|
userID,
|
|
userRole,
|
|
accessToken
|
|
);
|
|
if (models) {
|
|
setUserModels(models);
|
|
}
|
|
} catch (error) {
|
|
console.error("Error fetching user models:", error);
|
|
}
|
|
};
|
|
|
|
fetchUserModels();
|
|
}, [accessToken, userID, userRole]);
|
|
|
|
const handleModelLimitClick = (token: KeyResponse) => {
|
|
setSelectedToken(token);
|
|
setModelLimitModalVisible(true);
|
|
};
|
|
|
|
const handleModelLimitSubmit = async (updatedMetadata: any) => {
|
|
if (accessToken == null || selectedToken == null) {
|
|
return;
|
|
}
|
|
|
|
const formValues = {
|
|
...selectedToken,
|
|
metadata: updatedMetadata,
|
|
key: selectedToken.token,
|
|
};
|
|
|
|
try {
|
|
let newKeyValues = await keyUpdateCall(accessToken, formValues);
|
|
console.log("Model limits updated:", newKeyValues);
|
|
|
|
// Update the keys with the updated key
|
|
if (data) {
|
|
const updatedData = data.map((key) =>
|
|
key.token === selectedToken.token ? newKeyValues : key
|
|
);
|
|
setData(updatedData);
|
|
}
|
|
message.success("Model-specific limits updated successfully");
|
|
} catch (error) {
|
|
console.error("Error updating model-specific limits:", error);
|
|
message.error("Failed to update model-specific limits");
|
|
}
|
|
|
|
setModelLimitModalVisible(false);
|
|
setSelectedToken(null);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (teams) {
|
|
const teamIDSet: Set<string> = new Set();
|
|
teams.forEach((team: any, index: number) => {
|
|
const team_obj: string = team.team_id;
|
|
teamIDSet.add(team_obj);
|
|
});
|
|
setKnownTeamIDs(teamIDSet);
|
|
}
|
|
}, [teams]);
|
|
|
|
const confirmDelete = async () => {
|
|
if (keyToDelete == null || data == null) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await keyDeleteCall(accessToken, keyToDelete);
|
|
// Successfully completed the deletion. Update the state to trigger a rerender.
|
|
const filteredData = data.filter((item) => item.token !== keyToDelete);
|
|
setData(filteredData);
|
|
} catch (error) {
|
|
console.error("Error deleting the key:", error);
|
|
// Handle any error situations, such as displaying an error message to the user.
|
|
}
|
|
|
|
// Close the confirmation modal and reset the keyToDelete
|
|
setIsDeleteModalOpen(false);
|
|
setKeyToDelete(null);
|
|
};
|
|
|
|
const cancelDelete = () => {
|
|
// Close the confirmation modal and reset the keyToDelete
|
|
setIsDeleteModalOpen(false);
|
|
setKeyToDelete(null);
|
|
};
|
|
|
|
const handleRegenerateClick = (token: any) => {
|
|
setSelectedToken(token);
|
|
setNewExpiryTime(null);
|
|
regenerateForm.setFieldsValue({
|
|
key_alias: token.key_alias,
|
|
max_budget: token.max_budget,
|
|
tpm_limit: token.tpm_limit,
|
|
rpm_limit: token.rpm_limit,
|
|
duration: token.duration || "",
|
|
});
|
|
setRegenerateDialogVisible(true);
|
|
};
|
|
|
|
const handleRegenerateFormChange = (field: string, value: any) => {
|
|
setRegenerateFormData((prev: any) => ({
|
|
...prev,
|
|
[field]: value,
|
|
}));
|
|
};
|
|
|
|
const handleRegenerateKey = async () => {
|
|
if (!premiumUser) {
|
|
message.error(
|
|
"Regenerate API Key is an Enterprise feature. Please upgrade to use this feature."
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (selectedToken == null) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const formValues = await regenerateForm.validateFields();
|
|
const response = await regenerateKeyCall(
|
|
accessToken,
|
|
selectedToken.token,
|
|
formValues
|
|
);
|
|
setRegeneratedKey(response.key);
|
|
|
|
// Update the data state with the new key_name
|
|
if (data) {
|
|
const updatedData = data.map((item) =>
|
|
item.token === selectedToken?.token ?
|
|
{ ...item, key_name: response.key_name, ...formValues }
|
|
: item
|
|
);
|
|
setData(updatedData);
|
|
}
|
|
|
|
setRegenerateDialogVisible(false);
|
|
regenerateForm.resetFields();
|
|
message.success("API Key regenerated successfully");
|
|
} catch (error) {
|
|
console.error("Error regenerating key:", error);
|
|
message.error("Failed to regenerate API Key");
|
|
}
|
|
};
|
|
|
|
|
|
return (
|
|
<div>
|
|
<AllKeysTable
|
|
keys={keys}
|
|
setKeys={setKeys}
|
|
isLoading={isLoading}
|
|
pagination={pagination}
|
|
onPageChange={handlePageChange}
|
|
pageSize={100}
|
|
teams={teams}
|
|
selectedTeam={selectedTeam}
|
|
setSelectedTeam={setSelectedTeam}
|
|
accessToken={accessToken}
|
|
userID={userID}
|
|
userRole={userRole}
|
|
organizations={organizations}
|
|
setCurrentOrg={setCurrentOrg}
|
|
refresh={refresh}
|
|
selectedKeyAlias={selectedKeyAlias}
|
|
setSelectedKeyAlias={setSelectedKeyAlias}
|
|
/>
|
|
|
|
{isDeleteModalOpen && (
|
|
<div className="fixed z-10 inset-0 overflow-y-auto">
|
|
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
|
<div
|
|
className="fixed inset-0 transition-opacity"
|
|
aria-hidden="true"
|
|
>
|
|
<div className="absolute inset-0 bg-gray-500 opacity-75"></div>
|
|
</div>
|
|
|
|
{/* Modal Panel */}
|
|
<span
|
|
className="hidden sm:inline-block sm:align-middle sm:h-screen"
|
|
aria-hidden="true"
|
|
>
|
|
​
|
|
</span>
|
|
|
|
{/* Confirmation Modal Content */}
|
|
<div className="inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-lg sm:w-full">
|
|
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
|
<div className="sm:flex sm:items-start">
|
|
<div className="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left">
|
|
<h3 className="text-lg leading-6 font-medium text-gray-900">
|
|
Delete Key
|
|
</h3>
|
|
<div className="mt-2">
|
|
<p className="text-sm text-gray-500">
|
|
Are you sure you want to delete this key ?
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
|
|
<Button onClick={confirmDelete} color="red" className="ml-2">
|
|
Delete
|
|
</Button>
|
|
<Button onClick={cancelDelete}>Cancel</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Regenerate Key Form Modal */}
|
|
<Modal
|
|
title="Regenerate API Key"
|
|
visible={regenerateDialogVisible}
|
|
onCancel={() => {
|
|
setRegenerateDialogVisible(false);
|
|
regenerateForm.resetFields();
|
|
}}
|
|
footer={[
|
|
<Button
|
|
key="cancel"
|
|
onClick={() => {
|
|
setRegenerateDialogVisible(false);
|
|
regenerateForm.resetFields();
|
|
}}
|
|
className="mr-2"
|
|
>
|
|
Cancel
|
|
</Button>,
|
|
<Button
|
|
key="regenerate"
|
|
onClick={handleRegenerateKey}
|
|
disabled={!premiumUser}
|
|
>
|
|
{premiumUser ? "Regenerate" : "Upgrade to Regenerate"}
|
|
</Button>,
|
|
]}
|
|
>
|
|
{premiumUser ?
|
|
<Form
|
|
form={regenerateForm}
|
|
layout="vertical"
|
|
onValuesChange={(changedValues, allValues) => {
|
|
if ("duration" in changedValues) {
|
|
handleRegenerateFormChange("duration", changedValues.duration);
|
|
}
|
|
}}
|
|
>
|
|
<Form.Item name="key_alias" label="Key Alias">
|
|
<TextInput disabled={true} />
|
|
</Form.Item>
|
|
<Form.Item name="max_budget" label="Max Budget (USD)">
|
|
<InputNumber
|
|
step={0.01}
|
|
precision={2}
|
|
style={{ width: "100%" }}
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item name="tpm_limit" label="TPM Limit">
|
|
<InputNumber style={{ width: "100%" }} />
|
|
</Form.Item>
|
|
<Form.Item name="rpm_limit" label="RPM Limit">
|
|
<InputNumber style={{ width: "100%" }} />
|
|
</Form.Item>
|
|
<Form.Item
|
|
name="duration"
|
|
label="Expire Key (eg: 30s, 30h, 30d)"
|
|
className="mt-8"
|
|
>
|
|
<TextInput placeholder="" />
|
|
</Form.Item>
|
|
<div className="mt-2 text-sm text-gray-500">
|
|
Current expiry:{" "}
|
|
{selectedToken?.expires != null ?
|
|
new Date(selectedToken.expires).toLocaleString()
|
|
: "Never"}
|
|
</div>
|
|
{newExpiryTime && (
|
|
<div className="mt-2 text-sm text-green-600">
|
|
New expiry: {newExpiryTime}
|
|
</div>
|
|
)}
|
|
</Form>
|
|
: <div>
|
|
<p className="mb-2 text-gray-500 italic text-[12px]">
|
|
Upgrade to use this feature
|
|
</p>
|
|
<Button variant="primary" className="mb-2">
|
|
<a
|
|
href="https://calendly.com/d/4mp-gd3-k5k/litellm-1-1-onboarding-chat"
|
|
target="_blank"
|
|
>
|
|
Get Free Trial
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
}
|
|
</Modal>
|
|
|
|
{/* Regenerated Key Display Modal */}
|
|
{regeneratedKey && (
|
|
<Modal
|
|
visible={!!regeneratedKey}
|
|
onCancel={() => setRegeneratedKey(null)}
|
|
footer={[
|
|
<Button key="close" onClick={() => setRegeneratedKey(null)}>
|
|
Close
|
|
</Button>,
|
|
]}
|
|
>
|
|
<Grid numItems={1} className="gap-2 w-full">
|
|
<Title>Regenerated Key</Title>
|
|
<Col numColSpan={1}>
|
|
<p>
|
|
Please replace your old key with the new key generated. 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}>
|
|
<Text className="mt-3">Key Alias:</Text>
|
|
<div
|
|
style={{
|
|
background: "#f8f8f8",
|
|
padding: "10px",
|
|
borderRadius: "5px",
|
|
marginBottom: "10px",
|
|
}}
|
|
>
|
|
<pre style={{ wordWrap: "break-word", whiteSpace: "normal" }}>
|
|
{selectedToken?.key_alias || "No alias set"}
|
|
</pre>
|
|
</div>
|
|
<Text className="mt-3">New API Key:</Text>
|
|
<div
|
|
style={{
|
|
background: "#f8f8f8",
|
|
padding: "10px",
|
|
borderRadius: "5px",
|
|
marginBottom: "10px",
|
|
}}
|
|
>
|
|
<pre style={{ wordWrap: "break-word", whiteSpace: "normal" }}>
|
|
{regeneratedKey}
|
|
</pre>
|
|
</div>
|
|
<CopyToClipboard
|
|
text={regeneratedKey}
|
|
onCopy={() => message.success("API Key copied to clipboard")}
|
|
>
|
|
<Button className="mt-3">Copy API Key</Button>
|
|
</CopyToClipboard>
|
|
</Col>
|
|
</Grid>
|
|
</Modal>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// Update the type declaration to include the new function
|
|
declare global {
|
|
interface Window {
|
|
refreshKeysList?: () => void;
|
|
addNewKeyToList?: (newKey: any) => void;
|
|
}
|
|
}
|
|
|
|
export default ViewKeyTable;
|