Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions python/cuopt_self_hosted/cuopt_sh_client/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -400,14 +400,35 @@ 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):
return
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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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={
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
251 changes: 251 additions & 0 deletions python/cuopt_self_hosted/cuopt_sh_client/cuopt_web_hosted_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
# SPDX-FileCopyrightText: Copyright (c) 2023-2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # noqa
# SPDX-License-Identifier: Apache-2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import logging
import os
import warnings
from typing import Dict, Optional, Union
from urllib.parse import urlparse, urljoin

import requests

from .cuopt_self_host_client import CuOptServiceSelfHostClient, mime_type

log = logging.getLogger(__name__)


class CuOptServiceWebHostedClient(CuOptServiceSelfHostClient):
"""
Web-hosted version of the CuOptServiceClient that supports endpoint URLs
and authentication mechanisms for cloud-hosted services.

This client is specifically designed for web-hosted cuOpt services and
requires an endpoint URL. For self-hosted services with ip/port parameters,
use CuOptServiceSelfHostClient instead.

Parameters
----------
endpoint : str
Full endpoint URL for the cuOpt service. Required parameter. Examples:
- "https://api.nvidia.com/cuopt/v1"
- "https://inference.nvidia.com/cuopt"
- "http://my-cuopt-service.com:8080/api"
api_key : str, optional
API key for authentication. Will be sent as Bearer token in Authorization header.
Can also be set via CUOPT_API_KEY environment variable.
base_path : str, optional
Base path to append to the endpoint if not included in endpoint URL.
Defaults to "/cuopt" if not specified in endpoint.
self_signed_cert : str, optional
Path to self-signed certificate for HTTPS connections.
**kwargs
Additional parameters passed to parent CuOptServiceSelfHostClient
(excluding ip, port, use_https which are determined from endpoint)
"""

def __init__(
self,
endpoint: str,
api_key: Optional[str] = None,
base_path: Optional[str] = None,
self_signed_cert: str = "",
**kwargs
):
if not endpoint:
raise ValueError("endpoint parameter is required for CuOptServiceWebHostedClient")

# Handle authentication from environment variables
self.api_key = api_key or os.getenv("CUOPT_API_KEY")

# Parse endpoint URL
self._parsed_endpoint = self._parse_endpoint_url(endpoint, base_path)

# Extract connection parameters from endpoint
ip = self._parsed_endpoint["host"]
port = str(self._parsed_endpoint["port"]) if self._parsed_endpoint["port"] else ""
use_https = self._parsed_endpoint["scheme"] == "https"
self._base_path = self._parsed_endpoint["path"]

# Initialize parent class with extracted parameters
super().__init__(
ip=ip,
port=port,
use_https=use_https,
self_signed_cert=self_signed_cert,
**kwargs
)

# Override URL construction with endpoint-based URLs
self._construct_endpoint_urls()

def _parse_endpoint_url(self, endpoint: str, base_path: Optional[str] = None) -> Dict[str, Union[str, int, None]]:
"""
Parse endpoint URL and extract components.

Parameters
----------
endpoint : str
Full endpoint URL
base_path : str, optional
Base path to use if not included in endpoint

Returns
-------
dict
Parsed URL components
"""
# Add protocol if missing
if not endpoint.startswith(("http://", "https://")):
log.warning(f"No protocol specified in endpoint '{endpoint}', assuming https://")
endpoint = f"https://{endpoint}"

parsed = urlparse(endpoint)

if not parsed.hostname:
raise ValueError(f"Invalid endpoint URL: {endpoint}")

# Determine base path
path = parsed.path.rstrip("/")
if not path and base_path:
path = base_path.rstrip("/")
elif not path:
path = "/cuopt"

return {
"scheme": parsed.scheme,
"host": parsed.hostname,
"port": parsed.port,
"path": path,
"full_url": f"{parsed.scheme}://{parsed.netloc}{path}"
}

def _construct_endpoint_urls(self):
"""Construct service URLs from parsed endpoint.

For web-hosted cuOpt services (like NVIDIA's API), the endpoint
URL is typically the complete service endpoint that handles all
operations. Unlike self-hosted services that have separate paths
for /request, /log, /solution, web-hosted APIs often use a single
endpoint for all operations.
"""
base_url = self._parsed_endpoint["full_url"]

# For web-hosted services, use the provided endpoint directly
# This matches the curl example which POSTs directly to the endpoint
self.request_url = base_url

# Log and solution URLs may still follow traditional patterns
# but many web-hosted APIs use the same endpoint for all operations
self.log_url = urljoin(base_url + "/", "log")
self.solution_url = urljoin(base_url + "/", "solution")

def _get_auth_headers(self) -> Dict[str, str]:
"""Get authentication headers."""
headers = {}

if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"

return headers

def _make_http_request(self, method: str, url: str, **kwargs):
"""
Override parent method to add authentication headers and handle auth errors.

Parameters
----------
method : str
HTTP method (GET, POST, DELETE, etc.)
url : str
Request URL
**kwargs
Additional arguments passed to requests.request()

Returns
-------
requests.Response
HTTP response object
"""
# Add authentication headers
headers = kwargs.get("headers", {})
headers.update(self._get_auth_headers())
kwargs["headers"] = headers

# Make request
response = requests.request(method, url, **kwargs)

# Handle authentication errors
if response.status_code == 401:
raise ValueError("Authentication failed. Please check your API key.")
elif response.status_code == 403:
raise ValueError("Access forbidden. Please check your permissions.")

return response


def create_client(
endpoint: Optional[str] = None,
api_key: Optional[str] = None,
**kwargs
) -> Union[CuOptServiceWebHostedClient, CuOptServiceSelfHostClient]:
"""
Factory function to create appropriate client based on parameters.

Creates CuOptServiceWebHostedClient if endpoint is provided, otherwise
creates CuOptServiceSelfHostClient for legacy ip/port usage.

Parameters
----------
endpoint : str, optional
Full endpoint URL. If provided, creates a web-hosted client.
Required for web-hosted client creation.
api_key : str, optional
API key for web-hosted client authentication. Will be sent as Bearer token.
**kwargs
Additional parameters passed to the selected client

Returns
-------
CuOptServiceWebHostedClient or CuOptServiceSelfHostClient
Web-hosted client if endpoint provided, self-hosted client otherwise

Examples
--------
# Creates web-hosted client
client = create_client(
endpoint="https://api.nvidia.com/cuopt/v1",
api_key="your-key"
)

# Creates self-hosted client
client = create_client(ip="192.168.1.100", port="5000")
"""
if endpoint:
# Create web-hosted client - endpoint is required
return CuOptServiceWebHostedClient(
endpoint=endpoint,
api_key=api_key,
**kwargs
)
elif api_key:
# Authentication provided but no endpoint - this is an error
raise ValueError(
"api_key provided but no endpoint specified. "
"Web-hosted client requires an endpoint URL. "
"Use CuOptServiceSelfHostClient for ip/port connections."
)
else:
# Create self-hosted client for legacy ip/port usage
return CuOptServiceSelfHostClient(**kwargs)
Loading
Loading