Skip to content
Merged
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
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
### Added

- `AnthropicLLM` now supports structured output via the `response_format` argument, accepting a Pydantic model or an Anthropic `output_config` dict, alongside `OpenAILLM` and `VertexAILLM`.
- Added `neo4j_graphrag.llm.utils.split_http_client_kwargs`, a shared helper that routes a constructor's `http_client` kwarg to whichever of the sync/async SDK clients it matches. `AnthropicLLM`, `OpenAILLM`, and `AzureOpenAILLM` now all use this single implementation instead of three separately maintained copies of the same logic. Custom subclasses that construct their own SDK clients can call it to get the same behavior.
- Added `neo4j_graphrag.llm.utils.split_http_client_kwargs`, a shared helper that routes a constructor's `http_client` kwarg to whichever of the sync/async SDK clients it matches. `AnthropicLLM`, `OpenAILLM`, and `AzureOpenAILLM` now all use this single implementation instead of three separately maintained copies of the same logic. Custom subclasses that construct their own SDK clients can call it to get the same behavior; it is exported from `neo4j_graphrag.llm` for that purpose.
- Added `BaseAnthropicLLM`, a new base class holding all of `AnthropicLLM`'s shared message-building, schema-conversion, and response-parsing logic, mirroring `BaseOpenAILLM`. Both `BaseAnthropicLLM` and `BaseOpenAILLM` are now exported from `neo4j_graphrag.llm` as documented, supported extension points for subclassing to reach custom Anthropic/OpenAI-compatible endpoints.
- Added an explicit `base_url` keyword parameter to `AnthropicLLM` and `OpenAILLM`, passed through to both the sync and async SDK clients of each.
- `split_http_client_kwargs` now emits a `UserWarning` when the provided `http_client` has a `base_url` configured, since the SDKs ignore it — the LLM constructor's `base_url` parameter is the supported way to change the endpoint.
- Added a new docs page, `docs/source/llm.rst`, describing the `http_client`/`base_url` injection contract shared by `AnthropicLLM` and `OpenAILLM`, with a worked example of subclassing `BaseAnthropicLLM` to reach a custom endpoint.

### Changed

Expand Down
20 changes: 20 additions & 0 deletions docs/source/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,16 @@ LLMBase
:members:


BaseOpenAILLM
-------------

See :ref:`llm-extensibility` for the ``http_client``/``base_url`` extension
contract.

.. autoclass:: neo4j_graphrag.llm.openai_llm.BaseOpenAILLM
:members:


OpenAILLM
---------

Expand Down Expand Up @@ -374,6 +384,16 @@ VertexAILLM
.. autoclass:: neo4j_graphrag.llm.vertexai_llm.VertexAILLM
:members:

BaseAnthropicLLM
----------------

See :ref:`llm-extensibility` for the ``http_client``/``base_url`` extension
contract.

.. autoclass:: neo4j_graphrag.llm.anthropic_llm.BaseAnthropicLLM
:members:


AnthropicLLM
------------

Expand Down
2 changes: 2 additions & 0 deletions docs/source/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Topics
+ :ref:`user-guide-rag`
+ :ref:`user-guide-kg-builder`
+ :ref:`user-guide-pipeline`
+ :ref:`llm-extensibility`
+ :ref:`api-documentation`
+ :ref:`types-documentation`

Expand All @@ -50,6 +51,7 @@ Topics
user_guide_rag.rst
user_guide_kg_builder.rst
user_guide_pipeline.rst
llm.rst
api.rst
types.rst

Expand Down
126 changes: 126 additions & 0 deletions docs/source/llm.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
.. _llm-page:

***********************************************
LLMs
***********************************************

This page gathers LLM-related documentation: configuring the built-in
providers and extending them to reach custom endpoints.

.. _llm-extensibility:

Extending LLMs: BaseAnthropicLLM/BaseOpenAILLM
==============================================

