mirror of
https://github.com/langchain-ai/langchain.git
synced 2026-10-05 17:35:28 +03:00
Merge branch 'master' into imnishitha/langchain/tool-choice-override
This commit is contained in:
18 files changed
+420
-156
No files matched your search
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
+34
-11
@@ -4,6 +4,7 @@ from collections.abc import Callable, Sequence
|
||||
from itertools import cycle
|
||||
from typing import Any, Literal
|
||||
|
||||
import pytest
|
||||
from langchain_core.language_models import LanguageModelInput
|
||||
from langchain_core.language_models.chat_models import BaseChatModel
|
||||
from langchain_core.language_models.fake_chat_models import GenericFakeChatModel
|
||||
@@ -12,6 +13,7 @@ from langchain_core.outputs import ChatGeneration, ChatResult
|
||||
from langchain_core.runnables import Runnable, RunnableConfig
|
||||
from langchain_core.tools import BaseTool, tool
|
||||
from pydantic import BaseModel, Field
|
||||
from pytest_mock import MockerFixture
|
||||
from typing_extensions import override
|
||||
|
||||
from langchain.agents import create_agent
|
||||
@@ -460,17 +462,38 @@ class TestLLMToolEmulatorModelConfiguration:
|
||||
# Should use the custom model for emulation
|
||||
assert isinstance(result["messages"][-1], AIMessage)
|
||||
|
||||
def test_default_model_used_when_none(self) -> None:
|
||||
"""Test that default model is used when model=None."""
|
||||
# Just test that initialization doesn't fail - don't require anthropic package
|
||||
# The actual default model requires langchain_anthropic which may not be installed
|
||||
try:
|
||||
emulator = LLMToolEmulator(tools=["get_weather"], model=None)
|
||||
assert emulator.model is not None
|
||||
except ImportError:
|
||||
# If anthropic isn't installed, that's fine for this unit test
|
||||
# The integration tests will verify the full functionality
|
||||
pass
|
||||
def test_default_model_deprecated_and_missing_langchain_anthropic_raises_clear_error(
|
||||
self,
|
||||
) -> None:
|
||||
"""Test the `model=None` default path without `langchain-anthropic` installed.
|
||||
|
||||
Regression test: omitting `model` used to either silently depend on
|
||||
`langchain-anthropic` or (in an earlier draft of this fix) raise a
|
||||
`TypeError` for a previously-supported call shape. It should instead
|
||||
keep working when a model provider is available, and raise an
|
||||
actionable `ImportError` (plus a `DeprecationWarning`) when it isn't.
|
||||
"""
|
||||
with (
|
||||
pytest.warns(DeprecationWarning, match="deprecated"),
|
||||
pytest.raises(ImportError, match="langchain-anthropic"),
|
||||
):
|
||||
LLMToolEmulator(tools=["get_weather"])
|
||||
|
||||
def test_default_model_used_when_none(self, mocker: MockerFixture) -> None:
|
||||
"""Test that the default model is used and a deprecation warning is raised."""
|
||||
fake_model = FakeEmulatorModel(responses=["response"])
|
||||
init_chat_model_mock = mocker.patch(
|
||||
"langchain.agents.middleware.tool_emulator.init_chat_model",
|
||||
return_value=fake_model,
|
||||
)
|
||||
|
||||
with pytest.warns(DeprecationWarning, match="deprecated"):
|
||||
emulator = LLMToolEmulator(tools=["get_weather"])
|
||||
|
||||
assert emulator.model is fake_model
|
||||
init_chat_model_mock.assert_called_once_with(
|
||||
"anthropic:claude-sonnet-4-5-20250929", temperature=1
|
||||
)
|
||||
|
||||
|
||||
class TestLLMToolEmulatorAsync:
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -27,12 +27,9 @@ def _get_metadata(result: Any) -> dict[str, Any]:
|
||||
"published_date": result.published_date,
|
||||
"author": result.author,
|
||||
}
|
||||
if getattr(result, "highlights"):
|
||||
metadata["highlights"] = result.highlights
|
||||
if getattr(result, "highlight_scores"):
|
||||
metadata["highlight_scores"] = result.highlight_scores
|
||||
if getattr(result, "summary"):
|
||||
metadata["summary"] = result.summary
|
||||
for attribute in ("highlights", "highlight_scores", "summary"):
|
||||
if value := getattr(result, attribute, None):
|
||||
metadata[attribute] = value
|
||||
return metadata
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Unit tests for the Exa retriever."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
from langchain_exa.retrievers import _get_metadata
|
||||
|
||||
|
||||
def _make_result(**optional_metadata: Any) -> SimpleNamespace:
|
||||
return SimpleNamespace(
|
||||
title="Example",
|
||||
url="https://example.com",
|
||||
id="result-1",
|
||||
score=0.95,
|
||||
published_date="2024-01-01",
|
||||
author="Author",
|
||||
**optional_metadata,
|
||||
)
|
||||
|
||||
|
||||
def test_get_metadata_omits_missing_optional_attributes() -> None:
|
||||
"""Test that missing optional result attributes are omitted."""
|
||||
assert _get_metadata(_make_result()) == {
|
||||
"title": "Example",
|
||||
"url": "https://example.com",
|
||||
"id": "result-1",
|
||||
"score": 0.95,
|
||||
"published_date": "2024-01-01",
|
||||
"author": "Author",
|
||||
}
|
||||
|
||||
|
||||
def test_get_metadata_includes_available_optional_attributes() -> None:
|
||||
"""Test that available optional attributes are retained independently."""
|
||||
metadata = _get_metadata(
|
||||
_make_result(highlights=["Excerpt"], summary="A short summary")
|
||||
)
|
||||
|
||||
assert metadata["highlights"] == ["Excerpt"]
|
||||
assert metadata["summary"] == "A short summary"
|
||||
assert "highlight_scores" not in metadata
|
||||
@@ -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",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Version information for `langchain-openai`."""
|
||||
|
||||
__version__ = "1.4.1"
|
||||
__version__ = "1.4.2"
|
||||
@@ -564,6 +564,7 @@ def _handle_openai_bad_request(e: openai.BadRequestError) -> None:
|
||||
"context_length_exceeded" in str(e)
|
||||
or "Input tokens exceed the configured limit" in e.message
|
||||
or "prompt is too long" in e.message
|
||||
or "ContextWindowExceededError" in e.message
|
||||
):
|
||||
raise OpenAIContextOverflowError(
|
||||
message=e.message, response=e.response, body=e.body
|
||||
@@ -984,7 +985,11 @@ class BaseChatOpenAI(BaseChatModel):
|
||||
"""
|
||||
|
||||
include_response_headers: bool = False
|
||||
"""Whether to include response headers in the output message `response_metadata`."""
|
||||
"""Whether to include response headers in the output message `response_metadata`.
|
||||
|
||||
Note: some inference providers return additional metadata (such as served model
|
||||
names) in the response headers. Enable to capture these metadata.
|
||||
"""
|
||||
|
||||
disabled_params: dict[str, Any] | None = Field(default=None)
|
||||
"""Parameters of the OpenAI client or `chat.completions` endpoint that should be
|
||||
@@ -3306,6 +3311,24 @@ class ChatOpenAI(BaseChatOpenAI): # type: ignore[override]
|
||||
)
|
||||
```
|
||||
|
||||
!!! warning "Model name can trigger Responses API routing"
|
||||
|
||||
The choice between the Chat Completions API (`/v1/chat/completions`)
|
||||
and the Responses API (`/v1/responses`) is inferred in part from the
|
||||
model name, independent of `base_url`.
|
||||
|
||||
`use_responses_api` should generally be set explicitly to avoid ambiguity,
|
||||
especially when using OpenAI-compatible providers:
|
||||
|
||||
```python
|
||||
model = ChatOpenAI(
|
||||
base_url="http://localhost:8000/v1",
|
||||
api_key="EMPTY",
|
||||
model="codex-7b-instruct",
|
||||
use_responses_api=False,
|
||||
)
|
||||
```
|
||||
|
||||
??? info "`model_kwargs` vs `extra_body`"
|
||||
|
||||
Use the correct parameter for different types of API arguments:
|
||||
|
||||
@@ -20,10 +20,10 @@ classifiers = [
|
||||
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
||||
]
|
||||
|
||||
version = "1.4.1"
|
||||
version = "1.4.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",
|
||||
]
|
||||
|
||||
@@ -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()
|
||||
|
||||
Generated
+8
-8
@@ -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" },
|
||||
]
|
||||
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}}]}
|
||||
|
||||
Reference in new issue
Block a user