Repository: dwavesystems/dwave-cloud-client (language: python)

Issue:
Use full client config for `dwave.cloud.api.Regions` requests
Currently we only use `metadata_api_endpoint` and `headers` because we need to resolve regions mid `Client` construction, i.e. config parsing.

Blocked by #504.
Client init fails in 0.9.2 if proxy/permissive_ssl is required
We currently resolve region (using Metadata API) during `Client` construction. If `proxy` or `permissive_ssl` options are required for Metadata API requests (in addition to SAPI requests), region resolution (and Client init) will fail as those options are not parsed in time.

Implementing #505 will fully resolve this, but we might want to address it with a partial fix sooner.


Discussion / hints:

Temporary workaround is to explicitly specify `endpoint`, in which case endpoint/region is not resolved (and request to Metadata API is never made).

Fix patch:
diff --git a/dwave/cloud/api/client.py b/dwave/cloud/api/client.py
index d330edae..aeb093fa 100644
--- a/dwave/cloud/api/client.py
+++ b/dwave/cloud/api/client.py
@@ -12,9 +12,11 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
+from __future__ import annotations
+
 import logging
 from collections import deque, namedtuple
-from typing import Optional
+from typing import Optional, Union
 
 import requests
 import urllib3
@@ -22,9 +24,11 @@
 from werkzeug.http import parse_options_header, dump_options_header
 
 from dwave.cloud.api import constants, exceptions
+from dwave.cloud.config import load_config, validate_config_v1, update_config
+from dwave.cloud.config.models import ClientConfig
 from dwave.cloud.package_info import __packagename__, __version__
 from dwave.cloud.utils import (
-    TimeoutingHTTPAdapter, BaseUrlSession, user_agent, is_caused_by)
+    PretimedHTTPAdapter, BaseUrlSession, user_agent, is_caused_by)
 
 __all__ = ['DWaveAPIClient', 'SolverAPIClient', 'MetadataAPIClient', 'LeapAPIClient']
 
@@ -298,10 +302,80 @@ class DWaveAPIClient:
     def __init__(self, **config):
         self.config = {}
         for opt, default in self.DEFAULTS.items():
-            self.config[opt] = config.get(opt, default)
+            val = config.get(opt)
+            if val is None:
+                val = default
+            self.config[opt] = val
+
+        logger.debug(f"{type(self).__name__} initialized with config={self.config!r}")
 
         self.session = self._create_session(self.config)
 
+    @classmethod
+    def from_client_config(cls, config: ClientConfig, **kwargs):
+        """Create class instance based on
+        :class:`~dwave.cloud.config.models.ClientConfig` config.
+        """
+        logger.trace(f"{cls.__name__}.from_client_config(config={config!r}, **{kwargs!r})")
+
+        if config.headers:
+            headers = config.headers.copy()
+        else:
+            headers = {}
+        if config.connection_close:
+            headers.update({'Connection': 'close'})
+
+        opts = dict(
+            endpoint=config.endpoint,
+            token=config.token,
+            cert=config.cert,
+            headers=headers,
+            timeout=config.request_timeout,
+            retry=config.request_retry.model_dump(),
+            proxies=dict(http=config.proxy, https=config.proxy),
+            verify=not config.permissive_ssl,
+        )
+
+        # context-sensitive config update
+        update_config(opts, kwargs)
+
+        return cls(**opts)
+
+    @classmethod
+    def from_config_file(cls, config_file: Optional[str] = None,
+                         profile: Optional[str] = None, **kwargs):
+        """Create class instance based on config file / environment / kwarg options
+        (same format as :class:`~dwave.cloud.client.base.Client`).
+        """
+        logger.trace(f"{cls.__name__}.from_config_file("
+                     f"config_file={config_file!r}, profile={profile!r}, **{kwargs!r})")
+
+        options = load_config(config_file=config_file, profile=profile, **kwargs)
+        config = validate_config_v1(options)
+        return cls.from_client_config(config)
+
+    @classmethod
+    def from_config(cls, config: Optional[Union[ClientConfig, str]] = None, **kwargs):
+        """Create class instance based on client config or config file.
+
+        Args:
+            config:
+                Client config model or path to configuration file. Based on `config`
+                type, dispatches object creation to either :meth:`.from_client_config`
+                or :meth:`.from_config_file`. If omitted, attempts to load
+                configuration from file.
+
+            **kwargs:
+                Arguments passed to the dispatched method. See `config`.
+        """
+        logger.trace(f"{cls.__name__}.from_config(config={config!r}, **{kwargs!r}")
+
+        if isinstance(config, ClientConfig):
+            return cls.from_client_config(config, **kwargs)
+
+        kwargs['config_file'] = config
+        return cls.from_config_file(**kwargs)
+
     def close(self):
         self.session.close()
 
@@ -315,9 +389,6 @@ def __exit__(self, exc_type, exc_value, traceback):
     def _retry_config(backoff_max=None, **kwargs):
         """Create http idempotent urllib3.Retry config."""
 
-        if not kwargs:
-            return None
-
         retry = urllib3.Retry(**kwargs)
 
         # note: prior to `urllib3==2`, backoff_max had to be set manually on object
@@ -351,12 +422,10 @@ def _create_session(cls, config):
             strict_mode=config['version_strict_mode'])
         timeout = config['timeout']
         retry = config['retry']
-        session.mount('http://',
-            TimeoutingHTTPAdapter(
-                timeout=timeout, max_retries=cls._retry_config(**retry)))
-        session.mount('https://',
-            TimeoutingHTTPAdapter(
-                timeout=timeout, max_retries=cls._retry_config(**retry)))
+        session.mount('http://', PretimedHTTPAdapter(
+            timeout=timeout, max_retries=cls._retry_config(**retry)))
+        session.mount('https://', PretimedHTTPAdapter(
+            timeout=timeout, max_retries=cls._retry_config(**retry)))
 
         # configure headers
         session.headers.update({'User-Agent': cls.user_agent})
@@ -437,54 +506,28 @@ class SolverAPIClient(DWaveAPIClient):
     """Client for D-Wave's Solver API."""
 
     def __init__(self, **config):
-        config.setdefault('endpoint', constants.DEFAULT_SOLVER_API_ENDPOINT)
-        super().__init__(**config)
-
-    @classmethod
-    def from_client_config(cls, client):
-        """Create SAPI client instance configured from a
-        :class:`~dwave.cloud.client.base.Client' instance.
-        """
-
-        headers = client.headers.copy()
-        if client.connection_close:
-            headers.update({'Connection': 'close'})
-
-        opts = dict(
-            endpoint=client.endpoint,
-            token=client.token,
-            cert=client.client_cert,
-            timeout=client.request_timeout,
-            proxies=dict(
-                http=client.proxy,
-                https=client.proxy,
-            ),
-            retry=dict(
-                total=client.http_retry_total,
-                connect=client.http_retry_connect,
-                read=client.http_retry_read,
-                redirect=client.http_retry_redirect,
-                status=client.http_retry_status,
-                raise_on_redirect=True,
-                raise_on_status=True,
-                respect_retry_after_header=True,
-                backoff_factor=client.http_retry_backoff_factor,
-                backoff_max=client.http_retry_backoff_max,
-            ),
-            headers=client.headers,
-            verify=not client.permissive_ssl,
-        )
+        # TODO: make endpoint region-sensitive
+        self.DEFAULTS = super().DEFAULTS.copy()
+        self.DEFAULTS.update(endpoint=constants.DEFAULT_SOLVER_API_ENDPOINT)
 
-        return cls(**opts)
+        super().__init__(**config)
 
 
 class MetadataAPIClient(DWaveAPIClient):
     """Client for D-Wave's Metadata API."""
 
     def __init__(self, **config):
-        config.setdefault('endpoint', constants.DEFAULT_METADATA_API_ENDPOINT)
+        # TODO: make endpoint region-sensitive
+        self.DEFAULTS = super().DEFAULTS.copy()
+        self.DEFAULTS.update(endpoint=constants.DEFAULT_METADATA_API_ENDPOINT)
+
         super().__init__(**config)
 
+    @classmethod
+    def from_client_config(cls, config: ClientConfig, **kwargs):
+        kwargs.setdefault('endpoint', config.metadata_api_endpoint)
+        return super().from_client_config(config, **kwargs)
+
 
 class LeapAPIClient(DWaveAPIClient):
     """Client for D-Wave's Leap API."""
@@ -495,5 +538,15 @@ def _set_session_auth(cls, session, config):
             session.headers.update({'Authorization': f"Bearer {config['token']}"})
 
     def __init__(self, **config):
-        config.setdefault('endpoint', constants.DEFAULT_LEAP_API_ENDPOINT)
+        # TODO: make endpoint region-sensitive
+        self.DEFAULTS = super().DEFAULTS.copy()
+        self.DEFAULTS.update(endpoint=constants.DEFAULT_LEAP_API_ENDPOINT)
+
         super().__init__(**config)
+
+    @classmethod
+    def from_client_config(cls, config: ClientConfig, **kwargs):
+        # XXX: replace endpoint with `leap_api_endpoint` after we implement
+        # https://github.com/dwavesystems/dwave-cloud-client/issues/569
+        kwargs.setdefault('endpoint', constants.DEFAULT_LEAP_API_ENDPOINT)
+        return super().from_client_config(config, **kwargs)
diff --git a/dwave/cloud/api/resources.py b/dwave/cloud/api/resources.py
index fadf0a15..da37c4ff 100644
--- a/dwave/cloud/api/resources.py
+++ b/dwave/cloud/api/resources.py
@@ -21,6 +21,7 @@
 from dwave.cloud.api.client import (
     DWaveAPIClient, SolverAPIClient, MetadataAPIClient, LeapAPIClient)
 from dwave.cloud.api import constants, models
+from dwave.cloud.config.models import ClientConfig
 from dwave.cloud.utils import NumpyEncoder
 
 __all__ = ['Solvers', 'Problems', 'Regions']
@@ -61,8 +62,11 @@ class ResourceBase:
     # endpoint path prefix (base path) specific to all methods on the resource
     resource_path: str = None
 
-    def __init__(self, **config):
-        self.client = self.client_class(**config)
+    def __init__(self, client: Optional[DWaveAPIClient] = None, **config):
+        if isinstance(client, DWaveAPIClient):
+            self.client = client
+        else:
+            self.client = self.client_class(**config)
 
     @property
     def session(self):
@@ -86,16 +90,9 @@ def __exit__(self, exc_type, exc_value, traceback):
         self.close()
 
     @classmethod
-    def from_client_config(cls, client: Union[DWaveAPIClient, 'dwave.cloud.client.base.Client']):
-        """Create Resource instance configured from a
-        :class:`~dwave.cloud.client.base.Client' instance.
-        """
-        # TODO: also accept configuration dict/dataclass when config/client refactored
-        if isinstance(client, DWaveAPIClient):
-            return cls(**client.config)
-        else: # assume isinstance(client, dwave.cloud.Client), without importing
-            sapiclient = SolverAPIClient.from_client_config(client)
-            return cls(**sapiclient.config)
+    def from_config(cls, config: Optional[Union[ClientConfig, str]] = None, **kwargs):
+        client = cls.client_class.from_config(config, **kwargs)
+        return cls(client=client)
 
 
 class Regions(ResourceBase):
diff --git a/dwave/cloud/cli.py b/dwave/cloud/cli.py
index 3431b490..03390645 100644
--- a/dwave/cloud/cli.py
+++ b/dwave/cloud/cli.py
@@ -31,6 +31,7 @@
 
 import dwave.cloud
 from dwave.cloud import Client
+from dwave.cloud import api
 from dwave.cloud.solver import StructuredSolver, BaseUnstructuredSolver
 from dwave.cloud.utils import (
     default_text_input, generate_random_ising_problem,
@@ -46,7 +47,6 @@
     load_profile_from_files, load_config_from_files, get_default_config,
     get_configfile_path, get_default_configfile_path,
     get_configfile_paths)
-from dwave.cloud.api.constants import DEFAULT_METADATA_API_ENDPOINT
 
 
 # show defaults for all click options when printing --help
@@ -232,9 +232,16 @@ def _config_create(config_file, profile, ask_full=False):
     """Full/simplified dwave create flows."""
 
     if ask_full:
