[UI] - Move passthrough endpoints under Models + Endpoints (#11871)

* use cost_per_request

* fix cost_per_request

* fixes cost_per_request

* fixes for cost per request for pass through

* ui fix param name

* fixes for _set_cost_per_request

* test cost per request pass through endpoints

* Update tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* tests pass through endpoints

* rename left nav

* move pass through to models + endpoints

* move pass through under models

* add pt info view

* add get/update pt

* fixes for PT ui

* allow updating pt endpoints

* fix pass through quick actions

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Ishaan Jaff 2025-06-18 16:26:24 -07:00 committed by GitHub
parent 4782d435ed
commit 6630b39c79
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 566 additions and 18 deletions

View file

@ -1139,11 +1139,88 @@ async def get_pass_through_endpoints(
"/config/pass_through_endpoint/{endpoint_id}",
dependencies=[Depends(user_api_key_auth)],
)
async def update_pass_through_endpoints(request: Request, endpoint_id: str):
async def update_pass_through_endpoints(
endpoint_id: str,
data: PassThroughGenericEndpoint,
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
):
"""
Update a pass-through endpoint
"""
pass
from litellm.proxy.proxy_server import (
get_config_general_settings,
update_config_general_settings,
)
## Get existing pass-through endpoint field value
try:
response: ConfigFieldInfo = await get_config_general_settings(
field_name="pass_through_endpoints", user_api_key_dict=user_api_key_dict
)
except Exception:
raise HTTPException(
status_code=404,
detail={"error": "No pass-through endpoints found"},
)
pass_through_endpoint_data: Optional[List] = response.field_value
if pass_through_endpoint_data is None:
raise HTTPException(
status_code=404,
detail={"error": "No pass-through endpoints found"},
)
# Find and update the endpoint
updated_endpoint: Optional[PassThroughGenericEndpoint] = None
endpoint_found = False
for idx, endpoint in enumerate(pass_through_endpoint_data):
_endpoint: Optional[PassThroughGenericEndpoint] = None
if isinstance(endpoint, dict):
_endpoint = PassThroughGenericEndpoint(**endpoint)
elif isinstance(endpoint, PassThroughGenericEndpoint):
_endpoint = endpoint
if _endpoint is not None and _endpoint.path == endpoint_id:
endpoint_found = True
# Get the update data as dict, excluding None values for partial updates
update_data = data.model_dump(exclude_none=True)
# Start with existing endpoint data
endpoint_dict = _endpoint.model_dump()
# Update with new data (only non-None values)
endpoint_dict.update(update_data)
# Ensure the path stays the same (can't change the endpoint_id)
endpoint_dict["path"] = endpoint_id
# Create updated endpoint object
updated_endpoint = PassThroughGenericEndpoint(**endpoint_dict)
# Update the list
pass_through_endpoint_data[idx] = endpoint_dict
break
if not endpoint_found:
raise HTTPException(
status_code=404,
detail={
"error": f"Endpoint with path '{endpoint_id}' not found"
},
)
## Update db
updated_data = ConfigFieldUpdate(
field_name="pass_through_endpoints",
field_value=pass_through_endpoint_data,
config_type="general_settings",
)
await update_config_general_settings(
data=updated_data, user_api_key_dict=user_api_key_dict
)
return PassThroughEndpointResponse(endpoints=[updated_endpoint] if updated_endpoint else [])
@router.post(

View file

@ -77,7 +77,7 @@ const AddPassThroughEndpoint: React.FC<AddFallbacksProps> = ({
return (
<div>
<Button
className="mx-auto mb-4"
className="mx-auto mb-4 mt-4"
onClick={() => setIsModalVisible(true)}
>
+ Add Pass-Through Endpoint

View file

@ -58,7 +58,7 @@ const Sidebar: React.FC<SidebarProps> = ({
const menuItems: MenuItem[] = [
{ key: "1", page: "api-keys", label: "Virtual Keys", icon: <KeyOutlined /> },
{ key: "3", page: "llm-playground", label: "Test Key", icon: <PlayCircleOutlined />, roles: rolesWithWriteAccess },
{ key: "2", page: "models", label: "Models", icon: <BlockOutlined />, roles: rolesWithWriteAccess },
{ key: "2", page: "models", label: "Models + Endpoints", icon: <BlockOutlined />, roles: rolesWithWriteAccess },
{ key: "12", page: "new_usage", label: "Usage", icon: <BarChartOutlined />, roles: [...all_admin_roles, ...internalUserRoles] },
{ key: "6", page: "teams", label: "Teams", icon: <TeamOutlined /> },
{ key: "17", page: "organizations", label: "Organizations", icon: <BankOutlined />, roles: all_admin_roles },
@ -90,7 +90,6 @@ const Sidebar: React.FC<SidebarProps> = ({
roles: all_admin_roles,
children: [
{ key: "11", page: "general-settings", label: "Router Settings", icon: <SettingOutlined />, roles: all_admin_roles },
{ key: "12", page: "pass-through-settings", label: "Pass-Through", icon: <ApiOutlined />, roles: all_admin_roles },
{ key: "8", page: "settings", label: "Logging & Alerts", icon: <SettingOutlined />, roles: all_admin_roles },
{ key: "13", page: "admin-panel", label: "Admin Settings", icon: <SettingOutlined />, roles: all_admin_roles },
]

View file

@ -114,6 +114,7 @@ import AddModelTab from "./add_model/add_model_tab";
import { ModelDataTable } from "./model_dashboard/table";
import { columns } from "./model_dashboard/columns";
import HealthCheckComponent from "./model_dashboard/HealthCheckComponent";
import PassThroughSettings from "./pass_through_settings";
import { all_admin_roles } from "@/utils/roles";
import { Table as TableInstance } from '@tanstack/react-table';
@ -1094,6 +1095,7 @@ const ModelDashboard: React.FC<ModelDashboardProps> = ({
{all_admin_roles.includes(userRole) ? <Tab>All Models</Tab> : <Tab>Your Models</Tab>}
<Tab>Add Model</Tab>
{all_admin_roles.includes(userRole) && <Tab>LLM Credentials</Tab>}
{all_admin_roles.includes(userRole) && <Tab>Pass-Through Endpoints</Tab>}
{all_admin_roles.includes(userRole) && <Tab>
Health Status
</Tab>}
@ -1263,6 +1265,14 @@ const ModelDashboard: React.FC<ModelDashboardProps> = ({
<TabPanel>
<CredentialsPanel accessToken={accessToken} uploadProps={uploadProps} credentialList={credentialsList} fetchCredentials={fetchCredentials} />
</TabPanel>
<TabPanel>
<PassThroughSettings
accessToken={accessToken}
userRole={userRole}
userID={userID}
modelData={modelData}
/>
</TabPanel>
<TabPanel>
<HealthCheckComponent
accessToken={accessToken}

View file

@ -5631,4 +5631,75 @@ export const getRemainingUsers = async (accessToken: string): Promise<{
console.error("Failed to fetch remaining users:", error);
throw error;
}
};
export const updatePassThroughEndpoint = async (
accessToken: string,
endpointPath: string,
formValues: Record<string, any>
) => {
try {
let url = proxyBaseUrl
? `${proxyBaseUrl}/config/pass_through_endpoint/${encodeURIComponent(endpointPath)}`
: `/config/pass_through_endpoint/${encodeURIComponent(endpointPath)}`;
const response = await fetch(url, {
method: "POST",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(formValues),
});
if (!response.ok) {
const errorData = await response.text();
handleError(errorData);
throw new Error("Network response was not ok");
}
const data = await response.json();
message.success("Pass through endpoint updated successfully");
return data;
} catch (error) {
console.error("Failed to update pass through endpoint:", error);
throw error;
}
};
export const getPassThroughEndpointInfo = async (
accessToken: string,
endpointPath: string
) => {
try {
let url = proxyBaseUrl
? `${proxyBaseUrl}/config/pass_through_endpoint?endpoint_id=${encodeURIComponent(endpointPath)}`
: `/config/pass_through_endpoint?endpoint_id=${encodeURIComponent(endpointPath)}`;
const response = await fetch(url, {
method: "GET",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.text();
handleError(errorData);
throw new Error("Network response was not ok");
}
const data = await response.json();
const endpoints = data["endpoints"];
if (!endpoints || endpoints.length === 0) {
throw new Error("Pass through endpoint not found");
}
return endpoints[0]; // Return the first (and should be only) endpoint
} catch (error) {
console.error("Failed to get pass through endpoint info:", error);
throw error;
}
};

View file

@ -0,0 +1,353 @@
import React, { useState, useEffect } from "react";
import {
Card,
Title,
Text,
Grid,
Badge,
Button as TremorButton,
Tab,
TabGroup,
TabList,
TabPanel,
TabPanels,
TextInput,
} from "@tremor/react";
import { Button, Form, Input, Switch, message, InputNumber } from "antd";
import {
getPassThroughEndpointInfo,
updatePassThroughEndpoint,
deletePassThroughEndpointsCall
} from "./networking";
import { Eye, EyeOff } from "lucide-react";
export interface PassThroughInfoProps {
endpointPath: string;
onClose: () => void;
accessToken: string | null;
isAdmin: boolean;
onEndpointUpdated?: () => void;
}
interface PassThroughEndpoint {
path: string;
target: string;
headers: Record<string, any>;
include_subpath?: boolean;
cost_per_request?: number;
}
// Password field component for headers
const PasswordField: React.FC<{ value: Record<string, any> }> = ({ value }) => {
const [showPassword, setShowPassword] = useState(false);
const headerString = JSON.stringify(value, null, 2);
return (
<div className="flex items-center space-x-2">
<pre className="font-mono text-xs bg-gray-50 p-2 rounded max-w-md overflow-auto">
{showPassword ? headerString : "••••••••"}
</pre>
<button
onClick={() => setShowPassword(!showPassword)}
className="p-1 hover:bg-gray-100 rounded"
type="button"
>
{showPassword ? (
<EyeOff className="w-4 h-4 text-gray-500" />
) : (
<Eye className="w-4 h-4 text-gray-500" />
)}
</button>
</div>
);
};
const PassThroughInfoView: React.FC<PassThroughInfoProps> = ({
endpointPath,
onClose,
accessToken,
isAdmin,
onEndpointUpdated
}) => {
const [endpointData, setEndpointData] = useState<PassThroughEndpoint | null>(null);
const [loading, setLoading] = useState(true);
const [isEditing, setIsEditing] = useState(false);
const [form] = Form.useForm();
const fetchEndpointInfo = async () => {
try {
setLoading(true);
if (!accessToken) return;
const response = await getPassThroughEndpointInfo(accessToken, endpointPath);
setEndpointData(response);
} catch (error) {
message.error("Failed to load pass through endpoint information");
console.error("Error fetching endpoint info:", error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchEndpointInfo();
}, [endpointPath, accessToken]);
const handleEndpointUpdate = async (values: any) => {
try {
if (!accessToken) return;
// Parse headers if provided as string
let headers = {};
if (values.headers) {
try {
headers = typeof values.headers === 'string'
? JSON.parse(values.headers)
: values.headers;
} catch (e) {
message.error("Invalid JSON format for headers");
return;
}
}
const updateData = {
target: values.target,
headers: headers,
include_subpath: values.include_subpath,
cost_per_request: values.cost_per_request,
};
await updatePassThroughEndpoint(accessToken, endpointPath, updateData);
message.success("Pass through endpoint updated successfully");
fetchEndpointInfo();
setIsEditing(false);
if (onEndpointUpdated) {
onEndpointUpdated();
}
} catch (error) {
console.error("Error updating endpoint:", error);
message.error("Failed to update pass through endpoint");
}
};
const handleDeleteEndpoint = async () => {
try {
if (!accessToken) return;
await deletePassThroughEndpointsCall(accessToken, endpointPath);
message.success("Pass through endpoint deleted successfully");
onClose();
if (onEndpointUpdated) {
onEndpointUpdated();
}
} catch (error) {
console.error("Error deleting endpoint:", error);
message.error("Failed to delete pass through endpoint");
}
};
if (loading) {
return <div className="p-4">Loading...</div>;
}
if (!endpointData) {
return <div className="p-4">Pass through endpoint not found</div>;
}
return (
<div className="p-4">
<div className="flex justify-between items-center mb-6">
<div>
<Button onClick={onClose} className="mb-4"> Back</Button>
<Title>Pass Through Endpoint</Title>
<Text className="text-gray-500 font-mono">{endpointData.path}</Text>
</div>
</div>
<TabGroup>
<TabList className="mb-4">
<Tab key="overview">Overview</Tab>
{isAdmin ? <Tab key="settings">Settings</Tab> : <></>}
</TabList>
<TabPanels>
{/* Overview Panel */}
<TabPanel>
<Grid numItems={1} numItemsSm={2} numItemsLg={3} className="gap-6">
<Card>
<Text>Path</Text>
<div className="mt-2">
<Title className="font-mono">{endpointData.path}</Title>
</div>
</Card>
<Card>
<Text>Target</Text>
<div className="mt-2">
<Title>{endpointData.target}</Title>
</div>
</Card>
<Card>
<Text>Configuration</Text>
<div className="mt-2 space-y-2">
<div>
<Badge color={endpointData.include_subpath ? "green" : "gray"}>
{endpointData.include_subpath ? "Include Subpath" : "Exact Path"}
</Badge>
</div>
{endpointData.cost_per_request !== undefined && (
<div>
<Text>Cost per request: ${endpointData.cost_per_request}</Text>
</div>
)}
</div>
</Card>
</Grid>
{endpointData.headers && Object.keys(endpointData.headers).length > 0 && (
<Card className="mt-6">
<div className="flex justify-between items-center">
<Text className="font-medium">Headers</Text>
<Badge color="blue">
{Object.keys(endpointData.headers).length} headers configured
</Badge>
</div>
<div className="mt-4">
<PasswordField value={endpointData.headers} />
</div>
</Card>
)}
</TabPanel>
{/* Settings Panel (only for admins) */}
{isAdmin && (
<TabPanel>
<Card>
<div className="flex justify-between items-center mb-4">
<Title>Pass Through Endpoint Settings</Title>
<div className="space-x-2">
{!isEditing && (
<>
<TremorButton
onClick={() => setIsEditing(true)}
>
Edit Settings
</TremorButton>
<TremorButton
onClick={handleDeleteEndpoint}
variant="secondary"
color="red"
>
Delete Endpoint
</TremorButton>
</>
)}
</div>
</div>
{isEditing ? (
<Form
form={form}
onFinish={handleEndpointUpdate}
initialValues={{
target: endpointData.target,
headers: endpointData.headers
? JSON.stringify(endpointData.headers, null, 2)
: "",
include_subpath: endpointData.include_subpath || false,
cost_per_request: endpointData.cost_per_request,
}}
layout="vertical"
>
<Form.Item
label="Target URL"
name="target"
rules={[{ required: true, message: "Please input a target URL" }]}
>
<TextInput placeholder="https://api.example.com" />
</Form.Item>
<Form.Item
label="Headers (JSON)"
name="headers"
>
<Input.TextArea
rows={5}
placeholder='{"Authorization": "Bearer your-token", "Content-Type": "application/json"}'
/>
</Form.Item>
<Form.Item
label="Include Subpath"
name="include_subpath"
valuePropName="checked"
>
<Switch />
</Form.Item>
<Form.Item
label="Cost per Request"
name="cost_per_request"
>
<InputNumber
min={0}
step={0.01}
precision={2}
placeholder="0.00"
addonBefore="$"
/>
</Form.Item>
<div className="flex justify-end gap-2 mt-6">
<Button onClick={() => setIsEditing(false)}>
Cancel
</Button>
<TremorButton>
Save Changes
</TremorButton>
</div>
</Form>
) : (
<div className="space-y-4">
<div>
<Text className="font-medium">Path</Text>
<div className="font-mono">{endpointData.path}</div>
</div>
<div>
<Text className="font-medium">Target URL</Text>
<div>{endpointData.target}</div>
</div>
<div>
<Text className="font-medium">Include Subpath</Text>
<Badge color={endpointData.include_subpath ? "green" : "gray"}>
{endpointData.include_subpath ? "Yes" : "No"}
</Badge>
</div>
{endpointData.cost_per_request !== undefined && (
<div>
<Text className="font-medium">Cost per Request</Text>
<div>${endpointData.cost_per_request}</div>
</div>
)}
<div>
<Text className="font-medium">Headers</Text>
{endpointData.headers && Object.keys(endpointData.headers).length > 0 ? (
<div className="mt-2">
<PasswordField value={endpointData.headers} />
</div>
) : (
<div className="text-gray-500">No headers configured</div>
)}
</div>
</div>
)}
</Card>
</TabPanel>
)}
</TabPanels>
</TabGroup>
</div>
);
};
export default PassThroughInfoView;

View file

@ -48,6 +48,7 @@ import {
} from "@heroicons/react/outline";
import AddFallbacks from "./add_fallbacks";
import AddPassThroughEndpoint from "./add_pass_through";
import PassThroughInfoView from "./pass_through_info";
import openai from "openai";
import Paragraph from "antd/es/skeleton/Paragraph";
import { DataTable } from "./view_logs/table";
@ -116,6 +117,7 @@ const PassThroughSettings: React.FC<GeneralSettingsPageProps> = ({
const [generalSettings, setGeneralSettings] = useState<passThroughItem[]>(
[]
);
const [selectedEndpointPath, setSelectedEndpointPath] = useState<string | null>(null);
useEffect(() => {
if (!accessToken || !userRole || !userID) {
@ -127,6 +129,16 @@ const PassThroughSettings: React.FC<GeneralSettingsPageProps> = ({
});
}, [accessToken, userRole, userID]);
const handleEndpointUpdated = () => {
// Refresh the endpoints list when an endpoint is updated
if (accessToken) {
getPassThroughEndpointsCall(accessToken).then((data) => {
let general_settings = data["endpoints"];
setGeneralSettings(general_settings);
});
}
};
const handleResetField = (fieldName: string, idx: number) => {
if (!accessToken) {
return;
@ -150,7 +162,16 @@ const PassThroughSettings: React.FC<GeneralSettingsPageProps> = ({
header: "Path",
accessorKey: "path",
cell: (info: any) => (
<Text className="font-mono">{info.getValue()}</Text>
<div className="overflow-hidden">
<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={() => setSelectedEndpointPath(info.getValue())}
>
{info.getValue()}
</Button>
</div>
),
},
{
@ -168,17 +189,23 @@ const PassThroughSettings: React.FC<GeneralSettingsPageProps> = ({
),
},
{
header: "Action",
header: "Actions",
id: "actions",
cell: ({ row }) => (
<Icon
icon={TrashIcon}
color="red"
className="cursor-pointer"
onClick={() => handleResetField(row.original.path, row.index)}
>
Delete
</Icon>
<div className="flex space-x-1">
<Icon
icon={PencilAltIcon}
size="sm"
onClick={() => setSelectedEndpointPath(row.original.path)}
title="Edit"
/>
<Icon
icon={TrashIcon}
size="sm"
onClick={() => handleResetField(row.original.path, row.index)}
title="Delete"
/>
</div>
),
},
];
@ -187,16 +214,27 @@ const PassThroughSettings: React.FC<GeneralSettingsPageProps> = ({
return null;
}
// If a specific endpoint is selected, show the info view
if (selectedEndpointPath) {
return (
<PassThroughInfoView
endpointPath={selectedEndpointPath}
onClose={() => setSelectedEndpointPath(null)}
accessToken={accessToken}
isAdmin={userRole === "Admin" || userRole === "admin"}
onEndpointUpdated={handleEndpointUpdated}
/>
);
}
return (
<div className="w-full h-[75vh] p-6">
<div className="mb-2 mt-4">
<div>
<div>
<Title>Pass Through Endpoints</Title>
<Text className="text-tremor-content">
Configure and manage your pass-through endpoints
</Text>
</div>
</div>
<AddPassThroughEndpoint
accessToken={accessToken}