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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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/10] 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