Merge branch 'master' into nm/langchain/hitl-async-interrupt-context

This commit is contained in:
Nishitha M authored and GitHub committed 2026-08-07 10:22:11 -04:00
commit 41bc1f6950
5 files changed
+133 -14

No files matched your search

@@ -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:
@@ -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:
@@ -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:
@@ -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"):
@@ -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"}}]}