libs/partners/anthropic/tests/unit_tests/test_chat_models.py PYTHON 5,155 lines View on github.com → Search inside
File is large — showing lines 1–2,000 of 5,155.
1"""Test chat model integration."""23from __future__ import annotations45import copy6import json7import os8import warnings9from collections.abc import Callable10from types import SimpleNamespace11from typing import Any, Literal, cast12from unittest.mock import MagicMock, patch1314import anthropic15import pytest16from anthropic.types import Message, TextBlock, Usage17from blockbuster import blockbuster_ctx18from langchain_core.exceptions import (19    ContextOverflowError,20    ModelAPIError,21    ModelAuthenticationError,22    ModelConnectionError,23    ModelError,24    ModelInvalidRequestError,25    ModelNotFoundError,26    ModelPermissionDeniedError,27    ModelRateLimitError,28    ModelTimeoutError,29)30from langchain_core.messages import (31    AIMessage,32    AIMessageChunk,33    HumanMessage,34    SystemMessage,35    ToolMessage,36)37from langchain_core.messages.content import create_text_block38from langchain_core.runnables import RunnableBinding39from langchain_core.tools import BaseTool, tool40from langchain_core.tracers.base import BaseTracer41from langchain_core.tracers.schemas import Run42from langchain_core.utils._gateway import GATEWAY_METADATA_RESPONSE_KEY43from pydantic import BaseModel, Field, RootModel, SecretStr, ValidationError44from pytest import CaptureFixture, MonkeyPatch4546from langchain_anthropic import ChatAnthropic47from langchain_anthropic._sdk_compat import _unsupported_sampling_params48from langchain_anthropic._version import __version__49from langchain_anthropic.chat_models import (50    _TOOL_CALL_ID_PATTERN,51    _create_usage_metadata,52    _drop_unsupported_root_composition_tools,53    _format_image,54    _format_messages,55    _is_builtin_tool,56    _merge_messages,57    _normalize_tool_call_id,58    _thinking_in_params,59    convert_to_anthropic_tool,60)61from tests.unit_tests._httpx_compat import httpx6263os.environ["ANTHROPIC_API_KEY"] = "foo"6465MODEL_NAME = "claude-sonnet-4-5-20250929"666768class _GatewayMetadataTracer(BaseTracer):69    """Captures gateway metadata promoted onto completed LLM runs."""7071    def __init__(self) -> None:72        super().__init__()73        self.gateway_metadata: dict[str, Any] | None = None7475    def _persist_run(self, run: Run) -> None:76        """No-op; runs are inspected as they complete."""7778    def _on_llm_end(self, run: Run) -> None:79        metadata = run.extra.get("metadata", {})80        gateway_metadata = metadata.get("ls_gateway_info")81        if isinstance(gateway_metadata, dict):82            self.gateway_metadata = gateway_metadata838485_GATEWAY_METADATA = {"provider": "anthropic"}86_MESSAGE_RESPONSE = {87    "id": "msg_123",88    "content": [{"type": "text", "text": "Bar Baz", "citations": None}],89    "model": MODEL_NAME,90    "role": "assistant",91    "stop_reason": "end_turn",92    "stop_sequence": None,93    "usage": {"input_tokens": 2, "output_tokens": 1},94    "type": "message",95}96_STREAM_EVENTS: list[dict[str, Any]] = [97    {98        "type": "message_start",99        "message": {100            **_MESSAGE_RESPONSE,101            "content": [],102            "stop_reason": None,103            "usage": {"input_tokens": 2, "output_tokens": 0},104        },105    },106    {107        "type": "content_block_delta",108        "index": 0,109        "delta": {"type": "text_delta", "text": "Bar Baz"},110    },111    {112        "type": "message_delta",113        "delta": {"stop_reason": "end_turn", "stop_sequence": None},114        "usage": {"input_tokens": 2, "output_tokens": 1},115    },116    {"type": "message_stop"},117]118119120def _gateway_handler(121    expected_beta: str | None,122) -> Callable[[Any], Any]:123    # Annotated `Any`: the concrete request/response classes come from `httpx`124    # or `httpx2` depending on the installed anthropic SDK.125    def handler(request: Any) -> Any:126        assert request.headers.get("anthropic-beta") == expected_beta127        headers = {"x-langsmith-gateway-metadata": json.dumps(_GATEWAY_METADATA)}128        if json.loads(request.content).get("stream"):129            stream = "".join(130                f"event: {event['type']}\ndata: {json.dumps(event)}\n\n"131                for event in _STREAM_EVENTS132            )133            return httpx.Response(134                200,135                text=stream,136                headers={**headers, "content-type": "text/event-stream"},137            )138        return httpx.Response(200, json=_MESSAGE_RESPONSE, headers=headers)139140    return handler141142143def _sync_gateway_client(betas: list[str] | None) -> anthropic.Client:144    return anthropic.Client(145        api_key="lsv2_pt_example",146        http_client=httpx.Client(147            transport=httpx.MockTransport(148                _gateway_handler(",".join(betas) if betas else None)149            )150        ),151    )152153154def _async_gateway_client(betas: list[str] | None) -> anthropic.AsyncClient:155    return anthropic.AsyncClient(156        api_key="lsv2_pt_example",157        http_client=httpx.AsyncClient(158            transport=httpx.MockTransport(159                _gateway_handler(",".join(betas) if betas else None)160            )161        ),162    )163164165@pytest.mark.parametrize("betas", [None, ["test-beta"]])166def test_anthropic_invoke_surfaces_gateway_metadata(167    betas: list[str] | None,168) -> None:169    """Gateway metadata header is surfaced on `generation_info`, not the message."""170    llm = ChatAnthropic(171        model=MODEL_NAME,172        api_key="lsv2_pt_example",173        betas=betas,174        max_tokens=10,175    )176    client = _sync_gateway_client(betas)177    tracer = _GatewayMetadataTracer()178    try:179        with patch.object(llm, "_client", client):180            result = llm.invoke("bar", config={"callbacks": [tracer]})181    finally:182        client.close()183184    assert tracer.gateway_metadata == _GATEWAY_METADATA185    assert GATEWAY_METADATA_RESPONSE_KEY not in result.response_metadata186187188@pytest.mark.parametrize("betas", [None, ["test-beta"]])189async def test_anthropic_ainvoke_surfaces_gateway_metadata(190    betas: list[str] | None,191) -> None:192    """Async gateway responses surface metadata on `generation_info`."""193    llm = ChatAnthropic(194        model=MODEL_NAME,195        api_key="lsv2_pt_example",196        betas=betas,197        max_tokens=10,198    )199    client = _async_gateway_client(betas)200    tracer = _GatewayMetadataTracer()201    try:202        with patch.object(llm, "_async_client", client):203            result = await llm.ainvoke("bar", config={"callbacks": [tracer]})204    finally:205        await client.close()206207    assert tracer.gateway_metadata == _GATEWAY_METADATA208    assert GATEWAY_METADATA_RESPONSE_KEY not in result.response_metadata209210211@pytest.mark.parametrize("betas", [None, ["test-beta"]])212def test_anthropic_stream_surfaces_gateway_metadata(213    betas: list[str] | None,214) -> None:215    """Gateway metadata is attached to the first streaming generation chunk."""216    llm = ChatAnthropic(217        model=MODEL_NAME,218        api_key="lsv2_pt_example",219        betas=betas,220        max_tokens=10,221    )222    client = _sync_gateway_client(betas)223    try:224        with patch.object(llm, "_client", client):225            chunks = list(llm._stream([HumanMessage("bar")]))226    finally:227        client.close()228229    assert [chunk.generation_info for chunk in chunks] == [230        {GATEWAY_METADATA_RESPONSE_KEY: _GATEWAY_METADATA},231        None,232        None,233    ]234235236@pytest.mark.parametrize("betas", [None, ["test-beta"]])237async def test_anthropic_astream_surfaces_gateway_metadata(238    betas: list[str] | None,239) -> None:240    """Async gateway metadata is attached to the first streaming chunk."""241    llm = ChatAnthropic(242        model=MODEL_NAME,243        api_key="lsv2_pt_example",244        betas=betas,245        max_tokens=10,246    )247    client = _async_gateway_client(betas)248    try:249        with patch.object(llm, "_async_client", client):250            chunks = [chunk async for chunk in llm._astream([HumanMessage("bar")])]251    finally:252        await client.close()253254    assert [chunk.generation_info for chunk in chunks] == [255        {GATEWAY_METADATA_RESPONSE_KEY: _GATEWAY_METADATA},256        None,257        None,258    ]259260261def test_initialization() -> None:262    """Test chat model initialization."""263    with patch.dict(os.environ, {"ANTHROPIC_API_URL": "https://api.anthropic.com"}):264        for model in [265            ChatAnthropic(model_name=MODEL_NAME, api_key="xyz", timeout=2),  # type: ignore[arg-type, call-arg]266            ChatAnthropic(  # type: ignore[call-arg, call-arg, call-arg]267                model=MODEL_NAME,268                anthropic_api_key="xyz",269                default_request_timeout=2,270                base_url="https://api.anthropic.com",271            ),272        ]:273            assert model.model == MODEL_NAME274            assert (275                cast("SecretStr", model.anthropic_api_key).get_secret_value() == "xyz"276            )277            assert model.default_request_timeout == 2.0278            assert model.anthropic_api_url == "https://api.anthropic.com"279280281def test_user_agent_header_in_client_params() -> None:282    """Test that _client_params includes a User-Agent header."""283    llm = ChatAnthropic(model=MODEL_NAME, api_key="test-key")  # type: ignore[arg-type]284    params = llm._client_params285    assert "default_headers" in params286    assert "User-Agent" in params["default_headers"]287    assert params["default_headers"]["User-Agent"].startswith("langchain-anthropic/")288289290@pytest.mark.parametrize("async_api", [True, False])291def test_streaming_attribute_should_stream(async_api: bool) -> None:  # noqa: FBT001292    llm = ChatAnthropic(model=MODEL_NAME, streaming=True)293    assert llm._should_stream(async_api=async_api)294295296def test_anthropic_client_caching() -> None:297    """Test that the OpenAI client is cached."""298    llm1 = ChatAnthropic(model=MODEL_NAME)299    llm2 = ChatAnthropic(model=MODEL_NAME)300    assert llm1._client._client is llm2._client._client301302    llm3 = ChatAnthropic(model=MODEL_NAME, base_url="foo")303    assert llm1._client._client is not llm3._client._client304305    llm4 = ChatAnthropic(model=MODEL_NAME, timeout=None)306    assert llm1._client._client is llm4._client._client307308    llm5 = ChatAnthropic(model=MODEL_NAME, timeout=3)309    assert llm1._client._client is not llm5._client._client310311312def test_anthropic_proxy_support() -> None:313    """Test that both sync and async clients support proxy configuration."""314    proxy_url = "http://proxy.example.com:8080"315316    # Test sync client with proxy317    llm_sync = ChatAnthropic(model=MODEL_NAME, anthropic_proxy=proxy_url)318    sync_client = llm_sync._client319    assert sync_client is not None320321    # Test async client with proxy - this should not raise TypeError322    async_client = llm_sync._async_client323    assert async_client is not None324325    # Test that clients with different proxy settings are not cached together326    llm_no_proxy = ChatAnthropic(model=MODEL_NAME)327    llm_with_proxy = ChatAnthropic(model=MODEL_NAME, anthropic_proxy=proxy_url)328329    # Different proxy settings should result in different cached clients330    assert llm_no_proxy._client._client is not llm_with_proxy._client._client331332333def test_anthropic_proxy_from_environment() -> None:334    """Test that proxy can be set from ANTHROPIC_PROXY environment variable."""335    proxy_url = "http://env-proxy.example.com:8080"336337    # Test with environment variable set338    with patch.dict(os.environ, {"ANTHROPIC_PROXY": proxy_url}):339        llm = ChatAnthropic(model=MODEL_NAME)340        assert llm.anthropic_proxy == proxy_url341342        # Should be able to create clients successfully343        sync_client = llm._client344        async_client = llm._async_client345        assert sync_client is not None346        assert async_client is not None347348    # Test that explicit parameter overrides environment variable349    with patch.dict(os.environ, {"ANTHROPIC_PROXY": "http://env-proxy.com"}):350        explicit_proxy = "http://explicit-proxy.com"351        llm = ChatAnthropic(model=MODEL_NAME, anthropic_proxy=explicit_proxy)352        assert llm.anthropic_proxy == explicit_proxy353354355def test_set_default_max_tokens() -> None:356    """Test the set_default_max_tokens function."""357    # Test claude-sonnet-4-5 models358    llm = ChatAnthropic(model="claude-sonnet-4-5-20250929", anthropic_api_key="test")359    assert llm.max_tokens == 64000360361    # Test claude-haiku-4-5 models362    llm = ChatAnthropic(model="claude-haiku-4-5-20251001", anthropic_api_key="test")363    assert llm.max_tokens == 64000364365    # Test claude-3-5-haiku models (profile removed, should fall back to 4096)366    llm = ChatAnthropic(model="claude-3-5-haiku-20241022", anthropic_api_key="test")367    assert llm.max_tokens == 4096368369    # Test claude-3-haiku models (should default to 4096)370    llm = ChatAnthropic(model="claude-3-haiku-20240307", anthropic_api_key="test")371    assert llm.max_tokens == 4096372373    # Test that existing max_tokens values are preserved374    llm = ChatAnthropic(model=MODEL_NAME, max_tokens=2048, anthropic_api_key="test")375    assert llm.max_tokens == 2048376377    # Test that explicitly set max_tokens values are preserved378    llm = ChatAnthropic(model=MODEL_NAME, max_tokens=4096, anthropic_api_key="test")379    assert llm.max_tokens == 4096380381382@pytest.mark.requires("anthropic")383def test_anthropic_model_name_param() -> None:384    llm = ChatAnthropic(model_name=MODEL_NAME)  # type: ignore[call-arg, call-arg]385    assert llm.model == MODEL_NAME386387388@pytest.mark.requires("anthropic")389def test_anthropic_model_param() -> None:390    llm = ChatAnthropic(model=MODEL_NAME)  # type: ignore[call-arg]391    assert llm.model == MODEL_NAME392393394@pytest.mark.requires("anthropic")395def test_anthropic_model_kwargs() -> None:396    llm = ChatAnthropic(model_name=MODEL_NAME, model_kwargs={"foo": "bar"})  # type: ignore[call-arg, call-arg]397    assert llm.model_kwargs == {"foo": "bar"}398399400@pytest.mark.requires("anthropic")401def test_anthropic_fields_in_model_kwargs() -> None:402    """Test that for backwards compatibility fields can be passed in as model_kwargs."""403    with pytest.warns(404        UserWarning,405        match=(406            "Parameters {'max_tokens_to_sample'} should be specified explicitly. "407            "Instead they were passed in as part of `model_kwargs` parameter."408        ),409    ):410        llm = ChatAnthropic(model=MODEL_NAME, model_kwargs={"max_tokens_to_sample": 5})  # type: ignore[call-arg]411    assert llm.max_tokens == 5412    with pytest.warns(413        UserWarning,414        match=(415            "Parameters {'max_tokens'} should be specified explicitly. Instead they "416            "were passed in as part of `model_kwargs` parameter."417        ),418    ):419        llm = ChatAnthropic(model=MODEL_NAME, model_kwargs={"max_tokens": 5})  # type: ignore[call-arg]420    assert llm.max_tokens == 5421422423@pytest.mark.requires("anthropic")424def test_anthropic_incorrect_field() -> None:425    with pytest.warns(match="not default parameter"):426        llm = ChatAnthropic(model=MODEL_NAME, foo="bar")  # type: ignore[call-arg, call-arg]427    assert llm.model_kwargs == {"foo": "bar"}428429430@pytest.mark.requires("anthropic")431def test_anthropic_initialization() -> None:432    """Test anthropic initialization."""433    # Verify that chat anthropic can be initialized using a secret key provided434    # as a parameter rather than an environment variable.435    ChatAnthropic(model=MODEL_NAME, anthropic_api_key="test")  # type: ignore[call-arg, call-arg]436437438def test__format_output() -> None:439    anthropic_msg = Message(440        id="foo",441        content=[TextBlock(type="text", text="bar")],442        model="baz",443        role="assistant",444        stop_reason=None,445        stop_sequence=None,446        usage=Usage(input_tokens=2, output_tokens=1),447        type="message",448    )449    expected = AIMessage(  # type: ignore[misc]450        "bar",451        usage_metadata={452            "input_tokens": 2,453            "output_tokens": 1,454            "total_tokens": 3,455            "input_token_details": {},456        },457        response_metadata={"model_provider": "anthropic"},458    )459    llm = ChatAnthropic(model=MODEL_NAME, anthropic_api_key="test")  # type: ignore[call-arg, call-arg]460    actual = llm._format_output(anthropic_msg)461    assert actual.generations[0].message == expected462463464def test__format_output_cached() -> None:465    anthropic_msg = Message(466        id="foo",467        content=[TextBlock(type="text", text="bar")],468        model="baz",469        role="assistant",470        stop_reason=None,471        stop_sequence=None,472        usage=Usage(473            input_tokens=2,474            output_tokens=1,475            cache_creation_input_tokens=3,476            cache_read_input_tokens=4,477        ),478        type="message",479    )480    expected = AIMessage(  # type: ignore[misc]481        "bar",482        usage_metadata={483            "input_tokens": 9,484            "output_tokens": 1,485            "total_tokens": 10,486            "input_token_details": {"cache_creation": 3, "cache_read": 4},487        },488        response_metadata={"model_provider": "anthropic"},489    )490491    llm = ChatAnthropic(model=MODEL_NAME, anthropic_api_key="test")  # type: ignore[call-arg, call-arg]492    actual = llm._format_output(anthropic_msg)493    assert actual.generations[0].message == expected494495496def test__merge_messages() -> None:497    messages = [498        SystemMessage("foo"),  # type: ignore[misc]499        HumanMessage("bar"),  # type: ignore[misc]500        AIMessage(  # type: ignore[misc]501            [502                {"text": "baz", "type": "text"},503                {504                    "tool_input": {"a": "b"},505                    "type": "tool_use",506                    "id": "1",507                    "text": None,508                    "name": "buz",509                },510                {"text": "baz", "type": "text"},511                {512                    "tool_input": {"a": "c"},513                    "type": "tool_use",514                    "id": "2",515                    "text": None,516                    "name": "blah",517                },518                {519                    "tool_input": {"a": "c"},520                    "type": "tool_use",521                    "id": "3",522                    "text": None,523                    "name": "blah",524                },525            ],526        ),527        ToolMessage("buz output", tool_call_id="1", status="error"),  # type: ignore[misc]528        ToolMessage(529            content=[530                {531                    "type": "image",532                    "source": {533                        "type": "base64",534                        "media_type": "image/jpeg",535                        "data": "fake_image_data",536                    },537                },538            ],539            tool_call_id="2",540        ),  # type: ignore[misc]541        ToolMessage([], tool_call_id="3"),  # type: ignore[misc]542        HumanMessage("next thing"),  # type: ignore[misc]543    ]544    expected = [545        SystemMessage("foo"),  # type: ignore[misc]546        HumanMessage("bar"),  # type: ignore[misc]547        AIMessage(  # type: ignore[misc]548            [549                {"text": "baz", "type": "text"},550                {551                    "tool_input": {"a": "b"},552                    "type": "tool_use",553                    "id": "1",554                    "text": None,555                    "name": "buz",556                },557                {"text": "baz", "type": "text"},558                {559                    "tool_input": {"a": "c"},560                    "type": "tool_use",561                    "id": "2",562                    "text": None,563                    "name": "blah",564                },565                {566                    "tool_input": {"a": "c"},567                    "type": "tool_use",568                    "id": "3",569                    "text": None,570                    "name": "blah",571                },572            ],573        ),574        HumanMessage(  # type: ignore[misc]575            [576                {577                    "type": "tool_result",578                    "content": "buz output",579                    "tool_use_id": "1",580                    "is_error": True,581                },582                {583                    "type": "tool_result",584                    "content": [585                        {586                            "type": "image",587                            "source": {588                                "type": "base64",589                                "media_type": "image/jpeg",590                                "data": "fake_image_data",591                            },592                        },593                    ],594                    "tool_use_id": "2",595                    "is_error": False,596                },597                {598                    "type": "tool_result",599                    "content": [],600                    "tool_use_id": "3",601                    "is_error": False,602                },603                {"type": "text", "text": "next thing"},604            ],605        ),606    ]607    actual = _merge_messages(messages)608    assert expected == actual609610    # Test tool message case611    messages = [612        ToolMessage("buz output", tool_call_id="1"),  # type: ignore[misc]613        ToolMessage(  # type: ignore[misc]614            content=[615                {"type": "tool_result", "content": "blah output", "tool_use_id": "2"},616            ],617            tool_call_id="2",618        ),619    ]620    expected = [621        HumanMessage(  # type: ignore[misc]622            [623                {624                    "type": "tool_result",625                    "content": "buz output",626                    "tool_use_id": "1",627                    "is_error": False,628                },629                {"type": "tool_result", "content": "blah output", "tool_use_id": "2"},630            ],631        ),632    ]633    actual = _merge_messages(messages)634    assert expected == actual635636637def test__merge_messages_mutation() -> None:638    original_messages = [639        HumanMessage([{"type": "text", "text": "bar"}]),  # type: ignore[misc]640        HumanMessage("next thing"),  # type: ignore[misc]641    ]642    messages = [643        HumanMessage([{"type": "text", "text": "bar"}]),  # type: ignore[misc]644        HumanMessage("next thing"),  # type: ignore[misc]645    ]646    expected = [647        HumanMessage(  # type: ignore[misc]648            [{"type": "text", "text": "bar"}, {"type": "text", "text": "next thing"}],649        ),650    ]651    actual = _merge_messages(messages)652    assert expected == actual653    assert messages == original_messages654655656def test__merge_messages_tool_message_cache_control() -> None:657    """Test that cache_control is hoisted from content blocks to tool_result level."""658    # Test with cache_control in content block659    messages = [660        ToolMessage(661            content=[662                {663                    "type": "text",664                    "text": "tool output",665                    "cache_control": {"type": "ephemeral"},666                }667            ],668            tool_call_id="1",669        )670    ]671    original_messages = [copy.deepcopy(m) for m in messages]672    expected = [673        HumanMessage(674            [675                {676                    "type": "tool_result",677                    "content": [{"type": "text", "text": "tool output"}],678                    "tool_use_id": "1",679                    "is_error": False,680                    "cache_control": {"type": "ephemeral"},681                }682            ]683        )684    ]685    actual = _merge_messages(messages)686    assert expected == actual687    # Verify no mutation688    assert messages == original_messages689690    # Test with multiple content blocks, cache_control on last one691    messages = [692        ToolMessage(693            content=[694                {"type": "text", "text": "first output"},695                {696                    "type": "text",697                    "text": "second output",698                    "cache_control": {"type": "ephemeral"},699                },700            ],701            tool_call_id="2",702        )703    ]704    expected = [705        HumanMessage(706            [707                {708                    "type": "tool_result",709                    "content": [710                        {"type": "text", "text": "first output"},711                        {"type": "text", "text": "second output"},712                    ],713                    "tool_use_id": "2",714                    "is_error": False,715                    "cache_control": {"type": "ephemeral"},716                }717            ]718        )719    ]720    actual = _merge_messages(messages)721    assert expected == actual722723    # Test without cache_control724    messages = [ToolMessage(content="simple output", tool_call_id="3")]725    expected = [726        HumanMessage(727            [728                {729                    "type": "tool_result",730                    "content": "simple output",731                    "tool_use_id": "3",732                    "is_error": False,733                }734            ]735        )736    ]737    actual = _merge_messages(messages)738    assert expected == actual739740741def test__format_image() -> None:742    url = "dummyimage.com/600x400/000/fff"743    with pytest.raises(ValueError):744        _format_image(url)745746747@pytest.fixture748def pydantic() -> type[BaseModel]:749    class dummy_function(BaseModel):  # noqa: N801750        """Dummy function."""751752        arg1: int = Field(..., description="foo")753        arg2: Literal["bar", "baz"] = Field(..., description="one of 'bar', 'baz'")754755    return dummy_function756757758@pytest.fixture759def function() -> Callable:760    def dummy_function(arg1: int, arg2: Literal["bar", "baz"]) -> None:761        """Dummy function.762763        Args:764            arg1: foo765            arg2: one of 'bar', 'baz'766767        """768769    return dummy_function770771772@pytest.fixture773def dummy_tool() -> BaseTool:774    class Schema(BaseModel):775        arg1: int = Field(..., description="foo")776        arg2: Literal["bar", "baz"] = Field(..., description="one of 'bar', 'baz'")777778    class DummyFunction(BaseTool):  # type: ignore[override]779        args_schema: type[BaseModel] = Schema780        name: str = "dummy_function"781        description: str = "Dummy function."782783        def _run(self, *args: Any, **kwargs: Any) -> Any:784            pass785786    return DummyFunction()787788789@pytest.fixture790def json_schema() -> dict:791    return {792        "title": "dummy_function",793        "description": "Dummy function.",794        "type": "object",795        "properties": {796            "arg1": {"description": "foo", "type": "integer"},797            "arg2": {798                "description": "one of 'bar', 'baz'",799                "enum": ["bar", "baz"],800                "type": "string",801            },802        },803        "required": ["arg1", "arg2"],804    }805806807@pytest.fixture808def openai_function() -> dict:809    return {810        "name": "dummy_function",811        "description": "Dummy function.",812        "parameters": {813            "type": "object",814            "properties": {815                "arg1": {"description": "foo", "type": "integer"},816                "arg2": {817                    "description": "one of 'bar', 'baz'",818                    "enum": ["bar", "baz"],819                    "type": "string",820                },821            },822            "required": ["arg1", "arg2"],823        },824    }825826827def test_convert_to_anthropic_tool(828    pydantic: type[BaseModel],829    function: Callable,830    dummy_tool: BaseTool,831    json_schema: dict,832    openai_function: dict,833) -> None:834    expected = {835        "name": "dummy_function",836        "description": "Dummy function.",837        "input_schema": {838            "type": "object",839            "properties": {840                "arg1": {"description": "foo", "type": "integer"},841                "arg2": {842                    "description": "one of 'bar', 'baz'",843                    "enum": ["bar", "baz"],844                    "type": "string",845                },846            },847            "required": ["arg1", "arg2"],848        },849    }850851    for fn in (pydantic, function, dummy_tool, json_schema, expected, openai_function):852        actual = convert_to_anthropic_tool(fn)853        assert actual == expected854855856def test__format_messages_with_tool_calls() -> None:857    system = SystemMessage("fuzz")  # type: ignore[misc]858    human = HumanMessage("foo")  # type: ignore[misc]859    ai = AIMessage(860        "",  # with empty string861        tool_calls=[{"name": "bar", "id": "1", "args": {"baz": "buzz"}}],862    )863    ai2 = AIMessage(864        [],  # with empty list865        tool_calls=[{"name": "bar", "id": "2", "args": {"baz": "buzz"}}],866    )867    tool = ToolMessage(868        "blurb",869        tool_call_id="1",870    )871    tool_image_url = ToolMessage(872        [{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,...."}}],873        tool_call_id="2",874    )875    tool_image = ToolMessage(876        [877            {878                "type": "image",879                "source": {880                    "data": "....",881                    "type": "base64",882                    "media_type": "image/jpeg",883                },884            },885        ],886        tool_call_id="3",887    )888    messages = [system, human, ai, tool, ai2, tool_image_url, tool_image]889    expected = (890        "fuzz",891        [892            {"role": "user", "content": "foo"},893            {894                "role": "assistant",895                "content": [896                    {897                        "type": "tool_use",898                        "name": "bar",899                        "id": "1",900                        "input": {"baz": "buzz"},901                    },902                ],903            },904            {905                "role": "user",906                "content": [907                    {908                        "type": "tool_result",909                        "content": "blurb",910                        "tool_use_id": "1",911                        "is_error": False,912                    },913                ],914            },915            {916                "role": "assistant",917                "content": [918                    {919                        "type": "tool_use",920                        "name": "bar",921                        "id": "2",922                        "input": {"baz": "buzz"},923                    },924                ],925            },926            {927                "role": "user",928                "content": [929                    {930                        "type": "tool_result",931                        "content": [932                            {933                                "type": "image",934                                "source": {935                                    "data": "....",936                                    "type": "base64",937                                    "media_type": "image/jpeg",938                                },939                            },940                        ],941                        "tool_use_id": "2",942                        "is_error": False,943                    },944                    {945                        "type": "tool_result",946                        "content": [947                            {948                                "type": "image",949                                "source": {950                                    "data": "....",951                                    "type": "base64",952                                    "media_type": "image/jpeg",953                                },954                            },955                        ],956                        "tool_use_id": "3",957                        "is_error": False,958                    },959                ],960            },961        ],962    )963    actual = _format_messages(messages)964    assert expected == actual965966    # Check handling of empty AIMessage967    empty_contents: list[str | list[str | dict[str, Any]]] = ["", []]968    for empty_content in empty_contents:969        ## Permit message in final position970        _, anthropic_messages = _format_messages([human, AIMessage(empty_content)])971        expected_messages = [972            {"role": "user", "content": "foo"},973            {"role": "assistant", "content": empty_content},974        ]975        assert expected_messages == anthropic_messages976977        ## Remove message otherwise978        _, anthropic_messages = _format_messages(979            [human, AIMessage(empty_content), human]980        )981        expected_messages = [982            {"role": "user", "content": "foo"},983            {"role": "user", "content": "foo"},984        ]985        assert expected_messages == anthropic_messages986987        actual = _format_messages(988            [system, human, ai, tool, AIMessage(empty_content), human]989        )990        assert actual[0] == "fuzz"991        assert [message["role"] for message in actual[1]] == [992            "user",993            "assistant",994            "user",995            "user",996        ]997998999def test__normalize_tool_call_id() -> None:1000    # Already-valid IDs (including native Anthropic and OpenAI styles) pass1001    # through unchanged.1002    for valid in ("1", "toolu_01abcDEF-_", "call_Ao02pnFYXD6GN1yzc0uXPsvF"):1003        assert _normalize_tool_call_id(valid) == valid10041005    # Empty and None IDs pass through so a malformed request surfaces a clear1006    # error from Anthropic rather than a synthesized ID.1007    assert _normalize_tool_call_id("") == ""1008    assert _normalize_tool_call_id(None) is None10091010    # Foreign IDs with characters Anthropic rejects (e.g. Fireworks/Kimi's1011    # `functions.write_todos:0`) are rewritten to a compatible form.1012    invalid = "functions.write_todos:0"1013    normalized = _normalize_tool_call_id(invalid)1014    assert normalized is not None1015    assert normalized != invalid1016    assert _TOOL_CALL_ID_PATTERN.match(normalized)10171018    # Deterministic + idempotent: same input always maps to the same output.1019    assert _normalize_tool_call_id(invalid) == normalized1020    assert _normalize_tool_call_id(normalized) == normalized10211022    # Distinct invalid IDs map to distinct replacements (no collision that1023    # would break multi-tool turns).1024    other = _normalize_tool_call_id("functions.read_file:1")1025    assert other != normalized102610271028def test__format_messages_normalizes_cross_provider_tool_call_ids() -> None:1029    """A `tool_use.id` and its paired `tool_use_id` must normalize identically.10301031    Reproduces the Fireworks/Kimi -> Anthropic 400 from replaying a thread whose1032    tool-call IDs were minted by another provider.1033    """1034    bad_id = "functions.write_todos:0"1035    ai = AIMessage(1036        "",1037        tool_calls=[{"name": "write_todos", "id": bad_id, "args": {"todos": []}}],1038    )1039    tool = ToolMessage("done", tool_call_id=bad_id)10401041    _, formatted = _format_messages([HumanMessage("hi"), ai, tool])10421043    tool_use = formatted[1]["content"][0]1044    tool_result = formatted[2]["content"][0]1045    assert tool_use["type"] == "tool_use"1046    assert tool_result["type"] == "tool_result"10471048    # The rewritten IDs are valid and still reference each other.1049    assert _TOOL_CALL_ID_PATTERN.match(tool_use["id"])1050    assert tool_use["id"] == tool_result["tool_use_id"]1051    assert tool_use["id"] == _normalize_tool_call_id(bad_id)105210531054def test__format_messages_normalizes_prestructured_tool_result_id() -> None:1055    """A `ToolMessage` whose content is already `tool_result` blocks is covered.10561057    This shape bypasses the `tool_call_id` normalization in `_merge_messages` and1058    flows through the `tool_result` content branch, so its `tool_use_id` must1059    still be normalized to match the paired `tool_use.id`.1060    """1061    bad_id = "functions.write_todos:0"1062    ai = AIMessage(1063        "",1064        tool_calls=[{"name": "write_todos", "id": bad_id, "args": {"todos": []}}],1065    )1066    tool = ToolMessage(1067        [{"type": "tool_result", "tool_use_id": bad_id, "content": "done"}],1068        tool_call_id=bad_id,1069    )10701071    _, formatted = _format_messages([HumanMessage("hi"), ai, tool])10721073    tool_use = formatted[1]["content"][0]1074    tool_result = formatted[2]["content"][0]1075    assert tool_use["id"] == tool_result["tool_use_id"]1076    assert tool_use["id"] == _normalize_tool_call_id(bad_id)107710781079def test__format_messages_normalizes_inline_tool_use_block() -> None:1080    """An invalid ID on an inline `tool_use` content block is normalized.10811082    Covers the v1-compat destination where tool calls are stored as content1083    blocks rather than the `tool_calls` attribute, paired with a `ToolMessage`.1084    """1085    bad_id = "functions.search:2"1086    ai = AIMessage(1087        [{"type": "tool_use", "name": "search", "id": bad_id, "input": {"q": "x"}}],1088    )1089    tool = ToolMessage("result", tool_call_id=bad_id)10901091    _, formatted = _format_messages([HumanMessage("hi"), ai, tool])10921093    tool_use = formatted[1]["content"][0]1094    tool_result = formatted[2]["content"][0]1095    assert _TOOL_CALL_ID_PATTERN.match(tool_use["id"])1096    assert tool_use["id"] == tool_result["tool_use_id"]109710981099def test__format_messages_dedupes_overlapping_normalized_tool_use() -> None:1100    """An invalid ID shared by a `tool_use` block and `tool_calls` yields one block.11011102    Guards the dedup branch: `tool_use_ids` are normalized, so the comparison1103    against the (also normalized) tool-call ID must not re-emit a duplicate block.1104    """1105    bad_id = "functions.write_todos:0"1106    ai = AIMessage(1107        [{"type": "tool_use", "name": "write_todos", "id": bad_id, "input": {"a": 1}}],1108        tool_calls=[{"name": "write_todos", "id": bad_id, "args": {"a": 1}}],1109    )11101111    _, formatted = _format_messages([HumanMessage("hi"), ai])11121113    tool_use_blocks = [b for b in formatted[1]["content"] if b["type"] == "tool_use"]1114    assert len(tool_use_blocks) == 11115    assert _TOOL_CALL_ID_PATTERN.match(tool_use_blocks[0]["id"])111611171118def test__format_messages_normalizes_distinct_ids_independently() -> None:1119    """Multiple distinct invalid IDs in one turn stay distinct and correctly paired."""1120    id_a = "functions.write_todos:0"1121    id_b = "functions.read_file:1"1122    ai = AIMessage(1123        "",1124        tool_calls=[1125            {"name": "write_todos", "id": id_a, "args": {}},1126            {"name": "read_file", "id": id_b, "args": {}},1127        ],1128    )1129    tool_a = ToolMessage("a", tool_call_id=id_a)1130    tool_b = ToolMessage("b", tool_call_id=id_b)11311132    _, formatted = _format_messages([HumanMessage("hi"), ai, tool_a, tool_b])11331134    tool_uses = formatted[1]["content"]1135    results = formatted[2]["content"]1136    assert tool_uses[0]["id"] == _normalize_tool_call_id(id_a)1137    assert tool_uses[1]["id"] == _normalize_tool_call_id(id_b)1138    assert tool_uses[0]["id"] != tool_uses[1]["id"]1139    # Each result still pairs with its own tool_use.1140    assert {r["tool_use_id"] for r in results} == {1141        tool_uses[0]["id"],1142        tool_uses[1]["id"],1143    }114411451146def test__format_tool_use_block() -> None:1147    # Test we correctly format tool_use blocks when there is no corresponding tool_call.1148    message = AIMessage(1149        [1150            {1151                "type": "tool_use",1152                "name": "foo_1",1153                "id": "1",1154                "input": {"bar_1": "baz_1"},1155            },1156            {1157                "type": "tool_use",1158                "name": "foo_2",1159                "id": "2",1160                "input": {},1161                "partial_json": '{"bar_2": "baz_2"}',1162                "index": 1,1163            },1164        ]1165    )1166    result = _format_messages([message])1167    expected = {1168        "role": "assistant",1169        "content": [1170            {1171                "type": "tool_use",1172                "name": "foo_1",1173                "id": "1",1174                "input": {"bar_1": "baz_1"},1175            },1176            {1177                "type": "tool_use",1178                "name": "foo_2",1179                "id": "2",1180                "input": {"bar_2": "baz_2"},1181            },1182        ],1183    }1184    assert result == (None, [expected])118511861187def test__format_messages_with_str_content_and_tool_calls() -> None:1188    system = SystemMessage("fuzz")  # type: ignore[misc]1189    human = HumanMessage("foo")  # type: ignore[misc]1190    # If content and tool_calls are specified and content is a string, then both are1191    # included with content first.1192    ai = AIMessage(  # type: ignore[misc]1193        "thought",1194        tool_calls=[{"name": "bar", "id": "1", "args": {"baz": "buzz"}}],1195    )1196    tool = ToolMessage("blurb", tool_call_id="1")  # type: ignore[misc]1197    messages = [system, human, ai, tool]1198    expected = (1199        "fuzz",1200        [1201            {"role": "user", "content": "foo"},1202            {1203                "role": "assistant",1204                "content": [1205                    {"type": "text", "text": "thought"},1206                    {1207                        "type": "tool_use",1208                        "name": "bar",1209                        "id": "1",1210                        "input": {"baz": "buzz"},1211                    },1212                ],1213            },1214            {1215                "role": "user",1216                "content": [1217                    {1218                        "type": "tool_result",1219                        "content": "blurb",1220                        "tool_use_id": "1",1221                        "is_error": False,1222                    },1223                ],1224            },1225        ],1226    )1227    actual = _format_messages(messages)1228    assert expected == actual122912301231def test__format_messages_with_list_content_and_tool_calls() -> None:1232    system = SystemMessage("fuzz")  # type: ignore[misc]1233    human = HumanMessage("foo")  # type: ignore[misc]1234    ai = AIMessage(  # type: ignore[misc]1235        [{"type": "text", "text": "thought"}],1236        tool_calls=[{"name": "bar", "id": "1", "args": {"baz": "buzz"}}],1237    )1238    tool = ToolMessage(  # type: ignore[misc]1239        "blurb",1240        tool_call_id="1",1241    )1242    messages = [system, human, ai, tool]1243    expected = (1244        "fuzz",1245        [1246            {"role": "user", "content": "foo"},1247            {1248                "role": "assistant",1249                "content": [1250                    {"type": "text", "text": "thought"},1251                    {1252                        "type": "tool_use",1253                        "name": "bar",1254                        "id": "1",1255                        "input": {"baz": "buzz"},1256                    },1257                ],1258            },1259            {1260                "role": "user",1261                "content": [1262                    {1263                        "type": "tool_result",1264                        "content": "blurb",1265                        "tool_use_id": "1",1266                        "is_error": False,1267                    },1268                ],1269            },1270        ],1271    )1272    actual = _format_messages(messages)1273    assert expected == actual127412751276def test__format_messages_with_tool_use_blocks_and_tool_calls() -> None:1277    """Show that tool_calls are preferred to tool_use blocks when both have same id."""1278    system = SystemMessage("fuzz")  # type: ignore[misc]1279    human = HumanMessage("foo")  # type: ignore[misc]1280    # NOTE: tool_use block in contents and tool_calls have different arguments.1281    ai = AIMessage(  # type: ignore[misc]1282        [1283            {"type": "text", "text": "thought"},1284            {1285                "type": "tool_use",1286                "name": "bar",1287                "id": "1",1288                "input": {"baz": "NOT_BUZZ"},1289            },1290        ],1291        tool_calls=[{"name": "bar", "id": "1", "args": {"baz": "BUZZ"}}],1292    )1293    tool = ToolMessage("blurb", tool_call_id="1")  # type: ignore[misc]1294    messages = [system, human, ai, tool]1295    expected = (1296        "fuzz",1297        [1298            {"role": "user", "content": "foo"},1299            {1300                "role": "assistant",1301                "content": [1302                    {"type": "text", "text": "thought"},1303                    {1304                        "type": "tool_use",1305                        "name": "bar",1306                        "id": "1",1307                        "input": {"baz": "BUZZ"},  # tool_calls value preferred.1308                    },1309                ],1310            },1311            {1312                "role": "user",1313                "content": [1314                    {1315                        "type": "tool_result",1316                        "content": "blurb",1317                        "tool_use_id": "1",1318                        "is_error": False,1319                    },1320                ],1321            },1322        ],1323    )1324    actual = _format_messages(messages)1325    assert expected == actual132613271328def test__format_messages_with_cache_control() -> None:1329    messages = [1330        SystemMessage(1331            [1332                {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1333            ],1334        ),1335        HumanMessage(1336            [1337                {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1338                {1339                    "type": "text",1340                    "text": "foo",1341                },1342            ],1343        ),1344    ]1345    expected_system = [1346        {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1347    ]1348    expected_messages = [1349        {1350            "role": "user",1351            "content": [1352                {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1353                {"type": "text", "text": "foo"},1354            ],1355        },1356    ]1357    actual_system, actual_messages = _format_messages(messages)1358    assert expected_system == actual_system1359    assert expected_messages == actual_messages13601361    # Test standard multi-modal format (v0)1362    messages = [1363        HumanMessage(1364            [1365                {1366                    "type": "text",1367                    "text": "Summarize this document:",1368                },1369                {1370                    "type": "file",1371                    "source_type": "base64",1372                    "mime_type": "application/pdf",1373                    "data": "<base64 data>",1374                    "cache_control": {"type": "ephemeral"},1375                },1376            ],1377        ),1378    ]1379    actual_system, actual_messages = _format_messages(messages)1380    assert actual_system is None1381    expected_messages = [1382        {1383            "role": "user",1384            "content": [1385                {1386                    "type": "text",1387                    "text": "Summarize this document:",1388                },1389                {1390                    "type": "document",1391                    "source": {1392                        "type": "base64",1393                        "media_type": "application/pdf",1394                        "data": "<base64 data>",1395                    },1396                    "cache_control": {"type": "ephemeral"},1397                },1398            ],1399        },1400    ]1401    assert actual_messages == expected_messages14021403    # Test standard multi-modal format (v1)1404    messages = [1405        HumanMessage(1406            [1407                {1408                    "type": "text",1409                    "text": "Summarize this document:",1410                },1411                {1412                    "type": "file",1413                    "mime_type": "application/pdf",1414                    "base64": "<base64 data>",1415                    "extras": {"cache_control": {"type": "ephemeral"}},1416                },1417            ],1418        ),1419    ]1420    actual_system, actual_messages = _format_messages(messages)1421    assert actual_system is None1422    expected_messages = [1423        {1424            "role": "user",1425            "content": [1426                {1427                    "type": "text",1428                    "text": "Summarize this document:",1429                },1430                {1431                    "type": "document",1432                    "source": {1433                        "type": "base64",1434                        "media_type": "application/pdf",1435                        "data": "<base64 data>",1436                    },1437                    "cache_control": {"type": "ephemeral"},1438                },1439            ],1440        },1441    ]1442    assert actual_messages == expected_messages14431444    # Test standard multi-modal format (v1, unpacked extras)1445    messages = [1446        HumanMessage(1447            [1448                {1449                    "type": "text",1450                    "text": "Summarize this document:",1451                },1452                {1453                    "type": "file",1454                    "mime_type": "application/pdf",1455                    "base64": "<base64 data>",1456                    "cache_control": {"type": "ephemeral"},1457                },1458            ],1459        ),1460    ]1461    actual_system, actual_messages = _format_messages(messages)1462    assert actual_system is None1463    expected_messages = [1464        {1465            "role": "user",1466            "content": [1467                {1468                    "type": "text",1469                    "text": "Summarize this document:",1470                },1471                {1472                    "type": "document",1473                    "source": {1474                        "type": "base64",1475                        "media_type": "application/pdf",1476                        "data": "<base64 data>",1477                    },1478                    "cache_control": {"type": "ephemeral"},1479                },1480            ],1481        },1482    ]1483    assert actual_messages == expected_messages14841485    # Also test file inputs1486    ## Images1487    for block in [1488        # v11489        {1490            "type": "image",1491            "file_id": "abc123",1492        },1493        # v01494        {1495            "type": "image",1496            "source_type": "id",1497            "id": "abc123",1498        },1499    ]:1500        messages = [1501            HumanMessage(1502                [1503                    {1504                        "type": "text",1505                        "text": "Summarize this image:",1506                    },1507                    block,1508                ],1509            ),1510        ]1511        actual_system, actual_messages = _format_messages(messages)1512        assert actual_system is None1513        expected_messages = [1514            {1515                "role": "user",1516                "content": [1517                    {1518                        "type": "text",1519                        "text": "Summarize this image:",1520                    },1521                    {1522                        "type": "image",1523                        "source": {1524                            "type": "file",1525                            "file_id": "abc123",1526                        },1527                    },1528                ],1529            },1530        ]1531        assert actual_messages == expected_messages15321533    ## Documents1534    for block in [1535        # v11536        {1537            "type": "file",1538            "file_id": "abc123",1539        },1540        # v01541        {1542            "type": "file",1543            "source_type": "id",1544            "id": "abc123",1545        },1546    ]:1547        messages = [1548            HumanMessage(1549                [1550                    {1551                        "type": "text",1552                        "text": "Summarize this document:",1553                    },1554                    block,1555                ],1556            ),1557        ]1558        actual_system, actual_messages = _format_messages(messages)1559        assert actual_system is None1560        expected_messages = [1561            {1562                "role": "user",1563                "content": [1564                    {1565                        "type": "text",1566                        "text": "Summarize this document:",1567                    },1568                    {1569                        "type": "document",1570                        "source": {1571                            "type": "file",1572                            "file_id": "abc123",1573                        },1574                    },1575                ],1576            },1577        ]1578        assert actual_messages == expected_messages157915801581def test__format_messages_with_citations() -> None:1582    input_messages = [1583        HumanMessage(1584            content=[1585                {1586                    "type": "file",1587                    "source_type": "text",1588                    "text": "The grass is green. The sky is blue.",1589                    "mime_type": "text/plain",1590                    "citations": {"enabled": True},1591                },1592                {"type": "text", "text": "What color is the grass and sky?"},1593            ],1594        ),1595    ]1596    expected_messages = [1597        {1598            "role": "user",1599            "content": [1600                {1601                    "type": "document",1602                    "source": {1603                        "type": "text",1604                        "media_type": "text/plain",1605                        "data": "The grass is green. The sky is blue.",1606                    },1607                    "citations": {"enabled": True},1608                },1609                {"type": "text", "text": "What color is the grass and sky?"},1610            ],1611        },1612    ]1613    actual_system, actual_messages = _format_messages(input_messages)1614    assert actual_system is None1615    assert actual_messages == expected_messages161616171618def test__format_messages_openai_image_format() -> None:1619    message = HumanMessage(1620        content=[1621            {1622                "type": "text",1623                "text": "Can you highlight the differences between these two images?",1624            },1625            {1626                "type": "image_url",1627                "image_url": {"url": "data:image/jpeg;base64,<base64 data>"},1628            },1629            {1630                "type": "image_url",1631                "image_url": {"url": "https://<image url>"},1632            },1633        ],1634    )1635    actual_system, actual_messages = _format_messages([message])1636    assert actual_system is None1637    expected_messages = [1638        {1639            "role": "user",1640            "content": [1641                {1642                    "type": "text",1643                    "text": (1644                        "Can you highlight the differences between these two images?"1645                    ),1646                },1647                {1648                    "type": "image",1649                    "source": {1650                        "type": "base64",1651                        "media_type": "image/jpeg",1652                        "data": "<base64 data>",1653                    },1654                },1655                {1656                    "type": "image",1657                    "source": {1658                        "type": "url",1659                        "url": "https://<image url>",1660                    },1661                },1662            ],1663        },1664    ]1665    assert actual_messages == expected_messages166616671668def test__format_messages_with_multiple_system() -> None:1669    messages = [1670        HumanMessage("baz"),1671        SystemMessage("bar"),1672        SystemMessage("baz"),1673        SystemMessage(1674            [1675                {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1676            ],1677        ),1678    ]1679    expected_system = [1680        {"type": "text", "text": "bar"},1681        {"type": "text", "text": "baz"},1682        {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1683    ]1684    expected_messages = [{"role": "user", "content": "baz"}]1685    actual_system, actual_messages = _format_messages(messages)1686    assert expected_system == actual_system1687    assert expected_messages == actual_messages168816891690def test__format_messages_system_v1_content_blocks_drop_id() -> None:1691    """System text blocks from `create_text_block` must not leak the `id` field.16921693    See https://github.com/langchain-ai/langchain/issues/391001694    """1695    messages = [1696        SystemMessage(content_blocks=[create_text_block("You are helpful.")]),1697        HumanMessage("hi"),1698    ]1699    actual_system, actual_messages = _format_messages(messages)1700    assert actual_system == [{"type": "text", "text": "You are helpful."}]1701    assert actual_messages == [{"role": "user", "content": "hi"}]170217031704def test__format_messages_system_text_block_preserves_supported_fields() -> None:1705    """Sanitizing system text blocks keeps Anthropic-supported fields."""1706    messages = [1707        SystemMessage(1708            [1709                {1710                    "type": "text",1711                    "text": "foo",1712                    "id": "lc_abc123",1713                    "cache_control": {"type": "ephemeral"},1714                },1715            ],1716        ),1717        HumanMessage("hi"),1718    ]1719    actual_system, _ = _format_messages(messages)1720    assert actual_system == [1721        {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1722    ]172317241725def test_anthropic_api_key_is_secret_string() -> None:1726    """Test that the API key is stored as a SecretStr."""1727    chat_model = ChatAnthropic(  # type: ignore[call-arg, call-arg]1728        model=MODEL_NAME,1729        anthropic_api_key="secret-api-key",1730    )1731    assert isinstance(chat_model.anthropic_api_key, SecretStr)173217331734def test_anthropic_api_key_masked_when_passed_from_env(1735    monkeypatch: MonkeyPatch,1736    capsys: CaptureFixture,1737) -> None:1738    """Test that the API key is masked when passed from an environment variable."""1739    monkeypatch.setenv("ANTHROPIC_API_KEY ", "secret-api-key")1740    chat_model = ChatAnthropic(  # type: ignore[call-arg]1741        model=MODEL_NAME,1742    )1743    print(chat_model.anthropic_api_key, end="")  # noqa: T2011744    captured = capsys.readouterr()17451746    assert captured.out == "**********"174717481749def test_anthropic_api_key_masked_when_passed_via_constructor(1750    capsys: CaptureFixture,1751) -> None:1752    """Test that the API key is masked when passed via the constructor."""1753    chat_model = ChatAnthropic(  # type: ignore[call-arg, call-arg]1754        model=MODEL_NAME,1755        anthropic_api_key="secret-api-key",1756    )1757    print(chat_model.anthropic_api_key, end="")  # noqa: T2011758    captured = capsys.readouterr()17591760    assert captured.out == "**********"176117621763def test_anthropic_uses_actual_secret_value_from_secretstr() -> None:1764    """Test that the actual secret value is correctly retrieved."""1765    chat_model = ChatAnthropic(  # type: ignore[call-arg, call-arg]1766        model=MODEL_NAME,1767        anthropic_api_key="secret-api-key",1768    )1769    assert (1770        cast("SecretStr", chat_model.anthropic_api_key).get_secret_value()1771        == "secret-api-key"1772    )177317741775class GetWeather(BaseModel):1776    """Get the current weather in a given location."""17771778    location: str = Field(..., description="The city and state, e.g. San Francisco, CA")177917801781def test_anthropic_bind_tools_tool_choice() -> None:1782    chat_model = ChatAnthropic(  # type: ignore[call-arg, call-arg]1783        model=MODEL_NAME,1784        anthropic_api_key="secret-api-key",1785    )1786    chat_model_with_tools = chat_model.bind_tools(1787        [GetWeather],1788        tool_choice={"type": "tool", "name": "GetWeather"},1789    )1790    assert cast("RunnableBinding", chat_model_with_tools).kwargs["tool_choice"] == {1791        "type": "tool",1792        "name": "GetWeather",1793    }1794    chat_model_with_tools = chat_model.bind_tools(1795        [GetWeather],1796        tool_choice="GetWeather",1797    )1798    assert cast("RunnableBinding", chat_model_with_tools).kwargs["tool_choice"] == {1799        "type": "tool",1800        "name": "GetWeather",1801    }1802    chat_model_with_tools = chat_model.bind_tools([GetWeather], tool_choice="auto")1803    assert cast("RunnableBinding", chat_model_with_tools).kwargs["tool_choice"] == {1804        "type": "auto",1805    }1806    chat_model_with_tools = chat_model.bind_tools([GetWeather], tool_choice="any")1807    assert cast("RunnableBinding", chat_model_with_tools).kwargs["tool_choice"] == {1808        "type": "any",1809    }181018111812def test_anthropic_bind_tools_does_not_mutate_tool_choice() -> None:1813    chat_model = ChatAnthropic(  # type: ignore[call-arg, call-arg]1814        model=MODEL_NAME,1815        anthropic_api_key="secret-api-key",1816    )1817    tool_choice = {"type": "tool", "name": "GetWeather"}18181819    chat_model_with_tools = chat_model.bind_tools(1820        [GetWeather], tool_choice=tool_choice, parallel_tool_calls=False1821    )18221823    assert tool_choice == {"type": "tool", "name": "GetWeather"}1824    assert cast("RunnableBinding", chat_model_with_tools).kwargs["tool_choice"] == {1825        "type": "tool",1826        "name": "GetWeather",1827        "disable_parallel_tool_use": True,1828    }182918301831def test_bind_tools_drops_top_level_composition() -> None:1832    """Tools with a root `oneOf`/`anyOf` are dropped with a warning.18331834    The Anthropic API rejects tool schemas carrying these keywords at the top1835    level, failing the whole request. MCP servers can emit them. See1836    https://github.com/langchain-ai/langchain/issues/39271.1837    """1838    chat_model = ChatAnthropic(  # type: ignore[call-arg, call-arg]1839        model=MODEL_NAME,1840        anthropic_api_key="secret-api-key",1841    )1842    valid_tool = {1843        "name": "search",1844        "description": "Search",1845        "input_schema": {1846            "type": "object",1847            "properties": {"query": {"type": "string"}},1848            "required": ["query"],1849        },1850    }1851    invalid_tool = {1852        "name": "notion_create_attachment",1853        "description": "Create an attachment",1854        "input_schema": {1855            "type": "object",1856            "anyOf": [1857                {1858                    "type": "object",1859                    "properties": {"content": {"type": "string"}},1860                    "required": ["content"],1861                },1862                {1863                    "type": "object",1864                    "properties": {"source_url": {"type": "string"}},1865                    "required": ["source_url"],1866                },1867            ],1868        },1869    }1870    with pytest.warns(UserWarning, match="notion_create_attachment"):1871        chat_model_with_tools = chat_model.bind_tools([valid_tool, invalid_tool])18721873    bound = cast("RunnableBinding", chat_model_with_tools).kwargs["tools"]1874    assert [t["name"] for t in bound] == ["search"]187518761877def test_bind_tools_keeps_nested_composition_without_warning() -> None:1878    """Combinators nested under `properties` are valid and left untouched."""1879    chat_model = ChatAnthropic(  # type: ignore[call-arg, call-arg]1880        model=MODEL_NAME,1881        anthropic_api_key="secret-api-key",1882    )1883    tool = {1884        "name": "search",1885        "description": "Search",1886        "input_schema": {1887            "type": "object",1888            "properties": {1889                "value": {"anyOf": [{"type": "string"}, {"type": "integer"}]},1890            },1891            "required": ["value"],1892        },1893    }1894    with warnings.catch_warnings():1895        warnings.simplefilter("error")  # no warning expected1896        chat_model_with_tools = chat_model.bind_tools([tool])18971898    bound = cast("RunnableBinding", chat_model_with_tools).kwargs["tools"]1899    assert [t["name"] for t in bound] == ["search"]1900    assert bound[0]["input_schema"] == tool["input_schema"]190119021903def _composition_tool(name: str, keyword: str = "anyOf") -> dict:1904    """A tool whose root `input_schema` uses a top-level combinator."""1905    return {1906        "name": name,1907        "description": "Root schema composition.",1908        "input_schema": {1909            "type": "object",1910            keyword: [1911                {1912                    "type": "object",1913                    "properties": {"content": {"type": "string"}},1914                    "required": ["content"],1915                }1916            ],1917        },1918    }191919201921def _plain_tool(name: str) -> dict:1922    """A tool with a supported root `input_schema`."""1923    return {1924        "name": name,1925        "description": "Supported.",1926        "input_schema": {"type": "object", "properties": {}},1927    }192819291930@pytest.mark.parametrize("keyword", ["oneOf", "anyOf"])1931def test_bind_tools_drops_each_root_combinator(keyword: str) -> None:1932    """Every combinator in the unsupported set is filtered and named in the warning."""1933    chat_model = ChatAnthropic(  # type: ignore[call-arg, call-arg]1934        model=MODEL_NAME,1935        anthropic_api_key="secret-api-key",1936    )1937    with pytest.warns(UserWarning, match=f"top-level {keyword}") as record:1938        bound = chat_model.bind_tools(1939            [_plain_tool("search"), _composition_tool("attach", keyword)]1940        )19411942    assert [t["name"] for t in cast("RunnableBinding", bound).kwargs["tools"]] == [1943        "search"1944    ]1945    assert "attach" in str(record[0].message)194619471948def test_bind_tools_keeps_root_all_of_without_warning() -> None:1949    """A root `allOf` schema is supported and remains available to the model."""1950    chat_model = ChatAnthropic(  # type: ignore[call-arg, call-arg]1951        model=MODEL_NAME,1952        anthropic_api_key="secret-api-key",1953    )1954    tool = _composition_tool("attach", "allOf")1955    with warnings.catch_warnings():1956        warnings.simplefilter("error")1957        bound = chat_model.bind_tools([tool])19581959    bound_tools = cast("RunnableBinding", bound).kwargs["tools"]1960    assert [tool["name"] for tool in bound_tools] == ["attach"]1961    assert bound_tools[0]["input_schema"] == tool["input_schema"]196219631964def test_bind_tools_warning_names_every_offending_combinator() -> None:1965    """A schema with several root combinators reports all of them."""1966    chat_model = ChatAnthropic(  # type: ignore[call-arg, call-arg]1967        model=MODEL_NAME,1968        anthropic_api_key="secret-api-key",1969    )1970    tool = _composition_tool("attach")1971    tool["input_schema"]["oneOf"] = [{"type": "object", "properties": {}}]1972    with pytest.warns(UserWarning, match="top-level oneOf/anyOf"):1973        chat_model.bind_tools([_plain_tool("search"), tool])197419751976def test_bind_tools_passes_builtin_tools_through_unfiltered() -> None:1977    """Built-in server-side tools have no `input_schema` and are never dropped."""1978    chat_model = ChatAnthropic(  # type: ignore[call-arg, call-arg]1979        model=MODEL_NAME,1980        anthropic_api_key="secret-api-key",1981    )1982    builtin = {"type": "mcp_toolset", "mcp_server_name": "notion"}1983    with warnings.catch_warnings():1984        warnings.simplefilter("error")  # no warning expected1985        bound = chat_model.bind_tools([builtin])19861987    assert cast("RunnableBinding", bound).kwargs["tools"] == [builtin]198819891990def test_drop_unsupported_tools_describes_unnamed_tool() -> None:1991    """A dropped tool with no `name` is described, not rendered as `None`.19921993    Exercised on the helper directly: `convert_to_anthropic_tool` rejects a1994    nameless tool before `bind_tools` could ever reach this branch.1995    """1996    unnamed = _composition_tool("attach")1997    del unnamed["name"]1998    with pytest.warns(UserWarning, match="Dropping tool with no name") as record:1999        kept, dropped_names = _drop_unsupported_root_composition_tools([unnamed])

Findings

✓ No findings reported for this file.

Get this view in your editor

Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.