NousResearch/hermes-agent

SSL verification options for custom HTTPS providers

Cluster 28260 · agent. Data as of

Nothing open on GitHub. Fix merged in #56681 on 1 Jul 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #56681. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

These members address failures when custom providers use self-signed or custom CA certificates, covering the bug report and proposed fixes to add SSL verification configuration support.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #56681 fix(agent): honor custom CA certs for custom_providers HTTPS endpoints (main + aux)

    merged to main

    files: agent/agent_init.py, agent/agent_runtime_helpers.py, agent/auxiliary_client.py, agent/process_bootstrap.py, agent/ssl_verify.py, hermes_cli/config.py, run_agent.py, tests/agent/test_auxiliary_client_ssl_verify.py, tests/agent/test_ssl_verify.py, tests/hermes_cli/test_custom_provider_tls.py, tests/run_agent/test_create_openai_client_ssl_verify.py

    Show the diff
    • agent/agent_init.py
      @@ -974,6 +974,21 @@ def _moa_reference_relay(event: str, **kwargs: Any) -> None:
               # this mutation is reflected in the client built just below.
               agent._apply_user_default_headers()
       
      +        try:
      +            from hermes_cli.config import (
      +                apply_custom_provider_tls_to_client_kwargs,
      +                get_compatible_custom_providers,
      +                load_config,
      +            )
      +
      +            apply_custom_provider_tls_to_client_kwargs(
      +                client_kwargs,
      +                str(client_kwargs.get("base_url") or agent.base_url or ""),
      +                get_compatible_custom_providers(load_config()),
      +            )
      +        except Exception:
      +            logger.debug("custom-provider TLS resolution skipped", exc_info=True)
      +
               agent.api_key = client_kwargs.get("api_key", "")
               agent.base_url = client_kwargs.get("base_url", agent.base_url)
               try:
    • agent/agent_runtime_helpers.py
      @@ -1513,6 +1513,7 @@ def anthropic_prompt_cache_policy(
       
       def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: bool) -> Any:
           from agent.auxiliary_client import _validate_base_url, _validate_proxy_env_urls
      +    from agent.ssl_verify import resolve_httpx_verify
           # Treat client_kwargs as read-only. Callers pass agent._client_kwargs (or shallow
           # copies of it) in; any in-place mutation leaks back into the stored dict and is
           # reused on subsequent requests. #10933 hit this by injecting an httpx.Client
      @@ -1522,6 +1523,9 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo
           # copy locks the contract so future transport/keepalive work can't reintroduce
           # the same class of bug.
           client_kwargs = dict(client_kwargs)
      +    ssl_ca_cert = client_kwargs.pop("ssl_ca_cert", None)
      +    ssl_verify_cfg = client_kwargs.pop("ssl_verify", None)
      +    httpx_verify = resolve_httpx_verify(ca_bundle=ssl_ca_cert, ssl_verify=ssl_verify_cfg)
           _validate_proxy_env_urls()
           _validate_base_url(client_kwargs.get("base_url"))
           if agent.provider == "copilot-acp" or str(client_kwargs.get("base_url", "")).startswith("acp://copilot"):
      @@ -1545,7 +1549,9 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo
                       if k in {"api_key", "base_url", "default_headers", "timeout", "http_client"}
                   }
                   if "http_client" not in safe_kwargs:
      -                keepalive_http = agent._build_keepalive_http_client(base_url)
      +                keepalive_http = agent._build_keepalive_http_client(
      +                    base_url, verify=httpx_verify,
      +                )
                       if keepalive_http is not None:
                           safe_kwargs["http_client"] = keepalive_http
                   client = GeminiNativeClient(**safe_kwargs)
      @@ -1574,7 +1580,9 @@ def create_openai_client(agent, client_kwargs: dict, *, reason: str, shared: boo
           # Tests in ``tests/run_agent/test_create_openai_client_reuse.py`` and
           # ``tests/run_agent/test_sequential_chats_live.py`` pin this invariant.
           if "http_client" not in client_kwargs:
      -        keepalive_http = agent._build_keepalive_http_client(client_kwargs.get("base_url", ""))
      +        keepalive_http = agent._build_keepalive_http_client(
      +            client_kwargs.get("base_url", ""), verify=httpx_verify,
      +        )
               if keepalive_http is not None:
                   client_kwargs["http_client"] = keepalive_http
           # Delegate all rate-limit / 5xx retry to hermes's outer conversation loop,
      @@ -1778,6 +1786,24 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
                       "api_key": [redacted:secret-assignment]
                       "base_url": effective_base,
                   }
      +            try:
      +                from hermes_cli.config import (
      +                    apply_custom_provider_tls_to_client_kwargs,
      +                    get_compatible_custom_providers,
      +                    load_config_readonly,
      +                )
      +
      +                # Read custom_providers from live config (not the init-time
      +                # snapshot on ``agent._custom_providers``) so ssl_ca_cert /
      +                # ssl_verify edits are honored when switching mid-session,
      +                # matching the context-length reload below (#15779).
      +                apply_custom_provider_tls_to_client_kwargs(
      +                    agent._client_kwargs,
      +                    str(effective_base or ""),
      +                    get_compatible_custom_providers(load_config_readonly()),
      +                )
      +            except Exception:
      +                logger.debug("custom-provider TLS resolution skipped on switch_model", exc_info=True)
                   _sm_timeout = get_provider_request_timeout(agent.provider, agent.model)
                   if _sm_timeout is not None:
                       agent._client_kwargs["timeout"] = _sm_timeout
    • agent/auxiliary_client.py
      @@ -128,13 +128,45 @@ def __repr__(self):
       _LOGGED_UNSUPPORTED_OAUTH_KEYS: set = set()
       
       
      +def _resolve_aux_verify(base_url: Optional[str]) -> Any:
      +    """Resolve httpx ``verify`` for an auxiliary-client base_url.
      +
      +    Mirrors the main client's TLS resolution so auxiliary calls (compression,
      +    vision, web_extract, title generation, etc.) honor per-provider
      +    ``ssl_ca_cert`` / ``ssl_verify`` config and the ``HERMES_CA_BUNDLE`` /
      +    ``SSL_CERT_FILE`` env conventions. Best-effort: any failure falls back to
      +    the httpx/certifi default (``True``).
      +    """
      +    try:
      +        from agent.ssl_verify import resolve_httpx_verify
      +        from hermes_cli.config import (
      +            get_custom_provider_tls_settings,
      +            load_config_readonly,
      +        )
      +
      +        tls = get_custom_provider_tls_settings(
      +            str(base_url or ""), config=load_config_readonly()
      +        )
      +        return resolve_httpx_verify(
      +            ca_bundle=tls.get("ssl_ca_cert"),
      +            ssl_verify=tls.get("ssl_verify"),
      +            base_url=str(base_url or ""),
      +        )
      +    except Exception:
      +        return True
      +
      +
       def _openai_http_client_kwargs(
           base_url: Optional[str],
           *,
           async_mode: bool = False,
       ) -> Dict[str, Any]:
           """Inject keepalive httpx client with env-only proxy (not macOS system proxy)."""
      -    client = build_keepalive_http_client(str(base_url or ""), async_mode=async_mode)
      +    client = build_keepalive_http_client(
      +        str(base_url or ""),
      +        async_mode=async_mode,
      +        verify=_resolve_aux_verify(base_url),
      +    )
           if client is None:
               return {}
           return {"http_client": client}
    • agent/process_bootstrap.py
      @@ -146,6 +146,7 @@ def build_keepalive_http_client(
           base_url: str = "",
           *,
           async_mode: bool = False,
      +    verify: Any = True,
       ) -> Optional[Any]:
           """Build an httpx client for OpenAI SDK calls with env-only proxy policy.
       
      @@ -154,6 +155,13 @@ def build_keepalive_http_client(
           ``trust_env`` path, so macOS system proxy settings from
           ``urllib.request.getproxies()`` (which omit the ExceptionsList) are not
           applied. Mirrors ``AIAgent._build_keepalive_http_client``.
      +
      +    ``verify`` is forwarded to httpx so auxiliary-client calls (compression,
      +    vision, web_extract, title generation, etc.) honor the same per-provider
      +    ``ssl_ca_cert`` / ``ssl_verify`` and ``HERMES_CA_BUNDLE`` settings the main
      +    client uses. It is passed on the ``HTTPTransport`` (which owns the SSL
      +    context when a custom transport is supplied) and, for the copilot branch
      +    that has no custom transport, on the client itself.
           """
           try:
               import httpx
      @@ -161,7 +169,7 @@ def build_keepalive_http_client(
       
               if "api.githubcopilot.com" in str(base_url or "").lower():
                   client_cls = httpx.AsyncClient if async_mode else httpx.Client
      -            return client_cls()
      +            return client_cls(verify=verify)
       
               sock_opts = [(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)]
               if hasattr(socket, "TCP_KEEPIDLE"):
      @@ -174,8 +182,10 @@ def build_keepalive_http_client(
               proxy = _get_proxy_for_base_url(base_url)
               transport_cls = httpx.AsyncHTTPTransport if async_mode else httpx.HTTPTransport
               client_cls = httpx.AsyncClient if async_mode else httpx.Client
      +        # verify lives on the transport: httpx ignores the client-level
      +        # ``verify`` when a custom ``transport=`` is supplied.
               return client_cls(
      -            transport=transport_cls(socket_options=sock_opts),
      +            transport=transport_cls(socket_options=sock_opts, verify=verify),
                   proxy=proxy,
               )
           except Exception:
    • agent/ssl_verify.py
      @@ -0,0 +1,63 @@
      +"""TLS verify resolution for httpx/OpenAI provider clients."""
      +
      +from __future__ import annotations
      +
      +import logging
      +import os
      +import ssl
      +from pathlib import Path
      +from typing import Any, Optional
      +
      +logger = logging.getLogger(__name__)
      +
      +
      +def _coerce_insecure(ssl_verify: Any) -> bool:
      +    if ssl_verify is False:
      +        return True
      +    if isinstance(ssl_verify, str) and ssl_verify.strip().lower() in {"false", "0", "no", "off"}:
      +        return True
      +    return False
      +
      +
      +def resolve_httpx_verify(
      +    *,
      +    ca_bundle: Optional[str] = None,
      +    ssl_verify: Any = None,
      +    base_url: str = "",
      +) -> bool | ssl.SSLContext:
      +    """Resolve httpx ``verify`` for provider HTTP clients.
      +
      +    Priority:
      +    1. ``ssl_verify: false`` — disable verification (local dev only)
      +    2. explicit ``ca_bundle`` (per-provider ``ssl_ca_cert`` config field)
      +    3. ``HERMES_CA_BUNDLE``, ``SSL_CERT_FILE``, ``REQUESTS_CA_BUNDLE``,
      +       ``CURL_CA_BUNDLE`` env vars
      +    4. ``True`` (httpx/certifi default)
      +
      +    ``base_url`` is used only for the insecure-mode warning message.
      +    """
      +    if _coerce_insecure(ssl_verify):
      +        logger.warning(
      +            "TLS certificate verification DISABLED (ssl_verify: false) for %s — "
      +            "this is intended for local development only and is unsafe on any "
      +            "network you do not fully control.",
      +            base_url or "a custom provider endpoint",
      +        )
      +        return False
      +
      +    effective_ca = (
      +        (ca_bundle or "").strip()
      +        or os.getenv("HERMES_CA_BUNDLE", "").strip()
      +        or os.getenv("SSL_CERT_FILE", "").strip()
      +        or os.getenv("REQUESTS_CA_BUNDLE", "").strip()
      +        or os.getenv("CURL_CA_BUNDLE", "").strip()
      +    )
      +    if effective_ca:
      +        ca_path = str(Path(effective_ca).expanduser())
      +        if os.path.isfile(ca_path):
      +            return ssl.create_default_context(cafile=ca_path)
      +        logger.warning(
      +            "CA bundle path does not exist: %s — falling back to default certificates",
      +            effective_ca,
      +        )
      +    return True
    • hermes_cli/config.py
      @@ -4478,7 +4478,7 @@ def _normalize_custom_provider_entry(
               "api_mode", "transport", "model", "default_model", "models",
               "context_length", "rate_limit_delay",
               "request_timeout_seconds", "stale_timeout_seconds",
      -        "discover_models", "extra_body",
      +        "discover_models", "extra_body", "ssl_ca_cert", "ssl_verify",
           }
           for camel, snake in _CAMEL_ALIASES.items():
               if camel in entry and snake not in entry:
      @@ -4585,6 +4585,16 @@ def _normalize_custom_provider_entry(
           if isinstance(extra_body, dict):
               normalized["extra_body"] = dict(extra_body)
       
      +    ssl_ca_cert = entry.get("ssl_ca_cert")
      +    if isinstance(ssl_ca_cert, str) and ssl_ca_cert.strip():
      +        normalized["ssl_ca_cert"] = ssl_ca_cert.strip()
      +
      +    ssl_verify = entry.get("ssl_verify")
      +    if isinstance(ssl_verify, bool):
      +        normalized["ssl_verify"] = ssl_verify
      +    elif isinstance(ssl_verify, str) and ssl_verify.strip():
      +        normalized["ssl_verify"] = ssl_verify.strip()
      +
           return normalized
       
       
      @@ -4612,6 +4622,8 @@ def _custom_provider_entry_to_provider_config(
               "rate_limit_delay",
               "discover_models",
               "extra_body",
      +        "ssl_ca_cert",
      +        "ssl_verify",
           ):
               if field in normalized:
                   provider_entry[field] = normalized[field]
      @@ -4688,6 +4700,71 @@ def _append_if_new(entry: Optional[Dict[str, Any]]) -> None:
           return compatible
       
       
      +def _coerce_ssl_verify(value: Any) -> Optional[bool]:
      +    if value is None:
      +        return None
      +    if isinstance(value, bool):
      +        return value
      +    if isinstance(value, str):
      +        lowered = value.strip().lower()
      +        if lowered in {"false", "0", "no", "off"}:
      +            return False
      +        if lowered in {"true", "1", "yes", "on"}:
      +            return True
      +    return None
      +
      +
      +def get_custom_provider_tls_settings(
      +    base_url: str,
      +    custom_providers: Optional[List[Dict[str, Any]]] = None,
      +    config: Optional[Dict[str, Any]] = None,
      +) -> Dict[str, Any]:
      +    """Return TLS settings from a matching ``custom_providers`` / ``providers`` entry."""
      +    if custom_providers is None:
      +        try:
      +            custom_providers = get_compatible_custom_providers(config)
      +        except Exception:
      +            custom_providers = []
      +    if not base_url or not isinstance(custom_providers, list):
      +        return {}
      +
      +    # Case-insensitive compare: elsewhere custom_providers are keyed on a
      +    # lowercased base_url (see get_compatible_custom_providers dedup), and
      +    # scheme/host are case-insensitive anyway — so a config entry written as
      +    # https://Ollama.Example.com/v1 must still match a lowercased runtime
      +    # base_url. Exact match after rstrip('/') + lower() (no prefix/substring).
      +    target_url = (base_url or "").rstrip("/").lower()
      +    for entry in custom_providers:
      +        if not isinstance(entry, dict):
      +            continue
      +        entry_url = (entry.get("base_url") or "").rstrip("/").lower()
      +        if not entry_url or entry_url != target_url:
      +            continue
      +        out: Dict[str, Any] = {}
      +        ca = entry.get("ssl_ca_cert")
      +        if isinstance(ca, str) and ca.strip():
      +            out["ssl_ca_cert"] = ca.strip()
      +        verify = _coerce_ssl_verify(entry.get("ssl_verify"))
      +        if verify is not None:
      +            out["ssl_verify"] = verify
      +        return out
      +    return {}
      +
      +
      +def apply_custom_provider_tls_to_client_kwargs(
      +    client_kwargs: Dict[str, Any],
      +    base_url: str,
      +    custom_providers: Optional[List[Dict[str, Any]]] = None,
      +    config: Optional[Dict[str, Any]] = None,
      +) -> None:
      +    """Attach per-provider TLS knobs to OpenAI client kwargs when matched."""
      +    tls = get_custom_provider_tls_settings(base_url, custom_providers, config)
      +    if tls.get("ssl_ca_cert"):
      +        client_kwargs["ssl_ca_cert"] = tls["ssl_ca_cert"]
      +    if "ssl_verify" in tls:
      +        client_kwargs["ssl_verify"] = tls["ssl_verify"]
      +
      +
       def get_custom_provider_context_length(
           model: str,
           base_url: str,
      @@ -4813,6 +4890,7 @@ def check_config_version() -> Tuple[int, int]:
       _VALID_CUSTOM_PROVIDER_FIELDS = {
           "name", "base_url", "api_key", "api_mode", "model", "models",
           "context_length", "rate_limit_delay", "extra_body",
      +    "ssl_ca_cert", "ssl_verify",
           # key_env is read at runtime by runtime_provider.py and auxiliary_client.py
           # — include it here so the set accurately describes the supported schema.
           "key_env",
    • run_agent.py
      @@ -3884,13 +3884,13 @@ def _is_openai_client_closed(client: Any) -> bool:
               return False
       
           @staticmethod
      -    def _build_keepalive_http_client(base_url: str = "") -> Any:
      +    def _build_keepalive_http_client(base_url: str = "", *, verify: Any = True) -> Any:
               try:
                   import httpx as _httpx
                   import socket as _socket
       
                   if "api.githubcopilot.com" in str(base_url or "").lower():
      -                return _httpx.Client()
      +                return _httpx.Client(verify=verify)
       
                   _sock_opts = [(_socket.SOL_SOCKET, _socket.SO_KEEPALIVE, 1)]
                   if hasattr(_socket, "TCP_KEEPIDLE"):
      @@ -3904,8 +3904,10 @@ def _build_keepalive_http_client(base_url: str = "") -> Any:
                   # Explicitly read proxy settings while still honoring NO_PROXY for
                   # loopback / local endpoints such as a locally hosted sub2api.
                   _proxy = _get_proxy_for_base_url(base_url)
      +            # verify lives on the transport: httpx ignores the client-level
      +            # ``verify`` when a custom ``transport=`` is supplied.
                   return _httpx.Client(
      -                transport=_httpx.HTTPTransport(socket_options=_sock_opts),
      +                transport=_httpx.HTTPTransport(socket_options=_sock_opts, verify=verify),
                       proxy=_proxy,
                   )
               except Exception:
    • tests/agent/test_auxiliary_client_ssl_verify.py
      @@ -0,0 +1,79 @@
      +"""Regression: auxiliary-client keepalive httpx client must honor custom CA bundles.
      +
      +The main OpenAI client resolves per-provider ``ssl_ca_cert`` / ``ssl_verify`` and
      +``HERMES_CA_BUNDLE`` via ``agent.ssl_verify.resolve_httpx_verify``. Auxiliary calls
      +(compression, vision, web_extract, title generation, session_search) build their own
      +keepalive client through ``agent.process_bootstrap.build_keepalive_http_client`` and must
      +apply the same TLS settings — otherwise an HTTPS custom_providers endpoint signed by a
      +private CA works for chat but fails ``APIConnectionError`` on every auxiliary task.
      +"""
      +
      +import ssl
      +
      +import certifi
      +import httpx
      +import pytest
      +
      +from agent.process_bootstrap import build_keepalive_http_client
      +
      +_CA_ENV_VARS = ("HERMES_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "HTTPS_PROXY")
      +
      +
      [redacted:email]
      +def clean_tls_env(monkeypatch):
      +    for var in _CA_ENV_VARS:
      +        monkeypatch.delenv(var, raising=False)
      +
      +
      +def test_build_keepalive_http_client_forwards_verify_context(clean_tls_env):
      +    ctx = ssl.create_default_context(cafile=certifi.where())
      +    client = build_keepalive_http_client("https://ollama.example.com/v1", verify=ctx)
      +    assert isinstance(client, httpx.Client)
      +    assert client._transport._pool._ssl_context is ctx
      +
      +
      +def test_build_keepalive_http_client_verify_false_disables_hostname_check(clean_tls_env):
      +    client = build_keepalive_http_client("https://ollama.example.com/v1", verify=False)
      +    assert isinstance(client, httpx.Client)
      +    assert client._transport._pool._ssl_context.check_hostname is False
      +
      +
      +def test_build_keepalive_http_client_default_verify_true(clean_tls_env):
      +    client = build_keepalive_http_client("https://ollama.example.com/v1")
      +    assert isinstance(client, httpx.Client)
      +
      +
      +def test_resolve_aux_verify_uses_per_provider_ssl_ca_cert(clean_tls_env, monkeypatch):
      +    """_resolve_aux_verify should mirror the main-client resolution for a matched base_url."""
      +    import hermes_cli.config as cfg
      +    from agent import auxiliary_client
      +
      +    # get_custom_provider_tls_settings is imported inside the function from
      +    # hermes_cli.config, so patch it at the source module.
      +    monkeypatch.setattr(
      +        cfg,
      +        "get_custom_provider_tls_settings",
      +        lambda *a, **k: {"ssl_ca_cert": certifi.where()},
      +    )
      +    verify = auxiliary_client._resolve_aux_verify("https://ollama.example.com/v1")
      +    assert isinstance(verify, ssl.SSLContext)
      +
      +
      +def test_resolve_aux_verify_ssl_verify_false(clean_tls_env, monkeypatch):
      +    import hermes_cli.config as cfg
      +    from agent import auxiliary_client
      +
      +    monkeypatch.setattr(
      +        cfg,
      +        "get_custom_provider_tls_settings",
      +        lambda *a, **k: {"ssl_verify": False},
      +    )
      +    assert auxiliary_client._resolve_aux_verify("https://ollama.example.com/v1") is False
      +
      +
      +def test_resolve_aux_verify_no_match_defaults_true(clean_tls_env, monkeypatch):
      +    import hermes_cli.config as cfg
      +    from agent import auxiliary_client
      +
      +    monkeypatch.setattr(cfg, "get_custom_provider_tls_settings", lambda *a, **k: {})
      +    assert auxiliary_client._resolve_aux_verify("https://openrouter.ai/api/v1") is True
    • tests/agent/test_ssl_verify.py
      @@ -0,0 +1,40 @@
      +"""Tests for agent.ssl_verify.resolve_httpx_verify."""
      +
      +import ssl
      +
      +import certifi
      +import pytest
      +
      +from agent.ssl_verify import resolve_httpx_verify
      +
      +_CA_ENV_VARS = ("HERMES_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE")
      +
      +
      [redacted:email]
      +def clean_ca_env(monkeypatch):
      +    for var in _CA_ENV_VARS:
      +        monkeypatch.delenv(var, raising=False)
      +
      +
      +def test_ssl_verify_false_disables_verification(clean_ca_env):
      +    assert resolve_httpx_verify(ssl_verify=False) is False
      +
      +
      +def test_hermes_ca_bundle_returns_ssl_context(clean_ca_env, monkeypatch):
      +    monkeypatch.setenv("HERMES_CA_BUNDLE", certifi.where())
      +    result = resolve_httpx_verify()
      +    assert isinstance(result, ssl.SSLContext)
      +
      +
      +def test_explicit_ca_bundle_param(clean_ca_env):
      +    result = resolve_httpx_verify(ca_bundle=certifi.where())
      +    assert isinstance(result, ssl.SSLContext)
      +
      +
      +def test_missing_ca_bundle_falls_back_to_true(clean_ca_env, monkeypatch):
      +    monkeypatch.setenv("HERMES_CA_BUNDLE", "/nonexistent/root-ca.pem")
      +    assert resolve_httpx_verify() is True
      +
      +
      +def test_default_without_env_is_true(clean_ca_env):
      +    assert resolve_httpx_verify() is True
    • tests/hermes_cli/test_custom_provider_tls.py
      @@ -0,0 +1,72 @@
      +"""Tests for per-provider TLS settings in custom_providers config."""
      +
      +from hermes_cli.config import (
      +    apply_custom_provider_tls_to_client_kwargs,
      +    get_custom_provider_tls_settings,
      +)
      +
      +
      +def test_get_custom_provider_tls_settings_matches_base_url():
      +    providers = [
      +        {
      +            "name": "Ollama",
      +            "base_url": "https://ollama.example.com/v1",
      +            "ssl_ca_cert": "/etc/ssl/mkcert-root.pem",
      +        }
      +    ]
      +    tls = get_custom_provider_tls_settings(
      +        "https://ollama.example.com/v1/",
      +        custom_providers=providers,
      +    )
      +    assert tls == {"ssl_ca_cert": "/etc/ssl/mkcert-root.pem"}
      +
      +
      +def test_apply_custom_provider_tls_to_client_kwargs():
      +    client_kwargs = {"api_key": "x", "base_url": "https://ollama.example.com/v1"}
      +    providers = [
      +        {
      +            "name": "Ollama",
      +            "base_url": "https://ollama.example.com/v1",
      +            "ssl_ca_cert": "/etc/ssl/mkcert-root.pem",
      +            "ssl_verify": True,
      +        }
      +    ]
      +    apply_custom_provider_tls_to_client_kwargs(
      +        client_kwargs,
      +        "https://ollama.example.com/v1",
      +        custom_providers=providers,
      +    )
      +    assert client_kwargs["ssl_ca_cert"] == "/etc/ssl/mkcert-root.pem"
      +    assert client_kwargs["ssl_verify"] is True
      +
      +
      +def test_get_custom_provider_tls_settings_matches_case_insensitively():
      +    """A config base_url with mixed case must still match a lowercased runtime base_url."""
      +    providers = [
      +        {
      +            "name": "Ollama",
      +            "base_url": "https://Ollama.Example.com/v1",
      +            "ssl_ca_cert": "/etc/ssl/mkcert-root.pem",
      +        }
      +    ]
      +    tls = get_custom_provider_tls_settings(
      +        "https://ollama.example.com/v1",
      +        custom_providers=providers,
      +    )
      +    assert tls == {"ssl_ca_cert": "/etc/ssl/mkcert-root.pem"}
      +
      +
      +def test_get_custom_provider_tls_settings_no_substring_bypass():
      +    """A base_url that is only a prefix of an entry must NOT match."""
      +    providers = [
      +        {
      +            "name": "Ollama",
      +            "base_url": "https://ollama.example.com/v1",
      +            "ssl_verify": False,
      +        }
      +    ]
      +    # A different host that shares a prefix must not pick up ssl_verify:false.
      +    assert get_custom_provider_tls_settings(
      +        "https://ollama.example.com.attacker.test/v1",
      +        custom_providers=providers,
      +    ) == {}
    • tests/run_agent/test_create_openai_client_ssl_verify.py
      @@ -0,0 +1,46 @@
      +"""Regression: keepalive httpx client must honor custom CA bundles for HTTPS providers."""
      +
      +import ssl
      +
      +import certifi
      +import httpx
      +import pytest
      +
      +from agent.ssl_verify import resolve_httpx_verify
      +from run_agent import AIAgent
      +
      +_CA_ENV_VARS = ("HERMES_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "HTTPS_PROXY")
      +
      +
      [redacted:email]
      +def clean_tls_env(monkeypatch):
      +    for var in _CA_ENV_VARS:
      +        monkeypatch.delenv(var, raising=False)
      +
      +
      +def test_build_keepalive_http_client_uses_hermes_ca_bundle(clean_tls_env, monkeypatch):
      +    monkeypatch.setenv("HERMES_CA_BUNDLE", certifi.where())
      +    verify = resolve_httpx_verify()
      +    client = AIAgent._build_keepalive_http_client(
      +        "https://ollama.example.com/v1", verify=verify,
      +    )
      +    assert isinstance(client, httpx.Client)
      +    assert isinstance(client._transport._pool._ssl_context, ssl.SSLContext)
      +
      +
      +def test_build_keepalive_http_client_honors_per_provider_ssl_ca_cert(clean_tls_env):
      +    verify = resolve_httpx_verify(ca_bundle=certifi.where())
      +    client = AIAgent._build_keepalive_http_client(
      +        "https://ollama.example.com/v1", verify=verify,
      +    )
      +    assert isinstance(client, httpx.Client)
      +    assert isinstance(client._transport._pool._ssl_context, ssl.SSLContext)
      +
      +
      +def test_build_keepalive_http_client_ssl_verify_false(clean_tls_env):
      +    verify = resolve_httpx_verify(ssl_verify=False)
      +    client = AIAgent._build_keepalive_http_client(
      +        "https://ollama.example.com/v1", verify=verify,
      +    )
      +    assert isinstance(client, httpx.Client)
      +    assert client._transport._pool._ssl_context.check_hostname is False

1 PR closed without merging

From the close comments: superseded by another PR 1

  • #28271 feat(providers): add ssl_verify field to custom_providers for self-signed HTTPS endpoints · superseded by another PR → #56681
Closed issues · 1
  • Closed#28260[Bug]: custom_providers with self-signed HTTPS endpoints fail with APIConnectionError (ssl verify)
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
agent
Merged PRs
#56681
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
1 Jul 2026

For your agent

The same facts, typed.