From f19a5e63cee13ca4da8602a210146fab896d0981 Mon Sep 17 00:00:00 2001 From: Tony Salim Date: Thu, 9 Oct 2025 15:03:01 -0700 Subject: [PATCH 1/4] init implementation of web-hosted cuopt client --- .../cuopt_sh_client/__init__.py | 4 + .../cuopt_sh_client/cuopt_self_host_client.py | 50 +- .../cuopt_web_hosted_client.py | 249 +++++++ .../cuopt_sh_client/cuopt_web_hosted_sh.py | 681 ++++++++++++++++++ .../examples/web_hosted_client_examples.py | 271 +++++++ python/cuopt_self_hosted/pyproject.toml | 1 + .../tests/test_web_hosted_client.py | 287 ++++++++ 7 files changed, 1533 insertions(+), 10 deletions(-) create mode 100644 python/cuopt_self_hosted/cuopt_sh_client/cuopt_web_hosted_client.py create mode 100644 python/cuopt_self_hosted/cuopt_sh_client/cuopt_web_hosted_sh.py create mode 100644 python/cuopt_self_hosted/examples/web_hosted_client_examples.py create mode 100644 python/cuopt_self_hosted/tests/test_web_hosted_client.py 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..9f0afc183 --- /dev/null +++ b/python/cuopt_self_hosted/cuopt_sh_client/cuopt_web_hosted_client.py @@ -0,0 +1,249 @@ +# 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. Can also be set via CUOPT_API_KEY + environment variable. + bearer_token : str, optional + Bearer token for authentication. Can also be set via CUOPT_BEARER_TOKEN + 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, + bearer_token: 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") + self.bearer_token = bearer_token or os.getenv("CUOPT_BEARER_TOKEN") + + # 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.""" + base_url = self._parsed_endpoint["full_url"] + self.request_url = urljoin(base_url + "/", "request") + 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["X-API-Key"] = self.api_key + elif self.bearer_token: + headers["Authorization"] = f"Bearer {self.bearer_token}" + + 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 or bearer token.") + 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, + bearer_token: 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 + bearer_token : str, optional + Bearer token for web-hosted client authentication + **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, + bearer_token=bearer_token, + **kwargs + ) + elif api_key or bearer_token: + # Authentication provided but no endpoint - this is an error + raise ValueError( + "api_key or bearer_token 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/cuopt_sh_client/cuopt_web_hosted_sh.py b/python/cuopt_self_hosted/cuopt_sh_client/cuopt_web_hosted_sh.py new file mode 100644 index 000000000..7f80728e4 --- /dev/null +++ b/python/cuopt_self_hosted/cuopt_sh_client/cuopt_web_hosted_sh.py @@ -0,0 +1,681 @@ +#! /usr/bin/python3 + +# 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 argparse +import json +import logging +import os +import warnings + +from cuopt_sh_client import ( + CuOptServiceSelfHostClient, + CuOptServiceWebHostedClient, + create_client, + get_version, + is_uuid, + mime_type, + set_log_level, +) + +port_default = "5000" +ip_default = "0.0.0.0" + +result_types = { + "json": mime_type.JSON, + "msgpack": mime_type.MSGPACK, + "zlib": mime_type.ZLIB, + "*": mime_type.WILDCARD, +} + + +def create_cuopt_client(args): + """Create the appropriate CuOpt client based on arguments.""" + # Check if web-hosted parameters are provided + endpoint = getattr(args, 'endpoint', None) + api_key = getattr(args, 'api_key', None) + bearer_token = getattr(args, 'bearer_token', None) + + # Validate parameter combinations + if endpoint and (args.ip != ip_default or args.port != port_default): + warnings.warn( + "Both endpoint and ip/port parameters provided. " + "Endpoint takes precedence. Consider using only endpoint parameter.", + UserWarning + ) + + if (api_key or bearer_token) and not endpoint: + raise ValueError( + "API key or bearer token provided but no endpoint specified. " + "Web-hosted authentication requires an endpoint URL. " + "Use -e/--endpoint to specify the service endpoint." + ) + + # Create client using factory function + try: + return create_client( + endpoint=endpoint, + api_key=api_key, + bearer_token=bearer_token, + ip=args.ip, + port=args.port, + use_https=args.ssl, + self_signed_cert=args.self_signed_cert, + polling_timeout=getattr(args, 'poll_timeout', 120), + only_validate=getattr(args, 'only_validation', False), + timeout_exception=False, + result_type=result_types[args.result_type], + http_general_timeout=getattr(args, 'http_timeout', 30), + ) + except ValueError as e: + # Re-raise with more helpful CLI context + raise ValueError(f"Client creation failed: {e}") + + +def status(args): + cuopt_service_client = create_cuopt_client(args) + + try: + solve_result = cuopt_service_client.status(args.data[0]) + + if solve_result: + print(solve_result) + + except Exception as e: + if args.log_level == "debug": + import traceback + traceback.print_exc() + print(str(e)) + + +def delete_request(args): + cuopt_service_client = create_cuopt_client(args) + + try: + # Interpretation of flags is done on the server, just pass them + solve_result = cuopt_service_client.delete( + args.data[0], + queued=args.queued, + running=args.running, + cached=args.cache, + ) + + if solve_result: + print(solve_result) + + except Exception as e: + if args.log_level == "debug": + import traceback + traceback.print_exc() + print(str(e)) + + +def upload_solution(args): + cuopt_service_client = create_cuopt_client(args) + + try: + # Interpretation of flags is done on the server, just pass them + reqId = cuopt_service_client.upload_solution(args.data[0]) + + if reqId: + print(reqId) + + except Exception as e: + if args.log_level == "debug": + import traceback + traceback.print_exc() + print(str(e)) + + +def delete_solution(args): + cuopt_service_client = create_cuopt_client(args) + + try: + solve_result = cuopt_service_client.delete_solution(args.data[0]) + + if solve_result: + print(solve_result) + + except Exception as e: + if args.log_level == "debug": + import traceback + traceback.print_exc() + print(str(e)) + + +def solve(args): + problem_data = args.data + + # Set the problem data + solver_settings = None + repoll_req = False + + def read_input_data(i_file): + repoll_req = False + if args.filepath: + data = i_file + elif not os.path.isfile(i_file): + if is_uuid(i_file): + data = i_file + repoll_req = True + else: + # Might be raw json data for repoll or a problem... + try: + data = json.loads(i_file) + repoll_req = "reqId" in data + except Exception: + data = i_file + else: + # Allow a repoll requestid to be in a file + with open(i_file, "r") as f: + try: + val = f.read(32) + except Exception: + val = "" + if len(val) >= 8 and val[0:8] == '{"reqId"': + f.seek(0) + data = json.load(f) + repoll_req = True # noqa + else: + data = i_file + return data, repoll_req + + if len(problem_data) == 1: + cuopt_problem_data, repoll_req = read_input_data(problem_data[0]) + elif args.type == "LP": + cuopt_problem_data = [] + for i_file in problem_data: + i_data, repoll_req = read_input_data(i_file) + if repoll_req: + raise Exception( + "Cannot have a repoll request in LP batch data" + ) + cuopt_problem_data.append(i_data) + else: + raise Exception("Cannot have multiple problem inputs for VRP") + + if args.type == "LP" and args.solver_settings: + if not os.path.isfile(args.solver_settings): + # Might be raw json data... + try: + solver_settings = json.loads(args.solver_settings) + except Exception: + print("solver settings is neither a filename nor valid JSON") + return + else: + with open(args.solver_settings, "r") as f: + solver_settings = json.load(f) + + # Initialize the CuOptServiceClient + cuopt_service_client = create_cuopt_client(args) + + try: + if repoll_req: + solve_result = cuopt_service_client.repoll( + cuopt_problem_data, + response_type="dict", + delete_solution=not args.keep, + ) + elif args.type == "VRP": + solve_result = cuopt_service_client.get_optimized_routes( + cuopt_problem_data, + args.filepath, + args.cache, + args.output, + delete_solution=not args.keep, + initial_ids=args.init_ids, + ) + elif args.type == "LP": + if args.init_ids: + raise Exception("Initial ids are not supported for LP") + + def log_callback(name): + def print_log(log): + ln = "\n".join(log) + if name: + with open(name, "a+") as f: + f.write(ln) + elif ln: + print(ln, end="") + + return print_log + + def inc_callback(name): + def print_inc(sol, cost): + if name: + with open(name, "a+") as f: + f.write(f"{sol} {cost}\n") + else: + print(sol, cost) + + return print_inc + + logging_callback = None + incumbent_callback = None + if args.solver_logs is not None: + # empty string means log to screen + if args.solver_logs: + with open(args.solver_logs, "w") as f: + pass # truncuate + logging_callback = log_callback(args.solver_logs) + + if args.incumbent_logs is not None: + # empty string means log to screen + if args.incumbent_logs: + with open(args.incumbent_logs, "w") as f: + pass # truncuate + incumbent_callback = inc_callback(args.incumbent_logs) + + solve_result = cuopt_service_client.get_LP_solve( + cuopt_problem_data, + solver_settings, + response_type="dict", + filepath=args.filepath, + cache=args.cache, + output=args.output, + delete_solution=not args.keep, + incumbent_callback=incumbent_callback, + logging_callback=logging_callback, + warmstart_id=args.warmstart_id, + ) + else: + raise Exception("Invalid type of problem.") + if solve_result: + if ( + isinstance(solve_result, dict) + and len(solve_result) == 1 + and "reqId" in solve_result + ): + # Build repoll command with appropriate parameters + repoll = "cuopt_web_sh " + + # Add endpoint or ip/port parameters + endpoint = getattr(args, 'endpoint', None) + if endpoint: + repoll += f"-e '{endpoint}' " + else: + if args.ip != ip_default: + repoll += f"-i {args.ip} " + if args.port != port_default: + repoll += f"-p {args.port} " + + # Add authentication parameters if present + if getattr(args, 'api_key', None): + repoll += f"--api-key '{args.api_key}' " + elif getattr(args, 'bearer_token', None): + repoll += f"--bearer-token '{args.bearer_token}' " + + status = repoll + "-st " + repoll += solve_result["reqId"] + status += solve_result["reqId"] + print( + "Request timed out.\n" + "Check for status with the following command:\n" + status + ) + print( + "\nPoll for a result with the following command:\n" + + repoll + ) + else: + print(solve_result) + + except Exception as e: + if args.log_level == "debug": + import traceback + traceback.print_exc() + print(str(e)) + + +def main(): + levels = { + "critical": logging.CRITICAL, + "error": logging.ERROR, + "warning": logging.WARNING, + "info": logging.INFO, + "debug": logging.DEBUG, + } + + result_types = { + "json": mime_type.JSON, + "msgpack": mime_type.MSGPACK, + "zlib": mime_type.ZLIB, + "*": mime_type.WILDCARD, + } + + parser = argparse.ArgumentParser( + description="Solve a cuOpt problem using a web-hosted or self-hosted service client.", + epilog=""" +Examples: + # Self-hosted (legacy mode) + cuopt_web_sh -i 192.168.1.100 -p 5000 problem.json + + # Web-hosted with endpoint URL + cuopt_web_sh -e https://api.nvidia.com/cuopt/v1 --api-key YOUR_KEY problem.json + + # Web-hosted with bearer token + cuopt_web_sh -e https://inference.nvidia.com/cuopt --bearer-token YOUR_TOKEN problem.json + +Environment Variables: + CUOPT_API_KEY - API key for authentication + CUOPT_BEARER_TOKEN - Bearer token for authentication + """, + formatter_class=argparse.RawDescriptionHelpFormatter + ) + + # Data argument + parser.add_argument( + "data", + type=str, + nargs="*", + default="", + help="Filename, or JSON string containing a request id. " + "Data may be a cuopt problem or a request id " + "as displayed in the output from a previous request which timed out. " + "A cuopt problem must be in a file, but a request id may be " + "passed in a file or as a JSON string. " + " " + "For VRP:" + "A single problem file is expected or file_name." + " " + "For LP: " + "A single problem file in mps/json format or file_name." + "Batch mode is supported in case of mps files only for LP and" + "not for MILP, where a list of mps" + "files can be shared to be solved in parallel.", + ) + + # Web-hosted client parameters + web_group = parser.add_argument_group('Web-hosted service options') + web_group.add_argument( + "-e", + "--endpoint", + type=str, + help="Full endpoint URL for the cuOpt service. Examples: " + "'https://api.nvidia.com/cuopt/v1', " + "'https://inference.nvidia.com/cuopt', " + "'http://my-service.com:8080/api'. " + "If provided, this takes precedence over -i/-p parameters.", + ) + web_group.add_argument( + "--api-key", + type=str, + help="API key for authentication. Can also be set via CUOPT_API_KEY environment variable.", + ) + web_group.add_argument( + "--bearer-token", + type=str, + help="Bearer token for authentication. Can also be set via CUOPT_BEARER_TOKEN environment variable.", + ) + + # Legacy self-hosted parameters + legacy_group = parser.add_argument_group('Self-hosted service options (legacy)') + legacy_group.add_argument( + "-i", + "--ip", + type=str, + default=ip_default, + help=f"Host address for the cuOpt server (default {ip_default}). " + "Ignored if --endpoint is provided.", + ) + legacy_group.add_argument( + "-p", + "--port", + type=str, + default=port_default, + help="Port for the cuOpt server. Set to empty string ('') to omit " + f"the port number from the url (default {port_default}). " + "Ignored if --endpoint is provided.", + ) + legacy_group.add_argument( + "-s", + "--ssl", + action="store_true", + help="Use https scheme (default is http). Ignored if --endpoint is provided.", + default=False, + ) + legacy_group.add_argument( + "-c", + "--self-signed-cert", + type=str, + help="Path to self signed certificates only, " + "skip for standard certificates", + default="", + ) + + # All other existing parameters... + parser.add_argument( + "-id", + "--init-ids", + type=str, + nargs="*", + default=None, + help="reqId of a solution to use as an initial solution for a " + "VRP problem. There may be more than one, separated by spaces. " + "The list of ids will be terminated when the next option flag " + "is seen or there are no more arguments.", + ) + parser.add_argument( + "-wid", + "--warmstart-id", + type=str, + default=None, + help="reqId of a solution to use as a warmstart data for a " + "single LP problem. This allows to restart PDLP with a " + "previous solution context. Not enabled for Batch LP problem", + ) + parser.add_argument( + "-ca", + "--cache", + action="store_true", + help="Indicates that the DATA needs to be cached. This does not " + "solve the problem but stores the problem data and returns the reqId. " + "The reqId can be used later to solve the problem. This flag also " + "may be used alongside the delete argument to delete a cached request." + "(see the server documentation for more detail).", + default=None, + ) + parser.add_argument( + "-f", + "--filepath", + action="store_true", + help="Indicates that the DATA argument is the relative path " + "of a cuopt data file under the server's data directory. " + "The data directory is specified when the server is started " + "(see the server documentation for more detail).", + default=False, + ) + parser.add_argument( + "-d", + "--delete", + action="store_true", + help="Deletes cached requests or aborts requests on the cuOpt server. " + "The DATA argument may be the specific reqId of a request or " + "or it may be the wildcard '*' which will match any request. " + "If a specific reqId is given and the -r, -q, and -ca flags are " + "all unspecified, the reqId will always be deleted if it exists. " + "If any of the -r, -q, or -ca flags are specified, a specific reqId " + "will only be deleted if it matches the specified flags. " + "If the wildard reqId '*' is given, the -r, -q and/or -ca flags " + "must always be set explicitly. To flush the request queue, give '*' " + "as the reqId " + "and specify the -q flag. To delete all currently running requests, " + "give '*' as the reqId and specify the -r flag. To clear the request " + "cache, give '*' as the reqId and specify the -ca flag.", + default=False, + ) + parser.add_argument( + "-r", + "--running", + action="store_true", + help="Aborts a request only if it is running. Should be used with " + "-d argument", + default=None, + ) + parser.add_argument( + "-q", + "--queued", + action="store_true", + help="Aborts a request only if it is queued. Should be used with " + "-d argument", + default=None, + ) + parser.add_argument( + "-ds", + "--delete_solution", + action="store_true", + help="Deletes solutions on the cuOpt server. " + "The DATA argument is the specific reqId of a solution.", + ) + parser.add_argument( + "-k", + "--keep", + action="store_true", + help="Do not delete a solution from the server when it is retrieved. " + "Default is to delete the solution when it is retrieved.", + ) + parser.add_argument( + "-st", + "--status", + action="store_true", + help="Report the status of a request " + "(completed, aborted, running, queued)", + ) + parser.add_argument( + "-t", + "--type", + type=str, + help="The type of problem to solve. " + "Supported options are VRP and LP (defaults to VRP)", + default="VRP", + ) + parser.add_argument( + "-ss", + "--solver-settings", + type=str, + default="", + help="Filename or JSON string containing " + "solver settings for LP problem type", + ) + parser.add_argument( + "-o", + "--output", + type=str, + help="Optional name of the result file. If the server " + "has been configured to write results to files and " + "the size of the result is greater than the configured " + "limit, the server will write the result to a file with " + "this name under the server's result directory (see the " + "server documentation for more detail). A default name will " + "be used if this is not specified.", + default="", + ) + parser.add_argument( + "-pt", + "--poll-timeout", + type=int, + help="Number of seconds to poll for a result before timing out " + "and returning a request id to re-query (defaults to 120)", + default=120, + ) + parser.add_argument( + "-rt", + "--result-type", + type=str, + choices=list(result_types.keys()), + help="Mime type of result in response" + "If not provided it is set to msgpack", + default="msgpack", + ) + parser.add_argument( + "-l", + "--log-level", + type=str, + choices=list(levels.keys()), + help="Log level", + default="info", + ) + parser.add_argument( + "-ov", + "--only-validation", + action="store_true", + help="If set, only validates input", + ) + parser.add_argument( + "-v", + "--version", + action="store_true", + help="Print client version and exit.", + ) + parser.add_argument( + "-sl", + "--solver-logs", + nargs="?", + const="", + default=None, + help="If set detailed MIP solver logs will be returned. If a filename " + "argument is given logs will be written to that file. If no argument " + "is given logs will be written to stdout.", + ), + parser.add_argument( + "-il", + "--incumbent-logs", + nargs="?", + const="", + default=None, + help="If set MIP incumbent solutions will be returned. If a filename " + "argument is given incumbents will be written to that file. " + "If no argument is given incumbents will be written to stdout.", + ), + parser.add_argument( + "-us", + "--upload-solution", + action="store_true", + help="Upload a solution to be cached on the server. The reqId " + "returned may be used as an initial solution for VRP.", + ) + parser.add_argument( + "-ht", + "--http-timeout", + type=int, + default=30, + help="Timeout in seconds for http requests. May need to be increased " + "for large datasets or slow networks. Default is 30s. " + "Set to None to never timeout.", + ) + + args = parser.parse_args() + set_log_level(levels[args.log_level]) + + if args.version: + print(get_version()) + elif not args.data: + print("expected data argument") + parser.print_help() + elif args.status: + status(args) + elif args.delete: + delete_request(args) + elif args.delete_solution: + delete_solution(args) + elif args.upload_solution: + upload_solution(args) + else: + solve(args) + + +if __name__ == "__main__": + main() diff --git a/python/cuopt_self_hosted/examples/web_hosted_client_examples.py b/python/cuopt_self_hosted/examples/web_hosted_client_examples.py new file mode 100644 index 000000000..d58b6ff1e --- /dev/null +++ b/python/cuopt_self_hosted/examples/web_hosted_client_examples.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 + +# 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. + +""" +Examples demonstrating the usage of CuOptServiceWebHostedClient. + +This module provides comprehensive examples of how to use the web-hosted +client for various scenarios including different authentication methods, +endpoint configurations, and problem types. +""" + +import os +import json +from cuopt_sh_client import CuOptServiceWebHostedClient, create_client + + +def example_basic_web_hosted_client(): + """Basic example using web-hosted client with API key.""" + print("=== Basic Web-Hosted Client Example ===") + + # Create client with endpoint and API key + client = CuOptServiceWebHostedClient( + endpoint="https://api.nvidia.com/cuopt/v1", + api_key="your-api-key-here" # Or set CUOPT_API_KEY environment variable + ) + + # Example problem data (simplified) + problem_data = { + "cost_matrix": [[0, 10, 15], [10, 0, 20], [15, 20, 0]], + "fleet_data": {"vehicle_count": 1}, + "task_data": {"demand": [0, 1, 1]}, + "solver_config": {"time_limit": 10} + } + + try: + # Solve the problem + result = client.get_optimized_routes(problem_data) + print(f"Solution found: {result}") + except Exception as e: + print(f"Error: {e}") + + +def example_bearer_token_authentication(): + """Example using bearer token authentication.""" + print("\n=== Bearer Token Authentication Example ===") + + client = CuOptServiceWebHostedClient( + endpoint="https://inference.nvidia.com/cuopt", + bearer_token="your-bearer-token-here" # Or set CUOPT_BEARER_TOKEN env var + ) + + # Rest of the example would be similar to basic example + print("Client created with bearer token authentication") + + +def example_environment_variables(): + """Example using environment variables for authentication.""" + print("\n=== Environment Variables Example ===") + + # Set environment variables (in practice, these would be set externally) + os.environ["CUOPT_API_KEY"] = "your-api-key-from-env" + + # Client will automatically pick up the API key from environment + client = CuOptServiceWebHostedClient( + endpoint="https://api.nvidia.com/cuopt/v1" + ) + + print("Client created using API key from environment variable") + + +def example_custom_endpoint_with_path(): + """Example with custom endpoint including path.""" + print("\n=== Custom Endpoint with Path Example ===") + + client = CuOptServiceWebHostedClient( + endpoint="https://my-custom-service.com:8080/api/v2/cuopt", + api_key="custom-service-key" + ) + + print(f"Request URL: {client.request_url}") + print(f"Solution URL: {client.solution_url}") + + +def example_factory_function(): + """Example using the factory function to create clients.""" + print("\n=== Factory Function Examples ===") + + # Creates web-hosted client + web_client = create_client( + endpoint="https://api.nvidia.com/cuopt/v1", + api_key="your-key" + ) + print(f"Created: {type(web_client).__name__}") + + # Creates self-hosted client (legacy mode) + self_hosted_client = create_client( + ip="192.168.1.100", + port="5000" + ) + print(f"Created: {type(self_hosted_client).__name__}") + + +def example_linear_programming(): + """Example solving a linear programming problem.""" + print("\n=== Linear Programming Example ===") + + client = CuOptServiceWebHostedClient( + endpoint="https://api.nvidia.com/cuopt/v1", + api_key="your-api-key-here" + ) + + # Example MPS file path or problem data + mps_file_path = "example_problem.mps" # This would be your actual MPS file + + try: + # Solve LP problem + result = client.get_LP_solve( + mps_file_path, + response_type="obj" # Returns ThinClientSolution object + ) + print(f"LP solution status: {result['status']}") + print(f"Objective value: {result['solution'].primal_objective}") + except Exception as e: + print(f"Error solving LP: {e}") + + +def example_with_callbacks(): + """Example using callbacks for MIP solver logs and incumbents.""" + print("\n=== Callbacks Example ===") + + client = CuOptServiceWebHostedClient( + endpoint="https://api.nvidia.com/cuopt/v1", + api_key="your-api-key-here" + ) + + def logging_callback(log_lines): + """Callback to handle solver logs.""" + for line in log_lines: + print(f"SOLVER LOG: {line}") + + def incumbent_callback(solution, cost): + """Callback to handle incumbent solutions.""" + print(f"New incumbent found with cost: {cost}") + + # Example MILP problem data + problem_data = "example_milp.mps" + + try: + result = client.get_LP_solve( + problem_data, + logging_callback=logging_callback, + incumbent_callback=incumbent_callback, + response_type="obj" + ) + print(f"Final solution cost: {result['solution'].primal_objective}") + except Exception as e: + print(f"Error: {e}") + + +def example_error_handling(): + """Example demonstrating error handling.""" + print("\n=== Error Handling Example ===") + + # Example with invalid API key + client = CuOptServiceWebHostedClient( + endpoint="https://api.nvidia.com/cuopt/v1", + api_key="invalid-key" + ) + + try: + result = client.get_optimized_routes({"invalid": "data"}) + except ValueError as e: + if "Authentication failed" in str(e): + print("Authentication error - check your API key") + elif "Access forbidden" in str(e): + print("Access forbidden - check your permissions") + else: + print(f"Other error: {e}") + + +def example_timeout_and_polling(): + """Example demonstrating timeout and polling behavior.""" + print("\n=== Timeout and Polling Example ===") + + client = CuOptServiceWebHostedClient( + endpoint="https://api.nvidia.com/cuopt/v1", + api_key="your-api-key-here", + polling_timeout=60, # Timeout after 60 seconds + timeout_exception=False # Return request ID instead of raising exception + ) + + problem_data = {"large": "problem_data"} + + try: + result = client.get_optimized_routes(problem_data) + + if isinstance(result, dict) and "reqId" in result: + print(f"Request timed out, got request ID: {result['reqId']}") + + # Later, you can poll for the result + final_result = client.repoll(result["reqId"]) + print(f"Final result: {final_result}") + else: + print(f"Solution completed: {result}") + + except Exception as e: + print(f"Error: {e}") + + +def example_backward_compatibility(): + """Example showing backward compatibility with legacy parameters.""" + print("\n=== Backward Compatibility Example ===") + + # This still works (legacy mode) + legacy_client = CuOptServiceWebHostedClient( + ip="192.168.1.100", + port="5000", + use_https=True + ) + print(f"Legacy client URL: {legacy_client.request_url}") + + # But if you provide endpoint, it takes precedence + mixed_client = CuOptServiceWebHostedClient( + endpoint="https://api.nvidia.com/cuopt/v1", + ip="192.168.1.100", # This will be ignored + port="5000", # This will be ignored + api_key="your-key" + ) + print(f"Mixed client URL: {mixed_client.request_url}") + + +def main(): + """Run all examples.""" + print("CuOpt Web-Hosted Client Examples") + print("=" * 50) + + # Note: These examples won't actually run without valid credentials + # and endpoints. They're provided for demonstration purposes. + + example_basic_web_hosted_client() + example_bearer_token_authentication() + example_environment_variables() + example_custom_endpoint_with_path() + example_factory_function() + example_linear_programming() + example_with_callbacks() + example_error_handling() + example_timeout_and_polling() + example_backward_compatibility() + + print("\n" + "=" * 50) + print("Examples completed. Note: Actual execution requires valid") + print("credentials and endpoints.") + + +if __name__ == "__main__": + main() diff --git a/python/cuopt_self_hosted/pyproject.toml b/python/cuopt_self_hosted/pyproject.toml index d035b105f..19c2b123e 100644 --- a/python/cuopt_self_hosted/pyproject.toml +++ b/python/cuopt_self_hosted/pyproject.toml @@ -57,6 +57,7 @@ Source = "https://github.com/nvidia/cuopt" [project.scripts] cuopt_sh = "cuopt_sh_client.cuopt_sh:main" +cuopt_web_sh = "cuopt_sh_client.cuopt_web_hosted_sh:main" [tool.setuptools] zip-safe = false 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..f632993b3 --- /dev/null +++ b/python/cuopt_self_hosted/tests/test_web_hosted_client.py @@ -0,0 +1,287 @@ +# 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 + client = CuOptServiceWebHostedClient( + endpoint="https://api.nvidia.com/cuopt/v1", + api_key="test-api-key" + ) + headers = client._get_auth_headers() + assert headers["X-API-Key"] == "test-api-key" + + # Test bearer token + client = CuOptServiceWebHostedClient( + endpoint="https://api.nvidia.com/cuopt/v1", + bearer_token="test-bearer-token" + ) + headers = client._get_auth_headers() + assert headers["Authorization"] == "Bearer test-bearer-token" + + # 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["X-API-Key"] == "env-api-key" + + @patch.dict(os.environ, {"CUOPT_BEARER_TOKEN": "env-bearer-token"}) + def test_bearer_token_from_environment(self): + """Test bearer token setup from environment variables.""" + client = CuOptServiceWebHostedClient(endpoint="https://api.nvidia.com/cuopt/v1") + headers = client._get_auth_headers() + assert headers["Authorization"] == "Bearer env-bearer-token" + + 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["X-API-Key"] == "param-api-key" + + def test_api_key_precedence_over_bearer_token(self): + """Test that API key takes precedence over bearer token.""" + client = CuOptServiceWebHostedClient( + endpoint="https://api.nvidia.com/cuopt/v1", + api_key="test-api-key", + bearer_token="test-bearer-token" + ) + headers = client._get_auth_headers() + assert "X-API-Key" in headers + assert "Authorization" not in headers + + def test_no_backward_compatibility_mode(self): + """Test that web-hosted client requires endpoint (no backward compatibility).""" + # Web-hosted client should not accept ip/port parameters without endpoint + with pytest.raises(ValueError, match="endpoint parameter is required"): + CuOptServiceWebHostedClient(ip="192.168.1.100", port="8080") + + 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/request" + 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/request" + 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 API key header + mock_request.assert_called_once() + call_args = mock_request.call_args + headers = call_args[1]["headers"] + assert headers["X-API-Key"] == "test-api-key" + + @patch('cuopt_sh_client.cuopt_web_hosted_client.requests.request') + def test_authenticated_request_with_bearer_token(self, mock_request): + """Test that authenticated requests include bearer token.""" + mock_response = MagicMock() + mock_response.status_code = 200 + mock_request.return_value = mock_response + + client = CuOptServiceWebHostedClient( + endpoint="https://api.nvidia.com/cuopt/v1", + bearer_token="test-bearer-token" + ) + + 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-bearer-token" + + @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/request" + + +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 or bearer_token provided but no endpoint"): + create_client(api_key="test-key") + + with pytest.raises(ValueError, match="api_key or bearer_token provided but no endpoint"): + create_client(bearer_token="test-token") + + 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__]) From 0c318ce083189a36b77304d4db627aafe60a6391 Mon Sep 17 00:00:00 2001 From: Tony Salim Date: Thu, 9 Oct 2025 16:10:01 -0700 Subject: [PATCH 2/4] Remove duplicate logic on api and token remove the parameter bearer token as it is can be inferred --- .../cuopt_web_hosted_client.py | 42 ++++++----- .../cuopt_sh_client/cuopt_web_hosted_sh.py | 22 ++---- .../tests/test_web_hosted_client.py | 75 +++---------------- 3 files changed, 38 insertions(+), 101 deletions(-) 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 index 9f0afc183..1323fdfa8 100644 --- 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 @@ -43,11 +43,8 @@ class CuOptServiceWebHostedClient(CuOptServiceSelfHostClient): - "https://inference.nvidia.com/cuopt" - "http://my-cuopt-service.com:8080/api" api_key : str, optional - API key for authentication. Can also be set via CUOPT_API_KEY - environment variable. - bearer_token : str, optional - Bearer token for authentication. Can also be set via CUOPT_BEARER_TOKEN - environment variable. + 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. @@ -62,7 +59,6 @@ def __init__( self, endpoint: str, api_key: Optional[str] = None, - bearer_token: Optional[str] = None, base_path: Optional[str] = None, self_signed_cert: str = "", **kwargs @@ -72,7 +68,6 @@ def __init__( # Handle authentication from environment variables self.api_key = api_key or os.getenv("CUOPT_API_KEY") - self.bearer_token = bearer_token or os.getenv("CUOPT_BEARER_TOKEN") # Parse endpoint URL self._parsed_endpoint = self._parse_endpoint_url(endpoint, base_path) @@ -137,9 +132,22 @@ def _parse_endpoint_url(self, endpoint: str, base_path: Optional[str] = None) -> } def _construct_endpoint_urls(self): - """Construct service URLs from parsed endpoint.""" + """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"] - self.request_url = urljoin(base_url + "/", "request") + + # 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") @@ -148,9 +156,7 @@ def _get_auth_headers(self) -> Dict[str, str]: headers = {} if self.api_key: - headers["X-API-Key"] = self.api_key - elif self.bearer_token: - headers["Authorization"] = f"Bearer {self.bearer_token}" + headers["Authorization"] = f"Bearer {self.api_key}" return headers @@ -182,7 +188,7 @@ def _make_http_request(self, method: str, url: str, **kwargs): # Handle authentication errors if response.status_code == 401: - raise ValueError("Authentication failed. Please check your API key or bearer token.") + raise ValueError("Authentication failed. Please check your API key.") elif response.status_code == 403: raise ValueError("Access forbidden. Please check your permissions.") @@ -192,7 +198,6 @@ def _make_http_request(self, method: str, url: str, **kwargs): def create_client( endpoint: Optional[str] = None, api_key: Optional[str] = None, - bearer_token: Optional[str] = None, **kwargs ) -> Union[CuOptServiceWebHostedClient, CuOptServiceSelfHostClient]: """ @@ -207,9 +212,7 @@ def create_client( 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 - bearer_token : str, optional - Bearer token for web-hosted client authentication + API key for web-hosted client authentication. Will be sent as Bearer token. **kwargs Additional parameters passed to the selected client @@ -234,13 +237,12 @@ def create_client( return CuOptServiceWebHostedClient( endpoint=endpoint, api_key=api_key, - bearer_token=bearer_token, **kwargs ) - elif api_key or bearer_token: + elif api_key: # Authentication provided but no endpoint - this is an error raise ValueError( - "api_key or bearer_token provided but no endpoint specified. " + "api_key provided but no endpoint specified. " "Web-hosted client requires an endpoint URL. " "Use CuOptServiceSelfHostClient for ip/port connections." ) diff --git a/python/cuopt_self_hosted/cuopt_sh_client/cuopt_web_hosted_sh.py b/python/cuopt_self_hosted/cuopt_sh_client/cuopt_web_hosted_sh.py index 7f80728e4..463111b79 100644 --- a/python/cuopt_self_hosted/cuopt_sh_client/cuopt_web_hosted_sh.py +++ b/python/cuopt_self_hosted/cuopt_sh_client/cuopt_web_hosted_sh.py @@ -48,7 +48,6 @@ def create_cuopt_client(args): # Check if web-hosted parameters are provided endpoint = getattr(args, 'endpoint', None) api_key = getattr(args, 'api_key', None) - bearer_token = getattr(args, 'bearer_token', None) # Validate parameter combinations if endpoint and (args.ip != ip_default or args.port != port_default): @@ -58,9 +57,9 @@ def create_cuopt_client(args): UserWarning ) - if (api_key or bearer_token) and not endpoint: + if api_key and not endpoint: raise ValueError( - "API key or bearer token provided but no endpoint specified. " + "API key provided but no endpoint specified. " "Web-hosted authentication requires an endpoint URL. " "Use -e/--endpoint to specify the service endpoint." ) @@ -70,7 +69,6 @@ def create_cuopt_client(args): return create_client( endpoint=endpoint, api_key=api_key, - bearer_token=bearer_token, ip=args.ip, port=args.port, use_https=args.ssl, @@ -316,8 +314,6 @@ def print_inc(sol, cost): # Add authentication parameters if present if getattr(args, 'api_key', None): repoll += f"--api-key '{args.api_key}' " - elif getattr(args, 'bearer_token', None): - repoll += f"--bearer-token '{args.bearer_token}' " status = repoll + "-st " repoll += solve_result["reqId"] @@ -366,12 +362,11 @@ def main(): # Web-hosted with endpoint URL cuopt_web_sh -e https://api.nvidia.com/cuopt/v1 --api-key YOUR_KEY problem.json - # Web-hosted with bearer token - cuopt_web_sh -e https://inference.nvidia.com/cuopt --bearer-token YOUR_TOKEN problem.json + # Web-hosted with API key (sent as Bearer token) + cuopt_web_sh -e https://inference.nvidia.com/cuopt --api-key YOUR_API_KEY problem.json Environment Variables: - CUOPT_API_KEY - API key for authentication - CUOPT_BEARER_TOKEN - Bearer token for authentication + CUOPT_API_KEY - API key for authentication (sent as Bearer token) """, formatter_class=argparse.RawDescriptionHelpFormatter ) @@ -413,12 +408,7 @@ def main(): web_group.add_argument( "--api-key", type=str, - help="API key for authentication. Can also be set via CUOPT_API_KEY environment variable.", - ) - web_group.add_argument( - "--bearer-token", - type=str, - help="Bearer token for authentication. Can also be set via CUOPT_BEARER_TOKEN environment variable.", + help="API key for authentication. Will be sent as Bearer token. Can also be set via CUOPT_API_KEY environment variable.", ) # Legacy self-hosted parameters diff --git a/python/cuopt_self_hosted/tests/test_web_hosted_client.py b/python/cuopt_self_hosted/tests/test_web_hosted_client.py index f632993b3..fce7d1514 100644 --- a/python/cuopt_self_hosted/tests/test_web_hosted_client.py +++ b/python/cuopt_self_hosted/tests/test_web_hosted_client.py @@ -71,21 +71,13 @@ def test_invalid_endpoint_url(self): def test_authentication_from_parameters(self): """Test authentication setup from parameters.""" - # Test API key + # 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["X-API-Key"] == "test-api-key" - - # Test bearer token - client = CuOptServiceWebHostedClient( - endpoint="https://api.nvidia.com/cuopt/v1", - bearer_token="test-bearer-token" - ) - headers = client._get_auth_headers() - assert headers["Authorization"] == "Bearer test-bearer-token" + assert headers["Authorization"] == "Bearer test-api-key" # Test no authentication client = CuOptServiceWebHostedClient(endpoint="https://api.nvidia.com/cuopt/v1") @@ -97,14 +89,7 @@ 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["X-API-Key"] == "env-api-key" - - @patch.dict(os.environ, {"CUOPT_BEARER_TOKEN": "env-bearer-token"}) - def test_bearer_token_from_environment(self): - """Test bearer token setup from environment variables.""" - client = CuOptServiceWebHostedClient(endpoint="https://api.nvidia.com/cuopt/v1") - headers = client._get_auth_headers() - assert headers["Authorization"] == "Bearer env-bearer-token" + assert headers["Authorization"] == "Bearer env-api-key" def test_parameter_precedence(self): """Test that parameters take precedence over environment variables.""" @@ -114,36 +99,19 @@ def test_parameter_precedence(self): api_key="param-api-key" ) headers = client._get_auth_headers() - assert headers["X-API-Key"] == "param-api-key" - - def test_api_key_precedence_over_bearer_token(self): - """Test that API key takes precedence over bearer token.""" - client = CuOptServiceWebHostedClient( - endpoint="https://api.nvidia.com/cuopt/v1", - api_key="test-api-key", - bearer_token="test-bearer-token" - ) - headers = client._get_auth_headers() - assert "X-API-Key" in headers - assert "Authorization" not in headers - - def test_no_backward_compatibility_mode(self): - """Test that web-hosted client requires endpoint (no backward compatibility).""" - # Web-hosted client should not accept ip/port parameters without endpoint - with pytest.raises(ValueError, match="endpoint parameter is required"): - CuOptServiceWebHostedClient(ip="192.168.1.100", port="8080") + 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/request" + 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/request" + 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" @@ -161,31 +129,11 @@ def test_authenticated_request_with_api_key(self, mock_request): client._make_http_request("GET", "https://api.nvidia.com/test") - # Check that the request was made with the API key header + # 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["X-API-Key"] == "test-api-key" - - @patch('cuopt_sh_client.cuopt_web_hosted_client.requests.request') - def test_authenticated_request_with_bearer_token(self, mock_request): - """Test that authenticated requests include bearer token.""" - mock_response = MagicMock() - mock_response.status_code = 200 - mock_request.return_value = mock_response - - client = CuOptServiceWebHostedClient( - endpoint="https://api.nvidia.com/cuopt/v1", - bearer_token="test-bearer-token" - ) - - 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-bearer-token" + 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): @@ -217,7 +165,7 @@ def test_base_path_handling(self): base_path="/custom/path" ) assert client._parsed_endpoint["path"] == "/custom/path" - assert client.request_url == "https://api.nvidia.com/custom/path/request" + assert client.request_url == "https://api.nvidia.com/custom/path" class TestCreateClientFactory: @@ -238,11 +186,8 @@ def test_creates_web_hosted_client_with_endpoint_and_auth(self): 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 or bearer_token provided but no endpoint"): + with pytest.raises(ValueError, match="api_key provided but no endpoint"): create_client(api_key="test-key") - - with pytest.raises(ValueError, match="api_key or bearer_token provided but no endpoint"): - create_client(bearer_token="test-token") def test_creates_self_hosted_client_by_default(self): """Test that self-hosted client is created by default.""" From 887313f81c46f2db2dca0a0ac065a966bce487f2 Mon Sep 17 00:00:00 2001 From: Tony Salim Date: Thu, 9 Oct 2025 16:43:23 -0700 Subject: [PATCH 3/4] Remove unnecessary file --- .../cuopt_sh_client/cuopt_web_hosted_sh.py | 671 ------------------ python/cuopt_self_hosted/pyproject.toml | 1 - 2 files changed, 672 deletions(-) delete mode 100644 python/cuopt_self_hosted/cuopt_sh_client/cuopt_web_hosted_sh.py diff --git a/python/cuopt_self_hosted/cuopt_sh_client/cuopt_web_hosted_sh.py b/python/cuopt_self_hosted/cuopt_sh_client/cuopt_web_hosted_sh.py deleted file mode 100644 index 463111b79..000000000 --- a/python/cuopt_self_hosted/cuopt_sh_client/cuopt_web_hosted_sh.py +++ /dev/null @@ -1,671 +0,0 @@ -#! /usr/bin/python3 - -# 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 argparse -import json -import logging -import os -import warnings - -from cuopt_sh_client import ( - CuOptServiceSelfHostClient, - CuOptServiceWebHostedClient, - create_client, - get_version, - is_uuid, - mime_type, - set_log_level, -) - -port_default = "5000" -ip_default = "0.0.0.0" - -result_types = { - "json": mime_type.JSON, - "msgpack": mime_type.MSGPACK, - "zlib": mime_type.ZLIB, - "*": mime_type.WILDCARD, -} - - -def create_cuopt_client(args): - """Create the appropriate CuOpt client based on arguments.""" - # Check if web-hosted parameters are provided - endpoint = getattr(args, 'endpoint', None) - api_key = getattr(args, 'api_key', None) - - # Validate parameter combinations - if endpoint and (args.ip != ip_default or args.port != port_default): - warnings.warn( - "Both endpoint and ip/port parameters provided. " - "Endpoint takes precedence. Consider using only endpoint parameter.", - UserWarning - ) - - if api_key and not endpoint: - raise ValueError( - "API key provided but no endpoint specified. " - "Web-hosted authentication requires an endpoint URL. " - "Use -e/--endpoint to specify the service endpoint." - ) - - # Create client using factory function - try: - return create_client( - endpoint=endpoint, - api_key=api_key, - ip=args.ip, - port=args.port, - use_https=args.ssl, - self_signed_cert=args.self_signed_cert, - polling_timeout=getattr(args, 'poll_timeout', 120), - only_validate=getattr(args, 'only_validation', False), - timeout_exception=False, - result_type=result_types[args.result_type], - http_general_timeout=getattr(args, 'http_timeout', 30), - ) - except ValueError as e: - # Re-raise with more helpful CLI context - raise ValueError(f"Client creation failed: {e}") - - -def status(args): - cuopt_service_client = create_cuopt_client(args) - - try: - solve_result = cuopt_service_client.status(args.data[0]) - - if solve_result: - print(solve_result) - - except Exception as e: - if args.log_level == "debug": - import traceback - traceback.print_exc() - print(str(e)) - - -def delete_request(args): - cuopt_service_client = create_cuopt_client(args) - - try: - # Interpretation of flags is done on the server, just pass them - solve_result = cuopt_service_client.delete( - args.data[0], - queued=args.queued, - running=args.running, - cached=args.cache, - ) - - if solve_result: - print(solve_result) - - except Exception as e: - if args.log_level == "debug": - import traceback - traceback.print_exc() - print(str(e)) - - -def upload_solution(args): - cuopt_service_client = create_cuopt_client(args) - - try: - # Interpretation of flags is done on the server, just pass them - reqId = cuopt_service_client.upload_solution(args.data[0]) - - if reqId: - print(reqId) - - except Exception as e: - if args.log_level == "debug": - import traceback - traceback.print_exc() - print(str(e)) - - -def delete_solution(args): - cuopt_service_client = create_cuopt_client(args) - - try: - solve_result = cuopt_service_client.delete_solution(args.data[0]) - - if solve_result: - print(solve_result) - - except Exception as e: - if args.log_level == "debug": - import traceback - traceback.print_exc() - print(str(e)) - - -def solve(args): - problem_data = args.data - - # Set the problem data - solver_settings = None - repoll_req = False - - def read_input_data(i_file): - repoll_req = False - if args.filepath: - data = i_file - elif not os.path.isfile(i_file): - if is_uuid(i_file): - data = i_file - repoll_req = True - else: - # Might be raw json data for repoll or a problem... - try: - data = json.loads(i_file) - repoll_req = "reqId" in data - except Exception: - data = i_file - else: - # Allow a repoll requestid to be in a file - with open(i_file, "r") as f: - try: - val = f.read(32) - except Exception: - val = "" - if len(val) >= 8 and val[0:8] == '{"reqId"': - f.seek(0) - data = json.load(f) - repoll_req = True # noqa - else: - data = i_file - return data, repoll_req - - if len(problem_data) == 1: - cuopt_problem_data, repoll_req = read_input_data(problem_data[0]) - elif args.type == "LP": - cuopt_problem_data = [] - for i_file in problem_data: - i_data, repoll_req = read_input_data(i_file) - if repoll_req: - raise Exception( - "Cannot have a repoll request in LP batch data" - ) - cuopt_problem_data.append(i_data) - else: - raise Exception("Cannot have multiple problem inputs for VRP") - - if args.type == "LP" and args.solver_settings: - if not os.path.isfile(args.solver_settings): - # Might be raw json data... - try: - solver_settings = json.loads(args.solver_settings) - except Exception: - print("solver settings is neither a filename nor valid JSON") - return - else: - with open(args.solver_settings, "r") as f: - solver_settings = json.load(f) - - # Initialize the CuOptServiceClient - cuopt_service_client = create_cuopt_client(args) - - try: - if repoll_req: - solve_result = cuopt_service_client.repoll( - cuopt_problem_data, - response_type="dict", - delete_solution=not args.keep, - ) - elif args.type == "VRP": - solve_result = cuopt_service_client.get_optimized_routes( - cuopt_problem_data, - args.filepath, - args.cache, - args.output, - delete_solution=not args.keep, - initial_ids=args.init_ids, - ) - elif args.type == "LP": - if args.init_ids: - raise Exception("Initial ids are not supported for LP") - - def log_callback(name): - def print_log(log): - ln = "\n".join(log) - if name: - with open(name, "a+") as f: - f.write(ln) - elif ln: - print(ln, end="") - - return print_log - - def inc_callback(name): - def print_inc(sol, cost): - if name: - with open(name, "a+") as f: - f.write(f"{sol} {cost}\n") - else: - print(sol, cost) - - return print_inc - - logging_callback = None - incumbent_callback = None - if args.solver_logs is not None: - # empty string means log to screen - if args.solver_logs: - with open(args.solver_logs, "w") as f: - pass # truncuate - logging_callback = log_callback(args.solver_logs) - - if args.incumbent_logs is not None: - # empty string means log to screen - if args.incumbent_logs: - with open(args.incumbent_logs, "w") as f: - pass # truncuate - incumbent_callback = inc_callback(args.incumbent_logs) - - solve_result = cuopt_service_client.get_LP_solve( - cuopt_problem_data, - solver_settings, - response_type="dict", - filepath=args.filepath, - cache=args.cache, - output=args.output, - delete_solution=not args.keep, - incumbent_callback=incumbent_callback, - logging_callback=logging_callback, - warmstart_id=args.warmstart_id, - ) - else: - raise Exception("Invalid type of problem.") - if solve_result: - if ( - isinstance(solve_result, dict) - and len(solve_result) == 1 - and "reqId" in solve_result - ): - # Build repoll command with appropriate parameters - repoll = "cuopt_web_sh " - - # Add endpoint or ip/port parameters - endpoint = getattr(args, 'endpoint', None) - if endpoint: - repoll += f"-e '{endpoint}' " - else: - if args.ip != ip_default: - repoll += f"-i {args.ip} " - if args.port != port_default: - repoll += f"-p {args.port} " - - # Add authentication parameters if present - if getattr(args, 'api_key', None): - repoll += f"--api-key '{args.api_key}' " - - status = repoll + "-st " - repoll += solve_result["reqId"] - status += solve_result["reqId"] - print( - "Request timed out.\n" - "Check for status with the following command:\n" + status - ) - print( - "\nPoll for a result with the following command:\n" - + repoll - ) - else: - print(solve_result) - - except Exception as e: - if args.log_level == "debug": - import traceback - traceback.print_exc() - print(str(e)) - - -def main(): - levels = { - "critical": logging.CRITICAL, - "error": logging.ERROR, - "warning": logging.WARNING, - "info": logging.INFO, - "debug": logging.DEBUG, - } - - result_types = { - "json": mime_type.JSON, - "msgpack": mime_type.MSGPACK, - "zlib": mime_type.ZLIB, - "*": mime_type.WILDCARD, - } - - parser = argparse.ArgumentParser( - description="Solve a cuOpt problem using a web-hosted or self-hosted service client.", - epilog=""" -Examples: - # Self-hosted (legacy mode) - cuopt_web_sh -i 192.168.1.100 -p 5000 problem.json - - # Web-hosted with endpoint URL - cuopt_web_sh -e https://api.nvidia.com/cuopt/v1 --api-key YOUR_KEY problem.json - - # Web-hosted with API key (sent as Bearer token) - cuopt_web_sh -e https://inference.nvidia.com/cuopt --api-key YOUR_API_KEY problem.json - -Environment Variables: - CUOPT_API_KEY - API key for authentication (sent as Bearer token) - """, - formatter_class=argparse.RawDescriptionHelpFormatter - ) - - # Data argument - parser.add_argument( - "data", - type=str, - nargs="*", - default="", - help="Filename, or JSON string containing a request id. " - "Data may be a cuopt problem or a request id " - "as displayed in the output from a previous request which timed out. " - "A cuopt problem must be in a file, but a request id may be " - "passed in a file or as a JSON string. " - " " - "For VRP:" - "A single problem file is expected or file_name." - " " - "For LP: " - "A single problem file in mps/json format or file_name." - "Batch mode is supported in case of mps files only for LP and" - "not for MILP, where a list of mps" - "files can be shared to be solved in parallel.", - ) - - # Web-hosted client parameters - web_group = parser.add_argument_group('Web-hosted service options') - web_group.add_argument( - "-e", - "--endpoint", - type=str, - help="Full endpoint URL for the cuOpt service. Examples: " - "'https://api.nvidia.com/cuopt/v1', " - "'https://inference.nvidia.com/cuopt', " - "'http://my-service.com:8080/api'. " - "If provided, this takes precedence over -i/-p parameters.", - ) - web_group.add_argument( - "--api-key", - type=str, - help="API key for authentication. Will be sent as Bearer token. Can also be set via CUOPT_API_KEY environment variable.", - ) - - # Legacy self-hosted parameters - legacy_group = parser.add_argument_group('Self-hosted service options (legacy)') - legacy_group.add_argument( - "-i", - "--ip", - type=str, - default=ip_default, - help=f"Host address for the cuOpt server (default {ip_default}). " - "Ignored if --endpoint is provided.", - ) - legacy_group.add_argument( - "-p", - "--port", - type=str, - default=port_default, - help="Port for the cuOpt server. Set to empty string ('') to omit " - f"the port number from the url (default {port_default}). " - "Ignored if --endpoint is provided.", - ) - legacy_group.add_argument( - "-s", - "--ssl", - action="store_true", - help="Use https scheme (default is http). Ignored if --endpoint is provided.", - default=False, - ) - legacy_group.add_argument( - "-c", - "--self-signed-cert", - type=str, - help="Path to self signed certificates only, " - "skip for standard certificates", - default="", - ) - - # All other existing parameters... - parser.add_argument( - "-id", - "--init-ids", - type=str, - nargs="*", - default=None, - help="reqId of a solution to use as an initial solution for a " - "VRP problem. There may be more than one, separated by spaces. " - "The list of ids will be terminated when the next option flag " - "is seen or there are no more arguments.", - ) - parser.add_argument( - "-wid", - "--warmstart-id", - type=str, - default=None, - help="reqId of a solution to use as a warmstart data for a " - "single LP problem. This allows to restart PDLP with a " - "previous solution context. Not enabled for Batch LP problem", - ) - parser.add_argument( - "-ca", - "--cache", - action="store_true", - help="Indicates that the DATA needs to be cached. This does not " - "solve the problem but stores the problem data and returns the reqId. " - "The reqId can be used later to solve the problem. This flag also " - "may be used alongside the delete argument to delete a cached request." - "(see the server documentation for more detail).", - default=None, - ) - parser.add_argument( - "-f", - "--filepath", - action="store_true", - help="Indicates that the DATA argument is the relative path " - "of a cuopt data file under the server's data directory. " - "The data directory is specified when the server is started " - "(see the server documentation for more detail).", - default=False, - ) - parser.add_argument( - "-d", - "--delete", - action="store_true", - help="Deletes cached requests or aborts requests on the cuOpt server. " - "The DATA argument may be the specific reqId of a request or " - "or it may be the wildcard '*' which will match any request. " - "If a specific reqId is given and the -r, -q, and -ca flags are " - "all unspecified, the reqId will always be deleted if it exists. " - "If any of the -r, -q, or -ca flags are specified, a specific reqId " - "will only be deleted if it matches the specified flags. " - "If the wildard reqId '*' is given, the -r, -q and/or -ca flags " - "must always be set explicitly. To flush the request queue, give '*' " - "as the reqId " - "and specify the -q flag. To delete all currently running requests, " - "give '*' as the reqId and specify the -r flag. To clear the request " - "cache, give '*' as the reqId and specify the -ca flag.", - default=False, - ) - parser.add_argument( - "-r", - "--running", - action="store_true", - help="Aborts a request only if it is running. Should be used with " - "-d argument", - default=None, - ) - parser.add_argument( - "-q", - "--queued", - action="store_true", - help="Aborts a request only if it is queued. Should be used with " - "-d argument", - default=None, - ) - parser.add_argument( - "-ds", - "--delete_solution", - action="store_true", - help="Deletes solutions on the cuOpt server. " - "The DATA argument is the specific reqId of a solution.", - ) - parser.add_argument( - "-k", - "--keep", - action="store_true", - help="Do not delete a solution from the server when it is retrieved. " - "Default is to delete the solution when it is retrieved.", - ) - parser.add_argument( - "-st", - "--status", - action="store_true", - help="Report the status of a request " - "(completed, aborted, running, queued)", - ) - parser.add_argument( - "-t", - "--type", - type=str, - help="The type of problem to solve. " - "Supported options are VRP and LP (defaults to VRP)", - default="VRP", - ) - parser.add_argument( - "-ss", - "--solver-settings", - type=str, - default="", - help="Filename or JSON string containing " - "solver settings for LP problem type", - ) - parser.add_argument( - "-o", - "--output", - type=str, - help="Optional name of the result file. If the server " - "has been configured to write results to files and " - "the size of the result is greater than the configured " - "limit, the server will write the result to a file with " - "this name under the server's result directory (see the " - "server documentation for more detail). A default name will " - "be used if this is not specified.", - default="", - ) - parser.add_argument( - "-pt", - "--poll-timeout", - type=int, - help="Number of seconds to poll for a result before timing out " - "and returning a request id to re-query (defaults to 120)", - default=120, - ) - parser.add_argument( - "-rt", - "--result-type", - type=str, - choices=list(result_types.keys()), - help="Mime type of result in response" - "If not provided it is set to msgpack", - default="msgpack", - ) - parser.add_argument( - "-l", - "--log-level", - type=str, - choices=list(levels.keys()), - help="Log level", - default="info", - ) - parser.add_argument( - "-ov", - "--only-validation", - action="store_true", - help="If set, only validates input", - ) - parser.add_argument( - "-v", - "--version", - action="store_true", - help="Print client version and exit.", - ) - parser.add_argument( - "-sl", - "--solver-logs", - nargs="?", - const="", - default=None, - help="If set detailed MIP solver logs will be returned. If a filename " - "argument is given logs will be written to that file. If no argument " - "is given logs will be written to stdout.", - ), - parser.add_argument( - "-il", - "--incumbent-logs", - nargs="?", - const="", - default=None, - help="If set MIP incumbent solutions will be returned. If a filename " - "argument is given incumbents will be written to that file. " - "If no argument is given incumbents will be written to stdout.", - ), - parser.add_argument( - "-us", - "--upload-solution", - action="store_true", - help="Upload a solution to be cached on the server. The reqId " - "returned may be used as an initial solution for VRP.", - ) - parser.add_argument( - "-ht", - "--http-timeout", - type=int, - default=30, - help="Timeout in seconds for http requests. May need to be increased " - "for large datasets or slow networks. Default is 30s. " - "Set to None to never timeout.", - ) - - args = parser.parse_args() - set_log_level(levels[args.log_level]) - - if args.version: - print(get_version()) - elif not args.data: - print("expected data argument") - parser.print_help() - elif args.status: - status(args) - elif args.delete: - delete_request(args) - elif args.delete_solution: - delete_solution(args) - elif args.upload_solution: - upload_solution(args) - else: - solve(args) - - -if __name__ == "__main__": - main() diff --git a/python/cuopt_self_hosted/pyproject.toml b/python/cuopt_self_hosted/pyproject.toml index 19c2b123e..d035b105f 100644 --- a/python/cuopt_self_hosted/pyproject.toml +++ b/python/cuopt_self_hosted/pyproject.toml @@ -57,7 +57,6 @@ Source = "https://github.com/nvidia/cuopt" [project.scripts] cuopt_sh = "cuopt_sh_client.cuopt_sh:main" -cuopt_web_sh = "cuopt_sh_client.cuopt_web_hosted_sh:main" [tool.setuptools] zip-safe = false From 105fdde675d88e7b5196a98b85d08af598d644a6 Mon Sep 17 00:00:00 2001 From: Tony Salim Date: Thu, 9 Oct 2025 16:45:44 -0700 Subject: [PATCH 4/4] Remove unnecessary example (provided as gist instead) provide the example as gist instead --- .../examples/web_hosted_client_examples.py | 271 ------------------ 1 file changed, 271 deletions(-) delete mode 100644 python/cuopt_self_hosted/examples/web_hosted_client_examples.py diff --git a/python/cuopt_self_hosted/examples/web_hosted_client_examples.py b/python/cuopt_self_hosted/examples/web_hosted_client_examples.py deleted file mode 100644 index d58b6ff1e..000000000 --- a/python/cuopt_self_hosted/examples/web_hosted_client_examples.py +++ /dev/null @@ -1,271 +0,0 @@ -#!/usr/bin/env python3 - -# 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. - -""" -Examples demonstrating the usage of CuOptServiceWebHostedClient. - -This module provides comprehensive examples of how to use the web-hosted -client for various scenarios including different authentication methods, -endpoint configurations, and problem types. -""" - -import os -import json -from cuopt_sh_client import CuOptServiceWebHostedClient, create_client - - -def example_basic_web_hosted_client(): - """Basic example using web-hosted client with API key.""" - print("=== Basic Web-Hosted Client Example ===") - - # Create client with endpoint and API key - client = CuOptServiceWebHostedClient( - endpoint="https://api.nvidia.com/cuopt/v1", - api_key="your-api-key-here" # Or set CUOPT_API_KEY environment variable - ) - - # Example problem data (simplified) - problem_data = { - "cost_matrix": [[0, 10, 15], [10, 0, 20], [15, 20, 0]], - "fleet_data": {"vehicle_count": 1}, - "task_data": {"demand": [0, 1, 1]}, - "solver_config": {"time_limit": 10} - } - - try: - # Solve the problem - result = client.get_optimized_routes(problem_data) - print(f"Solution found: {result}") - except Exception as e: - print(f"Error: {e}") - - -def example_bearer_token_authentication(): - """Example using bearer token authentication.""" - print("\n=== Bearer Token Authentication Example ===") - - client = CuOptServiceWebHostedClient( - endpoint="https://inference.nvidia.com/cuopt", - bearer_token="your-bearer-token-here" # Or set CUOPT_BEARER_TOKEN env var - ) - - # Rest of the example would be similar to basic example - print("Client created with bearer token authentication") - - -def example_environment_variables(): - """Example using environment variables for authentication.""" - print("\n=== Environment Variables Example ===") - - # Set environment variables (in practice, these would be set externally) - os.environ["CUOPT_API_KEY"] = "your-api-key-from-env" - - # Client will automatically pick up the API key from environment - client = CuOptServiceWebHostedClient( - endpoint="https://api.nvidia.com/cuopt/v1" - ) - - print("Client created using API key from environment variable") - - -def example_custom_endpoint_with_path(): - """Example with custom endpoint including path.""" - print("\n=== Custom Endpoint with Path Example ===") - - client = CuOptServiceWebHostedClient( - endpoint="https://my-custom-service.com:8080/api/v2/cuopt", - api_key="custom-service-key" - ) - - print(f"Request URL: {client.request_url}") - print(f"Solution URL: {client.solution_url}") - - -def example_factory_function(): - """Example using the factory function to create clients.""" - print("\n=== Factory Function Examples ===") - - # Creates web-hosted client - web_client = create_client( - endpoint="https://api.nvidia.com/cuopt/v1", - api_key="your-key" - ) - print(f"Created: {type(web_client).__name__}") - - # Creates self-hosted client (legacy mode) - self_hosted_client = create_client( - ip="192.168.1.100", - port="5000" - ) - print(f"Created: {type(self_hosted_client).__name__}") - - -def example_linear_programming(): - """Example solving a linear programming problem.""" - print("\n=== Linear Programming Example ===") - - client = CuOptServiceWebHostedClient( - endpoint="https://api.nvidia.com/cuopt/v1", - api_key="your-api-key-here" - ) - - # Example MPS file path or problem data - mps_file_path = "example_problem.mps" # This would be your actual MPS file - - try: - # Solve LP problem - result = client.get_LP_solve( - mps_file_path, - response_type="obj" # Returns ThinClientSolution object - ) - print(f"LP solution status: {result['status']}") - print(f"Objective value: {result['solution'].primal_objective}") - except Exception as e: - print(f"Error solving LP: {e}") - - -def example_with_callbacks(): - """Example using callbacks for MIP solver logs and incumbents.""" - print("\n=== Callbacks Example ===") - - client = CuOptServiceWebHostedClient( - endpoint="https://api.nvidia.com/cuopt/v1", - api_key="your-api-key-here" - ) - - def logging_callback(log_lines): - """Callback to handle solver logs.""" - for line in log_lines: - print(f"SOLVER LOG: {line}") - - def incumbent_callback(solution, cost): - """Callback to handle incumbent solutions.""" - print(f"New incumbent found with cost: {cost}") - - # Example MILP problem data - problem_data = "example_milp.mps" - - try: - result = client.get_LP_solve( - problem_data, - logging_callback=logging_callback, - incumbent_callback=incumbent_callback, - response_type="obj" - ) - print(f"Final solution cost: {result['solution'].primal_objective}") - except Exception as e: - print(f"Error: {e}") - - -def example_error_handling(): - """Example demonstrating error handling.""" - print("\n=== Error Handling Example ===") - - # Example with invalid API key - client = CuOptServiceWebHostedClient( - endpoint="https://api.nvidia.com/cuopt/v1", - api_key="invalid-key" - ) - - try: - result = client.get_optimized_routes({"invalid": "data"}) - except ValueError as e: - if "Authentication failed" in str(e): - print("Authentication error - check your API key") - elif "Access forbidden" in str(e): - print("Access forbidden - check your permissions") - else: - print(f"Other error: {e}") - - -def example_timeout_and_polling(): - """Example demonstrating timeout and polling behavior.""" - print("\n=== Timeout and Polling Example ===") - - client = CuOptServiceWebHostedClient( - endpoint="https://api.nvidia.com/cuopt/v1", - api_key="your-api-key-here", - polling_timeout=60, # Timeout after 60 seconds - timeout_exception=False # Return request ID instead of raising exception - ) - - problem_data = {"large": "problem_data"} - - try: - result = client.get_optimized_routes(problem_data) - - if isinstance(result, dict) and "reqId" in result: - print(f"Request timed out, got request ID: {result['reqId']}") - - # Later, you can poll for the result - final_result = client.repoll(result["reqId"]) - print(f"Final result: {final_result}") - else: - print(f"Solution completed: {result}") - - except Exception as e: - print(f"Error: {e}") - - -def example_backward_compatibility(): - """Example showing backward compatibility with legacy parameters.""" - print("\n=== Backward Compatibility Example ===") - - # This still works (legacy mode) - legacy_client = CuOptServiceWebHostedClient( - ip="192.168.1.100", - port="5000", - use_https=True - ) - print(f"Legacy client URL: {legacy_client.request_url}") - - # But if you provide endpoint, it takes precedence - mixed_client = CuOptServiceWebHostedClient( - endpoint="https://api.nvidia.com/cuopt/v1", - ip="192.168.1.100", # This will be ignored - port="5000", # This will be ignored - api_key="your-key" - ) - print(f"Mixed client URL: {mixed_client.request_url}") - - -def main(): - """Run all examples.""" - print("CuOpt Web-Hosted Client Examples") - print("=" * 50) - - # Note: These examples won't actually run without valid credentials - # and endpoints. They're provided for demonstration purposes. - - example_basic_web_hosted_client() - example_bearer_token_authentication() - example_environment_variables() - example_custom_endpoint_with_path() - example_factory_function() - example_linear_programming() - example_with_callbacks() - example_error_handling() - example_timeout_and_polling() - example_backward_compatibility() - - print("\n" + "=" * 50) - print("Examples completed. Note: Actual execution requires valid") - print("credentials and endpoints.") - - -if __name__ == "__main__": - main()