-        rs = Client._fetch_available_regions(DEFAULT_METADATA_API_ENDPOINT)
+        # try using existing file/env/kwarg=cli config for metadata api access,
+        # but fall back to Regions defaults
+        try:
+            rs = api.Regions.from_config(config_file=config_file, profile=profile)
+        except:
+            rs = api.Regions()
+
+        region_choices = [r.code for r in rs.list_regions()]
         prompts = dict(
-            region=dict(prompt="Solver API region", choices=[r['code'] for r in rs]),
+            region=dict(prompt="Solver API region", choices=region_choices),
             endpoint=dict(prompt="Solver API endpoint URL (overwrites 'region')"),
             token=dict(prompt="Authentication token"),
             client=dict(prompt="Client class", choices='base qpu sw hybrid'.split()),
diff --git a/dwave/cloud/client/base.py b/dwave/cloud/client/base.py
index 43f797e2..e1f8b9a6 100644
--- a/dwave/cloud/client/base.py
+++ b/dwave/cloud/client/base.py
@@ -36,7 +36,6 @@
 
 import re
 import time
-import json
 import copy
 import queue
 import logging
@@ -52,7 +51,7 @@
 
 from itertools import chain, zip_longest
 from functools import partial, wraps, cached_property
-from collections import abc, namedtuple
+from collections import namedtuple
 from concurrent.futures import ThreadPoolExecutor
 from typing import Optional, Tuple, Dict, Any
 from pydantic import TypeAdapter
@@ -66,14 +65,14 @@
 from dwave.cloud.package_info import __packagename__, __version__
 from dwave.cloud.exceptions import *    # TODO: fix
 from dwave.cloud.computation import Future
-from dwave.cloud.config import (
-    load_config, parse_float, parse_int, parse_boolean, update_config)
+from dwave.cloud.config import load_config, update_config, validate_config_v1
+from dwave.cloud.config.models import ClientConfig
 from dwave.cloud.solver import Solver, available_solvers
 from dwave.cloud.concurrency import PriorityThreadPoolExecutor
 from dwave.cloud.upload import ChunkedData
 from dwave.cloud.events import dispatches_events
 from dwave.cloud.utils import (
-    TimeoutingHTTPAdapter, BaseUrlSession, user_agent,
+    PretimedHTTPAdapter, BaseUrlSession, user_agent,
     datetime_to_timestamp, utcnow, cached, retried, is_caused_by)
 
 __all__ = ['Client']
@@ -81,6 +80,34 @@
 logger = logging.getLogger(__name__)
 
 
+def _deprecated_config_properties_to_config_map(options):
+    # Return a map of old/deprecated config property name to new lookup path
+    # in the config model.
+
+    def _translate(old_prefix, new_prefix):
+        return {k: f"{new_prefix}{k[len(old_prefix):]}"
+                for k in options if k.startswith(old_prefix)}
+
+    properties_map = {
+        'metadata_api_endpoint': 'metadata_api_endpoint',
+        'region': 'region',
+        'endpoint': 'endpoint',
+        'token': 'token',
+        'default_solver': 'solver',
+        'client_cert': 'cert',
+        'request_timeout': 'request_timeout',
+        'polling_timeout': 'polling_timeout',
+        'headers': 'headers',
+        'proxy': 'proxy',
+        'permissive_ssl': 'permissive_ssl',
+        'connection_close': 'connection_close',
+    }
+    properties_map.update(_translate("poll_", "polling_schedule."))
+    properties_map.update(_translate("http_retry_", "request_retry."))
+
+    return properties_map
+
+
 class Client(object):
     """Base client class for all D-Wave API clients. Used by QPU, software and
     hybrid :term:`sampler` classes.
@@ -271,6 +298,9 @@ class Client(object):
         'http_retry_backoff_max': 60,
     }
 
+    _DEPRECATED_CONFIG_PROPERTIES_MAP = \
+        _deprecated_config_properties_to_config_map(DEFAULTS)
+
     # Number of problems to include in a submit/status query
     _SUBMIT_BATCH_SIZE = 20
     _STATUS_QUERY_SIZE = 100
@@ -299,6 +329,15 @@ class Client(object):
     _UPLOAD_REQUEST_RETRIES = 2
     _UPLOAD_RETRIES_BACKOFF = lambda retry: 2 ** retry
 
+    @staticmethod
+    def _legacy_config_property_proxy(self, legacy_property, config_path):
+        warnings.warn(
+            f"`Client.{legacy_property}` property is deprecated since "
+            f"dwave-cloud-client 0.11.0, and will be removed in 0.12.0. "
+            f"Use `Client.config.{config_path}` instead.",
+            DeprecationWarning, stacklevel=2)
+        return pluck(self.config, config_path)
+
     @classmethod
     def from_config(cls, config_file=None, profile=None, client=None, **kwargs):
         """Client factory method to instantiate a client instance from configuration.
@@ -423,6 +462,13 @@ def __init__(self, *args, **kwargs):
 
         logger.debug("Client init called with: %r", kwargs)
 
+        # insert deprecated config properties that proxy values from the new `Client.config`
+        for legacy_property, config_path in Client._DEPRECATED_CONFIG_PROPERTIES_MAP.items():
+            proxy = partial(Client._legacy_config_property_proxy,
+                            legacy_property=legacy_property,
+                            config_path=config_path)
+            setattr(Client, legacy_property, property(proxy))
+
         # derive instance-level defaults from class defaults and init defaults
         self.defaults = copy.deepcopy(self.DEFAULTS)
         user_defaults = kwargs.pop('defaults', None)
@@ -434,115 +480,24 @@ def __init__(self, *args, **kwargs):
         # note: treat empty string values (e.g. from file/env) as undefined/None
         options = copy.deepcopy(self.defaults)
         update_config(options, kwargs)
+        logger.debug("Client options over defaults: %r", options)
 
-        logger.debug("Client options with defaults: %r", options)
-
-        # configure MetadataAPI access -- needed by Client.get_regions()
-        self.metadata_api_endpoint = options['metadata_api_endpoint']
-
-        # parse headers as they might be needed by Client.get_regions()
-        headers = options['headers']
-        if not headers:
-            headers_dict = {}
-        elif isinstance(headers, abc.Mapping):
-            headers_dict = headers
-        elif isinstance(headers, str):
-            try:
-                # valid  headers = "Field-1: value-1\nField-2: value-2"
-                headers_dict = {key.strip(): val.strip()
-                                for key, val in [line.split(':')
-                                                 for line in headers.strip().split('\n')]}
-            except Exception as e:
-                logger.debug("Invalid headers: %r", headers)
-                headers_dict = {}
-        else:
-            raise ValueError("HTTP headers expected in a dict, or a string")
-        logger.debug("Parsed headers=%r", headers_dict)
-        self.headers = headers_dict
+        self.config: ClientConfig = validate_config_v1(options)
+        logger.debug("Validated client config=%r", self.config)
 
         # resolve endpoint using region
-        region, endpoint = self._resolve_region_endpoint(
-            region=options.get('region'), endpoint=options.get('endpoint'))
+        self.config.region, self.config.endpoint = self._resolve_region_endpoint(
+            region=self.config.region, endpoint=self.config.endpoint)
+
+        logger.debug("Final client config=%r", self.config)
 
         # sanity check
-        if not endpoint:
+        if not self.config.endpoint:
             raise ValueError("API endpoint not defined")
 
-        token = options['token']
-        if not token:
+        if not self.config.token:
             raise ValueError("API token not defined")
 
-        # parse optional client certificate
-        client_cert = options['client_cert']
-        client_cert_key = options['client_cert_key']
-        if client_cert_key is not None:
-            if client_cert is not None:
-                client_cert = (client_cert, client_cert_key)
-            else:
-                raise ValueError(
-                    "Client certificate key given, but the cert is missing")
-
-        # parse solver
-        solver = options['solver']
-        if not solver:
-            solver_def = {}
-        elif isinstance(solver, abc.Mapping):
-            solver_def = solver
-        elif isinstance(solver, str):
-            # support features dict encoded as JSON in our config INI file
-            # TODO: push this decoding to the config module, once we switch to a
-            #       richer config format (JSON or YAML)
-            try:
-                solver_def = json.loads(solver)
-            except Exception:
-                # unparseable json, assume string name for solver
-                # we'll deprecate this eventually, but for now just convert it to
-                # features dict (equality constraint on full solver name)
-                logger.debug("Invalid solver JSON, assuming string name: %r", solver)
-                solver_def = dict(name__eq=solver)
-        else:
-            raise ValueError("Expecting a features dictionary or a string name for 'solver'")
-        logger.debug("Parsed solver=%r", solver_def)
-
-        # Store connection/session parameters
-        # TODO: consolidate all options under Client.options or similar
-        self.region = region    # for record only
-        self.endpoint = endpoint
-        self.token = token
-        self.default_solver = solver_def
-
-        self.client_cert = client_cert
-        self.request_timeout = parse_float(options['request_timeout'])
-        self.polling_timeout = parse_float(options['polling_timeout'])
-
-        self.proxy = options['proxy']
-        self.permissive_ssl = parse_boolean(options['permissive_ssl'])
-        self.connection_close = parse_boolean(options['connection_close'])
-
-        self.poll_backoff_min = parse_float(options['poll_backoff_min'])
-        self.poll_backoff_max = parse_float(options['poll_backoff_max'])
-        self.poll_backoff_base = parse_float(options['poll_backoff_base'])
-
-        self.http_retry_total = parse_int(options['http_retry_total'])
-        self.http_retry_connect = parse_int(options['http_retry_connect'])
-        self.http_retry_read = parse_int(options['http_retry_read'])
-        self.http_retry_redirect = parse_int(options['http_retry_redirect'])
-        self.http_retry_status = parse_int(options['http_retry_status'])
-        self.http_retry_backoff_factor = parse_float(options['http_retry_backoff_factor'])
-        self.http_retry_backoff_max = parse_float(options['http_retry_backoff_max'])
-
-        opts = (
-            'region', 'endpoint', 'token', 'default_solver',
-            'client_cert', 'request_timeout', 'polling_timeout',
-            'proxy', 'headers', 'permissive_ssl', 'connection_close',
-            'poll_backoff_min', 'poll_backoff_max', 'poll_backoff_base',
-            'http_retry_total', 'http_retry_connect', 'http_retry_read',
-            'http_retry_redirect', 'http_retry_status',
-            'http_retry_backoff_factor', 'http_retry_backoff_max')
-        logger.debug(
-            "Client initialized with (%s)",
-            ", ".join("{}={!r}".format(o, getattr(self, o)) for o in opts))
-
         # Create session for main thread only
         self.session = self.create_session()
 
@@ -616,49 +571,30 @@ def create_session(self):
         """
 
         # allow endpoint path to not end with /
-        endpoint = self.endpoint
+        endpoint = self.config.endpoint
         if not endpoint.endswith('/'):
             endpoint += '/'
 
-        # create http idempotent Retry config
-        def get_retry_conf():
-            retry = urllib3.Retry(
-                total=self.http_retry_total,
-                connect=self.http_retry_connect,
-                read=self.http_retry_read,
-                redirect=self.http_retry_redirect,
-                status=self.http_retry_status,
-                backoff_factor=self.http_retry_backoff_factor,
-                raise_on_redirect=True,
-                raise_on_status=True,
-                respect_retry_after_header=True)
-
-            if self.http_retry_backoff_max is not None:
-                # handle `urllib3>=1.21.1,<1.27` AND `urllib3>=1.21.1,<3`
-                retry.BACKOFF_MAX = retry.backoff_max = self.http_retry_backoff_max
-
-            return retry
-
         session = BaseUrlSession(base_url=endpoint)
-        session.mount('http://',
-            TimeoutingHTTPAdapter(timeout=self.request_timeout,
-                                  max_retries=get_retry_conf()))
-        session.mount('https://',
-            TimeoutingHTTPAdapter(timeout=self.request_timeout,
-                                  max_retries=get_retry_conf()))
+        session.mount('http://', PretimedHTTPAdapter(
+            timeout=self.config.request_timeout,
+            max_retries=self.config.request_retry.to_urllib3_retry()))
+        session.mount('https://', PretimedHTTPAdapter(
+            timeout=self.config.request_timeout,
+            max_retries=self.config.request_retry.to_urllib3_retry()))
 
         session.headers.update({'User-Agent': self._user_agent})
-        if self.headers:
-            session.headers.update(self.headers)
-        if self.token:
-            session.headers.update({'X-Auth-Token': self.token})
-        if self.client_cert:
-            session.cert = self.client_cert
-
-        session.proxies = {'http': self.proxy, 'https': self.proxy}
-        if self.permissive_ssl:
+        if self.config.headers:
+            session.headers.update(self.config.headers)
+        if self.config.token:
+            session.headers.update({'X-Auth-Token': self.config.token})
+        if self.config.cert:
+            session.cert = self.config.cert
+
+        session.proxies = {'http': self.config.proxy, 'https': self.config.proxy}
+        if self.config.permissive_ssl:
             session.verify = False
-        if self.connection_close:
+        if self.config.connection_close:
             session.headers.update({'Connection': 'close'})
 
         # Debug-log headers
@@ -755,11 +691,11 @@ def is_solver_handled(solver):
 
     @staticmethod
     @cached.ondisk(maxage=_REGIONS_CACHE_MAXAGE)
-    def _fetch_available_regions(metadata_api_endpoint: str, **config) -> Dict[str, str]:
+    def _fetch_available_regions(config: ClientConfig) -> Dict[str, str]:
         logger.info("Fetching available regions from the Metadata API at %r",
-                    metadata_api_endpoint)
+                    config.metadata_api_endpoint)
 
-        with api.Regions(endpoint=metadata_api_endpoint, **config) as regions:
+        with api.Regions.from_config(config) as regions:
             regions = regions.list_regions()
             data = TypeAdapter(Any).dump_python(regions)
 
@@ -777,15 +713,17 @@ def get_regions(self, refresh: bool = False) -> Dict[str, Dict[str, str]]:
         Returns:
             Mapping of region details (name and endpoint) over region codes.
         """
+        # make sure we don't cache by noise in config
+        config = self.config.model_copy(
+            update=dict(region=None, endpoint=None, token=None, client=None,
+                        solver=None, polling_schedule=None, polling_timeout=None),
+            deep=True)
         try:
-            rs = Client._fetch_available_regions(
-                metadata_api_endpoint=self.metadata_api_endpoint,
-                headers=self.headers,
-                refresh_=refresh)
+            rs = Client._fetch_available_regions(config=config, refresh_=refresh)
         except api.exceptions.RequestError as exc:
             logger.debug("Metadata API unavailable", exc_info=True)
             raise ValueError(
-                f"Metadata API unavailable at {self.metadata_api_endpoint!r}")
+                f"Metadata API unavailable at {self.config.metadata_api_endpoint!r}")
 
         logger.info("Using region metadata: %r", rs)
 
