diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 3cb9acc6110..0ca7a2c2622 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -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, diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_config_defined_endpoint_crud.py b/tests/test_litellm/proxy/pass_through_endpoints/test_config_defined_endpoint_crud.py new file mode 100644 index 00000000000..e8e834568ce --- /dev/null +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_config_defined_endpoint_crud.py @@ -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 == [] diff --git a/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTableColumns.test.tsx b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTableColumns.test.tsx new file mode 100644 index 00000000000..c203513a29b --- /dev/null +++ b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTableColumns.test.tsx @@ -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[]; data: passThroughItem[] }) { + const table = useReactTable({ columns, data, getCoreRowModel: getCoreRowModel() }); + return ( + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + ))} + +
{flexRender(cell.column.columnDef.cell, cell.getContext())}
+ ); +} + +const renderTable = (data: passThroughItem[]) => + render(); + +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"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTableColumns.tsx b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTableColumns.tsx index d22b274861a..1bdfbdfd5d7 100644 --- a/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTableColumns.tsx @@ -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 ( endpointId && onEndpointClick(endpointId)} + disabled={!endpointId || readOnly} + title={readOnly ? readOnlyHint : undefined} + onClick={() => endpointId && !readOnly && onEndpointClick(endpointId)} > Edit @@ -95,8 +99,9 @@ function EndpointRowActions({ endpoint, onEndpointClick, onDeleteClick }: Endpoi endpointId && onDeleteClick(endpointId)} + disabled={!endpointId || readOnly} + title={readOnly ? readOnlyHint : undefined} + onClick={() => endpointId && !readOnly && onDeleteClick(endpointId)} > Delete @@ -187,6 +192,24 @@ export const getPassThroughEndpointsTableColumns = ({ enableSorting: false, cell: ({ row }) => , }, + { + id: "source", + meta: { title: "Source", skeleton: "badge" }, + header: () => ( + + ), + size: 130, + enableSorting: false, + cell: ({ row }) => + row.original.is_from_config ? ( + Config file + ) : ( + Database + ), + }, { id: "actions", meta: { className: "text-right", headerClassName: "text-right" }, diff --git a/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughSettings.tsx b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughSettings.tsx index 2dc6fdbd32c..9602664a74a 100644 --- a/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughSettings.tsx +++ b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughSettings.tsx @@ -25,6 +25,7 @@ export interface passThroughItem { methods?: string[]; guardrails?: Record; default_query_params?: Record; + is_from_config?: boolean; } const PassThroughSettings: React.FC = ({ accessToken, userRole, userID, premiumUser }) => { diff --git a/ui/litellm-dashboard/src/components/pass_through_info.tsx b/ui/litellm-dashboard/src/components/pass_through_info.tsx index ce7c60dcec0..a693f5196c9 100644 --- a/ui/litellm-dashboard/src/components/pass_through_info.tsx +++ b/ui/litellm-dashboard/src/components/pass_through_info.tsx @@ -106,6 +106,7 @@ interface PassThroughEndpoint { auth?: boolean; methods?: string[]; guardrails?: Record; + is_from_config?: boolean; } // Password field component for headers @@ -244,6 +245,11 @@ const PassThroughInfoView: React.FC = ({

Pass Through Endpoint: {endpointData.path}

{endpointData.id}

+ {endpointData.is_from_config && ( + + Defined in config file — read-only, manage via config.yaml + + )} @@ -252,7 +258,7 @@ const PassThroughInfoView: React.FC = ({ Overview - {isAdmin && ( + {isAdmin && !endpointData.is_from_config && ( Settings @@ -363,8 +369,8 @@ const PassThroughInfoView: React.FC = ({ )} - {/* Settings Panel (only for admins) */} - {isAdmin && ( + {/* Settings Panel (only for admins; config-file endpoints are read-only) */} + {isAdmin && !endpointData.is_from_config && (