This commit is contained in:
KevinZhou 2026-09-05 12:34:53 +08:00 committed by GitHub
commit 1393000b8e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 288 additions and 7 deletions

View file

@ -3484,6 +3484,7 @@ async def update_pass_through_endpoints(
found_endpoint: Final = _find_endpoint_by_id(pass_through_endpoint_data, endpoint_id)
if found_endpoint is None:
_raise_if_config_defined_endpoint(endpoint_id=endpoint_id, action="updated")
raise HTTPException(
status_code=404,
detail={"error": f"Endpoint with ID '{endpoint_id}' not found"},
@ -3715,6 +3716,7 @@ async def delete_pass_through_endpoints(
found_endpoint: Final = _find_endpoint_by_id(pass_through_endpoint_data, endpoint_id)
if found_endpoint is None:
_raise_if_config_defined_endpoint(endpoint_id=endpoint_id, action="deleted")
raise HTTPException(
status_code=400,
detail={"error": f"Endpoint with ID '{endpoint_id}' was not found in pass-through endpoint list."},
@ -3752,6 +3754,29 @@ async def delete_pass_through_endpoints(
return PassThroughEndpointResponse(endpoints=[response_obj])
def _raise_if_config_defined_endpoint(
endpoint_id: str,
action: str,
) -> None:
"""
Raise a targeted error when the endpoint_id belongs to a config-file-defined
endpoint. The DB-backed CRUD handlers can only manage the DB copy, so a
config-file entry otherwise surfaces as a misleading "not found" while the
GET list keeps showing it (config entries are merged back in on every read).
"""
config_endpoints: Final = _get_pass_through_endpoints_from_config()
if _find_endpoint_by_id(config_endpoints, endpoint_id) is not None:
raise HTTPException(
status_code=400,
detail={
"error": (
f"Endpoint with ID '{endpoint_id}' is defined in your config file and cannot be {action} from the UI. "
"Remove it from general_settings > pass_through_endpoints in your config.yaml and restart the proxy."
)
},
)
def _find_endpoint_by_id(
endpoints_data: list,
endpoint_id: str,

View file

@ -0,0 +1,141 @@
"""
Regression tests for #38195: config-file-defined pass-through endpoints are
merged into the GET list but can never be managed by the DB-backed CRUD
handlers. DELETE/UPDATE must return a targeted error pointing the operator
back to config.yaml instead of a misleading "not found".
"""
from collections.abc import Iterator
from contextlib import contextmanager
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import HTTPException
from litellm.proxy._types import ConfigFieldInfo, PassThroughGenericEndpoint, UserAPIKeyAuth
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
delete_pass_through_endpoints,
update_pass_through_endpoints,
)
CONFIG_ENDPOINT = {
"path": "/config-defined-endpoint",
"target": "https://example.com/config-defined",
"headers": {},
"id": "config-endpoint-id",
}
DB_ENDPOINT = {
"path": "/db-endpoint",
"target": "https://example.com/db",
"headers": {},
"id": "db-endpoint-id",
}
def _user() -> UserAPIKeyAuth:
return UserAPIKeyAuth(user_id="user-1")
def _db_getter():
"""get_config_general_settings stand-in that only sees the DB copy."""
async def _get(field_name: str, user_api_key_dict=None):
return ConfigFieldInfo(
field_name=field_name,
field_value=[dict(DB_ENDPOINT)],
)
return _get
@contextmanager
def _db_only_crud_env() -> Iterator[None]:
"""Point the CRUD handlers at a DB copy plus one config-file endpoint,
the state a user of #38195 ends up in: the config endpoint is listed by
GET but absent from the DB-backed CRUD path."""
with (
patch( # test-quality-ok: proxy_server module global is the endpoint's only injection point
"litellm.proxy.proxy_server.config_passthrough_endpoints",
[dict(CONFIG_ENDPOINT)],
),
patch( # test-quality-ok: handlers import this from proxy_server at call time; no seam to inject
"litellm.proxy.proxy_server.get_config_general_settings",
side_effect=_db_getter(),
),
):
yield
@pytest.mark.asyncio
async def test_delete_config_defined_endpoint_returns_targeted_error():
with _db_only_crud_env():
with pytest.raises(HTTPException) as exc_info:
await delete_pass_through_endpoints(
endpoint_id="config-endpoint-id",
user_api_key_dict=_user(),
)
assert exc_info.value.status_code == 400
assert "defined in your config file" in str(exc_info.value.detail)
assert "config.yaml" in str(exc_info.value.detail)
@pytest.mark.asyncio
async def test_update_config_defined_endpoint_returns_targeted_error():
with _db_only_crud_env():
with pytest.raises(HTTPException) as exc_info:
await update_pass_through_endpoints(
endpoint_id="config-endpoint-id",
data=PassThroughGenericEndpoint(
path="/config-defined-endpoint",
target="https://example.com/updated",
),
request=None,
user_api_key_dict=_user(),
)
assert exc_info.value.status_code == 400
assert "defined in your config file" in str(exc_info.value.detail)
@pytest.mark.asyncio
async def test_delete_unknown_endpoint_keeps_generic_not_found():
with _db_only_crud_env():
with pytest.raises(HTTPException) as exc_info:
await delete_pass_through_endpoints(
endpoint_id="does-not-exist",
user_api_key_dict=_user(),
)
assert exc_info.value.status_code == 400
assert "was not found in pass-through endpoint list" in str(exc_info.value.detail)
@pytest.mark.asyncio
async def test_delete_db_endpoint_still_works():
with (
patch( # test-quality-ok: proxy_server module global is the endpoint's only injection point
"litellm.proxy.proxy_server.config_passthrough_endpoints",
[dict(CONFIG_ENDPOINT)],
),
patch( # test-quality-ok: handlers import this from proxy_server at call time; no seam to inject
"litellm.proxy.proxy_server.get_config_general_settings",
side_effect=_db_getter(),
),
patch( # test-quality-ok: handlers import this from proxy_server at call time; no seam to inject
"litellm.proxy.proxy_server.update_config_general_settings",
new_callable=AsyncMock,
) as mock_update,
patch( # test-quality-ok: mutates the live FastAPI route registry; no injection seam
"litellm.proxy.pass_through_endpoints.pass_through_endpoints.InitPassThroughEndpointHelpers.remove_endpoint_routes"
),
):
response = await delete_pass_through_endpoints(
endpoint_id="db-endpoint-id",
user_api_key_dict=_user(),
)
assert response.endpoints[0].id == "db-endpoint-id"
saved_value = mock_update.call_args.kwargs["data"].field_value
assert saved_value == []

View file

@ -0,0 +1,85 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { flexRender, getCoreRowModel, useReactTable, type ColumnDef } from "@tanstack/react-table";
import { getPassThroughEndpointsTableColumns } from "./PassThroughEndpointsTableColumns";
import type { passThroughItem } from "./PassThroughSettings";
const dbEndpoint: passThroughItem = {
id: "db-endpoint-id",
path: "/db-endpoint",
target: "https://example.com/db",
headers: {},
};
const configEndpoint: passThroughItem = {
id: "config-endpoint-id",
path: "/config-endpoint",
target: "https://example.com/config",
headers: {},
is_from_config: true,
};
const defaultDeps = {
onEndpointClick: vi.fn(),
onDeleteClick: vi.fn(),
};
// Renders the column definitions through a real TanStack table so each `cell`
// renderer runs exactly as the DataTable runs it.
function TableHarness({ columns, data }: { columns: ColumnDef<passThroughItem>[]; data: passThroughItem[] }) {
const table = useReactTable({ columns, data, getCoreRowModel: getCoreRowModel() });
return (
<table>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id}>
{row.getVisibleCells().map((cell) => (
<td key={cell.id}>{flexRender(cell.column.columnDef.cell, cell.getContext())}</td>
))}
</tr>
))}
</tbody>
</table>
);
}
const renderTable = (data: passThroughItem[]) =>
render(<TableHarness columns={getPassThroughEndpointsTableColumns(defaultDeps)} data={data} />);
describe("getPassThroughEndpointsTableColumns", () => {
it("shows the source of each endpoint", () => {
renderTable([dbEndpoint, configEndpoint]);
expect(screen.getByText("Database")).toBeInTheDocument();
expect(screen.getByText("Config file")).toBeInTheDocument();
});
it("disables Edit and Delete for config-file-defined endpoints", async () => {
const user = userEvent.setup();
renderTable([configEndpoint]);
await user.click(screen.getByTestId("endpoint-actions-config-endpoint-id"));
const editItem = await screen.findByTestId("endpoint-action-edit");
const deleteItem = screen.getByTestId("endpoint-action-delete");
expect(editItem).toHaveAttribute("aria-disabled", "true");
expect(deleteItem).toHaveAttribute("aria-disabled", "true");
});
it("keeps Edit and Delete enabled for DB endpoints", async () => {
const user = userEvent.setup();
renderTable([dbEndpoint]);
await user.click(screen.getByTestId("endpoint-actions-db-endpoint-id"));
const editItem = await screen.findByTestId("endpoint-action-edit");
const deleteItem = screen.getByTestId("endpoint-action-delete");
expect(editItem).not.toHaveAttribute("aria-disabled", "true");
expect(deleteItem).not.toHaveAttribute("aria-disabled", "true");
await user.click(deleteItem);
expect(defaultDeps.onDeleteClick).toHaveBeenCalledWith("db-endpoint-id");
});
});

View file

@ -73,6 +73,9 @@ interface EndpointRowActionsProps {
function EndpointRowActions({ endpoint, onEndpointClick, onDeleteClick }: EndpointRowActionsProps) {
const endpointId = endpoint.id;
// The DB-backed CRUD API cannot manage config-file-defined endpoints.
const readOnly = endpoint.is_from_config === true;
const readOnlyHint = "Defined in config file — edit config.yaml to manage this endpoint";
return (
<DropdownMenu>
<DropdownMenuTrigger
@ -85,8 +88,9 @@ function EndpointRowActions({ endpoint, onEndpointClick, onDeleteClick }: Endpoi
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuItem
data-testid="endpoint-action-edit"
disabled={!endpointId}
onClick={() => endpointId && onEndpointClick(endpointId)}
disabled={!endpointId || readOnly}
title={readOnly ? readOnlyHint : undefined}
onClick={() => endpointId && !readOnly && onEndpointClick(endpointId)}
>
<Pencil />
Edit
@ -95,8 +99,9 @@ function EndpointRowActions({ endpoint, onEndpointClick, onDeleteClick }: Endpoi
<DropdownMenuItem
variant="destructive"
data-testid="endpoint-action-delete"
disabled={!endpointId}
onClick={() => endpointId && onDeleteClick(endpointId)}
disabled={!endpointId || readOnly}
title={readOnly ? readOnlyHint : undefined}
onClick={() => endpointId && !readOnly && onDeleteClick(endpointId)}
>
<Trash2 />
Delete
@ -187,6 +192,24 @@ export const getPassThroughEndpointsTableColumns = ({
enableSorting: false,
cell: ({ row }) => <HeadersCell value={row.original.headers || {}} />,
},
{
id: "source",
meta: { title: "Source", skeleton: "badge" },
header: () => (
<HeaderWithTooltip
title="Source"
tooltip="Config file endpoints are read-only in the UI — manage them via config.yaml"
/>
),
size: 130,
enableSorting: false,
cell: ({ row }) =>
row.original.is_from_config ? (
<Badge variant="outline">Config file</Badge>
) : (
<Badge variant="secondary">Database</Badge>
),
},
{
id: "actions",
meta: { className: "text-right", headerClassName: "text-right" },

View file

@ -25,6 +25,7 @@ export interface passThroughItem {
methods?: string[];
guardrails?: Record<string, { request_fields?: string[]; response_fields?: string[] } | null>;
default_query_params?: Record<string, string>;
is_from_config?: boolean;
}
const PassThroughSettings: React.FC<PassThroughSettingsProps> = ({ accessToken, userRole, userID, premiumUser }) => {

View file

@ -106,6 +106,7 @@ interface PassThroughEndpoint {
auth?: boolean;
methods?: string[];
guardrails?: Record<string, { request_fields?: string[]; response_fields?: string[] } | null>;
is_from_config?: boolean;
}
// Password field component for headers
@ -244,6 +245,11 @@ const PassThroughInfoView: React.FC<PassThroughInfoProps> = ({
</Button>
<h2 className="text-xl font-semibold">Pass Through Endpoint: {endpointData.path}</h2>
<p className="text-sm text-muted-foreground font-mono">{endpointData.id}</p>
{endpointData.is_from_config && (
<Badge variant="outline" className="mt-2">
Defined in config file read-only, manage via config.yaml
</Badge>
)}
</div>
</div>
@ -252,7 +258,7 @@ const PassThroughInfoView: React.FC<PassThroughInfoProps> = ({
<TabsTrigger value="overview" className="flex-none rounded-none px-4 py-2">
Overview
</TabsTrigger>
{isAdmin && (
{isAdmin && !endpointData.is_from_config && (
<TabsTrigger value="settings" className="flex-none rounded-none px-4 py-2">
Settings
</TabsTrigger>
@ -363,8 +369,8 @@ const PassThroughInfoView: React.FC<PassThroughInfoProps> = ({
)}
</TabsContent>
{/* Settings Panel (only for admins) */}
{isAdmin && (
{/* Settings Panel (only for admins; config-file endpoints are read-only) */}
{isAdmin && !endpointData.is_from_config && (
<TabsContent value="settings" keepMounted>
<Card className="block p-6">
<div className="flex justify-between items-center mb-4">