mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-08 22:21:35 +00:00
Merge 19e5837c93 into 9c05c158cb
This commit is contained in:
commit
1393000b8e
6 changed files with 288 additions and 7 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 == []
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue