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