Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ ENVIRONMENT MANAGEMENT:
- `WeakKeyDictionary` works for caching per-driver since `neo4j.Driver` is hashable
- Neo4j 2026 CREATE VECTOR INDEX syntax: WITH clause must come BEFORE OPTIONS, not after
- E2E tests for SEARCH clause: use `docker compose -f tests/e2e/docker-compose.neo4j2026.yml up -d`
- If `tests/unit/llm/test_anthropic_llm.py` fails with `AttributeError: module 'anthropic' has no attribute 'omit'`, or other unit test files error on missing optional-dependency imports (openai, cohere, etc.) at collection time, the local `.venv` is stale relative to `pyproject.toml` extras. Run `uv sync --all-extras` to fix.

---

Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 21 additions & 2 deletions src/neo4j_graphrag/llm/anthropic_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from __future__ import annotations

import json
import warnings
from typing import (
TYPE_CHECKING,
Any,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
61 changes: 61 additions & 0 deletions tests/unit/llm/test_anthropic_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down
Loading