@@ -1245,8 +1183,8 @@ def get_solver(self, name=None, refresh=False, **filters):
         order_by = filters.pop('order_by', None)
 
         # in absence of other filters, config/env solver filters/name are used
-        if not filters and self.default_solver:
-            filters = copy.deepcopy(self.default_solver)
+        if not filters and self.config.solver:
+            filters = copy.deepcopy(self.config.solver)
 
         # allow `order_by` from default config/init override
         if order_by is None:
@@ -1517,13 +1455,13 @@ def _poll(self, future):
 
         if future._poll_backoff is None:
             # on first poll, start with minimal back-off
-            future._poll_backoff = self.poll_backoff_min
+            future._poll_backoff = self.config.polling_schedule.backoff_min
         else:
             # on subsequent polls, do exponential back-off, clipped to a range
             future._poll_backoff = \
-                max(self.poll_backoff_min,
-                    min(future._poll_backoff * self.poll_backoff_base,
-                        self.poll_backoff_max))
+                max(self.config.polling_schedule.backoff_min,
+                    min(future._poll_backoff * self.config.polling_schedule.backoff_base,
+                        self.config.polling_schedule.backoff_max))
 
         # for poll priority we use timestamp of next scheduled poll
         at = time.time() + future._poll_backoff
@@ -1535,9 +1473,9 @@ def _poll(self, future):
 
         # don't enqueue for next poll if polling_timeout is exceeded by then
         future_age_on_next_poll = future_age + (at - datetime_to_timestamp(now))
-        if self.polling_timeout is not None and future_age_on_next_poll > self.polling_timeout:
+        if self.config.polling_timeout is not None and future_age_on_next_poll > self.config.polling_timeout:
             logger.debug("Polling timeout exceeded before next poll: %.2f sec > %.2f sec, aborting polling!",
-                         future_age_on_next_poll, self.polling_timeout)
+                         future_age_on_next_poll, self.config.polling_timeout)
             raise PollingTimeout
 
         self._poll_queue.put((at, future))
diff --git a/dwave/cloud/config/__init__.py b/dwave/cloud/config/__init__.py
new file mode 100644
index 00000000..c1283795
--- /dev/null
+++ b/dwave/cloud/config/__init__.py
@@ -0,0 +1,160 @@
+# Copyright 2023 D-Wave Systems Inc.
+#
+# 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.
+
+"""
+Configuration for communicating with a solver.
+
+Communicating with a solver---submitting a problem, monitoring its progress, receiving
+samples---requires configuration of several parameters such as the selected solver,
+its URL, an API token, etc. D-Wave Cloud Client provides multiple options for configuring
+those parameters:
+
+* One or more locally saved configuration files.
+* Environment variables
+* Direct setting of key values in functions
+
+These options can be flexibly used together. The standard use is through
+the :func:`~dwave.cloud.client.Client.from_config` classmethod.
+
+Configuration values can be specified in multiple ways, ranked in the following
+order (with 1 the highest ranked):
+
+1. Values specified as keyword arguments.
+2. Values specified as environment variables.
+3. Values specified in the configuration file.
+4. Values specified in :class:`~dwave.cloud.client.Client` instance ``defaults``.
+5. Values specified in :class:`~dwave.cloud.client.Client` class
+   :attr:`~dwave.cloud.client.Client.DEFAULTS`.
+
+Configuration files comply with standard Windows INI-like format,
+parsable with Python's :mod:`configparser`. An optional ``defaults`` section
+provides default key-value pairs for all other sections. User-defined key-value
+pairs (unrecognized keys) are passed through to the client.
+
+Typically configuration files are created, inspected, and changed using interactive
+CLI commands from your system's console, such as :code:`dwave config create` and
+:code:`dwave config inspect` (run :code:`dwave --help` for information on CLI options).
+
+Environment variables:
+
+*   ``DWAVE_API_CLIENT``: API client class. Supported values are ``qpu``, ``sw`` and ``hybrid``.
+*   ``DWAVE_API_ENDPOINT``: Solver API endpoint URL.
+*   ``DWAVE_API_HEADERS``: Optional additional HTTP headers.
+*   ``DWAVE_API_PROXY``: URL for proxy connections to D-Wave API.
+*   ``DWAVE_API_REGION``: API region code.
+*   ``DWAVE_API_SOLVER``: Default solver.
+*   ``DWAVE_API_TOKEN``: Solver API authorization token.
+*   ``DWAVE_CONFIG_FILE``: Configuration file path.
+*   ``DWAVE_METADATA_API_ENDPOINT``: Metadata API endpoint URL.
+*   ``DWAVE_PROFILE``: Name of profile (section).
+
+Examples:
+    The following are typical and advanced examples of using
+    :func:`~dwave.cloud.client.Client.from_config` to create a configured client.
+
+    *   **Example** Typical use for QPU and hybrid solvers
+
+    This example uses a configuration file in one of the standard paths,
+    :code:`~/.config/dwave/dwave.conf`, selected through auto-detection
+    on the local system following the user/system configuration paths of
+    :func:`get_configfile_paths`, to provide the following default
+    configuration::
+
+        [defaults]
+        token = ABC-123456789123456789123456789
+
+        [default-solver]
+        solver = {"qpu": true, "num_qubits__gt": 5000}
+
+        [bqm]
+        client = hybrid
+        solver = {"supported_problem_types__contains": "bqm"}
+
+        [cqm]
+        client = hybrid
+        solver = {"supported_problem_types__contains": "cqm"}
+
+        [europe]
+        region = eu-central-1
+
+    This configuration file sets a default API token used by all following
+    profiles unless overridden, ensures that selecting a solver with the
+    :func:`~dwave.cloud.client.Client.get_solver` method by default returns a
+    QPU solver with at least 5000 qubits, and configures profiles for selecting
+    a quantum-classical hybrid solver and solvers from Leap's European region.
+
+    The code below instantiates clients for a QPU solver and a hybrid CQM solver.
+
+    >>> with Client.from_config() as client:   # doctest: +SKIP
+    ...     solver_qpu = client.get_solver()
+    >>> with Client.from_config(profile="cqm") as client:   # doctest: +SKIP
+    ...     solver_cqm = client.get_solver()
+
+    *   **Example:** Explicitly specified configuration file, unrecognized parameter
+
+    This example explicitly specifies the following configuration file,
+    :code:`~/jane/my_path_to_config/my_cloud_conf.conf`::
+
+        [defaults]
+        token = ABC-123456789123456789123456789
+
+        [first-qpu]
+        solver = {"qpu": true}
+
+    The code below creates a client that it later explicitly closes. It also
+    passes through to the instantiated client an unrecognized key-value pair
+    ``my_param="my_value"``.
+
+    >>> from dwave.cloud import Client
+    >>> client = Client.from_config(config_file='~/jane/my_path_to_config/my_cloud_conf.conf',
+    ...                             my_param="my_value")  # doctest: +SKIP
+    >>> # code that uses client
+    >>> client.close()   # doctest: +SKIP
+
+    *   **Advanced Example:** Multiple auto-detected configuration files
+
+    This example uses two configuration files: (1) a user-local file,
+    ``/usr/local/share/dwave/dwave.conf``::
+
+        [defaults]
+        token = ABC-123456789123456789123456789
+        solver = {"qpu": true}
+
+        [advantage]
+        region = eu-central-1
+
+    and (2), a ``./dwave.conf`` file in the current working directory::
+
+        [advantage]
+        token = DEF-987654321987654321987654321
+
+    The code below supplements the API token from higher priority file (the
+    ``./dwave.conf`` file in the current working directory), overriding the value
+    from the ``[defaults]`` and first (``[advantage]``) sections of the
+    lower-priority user-local file, ``/usr/local/share/dwave/dwave.conf``. Use
+    of the :func:`~dwave.cloud.client.Client.get_solver` method would select
+    an Advantage from Leap's European region using the ``DEF-987 ...`` token.
+
+    >>> from dwave.cloud import Client
+    >>> client = Client.from_config()  # doctest: +SKIP
+    >>> print(client.endpoint)      # doctest: +SKIP
+    https://eu-central-1.cloud.dwavesys.com/sapi/v2/
+    >>> print(client.token)  # doctest: +SKIP
+    DEF-987654321987654321987654321
+    >>> # code that uses client
+    >>> client.close() # doctest: +SKIP
+"""
+
+from .loaders import *
+from .models import *
diff --git a/dwave/cloud/config.py b/dwave/cloud/config/loaders.py
similarity index 79%
rename from dwave/cloud/config.py
rename to dwave/cloud/config/loaders.py
index 69d0f628..a5f919d1 100644
--- a/dwave/cloud/config.py
+++ b/dwave/cloud/config/loaders.py
@@ -12,163 +12,23 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 
-"""
-Configuration for communicating with a solver.
-
-Communicating with a solver---submitting a problem, monitoring its progress, receiving
-samples---requires configuration of several parameters such as the selected solver,
-its URL, an API token, etc. D-Wave Cloud Client provides multiple options for configuring
-those parameters:
-
-* One or more locally saved configuration files.
-* Environment variables
-* Direct setting of key values in functions
-
-These options can be flexibly used together. The standard use is through
-the :func:`~dwave.cloud.client.Client.from_config` classmethod.
-
-Configuration values can be specified in multiple ways, ranked in the following
-order (with 1 the highest ranked):
-
-1. Values specified as keyword arguments.
-2. Values specified as environment variables.
-3. Values specified in the configuration file.
-4. Values specified in :class:`~dwave.cloud.client.Client` instance ``defaults``.
-5. Values specified in :class:`~dwave.cloud.client.Client` class
-   :attr:`~dwave.cloud.client.Client.DEFAULTS`.
-
-Configuration files comply with standard Windows INI-like format,
-parsable with Python's :mod:`configparser`. An optional `defaults` section
-provides default key-value pairs for all other sections. User-defined key-value
-pairs (unrecognized keys) are passed through to the client.
-
-Typically configuration files are created, inspected, and changed using interactive
-CLI commands from your system's console, such as :code:`dwave config create` and
-:code:`dwave config inspect` (run :code:`dwave --help` for information on CLI options).
-
-Environment variables:
-
-*   ``DWAVE_API_CLIENT``: API client class. Supported values are ``qpu``, ``sw`` and ``hybrid``.
-*   ``DWAVE_API_ENDPOINT``: API endpoint URL.
-*   ``DWAVE_API_HEADERS``: Optional additional HTTP headers.
-*   ``DWAVE_API_PROXY``: URL for proxy connections to D-Wave API.
-*   ``DWAVE_API_REGION``: API region code.
-*   ``DWAVE_API_SOLVER``: Default solver.
-*   ``DWAVE_API_TOKEN``: API authorization token.
-*   ``DWAVE_CONFIG_FILE``: Configuration file path. 
-*   ``DWAVE_METADATA_API_ENDPOINT``: Metadata API endpoint URL.
-*   ``DWAVE_PROFILE``: Name of profile (section).
-
-Examples:
-    The following are typical and advanced examples of using 
-    :func:`~dwave.cloud.client.Client.from_config` to create a configured client.
-
-    *   **Example** Typical use for QPU and hybrid solvers
-
-    This example uses a configuration file in one of the standard paths, 
-    :code:`~/.config/dwave/dwave.conf`, selected through auto-detection
-    on the local system following the user/system configuration paths of 
-    :func:`get_configfile_paths`, to provide the following default 
-    configuration::
-
-        [defaults]
-        token = ABC-123456789123456789123456789
-
-        [default-solver]
-        solver = {"qpu": true, "num_qubits__gt": 5000}
-
-        [bqm]
-        client = hybrid
-        solver = {"supported_problem_types__contains": "bqm"}
-
-        [cqm]
-        client = hybrid
-        solver = {"supported_problem_types__contains": "cqm"}
-
-        [europe]
-        region = eu-central-1
-    
-    This configuration file sets a default API token used by all following 
-    profiles unless overridden, ensures that selecting a solver with the 
-    :func:`~dwave.cloud.client.Client.get_solver` method by default returns a 
-    QPU solver with at least 5000 qubits, and configures profiles for selecting 
-    a quantum-classical hybrid solver and solvers from Leap's European region.
-
-    The code below instantiates clients for a QPU solver and a hybrid CQM solver.
-
-    >>> with Client.from_config() as client:   # doctest: +SKIP
-    ...     solver_qpu = client.get_solver()
-    >>> with Client.from_config(profile="cqm") as client:   # doctest: +SKIP
-    ...     solver_cqm = client.get_solver()
-
-    *   **Example:** Explicitly specified configuration file, unrecognized parameter
-    
-    This example explicitly specifies the following configuration file, 
-    :code:`~/jane/my_path_to_config/my_cloud_conf.conf`::
-
-        [defaults]
-        token = ABC-123456789123456789123456789
-
-        [first-qpu]
-        solver = {"qpu": true}
-
-    The code below creates a client that it later explicitly closes. It also 
-    passes through to the instantiated client an unrecognized key-value pair 
-    ``my_param="my_value"``.
-
-    >>> from dwave.cloud import Client
-    >>> client = Client.from_config(config_file='~/jane/my_path_to_config/my_cloud_conf.conf',
-    ...                             my_param="my_value")  # doctest: +SKIP
-    >>> # code that uses client
-    >>> client.close()   # doctest: +SKIP
-
-    *   **Advanced Example:** Multiple auto-detected configuration files
-    
-    This example uses two configuration files: (1) a user-local file, 
-    ``/usr/local/share/dwave/dwave.conf``::
-
-        [defaults]
-        token = ABC-123456789123456789123456789
-        solver = {"qpu": true}
-
-        [advantage]
-        region = eu-central-1
-
-    and (2), a ``./dwave.conf`` file in the current working directory::
-
-        [advantage]
-        token = DEF-987654321987654321987654321
-
-    The code below supplements the API token from higher priority file (the 
-    ``./dwave.conf`` file in the current working directory), overriding the value
-    from the ``[defaults]`` and first (``[advantage]``) sections of the 
-    lower-priority user-local file, ``/usr/local/share/dwave/dwave.conf``. Use
-    of the :func:`~dwave.cloud.client.Client.get_solver` method would select 
-    an Advantage from Leap's European region using the ``DEF-987 ...`` token.
-
-    >>> from dwave.cloud import Client
-    >>> client = Client.from_config()  # doctest: +SKIP
-    >>> print(client.endpoint)      # doctest: +SKIP
-    https://eu-central-1.cloud.dwavesys.com/sapi/v2/
-    >>> print(client.token)  # doctest: +SKIP
-    DEF-987654321987654321987654321
-    >>> # code that uses client
-    >>> client.close() # doctest: +SKIP
-
-"""
-
 import os
 import ast
 import logging
 import configparser
