diff --git a/README.md b/README.md index 0fdf97ee8..d6ce65df9 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,9 @@ ibm_cloud_service = QiskitRuntimeService(channel="ibm_cloud", token="MY_IBM_CLOU # For an IBM Quantum account. ibm_quantum_service = QiskitRuntimeService(channel="ibm_quantum", token="MY_IBM_QUANTUM_TOKEN") + +# For a generic/custom channel platform. +ibm_quantum_service = QiskitRuntimeService(channel="generic", token="MY_TOKEN", url="https://my.url:1234/") ``` ## Primitives diff --git a/qiskit_ibm_runtime/accounts/account.py b/qiskit_ibm_runtime/accounts/account.py index f369f02ac..849d1bd46 100644 --- a/qiskit_ibm_runtime/accounts/account.py +++ b/qiskit_ibm_runtime/accounts/account.py @@ -22,7 +22,7 @@ from ..utils.hgp import from_instance_format from .exceptions import InvalidAccountError, CloudResourceNameResolutionError -from ..api.auth import QuantumAuth, CloudAuth +from ..api.auth import QuantumAuth, CloudAuth, GenericAuth from ..utils import resolve_crn AccountType = Optional[Literal["cloud", "legacy"]] @@ -42,11 +42,12 @@ def __init__( instance: Optional[str] = None, proxies: Optional[ProxyConfiguration] = None, verify: Optional[bool] = True, + url: Optional[str] = None, ): """Account constructor. Args: - channel: Channel type, ``ibm_cloud`` or ``ibm_quantum``. + channel: Channel type, ``ibm_cloud`` or ``ibm_quantum`` or ``generic``. token: Account token to use. url: Authentication URL. instance: Service instance to use. @@ -54,7 +55,7 @@ def __init__( verify: Whether to verify server's TLS certificate. """ self.channel: str = None - self.url: str = None + self.url: str = url self.token = token self.instance = instance self.proxies = proxies @@ -118,10 +119,19 @@ def create_account( verify=verify, private_endpoint=private_endpoint, ) + elif channel == "generic": + return GenericAccount( + url=url, + token=token, + instance=instance, + proxies=proxies, + verify=verify, + private_endpoint=private_endpoint, + ) else: raise InvalidAccountError( f"Invalid `channel` value. Expected one of " - f"{['ibm_cloud', 'ibm_quantum']}, got '{channel}'." + f"{['ibm_cloud', 'ibm_quantum', 'generic']}, got '{channel}'." ) def resolve_crn(self) -> None: @@ -164,10 +174,10 @@ def validate(self) -> "Account": @staticmethod def _assert_valid_channel(channel: ChannelType) -> None: """Assert that the channel parameter is valid.""" - if not (channel in ["ibm_cloud", "ibm_quantum"]): + if not (channel in ["ibm_cloud", "ibm_quantum", "generic"]): raise InvalidAccountError( f"Invalid `channel` value. Expected one of " - f"['ibm_cloud', 'ibm_quantum'], got '{channel}'." + f"['ibm_cloud', 'ibm_quantum', 'generic'], got '{channel}'." ) @staticmethod @@ -312,3 +322,62 @@ def _assert_valid_instance(instance: str) -> None: "If using the ibm_quantum channel,", "please specify the channel when saving your account with `channel = 'ibm_quantum'`.", ) + +class GenericAccount(Account): + """Class that represents an account with channel 'generic'.""" + + def __init__( + self, + token: str, + url: Optional[str] = None, + instance: Optional[str] = None, + proxies: Optional[ProxyConfiguration] = None, + verify: Optional[bool] = True, + private_endpoint: Optional[bool] = False, + ): + """Account constructor. + + Args: + token: Account token to use. + url: Authentication URL. + instance: Service instance to use. + proxies: Proxy configuration. + verify: Whether to verify server's TLS certificate. + private_endpoint: Connect to private API URL. + """ + super().__init__(token, instance, proxies, verify) + self.channel = "generic" + self.url = url + self.private_endpoint = private_endpoint + + def get_auth_handler(self) -> AuthBase: + """Returns the generic authentication handler.""" + return GenericAuth(api_key=self.token, crn=self.instance) + + def resolve_crn(self) -> None: + """Resolves the corresponding unique Cloud Resource Name (CRN) for the given non-unique service + instance name and updates the ``instance`` attribute accordingly. + """ + + crn = resolve_crn( + channel="generic", + url=self.url, + token=self.token, + instance=self.instance, + ) + if len(crn) > 1: + # handle edge-case where multiple service instances with the same name exist + logger.warning( + "Multiple CRN values found for service name %s: %s. Using %s.", + self.instance, + crn, + crn[0], + ) + + # overwrite with CRN value + self.instance = crn[0] + + @staticmethod + def _assert_valid_instance(instance: str) -> None: + """Assert that the instance name is valid, if given. As it is an optional parameter, passes.""" + pass diff --git a/qiskit_ibm_runtime/api/auth.py b/qiskit_ibm_runtime/api/auth.py index bb6fd849c..c867befec 100644 --- a/qiskit_ibm_runtime/api/auth.py +++ b/qiskit_ibm_runtime/api/auth.py @@ -64,3 +64,33 @@ def __call__(self, r: PreparedRequest) -> PreparedRequest: def get_headers(self) -> Dict: """Return authorization information to be stored in header.""" return {"X-Access-Token": self.access_token} + + +class GenericAuth(AuthBase): + """Attaches Generic Authentication to the given Request object.\n + """ + + def __init__(self, api_key: str, crn: str): + self.api_key = api_key + self.crn = crn + + def __eq__(self, other: object) -> bool: + if isinstance(other, GenericAuth): + return all( + [ + self.api_key == other.api_key, + self.crn == other.crn, + ] + ) + return False + + def __call__(self, r: PreparedRequest) -> PreparedRequest: + r.headers.update(self.get_headers()) + return r + + def get_headers(self) -> Dict: + """Return authorization information to be stored in header.""" + if self.crn is None: + return {"Authorization": f"apikey {self.api_key}"} + else: + return {"Service-CRN": self.crn, "Authorization": f"apikey {self.api_key}"} diff --git a/qiskit_ibm_runtime/api/client_parameters.py b/qiskit_ibm_runtime/api/client_parameters.py index b6d4a94b5..10f6744c1 100644 --- a/qiskit_ibm_runtime/api/client_parameters.py +++ b/qiskit_ibm_runtime/api/client_parameters.py @@ -16,7 +16,7 @@ from ..proxies import ProxyConfiguration from ..utils import default_runtime_url_resolver -from ..api.auth import QuantumAuth, CloudAuth +from ..api.auth import QuantumAuth, CloudAuth, GenericAuth TEMPLATE_IBM_HUBS = "{prefix}/Network/{hub}/Groups/{group}/Projects/{project}" """str: Template for creating an IBM Quantum URL with hub/group/project information.""" @@ -59,16 +59,21 @@ def __init__( url_resolver = default_runtime_url_resolver self.url_resolver = url_resolver - def get_auth_handler(self) -> Union[CloudAuth, QuantumAuth]: + def get_auth_handler(self) -> Union[CloudAuth, QuantumAuth, GenericAuth]: """Returns the respective authentication handler.""" if self.channel == "ibm_cloud": return CloudAuth(api_key=self.token, crn=self.instance) - - return QuantumAuth(access_token=self.token) + elif self.channel == "generic": + return GenericAuth(api_key=self.token, crn=self.instance) + else: + return QuantumAuth(access_token=self.token) def get_runtime_api_base_url(self) -> str: """Returns the Runtime API base url.""" - return self.url_resolver(self.url, self.instance, self.private_endpoint) + if self.channel == "generic": + return self.url + else: + return self.url_resolver(self.url, self.instance, self.private_endpoint) def connection_parameters(self) -> Dict[str, Any]: """Construct connection related parameters. diff --git a/qiskit_ibm_runtime/qiskit_runtime_service.py b/qiskit_ibm_runtime/qiskit_runtime_service.py index 4089ad6fa..3e15c409a 100644 --- a/qiskit_ibm_runtime/qiskit_runtime_service.py +++ b/qiskit_ibm_runtime/qiskit_runtime_service.py @@ -157,6 +157,9 @@ def __init__( if self._channel == "ibm_cloud": self._api_client = RuntimeClient(self._client_params) self._backend_allowed_list = self._discover_cloud_backends() + elif self._channel == "generic": + self._api_client = RuntimeClient(self._client_params) + self._backend_allowed_list = self._discover_cloud_backends() # same way as in ibm_cloud else: auth_client = self._authenticate_ibm_quantum_account(self._client_params) # Update client parameters to use authenticated values. @@ -213,8 +216,8 @@ def _discover_account( ) account = AccountManager.get(filename=filename, name=name) elif channel: - if channel and channel not in ["ibm_cloud", "ibm_quantum"]: - raise ValueError("'channel' can only be 'ibm_cloud' or 'ibm_quantum'") + if channel and channel not in ["ibm_cloud", "ibm_quantum", "generic"]: + raise ValueError("'channel' can only be 'ibm_cloud', 'ibm_quantum' or 'generic'") if token: account = Account.create_account( channel=channel, @@ -246,7 +249,8 @@ def _discover_account( account.verify = verify # resolve CRN if needed - self._resolve_crn(account) + if(not channel == 'generic'): + self._resolve_crn(account) # ensure account is valid, fail early if not account.validate() diff --git a/qiskit_ibm_runtime/utils/utils.py b/qiskit_ibm_runtime/utils/utils.py index 6a686915a..32a8cecc8 100644 --- a/qiskit_ibm_runtime/utils/utils.py +++ b/qiskit_ibm_runtime/utils/utils.py @@ -145,8 +145,8 @@ def get_resource_controller_api_url(cloud_url: str) -> str: def resolve_crn(channel: str, url: str, instance: str, token: str) -> List[str]: """Resolves the Cloud Resource Name (CRN) for the given cloud account.""" - if channel != "ibm_cloud": - raise ValueError("CRN value can only be resolved for cloud accounts.") + if channel not in["ibm_cloud", "generic"]: + raise ValueError("CRN value can only be resolved for cloud and generic accounts.") if is_crn(instance): # no need to resolve CRN value by name diff --git a/release-notes/unreleased/todo.feat.rst b/release-notes/unreleased/todo.feat.rst new file mode 100644 index 000000000..7109ddb86 --- /dev/null +++ b/release-notes/unreleased/todo.feat.rst @@ -0,0 +1,8 @@ +Create a new channel `generic`. This channel allows to use an alternative +and custom channel platform implementation, i.e. neither IBM Quantum Platform +nor IBM Cloud. The url parameter can be used to describe how the channel +platform is reached. It will not be further modified as with other channel +options. +While `token` and `instance` can be used if the HTTP headers match what the +custom channel platform expects, additional headers can be set through the +environment variable `QISKIT_IBM_RUNTIME_CUSTOM_CLIENT_APP_HEADER`. \ No newline at end of file