:class:`~neo4j_graphrag.llm.anthropic_llm.BaseAnthropicLLM` and
:class:`~neo4j_graphrag.llm.openai_llm.BaseOpenAILLM` are the shared base classes behind
:class:`~neo4j_graphrag.llm.anthropic_llm.AnthropicLLM` and
:class:`~neo4j_graphrag.llm.openai_llm.OpenAILLM` respectively. They hold all
the provider-agnostic logic (message building, schema conversion, response
parsing, structured output handling) and leave SDK client construction to
their subclasses. This page documents the ``http_client``/``base_url``
injection contract they expose, so you can point either provider at a custom
or self-hosted, API-compatible endpoint.

Both ``AnthropicLLM`` and ``OpenAILLM`` accept two related, independent
constructor settings:

- ``base_url`` (``Optional[str]``): an explicit constructor parameter on both
classes that overrides the default API endpoint. Passed through to both the
sync and async SDK clients (``anthropic.Anthropic`` /
``anthropic.AsyncAnthropic`` for Anthropic, and the equivalent OpenAI SDK
clients for OpenAI).
- ``http_client`` (``Optional[httpx.Client | httpx.AsyncClient]``): an
already-configured ``httpx`` client to use for requests, e.g. to add custom
TLS settings, proxies, or timeouts. Accepted through ``**kwargs`` by both
classes. The concrete class inspects the type of the object you pass: an
``httpx.Client`` is routed to the sync SDK client, and an
``httpx.AsyncClient`` is routed to the async SDK client. Passing something
else emits a ``UserWarning`` (via ``warnings.warn``) and falls back to the
SDK's default client.

Both settings can be used together: ``base_url`` changes where requests go,
while ``http_client`` changes how they're sent. Note that a single
``http_client`` only customizes one direction: calling the other one (e.g.
``ainvoke`` after passing a sync ``httpx.Client``) still works and targets
``base_url``, but through the SDK's default transport.

.. note::

A ``base_url`` configured on the ``httpx`` client itself is ignored (a
``UserWarning`` is emitted when one is detected): both
SDKs build absolute request URLs from their own ``base_url`` and only use
the ``httpx`` client as transport. To change the endpoint, always use the
``base_url`` constructor parameter:

.. code-block:: python

# IGNORED -- the httpx base_url is never used; requests still go to
# the SDK's default endpoint
AnthropicLLM(
model_name="...",
http_client=httpx.Client(base_url="https://my-endpoint"),
)

# WORKS -- requests go to the custom endpoint, with the custom transport
AnthropicLLM(
model_name="...",
base_url="https://my-endpoint",
http_client=httpx.Client(proxy="http://my-proxy:8080"),
)

The sync/async routing is implemented by
:func:`neo4j_graphrag.llm.utils.split_http_client_kwargs`, which is exported
for exactly one reason: a custom subclass that constructs its own SDK clients
should call it too, so it preserves the same routing contract instead of
reintroducing the type-mismatch bug the helper fixes.

Subclassing example
-------------------

Because :class:`~neo4j_graphrag.llm.anthropic_llm.BaseAnthropicLLM` /
:class:`~neo4j_graphrag.llm.openai_llm.BaseOpenAILLM` only require the concrete
subclass to assign ``self.client``/``self.async_client``, you can build your
own thin subclass to reach a custom Anthropic-compatible endpoint with
different defaults or credential handling than the built-in ``AnthropicLLM``:

.. code:: python

from typing import Any, Optional

import anthropic

from neo4j_graphrag.llm import BaseAnthropicLLM
from neo4j_graphrag.llm.utils import split_http_client_kwargs


class MyCustomAnthropicLLM(BaseAnthropicLLM):
"""Talks to a self-hosted, Anthropic-compatible endpoint."""

DEFAULT_ENDPOINT = "https://my-custom-endpoint.example.com"