+from typing import Optional, Union
 
 import homebase
 
 from dwave.cloud.exceptions import ConfigFileReadError, ConfigFileParseError
 from dwave.cloud.package_info import __version__, __packagename__
 
-__all__ = ['get_configfile_paths', 'get_configfile_path', 'get_default_configfile_path',
-           'load_config_from_files', 'load_profile_from_files', 'load_config']
+__all__ = ['get_configfile_paths', 'get_configfile_path',
+           'get_default_configfile_path', 'get_default_config',
+           'load_config_from_files', 'load_profile_from_files',
+           'load_config', 'update_config',
+           # XXX: for backward compat only / temporary
+           'parse_float', 'parse_int', 'parse_boolean', 'get_cache_dir']
 
 logger = logging.getLogger(__name__)
 
@@ -594,7 +454,9 @@ def get_default_config():
     return config
 
 
-def load_config(config_file=None, profile=None, **kwargs):
+def load_config(config_file: Optional[Union[str, bool]] = None,
+                profile: Optional[str] = None,
+                **kwargs) -> dict:
     """Load D-Wave Cloud Client configuration based on a configuration file.
 
     Configuration values can be specified in multiple ways, ranked in the following
diff --git a/dwave/cloud/config/models.py b/dwave/cloud/config/models.py
new file mode 100644
index 00000000..dc7f6dd9
--- /dev/null
+++ b/dwave/cloud/config/models.py
@@ -0,0 +1,303 @@
+# Copyright 2023 D-Wave Systems Inc.
+#
+# 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 copy
+import json
+import logging
+from collections import abc
+from typing import Optional, Union, Literal, Tuple, Dict, Any
+
+import urllib3
+from pydantic import BaseModel
+
+from dwave.cloud.config.loaders import update_config
+from dwave.cloud.api.constants import (
+    DEFAULT_REGION, DEFAULT_METADATA_API_ENDPOINT)
+
+__all__ = ['RequestRetryConfig', 'PollingSchedule', 'ClientConfig',
+           'validate_config_v1', 'dump_config_v1', 'load_config_v1']
+
+logger = logging.getLogger(__name__)
+
+
+class GetterMixin:
+    """Expose class attributes via item getter protocol."""
+
+    def __getitem__(self, item):
+        return getattr(self, item)
+
+
+class RequestRetryConfig(BaseModel, GetterMixin):
+    """Request retry config conformant with :class:`urllib3.Retry` interface."""
+
+    #: Total number of retries to allow. Takes precedence over other counts.
+    total: Optional[Union[int, Literal[False]]] = 10
+
+    #: How many connection-related errors to retry on.
+    connect: Optional[int] = None
+
+    #: How many times to retry on read errors.
+    read: Optional[int] = None
+
+    #: How many redirects to perform. Limit this to avoid infinite redirect loops.
+    redirect: Optional[Union[int, Literal[False]]] = 10
+
+    #: How many times to retry on bad status codes.
+    status: Optional[int] = None
+
+    #: How many times to retry on other errors.
+    other: Optional[int] = None
+
+    #: A backoff factor to apply between attempts after the second try.
+    backoff_factor: Optional[float] = 0.01
+
+    #: No backoff will ever be longer than `backoff_max` seconds.
+    backoff_max: Optional[float] = 60.0
+
+    def to_urllib3_retry(self) -> urllib3.Retry:
+        """Return :class:`urllib3.Retry` configuration matching the model.
+        """
+        # dev note: after we drop support for urllib3<2, this whole function can
+        # be replaced with `Retry(**self.model_dump())`
+
+        params = self.model_dump()
+
+        # `urllib3<2` doesn't support `backoff_max` in `Retry()`
+        del params['backoff_max']
+
+        retry = urllib3.Retry(**params)
+
+        if self.backoff_max is not None:
+            # handle `urllib3>=1.21.1,<1.27` AND `urllib3>=1.21.1,<3`
+            retry.BACKOFF_MAX = retry.backoff_max = self.backoff_max
+
+        return retry
+
+
+class PollingSchedule(BaseModel, GetterMixin):
+    """Problem status polling exponential back-off schedule params."""
+
+    #: Duration of the first interval (between first and second poll), in seconds.
+    backoff_min: Optional[float] = 0.05
+
+    #: Maximum back-off period, in seconds.
+    backoff_max: Optional[float] = 60.0
+
+    #: Exponential function base. For poll `i`, back-off period (in seconds) is
+    #: defined as `backoff_min * (backoff_base ** i)`.
+    backoff_base: Optional[float] = 1.3
+
+
+class ClientConfig(BaseModel, GetterMixin):
+    # api region
+    metadata_api_endpoint: str = DEFAULT_METADATA_API_ENDPOINT
+    region: Optional[str] = DEFAULT_REGION
+
+    # resolved api endpoint
+    endpoint: Optional[str] = None
+    token: Optional[str] = None
+
+    # [sapi client specific] feature-based solver selection query
+    client: Optional[str] = 'base'
+    solver: Optional[Dict[str, Any]] = None
+
+    # [sapi client specific] poll back-off schedule defaults [sec]
+    polling_schedule: Optional[PollingSchedule] = PollingSchedule()
+    polling_timeout: Optional[float] = None
+
+    # general http(s) connection params
+    cert: Optional[Union[str, Tuple[str, str]]] = None
+    headers: Optional[Dict[str, str]] = None
+    proxy: Optional[str] = None
+
+    # specific connection options
+    permissive_ssl: bool = False
+    connection_close: bool = False
+
+    # api request retry params
+    request_retry: RequestRetryConfig = RequestRetryConfig()
+    request_timeout: Optional[Union[float, Tuple[float, float]]] = (60.0, 120.0)
+
+
+def validate_config_v1(raw_config: dict) -> ClientConfig:
+    """Validate raw config data (as obtained from the current INI-style config
+    via :func:`~dwave.cloud.config.loaders.load_config`) and construct a
+    :class:`ClientConfig` object.
+
+    Note: v1 config data is a flat dictionary of config options we transform
+    and structure to fit the :class:`ClientConfig` model.
+    """
+
+    config = raw_config.copy()
+
+    # parse string headers
+    headers = config.get('headers')
+    if not headers:
+        headers_dict = {}
+    elif isinstance(headers, abc.Mapping):
+        headers_dict = headers
+    elif isinstance(headers, str):
+        try:
+            # valid  headers = "Field-1: value-1\nField-2: value-2"
+            headers_dict = {key.strip(): val.strip()
+                            for key, val in [line.split(':') for line in
+                                             headers.strip().split('\n')]}
+        except Exception as e:
+            logger.debug("Invalid headers: %r", headers)
+            headers_dict = {}
+    else:
+        raise ValueError("HTTP headers expected in a dict, or a string")
+    logger.trace("parsed headers: %r", headers_dict)
+    config['headers'] = headers_dict
+
+    # parse optional client certificate
+    # (if string, path to ssl client cert file (.pem). If tuple, (‘cert’, ‘key’) pair.)
+    client_cert = config.get('client_cert')
+    client_cert_key = config.get('client_cert_key')
+    if client_cert_key is not None:
+        if client_cert is not None:
+            client_cert = (client_cert, client_cert_key)
+        else:
+            raise ValueError(
+                "Client certificate key given, but the cert is missing")
+    logger.trace("parsed client cert: %r", client_cert)
+    config['cert'] = client_cert
+
+    # parse solver
+    solver = config.get('solver')
+    if isinstance(solver, str):
+        solver = solver.strip()
+    if not solver:
+        solver_def = {}
+    elif isinstance(solver, abc.Mapping):
+        solver_def = solver
+    elif isinstance(solver, str):
+        # support features dict encoded as JSON in our config INI file
+        try:
+            solver_def = json.loads(solver)
+        except Exception:
+            # unparseable (or non-dict) json, assume string name for solver
+            # we'll deprecate this eventually, but for now just convert it to
+            # features dict (equality constraint on full solver name)
+            logger.debug("Invalid solver JSON, assuming string name: %r", solver)
+            solver_def = dict(name__eq=solver)
+        else:
+            # if valid json, has to be a dict
+            if not isinstance(solver_def, abc.Mapping):
+                logger.debug("Non-dict solver JSON, assuming string name: %r", solver)
+                solver_def = dict(name__eq=solver)
+    else:
+        raise ValueError("Expecting a features dictionary or a string name for 'solver'")
+    logger.trace("parsed solver definition: %r", solver_def)
+    config['solver'] = solver_def
+
+    # polling
+    prefix = 'poll_'
+    config['polling_schedule'] = {k[len(prefix):]: v for k, v in config.items()
+                                  if k.startswith(prefix)}
+
+    # retry
+    prefix = 'http_retry_'
+    config['request_retry'] = {k[len(prefix):]: v for k, v in config.items()
+                               if k.startswith(prefix)}
+
+    return ClientConfig.model_validate(config)
+
+
+def dump_config_v1(config: ClientConfig) -> dict:
+    """Dump :class:`ClientConfig` to a flat dict of v1 config options.
+
+    Note: consider this to be an approximate inverse of :func:`validate_config_v1`::
+
+        dump_config_v1(validate_config_v1(raw_config)) ~= raw_config
+
+    The inverse might not be exact due to field type casting during validation.
+    E.g. original boolean value of 'off' or '0' will be dumped as 'false'.
+    """
+
+    raw_config = config.model_dump()
+
+    # invert transformations during validation
+    if h := raw_config['headers']:
+        raw_config['headers'] = '\n'.join(f"{k}: {v}" for k, v in h.items())
+
+    cert_pair = (None, None)
+    if c := raw_config.get('cert'):
+        if isinstance(c, tuple):
+            cert_pair = c
+        else:   # assume string
+            cert_pair = (c, None)
+    raw_config.update(zip(("client_cert", "client_cert_key"), cert_pair))
+    del raw_config['cert']
+
+    raw_config['solver'] = json.dumps(raw_config['solver'])
+
+    # TODO: undo this downcast once we support tuple input for `request_timeout` in the v1
+    # config format (issue https://github.com/dwavesystems/dwave-cloud-client/issues/440).
+    if isinstance(rt := raw_config['request_timeout'], tuple):
+        raw_config['request_timeout'] = rt[0]
+
+    # expand/translate polling
+    raw_config.update({f"poll_{k}": v for k, v in raw_config['polling_schedule'].items()})
+    del raw_config['polling_schedule']
+
+    # expand/translate retry config
+    raw_config.update({f"http_retry_{k}": v for k, v in raw_config['request_retry'].items()})
+    del raw_config['request_retry']
+
+    return raw_config
+
+
+# XXX: replace with model defaults?
+_V1_CONFIG_DEFAULTS = {
+    'client': 'base',
+    'metadata_api_endpoint': DEFAULT_METADATA_API_ENDPOINT,
+    'region': DEFAULT_REGION,
+    'endpoint': None,
+    'token': None,
+    'solver': None,
+    'proxy': None,
+    'permissive_ssl': False,
+    'request_timeout': 60,
+    'polling_timeout': None,
+    'connection_close': False,
+    'headers': None,
+    'client_cert': None,
+    'client_cert_key': None,
+    # poll back-off schedule defaults
+    'poll_backoff_min': 0.05,
+    'poll_backoff_max': 60,
+    'poll_backoff_base': 1.3,
+    # idempotent http requests retry params
+    'http_retry_total': 10,
+    'http_retry_connect': None,
+    'http_retry_read': None,
+    'http_retry_redirect': None,
+    'http_retry_status': None,
+    'http_retry_backoff_factor': 0.01,
+    'http_retry_backoff_max': 60,
+}
+
+def load_config_v1(raw_config: dict, defaults: Optional[dict] = None) -> ClientConfig:
+    if defaults is None:
+        defaults = {}
+
+    config = copy.deepcopy(_V1_CONFIG_DEFAULTS)
+    update_config(config, defaults)
+    logger.debug("defaults (global + local) = %r", config)
+
+    update_config(config, raw_config)
+    logger.debug("raw config with defaults = %r", config)
+
+    return validate_config_v1(config)
diff --git a/dwave/cloud/utils.py b/dwave/cloud/utils.py
index 1a9330f8..820a5fa4 100644
--- a/dwave/cloud/utils.py
+++ b/dwave/cloud/utils.py
@@ -284,7 +284,7 @@ def default(self, obj):
         return super().default(obj)
 
 
-class TimeoutingHTTPAdapter(requests.adapters.HTTPAdapter):
+class PretimedHTTPAdapter(requests.adapters.HTTPAdapter):
     """Sets a default timeout for all adapter (think session) requests. It is
     overridden with per-request timeout. But it can not be reset back to
     infinite wait (``None``).
