diff --git a/CHANGELOG.md b/CHANGELOG.md index e54fff1dd..e080385fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ - (**breaking**) `AnthropicLLM.supports_structured_output` is now `True`. As a result, `SchemaFromTextExtractor` and `LLMEntityRelationExtractor` (and `SimpleKGPipeline`, which enables structured output automatically when the LLM supports it) now use structured output by default with `AnthropicLLM`. This requires a Claude 4.5+ model (e.g. `claude-sonnet-4-5`); using `AnthropicLLM` with an older Claude model in these components will now raise an error where it previously worked. To keep the previous behavior, use a Claude 4.5+ model, or construct `LLMEntityRelationExtractor` / `SchemaFromTextExtractor` directly with `use_structured_output=False`. +### Fixed + +- Fixed a bug in `AnthropicLLM` where an `http_client` passed via kwargs (whether an `httpx.Client` or `httpx.AsyncClient`) was forwarded to both the sync `anthropic.Anthropic` and async `anthropic.AsyncAnthropic` clients, causing a type mismatch. `http_client` is now routed to the matching sync/async client only; other kwargs remain shared. An `http_client` of an unrecognized type now emits a warning and is ignored instead of raising, matching `OpenAILLM`'s existing behavior. + ## 1.18.0 ### Changed diff --git a/src/neo4j_graphrag/llm/anthropic_llm.py b/src/neo4j_graphrag/llm/anthropic_llm.py index 7774f0be1..556b39199 100644 --- a/src/neo4j_graphrag/llm/anthropic_llm.py +++ b/src/neo4j_graphrag/llm/anthropic_llm.py @@ -14,6 +14,7 @@ from __future__ import annotations import json +import warnings from typing import ( TYPE_CHECKING, Any, @@ -25,6 +26,12 @@ cast, ) +# 3rd party dependencies +try: + import httpx +except ImportError: + httpx = None # type: ignore[assignment] + from pydantic import BaseModel, ValidationError from neo4j_graphrag.exceptions import LLMGenerationError @@ -218,8 +225,20 @@ def __init__( **kwargs, ) self.anthropic = anthropic - self.client = anthropic.Anthropic(**kwargs) - self.async_client = anthropic.AsyncAnthropic(**kwargs) + http_client = kwargs.pop("http_client", None) + sync_params = kwargs.copy() + async_params = kwargs.copy() + if httpx is not None and isinstance(http_client, httpx.Client): + sync_params["http_client"] = http_client + elif httpx is not None and isinstance(http_client, httpx.AsyncClient): + async_params["http_client"] = http_client + elif http_client is not None: + warnings.warn( + f"Invalid http_client type (got {type(http_client)}, expected httpx.Client or httpx.AsyncClient). Using default client.", + stacklevel=2, + ) + self.client = anthropic.Anthropic(**sync_params) + self.async_client = anthropic.AsyncAnthropic(**async_params) def invoke( self, diff --git a/tests/unit/llm/test_anthropic_llm.py b/tests/unit/llm/test_anthropic_llm.py index 113fd3637..6e9f4bae4 100644 --- a/tests/unit/llm/test_anthropic_llm.py +++ b/tests/unit/llm/test_anthropic_llm.py @@ -19,6 +19,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch import anthropic +import httpx import pytest from neo4j_graphrag.exceptions import LLMGenerationError from neo4j_graphrag.experimental.components.types import Neo4jGraph @@ -538,6 +539,66 @@ def test_anthropic_llm_close(mock_anthropic: Mock) -> None: mock_anthropic.AsyncAnthropic.return_value.close.assert_called_once() +# --------------------------------------------------------------------------- +# http_client sync/async routing tests +# --------------------------------------------------------------------------- + + +def test_anthropic_llm_with_httpx_client(mock_anthropic: Mock) -> None: + """Test that httpx.Client is forwarded only to the sync Anthropic client.""" + http_client = httpx.Client() + try: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + AnthropicLLM(model_name="claude-3-opus-20240229", http_client=http_client) + + assert not any("Invalid http_client" in str(w.message) for w in caught) + _, sync_kwargs = mock_anthropic.Anthropic.call_args + assert sync_kwargs.get("http_client") is http_client + _, async_kwargs = mock_anthropic.AsyncAnthropic.call_args + assert "http_client" not in async_kwargs + finally: + http_client.close() + + +@pytest.mark.asyncio +async def test_anthropic_llm_with_httpx_async_client(mock_anthropic: Mock) -> None: + """Test that httpx.AsyncClient is forwarded only to the async Anthropic client.""" + async_http_client = httpx.AsyncClient() + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + AnthropicLLM(model_name="claude-3-opus-20240229", http_client=async_http_client) + + assert not any("Invalid http_client" in str(w.message) for w in caught) + _, sync_kwargs = mock_anthropic.Anthropic.call_args + assert "http_client" not in sync_kwargs + _, async_kwargs = mock_anthropic.AsyncAnthropic.call_args + assert async_kwargs.get("http_client") is async_http_client + + await async_http_client.aclose() + + +def test_anthropic_llm_no_http_client_no_warning(mock_anthropic: Mock) -> None: + """Test that omitting http_client does not emit a warning.""" + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + AnthropicLLM(model_name="claude-3-opus-20240229") + + assert not any("Invalid http_client" in str(w.message) for w in caught) + + +def test_anthropic_llm_with_invalid_http_client_warns(mock_anthropic: Mock) -> None: + """Test that an invalid http_client type emits a warning and falls back to + default construction for both clients.""" + with pytest.warns(UserWarning, match="Invalid http_client type"): + AnthropicLLM(model_name="claude-3-opus-20240229", http_client="not-a-client") + + _, sync_kwargs = mock_anthropic.Anthropic.call_args + _, async_kwargs = mock_anthropic.AsyncAnthropic.call_args + assert "http_client" not in sync_kwargs + assert "http_client" not in async_kwargs + + @pytest.mark.asyncio async def test_anthropic_llm_aclose(mock_anthropic: Mock) -> None: mock_anthropic.AsyncAnthropic.return_value.close = AsyncMock()