From 8021a5a41eeaa2e85589656266376d0c6580ee34 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 14 Oct 2026 20:07:37 -0400 Subject: [PATCH 1/9] Improve AI dependency and request error handling --- pointblank/_utils_ai.py | 134 +++++++++++++++++++--------------------- 1 file changed, 62 insertions(+), 72 deletions(-) diff --git a/pointblank/_utils_ai.py b/pointblank/_utils_ai.py index d9f92893b..d15004107 100644 --- a/pointblank/_utils_ai.py +++ b/pointblank/_utils_ai.py @@ -1,6 +1,7 @@ from __future__ import annotations import hashlib +import importlib import json import logging import pathlib @@ -22,6 +23,35 @@ # LLM Configuration and Chat Interface # ============================================================================ +# All of the AI features' dependencies are in the `generate` extra +_AI_INSTALL_HINT = '`pip install "pointblank[generate]"`' + +# The packages each provider needs, besides chatlas (Bedrock goes through `anthropic[bedrock]`) +_PROVIDER_PACKAGES = { + "anthropic": ["anthropic"], + "openai": ["openai"], + "ollama": ["openai"], + "bedrock": ["anthropic", "boto3"], + "azure-openai": ["openai"], +} + + +def _check_ai_package(package: str, needed_for: str) -> None: + """Raise an `ImportError` with an install hint if a package needed for an AI feature is missing.""" + try: + importlib.import_module(package) + except ImportError as e: + raise ImportError( + f"The `{package}` package is required {needed_for}. Install Pointblank's AI " + f"dependencies with {_AI_INSTALL_HINT}." + ) from e + + +def _check_provider_packages(provider: str) -> None: + """Check that the packages a model provider needs are installed.""" + for package in _PROVIDER_PACKAGES.get(provider, []): + _check_ai_package(package, f"to use the `{provider}` provider") + @dataclass class _LLMConfig: @@ -94,14 +124,7 @@ def _create_chat_instance( ------- Chat instance from chatlas. """ - # Check if chatlas is installed - try: - import chatlas # noqa - except ImportError: # pragma: no cover - raise ImportError( - "The `chatlas` package is required for AI validation. " - "Please install it using `pip install chatlas`." - ) + _check_ai_package("chatlas", "for AI validation") # Validate provider if provider not in MODEL_PROVIDERS: @@ -110,32 +133,20 @@ def _create_chat_instance( f"Supported providers: {', '.join(MODEL_PROVIDERS)}" ) + _check_provider_packages(provider) + # Use the row-validation system prompt unless a caller supplies its own if system_prompt is None: system_prompt = _ROW_VALIDATION_SYSTEM_PROMPT # Create httpx client with SSL verification settings - try: - import httpx # noqa - except ImportError: # pragma: no cover - raise ImportError( # pragma: no cover - "The `httpx` package is required for SSL configuration. " - "Please install it using `pip install httpx`." - ) + _check_ai_package("httpx", "for SSL configuration") + import httpx http_client = httpx.AsyncClient(verify=verify_ssl) # Create provider-specific chat instance - if provider == "anthropic": # pragma: no cover - # Check that the anthropic package is installed - try: - import anthropic # noqa # type: ignore[import-not-found] - except ImportError: - raise ImportError( - "The `anthropic` package is required to use AI validation with " - "`anthropic`. Please install it using `pip install anthropic`." - ) - + if provider == "anthropic": from chatlas import ChatAnthropic chat = ChatAnthropic( @@ -146,15 +157,6 @@ def _create_chat_instance( ) elif provider == "openai": # pragma: no cover - # Check that the openai package is installed - try: - import openai # noqa - except ImportError: - raise ImportError( - "The `openai` package is required to use AI validation with " - "`openai`. Please install it using `pip install openai`." - ) - from chatlas import ChatOpenAI chat = ChatOpenAI( @@ -165,15 +167,6 @@ def _create_chat_instance( ) elif provider == "ollama": # pragma: no cover - # Check that the openai package is installed (required for Ollama) - try: - import openai # noqa - except ImportError: - raise ImportError( - "The `openai` package is required to use AI validation with " - "`ollama`. Please install it using `pip install openai`." - ) - from chatlas import ChatOllama chat = ChatOllama( @@ -182,7 +175,7 @@ def _create_chat_instance( kwargs={"http_client": http_client}, ) - elif provider == "bedrock": # pragma: no cover + elif provider == "bedrock": from chatlas import ChatBedrockAnthropic # ChatBedrockAnthropic creates both a sync and async client internally, @@ -194,14 +187,6 @@ def _create_chat_instance( ) elif provider == "azure-openai": # pragma: no cover - try: - import openai # noqa - except ImportError: - raise ImportError( - "The `openai` package is required to use AI validation with " - "`azure-openai`. Please install it using `pip install openai`." - ) - import os endpoint = os.getenv("AZURE_OPENAI_ENDPOINT") @@ -252,18 +237,13 @@ def _prepare_attachments(attachments: Optional[List[Any]]) -> List[Any]: if not isinstance(attachments, (list, tuple)): raise TypeError(f"attachments must be a list or tuple, got {type(attachments).__name__}") - try: - from chatlas import ( - content_image_file, - content_image_url, - content_pdf_file, - content_pdf_url, - ) - except ImportError: # pragma: no cover - raise ImportError( - "The `chatlas` package is required for attachments support. " - "Please install it using `pip install chatlas`." - ) + _check_ai_package("chatlas", "for attachments support") + from chatlas import ( + content_image_file, + content_image_url, + content_pdf_file, + content_pdf_url, + ) prepared: List[Any] = [] for item in attachments: @@ -945,22 +925,32 @@ def validate_batch(batch: Dict[str, Any]) -> List[Dict[str, Any]]: logger.info(f"Successfully validated batch {batch['batch_id']}") return results - except Exception as e: # pragma: no cover - logger.error( - f"Failed to validate batch {batch['batch_id']}: {e}" - ) # pragma: no cover + except Exception as e: + logger.error(f"Failed to validate batch {batch['batch_id']}: {e}") + errors.append(e) # Return default results (all False) for failed batches - default_results = [] # pragma: no cover - for i in range(batch["start_row"], batch["end_row"]): # pragma: no cover - default_results.append({"index": i, "result": False}) # pragma: no cover - return default_results # pragma: no cover + default_results = [] + for i in range(batch["start_row"], batch["end_row"]): + default_results.append({"index": i, "result": False}) + return default_results # Execute all batch validations sequentially (chatlas is synchronous) + errors: List[Exception] = [] final_results = [] for batch in batches: result = validate_batch(batch) final_results.append(result) + # If no batch could be validated (e.g., an invalid API key), every row would be reported as + # failing, so treat the step as one that couldn't be evaluated instead + if batches and len(errors) == len(batches): + from pointblank._interrogation import StepEvaluationError, _first_line + + raise StepEvaluationError( + f"None of the {len(batches)} requests to the model succeeded " + f"(the first failed with {_first_line(errors[0])})." + ) from errors[0] + return final_results def validate_single_batch( From 8edcb264fad56265270082aadae12724fa82bc79 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 14 Oct 2026 20:08:55 -0400 Subject: [PATCH 2/9] Surface AI step errors and reuse dependency checks --- pointblank/_interrogation.py | 52 +++++------------------------------- pointblank/assistant.py | 48 ++++----------------------------- pointblank/draft.py | 9 ++----- pointblank/edit.py | 9 ++----- 4 files changed, 16 insertions(+), 102 deletions(-) diff --git a/pointblank/_interrogation.py b/pointblank/_interrogation.py index 03060f994..5b3e97f62 100644 --- a/pointblank/_interrogation.py +++ b/pointblank/_interrogation.py @@ -2478,54 +2478,16 @@ def interrogate_prompt( # pragma: no cover return result_tbl - except ImportError as e: - logger.error(f"Missing dependencies for AI validation: {e}") - logger.error("Install required packages: pip install openai anthropic aiohttp") - - # Return all False results as fallback (nw_tbl and total_rows defined at function start) - native_tbl = nw_tbl.to_native() - validation_results = [False] * total_rows - - if hasattr(native_tbl, "with_columns"): # Polars - import polars as pl - - result_tbl = native_tbl.with_columns(pb_is_good_=pl.Series(validation_results)) - - elif hasattr(native_tbl, "assign"): # Pandas - import pandas as pd - - result_tbl = native_tbl.assign(pb_is_good_=pd.Series(validation_results, dtype=bool)) - - else: - # Fallback - result_tbl = native_tbl.copy() if hasattr(native_tbl, "copy") else native_tbl - result_tbl["pb_is_good_"] = validation_results + except StepEvaluationError: + raise - return result_tbl + except ImportError as e: + # A missing package (e.g., the provider's SDK); the message says how to install it + raise StepEvaluationError(f"The prompt() step couldn't be run: {e}") from e except Exception as e: - logger.error(f"AI validation failed: {e}") - - # Return all False results as fallback (nw_tbl and total_rows defined at function start) - native_tbl = nw_tbl.to_native() - validation_results = [False] * total_rows - - if hasattr(native_tbl, "with_columns"): # Polars - import polars as pl - - result_tbl = native_tbl.with_columns(pb_is_good_=pl.Series(validation_results)) - - elif hasattr(native_tbl, "assign"): # Pandas - import pandas as pd - - result_tbl = native_tbl.assign(pb_is_good_=pd.Series(validation_results, dtype=bool)) - - else: - # Fallback - result_tbl = native_tbl.copy() if hasattr(native_tbl, "copy") else native_tbl - result_tbl["pb_is_good_"] = validation_results - - return result_tbl + # Anything else that stops the step as a whole (e.g., an invalid model configuration) + raise StepEvaluationError(f"The prompt() step couldn't be run ({_first_line(e)}).") from e def data_freshness( diff --git a/pointblank/assistant.py b/pointblank/assistant.py index fea2028ce..e3176753d 100644 --- a/pointblank/assistant.py +++ b/pointblank/assistant.py @@ -5,6 +5,7 @@ from importlib_resources import files from pointblank._constants import MODEL_PROVIDERS +from pointblank._utils_ai import _check_ai_package, _check_provider_packages from pointblank.datascan import DataScan __all__ = [ @@ -162,23 +163,9 @@ def assistant( library. The loading preference is Polars first, then Pandas as a fallback. """ - # Check that the chatlas package is installed - try: - import chatlas # noqa - except ImportError: - raise ImportError( - "The `chatlas` package is required to use the `Assistant` class. " - "Please install it using `pip install chatlas`." - ) + _check_ai_package("chatlas", "to use the `Assistant` class") - # Check that the shiny package is installed - try: - import shiny # noqa - except ImportError: - raise ImportError( - "The `shiny` package is required to use the `Assistant` class. " - "Please install it using `pip install shiny`." - ) + _check_ai_package("shiny", "to use the `Assistant` class") if display is None: display = "browser" @@ -291,16 +278,9 @@ def assistant( " per line)" ) - if provider == "anthropic": # pragma: no cover - # Check that the anthropic package is installed - try: - import anthropic # noqa # type: ignore[import-not-found] - except ImportError: # pragma: no cover - raise ImportError( # pragma: no cover - "The `anthropic` package is required to use the `DraftValidation` class with " - "the `anthropic` provider. Please install it using `pip install anthropic`." - ) + _check_provider_packages(provider) + if provider == "anthropic": # pragma: no cover from chatlas import ChatAnthropic # pragma: no cover chat = ChatAnthropic( # pragma: no cover @@ -310,15 +290,6 @@ def assistant( ) if provider == "openai": # pragma: no cover - # Check that the openai package is installed - try: - import openai # noqa - except ImportError: # pragma: no cover - raise ImportError( # pragma: no cover - "The `openai` package is required to use the `DraftValidation` class with the " - "`openai` provider. Please install it using `pip install openai`." - ) - from chatlas import ChatOpenAI # pragma: no cover chat = ChatOpenAI( # pragma: no cover @@ -328,15 +299,6 @@ def assistant( ) if provider == "ollama": # pragma: no cover - # Check that the openai package is installed - try: - import openai # noqa - except ImportError: # pragma: no cover - raise ImportError( # pragma: no cover - "The `openai` package is required to use the `DraftValidation` class with " - "`ollama`. Please install it using `pip install openai`." - ) - from chatlas import ChatOllama chat = ChatOllama( # pragma: no cover diff --git a/pointblank/draft.py b/pointblank/draft.py index e1b4e7d42..4b8c47d41 100644 --- a/pointblank/draft.py +++ b/pointblank/draft.py @@ -7,6 +7,7 @@ from pointblank._constants import MODEL_PROVIDERS from pointblank._utils_ai import ( + _check_ai_package, _check_syntax, _create_chat_instance, _extract_chain_steps, @@ -245,13 +246,7 @@ class DraftValidation: def __post_init__(self) -> None: # Check that the chatlas package is installed - try: - import chatlas # noqa - except ImportError: - raise ImportError( - "The `chatlas` package is required to use the `DraftValidation` class. " - "Please install it using `pip install chatlas`." - ) + _check_ai_package("chatlas", "to use the `DraftValidation` class") # Generate a table summary in JSON format using the `DataScan` class tbl_json = DataScan(data=self.data).to_json() diff --git a/pointblank/edit.py b/pointblank/edit.py index 1b657b491..ca0b860f5 100644 --- a/pointblank/edit.py +++ b/pointblank/edit.py @@ -9,6 +9,7 @@ from pointblank._constants import MODEL_PROVIDERS from pointblank._utils_ai import ( + _check_ai_package, _check_syntax, _create_chat_instance, _diff_plan_steps, @@ -201,13 +202,7 @@ class EditValidation: def __post_init__(self) -> None: # Verify chatlas is available (mirrors DraftValidation) - try: - import chatlas # noqa - except ImportError: - raise ImportError( - "The `chatlas` package is required to use the `EditValidation` class. " - "Please install it using `pip install chatlas`." - ) + _check_ai_package("chatlas", "to use the `EditValidation` class") # Validate the model string and provider up front if ":" not in self.model: From 3c99c980802b0b9df6ccd3573ef4d1a95ad870a0 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 14 Oct 2026 20:09:16 -0400 Subject: [PATCH 3/9] Add Anthropic Bedrock dependency for tests --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 6d6b20535..94c3195e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,6 +96,7 @@ docs = [ [dependency-groups] dev = [ + "anthropic[bedrock]>=0.68.0", # Lets the AI provider setup paths run in tests "chatlas>=0.6.1", "duckdb>=1.2.0,<1.3.3", # Pin to stable versions avoiding 1.4.0+ RecordBatchReader issues "hypothesis>=6.129.2", From 8abe1501e294ef0db6fcdf130e4a02d6ba30784e Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 14 Oct 2026 20:13:33 -0400 Subject: [PATCH 4/9] Update provider setup instructions --- .../references/providers-reference.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/skills/draft-validation/references/providers-reference.md b/skills/draft-validation/references/providers-reference.md index 2df7e42fa..428ecb3ba 100644 --- a/skills/draft-validation/references/providers-reference.md +++ b/skills/draft-validation/references/providers-reference.md @@ -16,7 +16,7 @@ model="anthropic:claude-opus-4-8" Environment variable: `ANTHROPIC_API_KEY` -Install: `pip install anthropic` +Install: `pip install "pointblank[generate]"` (includes chatlas and every provider's SDK) ### OpenAI @@ -28,7 +28,7 @@ model="openai:gpt-4-turbo" Environment variable: `OPENAI_API_KEY` -Install: `pip install openai` +Install: `pip install "pointblank[generate]"` ### Ollama (local) @@ -38,7 +38,7 @@ model="ollama:mistral" model="ollama:codellama" ``` -No API key needed. Requires Ollama running locally. +No API key needed. Requires Ollama running locally, and the `openai` package (`pip install "pointblank[generate]"`). Setup: ```bash @@ -56,7 +56,7 @@ model="bedrock:anthropic.claude-haiku-4-5-20251001-v1:0" Uses AWS credentials from the environment (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_DEFAULT_REGION) or AWS profiles. -Install: `pip install boto3` +Install: `pip install "pointblank[generate]"` (Bedrock uses `anthropic[bedrock]`, which brings in boto3) ### Azure OpenAI @@ -67,9 +67,9 @@ model="azure-openai:my-deployment-name" Environment variables: - `AZURE_OPENAI_API_KEY` - `AZURE_OPENAI_ENDPOINT` -- `AZURE_OPENAI_API_VERSION` +- `OPENAI_API_VERSION` (e.g., `2024-06-01`) -Install: `pip install openai` +Install: `pip install "pointblank[generate]"` ## API key options From df7ff4a01a883db75eec751164774568227bce11 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 14 Oct 2026 20:13:46 -0400 Subject: [PATCH 5/9] Expand AI chat provider dependency tests --- tests/test__utils_ai.py | 68 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 61 insertions(+), 7 deletions(-) diff --git a/tests/test__utils_ai.py b/tests/test__utils_ai.py index 21ee247d8..4a007b511 100644 --- a/tests/test__utils_ai.py +++ b/tests/test__utils_ai.py @@ -1,3 +1,6 @@ +import re +import sys + import pytest import pandas as pd import polars as pl @@ -96,13 +99,6 @@ def test_llm_config_with_api_key(): # ============================================================================ -def test_create_chat_instance_missing_chatlas(): - """Test error when chatlas is not installed.""" - # We can't easily mock the import machinery, so we'll just test the error case - # This is covered by the integration tests anyway - pass # Skip this complex mocking test - - def test_create_chat_instance_basic_functionality(): """Test basic chat instance creation functionality without deep mocking.""" # Test that invalid provider raises error @@ -867,3 +863,61 @@ def test_default_results_creation(): assert results[0]["index"] == 1 assert results[1]["index"] == 2 assert results[2]["index"] == 3 + + +# ============================================================================ +# Provider packages and chat construction (no network access needed) +# ============================================================================ + + +@pytest.mark.parametrize( + "provider, package", + [ + ("anthropic", "anthropic"), + ("bedrock", "anthropic"), + ("bedrock", "boto3"), + ("openai", "openai"), + ("ollama", "openai"), + ("azure-openai", "openai"), + ], +) +def test_create_chat_instance_missing_provider_package(monkeypatch, provider, package): + """A missing provider package gives an error that points to the `generate` extra.""" + pytest.importorskip("chatlas") + monkeypatch.setitem(sys.modules, package, None) + + with pytest.raises(ImportError, match=re.escape('pip install "pointblank[generate]"')) as e: + _create_chat_instance(provider, "model") + assert f"`{package}`" in str(e.value) + + +def test_create_chat_instance_missing_chatlas(monkeypatch): + monkeypatch.setitem(sys.modules, "chatlas", None) + + with pytest.raises(ImportError, match=re.escape('pip install "pointblank[generate]"')): + _create_chat_instance("anthropic", "model") + + +def test_create_chat_instance_anthropic(): + pytest.importorskip("anthropic") + chatlas = pytest.importorskip("chatlas") + + chat = _create_chat_instance("anthropic", "claude-sonnet-4-5", api_key="test-key") + + assert isinstance(chat, chatlas.Chat) + assert chat.provider.name == "Anthropic" + + +def test_create_chat_instance_bedrock(monkeypatch): + pytest.importorskip("anthropic") + pytest.importorskip("boto3") + chatlas = pytest.importorskip("chatlas") + monkeypatch.delenv("AWS_PROFILE", raising=False) + monkeypatch.setenv("AWS_REGION", "us-east-1") + monkeypatch.setenv("AWS_ACCESS_KEY_ID", "test") + monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "test") + + chat = _create_chat_instance("bedrock", "us.anthropic.claude-sonnet-4-5-v1:0") + + assert isinstance(chat, chatlas.Chat) + assert chat.provider.name == "AWS/Bedrock" From ec25089acbccb5a86fe80a179d86ba69ad27b5a9 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 14 Oct 2026 20:13:57 -0400 Subject: [PATCH 6/9] Test prompt evaluation errors --- tests/test_prompt_method.py | 47 +++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/test_prompt_method.py b/tests/test_prompt_method.py index 9abafc0ed..c3904e794 100644 --- a/tests/test_prompt_method.py +++ b/tests/test_prompt_method.py @@ -1,3 +1,5 @@ +import sys + import pytest import pandas as pd import polars as pl @@ -576,3 +578,48 @@ def preprocess_func(df): assert val_info.actions is actions assert val_info.brief == "Comprehensive test brief" assert val_info.active is True + + +# ============================================================================ +# Test steps that can't be evaluated +# ============================================================================ + + +def test_prompt_missing_provider_package_is_eval_error(monkeypatch, sample_data_pl): + """A missing provider package marks the step as an evaluation error (with an install hint) + instead of reporting every row as failing.""" + pytest.importorskip("chatlas") + monkeypatch.setitem(sys.modules, "anthropic", None) + + validation = ( + Validate(data=sample_data_pl) + .prompt(prompt="Each row has a name", model="anthropic:claude-sonnet-4-5") + .col_exists(columns="name") + .interrogate() + ) + + step = validation.validation_info[0] + assert step.eval_error is True + assert step.n_failed is None + assert 'pip install "pointblank[generate]"' in step.notes["eval_error"]["text"] + + # The other steps are still interrogated + assert validation.n_passed(i=2, scalar=True) == 1 + + +def test_prompt_all_requests_failing_is_eval_error(monkeypatch, sample_data_pl): + """If no request to the model succeeds (e.g., an invalid API key), the step can't be + evaluated, rather than every row failing.""" + chat = Mock() + chat.chat.side_effect = RuntimeError("401 invalid x-api-key") + monkeypatch.setattr("pointblank._utils_ai._create_chat_instance", lambda **kwargs: chat) + + validation = ( + Validate(data=sample_data_pl) + .prompt(prompt="Each row has a name", model="anthropic:claude-sonnet-4-5") + .interrogate() + ) + + step = validation.validation_info[0] + assert step.eval_error is True + assert "401 invalid x-api-key" in step.notes["eval_error"]["text"] From 94029f372ea883b42ef2b80fece4f03b89143255 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 14 Oct 2026 20:14:00 -0400 Subject: [PATCH 7/9] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a8e0d36ac..b156a3674 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ _Data validation toolkit for assessing and monitoring data quality._ [![Python Versions](https://img.shields.io/pypi/pyversions/pointblank.svg)](https://pypi.python.org/pypi/pointblank) [![PyPI](https://img.shields.io/pypi/v/pointblank)](https://pypi.org/project/pointblank/#history) -[![PyPI Downloads](https://static.pepy.tech/badge/pointblank)](https://pepy.tech/projects/pointblank) +[![PyPI Downloads](https://img.shields.io/pypi/dm/pointblank)](https://pypistats.org/packages/pointblank) [![Conda Version](https://img.shields.io/conda/vn/conda-forge/pointblank.svg)](https://anaconda.org/conda-forge/pointblank) [![License](https://img.shields.io/github/license/posit-dev/pointblank)](https://img.shields.io/github/license/posit-dev/pointblank) From 3bd9b3e1dd5040615d5f94f3e98aa5ed3e8438c0 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 14 Oct 2026 21:00:57 -0400 Subject: [PATCH 8/9] Match AI HTTP clients to provider SDK dependencies --- pointblank/_utils_ai.py | 41 +++++++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/pointblank/_utils_ai.py b/pointblank/_utils_ai.py index d15004107..918a1b9fe 100644 --- a/pointblank/_utils_ai.py +++ b/pointblank/_utils_ai.py @@ -5,6 +5,7 @@ import json import logging import pathlib +import re from dataclasses import dataclass from typing import Any, Dict, List, Optional, Tuple @@ -53,6 +54,32 @@ def _check_provider_packages(provider: str) -> None: _check_ai_package(package, f"to use the `{provider}` provider") +def _http_library(sdk: str) -> str: + """ + Get the HTTP library a provider SDK is built on: `httpx2` or `httpx`. + + `anthropic` (from 1.0) and `openai` (from 3.0) moved from `httpx` to `httpx2`, and they + reject (or fail later with) a client from the other library. Their package metadata says + which one they need. + """ + from importlib.metadata import PackageNotFoundError, requires + + try: + requirements = requires(sdk) or [] + except PackageNotFoundError: # pragma: no cover + requirements = [] + + names = {re.split(r"[\s;<>=!~\[]", r, maxsplit=1)[0].lower() for r in requirements} + return "httpx2" if "httpx2" in names else "httpx" + + +def _async_http_client(sdk: str, verify_ssl: bool) -> Any: + """Create an async HTTP client (with the SSL setting) of the kind that a provider SDK accepts.""" + library = _http_library(sdk) + _check_ai_package(library, "for SSL configuration") + return importlib.import_module(library).AsyncClient(verify=verify_ssl) + + @dataclass class _LLMConfig: """Configuration for LLM provider. @@ -139,12 +166,6 @@ def _create_chat_instance( if system_prompt is None: system_prompt = _ROW_VALIDATION_SYSTEM_PROMPT - # Create httpx client with SSL verification settings - _check_ai_package("httpx", "for SSL configuration") - import httpx - - http_client = httpx.AsyncClient(verify=verify_ssl) - # Create provider-specific chat instance if provider == "anthropic": from chatlas import ChatAnthropic @@ -153,7 +174,7 @@ def _create_chat_instance( model=model_name, api_key=api_key, system_prompt=system_prompt, - kwargs={"http_client": http_client}, + kwargs={"http_client": _async_http_client("anthropic", verify_ssl)}, ) elif provider == "openai": # pragma: no cover @@ -163,7 +184,7 @@ def _create_chat_instance( model=model_name, api_key=api_key, system_prompt=system_prompt, - kwargs={"http_client": http_client}, + kwargs={"http_client": _async_http_client("openai", verify_ssl)}, ) elif provider == "ollama": # pragma: no cover @@ -172,7 +193,7 @@ def _create_chat_instance( chat = ChatOllama( model=model_name, system_prompt=system_prompt, - kwargs={"http_client": http_client}, + kwargs={"http_client": _async_http_client("openai", verify_ssl)}, ) elif provider == "bedrock": @@ -210,7 +231,7 @@ def _create_chat_instance( api_version=api_version, api_key=api_key, system_prompt=system_prompt, - kwargs={"http_client": http_client}, + kwargs={"http_client": _async_http_client("openai", verify_ssl)}, ) else: From 502143c5466e69cadf99030eedf1dc1fbbefabf2 Mon Sep 17 00:00:00 2001 From: Richard Iannone Date: Wed, 14 Oct 2026 21:01:06 -0400 Subject: [PATCH 9/9] Add AI provider HTTP client compatibility tests --- tests/test__utils_ai.py | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/tests/test__utils_ai.py b/tests/test__utils_ai.py index 4a007b511..470d94650 100644 --- a/tests/test__utils_ai.py +++ b/tests/test__utils_ai.py @@ -921,3 +921,42 @@ def test_create_chat_instance_bedrock(monkeypatch): assert isinstance(chat, chatlas.Chat) assert chat.provider.name == "AWS/Bedrock" + + +def test_create_chat_instance_openai(): + pytest.importorskip("openai") + chatlas = pytest.importorskip("chatlas") + + chat = _create_chat_instance("openai", "gpt-4o", api_key="test-key") + + assert isinstance(chat, chatlas.Chat) + assert chat.provider.name == "OpenAI" + + +@pytest.mark.parametrize( + "requirements, library", + [ + # anthropic >= 1.0 and openai >= 3.0 + (["httpx2<3,>=2.0.0", "pydantic<3,>=1.9.0"], "httpx2"), + (["httpx2[http2]>=2.12.0"], "httpx2"), + # Earlier releases (`httpx-aiohttp` is a different package) + (["httpx<1,>=0.25.0", 'httpx-aiohttp>=0.1.9; extra == "aiohttp"'], "httpx"), + ], +) +def test_http_library_follows_the_sdk(requirements, library): + """The HTTP client must come from the library the provider SDK is built on.""" + from pointblank._utils_ai import _http_library + + with patch("importlib.metadata.requires", return_value=requirements): + assert _http_library("anthropic") == library + + +@pytest.mark.parametrize("library", ["httpx", "httpx2"]) +def test_async_http_client_uses_the_sdk_library(library): + from pointblank._utils_ai import _async_http_client + + module = pytest.importorskip(library) + with patch("pointblank._utils_ai._http_library", return_value=library): + client = _async_http_client("anthropic", verify_ssl=False) + + assert isinstance(client, module.AsyncClient)