@@ -292,8 +292,8 @@ class TimeoutingHTTPAdapter(requests.adapters.HTTPAdapter):
     Usage:
 
         s = requests.Session()
-        s.mount("http://", TimeoutingHTTPAdapter(timeout=5))
-        s.mount("https://", TimeoutingHTTPAdapter(timeout=5))
+        s.mount("http://", PretimedHTTPAdapter(timeout=5))
+        s.mount("https://", PretimedHTTPAdapter(timeout=5))
 
         s.get('http://httpbin.org/delay/6')                 # -> timeouts after 5sec
         s.get('http://httpbin.org/delay/6', timeout=10)     # -> completes after 6sec
@@ -312,6 +312,12 @@ def send(self, *args, **kwargs):
         return super().send(*args, **kwargs)
 
 
+class TimeoutingHTTPAdapter(PretimedHTTPAdapter):
+    """Alias for :class:`~dwave.cloud.utils.PretimedHTTPAdapter`. Deprecated,
+    but retained for backward compatibility.
+    """
+
+
 # Note: BaseUrlSession is taken from https://github.com/requests/toolbelt under
 # an Apache 2 license. This simple extension didn't warrant a new dependency.
 # If we later decide to use additional features from `requests-toolbelt`,
@@ -608,8 +614,9 @@ class deprecated(object):
     decorated function.
     """
 
-    def __init__(self, msg=None):
+    def __init__(self, msg=None, stacklevel=2):
         self.msg = msg
+        self.stacklevel = stacklevel
 
     def __call__(self, fn):
         if not callable(fn):
@@ -621,7 +628,7 @@ def wrapped(*args, **kwargs):
             if not msg:
                 fn_name = getattr(fn, '__name__', 'unnamed')
                 msg = "{}() has been deprecated".format(fn_name)
-            warnings.warn(msg, DeprecationWarning)
+            warnings.warn(msg, DeprecationWarning, stacklevel=self.stacklevel)
 
             return fn(*args, **kwargs)
 
diff --git a/releasenotes/notes/refactor-config-696d28038c6b255f.yaml b/releasenotes/notes/refactor-config-696d28038c6b255f.yaml
new file mode 100644
index 00000000..08b87445
--- /dev/null
+++ b/releasenotes/notes/refactor-config-696d28038c6b255f.yaml
@@ -0,0 +1,41 @@
+---
+features:
+  - |
+    Add Pydantic-based data model for cloud-client configuration,
+    ``dwave.cloud.config.models.ClientConfig``.
+  - |
+    Consolidate client runtime configuration options under ``Client.config``,
+    a data model defined with ``dwave.cloud.config.models.ClientConfig`` and
+    decouple config parsing from ``dwave.cloud.Client`` construction.
+    See `#504 <https://github.com/dwavesystems/dwave-cloud-client/issues/504>`_.
+  - |
+    Add ``from_config()`` family of factories to ``dwave.cloud.api.client.DWaveAPIClient``
+    and subclasses for SolverAPI, MetadataAPI and LeapAPI. ``from_config()`` dispatches
+    to ``from_client_config()`` or ``from_config_file()``, depending on arguments.
+    See `#572 <https://github.com/dwavesystems/dwave-cloud-client/issues/572>`_.
+  - |
+    Use full client config for Metadata API access during region/endpoint resolution
+    on ``dwave.cloud.Client`` construction.
+    See `#505 <https://github.com/dwavesystems/dwave-cloud-client/issues/505>`_.
+fixes:
+  - |
+    Fix region/endpoint resolution during ``dwave.cloud.Client`` construction
+    when insecure connection is required (config: ``permissive_ssl = On``) and/or
+    a proxy is used (config: ``proxy = socks5://...``). Previously client
+    construction would fail completely.
+    See `#507 <https://github.com/dwavesystems/dwave-cloud-client/issues/507>`_.
+deprecations:
+  - |
+    Individual config options exposed as ``dwave.cloud.Client`` attributes are
+    deprecated since ``dwave-cloud-client==0.11.0`` and will be removed in
+    ``dwave-cloud-client==0.12.0``. Use ``Client.config`` data model instead.
+upgrade:
+  - |
+    Behavior of ``from_client_config()`` on all ``dwave.cloud.api.resources.ResourceBase``
+    resource classes changed. Previously a (cloud-)client instance was accepted and now
+    ``dwave.cloud.config.models.ClientConfig`` is required.
+
+    To upgrade, replace ``{ResourceBase}.from_client_config(cloud_client)`` with
+    ``{ResourceBase}.from_client_config(client.config)``,
+    and ``{ResourceBase}.from_client_config(dwave_api_client)`` with
+    ``{ResourceBase}(dwave_api_client)``.


Test patch:
diff --git a/tests/api/test_api_clients.py b/tests/api/test_api_clients.py
index 8362a056..8b086fd7 100644
--- a/tests/api/test_api_clients.py
+++ b/tests/api/test_api_clients.py
@@ -19,7 +19,9 @@
 import requests_mock
 
 from dwave.cloud.api import exceptions, constants
-from dwave.cloud.api.client import DWaveAPIClient, SolverAPIClient, MetadataAPIClient
+from dwave.cloud.api.client import (
+    DWaveAPIClient, SolverAPIClient, MetadataAPIClient, LeapAPIClient)
+from dwave.cloud.config import ClientConfig
 from dwave.cloud.package_info import __packagename__, __version__
 
 
@@ -81,6 +83,43 @@ def test_metadata_client(self):
             self.assertEqual(client.session.base_url,
                              constants.DEFAULT_METADATA_API_ENDPOINT)
 
+    def test_leap_client(self):
+        with LeapAPIClient() as client:
+            self.assertEqual(client.session.base_url,
+                             constants.DEFAULT_LEAP_API_ENDPOINT)
+
+    def test_from_client_config_factory(self):
+        config = ClientConfig(region='ignored', connection_close=True,
+                              permissive_ssl=True)
+        kwargs = dict(endpoint='https://test.com/path/')
+
+        def _verify(client):
+            # note: region is removed due to endpoint kwarg update
+            self.assertIsNone(client.config.get('region'))
+            self.assertEqual(client.session.base_url, kwargs['endpoint'])
+            self.assertIn('Connection', client.session.headers)
+            self.assertFalse(client.session.verify)
+
+        with DWaveAPIClient.from_client_config(config, **kwargs) as client:
+            _verify(client)
+
+        # also test .from_config dispatch
+        with DWaveAPIClient.from_config(config, **kwargs) as client:
+            _verify(client)
+
+    def test_from_config_file(self):
+        # `load_config` is already tested thoroughly in `tests.test_config`,
+        # so it's ok to just mock it here
+        config = dict(endpoint='https://test.com/path/')
+        with unittest.mock.patch("dwave.cloud.api.client.load_config",
+                                 lambda *a, **kw: config):
+            with DWaveAPIClient.from_config_file() as client:
+                self.assertEqual(client.session.base_url, config['endpoint'])
+
+            # also test .from_config dispatch
+            with DWaveAPIClient.from_config() as client:
+                self.assertEqual(client.session.base_url, config['endpoint'])
+
 
 class TestRequests(unittest.TestCase):
 
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 9893fee0..b1207e60 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -66,7 +66,7 @@ def test_create(self, name, extra_opts, inputs):
     def test_default_flows(self, name, extra_opts, inputs):
         runner = CliRunner(mix_stderr=False)
         with runner.isolated_filesystem():
-            with mock.patch("dwave.cloud.config.get_configfile_paths", lambda: ['dwave.conf']):
+            with mock.patch("dwave.cloud.config.loaders.get_configfile_paths", lambda: ['dwave.conf']):
                 with mock.patch("dwave.cloud.utils.input", side_effect=inputs):
                     result = runner.invoke(cli, [
                         'config', 'create'
@@ -121,9 +121,9 @@ def test_config_ls(self):
         runner = CliRunner(mix_stderr=False)
         with runner.isolated_filesystem():
             touch('dwave.conf')
-            with mock.patch('dwave.cloud.config.homebase.site_config_dir_list',
+            with mock.patch('dwave.cloud.config.loaders.homebase.site_config_dir_list',
                             lambda **kw: ['system1', 'system2']):
-                with mock.patch('dwave.cloud.config.homebase.user_config_dir',
+                with mock.patch('dwave.cloud.config.loaders.homebase.user_config_dir',
                                 lambda **kw: 'user'):
                     with mock.patch('os.path.exists', lambda *x: True):
                         # test listing of all auto-detected config files
@@ -216,7 +216,7 @@ def test_configure_inspect(self):
                     token = 3''')
 
             # test auto-detected case
-            with mock.patch('dwave.cloud.config.get_configfile_paths',
+            with mock.patch('dwave.cloud.config.loaders.get_configfile_paths',
                             lambda **kw: [config_file]):
                 result = runner.invoke(cli, [
                     'config', 'inspect'
diff --git a/tests/test_client.py b/tests/test_client.py
index e59df11d..4bc50bf8 100644
--- a/tests/test_client.py
+++ b/tests/test_client.py
@@ -20,7 +20,6 @@
 
 import json
 import time
-import warnings
 import unittest
 from unittest import mock
 from contextlib import contextmanager
@@ -29,8 +28,9 @@
 import requests
 from plucky import merge
 
-from dwave.cloud.api import constants
+from dwave.cloud.api import constants, Regions
 from dwave.cloud.client import Client
+from dwave.cloud.config.models import dump_config_v1
 from dwave.cloud.solver import StructuredSolver, UnstructuredSolver
 from dwave.cloud.exceptions import (
     SolverAuthenticationError, SolverError, SolverNotFoundError)
@@ -196,8 +196,8 @@ def test_default(self):
         conf = {k: k for k in 'endpoint token'.split()}
         with mock.patch("dwave.cloud.client.base.load_config", lambda **kw: conf):
             with dwave.cloud.Client.from_config() as client:
-                self.assertEqual(client.endpoint, 'endpoint')
-                self.assertEqual(client.token, 'token')
+                self.assertEqual(client.config.endpoint, 'endpoint')
+                self.assertEqual(client.config.token, 'token')
                 self.assertIsInstance(client, dwave.cloud.client.Client)
                 self.assertNotIsInstance(client, dwave.cloud.sw.Client)
                 self.assertNotIsInstance(client, dwave.cloud.qpu.Client)
@@ -207,9 +207,9 @@ def test_region_default(self):
         conf = {k: k for k in 'token'.split()}
         with mock.patch("dwave.cloud.client.base.load_config", lambda **kw: conf):
             with dwave.cloud.Client.from_config() as client:
-                self.assertEqual(client.region, constants.DEFAULT_REGION)
-                self.assertEqual(client.endpoint, constants.DEFAULT_SOLVER_API_ENDPOINT)
-                self.assertEqual(client.token, 'token')
+                self.assertEqual(client.config.region, constants.DEFAULT_REGION)
+                self.assertEqual(client.config.endpoint, constants.DEFAULT_SOLVER_API_ENDPOINT)
+                self.assertEqual(client.config.token, 'token')
                 self.assertIsInstance(client, dwave.cloud.client.Client)
                 self.assertNotIsInstance(client, dwave.cloud.sw.Client)
                 self.assertNotIsInstance(client, dwave.cloud.qpu.Client)
@@ -221,11 +221,11 @@ def test_region_selection_over_defaults(self):
         region_endpoint = 'region-endpoint'
         region_conf = {region_code: {"endpoint": region_endpoint}}
 
-        with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: conf):
+        with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
             with mock.patch("dwave.cloud.Client.get_regions", lambda s: region_conf):
                 with dwave.cloud.Client.from_config(region=region_code) as client:
-                    self.assertEqual(client.region, region_code)
-                    self.assertEqual(client.endpoint, region_endpoint)
+                    self.assertEqual(client.config.region, region_code)
+                    self.assertEqual(client.config.endpoint, region_endpoint)
 
     def test_region_kwarg_overrides_endpoint_from_config(self):
         conf = {k: k for k in 'endpoint token'.split()}
@@ -233,11 +233,11 @@ def test_region_kwarg_overrides_endpoint_from_config(self):
         region_endpoint = 'region-endpoint'
         region_conf = {region_code: {"endpoint": region_endpoint}}
 
-        with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: conf):
+        with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
             with mock.patch("dwave.cloud.Client.get_regions", lambda s: region_conf):
                 with dwave.cloud.Client.from_config(region=region_code) as client:
-                    self.assertEqual(client.region, region_code)
-                    self.assertEqual(client.endpoint, region_endpoint)
+                    self.assertEqual(client.config.region, region_code)
+                    self.assertEqual(client.config.endpoint, region_endpoint)
 
     def test_region_endpoint_pair_kwarg_overrides_region_endpoint_pair_from_config(self):
         conf = {k: k for k in 'region endpoint token'.split()}
@@ -245,11 +245,11 @@ def test_region_endpoint_pair_kwarg_overrides_region_endpoint_pair_from_config(s
         region_endpoint = 'region-endpoint'
         region_conf = {region_code: {"endpoint": region_endpoint}}
 
-        with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: conf):
+        with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
             with mock.patch("dwave.cloud.Client.get_regions", lambda s: region_conf):
                 with dwave.cloud.Client.from_config(region=region_code, endpoint=region_endpoint) as client:
-                    self.assertEqual(client.region, region_code)
-                    self.assertEqual(client.endpoint, region_endpoint)
+                    self.assertEqual(client.config.region, region_code)
+                    self.assertEqual(client.config.endpoint, region_endpoint)
 
     def test_region_from_env_overrides_endpoint_from_config(self):
         conf = {k: k for k in 'endpoint token'.split()}
@@ -258,24 +258,24 @@ def test_region_from_env_overrides_endpoint_from_config(self):
         region_conf = {region_code: {"endpoint": region_endpoint}}
 
         with isolated_environ(add={'DWAVE_API_REGION': region_code}):
-            with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: conf):
+            with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
                 with mock.patch("dwave.cloud.Client.get_regions", lambda s: region_conf):
                     with dwave.cloud.Client.from_config() as client:
-                        self.assertEqual(client.region, region_code)
-                        self.assertEqual(client.endpoint, region_endpoint)
+                        self.assertEqual(client.config.region, region_code)
+                        self.assertEqual(client.config.endpoint, region_endpoint)
 
     def test_metadata_api_endpoint_from_env_accepted(self):
         metadata_api_endpoint = 'metadata-endpoint'
         with isolated_environ(add={'DWAVE_METADATA_API_ENDPOINT': metadata_api_endpoint}):
             conf = {k: k for k in 'endpoint metadata_api_endpoint token'.split()}
-            with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: conf):
+            with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
                 with dwave.cloud.Client.from_config() as client:
-                    self.assertEqual(client.metadata_api_endpoint, metadata_api_endpoint)
+                    self.assertEqual(client.config.metadata_api_endpoint, metadata_api_endpoint)
 
     def test_client_type(self):
         conf = {k: k for k in 'endpoint token'.split()}
 
-        with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: conf):
+        with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
             with dwave.cloud.Client.from_config() as client:
                 self.assertIsInstance(client, dwave.cloud.client.Client)
 
@@ -305,7 +305,7 @@ def test_client_type(self):
 
     def test_custom_kwargs(self):
         conf = {k: k for k in 'endpoint token'.split()}
-        with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: conf):
+        with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
             with mock.patch("dwave.cloud.client.Client.__init__", return_value=None) as init:
                 dwave.cloud.Client.from_config(custom='custom')
                 init.assert_called_once_with(
@@ -313,7 +313,7 @@ def test_custom_kwargs(self):
 
     def test_custom_kwargs_overrides_config(self):
         conf = {k: k for k in 'endpoint token custom'.split()}
-        with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: conf):
+        with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
             with mock.patch("dwave.cloud.client.Client.__init__", return_value=None) as init:
                 dwave.cloud.Client.from_config(custom='new-custom')
                 init.assert_called_once_with(
@@ -324,25 +324,25 @@ def test_solver_features_from_config(self):
         conf = {k: k for k in 'endpoint token'.split()}
         conf.update(solver=json.dumps(solver_def))
 
-        with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: conf):
+        with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
             with dwave.cloud.Client.from_config() as client:
-                self.assertEqual(client.default_solver, solver_def)
+                self.assertEqual(client.config.solver, solver_def)
 
     def test_solver_name_from_config(self):
         solver_def = {"name__eq": "solver"}
         conf = {k: k for k in 'endpoint token solver'.split()}
 
-        with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: conf):
+        with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
             with dwave.cloud.Client.from_config() as client:
-                self.assertEqual(client.default_solver, solver_def)
+                self.assertEqual(client.config.solver, solver_def)
 
     def test_solver_features_kwargs_override_config(self):
         new_solver_def = {"software": True}
         conf = {k: k for k in 'endpoint token solver'.split()}
 
-        with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: conf):
+        with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
             with dwave.cloud.Client.from_config(solver=new_solver_def) as client:
-                self.assertEqual(client.default_solver, new_solver_def)
+                self.assertEqual(client.config.solver, new_solver_def)
 
     def test_none_kwargs_do_not_override_config(self):
         """kwargs with value ``None`` should be ignored (issue #430)"""
@@ -351,26 +351,26 @@ def test_none_kwargs_do_not_override_config(self):
         conf.update(solver=solver_json)
         solver = json.loads(solver_json)
 
-        with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: conf):
+        with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
             with dwave.cloud.Client.from_config(endpoint=None, solver=None) as client:
