From dc2c1122bf6363ef54e5074d2bcc6295cf40e757 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Fri, 20 Jun 2025 18:35:38 -0700 Subject: [PATCH] [Feat] UI QA: Pass through endpoints (#11939) * use ID for pass through management * use id for pass through * fix columns * fix PassThroughInfoView * cleanup * working edit and delete pass through * fix rendering id for pt row * fixes for pt info view * working delete pass through * fix use NumericalInput * fix alignment * qa - creating pt * show route preview * fix show just 1 msg * test_create_pass_through_endpoint * fix ui linting --- litellm/proxy/_types.py | 4 + .../pass_through_endpoints.py | 202 +++++---- .../test_pass_through_endpoints.py | 393 ++++++++++++++++++ .../src/components/add_pass_through.tsx | 21 +- .../src/components/key_value_input.tsx | 9 +- .../src/components/pass_through_info.tsx | 60 ++- .../src/components/pass_through_settings.tsx | 140 +++++-- .../src/components/settings.tsx | 2 +- 8 files changed, 681 insertions(+), 150 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index a040d7fe6f..4546a3d579 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1431,6 +1431,10 @@ class DynamoDBArgs(LiteLLMPydanticObjectBase): class PassThroughGenericEndpoint(LiteLLMPydanticObjectBase): + id: Optional[str] = Field( + default=None, + description="Optional unique identifier for the pass-through endpoint. If not provided, endpoints will be identified by path for backwards compatibility.", + ) path: str = Field(description="The route to be added to the LiteLLM Proxy Server.") target: str = Field( description="The URL to which requests for this path should be forwarded." diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index b022043965..fe0ede1726 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -1023,6 +1023,7 @@ async def initialize_pass_through_endpoints( Returns: None """ + import uuid verbose_proxy_logger.debug("initializing pass through endpoints") from litellm.proxy._types import CommonProxyErrors, LiteLLMRoutes from litellm.proxy.proxy_server import app, premium_user @@ -1030,6 +1031,11 @@ async def initialize_pass_through_endpoints( for endpoint in pass_through_endpoints: if isinstance(endpoint, PassThroughGenericEndpoint): endpoint = endpoint.model_dump() + + # Auto-generate ID for backwards compatibility if not present + if endpoint.get("id") is None: + endpoint["id"] = str(uuid.uuid4()) + _target = endpoint.get("target", None) _path: Optional[str] = endpoint.get("path", None) if _path is None: @@ -1056,7 +1062,7 @@ async def initialize_pass_through_endpoints( continue # Add exact path route - verbose_proxy_logger.debug("Initializing pass through endpoint: %s", _path) + verbose_proxy_logger.debug("Initializing pass through endpoint: %s (ID: %s)", _path, endpoint.get("id")) InitPassThroughEndpointHelpers.add_exact_path_route( app=app, path=_path, @@ -1081,7 +1087,7 @@ async def initialize_pass_through_endpoints( cost_per_request=endpoint.get("cost_per_request", None), ) - verbose_proxy_logger.debug("Added new pass through endpoint: %s", _path) + verbose_proxy_logger.debug("Added new pass through endpoint: %s (ID: %s)", _path, endpoint.get("id")) async def _get_pass_through_endpoints_from_db( @@ -1103,21 +1109,20 @@ async def _get_pass_through_endpoints_from_db( returned_endpoints: List[PassThroughGenericEndpoint] = [] if endpoint_id is None: + # Return all endpoints for endpoint in pass_through_endpoint_data: if isinstance(endpoint, dict): returned_endpoints.append(PassThroughGenericEndpoint(**endpoint)) elif isinstance(endpoint, PassThroughGenericEndpoint): returned_endpoints.append(endpoint) - elif endpoint_id is not None: - for endpoint in 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: - returned_endpoints.append(_endpoint) + else: + # Find specific endpoint by ID + found_endpoint = _find_endpoint_by_id( + pass_through_endpoint_data, endpoint_id + ) + if found_endpoint is not None: + returned_endpoints.append(found_endpoint) + return returned_endpoints @@ -1151,7 +1156,7 @@ async def update_pass_through_endpoints( user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ - Update a pass-through endpoint + Update a pass-through endpoint by ID. """ from litellm.proxy.proxy_server import ( get_config_general_settings, @@ -1176,46 +1181,54 @@ async def update_pass_through_endpoints( detail={"error": "No pass-through endpoints found"}, ) - # Find and update the endpoint - updated_endpoint: Optional[PassThroughGenericEndpoint] = None - endpoint_found = False + # Find the endpoint to update + found_endpoint = _find_endpoint_by_id( + pass_through_endpoint_data, endpoint_id + ) - 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: + if found_endpoint is None: raise HTTPException( status_code=404, detail={ - "error": f"Endpoint with path '{endpoint_id}' not found" + "error": f"Endpoint with ID '{endpoint_id}' not found" }, ) + # Find the index for updating the list + endpoint_index = None + for idx, endpoint in enumerate(pass_through_endpoint_data): + _endpoint = PassThroughGenericEndpoint(**endpoint) if isinstance(endpoint, dict) else endpoint + if _endpoint.id == endpoint_id: + endpoint_index = idx + break + + if endpoint_index is None: + raise HTTPException( + status_code=404, + detail={ + "error": f"Could not find index for endpoint with ID '{endpoint_id}'" + }, + ) + + # 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 = found_endpoint.model_dump() + + # Update with new data (only non-None values) + endpoint_dict.update(update_data) + + # Preserve existing ID if not provided in update and endpoint has ID + if "id" not in update_data and found_endpoint.id is not None: + endpoint_dict["id"] = found_endpoint.id + + # Create updated endpoint object + updated_endpoint = PassThroughGenericEndpoint(**endpoint_dict) + + # Update the list + pass_through_endpoint_data[endpoint_index] = endpoint_dict + ## Update db updated_data = ConfigFieldUpdate( field_name="pass_through_endpoints", @@ -1240,6 +1253,8 @@ async def create_pass_through_endpoints( """ Create new pass-through endpoint """ + import uuid + from litellm.proxy.proxy_server import ( get_config_general_settings, update_config_general_settings, @@ -1256,8 +1271,11 @@ async def create_pass_through_endpoints( field_name="pass_through_endpoints", field_value=None ) - ## Update field with new endpoint + ## Auto-generate ID if not provided data_dict = data.model_dump() + if data_dict.get("id") is None: + data_dict["id"] = str(uuid.uuid4()) + if response.field_value is None: response.field_value = [data_dict] elif isinstance(response.field_value, List): @@ -1273,6 +1291,10 @@ async def create_pass_through_endpoints( data=updated_data, user_api_key_dict=user_api_key_dict ) + # Return the created endpoint with the generated ID + created_endpoint = PassThroughGenericEndpoint(**data_dict) + return PassThroughEndpointResponse(endpoints=[created_endpoint]) + @router.delete( "/config/pass_through_endpoint", @@ -1284,7 +1306,7 @@ async def delete_pass_through_endpoints( user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ - Delete a pass-through endpoint + Delete a pass-through endpoint by ID. Returns - the deleted endpoint """ @@ -1306,27 +1328,46 @@ async def delete_pass_through_endpoints( ## Update field by removing endpoint pass_through_endpoint_data: Optional[List] = response.field_value - response_obj: Optional[PassThroughGenericEndpoint] = None if response.field_value is None or pass_through_endpoint_data is None: raise HTTPException( status_code=400, detail={"error": "There are no pass-through endpoints setup."}, ) - elif isinstance(response.field_value, List): - invalid_idx: Optional[int] = None - 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: - invalid_idx = idx - response_obj = _endpoint + # Find the endpoint to delete + found_endpoint = _find_endpoint_by_id( + pass_through_endpoint_data, endpoint_id + ) + + if found_endpoint is None: + raise HTTPException( + status_code=400, + detail={ + "error": "Endpoint with ID '{}' was not found in pass-through endpoint list.".format( + endpoint_id + ) + }, + ) + + # Find the index for deleting from the list + endpoint_index = None + for idx, endpoint in enumerate(pass_through_endpoint_data): + _endpoint = PassThroughGenericEndpoint(**endpoint) if isinstance(endpoint, dict) else endpoint + if _endpoint.id == endpoint_id: + endpoint_index = idx + break - if invalid_idx is not None: - pass_through_endpoint_data.pop(invalid_idx) + if endpoint_index is None: + raise HTTPException( + status_code=400, + detail={ + "error": f"Could not find index for endpoint with ID '{endpoint_id}'" + }, + ) + + # Remove the endpoint + pass_through_endpoint_data.pop(endpoint_index) + response_obj = found_endpoint ## Update db updated_data = ConfigFieldUpdate( @@ -1338,18 +1379,37 @@ async def delete_pass_through_endpoints( data=updated_data, user_api_key_dict=user_api_key_dict ) - if response_obj is None: - raise HTTPException( - status_code=400, - detail={ - "error": "Endpoint={} was not found in pass-through endpoint list.".format( - endpoint_id - ) - }, - ) return PassThroughEndpointResponse(endpoints=[response_obj]) +def _find_endpoint_by_id( + endpoints_data: List, + endpoint_id: str, +) -> Optional[PassThroughGenericEndpoint]: + """ + Find an endpoint by ID. + + Args: + endpoints_data: List of endpoint data (dicts or PassThroughGenericEndpoint objects) + endpoint_id: ID to search for + + Returns: + Found endpoint or None if not found + """ + for endpoint in endpoints_data: + _endpoint: Optional[PassThroughGenericEndpoint] = None + if isinstance(endpoint, dict): + _endpoint = PassThroughGenericEndpoint(**endpoint) + elif isinstance(endpoint, PassThroughGenericEndpoint): + _endpoint = endpoint + + # Only compare IDs to IDs + if _endpoint is not None and _endpoint.id == endpoint_id: + return _endpoint + + return None + + async def initialize_pass_through_endpoints_in_db(): """ Gets all pass-through endpoints from db and initializes them in the proxy server. diff --git a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py index 70ae9db55d..b8381201b0 100644 --- a/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py +++ b/tests/test_litellm/proxy/pass_through_endpoints/test_pass_through_endpoints.py @@ -852,3 +852,396 @@ async def test_pass_through_request_contains_proxy_server_request_in_kwargs(): assert metadata["user_api_key_alias"] == "test-alias" assert metadata["user_api_key_user_email"] == "test@example.com" assert metadata["user_api_key_user_id"] == "test-user-id" + + +@pytest.mark.asyncio +async def test_create_pass_through_endpoint(): + """ + Test creating a new pass-through endpoint + + This test verifies that the create_pass_through_endpoints function: + 1. Accepts a PassThroughGenericEndpoint object + 2. Auto-generates an ID if not provided + 3. Adds the endpoint to the database + 4. Returns the created endpoint with the generated ID + """ + from litellm.proxy._types import ( + ConfigFieldInfo, + ConfigFieldUpdate, + PassThroughEndpointResponse, + PassThroughGenericEndpoint, + UserAPIKeyAuth, + ) + from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( + create_pass_through_endpoints, + ) + + # Mock the database functions + with patch("litellm.proxy.proxy_server.get_config_general_settings") as mock_get_config: + with patch("litellm.proxy.proxy_server.update_config_general_settings") as mock_update_config: + # Mock existing config (empty list) + mock_get_config.return_value = ConfigFieldInfo( + field_name="pass_through_endpoints", + field_value=[] + ) + + # Create test endpoint data + test_endpoint = PassThroughGenericEndpoint( + path="/test/endpoint", + target="http://example.com/api", + headers={"Authorization": "Bearer test-token"}, + include_subpath=True, + cost_per_request=0.50 + ) + + # Mock user API key dict + mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth) + + # Call the create function + result = await create_pass_through_endpoints( + data=test_endpoint, + user_api_key_dict=mock_user_api_key_dict + ) + + # Verify the result + assert isinstance(result, PassThroughEndpointResponse) + assert len(result.endpoints) == 1 + + created_endpoint = result.endpoints[0] + assert created_endpoint.path == "/test/endpoint" + assert created_endpoint.target == "http://example.com/api" + assert created_endpoint.headers == {"Authorization": "Bearer test-token"} + assert created_endpoint.include_subpath is True + assert created_endpoint.cost_per_request == 0.50 + assert created_endpoint.id is not None # Should be auto-generated + + # Verify database calls + mock_get_config.assert_called_once_with( + field_name="pass_through_endpoints", + user_api_key_dict=mock_user_api_key_dict + ) + + mock_update_config.assert_called_once() + update_call_args = mock_update_config.call_args[1] + assert update_call_args["data"].field_name == "pass_through_endpoints" + assert len(update_call_args["data"].field_value) == 1 + assert update_call_args["data"].field_value[0]["path"] == "/test/endpoint" + assert update_call_args["data"].field_value[0]["id"] is not None + + +@pytest.mark.asyncio +async def test_update_pass_through_endpoint(): + """ + Test updating an existing pass-through endpoint + + This test verifies that the update_pass_through_endpoints function: + 1. Finds the existing endpoint by ID + 2. Updates only the provided fields (partial updates) + 3. Preserves the existing ID + 4. Updates the database with the modified endpoint + 5. Returns the updated endpoint + """ + from litellm.proxy._types import ( + ConfigFieldInfo, + ConfigFieldUpdate, + PassThroughEndpointResponse, + PassThroughGenericEndpoint, + UserAPIKeyAuth, + ) + from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( + update_pass_through_endpoints, + ) + + # Mock the database functions + with patch("litellm.proxy.proxy_server.get_config_general_settings") as mock_get_config: + with patch("litellm.proxy.proxy_server.update_config_general_settings") as mock_update_config: + # Create existing endpoint data + existing_endpoint_id = "test-endpoint-123" + existing_endpoints = [ + { + "id": existing_endpoint_id, + "path": "/test/endpoint", + "target": "http://example.com/api", + "headers": {"Authorization": "Bearer old-token"}, + "include_subpath": False, + "cost_per_request": 0.25 + } + ] + + # Mock existing config + mock_get_config.return_value = ConfigFieldInfo( + field_name="pass_through_endpoints", + field_value=existing_endpoints + ) + + # Create update data (partial update) + update_data = PassThroughGenericEndpoint( + path="/test/endpoint", # Keep same path + target="http://newapi.com/v2", # Update target + headers={"Authorization": "Bearer new-token", "X-Custom": "header"}, # Update headers + cost_per_request=0.75 # Update cost + # include_subpath not provided - should preserve existing value + ) + + # Mock user API key dict + mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth) + + # Call the update function + result = await update_pass_through_endpoints( + endpoint_id=existing_endpoint_id, + data=update_data, + user_api_key_dict=mock_user_api_key_dict + ) + + # Verify the result + assert isinstance(result, PassThroughEndpointResponse) + assert len(result.endpoints) == 1 + + updated_endpoint = result.endpoints[0] + assert updated_endpoint.id == existing_endpoint_id # ID preserved + assert updated_endpoint.path == "/test/endpoint" + assert updated_endpoint.target == "http://newapi.com/v2" # Updated + assert updated_endpoint.headers == {"Authorization": "Bearer new-token", "X-Custom": "header"} # Updated + assert updated_endpoint.include_subpath is False # Preserved existing value + assert updated_endpoint.cost_per_request == 0.75 # Updated + + # Verify database calls + mock_get_config.assert_called_once_with( + field_name="pass_through_endpoints", + user_api_key_dict=mock_user_api_key_dict + ) + + mock_update_config.assert_called_once() + update_call_args = mock_update_config.call_args[1] + assert update_call_args["data"].field_name == "pass_through_endpoints" + assert len(update_call_args["data"].field_value) == 1 + updated_data = update_call_args["data"].field_value[0] + assert updated_data["id"] == existing_endpoint_id + assert updated_data["target"] == "http://newapi.com/v2" + assert updated_data["cost_per_request"] == 0.75 + + +@pytest.mark.asyncio +async def test_update_pass_through_endpoint_not_found(): + """ + Test updating a non-existent pass-through endpoint raises HTTPException + """ + from fastapi import HTTPException + + from litellm.proxy._types import ( + ConfigFieldInfo, + PassThroughGenericEndpoint, + UserAPIKeyAuth, + ) + from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( + update_pass_through_endpoints, + ) + + # Mock the database functions + with patch("litellm.proxy.proxy_server.get_config_general_settings") as mock_get_config: + # Mock existing config with different endpoint + existing_endpoints = [ + { + "id": "different-endpoint-456", + "path": "/different/endpoint", + "target": "http://different.com/api", + "headers": {}, + "include_subpath": False, + "cost_per_request": 0.0 + } + ] + + mock_get_config.return_value = ConfigFieldInfo( + field_name="pass_through_endpoints", + field_value=existing_endpoints + ) + + # Create update data + update_data = PassThroughGenericEndpoint( + path="/test/endpoint", + target="http://newapi.com/v2" + ) + + # Mock user API key dict + mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth) + + # Call the update function with non-existent ID + with pytest.raises(HTTPException) as exc_info: + await update_pass_through_endpoints( + endpoint_id="non-existent-endpoint-123", + data=update_data, + user_api_key_dict=mock_user_api_key_dict + ) + + # Verify the exception + assert exc_info.value.status_code == 404 + assert "not found" in str(exc_info.value.detail).lower() + + +@pytest.mark.asyncio +async def test_delete_pass_through_endpoint(): + """ + Test deleting an existing pass-through endpoint + + This test verifies that the delete_pass_through_endpoints function: + 1. Finds the existing endpoint by ID + 2. Removes it from the database + 3. Returns the deleted endpoint + """ + from litellm.proxy._types import ( + ConfigFieldInfo, + ConfigFieldUpdate, + PassThroughEndpointResponse, + UserAPIKeyAuth, + ) + from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( + delete_pass_through_endpoints, + ) + + # Mock the database functions + with patch("litellm.proxy.proxy_server.get_config_general_settings") as mock_get_config: + with patch("litellm.proxy.proxy_server.update_config_general_settings") as mock_update_config: + # Create existing endpoint data + endpoint_to_delete_id = "test-endpoint-123" + other_endpoint_id = "other-endpoint-456" + existing_endpoints = [ + { + "id": endpoint_to_delete_id, + "path": "/test/endpoint", + "target": "http://example.com/api", + "headers": {"Authorization": "Bearer test-token"}, + "include_subpath": True, + "cost_per_request": 0.50 + }, + { + "id": other_endpoint_id, + "path": "/other/endpoint", + "target": "http://other.com/api", + "headers": {}, + "include_subpath": False, + "cost_per_request": 0.25 + } + ] + + # Mock existing config + mock_get_config.return_value = ConfigFieldInfo( + field_name="pass_through_endpoints", + field_value=existing_endpoints + ) + + # Mock user API key dict + mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth) + + # Call the delete function + result = await delete_pass_through_endpoints( + endpoint_id=endpoint_to_delete_id, + user_api_key_dict=mock_user_api_key_dict + ) + + # Verify the result + assert isinstance(result, PassThroughEndpointResponse) + assert len(result.endpoints) == 1 + + deleted_endpoint = result.endpoints[0] + assert deleted_endpoint.id == endpoint_to_delete_id + assert deleted_endpoint.path == "/test/endpoint" + assert deleted_endpoint.target == "http://example.com/api" + assert deleted_endpoint.headers == {"Authorization": "Bearer test-token"} + assert deleted_endpoint.include_subpath is True + assert deleted_endpoint.cost_per_request == 0.50 + + # Verify database calls + mock_get_config.assert_called_once_with( + field_name="pass_through_endpoints", + user_api_key_dict=mock_user_api_key_dict + ) + + mock_update_config.assert_called_once() + update_call_args = mock_update_config.call_args[1] + assert update_call_args["data"].field_name == "pass_through_endpoints" + # Should only have the other endpoint remaining + assert len(update_call_args["data"].field_value) == 1 + remaining_endpoint = update_call_args["data"].field_value[0] + assert remaining_endpoint["id"] == other_endpoint_id + assert remaining_endpoint["path"] == "/other/endpoint" + + +@pytest.mark.asyncio +async def test_delete_pass_through_endpoint_not_found(): + """ + Test deleting a non-existent pass-through endpoint raises HTTPException + """ + 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, + ) + + # Mock the database functions + with patch("litellm.proxy.proxy_server.get_config_general_settings") as mock_get_config: + # Mock existing config with different endpoint + existing_endpoints = [ + { + "id": "different-endpoint-456", + "path": "/different/endpoint", + "target": "http://different.com/api", + "headers": {}, + "include_subpath": False, + "cost_per_request": 0.0 + } + ] + + mock_get_config.return_value = ConfigFieldInfo( + field_name="pass_through_endpoints", + field_value=existing_endpoints + ) + + # Mock user API key dict + mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth) + + # Call the delete function with non-existent ID + with pytest.raises(HTTPException) as exc_info: + await delete_pass_through_endpoints( + endpoint_id="non-existent-endpoint-123", + user_api_key_dict=mock_user_api_key_dict + ) + + # Verify the exception + assert exc_info.value.status_code == 400 + assert "not found" in str(exc_info.value.detail).lower() + + +@pytest.mark.asyncio +async def test_delete_pass_through_endpoint_empty_list(): + """ + Test deleting from an empty endpoint list raises HTTPException + """ + 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, + ) + + # Mock the database functions + with patch("litellm.proxy.proxy_server.get_config_general_settings") as mock_get_config: + # Mock empty config + mock_get_config.return_value = ConfigFieldInfo( + field_name="pass_through_endpoints", + field_value=None + ) + + # Mock user API key dict + mock_user_api_key_dict = MagicMock(spec=UserAPIKeyAuth) + + # Call the delete function + with pytest.raises(HTTPException) as exc_info: + await delete_pass_through_endpoints( + endpoint_id="any-endpoint-123", + user_api_key_dict=mock_user_api_key_dict + ) + + # Verify the exception + assert exc_info.value.status_code == 400 + assert "no pass-through endpoints setup" in str(exc_info.value.detail).lower() diff --git a/ui/litellm-dashboard/src/components/add_pass_through.tsx b/ui/litellm-dashboard/src/components/add_pass_through.tsx index 462d5b424e..ac19d0b6a3 100644 --- a/ui/litellm-dashboard/src/components/add_pass_through.tsx +++ b/ui/litellm-dashboard/src/components/add_pass_through.tsx @@ -21,6 +21,7 @@ import { Divider, Collapse, } from "antd"; +import NumericalInput from "./shared/numerical_input"; import { InfoCircleOutlined, ApiOutlined, ExclamationCircleOutlined, CheckCircleOutlined, CopyOutlined } from "@ant-design/icons"; import { keyCreateCall, slackBudgetAlertsHealthCheck, modelAvailableCall } from "./networking"; import { list } from "postcss"; @@ -71,17 +72,12 @@ const AddPassThroughEndpoint: React.FC = ({ try { console.log(`formValues: ${JSON.stringify(formValues)}`); - const newPassThroughItem: passThroughItem = { - "headers": formValues["headers"], - "path": formValues["path"], - "target": formValues["target"], - "include_subpath": formValues["include_subpath"] || false, - "cost_per_request": formValues["cost_per_request"] || 0 - } + const response = await createPassThroughEndpoint(accessToken, formValues); - await createPassThroughEndpoint(accessToken, formValues); + // Use the created endpoint from the API response (includes the generated ID) + const createdEndpoint = response.endpoints[0]; - const updatedPassThroughSettings = [...passThroughItems, newPassThroughItem] + const updatedPassThroughSettings = [...passThroughItems, createdEndpoint] setPassThroughItems(updatedPassThroughSettings) message.success("Pass-through endpoint created successfully"); @@ -284,13 +280,12 @@ const AddPassThroughEndpoint: React.FC = ({ } > - diff --git a/ui/litellm-dashboard/src/components/key_value_input.tsx b/ui/litellm-dashboard/src/components/key_value_input.tsx index 90f58eede6..361d4c0e42 100644 --- a/ui/litellm-dashboard/src/components/key_value_input.tsx +++ b/ui/litellm-dashboard/src/components/key_value_input.tsx @@ -32,7 +32,7 @@ const KeyValueInput: React.FC = ({ value = {}, onChange }) = return (
{pairs.map(([key, val], index) => ( - + = ({ value = {}, onChange }) = value={val} onChange={(e) => handleChange(index, key, e.target.value)} /> - handleRemove(index)} /> +
+ handleRemove(index)} + style={{ cursor: 'pointer' }} + /> +
))} - Pass Through Endpoint - {endpointData.path} + Pass Through Endpoint: {endpointData.path} + {endpointData.id}
@@ -204,6 +193,15 @@ const PassThroughInfoView: React.FC = ({ + {/* Route Preview Section */} +
+ +
+ {endpointData.headers && Object.keys(endpointData.headers).length > 0 && (
diff --git a/ui/litellm-dashboard/src/components/pass_through_settings.tsx b/ui/litellm-dashboard/src/components/pass_through_settings.tsx index 9f80060c6b..6faea63551 100644 --- a/ui/litellm-dashboard/src/components/pass_through_settings.tsx +++ b/ui/litellm-dashboard/src/components/pass_through_settings.tsx @@ -34,23 +34,14 @@ import { Button as Button2, message, InputNumber, + Tooltip, } from "antd"; import { - InformationCircleIcon, PencilAltIcon, - PencilIcon, - StatusOnlineIcon, TrashIcon, - RefreshIcon, - CheckCircleIcon, - XCircleIcon, - QuestionMarkCircleIcon, } 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"; import { ColumnDef } from "@tanstack/react-table"; import { Eye, EyeOff } from "lucide-react"; @@ -76,6 +67,7 @@ interface nestedFieldItem { } export interface passThroughItem { + id?: string path: string target: string headers: object @@ -117,7 +109,9 @@ const PassThroughSettings: React.FC = ({ const [generalSettings, setGeneralSettings] = useState( [] ); - const [selectedEndpointPath, setSelectedEndpointPath] = useState(null); + const [selectedEndpointId, setSelectedEndpointId] = useState(null); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [endpointToDelete, setEndpointToDelete] = useState(null); useEffect(() => { if (!accessToken || !userRole || !userID) { @@ -139,41 +133,65 @@ const PassThroughSettings: React.FC = ({ } }; - const handleResetField = (fieldName: string, idx: number) => { - if (!accessToken) { + const handleDelete = async (endpointId: string) => { + // Set the endpoint to delete and open the confirmation modal + setEndpointToDelete(endpointId); + setIsDeleteModalOpen(true); + }; + + const confirmDelete = async () => { + if (endpointToDelete == null || !accessToken) { return; } try { - deletePassThroughEndpointsCall(accessToken, fieldName); + await deletePassThroughEndpointsCall(accessToken, endpointToDelete); - const updatedSettings = generalSettings.filter((setting) => setting.path !== fieldName); + const updatedSettings = generalSettings.filter((setting) => setting.id !== endpointToDelete); setGeneralSettings(updatedSettings); message.success("Endpoint deleted successfully."); } catch (error) { - // do something + console.error("Error deleting the endpoint:", error); + message.error("Error deleting the endpoint: " + error); } + + // Close the confirmation modal and reset the endpointToDelete + setIsDeleteModalOpen(false); + setEndpointToDelete(null); + }; + + const cancelDelete = () => { + // Close the confirmation modal and reset the endpointToDelete + setIsDeleteModalOpen(false); + setEndpointToDelete(null); + }; + + const handleResetField = (endpointId: string, idx: number) => { + // Use handleDelete instead of direct deletion + handleDelete(endpointId); }; // Define columns for the DataTable const columns: ColumnDef[] = [ { - header: "Path", - accessorKey: "path", + header: "ID", + accessorKey: "id", cell: (info: any) => ( -
- -
+ {info.row.original.id} +
+ ), }, + { + header: "Path", + accessorKey: "path" + }, { header: "Target", accessorKey: "target", @@ -196,13 +214,13 @@ const PassThroughSettings: React.FC = ({ setSelectedEndpointPath(row.original.path)} + onClick={() => row.original.id && setSelectedEndpointId(row.original.id)} title="Edit" /> handleResetField(row.original.path, row.index)} + onClick={() => handleResetField(row.original.id!, row.index)} title="Delete" /> @@ -215,11 +233,20 @@ const PassThroughSettings: React.FC = ({ } // If a specific endpoint is selected, show the info view - if (selectedEndpointPath) { + if (selectedEndpointId) { + // Find the endpoint by ID to get the endpoint data for the info view + console.log("selectedEndpointId", selectedEndpointId); + console.log("generalSettings", generalSettings); + const selectedEndpoint = generalSettings.find(endpoint => endpoint.id === selectedEndpointId); + + if (!selectedEndpoint) { + return
Endpoint not found
; + } + return ( setSelectedEndpointPath(null)} + endpointData={selectedEndpoint} + onClose={() => setSelectedEndpointId(null)} accessToken={accessToken} isAdmin={userRole === "Admin" || userRole === "admin"} onEndpointUpdated={handleEndpointUpdated} @@ -250,6 +277,55 @@ const PassThroughSettings: React.FC = ({ isLoading={false} noDataMessage="No pass-through endpoints configured" /> + + {isDeleteModalOpen && ( +
+
+ + + {/* Modal Panel */} + + + {/* Confirmation Modal Content */} +
+
+
+
+

+ Delete Pass-Through Endpoint +

+
+

+ Are you sure you want to delete this pass-through endpoint? This action cannot be undone. +

+
+
+
+
+
+ + +
+
+
+
+ )} ); }; diff --git a/ui/litellm-dashboard/src/components/settings.tsx b/ui/litellm-dashboard/src/components/settings.tsx index eef5014ccf..f661337b82 100644 --- a/ui/litellm-dashboard/src/components/settings.tsx +++ b/ui/litellm-dashboard/src/components/settings.tsx @@ -783,7 +783,7 @@ const Settings: React.FC = ({ okButtonProps={{ danger: true }} >

- Are you sure you want to delete the callback "{callbackToDelete}"? + Are you sure you want to delete the callback - {callbackToDelete}? This action cannot be undone.