mirror of
https://github.com/langchain-ai/langchain.git
synced 2026-10-05 09:25:14 +03:00
Merge branch 'master' into imnishitha/openai/responses-api-strict-default
This commit is contained in:
78 files changed
+2259
-489
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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__(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,3 +1,3 @@
|
||||
"""Version information for `langchain-core`."""
|
||||
|
||||
VERSION = "1.5.3"
|
||||
VERSION = "1.5.4"
|
||||
@@ -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."
|
||||
|
||||
@@ -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(
|
||||
|
||||
Generated
+7
-5
@@ -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" },
|
||||
|
||||
Generated
+7
-7
@@ -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,3 +1,3 @@
|
||||
"""Main entrypoint into LangChain."""
|
||||
|
||||
__version__ = "1.3.14"
|
||||
__version__ = "1.3.15"
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
+11
-5
@@ -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]
|
||||
|
||||
+229
-4
@@ -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:
|
||||
|
||||
+34
-11
@@ -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
|
||||
|
||||
Generated
+18
-18
@@ -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:
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Generated
+8
-8
@@ -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",
|
||||
|
||||
Generated
+9
-9
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
Generated
+13
-13
@@ -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"}}]}
|
||||
|
||||
Generated
+43
-43
@@ -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
|
||||
|
||||
Reference in new issue
Block a user