-                self.assertEqual(client.endpoint, conf['endpoint'])
-                self.assertEqual(client.default_solver, solver)
+                self.assertEqual(client.config.endpoint, conf['endpoint'])
+                self.assertEqual(client.config.solver, solver)
 
     def test_solver_name_overrides_config_features(self):
         conf = {k: k for k in 'endpoint token solver'.split()}
         conf.update(solver=json.dumps({"software": True}))
 
-        with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: conf):
+        with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
             with dwave.cloud.Client.from_config(solver='solver') as client:
-                self.assertEqual(client.default_solver, {"name__eq": "solver"})
+                self.assertEqual(client.config.solver, {"name__eq": "solver"})
 
     def test_boolean_options_parsed_from_config(self):
         conf = {'connection_close': 'off', 'permissive_ssl': 'true'}
 
-        with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: conf):
+        with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
             with dwave.cloud.Client.from_config(token='token') as client:
-                self.assertFalse(client.connection_close)
-                self.assertTrue(client.permissive_ssl)
+                self.assertFalse(client.config.connection_close)
+                self.assertTrue(client.config.permissive_ssl)
 
     def test_class_defaults(self):
         token = 'value'
@@ -380,31 +380,31 @@ def test_class_defaults(self):
         with mock.patch("dwave.cloud.client.base.load_config", lambda **kw: {}):
             with mock.patch.multiple("dwave.cloud.Client", DEFAULTS=DEFAULTS):
                 with dwave.cloud.Client.from_config() as client:
-                    self.assertEqual(client.token, token)
+                    self.assertEqual(client.config.token, token)
 
                 # None defaults are ignored
                 with dwave.cloud.Client(defaults=None) as client:
-                    self.assertEqual(client.token, token)
+                    self.assertEqual(client.config.token, token)
 
                 # explicit None kwargs do not modify defaults
                 with dwave.cloud.Client(
                         endpoint=None, token=None, solver=None,
                         connection_close=None, poll_backoff_min=None) as client:
 
-                    self.assertEqual(client.endpoint, client.DEFAULT_API_ENDPOINT)
-                    self.assertEqual(client.token, token)
-                    self.assertEqual(client.default_solver, {})
+                    self.assertEqual(client.config.endpoint, client.DEFAULT_API_ENDPOINT)
+                    self.assertEqual(client.config.token, token)
+                    self.assertEqual(client.config.solver, {})
 
-                    self.assertEqual(client.connection_close, DEFAULTS['connection_close'])
-                    self.assertEqual(client.poll_backoff_min, DEFAULTS['poll_backoff_min'])
+                    self.assertEqual(client.config.connection_close, DEFAULTS['connection_close'])
+                    self.assertEqual(client.config.polling_schedule.backoff_min, DEFAULTS['poll_backoff_min'])
 
     def test_defaults_as_kwarg(self):
         token = 'value'
         defaults = dict(token=token)
 
-        with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: {}):
+        with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: {}):
             with dwave.cloud.Client.from_config(defaults=defaults) as client:
-                self.assertEqual(client.token, token)
+                self.assertEqual(client.config.token, token)
 
     def test_defaults_partial_update(self):
         """Some options come from DEFAULTS, some from defaults, some from config, and some from kwargs"""
@@ -422,21 +422,21 @@ def test_defaults_partial_update(self):
 
         kwargs = dict(token=token, defaults=defaults, request_timeout=None)
 
-        with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: conf):
+        with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
             with mock.patch.multiple("dwave.cloud.Client", DEFAULTS=DEFAULTS):
                 with dwave.cloud.Client.from_config(**kwargs) as client:
 
                     # token: set on DEFAULTS, overwritten in kwargs
-                    self.assertEqual(client.token, token)
+                    self.assertEqual(client.config.token, token)
 
                     # solver: set in defaults, overwritten in file conf
-                    self.assertEqual(client.default_solver, solver)
+                    self.assertEqual(client.config.solver, solver)
 
                     # endpoint: derived from region
-                    self.assertEqual(client.endpoint, constants.DEFAULT_SOLVER_API_ENDPOINT)
+                    self.assertEqual(client.config.endpoint, constants.DEFAULT_SOLVER_API_ENDPOINT)
 
                     # None kwarg: used from class defaults
-                    self.assertEqual(client.request_timeout, request_timeout)
+                    self.assertEqual(client.config.request_timeout, request_timeout)
 
     def test_headers_from_config(self):
         headers_dict = {"key-1": "value-1", "key-2": "value-2"}
@@ -447,7 +447,7 @@ def test_headers_from_config(self):
 
         with mock.patch("dwave.cloud.client.base.load_config", lambda **kw: conf):
             with dwave.cloud.Client.from_config() as client:
-                self.assertDictEqual(client.headers, headers_dict)
+                self.assertDictEqual(client.config.headers, headers_dict)
 
     def test_headers_from_kwargs(self):
         headers_dict = {"key-1": "value-1", "key-2": "value-2"}
@@ -455,14 +455,14 @@ def test_headers_from_kwargs(self):
         conf = dict(token='token')
 
         # headers as dict
-        with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: conf):
+        with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
             with dwave.cloud.Client.from_config(headers=headers_dict) as client:
-                self.assertDictEqual(client.headers, headers_dict)
+                self.assertDictEqual(client.config.headers, headers_dict)
 
         # headers as str
-        with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: conf):
+        with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
             with dwave.cloud.Client.from_config(headers=headers_str) as client:
-                self.assertDictEqual(client.headers, headers_dict)
+                self.assertDictEqual(client.config.headers, headers_dict)
 
     def test_client_cert_from_config(self):
         crt = '/path/to/crt'
@@ -472,9 +472,9 @@ def test_client_cert_from_config(self):
         client_cert = crt
         conf = dict(token='token', client_cert=crt)
 
-        with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: conf):
+        with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
             with dwave.cloud.Client.from_config() as client:
-                self.assertEqual(client.client_cert, client_cert)
+                self.assertEqual(client.config.cert, client_cert)
 
                 session = client.create_session()
                 self.assertEqual(session.cert, client_cert)
@@ -483,9 +483,9 @@ def test_client_cert_from_config(self):
         client_cert = (crt, key)
         conf = dict(token='token', client_cert=crt, client_cert_key=key)
 
-        with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: conf):
+        with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
             with dwave.cloud.Client.from_config() as client:
-                self.assertEqual(client.client_cert, client_cert)
+                self.assertEqual(client.config.cert, client_cert)
 
                 session = client.create_session()
                 self.assertEqual(session.cert, client_cert)
@@ -504,7 +504,7 @@ def load_config(**kwargs):
 
         with mock.patch("dwave.cloud.client.base.load_config", load_config):
             with dwave.cloud.Client.from_config(**conf) as client:
-                self.assertEqual(client.client_cert, client_cert)
+                self.assertEqual(client.config.cert, client_cert)
 
         # separate cert and key files
         client_cert = (crt, key)
@@ -512,7 +512,7 @@ def load_config(**kwargs):
 
         with mock.patch("dwave.cloud.client.base.load_config", load_config):
             with dwave.cloud.Client.from_config(**conf) as client:
-                self.assertEqual(client.client_cert, client_cert)
+                self.assertEqual(client.config.cert, client_cert)
 
         # client_cert as tuple (direct `requests` format)
         client_cert = (crt, key)
@@ -520,33 +520,33 @@ def load_config(**kwargs):
 
         with mock.patch("dwave.cloud.client.base.load_config", load_config):
             with dwave.cloud.Client.from_config(**conf) as client:
-                self.assertEqual(client.client_cert, client_cert)
+                self.assertEqual(client.config.cert, client_cert)
 
     def test_polling_params_from_config(self):
         poll_conf = {"poll_backoff_min": "0.1", "poll_backoff_max": "1"}
         conf = dict(token='token', **poll_conf)
 
         # polling params from config file propagated to client object
-        with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: conf):
+        with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
             with dwave.cloud.Client.from_config() as client:
-                self.assertEqual(client.poll_backoff_min, 0.1)
-                self.assertEqual(client.poll_backoff_max, 1.0)
+                self.assertEqual(client.config.polling_schedule.backoff_min, 0.1)
+                self.assertEqual(client.config.polling_schedule.backoff_max, 1.0)
 
         # test defaults
         conf = dict(token='token')
-        with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: conf):
+        with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
             with dwave.cloud.Client.from_config() as client:
-                self.assertEqual(client.poll_backoff_min, Client.DEFAULTS['poll_backoff_min'])
-                self.assertEqual(client.poll_backoff_max, Client.DEFAULTS['poll_backoff_max'])
+                self.assertEqual(client.config.polling_schedule.backoff_min, Client.DEFAULTS['poll_backoff_min'])
+                self.assertEqual(client.config.polling_schedule.backoff_max, Client.DEFAULTS['poll_backoff_max'])
 
     def test_polling_params_from_kwargs(self):
         poll_conf = {"poll_backoff_min": "0.1", "poll_backoff_max": "1"}
         conf = dict(token='token', **poll_conf)
 
-        with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: conf):
+        with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
             with dwave.cloud.Client.from_config(poll_backoff_min=0.5) as client:
-                self.assertEqual(client.poll_backoff_min, 0.5)
-                self.assertEqual(client.poll_backoff_max, 1.0)
+                self.assertEqual(client.config.polling_schedule.backoff_min, 0.5)
+                self.assertEqual(client.config.polling_schedule.backoff_max, 1.0)
 
     def _verify_retry_config(self, retry, opts):
         self.assertEqual(retry.total, opts['http_retry_total'])
