This commit is contained in:
kgod
2026-05-26 21:02:17 +08:00
commit 8697477a53
10000 changed files with 1541403 additions and 0 deletions
@@ -0,0 +1,20 @@
"""`langchain-core` defines the base abstractions for the LangChain ecosystem.
The interfaces for core components like chat models, LLMs, vector stores, retrievers,
and more are defined here. The universal invocation protocol (Runnables) along with
a syntax for combining components are also defined here.
**No third-party integrations are defined here.** The dependencies are kept purposefully
very lightweight.
"""
from langchain_core._api import (
surface_langchain_beta_warnings,
surface_langchain_deprecation_warnings,
)
from langchain_core.version import VERSION
__version__ = VERSION
surface_langchain_deprecation_warnings()
surface_langchain_beta_warnings()
@@ -0,0 +1,87 @@
"""Helper functions for managing the LangChain API.
This module is only relevant for LangChain developers, not for users.
!!! warning
This module and its submodules are for internal use only. Do not use them in your
own code. We may change the API at any time with no warning.
"""
from typing import TYPE_CHECKING
from langchain_core._import_utils import import_attr
if TYPE_CHECKING:
from langchain_core._api.beta_decorator import (
LangChainBetaWarning,
beta,
suppress_langchain_beta_warning,
surface_langchain_beta_warnings,
)
from langchain_core._api.deprecation import (
LangChainDeprecationWarning,
deprecated,
suppress_langchain_deprecation_warning,
surface_langchain_deprecation_warnings,
warn_deprecated,
)
from langchain_core._api.path import as_import_path, get_relative_path
__all__ = (
"LangChainBetaWarning",
"LangChainDeprecationWarning",
"as_import_path",
"beta",
"deprecated",
"get_relative_path",
"suppress_langchain_beta_warning",
"suppress_langchain_deprecation_warning",
"surface_langchain_beta_warnings",
"surface_langchain_deprecation_warnings",
"warn_deprecated",
)
_dynamic_imports = {
"LangChainBetaWarning": "beta_decorator",
"beta": "beta_decorator",
"suppress_langchain_beta_warning": "beta_decorator",
"surface_langchain_beta_warnings": "beta_decorator",
"as_import_path": "path",
"get_relative_path": "path",
"LangChainDeprecationWarning": "deprecation",
"deprecated": "deprecation",
"surface_langchain_deprecation_warnings": "deprecation",
"suppress_langchain_deprecation_warning": "deprecation",
"warn_deprecated": "deprecation",
}
def __getattr__(attr_name: str) -> object:
"""Dynamically import and return an attribute from a submodule.
This function enables lazy loading of API functions from submodules, reducing
initial import time and circular dependency issues.
Args:
attr_name: Name of the attribute to import.
Returns:
The imported attribute object.
Raises:
AttributeError: If the attribute is not a valid dynamic import.
"""
module_name = _dynamic_imports.get(attr_name)
result = import_attr(attr_name, module_name, __spec__.parent)
globals()[attr_name] = result
return result
def __dir__() -> list[str]:
"""Return a list of available attributes for this module.
Returns:
List of attribute names that can be imported from this module.
"""
return list(__all__)
@@ -0,0 +1,253 @@
"""Helper functions for marking parts of the LangChain API as beta.
This module was loosely adapted from matplotlib's [`_api/deprecation.py`](https://github.com/matplotlib/matplotlib/blob/main/lib/matplotlib/_api/deprecation.py)
module.
!!! warning
This module is for internal use only. Do not use it in your own code. We may change
the API at any time with no warning.
"""
import contextlib
import functools
import inspect
import warnings
from collections.abc import Callable, Generator
from typing import Any, TypeVar, cast
from langchain_core._api.internal import is_caller_internal
class LangChainBetaWarning(DeprecationWarning):
"""A class for issuing beta warnings for LangChain users."""
# PUBLIC API
T = TypeVar("T", bound=Callable[..., Any] | type)
def beta(
*,
message: str = "",
name: str = "",
obj_type: str = "",
addendum: str = "",
) -> Callable[[T], T]:
"""Decorator to mark a function, a class, or a property as beta.
When marking a classmethod, a staticmethod, or a property, the `@beta` decorator
should go *under* `@classmethod` and `@staticmethod` (i.e., `beta` should directly
decorate the underlying callable), but *over* `@property`.
When marking a class `C` intended to be used as a base class in a multiple
inheritance hierarchy, `C` *must* define an `__init__` method (if `C` instead
inherited its `__init__` from its own base class, then `@beta` would mess up
`__init__` inheritance when installing its own (annotation-emitting) `C.__init__`).
Args:
message: Override the default beta message.
The %(since)s, %(name)s, %(alternative)s, %(obj_type)s, %(addendum)s, and
%(removal)s format specifiers will be replaced by the values of the
respective arguments passed to this function.
name: The name of the beta object.
obj_type: The object type being beta.
addendum: Additional text appended directly to the final message.
Returns:
A decorator which can be used to mark functions or classes as beta.
Example:
```python
@beta
def the_function_to_annotate():
pass
```
"""
def beta(
obj: T,
*,
_obj_type: str = obj_type,
_name: str = name,
_message: str = message,
_addendum: str = addendum,
) -> T:
"""Implementation of the decorator returned by `beta`."""
def emit_warning() -> None:
"""Emit the warning."""
warn_beta(
message=_message,
name=_name,
obj_type=_obj_type,
addendum=_addendum,
)
warned = False
def warning_emitting_wrapper(*args: Any, **kwargs: Any) -> Any:
"""Wrapper for the original wrapped callable that emits a warning.
Args:
*args: The positional arguments to the function.
**kwargs: The keyword arguments to the function.
Returns:
The return value of the function being wrapped.
"""
nonlocal warned
if not warned and not is_caller_internal():
warned = True
emit_warning()
return wrapped(*args, **kwargs)
async def awarning_emitting_wrapper(*args: Any, **kwargs: Any) -> Any:
"""Same as warning_emitting_wrapper, but for async functions."""
nonlocal warned
if not warned and not is_caller_internal():
warned = True
emit_warning()
return await wrapped(*args, **kwargs)
if isinstance(obj, type):
if not _obj_type:
_obj_type = "class"
wrapped = obj.__init__ # type: ignore[misc]
_name = _name or obj.__qualname__
old_doc = obj.__doc__
def finalize(_: Callable[..., Any], new_doc: str, /) -> T:
"""Finalize the annotation of a class."""
# Can't set new_doc on some extension objects.
with contextlib.suppress(AttributeError):
obj.__doc__ = new_doc
def warn_if_direct_instance(
self: Any, *args: Any, **kwargs: Any
) -> Any:
"""Warn that the class is in beta."""
nonlocal warned
if not warned and type(self) is obj and not is_caller_internal():
warned = True
emit_warning()
return wrapped(self, *args, **kwargs)
obj.__init__ = functools.wraps(obj.__init__)( # type: ignore[misc]
warn_if_direct_instance
)
return obj
elif isinstance(obj, property):
if not _obj_type:
_obj_type = "attribute"
wrapped = None
_name = _name or obj.fget.__qualname__
old_doc = obj.__doc__
def _fget(instance: Any) -> Any:
if instance is not None:
emit_warning()
return obj.fget(instance)
def _fset(instance: Any, value: Any) -> None:
if instance is not None:
emit_warning()
obj.fset(instance, value)
def _fdel(instance: Any) -> None:
if instance is not None:
emit_warning()
obj.fdel(instance)
def finalize(_: Callable[..., Any], new_doc: str, /) -> Any:
"""Finalize the property."""
return property(fget=_fget, fset=_fset, fdel=_fdel, doc=new_doc)
else:
_name = _name or obj.__qualname__
if not _obj_type:
# edge case: when a function is within another function
# within a test, this will call it a "method" not a "function"
_obj_type = "function" if "." not in _name else "method"
wrapped = obj
old_doc = wrapped.__doc__
def finalize(wrapper: Callable[..., Any], new_doc: str, /) -> T:
"""Wrap the wrapped function using the wrapper and update the docstring.
Args:
wrapper: The wrapper function.
new_doc: The new docstring.
Returns:
The wrapped function.
"""
wrapper = functools.wraps(wrapped)(wrapper)
wrapper.__doc__ = new_doc
return cast("T", wrapper)
old_doc = inspect.cleandoc(old_doc or "").strip("\n") or ""
components = [message, addendum]
details = " ".join([component.strip() for component in components if component])
new_doc = f".. beta::\n {details}\n\n{old_doc}\n"
if inspect.iscoroutinefunction(obj):
return finalize(awarning_emitting_wrapper, new_doc)
return finalize(warning_emitting_wrapper, new_doc)
return beta
@contextlib.contextmanager
def suppress_langchain_beta_warning() -> Generator[None, None, None]:
"""Context manager to suppress `LangChainDeprecationWarning`."""
with warnings.catch_warnings():
warnings.simplefilter("ignore", LangChainBetaWarning)
yield
def warn_beta(
*,
message: str = "",
name: str = "",
obj_type: str = "",
addendum: str = "",
) -> None:
"""Display a standardized beta annotation.
Args:
message: Override the default beta message.
The %(name)s, %(obj_type)s, %(addendum)s format specifiers will be replaced
by the values of the respective arguments passed to this function.
name: The name of the annotated object.
obj_type: The object type being annotated.
addendum: Additional text appended directly to the final message.
"""
if not message:
message = ""
if obj_type:
message += f"The {obj_type} `{name}`"
else:
message += f"`{name}`"
message += " is in beta. It is actively being worked on, so the API may change."
if addendum:
message += f" {addendum}"
warning = LangChainBetaWarning(message)
warnings.warn(warning, category=LangChainBetaWarning, stacklevel=4)
def surface_langchain_beta_warnings() -> None:
"""Unmute LangChain beta warnings."""
warnings.filterwarnings(
"default",
category=LangChainBetaWarning,
)
@@ -0,0 +1,617 @@
"""Helper functions for deprecating parts of the LangChain API.
This module was adapted from matplotlib's [`_api/deprecation.py`](https://github.com/matplotlib/matplotlib/blob/main/lib/matplotlib/_api/deprecation.py)
module.
!!! warning
This module is for internal use only. Do not use it in your own code. We may change
the API at any time with no warning.
"""
import contextlib
import functools
import inspect
import sys
import warnings
from collections.abc import Callable, Generator
from typing import (
TYPE_CHECKING,
Any,
ParamSpec,
TypeGuard,
TypeVar,
cast,
)
from pydantic.fields import FieldInfo
from langchain_core._api.internal import is_caller_internal
if TYPE_CHECKING:
from pydantic.v1.fields import FieldInfo as FieldInfoV1
def _is_pydantic_v1_field_info(obj: Any) -> TypeGuard["FieldInfoV1"]:
"""Check if `obj` is a `pydantic.v1.fields.FieldInfo` without forcing import.
Importing `pydantic.v1` emits a `UserWarning` on Python 3.14+. Skipping the
import entirely when no caller has constructed a v1 `FieldInfo` keeps that
warning out of `langchain_core`'s import path. If a caller did construct one,
`pydantic.v1.fields` is already in `sys.modules` and isinstance is safe.
"""
mod = sys.modules.get("pydantic.v1.fields")
if mod is None:
return False
return isinstance(obj, mod.FieldInfo)
def _build_deprecation_message(
*,
alternative: str = "",
alternative_import: str = "",
) -> str:
"""Build a simple deprecation message for `__deprecated__` attribute.
Args:
alternative: An alternative API name.
alternative_import: A fully qualified import path for the alternative.
Returns:
A deprecation message string for IDE/type checker display.
"""
if alternative_import:
return f"Use {alternative_import} instead."
if alternative:
return f"Use {alternative} instead."
return "Deprecated."
class LangChainDeprecationWarning(DeprecationWarning):
"""A class for issuing deprecation warnings for LangChain users."""
class LangChainPendingDeprecationWarning(PendingDeprecationWarning):
"""A class for issuing deprecation warnings for LangChain users."""
# PUBLIC API
# Bound is `Any` (not `FieldInfoV1`) because importing `pydantic.v1` at module
# scope emits a `UserWarning` on Python 3.14+; v1 `FieldInfo` support is handled
# at runtime via `_is_pydantic_v1_field_info`.
T = TypeVar("T", bound=type | Callable[..., Any] | Any)
def _validate_deprecation_params(
removal: str,
alternative: str,
alternative_import: str,
*,
pending: bool,
) -> None:
"""Validate the deprecation parameters."""
if pending and removal:
msg = "A pending deprecation cannot have a scheduled removal"
raise ValueError(msg)
if alternative and alternative_import:
msg = "Cannot specify both alternative and alternative_import"
raise ValueError(msg)
if alternative_import and "." not in alternative_import:
msg = (
"alternative_import must be a fully qualified module path. Got "
f" {alternative_import}"
)
raise ValueError(msg)
def deprecated(
since: str,
*,
message: str = "",
name: str = "",
alternative: str = "",
alternative_import: str = "",
pending: bool = False,
obj_type: str = "",
addendum: str = "",
removal: str = "",
package: str = "",
) -> Callable[[T], T]:
"""Decorator to mark a function, a class, or a property as deprecated.
When deprecating a classmethod, a staticmethod, or a property, the `@deprecated`
decorator should go *under* `@classmethod` and `@staticmethod` (i.e., `deprecated`
should directly decorate the underlying callable), but *over* `@property`.
When deprecating a class `C` intended to be used as a base class in a multiple
inheritance hierarchy, `C` *must* define an `__init__` method (if `C` instead
inherited its `__init__` from its own base class, then `@deprecated` would mess up
`__init__` inheritance when installing its own (deprecation-emitting) `C.__init__`).
Parameters are the same as for `warn_deprecated`, except that *obj_type* defaults to
'class' if decorating a class, 'attribute' if decorating a property, and 'function'
otherwise.
Args:
since: The release at which this API became deprecated.
message: Override the default deprecation message.
The `%(since)s`, `%(name)s`, `%(alternative)s`, `%(obj_type)s`,
`%(addendum)s`, and `%(removal)s` format specifiers will be replaced by the
values of the respective arguments passed to this function.
name: The name of the deprecated object.
alternative: An alternative API that the user may use in place of the deprecated
API.
The deprecation warning will tell the user about this alternative if
provided.
alternative_import: An alternative import that the user may use instead.
pending: If `True`, uses a `PendingDeprecationWarning` instead of a
`DeprecationWarning`.
Cannot be used together with removal.
obj_type: The object type being deprecated.
addendum: Additional text appended directly to the final message.
removal: The expected removal version.
With the default (an empty string), no removal version is shown in the
warning message.
Cannot be used together with pending.
package: The package of the deprecated object.
Returns:
A decorator to mark a function or class as deprecated.
Example:
```python
@deprecated("1.4.0")
def the_function_to_deprecate():
pass
```
"""
_validate_deprecation_params(
removal, alternative, alternative_import, pending=pending
)
def deprecate(
obj: T,
*,
_obj_type: str = obj_type,
_name: str = name,
_message: str = message,
_alternative: str = alternative,
_alternative_import: str = alternative_import,
_pending: bool = pending,
_addendum: str = addendum,
_package: str = package,
) -> T:
"""Implementation of the decorator returned by `deprecated`."""
def emit_warning() -> None:
"""Emit the warning."""
warn_deprecated(
since,
message=_message,
name=_name,
alternative=_alternative,
alternative_import=_alternative_import,
pending=_pending,
obj_type=_obj_type,
addendum=_addendum,
removal=removal,
package=_package,
)
warned = False
def warning_emitting_wrapper(*args: Any, **kwargs: Any) -> Any:
"""Wrapper for the original wrapped callable that emits a warning.
Args:
*args: The positional arguments to the function.
**kwargs: The keyword arguments to the function.
Returns:
The return value of the function being wrapped.
"""
nonlocal warned
if not warned and not is_caller_internal():
warned = True
emit_warning()
return wrapped(*args, **kwargs)
async def awarning_emitting_wrapper(*args: Any, **kwargs: Any) -> Any:
"""Same as warning_emitting_wrapper, but for async functions."""
nonlocal warned
if not warned and not is_caller_internal():
warned = True
emit_warning()
return await wrapped(*args, **kwargs)
_package = _package or obj.__module__.split(".")[0].replace("_", "-")
if isinstance(obj, type):
if not _obj_type:
_obj_type = "class"
wrapped = obj.__init__ # type: ignore[misc]
_name = _name or obj.__qualname__
old_doc = obj.__doc__
def finalize(_: Callable[..., Any], new_doc: str, /) -> T:
"""Finalize the deprecation of a class."""
# Can't set new_doc on some extension objects.
with contextlib.suppress(AttributeError):
obj.__doc__ = new_doc
def warn_if_direct_instance(
self: Any, *args: Any, **kwargs: Any
) -> Any:
"""Warn that the class is in beta."""
nonlocal warned
if not warned and type(self) is obj and not is_caller_internal():
warned = True
emit_warning()
return wrapped(self, *args, **kwargs)
obj.__init__ = functools.wraps(obj.__init__)( # type: ignore[misc]
warn_if_direct_instance
)
# Set __deprecated__ for PEP 702 (IDE/type checker support)
obj.__deprecated__ = _build_deprecation_message( # type: ignore[attr-defined]
alternative=alternative,
alternative_import=alternative_import,
)
return obj
elif _is_pydantic_v1_field_info(obj):
wrapped = None
if not _obj_type:
_obj_type = "attribute"
if not _name:
msg = f"Field {obj} must have a name to be deprecated."
raise ValueError(msg)
old_doc = obj.description
def finalize(_: Callable[..., Any], new_doc: str, /) -> T:
from pydantic.v1.fields import FieldInfo as FieldInfoV1 # noqa: PLC0415
return cast(
"T",
FieldInfoV1(
default=obj.default,
default_factory=obj.default_factory,
description=new_doc,
alias=obj.alias,
exclude=obj.exclude,
),
)
elif isinstance(obj, FieldInfo):
wrapped = None
if not _obj_type:
_obj_type = "attribute"
if not _name:
msg = f"Field {obj} must have a name to be deprecated."
raise ValueError(msg)
old_doc = obj.description
def finalize(_: Callable[..., Any], new_doc: str, /) -> T:
return cast(
"T",
FieldInfo(
default=obj.default,
default_factory=obj.default_factory,
description=new_doc,
alias=obj.alias,
exclude=obj.exclude,
),
)
elif isinstance(obj, property):
if not _obj_type:
_obj_type = "attribute"
wrapped = None
_name = _name or cast("type | Callable", obj.fget).__qualname__
old_doc = obj.__doc__
class _DeprecatedProperty(property):
"""A deprecated property."""
def __init__(
self,
fget: Callable[[Any], Any] | None = None,
fset: Callable[[Any, Any], None] | None = None,
fdel: Callable[[Any], None] | None = None,
doc: str | None = None,
) -> None:
super().__init__(fget, fset, fdel, doc)
self.__orig_fget = fget
self.__orig_fset = fset
self.__orig_fdel = fdel
def __get__(self, instance: Any, owner: type | None = None) -> Any:
if instance is not None or owner is not None:
emit_warning()
if self.fget is None:
return None
return self.fget(instance)
def __set__(self, instance: Any, value: Any) -> None:
if instance is not None:
emit_warning()
if self.fset is not None:
self.fset(instance, value)
def __delete__(self, instance: Any) -> None:
if instance is not None:
emit_warning()
if self.fdel is not None:
self.fdel(instance)
def __set_name__(self, owner: type | None, set_name: str) -> None:
nonlocal _name
if _name == "<lambda>":
_name = set_name
def finalize(_: Callable[..., Any], new_doc: str, /) -> T:
"""Finalize the property."""
prop = _DeprecatedProperty(
fget=obj.fget, fset=obj.fset, fdel=obj.fdel, doc=new_doc
)
# Set __deprecated__ for PEP 702 (IDE/type checker support)
prop.__deprecated__ = _build_deprecation_message( # type: ignore[attr-defined]
alternative=alternative,
alternative_import=alternative_import,
)
return cast("T", prop)
else:
_name = _name or cast("type | Callable", obj).__qualname__
if not _obj_type:
# edge case: when a function is within another function
# within a test, this will call it a "method" not a "function"
_obj_type = "function" if "." not in _name else "method"
wrapped = obj
old_doc = wrapped.__doc__
def finalize(wrapper: Callable[..., Any], new_doc: str, /) -> T:
"""Wrap the wrapped function using the wrapper and update the docstring.
Args:
wrapper: The wrapper function.
new_doc: The new docstring.
Returns:
The wrapped function.
"""
wrapper = functools.wraps(wrapped)(wrapper)
wrapper.__doc__ = new_doc
# Set __deprecated__ for PEP 702 (IDE/type checker support)
wrapper.__deprecated__ = _build_deprecation_message( # type: ignore[attr-defined]
alternative=alternative,
alternative_import=alternative_import,
)
return cast("T", wrapper)
old_doc = inspect.cleandoc(old_doc or "").strip("\n")
# old_doc can be None
if not old_doc:
old_doc = ""
# Modify the docstring to include a deprecation notice.
if (
_alternative
and _alternative.rsplit(".", maxsplit=1)[-1].lower()
== _alternative.rsplit(".", maxsplit=1)[-1]
) or _alternative:
_alternative = f"`{_alternative}`"
if (
_alternative_import
and _alternative_import.rsplit(".", maxsplit=1)[-1].lower()
== _alternative_import.rsplit(".", maxsplit=1)[-1]
) or _alternative_import:
_alternative_import = f"`{_alternative_import}`"
components = [
_message,
f"Use {_alternative} instead." if _alternative else "",
f"Use {_alternative_import} instead." if _alternative_import else "",
_addendum,
]
details = " ".join([component.strip() for component in components if component])
package = _package or (
_name.split(".")[0].replace("_", "-") if "." in _name else None
)
if removal:
if removal.startswith("1.") and package and package.startswith("langchain"):
removal_str = f"It will not be removed until {package}=={removal}."
else:
removal_str = f"It will be removed in {package}=={removal}."
else:
removal_str = ""
new_doc = f"""\
!!! deprecated "{since} {details} {removal_str}"
{old_doc}\
"""
if inspect.iscoroutinefunction(obj):
return finalize(awarning_emitting_wrapper, new_doc)
return finalize(warning_emitting_wrapper, new_doc)
return deprecate
@contextlib.contextmanager
def suppress_langchain_deprecation_warning() -> Generator[None, None, None]:
"""Context manager to suppress `LangChainDeprecationWarning`."""
with warnings.catch_warnings():
warnings.simplefilter("ignore", LangChainDeprecationWarning)
warnings.simplefilter("ignore", LangChainPendingDeprecationWarning)
yield
def warn_deprecated(
since: str,
*,
message: str = "",
name: str = "",
alternative: str = "",
alternative_import: str = "",
pending: bool = False,
obj_type: str = "",
addendum: str = "",
removal: str = "",
package: str = "",
) -> None:
"""Display a standardized deprecation.
Args:
since: The release at which this API became deprecated.
message: Override the default deprecation message.
The `%(since)s`, `%(name)s`, `%(alternative)s`, `%(obj_type)s`,
`%(addendum)s`, and `%(removal)s` format specifiers will be replaced by the
values of the respective arguments passed to this function.
name: The name of the deprecated object.
alternative: An alternative API that the user may use in place of the
deprecated API.
The deprecation warning will tell the user about this alternative if
provided.
alternative_import: An alternative import that the user may use instead.
pending: If `True`, uses a `PendingDeprecationWarning` instead of a
`DeprecationWarning`.
Cannot be used together with removal.
obj_type: The object type being deprecated.
addendum: Additional text appended directly to the final message.
removal: The expected removal version.
With the default (an empty string), no removal version is shown in the
warning message.
Cannot be used together with pending.
package: The package of the deprecated object.
"""
if not pending and removal:
removal = f"in {removal}"
if not message:
message = ""
package_ = (
package or name.split(".", maxsplit=1)[0].replace("_", "-")
if "." in name
else "LangChain"
)
if obj_type:
message += f"The {obj_type} `{name}`"
else:
message += f"`{name}`"
if pending:
message += " will be deprecated in a future version"
else:
message += f" was deprecated in {package_} {since}"
if removal:
message += f" and will be removed {removal}"
if alternative_import:
alt_package = alternative_import.split(".", maxsplit=1)[0].replace("_", "-")
if alt_package == package_:
message += f". Use {alternative_import} instead."
else:
alt_module, alt_name = alternative_import.rsplit(".", 1)
message += (
f". An updated version of the {obj_type} exists in the "
f"{alt_package} package and should be used instead. To use it run "
f"`pip install -U {alt_package}` and import as "
f"`from {alt_module} import {alt_name}`."
)
elif alternative:
message += f". Use {alternative} instead."
if addendum:
message += f" {addendum}"
warning_cls = (
LangChainPendingDeprecationWarning if pending else LangChainDeprecationWarning
)
warning = warning_cls(message)
warnings.warn(warning, category=LangChainDeprecationWarning, stacklevel=4)
def surface_langchain_deprecation_warnings() -> None:
"""Unmute LangChain deprecation warnings."""
warnings.filterwarnings(
"default",
category=LangChainPendingDeprecationWarning,
)
warnings.filterwarnings(
"default",
category=LangChainDeprecationWarning,
)
_P = ParamSpec("_P")
_R = TypeVar("_R")
def rename_parameter(
*,
since: str,
removal: str,
old: str,
new: str,
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
"""Decorator indicating that parameter *old* of *func* is renamed to *new*.
The actual implementation of *func* should use *new*, not *old*. If *old* is passed
to *func*, a `DeprecationWarning` is emitted, and its value is used, even if *new*
is also passed by keyword.
Args:
since: The version in which the parameter was renamed.
removal: The version in which the old parameter will be removed.
old: The old parameter name.
new: The new parameter name.
Returns:
A decorator indicating that a parameter was renamed.
Example:
```python
@_api.rename_parameter("3.1", "bad_name", "good_name")
def func(good_name): ...
```
"""
def decorator(f: Callable[_P, _R]) -> Callable[_P, _R]:
@functools.wraps(f)
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
if new in kwargs and old in kwargs:
msg = f"{f.__name__}() got multiple values for argument {new!r}"
raise TypeError(msg)
if old in kwargs:
warn_deprecated(
since,
removal=removal,
message=f"The parameter `{old}` of `{f.__name__}` was "
f"deprecated in {since} and will be removed "
f"in {removal} Use `{new}` instead.",
)
kwargs[new] = kwargs.pop(old)
return f(*args, **kwargs)
return wrapper
return decorator
@@ -0,0 +1,23 @@
import inspect
from typing import cast
def is_caller_internal(depth: int = 2) -> bool:
"""Return whether the caller at `depth` of this function is internal."""
try:
frame = inspect.currentframe()
except AttributeError:
return False
if frame is None:
return False
try:
for _ in range(depth):
frame = frame.f_back
if frame is None:
return False
# Directly access the module name from the frame's global variables
module_globals = frame.f_globals
caller_module_name = cast("str", module_globals.get("__name__", ""))
return caller_module_name.startswith("langchain")
finally:
del frame
@@ -0,0 +1,50 @@
import os
from pathlib import Path
HERE = Path(__file__).parent
# Get directory of langchain package
PACKAGE_DIR = HERE.parent
SEPARATOR = os.sep
def get_relative_path(file: Path | str, *, relative_to: Path = PACKAGE_DIR) -> str:
"""Get the path of the file as a relative path to the package directory.
Args:
file: The file path to convert.
relative_to: The base path to make the file path relative to.
Returns:
The relative path as a string.
"""
if isinstance(file, str):
file = Path(file)
return str(file.relative_to(relative_to))
def as_import_path(
file: Path | str,
*,
suffix: str | None = None,
relative_to: Path = PACKAGE_DIR,
) -> str:
"""Path of the file as a LangChain import exclude langchain top namespace.
Args:
file: The file path to convert.
suffix: An optional suffix to append to the import path.
relative_to: The base path to make the file path relative to.
Returns:
The import path as a string.
"""
if isinstance(file, str):
file = Path(file)
path = get_relative_path(file, relative_to=relative_to)
if file.is_file():
path = path[: -len(file.suffix)]
import_path = path.replace(SEPARATOR, ".")
if suffix:
import_path += "." + suffix
return import_path
@@ -0,0 +1,41 @@
from importlib import import_module
def import_attr(
attr_name: str,
module_name: str | None,
package: str | None,
) -> object:
"""Import an attribute from a module located in a package.
This utility function is used in custom `__getattr__` methods within `__init__.py`
files to dynamically import attributes.
Args:
attr_name: The name of the attribute to import.
module_name: The name of the module to import from.
If `None`, the attribute is imported from the package itself.
package: The name of the package where the module is located.
Raises:
ImportError: If the module cannot be found.
AttributeError: If the attribute does not exist in the module or package.
Returns:
The imported attribute.
"""
if module_name == "__module__" or module_name is None:
try:
result = import_module(f".{attr_name}", package=package)
except ModuleNotFoundError:
msg = f"module '{package!r}' has no attribute {attr_name!r}"
raise AttributeError(msg) from None
else:
try:
module = import_module(f".{module_name}", package=package)
except ModuleNotFoundError as err:
msg = f"module '{package!r}.{module_name!r}' not found ({err})"
raise ImportError(msg) from None
result = getattr(module, attr_name)
return result
@@ -0,0 +1,36 @@
"""SSRF protection and security utilities.
This is an **internal** module (note the `_security` prefix). It is NOT part of
the public `langchain-core` API and may change or be removed at any time without
notice. External code should not import from or depend on anything in this
module. Any vulnerability reports should target the public APIs that use these
utilities, not this internal module directly.
"""
from langchain_core._security._exceptions import SSRFBlockedError
from langchain_core._security._policy import (
SSRFPolicy,
validate_hostname,
validate_resolved_ip,
validate_url,
validate_url_sync,
)
from langchain_core._security._transport import (
SSRFSafeSyncTransport,
SSRFSafeTransport,
ssrf_safe_async_client,
ssrf_safe_client,
)
__all__ = [
"SSRFBlockedError",
"SSRFPolicy",
"SSRFSafeSyncTransport",
"SSRFSafeTransport",
"ssrf_safe_async_client",
"ssrf_safe_client",
"validate_hostname",
"validate_resolved_ip",
"validate_url",
"validate_url_sync",
]
@@ -0,0 +1,9 @@
"""SSRF protection exceptions."""
class SSRFBlockedError(Exception):
"""Raised when a request is blocked by SSRF protection policy."""
def __init__(self, reason: str) -> None:
self.reason = reason
super().__init__(f"SSRF blocked: {reason}")
@@ -0,0 +1,306 @@
"""SSRF protection policy with IP validation and DNS-aware URL checking."""
import asyncio
import dataclasses
import ipaddress
import os
import socket
import urllib.parse
from langchain_core._security._exceptions import SSRFBlockedError
# ---------------------------------------------------------------------------
# Blocklist constants
# ---------------------------------------------------------------------------
_BLOCKED_IPV4_NETWORKS: tuple[ipaddress.IPv4Network, ...] = tuple(
ipaddress.IPv4Network(n)
for n in (
"10.0.0.0/8", # RFC 1918 - private class A
"172.16.0.0/12", # RFC 1918 - private class B
"192.168.0.0/16", # RFC 1918 - private class C
"127.0.0.0/8", # RFC 1122 - loopback
"169.254.0.0/16", # RFC 3927 - link-local
"0.0.0.0/8", # RFC 1122 - "this network"
"100.64.0.0/10", # RFC 6598 - shared/CGN address space
"192.0.0.0/24", # RFC 6890 - IETF protocol assignments
"192.0.2.0/24", # RFC 5737 - TEST-NET-1 (documentation)
"198.18.0.0/15", # RFC 2544 - benchmarking
"198.51.100.0/24", # RFC 5737 - TEST-NET-2 (documentation)
"203.0.113.0/24", # RFC 5737 - TEST-NET-3 (documentation)
"224.0.0.0/4", # RFC 5771 - multicast
"240.0.0.0/4", # RFC 1112 - reserved for future use
"255.255.255.255/32", # RFC 919 - limited broadcast
)
)
_BLOCKED_IPV6_NETWORKS: tuple[ipaddress.IPv6Network, ...] = tuple(
ipaddress.IPv6Network(n)
for n in (
"::1/128", # RFC 4291 - loopback
"fc00::/7", # RFC 4193 - unique local addresses (ULA)
"fe80::/10", # RFC 4291 - link-local
"ff00::/8", # RFC 4291 - multicast
"::ffff:0:0/96", # RFC 4291 - IPv4-mapped IPv6 addresses
"::0.0.0.0/96", # RFC 4291 - IPv4-compatible IPv6 (deprecated)
"64:ff9b::/96", # RFC 6052 - NAT64 well-known prefix
"64:ff9b:1::/48", # RFC 8215 - NAT64 discovery prefix
)
)
_CLOUD_METADATA_IPS: frozenset[str] = frozenset(
{
"169.254.169.254", # AWS, GCP, Azure, DigitalOcean, Oracle Cloud
"169.254.170.2", # AWS ECS task metadata
"169.254.170.23", # AWS EKS Pod Identity Agent
"100.100.100.200", # Alibaba Cloud metadata
"fd00:ec2::254", # AWS EC2 IMDSv2 over IPv6 (Nitro instances)
"fd00:ec2::23", # AWS EKS Pod Identity Agent (IPv6)
"fe80::a9fe:a9fe", # OpenStack Nova metadata (IPv6 link-local)
}
)
# Network ranges that are always blocked when block_cloud_metadata=True,
# independent of block_private_ips. The entire link-local range is used by
# cloud metadata services across providers.
_CLOUD_METADATA_NETWORKS: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...] = (
ipaddress.IPv4Network("169.254.0.0/16"),
)
_CLOUD_METADATA_HOSTNAMES: frozenset[str] = frozenset(
{
"metadata.google.internal",
"metadata.amazonaws.com",
"metadata",
"instance-data",
}
)
_LOCALHOST_NAMES: frozenset[str] = frozenset(
{
"localhost",
"localhost.localdomain",
"host.docker.internal",
}
)
_K8S_SUFFIX = ".svc.cluster.local"
_LOOPBACK_IPV4 = ipaddress.IPv4Network("127.0.0.0/8")
_LOOPBACK_IPV6 = ipaddress.IPv6Address("::1")
# NAT64 well-known prefixes
_NAT64_PREFIX = ipaddress.IPv6Network("64:ff9b::/96")
_NAT64_DISCOVERY_PREFIX = ipaddress.IPv6Network("64:ff9b:1::/48")
# ---------------------------------------------------------------------------
# SSRFPolicy
# ---------------------------------------------------------------------------
@dataclasses.dataclass(frozen=True)
class SSRFPolicy:
"""Immutable policy controlling which URLs/IPs are considered safe."""
allowed_schemes: frozenset[str] = frozenset({"http", "https"})
block_private_ips: bool = True
block_localhost: bool = True
block_cloud_metadata: bool = True
block_k8s_internal: bool = True
allowed_hosts: frozenset[str] = frozenset()
additional_blocked_cidrs: tuple[
ipaddress.IPv4Network | ipaddress.IPv6Network, ...
] = ()
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _extract_embedded_ipv4(
addr: ipaddress.IPv6Address,
) -> ipaddress.IPv4Address | None:
"""Extract an embedded IPv4 from IPv4-mapped or NAT64 IPv6 addresses."""
# Check ipv4_mapped first (covers ::ffff:x.x.x.x)
if addr.ipv4_mapped is not None:
return addr.ipv4_mapped
# Check NAT64 prefixes — embedded IPv4 is in the last 4 bytes
if addr in _NAT64_PREFIX or addr in _NAT64_DISCOVERY_PREFIX:
raw = addr.packed
return ipaddress.IPv4Address(raw[-4:])
return None
def _ip_in_blocked_networks(
addr: ipaddress.IPv4Address | ipaddress.IPv6Address,
policy: SSRFPolicy,
) -> str | None:
"""Return a reason string if *addr* falls in a blocked range, else None."""
# NOTE: if profiling shows this is a hot path, consider memoising with
# @functools.lru_cache (key on (addr, id(policy))).
if isinstance(addr, ipaddress.IPv4Address):
if policy.block_private_ips:
for net in _BLOCKED_IPV4_NETWORKS:
if addr in net:
return "private IP range"
for net in policy.additional_blocked_cidrs: # type: ignore[assignment]
if isinstance(net, ipaddress.IPv4Network) and addr in net:
return "blocked CIDR"
else:
if policy.block_private_ips:
for net in _BLOCKED_IPV6_NETWORKS: # type: ignore[assignment]
if addr in net:
return "private IP range"
for net in policy.additional_blocked_cidrs: # type: ignore[assignment]
if isinstance(net, ipaddress.IPv6Network) and addr in net:
return "blocked CIDR"
# Loopback check — independent of block_private_ips so that
# block_localhost=True still catches 127.x.x.x / ::1 even when
# private IPs are allowed.
if policy.block_localhost:
if isinstance(addr, ipaddress.IPv4Address) and (
addr in _LOOPBACK_IPV4 or addr in ipaddress.IPv4Network("0.0.0.0/8")
):
return "localhost address"
if isinstance(addr, ipaddress.IPv6Address) and addr == _LOOPBACK_IPV6:
return "localhost address"
# Cloud metadata check — IP set *and* network ranges (e.g. 169.254.0.0/16).
# Independent of block_private_ips so that allow_private=True still blocks
# cloud metadata endpoints.
if policy.block_cloud_metadata:
if str(addr) in _CLOUD_METADATA_IPS:
return "cloud metadata endpoint"
for net in _CLOUD_METADATA_NETWORKS: # type: ignore[assignment]
if addr in net:
return "cloud metadata endpoint"
return None
# ---------------------------------------------------------------------------
# Public validation functions
# ---------------------------------------------------------------------------
def validate_resolved_ip(ip_str: str, policy: SSRFPolicy) -> None:
"""Validate a resolved IP address against the SSRF policy.
Raises SSRFBlockedError if the IP is blocked.
"""
try:
addr = ipaddress.ip_address(ip_str)
except ValueError as exc:
raise SSRFBlockedError("invalid IP address") from exc
if isinstance(addr, ipaddress.IPv6Address):
inner = _extract_embedded_ipv4(addr)
if inner is not None:
addr = inner
reason = _ip_in_blocked_networks(addr, policy)
if reason is not None:
raise SSRFBlockedError(reason)
def validate_hostname(hostname: str, policy: SSRFPolicy) -> None:
"""Validate a hostname against the SSRF policy.
Raises SSRFBlockedError if the hostname is blocked.
"""
lower = hostname.lower()
if policy.block_localhost and lower in _LOCALHOST_NAMES:
raise SSRFBlockedError("localhost address")
if policy.block_cloud_metadata and lower in _CLOUD_METADATA_HOSTNAMES:
raise SSRFBlockedError("cloud metadata endpoint")
if policy.block_k8s_internal and lower.endswith(_K8S_SUFFIX):
raise SSRFBlockedError("Kubernetes internal DNS")
def _effective_allowed_hosts(policy: SSRFPolicy) -> frozenset[str]:
"""Return allowed_hosts, augmented for local environments."""
extra: set[str] = set()
if os.environ.get("LANGCHAIN_ENV", "").startswith("local"):
extra.update({"localhost", "testserver"})
if extra:
return policy.allowed_hosts | frozenset(extra)
return policy.allowed_hosts
async def validate_url(url: str, policy: SSRFPolicy = SSRFPolicy()) -> None:
"""Validate a URL against the SSRF policy, including DNS resolution.
This is the primary entry-point for async code paths. It delegates
scheme/hostname/allowed-hosts checks to `validate_url_sync`, then
resolves DNS and validates every resolved IP.
Raises:
SSRFBlockedError: If the URL violates the policy.
"""
parsed = urllib.parse.urlparse(url)
hostname = parsed.hostname or ""
validate_url_sync(url, policy)
allowed = {h.lower() for h in _effective_allowed_hosts(policy)}
if hostname.lower() in allowed:
return
scheme = (parsed.scheme or "").lower()
port = parsed.port or (443 if scheme == "https" else 80)
try:
addrinfo = await asyncio.to_thread(
socket.getaddrinfo, hostname, port, type=socket.SOCK_STREAM
)
except socket.gaierror as exc:
msg = "DNS resolution failed"
raise SSRFBlockedError(msg) from exc
for _family, _type, _proto, _canonname, sockaddr in addrinfo:
validate_resolved_ip(str(sockaddr[0]), policy)
def validate_url_sync(url: str, policy: SSRFPolicy = SSRFPolicy()) -> None:
"""Synchronous URL validation (no DNS resolution).
Suitable for Pydantic validators and other sync contexts. Checks scheme
and hostname patterns only - use `validate_url` for full DNS-aware checking.
Raises:
SSRFBlockedError: If the URL violates the policy.
"""
parsed = urllib.parse.urlparse(url)
scheme = (parsed.scheme or "").lower()
if scheme not in policy.allowed_schemes:
msg = f"scheme '{scheme}' not allowed"
raise SSRFBlockedError(msg)
hostname = parsed.hostname
if not hostname:
msg = "missing hostname"
raise SSRFBlockedError(msg)
allowed = _effective_allowed_hosts(policy)
if hostname.lower() in {h.lower() for h in allowed}:
return
try:
ipaddress.ip_address(hostname)
validate_resolved_ip(hostname, policy)
except SSRFBlockedError:
raise
except ValueError:
pass
else:
return
validate_hostname(hostname, policy)
@@ -0,0 +1,155 @@
"""SSRF Protection - thin wrapper raising ValueError for internal callers.
Delegates all validation to `langchain_core._security._policy`.
"""
import os
import socket
from typing import Annotated, Any
from urllib.parse import urlparse
from pydantic import (
AnyHttpUrl,
BeforeValidator,
HttpUrl,
)
from langchain_core._security._exceptions import SSRFBlockedError
from langchain_core._security._policy import (
SSRFPolicy,
)
from langchain_core._security._policy import (
validate_resolved_ip as _validate_resolved_ip,
)
from langchain_core._security._policy import (
validate_url_sync as _validate_url_sync,
)
def _policy_for(*, allow_private: bool, allow_http: bool) -> SSRFPolicy:
"""Build an `SSRFPolicy` from the legacy flag interface."""
schemes = frozenset({"http", "https"}) if allow_http else frozenset({"https"})
return SSRFPolicy(
allowed_schemes=schemes,
block_private_ips=not allow_private,
block_localhost=not allow_private,
block_cloud_metadata=True,
block_k8s_internal=True,
)
def validate_safe_url(
url: str | AnyHttpUrl,
*,
allow_private: bool = False,
allow_http: bool = True,
) -> str:
"""Validate a URL for SSRF protection.
This function validates URLs to prevent Server-Side Request Forgery (SSRF) attacks
by blocking requests to private networks and cloud metadata endpoints.
Args:
url: The URL to validate (string or Pydantic HttpUrl).
allow_private: If `True`, allows private IPs and localhost (for development).
Cloud metadata endpoints are ALWAYS blocked.
allow_http: If `True`, allows both HTTP and HTTPS. If `False`, only HTTPS.
Returns:
The validated URL as a string.
Raises:
ValueError: If URL is invalid or potentially dangerous.
"""
url_str = str(url)
parsed = urlparse(url_str)
hostname = parsed.hostname or ""
# Test-environment bypass (preserved from original implementation)
if (
os.environ.get("LANGCHAIN_ENV") == "local_test"
and hostname.startswith("test")
and "server" in hostname
):
return url_str
policy = _policy_for(allow_private=allow_private, allow_http=allow_http)
# Synchronous scheme + hostname checks
try:
_validate_url_sync(url_str, policy)
except SSRFBlockedError as exc:
raise ValueError(str(exc)) from exc
# DNS resolution and IP validation
try:
addr_info = socket.getaddrinfo(
hostname,
parsed.port or (443 if parsed.scheme == "https" else 80),
socket.AF_UNSPEC,
socket.SOCK_STREAM,
)
for result in addr_info:
ip_str: str = result[4][0] # type: ignore[assignment]
try:
_validate_resolved_ip(ip_str, policy)
except SSRFBlockedError as exc:
raise ValueError(str(exc)) from exc
except socket.gaierror as e:
msg = f"Failed to resolve hostname '{hostname}': {e}"
raise ValueError(msg) from e
except OSError as e:
msg = f"Network error while validating URL: {e}"
raise ValueError(msg) from e
return url_str
def is_safe_url(
url: str | AnyHttpUrl,
*,
allow_private: bool = False,
allow_http: bool = True,
) -> bool:
"""Non-throwing version of `validate_safe_url`."""
try:
validate_safe_url(url, allow_private=allow_private, allow_http=allow_http)
except ValueError:
return False
else:
return True
def _validate_url_ssrf_strict(v: Any) -> Any:
"""Validate URL for SSRF protection (strict mode)."""
if isinstance(v, str):
validate_safe_url(v, allow_private=False, allow_http=True)
return v
def _validate_url_ssrf_https_only(v: Any) -> Any:
if isinstance(v, str):
validate_safe_url(v, allow_private=False, allow_http=False)
return v
def _validate_url_ssrf_relaxed(v: Any) -> Any:
"""Validate URL for SSRF protection (relaxed mode - allows private IPs)."""
if isinstance(v, str):
validate_safe_url(v, allow_private=True, allow_http=True)
return v
# Annotated types with SSRF protection
SSRFProtectedUrl = Annotated[HttpUrl, BeforeValidator(_validate_url_ssrf_strict)]
SSRFProtectedUrlRelaxed = Annotated[
HttpUrl, BeforeValidator(_validate_url_ssrf_relaxed)
]
SSRFProtectedHttpsUrl = Annotated[
HttpUrl, BeforeValidator(_validate_url_ssrf_https_only)
]
SSRFProtectedHttpsUrlStr = Annotated[
str, BeforeValidator(_validate_url_ssrf_https_only)
]
@@ -0,0 +1,252 @@
"""SSRF-safe httpx transport with DNS resolution and IP pinning."""
import asyncio
import socket
import httpx
from langchain_core._security._exceptions import SSRFBlockedError
from langchain_core._security._policy import (
SSRFPolicy,
_effective_allowed_hosts,
validate_resolved_ip,
validate_url_sync,
)
# Keys that AsyncHTTPTransport accepts (forwarded from factory kwargs).
_TRANSPORT_KWARGS = frozenset(
{
"verify",
"cert",
"trust_env",
"http1",
"http2",
"limits",
"retries",
}
)
class SSRFSafeTransport(httpx.AsyncBaseTransport):
"""httpx async transport that validates DNS results against an SSRF policy.
For every outgoing request the transport:
1. Checks the URL scheme against `policy.allowed_schemes`.
2. Validates the hostname against blocked patterns.
3. Resolves DNS and validates **all** returned IPs.
4. Rewrites the request to connect to the first valid IP while
preserving the original `Host` header and TLS SNI hostname.
Redirects are re-validated on each hop because `follow_redirects`
is set on the *client*, causing `handle_async_request` to be called
again for each redirect target.
"""
def __init__(
self,
policy: SSRFPolicy = SSRFPolicy(),
**transport_kwargs: object,
) -> None:
self._policy = policy
self._inner = httpx.AsyncHTTPTransport(**transport_kwargs) # type: ignore[arg-type]
# ------------------------------------------------------------------ #
# Core request handler
# ------------------------------------------------------------------ #
async def handle_async_request(
self,
request: httpx.Request,
) -> httpx.Response:
hostname = request.url.host or ""
scheme = request.url.scheme.lower()
# 1-3. Scheme, hostname, and pattern checks (reuse sync validator).
try:
validate_url_sync(str(request.url), self._policy)
except SSRFBlockedError:
raise
# Allowed-hosts bypass - skip DNS/IP validation entirely.
allowed = {h.lower() for h in _effective_allowed_hosts(self._policy)}
if hostname.lower() in allowed:
return await self._inner.handle_async_request(request)
# 4. DNS resolution
port = request.url.port or (443 if scheme == "https" else 80)
try:
addrinfo = await asyncio.to_thread(
socket.getaddrinfo,
hostname,
port,
type=socket.SOCK_STREAM,
)
except socket.gaierror as exc:
raise SSRFBlockedError("DNS resolution failed") from exc
if not addrinfo:
raise SSRFBlockedError("DNS resolution returned no results")
# 5. Validate ALL resolved IPs - any blocked means reject.
for _family, _type, _proto, _canonname, sockaddr in addrinfo:
ip_str: str = sockaddr[0] # type: ignore[assignment]
validate_resolved_ip(ip_str, self._policy)
# 6. Pin to first resolved IP.
pinned_ip = addrinfo[0][4][0]
# 7. Rewrite URL to use pinned IP, preserving Host header and SNI.
pinned_url = request.url.copy_with(host=pinned_ip)
# Build extensions dict, adding sni_hostname for HTTPS so TLS
# certificate validation uses the original hostname.
extensions = dict(request.extensions)
if scheme == "https":
extensions["sni_hostname"] = hostname.encode("ascii")
pinned_request = httpx.Request(
method=request.method,
url=pinned_url,
headers=request.headers, # Host header already set to original
content=request.content,
extensions=extensions,
)
return await self._inner.handle_async_request(pinned_request)
# ------------------------------------------------------------------ #
# Lifecycle
# ------------------------------------------------------------------ #
async def aclose(self) -> None:
await self._inner.aclose()
# ---------------------------------------------------------------------- #
# Factory
# ---------------------------------------------------------------------- #
class SSRFSafeSyncTransport(httpx.BaseTransport):
"""httpx sync transport that validates DNS results against an SSRF policy.
Sync mirror of `SSRFSafeTransport`. See that class for full documentation.
"""
def __init__(
self,
policy: SSRFPolicy = SSRFPolicy(),
**transport_kwargs: object,
) -> None:
self._policy = policy
self._inner = httpx.HTTPTransport(**transport_kwargs) # type: ignore[arg-type]
def handle_request(
self,
request: httpx.Request,
) -> httpx.Response:
hostname = request.url.host or ""
scheme = request.url.scheme.lower()
validate_url_sync(str(request.url), self._policy)
allowed = {h.lower() for h in _effective_allowed_hosts(self._policy)}
if hostname.lower() in allowed:
return self._inner.handle_request(request)
port = request.url.port or (443 if scheme == "https" else 80)
try:
addrinfo = socket.getaddrinfo(
hostname,
port,
type=socket.SOCK_STREAM,
)
except socket.gaierror as exc:
raise SSRFBlockedError("DNS resolution failed") from exc
if not addrinfo:
raise SSRFBlockedError("DNS resolution returned no results")
for _family, _type, _proto, _canonname, sockaddr in addrinfo:
ip_str: str = sockaddr[0] # type: ignore[assignment]
validate_resolved_ip(ip_str, self._policy)
pinned_ip = addrinfo[0][4][0]
pinned_url = request.url.copy_with(host=pinned_ip)
extensions = dict(request.extensions)
if scheme == "https":
extensions["sni_hostname"] = hostname.encode("ascii")
pinned_request = httpx.Request(
method=request.method,
url=pinned_url,
headers=request.headers,
content=request.content,
extensions=extensions,
)
return self._inner.handle_request(pinned_request)
def close(self) -> None:
self._inner.close()
# ---------------------------------------------------------------------- #
# Factories
# ---------------------------------------------------------------------- #
def ssrf_safe_client(
policy: SSRFPolicy = SSRFPolicy(),
**kwargs: object,
) -> httpx.Client:
"""Create an `httpx.Client` with SSRF protection."""
transport_kwargs: dict[str, object] = {}
client_kwargs: dict[str, object] = {}
for key, value in kwargs.items():
if key in _TRANSPORT_KWARGS:
transport_kwargs[key] = value
else:
client_kwargs[key] = value
transport = SSRFSafeSyncTransport(policy=policy, **transport_kwargs)
client_kwargs.setdefault("follow_redirects", True)
client_kwargs.setdefault("max_redirects", 10)
return httpx.Client(
transport=transport,
**client_kwargs, # type: ignore[arg-type]
)
def ssrf_safe_async_client(
policy: SSRFPolicy = SSRFPolicy(),
**kwargs: object,
) -> httpx.AsyncClient:
"""Create an `httpx.AsyncClient` with SSRF protection.
Drop-in replacement for `httpx.AsyncClient(...)` - callers just swap
the constructor call. Transport-specific kwargs (`verify`, `cert`,
`retries`, etc.) are forwarded to the inner `AsyncHTTPTransport`;
everything else goes to the `AsyncClient`.
"""
transport_kwargs: dict[str, object] = {}
client_kwargs: dict[str, object] = {}
for key, value in kwargs.items():
if key in _TRANSPORT_KWARGS:
transport_kwargs[key] = value
else:
client_kwargs[key] = value
transport = SSRFSafeTransport(policy=policy, **transport_kwargs)
# Apply defaults only if not overridden by caller.
client_kwargs.setdefault("follow_redirects", True)
client_kwargs.setdefault("max_redirects", 10)
return httpx.AsyncClient(
transport=transport,
**client_kwargs, # type: ignore[arg-type]
)
@@ -0,0 +1,256 @@
"""Schema definitions for representing agent actions, observations, and return values.
!!! warning
The schema definitions are provided for backwards compatibility.
!!! warning
New agents should be built using the
[`langchain` library](https://pypi.org/project/langchain/), which provides a
simpler and more flexible way to define agents.
See docs on [building agents](https://docs.langchain.com/oss/python/langchain/agents).
Agents use language models to choose a sequence of actions to take.
A basic agent works in the following manner:
1. Given a prompt an agent uses an LLM to request an action to take
(e.g., a tool to run).
2. The agent executes the action (e.g., runs the tool), and receives an observation.
3. The agent returns the observation to the LLM, which can then be used to generate
the next action.
4. When the agent reaches a stopping condition, it returns a final return value.
The schemas for the agents themselves are defined in `langchain.agents.agent`.
"""
from __future__ import annotations
import json
from collections.abc import Sequence
from typing import Any, Literal
from langchain_core.load.serializable import Serializable
from langchain_core.messages import (
AIMessage,
BaseMessage,
FunctionMessage,
HumanMessage,
)
class AgentAction(Serializable):
"""Represents a request to execute an action by an agent.
The action consists of the name of the tool to execute and the input to pass
to the tool. The log is used to pass along extra information about the action.
"""
tool: str
"""The name of the `Tool` to execute."""
tool_input: str | dict
"""The input to pass in to the `Tool`."""
log: str
"""Additional information to log about the action.
This log can be used in a few ways. First, it can be used to audit what exactly the
LLM predicted to lead to this `(tool, tool_input)`.
Second, it can be used in future iterations to show the LLMs prior thoughts. This is
useful when `(tool, tool_input)` does not contain full information about the LLM
prediction (for example, any `thought` before the tool/tool_input).
"""
type: Literal["AgentAction"] = "AgentAction"
# Override init to support instantiation by position for backward compat.
def __init__(self, tool: str, tool_input: str | dict, log: str, **kwargs: Any):
"""Create an `AgentAction`.
Args:
tool: The name of the tool to execute.
tool_input: The input to pass in to the `Tool`.
log: Additional information to log about the action.
"""
super().__init__(tool=tool, tool_input=tool_input, log=log, **kwargs)
@classmethod
def is_lc_serializable(cls) -> bool:
"""`AgentAction` is serializable.
Returns:
`True`
"""
return True
@classmethod
def get_lc_namespace(cls) -> list[str]:
"""Get the namespace of the LangChain object.
Returns:
`["langchain", "schema", "agent"]`
"""
return ["langchain", "schema", "agent"]
@property
def messages(self) -> Sequence[BaseMessage]:
"""Return the messages that correspond to this action."""
return _convert_agent_action_to_messages(self)
class AgentActionMessageLog(AgentAction):
"""Representation of an action to be executed by an agent.
This is similar to `AgentAction`, but includes a message log consisting of
chat messages.
This is useful when working with `ChatModels`, and is used to reconstruct
conversation history from the agent's perspective.
"""
message_log: Sequence[BaseMessage]
"""Similar to log, this can be used to pass along extra information about what exact
messages were predicted by the LLM before parsing out the `(tool, tool_input)`.
This is again useful if `(tool, tool_input)` cannot be used to fully recreate the
LLM prediction, and you need that LLM prediction (for future agent iteration).
Compared to `log`, this is useful when the underlying LLM is a chat model (and
therefore returns messages rather than a string).
"""
# Ignoring type because we're overriding the type from AgentAction.
# And this is the correct thing to do in this case.
# The type literal is used for serialization purposes.
type: Literal["AgentActionMessageLog"] = "AgentActionMessageLog" # type: ignore[assignment]
class AgentStep(Serializable):
"""Result of running an `AgentAction`."""
action: AgentAction
"""The `AgentAction` that was executed."""
observation: Any
"""The result of the `AgentAction`."""
@property
def messages(self) -> Sequence[BaseMessage]:
"""Messages that correspond to this observation."""
return _convert_agent_observation_to_messages(self.action, self.observation)
class AgentFinish(Serializable):
"""Final return value of an `ActionAgent`.
Agents return an `AgentFinish` when they have reached a stopping condition.
"""
return_values: dict
"""Dictionary of return values."""
log: str
"""Additional information to log about the return value.
This is used to pass along the full LLM prediction, not just the parsed out
return value.
For example, if the full LLM prediction was `Final Answer: 2` you may want to just
return `2` as a return value, but pass along the full string as a `log` (for
debugging or observability purposes).
"""
type: Literal["AgentFinish"] = "AgentFinish"
def __init__(self, return_values: dict, log: str, **kwargs: Any):
"""Override init to support instantiation by position for backward compat."""
super().__init__(return_values=return_values, log=log, **kwargs)
@classmethod
def is_lc_serializable(cls) -> bool:
"""Return `True` as this class is serializable."""
return True
@classmethod
def get_lc_namespace(cls) -> list[str]:
"""Get the namespace of the LangChain object.
Returns:
`["langchain", "schema", "agent"]`
"""
return ["langchain", "schema", "agent"]
@property
def messages(self) -> Sequence[BaseMessage]:
"""Messages that correspond to this observation."""
return [AIMessage(content=self.log)]
def _convert_agent_action_to_messages(
agent_action: AgentAction,
) -> Sequence[BaseMessage]:
"""Convert an agent action to a message.
This code is used to reconstruct the original AI message from the agent action.
Args:
agent_action: Agent action to convert.
Returns:
`AIMessage` that corresponds to the original tool invocation.
"""
if isinstance(agent_action, AgentActionMessageLog):
return agent_action.message_log
return [AIMessage(content=agent_action.log)]
def _convert_agent_observation_to_messages(
agent_action: AgentAction, observation: Any
) -> Sequence[BaseMessage]:
"""Convert an agent action to a message.
This code is used to reconstruct the original AI message from the agent action.
Args:
agent_action: Agent action to convert.
observation: Observation to convert to a message.
Returns:
`AIMessage` that corresponds to the original tool invocation.
"""
if isinstance(agent_action, AgentActionMessageLog):
return [_create_function_message(agent_action, observation)]
content = observation
if not isinstance(observation, str):
try:
content = json.dumps(observation, ensure_ascii=False)
except Exception:
content = str(observation)
return [HumanMessage(content=content)]
def _create_function_message(
agent_action: AgentAction, observation: Any
) -> FunctionMessage:
"""Convert agent action and observation into a function message.
Args:
agent_action: the tool invocation request from the agent.
observation: the result of the tool invocation.
Returns:
`FunctionMessage` that corresponds to the original tool invocation.
"""
if not isinstance(observation, str):
try:
content = json.dumps(observation, ensure_ascii=False)
except Exception:
content = str(observation)
else:
content = observation
return FunctionMessage(
name=agent_action.tool,
content=content,
)
@@ -0,0 +1,272 @@
"""Optional caching layer for language models.
Distinct from provider-based [prompt caching](https://docs.langchain.com/oss/python/langchain/models#prompt-caching).
!!! warning "Beta feature"
This is a beta feature. Please be wary of deploying experimental code to production
unless you've taken appropriate precautions.
A cache is useful for two reasons:
1. It can save you money by reducing the number of API calls you make to the LLM
provider if you're often requesting the same completion multiple times.
2. It can speed up your application by reducing the number of API calls you make to the
LLM provider.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from collections.abc import Sequence
from typing import Any
from typing_extensions import override
from langchain_core.outputs import Generation
from langchain_core.runnables import run_in_executor
RETURN_VAL_TYPE = Sequence[Generation]
class BaseCache(ABC):
"""Interface for a caching layer for LLMs and Chat models.
The cache interface consists of the following methods:
- lookup: Look up a value based on a prompt and `llm_string`.
- update: Update the cache based on a prompt and `llm_string`.
- clear: Clear the cache.
In addition, the cache interface provides an async version of each method.
The default implementation of the async methods is to run the synchronous
method in an executor. It's recommended to override the async methods
and provide async implementations to avoid unnecessary overhead.
"""
@abstractmethod
def lookup(self, prompt: str, llm_string: str) -> RETURN_VAL_TYPE | None:
"""Look up based on `prompt` and `llm_string`.
A cache implementation is expected to generate a key from the 2-tuple
of `prompt` and `llm_string` (e.g., by concatenating them with a delimiter).
Args:
prompt: A string representation of the prompt.
In the case of a chat model, the prompt is a non-trivial
serialization of the prompt into the language model.
llm_string: A string representation of the LLM configuration.
This is used to capture the invocation parameters of the LLM
(e.g., model name, temperature, stop tokens, max tokens, etc.).
These invocation parameters are serialized into a string representation.
Returns:
On a cache miss, return `None`. On a cache hit, return the cached value.
The cached value is a list of `Generation` (or subclasses).
"""
@abstractmethod
def update(self, prompt: str, llm_string: str, return_val: RETURN_VAL_TYPE) -> None:
"""Update cache based on `prompt` and `llm_string`.
The `prompt` and `llm_string` are used to generate a key for the cache. The key
should match that of the lookup method.
Args:
prompt: A string representation of the prompt.
In the case of a chat model, the prompt is a non-trivial
serialization of the prompt into the language model.
llm_string: A string representation of the LLM configuration.
This is used to capture the invocation parameters of the LLM
(e.g., model name, temperature, stop tokens, max tokens, etc.).
These invocation parameters are serialized into a string
representation.
return_val: The value to be cached.
The value is a list of `Generation` (or subclasses).
"""
@abstractmethod
def clear(self, **kwargs: Any) -> None:
"""Clear cache that can take additional keyword arguments."""
async def alookup(self, prompt: str, llm_string: str) -> RETURN_VAL_TYPE | None:
"""Async look up based on `prompt` and `llm_string`.
A cache implementation is expected to generate a key from the 2-tuple
of `prompt` and `llm_string` (e.g., by concatenating them with a delimiter).
Args:
prompt: A string representation of the prompt.
In the case of a chat model, the prompt is a non-trivial
serialization of the prompt into the language model.
llm_string: A string representation of the LLM configuration.
This is used to capture the invocation parameters of the LLM
(e.g., model name, temperature, stop tokens, max tokens, etc.).
These invocation parameters are serialized into a string
representation.
Returns:
On a cache miss, return `None`. On a cache hit, return the cached value.
The cached value is a list of `Generation` (or subclasses).
"""
return await run_in_executor(None, self.lookup, prompt, llm_string)
async def aupdate(
self, prompt: str, llm_string: str, return_val: RETURN_VAL_TYPE
) -> None:
"""Async update cache based on `prompt` and `llm_string`.
The prompt and llm_string are used to generate a key for the cache.
The key should match that of the look up method.
Args:
prompt: A string representation of the prompt.
In the case of a chat model, the prompt is a non-trivial
serialization of the prompt into the language model.
llm_string: A string representation of the LLM configuration.
This is used to capture the invocation parameters of the LLM
(e.g., model name, temperature, stop tokens, max tokens, etc.).
These invocation parameters are serialized into a string
representation.
return_val: The value to be cached. The value is a list of `Generation`
(or subclasses).
"""
return await run_in_executor(None, self.update, prompt, llm_string, return_val)
async def aclear(self, **kwargs: Any) -> None:
"""Async clear cache that can take additional keyword arguments."""
return await run_in_executor(None, self.clear, **kwargs)
class InMemoryCache(BaseCache):
"""Cache that stores things in memory.
Example:
```python
from langchain_core.caches import InMemoryCache
from langchain_core.outputs import Generation
# Initialize cache
cache = InMemoryCache()
# Update cache
cache.update(
prompt="What is the capital of France?",
llm_string="model='gpt-5.4-mini',
return_val=[Generation(text="Paris")],
)
# Lookup cache
result = cache.lookup(
prompt="What is the capital of France?",
llm_string="model='gpt-5.4-mini',
)
# result is [Generation(text="Paris")]
```
"""
def __init__(self, *, maxsize: int | None = None) -> None:
"""Initialize with empty cache.
Args:
maxsize: The maximum number of items to store in the cache.
If `None`, the cache has no maximum size.
If the cache exceeds the maximum size, the oldest items are removed.
Raises:
ValueError: If `maxsize` is less than or equal to `0`.
"""
self._cache: dict[tuple[str, str], RETURN_VAL_TYPE] = {}
if maxsize is not None and maxsize <= 0:
msg = "maxsize must be greater than 0"
raise ValueError(msg)
self._maxsize = maxsize
def lookup(self, prompt: str, llm_string: str) -> RETURN_VAL_TYPE | None:
"""Look up based on `prompt` and `llm_string`.
Args:
prompt: A string representation of the prompt.
In the case of a chat model, the prompt is a non-trivial
serialization of the prompt into the language model.
llm_string: A string representation of the LLM configuration.
Returns:
On a cache miss, return `None`. On a cache hit, return the cached value.
"""
return self._cache.get((prompt, llm_string), None)
def update(self, prompt: str, llm_string: str, return_val: RETURN_VAL_TYPE) -> None:
"""Update cache based on `prompt` and `llm_string`.
Args:
prompt: A string representation of the prompt.
In the case of a chat model, the prompt is a non-trivial
serialization of the prompt into the language model.
llm_string: A string representation of the LLM configuration.
return_val: The value to be cached.
The value is a list of `Generation` (or subclasses).
"""
if self._maxsize is not None and len(self._cache) == self._maxsize:
del self._cache[next(iter(self._cache))]
self._cache[prompt, llm_string] = return_val
@override
def clear(self, **kwargs: Any) -> None:
"""Clear cache."""
self._cache = {}
async def alookup(self, prompt: str, llm_string: str) -> RETURN_VAL_TYPE | None:
"""Async look up based on `prompt` and `llm_string`.
Args:
prompt: A string representation of the prompt.
In the case of a chat model, the prompt is a non-trivial
serialization of the prompt into the language model.
llm_string: A string representation of the LLM configuration.
Returns:
On a cache miss, return `None`. On a cache hit, return the cached value.
"""
return self.lookup(prompt, llm_string)
async def aupdate(
self, prompt: str, llm_string: str, return_val: RETURN_VAL_TYPE
) -> None:
"""Async update cache based on `prompt` and `llm_string`.
Args:
prompt: A string representation of the prompt.
In the case of a chat model, the prompt is a non-trivial
serialization of the prompt into the language model.
llm_string: A string representation of the LLM configuration.
return_val: The value to be cached. The value is a list of `Generation`
(or subclasses).
"""
self.update(prompt, llm_string, return_val)
@override
async def aclear(self, **kwargs: Any) -> None:
"""Async clear cache."""
self.clear()
@@ -0,0 +1,132 @@
"""Callback handlers allow listening to events in LangChain."""
from typing import TYPE_CHECKING
from langchain_core._import_utils import import_attr
if TYPE_CHECKING:
from langchain_core.callbacks.base import (
AsyncCallbackHandler,
BaseCallbackHandler,
BaseCallbackManager,
CallbackManagerMixin,
Callbacks,
ChainManagerMixin,
LLMManagerMixin,
RetrieverManagerMixin,
RunManagerMixin,
ToolManagerMixin,
)
from langchain_core.callbacks.file import FileCallbackHandler
from langchain_core.callbacks.manager import (
AsyncCallbackManager,
AsyncCallbackManagerForChainGroup,
AsyncCallbackManagerForChainRun,
AsyncCallbackManagerForLLMRun,
AsyncCallbackManagerForRetrieverRun,
AsyncCallbackManagerForToolRun,
AsyncParentRunManager,
AsyncRunManager,
BaseRunManager,
CallbackManager,
CallbackManagerForChainGroup,
CallbackManagerForChainRun,
CallbackManagerForLLMRun,
CallbackManagerForRetrieverRun,
CallbackManagerForToolRun,
ParentRunManager,
RunManager,
adispatch_custom_event,
dispatch_custom_event,
)
from langchain_core.callbacks.stdout import StdOutCallbackHandler
from langchain_core.callbacks.streaming_stdout import StreamingStdOutCallbackHandler
from langchain_core.callbacks.usage import (
UsageMetadataCallbackHandler,
get_usage_metadata_callback,
)
__all__ = (
"AsyncCallbackHandler",
"AsyncCallbackManager",
"AsyncCallbackManagerForChainGroup",
"AsyncCallbackManagerForChainRun",
"AsyncCallbackManagerForLLMRun",
"AsyncCallbackManagerForRetrieverRun",
"AsyncCallbackManagerForToolRun",
"AsyncParentRunManager",
"AsyncRunManager",
"BaseCallbackHandler",
"BaseCallbackManager",
"BaseRunManager",
"CallbackManager",
"CallbackManagerForChainGroup",
"CallbackManagerForChainRun",
"CallbackManagerForLLMRun",
"CallbackManagerForRetrieverRun",
"CallbackManagerForToolRun",
"CallbackManagerMixin",
"Callbacks",
"ChainManagerMixin",
"FileCallbackHandler",
"LLMManagerMixin",
"ParentRunManager",
"RetrieverManagerMixin",
"RunManager",
"RunManagerMixin",
"StdOutCallbackHandler",
"StreamingStdOutCallbackHandler",
"ToolManagerMixin",
"UsageMetadataCallbackHandler",
"adispatch_custom_event",
"dispatch_custom_event",
"get_usage_metadata_callback",
)
_dynamic_imports = {
"AsyncCallbackHandler": "base",
"BaseCallbackHandler": "base",
"BaseCallbackManager": "base",
"CallbackManagerMixin": "base",
"Callbacks": "base",
"ChainManagerMixin": "base",
"LLMManagerMixin": "base",
"RetrieverManagerMixin": "base",
"RunManagerMixin": "base",
"ToolManagerMixin": "base",
"FileCallbackHandler": "file",
"AsyncCallbackManager": "manager",
"AsyncCallbackManagerForChainGroup": "manager",
"AsyncCallbackManagerForChainRun": "manager",
"AsyncCallbackManagerForLLMRun": "manager",
"AsyncCallbackManagerForRetrieverRun": "manager",
"AsyncCallbackManagerForToolRun": "manager",
"AsyncParentRunManager": "manager",
"AsyncRunManager": "manager",
"BaseRunManager": "manager",
"CallbackManager": "manager",
"CallbackManagerForChainGroup": "manager",
"CallbackManagerForChainRun": "manager",
"CallbackManagerForLLMRun": "manager",
"CallbackManagerForRetrieverRun": "manager",
"CallbackManagerForToolRun": "manager",
"ParentRunManager": "manager",
"RunManager": "manager",
"adispatch_custom_event": "manager",
"dispatch_custom_event": "manager",
"StdOutCallbackHandler": "stdout",
"StreamingStdOutCallbackHandler": "streaming_stdout",
"UsageMetadataCallbackHandler": "usage",
"get_usage_metadata_callback": "usage",
}
def __getattr__(attr_name: str) -> object:
module_name = _dynamic_imports.get(attr_name)
result = import_attr(attr_name, module_name, __spec__.parent)
globals()[attr_name] = result
return result
def __dir__() -> list[str]:
return list(__all__)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,267 @@
"""Callback handler that writes to a file."""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING, Any, TextIO, cast
from typing_extensions import Self, override
from langchain_core._api import warn_deprecated
from langchain_core.callbacks import BaseCallbackHandler
from langchain_core.utils.input import print_text
if TYPE_CHECKING:
from langchain_core.agents import AgentAction, AgentFinish
_GLOBAL_DEPRECATION_WARNED = False
class FileCallbackHandler(BaseCallbackHandler):
"""Callback handler that writes to a file.
This handler supports both context manager usage (recommended) and direct
instantiation (deprecated) for backwards compatibility.
Examples:
Using as a context manager (recommended):
```python
with FileCallbackHandler("output.txt") as handler:
# Use handler with your chain/agent
chain.invoke(inputs, config={"callbacks": [handler]})
```
Direct instantiation (deprecated):
```python
handler = FileCallbackHandler("output.txt")
# File remains open until handler is garbage collected
try:
chain.invoke(inputs, config={"callbacks": [handler]})
finally:
handler.close() # Explicit cleanup recommended
```
Args:
filename: The file path to write to.
mode: The file open mode. Defaults to `'a'` (append).
color: Default color for text output.
!!! note
When not used as a context manager, a deprecation warning will be issued on
first use. The file will be opened immediately in `__init__` and closed in
`__del__` or when `close()` is called explicitly.
"""
def __init__(
self, filename: str, mode: str = "a", color: str | None = None
) -> None:
"""Initialize the file callback handler.
Args:
filename: Path to the output file.
mode: File open mode (e.g., `'w'`, `'a'`, `'x'`). Defaults to `'a'`.
color: Default text color for output.
"""
self.filename = filename
self.mode = mode
self.color = color
self._file_opened_in_context = False
self.file: TextIO = cast(
"TextIO",
# Open the file in the specified mode with UTF-8 encoding.
Path(self.filename).open(self.mode, encoding="utf-8"), # noqa: SIM115
)
def __enter__(self) -> Self:
"""Enter the context manager.
Returns:
The `FileCallbackHandler` instance.
!!! note
The file is already opened in `__init__`, so this just marks that the
handler is being used as a context manager.
"""
self._file_opened_in_context = True
return self
def __exit__(
self,
exc_type: type[BaseException] | None,
exc_val: BaseException | None,
exc_tb: object,
) -> None:
"""Exit the context manager and close the file.
Args:
exc_type: Exception type if an exception occurred.
exc_val: Exception value if an exception occurred.
exc_tb: Exception traceback if an exception occurred.
"""
self.close()
def __del__(self) -> None:
"""Destructor to cleanup when done."""
self.close()
def close(self) -> None:
"""Close the file if it's open.
This method is safe to call multiple times and will only close
the file if it's currently open.
"""
if hasattr(self, "file") and self.file and not self.file.closed:
self.file.close()
def _write(
self,
text: str,
color: str | None = None,
end: str = "",
) -> None:
"""Write text to the file with deprecation warning if needed.
Args:
text: The text to write to the file.
color: Optional color for the text. Defaults to `self.color`.
end: String appended after the text.
file: Optional file to write to. Defaults to `self.file`.
Raises:
RuntimeError: If the file is closed or not available.
"""
global _GLOBAL_DEPRECATION_WARNED # noqa: PLW0603
if not self._file_opened_in_context and not _GLOBAL_DEPRECATION_WARNED:
warn_deprecated(
since="0.3.67",
pending=True,
message=(
"Using FileCallbackHandler without a context manager is "
"deprecated. Use 'with FileCallbackHandler(...) as "
"handler:' instead."
),
)
_GLOBAL_DEPRECATION_WARNED = True
if not hasattr(self, "file") or self.file is None or self.file.closed:
msg = "File is not open. Use FileCallbackHandler as a context manager."
raise RuntimeError(msg)
print_text(text, file=self.file, color=color, end=end)
@override
def on_chain_start(
self, serialized: dict[str, Any], inputs: dict[str, Any], **kwargs: Any
) -> None:
"""Print that we are entering a chain.
Args:
serialized: The serialized chain information.
inputs: The inputs to the chain.
**kwargs: Additional keyword arguments that may contain `'name'`.
"""
name = (
kwargs.get("name")
or serialized.get("name", serialized.get("id", ["<unknown>"])[-1])
or "<unknown>"
)
self._write(f"\n\n> Entering new {name} chain...", end="\n")
@override
def on_chain_end(self, outputs: dict[str, Any], **kwargs: Any) -> None:
"""Print that we finished a chain.
Args:
outputs: The outputs of the chain.
**kwargs: Additional keyword arguments.
"""
self._write("\n> Finished chain.", end="\n")
@override
def on_agent_action(
self, action: AgentAction, color: str | None = None, **kwargs: Any
) -> Any:
"""Handle agent action by writing the action log.
Args:
action: The agent action containing the log to write.
color: Color override for this specific output.
If `None`, uses `self.color`.
**kwargs: Additional keyword arguments.
"""
self._write(action.log, color=color or self.color)
@override
def on_tool_end(
self,
output: str,
color: str | None = None,
observation_prefix: str | None = None,
llm_prefix: str | None = None,
**kwargs: Any,
) -> None:
"""Handle tool end by writing the output with optional prefixes.
Args:
output: The tool output to write.
color: Color override for this specific output.
If `None`, uses `self.color`.
observation_prefix: Optional prefix to write before the output.
llm_prefix: Optional prefix to write after the output.
**kwargs: Additional keyword arguments.
"""
if observation_prefix is not None:
self._write(f"\n{observation_prefix}")
self._write(output)
if llm_prefix is not None:
self._write(f"\n{llm_prefix}")
@override
def on_text(
self, text: str, color: str | None = None, end: str = "", **kwargs: Any
) -> None:
"""Handle text output.
Args:
text: The text to write.
color: Color override for this specific output.
If `None`, uses `self.color`.
end: String appended after the text.
**kwargs: Additional keyword arguments.
"""
self._write(text, color=color or self.color, end=end)
@override
def on_agent_finish(
self, finish: AgentFinish, color: str | None = None, **kwargs: Any
) -> None:
"""Handle agent finish by writing the finish log.
Args:
finish: The agent finish object containing the log to write.
color: Color override for this specific output.
If `None`, uses `self.color`.
**kwargs: Additional keyword arguments.
"""
self._write(finish.log, color=color or self.color, end="\n")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,123 @@
"""Callback handler that prints to std out."""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
from typing_extensions import override
from langchain_core.callbacks.base import BaseCallbackHandler
from langchain_core.utils import print_text
if TYPE_CHECKING:
from langchain_core.agents import AgentAction, AgentFinish
class StdOutCallbackHandler(BaseCallbackHandler):
"""Callback handler that prints to std out."""
def __init__(self, color: str | None = None) -> None:
"""Initialize callback handler.
Args:
color: The color to use for the text.
"""
self.color = color
@override
def on_chain_start(
self, serialized: dict[str, Any], inputs: dict[str, Any], **kwargs: Any
) -> None:
"""Print out that we are entering a chain.
Args:
serialized: The serialized chain.
inputs: The inputs to the chain.
**kwargs: Additional keyword arguments.
"""
if "name" in kwargs:
name = kwargs["name"]
elif serialized:
name = serialized.get("name", serialized.get("id", ["<unknown>"])[-1])
else:
name = "<unknown>"
print(f"\n\n\033[1m> Entering new {name} chain...\033[0m") # noqa: T201
@override
def on_chain_end(self, outputs: dict[str, Any], **kwargs: Any) -> None:
"""Print out that we finished a chain.
Args:
outputs: The outputs of the chain.
**kwargs: Additional keyword arguments.
"""
print("\n\033[1m> Finished chain.\033[0m") # noqa: T201
@override
def on_agent_action(
self, action: AgentAction, color: str | None = None, **kwargs: Any
) -> Any:
"""Run on agent action.
Args:
action: The agent action.
color: The color to use for the text.
**kwargs: Additional keyword arguments.
"""
print_text(action.log, color=color or self.color)
@override
def on_tool_end(
self,
output: Any,
color: str | None = None,
observation_prefix: str | None = None,
llm_prefix: str | None = None,
**kwargs: Any,
) -> None:
"""If not the final action, print out observation.
Args:
output: The output to print.
color: The color to use for the text.
observation_prefix: The observation prefix.
llm_prefix: The LLM prefix.
**kwargs: Additional keyword arguments.
"""
output = str(output)
if observation_prefix is not None:
print_text(f"\n{observation_prefix}")
print_text(output, color=color or self.color)
if llm_prefix is not None:
print_text(f"\n{llm_prefix}")
@override
def on_text(
self,
text: str,
color: str | None = None,
end: str = "",
**kwargs: Any,
) -> None:
"""Run when the agent ends.
Args:
text: The text to print.
color: The color to use for the text.
end: The end character to use.
**kwargs: Additional keyword arguments.
"""
print_text(text, color=color or self.color, end=end)
@override
def on_agent_finish(
self, finish: AgentFinish, color: str | None = None, **kwargs: Any
) -> None:
"""Run on the agent end.
Args:
finish: The agent finish.
color: The color to use for the text.
**kwargs: Additional keyword arguments.
"""
print_text(finish.log, color=color or self.color, end="\n")
@@ -0,0 +1,152 @@
"""Callback Handler streams to stdout on new llm token."""
from __future__ import annotations
import sys
from typing import TYPE_CHECKING, Any
from typing_extensions import override
from langchain_core.callbacks.base import BaseCallbackHandler
if TYPE_CHECKING:
from langchain_core.agents import AgentAction, AgentFinish
from langchain_core.messages import BaseMessage
from langchain_core.outputs import LLMResult
class StreamingStdOutCallbackHandler(BaseCallbackHandler):
"""Callback handler for streaming.
!!! warning "Only works with LLMs that support streaming."
"""
def on_llm_start(
self, serialized: dict[str, Any], prompts: list[str], **kwargs: Any
) -> None:
"""Run when LLM starts running.
Args:
serialized: The serialized LLM.
prompts: The prompts to run.
**kwargs: Additional keyword arguments.
"""
def on_chat_model_start(
self,
serialized: dict[str, Any],
messages: list[list[BaseMessage]],
**kwargs: Any,
) -> None:
"""Run when LLM starts running.
Args:
serialized: The serialized LLM.
messages: The messages to run.
**kwargs: Additional keyword arguments.
"""
@override
def on_llm_new_token(self, token: str, **kwargs: Any) -> None:
"""Run on new LLM token. Only available when streaming is enabled.
Args:
token: The new token.
**kwargs: Additional keyword arguments.
"""
sys.stdout.write(token)
sys.stdout.flush()
def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None:
"""Run when LLM ends running.
Args:
response: The response from the LLM.
**kwargs: Additional keyword arguments.
"""
def on_llm_error(self, error: BaseException, **kwargs: Any) -> None:
"""Run when LLM errors.
Args:
error: The error that occurred.
**kwargs: Additional keyword arguments.
"""
def on_chain_start(
self, serialized: dict[str, Any], inputs: dict[str, Any], **kwargs: Any
) -> None:
"""Run when a chain starts running.
Args:
serialized: The serialized chain.
inputs: The inputs to the chain.
**kwargs: Additional keyword arguments.
"""
def on_chain_end(self, outputs: dict[str, Any], **kwargs: Any) -> None:
"""Run when a chain ends running.
Args:
outputs: The outputs of the chain.
**kwargs: Additional keyword arguments.
"""
def on_chain_error(self, error: BaseException, **kwargs: Any) -> None:
"""Run when chain errors.
Args:
error: The error that occurred.
**kwargs: Additional keyword arguments.
"""
def on_tool_start(
self, serialized: dict[str, Any], input_str: str, **kwargs: Any
) -> None:
"""Run when the tool starts running.
Args:
serialized: The serialized tool.
input_str: The input string.
**kwargs: Additional keyword arguments.
"""
def on_agent_action(self, action: AgentAction, **kwargs: Any) -> Any:
"""Run on agent action.
Args:
action: The agent action.
**kwargs: Additional keyword arguments.
"""
def on_tool_end(self, output: Any, **kwargs: Any) -> None:
"""Run when tool ends running.
Args:
output: The output of the tool.
**kwargs: Additional keyword arguments.
"""
def on_tool_error(self, error: BaseException, **kwargs: Any) -> None:
"""Run when tool errors.
Args:
error: The error that occurred.
**kwargs: Additional keyword arguments.
"""
def on_text(self, text: str, **kwargs: Any) -> None:
"""Run on an arbitrary text.
Args:
text: The text to print.
**kwargs: Additional keyword arguments.
"""
def on_agent_finish(self, finish: AgentFinish, **kwargs: Any) -> None:
"""Run on the agent end.
Args:
finish: The agent finish.
**kwargs: Additional keyword arguments.
"""
@@ -0,0 +1,149 @@
"""Callback Handler that tracks `AIMessage.usage_metadata`."""
import threading
from collections.abc import Generator
from contextlib import contextmanager
from contextvars import ContextVar
from typing import Any
from typing_extensions import override
from langchain_core.callbacks import BaseCallbackHandler
from langchain_core.messages import AIMessage
from langchain_core.messages.ai import UsageMetadata, add_usage
from langchain_core.outputs import ChatGeneration, LLMResult
from langchain_core.tracers.context import register_configure_hook
class UsageMetadataCallbackHandler(BaseCallbackHandler):
"""Callback Handler that tracks `AIMessage.usage_metadata`.
Example:
```python
from langchain.chat_models import init_chat_model
from langchain_core.callbacks import UsageMetadataCallbackHandler
llm_1 = init_chat_model(model="openai:gpt-4o-mini")
llm_2 = init_chat_model(model="anthropic:claude-haiku-4-5-20251001")
callback = UsageMetadataCallbackHandler()
result_1 = llm_1.invoke("Hello", config={"callbacks": [callback]})
result_2 = llm_2.invoke("Hello", config={"callbacks": [callback]})
callback.usage_metadata
```
```txt
{'gpt-4o-mini-2024-07-18': {'input_tokens': 8,
'output_tokens': 10,
'total_tokens': 18,
'input_token_details': {'audio': 0, 'cache_read': 0},
'output_token_details': {'audio': 0, 'reasoning': 0}},
'claude-haiku-4-5-20251001': {'input_tokens': 8,
'output_tokens': 21,
'total_tokens': 29,
'input_token_details': {'cache_read': 0, 'cache_creation': 0}}}
```
!!! version-added "Added in `langchain-core` 0.3.49"
"""
def __init__(self) -> None:
"""Initialize the `UsageMetadataCallbackHandler`."""
super().__init__()
self._lock = threading.Lock()
self.usage_metadata: dict[str, UsageMetadata] = {}
@override
def __repr__(self) -> str:
return str(self.usage_metadata)
@override
def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None:
"""Collect token usage."""
# Check for usage_metadata (langchain-core >= 0.2.2)
try:
generation = response.generations[0][0]
except IndexError:
generation = None
usage_metadata = None
model_name = None
if isinstance(generation, ChatGeneration):
try:
message = generation.message
if isinstance(message, AIMessage):
usage_metadata = message.usage_metadata
model_name = message.response_metadata.get("model_name")
except AttributeError:
pass
# update shared state behind lock
if usage_metadata and model_name:
with self._lock:
if model_name not in self.usage_metadata:
self.usage_metadata[model_name] = usage_metadata
else:
self.usage_metadata[model_name] = add_usage(
self.usage_metadata[model_name], usage_metadata
)
@contextmanager
def get_usage_metadata_callback(
name: str = "usage_metadata_callback",
) -> Generator[UsageMetadataCallbackHandler, None, None]:
"""Get usage metadata callback.
Get context manager for tracking usage metadata across chat model calls using
[`AIMessage.usage_metadata`][langchain.messages.AIMessage.usage_metadata].
Args:
name: The name of the context variable.
Yields:
The usage metadata callback.
Example:
```python
from langchain.chat_models import init_chat_model
from langchain_core.callbacks import get_usage_metadata_callback
llm_1 = init_chat_model(model="openai:gpt-4o-mini")
llm_2 = init_chat_model(model="anthropic:claude-haiku-4-5-20251001")
with get_usage_metadata_callback() as cb:
llm_1.invoke("Hello")
llm_2.invoke("Hello")
print(cb.usage_metadata)
```
```txt
{
"gpt-4o-mini-2024-07-18": {
"input_tokens": 8,
"output_tokens": 10,
"total_tokens": 18,
"input_token_details": {"audio": 0, "cache_read": 0},
"output_token_details": {"audio": 0, "reasoning": 0},
},
"claude-haiku-4-5-20251001": {
"input_tokens": 8,
"output_tokens": 21,
"total_tokens": 29,
"input_token_details": {"cache_read": 0, "cache_creation": 0},
},
}
```
!!! version-added "Added in `langchain-core` 0.3.49"
"""
usage_metadata_callback_var: ContextVar[UsageMetadataCallbackHandler | None] = (
ContextVar(name, default=None)
)
register_configure_hook(usage_metadata_callback_var, inheritable=True)
cb = UsageMetadataCallbackHandler()
usage_metadata_callback_var.set(cb)
yield cb
usage_metadata_callback_var.set(None)
@@ -0,0 +1,246 @@
"""Chat message history stores a history of the message interactions in a chat."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from pydantic import BaseModel, Field
from langchain_core.messages import (
AIMessage,
BaseMessage,
HumanMessage,
get_buffer_string,
)
from langchain_core.runnables.config import run_in_executor
if TYPE_CHECKING:
from collections.abc import Sequence
class BaseChatMessageHistory(ABC):
"""Abstract base class for storing chat message history.
Implementations guidelines:
Implementations are expected to over-ride all or some of the following methods:
* `add_messages`: sync variant for bulk addition of messages
* `aadd_messages`: async variant for bulk addition of messages
* `messages`: sync variant for getting messages
* `aget_messages`: async variant for getting messages
* `clear`: sync variant for clearing messages
* `aclear`: async variant for clearing messages
`add_messages` contains a default implementation that calls `add_message`
for each message in the sequence. This is provided for backwards compatibility
with existing implementations which only had `add_message`.
Async variants all have default implementations that call the sync variants.
Implementers can choose to override the async implementations to provide
truly async implementations.
Usage guidelines:
When used for updating history, users should favor usage of `add_messages`
over `add_message` or other variants like `add_user_message` and `add_ai_message`
to avoid unnecessary round-trips to the underlying persistence layer.
Example:
```python
import json
import os
from langchain_core.messages import messages_from_dict, message_to_dict
class FileChatMessageHistory(BaseChatMessageHistory):
storage_path: str
session_id: str
@property
def messages(self) -> list[BaseMessage]:
try:
with open(
os.path.join(self.storage_path, self.session_id),
"r",
encoding="utf-8",
) as f:
messages_data = json.load(f)
return messages_from_dict(messages_data)
except FileNotFoundError:
return []
def add_messages(self, messages: Sequence[BaseMessage]) -> None:
all_messages = list(self.messages) # Existing messages
all_messages.extend(messages) # Add new messages
serialized = [message_to_dict(message) for message in all_messages]
file_path = os.path.join(self.storage_path, self.session_id)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, "w", encoding="utf-8") as f:
json.dump(serialized, f)
def clear(self) -> None:
file_path = os.path.join(self.storage_path, self.session_id)
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, "w", encoding="utf-8") as f:
json.dump([], f)
```
"""
messages: list[BaseMessage]
"""A property or attribute that returns a list of messages.
In general, getting the messages may involve IO to the underlying persistence
layer, so this operation is expected to incur some latency.
"""
async def aget_messages(self) -> list[BaseMessage]:
"""Async version of getting messages.
Can over-ride this method to provide an efficient async implementation.
In general, fetching messages may involve IO to the underlying persistence
layer.
Returns:
The messages.
"""
return await run_in_executor(None, lambda: self.messages)
def add_user_message(self, message: HumanMessage | str) -> None:
"""Convenience method for adding a human message string to the store.
!!! note
This is a convenience method. Code should favor the bulk `add_messages`
interface instead to save on round-trips to the persistence layer.
This method may be deprecated in a future release.
Args:
message: The `HumanMessage` to add to the store.
"""
if isinstance(message, HumanMessage):
self.add_message(message)
else:
self.add_message(HumanMessage(content=message))
def add_ai_message(self, message: AIMessage | str) -> None:
"""Convenience method for adding an `AIMessage` string to the store.
!!! note
This is a convenience method. Code should favor the bulk `add_messages`
interface instead to save on round-trips to the persistence layer.
This method may be deprecated in a future release.
Args:
message: The `AIMessage` to add.
"""
if isinstance(message, AIMessage):
self.add_message(message)
else:
self.add_message(AIMessage(content=message))
def add_message(self, message: BaseMessage) -> None:
"""Add a Message object to the store.
Args:
message: A `BaseMessage` object to store.
Raises:
NotImplementedError: If the sub-class has not implemented an efficient
`add_messages` method.
"""
if type(self).add_messages != BaseChatMessageHistory.add_messages:
# This means that the sub-class has implemented an efficient add_messages
# method, so we should use it.
self.add_messages([message])
else:
msg = (
"add_message is not implemented for this class. "
"Please implement add_message or add_messages."
)
raise NotImplementedError(msg)
def add_messages(self, messages: Sequence[BaseMessage]) -> None:
"""Add a list of messages.
Implementations should over-ride this method to handle bulk addition of messages
in an efficient manner to avoid unnecessary round-trips to the underlying store.
Args:
messages: A sequence of `BaseMessage` objects to store.
"""
for message in messages:
self.add_message(message)
async def aadd_messages(self, messages: Sequence[BaseMessage]) -> None:
"""Async add a list of messages.
Args:
messages: A sequence of `BaseMessage` objects to store.
"""
await run_in_executor(None, self.add_messages, messages)
@abstractmethod
def clear(self) -> None:
"""Remove all messages from the store."""
async def aclear(self) -> None:
"""Async remove all messages from the store."""
await run_in_executor(None, self.clear)
def __str__(self) -> str:
"""Return a string representation of the chat history."""
return get_buffer_string(self.messages)
class InMemoryChatMessageHistory(BaseChatMessageHistory, BaseModel):
"""In memory implementation of chat message history.
Stores messages in a memory list.
"""
messages: list[BaseMessage] = Field(default_factory=list)
"""A list of messages stored in memory."""
async def aget_messages(self) -> list[BaseMessage]:
"""Async version of getting messages.
Can over-ride this method to provide an efficient async implementation.
In general, fetching messages may involve IO to the underlying persistence
layer.
Returns:
List of messages.
"""
return self.messages
def add_message(self, message: BaseMessage) -> None:
"""Add a self-created message to the store.
Args:
message: The message to add.
"""
self.messages.append(message)
async def aadd_messages(self, messages: Sequence[BaseMessage]) -> None:
"""Async add messages to the store.
Args:
messages: The messages to add.
"""
self.add_messages(messages)
def clear(self) -> None:
"""Clear all messages from the store."""
self.messages = []
async def aclear(self) -> None:
"""Async clear all messages from the store."""
self.clear()
@@ -0,0 +1,26 @@
"""Chat loaders."""
from abc import ABC, abstractmethod
from collections.abc import Iterator
from langchain_core.chat_sessions import ChatSession
class BaseChatLoader(ABC):
"""Base class for chat loaders."""
@abstractmethod
def lazy_load(self) -> Iterator[ChatSession]:
"""Lazy load the chat sessions.
Returns:
An iterator of chat sessions.
"""
def load(self) -> list[ChatSession]:
"""Eagerly load the chat sessions into memory.
Returns:
A list of chat sessions.
"""
return list(self.lazy_load())
@@ -0,0 +1,19 @@
"""**Chat Sessions** are a collection of messages and function calls."""
from collections.abc import Sequence
from typing import TypedDict
from langchain_core.messages import BaseMessage
class ChatSession(TypedDict, total=False):
"""Chat Session.
Chat Session represents a single conversation, channel, or other group of messages.
"""
messages: Sequence[BaseMessage]
"""A sequence of the LangChain chat messages loaded from the source."""
functions: Sequence[dict]
"""A sequence of the function calling specs for the messages."""
@@ -0,0 +1,18 @@
"""Cross Encoder interface."""
from abc import ABC, abstractmethod
class BaseCrossEncoder(ABC):
"""Interface for cross encoder models."""
@abstractmethod
def score(self, text_pairs: list[tuple[str, str]]) -> list[float]:
"""Score pairs' similarity.
Args:
text_pairs: List of pairs of texts.
Returns:
List of scores.
"""
@@ -0,0 +1,39 @@
"""Document loaders."""
from typing import TYPE_CHECKING
from langchain_core._import_utils import import_attr
if TYPE_CHECKING:
from langchain_core.document_loaders.base import BaseBlobParser, BaseLoader
from langchain_core.document_loaders.blob_loaders import Blob, BlobLoader, PathLike
from langchain_core.document_loaders.langsmith import LangSmithLoader
__all__ = (
"BaseBlobParser",
"BaseLoader",
"Blob",
"BlobLoader",
"LangSmithLoader",
"PathLike",
)
_dynamic_imports = {
"BaseBlobParser": "base",
"BaseLoader": "base",
"Blob": "blob_loaders",
"BlobLoader": "blob_loaders",
"PathLike": "blob_loaders",
"LangSmithLoader": "langsmith",
}
def __getattr__(attr_name: str) -> object:
module_name = _dynamic_imports.get(attr_name)
result = import_attr(attr_name, module_name, __spec__.parent)
globals()[attr_name] = result
return result
def __dir__() -> list[str]:
return list(__all__)
@@ -0,0 +1,155 @@
"""Abstract interface for document loader implementations."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from langchain_core.runnables import run_in_executor
if TYPE_CHECKING:
from collections.abc import AsyncIterator, Iterator
from langchain_text_splitters import TextSplitter
from langchain_core.documents import Document
from langchain_core.documents.base import Blob
try:
from langchain_text_splitters import RecursiveCharacterTextSplitter
_HAS_TEXT_SPLITTERS = True
except ImportError:
_HAS_TEXT_SPLITTERS = False
class BaseLoader(ABC): # noqa: B024
"""Interface for document loader.
Implementations should implement the lazy-loading method using generators to avoid
loading all documents into memory at once.
`load` is provided just for user convenience and should not be overridden.
"""
# Sub-classes should not implement this method directly. Instead, they
# should implement the lazy load method.
def load(self) -> list[Document]:
"""Load data into `Document` objects.
Returns:
The documents.
"""
return list(self.lazy_load())
async def aload(self) -> list[Document]:
"""Load data into `Document` objects.
Returns:
The documents.
"""
return [document async for document in self.alazy_load()]
def load_and_split(
self, text_splitter: TextSplitter | None = None
) -> list[Document]:
"""Load `Document` and split into chunks. Chunks are returned as `Document`.
!!! danger
Do not override this method. It should be considered to be deprecated!
Args:
text_splitter: `TextSplitter` instance to use for splitting documents.
Defaults to `RecursiveCharacterTextSplitter`.
Raises:
ImportError: If `langchain-text-splitters` is not installed and no
`text_splitter` is provided.
Returns:
List of `Document` objects.
"""
if text_splitter is None:
if not _HAS_TEXT_SPLITTERS:
msg = (
"Unable to import from langchain_text_splitters. Please specify "
"text_splitter or install langchain_text_splitters with "
"`pip install -U langchain-text-splitters`."
)
raise ImportError(msg)
text_splitter_: TextSplitter = RecursiveCharacterTextSplitter()
else:
text_splitter_ = text_splitter
docs = self.load()
return text_splitter_.split_documents(docs)
# Attention: This method will be upgraded into an abstractmethod once it's
# implemented in all the existing subclasses.
def lazy_load(self) -> Iterator[Document]:
"""A lazy loader for `Document`.
Yields:
The `Document` objects.
"""
if type(self).load != BaseLoader.load:
return iter(self.load())
msg = f"{self.__class__.__name__} does not implement lazy_load()"
raise NotImplementedError(msg)
async def alazy_load(self) -> AsyncIterator[Document]:
"""A lazy loader for `Document`.
Yields:
The `Document` objects.
"""
iterator = await run_in_executor(None, self.lazy_load)
done = object()
while True:
doc = await run_in_executor(None, next, iterator, done)
if doc is done:
break
yield doc # type: ignore[misc]
class BaseBlobParser(ABC):
"""Abstract interface for blob parsers.
A blob parser provides a way to parse raw data stored in a blob into one or more
`Document` objects.
The parser can be composed with blob loaders, making it easy to reuse a parser
independent of how the blob was originally loaded.
"""
@abstractmethod
def lazy_parse(self, blob: Blob) -> Iterator[Document]:
"""Lazy parsing interface.
Subclasses are required to implement this method.
Args:
blob: `Blob` instance
Returns:
Generator of `Document` objects
"""
def parse(self, blob: Blob) -> list[Document]:
"""Eagerly parse the blob into a `Document` or list of `Document` objects.
This is a convenience method for interactive development environment.
Production applications should favor the `lazy_parse` method instead.
Subclasses should generally not over-ride this parse method.
Args:
blob: `Blob` instance
Returns:
List of `Document` objects
"""
return list(self.lazy_parse(blob))
@@ -0,0 +1,38 @@
"""Schema for Blobs and Blob Loaders.
The goal is to facilitate decoupling of content loading from content parsing code. In
addition, content loading code should provide a lazy loading interface by default.
"""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
# Re-export Blob and PathLike for backwards compatibility
from langchain_core.documents.base import Blob, PathLike
if TYPE_CHECKING:
from collections.abc import Iterator
class BlobLoader(ABC):
"""Abstract interface for blob loaders implementation.
Implementer should be able to load raw content from a storage system according to
some criteria and return the raw content lazily as a stream of blobs.
"""
@abstractmethod
def yield_blobs(
self,
) -> Iterator[Blob]:
"""A lazy loader for raw data represented by LangChain's `Blob` object.
Yields:
`Blob` objects.
"""
# Re-export Blob and Pathlike for backwards compatibility
__all__ = ["Blob", "BlobLoader", "PathLike"]
@@ -0,0 +1,143 @@
"""LangSmith document loader."""
import datetime
import json
import uuid
from collections.abc import Callable, Iterator, Sequence
from typing import Any
from langsmith import Client as LangSmithClient
from typing_extensions import override
from langchain_core.document_loaders.base import BaseLoader
from langchain_core.documents import Document
from langchain_core.tracers._compat import pydantic_to_dict
class LangSmithLoader(BaseLoader):
"""Load LangSmith Dataset examples as `Document` objects.
Loads the example inputs as the `Document` page content and places the entire
example into the `Document` metadata. This allows you to easily create few-shot
example retrievers from the loaded documents.
??? example "Lazy loading"
```python
from langchain_core.document_loaders import LangSmithLoader
loader = LangSmithLoader(dataset_id="...", limit=100)
docs = []
for doc in loader.lazy_load():
docs.append(doc)
```
```python
# -> [Document("...", metadata={"inputs": {...}, "outputs": {...}, ...}), ...]
```
"""
def __init__(
self,
*,
dataset_id: uuid.UUID | str | None = None,
dataset_name: str | None = None,
example_ids: Sequence[uuid.UUID | str] | None = None,
as_of: datetime.datetime | str | None = None,
splits: Sequence[str] | None = None,
inline_s3_urls: bool = True,
offset: int = 0,
limit: int | None = None,
metadata: dict | None = None,
filter: str | None = None, # noqa: A002
content_key: str = "",
format_content: Callable[..., str] | None = None,
client: LangSmithClient | None = None,
**client_kwargs: Any,
) -> None:
"""Create a LangSmith loader.
Args:
dataset_id: The ID of the dataset to filter by.
dataset_name: The name of the dataset to filter by.
content_key: The inputs key to set as `Document` page content.
`'.'` characters are interpreted as nested keys, e.g.
`content_key="first.second"` will result in
`Document(page_content=format_content(example.inputs["first"]["second"]))`
format_content: Function for converting the content extracted from the example
inputs into a string.
Defaults to JSON-encoding the contents.
example_ids: The IDs of the examples to filter by.
as_of: The dataset version tag or timestamp to retrieve the examples as of.
Response examples will only be those that were present at the time of
the tagged (or timestamped) version.
splits: A list of dataset splits, which are divisions of your dataset such
as `train`, `test`, or `validation`.
Returns examples only from the specified splits.
inline_s3_urls: Whether to inline S3 URLs.
offset: The offset to start from.
limit: The maximum number of examples to return.
metadata: Metadata to filter by.
filter: A structured filter string to apply to the examples.
client: LangSmith Client.
If not provided will be initialized from below args.
client_kwargs: Keyword args to pass to LangSmith client init.
Should only be specified if `client` isn't.
Raises:
ValueError: If both `client` and `client_kwargs` are provided.
""" # noqa: E501
if client and client_kwargs:
raise ValueError
self._client = client or LangSmithClient(**client_kwargs)
self.content_key = list(content_key.split(".")) if content_key else []
self.format_content = format_content or _stringify
self.dataset_id = dataset_id
self.dataset_name = dataset_name
self.example_ids = example_ids
self.as_of = as_of
self.splits = splits
self.inline_s3_urls = inline_s3_urls
self.offset = offset
self.limit = limit
self.metadata = metadata
self.filter = filter
@override
def lazy_load(self) -> Iterator[Document]:
for example in self._client.list_examples(
dataset_id=self.dataset_id,
dataset_name=self.dataset_name,
example_ids=self.example_ids,
as_of=self.as_of,
splits=self.splits,
inline_s3_urls=self.inline_s3_urls,
offset=self.offset,
limit=self.limit,
metadata=self.metadata,
filter=self.filter,
):
content: Any = example.inputs
for key in self.content_key:
content = content[key]
content_str = self.format_content(content)
metadata = pydantic_to_dict(example)
# Stringify datetime and UUID types.
for k in ("dataset_id", "created_at", "modified_at", "source_run_id", "id"):
metadata[k] = str(metadata[k]) if metadata[k] else metadata[k]
yield Document(content_str, metadata=metadata)
def _stringify(x: str | dict[str, Any]) -> str:
if isinstance(x, str):
return x
try:
return json.dumps(x, indent=2)
except Exception:
return str(x)
@@ -0,0 +1,55 @@
"""Documents module for data retrieval and processing workflows.
This module provides core abstractions for handling data in retrieval-augmented
generation (RAG) pipelines, vector stores, and document processing workflows.
!!! warning "Documents vs. message content"
This module is distinct from `langchain_core.messages.content`, which provides
multimodal content blocks for **LLM chat I/O** (text, images, audio, etc. within
messages).
**Key distinction:**
- **Documents** (this module): For **data retrieval and processing workflows**
- Vector stores, retrievers, RAG pipelines
- Text chunking, embedding, and semantic search
- Example: Chunks of a PDF stored in a vector database
- **Content Blocks** (`messages.content`): For **LLM conversational I/O**
- Multimodal message content sent to/from models
- Tool calls, reasoning, citations within chat
- Example: An image sent to a vision model in a chat message (via
[`ImageContentBlock`][langchain.messages.ImageContentBlock])
While both can represent similar data types (text, files), they serve different
architectural purposes in LangChain applications.
"""
from typing import TYPE_CHECKING
from langchain_core._import_utils import import_attr
if TYPE_CHECKING:
from langchain_core.documents.base import Document
from langchain_core.documents.compressor import BaseDocumentCompressor
from langchain_core.documents.transformers import BaseDocumentTransformer
__all__ = ("BaseDocumentCompressor", "BaseDocumentTransformer", "Document")
_dynamic_imports = {
"Document": "base",
"BaseDocumentCompressor": "compressor",
"BaseDocumentTransformer": "transformers",
}
def __getattr__(attr_name: str) -> object:
module_name = _dynamic_imports.get(attr_name)
result = import_attr(attr_name, module_name, __spec__.parent)
globals()[attr_name] = result
return result
def __dir__() -> list[str]:
return list(__all__)
@@ -0,0 +1,347 @@
"""Base classes for media and documents.
This module contains core abstractions for **data retrieval and processing workflows**:
- `BaseMedia`: Base class providing `id` and `metadata` fields
- `Blob`: Raw data loading (files, binary data) - used by document loaders
- `Document`: Text content for retrieval (RAG, vector stores, semantic search)
!!! note "Not for LLM chat messages"
These classes are for data processing pipelines, not LLM I/O. For multimodal
content in chat messages (images, audio in conversations), see
`langchain.messages` content blocks instead.
"""
from __future__ import annotations
import contextlib
import mimetypes
from io import BufferedReader, BytesIO
from pathlib import Path, PurePath
from typing import TYPE_CHECKING, Any, Literal, cast
from pydantic import ConfigDict, Field, model_validator
from langchain_core.load.serializable import Serializable
if TYPE_CHECKING:
from collections.abc import Generator
PathLike = str | PurePath
class BaseMedia(Serializable):
"""Base class for content used in retrieval and data processing workflows.
Provides common fields for content that needs to be stored, indexed, or searched.
!!! note
For multimodal content in **chat messages** (images, audio sent to/from LLMs),
use `langchain.messages` content blocks instead.
"""
# The ID field is optional at the moment.
# It will likely become required in a future major release after
# it has been adopted by enough VectorStore implementations.
id: str | None = Field(default=None, coerce_numbers_to_str=True)
"""An optional identifier for the document.
Ideally this should be unique across the document collection and formatted
as a UUID, but this will not be enforced.
"""
metadata: dict = Field(default_factory=dict)
"""Arbitrary metadata associated with the content."""
class Blob(BaseMedia):
"""Raw data abstraction for document loading and file processing.
Represents raw bytes or text, either in-memory or by file reference. Used
primarily by document loaders to decouple data loading from parsing.
Inspired by [Mozilla's `Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob)
???+ example "Initialize a blob from in-memory data"
```python
from langchain_core.documents import Blob
blob = Blob.from_data("Hello, world!")
# Read the blob as a string
print(blob.as_string())
# Read the blob as bytes
print(blob.as_bytes())
# Read the blob as a byte stream
with blob.as_bytes_io() as f:
print(f.read())
```
??? example "Load from memory and specify MIME type and metadata"
```python
from langchain_core.documents import Blob
blob = Blob.from_data(
data="Hello, world!",
mime_type="text/plain",
metadata={"source": "https://example.com"},
)
```
??? example "Load the blob from a file"
```python
from langchain_core.documents import Blob
blob = Blob.from_path("path/to/file.txt")
# Read the blob as a string
print(blob.as_string())
# Read the blob as bytes
print(blob.as_bytes())
# Read the blob as a byte stream
with blob.as_bytes_io() as f:
print(f.read())
```
"""
data: bytes | str | None = None
"""Raw data associated with the `Blob`."""
mimetype: str | None = None
"""MIME type, not to be confused with a file extension."""
encoding: str = "utf-8"
"""Encoding to use if decoding the bytes into a string.
Uses `utf-8` as default encoding if decoding to string.
"""
path: PathLike | None = None
"""Location where the original content was found."""
model_config = ConfigDict(
arbitrary_types_allowed=True,
frozen=True,
)
@property
def source(self) -> str | None:
"""The source location of the blob as string if known otherwise none.
If a path is associated with the `Blob`, it will default to the path location.
Unless explicitly set via a metadata field called `'source'`, in which
case that value will be used instead.
"""
if self.metadata and "source" in self.metadata:
return cast("str | None", self.metadata["source"])
return str(self.path) if self.path else None
@model_validator(mode="before")
@classmethod
def check_blob_is_valid(cls, values: dict[str, Any]) -> Any:
"""Verify that either data or path is provided."""
if "data" not in values and "path" not in values:
msg = "Either data or path must be provided"
raise ValueError(msg)
return values
def as_string(self) -> str:
"""Read data as a string.
Raises:
ValueError: If the blob cannot be represented as a string.
Returns:
The data as a string.
"""
if self.data is None and self.path:
return Path(self.path).read_text(encoding=self.encoding)
if isinstance(self.data, bytes):
return self.data.decode(self.encoding)
if isinstance(self.data, str):
return self.data
msg = f"Unable to get string for blob {self}"
raise ValueError(msg)
def as_bytes(self) -> bytes:
"""Read data as bytes.
Raises:
ValueError: If the blob cannot be represented as bytes.
Returns:
The data as bytes.
"""
if isinstance(self.data, bytes):
return self.data
if isinstance(self.data, str):
return self.data.encode(self.encoding)
if self.data is None and self.path:
return Path(self.path).read_bytes()
msg = f"Unable to get bytes for blob {self}"
raise ValueError(msg)
@contextlib.contextmanager
def as_bytes_io(self) -> Generator[BytesIO | BufferedReader, None, None]:
"""Read data as a byte stream.
Raises:
NotImplementedError: If the blob cannot be represented as a byte stream.
Yields:
The data as a byte stream.
"""
if isinstance(self.data, bytes):
yield BytesIO(self.data)
elif self.data is None and self.path:
with Path(self.path).open("rb") as f:
yield f
else:
msg = f"Unable to convert blob {self}"
raise NotImplementedError(msg)
@classmethod
def from_path(
cls,
path: PathLike,
*,
encoding: str = "utf-8",
mime_type: str | None = None,
guess_type: bool = True,
metadata: dict | None = None,
) -> Blob:
"""Load the blob from a path like object.
Args:
path: Path-like object to file to be read
encoding: Encoding to use if decoding the bytes into a string
mime_type: If provided, will be set as the MIME type of the data
guess_type: If `True`, the MIME type will be guessed from the file
extension, if a MIME type was not provided
metadata: Metadata to associate with the `Blob`
Returns:
`Blob` instance
"""
if mime_type is None and guess_type:
mimetype = mimetypes.guess_type(path)[0]
else:
mimetype = mime_type
# We do not load the data immediately, instead we treat the blob as a
# reference to the underlying data.
return cls(
data=None,
mimetype=mimetype,
encoding=encoding,
path=path,
metadata=metadata if metadata is not None else {},
)
@classmethod
def from_data(
cls,
data: str | bytes,
*,
encoding: str = "utf-8",
mime_type: str | None = None,
path: str | None = None,
metadata: dict | None = None,
) -> Blob:
"""Initialize the `Blob` from in-memory data.
Args:
data: The in-memory data associated with the `Blob`
encoding: Encoding to use if decoding the bytes into a string
mime_type: If provided, will be set as the MIME type of the data
path: If provided, will be set as the source from which the data came
metadata: Metadata to associate with the `Blob`
Returns:
`Blob` instance
"""
return cls(
data=data,
mimetype=mime_type,
encoding=encoding,
path=path,
metadata=metadata if metadata is not None else {},
)
def __repr__(self) -> str:
"""Return the blob representation."""
str_repr = f"Blob {id(self)}"
if self.source:
str_repr += f" {self.source}"
return str_repr
class Document(BaseMedia):
"""Class for storing a piece of text and associated metadata.
!!! note
`Document` is for **retrieval workflows**, not chat I/O. For sending text
to an LLM in a conversation, use message types from `langchain.messages`.
Example:
```python
from langchain_core.documents import Document
document = Document(
page_content="Hello, world!", metadata={"source": "https://example.com"}
)
```
"""
page_content: str
"""String text."""
type: Literal["Document"] = "Document"
def __init__(self, page_content: str, **kwargs: Any) -> None:
"""Pass page_content in as positional or named arg."""
# my-py is complaining that page_content is not defined on the base class.
# Here, we're relying on pydantic base class to handle the validation.
super().__init__(page_content=page_content, **kwargs) # type: ignore[call-arg,unused-ignore]
@classmethod
def is_lc_serializable(cls) -> bool:
"""Return `True` as this class is serializable."""
return True
@classmethod
def get_lc_namespace(cls) -> list[str]:
"""Get the namespace of the LangChain object.
Returns:
`["langchain", "schema", "document"]`
"""
return ["langchain", "schema", "document"]
def __str__(self) -> str:
"""Override `__str__` to restrict it to page_content and metadata.
Returns:
A string representation of the `Document`.
"""
# The format matches pydantic format for __str__.
#
# The purpose of this change is to make sure that user code that feeds
# Document objects directly into prompts remains unchanged due to the addition
# of the id field (or any other fields in the future).
#
# This override will likely be removed in the future in favor of a more general
# solution of formatting content directly inside the prompts.
if self.metadata:
return f"page_content='{self.page_content}' metadata={self.metadata}"
return f"page_content='{self.page_content}'"
@@ -0,0 +1,74 @@
"""Document compressor."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING
from pydantic import BaseModel
from langchain_core.runnables import run_in_executor
if TYPE_CHECKING:
from collections.abc import Sequence
from langchain_core.callbacks import Callbacks
from langchain_core.documents import Document
class BaseDocumentCompressor(BaseModel, ABC):
"""Base class for document compressors.
This abstraction is primarily used for post-processing of retrieved documents.
`Document` objects matching a given query are first retrieved.
Then the list of documents can be further processed.
For example, one could re-rank the retrieved documents using an LLM.
!!! note
Users should favor using a `RunnableLambda` instead of sub-classing from this
interface.
"""
@abstractmethod
def compress_documents(
self,
documents: Sequence[Document],
query: str,
callbacks: Callbacks | None = None,
) -> Sequence[Document]:
"""Compress retrieved documents given the query context.
Args:
documents: The retrieved `Document` objects.
query: The query context.
callbacks: Optional `Callbacks` to run during compression.
Returns:
The compressed documents.
"""
async def acompress_documents(
self,
documents: Sequence[Document],
query: str,
callbacks: Callbacks | None = None,
) -> Sequence[Document]:
"""Async compress retrieved documents given the query context.
Args:
documents: The retrieved `Document` objects.
query: The query context.
callbacks: Optional `Callbacks` to run during compression.
Returns:
The compressed documents.
"""
return await run_in_executor(
None, self.compress_documents, documents, query, callbacks
)
@@ -0,0 +1,79 @@
"""Document transformers."""
from __future__ import annotations
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any
from langchain_core.runnables.config import run_in_executor
if TYPE_CHECKING:
from collections.abc import Sequence
from langchain_core.documents import Document
class BaseDocumentTransformer(ABC):
"""Abstract base class for document transformation.
A document transformation takes a sequence of `Document` objects and returns a
sequence of transformed `Document` objects.
Example:
```python
class EmbeddingsRedundantFilter(BaseDocumentTransformer, BaseModel):
embeddings: Embeddings
similarity_fn: Callable = cosine_similarity
similarity_threshold: float = 0.95
class Config:
arbitrary_types_allowed = True
def transform_documents(
self, documents: Sequence[Document], **kwargs: Any
) -> Sequence[Document]:
stateful_documents = get_stateful_documents(documents)
embedded_documents = _get_embeddings_from_stateful_docs(
self.embeddings, stateful_documents
)
included_idxs = _filter_similar_embeddings(
embedded_documents,
self.similarity_fn,
self.similarity_threshold,
)
return [stateful_documents[i] for i in sorted(included_idxs)]
async def atransform_documents(
self, documents: Sequence[Document], **kwargs: Any
) -> Sequence[Document]:
raise NotImplementedError
```
"""
@abstractmethod
def transform_documents(
self, documents: Sequence[Document], **kwargs: Any
) -> Sequence[Document]:
"""Transform a list of documents.
Args:
documents: A sequence of `Document` objects to be transformed.
Returns:
A sequence of transformed `Document` objects.
"""
async def atransform_documents(
self, documents: Sequence[Document], **kwargs: Any
) -> Sequence[Document]:
"""Asynchronously transform a list of documents.
Args:
documents: A sequence of `Document` objects to be transformed.
Returns:
A sequence of transformed `Document` objects.
"""
return await run_in_executor(
None, self.transform_documents, documents, **kwargs
)
@@ -0,0 +1,31 @@
"""Embeddings."""
from typing import TYPE_CHECKING
from langchain_core._import_utils import import_attr
if TYPE_CHECKING:
from langchain_core.embeddings.embeddings import Embeddings
from langchain_core.embeddings.fake import (
DeterministicFakeEmbedding,
FakeEmbeddings,
)
__all__ = ("DeterministicFakeEmbedding", "Embeddings", "FakeEmbeddings")
_dynamic_imports = {
"Embeddings": "embeddings",
"DeterministicFakeEmbedding": "fake",
"FakeEmbeddings": "fake",
}
def __getattr__(attr_name: str) -> object:
module_name = _dynamic_imports.get(attr_name)
result = import_attr(attr_name, module_name, __spec__.parent)
globals()[attr_name] = result
return result
def __dir__() -> list[str]:
return list(__all__)
@@ -0,0 +1,78 @@
"""**Embeddings** interface."""
from abc import ABC, abstractmethod
from langchain_core.runnables.config import run_in_executor
class Embeddings(ABC):
"""Interface for embedding models.
This is an interface meant for implementing text embedding models.
Text embedding models are used to map text to a vector (a point in n-dimensional
space).
Texts that are similar will usually be mapped to points that are close to each
other in this space. The exact details of what's considered "similar" and how
"distance" is measured in this space are dependent on the specific embedding model.
This abstraction contains a method for embedding a list of documents and a method
for embedding a query text. The embedding of a query text is expected to be a single
vector, while the embedding of a list of documents is expected to be a list of
vectors.
Usually the query embedding is identical to the document embedding, but the
abstraction allows treating them independently.
In addition to the synchronous methods, this interface also provides asynchronous
versions of the methods.
By default, the asynchronous methods are implemented using the synchronous methods;
however, implementations may choose to override the asynchronous methods with
an async native implementation for performance reasons.
"""
@abstractmethod
def embed_documents(self, texts: list[str]) -> list[list[float]]:
"""Embed search docs.
Args:
texts: List of text to embed.
Returns:
List of embeddings.
"""
@abstractmethod
def embed_query(self, text: str) -> list[float]:
"""Embed query text.
Args:
text: Text to embed.
Returns:
Embedding.
"""
async def aembed_documents(self, texts: list[str]) -> list[list[float]]:
"""Asynchronous Embed search docs.
Args:
texts: List of text to embed.
Returns:
List of embeddings.
"""
return await run_in_executor(None, self.embed_documents, texts)
async def aembed_query(self, text: str) -> list[float]:
"""Asynchronous Embed query text.
Args:
text: Text to embed.
Returns:
Embedding.
"""
return await run_in_executor(None, self.embed_query, text)
@@ -0,0 +1,129 @@
"""Module contains a few fake embedding models for testing purposes."""
# Please do not add additional fake embedding model implementations here.
import contextlib
import hashlib
from pydantic import BaseModel
from typing_extensions import override
from langchain_core.embeddings import Embeddings
with contextlib.suppress(ImportError):
import numpy as np
class FakeEmbeddings(Embeddings, BaseModel):
"""Fake embedding model for unit testing purposes.
This embedding model creates embeddings by sampling from a normal distribution.
!!! danger "Toy model"
Do not use this outside of testing, as it is not a real embedding model.
Instantiate:
```python
from langchain_core.embeddings import FakeEmbeddings
embed = FakeEmbeddings(size=100)
```
Embed single text:
```python
input_text = "The meaning of life is 42"
vector = embed.embed_query(input_text)
print(vector[:3])
```
```python
[-0.700234640213188, -0.581266257710429, -1.1328482266445354]
```
Embed multiple texts:
```python
input_texts = ["Document 1...", "Document 2..."]
vectors = embed.embed_documents(input_texts)
print(len(vectors))
# The first 3 coordinates for the first vector
print(vectors[0][:3])
```
```python
2
[-0.5670477847544458, -0.31403828652395727, -0.5840547508955257]
```
"""
size: int
"""The size of the embedding vector."""
def _get_embedding(self) -> list[float]:
return list(np.random.default_rng().normal(size=self.size))
@override
def embed_documents(self, texts: list[str]) -> list[list[float]]:
return [self._get_embedding() for _ in texts]
@override
def embed_query(self, text: str) -> list[float]:
return self._get_embedding()
class DeterministicFakeEmbedding(Embeddings, BaseModel):
"""Deterministic fake embedding model for unit testing purposes.
This embedding model creates embeddings by sampling from a normal distribution
with a seed based on the hash of the text.
!!! danger "Toy model"
Do not use this outside of testing, as it is not a real embedding model.
Instantiate:
```python
from langchain_core.embeddings import DeterministicFakeEmbedding
embed = DeterministicFakeEmbedding(size=100)
```
Embed single text:
```python
input_text = "The meaning of life is 42"
vector = embed.embed_query(input_text)
print(vector[:3])
```
```python
[-0.700234640213188, -0.581266257710429, -1.1328482266445354]
```
Embed multiple texts:
```python
input_texts = ["Document 1...", "Document 2..."]
vectors = embed.embed_documents(input_texts)
print(len(vectors))
# The first 3 coordinates for the first vector
print(vectors[0][:3])
```
```python
2
[-0.5670477847544458, -0.31403828652395727, -0.5840547508955257]
```
"""
size: int
"""The size of the embedding vector."""
def _get_embedding(self, seed: int) -> list[float]:
# set the seed for the random generator
rng = np.random.default_rng(seed)
return list(rng.normal(size=self.size))
@staticmethod
def _get_seed(text: str) -> int:
"""Get a seed for the random generator, using the hash of the text."""
return int(hashlib.sha256(text.encode("utf-8")).hexdigest(), 16) % 10**8
@override
def embed_documents(self, texts: list[str]) -> list[list[float]]:
return [self._get_embedding(seed=self._get_seed(_)) for _ in texts]
@override
def embed_query(self, text: str) -> list[float]:
return self._get_embedding(seed=self._get_seed(text))
@@ -0,0 +1,22 @@
"""Utilities for getting information about the runtime environment."""
import platform
from functools import lru_cache
from langchain_core import __version__
@lru_cache(maxsize=1)
def get_runtime_environment() -> dict:
"""Get information about the LangChain runtime environment.
Returns:
A dictionary with information about the runtime environment.
"""
return {
"library_version": __version__,
"library": "langchain-core",
"platform": platform.platform(),
"runtime": "python",
"runtime_version": platform.python_version(),
}
@@ -0,0 +1,47 @@
"""Example selectors.
**Example selector** implements logic for selecting examples to include them in prompts.
This allows us to select examples that are most relevant to the input.
"""
from typing import TYPE_CHECKING
from langchain_core._import_utils import import_attr
if TYPE_CHECKING:
from langchain_core.example_selectors.base import BaseExampleSelector
from langchain_core.example_selectors.length_based import (
LengthBasedExampleSelector,
)
from langchain_core.example_selectors.semantic_similarity import (
MaxMarginalRelevanceExampleSelector,
SemanticSimilarityExampleSelector,
sorted_values,
)
__all__ = (
"BaseExampleSelector",
"LengthBasedExampleSelector",
"MaxMarginalRelevanceExampleSelector",
"SemanticSimilarityExampleSelector",
"sorted_values",
)
_dynamic_imports = {
"BaseExampleSelector": "base",
"LengthBasedExampleSelector": "length_based",
"MaxMarginalRelevanceExampleSelector": "semantic_similarity",
"SemanticSimilarityExampleSelector": "semantic_similarity",
"sorted_values": "semantic_similarity",
}
def __getattr__(attr_name: str) -> object:
module_name = _dynamic_imports.get(attr_name)
result = import_attr(attr_name, module_name, __spec__.parent)
globals()[attr_name] = result
return result
def __dir__() -> list[str]:
return list(__all__)
@@ -0,0 +1,58 @@
"""Interface for selecting examples to include in prompts."""
from abc import ABC, abstractmethod
from typing import Any
from langchain_core.runnables import run_in_executor
class BaseExampleSelector(ABC):
"""Interface for selecting examples to include in prompts."""
@abstractmethod
def add_example(self, example: dict[str, str]) -> Any:
"""Add new example to store.
Args:
example: A dictionary with keys as input variables
and values as their values.
Returns:
Any return value.
"""
async def aadd_example(self, example: dict[str, str]) -> Any:
"""Async add new example to store.
Args:
example: A dictionary with keys as input variables
and values as their values.
Returns:
Any return value.
"""
return await run_in_executor(None, self.add_example, example)
@abstractmethod
def select_examples(self, input_variables: dict[str, str]) -> list[dict]:
"""Select which examples to use based on the inputs.
Args:
input_variables: A dictionary with keys as input variables
and values as their values.
Returns:
A list of examples.
"""
async def aselect_examples(self, input_variables: dict[str, str]) -> list[dict]:
"""Async select which examples to use based on the inputs.
Args:
input_variables: A dictionary with keys as input variables
and values as their values.
Returns:
A list of examples.
"""
return await run_in_executor(None, self.select_examples, input_variables)
@@ -0,0 +1,128 @@
"""Select examples based on length."""
import re
from collections.abc import Callable
from pydantic import BaseModel, Field, model_validator
from typing_extensions import Self
from langchain_core.example_selectors.base import BaseExampleSelector
from langchain_core.prompts.prompt import PromptTemplate
def _get_length_based(text: str) -> int:
return len(re.split(r"\n| ", text))
class LengthBasedExampleSelector(BaseExampleSelector, BaseModel):
r"""Select examples based on length.
Example:
```python
from langchain_core.example_selectors import LengthBasedExampleSelector
from langchain_core.prompts import PromptTemplate
# Define examples
examples = [
{"input": "happy", "output": "sad"},
{"input": "tall", "output": "short"},
{"input": "fast", "output": "slow"},
]
# Create prompt template
example_prompt = PromptTemplate(
input_variables=["input", "output"],
template="Input: {input}\nOutput: {output}",
)
# Create selector with max length constraint
selector = LengthBasedExampleSelector(
examples=examples,
example_prompt=example_prompt,
max_length=50, # Maximum prompt length
)
# Select examples for a new input
selected = selector.select_examples({"input": "large", "output": "tiny"})
# Returns examples that fit within max_length constraint
```
"""
examples: list[dict]
"""A list of the examples that the prompt template expects."""
example_prompt: PromptTemplate
"""Prompt template used to format the examples."""
get_text_length: Callable[[str], int] = _get_length_based
"""Function to measure prompt length. Defaults to word count."""
max_length: int = 2048
"""Max length for the prompt, beyond which examples are cut."""
example_text_lengths: list[int] = Field(default_factory=list)
"""Length of each example."""
def add_example(self, example: dict[str, str]) -> None:
"""Add new example to list.
Args:
example: A dictionary with keys as input variables
and values as their values.
"""
self.examples.append(example)
string_example = self.example_prompt.format(**example)
self.example_text_lengths.append(self.get_text_length(string_example))
async def aadd_example(self, example: dict[str, str]) -> None:
"""Async add new example to list.
Args:
example: A dictionary with keys as input variables
and values as their values.
"""
self.add_example(example)
@model_validator(mode="after")
def post_init(self) -> Self:
"""Validate that the examples are formatted correctly."""
if self.example_text_lengths:
return self
string_examples = [self.example_prompt.format(**eg) for eg in self.examples]
self.example_text_lengths = [self.get_text_length(eg) for eg in string_examples]
return self
def select_examples(self, input_variables: dict[str, str]) -> list[dict]:
"""Select which examples to use based on the input lengths.
Args:
input_variables: A dictionary with keys as input variables
and values as their values.
Returns:
A list of examples to include in the prompt.
"""
inputs = " ".join(input_variables.values())
remaining_length = self.max_length - self.get_text_length(inputs)
i = 0
examples = []
while remaining_length > 0 and i < len(self.examples):
new_length = remaining_length - self.example_text_lengths[i]
if new_length < 0:
break
examples.append(self.examples[i])
remaining_length = new_length
i += 1
return examples
async def aselect_examples(self, input_variables: dict[str, str]) -> list[dict]:
"""Async select which examples to use based on the input lengths.
Args:
input_variables: A dictionary with keys as input variables
and values as their values.
Returns:
A list of examples to include in the prompt.
"""
return self.select_examples(input_variables)
@@ -0,0 +1,358 @@
"""Example selector that selects examples based on SemanticSimilarity."""
from __future__ import annotations
from abc import ABC
from typing import TYPE_CHECKING, Any
from pydantic import BaseModel, ConfigDict
from langchain_core.example_selectors.base import BaseExampleSelector
from langchain_core.vectorstores import VectorStore
if TYPE_CHECKING:
from langchain_core.documents import Document
from langchain_core.embeddings import Embeddings
def sorted_values(values: dict[str, str]) -> list[Any]:
"""Return a list of values in dict sorted by key.
Args:
values: A dictionary with keys as input variables
and values as their values.
Returns:
A list of values in dict sorted by key.
"""
return [values[val] for val in sorted(values)]
class _VectorStoreExampleSelector(BaseExampleSelector, BaseModel, ABC):
"""Example selector that selects examples based on SemanticSimilarity."""
vectorstore: VectorStore
"""VectorStore that contains information about examples."""
k: int = 4
"""Number of examples to select."""
example_keys: list[str] | None = None
"""Optional keys to filter examples to."""
input_keys: list[str] | None = None
"""Optional keys to filter input to. If provided, the search is based on
the input variables instead of all variables."""
vectorstore_kwargs: dict[str, Any] | None = None
"""Extra arguments passed to similarity_search function of the `VectorStore`."""
model_config = ConfigDict(
arbitrary_types_allowed=True,
extra="forbid",
)
@staticmethod
def _example_to_text(example: dict[str, str], input_keys: list[str] | None) -> str:
if input_keys:
return " ".join(sorted_values({key: example[key] for key in input_keys}))
return " ".join(sorted_values(example))
def _documents_to_examples(self, documents: list[Document]) -> list[dict]:
# Get the examples from the metadata.
# This assumes that examples are stored in metadata.
examples = [dict(e.metadata) for e in documents]
# If example keys are provided, filter examples to those keys.
if self.example_keys:
examples = [{k: eg[k] for k in self.example_keys} for eg in examples]
return examples
def add_example(self, example: dict[str, str]) -> str:
"""Add a new example to vectorstore.
Args:
example: A dictionary with keys as input variables
and values as their values.
Returns:
The ID of the added example.
"""
ids = self.vectorstore.add_texts(
[self._example_to_text(example, self.input_keys)], metadatas=[example]
)
return ids[0]
async def aadd_example(self, example: dict[str, str]) -> str:
"""Async add new example to vectorstore.
Args:
example: A dictionary with keys as input variables
and values as their values.
Returns:
The ID of the added example.
"""
ids = await self.vectorstore.aadd_texts(
[self._example_to_text(example, self.input_keys)], metadatas=[example]
)
return ids[0]
class SemanticSimilarityExampleSelector(_VectorStoreExampleSelector):
"""Select examples based on semantic similarity."""
def select_examples(self, input_variables: dict[str, str]) -> list[dict]:
"""Select examples based on semantic similarity.
Args:
input_variables: The input variables to use for search.
Returns:
The selected examples.
"""
# Get the docs with the highest similarity.
vectorstore_kwargs = self.vectorstore_kwargs or {}
example_docs = self.vectorstore.similarity_search(
self._example_to_text(input_variables, self.input_keys),
k=self.k,
**vectorstore_kwargs,
)
return self._documents_to_examples(example_docs)
async def aselect_examples(self, input_variables: dict[str, str]) -> list[dict]:
"""Asynchronously select examples based on semantic similarity.
Args:
input_variables: The input variables to use for search.
Returns:
The selected examples.
"""
# Get the docs with the highest similarity.
vectorstore_kwargs = self.vectorstore_kwargs or {}
example_docs = await self.vectorstore.asimilarity_search(
self._example_to_text(input_variables, self.input_keys),
k=self.k,
**vectorstore_kwargs,
)
return self._documents_to_examples(example_docs)
@classmethod
def from_examples(
cls,
examples: list[dict],
embeddings: Embeddings,
vectorstore_cls: type[VectorStore],
k: int = 4,
input_keys: list[str] | None = None,
*,
example_keys: list[str] | None = None,
vectorstore_kwargs: dict | None = None,
**vectorstore_cls_kwargs: Any,
) -> SemanticSimilarityExampleSelector:
"""Create k-shot example selector using example list and embeddings.
Reshuffles examples dynamically based on query similarity.
Args:
examples: List of examples to use in the prompt.
embeddings: An initialized embedding API interface, e.g. OpenAIEmbeddings().
vectorstore_cls: A vector store DB interface class, e.g. FAISS.
k: Number of examples to select.
input_keys: If provided, the search is based on the input variables
instead of all variables.
example_keys: If provided, keys to filter examples to.
vectorstore_kwargs: Extra arguments passed to similarity_search function
of the `VectorStore`.
vectorstore_cls_kwargs: optional kwargs containing url for vector store
Returns:
The ExampleSelector instantiated, backed by a vector store.
"""
string_examples = [cls._example_to_text(eg, input_keys) for eg in examples]
vectorstore = vectorstore_cls.from_texts(
string_examples, embeddings, metadatas=examples, **vectorstore_cls_kwargs
)
return cls(
vectorstore=vectorstore,
k=k,
input_keys=input_keys,
example_keys=example_keys,
vectorstore_kwargs=vectorstore_kwargs,
)
@classmethod
async def afrom_examples(
cls,
examples: list[dict],
embeddings: Embeddings,
vectorstore_cls: type[VectorStore],
k: int = 4,
input_keys: list[str] | None = None,
*,
example_keys: list[str] | None = None,
vectorstore_kwargs: dict | None = None,
**vectorstore_cls_kwargs: Any,
) -> SemanticSimilarityExampleSelector:
"""Async create k-shot example selector using example list and embeddings.
Reshuffles examples dynamically based on query similarity.
Args:
examples: List of examples to use in the prompt.
embeddings: An initialized embedding API interface, e.g. OpenAIEmbeddings().
vectorstore_cls: A vector store DB interface class, e.g. FAISS.
k: Number of examples to select.
input_keys: If provided, the search is based on the input variables
instead of all variables.
example_keys: If provided, keys to filter examples to.
vectorstore_kwargs: Extra arguments passed to similarity_search function
of the `VectorStore`.
vectorstore_cls_kwargs: optional kwargs containing url for vector store
Returns:
The ExampleSelector instantiated, backed by a vector store.
"""
string_examples = [cls._example_to_text(eg, input_keys) for eg in examples]
vectorstore = await vectorstore_cls.afrom_texts(
string_examples, embeddings, metadatas=examples, **vectorstore_cls_kwargs
)
return cls(
vectorstore=vectorstore,
k=k,
input_keys=input_keys,
example_keys=example_keys,
vectorstore_kwargs=vectorstore_kwargs,
)
class MaxMarginalRelevanceExampleSelector(_VectorStoreExampleSelector):
"""Select examples based on Max Marginal Relevance.
This was shown to improve performance in this paper:
https://arxiv.org/pdf/2211.13892.pdf
"""
fetch_k: int = 20
"""Number of examples to fetch to rerank."""
def select_examples(self, input_variables: dict[str, str]) -> list[dict]:
"""Select examples based on Max Marginal Relevance.
Args:
input_variables: The input variables to use for search.
Returns:
The selected examples.
"""
example_docs = self.vectorstore.max_marginal_relevance_search(
self._example_to_text(input_variables, self.input_keys),
k=self.k,
fetch_k=self.fetch_k,
)
return self._documents_to_examples(example_docs)
async def aselect_examples(self, input_variables: dict[str, str]) -> list[dict]:
"""Asynchronously select examples based on Max Marginal Relevance.
Args:
input_variables: The input variables to use for search.
Returns:
The selected examples.
"""
example_docs = await self.vectorstore.amax_marginal_relevance_search(
self._example_to_text(input_variables, self.input_keys),
k=self.k,
fetch_k=self.fetch_k,
)
return self._documents_to_examples(example_docs)
@classmethod
def from_examples(
cls,
examples: list[dict],
embeddings: Embeddings,
vectorstore_cls: type[VectorStore],
k: int = 4,
input_keys: list[str] | None = None,
fetch_k: int = 20,
example_keys: list[str] | None = None,
vectorstore_kwargs: dict | None = None,
**vectorstore_cls_kwargs: Any,
) -> MaxMarginalRelevanceExampleSelector:
"""Create k-shot example selector using example list and embeddings.
Reshuffles examples dynamically based on Max Marginal Relevance.
Args:
examples: List of examples to use in the prompt.
embeddings: An initialized embedding API interface, e.g. OpenAIEmbeddings().
vectorstore_cls: A vector store DB interface class, e.g. FAISS.
k: Number of examples to select.
fetch_k: Number of `Document` objects to fetch to pass to MMR algorithm.
input_keys: If provided, the search is based on the input variables
instead of all variables.
example_keys: If provided, keys to filter examples to.
vectorstore_kwargs: Extra arguments passed to similarity_search function
of the `VectorStore`.
vectorstore_cls_kwargs: optional kwargs containing url for vector store
Returns:
The ExampleSelector instantiated, backed by a vector store.
"""
string_examples = [cls._example_to_text(eg, input_keys) for eg in examples]
vectorstore = vectorstore_cls.from_texts(
string_examples, embeddings, metadatas=examples, **vectorstore_cls_kwargs
)
return cls(
vectorstore=vectorstore,
k=k,
fetch_k=fetch_k,
input_keys=input_keys,
example_keys=example_keys,
vectorstore_kwargs=vectorstore_kwargs,
)
@classmethod
async def afrom_examples(
cls,
examples: list[dict],
embeddings: Embeddings,
vectorstore_cls: type[VectorStore],
*,
k: int = 4,
input_keys: list[str] | None = None,
fetch_k: int = 20,
example_keys: list[str] | None = None,
vectorstore_kwargs: dict | None = None,
**vectorstore_cls_kwargs: Any,
) -> MaxMarginalRelevanceExampleSelector:
"""Create k-shot example selector using example list and embeddings.
Reshuffles examples dynamically based on Max Marginal Relevance.
Args:
examples: List of examples to use in the prompt.
embeddings: An initialized embedding API interface, e.g. OpenAIEmbeddings().
vectorstore_cls: A vector store DB interface class, e.g. FAISS.
k: Number of examples to select.
fetch_k: Number of `Document` objects to fetch to pass to MMR algorithm.
input_keys: If provided, the search is based on the input variables
instead of all variables.
example_keys: If provided, keys to filter examples to.
vectorstore_kwargs: Extra arguments passed to similarity_search function
of the `VectorStore`.
vectorstore_cls_kwargs: optional kwargs containing url for vector store
Returns:
The ExampleSelector instantiated, backed by a vector store.
"""
string_examples = [cls._example_to_text(eg, input_keys) for eg in examples]
vectorstore = await vectorstore_cls.afrom_texts(
string_examples, embeddings, metadatas=examples, **vectorstore_cls_kwargs
)
return cls(
vectorstore=vectorstore,
k=k,
fetch_k=fetch_k,
input_keys=input_keys,
example_keys=example_keys,
vectorstore_kwargs=vectorstore_kwargs,
)
@@ -0,0 +1,111 @@
"""Custom **exceptions** for LangChain."""
from enum import Enum
from typing import Any
class LangChainException(Exception): # noqa: N818
"""General LangChain exception."""
class TracerException(LangChainException):
"""Base class for exceptions in tracers module."""
class OutputParserException(ValueError, LangChainException): # noqa: N818
"""Exception that output parsers should raise to signify a parsing error.
This exists to differentiate parsing errors from other code or execution errors
that also may arise inside the output parser.
`OutputParserException` will be available to catch and handle in ways to fix the
parsing error, while other errors will be raised.
"""
def __init__(
self,
error: Any,
observation: str | None = None,
llm_output: str | None = None,
send_to_llm: bool = False, # noqa: FBT001,FBT002
):
"""Create an `OutputParserException`.
Args:
error: The error that's being re-raised or an error message.
observation: String explanation of error which can be passed to a model to
try and remediate the issue.
llm_output: String model output which is error-ing.
send_to_llm: Whether to send the observation and llm_output back to an Agent
after an `OutputParserException` has been raised.
This gives the underlying model driving the agent the context that the
previous output was improperly structured, in the hopes that it will
update the output to the correct format.
Raises:
ValueError: If `send_to_llm` is `True` but either observation or
`llm_output` are not provided.
"""
if isinstance(error, str):
error = create_message(
message=error, error_code=ErrorCode.OUTPUT_PARSING_FAILURE
)
super().__init__(error)
if send_to_llm and (observation is None or llm_output is None):
msg = (
"Arguments 'observation' & 'llm_output'"
" are required if 'send_to_llm' is True"
)
raise ValueError(msg)
self.observation = observation
self.llm_output = llm_output
self.send_to_llm = send_to_llm
class ContextOverflowError(LangChainException):
"""Exception raised when input exceeds the model's context limit.
This exception is raised by chat models when the input tokens exceed
the maximum context window supported by the model.
"""
class ErrorCode(Enum):
"""Error codes."""
INVALID_PROMPT_INPUT = "INVALID_PROMPT_INPUT"
INVALID_TOOL_RESULTS = "INVALID_TOOL_RESULTS" # Used in JS; not Py (yet)
MESSAGE_COERCION_FAILURE = "MESSAGE_COERCION_FAILURE"
MODEL_AUTHENTICATION = "MODEL_AUTHENTICATION" # Used in JS; not Py (yet)
MODEL_NOT_FOUND = "MODEL_NOT_FOUND" # Used in JS; not Py (yet)
MODEL_RATE_LIMIT = "MODEL_RATE_LIMIT" # Used in JS; not Py (yet)
OUTPUT_PARSING_FAILURE = "OUTPUT_PARSING_FAILURE"
def create_message(*, message: str, error_code: ErrorCode) -> str:
"""Create a message with a link to the LangChain troubleshooting guide.
Args:
message: The message to display.
error_code: The error code to display.
Returns:
The full message with the troubleshooting link.
Example:
```python
create_message(
message="Failed to parse output",
error_code=ErrorCode.OUTPUT_PARSING_FAILURE,
)
"Failed to parse output. For troubleshooting, visit: ..."
```
"""
return (
f"{message}\n"
"For troubleshooting, visit: https://docs.langchain.com/oss/python/langchain"
f"/errors/{error_code.value} "
)
@@ -0,0 +1,72 @@
"""Global values and configuration that apply to all of LangChain."""
from typing import TYPE_CHECKING, Optional
if TYPE_CHECKING:
from langchain_core.caches import BaseCache
# DO NOT USE THESE VALUES DIRECTLY!
# Use them only via `get_<X>()` and `set_<X>()` below,
# or else your code may behave unexpectedly with other uses of these global settings:
# https://github.com/langchain-ai/langchain/pull/11311#issuecomment-1743780004
_verbose: bool = False
_debug: bool = False
_llm_cache: Optional["BaseCache"] = None
def set_verbose(value: bool) -> None: # noqa: FBT001
"""Set a new value for the `verbose` global setting.
Args:
value: The new value for the `verbose` global setting.
"""
global _verbose # noqa: PLW0603
_verbose = value
def get_verbose() -> bool:
"""Get the value of the `verbose` global setting.
Returns:
The value of the `verbose` global setting.
"""
return _verbose
def set_debug(value: bool) -> None: # noqa: FBT001
"""Set a new value for the `debug` global setting.
Args:
value: The new value for the `debug` global setting.
"""
global _debug # noqa: PLW0603
_debug = value
def get_debug() -> bool:
"""Get the value of the `debug` global setting.
Returns:
The value of the `debug` global setting.
"""
return _debug
def set_llm_cache(value: Optional["BaseCache"]) -> None:
"""Set a new LLM cache, overwriting the previous value, if any.
Args:
value: The new LLM cache to use. If `None`, the LLM cache is disabled.
"""
global _llm_cache # noqa: PLW0603
_llm_cache = value
def get_llm_cache() -> Optional["BaseCache"]:
"""Get the value of the `llm_cache` global setting.
Returns:
The value of the `llm_cache` global setting.
"""
return _llm_cache
@@ -0,0 +1,53 @@
"""Code to help indexing data into a vectorstore.
This package contains helper logic to help deal with indexing data into
a `VectorStore` while avoiding duplicated content and over-writing content
if it's unchanged.
"""
from typing import TYPE_CHECKING
from langchain_core._import_utils import import_attr
if TYPE_CHECKING:
from langchain_core.indexing.api import IndexingResult, aindex, index
from langchain_core.indexing.base import (
DeleteResponse,
DocumentIndex,
InMemoryRecordManager,
RecordManager,
UpsertResponse,
)
__all__ = (
"DeleteResponse",
"DocumentIndex",
"InMemoryRecordManager",
"IndexingResult",
"RecordManager",
"UpsertResponse",
"aindex",
"index",
)
_dynamic_imports = {
"aindex": "api",
"index": "api",
"IndexingResult": "api",
"DeleteResponse": "base",
"DocumentIndex": "base",
"InMemoryRecordManager": "base",
"RecordManager": "base",
"UpsertResponse": "base",
}
def __getattr__(attr_name: str) -> object:
module_name = _dynamic_imports.get(attr_name)
result = import_attr(attr_name, module_name, __spec__.parent)
globals()[attr_name] = result
return result
def __dir__() -> list[str]:
return list(__all__)
@@ -0,0 +1,954 @@
"""Module contains logic for indexing documents into vector stores."""
from __future__ import annotations
import hashlib
import json
import uuid
import warnings
from itertools import islice
from typing import (
TYPE_CHECKING,
Any,
Literal,
TypedDict,
TypeVar,
cast,
)
from langchain_core.document_loaders.base import BaseLoader
from langchain_core.documents import Document
from langchain_core.exceptions import LangChainException
from langchain_core.indexing.base import DocumentIndex, RecordManager
from langchain_core.vectorstores import VectorStore
if TYPE_CHECKING:
from collections.abc import (
AsyncIterable,
AsyncIterator,
Callable,
Iterable,
Iterator,
Sequence,
)
# Magic UUID to use as a namespace for hashing.
# Used to try and generate a unique UUID for each document
# from hashing the document content and metadata.
NAMESPACE_UUID = uuid.UUID(int=1984)
T = TypeVar("T")
def _hash_string_to_uuid(input_string: str) -> str:
"""Hashes a string and returns the corresponding UUID."""
hash_value = hashlib.sha1(
input_string.encode("utf-8"), usedforsecurity=False
).hexdigest()
return str(uuid.uuid5(NAMESPACE_UUID, hash_value))
_WARNED_ABOUT_SHA1: bool = False
def _warn_about_sha1() -> None:
"""Emit a one-time warning about SHA-1 collision weaknesses."""
# Global variable OK in this case
global _WARNED_ABOUT_SHA1 # noqa: PLW0603
if not _WARNED_ABOUT_SHA1:
warnings.warn(
"Using SHA-1 for document hashing. SHA-1 is *not* "
"collision-resistant; a motivated attacker can construct distinct inputs "
"that map to the same fingerprint. If this matters in your "
"threat model, switch to a stronger algorithm such "
"as 'blake2b', 'sha256', or 'sha512' by specifying "
" `key_encoder` parameter in the `index` or `aindex` function. ",
category=UserWarning,
stacklevel=2,
)
_WARNED_ABOUT_SHA1 = True
def _hash_string(
input_string: str, *, algorithm: Literal["sha1", "sha256", "sha512", "blake2b"]
) -> uuid.UUID:
"""Hash *input_string* to a deterministic UUID using the configured algorithm."""
if algorithm == "sha1":
_warn_about_sha1()
hash_value = _calculate_hash(input_string, algorithm)
return uuid.uuid5(NAMESPACE_UUID, hash_value)
def _hash_nested_dict(
data: dict[Any, Any], *, algorithm: Literal["sha1", "sha256", "sha512", "blake2b"]
) -> uuid.UUID:
"""Hash a nested dictionary to a UUID using the configured algorithm."""
serialized_data = json.dumps(data, sort_keys=True)
return _hash_string(serialized_data, algorithm=algorithm)
def _batch(size: int, iterable: Iterable[T]) -> Iterator[list[T]]:
"""Utility batching function."""
if size <= 0:
msg = f"Batch size must be a positive integer, got {size}."
raise ValueError(msg)
it = iter(iterable)
while True:
chunk = list(islice(it, size))
if not chunk:
return
yield chunk
async def _abatch(size: int, iterable: AsyncIterable[T]) -> AsyncIterator[list[T]]:
"""Utility batching function."""
if size <= 0:
msg = f"Batch size must be a positive integer, got {size}."
raise ValueError(msg)
batch: list[T] = []
async for element in iterable:
if len(batch) < size:
batch.append(element)
if len(batch) >= size:
yield batch
batch = []
if batch:
yield batch
def _get_source_id_assigner(
source_id_key: str | Callable[[Document], str] | None,
) -> Callable[[Document], str | None]:
"""Get the source id from the document."""
if source_id_key is None:
return lambda _doc: None
if isinstance(source_id_key, str):
return lambda doc: doc.metadata[source_id_key]
if callable(source_id_key):
return source_id_key
msg = (
f"source_id_key should be either None, a string or a callable. "
f"Got {source_id_key} of type {type(source_id_key)}."
)
raise ValueError(msg)
def _deduplicate_in_order(
hashed_documents: Iterable[Document],
) -> Iterator[Document]:
"""Deduplicate a list of hashed documents while preserving order."""
seen: set[str] = set()
for hashed_doc in hashed_documents:
if hashed_doc.id not in seen:
# At this stage, the id is guaranteed to be a string.
# Avoiding unnecessary run time checks.
seen.add(cast("str", hashed_doc.id))
yield hashed_doc
class IndexingException(LangChainException):
"""Raised when an indexing operation fails."""
def _calculate_hash(
text: str, algorithm: Literal["sha1", "sha256", "sha512", "blake2b"]
) -> str:
"""Return a hexadecimal digest of *text* using *algorithm*."""
if algorithm == "sha1":
# Calculate the SHA-1 hash and return it as a UUID.
digest = hashlib.sha1(text.encode("utf-8"), usedforsecurity=False).hexdigest()
return str(uuid.uuid5(NAMESPACE_UUID, digest))
if algorithm == "blake2b":
return hashlib.blake2b(text.encode("utf-8")).hexdigest()
if algorithm == "sha256":
return hashlib.sha256(text.encode("utf-8")).hexdigest()
if algorithm == "sha512":
return hashlib.sha512(text.encode("utf-8")).hexdigest()
msg = f"Unsupported hashing algorithm: {algorithm}"
raise ValueError(msg)
def _get_document_with_hash(
document: Document,
*,
key_encoder: Callable[[Document], str]
| Literal["sha1", "sha256", "sha512", "blake2b"],
) -> Document:
"""Calculate a hash of the document, and assign it to the uid.
When using one of the predefined hashing algorithms, the hash is calculated
by hashing the content and the metadata of the document.
Args:
document: Document to hash.
key_encoder: Hashing algorithm to use for hashing the document.
If not provided, a default encoder using SHA-1 will be used.
SHA-1 is not collision-resistant, and a motivated attacker
could craft two different texts that hash to the
same cache key.
New applications should use one of the alternative encoders
or provide a custom and strong key encoder function to avoid this risk.
When changing the key encoder, you must change the
index as well to avoid duplicated documents in the cache.
Raises:
ValueError: If the metadata cannot be serialized using json.
Returns:
Document with a unique identifier based on the hash of the content and metadata.
"""
metadata: dict[str, Any] = dict(document.metadata or {})
if callable(key_encoder):
# If key_encoder is a callable, we use it to generate the hash.
hash_ = key_encoder(document)
else:
# The hashes are calculated separate for the content and the metadata.
content_hash = _calculate_hash(document.page_content, algorithm=key_encoder)
try:
serialized_meta = json.dumps(metadata, sort_keys=True)
except Exception as e:
msg = (
f"Failed to hash metadata: {e}. "
f"Please use a dict that can be serialized using json."
)
raise ValueError(msg) from e
metadata_hash = _calculate_hash(serialized_meta, algorithm=key_encoder)
hash_ = _calculate_hash(content_hash + metadata_hash, algorithm=key_encoder)
return Document(
# Assign a unique identifier based on the hash.
id=hash_,
page_content=document.page_content,
metadata=document.metadata,
)
# This internal abstraction was imported by the langchain package internally, so
# we keep it here for backwards compatibility.
class _HashedDocument:
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Raise an error if this class is instantiated."""
msg = (
"_HashedDocument is an internal abstraction that was deprecated in "
" langchain-core 0.3.63. This abstraction is marked as private and "
" should not have been used directly. If you are seeing this error, please "
" update your code appropriately."
)
raise NotImplementedError(msg)
def _delete(
vector_store: VectorStore | DocumentIndex,
ids: list[str],
) -> None:
"""Delete documents from a vector store or document index by their IDs.
Args:
vector_store: The vector store or document index to delete from.
ids: List of document IDs to delete.
Raises:
IndexingException: If the delete operation fails.
TypeError: If the `vector_store` is neither a `VectorStore` nor a
`DocumentIndex`.
"""
if isinstance(vector_store, VectorStore):
delete_ok = vector_store.delete(ids)
if delete_ok is not None and delete_ok is False:
msg = "The delete operation to VectorStore failed."
raise IndexingException(msg)
elif isinstance(vector_store, DocumentIndex):
delete_response = vector_store.delete(ids)
if "num_failed" in delete_response and delete_response["num_failed"] > 0:
msg = "The delete operation to DocumentIndex failed."
raise IndexingException(msg)
else:
msg = (
f"Vectorstore should be either a VectorStore or a DocumentIndex. "
f"Got {type(vector_store)}."
)
raise TypeError(msg)
# PUBLIC API
class IndexingResult(TypedDict):
"""Return a detailed a breakdown of the result of the indexing operation."""
num_added: int
"""Number of added documents."""
num_updated: int
"""Number of updated documents because they were not up to date."""
num_deleted: int
"""Number of deleted documents."""
num_skipped: int
"""Number of skipped documents because they were already up to date."""
def index(
docs_source: BaseLoader | Iterable[Document],
record_manager: RecordManager,
vector_store: VectorStore | DocumentIndex,
*,
batch_size: int = 100,
cleanup: Literal["incremental", "full", "scoped_full"] | None = None,
source_id_key: str | Callable[[Document], str] | None = None,
cleanup_batch_size: int = 1_000,
force_update: bool = False,
key_encoder: Literal["sha1", "sha256", "sha512", "blake2b"]
| Callable[[Document], str] = "sha1",
upsert_kwargs: dict[str, Any] | None = None,
) -> IndexingResult:
"""Index data from the loader into the vector store.
Indexing functionality uses a manager to keep track of which documents
are in the vector store.
This allows us to keep track of which documents were updated, and which
documents were deleted, which documents should be skipped.
For the time being, documents are indexed using their hashes, and users
are not able to specify the uid of the document.
!!! warning "Behavior changed in `langchain-core` 0.3.25"
Added `scoped_full` cleanup mode.
!!! warning
* In full mode, the loader should be returning
the entire dataset, and not just a subset of the dataset.
Otherwise, the auto_cleanup will remove documents that it is not
supposed to.
* In incremental mode, if documents associated with a particular
source id appear across different batches, the indexing API
will do some redundant work. This will still result in the
correct end state of the index, but will unfortunately not be
100% efficient. For example, if a given document is split into 15
chunks, and we index them using a batch size of 5, we'll have 3 batches
all with the same source id. In general, to avoid doing too much
redundant work select as big a batch size as possible.
* The `scoped_full` mode is suitable if determining an appropriate batch size
is challenging or if your data loader cannot return the entire dataset at
once. This mode keeps track of source IDs in memory, which should be fine
for most use cases. If your dataset is large (10M+ docs), you will likely
need to parallelize the indexing process regardless.
Args:
docs_source: Data loader or iterable of documents to index.
record_manager: Timestamped set to keep track of which documents were
updated.
vector_store: `VectorStore` or DocumentIndex to index the documents into.
batch_size: Batch size to use when indexing.
cleanup: How to handle clean up of documents.
- incremental: Cleans up all documents that haven't been updated AND
that are associated with source IDs that were seen during indexing.
Clean up is done continuously during indexing helping to minimize the
probability of users seeing duplicated content.
- full: Delete all documents that have not been returned by the loader
during this run of indexing.
Clean up runs after all documents have been indexed.
This means that users may see duplicated content during indexing.
- scoped_full: Similar to Full, but only deletes all documents
that haven't been updated AND that are associated with
source IDs that were seen during indexing.
- None: Do not delete any documents.
source_id_key: Optional key that helps identify the original source
of the document.
cleanup_batch_size: Batch size to use when cleaning up documents.
force_update: Force update documents even if they are present in the
record manager. Useful if you are re-indexing with updated embeddings.
key_encoder: Hashing algorithm to use for hashing the document content and
metadata. Options include "blake2b", "sha256", and "sha512".
!!! version-added "Added in `langchain-core` 0.3.66"
key_encoder: Hashing algorithm to use for hashing the document.
If not provided, a default encoder using SHA-1 will be used.
SHA-1 is not collision-resistant, and a motivated attacker
could craft two different texts that hash to the
same cache key.
New applications should use one of the alternative encoders
or provide a custom and strong key encoder function to avoid this risk.
When changing the key encoder, you must change the
index as well to avoid duplicated documents in the cache.
upsert_kwargs: Additional keyword arguments to pass to the add_documents
method of the `VectorStore` or the upsert method of the DocumentIndex.
For example, you can use this to specify a custom vector_field:
upsert_kwargs={"vector_field": "embedding"}
!!! version-added "Added in `langchain-core` 0.3.10"
Returns:
Indexing result which contains information about how many documents
were added, updated, deleted, or skipped.
Raises:
ValueError: If cleanup mode is not one of 'incremental', 'full' or None
ValueError: If cleanup mode is incremental and source_id_key is None.
ValueError: If `VectorStore` does not have
"delete" and "add_documents" required methods.
ValueError: If source_id_key is not None, but is not a string or callable.
TypeError: If `vectorstore` is not a `VectorStore` or a DocumentIndex.
AssertionError: If `source_id` is None when cleanup mode is incremental.
(should be unreachable code).
"""
# Behavior is deprecated, but we keep it for backwards compatibility.
# # Warn only once per process.
if key_encoder == "sha1":
_warn_about_sha1()
if cleanup not in {"incremental", "full", "scoped_full", None}:
msg = (
f"cleanup should be one of 'incremental', 'full', 'scoped_full' or None. "
f"Got {cleanup}."
)
raise ValueError(msg)
if (cleanup in {"incremental", "scoped_full"}) and source_id_key is None:
msg = (
"Source id key is required when cleanup mode is incremental or scoped_full."
)
raise ValueError(msg)
destination = vector_store # Renaming internally for clarity
# If it's a vectorstore, let's check if it has the required methods.
if isinstance(destination, VectorStore):
# Check that the Vectorstore has required methods implemented
methods = ["delete", "add_documents"]
for method in methods:
if not hasattr(destination, method):
msg = (
f"Vectorstore {destination} does not have required method {method}"
)
raise ValueError(msg)
if type(destination).delete == VectorStore.delete:
# Checking if the VectorStore has overridden the default delete method
# implementation which just raises a NotImplementedError
msg = "Vectorstore has not implemented the delete method"
raise ValueError(msg)
elif isinstance(destination, DocumentIndex):
pass
else:
msg = (
f"Vectorstore should be either a VectorStore or a DocumentIndex. "
f"Got {type(destination)}."
)
raise TypeError(msg)
if isinstance(docs_source, BaseLoader):
try:
doc_iterator = docs_source.lazy_load()
except NotImplementedError:
doc_iterator = iter(docs_source.load())
else:
doc_iterator = iter(docs_source)
source_id_assigner = _get_source_id_assigner(source_id_key)
# Mark when the update started.
index_start_dt = record_manager.get_time()
num_added = 0
num_skipped = 0
num_updated = 0
num_deleted = 0
scoped_full_cleanup_source_ids: set[str] = set()
for doc_batch in _batch(batch_size, doc_iterator):
# Track original batch size before deduplication
original_batch_size = len(doc_batch)
hashed_docs = list(
_deduplicate_in_order(
[
_get_document_with_hash(doc, key_encoder=key_encoder)
for doc in doc_batch
]
)
)
# Count documents removed by within-batch deduplication
num_skipped += original_batch_size - len(hashed_docs)
source_ids: Sequence[str | None] = [
source_id_assigner(hashed_doc) for hashed_doc in hashed_docs
]
if cleanup in {"incremental", "scoped_full"}:
# Source IDs are required.
for source_id, hashed_doc in zip(source_ids, hashed_docs, strict=False):
if source_id is None:
msg = (
f"Source IDs are required when cleanup mode is "
f"incremental or scoped_full. "
f"Document that starts with "
f"content: {hashed_doc.page_content[:100]} "
f"was not assigned as source id."
)
raise ValueError(msg)
if cleanup == "scoped_full":
scoped_full_cleanup_source_ids.add(source_id)
# Source IDs cannot be None after for loop above.
source_ids = cast("Sequence[str]", source_ids)
exists_batch = record_manager.exists(
cast("Sequence[str]", [doc.id for doc in hashed_docs])
)
# Filter out documents that already exist in the record store.
uids = []
docs_to_index = []
uids_to_refresh = []
seen_docs: set[str] = set()
for hashed_doc, doc_exists in zip(hashed_docs, exists_batch, strict=False):
hashed_id = cast("str", hashed_doc.id)
if doc_exists:
if force_update:
seen_docs.add(hashed_id)
else:
uids_to_refresh.append(hashed_id)
continue
uids.append(hashed_id)
docs_to_index.append(hashed_doc)
# Update refresh timestamp
if uids_to_refresh:
record_manager.update(uids_to_refresh, time_at_least=index_start_dt)
num_skipped += len(uids_to_refresh)
# Be pessimistic and assume that all vector store write will fail.
# First write to vector store
if docs_to_index:
if isinstance(destination, VectorStore):
destination.add_documents(
docs_to_index,
ids=uids,
batch_size=batch_size,
**(upsert_kwargs or {}),
)
elif isinstance(destination, DocumentIndex):
destination.upsert(
docs_to_index,
**(upsert_kwargs or {}),
)
num_added += len(docs_to_index) - len(seen_docs)
num_updated += len(seen_docs)
# And only then update the record store.
# Update ALL records, even if they already exist since we want to refresh
# their timestamp.
record_manager.update(
cast("Sequence[str]", [doc.id for doc in hashed_docs]),
group_ids=source_ids,
time_at_least=index_start_dt,
)
# If source IDs are provided, we can do the deletion incrementally!
if cleanup == "incremental":
# Get the uids of the documents that were not returned by the loader.
# mypy isn't good enough to determine that source IDs cannot be None
# here due to a check that's happening above, so we check again.
for source_id in source_ids:
if source_id is None:
msg = (
"source_id cannot be None at this point. "
"Reached unreachable code."
)
raise AssertionError(msg)
source_ids_ = cast("Sequence[str]", source_ids)
while uids_to_delete := record_manager.list_keys(
group_ids=source_ids_, before=index_start_dt, limit=cleanup_batch_size
):
# Then delete from vector store.
_delete(destination, uids_to_delete)
# First delete from record store.
record_manager.delete_keys(uids_to_delete)
num_deleted += len(uids_to_delete)
if cleanup == "full" or (
cleanup == "scoped_full" and scoped_full_cleanup_source_ids
):
delete_group_ids: Sequence[str] | None = None
if cleanup == "scoped_full":
delete_group_ids = list(scoped_full_cleanup_source_ids)
while uids_to_delete := record_manager.list_keys(
group_ids=delete_group_ids, before=index_start_dt, limit=cleanup_batch_size
):
# First delete from record store.
_delete(destination, uids_to_delete)
# Then delete from record manager.
record_manager.delete_keys(uids_to_delete)
num_deleted += len(uids_to_delete)
return {
"num_added": num_added,
"num_updated": num_updated,
"num_skipped": num_skipped,
"num_deleted": num_deleted,
}
# Define an asynchronous generator function
async def _to_async_iterator(iterator: Iterable[T]) -> AsyncIterator[T]:
"""Convert an iterable to an async iterator."""
for item in iterator:
yield item
async def _adelete(
vector_store: VectorStore | DocumentIndex,
ids: list[str],
) -> None:
if isinstance(vector_store, VectorStore):
delete_ok = await vector_store.adelete(ids)
if delete_ok is not None and delete_ok is False:
msg = "The delete operation to VectorStore failed."
raise IndexingException(msg)
elif isinstance(vector_store, DocumentIndex):
delete_response = await vector_store.adelete(ids)
if "num_failed" in delete_response and delete_response["num_failed"] > 0:
msg = "The delete operation to DocumentIndex failed."
raise IndexingException(msg)
else:
msg = (
f"Vectorstore should be either a VectorStore or a DocumentIndex. "
f"Got {type(vector_store)}."
)
raise TypeError(msg)
async def aindex(
docs_source: BaseLoader | Iterable[Document] | AsyncIterator[Document],
record_manager: RecordManager,
vector_store: VectorStore | DocumentIndex,
*,
batch_size: int = 100,
cleanup: Literal["incremental", "full", "scoped_full"] | None = None,
source_id_key: str | Callable[[Document], str] | None = None,
cleanup_batch_size: int = 1_000,
force_update: bool = False,
key_encoder: Literal["sha1", "sha256", "sha512", "blake2b"]
| Callable[[Document], str] = "sha1",
upsert_kwargs: dict[str, Any] | None = None,
) -> IndexingResult:
"""Async index data from the loader into the vector store.
Indexing functionality uses a manager to keep track of which documents
are in the vector store.
This allows us to keep track of which documents were updated, and which
documents were deleted, which documents should be skipped.
For the time being, documents are indexed using their hashes, and users
are not able to specify the uid of the document.
!!! warning "Behavior changed in `langchain-core` 0.3.25"
Added `scoped_full` cleanup mode.
!!! warning
* In full mode, the loader should be returning
the entire dataset, and not just a subset of the dataset.
Otherwise, the auto_cleanup will remove documents that it is not
supposed to.
* In incremental mode, if documents associated with a particular
source id appear across different batches, the indexing API
will do some redundant work. This will still result in the
correct end state of the index, but will unfortunately not be
100% efficient. For example, if a given document is split into 15
chunks, and we index them using a batch size of 5, we'll have 3 batches
all with the same source id. In general, to avoid doing too much
redundant work select as big a batch size as possible.
* The `scoped_full` mode is suitable if determining an appropriate batch size
is challenging or if your data loader cannot return the entire dataset at
once. This mode keeps track of source IDs in memory, which should be fine
for most use cases. If your dataset is large (10M+ docs), you will likely
need to parallelize the indexing process regardless.
Args:
docs_source: Data loader or iterable of documents to index.
record_manager: Timestamped set to keep track of which documents were
updated.
vector_store: `VectorStore` or DocumentIndex to index the documents into.
batch_size: Batch size to use when indexing.
cleanup: How to handle clean up of documents.
- incremental: Cleans up all documents that haven't been updated AND
that are associated with source IDs that were seen during indexing.
Clean up is done continuously during indexing helping to minimize the
probability of users seeing duplicated content.
- full: Delete all documents that have not been returned by the loader
during this run of indexing.
Clean up runs after all documents have been indexed.
This means that users may see duplicated content during indexing.
- scoped_full: Similar to Full, but only deletes all documents
that haven't been updated AND that are associated with
source IDs that were seen during indexing.
- None: Do not delete any documents.
source_id_key: Optional key that helps identify the original source
of the document.
cleanup_batch_size: Batch size to use when cleaning up documents.
force_update: Force update documents even if they are present in the
record manager. Useful if you are re-indexing with updated embeddings.
key_encoder: Hashing algorithm to use for hashing the document content and
metadata. Options include "blake2b", "sha256", and "sha512".
!!! version-added "Added in `langchain-core` 0.3.66"
key_encoder: Hashing algorithm to use for hashing the document.
If not provided, a default encoder using SHA-1 will be used.
SHA-1 is not collision-resistant, and a motivated attacker
could craft two different texts that hash to the
same cache key.
New applications should use one of the alternative encoders
or provide a custom and strong key encoder function to avoid this risk.
When changing the key encoder, you must change the
index as well to avoid duplicated documents in the cache.
upsert_kwargs: Additional keyword arguments to pass to the add_documents
method of the `VectorStore` or the upsert method of the DocumentIndex.
For example, you can use this to specify a custom vector_field:
upsert_kwargs={"vector_field": "embedding"}
!!! version-added "Added in `langchain-core` 0.3.10"
Returns:
Indexing result which contains information about how many documents
were added, updated, deleted, or skipped.
Raises:
ValueError: If cleanup mode is not one of 'incremental', 'full' or None
ValueError: If cleanup mode is incremental and source_id_key is None.
ValueError: If `VectorStore` does not have
"adelete" and "aadd_documents" required methods.
ValueError: If source_id_key is not None, but is not a string or callable.
TypeError: If `vector_store` is not a `VectorStore` or DocumentIndex.
AssertionError: If `source_id_key` is None when cleanup mode is
incremental or `scoped_full` (should be unreachable).
"""
# Behavior is deprecated, but we keep it for backwards compatibility.
# # Warn only once per process.
if key_encoder == "sha1":
_warn_about_sha1()
if cleanup not in {"incremental", "full", "scoped_full", None}:
msg = (
f"cleanup should be one of 'incremental', 'full', 'scoped_full' or None. "
f"Got {cleanup}."
)
raise ValueError(msg)
if (cleanup in {"incremental", "scoped_full"}) and source_id_key is None:
msg = (
"Source id key is required when cleanup mode is incremental or scoped_full."
)
raise ValueError(msg)
destination = vector_store # Renaming internally for clarity
# If it's a vectorstore, let's check if it has the required methods.
if isinstance(destination, VectorStore):
# Check that the Vectorstore has required methods implemented
# Check that the Vectorstore has required methods implemented
methods = ["adelete", "aadd_documents"]
for method in methods:
if not hasattr(destination, method):
msg = (
f"Vectorstore {destination} does not have required method {method}"
)
raise ValueError(msg)
if (
type(destination).adelete == VectorStore.adelete
and type(destination).delete == VectorStore.delete
):
# Checking if the VectorStore has overridden the default adelete or delete
# methods implementation which just raises a NotImplementedError
msg = "Vectorstore has not implemented the adelete or delete method"
raise ValueError(msg)
elif isinstance(destination, DocumentIndex):
pass
else:
msg = (
f"Vectorstore should be either a VectorStore or a DocumentIndex. "
f"Got {type(destination)}."
)
raise TypeError(msg)
async_doc_iterator: AsyncIterator[Document]
if isinstance(docs_source, BaseLoader):
try:
async_doc_iterator = docs_source.alazy_load()
except NotImplementedError:
# Exception triggered when neither lazy_load nor alazy_load are implemented.
# * The default implementation of alazy_load uses lazy_load.
# * The default implementation of lazy_load raises NotImplementedError.
# In such a case, we use the load method and convert it to an async
# iterator.
async_doc_iterator = _to_async_iterator(docs_source.load())
elif hasattr(docs_source, "__aiter__"):
async_doc_iterator = docs_source # type: ignore[assignment]
else:
async_doc_iterator = _to_async_iterator(docs_source)
source_id_assigner = _get_source_id_assigner(source_id_key)
# Mark when the update started.
index_start_dt = await record_manager.aget_time()
num_added = 0
num_skipped = 0
num_updated = 0
num_deleted = 0
scoped_full_cleanup_source_ids: set[str] = set()
async for doc_batch in _abatch(batch_size, async_doc_iterator):
# Track original batch size before deduplication
original_batch_size = len(doc_batch)
hashed_docs = list(
_deduplicate_in_order(
[
_get_document_with_hash(doc, key_encoder=key_encoder)
for doc in doc_batch
]
)
)
# Count documents removed by within-batch deduplication
num_skipped += original_batch_size - len(hashed_docs)
source_ids: Sequence[str | None] = [
source_id_assigner(doc) for doc in hashed_docs
]
if cleanup in {"incremental", "scoped_full"}:
# If the cleanup mode is incremental, source IDs are required.
for source_id, hashed_doc in zip(source_ids, hashed_docs, strict=False):
if source_id is None:
msg = (
f"Source IDs are required when cleanup mode is "
f"incremental or scoped_full. "
f"Document that starts with "
f"content: {hashed_doc.page_content[:100]} "
f"was not assigned as source id."
)
raise ValueError(msg)
if cleanup == "scoped_full":
scoped_full_cleanup_source_ids.add(source_id)
# Source IDs cannot be None after for loop above.
source_ids = cast("Sequence[str]", source_ids)
exists_batch = await record_manager.aexists(
cast("Sequence[str]", [doc.id for doc in hashed_docs])
)
# Filter out documents that already exist in the record store.
uids: list[str] = []
docs_to_index: list[Document] = []
uids_to_refresh = []
seen_docs: set[str] = set()
for hashed_doc, doc_exists in zip(hashed_docs, exists_batch, strict=False):
hashed_id = cast("str", hashed_doc.id)
if doc_exists:
if force_update:
seen_docs.add(hashed_id)
else:
uids_to_refresh.append(hashed_id)
continue
uids.append(hashed_id)
docs_to_index.append(hashed_doc)
if uids_to_refresh:
# Must be updated to refresh timestamp.
await record_manager.aupdate(uids_to_refresh, time_at_least=index_start_dt)
num_skipped += len(uids_to_refresh)
# Be pessimistic and assume that all vector store write will fail.
# First write to vector store
if docs_to_index:
if isinstance(destination, VectorStore):
await destination.aadd_documents(
docs_to_index,
ids=uids,
batch_size=batch_size,
**(upsert_kwargs or {}),
)
elif isinstance(destination, DocumentIndex):
await destination.aupsert(
docs_to_index,
**(upsert_kwargs or {}),
)
num_added += len(docs_to_index) - len(seen_docs)
num_updated += len(seen_docs)
# And only then update the record store.
# Update ALL records, even if they already exist since we want to refresh
# their timestamp.
await record_manager.aupdate(
cast("Sequence[str]", [doc.id for doc in hashed_docs]),
group_ids=source_ids,
time_at_least=index_start_dt,
)
# If source IDs are provided, we can do the deletion incrementally!
if cleanup == "incremental":
# Get the uids of the documents that were not returned by the loader.
# mypy isn't good enough to determine that source IDs cannot be None
# here due to a check that's happening above, so we check again.
for source_id in source_ids:
if source_id is None:
msg = (
"source_id cannot be None at this point. "
"Reached unreachable code."
)
raise AssertionError(msg)
source_ids_ = cast("Sequence[str]", source_ids)
while uids_to_delete := await record_manager.alist_keys(
group_ids=source_ids_, before=index_start_dt, limit=cleanup_batch_size
):
# Then delete from vector store.
await _adelete(destination, uids_to_delete)
# First delete from record store.
await record_manager.adelete_keys(uids_to_delete)
num_deleted += len(uids_to_delete)
if cleanup == "full" or (
cleanup == "scoped_full" and scoped_full_cleanup_source_ids
):
delete_group_ids: Sequence[str] | None = None
if cleanup == "scoped_full":
delete_group_ids = list(scoped_full_cleanup_source_ids)
while uids_to_delete := await record_manager.alist_keys(
group_ids=delete_group_ids, before=index_start_dt, limit=cleanup_batch_size
):
# First delete from record store.
await _adelete(destination, uids_to_delete)
# Then delete from record manager.
await record_manager.adelete_keys(uids_to_delete)
num_deleted += len(uids_to_delete)
return {
"num_added": num_added,
"num_updated": num_updated,
"num_skipped": num_skipped,
"num_deleted": num_deleted,
}
@@ -0,0 +1,661 @@
"""Base classes for indexing."""
from __future__ import annotations
import abc
import time
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any, TypedDict
from typing_extensions import override
from langchain_core._api import beta
from langchain_core.retrievers import BaseRetriever
from langchain_core.runnables import run_in_executor
if TYPE_CHECKING:
from collections.abc import Sequence
from langchain_core.documents import Document
class RecordManager(ABC):
"""Abstract base class representing the interface for a record manager.
The record manager abstraction is used by the langchain indexing API.
The record manager keeps track of which documents have been
written into a `VectorStore` and when they were written.
The indexing API computes hashes for each document and stores the hash
together with the write time and the source id in the record manager.
On subsequent indexing runs, the indexing API can check the record manager
to determine which documents have already been indexed and which have not.
This allows the indexing API to avoid re-indexing documents that have
already been indexed, and to only index new documents.
The main benefit of this abstraction is that it works across many vectorstores.
To be supported, a `VectorStore` needs to only support the ability to add and
delete documents by ID. Using the record manager, the indexing API will
be able to delete outdated documents and avoid redundant indexing of documents
that have already been indexed.
The main constraints of this abstraction are:
1. It relies on the time-stamps to determine which documents have been
indexed and which have not. This means that the time-stamps must be
monotonically increasing. The timestamp should be the timestamp
as measured by the server to minimize issues.
2. The record manager is currently implemented separately from the
vectorstore, which means that the overall system becomes distributed
and may create issues with consistency. For example, writing to
record manager succeeds, but corresponding writing to `VectorStore` fails.
"""
def __init__(
self,
namespace: str,
) -> None:
"""Initialize the record manager.
Args:
namespace: The namespace for the record manager.
"""
self.namespace = namespace
@abstractmethod
def create_schema(self) -> None:
"""Create the database schema for the record manager."""
@abstractmethod
async def acreate_schema(self) -> None:
"""Asynchronously create the database schema for the record manager."""
@abstractmethod
def get_time(self) -> float:
"""Get the current server time as a high resolution timestamp!
It's important to get this from the server to ensure a monotonic clock,
otherwise there may be data loss when cleaning up old documents!
Returns:
The current server time as a float timestamp.
"""
@abstractmethod
async def aget_time(self) -> float:
"""Asynchronously get the current server time as a high resolution timestamp.
It's important to get this from the server to ensure a monotonic clock,
otherwise there may be data loss when cleaning up old documents!
Returns:
The current server time as a float timestamp.
"""
@abstractmethod
def update(
self,
keys: Sequence[str],
*,
group_ids: Sequence[str | None] | None = None,
time_at_least: float | None = None,
) -> None:
"""Upsert records into the database.
Args:
keys: A list of record keys to upsert.
group_ids: A list of group IDs corresponding to the keys.
time_at_least: Optional timestamp. Implementation can use this
to optionally verify that the timestamp IS at least this time
in the system that stores the data.
e.g., use to validate that the time in the postgres database
is equal to or larger than the given timestamp, if not
raise an error.
This is meant to help prevent time-drift issues since
time may not be monotonically increasing!
Raises:
ValueError: If the length of keys doesn't match the length of group_ids.
"""
@abstractmethod
async def aupdate(
self,
keys: Sequence[str],
*,
group_ids: Sequence[str | None] | None = None,
time_at_least: float | None = None,
) -> None:
"""Asynchronously upsert records into the database.
Args:
keys: A list of record keys to upsert.
group_ids: A list of group IDs corresponding to the keys.
time_at_least: Optional timestamp. Implementation can use this
to optionally verify that the timestamp IS at least this time
in the system that stores the data.
e.g., use to validate that the time in the postgres database
is equal to or larger than the given timestamp, if not
raise an error.
This is meant to help prevent time-drift issues since
time may not be monotonically increasing!
Raises:
ValueError: If the length of keys doesn't match the length of group_ids.
"""
@abstractmethod
def exists(self, keys: Sequence[str]) -> list[bool]:
"""Check if the provided keys exist in the database.
Args:
keys: A list of keys to check.
Returns:
A list of boolean values indicating the existence of each key.
"""
@abstractmethod
async def aexists(self, keys: Sequence[str]) -> list[bool]:
"""Asynchronously check if the provided keys exist in the database.
Args:
keys: A list of keys to check.
Returns:
A list of boolean values indicating the existence of each key.
"""
@abstractmethod
def list_keys(
self,
*,
before: float | None = None,
after: float | None = None,
group_ids: Sequence[str] | None = None,
limit: int | None = None,
) -> list[str]:
"""List records in the database based on the provided filters.
Args:
before: Filter to list records updated before this time.
after: Filter to list records updated after this time.
group_ids: Filter to list records with specific group IDs.
limit: optional limit on the number of records to return.
Returns:
A list of keys for the matching records.
"""
@abstractmethod
async def alist_keys(
self,
*,
before: float | None = None,
after: float | None = None,
group_ids: Sequence[str] | None = None,
limit: int | None = None,
) -> list[str]:
"""Asynchronously list records in the database based on the provided filters.
Args:
before: Filter to list records updated before this time.
after: Filter to list records updated after this time.
group_ids: Filter to list records with specific group IDs.
limit: optional limit on the number of records to return.
Returns:
A list of keys for the matching records.
"""
@abstractmethod
def delete_keys(self, keys: Sequence[str]) -> None:
"""Delete specified records from the database.
Args:
keys: A list of keys to delete.
"""
@abstractmethod
async def adelete_keys(self, keys: Sequence[str]) -> None:
"""Asynchronously delete specified records from the database.
Args:
keys: A list of keys to delete.
"""
class _Record(TypedDict):
group_id: str | None
updated_at: float
class InMemoryRecordManager(RecordManager):
"""An in-memory record manager for testing purposes."""
def __init__(self, namespace: str) -> None:
"""Initialize the in-memory record manager.
Args:
namespace: The namespace for the record manager.
"""
super().__init__(namespace)
# Each key points to a dictionary
# of {'group_id': group_id, 'updated_at': timestamp}
self.records: dict[str, _Record] = {}
self.namespace = namespace
def create_schema(self) -> None:
"""In-memory schema creation is simply ensuring the structure is initialized."""
async def acreate_schema(self) -> None:
"""In-memory schema creation is simply ensuring the structure is initialized."""
@override
def get_time(self) -> float:
return time.time()
@override
async def aget_time(self) -> float:
return self.get_time()
def update(
self,
keys: Sequence[str],
*,
group_ids: Sequence[str | None] | None = None,
time_at_least: float | None = None,
) -> None:
"""Upsert records into the database.
Args:
keys: A list of record keys to upsert.
group_ids: A list of group IDs corresponding to the keys.
time_at_least: Optional timestamp. Implementation can use this
to optionally verify that the timestamp IS at least this time
in the system that stores.
E.g., use to validate that the time in the postgres database
is equal to or larger than the given timestamp, if not
raise an error.
This is meant to help prevent time-drift issues since
time may not be monotonically increasing!
Raises:
ValueError: If the length of keys doesn't match the length of group
ids.
ValueError: If time_at_least is in the future.
"""
if group_ids and len(keys) != len(group_ids):
msg = "Length of keys must match length of group_ids"
raise ValueError(msg)
for index, key in enumerate(keys):
group_id = group_ids[index] if group_ids else None
if time_at_least and time_at_least > self.get_time():
msg = "time_at_least must be in the past"
raise ValueError(msg)
self.records[key] = {"group_id": group_id, "updated_at": self.get_time()}
async def aupdate(
self,
keys: Sequence[str],
*,
group_ids: Sequence[str | None] | None = None,
time_at_least: float | None = None,
) -> None:
"""Async upsert records into the database.
Args:
keys: A list of record keys to upsert.
group_ids: A list of group IDs corresponding to the keys.
time_at_least: Optional timestamp. Implementation can use this
to optionally verify that the timestamp IS at least this time
in the system that stores.
E.g., use to validate that the time in the postgres database
is equal to or larger than the given timestamp, if not
raise an error.
This is meant to help prevent time-drift issues since
time may not be monotonically increasing!
"""
self.update(keys, group_ids=group_ids, time_at_least=time_at_least)
def exists(self, keys: Sequence[str]) -> list[bool]:
"""Check if the provided keys exist in the database.
Args:
keys: A list of keys to check.
Returns:
A list of boolean values indicating the existence of each key.
"""
return [key in self.records for key in keys]
async def aexists(self, keys: Sequence[str]) -> list[bool]:
"""Async check if the provided keys exist in the database.
Args:
keys: A list of keys to check.
Returns:
A list of boolean values indicating the existence of each key.
"""
return self.exists(keys)
def list_keys(
self,
*,
before: float | None = None,
after: float | None = None,
group_ids: Sequence[str] | None = None,
limit: int | None = None,
) -> list[str]:
"""List records in the database based on the provided filters.
Args:
before: Filter to list records updated before this time.
after: Filter to list records updated after this time.
group_ids: Filter to list records with specific group IDs.
limit: optional limit on the number of records to return.
Returns:
A list of keys for the matching records.
"""
result = []
for key, data in self.records.items():
if before and data["updated_at"] >= before:
continue
if after and data["updated_at"] <= after:
continue
if group_ids and data["group_id"] not in group_ids:
continue
result.append(key)
if limit:
return result[:limit]
return result
async def alist_keys(
self,
*,
before: float | None = None,
after: float | None = None,
group_ids: Sequence[str] | None = None,
limit: int | None = None,
) -> list[str]:
"""Async list records in the database based on the provided filters.
Args:
before: Filter to list records updated before this time.
after: Filter to list records updated after this time.
group_ids: Filter to list records with specific group IDs.
limit: optional limit on the number of records to return.
Returns:
A list of keys for the matching records.
"""
return self.list_keys(
before=before, after=after, group_ids=group_ids, limit=limit
)
def delete_keys(self, keys: Sequence[str]) -> None:
"""Delete specified records from the database.
Args:
keys: A list of keys to delete.
"""
for key in keys:
if key in self.records:
del self.records[key]
async def adelete_keys(self, keys: Sequence[str]) -> None:
"""Async delete specified records from the database.
Args:
keys: A list of keys to delete.
"""
self.delete_keys(keys)
class UpsertResponse(TypedDict):
"""A generic response for upsert operations.
The upsert response will be used by abstractions that implement an upsert
operation for content that can be upserted by ID.
Upsert APIs that accept inputs with IDs and generate IDs internally
will return a response that includes the IDs that succeeded and the IDs
that failed.
If there are no failures, the failed list will be empty, and the order
of the IDs in the succeeded list will match the order of the input documents.
If there are failures, the response becomes ill defined, and a user of the API
cannot determine which generated ID corresponds to which input document.
It is recommended for users explicitly attach the IDs to the items being
indexed to avoid this issue.
"""
succeeded: list[str]
"""The IDs that were successfully indexed."""
failed: list[str]
"""The IDs that failed to index."""
class DeleteResponse(TypedDict, total=False):
"""A generic response for delete operation.
The fields in this response are optional and whether the `VectorStore`
returns them or not is up to the implementation.
"""
num_deleted: int
"""The number of items that were successfully deleted.
If returned, this should only include *actual* deletions.
If the ID did not exist to begin with,
it should not be included in this count.
"""
succeeded: Sequence[str]
"""The IDs that were successfully deleted.
If returned, this should only include *actual* deletions.
If the ID did not exist to begin with,
it should not be included in this list.
"""
failed: Sequence[str]
"""The IDs that failed to be deleted.
!!! warning
Deleting an ID that does not exist is **NOT** considered a failure.
"""
num_failed: int
"""The number of items that failed to be deleted."""
@beta(message="Added in 0.2.29. The abstraction is subject to change.")
class DocumentIndex(BaseRetriever):
"""A document retriever that supports indexing operations.
This indexing interface is designed to be a generic abstraction for storing and
querying documents that has an ID and metadata associated with it.
The interface is designed to be agnostic to the underlying implementation of the
indexing system.
The interface is designed to support the following operations:
1. Storing document in the index.
2. Fetching document by ID.
3. Searching for document using a query.
"""
@abc.abstractmethod
def upsert(self, items: Sequence[Document], /, **kwargs: Any) -> UpsertResponse:
"""Upsert documents into the index.
The upsert functionality should utilize the ID field of the content object
if it is provided. If the ID is not provided, the upsert method is free
to generate an ID for the content.
When an ID is specified and the content already exists in the `VectorStore`,
the upsert method should update the content with the new data. If the content
does not exist, the upsert method should add the item to the `VectorStore`.
Args:
items: Sequence of documents to add to the `VectorStore`.
**kwargs: Additional keyword arguments.
Returns:
A response object that contains the list of IDs that were
successfully added or updated in the `VectorStore` and the list of IDs that
failed to be added or updated.
"""
async def aupsert(
self, items: Sequence[Document], /, **kwargs: Any
) -> UpsertResponse:
"""Add or update documents in the `VectorStore`. Async version of `upsert`.
The upsert functionality should utilize the ID field of the item
if it is provided. If the ID is not provided, the upsert method is free
to generate an ID for the item.
When an ID is specified and the item already exists in the `VectorStore`,
the upsert method should update the item with the new data. If the item
does not exist, the upsert method should add the item to the `VectorStore`.
Args:
items: Sequence of documents to add to the `VectorStore`.
**kwargs: Additional keyword arguments.
Returns:
A response object that contains the list of IDs that were
successfully added or updated in the `VectorStore` and the list of IDs that
failed to be added or updated.
"""
return await run_in_executor(
None,
self.upsert,
items,
**kwargs,
)
@abc.abstractmethod
def delete(self, ids: list[str] | None = None, **kwargs: Any) -> DeleteResponse:
"""Delete by IDs or other criteria.
Calling delete without any input parameters should raise a ValueError!
Args:
ids: List of IDs to delete.
**kwargs: Additional keyword arguments. This is up to the implementation.
For example, can include an option to delete the entire index,
or else issue a non-blocking delete etc.
Returns:
A response object that contains the list of IDs that were
successfully deleted and the list of IDs that failed to be deleted.
"""
async def adelete(
self, ids: list[str] | None = None, **kwargs: Any
) -> DeleteResponse:
"""Delete by IDs or other criteria. Async variant.
Calling adelete without any input parameters should raise a ValueError!
Args:
ids: List of IDs to delete.
**kwargs: Additional keyword arguments. This is up to the implementation.
For example, can include an option to delete the entire index.
Returns:
A response object that contains the list of IDs that were
successfully deleted and the list of IDs that failed to be deleted.
"""
return await run_in_executor(
None,
self.delete,
ids,
**kwargs,
)
@abc.abstractmethod
def get(
self,
ids: Sequence[str],
/,
**kwargs: Any,
) -> list[Document]:
"""Get documents by id.
Fewer documents may be returned than requested if some IDs are not found or
if there are duplicated IDs.
Users should not assume that the order of the returned documents matches
the order of the input IDs. Instead, users should rely on the ID field of the
returned documents.
This method should **NOT** raise exceptions if no documents are found for
some IDs.
Args:
ids: List of IDs to get.
**kwargs: Additional keyword arguments. These are up to the implementation.
Returns:
List of documents that were found.
"""
async def aget(
self,
ids: Sequence[str],
/,
**kwargs: Any,
) -> list[Document]:
"""Get documents by id.
Fewer documents may be returned than requested if some IDs are not found or
if there are duplicated IDs.
Users should not assume that the order of the returned documents matches
the order of the input IDs. Instead, users should rely on the ID field of the
returned documents.
This method should **NOT** raise exceptions if no documents are found for
some IDs.
Args:
ids: List of IDs to get.
**kwargs: Additional keyword arguments. These are up to the implementation.
Returns:
List of documents that were found.
"""
return await run_in_executor(
None,
self.get,
ids,
**kwargs,
)

Some files were not shown because too many files have changed in this diff Show More