diff --git a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py index 227ea4db9f..91957144a3 100644 --- a/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py +++ b/litellm/proxy/pass_through_endpoints/pass_through_endpoints.py @@ -1818,23 +1818,91 @@ async def _get_pass_through_endpoints_from_db( return returned_endpoints +async def _filter_endpoints_by_team_allowed_routes( + team_id: str, + pass_through_endpoints: List[PassThroughGenericEndpoint], + prisma_client, +) -> List[PassThroughGenericEndpoint]: + """ + Filter pass-through endpoints based on team's allowed_passthrough_routes metadata. + + Args: + team_id: The team ID to check permissions for + pass_through_endpoints: List of endpoints to filter + prisma_client: Database client + + Returns: + Filtered list of endpoints based on team permissions + + Raises: + HTTPException: If team is not found + """ + # retrieve team from db + team = await prisma_client.db.litellm_teamtable.find_unique( + where={"team_id": team_id}, + ) + if team is None: + raise HTTPException( + status_code=404, + detail={"error": "Team not found"}, + ) + + # retrieve team metadata + team_metadata = team.metadata + if ( + team_metadata is not None + and team_metadata.get("allowed_passthrough_routes") is not None + ): + ## FILTER pass_through_endpoints by allowed_passthrough_routes + pass_through_endpoints = [ + endpoint + for endpoint in pass_through_endpoints + if endpoint.path in team_metadata.get("allowed_passthrough_routes") + ] + + return pass_through_endpoints + + @router.get( "/config/pass_through_endpoint", dependencies=[Depends(user_api_key_auth)], response_model=PassThroughEndpointResponse, ) +@router.get( + "/config/pass_through_endpoint/team/{team_id}", + dependencies=[Depends(user_api_key_auth)], + response_model=PassThroughEndpointResponse, +) async def get_pass_through_endpoints( endpoint_id: Optional[str] = None, user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), + team_id: Optional[str] = None, ): """ GET configured pass through endpoint. If no endpoint_id given, return all configured endpoints. """ ## Get existing pass-through endpoint field value + from litellm.proxy._types import CommonProxyErrors + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + raise HTTPException( + status_code=500, + detail={"error": CommonProxyErrors.db_not_connected_error.value}, + ) + pass_through_endpoints = await _get_pass_through_endpoints_from_db( endpoint_id=endpoint_id, user_api_key_dict=user_api_key_dict ) + + if team_id is not None: + pass_through_endpoints = await _filter_endpoints_by_team_allowed_routes( + team_id=team_id, + pass_through_endpoints=pass_through_endpoints, + prisma_client=prisma_client, + ) + return PassThroughEndpointResponse(endpoints=pass_through_endpoints) 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 1a26f8a39a..ce90c1ed8a 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 @@ -670,7 +670,7 @@ async def test_create_pass_through_route_with_cost_per_request(): # Create a proper UserAPIKeyAuth mock mock_user_api_key_dict = MagicMock() mock_user_api_key_dict.api_key = "test-key" - + await endpoint_func( request=mock_request, user_api_key_dict=mock_user_api_key_dict, @@ -747,12 +747,12 @@ async def test_pass_through_request_contains_proxy_server_request_in_kwargs(): """ Test that pass_through_request (parent method) correctly includes proxy_server_request in kwargs passed to the success handler. - - Critical Test: Ensures that when pass_through_request is called, the kwargs passed to + + Critical Test: Ensures that when pass_through_request is called, the kwargs passed to downstream methods contain the proxy server request details (url, method, body). """ print("running test_pass_through_request_contains_proxy_server_request_in_kwargs") - + with patch("litellm.proxy.proxy_server.proxy_logging_obj") as mock_proxy_logging: with patch( "litellm.proxy.pass_through_endpoints.pass_through_endpoints.HttpPassThroughEndpointHelpers.non_streaming_http_request_handler" @@ -766,38 +766,44 @@ async def test_pass_through_request_contains_proxy_server_request_in_kwargs(): with patch( "litellm.proxy.pass_through_endpoints.pass_through_endpoints.get_response_body" ) as mock_get_response_body: - # Setup mock for pre_call_hook and post_call_failure_hook - mock_proxy_logging.pre_call_hook = AsyncMock(return_value={"test": "data"}) + # Setup mock for pre_call_hook and post_call_failure_hook + mock_proxy_logging.pre_call_hook = AsyncMock( + return_value={"test": "data"} + ) mock_proxy_logging.post_call_failure_hook = AsyncMock() - + # Setup mock for http response mock_response = MagicMock() mock_response.status_code = 200 mock_response.headers = {} - mock_response.aread = AsyncMock(return_value=b'{"success": true}') + mock_response.aread = AsyncMock( + return_value=b'{"success": true}' + ) mock_response.text = '{"success": true}' mock_response.raise_for_status = MagicMock() - + # Mock the HTTP request handler directly mock_http_handler.return_value = mock_response - + # Mock response body parser mock_get_response_body.return_value = {"success": True} - + # Mock headers for custom headers mock_processing.get_custom_headers.return_value = {} - + # Mock success handler to capture kwargs mock_success_handler.return_value = None - + # Create mock request mock_request = MagicMock(spec=Request) mock_request.method = "POST" mock_request.url = "http://test-proxy.com/api/endpoint" - mock_request.body = AsyncMock(return_value=b'{"message": "test request"}') + mock_request.body = AsyncMock( + return_value=b'{"message": "test request"}' + ) mock_request.headers = Headers({}) mock_request.query_params = QueryParams({}) - + # Create mock user API key dict mock_user_api_key_dict = MagicMock() mock_user_api_key_dict.api_key = "test-api-key" @@ -809,7 +815,7 @@ async def test_pass_through_request_contains_proxy_server_request_in_kwargs(): mock_user_api_key_dict.team_alias = "test-team-alias" mock_user_api_key_dict.end_user_id = "test-end-user-id" mock_user_api_key_dict.request_route = "/api/endpoint" - + # Call pass_through_request (the parent method) result = await pass_through_request( request=mock_request, @@ -817,38 +823,38 @@ async def test_pass_through_request_contains_proxy_server_request_in_kwargs(): custom_headers={"X-Custom": "header"}, user_api_key_dict=mock_user_api_key_dict, ) - + # Verify the success handler was called mock_success_handler.assert_called_once() - + # Extract the kwargs passed to the success handler call_kwargs = mock_success_handler.call_args[1] - + # Verify that litellm_params exists in kwargs assert "litellm_params" in call_kwargs litellm_params = call_kwargs["litellm_params"] - + # Verify that proxy_server_request exists in litellm_params assert "proxy_server_request" in litellm_params proxy_server_request = litellm_params["proxy_server_request"] - + # Verify the proxy_server_request contains expected fields assert "url" in proxy_server_request assert "method" in proxy_server_request assert "body" in proxy_server_request - + # Verify the values match the original request assert proxy_server_request["url"] == str(mock_request.url) assert proxy_server_request["method"] == mock_request.method # The body should be the value returned by pre_call_hook, not the original request body assert proxy_server_request["body"] == {"test": "data"} - + # Verify other required kwargs are present assert "call_type" in call_kwargs assert call_kwargs["call_type"] == "pass_through_endpoint" assert "litellm_call_id" in call_kwargs assert "passthrough_logging_payload" in call_kwargs - + # Verify metadata contains user information assert "metadata" in litellm_params metadata = litellm_params["metadata"] @@ -862,7 +868,7 @@ async def test_pass_through_request_contains_proxy_server_request_in_kwargs(): 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 @@ -881,36 +887,38 @@ async def test_create_pass_through_endpoint(): ) # 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: + 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=[] + 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 + 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 + 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" @@ -918,13 +926,13 @@ async def test_create_pass_through_endpoint(): 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 + 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" @@ -937,7 +945,7 @@ async def test_create_pass_through_endpoint(): 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) @@ -957,8 +965,12 @@ async def test_update_pass_through_endpoint(): ) # 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: + 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 = [ @@ -968,53 +980,58 @@ async def test_update_pass_through_endpoint(): "target": "http://example.com/api", "headers": {"Authorization": "Bearer old-token"}, "include_subpath": False, - "cost_per_request": 0.25 + "cost_per_request": 0.25, } ] - + # Mock existing config mock_get_config.return_value = ConfigFieldInfo( - field_name="pass_through_endpoints", - field_value=existing_endpoints + 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 + 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 + 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.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 + 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" @@ -1042,7 +1059,9 @@ async def test_update_pass_through_endpoint_not_found(): ) # Mock the database functions - with patch("litellm.proxy.proxy_server.get_config_general_settings") as mock_get_config: + with patch( + "litellm.proxy.proxy_server.get_config_general_settings" + ) as mock_get_config: # Mock existing config with different endpoint existing_endpoints = [ { @@ -1051,32 +1070,30 @@ async def test_update_pass_through_endpoint_not_found(): "target": "http://different.com/api", "headers": {}, "include_subpath": False, - "cost_per_request": 0.0 + "cost_per_request": 0.0, } ] - + mock_get_config.return_value = ConfigFieldInfo( - field_name="pass_through_endpoints", - field_value=existing_endpoints + field_name="pass_through_endpoints", field_value=existing_endpoints ) - + # Create update data update_data = PassThroughGenericEndpoint( - path="/test/endpoint", - target="http://newapi.com/v2" + 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 + 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() @@ -1086,7 +1103,7 @@ async def test_update_pass_through_endpoint_not_found(): 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 @@ -1103,8 +1120,12 @@ async def test_delete_pass_through_endpoint(): ) # 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: + 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" @@ -1115,7 +1136,7 @@ async def test_delete_pass_through_endpoint(): "target": "http://example.com/api", "headers": {"Authorization": "Bearer test-token"}, "include_subpath": True, - "cost_per_request": 0.50 + "cost_per_request": 0.50, }, { "id": other_endpoint_id, @@ -1123,29 +1144,28 @@ async def test_delete_pass_through_endpoint(): "target": "http://other.com/api", "headers": {}, "include_subpath": False, - "cost_per_request": 0.25 - } + "cost_per_request": 0.25, + }, ] - + # Mock existing config mock_get_config.return_value = ConfigFieldInfo( - field_name="pass_through_endpoints", - field_value=existing_endpoints + 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 + 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" @@ -1153,13 +1173,13 @@ async def test_delete_pass_through_endpoint(): 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 + 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" @@ -1183,7 +1203,9 @@ async def test_delete_pass_through_endpoint_not_found(): ) # Mock the database functions - with patch("litellm.proxy.proxy_server.get_config_general_settings") as mock_get_config: + with patch( + "litellm.proxy.proxy_server.get_config_general_settings" + ) as mock_get_config: # Mock existing config with different endpoint existing_endpoints = [ { @@ -1192,25 +1214,24 @@ async def test_delete_pass_through_endpoint_not_found(): "target": "http://different.com/api", "headers": {}, "include_subpath": False, - "cost_per_request": 0.0 + "cost_per_request": 0.0, } ] - + mock_get_config.return_value = ConfigFieldInfo( - field_name="pass_through_endpoints", - field_value=existing_endpoints + 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 + 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() @@ -1229,34 +1250,33 @@ async def test_delete_pass_through_endpoint_empty_list(): ) # Mock the database functions - with patch("litellm.proxy.proxy_server.get_config_general_settings") as mock_get_config: + 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 + 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 + 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() - @pytest.mark.asyncio async def test_pass_through_request_query_params_forwarding(): """ Test that query parameters from the original request are properly forwarded to the target URL. - + This test verifies the fix for the bug where query parameters like api-version were being lost when forwarding requests to Azure OpenAI and other pass-through endpoints. """ @@ -1275,42 +1295,57 @@ async def test_pass_through_request_query_params_forwarding(): ) as mock_get_response_body: # Setup mock for pre_call_hook test_body = {"name": "Azure Assistant", "model": "gpt-4o"} - mock_proxy_logging.pre_call_hook = AsyncMock(return_value=test_body) - + mock_proxy_logging.pre_call_hook = AsyncMock( + return_value=test_body + ) + # Setup mock for http response mock_response = MagicMock() mock_response.status_code = 200 mock_response.headers = {"content-type": "application/json"} - mock_response.aread = AsyncMock(return_value=b'{"id": "asst_123", "object": "assistant"}') + mock_response.aread = AsyncMock( + return_value=b'{"id": "asst_123", "object": "assistant"}' + ) mock_response.text = '{"id": "asst_123", "object": "assistant"}' mock_response.raise_for_status = MagicMock() - + # Mock the HTTP request handler to capture the call mock_http_handler.return_value = mock_response - + # Mock response body parser - mock_get_response_body.return_value = {"id": "asst_123", "object": "assistant"} - + mock_get_response_body.return_value = { + "id": "asst_123", + "object": "assistant", + } + # Mock headers for custom headers mock_processing.get_custom_headers.return_value = {} - + # Mock success handler mock_success_handler.return_value = None - + # Create mock request with query parameters (Azure API version) mock_request = MagicMock(spec=Request) mock_request.method = "POST" - mock_request.url = "http://localhost:4000/azure-assistant/openai/assistants" - mock_request.body = AsyncMock(return_value=json.dumps(test_body).encode()) - mock_request.headers = Headers({"Content-Type": "application/json"}) - + mock_request.url = ( + "http://localhost:4000/azure-assistant/openai/assistants" + ) + mock_request.body = AsyncMock( + return_value=json.dumps(test_body).encode() + ) + mock_request.headers = Headers( + {"Content-Type": "application/json"} + ) + # Create QueryParams with api-version parameter - mock_request.query_params = QueryParams([("api-version", "2025-01-01-preview")]) - + mock_request.query_params = QueryParams( + [("api-version", "2025-01-01-preview")] + ) + # Create mock user API key dict mock_user_api_key_dict = MagicMock() mock_user_api_key_dict.api_key = "sk-1234" - + # Call pass_through_request result = await pass_through_request( request=mock_request, @@ -1318,20 +1353,25 @@ async def test_pass_through_request_query_params_forwarding(): custom_headers={"Authorization": "Bearer azure_token"}, user_api_key_dict=mock_user_api_key_dict, ) - + # Verify the HTTP handler was called mock_http_handler.assert_called_once() - + # Extract the call arguments to verify query parameters were passed call_kwargs = mock_http_handler.call_args[1] - + # The key assertion: query parameters should be preserved and passed to the HTTP handler assert "requested_query_params" in call_kwargs - assert call_kwargs["requested_query_params"] == {"api-version": "2025-01-01-preview"} - + assert call_kwargs["requested_query_params"] == { + "api-version": "2025-01-01-preview" + } + # Verify the target URL is correct - assert str(call_kwargs["url"]) == "https://krris-m2f9a9i7-eastus2.openai.azure.com/openai/assistants" - + assert ( + str(call_kwargs["url"]) + == "https://krris-m2f9a9i7-eastus2.openai.azure.com/openai/assistants" + ) + # Verify the request body is preserved assert call_kwargs["_parsed_body"] == test_body @@ -1356,15 +1396,16 @@ async def test_pass_through_with_httpbin_redirect(): mock_request.method = "GET" mock_request.headers = Headers({}) mock_request.query_params = QueryParams("") - + # Mock the body method to return empty bytes for GET request async def mock_body(): return b"" + mock_request.body = mock_body - + # Mock user API key dict mock_user_api_key_dict = MagicMock() - + try: # Test with httpbin.org redirect endpoint # This will redirect to httpbin.org/get @@ -1372,19 +1413,283 @@ async def test_pass_through_with_httpbin_redirect(): request=mock_request, target="https://httpbin.org/redirect/1", custom_headers={}, - user_api_key_dict=mock_user_api_key_dict + user_api_key_dict=mock_user_api_key_dict, ) - + # Should get the final response (200) from /get endpoint, not the redirect (302) assert response.status_code == 200 - + # The response should be from the /get endpoint - response_content = response.body.decode('utf-8') - + response_content = response.body.decode("utf-8") + # httpbin.org/get returns JSON with info about the request assert '"url": "https://httpbin.org/get"' in response_content print("GOT A Response from HTTPBIN=", response_content) except Exception as e: # If httpbin.org is not accessible, skip the test import pytest + pytest.skip(f"Could not reach httpbin.org for integration test: {e}") + + +@pytest.mark.asyncio +async def test_filter_endpoints_by_team_allowed_routes_with_filter(): + """ + Test that _filter_endpoints_by_team_allowed_routes correctly filters endpoints + when team has allowed_passthrough_routes in metadata + """ + from litellm.proxy._types import PassThroughGenericEndpoint + from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( + _filter_endpoints_by_team_allowed_routes, + ) + + # Create test endpoints + endpoints = [ + PassThroughGenericEndpoint( + id="endpoint-1", path="/api/allowed1", target="http://example.com/api1" + ), + PassThroughGenericEndpoint( + id="endpoint-2", path="/api/allowed2", target="http://example.com/api2" + ), + PassThroughGenericEndpoint( + id="endpoint-3", path="/api/notallowed", target="http://example.com/api3" + ), + ] + + # Mock prisma client + mock_prisma_client = MagicMock() + mock_team = MagicMock() + mock_team.metadata = { + "allowed_passthrough_routes": ["/api/allowed1", "/api/allowed2"] + } + mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_team + ) + + # Call the function + result = await _filter_endpoints_by_team_allowed_routes( + team_id="test-team-123", + pass_through_endpoints=endpoints, + prisma_client=mock_prisma_client, + ) + + # Should only return allowed endpoints + assert len(result) == 2 + assert result[0].path == "/api/allowed1" + assert result[1].path == "/api/allowed2" + + # Verify database call + mock_prisma_client.db.litellm_teamtable.find_unique.assert_called_once_with( + where={"team_id": "test-team-123"} + ) + + +@pytest.mark.asyncio +async def test_filter_endpoints_by_team_allowed_routes_team_not_found(): + """ + Test that _filter_endpoints_by_team_allowed_routes raises HTTPException + when team is not found + """ + from fastapi import HTTPException + + from litellm.proxy._types import PassThroughGenericEndpoint + from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( + _filter_endpoints_by_team_allowed_routes, + ) + + # Create test endpoints + endpoints = [ + PassThroughGenericEndpoint( + id="endpoint-1", path="/api/test", target="http://example.com/api" + ), + ] + + # Mock prisma client to return None (team not found) + mock_prisma_client = MagicMock() + mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=None) + + # Call the function and expect HTTPException + with pytest.raises(HTTPException) as exc_info: + await _filter_endpoints_by_team_allowed_routes( + team_id="non-existent-team", + pass_through_endpoints=endpoints, + prisma_client=mock_prisma_client, + ) + + # Verify the exception + assert exc_info.value.status_code == 404 + assert "Team not found" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +async def test_filter_endpoints_by_team_allowed_routes_no_metadata(): + """ + Test that _filter_endpoints_by_team_allowed_routes returns all endpoints + when team has no metadata + """ + from litellm.proxy._types import PassThroughGenericEndpoint + from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( + _filter_endpoints_by_team_allowed_routes, + ) + + # Create test endpoints + endpoints = [ + PassThroughGenericEndpoint( + id="endpoint-1", path="/api/test1", target="http://example.com/api1" + ), + PassThroughGenericEndpoint( + id="endpoint-2", path="/api/test2", target="http://example.com/api2" + ), + ] + + # Mock prisma client with team that has None metadata + mock_prisma_client = MagicMock() + mock_team = MagicMock() + mock_team.metadata = None + mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_team + ) + + # Call the function + result = await _filter_endpoints_by_team_allowed_routes( + team_id="test-team-123", + pass_through_endpoints=endpoints, + prisma_client=mock_prisma_client, + ) + + # Should return all endpoints when no metadata + assert len(result) == 2 + assert result[0].path == "/api/test1" + assert result[1].path == "/api/test2" + + +@pytest.mark.asyncio +async def test_filter_endpoints_by_team_allowed_routes_no_allowed_routes_key(): + """ + Test that _filter_endpoints_by_team_allowed_routes returns all endpoints + when team metadata doesn't have allowed_passthrough_routes key + """ + from litellm.proxy._types import PassThroughGenericEndpoint + from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( + _filter_endpoints_by_team_allowed_routes, + ) + + # Create test endpoints + endpoints = [ + PassThroughGenericEndpoint( + id="endpoint-1", path="/api/test1", target="http://example.com/api1" + ), + PassThroughGenericEndpoint( + id="endpoint-2", path="/api/test2", target="http://example.com/api2" + ), + ] + + # Mock prisma client with team that has metadata but no allowed_passthrough_routes + mock_prisma_client = MagicMock() + mock_team = MagicMock() + mock_team.metadata = {"some_other_key": "some_value"} + mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_team + ) + + # Call the function + result = await _filter_endpoints_by_team_allowed_routes( + team_id="test-team-123", + pass_through_endpoints=endpoints, + prisma_client=mock_prisma_client, + ) + + # Should return all endpoints when allowed_passthrough_routes key doesn't exist + assert len(result) == 2 + assert result[0].path == "/api/test1" + assert result[1].path == "/api/test2" + + +@pytest.mark.asyncio +async def test_filter_endpoints_by_team_allowed_routes_empty_allowed_list(): + """ + Test that _filter_endpoints_by_team_allowed_routes returns empty list + when team has empty allowed_passthrough_routes list + """ + from litellm.proxy._types import PassThroughGenericEndpoint + from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( + _filter_endpoints_by_team_allowed_routes, + ) + + # Create test endpoints + endpoints = [ + PassThroughGenericEndpoint( + id="endpoint-1", path="/api/test1", target="http://example.com/api1" + ), + PassThroughGenericEndpoint( + id="endpoint-2", path="/api/test2", target="http://example.com/api2" + ), + ] + + # Mock prisma client with team that has empty allowed_passthrough_routes + mock_prisma_client = MagicMock() + mock_team = MagicMock() + mock_team.metadata = {"allowed_passthrough_routes": []} + mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_team + ) + + # Call the function + result = await _filter_endpoints_by_team_allowed_routes( + team_id="test-team-123", + pass_through_endpoints=endpoints, + prisma_client=mock_prisma_client, + ) + + # Should return empty list when allowed_passthrough_routes is empty + assert len(result) == 0 + + +@pytest.mark.asyncio +async def test_filter_endpoints_by_team_allowed_routes_partial_match(): + """ + Test that _filter_endpoints_by_team_allowed_routes correctly filters + when only some endpoints match allowed routes + """ + from litellm.proxy._types import PassThroughGenericEndpoint + from litellm.proxy.pass_through_endpoints.pass_through_endpoints import ( + _filter_endpoints_by_team_allowed_routes, + ) + + # Create test endpoints + endpoints = [ + PassThroughGenericEndpoint( + id="endpoint-1", path="/api/openai", target="http://example.com/openai" + ), + PassThroughGenericEndpoint( + id="endpoint-2", + path="/api/anthropic", + target="http://example.com/anthropic", + ), + PassThroughGenericEndpoint( + id="endpoint-3", path="/api/azure", target="http://example.com/azure" + ), + PassThroughGenericEndpoint( + id="endpoint-4", path="/api/cohere", target="http://example.com/cohere" + ), + ] + + # Mock prisma client with team that allows only 2 routes + mock_prisma_client = MagicMock() + mock_team = MagicMock() + mock_team.metadata = {"allowed_passthrough_routes": ["/api/openai", "/api/azure"]} + mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock( + return_value=mock_team + ) + + # Call the function + result = await _filter_endpoints_by_team_allowed_routes( + team_id="test-team-123", + pass_through_endpoints=endpoints, + prisma_client=mock_prisma_client, + ) + + # Should return only the 2 allowed endpoints + assert len(result) == 2 + assert result[0].path == "/api/openai" + assert result[1].path == "/api/azure" diff --git a/ui/litellm-dashboard/src/components/common_components/PassThroughRoutesSelector.tsx b/ui/litellm-dashboard/src/components/common_components/PassThroughRoutesSelector.tsx index a2ed9c6d66..116e3f6aa3 100644 --- a/ui/litellm-dashboard/src/components/common_components/PassThroughRoutesSelector.tsx +++ b/ui/litellm-dashboard/src/components/common_components/PassThroughRoutesSelector.tsx @@ -9,6 +9,7 @@ interface PassThroughRoutesSelectorProps { accessToken: string; placeholder?: string; disabled?: boolean; + teamId?: string | null; } const PassThroughRoutesSelector: React.FC = ({ @@ -18,6 +19,7 @@ const PassThroughRoutesSelector: React.FC = ({ accessToken, placeholder = "Select pass through routes", disabled = false, + teamId, }) => { const [passThroughRoutes, setPassThroughRoutes] = useState([]); const [loading, setLoading] = useState(false); @@ -28,7 +30,7 @@ const PassThroughRoutesSelector: React.FC = ({ setLoading(true); try { - const response = await getPassThroughEndpointsCall(accessToken); + const response = await getPassThroughEndpointsCall(accessToken, teamId); if (response.endpoints) { const routes = response.endpoints.map((route: { path: string }) => route.path); setPassThroughRoutes(routes); @@ -41,7 +43,7 @@ const PassThroughRoutesSelector: React.FC = ({ }; fetchPassThroughRoutes(); - }, [accessToken]); + }, [accessToken, teamId]); return (