@@ -572,22 +572,18 @@ def test_http_retry_params_from_config(self):
         conf = dict(token='token', **retry_conf)
 
         # http retry params from config file propagated to client object
-        with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: conf):
+        with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
             with dwave.cloud.Client.from_config() as client:
-                for opt, val in retry_opts.items():
-                    self.assertEqual(getattr(client, opt), val,
-                                     "%s doesn't match" % opt)
-
-                # verify Retry object config
                 retry = client.session.get_adapter('https://').max_retries
                 self._verify_retry_config(retry, retry_opts)
 
         # test defaults
         conf = dict(token='token')
-        with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: conf):
+        with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
             with dwave.cloud.Client.from_config() as client:
+                v1_config = dump_config_v1(client.config)
                 for param in retry_conf:
-                    self.assertEqual(getattr(client, param), Client.DEFAULTS[param])
+                    self.assertEqual(v1_config.get(param), Client.DEFAULTS[param])
 
     def test_http_retry_params_from_kwargs(self):
         retry_kwargs = {
@@ -601,14 +597,8 @@ def test_http_retry_params_from_kwargs(self):
         }
         conf = dict(token='token')
 
-        with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: conf):
+        with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
             with dwave.cloud.Client.from_config(**retry_kwargs) as client:
-                # verify client final config
-                for opt, val in retry_kwargs.items():
-                    self.assertEqual(getattr(client, opt), val,
-                                     "%s doesn't match" % opt)
-
-                # verify Retry object config
                 retry = client.session.get_adapter('https://').max_retries
                 self._verify_retry_config(retry, retry_kwargs)
 
@@ -618,9 +608,9 @@ def test_positional_args(self):
             # up to 3 positional args are allowed
             endpoint, token, solver = 'endpoint token solver'.split()
             with dwave.cloud.Client(endpoint, token, solver) as client:
-                self.assertEqual(client.endpoint, endpoint)
-                self.assertEqual(client.token, token)
-                self.assertEqual(client.default_solver, {'name__eq': solver})
+                self.assertEqual(client.config.endpoint, endpoint)
+                self.assertEqual(client.config.token, token)
+                self.assertEqual(client.config.solver, {'name__eq': solver})
 
             # but they are deprecated
             with self.assertWarns(DeprecationWarning):
@@ -637,8 +627,8 @@ def test_positional_args(self):
 
             # even though non-conflicting combination is allowed
             with dwave.cloud.Client(endpoint, token=token) as client:
-                self.assertEqual(client.endpoint, endpoint)
-                self.assertEqual(client.token, token)
+                self.assertEqual(client.config.endpoint, endpoint)
+                self.assertEqual(client.config.token, token)
 
 
 @mock.patch.multiple(Client, get_regions=get_default_regions)
@@ -659,12 +649,12 @@ def test_custom_options(self):
             polling_timeout = {}
         """.format(request_timeout, polling_timeout)
 
-        with mock.patch("dwave.cloud.config.open", iterable_mock_open(config_body)):
+        with mock.patch("dwave.cloud.config.loaders.open", iterable_mock_open(config_body)):
             with Client.from_config('config_file', profile='custom') as client:
                 # check permissive_ssl and timeouts custom params passed-thru
                 self.assertFalse(client.session.verify)
-                self.assertEqual(client.request_timeout, request_timeout)
-                self.assertEqual(client.polling_timeout, polling_timeout)
+                self.assertEqual(client.config.request_timeout, request_timeout)
+                self.assertEqual(client.config.polling_timeout, polling_timeout)
 
                 # verify client uses those properly
                 def mock_send(*args, **kwargs):
@@ -693,11 +683,16 @@ def test_region_selection_mocked_end_to_end(self):
         selected_region = regions_response[0]['code']
         selected_endpoint = regions_response[0]['endpoint']
 
+        permissive_ssl = True
+        proxy = 'http://127.0.0.1/'
+
         config_body = f"""
             [defaults]
             metadata_api_endpoint = {metadata_api_endpoint}
-            region = {selected_region}
             token = token
+            region = {selected_region}
+            proxy = {proxy}
+            permissive_ssl = {permissive_ssl}
         """
 
         expected_get_regions_return = {
@@ -719,9 +714,19 @@ def get(url, **kwargs):
 
             return session
 
-        with mock.patch("dwave.cloud.config.open", iterable_mock_open(config_body)):
+        def _mocked_regions_factory(config, **kwargs):
+            # verify full client config is passed to `Regions.from_config` factory
+            self.assertEqual(config.metadata_api_endpoint, metadata_api_endpoint)
+            self.assertEqual(config.permissive_ssl, permissive_ssl)
+            self.assertEqual(config.proxy, proxy)
+
+            regions = Regions()
+            regions._session = _mocked_session()
+            return regions
+
+        with mock.patch("dwave.cloud.config.loaders.open", iterable_mock_open(config_body)):
             with mock.patch.multiple(Client._fetch_available_regions._cached, cache={}):
-                with mock.patch("dwave.cloud.client.base.api.Regions._session", _mocked_session(), create=True):
+                with mock.patch("dwave.cloud.client.base.api.Regions.from_config", _mocked_regions_factory):
                     # note: we specify config_file, otherwise reading from files
                     # might be skipped altogether if zero config files found on disk
                     # (i.e. config open mock above fails on CI)
@@ -731,9 +736,9 @@ def get(url, **kwargs):
                         self.assertEqual(regions, expected_get_regions_return)
 
                         # check region endpoint initialized from custom metadata_api_endpoint
-                        self.assertEqual(client.metadata_api_endpoint, metadata_api_endpoint)
-                        self.assertEqual(client.region, selected_region)
-                        self.assertEqual(client.endpoint, selected_endpoint)
+                        self.assertEqual(client.config.metadata_api_endpoint, metadata_api_endpoint)
+                        self.assertEqual(client.config.region, selected_region)
+                        self.assertEqual(client.config.endpoint, selected_endpoint)
 
     def test_region_endpoint_fallback_when_no_metadata_api(self):
         """Given region in config, and endpoint omitted,
@@ -747,8 +752,8 @@ def test_region_endpoint_fallback_when_no_metadata_api(self):
 
         with mock.patch("dwave.cloud.client.base.load_config", lambda **kw: conf):
             with dwave.cloud.Client.from_config(region=region) as client:
-                self.assertEqual(client.region, Client.DEFAULT_API_REGION)
-                self.assertEqual(client.endpoint, Client.DEFAULT_API_ENDPOINT)
+                self.assertEqual(client.config.region, Client.DEFAULT_API_REGION)
+                self.assertEqual(client.config.endpoint, Client.DEFAULT_API_ENDPOINT)
 
     @mock.patch.multiple(Client, get_regions=get_default_regions)
     def test_region_endpoint_fallback_when_region_unknown(self):
@@ -761,7 +766,7 @@ def test_region_endpoint_fallback_when_region_unknown(self):
         conf = dict(token='token')
         region = 'invalid-region-code'
 
-        with mock.patch("dwave.cloud.config.load_profile_from_files", lambda *pa, **kw: conf):
+        with mock.patch("dwave.cloud.config.loaders.load_profile_from_files", lambda *pa, **kw: conf):
             with self.assertRaises(ValueError):
                 Client.from_config(region=region)
 
@@ -777,15 +782,15 @@ def test_region_endpoint_null_case(self):
 
         with mock.patch("dwave.cloud.client.base.load_config", lambda **kw: conf):
             with dwave.cloud.Client.from_config() as client:
-                self.assertEqual(client.region, Client.DEFAULT_API_REGION)
-                self.assertEqual(client.endpoint, Client.DEFAULT_API_ENDPOINT)
+                self.assertEqual(client.config.region, Client.DEFAULT_API_REGION)
+                self.assertEqual(client.config.endpoint, Client.DEFAULT_API_ENDPOINT)
 
     @unittest.skipUnless(config, "No live server configuration available.")
     @mock.patch.multiple(Client._fetch_available_regions._cached, cache={})
     def test_region_selection_live_end_to_end(self):
         with Client(**config) as client:
-            self.assertEqual(client.region, constants.DEFAULT_REGION)
-            self.assertIn(constants.DEFAULT_REGION, client.endpoint)
+            self.assertEqual(client.config.region, constants.DEFAULT_REGION)
+            self.assertIn(constants.DEFAULT_REGION, client.config.endpoint)
             self.assertGreater(len(client.get_regions()), 0)
 
 
diff --git a/tests/test_config.py b/tests/test_config.py
index b7466f6f..d6d59555 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -18,6 +18,9 @@
 import configparser
 from unittest import mock
 from functools import partial
+from typing import Any, Dict, Callable
+
+from parameterized import parameterized
 
 from dwave.cloud.package_info import __packagename__, __version__
 from dwave.cloud.exceptions import ConfigFileParseError, ConfigFileReadError
@@ -25,7 +28,8 @@
 from dwave.cloud.config import (
     get_configfile_paths, load_config_from_files, load_config,
     parse_float, parse_int, parse_boolean, get_cache_dir, update_config)
-
+from dwave.cloud.config.models import ClientConfig
+from dwave.cloud.config.models import validate_config_v1, load_config_v1, dump_config_v1
 
 class TestConfigParsing(unittest.TestCase):
 
@@ -58,7 +62,7 @@ def parse_config_string(self, text):
         return config
 
     def test_config_load_from_file(self):
-        with mock.patch('dwave.cloud.config.open', iterable_mock_open(self.config_body), create=True):
+        with mock.patch('dwave.cloud.config.loaders.open', iterable_mock_open(self.config_body), create=True):
             config = load_config_from_files(filenames=["filename"])
             self.assertEqual(config.sections(), ['dw2000', 'software', 'alpha'])
             self.assertEqual(config['dw2000']['client'], 'qpu')
@@ -80,14 +84,14 @@ def test_config_load_from_file__invalid_format__duplicate_sections(self):
             [section]
             key = val
         """
-        with mock.patch('dwave.cloud.config.open', iterable_mock_open(myconfig), create=True):
+        with mock.patch('dwave.cloud.config.loaders.open', iterable_mock_open(myconfig), create=True):
             self.assertRaises(ConfigFileParseError, load_config_from_files, filenames=["filename"])
             self.assertRaises(ConfigFileParseError, load_config, config_file="filename", profile="section")
 
     def test_no_config_detected(self):
         """When no config file detected, `load_config_from_files` should return
         empty config."""
-        with mock.patch("dwave.cloud.config.get_configfile_paths", lambda: []):
+        with mock.patch("dwave.cloud.config.loaders.get_configfile_paths", lambda: []):
             self.assertFalse(load_config_from_files().sections())
 
     def test_invalid_filename_given(self):
@@ -143,13 +147,13 @@ def mock_open(filename, *pa, **kw):
 
         # config file via kwarg
         with mock.patch.dict(os.environ, env):
-            with mock.patch('dwave.cloud.config.open', mock_open) as m:
+            with mock.patch('dwave.cloud.config.loaders.open', mock_open) as m:
                 conf = load_config(config_file=config_file, profile=profile)
                 self.assertEqual(conf['valid'], 'yes')
 
         # config file via env var
         with mock.patch.dict(os.environ, env, DWAVE_CONFIG_FILE=config_file):
-            with mock.patch('dwave.cloud.config.open', mock_open) as m:
+            with mock.patch('dwave.cloud.config.loaders.open', mock_open) as m:
                 conf = load_config(profile=profile)
                 self.assertEqual(conf['valid'], 'yes')
 
@@ -169,23 +173,23 @@ def _load_config_from_files(self, asked, provided=None, data=None):
 
 
     def test_config_load_configfile_arg(self):
-        with mock.patch("dwave.cloud.config.load_config_from_files",
+        with mock.patch("dwave.cloud.config.loaders.load_config_from_files",
                         partial(self._load_config_from_files, provided=['myfile'])):
             self._assert_config_valid(load_config(config_file='myfile', profile='alpha'))
 
     def test_config_load_configfile_env(self):
-        with mock.patch("dwave.cloud.config.load_config_from_files",
+        with mock.patch("dwave.cloud.config.loaders.load_config_from_files",
                         partial(self._load_config_from_files, provided=['myfile'])):
             with mock.patch.dict(os.environ, {'DWAVE_CONFIG_FILE': 'myfile'}):
                 self._assert_config_valid(load_config(config_file=None, profile='alpha'))
 
     def test_config_load_configfile_detect(self):
-        with mock.patch("dwave.cloud.config.load_config_from_files",
+        with mock.patch("dwave.cloud.config.loaders.load_config_from_files",
                         partial(self._load_config_from_files, provided=None)):
             self._assert_config_valid(load_config(config_file=None, profile='alpha'))
 
     def test_config_load_skip_configfiles(self):
-        with mock.patch("dwave.cloud.config.load_config_from_files",
+        with mock.patch("dwave.cloud.config.loaders.load_config_from_files",
                         self._load_config_from_files):
 
             # don't load from file, use arg or env
@@ -201,7 +205,7 @@ def test_config_load_skip_configfiles(self):
                 self.assertEqual(load_config(config_file=False, endpoint='test')['endpoint'], 'test')
 
     def test_config_load_force_autodetection(self):
-        with mock.patch("dwave.cloud.config.load_config_from_files",
+        with mock.patch("dwave.cloud.config.loaders.load_config_from_files",
                         partial(self._load_config_from_files, provided=None)):
 
             # load from file
@@ -220,13 +224,13 @@ def test_config_load_force_autodetection(self):
                     self._assert_config_valid(load_config(config_file=True))
 
     def test_config_load_configfile_detect_profile_env(self):
-        with mock.patch("dwave.cloud.config.load_config_from_files",
+        with mock.patch("dwave.cloud.config.loaders.load_config_from_files",
                         partial(self._load_config_from_files, provided=None)):
             with mock.patch.dict(os.environ, {'DWAVE_PROFILE': 'alpha'}):
                 self._assert_config_valid(load_config())
 
     def test_config_load_configfile_env_profile_env(self):
-        with mock.patch("dwave.cloud.config.load_config_from_files",
+        with mock.patch("dwave.cloud.config.loaders.load_config_from_files",
                         partial(self._load_config_from_files, provided=['myfile'])):
             with mock.patch.dict(os.environ, {'DWAVE_CONFIG_FILE': 'myfile',
                                               'DWAVE_PROFILE': 'alpha'}):
@@ -234,7 +238,7 @@ def test_config_load_configfile_env_profile_env(self):
 
     def test_config_load_configfile_env_profile_env_key_arg(self):
         """Explicitly provided values should override env/file."""
-        with mock.patch("dwave.cloud.config.load_config_from_files",
+        with mock.patch("dwave.cloud.config.loaders.load_config_from_files",
                         partial(self._load_config_from_files, provided=['myfile'])):
             with mock.patch.dict(os.environ, {'DWAVE_CONFIG_FILE': 'myfile',
                                               'DWAVE_PROFILE': 'alpha'}):
@@ -249,7 +253,7 @@ def test_config_load__profile_arg_nonexisting(self):
         """load_config should fail if the profile specified in kwargs or env in
         non-existing.
         """
-        with mock.patch("dwave.cloud.config.load_config_from_files",
+        with mock.patch("dwave.cloud.config.loaders.load_config_from_files",
                         partial(self._load_config_from_files, provided=None)):
             self.assertRaises(ValueError, load_config, profile="nonexisting")
             with mock.patch.dict(os.environ, {'DWAVE_PROFILE': 'nonexisting'}):
@@ -259,7 +263,7 @@ def test_config_load_configfile_arg_profile_default(self):
         """Check the right profile is loaded when `profile` specified only in
         [defaults] section.
         """
-        with mock.patch("dwave.cloud.config.load_config_from_files",
+        with mock.patch("dwave.cloud.config.loaders.load_config_from_files",
                         partial(self._load_config_from_files, provided=['myfile'])):
             profile = load_config(config_file='myfile')
             self.assertEqual(profile['solver'], 'c4-sw_sample')
@@ -272,7 +276,7 @@ def test_config_load__profile_first_section(self):
             [first]
             solver = DW_2000Q_1
         """
