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