From 34f0d22d4a35134726230e68cc580b88ff4e17f1 Mon Sep 17 00:00:00 2001 From: pengzh1 Date: Thu, 27 Aug 2026 17:35:31 +0800 Subject: [PATCH 1/3] fix(proxy): mark config-file pass-through endpoints read-only Config-file-defined pass-through endpoints are merged into the GET /config/pass_through_endpoint list, but the DB-backed DELETE/UPDATE handlers can never manage them. The UI ignored is_from_config, so Edit/Delete stayed clickable and always failed with a misleading 'not found', matching the impossible-to-delete loop in #38195. - UI: disable Edit/Delete for is_from_config rows, add a Source column, hide the Settings tab and show a read-only badge in the detail view - API: DELETE/UPDATE now return a targeted error pointing operators to config.yaml when the id belongs to a config-file endpoint --- .../pass_through_endpoints.py | 25 +++ .../test_config_defined_endpoint_crud.py | 153 ++++++++++++++++++ .../PassThroughEndpointsTableColumns.test.tsx | 85 ++++++++++ .../PassThroughEndpointsTableColumns.tsx | 32 +++- .../PassThroughSettings.tsx | 1 + .../src/components/pass_through_info.tsx | 12 +- 6 files changed, 301 insertions(+), 7 deletions(-) create mode 100644 tests/test_litellm/proxy/pass_through_endpoints/test_config_defined_endpoint_crud.py create mode 100644 ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTableColumns.test.tsx diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 3d60f4f5f3a..45588a0bd21 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -3336,6 +3336,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"}, @@ -3567,6 +3568,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."}, @@ -3604,6 +3606,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..30e60007af7 --- /dev/null +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_config_defined_endpoint_crud.py @@ -0,0 +1,153 @@ +""" +What is this? +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 unittest.mock import AsyncMock, patch + +import pytest +from fastapi import HTTPException + +from litellm.proxy._types import ConfigFieldInfo, 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 + + +@pytest.mark.asyncio +async def test_delete_config_defined_endpoint_returns_targeted_error(): + with ( + patch( + "litellm.proxy.proxy_server.config_passthrough_endpoints", + [dict(CONFIG_ENDPOINT)], + ), + patch( + "litellm.proxy.proxy_server.get_config_general_settings", + side_effect=_db_getter(), + ), + ): + 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(): + from litellm.proxy._types import PassThroughGenericEndpoint + + with ( + patch( + "litellm.proxy.proxy_server.config_passthrough_endpoints", + [dict(CONFIG_ENDPOINT)], + ), + patch( + "litellm.proxy.proxy_server.get_config_general_settings", + side_effect=_db_getter(), + ), + ): + 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 ( + patch( + "litellm.proxy.proxy_server.config_passthrough_endpoints", + [dict(CONFIG_ENDPOINT)], + ), + patch( + "litellm.proxy.proxy_server.get_config_general_settings", + side_effect=_db_getter(), + ), + ): + 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(): + db_getter = _db_getter() + + with ( + patch( + "litellm.proxy.proxy_server.config_passthrough_endpoints", + [dict(CONFIG_ENDPOINT)], + ), + patch( + "litellm.proxy.proxy_server.get_config_general_settings", + side_effect=db_getter, + ), + patch( + "litellm.proxy.proxy_server.update_config_general_settings", + new_callable=AsyncMock, + ) as mock_update, + patch( + "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..171fc0e0f80 100644 --- a/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTableColumns.tsx @@ -73,6 +73,10 @@ interface EndpointRowActionsProps { function EndpointRowActions({ endpoint, onEndpointClick, onDeleteClick }: EndpointRowActionsProps) { const endpointId = endpoint.id; + // Config-file-defined endpoints can only be managed by editing config.yaml; + // the DB-backed API routes cannot create/update/delete them. + 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 +100,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 +193,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 && (
From c3465b9656451371066da63248487615ede89123 Mon Sep 17 00:00:00 2001 From: pengzh1 Date: Thu, 27 Aug 2026 19:53:07 +0800 Subject: [PATCH 2/3] test(proxy): suppress TQ008 for pass-through CRUD global patches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CRUD handlers reach their collaborators (config_passthrough_endpoints, get/update_config_general_settings, route registry) through proxy_server module globals imported at call time — there is no injection seam, so each patch carries a test-quality-ok reason per the repo convention. The three error-path tests now share one _db_only_crud_env contextmanager. --- .../test_config_defined_endpoint_crud.py | 58 ++++++++----------- 1 file changed, 23 insertions(+), 35 deletions(-) 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 index 30e60007af7..e8e834568ce 100644 --- 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 @@ -1,17 +1,18 @@ """ -What is this? 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, UserAPIKeyAuth +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, @@ -48,18 +49,27 @@ def _db_getter(): return _get -@pytest.mark.asyncio -async def test_delete_config_defined_endpoint_returns_targeted_error(): +@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( + 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( + 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", @@ -73,18 +83,7 @@ async def test_delete_config_defined_endpoint_returns_targeted_error(): @pytest.mark.asyncio async def test_update_config_defined_endpoint_returns_targeted_error(): - from litellm.proxy._types import PassThroughGenericEndpoint - - with ( - patch( - "litellm.proxy.proxy_server.config_passthrough_endpoints", - [dict(CONFIG_ENDPOINT)], - ), - patch( - "litellm.proxy.proxy_server.get_config_general_settings", - side_effect=_db_getter(), - ), - ): + with _db_only_crud_env(): with pytest.raises(HTTPException) as exc_info: await update_pass_through_endpoints( endpoint_id="config-endpoint-id", @@ -102,16 +101,7 @@ async def test_update_config_defined_endpoint_returns_targeted_error(): @pytest.mark.asyncio async def test_delete_unknown_endpoint_keeps_generic_not_found(): - with ( - patch( - "litellm.proxy.proxy_server.config_passthrough_endpoints", - [dict(CONFIG_ENDPOINT)], - ), - patch( - "litellm.proxy.proxy_server.get_config_general_settings", - side_effect=_db_getter(), - ), - ): + with _db_only_crud_env(): with pytest.raises(HTTPException) as exc_info: await delete_pass_through_endpoints( endpoint_id="does-not-exist", @@ -124,22 +114,20 @@ async def test_delete_unknown_endpoint_keeps_generic_not_found(): @pytest.mark.asyncio async def test_delete_db_endpoint_still_works(): - db_getter = _db_getter() - with ( - patch( + 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( + 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, + side_effect=_db_getter(), ), - patch( + 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( + 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" ), ): From 19e5837c93fbf3b434947f680a3bb23192381797 Mon Sep 17 00:00:00 2001 From: pengzh1 Date: Thu, 27 Aug 2026 19:55:54 +0800 Subject: [PATCH 3/3] style(ui): tighten read-only comment in pass-through endpoints table --- .../PassThroughSettings/PassThroughEndpointsTableColumns.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTableColumns.tsx b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTableColumns.tsx index 171fc0e0f80..1bdfbdfd5d7 100644 --- a/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTableColumns.tsx +++ b/ui/litellm-dashboard/src/components/PassThroughSettings/PassThroughEndpointsTableColumns.tsx @@ -73,8 +73,7 @@ interface EndpointRowActionsProps { function EndpointRowActions({ endpoint, onEndpointClick, onDeleteClick }: EndpointRowActionsProps) { const endpointId = endpoint.id; - // Config-file-defined endpoints can only be managed by editing config.yaml; - // the DB-backed API routes cannot create/update/delete them. + // 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 (