diff --git a/python/cuopt_self_hosted/cuopt_sh_client/__init__.py b/python/cuopt_self_hosted/cuopt_sh_client/__init__.py index 38069d79e..3e209aa85 100644 --- a/python/cuopt_self_hosted/cuopt_sh_client/__init__.py +++ b/python/cuopt_self_hosted/cuopt_sh_client/__init__.py @@ -22,6 +22,10 @@ mime_type, set_log_level, ) +from .cuopt_web_hosted_client import ( + CuOptServiceWebHostedClient, + create_client, +) from .thin_client_solution import ThinClientSolution from .thin_client_solver_settings import ( PDLPSolverMode, diff --git a/python/cuopt_self_hosted/cuopt_sh_client/cuopt_self_host_client.py b/python/cuopt_self_hosted/cuopt_sh_client/cuopt_self_host_client.py index 3cbd54829..f1b337381 100644 --- a/python/cuopt_self_hosted/cuopt_sh_client/cuopt_self_host_client.py +++ b/python/cuopt_self_hosted/cuopt_sh_client/cuopt_self_host_client.py @@ -400,6 +400,26 @@ def _handle_request_exception(self, response, reqId=None): if reqId: err += f"\nreqId: {reqId}" return err, complete + + def _make_http_request(self, method: str, url: str, **kwargs): + """ + Make HTTP request. Can be overridden by subclasses for authentication. + + Parameters + ---------- + method : str + HTTP method (GET, POST, DELETE, etc.) + url : str + Request URL + **kwargs + Additional arguments passed to requests.request() + + Returns + ------- + requests.Response + HTTP response object + """ + return requests.request(method, url, **kwargs) def _get_logs(self, reqId, logging_callback): if logging_callback is None or not callable(logging_callback): @@ -407,7 +427,8 @@ def _get_logs(self, reqId, logging_callback): try: headers = {"Accept": self.accept_type.value} params = {"frombyte": self.loggedbytes} - response = requests.get( + response = self._make_http_request( + "GET", self.log_url + f"/{reqId}", verify=self.verify, headers=headers, @@ -435,7 +456,8 @@ def _get_incumbents(self, reqId, incumbent_callback): return try: headers = {"Accept": self.accept_type.value} - response = requests.get( + response = self._make_http_request( + "GET", self.solution_url + f"/{reqId}/incumbents", verify=self.verify, headers=headers, @@ -544,7 +566,8 @@ def stop_threads(log_t, inc_t, done): try: log.debug(f"GET {self.solution_url}/{reqId}") headers = {"Accept": self.accept_type.value} - response = requests.get( + response = self._make_http_request( + "GET", self.solution_url + f"/{reqId}", verify=self.verify, headers=headers, @@ -623,7 +646,8 @@ def serialize(cuopt_problem_data): headers["CUOPT-RESULT-FILE"] = output headers["Content-Type"] = content_type headers["Accept"] = self.accept_type.value - response = requests.post( + response = self._make_http_request( + "POST", self.request_url, params=params, data=data, @@ -879,7 +903,8 @@ def delete(self, id, running=None, queued=None, cached=None): 'running' and 'queued' are unspecified, otherwise False. """ try: - response = requests.delete( + response = self._make_http_request( + "DELETE", self.request_url + f"/{id}", headers={"Accept": self.accept_type.value}, params={ @@ -918,7 +943,8 @@ def delete_solution(self, id): id = id["reqId"] try: headers = {"Accept": self.accept_type.value} - response = requests.delete( + response = self._make_http_request( + "DELETE", self.solution_url + f"/{id}", headers=headers, verify=self.verify, @@ -930,7 +956,8 @@ def delete_solution(self, id): # Get rid of a log if it exists. # It may not so just squash exceptions. try: - response = requests.delete( + response = self._make_http_request( + "DELETE", self.log_url + f"/{id}", verify=self.verify, timeout=self.http_general_timeout, @@ -969,7 +996,8 @@ def repoll(self, data, response_type="obj", delete_solution=True): data = data["reqId"] headers = {"Accept": self.accept_type.value} try: - response = requests.get( + response = self._make_http_request( + "GET", self.solution_url + f"/{data}", verify=self.verify, headers=headers, @@ -1007,7 +1035,8 @@ def status(self, id): id = id["reqId"] headers = {"Accept": self.accept_type.value} try: - response = requests.get( + response = self._make_http_request( + "GET", self.request_url + f"/{id}?status", verify=self.verify, headers=headers, @@ -1044,7 +1073,8 @@ def upload_solution(self, solution): "Content-Type": content_type, } try: - response = requests.post( + response = self._make_http_request( + "POST", self.solution_url, verify=self.verify, data=data, diff --git a/python/cuopt_self_hosted/cuopt_sh_client/cuopt_web_hosted_client.py b/python/cuopt_self_hosted/cuopt_sh_client/cuopt_web_hosted_client.py new file mode 100644 index 000000000..1323fdfa8 --- /dev/null +++ b/python/cuopt_self_hosted/cuopt_sh_client/cuopt_web_hosted_client.py @@ -0,0 +1,251 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # noqa +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import logging +import os +import warnings +from typing import Dict, Optional, Union +from urllib.parse import urlparse, urljoin + +import requests + +from .cuopt_self_host_client import CuOptServiceSelfHostClient, mime_type + +log = logging.getLogger(__name__) + + +class CuOptServiceWebHostedClient(CuOptServiceSelfHostClient): + """ + Web-hosted version of the CuOptServiceClient that supports endpoint URLs + and authentication mechanisms for cloud-hosted services. + + This client is specifically designed for web-hosted cuOpt services and + requires an endpoint URL. For self-hosted services with ip/port parameters, + use CuOptServiceSelfHostClient instead. + + Parameters + ---------- + endpoint : str + Full endpoint URL for the cuOpt service. Required parameter. Examples: + - "https://api.nvidia.com/cuopt/v1" + - "https://inference.nvidia.com/cuopt" + - "http://my-cuopt-service.com:8080/api" + api_key : str, optional + API key for authentication. Will be sent as Bearer token in Authorization header. + Can also be set via CUOPT_API_KEY environment variable. + base_path : str, optional + Base path to append to the endpoint if not included in endpoint URL. + Defaults to "/cuopt" if not specified in endpoint. + self_signed_cert : str, optional + Path to self-signed certificate for HTTPS connections. + **kwargs + Additional parameters passed to parent CuOptServiceSelfHostClient + (excluding ip, port, use_https which are determined from endpoint) + """ + + def __init__( + self, + endpoint: str, + api_key: Optional[str] = None, + base_path: Optional[str] = None, + self_signed_cert: str = "", + **kwargs + ): + if not endpoint: + raise ValueError("endpoint parameter is required for CuOptServiceWebHostedClient") + + # Handle authentication from environment variables + self.api_key = api_key or os.getenv("CUOPT_API_KEY") + + # Parse endpoint URL + self._parsed_endpoint = self._parse_endpoint_url(endpoint, base_path) + + # Extract connection parameters from endpoint + ip = self._parsed_endpoint["host"] + port = str(self._parsed_endpoint["port"]) if self._parsed_endpoint["port"] else "" + use_https = self._parsed_endpoint["scheme"] == "https" + self._base_path = self._parsed_endpoint["path"] + + # Initialize parent class with extracted parameters + super().__init__( + ip=ip, + port=port, + use_https=use_https, + self_signed_cert=self_signed_cert, + **kwargs + ) + + # Override URL construction with endpoint-based URLs + self._construct_endpoint_urls() + + def _parse_endpoint_url(self, endpoint: str, base_path: Optional[str] = None) -> Dict[str, Union[str, int, None]]: + """ + Parse endpoint URL and extract components. + + Parameters + ---------- + endpoint : str + Full endpoint URL + base_path : str, optional + Base path to use if not included in endpoint + + Returns + ------- + dict + Parsed URL components + """ + # Add protocol if missing + if not endpoint.startswith(("http://", "https://")): + log.warning(f"No protocol specified in endpoint '{endpoint}', assuming https://") + endpoint = f"https://{endpoint}" + + parsed = urlparse(endpoint) + + if not parsed.hostname: + raise ValueError(f"Invalid endpoint URL: {endpoint}") + + # Determine base path + path = parsed.path.rstrip("/") + if not path and base_path: + path = base_path.rstrip("/") + elif not path: + path = "/cuopt" + + return { + "scheme": parsed.scheme, + "host": parsed.hostname, + "port": parsed.port, + "path": path, + "full_url": f"{parsed.scheme}://{parsed.netloc}{path}" + } + + def _construct_endpoint_urls(self): + """Construct service URLs from parsed endpoint. + + For web-hosted cuOpt services (like NVIDIA's API), the endpoint + URL is typically the complete service endpoint that handles all + operations. Unlike self-hosted services that have separate paths + for /request, /log, /solution, web-hosted APIs often use a single + endpoint for all operations. + """ + base_url = self._parsed_endpoint["full_url"] + + # For web-hosted services, use the provided endpoint directly + # This matches the curl example which POSTs directly to the endpoint + self.request_url = base_url + + # Log and solution URLs may still follow traditional patterns + # but many web-hosted APIs use the same endpoint for all operations + self.log_url = urljoin(base_url + "/", "log") + self.solution_url = urljoin(base_url + "/", "solution") + + def _get_auth_headers(self) -> Dict[str, str]: + """Get authentication headers.""" + headers = {} + + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + + return headers + + def _make_http_request(self, method: str, url: str, **kwargs): + """ + Override parent method to add authentication headers and handle auth errors. + + Parameters + ---------- + method : str + HTTP method (GET, POST, DELETE, etc.) + url : str + Request URL + **kwargs + Additional arguments passed to requests.request() + + Returns + ------- + requests.Response + HTTP response object + """ + # Add authentication headers + headers = kwargs.get("headers", {}) + headers.update(self._get_auth_headers()) + kwargs["headers"] = headers + + # Make request + response = requests.request(method, url, **kwargs) + + # Handle authentication errors + if response.status_code == 401: + raise ValueError("Authentication failed. Please check your API key.") + elif response.status_code == 403: + raise ValueError("Access forbidden. Please check your permissions.") + + return response + + +def create_client( + endpoint: Optional[str] = None, + api_key: Optional[str] = None, + **kwargs +) -> Union[CuOptServiceWebHostedClient, CuOptServiceSelfHostClient]: + """ + Factory function to create appropriate client based on parameters. + + Creates CuOptServiceWebHostedClient if endpoint is provided, otherwise + creates CuOptServiceSelfHostClient for legacy ip/port usage. + + Parameters + ---------- + endpoint : str, optional + Full endpoint URL. If provided, creates a web-hosted client. + Required for web-hosted client creation. + api_key : str, optional + API key for web-hosted client authentication. Will be sent as Bearer token. + **kwargs + Additional parameters passed to the selected client + + Returns + ------- + CuOptServiceWebHostedClient or CuOptServiceSelfHostClient + Web-hosted client if endpoint provided, self-hosted client otherwise + + Examples + -------- + # Creates web-hosted client + client = create_client( + endpoint="https://api.nvidia.com/cuopt/v1", + api_key="your-key" + ) + + # Creates self-hosted client + client = create_client(ip="192.168.1.100", port="5000") + """ + if endpoint: + # Create web-hosted client - endpoint is required + return CuOptServiceWebHostedClient( + endpoint=endpoint, + api_key=api_key, + **kwargs + ) + elif api_key: + # Authentication provided but no endpoint - this is an error + raise ValueError( + "api_key provided but no endpoint specified. " + "Web-hosted client requires an endpoint URL. " + "Use CuOptServiceSelfHostClient for ip/port connections." + ) + else: + # Create self-hosted client for legacy ip/port usage + return CuOptServiceSelfHostClient(**kwargs) diff --git a/python/cuopt_self_hosted/tests/test_web_hosted_client.py b/python/cuopt_self_hosted/tests/test_web_hosted_client.py new file mode 100644 index 000000000..fce7d1514 --- /dev/null +++ b/python/cuopt_self_hosted/tests/test_web_hosted_client.py @@ -0,0 +1,232 @@ +# SPDX-FileCopyrightText: Copyright (c) 2023-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # noqa +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import pytest +import unittest.mock as mock +from unittest.mock import patch, MagicMock + +from cuopt_sh_client import ( + CuOptServiceWebHostedClient, + CuOptServiceSelfHostClient, + create_client, + mime_type, +) + + +class TestWebHostedClient: + """Test suite for CuOptServiceWebHostedClient.""" + + def test_endpoint_required(self): + """Test that endpoint parameter is required.""" + with pytest.raises(ValueError, match="endpoint parameter is required"): + CuOptServiceWebHostedClient() + + with pytest.raises(ValueError, match="endpoint parameter is required"): + CuOptServiceWebHostedClient(endpoint="") + + def test_endpoint_url_parsing(self): + """Test URL parsing functionality.""" + # Test basic HTTPS endpoint + client = CuOptServiceWebHostedClient(endpoint="https://api.nvidia.com/cuopt/v1") + assert client._parsed_endpoint["scheme"] == "https" + assert client._parsed_endpoint["host"] == "api.nvidia.com" + assert client._parsed_endpoint["port"] is None + assert client._parsed_endpoint["path"] == "/cuopt/v1" + + # Test endpoint with port + client = CuOptServiceWebHostedClient(endpoint="https://example.com:8080/api") + assert client._parsed_endpoint["scheme"] == "https" + assert client._parsed_endpoint["host"] == "example.com" + assert client._parsed_endpoint["port"] == 8080 + assert client._parsed_endpoint["path"] == "/api" + + # Test endpoint without protocol (should default to https) + with pytest.warns(UserWarning): + client = CuOptServiceWebHostedClient(endpoint="inference.nvidia.com/cuopt") + assert client._parsed_endpoint["scheme"] == "https" + assert client._parsed_endpoint["host"] == "inference.nvidia.com" + assert client._parsed_endpoint["path"] == "/cuopt" + + # Test endpoint without path (should default to /cuopt) + client = CuOptServiceWebHostedClient(endpoint="https://example.com") + assert client._parsed_endpoint["path"] == "/cuopt" + + def test_invalid_endpoint_url(self): + """Test handling of invalid endpoint URLs.""" + with pytest.raises(ValueError, match="Invalid endpoint URL"): + CuOptServiceWebHostedClient(endpoint="not-a-valid-url") + + def test_authentication_from_parameters(self): + """Test authentication setup from parameters.""" + # Test API key (sent as Bearer token) + client = CuOptServiceWebHostedClient( + endpoint="https://api.nvidia.com/cuopt/v1", + api_key="test-api-key" + ) + headers = client._get_auth_headers() + assert headers["Authorization"] == "Bearer test-api-key" + + # Test no authentication + client = CuOptServiceWebHostedClient(endpoint="https://api.nvidia.com/cuopt/v1") + headers = client._get_auth_headers() + assert len(headers) == 0 + + @patch.dict(os.environ, {"CUOPT_API_KEY": "env-api-key"}) + def test_authentication_from_environment(self): + """Test authentication setup from environment variables.""" + client = CuOptServiceWebHostedClient(endpoint="https://api.nvidia.com/cuopt/v1") + headers = client._get_auth_headers() + assert headers["Authorization"] == "Bearer env-api-key" + + def test_parameter_precedence(self): + """Test that parameters take precedence over environment variables.""" + with patch.dict(os.environ, {"CUOPT_API_KEY": "env-api-key"}): + client = CuOptServiceWebHostedClient( + endpoint="https://api.nvidia.com/cuopt/v1", + api_key="param-api-key" + ) + headers = client._get_auth_headers() + assert headers["Authorization"] == "Bearer param-api-key" + + def test_url_construction_with_endpoint(self): + """Test URL construction when endpoint is provided.""" + client = CuOptServiceWebHostedClient(endpoint="https://api.nvidia.com/cuopt/v1") + assert client.request_url == "https://api.nvidia.com/cuopt/v1" + assert client.log_url == "https://api.nvidia.com/cuopt/v1/log" + assert client.solution_url == "https://api.nvidia.com/cuopt/v1/solution" + + def test_url_construction_from_endpoint_parsing(self): + """Test URL construction from parsed endpoint components.""" + client = CuOptServiceWebHostedClient(endpoint="https://example.com:8080/custom") + assert client.request_url == "https://example.com:8080/custom" + assert client.log_url == "https://example.com:8080/custom/log" + assert client.solution_url == "https://example.com:8080/custom/solution" + + @patch('cuopt_sh_client.cuopt_web_hosted_client.requests.request') + def test_authenticated_request_with_api_key(self, mock_request): + """Test that authenticated requests include API key.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_request.return_value = mock_response + + client = CuOptServiceWebHostedClient( + endpoint="https://api.nvidia.com/cuopt/v1", + api_key="test-api-key" + ) + + client._make_http_request("GET", "https://api.nvidia.com/test") + + # Check that the request was made with the Bearer token header + mock_request.assert_called_once() + call_args = mock_request.call_args + headers = call_args[1]["headers"] + assert headers["Authorization"] == "Bearer test-api-key" + + @patch('cuopt_sh_client.cuopt_web_hosted_client.requests.request') + def test_authentication_error_handling(self, mock_request): + """Test handling of authentication errors.""" + # Test 401 Unauthorized + mock_response = MagicMock() + mock_response.status_code = 401 + mock_request.return_value = mock_response + + client = CuOptServiceWebHostedClient( + endpoint="https://api.nvidia.com/cuopt/v1", + api_key="invalid-key" + ) + + with pytest.raises(ValueError, match="Authentication failed"): + client._make_http_request("GET", "https://api.nvidia.com/test") + + # Test 403 Forbidden + mock_response.status_code = 403 + mock_request.return_value = mock_response + + with pytest.raises(ValueError, match="Access forbidden"): + client._make_http_request("GET", "https://api.nvidia.com/test") + + def test_base_path_handling(self): + """Test custom base path handling.""" + client = CuOptServiceWebHostedClient( + endpoint="https://api.nvidia.com", + base_path="/custom/path" + ) + assert client._parsed_endpoint["path"] == "/custom/path" + assert client.request_url == "https://api.nvidia.com/custom/path" + + +class TestCreateClientFactory: + """Test suite for the create_client factory function.""" + + def test_creates_web_hosted_client_with_endpoint(self): + """Test that web-hosted client is created when endpoint is provided.""" + client = create_client(endpoint="https://api.nvidia.com/cuopt/v1") + assert isinstance(client, CuOptServiceWebHostedClient) + + def test_creates_web_hosted_client_with_endpoint_and_auth(self): + """Test that web-hosted client is created with endpoint and auth.""" + client = create_client( + endpoint="https://api.nvidia.com/cuopt/v1", + api_key="test-key" + ) + assert isinstance(client, CuOptServiceWebHostedClient) + + def test_error_when_auth_without_endpoint(self): + """Test that error is raised when auth is provided without endpoint.""" + with pytest.raises(ValueError, match="api_key provided but no endpoint"): + create_client(api_key="test-key") + + def test_creates_self_hosted_client_by_default(self): + """Test that self-hosted client is created by default.""" + client = create_client(ip="192.168.1.100", port="8080") + assert isinstance(client, CuOptServiceSelfHostClient) + assert not isinstance(client, CuOptServiceWebHostedClient) + + def test_passes_parameters_correctly(self): + """Test that parameters are passed correctly to the client.""" + client = create_client( + endpoint="https://api.nvidia.com/cuopt/v1", + api_key="test-key", + polling_timeout=300, + result_type=mime_type.JSON + ) + assert isinstance(client, CuOptServiceWebHostedClient) + assert client.api_key == "test-key" + assert client.timeout == 300 + assert client.accept_type == mime_type.JSON + + +class TestCertificateHandling: + """Test suite for certificate handling.""" + + def test_self_signed_cert_parameter(self): + """Test that self_signed_cert parameter is handled correctly.""" + client = CuOptServiceWebHostedClient( + endpoint="https://api.nvidia.com/cuopt/v1", + self_signed_cert="/path/to/cert.pem" + ) + assert client.verify == "/path/to/cert.pem" + + def test_https_verification_default(self): + """Test that HTTPS verification is enabled by default.""" + client = CuOptServiceWebHostedClient( + endpoint="https://api.nvidia.com/cuopt/v1" + ) + assert client.verify is True + + +if __name__ == "__main__": + pytest.main([__file__])