Merge branch 'master' into imnishitha/openai/responses-api-strict-default

This commit is contained in:
Nishitha M authored and GitHub committed 2026-08-12 17:44:08 -04:00
commit fee4a089b9
78 files changed
+2259 -489

No files matched your search

+8 -3
View File
@@ -114,6 +114,11 @@ def get_usage_metadata_callback(
)
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)
token = usage_metadata_callback_var.set(cb)
try:
yield cb
finally:
# Always clear the context var, including when the with-block raises.
# Without finally, post-block model calls keep accumulating into cb
# (see #38989).
usage_metadata_callback_var.reset(token)
@@ -1888,9 +1888,9 @@ class BaseChatModel(BaseLanguageModel[AIMessage], ABC):
# We should check the cache unless it's explicitly set to False
# A None cache means we should use the default global cache
# if it's configured.
check_cache = self.cache or self.cache is None
check_cache = self.cache is not False
if check_cache:
if llm_cache:
if llm_cache is not None:
llm_string = self._get_llm_string(stop=stop, **kwargs)
normalized_messages = [
(
@@ -2032,7 +2032,7 @@ class BaseChatModel(BaseLanguageModel[AIMessage], ABC):
**result.llm_output,
**result.generations[0].message.response_metadata,
}
if check_cache and llm_cache:
if check_cache and llm_cache is not None:
llm_cache.update(prompt, llm_string, result.generations)
return result
@@ -2047,9 +2047,9 @@ class BaseChatModel(BaseLanguageModel[AIMessage], ABC):
# We should check the cache unless it's explicitly set to False
# A None cache means we should use the default global cache
# if it's configured.
check_cache = self.cache or self.cache is None
check_cache = self.cache is not False
if check_cache:
if llm_cache:
if llm_cache is not None:
llm_string = self._get_llm_string(stop=stop, **kwargs)
normalized_messages = [
(
@@ -2189,7 +2189,7 @@ class BaseChatModel(BaseLanguageModel[AIMessage], ABC):
**result.llm_output,
**result.generations[0].message.response_metadata,
}
if check_cache and llm_cache:
if check_cache and llm_cache is not None:
await llm_cache.aupdate(prompt, llm_string, result.generations)
return result
@@ -182,13 +182,13 @@ def get_prompts(
llm_cache = _resolve_cache(cache=cache)
for i, prompt in enumerate(prompts):
if llm_cache:
if llm_cache is not None:
cache_val = llm_cache.lookup(prompt, llm_string)
if isinstance(cache_val, list):
existing_prompts[i] = cache_val
else:
missing_prompts.append(prompt)
missing_prompt_idxs.append(i)
continue
missing_prompts.append(prompt)
missing_prompt_idxs.append(i)
return existing_prompts, llm_string, missing_prompt_idxs, missing_prompts
@@ -217,13 +217,13 @@ async def aget_prompts(
existing_prompts = {}
llm_cache = _resolve_cache(cache=cache)
for i, prompt in enumerate(prompts):
if llm_cache:
if llm_cache is not None:
cache_val = await llm_cache.alookup(prompt, llm_string)
if isinstance(cache_val, list):
existing_prompts[i] = cache_val
else:
missing_prompts.append(prompt)
missing_prompt_idxs.append(i)
continue
missing_prompts.append(prompt)
missing_prompt_idxs.append(i)
return existing_prompts, llm_string, missing_prompt_idxs, missing_prompts
@@ -288,7 +288,7 @@ async def aupdate_cache(
for i, result in enumerate(new_results.generations):
existing_prompts[missing_prompt_idxs[i]] = result
prompt = prompts[missing_prompt_idxs[i]]
if llm_cache:
if llm_cache is not None:
await llm_cache.aupdate(prompt, llm_string, result)
return new_results.llm_output
+20 -2
View File
@@ -12,6 +12,7 @@ from typing import (
from pydantic import BaseModel, ConfigDict
from pydantic.fields import FieldInfo
from pydantic_core import PydanticUndefined
from typing_extensions import NotRequired, override
logger = logging.getLogger(__name__)
@@ -70,14 +71,31 @@ def try_neq_default(value: Any, key: str, model: BaseModel) -> bool:
return _try_neq_default(value, field)
def _get_field_default(field: FieldInfo) -> Any:
# Pydantic 2.14+ returns ``PydanticUndefined`` (rather than ``None``) from
# ``get_default()`` for an un-called ``default_factory``. Restore the historical
# ``None`` so a factory-defaulted field at its default is still treated as
# unchanged. The factory is intentionally not called: ``get_default()`` keeps
# ``call_default_factory=False`` precisely because factories may have side effects.
default = field.get_default()
if default is PydanticUndefined and field.default_factory is not None:
return None
return default
def _try_neq_default(value: Any, field: FieldInfo) -> bool:
# Handle edge case: inequality of two objects does not evaluate to a bool (e.g. two
# Pandas DataFrames).
try:
return bool(field.get_default() != value)
default = _get_field_default(field)
except Exception as _:
# A raising default_factory means we cannot compare; treat as non-default.
return True
try:
return bool(default != value)
except Exception as _:
try:
return all(field.get_default() != value)
return all(default != value)
except Exception as _:
try:
return value is not field.default
@@ -26,6 +26,10 @@ def _populate_extras(
return standard_block
def _non_standard_block(block: dict[str, Any]) -> types.NonStandardContentBlock:
return {"type": "non_standard", "value": block}
def _convert_to_v1_from_anthropic_input(
content: list[types.ContentBlock],
) -> list[types.ContentBlock]:
@@ -54,87 +58,107 @@ def _convert_to_v1_from_anthropic_input(
]
for block in blocks:
block_type = block.get("type")
source = block.get("source")
if (
block_type == "document"
and "source" in block
and "type" in block["source"]
and isinstance(source, dict)
and "type" in source
):
if block["source"]["type"] == "base64":
if source["type"] == "base64":
if "data" not in source or "media_type" not in source:
yield _non_standard_block(block)
continue
file_block: types.FileContentBlock = {
"type": "file",
"base64": block["source"]["data"],
"mime_type": block["source"]["media_type"],
"base64": source["data"],
"mime_type": source["media_type"],
}
_populate_extras(file_block, block, {"type", "source"})
yield file_block
elif block["source"]["type"] == "url":
elif source["type"] == "url":
if "url" not in source:
yield _non_standard_block(block)
continue
file_block = {
"type": "file",
"url": block["source"]["url"],
"url": source["url"],
}
_populate_extras(file_block, block, {"type", "source"})
yield file_block
elif block["source"]["type"] == "file":
elif source["type"] == "file":
if "file_id" not in source:
yield _non_standard_block(block)
continue
file_block = {
"type": "file",
"id": block["source"]["file_id"],
"id": source["file_id"],
}
_populate_extras(file_block, block, {"type", "source"})
yield file_block
elif block["source"]["type"] == "text":
elif source["type"] == "text":
if "data" not in source:
yield _non_standard_block(block)
continue
plain_text_block: types.PlainTextContentBlock = {
"type": "text-plain",
"text": block["source"]["data"],
"text": source["data"],
"mime_type": block.get("media_type", "text/plain"),
}
_populate_extras(plain_text_block, block, {"type", "source"})
yield plain_text_block
else:
yield {"type": "non_standard", "value": block}
yield _non_standard_block(block)
elif (
block_type == "image"
and "source" in block
and "type" in block["source"]
block_type == "image" and isinstance(source, dict) and "type" in source
):
if block["source"]["type"] == "base64":
if source["type"] == "base64":
if "data" not in source or "media_type" not in source:
yield _non_standard_block(block)
continue
image_block: types.ImageContentBlock = {
"type": "image",
"base64": block["source"]["data"],
"mime_type": block["source"]["media_type"],
"base64": source["data"],
"mime_type": source["media_type"],
}
_populate_extras(image_block, block, {"type", "source"})
yield image_block
elif block["source"]["type"] == "url":
elif source["type"] == "url":
if "url" not in source:
yield _non_standard_block(block)
continue
image_block = {
"type": "image",
"url": block["source"]["url"],
"url": source["url"],
}
_populate_extras(image_block, block, {"type", "source"})
yield image_block
elif block["source"]["type"] == "file":
elif source["type"] == "file":
if "file_id" not in source:
yield _non_standard_block(block)
continue
image_block = {
"type": "image",
"id": block["source"]["file_id"],
"id": source["file_id"],
}
_populate_extras(image_block, block, {"type", "source"})
yield image_block
else:
yield {"type": "non_standard", "value": block}
yield _non_standard_block(block)
elif block_type in types.KNOWN_BLOCK_TYPES:
yield cast("types.ContentBlock", block)
else:
yield {"type": "non_standard", "value": block}
yield _non_standard_block(block)
return list(_iter_blocks())
@@ -143,6 +167,11 @@ def _convert_citation_to_v1(citation: dict[str, Any]) -> types.Annotation:
citation_type = citation.get("type")
if citation_type == "web_search_result_location":
if "cited_text" not in citation or "url" not in citation:
return {
"type": "non_standard_annotation",
"value": citation,
}
url_citation: types.Citation = {
"type": "citation",
"cited_text": citation["cited_text"],
@@ -165,6 +194,11 @@ def _convert_citation_to_v1(citation: dict[str, Any]) -> types.Annotation:
"page_location",
"search_result_location",
}:
if "cited_text" not in citation:
return {
"type": "non_standard_annotation",
"value": citation,
}
document_citation: types.Citation = {
"type": "citation",
"cited_text": citation["cited_text"],
+3 -1
View File
@@ -161,7 +161,7 @@ def _insert_input_variables(
warnings.warn(msg, stacklevel=2)
formatted[k] = _insert_input_variables(v, inputs, template_format)
elif isinstance(v, (list, tuple)):
formatted_v: list[str | dict[str, Any]] = []
formatted_v: list[Any] = []
for x in v:
if isinstance(x, str):
formatted_v.append(formatter(x, **inputs))
@@ -169,6 +169,8 @@ def _insert_input_variables(
formatted_v.append(
_insert_input_variables(x, inputs, template_format)
)
else:
formatted_v.append(x)
formatted[k] = type(v)(formatted_v)
else:
formatted[k] = v
@@ -69,7 +69,8 @@ class StructuredPrompt(ChatPromptTemplate):
f"{schema_}"
)
raise ValueError(err_msg)
structured_output_kwargs = structured_output_kwargs or {}
# Avoid mutating a caller-provided dict when merging extra kwargs.
structured_output_kwargs = dict(structured_output_kwargs or {})
for k in set(kwargs).difference(get_pydantic_field_names(self.__class__)):
structured_output_kwargs[k] = kwargs.pop(k)
super().__init__(
+20 -4
View File
@@ -477,6 +477,9 @@ class AddableDict(dict[str, Any]):
Returns:
A dictionary that is the result of adding the two dictionaries.
Raises:
TypeError: If a shared key holds values of incompatible types.
"""
chunk = AddableDict(self)
for key in other:
@@ -485,8 +488,13 @@ class AddableDict(dict[str, Any]):
elif other[key] is not None:
try:
added = chunk[key] + other[key]
except TypeError:
added = other[key]
except TypeError as exc:
msg = (
f"Cannot add incompatible types for key {key!r}: "
f"{type(chunk[key]).__name__!r} and "
f"{type(other[key]).__name__!r}."
)
raise TypeError(msg) from exc
chunk[key] = added
return chunk
@@ -498,6 +506,9 @@ class AddableDict(dict[str, Any]):
Returns:
A dictionary that is the result of adding the two dictionaries.
Raises:
TypeError: If a shared key holds values of incompatible types.
"""
chunk = AddableDict(other)
for key in self:
@@ -506,8 +517,13 @@ class AddableDict(dict[str, Any]):
elif self[key] is not None:
try:
added = chunk[key] + self[key]
except TypeError:
added = self[key]
except TypeError as exc:
msg = (
f"Cannot add incompatible types for key {key!r}: "
f"{type(chunk[key]).__name__!r} and "
f"{type(self[key]).__name__!r}."
)
raise TypeError(msg) from exc
chunk[key] = added
return chunk
+1 -1
View File
@@ -1238,7 +1238,7 @@ class ChildTool(BaseTool):
error_to_raise = ValueError(msg)
else:
content = response
except ValidationError as e:
except (ValidationError, ValidationErrorV1) as e:
if not self.handle_validation_error:
error_to_raise = e
else:
+14 -6
View File
@@ -4,7 +4,7 @@ import inspect
from collections.abc import Callable
from typing import Any, Literal, cast, get_type_hints, overload
from pydantic import BaseModel, Field, create_model
from pydantic import BaseModel, Field, RootModel, create_model
from langchain_core.callbacks import Callbacks
from langchain_core.runnables import Runnable
@@ -329,16 +329,17 @@ def tool(
)
# If someone doesn't want a schema applied, we must treat it as
# a simple string->string function
if dec_func.__doc__ is None:
tool_description = tool_description or dec_func.__doc__
if tool_description is None:
msg = (
"Function must have a docstring if "
"description not provided and infer_schema is False."
"Function must have either a docstring or description "
"when infer_schema is False."
)
raise ValueError(msg)
return Tool(
name=tool_name,
func=func,
description=f"{tool_name} tool",
description=tool_description,
return_direct=return_direct,
coroutine=coroutine,
response_format=response_format,
@@ -469,12 +470,19 @@ def convert_runnable_to_tool(
def invoke_wrapper(callbacks: Callbacks | None = None, **kwargs: Any) -> Any:
return runnable.invoke(kwargs, config={"callbacks": callbacks})
input_schema_cls = runnable.input_schema
# Detect `RootModel` input schemas so we don't use them directly as the tool
# args schema.
is_root_model = isinstance(input_schema_cls, type) and issubclass(
input_schema_cls, RootModel
)
if (
arg_types is None
and schema.get("type") == "object"
and schema.get("properties")
and not is_root_model
):
args_schema = runnable.input_schema
args_schema = input_schema_cls
else:
args_schema = _get_schema_from_runnable_and_arg_types(
runnable, name, arg_types=arg_types
+11 -1
View File
@@ -68,6 +68,15 @@ def merge_dicts(left: dict[str, Any], *others: dict[str, Any]) -> dict[str, Any]
merged[right_k] = merge_lists(merged[right_k], right_v)
elif merged[right_k] == right_v:
continue
elif isinstance(merged[right_k], bool):
# `bool` is a subclass of `int`, so without this check differing
# booleans would fall into the int branch below and get summed,
# silently turning e.g. `True + False` into the int `1`.
msg = (
f"Additional kwargs key {right_k} already exists in left dict and "
f"value has unsupported type {type(merged[right_k])}."
)
raise TypeError(msg)
elif isinstance(merged[right_k], int):
# Preserve identification and temporal fields using last-wins strategy
# instead of summing:
@@ -118,7 +127,8 @@ def merge_lists(left: list[Any] | None, *others: list[Any] | None) -> list[Any]
i
for i, e_left in enumerate(merged)
if (
"index" in e_left
isinstance(e_left, dict)
and "index" in e_left
and e_left["index"] == e["index"] # index matches
and ( # IDs not inconsistent
e_left.get("id") in {None, ""}
@@ -713,6 +713,13 @@ def tool_example_to_messages(
messages.append(
AIMessage(content="", additional_kwargs={"tool_calls": openai_tool_calls})
)
if tool_outputs is not None and len(tool_outputs) != len(openai_tool_calls):
msg = (
f"The number of tool_outputs ({len(tool_outputs)}) must match the number "
f"of tool_calls ({len(openai_tool_calls)}). Got {len(tool_outputs)} "
f"output(s) for {len(openai_tool_calls)} tool call(s)."
)
raise ValueError(msg)
tool_outputs = tool_outputs or ["You have correctly called this tool."] * len(
openai_tool_calls
)
+1 -1
View File
@@ -1,3 +1,3 @@
"""Version information for `langchain-core`."""
VERSION = "1.5.3"
VERSION = "1.5.4"
+2 -1
View File
@@ -21,10 +21,11 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules",
]
version = "1.5.3"
version = "1.5.4"
requires-python = ">=3.10.0,<4.0.0"
dependencies = [
"langsmith>=0.3.45,<1.0.0",
"httpx>=0.23.0,<1.0.0",
"tenacity!=8.4.0,>=8.1.0,<10.0.0",
"jsonpatch>=1.33.0,<2.0.0",
"PyYAML>=5.3.0,<7.0.0",
@@ -1,3 +1,4 @@
import contextlib
from typing import Any
from langchain_core.callbacks import (
@@ -120,3 +121,15 @@ async def test_usage_callback_async() -> None:
callback = UsageMetadataCallbackHandler()
_ = await llm.abatch(["Message 1", "Message 2"], config={"callbacks": [callback]})
assert callback.usage_metadata == {"test_model": total_1_2}
def test_usage_callback_clears_on_exception() -> None:
"""Callback must stop tracking after with-block exits via exception (#38989)."""
llm = FakeChatModelWithResponseMetadata(messages=iter(messages), model_name="fake")
with contextlib.suppress(RuntimeError), get_usage_metadata_callback() as cb:
_ = llm.invoke("in block")
raise RuntimeError
# Calls after the block must not accumulate into the previous callback.
_ = llm.invoke("outside block")
assert cb.usage_metadata == {"fake": usage1}
@@ -38,6 +38,10 @@ class InMemoryCache(BaseCache):
"""Clear cache."""
self._cache = {}
def __len__(self) -> int:
"""Return the number of cached entries."""
return len(self._cache)
def test_local_cache_sync() -> None:
"""Test that the local cache is being populated but not the global one."""
@@ -27,6 +27,10 @@ class InMemoryCache(BaseCache):
"""Clear cache."""
self._cache = {}
def __len__(self) -> int:
"""Return the number of cached entries."""
return len(self._cache)
async def test_local_cache_generate_async() -> None:
global_cache = InMemoryCache()
@@ -5,6 +5,8 @@ from typing import Any
import pytest
from pydantic import BaseModel, ConfigDict, Field, SecretStr
from pydantic.fields import FieldInfo
from pydantic_core import PydanticUndefined
from langchain_core._api import LangChainDeprecationWarning
from langchain_core._api.deprecation import LangChainPendingDeprecationWarning
@@ -13,7 +15,7 @@ from langchain_core.load import InitValidator, Serializable, dumpd, dumps, load,
from langchain_core.load.load import (
_get_default_allowed_class_paths,
)
from langchain_core.load.serializable import _is_field_useful
from langchain_core.load.serializable import _is_field_useful, _try_neq_default
from langchain_core.messages import AIMessage
from langchain_core.outputs import ChatGeneration, Generation
from langchain_core.prompts import (
@@ -23,6 +25,7 @@ from langchain_core.prompts import (
)
from langchain_core.runnables.history import RunnableWithMessageHistory
from langchain_core.tracers import log_stream
from langchain_core.utils import from_env
OPENAI_TEST_MODEL = "gpt-5.5"
@@ -144,6 +147,62 @@ def test__is_field_useful() -> None:
assert not _is_field_useful(foo, "y", foo.y)
def test_try_neq_default_none_factory(monkeypatch: pytest.MonkeyPatch) -> None:
"""An env-backed `default_factory` at its `None` default is not flagged as changed.
Mirrors the real `output_version` field on chat models
(`Field(default_factory=from_env(..., default=None))`). Regression test for issue
#39157.
"""
monkeypatch.delenv("LC_TEST_OUTPUT_VERSION", raising=False)
class Model(BaseModel):
none_factory: str | None = Field(
default_factory=from_env("LC_TEST_OUTPUT_VERSION", default=None)
)
field = Model.model_fields["none_factory"]
assert not _try_neq_default(None, field)
assert _try_neq_default("set", field)
def test_try_neq_default_simulating_pydantic_2_14(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A `None`-factory default is still recognized when `get_default()` is undefined.
Pydantic 2.14+ returns `PydanticUndefined` (instead of `None`) for an un-called
`default_factory`; this forces that behavior on the current pydantic. The factory
must not be invoked, so the sentinel is mapped back to `None` for comparison.
"""
monkeypatch.delenv("LC_TEST_OUTPUT_VERSION", raising=False)
called = False
def _factory() -> None:
nonlocal called
called = True
class Model(BaseModel):
none_factory: str | None = Field(default_factory=_factory)
real_get_default = FieldInfo.get_default
def fake_get_default(self: FieldInfo, **kwargs: Any) -> Any:
if self.default_factory is not None and not kwargs.get("call_default_factory"):
return PydanticUndefined
return real_get_default(self, **kwargs)
monkeypatch.setattr(FieldInfo, "get_default", fake_get_default)
field = Model.model_fields["none_factory"]
assert field.get_default() is PydanticUndefined
assert not _try_neq_default(None, field)
assert _try_neq_default("set", field)
# The factory must never be re-executed during comparison (possible side effects).
assert called is False
class Foo(Serializable):
bar: int
baz: str
@@ -1,3 +1,5 @@
from typing import Any
from langchain_core.messages import AIMessage, AIMessageChunk, HumanMessage
from langchain_core.messages import content as types
@@ -507,3 +509,66 @@ def test_convert_to_v1_from_anthropic_input() -> None:
]
assert message.content_blocks == expected
def test_convert_to_v1_from_anthropic_input_malformed_sources() -> None:
content: list[str | dict[Any, Any]] = [
{"type": "document", "source": {"type": "base64", "media_type": "app/pdf"}},
{"type": "document", "source": {"type": "url"}},
{"type": "document", "source": {"type": "file"}},
{"type": "document", "source": {"type": "text"}},
{"type": "image", "source": {"type": "base64", "media_type": "image/jpeg"}},
{"type": "image", "source": {"type": "url"}},
{"type": "image", "source": {"type": "file"}},
]
message = HumanMessage(content)
assert message.content_blocks == [
*[{"type": "non_standard", "value": block} for block in content[:4]],
*content[4:],
]
def test_convert_to_v1_from_anthropic_malformed_citations() -> None:
message = AIMessage(
[
{
"type": "text",
"text": "Source-backed answer.",
"citations": [
{
"type": "web_search_result_location",
"cited_text": "Source text",
},
{
"type": "search_result_location",
"title": "Document Title",
},
],
},
],
response_metadata={"model_provider": "anthropic"},
)
assert message.content_blocks == [
{
"type": "text",
"text": "Source-backed answer.",
"annotations": [
{
"type": "non_standard_annotation",
"value": {
"type": "web_search_result_location",
"cited_text": "Source text",
},
},
{
"type": "non_standard_annotation",
"value": {
"type": "search_result_location",
"title": "Document Title",
},
},
],
},
]
@@ -116,3 +116,46 @@ def test_prompt_template_blocks_attribute_access() -> None:
ValueError, match="Variable names cannot contain attribute access"
):
PromptTemplate.from_template("{name.__class__}", template_format="f-string")
def test_dict_prompt_template_preserves_non_str_items_in_list() -> None:
"""Non-str/non-dict items inside lists must be preserved, not silently dropped.
Regression test for #39152: int, float, bool, None and other scalars nested
inside a list value were silently discarded by _insert_input_variables.
"""
template = {
"type": "tool_use",
"id": "call_1",
"name": "search",
"input": {
"query": "{q}",
"top_k_scores": [1, 2, 3],
"flags": [True, None],
},
}
prompt = DictPromptTemplate(template=template, template_format="f-string")
result = prompt.format(q="cats")
assert result["input"]["query"] == "cats"
assert result["input"]["top_k_scores"] == [1, 2, 3]
assert result["input"]["flags"] == [True, None]
def test_dict_prompt_template_preserves_mixed_list() -> None:
"""Mixed-type lists must survive formatting unchanged (except str interpolation)."""
template = {
"type": "x",
"mixed": ["a {v}", 1, 2.5, {"k": "val"}, None, True],
}
prompt = DictPromptTemplate(template=template, template_format="f-string")
result = prompt.format(v="b")
assert result["mixed"] == ["a b", 1, 2.5, {"k": "val"}, None, True]
def test_dict_prompt_template_preserves_list_with_no_variables() -> None:
"""Items must survive even when the template has no variables at all."""
template = {"nums": [1, 2]}
prompt = DictPromptTemplate(template=template, template_format="mustache")
assert prompt.format() == {"nums": [1, 2]}
@@ -145,3 +145,36 @@ def test_structured_prompt_template_empty_vars() -> None:
schema={"type": "object", "properties": {}, "title": "foo"},
template_format="mustache",
)
def test_structured_prompt_does_not_mutate_caller_kwargs() -> None:
"""StructuredPrompt must not mutate caller-provided kwargs.
Merging extra structured-output kwargs into a caller-owned dict would leak
options into later prompts that reuse the same dict.
"""
schema = {
"type": "object",
"properties": {"answer": {"type": "string"}},
}
shared_options = {"method": "json_schema"}
first = StructuredPrompt(
[("human", "one")],
schema,
structured_output_kwargs=shared_options,
strict=True,
)
assert first.structured_output_kwargs == {
"method": "json_schema",
"strict": True,
}
assert shared_options == {"method": "json_schema"}
second = StructuredPrompt(
[("human", "two")],
schema,
structured_output_kwargs=shared_options,
)
assert second.structured_output_kwargs == {"method": "json_schema"}
assert shared_options == {"method": "json_schema"}
@@ -84,7 +84,7 @@
"fake",
"FakeListLLM"
],
"repr": "FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=['foo'], i=1)",
"repr": "FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=['foo'], i=1)",
"name": "FakeListLLM"
}
},
@@ -128,7 +128,7 @@
"fake",
"FakeListLLM"
],
"repr": "FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=['bar'])",
"repr": "FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=['bar'])",
"name": "FakeListLLM"
}
},
@@ -268,7 +268,7 @@
"fake",
"FakeListLLM"
],
"repr": "FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=['foo'], i=1)",
"repr": "FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=['foo'], i=1)",
"name": "FakeListLLM"
},
"fallbacks": [
@@ -281,7 +281,7 @@
"fake",
"FakeListLLM"
],
"repr": "FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=['bar'])",
"repr": "FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=['bar'])",
"name": "FakeListLLM"
}
],
@@ -322,7 +322,7 @@
"fake",
"FakeListLLM"
],
"repr": "FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=['foo'], i=1)",
"repr": "FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=['foo'], i=1)",
"name": "FakeListLLM"
},
"fallbacks": [
@@ -335,7 +335,7 @@
"fake",
"FakeListLLM"
],
"repr": "FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=['baz'], i=1)",
"repr": "FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=['baz'], i=1)",
"name": "FakeListLLM"
},
{
@@ -347,7 +347,7 @@
"fake",
"FakeListLLM"
],
"repr": "FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=['bar'])",
"repr": "FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=['bar'])",
"name": "FakeListLLM"
}
],
@@ -97,7 +97,7 @@
"fake_chat_models",
"FakeListChatModel"
],
"repr": "FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=['foo, bar'])",
"repr": "FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=['foo, bar'])",
"name": "FakeListChatModel"
}
],
@@ -227,7 +227,7 @@
"fake_chat_models",
"FakeListChatModel"
],
"repr": "FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=['baz, qux'])",
"repr": "FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=['baz, qux'])",
"name": "FakeListChatModel"
}
],
@@ -346,7 +346,7 @@
"fake_chat_models",
"FakeListChatModel"
],
"repr": "FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=['foo, bar'])",
"repr": "FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=['foo, bar'])",
"name": "FakeListChatModel"
},
{
@@ -457,7 +457,7 @@
"fake_chat_models",
"FakeListChatModel"
],
"repr": "FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=['baz, qux'])",
"repr": "FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=['baz, qux'])",
"name": "FakeListChatModel"
}
],
@@ -848,7 +848,7 @@
"fake",
"FakeStreamingListLLM"
],
"repr": "FakeStreamingListLLM(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=['first item, second item, third item'])",
"repr": "FakeStreamingListLLM(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=['first item, second item, third item'])",
"name": "FakeStreamingListLLM"
},
{
@@ -884,7 +884,7 @@
"fake",
"FakeStreamingListLLM"
],
"repr": "FakeStreamingListLLM(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=['this', 'is', 'a', 'test'])",
"repr": "FakeStreamingListLLM(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=['this', 'is', 'a', 'test'])",
"name": "FakeStreamingListLLM"
}
},
@@ -1009,7 +1009,7 @@
# name: test_prompt_with_chat_model
'''
ChatPromptTemplate(input_variables=['question'], input_types={}, partial_variables={}, messages=[SystemMessagePromptTemplate(prompt=PromptTemplate(input_variables=[], input_types={}, partial_variables={}, template='You are a nice assistant.'), additional_kwargs={}), HumanMessagePromptTemplate(prompt=PromptTemplate(input_variables=['question'], input_types={}, partial_variables={}, template='{question}'), additional_kwargs={})])
| FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=['foo'])
| FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=['foo'])
'''
# ---
# name: test_prompt_with_chat_model.1
@@ -1109,7 +1109,7 @@
"fake_chat_models",
"FakeListChatModel"
],
"repr": "FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=['foo'])",
"repr": "FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=['foo'])",
"name": "FakeListChatModel"
}
},
@@ -1220,7 +1220,7 @@
"fake_chat_models",
"FakeListChatModel"
],
"repr": "FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=['foo, bar'])",
"repr": "FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=['foo, bar'])",
"name": "FakeListChatModel"
}
],
@@ -1249,7 +1249,7 @@
# name: test_prompt_with_chat_model_async
'''
ChatPromptTemplate(input_variables=['question'], input_types={}, partial_variables={}, messages=[SystemMessagePromptTemplate(prompt=PromptTemplate(input_variables=[], input_types={}, partial_variables={}, template='You are a nice assistant.'), additional_kwargs={}), HumanMessagePromptTemplate(prompt=PromptTemplate(input_variables=['question'], input_types={}, partial_variables={}, template='{question}'), additional_kwargs={})])
| FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=['foo'])
| FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=['foo'])
'''
# ---
# name: test_prompt_with_chat_model_async.1
@@ -1349,7 +1349,7 @@
"fake_chat_models",
"FakeListChatModel"
],
"repr": "FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=['foo'])",
"repr": "FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=['foo'])",
"name": "FakeListChatModel"
}
},
@@ -1459,7 +1459,7 @@
"fake",
"FakeListLLM"
],
"repr": "FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=['foo', 'bar'])",
"repr": "FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=['foo', 'bar'])",
"name": "FakeListLLM"
}
},
@@ -1576,7 +1576,7 @@
"fake",
"FakeListLLM"
],
"repr": "FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=['foo', 'bar'])",
"repr": "FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=['foo', 'bar'])",
"name": "FakeListLLM"
}
],
@@ -1699,7 +1699,7 @@
"fake",
"FakeStreamingListLLM"
],
"repr": "FakeStreamingListLLM(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=['bear, dog, cat', 'tomato, lettuce, onion'])",
"repr": "FakeStreamingListLLM(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=['bear, dog, cat', 'tomato, lettuce, onion'])",
"name": "FakeStreamingListLLM"
}
],
@@ -1867,7 +1867,7 @@
"fake",
"FakeListLLM"
],
"repr": "FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=['4'])",
"repr": "FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=['4'])",
"name": "FakeListLLM"
}
},
@@ -1940,7 +1940,7 @@
"fake",
"FakeListLLM"
],
"repr": "FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=['2'])",
"repr": "FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=['2'])",
"name": "FakeListLLM"
}
},
@@ -13407,7 +13407,7 @@
just_to_test_lambda: RunnableLambda(...)
}
| ChatPromptTemplate(input_variables=['documents', 'question'], input_types={}, partial_variables={}, messages=[SystemMessagePromptTemplate(prompt=PromptTemplate(input_variables=[], input_types={}, partial_variables={}, template='You are a nice assistant.'), additional_kwargs={}), HumanMessagePromptTemplate(prompt=PromptTemplate(input_variables=['documents', 'question'], input_types={}, partial_variables={}, template='Context:\n{documents}\n\nQuestion:\n{question}'), additional_kwargs={})])
| FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=['foo, bar'])
| FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=['foo, bar'])
| CommaSeparatedListOutputParser()
'''
# ---
@@ -13610,7 +13610,7 @@
"fake_chat_models",
"FakeListChatModel"
],
"repr": "FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=['foo, bar'])",
"repr": "FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=['foo, bar'])",
"name": "FakeListChatModel"
}
],
@@ -13636,8 +13636,8 @@
ChatPromptTemplate(input_variables=['question'], input_types={}, partial_variables={}, messages=[SystemMessagePromptTemplate(prompt=PromptTemplate(input_variables=[], input_types={}, partial_variables={}, template='You are a nice assistant.'), additional_kwargs={}), HumanMessagePromptTemplate(prompt=PromptTemplate(input_variables=['question'], input_types={}, partial_variables={}, template='{question}'), additional_kwargs={})])
| RunnableLambda(...)
| {
chat: FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=["i'm a chatbot"]),
llm: FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=["i'm a textbot"])
chat: FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=["i'm a chatbot"]),
llm: FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=["i'm a textbot"])
}
'''
# ---
@@ -13762,7 +13762,7 @@
"fake_chat_models",
"FakeListChatModel"
],
"repr": "FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=[\"i'm a chatbot\"])",
"repr": "FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=[\"i'm a chatbot\"])",
"name": "FakeListChatModel"
},
"llm": {
@@ -13774,7 +13774,7 @@
"fake",
"FakeListLLM"
],
"repr": "FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=[\"i'm a textbot\"])",
"repr": "FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=[\"i'm a textbot\"])",
"name": "FakeListLLM"
}
}
@@ -13917,7 +13917,7 @@
"fake_chat_models",
"FakeListChatModel"
],
"repr": "FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=[\"i'm a chatbot\"])",
"repr": "FakeListChatModel(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=[\"i'm a chatbot\"])",
"name": "FakeListChatModel"
},
"kwargs": {
@@ -13938,7 +13938,7 @@
"fake",
"FakeListLLM"
],
"repr": "FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.3'}}, responses=[\"i'm a textbot\"])",
"repr": "FakeListLLM(metadata={'lc_versions': {'langchain-core': '1.5.4'}}, responses=[\"i'm a textbot\"])",
"name": "FakeListLLM"
},
"passthrough": {
@@ -5,6 +5,7 @@ import pytest
from langchain_core.runnables.base import RunnableLambda
from langchain_core.runnables.utils import (
AddableDict,
get_function_nonlocals,
get_lambda_source,
indent_lines_after_first,
@@ -73,3 +74,29 @@ def test_nonlocals() -> None:
assert RunnableLambda(my_func3).deps == [agent]
assert RunnableLambda(my_func4).deps == [global_agent]
assert RunnableLambda(func).deps == [nl]
def test_addable_dict_add_incompatible_types_raises() -> None:
left = AddableDict({"count": 1})
right = AddableDict({"count": "some_string"})
with pytest.raises(
TypeError,
match=r"Cannot add incompatible types for key 'count': 'int' and 'str'\.",
):
left + right
def test_addable_dict_radd_incompatible_types_raises() -> None:
left = AddableDict({"count": 1})
right = AddableDict({"count": "some_string"})
with pytest.raises(
TypeError,
match=r"Cannot add incompatible types for key 'count': 'int' and 'str'\.",
):
right.__radd__(left)
def test_addable_dict_add_none_seeded_key_is_unaffected() -> None:
left = AddableDict({"data": None})
right = AddableDict({"data": {"a": 1}})
assert (left + right) == AddableDict({"data": {"a": 1}})
@@ -225,6 +225,14 @@ def test_message_chunks() -> None:
assert (default_id_chunk + provider_chunk).id == meaningful_id
def test_message_chunks_bool_additional_kwargs_raises() -> None:
"""Differing booleans (e.g. `refusal`) must not silently coerce to `int`."""
a = AIMessageChunk(content="", additional_kwargs={"refusal": True})
b = AIMessageChunk(content="", additional_kwargs={"refusal": False})
with pytest.raises(TypeError, match="unsupported type"):
a + b
def test_chat_message_chunks() -> None:
assert ChatMessageChunk(role="User", content="I am", id="ai4") + ChatMessageChunk(
role="User", content=" indeed."
+101 -1
View File
@@ -23,7 +23,7 @@ from typing import (
)
import pytest
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from pydantic import BaseModel, ConfigDict, Field, RootModel, ValidationError
from pydantic.v1 import BaseModel as BaseModelV1
from pydantic.v1 import ValidationError as ValidationErrorV1
from typing_extensions import TypedDict, override
@@ -365,9 +365,34 @@ def test_structured_single_str_decorator_no_infer_schema() -> None:
assert isinstance(unstructured_tool_input, BaseTool)
assert unstructured_tool_input.args_schema is None
assert unstructured_tool_input.description == "Return the arguments directly."
assert unstructured_tool_input.run("foo") == "foo"
def test_simple_tool_decorator_no_infer_schema_uses_explicit_description() -> None:
"""Test that a simple tool preserves an explicit description."""
@tool(infer_schema=False, description="Echo the supplied input.")
def echo(tool_input: str) -> str:
return tool_input
assert echo.description == "Echo the supplied input."
def test_simple_tool_decorator_no_infer_schema_requires_description_or_docstring() -> (
None
):
"""Test that a simple tool requires an authored description."""
with pytest.raises(
ValueError,
match="Function must have either a docstring or description",
):
@tool(infer_schema=False)
def echo(tool_input: str) -> str:
return tool_input
def test_structured_tool_types_parsed() -> None:
"""Test the non-primitive types are correctly passed to structured tools."""
@@ -1123,6 +1148,30 @@ async def test_async_validation_error_handling_callable() -> None:
assert expected == actual
@pytest.mark.skipif(
sys.version_info >= (3, 14),
reason="pydantic.v1 namespace not supported with Python 3.14+",
)
async def test_async_validation_error_handling_pydantic_v1_schema() -> None:
"""Test async validation error handling for Pydantic V1 schemas."""
class Args(BaseModelV1):
x: int
def foo(x: int) -> str:
"""Return x as text."""
return str(x)
tool_ = StructuredTool.from_function(
foo,
args_schema=cast("ArgsSchema", Args),
handle_validation_error=True,
)
assert tool_.run({"x": "not-an-integer"}) == "Tool input validation error"
assert await tool_.arun({"x": "not-an-integer"}) == "Tool input validation error"
@pytest.mark.parametrize(
"handler",
[
@@ -1759,6 +1808,57 @@ def test_convert_from_runnable_dict() -> None:
assert result == "6"
def test_convert_from_runnable_root_model_input_schema() -> None:
"""`as_tool` should not advertise a `TypedDict` input nested under `root`.
Some `Runnable`s (e.g. a compiled `langgraph` `StateGraph`) expose a
`pydantic.RootModel` as `input_schema` even though `get_input_jsonschema`
reports a flat object schema. See:
"""
class Args(TypedDict):
foo: str
bar: str
class _RootModelInputRunnable(RunnableLambda[Args, str]):
@override
def get_input_schema(
self, config: RunnableConfig | None = None
) -> TypeBaseModel:
return RootModel[Args]
@override
def get_input_jsonschema(
self, config: RunnableConfig | None = None
) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"foo": {"type": "string"},
"bar": {"type": "string"},
},
"required": ["foo", "bar"],
}
def f(x: Args) -> str:
return f"{x['foo']} {x['bar']}"
runnable = _RootModelInputRunnable(f)
as_tool = runnable.as_tool(name="my_tool", description="Example tool.")
assert as_tool.args_schema is not None
assert isinstance(as_tool.args_schema, type)
assert not issubclass(as_tool.args_schema, RootModel)
oai_schema = convert_to_openai_tool(as_tool)
parameters = oai_schema["function"]["parameters"]
assert parameters["properties"].keys() == {"foo", "bar"}
assert "root" not in parameters["properties"]
result = as_tool.invoke({"foo": "hello", "bar": "world"})
assert result == "hello world"
def test_convert_from_runnable_other() -> None:
# String input
def f(x: str) -> str:
@@ -795,6 +795,24 @@ def test_tool_outputs() -> None:
assert not response.tool_calls
def test_tool_outputs_fewer_than_tool_calls_raises() -> None:
with pytest.raises(ValueError, match="must match"):
tool_example_to_messages(
input="Extract both values",
tool_calls=[FakeCall(data="a"), FakeCall(data="b")],
tool_outputs=["only one output"],
)
def test_tool_outputs_more_than_tool_calls_raises() -> None:
with pytest.raises(ValueError, match="must match"):
tool_example_to_messages(
input="Extract one value",
tool_calls=[FakeCall(data="a")],
tool_outputs=["output1", "extra output"],
)
@pytest.mark.parametrize(
"typed_dict",
[ExtensionsTypedDict, TypingTypedDict],
@@ -129,6 +129,18 @@ def test_check_package_version(
# Other integer fields should still be summed (e.g., token counts)
({"tokens": 10}, {"tokens": 5}, {"tokens": 15}),
({"count": 1}, {"count": 2}, {"count": 3}),
# Differing booleans must not silently coerce to `int` (e.g. `True + False`).
(
{"a": True},
{"a": False},
pytest.raises(
TypeError,
match=(
"Additional kwargs key a already exists in left dict and value "
r"has unsupported type .+bool.+."
),
),
),
],
)
def test_merge_dicts(
@@ -437,6 +449,16 @@ def test_generation_chunk_addition_type_error() -> None:
[{"no_index": "b"}],
[{"no_index": "a"}, {"no_index": "b"}],
),
# A string element whose text happens to contain the literal substring
# "index" must not be treated as index-keyed (it isn't a dict).
(
["the index is here", {"index": 0, "reference_ids": ["a"]}],
[{"index": 0, "reference_ids": ["b"]}],
[
"the index is here",
{"index": 0, "reference_ids": ["a", "b"]},
],
),
],
)
def test_merge_lists(
+7 -5
View File
@@ -1040,9 +1040,10 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.5.3"
version = "1.5.4"
source = { editable = "." }
dependencies = [
{ name = "httpx" },
{ name = "jsonpatch" },
{ name = "langchain-protocol" },
{ name = "langsmith" },
@@ -1090,6 +1091,7 @@ typing = [
[package.metadata]
requires-dist = [
{ name = "httpx", specifier = ">=0.23.0,<1.0.0" },
{ name = "jsonpatch", specifier = ">=1.33.0,<2.0.0" },
{ name = "langchain-protocol", specifier = ">=0.0.17" },
{ name = "langsmith", specifier = ">=0.3.45,<1.0.0" },
@@ -1107,7 +1109,7 @@ dev = [
{ name = "jupyter", specifier = ">=1.0.0,<2.0.0" },
{ name = "setuptools", specifier = ">=67.6.1,<84.0.0" },
]
lint = [{ name = "ruff", specifier = ">=0.15.0,<0.16.0" }]
lint = [{ name = "ruff", specifier = ">=0.15.0,<0.17.0" }]
test = [
{ name = "blockbuster", specifier = ">=1.5.18,<1.6.0" },
{ name = "freezegun", specifier = ">=1.2.2,<2.0.0" },
@@ -1182,11 +1184,11 @@ requires-dist = [
]
[package.metadata.requires-dev]
lint = [{ name = "ruff", specifier = ">=0.15.0,<0.16.0" }]
lint = [{ name = "ruff", specifier = ">=0.15.0,<0.17.0" }]
test = []
test-integration = []
typing = [
{ name = "mypy", specifier = ">=2.1.0,<2.2.0" },
{ name = "mypy", specifier = ">=2.1.0,<2.4.0" },
{ name = "types-pyyaml", specifier = ">=6.0.12.2,<7.0.0.0" },
]
@@ -1203,7 +1205,7 @@ requires-dist = [{ name = "langchain-core", editable = "." }]
[package.metadata.requires-dev]
dev = [{ name = "jupyter", specifier = ">=1.0.0,<2.0.0" }]
lint = [{ name = "ruff", specifier = ">=0.15.0,<0.16.0" }]
lint = [{ name = "ruff", specifier = ">=0.15.0,<0.17.0" }]
test = [
{ name = "freezegun", specifier = ">=1.2.2,<2.0.0" },
{ name = "pytest", specifier = ">=9.0.3,<10.0.0" },
+7 -7
View File
@@ -1970,15 +1970,15 @@ wheels = [
[[package]]
name = "h2"
version = "4.3.0"
version = "4.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "hpack" },
{ name = "hyperframe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
{ url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" },
]
[[package]]
@@ -1998,11 +1998,11 @@ wheels = [
[[package]]
name = "hpack"
version = "4.1.0"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" }
sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" },
{ url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" },
]
[[package]]
@@ -3114,7 +3114,7 @@ wheels = [
[[package]]
name = "langchain-openai"
version = "1.4.1"
version = "1.4.2"
source = { editable = "../partners/openai" }
dependencies = [
{ name = "langchain-core" },
+1 -1
View File
@@ -1,3 +1,3 @@
"""Main entrypoint into LangChain."""
__version__ = "1.3.14"
__version__ = "1.3.15"
+47 -15
View File
@@ -31,6 +31,10 @@ from langsmith import traceable
from typing_extensions import NotRequired, Required, TypedDict, overload
from langchain.agents._subagent_transformer import SubagentTransformer
from langchain.agents.middleware._trace_policy import (
_node_trace_policy,
_resolved_transform,
)
from langchain.agents.middleware.types import (
AgentMiddleware,
AgentState,
@@ -151,6 +155,22 @@ def _scrub_inputs(inputs: dict[str, Any]) -> dict[str, Any]:
return filtered
def _wrap_trace_kwargs(middleware: AgentMiddleware[Any, Any]) -> dict[str, Any]:
"""`traceable` kwargs for a middleware's `wrap_*` hook spans.
The `_scrub_inputs` baseline (strip the unserializable `handler`/`runtime`) always
runs first; the effective `TracePolicy` (the middleware's own, else the process-wide
default) composes on top. The effective policy is resolved at call time, so a
`configure_trace_policy` call after `create_agent` still applies.
"""
process_inputs = _resolved_transform(middleware.trace_policy, "process_inputs")
process_outputs = _resolved_transform(middleware.trace_policy, "process_outputs")
return {
"process_inputs": lambda inputs: process_inputs(_scrub_inputs(inputs)),
"process_outputs": process_outputs,
}
FALLBACK_MODELS_WITH_STRUCTURED_OUTPUT = [
# If model profile data are not available, model names matching these patterns
# are assumed to support provider-native structured output. These are regexes
@@ -436,7 +456,11 @@ def _resolve_schemas(schemas: list[type]) -> tuple[type, type, type]:
same field is declared by multiple schemas. Duplicates are harmless — a type
that appears more than once is processed at its last position.
"""
schema_hints = {schema: _get_schema_type_hints(schema) for schema in schemas}
schema_hints: dict[type, dict[str, Any]] = {}
for schema in schemas:
# Reinsert duplicates so dict iteration reflects their final position.
schema_hints.pop(schema, None)
schema_hints[schema] = _get_schema_type_hints(schema)
return (
_resolve_schema(schema_hints, "StateSchema", None),
_resolve_schema(schema_hints, "InputSchema", "input"),
@@ -1026,9 +1050,7 @@ def create_agent(
wrap_tool_call_wrapper = None
if middleware_w_wrap_tool_call:
wrappers = [
traceable(name=f"{m.name}.wrap_tool_call", process_inputs=_scrub_inputs)(
m.wrap_tool_call
)
traceable(name=f"{m.name}.wrap_tool_call", **_wrap_trace_kwargs(m))(m.wrap_tool_call)
for m in middleware_w_wrap_tool_call
]
wrap_tool_call_wrapper = _chain_tool_call_wrappers(wrappers)
@@ -1047,9 +1069,7 @@ def create_agent(
awrap_tool_call_wrapper = None
if middleware_w_awrap_tool_call:
async_wrappers = [
traceable(name=f"{m.name}.awrap_tool_call", process_inputs=_scrub_inputs)(
m.awrap_tool_call
)
traceable(name=f"{m.name}.awrap_tool_call", **_wrap_trace_kwargs(m))(m.awrap_tool_call)
for m in middleware_w_awrap_tool_call
]
awrap_tool_call_wrapper = _chain_async_tool_call_wrappers(async_wrappers)
@@ -1135,9 +1155,7 @@ def create_agent(
wrap_model_call_handler = None
if middleware_w_wrap_model_call:
sync_handlers = [
traceable(name=f"{m.name}.wrap_model_call", process_inputs=_scrub_inputs)(
m.wrap_model_call
)
traceable(name=f"{m.name}.wrap_model_call", **_wrap_trace_kwargs(m))(m.wrap_model_call)
for m in middleware_w_wrap_model_call
]
wrap_model_call_handler = _chain_model_call_handlers(sync_handlers)
@@ -1146,7 +1164,7 @@ def create_agent(
awrap_model_call_handler = None
if middleware_w_awrap_model_call:
async_handlers = [
traceable(name=f"{m.name}.awrap_model_call", process_inputs=_scrub_inputs)(
traceable(name=f"{m.name}.awrap_model_call", **_wrap_trace_kwargs(m))(
m.awrap_model_call
)
for m in middleware_w_awrap_model_call
@@ -1548,7 +1566,10 @@ def create_agent(
)
before_agent_node = RunnableCallable(sync_before_agent, async_before_agent, trace=False)
graph.add_node(
f"{m.name}.before_agent", before_agent_node, input_schema=resolved_state_schema
f"{m.name}.before_agent",
before_agent_node,
input_schema=resolved_state_schema,
trace_policy=_node_trace_policy(m.trace_policy),
)
if (
@@ -1569,7 +1590,10 @@ def create_agent(
)
before_node = RunnableCallable(sync_before, async_before, trace=False)
graph.add_node(
f"{m.name}.before_model", before_node, input_schema=resolved_state_schema
f"{m.name}.before_model",
before_node,
input_schema=resolved_state_schema,
trace_policy=_node_trace_policy(m.trace_policy),
)
if (
@@ -1589,7 +1613,12 @@ def create_agent(
else None
)
after_node = RunnableCallable(sync_after, async_after, trace=False)
graph.add_node(f"{m.name}.after_model", after_node, input_schema=resolved_state_schema)
graph.add_node(
f"{m.name}.after_model",
after_node,
input_schema=resolved_state_schema,
trace_policy=_node_trace_policy(m.trace_policy),
)
if (
m.__class__.after_agent is not AgentMiddleware.after_agent
@@ -1609,7 +1638,10 @@ def create_agent(
)
after_agent_node = RunnableCallable(sync_after_agent, async_after_agent, trace=False)
graph.add_node(
f"{m.name}.after_agent", after_agent_node, input_schema=resolved_state_schema
f"{m.name}.after_agent",
after_agent_node,
input_schema=resolved_state_schema,
trace_policy=_node_trace_policy(m.trace_policy),
)
# Determine the entry node (runs once at start): before_agent -> before_model -> model
@@ -2,6 +2,7 @@
from langgraph.runtime import Runtime
from langchain.agents.middleware._trace_policy import configure_trace_policy
from langchain.agents.middleware.context_editing import ClearToolUsesEdit, ContextEditingMiddleware
from langchain.agents.middleware.file_search import FilesystemFileSearchMiddleware
from langchain.agents.middleware.human_in_the_loop import (
@@ -37,12 +38,14 @@ from langchain.agents.middleware.types import (
ModelResponse,
OutputAgentState,
ToolCallRequest,
TracePolicy,
after_agent,
after_model,
before_agent,
before_model,
dynamic_prompt,
hook_config,
omit_payload,
wrap_model_call,
wrap_tool_call,
)
@@ -82,13 +85,16 @@ __all__ = [
"ToolCallRequest",
"ToolErrorMiddleware",
"ToolRetryMiddleware",
"TracePolicy",
"TriggerClause",
"after_agent",
"after_model",
"before_agent",
"before_model",
"configure_trace_policy",
"dynamic_prompt",
"hook_config",
"omit_payload",
"wrap_model_call",
"wrap_tool_call",
]
@@ -0,0 +1,61 @@
"""Process-global default `TracePolicy` for agent middleware.
`TracePolicy` (from langgraph) shapes what a middleware hook span records. This module
adds a process-wide default that individual middleware override via their own
`trace_policy` attribute. Set it once at startup with `configure_trace_policy`; the
default is read at span-creation time, so it applies to agents built before or after
the call.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any, Literal
from langgraph.types import TracePolicy
if TYPE_CHECKING:
from collections.abc import Callable
_DEFAULT_TRACE_POLICY: TracePolicy | None = None
def configure_trace_policy(policy: TracePolicy | None) -> None:
"""Set the process-wide default `TracePolicy` for agent middleware hook spans.
Call once at startup. A middleware's own `trace_policy` overrides this wholesale
(no field-level merge). Pass `None` to clear the default.
"""
global _DEFAULT_TRACE_POLICY # noqa: PLW0603 - process-wide default, set once at startup
_DEFAULT_TRACE_POLICY = policy
def _resolve(mw_policy: TracePolicy | None) -> TracePolicy | None:
"""The effective policy: the middleware's own if set, else the global default."""
return mw_policy if mw_policy is not None else _DEFAULT_TRACE_POLICY
def _resolved_transform(
mw_policy: TracePolicy | None,
field: Literal["process_inputs", "process_outputs"],
) -> Callable[[Any], Any]:
"""A trace processor that resolves the effective policy at call time.
Reading the global at call time (not build time) means `configure_trace_policy` can
run after `create_agent` and still apply. Passes the value through unchanged when no
effective policy defines `field`.
"""
def process(value: Any) -> Any:
effective = _resolve(mw_policy)
fn = getattr(effective, field) if effective is not None else None
return fn(value) if fn is not None else value
return process
def _node_trace_policy(mw_policy: TracePolicy | None) -> TracePolicy:
"""Build the resolver-backed `TracePolicy` for a middleware's node hook."""
return TracePolicy(
process_inputs=_resolved_transform(mw_policy, "process_inputs"),
process_outputs=_resolved_transform(mw_policy, "process_outputs"),
)
@@ -1,13 +1,8 @@
"""Tag and filter middleware-internal model calls.
Middleware may make bookkeeping model calls (e.g. summarization or tool
selection) in the same graph namespace as the main agent call, causing their
tokens to appear in `run.messages`.
Tag these calls with `internal_call_metadata()` and declare
`transformers = (InternalCallTransformer,)` on the middleware class so it's
only registered on agents that actually use it — see `AgentMiddleware.transformers`.
Both are public so third-party middleware can adopt the same pattern.
Tag internal calls with `internal_call_metadata()` and declare
`transformers = (InternalCallTransformer,)` on the middleware class to keep
them out of `run.messages`. Both APIs are public for third-party middleware.
"""
from __future__ import annotations
@@ -22,25 +17,17 @@ if TYPE_CHECKING:
from langgraph.stream._types import ProtocolEvent
INTERNAL_CALL_METADATA_KEY = "lc_internal_call"
"""`RunnableConfig` metadata key marking a model call as internal to middleware.
"""Metadata key marking a model call as internal to middleware.
Kept separate from `lc_source` (used by `SummarizationMiddleware` to advertise
that a summarization call is in flight) so tagging a call for filtering here
never changes what other consumers observe via that key.
Kept separate from `lc_source` so filtering doesn't affect its existing
semantics.
"""
_INTERNAL_CALL_TOKEN = secrets.token_hex(16)
"""Unguessable marker value, regenerated on import.
"""Process-local marker used to prevent callers from spoofing internal calls.
`config["metadata"]` ultimately comes from a `RunnableConfig`, which callers
of `invoke`/`stream_events` can populate with arbitrary values — including
the main agent turn's own call, since it goes through the same ambient
config. If the marker were a fixed value like `True`, a caller who can
influence invocation metadata (e.g. an API layer that forwards user-supplied
metadata) could set `lc_internal_call` themselves and hide the agent's real
answer from `run.messages`. Comparing against this process-local secret
instead of truthiness means a caller can't forge it without already being
able to run code in this process.
A random token prevents user-supplied metadata from hiding real model calls
from `run.messages`.
"""
@@ -56,33 +43,14 @@ def internal_call_metadata() -> dict[str, Any]:
class InternalCallTransformer(StreamTransformer):
"""Keep internal model calls out of `run.messages` and the raw event log.
Declared on `transformers` by middleware that makes internal calls (e.g.
`SummarizationMiddleware`), so it's only registered on agents using one of
those, and runs before built-in transformers.
Used by middleware that makes internal model calls and runs before built-in
transformers.
`messages`-mode events come in two shapes, and influencing
`MessagesTransformer`'s built-in exclusion rules for either one requires
mutating the event in place (there's no metadata hook it consults). That
mutation would misrepresent the call if it also reached raw event
consumers — a real AI response reported as `role: "tool"`, or a payload
replaced with `None`, neither a real messages-mode shape — so tagged
events are dropped from the raw log entirely rather than published in a
mutated form:
For tagged events, streamed `message-start` events are marked as tool-role and
whole-`AIMessage` payloads are cleared so `MessagesTransformer` ignores them.
The mutated events are then dropped from the raw log.
- Streamed protocol events (`message-start` / `content-block-*` /
`message-finish`): `message-start`'s `role` is rewritten to `"tool"`,
reusing `MessagesTransformer`'s existing tool-result exclusion, then
the event is dropped.
- Whole-`AIMessage` events — the fallback `MessagesTransformer` uses when
a chat model doesn't stream (notably, streaming context isn't
propagated on Python 3.10) or when a node returns a finalized message
as state: the payload is cleared so `MessagesTransformer` has nothing
left to route, then the event is dropped.
Only events in this transformer's own scope are touched — nested
subgraphs get their own scoped instance (if the offending middleware runs
there too), and `MessagesTransformer` itself ignores events outside its
scope, so mutating them here would be both unnecessary and unsafe.
Only events within this transformer's scope are modified.
"""
before_builtins: ClassVar[bool] = True
@@ -129,10 +97,9 @@ class InternalCallTransformer(StreamTransformer):
if not is_internal:
return True
# Only `message-start` needs mutating: once MessagesTransformer sees its
# `role` spoofed as "tool", its own tool-result bookkeeping ignores every
# later event for this run_id, so content-block-*/message-finish need no
# action here, the whole run gets dropped below regardless.
# Only `message-start` needs mutation: marking it as `"tool"` makes
# `MessagesTransformer` ignore the rest of that run. All events are still
# dropped from the raw log below.
if isinstance(payload, dict) and payload.get("event") == "message-start":
payload["role"] = "tool"
elif isinstance(payload, BaseMessage):
@@ -1,5 +1,6 @@
"""Summarization middleware."""
import logging
import uuid
import warnings
from collections.abc import Callable, Iterable, Mapping
@@ -32,6 +33,8 @@ from langchain.agents.middleware.internal_call_transformer import (
from langchain.agents.middleware.types import AgentMiddleware, AgentState, ContextT, ResponseT
from langchain.chat_models import BaseChatModel, init_chat_model
logger = logging.getLogger(__name__)
TokenCounter = Callable[[Iterable[MessageLikeRepresentation]], int]
DEFAULT_SUMMARY_PROMPT = """<role>
@@ -232,6 +235,11 @@ class SummarizationMiddleware(AgentMiddleware[AgentState[ResponseT], ContextT, R
This middleware monitors message token counts and automatically summarizes older
messages when a threshold is reached, preserving recent messages and maintaining
context continuity by ensuring AI/Tool message pairs remain together.
Transient summary-generation errors (rate limits, timeouts) are retried
in-process, up to 3 attempts total, via `Runnable.with_retry`. If a summary call
still fails after those attempts, the underlying error propagates rather than
fabricating a summary.
"""
transformers = (InternalCallTransformer,)
@@ -346,6 +354,9 @@ class SummarizationMiddleware(AgentMiddleware[AgentState[ResponseT], ContextT, R
model = init_chat_model(model)
self.model = model
# Retry transient errors (rate limits, timeouts) in-process, up to 3 attempts
# total, before giving up. See `Runnable.with_retry`.
self._summary_model = self.model.with_retry()
self.trigger: ContextSize | TriggerClause | list[ContextSize | TriggerClause] | None = (
self._copy_trigger(trigger)
@@ -395,6 +406,9 @@ class SummarizationMiddleware(AgentMiddleware[AgentState[ResponseT], ContextT, R
Returns:
An updated state with summarized messages if summarization was performed.
Raises:
Exception: If summary generation still fails once retries are exhausted.
"""
messages = state["messages"]
self._ensure_message_ids(messages)
@@ -433,6 +447,9 @@ class SummarizationMiddleware(AgentMiddleware[AgentState[ResponseT], ContextT, R
Returns:
An updated state with summarized messages if summarization was performed.
Raises:
Exception: If summary generation still fails once retries are exhausted.
"""
messages = state["messages"]
self._ensure_message_ids(messages)
@@ -817,6 +834,13 @@ class SummarizationMiddleware(AgentMiddleware[AgentState[ResponseT], ContextT, R
Args:
messages_to_summarize: Messages to summarize.
Returns:
The generated summary.
Raises:
Exception: If the summary model call still fails once retries (see
`Runnable.with_retry`) are exhausted.
"""
if not messages_to_summarize:
return "No previous conversation history."
@@ -829,20 +853,24 @@ class SummarizationMiddleware(AgentMiddleware[AgentState[ResponseT], ContextT, R
# prompt while excluding raw message metadata from the token budget.
formatted_messages = get_buffer_string(trimmed_messages, format="xml")
try:
response = self.model.invoke(
self.summary_prompt.format(messages=formatted_messages).rstrip(),
config={"metadata": {"lc_source": "summarization", **internal_call_metadata()}},
)
return response.text.strip()
except Exception as e:
return f"Error generating summary: {e!s}"
response = self._summary_model.invoke(
self.summary_prompt.format(messages=formatted_messages).rstrip(),
config={"metadata": {"lc_source": "summarization", **internal_call_metadata()}},
)
return response.text.strip()
async def _acreate_summary(self, messages_to_summarize: list[AnyMessage]) -> str:
"""Generate summary for the given messages.
Args:
messages_to_summarize: Messages to summarize.
Returns:
The generated summary.
Raises:
Exception: If the summary model call still fails once retries (see
`Runnable.with_retry`) are exhausted.
"""
if not messages_to_summarize:
return "No previous conversation history."
@@ -855,14 +883,11 @@ class SummarizationMiddleware(AgentMiddleware[AgentState[ResponseT], ContextT, R
# prompt while excluding raw message metadata from the token budget.
formatted_messages = get_buffer_string(trimmed_messages, format="xml")
try:
response = await self.model.ainvoke(
self.summary_prompt.format(messages=formatted_messages).rstrip(),
config={"metadata": {"lc_source": "summarization", **internal_call_metadata()}},
)
return response.text.strip()
except Exception as e:
return f"Error generating summary: {e!s}"
response = await self._summary_model.ainvoke(
self.summary_prompt.format(messages=formatted_messages).rstrip(),
config={"metadata": {"lc_source": "summarization", **internal_call_metadata()}},
)
return response.text.strip()
def _trim_messages_for_summary(self, messages: list[AnyMessage]) -> list[AnyMessage]:
"""Trim messages to fit within summary generation limits."""
@@ -2,6 +2,7 @@
from __future__ import annotations
import warnings
from typing import TYPE_CHECKING, Any, Generic
from langchain_core.language_models.chat_models import BaseChatModel
@@ -22,6 +23,8 @@ if TYPE_CHECKING:
from langchain.agents.middleware.types import ToolCallRequest
from langchain.tools import BaseTool
_DEFAULT_EMULATOR_MODEL = "anthropic:claude-sonnet-4-5-20250929"
class LLMToolEmulator(AgentMiddleware[AgentState[Any], ContextT], Generic[ContextT]):
"""Emulates specified tools using an LLM instead of executing them.
@@ -90,7 +93,14 @@ class LLMToolEmulator(AgentMiddleware[AgentState[Any], ContextT], Generic[Contex
If empty list, no tools will be emulated.
model: Model to use for emulation.
Defaults to `'anthropic:claude-sonnet-4-5-20250929'`.
Defaults to `'anthropic:claude-sonnet-4-5-20250929'`, which requires
`langchain-anthropic` to be installed.
!!! warning "Deprecated"
Relying on the implicit default is deprecated and will be
removed in a future release, since it makes this middleware
depend on `langchain-anthropic` even when unspecified. Pass
`model` explicitly instead.
Can be a model identifier string or `BaseChatModel` instance.
"""
@@ -101,7 +111,7 @@ class LLMToolEmulator(AgentMiddleware[AgentState[Any], ContextT], Generic[Contex
self.emulate_all = tools is None
self.tools_to_emulate: set[str] = set()
if not self.emulate_all and tools is not None:
if tools is not None:
for tool in tools:
if isinstance(tool, str):
self.tools_to_emulate.add(tool)
@@ -111,7 +121,22 @@ class LLMToolEmulator(AgentMiddleware[AgentState[Any], ContextT], Generic[Contex
# Initialize emulator model
if model is None:
self.model = init_chat_model("anthropic:claude-sonnet-4-5-20250929", temperature=1)
warnings.warn(
"LLMToolEmulator's default model "
f"({_DEFAULT_EMULATOR_MODEL!r}) is deprecated and will be removed "
"in a future release. Pass `model` explicitly instead.",
DeprecationWarning,
stacklevel=2,
)
try:
self.model = init_chat_model(_DEFAULT_EMULATOR_MODEL, temperature=1)
except ImportError as e:
msg = (
"LLMToolEmulator's default model requires `langchain-anthropic` "
"to be installed. Install it with `pip install langchain-anthropic`, "
"or pass `model=...` explicitly to use a different provider."
)
raise ImportError(msg) from e
elif isinstance(model, BaseChatModel):
self.model = model
else:
@@ -28,7 +28,7 @@ from langgraph.channels.ephemeral_value import EphemeralValue
from langgraph.graph.message import add_messages
from langgraph.prebuilt.tool_node import ToolCallRequest, ToolCallWrapper
from langgraph.runtime import Runtime
from langgraph.types import Command
from langgraph.types import Command, TracePolicy, omit_payload
from langgraph.typing import ContextT
from typing_extensions import NotRequired, Required, TypedDict, TypeVar, Unpack
@@ -54,12 +54,14 @@ __all__ = [
"StateT_co",
"ToolCallRequest",
"ToolCallWrapper",
"TracePolicy",
"after_agent",
"after_model",
"before_agent",
"before_model",
"dynamic_prompt",
"hook_config",
"omit_payload",
"wrap_tool_call",
]
@@ -398,6 +400,16 @@ class AgentMiddleware(Generic[StateT, ContextT, ResponseT]):
tools: Sequence[BaseTool]
"""Additional tools registered by the middleware."""
trace_policy: TracePolicy | None = None
"""Optional trace policy for this middleware's hook spans (`wrap_model_call`/
`wrap_tool_call` and the `before_*`/`after_*` node hooks).
By default (`None`), hook spans are traced normally. Set a `TracePolicy` to shape
what they record -- e.g. `TracePolicy(process_inputs=omit_payload)` to drop the
conversation `messages`/`state` payload while keeping the span and its timing.
Messages are still captured on the inner model-call span.
"""
transformers: Sequence[TransformerFactory] = ()
"""Stream transformer factories registered by the middleware.
@@ -999,6 +1011,9 @@ def before_model(
!!! example "With custom state schema"
Use a custom state schema when your middleware needs to read or write additional
state fields that aren't part of the default agent state.
```python
@before_model(state_schema=MyCustomState)
def custom_before_model(state: MyCustomState, runtime: Runtime) -> dict[str, Any]:
@@ -1173,6 +1188,9 @@ def after_model(
!!! example "With custom state schema"
Use a custom state schema when your middleware needs to read or write additional
state fields that aren't part of the default agent state.
```python
@after_model(state_schema=MyCustomState, name="MyAfterModelMiddleware")
def custom_after_model(state: MyCustomState, runtime: Runtime) -> dict[str, Any]:
@@ -1359,6 +1377,9 @@ def before_agent(
!!! example "With custom state schema"
Use a custom state schema when your middleware needs to read or write additional
state fields that aren't part of the default agent state.
```python
@before_agent(state_schema=MyCustomState)
def custom_before_agent(state: MyCustomState, runtime: Runtime) -> dict[str, Any]:
@@ -1558,6 +1579,9 @@ def after_agent(
!!! example "With custom state schema"
Use a custom state schema when your middleware needs to read or write additional
state fields that aren't part of the default agent state.
```python
@after_agent(state_schema=MyCustomState, name="MyAfterAgentMiddleware")
def custom_after_agent(state: MyCustomState, runtime: Runtime) -> dict[str, Any]:
@@ -1994,32 +2018,34 @@ def wrap_model_call(
@overload
def wrap_tool_call(
func: _CallableReturningToolResponse,
) -> AgentMiddleware: ...
) -> AgentMiddleware[StateT, ContextT]: ...
@overload
def wrap_tool_call(
func: None = None,
*,
state_schema: type[StateT] | None = None,
tools: list[BaseTool] | None = None,
name: str | None = None,
) -> Callable[
[_CallableReturningToolResponse],
AgentMiddleware,
AgentMiddleware[StateT, ContextT],
]: ...
def wrap_tool_call(
func: _CallableReturningToolResponse | None = None,
*,
state_schema: type[StateT] | None = None,
tools: list[BaseTool] | None = None,
name: str | None = None,
) -> (
Callable[
[_CallableReturningToolResponse],
AgentMiddleware,
AgentMiddleware[StateT, ContextT],
]
| AgentMiddleware
| AgentMiddleware[StateT, ContextT]
):
"""Create middleware with `wrap_tool_call` hook from a function.
@@ -2034,6 +2060,9 @@ def wrap_tool_call(
`Command`.
Can be sync or async.
state_schema: Optional custom state schema type.
If not provided, uses the default `AgentState` schema.
tools: Additional tools to register with this middleware.
name: Middleware class name.
@@ -2097,17 +2126,28 @@ def wrap_tool_call(
save_cache(request, result)
return result
```
!!! example "With custom state schema"
Use a custom state schema when your middleware needs to read or write additional
state fields that aren't part of the default agent state.
```python
@wrap_tool_call(state_schema=MyCustomState)
def custom_wrap_tool_call(request, handler):
return handler(request)
```
"""
def decorator(
func: _CallableReturningToolResponse,
) -> AgentMiddleware:
) -> AgentMiddleware[StateT, ContextT]:
is_async = iscoroutinefunction(func)
if is_async:
async def async_wrapped(
_self: AgentMiddleware,
_self: AgentMiddleware[StateT, ContextT],
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], Awaitable[ToolMessage | Command[Any]]],
) -> ToolMessage | Command[Any]:
@@ -2120,12 +2160,12 @@ def wrap_tool_call(
# `type(...)` builds the correct middleware subclass at runtime, but
# type checkers cannot infer its generic `AgentMiddleware` parameters.
return cast(
"AgentMiddleware",
"AgentMiddleware[StateT, ContextT]",
type(
middleware_name,
(AgentMiddleware,),
{
"state_schema": AgentState,
"state_schema": state_schema or AgentState,
"tools": tools or [],
"awrap_tool_call": async_wrapped,
},
@@ -2133,7 +2173,7 @@ def wrap_tool_call(
)
def wrapped(
_self: AgentMiddleware,
_self: AgentMiddleware[StateT, ContextT],
request: ToolCallRequest,
handler: Callable[[ToolCallRequest], ToolMessage | Command[Any]],
) -> ToolMessage | Command[Any]:
@@ -2144,12 +2184,12 @@ def wrap_tool_call(
# `type(...)` builds the correct middleware subclass at runtime, but
# type checkers cannot infer its generic `AgentMiddleware` parameters.
return cast(
"AgentMiddleware",
"AgentMiddleware[StateT, ContextT]",
type(
middleware_name,
(AgentMiddleware,),
{
"state_schema": AgentState,
"state_schema": state_schema or AgentState,
"tools": tools or [],
"wrap_tool_call": wrapped,
},
+3 -3
View File
@@ -21,11 +21,11 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules",
]
version = "1.3.14"
version = "1.3.15"
requires-python = ">=3.10.0,<4.0.0"
dependencies = [
"langchain-core>=1.5.3,<2.0.0",
"langgraph>=1.2.5,<1.3.0",
"langchain-core>=1.5.4,<2.0.0",
"langgraph>=1.2.11,<1.3.0",
"pydantic>=2.7.4,<3.0.0",
]
@@ -1,5 +1,11 @@
"""Tests for `InternalCallTransformer` filtering middleware-internal model calls."""
# `run.tool_calls`/`run.subagents` are stream projections registered dynamically by
# `create_agent` (via langgraph-prebuilt's `ToolCallTransformer` and langchain's
# `SubagentTransformer`), not declared on langgraph's typed `GraphRunStream`
# (langgraph#8389). The `# type: ignore[attr-defined]` below self-remove once
# langgraph adds a `__getattr__` fallback (strict mode's `warn_unused_ignores`).
from __future__ import annotations
from typing import TYPE_CHECKING, Any
@@ -97,7 +103,7 @@ def test_internal_call_transformer_not_registered_without_offending_middleware()
assert not any(isinstance(t, InternalCallTransformer) for t in transformers)
# Drain to close cleanly.
list(run.tool_calls)
list(run.tool_calls) # type: ignore[attr-defined]
def test_internal_call_transformer_registered_before_messages_transformer() -> None:
@@ -125,7 +131,7 @@ def test_internal_call_transformer_registered_before_messages_transformer() -> N
)
# Drain to close cleanly.
list(run.tool_calls)
list(run.tool_calls) # type: ignore[attr-defined]
def test_internal_call_transformer_deduped_across_middleware() -> None:
@@ -147,7 +153,7 @@ def test_internal_call_transformer_deduped_across_middleware() -> None:
assert sum(isinstance(t, InternalCallTransformer) for t in transformers) == 1
# Drain to close cleanly.
list(run.tool_calls)
list(run.tool_calls) # type: ignore[attr-defined]
def test_internal_call_transformer_deduped_alongside_builtins() -> None:
@@ -170,7 +176,7 @@ def test_internal_call_transformer_deduped_alongside_builtins() -> None:
assert sum(isinstance(t, InternalCallTransformer) for t in transformers) == 1
# Drain to close cleanly.
list(run.tool_calls)
list(run.tool_calls) # type: ignore[attr-defined]
def test_internal_call_transformer_dedup_accepts_unhashable_factories() -> None:
@@ -193,7 +199,7 @@ def test_internal_call_transformer_dedup_accepts_unhashable_factories() -> None:
)
run = agent.stream_events({"messages": [HumanMessage("hi")]}, version="v3")
list(run.tool_calls)
list(run.tool_calls) # type: ignore[attr-defined]
def test_internal_model_calls_excluded_from_messages_projection_sync() -> None:
@@ -0,0 +1,159 @@
"""End-to-end tests for `AgentMiddleware.trace_policy` and the global default."""
from collections.abc import Iterator
from typing import Any
import pytest
from langchain_core.messages import HumanMessage
from langchain_core.tracers import BaseTracer, Run
from langgraph.runtime import Runtime
from typing_extensions import override
from langchain.agents.factory import _wrap_trace_kwargs, create_agent
from langchain.agents.middleware import (
AgentMiddleware,
TracePolicy,
configure_trace_policy,
omit_payload,
)
from langchain.agents.middleware.types import AgentState
from tests.unit_tests.agents.model import FakeToolCallingModel
@pytest.fixture(autouse=True)
def _reset_global_trace_policy() -> Iterator[None]:
"""Keep the process-global default from leaking across tests."""
configure_trace_policy(None)
yield
configure_trace_policy(None)
class _CaptureInputs(BaseTracer):
"""Tracer that records the inputs each run reports, keyed by run name.
A real tracer (not a plain callback) so `TracePolicy` processors, which run only
when a tracer is attached, actually fire.
"""
def __init__(self) -> None:
super().__init__()
self.inputs_by_name: dict[str, Any] = {}
def _persist_run(self, run: Run) -> None:
pass
def _on_chain_start(self, run: Run) -> None:
self.inputs_by_name.setdefault(run.name, run.inputs)
def _recorded_before_model_inputs(middleware: AgentMiddleware) -> Any:
"""Run the agent and return what the middleware's before_model node recorded."""
agent = create_agent(model=FakeToolCallingModel(), middleware=[middleware])
capture = _CaptureInputs()
agent.invoke({"messages": [HumanMessage("hi")]}, {"callbacks": [capture]})
return capture.inputs_by_name[f"{middleware.name}.before_model"]
class _NoopBeforeModel(AgentMiddleware):
"""Middleware with a before_model hook and no `trace_policy`."""
@override
def before_model(self, state: AgentState[Any], runtime: Runtime) -> None:
return None
def test_tracing_records_hook_inputs_by_default() -> None:
# no `trace_policy`, no global -> hooks trace normally; real state is recorded
recorded = _recorded_before_model_inputs(_NoopBeforeModel())
assert "messages" in recorded
assert [m.content for m in recorded["messages"]] == ["hi"]
def test_tracing_omits_hook_inputs_with_omit_payload() -> None:
class Scrubbed(AgentMiddleware):
trace_policy = TracePolicy(process_inputs=omit_payload)
@override
def before_model(self, state: AgentState[Any], runtime: Runtime) -> None:
return None
# opt in via omit_payload -> the before_model node records an empty payload
assert _recorded_before_model_inputs(Scrubbed()) == {}
def test_global_default_applies_when_middleware_unset() -> None:
configure_trace_policy(TracePolicy(process_inputs=omit_payload))
# middleware leaves trace_policy=None -> inherits the global default
assert _recorded_before_model_inputs(_NoopBeforeModel()) == {}
def test_middleware_overrides_global_no_merge() -> None:
configure_trace_policy(TracePolicy(process_inputs=omit_payload))
class Override(AgentMiddleware):
# sets only process_outputs; per override-not-merge this replaces the global
# wholesale, so the global's input scrub does NOT apply -> inputs recorded raw
trace_policy = TracePolicy(process_outputs=omit_payload)
@override
def before_model(self, state: AgentState[Any], runtime: Runtime) -> None:
return None
recorded = _recorded_before_model_inputs(Override())
assert "messages" in recorded
def test_configure_after_create_agent_applies() -> None:
mw = _NoopBeforeModel()
agent = create_agent(model=FakeToolCallingModel(), middleware=[mw])
# configure AFTER the graph is built; the resolver reads the global at trace time
configure_trace_policy(TracePolicy(process_inputs=omit_payload))
capture = _CaptureInputs()
agent.invoke({"messages": [HumanMessage("hi")]}, {"callbacks": [capture]})
assert capture.inputs_by_name[f"{mw.name}.before_model"] == {}
def test_configure_none_clears_global() -> None:
configure_trace_policy(TracePolicy(process_inputs=omit_payload))
configure_trace_policy(None)
recorded = _recorded_before_model_inputs(_NoopBeforeModel())
assert "messages" in recorded
def test_wrap_trace_kwargs_composes_scrub_baseline() -> None:
seen: dict[str, Any] = {}
def process_inputs(inp: Any) -> Any:
seen["inputs"] = inp
return inp
class MW(AgentMiddleware):
trace_policy = TracePolicy(process_inputs=process_inputs)
process = _wrap_trace_kwargs(MW())["process_inputs"]
result = process({"request": {"x": 1}, "handler": lambda: None})
# the baseline (`_scrub_inputs`) strips `handler` before the policy callable runs
assert "handler" not in seen["inputs"]
assert seen["inputs"] == {"request": {"x": 1}}
assert result == {"request": {"x": 1}}
def test_wrap_trace_kwargs_omit_drops_everything() -> None:
class MW(AgentMiddleware):
trace_policy = TracePolicy(process_inputs=omit_payload)
process = _wrap_trace_kwargs(MW())["process_inputs"]
assert process({"request": {"x": 1}, "handler": lambda: None}) == {}
def test_wrap_trace_kwargs_inherits_global() -> None:
configure_trace_policy(TracePolicy(process_inputs=omit_payload))
class MW(AgentMiddleware):
pass
# no middleware policy -> wrap hook inherits the global, still after the baseline
process = _wrap_trace_kwargs(MW())["process_inputs"]
assert process({"request": {"x": 1}, "handler": lambda: None}) == {}
@@ -1,5 +1,11 @@
"""Tests for middleware-registered stream transformers."""
# `run.tool_calls`/`run.subagents` are stream projections registered dynamically by
# `create_agent` (via langgraph-prebuilt's `ToolCallTransformer` and langchain's
# `SubagentTransformer`), not declared on langgraph's typed `GraphRunStream`
# (langgraph#8389). The `# type: ignore[attr-defined]` below self-remove once
# langgraph adds a `__getattr__` fallback (strict mode's `warn_unused_ignores`).
from __future__ import annotations
from typing import TYPE_CHECKING, Any
@@ -62,7 +68,7 @@ def test_middleware_transformer_registered_on_compiled_graph() -> None:
assert "middleware_marker" in run._mux.extensions
# Drain to close the run cleanly.
list(run.tool_calls)
list(run.tool_calls) # type: ignore[attr-defined]
def test_middleware_and_user_transformers_compose_in_order() -> None:
@@ -91,7 +97,7 @@ def test_middleware_and_user_transformers_compose_in_order() -> None:
"transformers must register as: built-in, then middleware, then user-supplied"
)
list(run.tool_calls)
list(run.tool_calls) # type: ignore[attr-defined]
def test_transformers_from_multiple_middleware_preserve_middleware_order() -> None:
@@ -124,7 +130,7 @@ def test_transformers_from_multiple_middleware_preserve_middleware_order() -> No
idx_b = next(i for i, t in enumerate(transformers) if isinstance(t, _MarkerB))
assert idx_a < idx_b
list(run.tool_calls)
list(run.tool_calls) # type: ignore[attr-defined]
def test_middleware_without_transformers_does_not_affect_registry() -> None:
@@ -140,4 +146,4 @@ def test_middleware_without_transformers_does_not_affect_registry() -> None:
assert any(isinstance(t, ToolCallTransformer) for t in transformers)
assert not any(isinstance(t, _MiddlewareMarker) for t in transformers)
list(run.tool_calls)
list(run.tool_calls) # type: ignore[attr-defined]
@@ -6,7 +6,7 @@ focusing on the handler pattern (not generators).
import time
from collections.abc import Callable
from typing import Any
from typing import Any, TypedDict
from langchain_core.messages import HumanMessage, ToolCall, ToolMessage
from langchain_core.tools import BaseTool, tool
@@ -14,7 +14,7 @@ from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
from langchain.agents.factory import create_agent
from langchain.agents.middleware.types import ToolCallRequest, wrap_tool_call
from langchain.agents.middleware.types import AgentMiddleware, ToolCallRequest, wrap_tool_call
from tests.unit_tests.agents.model import FakeToolCallingModel
@@ -74,6 +74,27 @@ def test_wrap_tool_call_basic_passthrough() -> None:
assert "Results for: test" in tool_messages[0].content
def test_wrap_tool_call_with_custom_state_schema() -> None:
"""Test `state_schema` is accepted for consistency with other middleware decorators.
`before_model`, `after_model`, `wrap_model_call`, `before_agent`, and
`after_agent` all support a `state_schema` parameter.
"""
class CustomState(TypedDict):
messages: list[Any]
custom_field: str
@wrap_tool_call(state_schema=CustomState) # type: ignore[type-var]
def middleware_with_schema(
request: ToolCallRequest, handler: Callable[[ToolCallRequest], ToolMessage | Command[Any]]
) -> ToolMessage | Command[Any]:
return handler(request)
assert isinstance(middleware_with_schema, AgentMiddleware)
assert middleware_with_schema.state_schema == CustomState
def test_wrap_tool_call_logging() -> None:
"""Test logging tool call execution with wrap_tool_call decorator."""
call_log = []
@@ -1,5 +1,11 @@
"""Tests for PII detection middleware."""
# `run.tool_calls`/`run.subagents` are stream projections registered dynamically by
# `create_agent` (via langgraph-prebuilt's `ToolCallTransformer` and langchain's
# `SubagentTransformer`), not declared on langgraph's typed `GraphRunStream`
# (langgraph#8389). The `# type: ignore[attr-defined]` below self-remove once
# langgraph adds a `__getattr__` fallback (strict mode's `warn_unused_ignores`).
import re
from typing import Any
@@ -1973,7 +1979,7 @@ class TestPIIStreamTransformer:
)
# Drain to close cleanly.
list(run.tool_calls)
list(run.tool_calls) # type: ignore[attr-defined]
class TestPIIStreamingEndToEnd:
@@ -2113,8 +2119,6 @@ class TestPIIStreamingEndToEnd:
surfaces: list[str] = []
run = await agent.astream_events({"messages": [HumanMessage("hi")]}, version="v3")
async for event in run:
if not isinstance(event, dict):
continue
data = event.get("params", {}).get("data")
if isinstance(data, tuple) and len(data) == 2:
p = data[0]
@@ -2187,8 +2191,6 @@ class TestPIIStreamingEndToEnd:
surfaces: list[str] = []
run = await agent.astream_events({"messages": [HumanMessage("hi")]}, version="v3")
async for event in run:
if not isinstance(event, dict):
continue
data = event.get("params", {}).get("data")
if isinstance(data, tuple) and len(data) == 2:
p = data[0]
@@ -24,6 +24,7 @@ from langchain_core.messages import (
from langchain_core.messages.utils import count_tokens_approximately, get_buffer_string
from langchain_core.outputs import ChatGeneration, ChatResult
from langchain_core.runnables import RunnableConfig
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph.message import REMOVE_ALL_MESSAGES
from langgraph.runtime import Runtime
from pydantic import Field
@@ -246,8 +247,11 @@ def test_summarization_middleware_summary_creation() -> None:
return "mock"
middleware_error = SummarizationMiddleware(model=ErrorModel(), trigger=("tokens", 1000))
summary = middleware_error._create_summary(messages)
assert "Error generating summary: Model error" in summary
# Bypass the retry wrapper so this test isn't slowed by real backoff delay; retry
# behavior itself is covered by test_summarization_middleware_retries_transient_failure_in_call.
middleware_error._summary_model = middleware_error.model
with pytest.raises(ValueError, match="Model error"):
middleware_error._create_summary(messages)
# Test we raise warning if max_tokens_before_summary or messages_to_keep is specified
with pytest.warns(DeprecationWarning, match="max_tokens_before_summary is deprecated"):
@@ -256,6 +260,226 @@ def test_summarization_middleware_summary_creation() -> None:
SummarizationMiddleware(model=MockChatModel(), messages_to_keep=5)
def test_summarization_middleware_retries_transient_failure_in_call() -> None:
"""A transient failure must be retried in-process via `Runnable.with_retry`.
A single `_create_summary` call must succeed if the underlying model recovers
within 3 attempts, without the caller ever observing a failure.
"""
class FlakyModel(BaseChatModel):
"""Model that fails a fixed number of times, then succeeds."""
fail_count: int = Field(default=0)
attempts: int = Field(default=0)
@override
def _generate(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: CallbackManagerForLLMRun | None = None,
**kwargs: Any,
) -> ChatResult:
self.attempts += 1
if self.attempts <= self.fail_count:
msg = "429 Too Many Requests (simulated transient failure)"
raise RuntimeError(msg)
return ChatResult(generations=[ChatGeneration(message=AIMessage(content="Summary."))])
@property
def _llm_type(self) -> str:
return "mock"
model = FlakyModel(fail_count=2)
middleware = SummarizationMiddleware(model=model, trigger=("messages", 2))
# No delay between attempts so the test runs fast and deterministically; the
# retry count itself (3, `Runnable.with_retry`'s own default) is untouched.
middleware._summary_model.wait_exponential_jitter = False # type: ignore[attr-defined]
summary = middleware._create_summary([HumanMessage(content="hi")])
assert summary == "Summary."
assert model.attempts == 3
class _AlwaysFailingModel(BaseChatModel):
"""Chat model whose sync and async summary calls always raise."""
@override
def _generate(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: CallbackManagerForLLMRun | None = None,
**kwargs: Any,
) -> ChatResult:
msg = "429 Too Many Requests"
raise RuntimeError(msg)
@override
async def _agenerate(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: AsyncCallbackManagerForLLMRun | None = None,
**kwargs: Any,
) -> ChatResult:
msg = "429 Too Many Requests"
raise RuntimeError(msg)
@property
def _llm_type(self) -> str:
return "mock"
def _skip_retry(middleware: SummarizationMiddleware) -> None:
"""Bypass the retry wrapper so failure-path tests aren't slowed by real backoff.
Retry behavior itself is covered by
`test_summarization_middleware_retries_transient_failure_in_call`.
"""
middleware._summary_model = middleware.model
def test_create_summary_raises_on_failure() -> None:
"""`_create_summary` must raise, never fabricate a fake summary string."""
middleware = SummarizationMiddleware(model=_AlwaysFailingModel())
_skip_retry(middleware)
with pytest.raises(RuntimeError, match="429 Too Many Requests"):
middleware._create_summary([HumanMessage(content="hi")])
async def test_acreate_summary_raises_on_failure() -> None:
"""Async: `_acreate_summary` must raise on failure."""
middleware = SummarizationMiddleware(model=_AlwaysFailingModel())
_skip_retry(middleware)
with pytest.raises(RuntimeError, match="429 Too Many Requests"):
await middleware._acreate_summary([HumanMessage(content="hi")])
def test_summarization_middleware_before_model_raises_on_summary_failure() -> None:
"""A failed summary generation must raise, never fabricate a summary or delete history."""
middleware = SummarizationMiddleware(
model=_AlwaysFailingModel(), trigger=("messages", 2), keep=("messages", 1)
)
_skip_retry(middleware)
messages: list[AnyMessage] = [
HumanMessage(content="hi", id="h0"),
AIMessage(content="hello", id="a0"),
HumanMessage(content="how are you", id="h1"),
]
state = AgentState[Any](messages=messages)
with pytest.raises(RuntimeError, match="429 Too Many Requests"):
middleware.before_model(state, Runtime())
# The original message list is untouched - no fabricated summary, no deletion.
assert state["messages"] == messages
async def test_summarization_middleware_abefore_model_raises_on_summary_failure() -> None:
"""Async: same raise-on-failure behavior as `before_model`."""
middleware = SummarizationMiddleware(
model=_AlwaysFailingModel(), trigger=("messages", 2), keep=("messages", 1)
)
_skip_retry(middleware)
messages: list[AnyMessage] = [
HumanMessage(content="hi", id="h0"),
AIMessage(content="hello", id="a0"),
HumanMessage(content="how are you", id="h1"),
]
state = AgentState[Any](messages=messages)
with pytest.raises(RuntimeError, match="429 Too Many Requests"):
await middleware.abefore_model(state, Runtime())
assert state["messages"] == messages
def test_summarization_middleware_e2e_failure_raises_without_corrupting_history() -> None:
"""End-to-end regression test.
Verifies that exhausted summary retries raise an error without corrupting
checkpointed history, and that summarization succeeds on a later invocation
once the model recovers.
"""
class TemporarilyFailingSummaryModel(BaseChatModel):
"""Summary-only model that fails until `working` is set to `True`."""
working: bool = Field(default=False)
@override
def _generate(
self,
messages: list[BaseMessage],
stop: list[str] | None = None,
run_manager: CallbackManagerForLLMRun | None = None,
**kwargs: Any,
) -> ChatResult:
if not self.working:
msg = "429 Too Many Requests (simulated persistent failure)"
raise RuntimeError(msg)
summary = AIMessage(content="Summary of the conversation so far.")
return ChatResult(generations=[ChatGeneration(message=summary)])
@property
def _llm_type(self) -> str:
return "mock"
main_model = FakeToolCallingModel()
summary_model = TemporarilyFailingSummaryModel()
middleware = SummarizationMiddleware(
model=summary_model, trigger=("messages", 6), keep=("messages", 2)
)
_skip_retry(middleware)
agent = create_agent(
model=main_model,
tools=[],
middleware=[middleware],
checkpointer=InMemorySaver(),
)
config: RunnableConfig = {"configurable": {"thread_id": "e2e-summary-failure"}}
# Build up history below the ("messages", 6) trigger threshold.
for i in range(3):
agent.invoke({"messages": [HumanMessage(f"turn {i}")]}, config)
messages_below_threshold = agent.get_state(config).values["messages"]
assert len(messages_below_threshold) == 6
# This turn triggers summarization, which fails and raises. The new human
# message is checkpointed, but no AI response, summary, or message removal occurs.
with pytest.raises(RuntimeError, match="429 Too Many Requests"):
agent.invoke({"messages": [HumanMessage("turn 3")]}, config)
messages_after_failed_summary = agent.get_state(config).values["messages"]
assert messages_after_failed_summary[:-1] == messages_below_threshold
assert messages_after_failed_summary[-1].content == "turn 3"
assert not any(
"Error generating summary" in (m.content or "") for m in messages_after_failed_summary
)
# Once the summarizer is fixed, retrying the same turn must succeed and
# actually summarize, proving the earlier failure didn't corrupt anything.
summary_model.working = True
agent.invoke({"messages": [HumanMessage("turn 3")]}, config)
messages_after_recovered_summary = agent.get_state(config).values["messages"]
assert len(messages_after_recovered_summary) < len(messages_after_failed_summary)
assert any(
"summary of the conversation" in (m.content or "").lower()
for m in messages_after_recovered_summary
)
assert not any(m.content == "turn 0" for m in messages_after_recovered_summary), (
"history should actually be trimmed once summarization succeeds"
)
def test_summarization_middleware_trim_limit_none_keeps_all_messages() -> None:
"""Verify disabling trim limit preserves full message sequence."""
messages: list[AnyMessage] = [HumanMessage(content=str(i)) for i in range(10)]
@@ -941,9 +1165,10 @@ async def test_summarization_middleware_async_error_handling() -> None:
return "mock"
middleware = SummarizationMiddleware(model=ErrorAsyncModel(), trigger=("messages", 5))
_skip_retry(middleware)
messages: list[AnyMessage] = [HumanMessage(content="test")]
summary = await middleware._acreate_summary(messages)
assert "Error generating summary: Async model error" in summary
with pytest.raises(ValueError, match="Async model error"):
await middleware._acreate_summary(messages)
def test_summarization_middleware_cutoff_at_boundary() -> None:
@@ -4,6 +4,7 @@ from collections.abc import Callable, Sequence
from itertools import cycle
from typing import Any, Literal
import pytest
from langchain_core.language_models import LanguageModelInput
from langchain_core.language_models.chat_models import BaseChatModel
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
@@ -12,6 +13,7 @@ from langchain_core.outputs import ChatGeneration, ChatResult
from langchain_core.runnables import Runnable, RunnableConfig
from langchain_core.tools import BaseTool, tool
from pydantic import BaseModel, Field
from pytest_mock import MockerFixture
from typing_extensions import override
from langchain.agents import create_agent
@@ -460,17 +462,38 @@ class TestLLMToolEmulatorModelConfiguration:
# Should use the custom model for emulation
assert isinstance(result["messages"][-1], AIMessage)
def test_default_model_used_when_none(self) -> None:
"""Test that default model is used when model=None."""
# Just test that initialization doesn't fail - don't require anthropic package
# The actual default model requires langchain_anthropic which may not be installed
try:
emulator = LLMToolEmulator(tools=["get_weather"], model=None)
assert emulator.model is not None
except ImportError:
# If anthropic isn't installed, that's fine for this unit test
# The integration tests will verify the full functionality
pass
def test_default_model_deprecated_and_missing_langchain_anthropic_raises_clear_error(
self,
) -> None:
"""Test the `model=None` default path without `langchain-anthropic` installed.
Regression test: omitting `model` used to either silently depend on
`langchain-anthropic` or (in an earlier draft of this fix) raise a
`TypeError` for a previously-supported call shape. It should instead
keep working when a model provider is available, and raise an
actionable `ImportError` (plus a `DeprecationWarning`) when it isn't.
"""
with (
pytest.warns(DeprecationWarning, match="deprecated"),
pytest.raises(ImportError, match="langchain-anthropic"),
):
LLMToolEmulator(tools=["get_weather"])
def test_default_model_used_when_none(self, mocker: MockerFixture) -> None:
"""Test that the default model is used and a deprecation warning is raised."""
fake_model = FakeEmulatorModel(responses=["response"])
init_chat_model_mock = mocker.patch(
"langchain.agents.middleware.tool_emulator.init_chat_model",
return_value=fake_model,
)
with pytest.warns(DeprecationWarning, match="deprecated"):
emulator = LLMToolEmulator(tools=["get_weather"])
assert emulator.model is fake_model
init_chat_model_mock.assert_called_once_with(
"anthropic:claude-sonnet-4-5-20250929", temperature=1
)
class TestLLMToolEmulatorAsync:
@@ -1,5 +1,11 @@
"""Unit tests for create_agent graphs streaming via `stream_events(version="v3")`."""
# `run.tool_calls`/`run.subagents` are stream projections registered dynamically by
# `create_agent` (via langgraph-prebuilt's `ToolCallTransformer` and langchain's
# `SubagentTransformer`), not declared on langgraph's typed `GraphRunStream`
# (langgraph#8389). The `# type: ignore[attr-defined]` below self-remove once
# langgraph adds a `__getattr__` fallback (strict mode's `warn_unused_ignores`).
from __future__ import annotations
from typing import TYPE_CHECKING, Any
@@ -65,7 +71,7 @@ class TestAgentStreamV3Sync:
run = agent.stream_events({"messages": [HumanMessage("hi")]}, version="v3")
# Drain so the run closes cleanly.
list(run.tool_calls)
list(run.tool_calls) # type: ignore[attr-defined]
def test_tool_calls_populated_without_opt_in(self) -> None:
"""`ToolCallTransformer` is registered by default on the agent streamer."""
@@ -74,7 +80,7 @@ class TestAgentStreamV3Sync:
run = agent.stream_events({"messages": [HumanMessage("hi")]}, version="v3")
collected: list[ToolCallStream] = list(run.tool_calls)
collected: list[ToolCallStream] = list(run.tool_calls) # type: ignore[attr-defined]
assert len(collected) == 1
tc = collected[0]
assert tc.tool_name == "echo"
@@ -89,7 +95,7 @@ class TestAgentStreamV3Sync:
run = agent.stream_events({"messages": [HumanMessage("hi")]}, version="v3")
tool_calls: list[ToolCallStream] = []
for tc in run.tool_calls:
for tc in run.tool_calls: # type: ignore[attr-defined]
tool_calls.append(tc)
assert list(tc.output_deltas) == ["one", "two"]
assert len(tool_calls) == 1
@@ -100,7 +106,7 @@ class TestAgentStreamV3Sync:
agent = create_agent(model, [])
run = agent.stream_events({"messages": [HumanMessage("hi")]}, version="v3")
assert list(run.tool_calls) == []
assert list(run.tool_calls) == [] # type: ignore[attr-defined]
assert run.output is not None
def test_messages_projection_present(self) -> None:
@@ -116,7 +122,7 @@ class TestAgentStreamV3Sync:
assert "messages" in run._mux.extensions
assert hasattr(run, "messages")
# Drain so the run closes cleanly.
for tc in run.tool_calls:
for tc in run.tool_calls: # type: ignore[attr-defined]
list(tc.output_deltas)
def test_caller_transformers_appended_not_replaced(self) -> None:
@@ -161,7 +167,7 @@ class TestAgentStreamV3Sync:
"ToolCallTransformer must be registered before user-supplied transformers"
)
list(run.tool_calls)
list(run.tool_calls) # type: ignore[attr-defined]
def test_tool_error_sets_error_field(self) -> None:
"""Tool errors are surfaced on the `ToolCallStream.error` field.
@@ -178,7 +184,7 @@ class TestAgentStreamV3Sync:
collected: list[ToolCallStream] = []
def _drive() -> None:
for tc in run.tool_calls:
for tc in run.tool_calls: # type: ignore[attr-defined]
collected.append(tc)
list(tc.output_deltas)
@@ -265,7 +271,7 @@ class TestAgentStreamV3Async:
agent = create_agent(model, [echo])
run = await agent.astream_events({"messages": [HumanMessage("hi")]}, version="v3")
async for tc in run.tool_calls:
async for tc in run.tool_calls: # type: ignore[attr-defined]
async for _ in tc.output_deltas:
pass
@@ -277,7 +283,7 @@ class TestAgentStreamV3Async:
run = await agent.astream_events({"messages": [HumanMessage("hi")]}, version="v3")
collected: list[ToolCallStream] = []
async for tc in run.tool_calls:
async for tc in run.tool_calls: # type: ignore[attr-defined]
collected.append(tc)
deltas = [d async for d in tc.output_deltas]
assert deltas == ["hi", "hi!"]
@@ -302,6 +302,10 @@ def test_last_schema_wins_for_conflicting_field() -> None:
hints = get_type_hints(resolved, include_extras=True)
assert get_args(hints["shared_field"])[1] == "last"
resolved, _, _ = factory._resolve_schemas([LastState, MiddleState, LastState])
hints = get_type_hints(resolved, include_extras=True)
assert get_args(hints["shared_field"])[1] == "last"
def test_get_schema_type_hints_cache_hits_for_reused_schema() -> None:
"""Test repeated schema resolution reuses cached type hints for the same schema."""
@@ -6,6 +6,12 @@ The transformer surfaces, on `run.subagents`, any nested run that carries an
dispatches a nested `create_agent` from a tool, giving true end-to-end coverage.
"""
# `run.tool_calls`/`run.subagents` are stream projections registered dynamically by
# `create_agent` (via langgraph-prebuilt's `ToolCallTransformer` and langchain's
# `SubagentTransformer`), not declared on langgraph's typed `GraphRunStream`
# (langgraph#8389). The `# type: ignore[attr-defined]` below self-remove once
# langgraph adds a `__getattr__` fallback (strict mode's `warn_unused_ignores`).
from __future__ import annotations
import sys
@@ -52,7 +58,7 @@ def test_subagents_surfaces_named_subagent() -> None:
run = supervisor.stream_events({"messages": [HumanMessage("weather?")]}, version="v3")
handles = []
for handle in run.subagents:
for handle in run.subagents: # type: ignore[attr-defined]
handles.append(handle)
# Drain the nested run so it completes.
for _ in handle:
@@ -101,7 +107,7 @@ async def test_subagents_surfaces_named_subagent_async() -> None:
run = await supervisor.astream_events({"messages": [HumanMessage("weather?")]}, version="v3")
handles = []
async for handle in run.subagents:
async for handle in run.subagents: # type: ignore[attr-defined]
handles.append(handle)
# Drain the nested run so it completes.
async for _ in handle:
@@ -130,7 +136,7 @@ def test_plain_tool_not_surfaced() -> None:
run = supervisor.stream_events({"messages": [HumanMessage("weather?")]}, version="v3")
handles = list(run.subagents)
handles = list(run.subagents) # type: ignore[attr-defined]
# Drain the main run to completion.
for _ in run:
pass
@@ -166,7 +172,7 @@ def test_unnamed_inner_agent_surfaces_with_inherited_name() -> None:
run = supervisor.stream_events({"messages": [HumanMessage("weather?")]}, version="v3")
handles = []
for handle in run.subagents:
for handle in run.subagents: # type: ignore[attr-defined]
handles.append(handle)
for _ in handle:
pass
@@ -204,7 +210,7 @@ def test_same_name_nested_agent_surfaced() -> None:
run = supervisor.stream_events({"messages": [HumanMessage("weather?")]}, version="v3")
handles = []
for handle in run.subagents:
for handle in run.subagents: # type: ignore[attr-defined]
handles.append(handle)
for _ in handle:
pass
+18 -18
View File
@@ -1044,7 +1044,7 @@ name = "exceptiongroup"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
wheels = [
@@ -1650,15 +1650,15 @@ wheels = [
[[package]]
name = "h2"
version = "4.3.0"
version = "4.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "hpack" },
{ name = "hyperframe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
{ url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" },
]
[[package]]
@@ -1692,11 +1692,11 @@ wheels = [
[[package]]
name = "hpack"
version = "4.1.0"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" }
sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" },
{ url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" },
]
[[package]]
@@ -1968,7 +1968,7 @@ wheels = [
[[package]]
name = "langchain"
version = "1.3.14"
version = "1.3.15"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -2084,7 +2084,7 @@ requires-dist = [
{ name = "langchain-perplexity", marker = "extra == 'perplexity'" },
{ name = "langchain-together", marker = "extra == 'together'" },
{ name = "langchain-xai", marker = "extra == 'xai'" },
{ name = "langgraph", specifier = ">=1.2.5,<1.3.0" },
{ name = "langgraph", specifier = ">=1.2.11,<1.3.0" },
{ name = "pydantic", specifier = ">=2.7.4,<3.0.0" },
]
provides-extras = ["community", "anthropic", "openai", "azure-ai", "google-vertexai", "google-genai", "fireworks", "ollama", "together", "mistralai", "huggingface", "groq", "aws", "baseten", "deepseek", "xai", "perplexity", "meta"]
@@ -2120,7 +2120,7 @@ typing = [
[[package]]
name = "langchain-anthropic"
version = "1.5.3"
version = "1.5.5"
source = { editable = "../partners/anthropic" }
dependencies = [
{ name = "anthropic" },
@@ -2260,7 +2260,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.5.3"
version = "1.5.4"
source = { editable = "../core" }
dependencies = [
{ name = "jsonpatch" },
@@ -2462,7 +2462,7 @@ wheels = [
[[package]]
name = "langchain-openai"
version = "1.4.1"
version = "1.4.3"
source = { editable = "../partners/openai" }
dependencies = [
{ name = "langchain-core" },
@@ -2665,7 +2665,7 @@ wheels = [
[[package]]
name = "langgraph"
version = "1.2.5"
version = "1.2.11"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "langchain-core" },
@@ -2675,22 +2675,22 @@ dependencies = [
{ name = "pydantic" },
{ name = "xxhash" },
]
sdist = { url = "https://files.pythonhosted.org/packages/77/9d/7c9ebd17b95569122e2d2e641f535cf086c870d66bb8e59be33cdba856b3/langgraph-1.2.5.tar.gz", hash = "sha256:09a3bdec6fdb3228623fc78b6f69a1400d383f66348d0b04d0efb692022cc6ef", size = 712532, upload-time = "2026-06-12T20:30:58.498Z" }
sdist = { url = "https://files.pythonhosted.org/packages/56/0d/c8e7ee98896659e1b6555db0ab115a9ca899844744645d5d894032bab1d7/langgraph-1.2.11.tar.gz", hash = "sha256:9ecfe11e50d338b34b15cf4d8a442642de103e8ae6971320efba84e4542eb363", size = 725753, upload-time = "2026-08-11T14:00:36.945Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/03/187281cf61845c5a9c397ae6cd9cd73bb54b39435e5575a7b83c853e5b76/langgraph-1.2.5-py3-none-any.whl", hash = "sha256:9286bb5def82fc865959c14378fe473518dc097d586225f622f029637a2a4bb9", size = 246150, upload-time = "2026-06-12T20:30:57.018Z" },
{ url = "https://files.pythonhosted.org/packages/0a/7f/c5c30e4be99ff821029c7ac872a480676bb179c9f3df85ea3f38d13f86d4/langgraph-1.2.11-py3-none-any.whl", hash = "sha256:8bab70de7b2d00b5300fb289bcf38d8b241400f3184c1e95e8ce706fb0e8686b", size = 248854, upload-time = "2026-08-11T14:00:35.494Z" },
]
[[package]]
name = "langgraph-checkpoint"
version = "4.1.1"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "langchain-core" },
{ name = "ormsgpack" },
]
sdist = { url = "https://files.pythonhosted.org/packages/83/47/886af6f886f0bff2273164a45f008694e48a96ff3cd25ff0228f2aa9480e/langgraph_checkpoint-4.1.1.tar.gz", hash = "sha256:6c2bdb530c91f91d7d9c1bd100925d0fc4f498d418c17f3587d1526279482a25", size = 184020, upload-time = "2026-05-22T16:57:38.503Z" }
sdist = { url = "https://files.pythonhosted.org/packages/dc/e1/089c4c9e0a2fec7f883f82ae8e6a727138d50074cfeb6644bc2d13b1019b/langgraph_checkpoint-4.2.0.tar.gz", hash = "sha256:51a593b6bee684b0818e5d6e58e28ab340c6db7794575056ce7bd1b746a84ed7", size = 180239, upload-time = "2026-08-07T20:05:03.756Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/b4/71425e3e38be92611300b9cc5e46a5bf98ab23f5ea8a75b73d02a2f1413c/langgraph_checkpoint-4.1.1-py3-none-any.whl", hash = "sha256:25d29144b082827218e7bc3f1e9b0566a4bb007895cd6cc26f66a8428739f56e", size = 56212, upload-time = "2026-05-22T16:57:37.203Z" },
{ url = "https://files.pythonhosted.org/packages/05/71/3b475f09bd57d3a5649792c66353312b4432afd843f301739dfcebd157f0/langgraph_checkpoint-4.2.0-py3-none-any.whl", hash = "sha256:0547fd228935a0b758865de3a3d6d7a2537c308895d0f9ab092ce9151b5da942", size = 56833, upload-time = "2026-08-07T20:05:02.655Z" },
]
[[package]]
@@ -1,3 +1,3 @@
"""Version information for `langchain-anthropic`."""
__version__ = "1.5.4"
__version__ = "1.5.5"
@@ -36,7 +36,11 @@ from langchain_core.messages import (
is_data_content_block,
)
from langchain_core.messages import content as types
from langchain_core.messages.ai import InputTokenDetails, UsageMetadata
from langchain_core.messages.ai import (
InputTokenDetails,
OutputTokenDetails,
UsageMetadata,
)
from langchain_core.messages.tool import tool_call_chunk as create_tool_call_chunk
from langchain_core.output_parsers import (
JsonOutputKeyToolsParser,
@@ -2718,7 +2722,18 @@ def _create_usage_metadata(anthropic_usage: BaseModel) -> UsageMetadata:
)
output_tokens = getattr(anthropic_usage, "output_tokens", 0) or 0
return UsageMetadata(
# Reasoning (thinking) tokens are a decomposition of `output_tokens` (not
# additive), reported by Anthropic via `output_tokens_details.thinking_tokens`.
# Older models omit `output_tokens_details` entirely, so guard with `getattr`.
output_token_details: dict = {
"reasoning": getattr(
getattr(anthropic_usage, "output_tokens_details", None),
"thinking_tokens",
None,
),
}
usage_metadata = UsageMetadata(
input_tokens=input_tokens,
output_tokens=output_tokens,
total_tokens=input_tokens + output_tokens,
@@ -2726,3 +2741,13 @@ def _create_usage_metadata(anthropic_usage: BaseModel) -> UsageMetadata:
**{k: v for k, v in input_token_details.items() if v is not None},
),
)
filtered_output_token_details = {
k: v for k, v in output_token_details.items() if v is not None
}
if filtered_output_token_details:
usage_metadata["output_token_details"] = OutputTokenDetails(
**filtered_output_token_details,
)
return usage_metadata
@@ -34,13 +34,21 @@ _PROFILES: dict[str, dict[str, Any]] = {
"video_outputs": False,
"reasoning_output": True,
"tool_calling": True,
"structured_output": False,
"structured_output": True,
"attachment": True,
"temperature": False,
"image_url_inputs": True,
"pdf_tool_message": True,
"image_tool_message": True,
"tool_call_streaming": True,
"reasoning_effort_levels": [
"low",
"medium",
"high",
"xhigh",
"max",
],
"reasoning_effort_default": "high",
},
"claude-haiku-4-5": {
"name": "Claude Haiku 4.5 (latest)",
@@ -94,60 +102,6 @@ _PROFILES: dict[str, dict[str, Any]] = {
"image_tool_message": True,
"tool_call_streaming": True,
},
"claude-opus-4-1": {
"name": "Claude Opus 4.1 (latest)",
"status": "deprecated",
"release_date": "2025-08-05",
"last_updated": "2025-08-05",
"open_weights": False,
"max_input_tokens": 200000,
"max_output_tokens": 32000,
"text_inputs": True,
"image_inputs": True,
"audio_inputs": False,
"pdf_inputs": True,
"video_inputs": False,
"text_outputs": True,
"image_outputs": False,
"audio_outputs": False,
"video_outputs": False,
"reasoning_output": True,
"tool_calling": True,
"structured_output": True,
"attachment": True,
"temperature": True,
"image_url_inputs": True,
"pdf_tool_message": True,
"image_tool_message": True,
"tool_call_streaming": True,
},
"claude-opus-4-1-20250805": {
"name": "Claude Opus 4.1",
"status": "deprecated",
"release_date": "2025-08-05",
"last_updated": "2025-08-05",
"open_weights": False,
"max_input_tokens": 200000,
"max_output_tokens": 32000,
"text_inputs": True,
"image_inputs": True,
"audio_inputs": False,
"pdf_inputs": True,
"video_inputs": False,
"text_outputs": True,
"image_outputs": False,
"audio_outputs": False,
"video_outputs": False,
"reasoning_output": True,
"tool_calling": True,
"structured_output": False,
"attachment": True,
"temperature": True,
"image_url_inputs": True,
"pdf_tool_message": True,
"image_tool_message": True,
"tool_call_streaming": True,
},
"claude-opus-4-5": {
"name": "Claude Opus 4.5 (latest)",
"release_date": "2025-11-24",
@@ -450,7 +404,7 @@ _PROFILES: dict[str, dict[str, Any]] = {
"video_outputs": False,
"reasoning_output": True,
"tool_calling": True,
"structured_output": False,
"structured_output": True,
"attachment": True,
"temperature": False,
"image_url_inputs": True,
@@ -19,9 +19,6 @@ structured_output = true
reasoning_effort_levels = ["low", "medium", "high", "max"]
reasoning_effort_default = "high"
[overrides."claude-opus-4-1"]
structured_output = true
[overrides."claude-opus-4-5"]
structured_output = true
reasoning_effort_levels = ["low", "medium", "high"]
@@ -52,5 +49,11 @@ reasoning_effort_levels = ["low", "medium", "high", "xhigh", "max"]
reasoning_effort_default = "high"
[overrides."claude-sonnet-5"]
structured_output = true
reasoning_effort_levels = ["low", "medium", "high", "xhigh", "max"]
reasoning_effort_default = "high"
[overrides."claude-fable-5"]
structured_output = true
reasoning_effort_levels = ["low", "medium", "high", "xhigh", "max"]
reasoning_effort_default = "high"
@@ -240,7 +240,9 @@ class _StateClaudeFileToolMiddleware(AgentMiddleware):
Command for state update or string result.
"""
# Build args dict for handler methods
args: dict[str, Any] = {"path": path}
# `old_path` is populated for `_handle_rename`, which reads the source
# path under that key instead of `path`.
args: dict[str, Any] = {"path": path, "old_path": path}
if file_text is not None:
args["file_text"] = file_text
if old_str is not None:
@@ -742,7 +744,9 @@ class _FilesystemClaudeFileToolMiddleware(AgentMiddleware):
Command for message update or string result.
"""
# Build args dict for handler methods
args: dict[str, Any] = {"path": path}
# `old_path` is populated for `_handle_rename`, which reads the source
# path under that key instead of `path`.
args: dict[str, Any] = {"path": path, "old_path": path}
if file_text is not None:
args["file_text"] = file_text
if old_str is not None:
+2 -2
View File
@@ -20,11 +20,11 @@ classifiers = [
"Topic :: Scientific/Engineering :: Artificial Intelligence",
]
version = "1.5.4"
version = "1.5.5"
requires-python = ">=3.10.0,<4.0.0"
dependencies = [
"anthropic>=0.120.0,<1.0.0",
"langchain-core>=1.5.2,<2.0.0",
"langchain-core>=1.5.4,<2.0.0",
"pydantic>=2.7.4,<3.0.0",
]
@@ -1,13 +1,17 @@
"""Unit tests for Anthropic text editor and memory tool middleware."""
import tempfile
from pathlib import Path
from unittest.mock import MagicMock
import pytest
from langchain_core.messages import SystemMessage, ToolMessage
from langgraph.prebuilt import ToolRuntime
from langgraph.types import Command
from langchain_anthropic.middleware.anthropic_tools import (
AnthropicToolsState,
FilesystemClaudeTextEditorMiddleware,
StateClaudeMemoryMiddleware,
StateClaudeTextEditorMiddleware,
_validate_path,
@@ -309,6 +313,85 @@ class TestFileOperations:
assert files.get("/memories/new.txt") is not None
assert files["/memories/new.txt"]["content"] == ["line1"]
def test_rename_via_tool_dispatch(self) -> None:
"""End-to-end: renaming through the actual `file_tool` dispatch.
Regression test for the dispatch building `args` with the source path
under `"path"` while `_handle_rename` read `args["old_path"]`, which
raised `KeyError` on every rename command.
"""
middleware = StateClaudeTextEditorMiddleware()
state: AnthropicToolsState = {
"messages": [],
"text_editor_files": {
"/notes/old.txt": {
"content": ["hello"],
"created_at": "2025-01-01T00:00:00",
"modified_at": "2025-01-01T00:00:00",
}
},
}
(file_tool,) = middleware.tools
result = file_tool.invoke(
{
"command": "rename",
"path": "/notes/old.txt",
"new_path": "/notes/new.txt",
"runtime": ToolRuntime(
context=None,
state=state,
config={},
stream_writer=lambda _: None,
tool_call_id="tc-1",
store=None,
),
}
)
assert isinstance(result, Command)
assert result.update is not None
files = result.update["text_editor_files"]
assert files["/notes/old.txt"] is None
assert files["/notes/new.txt"]["content"] == ["hello"]
message = result.update["messages"][0]
assert isinstance(message, ToolMessage)
assert "renamed" in message.content
class TestFilesystemRenameViaToolDispatch:
"""End-to-end tests for filesystem-backed rename through `file_tool`."""
def test_rename_via_tool_dispatch(self) -> None:
"""Regression test mirroring `TestFileOperations.test_rename_via_tool_dispatch`
for the filesystem-backed middleware, which has its own dispatch closure
and `_handle_rename` implementation.
"""
with tempfile.TemporaryDirectory() as root:
(Path(root) / "old.txt").write_text("hello")
middleware = FilesystemClaudeTextEditorMiddleware(root_path=root)
(file_tool,) = middleware.tools
result = file_tool.invoke(
{
"command": "rename",
"path": "/old.txt",
"new_path": "/new.txt",
"runtime": ToolRuntime(
context=None,
state={},
config={},
stream_writer=lambda _: None,
tool_call_id="tc-2",
store=None,
),
}
)
assert isinstance(result, Command)
assert not (Path(root) / "old.txt").exists()
assert (Path(root) / "new.txt").read_text() == "hello"
class TestSystemMessageHandling:
"""Test system message handling in wrap_model_call."""
@@ -150,10 +150,6 @@ def test_set_default_max_tokens() -> None:
llm = ChatAnthropic(model="claude-sonnet-4-5-20250929", anthropic_api_key="test")
assert llm.max_tokens == 64000
# Test claude-opus-4-1 models
llm = ChatAnthropic(model="claude-opus-4-1-20250805", anthropic_api_key="test")
assert llm.max_tokens == 32000
# Test claude-haiku-4-5 models
llm = ChatAnthropic(model="claude-haiku-4-5-20251001", anthropic_api_key="test")
assert llm.max_tokens == 64000
@@ -2081,6 +2077,42 @@ def test_usage_metadata_standardization() -> None:
assert result["total_tokens"] == 0
def test_usage_metadata_reasoning_tokens() -> None:
"""Reasoning tokens should be surfaced via `output_token_details.reasoning`.
Anthropic reports these through `output_tokens_details.thinking_tokens` as a
decomposition of `output_tokens` (not additive).
"""
class OutputTokensDetails(BaseModel):
thinking_tokens: int = 20
class UsageWithReasoning(BaseModel):
input_tokens: int = 100
output_tokens: int = 50
output_tokens_details: OutputTokensDetails | None = OutputTokensDetails()
# Case 1: reasoning tokens present
result = _create_usage_metadata(UsageWithReasoning())
assert result["input_tokens"] == 100
assert result["output_tokens"] == 50
assert result["total_tokens"] == 150
assert result.get("output_token_details") == {"reasoning": 20}
# Case 2: output_tokens_details explicitly None
result = _create_usage_metadata(UsageWithReasoning(output_tokens_details=None))
assert result["output_tokens"] == 50
assert "output_token_details" not in result
# Case 3: output_tokens_details field absent (older models)
class UsageNoDetails(BaseModel):
input_tokens: int = 100
output_tokens: int = 50
result = _create_usage_metadata(UsageNoDetails())
assert "output_token_details" not in result
def test_usage_metadata_cache_creation_ttl() -> None:
"""Test _create_usage_metadata with granular cache_creation TTL fields."""
+8 -8
View File
@@ -343,7 +343,7 @@ name = "exceptiongroup"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
wheels = [
@@ -552,7 +552,7 @@ wheels = [
[[package]]
name = "langchain"
version = "1.3.14"
version = "1.3.15"
source = { editable = "../../langchain_v1" }
dependencies = [
{ name = "langchain-core" },
@@ -581,7 +581,7 @@ requires-dist = [
{ name = "langchain-perplexity", marker = "extra == 'perplexity'" },
{ name = "langchain-together", marker = "extra == 'together'" },
{ name = "langchain-xai", marker = "extra == 'xai'" },
{ name = "langgraph", specifier = ">=1.2.5,<1.3.0" },
{ name = "langgraph", specifier = ">=1.2.11,<1.3.0" },
{ name = "pydantic", specifier = ">=2.7.4,<3.0.0" },
]
provides-extras = ["community", "anthropic", "openai", "azure-ai", "google-vertexai", "google-genai", "fireworks", "ollama", "together", "mistralai", "huggingface", "groq", "aws", "baseten", "deepseek", "xai", "perplexity", "meta"]
@@ -617,7 +617,7 @@ typing = [
[[package]]
name = "langchain-anthropic"
version = "1.5.4"
version = "1.5.5"
source = { editable = "." }
dependencies = [
{ name = "anthropic" },
@@ -690,7 +690,7 @@ typing = [
[[package]]
name = "langchain-core"
version = "1.5.3"
version = "1.5.4"
source = { editable = "../../core" }
dependencies = [
{ name = "jsonpatch" },
@@ -808,7 +808,7 @@ typing = [
[[package]]
name = "langgraph"
version = "1.2.5"
version = "1.2.11"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "langchain-core" },
@@ -818,9 +818,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "xxhash" },
]
sdist = { url = "https://files.pythonhosted.org/packages/77/9d/7c9ebd17b95569122e2d2e641f535cf086c870d66bb8e59be33cdba856b3/langgraph-1.2.5.tar.gz", hash = "sha256:09a3bdec6fdb3228623fc78b6f69a1400d383f66348d0b04d0efb692022cc6ef", size = 712532, upload-time = "2026-06-12T20:30:58.498Z" }
sdist = { url = "https://files.pythonhosted.org/packages/56/0d/c8e7ee98896659e1b6555db0ab115a9ca899844744645d5d894032bab1d7/langgraph-1.2.11.tar.gz", hash = "sha256:9ecfe11e50d338b34b15cf4d8a442642de103e8ae6971320efba84e4542eb363", size = 725753, upload-time = "2026-08-11T14:00:36.945Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/03/187281cf61845c5a9c397ae6cd9cd73bb54b39435e5575a7b83c853e5b76/langgraph-1.2.5-py3-none-any.whl", hash = "sha256:9286bb5def82fc865959c14378fe473518dc097d586225f622f029637a2a4bb9", size = 246150, upload-time = "2026-06-12T20:30:57.018Z" },
{ url = "https://files.pythonhosted.org/packages/0a/7f/c5c30e4be99ff821029c7ac872a480676bb179c9f3df85ea3f38d13f86d4/langgraph-1.2.11-py3-none-any.whl", hash = "sha256:8bab70de7b2d00b5300fb289bcf38d8b241400f3184c1e95e8ce706fb0e8686b", size = 248854, upload-time = "2026-08-11T14:00:35.494Z" },
]
[[package]]
@@ -27,12 +27,9 @@ def _get_metadata(result: Any) -> dict[str, Any]:
"published_date": result.published_date,
"author": result.author,
}
if getattr(result, "highlights"):
metadata["highlights"] = result.highlights
if getattr(result, "highlight_scores"):
metadata["highlight_scores"] = result.highlight_scores
if getattr(result, "summary"):
metadata["summary"] = result.summary
for attribute in ("highlights", "highlight_scores", "summary"):
if value := getattr(result, attribute, None):
metadata[attribute] = value
return metadata
@@ -0,0 +1,41 @@
"""Unit tests for the Exa retriever."""
from types import SimpleNamespace
from typing import Any
from langchain_exa.retrievers import _get_metadata
def _make_result(**optional_metadata: Any) -> SimpleNamespace:
return SimpleNamespace(
title="Example",
url="https://example.com",
id="result-1",
score=0.95,
published_date="2024-01-01",
author="Author",
**optional_metadata,
)
def test_get_metadata_omits_missing_optional_attributes() -> None:
"""Test that missing optional result attributes are omitted."""
assert _get_metadata(_make_result()) == {
"title": "Example",
"url": "https://example.com",
"id": "result-1",
"score": 0.95,
"published_date": "2024-01-01",
"author": "Author",
}
def test_get_metadata_includes_available_optional_attributes() -> None:
"""Test that available optional attributes are retained independently."""
metadata = _get_metadata(
_make_result(highlights=["Excerpt"], summary="A short summary")
)
assert metadata["highlights"] == ["Excerpt"]
assert metadata["summary"] == "A short summary"
assert "highlight_scores" not in metadata
@@ -471,8 +471,6 @@ def _convert_chunk_to_message_chunk(
) -> BaseMessageChunk:
choices = chunk.get("choices") or []
response_metadata: dict[str, Any] = {"model_provider": "fireworks"}
if service_tier := chunk.get("service_tier"):
response_metadata["service_tier"] = service_tier
if not choices:
# Final chunk emitted when `stream_options.include_usage=True`:
# `choices` is empty and the chunk carries only `usage`.
@@ -1103,6 +1101,8 @@ class ChatFireworks(BaseChatModel):
if finish_reason := choice.get("finish_reason"):
generation_info["finish_reason"] = finish_reason
generation_info["model_name"] = self.model_name
if service_tier := chunk.get("service_tier"):
generation_info["service_tier"] = service_tier
logprobs = choice.get("logprobs")
if logprobs:
generation_info["logprobs"] = logprobs
@@ -1214,6 +1214,8 @@ class ChatFireworks(BaseChatModel):
if finish_reason := choice.get("finish_reason"):
generation_info["finish_reason"] = finish_reason
generation_info["model_name"] = self.model_name
if service_tier := chunk.get("service_tier"):
generation_info["service_tier"] = service_tier
logprobs = choice.get("logprobs")
if logprobs:
generation_info["logprobs"] = logprobs
@@ -1668,7 +1668,7 @@ class TestServiceTier:
assert isinstance(result, AIMessage)
assert result.response_metadata["service_tier"] == "priority"
def test_service_tier_echoed_in_stream_chunks(self) -> None:
def test_service_tier_echoed_once_in_stream_chunks(self) -> None:
model = _make_model(service_tier="priority")
model.client = MagicMock()
chunks: list[dict[str, Any]] = [
@@ -1676,6 +1676,10 @@ class TestServiceTier:
"choices": [{"delta": {"role": "assistant", "content": "hi"}}],
"service_tier": "priority",
},
{
"choices": [{"delta": {}, "finish_reason": "stop"}],
"service_tier": "priority",
},
{
"choices": [],
"usage": {
@@ -1687,10 +1691,42 @@ class TestServiceTier:
},
]
model.client.create.return_value = iter(chunks)
out = list(model.stream("Hello"))
tagged = [c for c in out if c.response_metadata.get("service_tier")]
assert tagged
assert all(c.response_metadata["service_tier"] == "priority" for c in tagged)
output = list(model.stream("Hello"))
tagged = [c for c in output if c.response_metadata.get("service_tier")]
assert len(tagged) == 1
assert tagged[0].response_metadata["service_tier"] == "priority"
combined = output[0]
for chunk in output[1:]:
combined += chunk
assert combined.response_metadata["service_tier"] == "priority"
async def test_service_tier_echoed_once_in_async_stream_chunks(self) -> None:
model = _make_model(service_tier="priority")
model.async_client = MagicMock()
chunks: list[dict[str, Any]] = [
{
"choices": [{"delta": {"role": "assistant", "content": "hi"}}],
"service_tier": "priority",
},
{
"choices": [{"delta": {}, "finish_reason": "stop"}],
"service_tier": "priority",
},
]
async def _aiter() -> Any:
for chunk in chunks:
yield chunk
async def _create(**_kwargs: Any) -> Any:
return _aiter()
model.async_client.create = MagicMock(side_effect=_create)
output = [chunk async for chunk in model.astream("Hello")]
tagged = [c for c in output if c.response_metadata.get("service_tier")]
assert len(tagged) == 1
assert tagged[0].response_metadata["service_tier"] == "priority"
def test_service_tier_absent_when_not_in_response(self) -> None:
model = _make_model()
@@ -145,6 +145,28 @@ _PROFILES: dict[str, dict[str, Any]] = {
"temperature": True,
"tool_call_streaming": True,
},
"Qwen/Qwen3-235B-A22B-Instruct-2507": {
"name": "Qwen3 235B-A22B Instruct 2507",
"release_date": "2025-07-21",
"last_updated": "2025-07-21",
"open_weights": True,
"max_input_tokens": 262144,
"max_output_tokens": 16384,
"text_inputs": True,
"image_inputs": False,
"audio_inputs": False,
"video_inputs": False,
"text_outputs": True,
"image_outputs": False,
"audio_outputs": False,
"video_outputs": False,
"reasoning_output": False,
"tool_calling": True,
"structured_output": True,
"attachment": False,
"temperature": True,
"tool_call_streaming": True,
},
"Qwen/Qwen3-235B-A22B-Thinking-2507": {
"name": "Qwen3-235B-A22B-Thinking-2507",
"release_date": "2025-07-25",
@@ -597,6 +619,50 @@ _PROFILES: dict[str, dict[str, Any]] = {
"temperature": True,
"tool_call_streaming": True,
},
"deepseek-ai/DeepSeek-V3": {
"name": "DeepSeek-V3",
"release_date": "2024-12-26",
"last_updated": "2024-12-26",
"open_weights": True,
"max_input_tokens": 64000,
"max_output_tokens": 8192,
"text_inputs": True,
"image_inputs": False,
"audio_inputs": False,
"video_inputs": False,
"text_outputs": True,
"image_outputs": False,
"audio_outputs": False,
"video_outputs": False,
"reasoning_output": False,
"tool_calling": True,
"structured_output": True,
"attachment": False,
"temperature": True,
"tool_call_streaming": True,
},
"deepseek-ai/DeepSeek-V3.1": {
"name": "DeepSeek-V3.1",
"release_date": "2025-08-21",
"last_updated": "2025-08-21",
"open_weights": True,
"max_input_tokens": 131072,
"max_output_tokens": 8192,
"text_inputs": True,
"image_inputs": False,
"audio_inputs": False,
"video_inputs": False,
"text_outputs": True,
"image_outputs": False,
"audio_outputs": False,
"video_outputs": False,
"reasoning_output": True,
"tool_calling": True,
"structured_output": True,
"attachment": False,
"temperature": True,
"tool_call_streaming": True,
},
"deepseek-ai/DeepSeek-V3.2": {
"name": "DeepSeek-V3.2",
"release_date": "2025-12-01",
+9 -9
View File
@@ -1113,7 +1113,7 @@ wheels = [
[[package]]
name = "langchain"
version = "1.3.14"
version = "1.3.15"
source = { editable = "../../langchain_v1" }
dependencies = [
{ name = "langchain-core" },
@@ -1142,7 +1142,7 @@ requires-dist = [
{ name = "langchain-perplexity", marker = "extra == 'perplexity'" },
{ name = "langchain-together", marker = "extra == 'together'" },
{ name = "langchain-xai", marker = "extra == 'xai'" },
{ name = "langgraph", specifier = ">=1.2.5,<1.3.0" },
{ name = "langgraph", specifier = ">=1.2.11,<1.3.0" },
{ name = "pydantic", specifier = ">=2.7.4,<3.0.0" },
]
provides-extras = ["community", "anthropic", "openai", "azure-ai", "google-vertexai", "google-genai", "fireworks", "ollama", "together", "mistralai", "huggingface", "groq", "aws", "baseten", "deepseek", "xai", "perplexity", "meta"]
@@ -1202,7 +1202,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.5.3"
version = "1.5.4"
source = { editable = "../../core" }
dependencies = [
{ name = "jsonpatch" },
@@ -1392,7 +1392,7 @@ typing = [
[[package]]
name = "langgraph"
version = "1.2.5"
version = "1.2.11"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "langchain-core" },
@@ -1402,9 +1402,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "xxhash" },
]
sdist = { url = "https://files.pythonhosted.org/packages/77/9d/7c9ebd17b95569122e2d2e641f535cf086c870d66bb8e59be33cdba856b3/langgraph-1.2.5.tar.gz", hash = "sha256:09a3bdec6fdb3228623fc78b6f69a1400d383f66348d0b04d0efb692022cc6ef", size = 712532, upload-time = "2026-06-12T20:30:58.498Z" }
sdist = { url = "https://files.pythonhosted.org/packages/56/0d/c8e7ee98896659e1b6555db0ab115a9ca899844744645d5d894032bab1d7/langgraph-1.2.11.tar.gz", hash = "sha256:9ecfe11e50d338b34b15cf4d8a442642de103e8ae6971320efba84e4542eb363", size = 725753, upload-time = "2026-08-11T14:00:36.945Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/03/187281cf61845c5a9c397ae6cd9cd73bb54b39435e5575a7b83c853e5b76/langgraph-1.2.5-py3-none-any.whl", hash = "sha256:9286bb5def82fc865959c14378fe473518dc097d586225f622f029637a2a4bb9", size = 246150, upload-time = "2026-06-12T20:30:57.018Z" },
{ url = "https://files.pythonhosted.org/packages/0a/7f/c5c30e4be99ff821029c7ac872a480676bb179c9f3df85ea3f38d13f86d4/langgraph-1.2.11-py3-none-any.whl", hash = "sha256:8bab70de7b2d00b5300fb289bcf38d8b241400f3184c1e95e8ce706fb0e8686b", size = 248854, upload-time = "2026-08-11T14:00:35.494Z" },
]
[[package]]
@@ -3407,11 +3407,11 @@ wheels = [
[[package]]
name = "setuptools"
version = "81.0.0"
version = "84.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0d/1c/73e719955c59b8e424d015ab450f51c0af856ae46ea2da83eba51cc88de1/setuptools-81.0.0.tar.gz", hash = "sha256:487b53915f52501f0a79ccfd0c02c165ffe06631443a886740b91af4b7a5845a", size = 1198299, upload-time = "2026-02-06T21:10:39.601Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/44/f5da03a8ef95d369145c5bb53050e7877c9f3d312e128605fd9504829143/setuptools-84.0.0.tar.gz", hash = "sha256:f4695c21257f0d9b537ec2692c941d02ee143b7cc1276941349a546573b2ef73", size = 1168449, upload-time = "2026-08-08T18:27:58.365Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/e3/c164c88b2e5ce7b24d667b9bd83589cf4f3520d97cad01534cd3c4f55fdb/setuptools-81.0.0-py3-none-any.whl", hash = "sha256:fdd925d5c5d9f62e4b74b30d6dd7828ce236fd6ed998a08d81de62ce5a6310d6", size = 1062021, upload-time = "2026-02-06T21:10:37.175Z" },
{ url = "https://files.pythonhosted.org/packages/95/9c/c510029fc6ef33a6275cd2c5d3cecd6613dfd6aa401d57c54f1c18852ccf/setuptools-84.0.0-py3-none-any.whl", hash = "sha256:51a52592b3b99e102b609654876bd65f19f999935166d1352678931132b0c670", size = 818216, upload-time = "2026-08-08T18:27:56.719Z" },
]
[[package]]
@@ -1,3 +1,3 @@
"""Version information for `langchain-openai`."""
__version__ = "1.4.1"
__version__ = "1.4.3"
@@ -165,7 +165,7 @@ def _convert_from_v1_to_chat_completions(message: AIMessage) -> AIMessage:
if block_type == "text":
# Strip annotations
new_content.append({"type": "text", "text": block["text"]})
elif block_type in ("reasoning", "tool_call"):
elif block_type in ("reasoning", "tool_call", "invalid_tool_call"):
pass
else:
new_content.append(block)
@@ -564,6 +564,7 @@ def _handle_openai_bad_request(e: openai.BadRequestError) -> None:
"context_length_exceeded" in str(e)
or "Input tokens exceed the configured limit" in e.message
or "prompt is too long" in e.message
or "ContextWindowExceededError" in e.message
):
raise OpenAIContextOverflowError(
message=e.message, response=e.response, body=e.body
@@ -984,7 +985,11 @@ class BaseChatOpenAI(BaseChatModel):
"""
include_response_headers: bool = False
"""Whether to include response headers in the output message `response_metadata`."""
"""Whether to include response headers in the output message `response_metadata`.
Note: some inference providers return additional metadata (such as served model
names) in the response headers. Enable to capture these metadata.
"""
disabled_params: dict[str, Any] | None = Field(default=None)
"""Parameters of the OpenAI client or `chat.completions` endpoint that should be
@@ -3306,6 +3311,24 @@ class ChatOpenAI(BaseChatOpenAI): # type: ignore[override]
)
```
!!! warning "Model name can trigger Responses API routing"
The choice between the Chat Completions API (`/v1/chat/completions`)
and the Responses API (`/v1/responses`) is inferred in part from the
model name, independent of `base_url`.
`use_responses_api` should generally be set explicitly to avoid ambiguity,
especially when using OpenAI-compatible providers:
```python
model = ChatOpenAI(
base_url="http://localhost:8000/v1",
api_key="EMPTY",
model="codex-7b-instruct",
use_responses_api=False,
)
```
??? info "`model_kwargs` vs `extra_body`"
Use the correct parameter for different types of API arguments:
+2 -2
View File
@@ -20,10 +20,10 @@ classifiers = [
"Topic :: Scientific/Engineering :: Artificial Intelligence",
]
version = "1.4.1"
version = "1.4.3"
requires-python = ">=3.10.0,<4.0.0"
dependencies = [
"langchain-core>=1.5.1,<2.0.0",
"langchain-core>=1.5.3,<2.0.0",
"openai>=2.45.0,<3.0.0",
"tiktoken>=0.7.0,<1.0.0",
]
@@ -3643,6 +3643,13 @@ def test_compat_responses_v03_apply_patch_tool_outputs() -> None:
"name": "get_weather",
"args": {"location": "San Francisco"},
},
{
"type": "invalid_tool_call",
"id": "call_234",
"name": "get_weather",
"args": '{"location":',
"error": "Failed to parse tool call arguments as JSON",
},
{
"type": "text",
"text": "Hello, world!",
@@ -3653,11 +3660,29 @@ def test_compat_responses_v03_apply_patch_tool_outputs() -> None:
],
id="chatcmpl-123",
response_metadata={"model_provider": "openai", "model_name": "gpt-4.1"},
invalid_tool_calls=[
InvalidToolCall(
id="call_234",
name="get_weather",
args='{"location":',
error="Failed to parse tool call arguments as JSON",
type="invalid_tool_call",
)
],
),
AIMessage(
[{"type": "text", "text": "Hello, world!"}],
id="chatcmpl-123",
response_metadata={"model_provider": "openai", "model_name": "gpt-4.1"},
invalid_tool_calls=[
InvalidToolCall(
id="call_234",
name="get_weather",
args='{"location":',
error="Failed to parse tool call arguments as JSON",
type="invalid_tool_call",
)
],
),
)
],
@@ -3668,6 +3693,7 @@ def test_convert_from_v1_to_chat_completions(
result = _convert_from_v1_to_chat_completions(message_v1)
assert result == expected
assert result.tool_calls == message_v1.tool_calls # tool calls remain cached
assert result.invalid_tool_calls == message_v1.invalid_tool_calls
# Check no mutation
assert message_v1 != result
@@ -4507,6 +4533,31 @@ def test_context_overflow_error_prompt_too_long() -> None:
assert "prompt is too long" in str(exc_info.value)
def test_context_overflow_error_context_window_exceeded() -> None:
"""Test context overflow error triggered by ContextWindowExceededError."""
error_body = {
"error": {
"message": "ContextWindowExceededError: maximum context length exceeded",
"type": "invalid_request_error",
"param": "messages",
"code": "invalid_request_error",
}
}
bad_request_error = openai.BadRequestError(
message=error_body["error"]["message"],
response=MagicMock(status_code=400),
body=error_body,
)
llm = ChatOpenAI()
with patch.object(llm.client, "with_raw_response") as mock_client:
mock_client.create.side_effect = bad_request_error
with pytest.raises(ContextOverflowError) as exc_info:
llm.invoke([HumanMessage(content="test")])
assert "ContextWindowExceededError" in str(exc_info.value)
def test_context_overflow_error_backwards_compatibility() -> None:
"""Test that ContextOverflowError can be caught as BadRequestError."""
llm = ChatOpenAI()
+13 -13
View File
@@ -601,7 +601,7 @@ wheels = [
[[package]]
name = "langchain"
version = "1.3.14"
version = "1.3.15"
source = { editable = "../../langchain_v1" }
dependencies = [
{ name = "langchain-core" },
@@ -630,13 +630,13 @@ requires-dist = [
{ name = "langchain-perplexity", marker = "extra == 'perplexity'" },
{ name = "langchain-together", marker = "extra == 'together'" },
{ name = "langchain-xai", marker = "extra == 'xai'" },
{ name = "langgraph", specifier = ">=1.2.5,<1.3.0" },
{ name = "langgraph", specifier = ">=1.2.11,<1.3.0" },
{ name = "pydantic", specifier = ">=2.7.4,<3.0.0" },
]
provides-extras = ["community", "anthropic", "openai", "azure-ai", "google-vertexai", "google-genai", "fireworks", "ollama", "together", "mistralai", "huggingface", "groq", "aws", "baseten", "deepseek", "xai", "perplexity", "meta"]
[package.metadata.requires-dev]
lint = [{ name = "ruff", specifier = ">=0.15.0,<0.16.0" }]
lint = [{ name = "ruff", specifier = ">=0.15.0,<0.17.0" }]
test = [
{ name = "blockbuster", specifier = ">=1.5.26,<1.6.0" },
{ name = "langchain-openai", editable = "." },
@@ -666,7 +666,7 @@ typing = [
[[package]]
name = "langchain-core"
version = "1.5.0"
version = "1.5.4"
source = { editable = "../../core" }
dependencies = [
{ name = "jsonpatch" },
@@ -697,9 +697,9 @@ requires-dist = [
dev = [
{ name = "grandalf", specifier = ">=0.8.0,<1.0.0" },
{ name = "jupyter", specifier = ">=1.0.0,<2.0.0" },
{ name = "setuptools", specifier = ">=67.6.1,<83.0.0" },
{ name = "setuptools", specifier = ">=67.6.1,<84.0.0" },
]
lint = [{ name = "ruff", specifier = ">=0.15.0,<0.16.0" }]
lint = [{ name = "ruff", specifier = ">=0.15.0,<0.17.0" }]
test = [
{ name = "blockbuster", specifier = ">=1.5.18,<1.6.0" },
{ name = "freezegun", specifier = ">=1.2.2,<2.0.0" },
@@ -728,7 +728,7 @@ typing = [
[[package]]
name = "langchain-openai"
version = "1.4.1"
version = "1.4.3"
source = { editable = "." }
dependencies = [
{ name = "langchain-core" },
@@ -777,7 +777,7 @@ requires-dist = [
[package.metadata.requires-dev]
dev = []
lint = [{ name = "ruff", specifier = ">=0.13.1,<0.16.0" }]
lint = [{ name = "ruff", specifier = ">=0.13.1,<0.17.0" }]
test = [
{ name = "freezegun", specifier = ">=1.2.2,<2.0.0" },
{ name = "langchain", editable = "../../langchain_v1" },
@@ -854,17 +854,17 @@ requires-dist = [
]
[package.metadata.requires-dev]
lint = [{ name = "ruff", specifier = ">=0.15.0,<0.16.0" }]
lint = [{ name = "ruff", specifier = ">=0.15.0,<0.17.0" }]
test = []
test-integration = []
typing = [
{ name = "mypy", specifier = ">=2.1.0,<2.2.0" },
{ name = "mypy", specifier = ">=2.1.0,<2.4.0" },
{ name = "types-pyyaml", specifier = ">=6.0.12.2,<7.0.0.0" },
]
[[package]]
name = "langgraph"
version = "1.2.5"
version = "1.2.11"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "langchain-core" },
@@ -874,9 +874,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "xxhash" },
]
sdist = { url = "https://files.pythonhosted.org/packages/77/9d/7c9ebd17b95569122e2d2e641f535cf086c870d66bb8e59be33cdba856b3/langgraph-1.2.5.tar.gz", hash = "sha256:09a3bdec6fdb3228623fc78b6f69a1400d383f66348d0b04d0efb692022cc6ef", size = 712532, upload-time = "2026-06-12T20:30:58.498Z" }
sdist = { url = "https://files.pythonhosted.org/packages/56/0d/c8e7ee98896659e1b6555db0ab115a9ca899844744645d5d894032bab1d7/langgraph-1.2.11.tar.gz", hash = "sha256:9ecfe11e50d338b34b15cf4d8a442642de103e8ae6971320efba84e4542eb363", size = 725753, upload-time = "2026-08-11T14:00:36.945Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a2/03/187281cf61845c5a9c397ae6cd9cd73bb54b39435e5575a7b83c853e5b76/langgraph-1.2.5-py3-none-any.whl", hash = "sha256:9286bb5def82fc865959c14378fe473518dc097d586225f622f029637a2a4bb9", size = 246150, upload-time = "2026-06-12T20:30:57.018Z" },
{ url = "https://files.pythonhosted.org/packages/0a/7f/c5c30e4be99ff821029c7ac872a480676bb179c9f3df85ea3f38d13f86d4/langgraph-1.2.11-py3-none-any.whl", hash = "sha256:8bab70de7b2d00b5300fb289bcf38d8b241400f3184c1e95e8ce706fb0e8686b", size = 248854, upload-time = "2026-08-11T14:00:35.494Z" },
]
[[package]]
@@ -88,6 +88,8 @@ def _create_stream_generation_info(
) -> dict[str, Any]:
generation_info = {"finish_reason": choice["finish_reason"]}
generation_info["model_name"] = chunk_dict.get("model") or model_name
if provider := chunk_dict.get("provider"):
generation_info["provider"] = provider
if system_fingerprint := chunk_dict.get("system_fingerprint"):
generation_info["system_fingerprint"] = system_fingerprint
if native_finish_reason := choice.get("native_finish_reason"):
@@ -616,8 +618,15 @@ class ChatOpenRouter(BaseChatModel):
# Usage-only chunk (no choices) — emit with usage_metadata
if usage := chunk_dict.get("usage"):
usage_metadata = _create_usage_metadata(usage)
response_metadata: dict[str, Any] = {}
if "cost" in usage:
response_metadata["cost"] = usage["cost"]
if "cost_details" in usage:
response_metadata["cost_details"] = usage["cost_details"]
usage_chunk = AIMessageChunk(
content="", usage_metadata=usage_metadata
content="",
usage_metadata=usage_metadata,
response_metadata=response_metadata,
)
generation_chunk = ChatGenerationChunk(message=usage_chunk)
if run_manager:
@@ -701,8 +710,15 @@ class ChatOpenRouter(BaseChatModel):
# Usage-only chunk (no choices) — emit with usage_metadata
if usage := chunk_dict.get("usage"):
usage_metadata = _create_usage_metadata(usage)
response_metadata = {}
if "cost" in usage:
response_metadata["cost"] = usage["cost"]
if "cost_details" in usage:
response_metadata["cost_details"] = usage["cost_details"]
usage_chunk = AIMessageChunk(
content="", usage_metadata=usage_metadata
content="",
usage_metadata=usage_metadata,
response_metadata=response_metadata,
)
generation_chunk = ChatGenerationChunk(message=usage_chunk)
if run_manager:
@@ -836,6 +852,7 @@ class ChatOpenRouter(BaseChatModel):
# Extract top-level response metadata
response_model = response.get("model")
system_fingerprint = response.get("system_fingerprint")
provider = response.get("provider")
for res in choices:
message = _convert_dict_to_message(res["message"])
@@ -849,6 +866,8 @@ class ChatOpenRouter(BaseChatModel):
"cost_details"
]
if isinstance(message, AIMessage):
if provider:
message.response_metadata["provider"] = provider
if system_fingerprint:
message.response_metadata["system_fingerprint"] = system_fingerprint
if native_finish_reason := res.get("native_finish_reason"):
@@ -390,7 +390,7 @@ _PROFILES: dict[str, dict[str, Any]] = {
"video_outputs": False,
"reasoning_output": True,
"tool_calling": True,
"structured_output": True,
"structured_output": False,
"attachment": True,
"temperature": True,
"tool_call_streaming": True,
@@ -781,6 +781,28 @@ _PROFILES: dict[str, dict[str, Any]] = {
"temperature": True,
"tool_call_streaming": True,
},
"bytedance-seed/seed-2.0-code": {
"name": "Seed 2.0 Code",
"release_date": "2026-02-14",
"last_updated": "2026-02-14",
"open_weights": False,
"max_input_tokens": 262144,
"max_output_tokens": 131072,
"text_inputs": True,
"image_inputs": True,
"audio_inputs": False,
"video_inputs": True,
"text_outputs": True,
"image_outputs": False,
"audio_outputs": False,
"video_outputs": False,
"reasoning_output": True,
"tool_calling": True,
"structured_output": True,
"attachment": True,
"temperature": True,
"tool_call_streaming": True,
},
"bytedance-seed/seed-2.0-lite": {
"name": "Seed-2.0-Lite",
"release_date": "2026-03-10",
@@ -1227,7 +1249,7 @@ _PROFILES: dict[str, dict[str, Any]] = {
"last_updated": "2026-07-31",
"open_weights": True,
"max_input_tokens": 1048576,
"max_output_tokens": 65536,
"max_output_tokens": 384000,
"text_inputs": True,
"image_inputs": False,
"audio_inputs": False,
@@ -1249,7 +1271,7 @@ _PROFILES: dict[str, dict[str, Any]] = {
"last_updated": "2026-04-24",
"open_weights": True,
"max_input_tokens": 1048576,
"max_output_tokens": 384000,
"max_output_tokens": 393216,
"text_inputs": True,
"image_inputs": False,
"audio_inputs": False,
@@ -1408,7 +1430,7 @@ _PROFILES: dict[str, dict[str, Any]] = {
"last_updated": "2025-12-17",
"open_weights": False,
"max_input_tokens": 1048576,
"max_output_tokens": 65535,
"max_output_tokens": 65536,
"text_inputs": True,
"image_inputs": True,
"audio_inputs": True,
@@ -1812,7 +1834,7 @@ _PROFILES: dict[str, dict[str, Any]] = {
"last_updated": "2026-04-02",
"open_weights": True,
"max_input_tokens": 262144,
"max_output_tokens": 16384,
"max_output_tokens": 262144,
"text_inputs": True,
"image_inputs": True,
"audio_inputs": False,
@@ -2070,11 +2092,33 @@ _PROFILES: dict[str, dict[str, Any]] = {
"temperature": True,
"tool_call_streaming": True,
},
"inclusionai/ling-3.0-flash:free": {
"name": "Ling-3.0-flash (free)",
"inclusionai/ling-3.0-flash": {
"name": "Ling-3.0-flash",
"release_date": "2026-07-23",
"last_updated": "2026-07-23",
"open_weights": False,
"open_weights": True,
"max_input_tokens": 262144,
"max_output_tokens": 32768,
"text_inputs": True,
"image_inputs": False,
"audio_inputs": False,
"video_inputs": False,
"text_outputs": True,
"image_outputs": False,
"audio_outputs": False,
"video_outputs": False,
"reasoning_output": True,
"tool_calling": True,
"structured_output": False,
"attachment": False,
"temperature": True,
"tool_call_streaming": True,
},
"inclusionai/ling-3.0-tiny:free": {
"name": "Ling 3.0 Tiny (free)",
"release_date": "2026-08-06",
"last_updated": "2026-08-06",
"open_weights": True,
"max_input_tokens": 262144,
"max_output_tokens": 32768,
"text_inputs": True,
@@ -2180,13 +2224,35 @@ _PROFILES: dict[str, dict[str, Any]] = {
"temperature": True,
"tool_call_streaming": True,
},
"liquid/lfm-2.5-2.6b:free": {
"name": "LFM2.5-2.6B (free)",
"release_date": "2026-08-11",
"last_updated": "2026-08-11",
"open_weights": True,
"max_input_tokens": 128000,
"max_output_tokens": 32768,
"text_inputs": True,
"image_inputs": False,
"audio_inputs": False,
"video_inputs": False,
"text_outputs": True,
"image_outputs": False,
"audio_outputs": False,
"video_outputs": False,
"reasoning_output": True,
"tool_calling": True,
"structured_output": True,
"attachment": False,
"temperature": True,
"tool_call_streaming": True,
},
"mancer/weaver": {
"name": "Weaver (alpha)",
"release_date": "2023-08-02",
"last_updated": "2023-08-02",
"open_weights": False,
"max_input_tokens": 8000,
"max_output_tokens": 2000,
"max_output_tokens": 6000,
"text_inputs": True,
"image_inputs": False,
"audio_inputs": False,
@@ -2400,6 +2466,28 @@ _PROFILES: dict[str, dict[str, Any]] = {
"temperature": True,
"tool_call_streaming": True,
},
"meta/muse-glimmer-30b": {
"name": "Muse Glimmer 30B",
"release_date": "2026-08-10",
"last_updated": "2026-08-10",
"open_weights": True,
"max_input_tokens": 131072,
"max_output_tokens": 131072,
"text_inputs": True,
"image_inputs": True,
"audio_inputs": False,
"video_inputs": False,
"text_outputs": True,
"image_outputs": False,
"audio_outputs": False,
"video_outputs": False,
"reasoning_output": True,
"tool_calling": True,
"structured_output": True,
"attachment": True,
"temperature": True,
"tool_call_streaming": True,
},
"meta/muse-spark-1.1": {
"name": "Muse Spark 1.1",
"release_date": "2026-04-08",
@@ -2423,6 +2511,29 @@ _PROFILES: dict[str, dict[str, Any]] = {
"temperature": True,
"tool_call_streaming": True,
},
"meta/muse-spark-1.2": {
"name": "Muse Spark 1.2",
"release_date": "2026-08-05",
"last_updated": "2026-08-05",
"open_weights": False,
"max_input_tokens": 1048576,
"max_output_tokens": 1048576,
"text_inputs": True,
"image_inputs": True,
"audio_inputs": True,
"pdf_inputs": True,
"video_inputs": True,
"text_outputs": True,
"image_outputs": False,
"audio_outputs": False,
"video_outputs": False,
"reasoning_output": True,
"tool_calling": True,
"structured_output": True,
"attachment": True,
"temperature": True,
"tool_call_streaming": True,
},
"microsoft/phi-4": {
"name": "Phi 4",
"release_date": "2025-01-10",
@@ -3191,7 +3302,7 @@ _PROFILES: dict[str, dict[str, Any]] = {
"text_inputs": True,
"image_inputs": True,
"audio_inputs": False,
"video_inputs": False,
"video_inputs": True,
"text_outputs": True,
"image_outputs": False,
"audio_outputs": False,
@@ -3385,7 +3496,7 @@ _PROFILES: dict[str, dict[str, Any]] = {
"last_updated": "2025-12-15",
"open_weights": True,
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"max_output_tokens": 228000,
"text_inputs": True,
"image_inputs": False,
"audio_inputs": False,
@@ -3555,6 +3666,50 @@ _PROFILES: dict[str, dict[str, Any]] = {
"temperature": True,
"tool_call_streaming": True,
},
"nvidia/nemotron-3.5-lightning": {
"name": "Nemotron 3.5 Lightning 30B A3B",
"release_date": "2026-08-11",
"last_updated": "2026-08-11",
"open_weights": True,
"max_input_tokens": 262144,
"max_output_tokens": 262144,
"text_inputs": True,
"image_inputs": False,
"audio_inputs": False,
"video_inputs": False,
"text_outputs": True,
"image_outputs": False,
"audio_outputs": False,
"video_outputs": False,
"reasoning_output": True,
"tool_calling": False,
"structured_output": True,
"attachment": False,
"temperature": True,
"tool_call_streaming": True,
},
"nvidia/nemotron-3.5-lightning:free": {
"name": "Nemotron 3.5 Lightning (free)",
"release_date": "2026-08-11",
"last_updated": "2026-08-11",
"open_weights": True,
"max_input_tokens": 1000000,
"max_output_tokens": 65536,
"text_inputs": True,
"image_inputs": False,
"audio_inputs": False,
"video_inputs": False,
"text_outputs": True,
"image_outputs": False,
"audio_outputs": False,
"video_outputs": False,
"reasoning_output": True,
"tool_calling": True,
"structured_output": False,
"attachment": False,
"temperature": True,
"tool_call_streaming": True,
},
"nvidia/nemotron-nano-12b-v2-vl:free": {
"name": "Nemotron Nano 12B 2 VL (free)",
"release_date": "2025-10-28",
@@ -4216,7 +4371,7 @@ _PROFILES: dict[str, dict[str, Any]] = {
"last_updated": "2025-12-10",
"open_weights": False,
"max_input_tokens": 128000,
"max_output_tokens": 16384,
"max_output_tokens": 32000,
"text_inputs": True,
"image_inputs": True,
"audio_inputs": False,
@@ -4278,29 +4433,6 @@ _PROFILES: dict[str, dict[str, Any]] = {
"temperature": False,
"tool_call_streaming": True,
},
"openai/gpt-5.3-chat": {
"name": "GPT-5.3 Chat",
"release_date": "2026-03-03",
"last_updated": "2026-03-03",
"open_weights": False,
"max_input_tokens": 128000,
"max_output_tokens": 16384,
"text_inputs": True,
"image_inputs": True,
"audio_inputs": False,
"pdf_inputs": True,
"video_inputs": False,
"text_outputs": True,
"image_outputs": False,
"audio_outputs": False,
"video_outputs": False,
"reasoning_output": False,
"tool_calling": True,
"structured_output": True,
"attachment": True,
"temperature": False,
"tool_call_streaming": True,
},
"openai/gpt-5.3-codex": {
"name": "GPT-5.3 Codex",
"release_date": "2026-02-05",
@@ -5453,7 +5585,7 @@ _PROFILES: dict[str, dict[str, Any]] = {
"last_updated": "2025-04-28",
"open_weights": True,
"max_input_tokens": 131072,
"max_output_tokens": 8192,
"max_output_tokens": 16384,
"text_inputs": True,
"image_inputs": False,
"audio_inputs": False,
@@ -5497,7 +5629,7 @@ _PROFILES: dict[str, dict[str, Any]] = {
"last_updated": "2025-07-21",
"open_weights": True,
"max_input_tokens": 262144,
"max_output_tokens": 32768,
"max_output_tokens": 16384,
"text_inputs": True,
"image_inputs": False,
"audio_inputs": False,
@@ -5673,7 +5805,7 @@ _PROFILES: dict[str, dict[str, Any]] = {
"last_updated": "2025-04",
"open_weights": True,
"max_input_tokens": 262144,
"max_output_tokens": 32768,
"max_output_tokens": 262144,
"text_inputs": True,
"image_inputs": False,
"audio_inputs": False,
@@ -5713,8 +5845,8 @@ _PROFILES: dict[str, dict[str, Any]] = {
},
"qwen/qwen3-coder-next": {
"name": "Qwen3 Coder Next",
"release_date": "2026-02-04",
"last_updated": "2026-02-04",
"release_date": "2026-02-03",
"last_updated": "2026-02-03",
"open_weights": True,
"max_input_tokens": 262144,
"max_output_tokens": 262144,
@@ -5827,7 +5959,7 @@ _PROFILES: dict[str, dict[str, Any]] = {
"last_updated": "2025-09",
"open_weights": True,
"max_input_tokens": 262144,
"max_output_tokens": 32768,
"max_output_tokens": 262144,
"text_inputs": True,
"image_inputs": False,
"audio_inputs": False,
@@ -6003,7 +6135,7 @@ _PROFILES: dict[str, dict[str, Any]] = {
"last_updated": "2026-02-23",
"open_weights": True,
"max_input_tokens": 262144,
"max_output_tokens": 65536,
"max_output_tokens": 81920,
"text_inputs": True,
"image_inputs": True,
"audio_inputs": False,
@@ -6069,7 +6201,7 @@ _PROFILES: dict[str, dict[str, Any]] = {
"last_updated": "2026-02-15",
"open_weights": True,
"max_input_tokens": 262144,
"max_output_tokens": 65536,
"max_output_tokens": 262144,
"text_inputs": True,
"image_inputs": True,
"audio_inputs": False,
@@ -6179,7 +6311,7 @@ _PROFILES: dict[str, dict[str, Any]] = {
"last_updated": "2026-04-22",
"open_weights": True,
"max_input_tokens": 262144,
"max_output_tokens": 131072,
"max_output_tokens": 262144,
"text_inputs": True,
"image_inputs": True,
"audio_inputs": False,
@@ -6481,6 +6613,29 @@ _PROFILES: dict[str, dict[str, Any]] = {
"temperature": False,
"tool_call_streaming": True,
},
"sakana/sakana-namazu": {
"name": "Sakana Namazu",
"release_date": "2026-08-11",
"last_updated": "2026-08-11",
"open_weights": False,
"max_input_tokens": 262144,
"max_output_tokens": 65536,
"text_inputs": True,
"image_inputs": True,
"audio_inputs": False,
"pdf_inputs": True,
"video_inputs": False,
"text_outputs": True,
"image_outputs": False,
"audio_outputs": False,
"video_outputs": False,
"reasoning_output": True,
"tool_calling": True,
"structured_output": True,
"attachment": True,
"temperature": False,
"tool_call_streaming": True,
},
"sao10k/l3-lunaris-8b": {
"name": "Llama 3 8B Lunaris",
"release_date": "2024-08-13",
@@ -6751,7 +6906,7 @@ _PROFILES: dict[str, dict[str, Any]] = {
"last_updated": "2026-07-15",
"open_weights": True,
"max_input_tokens": 1048576,
"max_output_tokens": 1048576,
"max_output_tokens": 262144,
"text_inputs": True,
"image_inputs": True,
"audio_inputs": True,
@@ -6784,7 +6939,7 @@ _PROFILES: dict[str, dict[str, Any]] = {
"video_outputs": False,
"reasoning_output": True,
"tool_calling": True,
"structured_output": False,
"structured_output": True,
"attachment": True,
"temperature": True,
"tool_call_streaming": True,
@@ -6795,7 +6950,7 @@ _PROFILES: dict[str, dict[str, Any]] = {
"last_updated": "2023-07-22",
"open_weights": True,
"max_input_tokens": 6144,
"max_output_tokens": 2048,
"max_output_tokens": 6144,
"text_inputs": True,
"image_inputs": False,
"audio_inputs": False,
@@ -6816,8 +6971,30 @@ _PROFILES: dict[str, dict[str, Any]] = {
"release_date": "2026-01-27",
"last_updated": "2026-01-27",
"open_weights": False,
"max_input_tokens": 128000,
"max_output_tokens": 128000,
"max_input_tokens": 131072,
"max_output_tokens": 131072,
"text_inputs": True,
"image_inputs": False,
"audio_inputs": False,
"video_inputs": False,
"text_outputs": True,
"image_outputs": False,
"audio_outputs": False,
"video_outputs": False,
"reasoning_output": True,
"tool_calling": True,
"structured_output": True,
"attachment": False,
"temperature": True,
"tool_call_streaming": True,
},
"upstage/solar-pro4": {
"name": "Solar Pro 4",
"release_date": "2026-08-10",
"last_updated": "2026-08-10",
"open_weights": False,
"max_input_tokens": 524288,
"max_output_tokens": 131072,
"text_inputs": True,
"image_inputs": False,
"audio_inputs": False,
@@ -7218,7 +7395,7 @@ _PROFILES: dict[str, dict[str, Any]] = {
"last_updated": "2026-04-07",
"open_weights": True,
"max_input_tokens": 204800,
"max_output_tokens": 128000,
"max_output_tokens": 131072,
"text_inputs": True,
"image_inputs": False,
"audio_inputs": False,
@@ -7240,7 +7417,7 @@ _PROFILES: dict[str, dict[str, Any]] = {
"last_updated": "2026-06-13",
"open_weights": True,
"max_input_tokens": 1048576,
"max_output_tokens": 262144,
"max_output_tokens": 131072,
"text_inputs": True,
"image_inputs": False,
"audio_inputs": False,
@@ -7376,7 +7553,7 @@ _PROFILES: dict[str, dict[str, Any]] = {
"last_updated": "2026-08-01",
"open_weights": False,
"max_input_tokens": 1048576,
"max_output_tokens": 65536,
"max_output_tokens": 262144,
"text_inputs": True,
"image_inputs": False,
"audio_inputs": False,
@@ -7448,7 +7625,7 @@ _PROFILES: dict[str, dict[str, Any]] = {
"text_inputs": True,
"image_inputs": True,
"audio_inputs": False,
"video_inputs": False,
"video_inputs": True,
"text_outputs": True,
"image_outputs": False,
"audio_outputs": False,
@@ -23,13 +23,36 @@ def test_basic_invoke() -> None:
def test_streaming() -> None:
"""Test streaming."""
"""Test streaming.
Also asserts that OpenRouter spend survives streaming (regression test for
#39333). With `stream_usage` enabled (the default), the final usage-only
chunk (`choices: []`) must surface `cost` in `response_metadata`, matching
the non-streaming path. Requires a funded OpenRouter account — a `:free`
model returns `cost: 0`.
"""
model = ChatOpenRouter(model="openai/gpt-4o-mini", temperature=0)
full: BaseMessageChunk | None = None
for chunk in model.stream("Say 'hello' and nothing else."):
full = chunk if full is None else full + chunk
assert isinstance(full, AIMessageChunk)
assert full.content
assert full.response_metadata["cost"] > 0
async def test_astreaming() -> None:
"""Test async streaming (sister to `test_streaming`).
Covers `_astream`, which surfaces `cost` on the usage-only chunk the same
way `_stream` does (#39333).
"""
model = ChatOpenRouter(model="openai/gpt-4o-mini", temperature=0)
full: BaseMessageChunk | None = None
async for chunk in model.astream("Say 'hello' and nothing else."):
full = chunk if full is None else full + chunk
assert isinstance(full, AIMessageChunk)
assert full.content
assert full.response_metadata["cost"] > 0
def test_tool_calling() -> None:
@@ -29,6 +29,7 @@ from langchain_openrouter.chat_models import (
_convert_file_block_to_openrouter,
_convert_message_to_dict,
_convert_video_block_to_openrouter,
_create_stream_generation_info,
_create_usage_metadata,
_format_message_content,
)
@@ -82,6 +83,7 @@ _SIMPLE_RESPONSE_DICT: dict[str, Any] = {
"model": MODEL_NAME,
"object": "chat.completion",
"created": 1700000000.0,
"provider": "Anthropic",
}
_TOOL_RESPONSE_DICT: dict[str, Any] = {
@@ -1908,6 +1910,30 @@ class TestCreateChatResult:
== "openrouter"
)
def test_provider_in_response_metadata(self) -> None:
"""Test that upstream provider is surfaced in response_metadata."""
model = _make_model()
result = model._create_chat_result(_SIMPLE_RESPONSE_DICT)
msg = result.generations[0].message
assert isinstance(msg, AIMessage)
assert msg.response_metadata["provider"] == "Anthropic"
def test_provider_absent_when_not_returned(self) -> None:
"""Test that provider is not in response_metadata when API omits it."""
model = _make_model()
response: dict[str, Any] = {
"choices": [
{
"message": {"role": "assistant", "content": "Hello!"},
"finish_reason": "stop",
}
],
}
result = model._create_chat_result(response)
msg = result.generations[0].message
assert isinstance(msg, AIMessage)
assert "provider" not in msg.response_metadata
def test_reasoning_from_response(self) -> None:
"""Test that reasoning content is extracted from response."""
model = _make_model()
@@ -2152,6 +2178,7 @@ class TestCreateChatResult:
assert isinstance(msg, AIMessage)
assert "system_fingerprint" not in msg.response_metadata
assert "native_finish_reason" not in msg.response_metadata
assert "provider" not in msg.response_metadata
assert "model" not in msg.response_metadata
assert result.llm_output is not None
assert "id" not in result.llm_output
@@ -2278,6 +2305,27 @@ class TestStreamingChunks:
assert isinstance(message_chunk, AIMessageChunk)
assert message_chunk.response_metadata.get("model_provider") == "openrouter"
def test_provider_in_stream_generation_info(self) -> None:
"""Test that upstream provider is included in stream generation_info."""
chunk_dict: dict[str, Any] = {
"id": "gen-stream",
"model": MODEL_NAME,
"provider": "Anthropic",
}
choice: dict[str, Any] = {"finish_reason": "stop"}
gen_info = _create_stream_generation_info(chunk_dict, choice, MODEL_NAME)
assert gen_info["provider"] == "Anthropic"
def test_provider_absent_from_stream_generation_info(self) -> None:
"""Test that provider is omitted from generation_info when not in chunk."""
chunk_dict: dict[str, Any] = {
"id": "gen-stream",
"model": MODEL_NAME,
}
choice: dict[str, Any] = {"finish_reason": "stop"}
gen_info = _create_stream_generation_info(chunk_dict, choice, MODEL_NAME)
assert "provider" not in gen_info
def test_chunk_without_reasoning(self) -> None:
"""Test that chunk without reasoning fields works correctly."""
chunk: dict[str, Any] = {"choices": [{"delta": {"content": "Hello"}}]}
+43 -43
View File
@@ -259,7 +259,7 @@ name = "coloredlogs"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "humanfriendly", marker = "python_full_version < '3.13'" },
{ name = "humanfriendly" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0", size = 278520, upload-time = "2021-06-11T10:22:45.202Z" }
wheels = [
@@ -271,7 +271,7 @@ name = "exceptiongroup"
version = "1.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
wheels = [
@@ -292,17 +292,17 @@ name = "fastembed"
version = "0.8.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huggingface-hub", marker = "python_full_version < '3.13'" },
{ name = "loguru", marker = "python_full_version < '3.13'" },
{ name = "mmh3", marker = "python_full_version < '3.13'" },
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "huggingface-hub" },
{ name = "loguru" },
{ name = "mmh3" },
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" },
{ name = "numpy", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
{ name = "onnxruntime", marker = "python_full_version < '3.13'" },
{ name = "pillow", marker = "python_full_version < '3.13'" },
{ name = "py-rust-stemmers", marker = "python_full_version < '3.13'" },
{ name = "requests", marker = "python_full_version < '3.13'" },
{ name = "tokenizers", marker = "python_full_version < '3.13'" },
{ name = "tqdm", marker = "python_full_version < '3.13'" },
{ name = "onnxruntime" },
{ name = "pillow" },
{ name = "py-rust-stemmers" },
{ name = "requests" },
{ name = "tokenizers" },
{ name = "tqdm" },
]
sdist = { url = "https://files.pythonhosted.org/packages/26/25/58865e36b6e8a9a0d0ff905b5601aa30db97956327c0df42ec4ed6accc21/fastembed-0.8.0.tar.gz", hash = "sha256:75966edfa8b006ee78514c726bd7f6a50721dadc89305279052be9db72fd53e8", size = 75115, upload-time = "2026-03-23T16:34:41.699Z" }
wheels = [
@@ -420,15 +420,15 @@ wheels = [
[[package]]
name = "h2"
version = "4.3.0"
version = "4.4.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "hpack" },
{ name = "hyperframe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281, upload-time = "2026-08-03T11:45:09.509Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" },
{ url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636, upload-time = "2026-08-03T11:44:59.164Z" },
]
[[package]]
@@ -448,11 +448,11 @@ wheels = [
[[package]]
name = "hpack"
version = "4.1.0"
version = "4.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" }
sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300, upload-time = "2026-06-23T18:34:46.667Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" },
{ url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246, upload-time = "2026-06-23T18:34:45.472Z" },
]
[[package]]
@@ -493,14 +493,14 @@ name = "huggingface-hub"
version = "0.35.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "filelock", marker = "python_full_version < '3.13'" },
{ name = "fsspec", marker = "python_full_version < '3.13'" },
{ name = "hf-xet", marker = "(python_full_version < '3.13' and platform_machine == 'aarch64') or (python_full_version < '3.13' and platform_machine == 'amd64') or (python_full_version < '3.13' and platform_machine == 'arm64') or (python_full_version < '3.13' and platform_machine == 'x86_64')" },
{ name = "packaging", marker = "python_full_version < '3.13'" },
{ name = "pyyaml", marker = "python_full_version < '3.13'" },
{ name = "requests", marker = "python_full_version < '3.13'" },
{ name = "tqdm", marker = "python_full_version < '3.13'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "filelock" },
{ name = "fsspec" },
{ name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" },
{ name = "packaging" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/10/7e/a0a97de7c73671863ca6b3f61fa12518caf35db37825e43d63a70956738c/huggingface_hub-0.35.3.tar.gz", hash = "sha256:350932eaa5cc6a4747efae85126ee220e4ef1b54e29d31c3b45c5612ddf0b32a", size = 461798, upload-time = "2025-09-29T14:29:58.625Z" }
wheels = [
@@ -512,7 +512,7 @@ name = "humanfriendly"
version = "10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyreadline3", marker = "python_full_version < '3.13' and sys_platform == 'win32'" },
{ name = "pyreadline3", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc", size = 360702, upload-time = "2021-09-17T21:40:43.31Z" }
wheels = [
@@ -569,7 +569,7 @@ wheels = [
[[package]]
name = "langchain-core"
version = "1.5.0"
version = "1.5.3"
source = { editable = "../../core" }
dependencies = [
{ name = "jsonpatch" },
@@ -600,9 +600,9 @@ requires-dist = [
dev = [
{ name = "grandalf", specifier = ">=0.8.0,<1.0.0" },
{ name = "jupyter", specifier = ">=1.0.0,<2.0.0" },
{ name = "setuptools", specifier = ">=67.6.1,<83.0.0" },
{ name = "setuptools", specifier = ">=67.6.1,<84.0.0" },
]
lint = [{ name = "ruff", specifier = ">=0.15.0,<0.16.0" }]
lint = [{ name = "ruff", specifier = ">=0.15.0,<0.17.0" }]
test = [
{ name = "blockbuster", specifier = ">=1.5.18,<1.6.0" },
{ name = "freezegun", specifier = ">=1.2.2,<2.0.0" },
@@ -745,11 +745,11 @@ requires-dist = [
]
[package.metadata.requires-dev]
lint = [{ name = "ruff", specifier = ">=0.15.0,<0.16.0" }]
lint = [{ name = "ruff", specifier = ">=0.15.0,<0.17.0" }]
test = []
test-integration = []
typing = [
{ name = "mypy", specifier = ">=2.1.0,<2.2.0" },
{ name = "mypy", specifier = ">=2.1.0,<2.4.0" },
{ name = "types-pyyaml", specifier = ">=6.0.12.2,<7.0.0.0" },
]
@@ -864,8 +864,8 @@ name = "loguru"
version = "0.7.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "python_full_version < '3.13' and sys_platform == 'win32'" },
{ name = "win32-setctime", marker = "python_full_version < '3.13' and sys_platform == 'win32'" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "win32-setctime", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559, upload-time = "2024-12-06T11:20:56.608Z" }
wheels = [
@@ -1244,13 +1244,13 @@ name = "onnxruntime"
version = "1.23.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "coloredlogs", marker = "python_full_version < '3.13'" },
{ name = "flatbuffers", marker = "python_full_version < '3.13'" },
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "coloredlogs" },
{ name = "flatbuffers" },
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11' or python_full_version >= '3.13'" },
{ name = "numpy", version = "2.3.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11' and python_full_version < '3.13'" },
{ name = "packaging", marker = "python_full_version < '3.13'" },
{ name = "protobuf", marker = "python_full_version < '3.13'" },
{ name = "sympy", marker = "python_full_version < '3.13'" },
{ name = "packaging" },
{ name = "protobuf" },
{ name = "sympy" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/4e/28/4c76b7feca063d47880e76bee235e829bcc4adb87cc26ecff248ece31f17/onnxruntime-1.23.0-cp310-cp310-macosx_13_0_arm64.whl", hash = "sha256:009bf5ecad107a7f11af8214fcff19e844214887b38c6673bd63a25af2f6121f", size = 17078761, upload-time = "2025-09-25T19:16:41.541Z" },
@@ -2137,7 +2137,7 @@ name = "sympy"
version = "1.14.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mpmath", marker = "python_full_version < '3.13'" },
{ name = "mpmath" },
]
sdist = { url = "https://files.pythonhosted.org/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517", size = 7793921, upload-time = "2025-04-27T18:05:01.611Z" }
wheels = [
@@ -2170,7 +2170,7 @@ name = "tokenizers"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "huggingface-hub", marker = "python_full_version < '3.13'" },
{ name = "huggingface-hub" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1c/46/fb6854cec3278fbfa4a75b50232c77622bc517ac886156e6afbfa4d8fc6e/tokenizers-0.22.1.tar.gz", hash = "sha256:61de6522785310a309b3407bac22d99c4db5dba349935e99e4d15ea2226af2d9", size = 363123, upload-time = "2025-09-19T09:49:23.424Z" }
wheels = [
@@ -2234,7 +2234,7 @@ name = "tqdm"
version = "4.67.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "python_full_version < '3.13' and sys_platform == 'win32'" },
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737, upload-time = "2024-11-24T20:12:22.481Z" }
wheels = [
@@ -127,11 +127,24 @@ class RecursiveJsonSplitter:
Returns:
A list of JSON chunks.
Raises:
TypeError: If `json_data` is not a dict and cannot be converted to
one. `None` returns an empty list rather than raising. A
top-level list is only accepted when `convert_lists` is `True`.
"""
is_list_input = isinstance(json_data, list)
if convert_lists:
chunks = self._json_split(self._list_to_dict_preprocessing(json_data))
else:
chunks = self._json_split(json_data)
json_data = self._list_to_dict_preprocessing(json_data)
if json_data is not None and not isinstance(json_data, dict):
msg = f"json_data must be a dict, got {type(json_data).__name__}."
if is_list_input and not convert_lists:
msg += " Top-level lists can be split by passing convert_lists=True."
raise TypeError(msg)
chunks = self._json_split(json_data)
# Remove the last chunk if it's empty
if not chunks[-1]:
@@ -3504,6 +3504,65 @@ def test_split_json_empty_dict_value_in_large_payload() -> None:
assert found_empty, "Empty dict value was lost during splitting"
@pytest.mark.parametrize(
("data", "type_name"),
[
([{"a": 1}, {"b": 2}], "list"),
("hello world", "str"),
(123, "int"),
(True, "bool"),
],
)
def test_split_json_non_dict_input_raises(data: object, type_name: str) -> None:
"""Non-dict, non-convertible top-level input raises TypeError, not silent []."""
splitter = RecursiveJsonSplitter(max_chunk_size=50)
with pytest.raises(TypeError, match=type_name):
splitter.split_json(data) # ty: ignore[invalid-argument-type]
def test_split_json_list_without_convert_lists_error_mentions_escape_hatch() -> None:
"""The error for a bare list should point users at convert_lists=True."""
splitter = RecursiveJsonSplitter(max_chunk_size=50)
with pytest.raises(TypeError, match="convert_lists=True"):
splitter.split_json([{"a": 1}, {"b": 2}]) # ty: ignore[invalid-argument-type]
def test_split_json_list_with_convert_lists_still_works() -> None:
"""Regression guard: convert_lists=True must keep working for top-level lists."""
splitter = RecursiveJsonSplitter(max_chunk_size=50)
data = [{"a": 1}, {"b": 2}]
chunks = splitter.split_json(data, convert_lists=True) # ty: ignore[invalid-argument-type]
assert chunks == [{"0": {"a": 1}, "1": {"b": 2}}]
def test_split_json_none_input_returns_empty_list() -> None:
"""Regression guard: None stays a no-op (matches split_json({}) -> [])."""
splitter = RecursiveJsonSplitter(max_chunk_size=50)
assert splitter.split_json(None) == [] # ty: ignore[invalid-argument-type]
def test_split_json_none_input_returns_empty_list_with_convert_lists() -> None:
"""Regression guard: None stays a no-op even when convert_lists=True."""
splitter = RecursiveJsonSplitter(max_chunk_size=50)
assert splitter.split_json(None, convert_lists=True) == [] # ty: ignore[invalid-argument-type]
def test_split_json_convert_lists_true_non_list_no_misleading_hint() -> None:
"""The convert_lists=True hint shouldn't appear when it wouldn't help."""
splitter = RecursiveJsonSplitter(max_chunk_size=50)
with pytest.raises(TypeError, match="str") as exc_info:
splitter.split_json("hello world", convert_lists=True) # ty: ignore[invalid-argument-type]
assert "convert_lists" not in str(exc_info.value)
def test_powershell_code_splitter_short_code() -> None:
splitter = RecursiveCharacterTextSplitter.from_language(
Language.POWERSHELL, chunk_size=60, chunk_overlap=0