mirror of
https://github.com/langchain-ai/langchain.git
synced 2026-10-05 09:25:14 +03:00
Merge branch 'master' into nm/langchain/hitl-async-interrupt-context
This commit is contained in:
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:
|
||||
|
||||
+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:
|
||||
|
||||
@@ -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"}}]}
|
||||
|
||||
Reference in new issue
Block a user