-        with mock.patch("dwave.cloud.config.load_config_from_files",
+        with mock.patch("dwave.cloud.config.loaders.load_config_from_files",
                         partial(self._load_config_from_files,
                                 provided=None, data=myconfig)):
             profile = load_config()
@@ -288,7 +292,7 @@ def test_config_load__profile_from_defaults(self):
             [defaults]
             solver = DW_2000Q_1
         """
-        with mock.patch("dwave.cloud.config.load_config_from_files",
+        with mock.patch("dwave.cloud.config.loaders.load_config_from_files",
                         partial(self._load_config_from_files,
                                 provided=None, data=myconfig)):
             profile = load_config()
@@ -306,7 +310,7 @@ def test_config_load_configfile_arg_profile_default_nonexisting(self):
             [some]
             solver = DW_2000Q_1
         """
-        with mock.patch("dwave.cloud.config.load_config_from_files",
+        with mock.patch("dwave.cloud.config.loaders.load_config_from_files",
                         partial(self._load_config_from_files,
                                 provided=['myfile'], data=myconfig)):
             self.assertRaises(ValueError, load_config, config_file='myfile')
@@ -327,11 +331,11 @@ def test_config_load_multiple_autodetected_configfiles(self):
             endpoint = beta
         """
 
-        with mock.patch("dwave.cloud.config.get_configfile_paths",
+        with mock.patch("dwave.cloud.config.loaders.get_configfile_paths",
                         lambda: ['config_system', 'config_user']):
 
             # test per-key override
-            with mock.patch('dwave.cloud.config.open', create=True) as m:
+            with mock.patch('dwave.cloud.config.loaders.open', create=True) as m:
                 m.side_effect = [iterable_mock_open(config_system)(),
                                  iterable_mock_open(config_user)()]
                 section = load_config(profile='alpha')
@@ -339,7 +343,7 @@ def test_config_load_multiple_autodetected_configfiles(self):
                 self.assertEqual(section['solver'], 'DW_2000Q_2')
 
             # test per-section override (section addition)
-            with mock.patch('dwave.cloud.config.open', create=True) as m:
+            with mock.patch('dwave.cloud.config.loaders.open', create=True) as m:
                 m.side_effect = [iterable_mock_open(config_system)(),
                                  iterable_mock_open(config_user)()]
                 section = load_config(profile='beta')
@@ -359,7 +363,7 @@ def test_config_load_multiple_explicit_configfiles(self):
             solver = DW_2000Q_2
         """
 
-        with mock.patch('dwave.cloud.config.open', create=True) as m:
+        with mock.patch('dwave.cloud.config.loaders.open', create=True) as m:
             m.side_effect=[iterable_mock_open(file1)(),
                            iterable_mock_open(file2)()]
             section = load_config(config_file=['file1', 'file2'], profile='alpha')
@@ -368,7 +372,7 @@ def test_config_load_multiple_explicit_configfiles(self):
             self.assertEqual(section['solver'], 'DW_2000Q_2')
 
     def test_config_load_env_override(self):
-        with mock.patch("dwave.cloud.config.load_config_from_files",
+        with mock.patch("dwave.cloud.config.loaders.load_config_from_files",
                         partial(self._load_config_from_files, data="", provided=['myfile'])):
 
             with mock.patch.dict(os.environ, {'DWAVE_API_CLIENT': 'test'}):
@@ -506,3 +510,125 @@ def test_config_update_mutually_exclusive_on_same_level(self):
         update = {'endpoint': 2, 'region': 2}
         update_config(config, update)
         self.assertEqual(config, update)
+
+
+class TestConfigModel(unittest.TestCase):
+
+    def _verify(self,
+                raw_config: Dict[str, Any],
+                get_field: Callable[[ClientConfig], Any],
+                model_value: Any):
+        """For a given ``raw_config``, test both model validate and dump."""
+
+        config = validate_config_v1(raw_config)
+
+        with self.subTest("validate"):
+            self.assertEqual(get_field(config), model_value)
+
+        with self.subTest("dump"):
+            config = validate_config_v1(dump_config_v1(config))
+            self.assertEqual(get_field(config), model_value)
+
+    @parameterized.expand([
+        ("null", {"client_cert": None, "client_cert_key": None}, None),
+        ("pem", {"client_cert": "/path/to/pem", "client_cert_key": None}, "/path/to/pem"),
+        ("pair", {"client_cert": "cert", "client_cert_key": "key"}, ("cert", "key")),
+    ])
+    def test_cert(self, name, raw_config, model_value):
+        self._verify(raw_config=raw_config,
+                     get_field=lambda config: config.cert,
+                     model_value=model_value)
+
+    @parameterized.expand([
+        ("null", None, {}),
+        ("blank", '', {}),
+        ("empty", '  ', {}),
+        ("dict", {"Accept": "*/*"}, {"Accept": "*/*"}),
+        ("valid string", "Field-1: value-1\nField-2: value-2",
+                         {"Field-1": "value-1", "Field-2": "value-2"}),
+        ("invalid string", "Field", {}),
+        ("empty field", "Field:", {"Field": ""}),
+        ("empty value", "\n", {}),
+    ])
+    def test_headers(self, name, raw_value, model_value):
+        self._verify(raw_config={"headers": raw_value},
+                     get_field=lambda config: config.headers,
+                     model_value=model_value)
+
+    @parameterized.expand([
+        ("null", None, {}),
+        ("blank", '', {}),
+        ("empty", '  ', {}),
+        ("string", "name", dict(name__eq="name")),
+        ("simple dict", dict(name="name"), dict(name="name")),
+        ("features dict", dict(feature__op="val", x="y"), dict(feature__op="val", x="y")),
+        ("valid json", '{"key": "val"}', dict(key="val")),
+        ("valid json non-dict", '"name"', dict(name__eq='"name"')),
+        ("invalid json", "{name}", dict(name__eq="{name}")),
+    ])
+    def test_solver(self, name, raw_value, model_value):
+        self._verify(raw_config={"solver": raw_value},
+                     get_field=lambda config: config.solver,
+                     model_value=model_value)
+
+    @parameterized.expand([
+        ("cert missing", {"client_cert": None, "client_cert_key": "key"}, ValueError),
+        ("invalid headers", {"headers": [1,2,3]}, ValueError),
+        ("invalid solver", {"solver": {1,2,3}}, ValueError),
+    ])
+    def test_validation_errors(self, name, raw_config, error):
+        with self.assertRaises(error):
+            validate_config_v1(raw_config)
+
+    @parameterized.expand([
+        ("total", 1, 1),
+        ("total", 1.0, 1),
+        ("total", False, False),
+        ("connect", 1.0, 1),
+        ("read", 1, 1),
+        ("redirect", 1, 1),
+        ("redirect", False, False),
+        ("status", 1, 1),
+        ("other", 1, 1),
+        ("backoff_factor", 1, 1.0),
+        ("backoff_max", 1, 1.0),
+    ])
+    def test_request_retry(self, key, raw_value, model_value):
+        self._verify(raw_config={f"http_retry_{key}": raw_value},
+                     get_field=lambda config: getattr(config.request_retry, key),
+                     model_value=model_value)
+
+    @parameterized.expand([
+        ("backoff_min", 1, 1.0),
+        ("backoff_min", 1.5, 1.5),
+        ("backoff_max", 1, 1.0),
+        ("backoff_max", 1.5, 1.5),
+        ("backoff_base", 1, 1.0),
+        ("backoff_base", 1.5, 1.5),
+    ])
+    def test_polling_schedule(self, key, raw_value, model_value):
+        self._verify(raw_config={f"poll_{key}": raw_value},
+                     get_field=lambda config: getattr(config.polling_schedule, key),
+                     model_value=model_value)
+
+    @parameterized.expand([
+        ("1", True), ("on", True), ("On", True), ("true", True), ("True", True),
+        ("0", False), ("off", False), ("Off", False), ("false", False), ("False", False),
+    ])
+    def test_scalars(self, value, model_value):
+        for flag in ("permissive_ssl", "connection_close"):
+            with self.subTest(flag):
+                self._verify(raw_config={flag: value},
+                             get_field=lambda config: getattr(config, flag),
+                             model_value=model_value)
+
+    def test_model_getter_mixin(self):
+        val = "https://some.url/path/"
+        config = validate_config_v1({"endpoint": val})
+        self.assertEqual(config.endpoint, val)
+        self.assertEqual(config['endpoint'], val)
+
+    def test_full_load_defaults(self):
+        # just a smoke test of `load_config_v1` for now
+        from dwave.cloud.config.models import _V1_CONFIG_DEFAULTS
+        self.assertEqual(load_config_v1({}),  validate_config_v1(_V1_CONFIG_DEFAULTS))
diff --git a/tests/test_mock_submission.py b/tests/test_mock_submission.py
index a0686a81..b69ed3c3 100644
--- a/tests/test_mock_submission.py
+++ b/tests/test_mock_submission.py
@@ -512,9 +512,10 @@ def continue_then_complete(path, state={'count': 0}):
                 future.result()
 
                 # after third poll, back-off interval should be 4 x initial back-off
+                schedule = client.config.polling_schedule
                 self.assertAlmostEqual(
                     future._poll_backoff,
-                    client.poll_backoff_min * client.poll_backoff_base**2)
+                    schedule.backoff_min * schedule.backoff_base**2)
 
     def test_immediate_polling(self):
         "First poll happens with minimal delay"
@@ -537,8 +538,9 @@ def create_mock_session(client):
                 solver = Solver(client, self.sapi.solver.data)
 
                 def assert_no_delay(s):
-                    s and self.assertTrue(
-                        abs(s - client.poll_backoff_min) < client.DEFAULTS['poll_backoff_min'])
+                    s = s or 0
+                    delay = abs(s - client.config.polling_schedule.backoff_min)
+                    self.assertLess(delay, client.DEFAULTS['poll_backoff_min'])
 
                 with mock.patch('time.sleep', assert_no_delay):
                     future = solver.sample_qubo({})
@@ -567,8 +569,9 @@ def create_mock_session(client):
                 solver = Solver(client, self.sapi.solver.data)
 
                 def assert_no_delay(s):
-                    s and self.assertTrue(
-                        abs(s - client.poll_backoff_min) < client.DEFAULTS['poll_backoff_min'])
+                    s = s or 0
+                    delay = abs(s - client.config.polling_schedule.backoff_min)
+                    self.assertLess(delay, client.DEFAULTS['poll_backoff_min'])
 
                 with mock.patch('time.sleep', assert_no_delay):
                     future = solver.sample_qubo({})
@@ -619,9 +622,10 @@ def continue_then_complete(path, state={'count': 0}):
                 future.result()
 
                 # after third poll, back-off interval should be 4 x initial back-off
+                schedule = client.config.polling_schedule
                 self.assertAlmostEqual(
                     future._poll_backoff,
-                    client.poll_backoff_min * client.poll_backoff_base**2)
+                    schedule.backoff_min * schedule.backoff_base**2)
 
 
 class DeleteEvent(Exception):