diff --git a/libs/core/langchain_core/callbacks/usage.py b/libs/core/langchain_core/callbacks/usage.py index 9fbcbb798d..17ad7781af 100644 --- a/libs/core/langchain_core/callbacks/usage.py +++ b/libs/core/langchain_core/callbacks/usage.py @@ -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) diff --git a/libs/core/langchain_core/language_models/chat_models.py b/libs/core/langchain_core/language_models/chat_models.py index bf5e6e58f9..ae75c67604 100644 --- a/libs/core/langchain_core/language_models/chat_models.py +++ b/libs/core/langchain_core/language_models/chat_models.py @@ -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 diff --git a/libs/core/langchain_core/language_models/llms.py b/libs/core/langchain_core/language_models/llms.py index 14b09ac29b..7903eec452 100644 --- a/libs/core/langchain_core/language_models/llms.py +++ b/libs/core/langchain_core/language_models/llms.py @@ -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 diff --git a/libs/core/langchain_core/load/serializable.py b/libs/core/langchain_core/load/serializable.py index 4764282655..753e9feb82 100644 --- a/libs/core/langchain_core/load/serializable.py +++ b/libs/core/langchain_core/load/serializable.py @@ -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 diff --git a/libs/core/langchain_core/messages/block_translators/anthropic.py b/libs/core/langchain_core/messages/block_translators/anthropic.py index fb70388d60..3f52b1c875 100644 --- a/libs/core/langchain_core/messages/block_translators/anthropic.py +++ b/libs/core/langchain_core/messages/block_translators/anthropic.py @@ -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"], diff --git a/libs/core/langchain_core/prompts/dict.py b/libs/core/langchain_core/prompts/dict.py index bb68d4f28d..b1323921de 100644 --- a/libs/core/langchain_core/prompts/dict.py +++ b/libs/core/langchain_core/prompts/dict.py @@ -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 diff --git a/libs/core/langchain_core/prompts/structured.py b/libs/core/langchain_core/prompts/structured.py index 77aeef00e1..5cc0160375 100644 --- a/libs/core/langchain_core/prompts/structured.py +++ b/libs/core/langchain_core/prompts/structured.py @@ -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__( diff --git a/libs/core/langchain_core/runnables/utils.py b/libs/core/langchain_core/runnables/utils.py index 080c6d01cb..0bc6b0780a 100644 --- a/libs/core/langchain_core/runnables/utils.py +++ b/libs/core/langchain_core/runnables/utils.py @@ -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 diff --git a/libs/core/langchain_core/tools/base.py b/libs/core/langchain_core/tools/base.py index 56ea214518..89fe3c4509 100644 --- a/libs/core/langchain_core/tools/base.py +++ b/libs/core/langchain_core/tools/base.py @@ -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: diff --git a/libs/core/langchain_core/tools/convert.py b/libs/core/langchain_core/tools/convert.py index 6b4a8c37c2..a4e7e510d3 100644 --- a/libs/core/langchain_core/tools/convert.py +++ b/libs/core/langchain_core/tools/convert.py @@ -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 diff --git a/libs/core/langchain_core/utils/_merge.py b/libs/core/langchain_core/utils/_merge.py index 8ddc797f62..15f06cc4e4 100644 --- a/libs/core/langchain_core/utils/_merge.py +++ b/libs/core/langchain_core/utils/_merge.py @@ -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, ""} diff --git a/libs/core/langchain_core/utils/function_calling.py b/libs/core/langchain_core/utils/function_calling.py index 1e63802a5d..301b5241e1 100644 --- a/libs/core/langchain_core/utils/function_calling.py +++ b/libs/core/langchain_core/utils/function_calling.py @@ -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 ) diff --git a/libs/core/langchain_core/version.py b/libs/core/langchain_core/version.py index bd67030feb..473710ce19 100644 --- a/libs/core/langchain_core/version.py +++ b/libs/core/langchain_core/version.py @@ -1,3 +1,3 @@ """Version information for `langchain-core`.""" -VERSION = "1.5.3" +VERSION = "1.5.4" diff --git a/libs/core/pyproject.toml b/libs/core/pyproject.toml index 767c897d0b..b1192229e0 100644 --- a/libs/core/pyproject.toml +++ b/libs/core/pyproject.toml @@ -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", diff --git a/libs/core/tests/unit_tests/callbacks/test_usage_callback.py b/libs/core/tests/unit_tests/callbacks/test_usage_callback.py index b583faecff..9d709dc079 100644 --- a/libs/core/tests/unit_tests/callbacks/test_usage_callback.py +++ b/libs/core/tests/unit_tests/callbacks/test_usage_callback.py @@ -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} diff --git a/libs/core/tests/unit_tests/language_models/chat_models/test_cache.py b/libs/core/tests/unit_tests/language_models/chat_models/test_cache.py index 804c7c7c90..7715f184b5 100644 --- a/libs/core/tests/unit_tests/language_models/chat_models/test_cache.py +++ b/libs/core/tests/unit_tests/language_models/chat_models/test_cache.py @@ -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.""" diff --git a/libs/core/tests/unit_tests/language_models/llms/test_cache.py b/libs/core/tests/unit_tests/language_models/llms/test_cache.py index 720c247a9c..e3379682c6 100644 --- a/libs/core/tests/unit_tests/language_models/llms/test_cache.py +++ b/libs/core/tests/unit_tests/language_models/llms/test_cache.py @@ -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() diff --git a/libs/core/tests/unit_tests/load/test_serializable.py b/libs/core/tests/unit_tests/load/test_serializable.py index 4e4b08f2fc..00cb1c3454 100644 --- a/libs/core/tests/unit_tests/load/test_serializable.py +++ b/libs/core/tests/unit_tests/load/test_serializable.py @@ -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 diff --git a/libs/core/tests/unit_tests/messages/block_translators/test_anthropic.py b/libs/core/tests/unit_tests/messages/block_translators/test_anthropic.py index 1b0ec03586..e67fb37998 100644 --- a/libs/core/tests/unit_tests/messages/block_translators/test_anthropic.py +++ b/libs/core/tests/unit_tests/messages/block_translators/test_anthropic.py @@ -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", + }, + }, + ], + }, + ] diff --git a/libs/core/tests/unit_tests/prompts/test_dict.py b/libs/core/tests/unit_tests/prompts/test_dict.py index 5b4bca8fce..de3e23cd73 100644 --- a/libs/core/tests/unit_tests/prompts/test_dict.py +++ b/libs/core/tests/unit_tests/prompts/test_dict.py @@ -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]} diff --git a/libs/core/tests/unit_tests/prompts/test_structured.py b/libs/core/tests/unit_tests/prompts/test_structured.py index b69a7ba9e0..83d9d99a69 100644 --- a/libs/core/tests/unit_tests/prompts/test_structured.py +++ b/libs/core/tests/unit_tests/prompts/test_structured.py @@ -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"} diff --git a/libs/core/tests/unit_tests/runnables/__snapshots__/test_fallbacks.ambr b/libs/core/tests/unit_tests/runnables/__snapshots__/test_fallbacks.ambr index 7ed1ee2f5d..282c7e792b 100644 --- a/libs/core/tests/unit_tests/runnables/__snapshots__/test_fallbacks.ambr +++ b/libs/core/tests/unit_tests/runnables/__snapshots__/test_fallbacks.ambr @@ -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" } ], diff --git a/libs/core/tests/unit_tests/runnables/__snapshots__/test_runnable.ambr b/libs/core/tests/unit_tests/runnables/__snapshots__/test_runnable.ambr index 282fc2627f..65b14f77ca 100644 --- a/libs/core/tests/unit_tests/runnables/__snapshots__/test_runnable.ambr +++ b/libs/core/tests/unit_tests/runnables/__snapshots__/test_runnable.ambr @@ -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": { diff --git a/libs/core/tests/unit_tests/runnables/test_utils.py b/libs/core/tests/unit_tests/runnables/test_utils.py index 031bb5aa23..2b77caf342 100644 --- a/libs/core/tests/unit_tests/runnables/test_utils.py +++ b/libs/core/tests/unit_tests/runnables/test_utils.py @@ -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}}) diff --git a/libs/core/tests/unit_tests/test_messages.py b/libs/core/tests/unit_tests/test_messages.py index c6e4f5b50b..cf92d4eeae 100644 --- a/libs/core/tests/unit_tests/test_messages.py +++ b/libs/core/tests/unit_tests/test_messages.py @@ -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." diff --git a/libs/core/tests/unit_tests/test_tools.py b/libs/core/tests/unit_tests/test_tools.py index 522500815f..2aaf509c5a 100644 --- a/libs/core/tests/unit_tests/test_tools.py +++ b/libs/core/tests/unit_tests/test_tools.py @@ -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: diff --git a/libs/core/tests/unit_tests/utils/test_function_calling.py b/libs/core/tests/unit_tests/utils/test_function_calling.py index 395ecbc4e4..e53c034406 100644 --- a/libs/core/tests/unit_tests/utils/test_function_calling.py +++ b/libs/core/tests/unit_tests/utils/test_function_calling.py @@ -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], diff --git a/libs/core/tests/unit_tests/utils/test_utils.py b/libs/core/tests/unit_tests/utils/test_utils.py index 95c7cf50a0..fe123a7ed3 100644 --- a/libs/core/tests/unit_tests/utils/test_utils.py +++ b/libs/core/tests/unit_tests/utils/test_utils.py @@ -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( diff --git a/libs/core/uv.lock b/libs/core/uv.lock index 32965d12fe..4403397d64 100644 --- a/libs/core/uv.lock +++ b/libs/core/uv.lock @@ -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" }, diff --git a/libs/langchain/uv.lock b/libs/langchain/uv.lock index d296d131c1..7f08003b81 100644 --- a/libs/langchain/uv.lock +++ b/libs/langchain/uv.lock @@ -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" }, diff --git a/libs/langchain_v1/langchain/__init__.py b/libs/langchain_v1/langchain/__init__.py index 89a7ea1e79..328771e077 100644 --- a/libs/langchain_v1/langchain/__init__.py +++ b/libs/langchain_v1/langchain/__init__.py @@ -1,3 +1,3 @@ """Main entrypoint into LangChain.""" -__version__ = "1.3.14" +__version__ = "1.3.15" diff --git a/libs/langchain_v1/langchain/agents/factory.py b/libs/langchain_v1/langchain/agents/factory.py index afda461d9f..b8712a1d55 100644 --- a/libs/langchain_v1/langchain/agents/factory.py +++ b/libs/langchain_v1/langchain/agents/factory.py @@ -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 diff --git a/libs/langchain_v1/langchain/agents/middleware/__init__.py b/libs/langchain_v1/langchain/agents/middleware/__init__.py index 3b642e5398..988992a501 100644 --- a/libs/langchain_v1/langchain/agents/middleware/__init__.py +++ b/libs/langchain_v1/langchain/agents/middleware/__init__.py @@ -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", ] diff --git a/libs/langchain_v1/langchain/agents/middleware/_trace_policy.py b/libs/langchain_v1/langchain/agents/middleware/_trace_policy.py new file mode 100644 index 0000000000..f07d79c285 --- /dev/null +++ b/libs/langchain_v1/langchain/agents/middleware/_trace_policy.py @@ -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"), + ) diff --git a/libs/langchain_v1/langchain/agents/middleware/internal_call_transformer.py b/libs/langchain_v1/langchain/agents/middleware/internal_call_transformer.py index bb8933e98f..cac55b98ed 100644 --- a/libs/langchain_v1/langchain/agents/middleware/internal_call_transformer.py +++ b/libs/langchain_v1/langchain/agents/middleware/internal_call_transformer.py @@ -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): diff --git a/libs/langchain_v1/langchain/agents/middleware/summarization.py b/libs/langchain_v1/langchain/agents/middleware/summarization.py index 0695693a84..407194087b 100644 --- a/libs/langchain_v1/langchain/agents/middleware/summarization.py +++ b/libs/langchain_v1/langchain/agents/middleware/summarization.py @@ -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 = """ @@ -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.""" diff --git a/libs/langchain_v1/langchain/agents/middleware/tool_emulator.py b/libs/langchain_v1/langchain/agents/middleware/tool_emulator.py index fe0b1766ea..51e3941e8e 100644 --- a/libs/langchain_v1/langchain/agents/middleware/tool_emulator.py +++ b/libs/langchain_v1/langchain/agents/middleware/tool_emulator.py @@ -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: diff --git a/libs/langchain_v1/langchain/agents/middleware/types.py b/libs/langchain_v1/langchain/agents/middleware/types.py index c8d7f2b38f..b7d5b8050a 100644 --- a/libs/langchain_v1/langchain/agents/middleware/types.py +++ b/libs/langchain_v1/langchain/agents/middleware/types.py @@ -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, }, diff --git a/libs/langchain_v1/pyproject.toml b/libs/langchain_v1/pyproject.toml index 54f36c91f8..7e9e1aca0d 100644 --- a/libs/langchain_v1/pyproject.toml +++ b/libs/langchain_v1/pyproject.toml @@ -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", ] diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/core/test_internal_call_transformer.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/core/test_internal_call_transformer.py index d5f5d37e57..7f6c8a5170 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/middleware/core/test_internal_call_transformer.py +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/core/test_internal_call_transformer.py @@ -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: diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/core/test_tracing.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/core/test_tracing.py new file mode 100644 index 0000000000..7e5a084b8e --- /dev/null +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/core/test_tracing.py @@ -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}) == {} diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/core/test_transformers.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/core/test_transformers.py index a4c2f0902c..92c4c82713 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/middleware/core/test_transformers.py +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/core/test_transformers.py @@ -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] diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/core/test_wrap_tool_call.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/core/test_wrap_tool_call.py index 9620bf400e..e1a5e3dc69 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/middleware/core/test_wrap_tool_call.py +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/core/test_wrap_tool_call.py @@ -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 = [] diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_pii.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_pii.py index 4513755f9f..4945e49a5f 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_pii.py +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_pii.py @@ -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] diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_summarization.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_summarization.py index a01b94e327..1c6a34579f 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_summarization.py +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_summarization.py @@ -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: diff --git a/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_tool_emulator.py b/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_tool_emulator.py index d2a28c975f..54feacc868 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_tool_emulator.py +++ b/libs/langchain_v1/tests/unit_tests/agents/middleware/implementations/test_tool_emulator.py @@ -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: diff --git a/libs/langchain_v1/tests/unit_tests/agents/test_agent_streaming.py b/libs/langchain_v1/tests/unit_tests/agents/test_agent_streaming.py index 4aa58a3f1a..8ec53edcbe 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/test_agent_streaming.py +++ b/libs/langchain_v1/tests/unit_tests/agents/test_agent_streaming.py @@ -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!"] diff --git a/libs/langchain_v1/tests/unit_tests/agents/test_state_schema.py b/libs/langchain_v1/tests/unit_tests/agents/test_state_schema.py index 65f10da9d2..dcb8772eaf 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/test_state_schema.py +++ b/libs/langchain_v1/tests/unit_tests/agents/test_state_schema.py @@ -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.""" diff --git a/libs/langchain_v1/tests/unit_tests/agents/test_subagent_transformer.py b/libs/langchain_v1/tests/unit_tests/agents/test_subagent_transformer.py index dc6f1bd99c..e78f1c392d 100644 --- a/libs/langchain_v1/tests/unit_tests/agents/test_subagent_transformer.py +++ b/libs/langchain_v1/tests/unit_tests/agents/test_subagent_transformer.py @@ -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 diff --git a/libs/langchain_v1/uv.lock b/libs/langchain_v1/uv.lock index f7bb40e6bd..4391f3e050 100644 --- a/libs/langchain_v1/uv.lock +++ b/libs/langchain_v1/uv.lock @@ -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]] diff --git a/libs/partners/anthropic/langchain_anthropic/_version.py b/libs/partners/anthropic/langchain_anthropic/_version.py index 9aa0a74cc5..af5e132d09 100644 --- a/libs/partners/anthropic/langchain_anthropic/_version.py +++ b/libs/partners/anthropic/langchain_anthropic/_version.py @@ -1,3 +1,3 @@ """Version information for `langchain-anthropic`.""" -__version__ = "1.5.4" +__version__ = "1.5.5" diff --git a/libs/partners/anthropic/langchain_anthropic/chat_models.py b/libs/partners/anthropic/langchain_anthropic/chat_models.py index 76e0aa8951..9d29118393 100644 --- a/libs/partners/anthropic/langchain_anthropic/chat_models.py +++ b/libs/partners/anthropic/langchain_anthropic/chat_models.py @@ -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 diff --git a/libs/partners/anthropic/langchain_anthropic/data/_profiles.py b/libs/partners/anthropic/langchain_anthropic/data/_profiles.py index 92da1d7f6c..6d3df5dc9f 100644 --- a/libs/partners/anthropic/langchain_anthropic/data/_profiles.py +++ b/libs/partners/anthropic/langchain_anthropic/data/_profiles.py @@ -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, diff --git a/libs/partners/anthropic/langchain_anthropic/data/profile_augmentations.toml b/libs/partners/anthropic/langchain_anthropic/data/profile_augmentations.toml index c97a7f08a3..cc7c3864aa 100644 --- a/libs/partners/anthropic/langchain_anthropic/data/profile_augmentations.toml +++ b/libs/partners/anthropic/langchain_anthropic/data/profile_augmentations.toml @@ -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" diff --git a/libs/partners/anthropic/langchain_anthropic/middleware/anthropic_tools.py b/libs/partners/anthropic/langchain_anthropic/middleware/anthropic_tools.py index ce83f980d5..ef557cb88e 100644 --- a/libs/partners/anthropic/langchain_anthropic/middleware/anthropic_tools.py +++ b/libs/partners/anthropic/langchain_anthropic/middleware/anthropic_tools.py @@ -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: diff --git a/libs/partners/anthropic/pyproject.toml b/libs/partners/anthropic/pyproject.toml index 4823077261..1ade713c8b 100644 --- a/libs/partners/anthropic/pyproject.toml +++ b/libs/partners/anthropic/pyproject.toml @@ -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", ] diff --git a/libs/partners/anthropic/tests/unit_tests/middleware/test_anthropic_tools.py b/libs/partners/anthropic/tests/unit_tests/middleware/test_anthropic_tools.py index 212c0eb659..f69ee111bc 100644 --- a/libs/partners/anthropic/tests/unit_tests/middleware/test_anthropic_tools.py +++ b/libs/partners/anthropic/tests/unit_tests/middleware/test_anthropic_tools.py @@ -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.""" diff --git a/libs/partners/anthropic/tests/unit_tests/test_chat_models.py b/libs/partners/anthropic/tests/unit_tests/test_chat_models.py index 079f7116a9..fb4d00ae63 100644 --- a/libs/partners/anthropic/tests/unit_tests/test_chat_models.py +++ b/libs/partners/anthropic/tests/unit_tests/test_chat_models.py @@ -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.""" diff --git a/libs/partners/anthropic/uv.lock b/libs/partners/anthropic/uv.lock index 3bbed224b0..4d680d83b4 100644 --- a/libs/partners/anthropic/uv.lock +++ b/libs/partners/anthropic/uv.lock @@ -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]] diff --git a/libs/partners/exa/langchain_exa/retrievers.py b/libs/partners/exa/langchain_exa/retrievers.py index f50196aa5e..eaa6bc375f 100644 --- a/libs/partners/exa/langchain_exa/retrievers.py +++ b/libs/partners/exa/langchain_exa/retrievers.py @@ -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 diff --git a/libs/partners/exa/tests/unit_tests/test_retrievers.py b/libs/partners/exa/tests/unit_tests/test_retrievers.py new file mode 100644 index 0000000000..3d807d86bb --- /dev/null +++ b/libs/partners/exa/tests/unit_tests/test_retrievers.py @@ -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 diff --git a/libs/partners/fireworks/langchain_fireworks/chat_models.py b/libs/partners/fireworks/langchain_fireworks/chat_models.py index fd5fd07957..42b8f15513 100644 --- a/libs/partners/fireworks/langchain_fireworks/chat_models.py +++ b/libs/partners/fireworks/langchain_fireworks/chat_models.py @@ -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 diff --git a/libs/partners/fireworks/tests/unit_tests/test_chat_models.py b/libs/partners/fireworks/tests/unit_tests/test_chat_models.py index 87088b5dcf..35263d1407 100644 --- a/libs/partners/fireworks/tests/unit_tests/test_chat_models.py +++ b/libs/partners/fireworks/tests/unit_tests/test_chat_models.py @@ -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() diff --git a/libs/partners/huggingface/langchain_huggingface/data/_profiles.py b/libs/partners/huggingface/langchain_huggingface/data/_profiles.py index a8067aa073..910355f5bf 100644 --- a/libs/partners/huggingface/langchain_huggingface/data/_profiles.py +++ b/libs/partners/huggingface/langchain_huggingface/data/_profiles.py @@ -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", diff --git a/libs/partners/huggingface/uv.lock b/libs/partners/huggingface/uv.lock index 3f12f918d0..0a0ba4f8e5 100644 --- a/libs/partners/huggingface/uv.lock +++ b/libs/partners/huggingface/uv.lock @@ -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]] diff --git a/libs/partners/openai/langchain_openai/_version.py b/libs/partners/openai/langchain_openai/_version.py index 80f44ae03a..0da670c805 100644 --- a/libs/partners/openai/langchain_openai/_version.py +++ b/libs/partners/openai/langchain_openai/_version.py @@ -1,3 +1,3 @@ """Version information for `langchain-openai`.""" -__version__ = "1.4.1" +__version__ = "1.4.3" diff --git a/libs/partners/openai/langchain_openai/chat_models/_compat.py b/libs/partners/openai/langchain_openai/chat_models/_compat.py index cc04a9235f..d64c854ad7 100644 --- a/libs/partners/openai/langchain_openai/chat_models/_compat.py +++ b/libs/partners/openai/langchain_openai/chat_models/_compat.py @@ -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) diff --git a/libs/partners/openai/langchain_openai/chat_models/base.py b/libs/partners/openai/langchain_openai/chat_models/base.py index ac234a036b..9cd4b72a45 100644 --- a/libs/partners/openai/langchain_openai/chat_models/base.py +++ b/libs/partners/openai/langchain_openai/chat_models/base.py @@ -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: diff --git a/libs/partners/openai/pyproject.toml b/libs/partners/openai/pyproject.toml index 3ff5a1f739..eeb369c98c 100644 --- a/libs/partners/openai/pyproject.toml +++ b/libs/partners/openai/pyproject.toml @@ -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", ] diff --git a/libs/partners/openai/tests/unit_tests/chat_models/test_base.py b/libs/partners/openai/tests/unit_tests/chat_models/test_base.py index caa60f0d30..bfca532a81 100644 --- a/libs/partners/openai/tests/unit_tests/chat_models/test_base.py +++ b/libs/partners/openai/tests/unit_tests/chat_models/test_base.py @@ -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() diff --git a/libs/partners/openai/uv.lock b/libs/partners/openai/uv.lock index 7931a70d34..e2ca7771f0 100644 --- a/libs/partners/openai/uv.lock +++ b/libs/partners/openai/uv.lock @@ -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]] diff --git a/libs/partners/openrouter/langchain_openrouter/chat_models.py b/libs/partners/openrouter/langchain_openrouter/chat_models.py index 293c2d0a4c..e15ad993d7 100644 --- a/libs/partners/openrouter/langchain_openrouter/chat_models.py +++ b/libs/partners/openrouter/langchain_openrouter/chat_models.py @@ -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"): diff --git a/libs/partners/openrouter/langchain_openrouter/data/_profiles.py b/libs/partners/openrouter/langchain_openrouter/data/_profiles.py index 8d495cea94..d5cce90424 100644 --- a/libs/partners/openrouter/langchain_openrouter/data/_profiles.py +++ b/libs/partners/openrouter/langchain_openrouter/data/_profiles.py @@ -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, diff --git a/libs/partners/openrouter/tests/integration_tests/test_chat_models.py b/libs/partners/openrouter/tests/integration_tests/test_chat_models.py index 9da103a0e0..447b26c828 100644 --- a/libs/partners/openrouter/tests/integration_tests/test_chat_models.py +++ b/libs/partners/openrouter/tests/integration_tests/test_chat_models.py @@ -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: diff --git a/libs/partners/openrouter/tests/unit_tests/test_chat_models.py b/libs/partners/openrouter/tests/unit_tests/test_chat_models.py index 3d4c6aeaa8..301ed3cf6f 100644 --- a/libs/partners/openrouter/tests/unit_tests/test_chat_models.py +++ b/libs/partners/openrouter/tests/unit_tests/test_chat_models.py @@ -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"}}]} diff --git a/libs/partners/qdrant/uv.lock b/libs/partners/qdrant/uv.lock index 788e686d83..aa20c2c9be 100644 --- a/libs/partners/qdrant/uv.lock +++ b/libs/partners/qdrant/uv.lock @@ -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 = [ diff --git a/libs/text-splitters/langchain_text_splitters/json.py b/libs/text-splitters/langchain_text_splitters/json.py index 2847a8fe53..c684fd3a86 100644 --- a/libs/text-splitters/langchain_text_splitters/json.py +++ b/libs/text-splitters/langchain_text_splitters/json.py @@ -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]: diff --git a/libs/text-splitters/tests/unit_tests/test_text_splitters.py b/libs/text-splitters/tests/unit_tests/test_text_splitters.py index e226501d73..a57f75db88 100644 --- a/libs/text-splitters/tests/unit_tests/test_text_splitters.py +++ b/libs/text-splitters/tests/unit_tests/test_text_splitters.py @@ -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