def __init__(
self,
model_name: str,
model_params: Optional[dict[str, Any]] = None,
**kwargs: Any,
):
super().__init__(model_name=model_name, model_params=model_params, **kwargs)
# Route an optional http_client kwarg to the matching sync/async
# client, exactly as the built-in AnthropicLLM does.
sync_params, async_params = split_http_client_kwargs(kwargs)
sync_params.setdefault("base_url", self.DEFAULT_ENDPOINT)
async_params.setdefault("base_url", self.DEFAULT_ENDPOINT)
self.client = anthropic.Anthropic(**sync_params)
self.async_client = anthropic.AsyncAnthropic(**async_params)


llm = MyCustomAnthropicLLM(model_name="claude-3-opus-20240229")
llm.invoke("Who is the mother of Paul Atreides?")

All of ``invoke``/``ainvoke``, structured-output handling, and message
building are inherited from :class:`~neo4j_graphrag.llm.anthropic_llm.BaseAnthropicLLM` unchanged; the subclass only
needs to decide how ``client``/``async_client`` get constructed.

The same pattern applies to :class:`~neo4j_graphrag.llm.openai_llm.BaseOpenAILLM`
for OpenAI-compatible endpoints.
12 changes: 12 additions & 0 deletions examples/customize/llms/anthropic_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,15 @@
) as llm:
res: LLMResponse = llm.invoke("say something")
print(res.content)

# To reach a custom or self-hosted, Anthropic-compatible endpoint instead of
# Anthropic's default API, pass `base_url`. It's forwarded to both the sync
# and async SDK clients.
with AnthropicLLM(
model_name="claude-3-opus-20240229",
model_params={"max_tokens": 1000},
api_key=api_key,
base_url="https://my-custom-endpoint.example.com",
) as custom_llm:
res = custom_llm.invoke("say something")
print(res.content)
8 changes: 6 additions & 2 deletions src/neo4j_graphrag/llm/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,21 @@
import warnings
from typing import Any

from .anthropic_llm import AnthropicLLM
from .anthropic_llm import AnthropicLLM, BaseAnthropicLLM
from .base import LLMBase, LLMInterface, LLMInterfaceV2
from .bedrock_llm import BedrockLLM
from .cohere_llm import CohereLLM
from .google_genai_llm import GeminiLLM
from .mistralai_llm import MistralAILLM
from .ollama_llm import OllamaLLM
from .openai_llm import AzureOpenAILLM, OpenAILLM
from .openai_llm import AzureOpenAILLM, BaseOpenAILLM, OpenAILLM
from .types import LLMResponse, LLMUsage
from .utils import split_http_client_kwargs
from .vertexai_llm import VertexAILLM

__all__ = [
"AnthropicLLM",
"BaseAnthropicLLM",
"BedrockLLM",
"CohereLLM",
"GeminiLLM",
Expand All @@ -38,9 +40,11 @@
"LLMInterfaceV2",
"OllamaLLM",
"OpenAILLM",
"BaseOpenAILLM",
"VertexAILLM",
"AzureOpenAILLM",
"MistralAILLM",
"split_http_client_kwargs",
]


Expand Down
95 changes: 66 additions & 29 deletions src/neo4j_graphrag/llm/anthropic_llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
# limitations under the License.
from __future__ import annotations

import abc
import json
from typing import (
TYPE_CHECKING,
Expand Down Expand Up @@ -50,7 +51,7 @@
)

if TYPE_CHECKING:
from anthropic import Omit
from anthropic import AsyncAnthropic, Anthropic, Omit
from anthropic.types.message_param import MessageParam


Expand Down Expand Up @@ -168,35 +169,19 @@ def _restore_open_maps(value: Any, schema: dict[str, Any], defs: dict[str, Any])


