From 167c537886fadaeaec2eb36a1d9bfa1e6cd42caa Mon Sep 17 00:00:00 2001 From: Nishitha M <32355027+imnishitha@users.noreply.github.com> Date: Thu, 6 Aug 2026 18:17:52 -0400 Subject: [PATCH 01/47] feat(langchain): add `state_schema` param to `wrap_tool_call` (#39292) Closes #36409 `wrap_tool_call` was the only middleware decorator lacking a `state_schema` parameter (`before_model`, `after_model`, `wrap_model_call`, `before_agent`, and `after_agent` all have it). Added it for consistency. Co-authored-by: @acookie <14118569+acoo4ie@user.noreply.gitee.com> --- .../langchain/agents/middleware/types.py | 50 +++++++++++++++---- .../middleware/core/test_wrap_tool_call.py | 25 +++++++++- 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/libs/langchain_v1/langchain/agents/middleware/types.py b/libs/langchain_v1/langchain/agents/middleware/types.py index c8d7f2b38f..0fa145bfdf 100644 --- a/libs/langchain_v1/langchain/agents/middleware/types.py +++ b/libs/langchain_v1/langchain/agents/middleware/types.py @@ -999,6 +999,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 +1176,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 +1365,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 +1567,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 +2006,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 +2048,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 +2114,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 +2148,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 +2161,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 +2172,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/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 = [] From ea52f5b409a3cba07a741a81076334e5c1e55975 Mon Sep 17 00:00:00 2001 From: Nishitha M <32355027+imnishitha@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:06:10 -0400 Subject: [PATCH 02/47] refactor(langchain): update doc strings (#39305) Update doc strings --- .../middleware/internal_call_transformer.py | 69 +++++-------------- 1 file changed, 18 insertions(+), 51 deletions(-) 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): From f48fa9478de0dfc51604d5fbe9e6ed4f0143eaac Mon Sep 17 00:00:00 2001 From: "langchain-oss-model-profiles[bot]" <262694797+langchain-oss-model-profiles[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:39:16 -0400 Subject: [PATCH 03/47] chore(model-profiles): refresh model profile data (#39299) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated refresh of model profile data for all in-monorepo partner integrations via `langchain-profiles refresh`. 🤖 Generated by the [`refresh_model_profiles` workflow](https://github.com/langchain-ai/langchain/blob/master/.github/workflows/refresh_model_profiles.yml). ## Summary of changes **5 added · 1 removed · 7 changed** across 3 provider(s).
anthropic **➖ 1 removed** - `claude-opus-4-1-20250805` **✏️ 1 changed** - `claude-opus-4-1`: removed attachments; audio input no → unset; audio output no → unset; removed image input; image output no → unset; last updated `2025-08-05` → unset; max input tokens 200,000 → unset; max output tokens 32,000 → unset; display name `Claude Opus 4.1 (latest)` → unset; open weights no → unset; removed reasoning; release date `2025-08-05` → unset; status `deprecated` → unset; removed temperature control; removed text input; removed text output; removed tool calling; video input no → unset; video output no → unset
huggingface **➕ 3 added** - `Qwen/Qwen3-235B-A22B-Instruct-2507` — 262,144 ctx, 16,384 out, tools - `deepseek-ai/DeepSeek-V3` — 64,000 ctx, 8,192 out, tools - `deepseek-ai/DeepSeek-V3.1` — 131,072 ctx, 8,192 out, reasoning, tools
openrouter **➕ 2 added** - `inclusionai/ling-3.0-flash` — 131,072 ctx, 16,384 out, reasoning, tools - `meta/muse-spark-1.2` — 1,048,576 ctx, 1,048,576 out, text+image+audio+video+pdf in, reasoning, tools **✏️ 6 changed** - `deepseek/deepseek-v4-flash`: max output tokens 393,216 → 131,072 - `inclusionai/ling-3.0-flash:free`: added open weights - `qwen/qwen3-235b-a22b-2507`: max output tokens 32,768 → 16,384 - `qwen/qwen3.6-27b`: max output tokens 131,072 → 262,144 - `upstage/solar-pro-3`: max input tokens 128,000 → 131,072; max output tokens 128,000 → 131,072 - `z-ai/glm-5.1`: max output tokens 128,000 → 131,072
--------- Co-authored-by: mdrxy <61371264+mdrxy@users.noreply.github.com> Co-authored-by: Mason Daugherty Co-authored-by: Mason Daugherty --- .../langchain_anthropic/data/_profiles.py | 52 +-------------- .../tests/unit_tests/test_chat_models.py | 4 -- .../langchain_huggingface/data/_profiles.py | 66 +++++++++++++++++++ .../langchain_openrouter/data/_profiles.py | 59 +++++++++++++++-- 4 files changed, 121 insertions(+), 60 deletions(-) diff --git a/libs/partners/anthropic/langchain_anthropic/data/_profiles.py b/libs/partners/anthropic/langchain_anthropic/data/_profiles.py index 92da1d7f6c..418757991c 100644 --- a/libs/partners/anthropic/langchain_anthropic/data/_profiles.py +++ b/libs/partners/anthropic/langchain_anthropic/data/_profiles.py @@ -95,57 +95,11 @@ _PROFILES: dict[str, dict[str, Any]] = { "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, + "image_url_inputs": True, "pdf_inputs": True, - "video_inputs": False, - "text_outputs": True, - "image_outputs": False, - "audio_outputs": False, - "video_outputs": False, - "reasoning_output": True, - "tool_calling": True, + "pdf_tool_message": True, + "image_tool_message": 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": { 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..76164e4d5d 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 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/openrouter/langchain_openrouter/data/_profiles.py b/libs/partners/openrouter/langchain_openrouter/data/_profiles.py index 8d495cea94..fae15cc1c0 100644 --- a/libs/partners/openrouter/langchain_openrouter/data/_profiles.py +++ b/libs/partners/openrouter/langchain_openrouter/data/_profiles.py @@ -1205,7 +1205,7 @@ _PROFILES: dict[str, dict[str, Any]] = { "last_updated": "2026-04-24", "open_weights": True, "max_input_tokens": 1048576, - "max_output_tokens": 393216, + "max_output_tokens": 131072, "text_inputs": True, "image_inputs": False, "audio_inputs": False, @@ -2070,11 +2070,33 @@ _PROFILES: dict[str, dict[str, Any]] = { "temperature": True, "tool_call_streaming": True, }, + "inclusionai/ling-3.0-flash": { + "name": "Ling-3.0-flash", + "release_date": "2026-07-23", + "last_updated": "2026-07-23", + "open_weights": True, + "max_input_tokens": 131072, + "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": True, + "tool_calling": True, + "structured_output": False, + "attachment": False, + "temperature": True, + "tool_call_streaming": True, + }, "inclusionai/ling-3.0-flash:free": { "name": "Ling-3.0-flash (free)", "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, @@ -2423,6 +2445,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", @@ -5497,7 +5542,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, @@ -6179,7 +6224,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, @@ -6816,8 +6861,8 @@ _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, @@ -7218,7 +7263,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, From 016e7cb9769e82d720e76fb25dbee3f9f4f188e7 Mon Sep 17 00:00:00 2001 From: ccurme Date: Fri, 7 Aug 2026 09:14:09 -0400 Subject: [PATCH 04/47] fix(openai): handle `ContextWindowExceededError` (#39300) --- .../langchain_openai/chat_models/base.py | 1 + .../tests/unit_tests/chat_models/test_base.py | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/libs/partners/openai/langchain_openai/chat_models/base.py b/libs/partners/openai/langchain_openai/chat_models/base.py index 576b24f7eb..e39433fb47 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 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 e4c953f6d2..5c54c458e6 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 @@ -4507,6 +4507,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() From 8ba2d26622b68382e4afb566406293a6298198a1 Mon Sep 17 00:00:00 2001 From: ccurme Date: Fri, 7 Aug 2026 09:20:00 -0400 Subject: [PATCH 05/47] release(openai): 1.4.2 (#39322) --- .../partners/openai/langchain_openai/_version.py | 2 +- libs/partners/openai/pyproject.toml | 4 ++-- libs/partners/openai/uv.lock | 16 ++++++++-------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/libs/partners/openai/langchain_openai/_version.py b/libs/partners/openai/langchain_openai/_version.py index 80f44ae03a..84d82c9fe8 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.2" diff --git a/libs/partners/openai/pyproject.toml b/libs/partners/openai/pyproject.toml index 3ff5a1f739..c6bbaf2d00 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.2" 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/uv.lock b/libs/partners/openai/uv.lock index 7931a70d34..3c75444ca8 100644 --- a/libs/partners/openai/uv.lock +++ b/libs/partners/openai/uv.lock @@ -636,7 +636,7 @@ requires-dist = [ 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.3" 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.2" 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,11 +854,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" }, ] From c9b301a8b7487f314117eba920016d6205f6001c Mon Sep 17 00:00:00 2001 From: ccurme Date: Fri, 7 Aug 2026 09:34:16 -0400 Subject: [PATCH 06/47] chore(openai): update docstring for `include_response_headers` (#39326) --- libs/partners/openai/langchain_openai/chat_models/base.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/libs/partners/openai/langchain_openai/chat_models/base.py b/libs/partners/openai/langchain_openai/chat_models/base.py index e39433fb47..9a5b159b66 100644 --- a/libs/partners/openai/langchain_openai/chat_models/base.py +++ b/libs/partners/openai/langchain_openai/chat_models/base.py @@ -985,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 From a4fb5f1a7a84861862f2c773855a7ddb1c29639f Mon Sep 17 00:00:00 2001 From: Nishitha M <32355027+imnishitha@users.noreply.github.com> Date: Fri, 7 Aug 2026 09:48:12 -0400 Subject: [PATCH 07/47] fix(langchain): handle import error in `LLMToolEmulator` by `model` (#39290) Closes #34274 - Removed the redundant double-check (`not self.emulate_all and tools is not None`) when building `tools_to_emulate`. - The middleware now: raises an actionable `ImportError` when `langchain-anthropic` isn't installed and emits a `DeprecationWarning` either way, so callers have a real migration window before model becomes required in a future release. ### Release notes `model` will be made required param in the future relase --------- Co-authored-by: keenborder786 <21110290@lums.edu.pk> --- .../agents/middleware/tool_emulator.py | 31 +++++++++++-- .../implementations/test_tool_emulator.py | 45 ++++++++++++++----- 2 files changed, 62 insertions(+), 14 deletions(-) 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/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: From 367df8e3d95da4b766d2a262b40ed7cb56fcc464 Mon Sep 17 00:00:00 2001 From: ccurme Date: Fri, 7 Aug 2026 10:00:45 -0400 Subject: [PATCH 08/47] chore(openai): update guidance for responses API for OpenAI-compatible providers (#39327) --- .../langchain_openai/chat_models/base.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/libs/partners/openai/langchain_openai/chat_models/base.py b/libs/partners/openai/langchain_openai/chat_models/base.py index 9a5b159b66..16baf11bf8 100644 --- a/libs/partners/openai/langchain_openai/chat_models/base.py +++ b/libs/partners/openai/langchain_openai/chat_models/base.py @@ -3311,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: From 2f4aa5a79c30638a75150b58065be7affb494835 Mon Sep 17 00:00:00 2001 From: Kellampalli Saathvik Date: Fri, 7 Aug 2026 19:32:52 +0530 Subject: [PATCH 09/47] feat(openrouter): preserve provider in response metadata (#39301) --- .../langchain_openrouter/chat_models.py | 5 ++ .../tests/unit_tests/test_chat_models.py | 48 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/libs/partners/openrouter/langchain_openrouter/chat_models.py b/libs/partners/openrouter/langchain_openrouter/chat_models.py index 293c2d0a4c..8d98d7118b 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"): @@ -836,6 +838,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 +852,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/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"}}]} From d616af71dd9f93928b08e72c1b68bdd87ac4baea Mon Sep 17 00:00:00 2001 From: Ali Satwat Khan Date: Fri, 7 Aug 2026 19:28:14 +0500 Subject: [PATCH 10/47] fix(exa): handle missing optional result metadata (#39171) --- libs/partners/exa/langchain_exa/retrievers.py | 9 ++-- .../exa/tests/unit_tests/test_retrievers.py | 41 +++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 libs/partners/exa/tests/unit_tests/test_retrievers.py 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 From 536ce56f4ed28903a17e0d8c4564338a8ba0ed6b Mon Sep 17 00:00:00 2001 From: Nishitha M <32355027+imnishitha@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:22:50 -0400 Subject: [PATCH 11/47] fix(anthropic): fix `KeyError` on rename in Claude file-tool middleware (#39293) Closes #35852, #38465 The `file_tool` dispatcher builds the operation arguments using `"path"` as the source path for all commands, including `rename`. However, `_handle_rename` was the only handler reading `args["old_path"]` instead of `args["path"]`, resulting in a `KeyError` on every rename across all Claude file/memory tool middleware classes. The fix makes `_handle_rename` read the source path from `args["path"]`, matching the dispatcher's behavior and keeping rename consistent with every other file operation. Claude adapts to the advertised tool schema: it initially emits the documented [Memory Tool rename payload ](https://platform.claude.com/docs/en/agents-and-tools/tool-use/memory-tool#rename)(`old_path/new_path`), but after receiving a validation error because path is required by this tool, it retries with both path and old_path. Since the dispatcher already normalizes the source path under path, the simplest and most consistent fix is for `_handle_rename `to read `args["path"]`, matching every other handler and the dispatcher itself. Otherwise making `path` as an optional arg in the tool would be a breaking change. Co-authored-by: LincolnBurrows2017 --- .../middleware/anthropic_tools.py | 8 +- .../middleware/test_anthropic_tools.py | 83 +++++++++++++++++++ 2 files changed, 89 insertions(+), 2 deletions(-) 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/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.""" From b6bce1a943009269055667c91a91adce7dd15159 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:25:16 -0700 Subject: [PATCH 12/47] chore: bump h2 from 4.3.0 to 4.4.1 in /libs/partners/qdrant (#39323) Bumps [h2](https://github.com/python-hyper/h2) from 4.3.0 to 4.4.1.
Changelog

Sourced from h2's changelog.

4.4.1 (2026-08-03)

Bugfixes

  • Performance improvement: remove consumed frames in-place from data buffer.
  • Reject duplicate Host headers in request headers. Thanks to Sunand Mohan for the report.

4.4.0 (2026-07-23)

API Changes (Backward Incompatible)

  • Support for Python 3.9 has been removed.
  • Support for PyPy 3.9 has been removed.
  • Stream.end_stream() now raises NoSuchStreamError or StreamClosedError exceptions, instead of a generic KeyError.
  • Duplicate content-length headers with different values now raise ProtocolError. Previously, the first content-length header was accepted and later conflicting values were ignored. Thanks to Harshal Parekh for the report.
  • Parse content-length headers according to RFC9110 grammar for numbers (1*DIGIT). Thanks to Arkadiusz Marta for the report.
  • backfill from v4.3.0 Convert emitted events into Python dataclass, which introduces new constructors with required arguments. Instantiating these events without arguments, as previously commonly used API pattern, will no longer work.

API Changes (Backward Compatible)

  • Support for Python 3.14 has been added.
  • H2Connection.receive_data now accepts any byte-like object that implements the buffer protocol, such as bytes, bytearray, and memoryview. Existing bytes callers are unaffected.
  • Align CONNECT pseudo-header validation with RFC 9113 s8.3 and RFC 8441 s4. Ordinary CONNECT now requires :method=CONNECT and :authority, and forbids :scheme/:path. Extended CONNECT (e.g., WebSocket) requires :scheme, :path, :authority plus :protocol. (PR #1309)
  • Fix incorrect substring matching of secure header in cookie and :method.

Bugfixes

  • Fix to allow sending 0 bytes on a stream even if the flow control window is negative.
  • Reject non-zero SETTINGS_ENABLE_PUSH values received from servers.
Commits
  • bc239af v4.4.1
  • 92b925e add test for duplicate host headers
  • 292a408 reject duplicate Host headers in request headers
  • 04d3b87 update changelog
  • 439b970 prepare for next release cycle
  • 9a7ff74 performance: remove consumed frames in place from data buffer (#1321)
  • 6cce763 v4.4.0
  • dfafda3 Bump pytest from 8.4.2 to 9.0.3 (#1320)
  • b45207c dependencies and packaging++
  • c40145f parse content-length headers according to RFC9110 grammar for numbers (1*DI...
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=h2&package-manager=uv&previous-version=4.3.0&new-version=4.4.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/langchain-ai/langchain/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- libs/partners/qdrant/uv.lock | 86 ++++++++++++++++++------------------ 1 file changed, 43 insertions(+), 43 deletions(-) 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 = [ From 44ae55a186092a5cc4303dc7900107332352c033 Mon Sep 17 00:00:00 2001 From: "langchain-oss-model-profiles[bot]" <262694797+langchain-oss-model-profiles[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 10:28:44 -0700 Subject: [PATCH 13/47] chore(model-profiles): refresh model profile data (#39318) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated refresh of model profile data for all in-monorepo partner integrations via `langchain-profiles refresh`. 🤖 Generated by the [`refresh_model_profiles` workflow](https://github.com/langchain-ai/langchain/blob/master/.github/workflows/refresh_model_profiles.yml). ## Summary of changes **1 added · 1 removed · 6 changed** across 1 provider(s). ### openrouter **➕ 1 added** - `inclusionai/ling-3.0-tiny:free` — 262,144 ctx, 32,768 out, reasoning, tools **➖ 1 removed** - `inclusionai/ling-3.0-flash:free` **✏️ 6 changed** - `anthropic/claude-opus-4.1`: removed structured output - `google/gemini-3-flash-preview`: max output tokens 65,535 → 65,536 - `inclusionai/ling-3.0-flash`: max input tokens 131,072 → 262,144; max output tokens 16,384 → 32,768 - `qwen/qwen3-coder-next`: last updated `2026-02-04` → `2026-02-03`; release date `2026-02-04` → `2026-02-03` - `qwen/qwen3.5-122b-a10b`: max output tokens 65,536 → 81,920 - `z-ai/glm-5.2`: max output tokens 262,144 → 131,072 Co-authored-by: mdrxy <61371264+mdrxy@users.noreply.github.com> --- .../langchain_openrouter/data/_profiles.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/libs/partners/openrouter/langchain_openrouter/data/_profiles.py b/libs/partners/openrouter/langchain_openrouter/data/_profiles.py index fae15cc1c0..494c7971b0 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, @@ -1408,7 +1408,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, @@ -2075,8 +2075,8 @@ _PROFILES: dict[str, dict[str, Any]] = { "release_date": "2026-07-23", "last_updated": "2026-07-23", "open_weights": True, - "max_input_tokens": 131072, - "max_output_tokens": 16384, + "max_input_tokens": 262144, + "max_output_tokens": 32768, "text_inputs": True, "image_inputs": False, "audio_inputs": False, @@ -2092,11 +2092,11 @@ _PROFILES: dict[str, dict[str, Any]] = { "temperature": True, "tool_call_streaming": True, }, - "inclusionai/ling-3.0-flash:free": { - "name": "Ling-3.0-flash (free)", - "release_date": "2026-07-23", - "last_updated": "2026-07-23", - "open_weights": 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": False, "max_input_tokens": 262144, "max_output_tokens": 32768, "text_inputs": True, @@ -5758,8 +5758,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, @@ -6048,7 +6048,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, @@ -7285,7 +7285,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, From bc16168d710d8b594d0bc622c70361876bd9cbda Mon Sep 17 00:00:00 2001 From: Nishitha M <32355027+imnishitha@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:20:15 -0400 Subject: [PATCH 14/47] fix(langchain): preserve history on `SummarizationMiddleware` summary failure (#39268) Closes #38867 Fixes `SummarizationMiddleware` replacing conversation history with error text when summary generation fails. Summary calls now retry transient failures up to 3 times via `Runnable.with_retry`. If all attempts fail, the underlying exception propagates and the original history remains untouched. `_create_summary`/`_acreate_summary` no longer convert exceptions into fabricated summaries. No new parameters or exception types are introduced. ### Release note `SummarizationMiddleware` now retries failed summary calls and propagates the underlying error if retries are exhausted, instead of replacing conversation history with error text. Thanks @harshxth for raising this! --------- Signed-off-by: Nishitha M <32355027+imnishitha@users.noreply.github.com> --- .../agents/middleware/summarization.py | 57 +++-- .../implementations/test_summarization.py | 233 +++++++++++++++++- 2 files changed, 270 insertions(+), 20 deletions(-) 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/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: From 0424e03dc71cc487dd11e5fa71d083533cb26816 Mon Sep 17 00:00:00 2001 From: Nishitha M <32355027+imnishitha@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:22:15 -0400 Subject: [PATCH 15/47] fix(core): preserve flat tool args schema for `RootModel` runnables (#39307) Closes #38713 Fixes `Runnable.as_tool()` advertising `TypedDict` inputs under a synthetic `root` key. When a runnable's `input_schema` is a `RootModel` (e.g. compiled `StateGraph`s), the tool schema is now built from the runnable's type hints instead, keeping the advertised schema consistent with the flat input the runnable actually expects. --------- Co-authored-by: shusnapx --- libs/core/langchain_core/tools/convert.py | 11 ++++- libs/core/tests/unit_tests/test_tools.py | 53 ++++++++++++++++++++++- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/libs/core/langchain_core/tools/convert.py b/libs/core/langchain_core/tools/convert.py index 6b4a8c37c2..d353fc5250 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 @@ -469,12 +469,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/tests/unit_tests/test_tools.py b/libs/core/tests/unit_tests/test_tools.py index 522500815f..f23fd9052a 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 @@ -1759,6 +1759,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: From 1499aa9900987c6050670eb1cbd47bc4db0a6d18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B9=BB?= <140176752+HUAN2022A@users.noreply.github.com> Date: Sat, 8 Aug 2026 10:20:14 +0800 Subject: [PATCH 16/47] fix(core): stop StructuredPrompt from mutating caller kwargs (#39174) Co-authored-by: Jony <13896935+zyz619963502zyz@users.noreply.github.com> --- .../core/langchain_core/prompts/structured.py | 3 +- .../unit_tests/prompts/test_structured.py | 33 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) 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/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"} From c318abed446e7d1a0e872a6967b9b17bc6f4761c Mon Sep 17 00:00:00 2001 From: Soban Shankar Date: Sat, 8 Aug 2026 07:59:49 +0530 Subject: [PATCH 17/47] fix(text-splitters): raise TypeError for non-dict, non-convertible input to RecursiveJsonSplitter (#39238) --- .../langchain_text_splitters/json.py | 19 +++++- .../tests/unit_tests/test_text_splitters.py | 59 +++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) 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 From a0a434c18550a21ed9137ddc5f0533cb74f8416b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:53:38 -0700 Subject: [PATCH 18/47] chore: bump h2 from 4.3.0 to 4.4.1 in /libs/langchain_v1 (#39324) Bumps [h2](https://github.com/python-hyper/h2) from 4.3.0 to 4.4.1.
Changelog

Sourced from h2's changelog.

4.4.1 (2026-08-03)

Bugfixes

  • Performance improvement: remove consumed frames in-place from data buffer.
  • Reject duplicate Host headers in request headers. Thanks to Sunand Mohan for the report.

4.4.0 (2026-07-23)

API Changes (Backward Incompatible)

  • Support for Python 3.9 has been removed.
  • Support for PyPy 3.9 has been removed.
  • Stream.end_stream() now raises NoSuchStreamError or StreamClosedError exceptions, instead of a generic KeyError.
  • Duplicate content-length headers with different values now raise ProtocolError. Previously, the first content-length header was accepted and later conflicting values were ignored. Thanks to Harshal Parekh for the report.
  • Parse content-length headers according to RFC9110 grammar for numbers (1*DIGIT). Thanks to Arkadiusz Marta for the report.
  • backfill from v4.3.0 Convert emitted events into Python dataclass, which introduces new constructors with required arguments. Instantiating these events without arguments, as previously commonly used API pattern, will no longer work.

API Changes (Backward Compatible)

  • Support for Python 3.14 has been added.
  • H2Connection.receive_data now accepts any byte-like object that implements the buffer protocol, such as bytes, bytearray, and memoryview. Existing bytes callers are unaffected.
  • Align CONNECT pseudo-header validation with RFC 9113 s8.3 and RFC 8441 s4. Ordinary CONNECT now requires :method=CONNECT and :authority, and forbids :scheme/:path. Extended CONNECT (e.g., WebSocket) requires :scheme, :path, :authority plus :protocol. (PR #1309)
  • Fix incorrect substring matching of secure header in cookie and :method.

Bugfixes

  • Fix to allow sending 0 bytes on a stream even if the flow control window is negative.
  • Reject non-zero SETTINGS_ENABLE_PUSH values received from servers.
Commits
  • bc239af v4.4.1
  • 92b925e add test for duplicate host headers
  • 292a408 reject duplicate Host headers in request headers
  • 04d3b87 update changelog
  • 439b970 prepare for next release cycle
  • 9a7ff74 performance: remove consumed frames in place from data buffer (#1321)
  • 6cce763 v4.4.0
  • dfafda3 Bump pytest from 8.4.2 to 9.0.3 (#1320)
  • b45207c dependencies and packaging++
  • c40145f parse content-length headers according to RFC9110 grammar for numbers (1*DI...
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=h2&package-manager=uv&previous-version=4.3.0&new-version=4.4.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/langchain-ai/langchain/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- libs/langchain_v1/uv.lock | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/libs/langchain_v1/uv.lock b/libs/langchain_v1/uv.lock index f7bb40e6bd..258a755f83 100644 --- a/libs/langchain_v1/uv.lock +++ b/libs/langchain_v1/uv.lock @@ -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]] @@ -2120,7 +2120,7 @@ typing = [ [[package]] name = "langchain-anthropic" -version = "1.5.3" +version = "1.5.4" source = { editable = "../partners/anthropic" } dependencies = [ { name = "anthropic" }, @@ -2462,7 +2462,7 @@ wheels = [ [[package]] name = "langchain-openai" -version = "1.4.1" +version = "1.4.2" source = { editable = "../partners/openai" } dependencies = [ { name = "langchain-core" }, From d048fbe170573b6e7056b5ef5f78d8451e54abaf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:53:45 -0700 Subject: [PATCH 19/47] chore: bump h2 from 4.3.0 to 4.4.1 in /libs/langchain (#39325) Bumps [h2](https://github.com/python-hyper/h2) from 4.3.0 to 4.4.1.
Changelog

Sourced from h2's changelog.

4.4.1 (2026-08-03)

Bugfixes

  • Performance improvement: remove consumed frames in-place from data buffer.
  • Reject duplicate Host headers in request headers. Thanks to Sunand Mohan for the report.

4.4.0 (2026-07-23)

API Changes (Backward Incompatible)

  • Support for Python 3.9 has been removed.
  • Support for PyPy 3.9 has been removed.
  • Stream.end_stream() now raises NoSuchStreamError or StreamClosedError exceptions, instead of a generic KeyError.
  • Duplicate content-length headers with different values now raise ProtocolError. Previously, the first content-length header was accepted and later conflicting values were ignored. Thanks to Harshal Parekh for the report.
  • Parse content-length headers according to RFC9110 grammar for numbers (1*DIGIT). Thanks to Arkadiusz Marta for the report.
  • backfill from v4.3.0 Convert emitted events into Python dataclass, which introduces new constructors with required arguments. Instantiating these events without arguments, as previously commonly used API pattern, will no longer work.

API Changes (Backward Compatible)

  • Support for Python 3.14 has been added.
  • H2Connection.receive_data now accepts any byte-like object that implements the buffer protocol, such as bytes, bytearray, and memoryview. Existing bytes callers are unaffected.
  • Align CONNECT pseudo-header validation with RFC 9113 s8.3 and RFC 8441 s4. Ordinary CONNECT now requires :method=CONNECT and :authority, and forbids :scheme/:path. Extended CONNECT (e.g., WebSocket) requires :scheme, :path, :authority plus :protocol. (PR #1309)
  • Fix incorrect substring matching of secure header in cookie and :method.

Bugfixes

  • Fix to allow sending 0 bytes on a stream even if the flow control window is negative.
  • Reject non-zero SETTINGS_ENABLE_PUSH values received from servers.
Commits
  • bc239af v4.4.1
  • 92b925e add test for duplicate host headers
  • 292a408 reject duplicate Host headers in request headers
  • 04d3b87 update changelog
  • 439b970 prepare for next release cycle
  • 9a7ff74 performance: remove consumed frames in place from data buffer (#1321)
  • 6cce763 v4.4.0
  • dfafda3 Bump pytest from 8.4.2 to 9.0.3 (#1320)
  • b45207c dependencies and packaging++
  • c40145f parse content-length headers according to RFC9110 grammar for numbers (1*DI...
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=h2&package-manager=uv&previous-version=4.3.0&new-version=4.4.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself) You can disable automated security fix PRs for this repo from the [Security Alerts page](https://github.com/langchain-ai/langchain/network/alerts).
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- libs/langchain/uv.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) 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" }, From 9f738f5297d14f46f0341f6c2d773cbf12962e82 Mon Sep 17 00:00:00 2001 From: ccurme Date: Sun, 9 Aug 2026 11:46:45 -0400 Subject: [PATCH 20/47] fix(core): compat with pydantic 2.14 (#39328) Co-authored-by: PRINCE KUMAR MAURYA <218506877+alwaysprince05@users.noreply.github.com> --- libs/core/langchain_core/load/serializable.py | 22 ++++++- .../unit_tests/load/test_serializable.py | 61 ++++++++++++++++++- 2 files changed, 80 insertions(+), 3 deletions(-) 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/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 From 9b4ef2ab34bdb6f2313bef3f8ee25202eb089ef2 Mon Sep 17 00:00:00 2001 From: ccurme Date: Sun, 9 Aug 2026 12:21:09 -0400 Subject: [PATCH 21/47] fix(openai): filter invalid tool calls from content (#39366) --- .../langchain_openai/chat_models/_compat.py | 2 +- .../tests/unit_tests/chat_models/test_base.py | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) 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/tests/unit_tests/chat_models/test_base.py b/libs/partners/openai/tests/unit_tests/chat_models/test_base.py index 5c54c458e6..3cc4474ccc 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 From 24e8d2b5960ce52985d99332d29ab503fc4be5f9 Mon Sep 17 00:00:00 2001 From: "langchain-oss-model-profiles[bot]" <262694797+langchain-oss-model-profiles[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 10:31:16 -0700 Subject: [PATCH 22/47] chore(model-profiles): refresh model profile data (#39344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated refresh of model profile data for all in-monorepo partner integrations via `langchain-profiles refresh`. 🤖 Generated by the [`refresh_model_profiles` workflow](https://github.com/langchain-ai/langchain/blob/master/.github/workflows/refresh_model_profiles.yml). ## Summary of changes **0 added · 0 removed · 8 changed** across 1 provider(s). ### openrouter **✏️ 8 changed** - `deepseek/deepseek-v4-flash`: max output tokens 131,072 → 393,216 - `deepseek/deepseek-v4-flash-0731`: max output tokens 65,536 → 384,000 - `mancer/weaver`: max output tokens 2,000 → 6,000 - `qwen/qwen3-next-80b-a3b-thinking`: max output tokens 32,768 → 262,144 - `thinkingmachines/inkling`: max output tokens 1,048,576 → 262,144 - `thinkingmachines/inkling-small`: added structured output - `undi95/remm-slerp-l2-13b`: max output tokens 2,048 → 6,144 - `~deepseek/deepseek-v4-flash-latest`: max output tokens 65,536 → 131,072 Co-authored-by: mdrxy <61371264+mdrxy@users.noreply.github.com> --- .../langchain_openrouter/data/_profiles.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/libs/partners/openrouter/langchain_openrouter/data/_profiles.py b/libs/partners/openrouter/langchain_openrouter/data/_profiles.py index 494c7971b0..759ae5488a 100644 --- a/libs/partners/openrouter/langchain_openrouter/data/_profiles.py +++ b/libs/partners/openrouter/langchain_openrouter/data/_profiles.py @@ -1205,7 +1205,7 @@ _PROFILES: dict[str, dict[str, Any]] = { "last_updated": "2026-04-24", "open_weights": True, "max_input_tokens": 1048576, - "max_output_tokens": 131072, + "max_output_tokens": 393216, "text_inputs": True, "image_inputs": False, "audio_inputs": False, @@ -1227,7 +1227,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, @@ -2208,7 +2208,7 @@ _PROFILES: dict[str, dict[str, Any]] = { "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, @@ -5872,7 +5872,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, @@ -6796,7 +6796,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, @@ -6829,7 +6829,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, @@ -6840,7 +6840,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, @@ -7421,7 +7421,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": 131072, "text_inputs": True, "image_inputs": False, "audio_inputs": False, From 56daacc8dff4103f430b121711f601ae503bddf9 Mon Sep 17 00:00:00 2001 From: ccurme Date: Mon, 10 Aug 2026 09:11:27 -0400 Subject: [PATCH 23/47] release(openai): 1.4.3 (#39485) --- libs/partners/openai/langchain_openai/_version.py | 2 +- libs/partners/openai/pyproject.toml | 2 +- libs/partners/openai/uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/partners/openai/langchain_openai/_version.py b/libs/partners/openai/langchain_openai/_version.py index 84d82c9fe8..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.2" +__version__ = "1.4.3" diff --git a/libs/partners/openai/pyproject.toml b/libs/partners/openai/pyproject.toml index c6bbaf2d00..eeb369c98c 100644 --- a/libs/partners/openai/pyproject.toml +++ b/libs/partners/openai/pyproject.toml @@ -20,7 +20,7 @@ classifiers = [ "Topic :: Scientific/Engineering :: Artificial Intelligence", ] -version = "1.4.2" +version = "1.4.3" requires-python = ">=3.10.0,<4.0.0" dependencies = [ "langchain-core>=1.5.3,<2.0.0", diff --git a/libs/partners/openai/uv.lock b/libs/partners/openai/uv.lock index 3c75444ca8..e7da3bb508 100644 --- a/libs/partners/openai/uv.lock +++ b/libs/partners/openai/uv.lock @@ -728,7 +728,7 @@ typing = [ [[package]] name = "langchain-openai" -version = "1.4.2" +version = "1.4.3" source = { editable = "." } dependencies = [ { name = "langchain-core" }, From f78df6d9772305e29ac07ae5508b468f56a4bcd3 Mon Sep 17 00:00:00 2001 From: "langchain-oss-model-profiles[bot]" <262694797+langchain-oss-model-profiles[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:31:02 -0700 Subject: [PATCH 24/47] chore(model-profiles): refresh model profile data (#39430) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated refresh of model profile data for all in-monorepo partner integrations via `langchain-profiles refresh`. 🤖 Generated by the [`refresh_model_profiles` workflow](https://github.com/langchain-ai/langchain/blob/master/.github/workflows/refresh_model_profiles.yml). ## Summary of changes **0 added · 0 removed · 5 changed** across 1 provider(s). ### openrouter **✏️ 5 changed** - `google/gemma-4-26b-a4b-it`: max output tokens 16,384 → 262,144 - `nvidia/nemotron-3-nano-30b-a3b`: max output tokens 262,144 → 228,000 - `qwen/qwen3-14b`: max output tokens 8,192 → 16,384 - `qwen/qwen3-next-80b-a3b-thinking`: max output tokens 262,144 → 32,768 - `z-ai/glm-5.2`: max output tokens 131,072 → 262,144 Co-authored-by: mdrxy <61371264+mdrxy@users.noreply.github.com> --- .../openrouter/langchain_openrouter/data/_profiles.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/libs/partners/openrouter/langchain_openrouter/data/_profiles.py b/libs/partners/openrouter/langchain_openrouter/data/_profiles.py index 759ae5488a..7765cb4fa6 100644 --- a/libs/partners/openrouter/langchain_openrouter/data/_profiles.py +++ b/libs/partners/openrouter/langchain_openrouter/data/_profiles.py @@ -1812,7 +1812,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, @@ -3430,7 +3430,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, @@ -5498,7 +5498,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, @@ -5872,7 +5872,7 @@ _PROFILES: dict[str, dict[str, Any]] = { "last_updated": "2025-09", "open_weights": True, "max_input_tokens": 262144, - "max_output_tokens": 262144, + "max_output_tokens": 32768, "text_inputs": True, "image_inputs": False, "audio_inputs": False, @@ -7285,7 +7285,7 @@ _PROFILES: dict[str, dict[str, Any]] = { "last_updated": "2026-06-13", "open_weights": True, "max_input_tokens": 1048576, - "max_output_tokens": 131072, + "max_output_tokens": 262144, "text_inputs": True, "image_inputs": False, "audio_inputs": False, From 39b4e0f9a8c3ab5537ee9fc5baa57ed98de165f1 Mon Sep 17 00:00:00 2001 From: ccurme Date: Tue, 11 Aug 2026 11:25:58 -0400 Subject: [PATCH 25/47] chore(langchain): fix type errors in tests (#39589) --- .../core/test_internal_call_transformer.py | 16 +++++++++---- .../middleware/core/test_transformers.py | 14 +++++++---- .../middleware/implementations/test_pii.py | 12 ++++++---- .../unit_tests/agents/test_agent_streaming.py | 24 ++++++++++++------- .../agents/test_subagent_transformer.py | 16 +++++++++---- libs/langchain_v1/uv.lock | 10 ++++---- 6 files changed, 59 insertions(+), 33 deletions(-) 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_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/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/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_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 258a755f83..4713c979c4 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 = [ @@ -2462,7 +2462,7 @@ wheels = [ [[package]] name = "langchain-openai" -version = "1.4.2" +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,9 +2675,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]] From a2a9b1bde43621e43cf31f100f7919b235f9f0dc Mon Sep 17 00:00:00 2001 From: ccurme Date: Tue, 11 Aug 2026 11:42:29 -0400 Subject: [PATCH 26/47] fix(anthropic): report reasoning tokens in usage metadata (#39590) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Philemon Schöpf --- .../langchain_anthropic/chat_models.py | 29 +++++++++++++-- .../tests/unit_tests/test_chat_models.py | 36 +++++++++++++++++++ 2 files changed, 63 insertions(+), 2 deletions(-) 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/tests/unit_tests/test_chat_models.py b/libs/partners/anthropic/tests/unit_tests/test_chat_models.py index 76164e4d5d..fb4d00ae63 100644 --- a/libs/partners/anthropic/tests/unit_tests/test_chat_models.py +++ b/libs/partners/anthropic/tests/unit_tests/test_chat_models.py @@ -2077,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.""" From ce8e8bd8b1a37388fc03c489d11b96ee86c9fb79 Mon Sep 17 00:00:00 2001 From: "langchain-oss-model-profiles[bot]" <262694797+langchain-oss-model-profiles[bot]@users.noreply.github.com> Date: Tue, 11 Aug 2026 08:49:43 -0700 Subject: [PATCH 27/47] chore(model-profiles): refresh model profile data (#39579) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated refresh of model profile data for all in-monorepo partner integrations via `langchain-profiles refresh`. 🤖 Generated by the [`refresh_model_profiles` workflow](https://github.com/langchain-ai/langchain/blob/master/.github/workflows/refresh_model_profiles.yml). ## Summary of changes **3 added · 1 removed · 8 changed** across 1 provider(s). ### openrouter **➕ 3 added** - `meta/muse-glimmer-30b` — 131,072 ctx, 131,072 out, text+image in, reasoning, tools - `sakana/sakana-namazu` — 262,144 ctx, 65,536 out, text+image+pdf in, reasoning, tools - `upstage/solar-pro4` — 524,288 ctx, 131,072 out, reasoning, tools **➖ 1 removed** - `openai/gpt-5.3-chat` **✏️ 8 changed** - `deepseek/deepseek-v4-pro`: max output tokens 384,000 → 393,216 - `inclusionai/ling-3.0-tiny:free`: added open weights - `moonshotai/kimi-k3`: added video input - `openai/gpt-5.2-chat`: max output tokens 16,384 → 32,000 - `qwen/qwen3-coder-30b-a3b-instruct`: max output tokens 32,768 → 262,144 - `qwen/qwen3-next-80b-a3b-thinking`: max output tokens 32,768 → 262,144 - `qwen/qwen3.5-397b-a17b`: max output tokens 65,536 → 262,144 - `~moonshotai/kimi-latest`: added video input Co-authored-by: mdrxy <61371264+mdrxy@users.noreply.github.com> --- .../langchain_openrouter/data/_profiles.py | 106 +++++++++++++----- 1 file changed, 75 insertions(+), 31 deletions(-) diff --git a/libs/partners/openrouter/langchain_openrouter/data/_profiles.py b/libs/partners/openrouter/langchain_openrouter/data/_profiles.py index 7765cb4fa6..eccf1d8684 100644 --- a/libs/partners/openrouter/langchain_openrouter/data/_profiles.py +++ b/libs/partners/openrouter/langchain_openrouter/data/_profiles.py @@ -1249,7 +1249,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, @@ -2096,7 +2096,7 @@ _PROFILES: dict[str, dict[str, Any]] = { "name": "Ling 3.0 Tiny (free)", "release_date": "2026-08-06", "last_updated": "2026-08-06", - "open_weights": False, + "open_weights": True, "max_input_tokens": 262144, "max_output_tokens": 32768, "text_inputs": True, @@ -2422,6 +2422,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", @@ -3236,7 +3258,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, @@ -4261,7 +4283,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, @@ -4323,29 +4345,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", @@ -5718,7 +5717,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, @@ -5872,7 +5871,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, @@ -6114,7 +6113,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, @@ -6526,6 +6525,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", @@ -6878,6 +6900,28 @@ _PROFILES: dict[str, dict[str, Any]] = { "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, + "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, + }, "writer/palmyra-x5": { "name": "Palmyra X5", "release_date": "2026-01-21", @@ -7493,7 +7537,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, From 1925966dc66ca339e7391e1bb8e0aabf415dbf96 Mon Sep 17 00:00:00 2001 From: ccurme Date: Tue, 11 Aug 2026 13:33:54 -0400 Subject: [PATCH 28/47] feat(langchain): expose `trace_policy` on `AgentMiddleware` (#38910) --- libs/langchain_v1/langchain/agents/factory.py | 56 ++++-- .../langchain/agents/middleware/__init__.py | 6 + .../agents/middleware/_trace_policy.py | 61 +++++++ .../langchain/agents/middleware/types.py | 14 +- libs/langchain_v1/pyproject.toml | 2 +- .../agents/middleware/core/test_tracing.py | 159 ++++++++++++++++++ libs/langchain_v1/uv.lock | 8 +- 7 files changed, 286 insertions(+), 20 deletions(-) create mode 100644 libs/langchain_v1/langchain/agents/middleware/_trace_policy.py create mode 100644 libs/langchain_v1/tests/unit_tests/agents/middleware/core/test_tracing.py diff --git a/libs/langchain_v1/langchain/agents/factory.py b/libs/langchain_v1/langchain/agents/factory.py index afda461d9f..32818171ed 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 @@ -1026,9 +1046,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 +1065,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 +1151,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 +1160,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 +1562,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 +1586,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 +1609,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 +1634,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/types.py b/libs/langchain_v1/langchain/agents/middleware/types.py index 0fa145bfdf..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. diff --git a/libs/langchain_v1/pyproject.toml b/libs/langchain_v1/pyproject.toml index 54f36c91f8..0f9c472f97 100644 --- a/libs/langchain_v1/pyproject.toml +++ b/libs/langchain_v1/pyproject.toml @@ -25,7 +25,7 @@ version = "1.3.14" requires-python = ">=3.10.0,<4.0.0" dependencies = [ "langchain-core>=1.5.3,<2.0.0", - "langgraph>=1.2.5,<1.3.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_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/uv.lock b/libs/langchain_v1/uv.lock index 4713c979c4..bac73de1ff 100644 --- a/libs/langchain_v1/uv.lock +++ b/libs/langchain_v1/uv.lock @@ -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"] @@ -2682,15 +2682,15 @@ wheels = [ [[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]] From 5ff19c613a92f448370322352c24b622d7989f18 Mon Sep 17 00:00:00 2001 From: ccurme Date: Tue, 11 Aug 2026 13:43:45 -0400 Subject: [PATCH 29/47] release(core): 1.5.4 (#39592) --- libs/core/langchain_core/version.py | 2 +- libs/core/pyproject.toml | 2 +- .../__snapshots__/test_fallbacks.ambr | 14 +++--- .../__snapshots__/test_runnable.ambr | 48 +++++++++---------- libs/core/uv.lock | 10 ++-- 5 files changed, 38 insertions(+), 38 deletions(-) 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..93f340e06c 100644 --- a/libs/core/pyproject.toml +++ b/libs/core/pyproject.toml @@ -21,7 +21,7 @@ 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", 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/uv.lock b/libs/core/uv.lock index 32965d12fe..6e470fe788 100644 --- a/libs/core/uv.lock +++ b/libs/core/uv.lock @@ -1040,7 +1040,7 @@ wheels = [ [[package]] name = "langchain-core" -version = "1.5.3" +version = "1.5.4" source = { editable = "." } dependencies = [ { name = "jsonpatch" }, @@ -1107,7 +1107,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 +1182,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 +1203,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" }, From f4bc5031dbcf24edb0374a07830915a285222567 Mon Sep 17 00:00:00 2001 From: ccurme Date: Tue, 11 Aug 2026 14:39:46 -0400 Subject: [PATCH 30/47] release(langchain): 1.3.15 (#39595) --- libs/langchain_v1/langchain/__init__.py | 2 +- libs/langchain_v1/pyproject.toml | 4 ++-- libs/langchain_v1/uv.lock | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) 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/pyproject.toml b/libs/langchain_v1/pyproject.toml index 0f9c472f97..7e9e1aca0d 100644 --- a/libs/langchain_v1/pyproject.toml +++ b/libs/langchain_v1/pyproject.toml @@ -21,10 +21,10 @@ 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", + "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/uv.lock b/libs/langchain_v1/uv.lock index bac73de1ff..4391f3e050 100644 --- a/libs/langchain_v1/uv.lock +++ b/libs/langchain_v1/uv.lock @@ -1968,7 +1968,7 @@ wheels = [ [[package]] name = "langchain" -version = "1.3.14" +version = "1.3.15" source = { editable = "." } dependencies = [ { name = "langchain-core" }, @@ -2120,7 +2120,7 @@ typing = [ [[package]] name = "langchain-anthropic" -version = "1.5.4" +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" }, From 119bf69a1e20e7f7dbb5a82582451285daeacd4a Mon Sep 17 00:00:00 2001 From: ccurme Date: Tue, 11 Aug 2026 15:12:52 -0400 Subject: [PATCH 31/47] release(anthropic): 1.5.5 (#39597) --- .../anthropic/langchain_anthropic/_version.py | 2 +- libs/partners/anthropic/pyproject.toml | 4 ++-- libs/partners/anthropic/uv.lock | 16 ++++++++-------- 3 files changed, 11 insertions(+), 11 deletions(-) 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/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/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]] From e34cd7634653e64d207c8023720f3118d44e80dd Mon Sep 17 00:00:00 2001 From: Willow Lopez <100782273+Oxygen56@users.noreply.github.com> Date: Wed, 12 Aug 2026 06:50:39 +0800 Subject: [PATCH 32/47] fix(core): guard malformed Anthropic content blocks (#38670) --- .../messages/block_translators/anthropic.py | 82 +++++++++++++------ .../block_translators/test_anthropic.py | 65 +++++++++++++++ 2 files changed, 123 insertions(+), 24 deletions(-) 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/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", + }, + }, + ], + }, + ] From 61c5678835a6ecd87fb5d98e6e6aeb0c59c63363 Mon Sep 17 00:00:00 2001 From: Rin Date: Wed, 12 Aug 2026 05:54:59 +0700 Subject: [PATCH 33/47] fix(core): raise ValueError when explicit tool_outputs length mismatches tool_calls in tool_example_to_messages (#39142) --- .../langchain_core/utils/function_calling.py | 7 +++++++ .../unit_tests/utils/test_function_calling.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+) 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/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], From 2c3b11c6c487c64f7528e7f0f7108e9147423edc Mon Sep 17 00:00:00 2001 From: John Kennedy <65985482+jkennedyvz@users.noreply.github.com> Date: Tue, 11 Aug 2026 16:49:16 -0700 Subject: [PATCH 34/47] chore: bump setuptools in Hugging Face lockfile (#39603) ## Summary - bumps `setuptools` from 81.0.0 to 84.0.0 in `libs/partners/huggingface/uv.lock` - resolves Dependabot alert #3955 / GHSA-h35f-9h28-mq5c / CVE-2026-59890 (first patched version: 83.0.0) - keeps the change scoped to the affected lockfile entry ## Validation - [x] `uv sync --project libs/partners/huggingface --frozen --no-install-project --no-dev` - [x] verified resolved `setuptools==84.0.0` is outside the vulnerable range - [x] `git diff --check` ## Notes `uv lock --locked` reports that the lockfile needs unrelated workspace metadata updates (`langchain`, `langchain-core`, and `langgraph`). Those unrelated refreshes were intentionally excluded to keep this security patch narrow. Co-authored-by: langsmith-fleet[bot] --- libs/partners/huggingface/uv.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/partners/huggingface/uv.lock b/libs/partners/huggingface/uv.lock index 3f12f918d0..4a57783141 100644 --- a/libs/partners/huggingface/uv.lock +++ b/libs/partners/huggingface/uv.lock @@ -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]] From 7cdf9658f0a78d2267cf1fdd547d9ff1a8186673 Mon Sep 17 00:00:00 2001 From: zerafachris Date: Wed, 12 Aug 2026 03:32:33 +0200 Subject: [PATCH 35/47] fix(core): preserve non-str/non-dict items in `DictPromptTemplate` list values (#39588) --- libs/core/langchain_core/prompts/dict.py | 4 +- .../tests/unit_tests/prompts/test_dict.py | 43 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) 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/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]} From 0727992d4a0da611502ba453fe55f4b5d7fdc719 Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Tue, 11 Aug 2026 21:45:00 -0700 Subject: [PATCH 36/47] fix(anthropic): correct model profile data for Fable 5, Sonnet 5, Opus 4.1 (#39604) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixed `ChatAnthropic` model profiles: `claude-fable-5` and `claude-sonnet-5` now correctly report structured output support, `claude-fable-5` reports its reasoning effort levels and default, and the retired `claude-opus-4-1` entry was removed. --- Users calling `model.profile` on `ChatAnthropic` get wrong capability data for three models: - `claude-fable-5` reported `structured_output: false` and no reasoning-effort metadata, even though Fable 5 supports structured outputs and all five effort levels (`low` through `max`, defaulting to `high`). - `claude-sonnet-5` reported `structured_output: false` despite supporting structured outputs. - `claude-opus-4-1` lingered as a sparse augmentation-only entry (six fields, no token limits or modalities) after models.dev dropped it — Anthropic retired the model on 2026-08-05, so the entry is removed rather than backfilled. Corrections verified against Anthropic's [structured outputs compatibility list](https://platform.claude.com/docs/en/build-with-claude/structured-outputs), [effort docs](https://platform.claude.com/docs/en/build-with-claude/effort), and [deprecation schedule](https://platform.claude.com/docs/en/about-claude/model-deprecations). `_profiles.py` regenerated with `langchain-profiles refresh --provider anthropic`. --- .../langchain_anthropic/data/_profiles.py | 20 +++++++++---------- .../data/profile_augmentations.toml | 9 ++++++--- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/libs/partners/anthropic/langchain_anthropic/data/_profiles.py b/libs/partners/anthropic/langchain_anthropic/data/_profiles.py index 418757991c..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,14 +102,6 @@ _PROFILES: dict[str, dict[str, Any]] = { "image_tool_message": True, "tool_call_streaming": True, }, - "claude-opus-4-1": { - "image_url_inputs": True, - "pdf_inputs": True, - "pdf_tool_message": True, - "image_tool_message": True, - "structured_output": True, - "tool_call_streaming": True, - }, "claude-opus-4-5": { "name": "Claude Opus 4.5 (latest)", "release_date": "2025-11-24", @@ -404,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" From 5a28e17fbb437ba7ba8ea7ed8751735c85239728 Mon Sep 17 00:00:00 2001 From: ccurme Date: Wed, 12 Aug 2026 10:11:28 -0400 Subject: [PATCH 37/47] chore(core): add httpx as an explicit dep (#39612) --- libs/core/pyproject.toml | 1 + libs/core/uv.lock | 2 ++ 2 files changed, 3 insertions(+) diff --git a/libs/core/pyproject.toml b/libs/core/pyproject.toml index 93f340e06c..b1192229e0 100644 --- a/libs/core/pyproject.toml +++ b/libs/core/pyproject.toml @@ -25,6 +25,7 @@ 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/uv.lock b/libs/core/uv.lock index 6e470fe788..4403397d64 100644 --- a/libs/core/uv.lock +++ b/libs/core/uv.lock @@ -1043,6 +1043,7 @@ name = "langchain-core" 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" }, From b6eccc5f975ed8ccb7a709d00f83954dbfc86ea9 Mon Sep 17 00:00:00 2001 From: Hunter Lovell <40191806+hntrl@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:39:16 -0700 Subject: [PATCH 38/47] fix(core): handle falsy LLM and chat model caches (#39283) --- .../language_models/chat_models.py | 12 ++++++------ .../langchain_core/language_models/llms.py | 18 +++++++++--------- .../language_models/chat_models/test_cache.py | 4 ++++ .../language_models/llms/test_cache.py | 4 ++++ 4 files changed, 23 insertions(+), 15 deletions(-) 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/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() From 4545c74216099d011f0c844633fc67440617b516 Mon Sep 17 00:00:00 2001 From: "langchain-oss-model-profiles[bot]" <262694797+langchain-oss-model-profiles[bot]@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:56:35 -0700 Subject: [PATCH 39/47] chore(model-profiles): refresh model profile data (#39609) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Automated refresh of model profile data for all in-monorepo partner integrations via `langchain-profiles refresh`. 🤖 Generated by the [`refresh_model_profiles` workflow](https://github.com/langchain-ai/langchain/blob/master/.github/workflows/refresh_model_profiles.yml). ## Summary of changes **4 added · 0 removed · 2 changed** across 1 provider(s). ### openrouter **➕ 4 added** - `bytedance-seed/seed-2.0-code` — 262,144 ctx, 131,072 out, text+image+video in, reasoning, tools - `liquid/lfm-2.5-2.6b:free` — 128,000 ctx, 32,768 out, reasoning, tools - `nvidia/nemotron-3.5-lightning` — 262,144 ctx, 262,144 out, reasoning - `nvidia/nemotron-3.5-lightning:free` — 1,000,000 ctx, 65,536 out, reasoning, tools **✏️ 2 changed** - `z-ai/glm-5.2`: max output tokens 262,144 → 131,072 - `~deepseek/deepseek-v4-flash-latest`: max output tokens 131,072 → 262,144 Co-authored-by: mdrxy <61371264+mdrxy@users.noreply.github.com> --- .../langchain_openrouter/data/_profiles.py | 92 ++++++++++++++++++- 1 file changed, 90 insertions(+), 2 deletions(-) diff --git a/libs/partners/openrouter/langchain_openrouter/data/_profiles.py b/libs/partners/openrouter/langchain_openrouter/data/_profiles.py index eccf1d8684..d5cce90424 100644 --- a/libs/partners/openrouter/langchain_openrouter/data/_profiles.py +++ b/libs/partners/openrouter/langchain_openrouter/data/_profiles.py @@ -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", @@ -2202,6 +2224,28 @@ _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", @@ -3622,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", @@ -7329,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, @@ -7465,7 +7553,7 @@ _PROFILES: dict[str, dict[str, Any]] = { "last_updated": "2026-08-01", "open_weights": False, "max_input_tokens": 1048576, - "max_output_tokens": 131072, + "max_output_tokens": 262144, "text_inputs": True, "image_inputs": False, "audio_inputs": False, From 2d47a5f398acebd99af7cc1cc2afc611abfdd214 Mon Sep 17 00:00:00 2001 From: Mason Daugherty Date: Wed, 12 Aug 2026 09:58:11 -0700 Subject: [PATCH 40/47] chore(partners): bump langgraph floor in openai and huggingface lockfiles (#39617) --- libs/partners/huggingface/uv.lock | 12 ++++++------ libs/partners/openai/uv.lock | 12 ++++++------ 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/libs/partners/huggingface/uv.lock b/libs/partners/huggingface/uv.lock index 4a57783141..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]] diff --git a/libs/partners/openai/uv.lock b/libs/partners/openai/uv.lock index e7da3bb508..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,7 +630,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"] @@ -666,7 +666,7 @@ typing = [ [[package]] name = "langchain-core" -version = "1.5.3" +version = "1.5.4" source = { editable = "../../core" } dependencies = [ { name = "jsonpatch" }, @@ -864,7 +864,7 @@ typing = [ [[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]] From 0a86934ab5a778065e2756669dc1415cc64be145 Mon Sep 17 00:00:00 2001 From: ccurme Date: Wed, 12 Aug 2026 13:32:51 -0400 Subject: [PATCH 41/47] fix(core): clear usage metadata callback on exceptions in context manager (#39616) Co-authored-by: Solaris-star <820622658@qq.com> --- libs/core/langchain_core/callbacks/usage.py | 11 ++++++++--- .../unit_tests/callbacks/test_usage_callback.py | 13 +++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) 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/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} From 1a2585ee5f4e199cee2d0226f6d26bed2220b65e Mon Sep 17 00:00:00 2001 From: iroiro147 Date: Wed, 12 Aug 2026 23:09:37 +0530 Subject: [PATCH 42/47] fix(openrouter): preserve cost metadata in usage chunks (#39338) Co-authored-by: Chester Curme --- .../langchain_openrouter/chat_models.py | 18 +++++++++++-- .../integration_tests/test_chat_models.py | 25 ++++++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/libs/partners/openrouter/langchain_openrouter/chat_models.py b/libs/partners/openrouter/langchain_openrouter/chat_models.py index 8d98d7118b..e15ad993d7 100644 --- a/libs/partners/openrouter/langchain_openrouter/chat_models.py +++ b/libs/partners/openrouter/langchain_openrouter/chat_models.py @@ -618,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: @@ -703,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: 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: From dff3b7328717a92ba110a560285edd5b26f616f4 Mon Sep 17 00:00:00 2001 From: Hunter Lovell <40191806+hntrl@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:13:20 -0700 Subject: [PATCH 43/47] fix(langchain): preserve final repeated schema ordering (#39284) --- libs/langchain_v1/langchain/agents/factory.py | 6 +++++- .../tests/unit_tests/agents/test_state_schema.py | 4 ++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/libs/langchain_v1/langchain/agents/factory.py b/libs/langchain_v1/langchain/agents/factory.py index 32818171ed..b8712a1d55 100644 --- a/libs/langchain_v1/langchain/agents/factory.py +++ b/libs/langchain_v1/langchain/agents/factory.py @@ -456,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"), 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.""" From 624fd031c2d8b81e4ca9225065465d599675dbc1 Mon Sep 17 00:00:00 2001 From: Hunter Lovell <40191806+hntrl@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:22:04 -0700 Subject: [PATCH 44/47] fix(core): handle tool descriptions for infer_schema=False (#39573) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 王俊锋 <37211900+kinch-tech@users.noreply.github.com> --- libs/core/langchain_core/tools/convert.py | 9 ++++---- libs/core/tests/unit_tests/test_tools.py | 25 +++++++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/libs/core/langchain_core/tools/convert.py b/libs/core/langchain_core/tools/convert.py index d353fc5250..a4e7e510d3 100644 --- a/libs/core/langchain_core/tools/convert.py +++ b/libs/core/langchain_core/tools/convert.py @@ -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, diff --git a/libs/core/tests/unit_tests/test_tools.py b/libs/core/tests/unit_tests/test_tools.py index f23fd9052a..28ec11af51 100644 --- a/libs/core/tests/unit_tests/test_tools.py +++ b/libs/core/tests/unit_tests/test_tools.py @@ -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.""" From 7fb045b744cab05aace2c21484e27a42ac9aafde Mon Sep 17 00:00:00 2001 From: ccurme Date: Wed, 12 Aug 2026 15:41:51 -0400 Subject: [PATCH 45/47] fix(fireworks): avoid repeated `service_tier` [closes #39619] (#39620) --- .../langchain_fireworks/chat_models.py | 6 ++- .../tests/unit_tests/test_chat_models.py | 46 +++++++++++++++++-- 2 files changed, 45 insertions(+), 7 deletions(-) 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() From 13b2f8d727f6d78daa4f38f0cc078898582377ce Mon Sep 17 00:00:00 2001 From: Hunter Lovell <40191806+hntrl@users.noreply.github.com> Date: Wed, 12 Aug 2026 12:49:24 -0700 Subject: [PATCH 46/47] fix(core): handle v1 base model validation in async path (#39576) --- libs/core/langchain_core/tools/base.py | 2 +- libs/core/tests/unit_tests/test_tools.py | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) 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/tests/unit_tests/test_tools.py b/libs/core/tests/unit_tests/test_tools.py index 28ec11af51..2aaf509c5a 100644 --- a/libs/core/tests/unit_tests/test_tools.py +++ b/libs/core/tests/unit_tests/test_tools.py @@ -1148,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", [ From a648460cfd432174b253a24bdd0309c6b385ddbe Mon Sep 17 00:00:00 2001 From: Nishitha M <32355027+imnishitha@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:40:35 -0400 Subject: [PATCH 47/47] fix(core): issues in merging chunks (#39535) Closes #38064, #35259, #38850 `merge_dicts`, `merge_lists`, and `AddableDict` all guessed at merge semantics for streaming chunks in ways that silently corrupted data instead of failing loudly: - `merge_dicts` fell into the `int` branch for differing `bool` values (since `bool` subclasses `int`) and summed them, turning `True + False` into `1`. It now raises `TypeError`, consistent with other unmergeable types. - `merge_lists` used `"index" in e_left` on untyped list elements; when an element was a plain `str` containing the literal substring `"index"`, it then subscripted the string and raised an unrelated `TypeError`. It now checks `isinstance(e_left, dict)` first. - `AddableDict.__add__`/`__radd__` silently discarded the left-hand value on any `TypeError` from `chunk[key] + other[key]`. They now re-raise a `TypeError` naming the key and both types. ### Release note `merge_dicts` now raises `TypeError` for differing boolean values at the same key instead of silently summing them to an `int`; `AddableDict` addition now raises `TypeError` on type-incompatible keys instead of silently dropping data; `merge_lists` no longer misidentifies non-dict elements as index-keyed. Co-authored-by: open-swe[bot] --- libs/core/langchain_core/runnables/utils.py | 24 ++++++++++++++--- libs/core/langchain_core/utils/_merge.py | 12 ++++++++- .../tests/unit_tests/runnables/test_utils.py | 27 +++++++++++++++++++ libs/core/tests/unit_tests/test_messages.py | 8 ++++++ .../core/tests/unit_tests/utils/test_utils.py | 22 +++++++++++++++ 5 files changed, 88 insertions(+), 5 deletions(-) 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/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/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/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(