Merge branch 'master' into imnishitha/langchain/tool-choice-override

This commit is contained in:
Nishitha M authored and GitHub committed 2026-08-07 10:54:31 -04:00
commit 3c367fec0a
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 = []
@@ -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:
+2 -2
View File
@@ -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()
+8 -8
View File
@@ -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"}}]}