# pylint: disable=redefined-builtin, arguments-differ, raise-missing-from, no-else-return, import-outside-toplevel
class AnthropicLLM(LLMBase):
Comment thread
stellasia marked this conversation as resolved.
"""Interface for large language models on Anthropic

Args:
model_name (str): Name of the LLM to use.
model_params (Optional[dict], optional): Additional parameters for LLMInterface(V1) passed to the model when text is sent to it. Defaults to None.
system_instruction: Optional[str], optional): Additional instructions for setting the behavior and context for the model in a conversation. Defaults to None.
rate_limit_handler (Optional[RateLimitHandler], optional): Handler for managing rate limits for LLMInterface(V1). Defaults to None.
**kwargs (Any): Arguments passed to the model when for the class is initialised. Defaults to None.

Raises:
LLMGenerationError: If there's an error generating the response from the model.

Example:

.. code-block:: python

from neo4j_graphrag.llm import AnthropicLLM
class BaseAnthropicLLM(LLMBase, abc.ABC):
"""Base class for Anthropic LLMs.

llm = AnthropicLLM(
model_name="claude-3-opus-20240229",
model_params={"max_tokens": 1000},
api_key="sk...", # can also be read from env vars
)
llm.invoke("Who is the mother of Paul Atreides?")
Holds all the shared message-building, schema-conversion, and
response-parsing logic. Subclasses are only responsible for
constructing the ``client``/``async_client`` SDK instances.
"""

supports_structured_output: bool = True

client: Anthropic
async_client: AsyncAnthropic

def __init__(
self,
model_name: str,
Expand All @@ -211,17 +196,14 @@ def __init__(
"""Could not import Anthropic Python client.
Please install it with `pip install "neo4j-graphrag[anthropic]"`."""
)
self.anthropic = anthropic
LLMBase.__init__(
self,
model_name=model_name,
model_params=model_params or {},
rate_limit_handler=rate_limit_handler,
**kwargs,
)
self.anthropic = anthropic
sync_params, async_params = split_http_client_kwargs(kwargs)
self.client = anthropic.Anthropic(**sync_params)
self.async_client = anthropic.AsyncAnthropic(**async_params)

def invoke(
self,
Expand Down Expand Up @@ -530,3 +512,58 @@ def get_messages_v2(
)
)
return system_instruction, messages


class AnthropicLLM(BaseAnthropicLLM):
"""Interface for large language models on Anthropic

Args:
model_name (str): Name of the LLM to use.
model_params (Optional[dict], optional): Additional parameters for LLMInterface(V1) passed to the model when text is sent to it. Defaults to None.
system_instruction: Optional[str], optional): Additional instructions for setting the behavior and context for the model in a conversation. Defaults to None.
rate_limit_handler (Optional[RateLimitHandler], optional): Handler for managing rate limits for LLMInterface(V1). Defaults to None.
base_url (Optional[str], optional): Base URL to use instead of Anthropic's default API
endpoint, e.g. to reach a custom Anthropic-compatible endpoint. Passed through to
both the sync and async SDK clients. Can be combined with an ``http_client``
passed via kwargs (``base_url`` sets where requests go, ``http_client`` how they
are sent); a base URL configured on the httpx client itself is ignored by the
SDK — use this parameter instead. Defaults to None.
**kwargs (Any): Arguments passed to the model when for the class is initialised. Defaults to None.

Raises:
LLMGenerationError: If there's an error generating the response from the model.

Example:

.. code-block:: python

from neo4j_graphrag.llm import AnthropicLLM

llm = AnthropicLLM(
model_name="claude-3-opus-20240229",
model_params={"max_tokens": 1000},
api_key="sk...", # can also be read from env vars
)
llm.invoke("Who is the mother of Paul Atreides?")
"""

def __init__(
self,
model_name: str,
model_params: Optional[dict[str, Any]] = None,
rate_limit_handler: Optional[RateLimitHandler] = None,
base_url: Optional[str] = None,
Comment thread
stellasia marked this conversation as resolved.
**kwargs: Any,
):
super().__init__(
model_name=model_name,
model_params=model_params,
rate_limit_handler=rate_limit_handler,
**kwargs,
)
sync_params, async_params = split_http_client_kwargs(kwargs)
if base_url is not None:
sync_params["base_url"] = base_url
async_params["base_url"] = base_url
self.client = self.anthropic.Anthropic(**sync_params)
self.async_client = self.anthropic.AsyncAnthropic(**async_params)
Loading
Loading