libs/partners/anthropic/tests/unit_tests/test_chat_models.py PYTHON 3,604 lines View on github.com → Search inside
File is large — showing lines 1–2,000 of 3,604.
1"""Test chat model integration."""23from __future__ import annotations45import copy6import os7import warnings8from collections.abc import Callable9from typing import Any, Literal, cast10from unittest.mock import MagicMock, patch1112import anthropic13import pytest14from anthropic.types import Message, TextBlock, Usage15from blockbuster import blockbuster_ctx16from langchain_core.exceptions import ContextOverflowError17from langchain_core.messages import (18    AIMessage,19    AIMessageChunk,20    HumanMessage,21    SystemMessage,22    ToolMessage,23)24from langchain_core.runnables import RunnableBinding25from langchain_core.tools import BaseTool, tool26from langchain_core.tracers.base import BaseTracer27from langchain_core.tracers.schemas import Run28from pydantic import BaseModel, Field, SecretStr, ValidationError29from pytest import CaptureFixture, MonkeyPatch3031from langchain_anthropic import ChatAnthropic32from langchain_anthropic._version import __version__33from langchain_anthropic.chat_models import (34    _TOOL_CALL_ID_PATTERN,35    _create_usage_metadata,36    _format_image,37    _format_messages,38    _is_builtin_tool,39    _merge_messages,40    _normalize_tool_call_id,41    _thinking_in_params,42    convert_to_anthropic_tool,43)4445os.environ["ANTHROPIC_API_KEY"] = "foo"4647MODEL_NAME = "claude-sonnet-4-5-20250929"484950def test_initialization() -> None:51    """Test chat model initialization."""52    with patch.dict(os.environ, {"ANTHROPIC_API_URL": "https://api.anthropic.com"}):53        for model in [54            ChatAnthropic(model_name=MODEL_NAME, api_key="xyz", timeout=2),  # type: ignore[arg-type, call-arg]55            ChatAnthropic(  # type: ignore[call-arg, call-arg, call-arg]56                model=MODEL_NAME,57                anthropic_api_key="xyz",58                default_request_timeout=2,59                base_url="https://api.anthropic.com",60            ),61        ]:62            assert model.model == MODEL_NAME63            assert (64                cast("SecretStr", model.anthropic_api_key).get_secret_value() == "xyz"65            )66            assert model.default_request_timeout == 2.067            assert model.anthropic_api_url == "https://api.anthropic.com"686970def test_user_agent_header_in_client_params() -> None:71    """Test that _client_params includes a User-Agent header."""72    llm = ChatAnthropic(model=MODEL_NAME, api_key="test-key")  # type: ignore[arg-type]73    params = llm._client_params74    assert "default_headers" in params75    assert "User-Agent" in params["default_headers"]76    assert params["default_headers"]["User-Agent"].startswith("langchain-anthropic/")777879@pytest.mark.parametrize("async_api", [True, False])80def test_streaming_attribute_should_stream(async_api: bool) -> None:  # noqa: FBT00181    llm = ChatAnthropic(model=MODEL_NAME, streaming=True)82    assert llm._should_stream(async_api=async_api)838485def test_anthropic_client_caching() -> None:86    """Test that the OpenAI client is cached."""87    llm1 = ChatAnthropic(model=MODEL_NAME)88    llm2 = ChatAnthropic(model=MODEL_NAME)89    assert llm1._client._client is llm2._client._client9091    llm3 = ChatAnthropic(model=MODEL_NAME, base_url="foo")92    assert llm1._client._client is not llm3._client._client9394    llm4 = ChatAnthropic(model=MODEL_NAME, timeout=None)95    assert llm1._client._client is llm4._client._client9697    llm5 = ChatAnthropic(model=MODEL_NAME, timeout=3)98    assert llm1._client._client is not llm5._client._client99100101def test_anthropic_proxy_support() -> None:102    """Test that both sync and async clients support proxy configuration."""103    proxy_url = "http://proxy.example.com:8080"104105    # Test sync client with proxy106    llm_sync = ChatAnthropic(model=MODEL_NAME, anthropic_proxy=proxy_url)107    sync_client = llm_sync._client108    assert sync_client is not None109110    # Test async client with proxy - this should not raise TypeError111    async_client = llm_sync._async_client112    assert async_client is not None113114    # Test that clients with different proxy settings are not cached together115    llm_no_proxy = ChatAnthropic(model=MODEL_NAME)116    llm_with_proxy = ChatAnthropic(model=MODEL_NAME, anthropic_proxy=proxy_url)117118    # Different proxy settings should result in different cached clients119    assert llm_no_proxy._client._client is not llm_with_proxy._client._client120121122def test_anthropic_proxy_from_environment() -> None:123    """Test that proxy can be set from ANTHROPIC_PROXY environment variable."""124    proxy_url = "http://env-proxy.example.com:8080"125126    # Test with environment variable set127    with patch.dict(os.environ, {"ANTHROPIC_PROXY": proxy_url}):128        llm = ChatAnthropic(model=MODEL_NAME)129        assert llm.anthropic_proxy == proxy_url130131        # Should be able to create clients successfully132        sync_client = llm._client133        async_client = llm._async_client134        assert sync_client is not None135        assert async_client is not None136137    # Test that explicit parameter overrides environment variable138    with patch.dict(os.environ, {"ANTHROPIC_PROXY": "http://env-proxy.com"}):139        explicit_proxy = "http://explicit-proxy.com"140        llm = ChatAnthropic(model=MODEL_NAME, anthropic_proxy=explicit_proxy)141        assert llm.anthropic_proxy == explicit_proxy142143144def test_set_default_max_tokens() -> None:145    """Test the set_default_max_tokens function."""146    # Test claude-sonnet-4-5 models147    llm = ChatAnthropic(model="claude-sonnet-4-5-20250929", anthropic_api_key="test")148    assert llm.max_tokens == 64000149150    # Test claude-opus-4-1 models151    llm = ChatAnthropic(model="claude-opus-4-1-20250805", anthropic_api_key="test")152    assert llm.max_tokens == 32000153154    # Test claude-haiku-4-5 models155    llm = ChatAnthropic(model="claude-haiku-4-5-20251001", anthropic_api_key="test")156    assert llm.max_tokens == 64000157158    # Test claude-3-5-haiku models (profile removed, should fall back to 4096)159    llm = ChatAnthropic(model="claude-3-5-haiku-20241022", anthropic_api_key="test")160    assert llm.max_tokens == 4096161162    # Test claude-3-haiku models (should default to 4096)163    llm = ChatAnthropic(model="claude-3-haiku-20240307", anthropic_api_key="test")164    assert llm.max_tokens == 4096165166    # Test that existing max_tokens values are preserved167    llm = ChatAnthropic(model=MODEL_NAME, max_tokens=2048, anthropic_api_key="test")168    assert llm.max_tokens == 2048169170    # Test that explicitly set max_tokens values are preserved171    llm = ChatAnthropic(model=MODEL_NAME, max_tokens=4096, anthropic_api_key="test")172    assert llm.max_tokens == 4096173174175@pytest.mark.requires("anthropic")176def test_anthropic_model_name_param() -> None:177    llm = ChatAnthropic(model_name=MODEL_NAME)  # type: ignore[call-arg, call-arg]178    assert llm.model == MODEL_NAME179180181@pytest.mark.requires("anthropic")182def test_anthropic_model_param() -> None:183    llm = ChatAnthropic(model=MODEL_NAME)  # type: ignore[call-arg]184    assert llm.model == MODEL_NAME185186187@pytest.mark.requires("anthropic")188def test_anthropic_model_kwargs() -> None:189    llm = ChatAnthropic(model_name=MODEL_NAME, model_kwargs={"foo": "bar"})  # type: ignore[call-arg, call-arg]190    assert llm.model_kwargs == {"foo": "bar"}191192193@pytest.mark.requires("anthropic")194def test_anthropic_fields_in_model_kwargs() -> None:195    """Test that for backwards compatibility fields can be passed in as model_kwargs."""196    with pytest.warns(197        UserWarning,198        match=(199            "Parameters {'max_tokens_to_sample'} should be specified explicitly. "200            "Instead they were passed in as part of `model_kwargs` parameter."201        ),202    ):203        llm = ChatAnthropic(model=MODEL_NAME, model_kwargs={"max_tokens_to_sample": 5})  # type: ignore[call-arg]204    assert llm.max_tokens == 5205    with pytest.warns(206        UserWarning,207        match=(208            "Parameters {'max_tokens'} should be specified explicitly. Instead they "209            "were passed in as part of `model_kwargs` parameter."210        ),211    ):212        llm = ChatAnthropic(model=MODEL_NAME, model_kwargs={"max_tokens": 5})  # type: ignore[call-arg]213    assert llm.max_tokens == 5214215216@pytest.mark.requires("anthropic")217def test_anthropic_incorrect_field() -> None:218    with pytest.warns(match="not default parameter"):219        llm = ChatAnthropic(model=MODEL_NAME, foo="bar")  # type: ignore[call-arg, call-arg]220    assert llm.model_kwargs == {"foo": "bar"}221222223@pytest.mark.requires("anthropic")224def test_anthropic_initialization() -> None:225    """Test anthropic initialization."""226    # Verify that chat anthropic can be initialized using a secret key provided227    # as a parameter rather than an environment variable.228    ChatAnthropic(model=MODEL_NAME, anthropic_api_key="test")  # type: ignore[call-arg, call-arg]229230231def test__format_output() -> None:232    anthropic_msg = Message(233        id="foo",234        content=[TextBlock(type="text", text="bar")],235        model="baz",236        role="assistant",237        stop_reason=None,238        stop_sequence=None,239        usage=Usage(input_tokens=2, output_tokens=1),240        type="message",241    )242    expected = AIMessage(  # type: ignore[misc]243        "bar",244        usage_metadata={245            "input_tokens": 2,246            "output_tokens": 1,247            "total_tokens": 3,248            "input_token_details": {},249        },250        response_metadata={"model_provider": "anthropic"},251    )252    llm = ChatAnthropic(model=MODEL_NAME, anthropic_api_key="test")  # type: ignore[call-arg, call-arg]253    actual = llm._format_output(anthropic_msg)254    assert actual.generations[0].message == expected255256257def test__format_output_cached() -> None:258    anthropic_msg = Message(259        id="foo",260        content=[TextBlock(type="text", text="bar")],261        model="baz",262        role="assistant",263        stop_reason=None,264        stop_sequence=None,265        usage=Usage(266            input_tokens=2,267            output_tokens=1,268            cache_creation_input_tokens=3,269            cache_read_input_tokens=4,270        ),271        type="message",272    )273    expected = AIMessage(  # type: ignore[misc]274        "bar",275        usage_metadata={276            "input_tokens": 9,277            "output_tokens": 1,278            "total_tokens": 10,279            "input_token_details": {"cache_creation": 3, "cache_read": 4},280        },281        response_metadata={"model_provider": "anthropic"},282    )283284    llm = ChatAnthropic(model=MODEL_NAME, anthropic_api_key="test")  # type: ignore[call-arg, call-arg]285    actual = llm._format_output(anthropic_msg)286    assert actual.generations[0].message == expected287288289def test__merge_messages() -> None:290    messages = [291        SystemMessage("foo"),  # type: ignore[misc]292        HumanMessage("bar"),  # type: ignore[misc]293        AIMessage(  # type: ignore[misc]294            [295                {"text": "baz", "type": "text"},296                {297                    "tool_input": {"a": "b"},298                    "type": "tool_use",299                    "id": "1",300                    "text": None,301                    "name": "buz",302                },303                {"text": "baz", "type": "text"},304                {305                    "tool_input": {"a": "c"},306                    "type": "tool_use",307                    "id": "2",308                    "text": None,309                    "name": "blah",310                },311                {312                    "tool_input": {"a": "c"},313                    "type": "tool_use",314                    "id": "3",315                    "text": None,316                    "name": "blah",317                },318            ],319        ),320        ToolMessage("buz output", tool_call_id="1", status="error"),  # type: ignore[misc]321        ToolMessage(322            content=[323                {324                    "type": "image",325                    "source": {326                        "type": "base64",327                        "media_type": "image/jpeg",328                        "data": "fake_image_data",329                    },330                },331            ],332            tool_call_id="2",333        ),  # type: ignore[misc]334        ToolMessage([], tool_call_id="3"),  # type: ignore[misc]335        HumanMessage("next thing"),  # type: ignore[misc]336    ]337    expected = [338        SystemMessage("foo"),  # type: ignore[misc]339        HumanMessage("bar"),  # type: ignore[misc]340        AIMessage(  # type: ignore[misc]341            [342                {"text": "baz", "type": "text"},343                {344                    "tool_input": {"a": "b"},345                    "type": "tool_use",346                    "id": "1",347                    "text": None,348                    "name": "buz",349                },350                {"text": "baz", "type": "text"},351                {352                    "tool_input": {"a": "c"},353                    "type": "tool_use",354                    "id": "2",355                    "text": None,356                    "name": "blah",357                },358                {359                    "tool_input": {"a": "c"},360                    "type": "tool_use",361                    "id": "3",362                    "text": None,363                    "name": "blah",364                },365            ],366        ),367        HumanMessage(  # type: ignore[misc]368            [369                {370                    "type": "tool_result",371                    "content": "buz output",372                    "tool_use_id": "1",373                    "is_error": True,374                },375                {376                    "type": "tool_result",377                    "content": [378                        {379                            "type": "image",380                            "source": {381                                "type": "base64",382                                "media_type": "image/jpeg",383                                "data": "fake_image_data",384                            },385                        },386                    ],387                    "tool_use_id": "2",388                    "is_error": False,389                },390                {391                    "type": "tool_result",392                    "content": [],393                    "tool_use_id": "3",394                    "is_error": False,395                },396                {"type": "text", "text": "next thing"},397            ],398        ),399    ]400    actual = _merge_messages(messages)401    assert expected == actual402403    # Test tool message case404    messages = [405        ToolMessage("buz output", tool_call_id="1"),  # type: ignore[misc]406        ToolMessage(  # type: ignore[misc]407            content=[408                {"type": "tool_result", "content": "blah output", "tool_use_id": "2"},409            ],410            tool_call_id="2",411        ),412    ]413    expected = [414        HumanMessage(  # type: ignore[misc]415            [416                {417                    "type": "tool_result",418                    "content": "buz output",419                    "tool_use_id": "1",420                    "is_error": False,421                },422                {"type": "tool_result", "content": "blah output", "tool_use_id": "2"},423            ],424        ),425    ]426    actual = _merge_messages(messages)427    assert expected == actual428429430def test__merge_messages_mutation() -> None:431    original_messages = [432        HumanMessage([{"type": "text", "text": "bar"}]),  # type: ignore[misc]433        HumanMessage("next thing"),  # type: ignore[misc]434    ]435    messages = [436        HumanMessage([{"type": "text", "text": "bar"}]),  # type: ignore[misc]437        HumanMessage("next thing"),  # type: ignore[misc]438    ]439    expected = [440        HumanMessage(  # type: ignore[misc]441            [{"type": "text", "text": "bar"}, {"type": "text", "text": "next thing"}],442        ),443    ]444    actual = _merge_messages(messages)445    assert expected == actual446    assert messages == original_messages447448449def test__merge_messages_tool_message_cache_control() -> None:450    """Test that cache_control is hoisted from content blocks to tool_result level."""451    # Test with cache_control in content block452    messages = [453        ToolMessage(454            content=[455                {456                    "type": "text",457                    "text": "tool output",458                    "cache_control": {"type": "ephemeral"},459                }460            ],461            tool_call_id="1",462        )463    ]464    original_messages = [copy.deepcopy(m) for m in messages]465    expected = [466        HumanMessage(467            [468                {469                    "type": "tool_result",470                    "content": [{"type": "text", "text": "tool output"}],471                    "tool_use_id": "1",472                    "is_error": False,473                    "cache_control": {"type": "ephemeral"},474                }475            ]476        )477    ]478    actual = _merge_messages(messages)479    assert expected == actual480    # Verify no mutation481    assert messages == original_messages482483    # Test with multiple content blocks, cache_control on last one484    messages = [485        ToolMessage(486            content=[487                {"type": "text", "text": "first output"},488                {489                    "type": "text",490                    "text": "second output",491                    "cache_control": {"type": "ephemeral"},492                },493            ],494            tool_call_id="2",495        )496    ]497    expected = [498        HumanMessage(499            [500                {501                    "type": "tool_result",502                    "content": [503                        {"type": "text", "text": "first output"},504                        {"type": "text", "text": "second output"},505                    ],506                    "tool_use_id": "2",507                    "is_error": False,508                    "cache_control": {"type": "ephemeral"},509                }510            ]511        )512    ]513    actual = _merge_messages(messages)514    assert expected == actual515516    # Test without cache_control517    messages = [ToolMessage(content="simple output", tool_call_id="3")]518    expected = [519        HumanMessage(520            [521                {522                    "type": "tool_result",523                    "content": "simple output",524                    "tool_use_id": "3",525                    "is_error": False,526                }527            ]528        )529    ]530    actual = _merge_messages(messages)531    assert expected == actual532533534def test__format_image() -> None:535    url = "dummyimage.com/600x400/000/fff"536    with pytest.raises(ValueError):537        _format_image(url)538539540@pytest.fixture541def pydantic() -> type[BaseModel]:542    class dummy_function(BaseModel):  # noqa: N801543        """Dummy function."""544545        arg1: int = Field(..., description="foo")546        arg2: Literal["bar", "baz"] = Field(..., description="one of 'bar', 'baz'")547548    return dummy_function549550551@pytest.fixture552def function() -> Callable:553    def dummy_function(arg1: int, arg2: Literal["bar", "baz"]) -> None:554        """Dummy function.555556        Args:557            arg1: foo558            arg2: one of 'bar', 'baz'559560        """561562    return dummy_function563564565@pytest.fixture566def dummy_tool() -> BaseTool:567    class Schema(BaseModel):568        arg1: int = Field(..., description="foo")569        arg2: Literal["bar", "baz"] = Field(..., description="one of 'bar', 'baz'")570571    class DummyFunction(BaseTool):  # type: ignore[override]572        args_schema: type[BaseModel] = Schema573        name: str = "dummy_function"574        description: str = "Dummy function."575576        def _run(self, *args: Any, **kwargs: Any) -> Any:577            pass578579    return DummyFunction()580581582@pytest.fixture583def json_schema() -> dict:584    return {585        "title": "dummy_function",586        "description": "Dummy function.",587        "type": "object",588        "properties": {589            "arg1": {"description": "foo", "type": "integer"},590            "arg2": {591                "description": "one of 'bar', 'baz'",592                "enum": ["bar", "baz"],593                "type": "string",594            },595        },596        "required": ["arg1", "arg2"],597    }598599600@pytest.fixture601def openai_function() -> dict:602    return {603        "name": "dummy_function",604        "description": "Dummy function.",605        "parameters": {606            "type": "object",607            "properties": {608                "arg1": {"description": "foo", "type": "integer"},609                "arg2": {610                    "description": "one of 'bar', 'baz'",611                    "enum": ["bar", "baz"],612                    "type": "string",613                },614            },615            "required": ["arg1", "arg2"],616        },617    }618619620def test_convert_to_anthropic_tool(621    pydantic: type[BaseModel],622    function: Callable,623    dummy_tool: BaseTool,624    json_schema: dict,625    openai_function: dict,626) -> None:627    expected = {628        "name": "dummy_function",629        "description": "Dummy function.",630        "input_schema": {631            "type": "object",632            "properties": {633                "arg1": {"description": "foo", "type": "integer"},634                "arg2": {635                    "description": "one of 'bar', 'baz'",636                    "enum": ["bar", "baz"],637                    "type": "string",638                },639            },640            "required": ["arg1", "arg2"],641        },642    }643644    for fn in (pydantic, function, dummy_tool, json_schema, expected, openai_function):645        actual = convert_to_anthropic_tool(fn)646        assert actual == expected647648649def test__format_messages_with_tool_calls() -> None:650    system = SystemMessage("fuzz")  # type: ignore[misc]651    human = HumanMessage("foo")  # type: ignore[misc]652    ai = AIMessage(653        "",  # with empty string654        tool_calls=[{"name": "bar", "id": "1", "args": {"baz": "buzz"}}],655    )656    ai2 = AIMessage(657        [],  # with empty list658        tool_calls=[{"name": "bar", "id": "2", "args": {"baz": "buzz"}}],659    )660    tool = ToolMessage(661        "blurb",662        tool_call_id="1",663    )664    tool_image_url = ToolMessage(665        [{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,...."}}],666        tool_call_id="2",667    )668    tool_image = ToolMessage(669        [670            {671                "type": "image",672                "source": {673                    "data": "....",674                    "type": "base64",675                    "media_type": "image/jpeg",676                },677            },678        ],679        tool_call_id="3",680    )681    messages = [system, human, ai, tool, ai2, tool_image_url, tool_image]682    expected = (683        "fuzz",684        [685            {"role": "user", "content": "foo"},686            {687                "role": "assistant",688                "content": [689                    {690                        "type": "tool_use",691                        "name": "bar",692                        "id": "1",693                        "input": {"baz": "buzz"},694                    },695                ],696            },697            {698                "role": "user",699                "content": [700                    {701                        "type": "tool_result",702                        "content": "blurb",703                        "tool_use_id": "1",704                        "is_error": False,705                    },706                ],707            },708            {709                "role": "assistant",710                "content": [711                    {712                        "type": "tool_use",713                        "name": "bar",714                        "id": "2",715                        "input": {"baz": "buzz"},716                    },717                ],718            },719            {720                "role": "user",721                "content": [722                    {723                        "type": "tool_result",724                        "content": [725                            {726                                "type": "image",727                                "source": {728                                    "data": "....",729                                    "type": "base64",730                                    "media_type": "image/jpeg",731                                },732                            },733                        ],734                        "tool_use_id": "2",735                        "is_error": False,736                    },737                    {738                        "type": "tool_result",739                        "content": [740                            {741                                "type": "image",742                                "source": {743                                    "data": "....",744                                    "type": "base64",745                                    "media_type": "image/jpeg",746                                },747                            },748                        ],749                        "tool_use_id": "3",750                        "is_error": False,751                    },752                ],753            },754        ],755    )756    actual = _format_messages(messages)757    assert expected == actual758759    # Check handling of empty AIMessage760    empty_contents: list[str | list[str | dict[str, Any]]] = ["", []]761    for empty_content in empty_contents:762        ## Permit message in final position763        _, anthropic_messages = _format_messages([human, AIMessage(empty_content)])764        expected_messages = [765            {"role": "user", "content": "foo"},766            {"role": "assistant", "content": empty_content},767        ]768        assert expected_messages == anthropic_messages769770        ## Remove message otherwise771        _, anthropic_messages = _format_messages(772            [human, AIMessage(empty_content), human]773        )774        expected_messages = [775            {"role": "user", "content": "foo"},776            {"role": "user", "content": "foo"},777        ]778        assert expected_messages == anthropic_messages779780        actual = _format_messages(781            [system, human, ai, tool, AIMessage(empty_content), human]782        )783        assert actual[0] == "fuzz"784        assert [message["role"] for message in actual[1]] == [785            "user",786            "assistant",787            "user",788            "user",789        ]790791792def test__normalize_tool_call_id() -> None:793    # Already-valid IDs (including native Anthropic and OpenAI styles) pass794    # through unchanged.795    for valid in ("1", "toolu_01abcDEF-_", "call_Ao02pnFYXD6GN1yzc0uXPsvF"):796        assert _normalize_tool_call_id(valid) == valid797798    # Empty and None IDs pass through so a malformed request surfaces a clear799    # error from Anthropic rather than a synthesized ID.800    assert _normalize_tool_call_id("") == ""801    assert _normalize_tool_call_id(None) is None802803    # Foreign IDs with characters Anthropic rejects (e.g. Fireworks/Kimi's804    # `functions.write_todos:0`) are rewritten to a compatible form.805    invalid = "functions.write_todos:0"806    normalized = _normalize_tool_call_id(invalid)807    assert normalized is not None808    assert normalized != invalid809    assert _TOOL_CALL_ID_PATTERN.match(normalized)810811    # Deterministic + idempotent: same input always maps to the same output.812    assert _normalize_tool_call_id(invalid) == normalized813    assert _normalize_tool_call_id(normalized) == normalized814815    # Distinct invalid IDs map to distinct replacements (no collision that816    # would break multi-tool turns).817    other = _normalize_tool_call_id("functions.read_file:1")818    assert other != normalized819820821def test__format_messages_normalizes_cross_provider_tool_call_ids() -> None:822    """A `tool_use.id` and its paired `tool_use_id` must normalize identically.823824    Reproduces the Fireworks/Kimi -> Anthropic 400 from replaying a thread whose825    tool-call IDs were minted by another provider.826    """827    bad_id = "functions.write_todos:0"828    ai = AIMessage(829        "",830        tool_calls=[{"name": "write_todos", "id": bad_id, "args": {"todos": []}}],831    )832    tool = ToolMessage("done", tool_call_id=bad_id)833834    _, formatted = _format_messages([HumanMessage("hi"), ai, tool])835836    tool_use = formatted[1]["content"][0]837    tool_result = formatted[2]["content"][0]838    assert tool_use["type"] == "tool_use"839    assert tool_result["type"] == "tool_result"840841    # The rewritten IDs are valid and still reference each other.842    assert _TOOL_CALL_ID_PATTERN.match(tool_use["id"])843    assert tool_use["id"] == tool_result["tool_use_id"]844    assert tool_use["id"] == _normalize_tool_call_id(bad_id)845846847def test__format_messages_normalizes_prestructured_tool_result_id() -> None:848    """A `ToolMessage` whose content is already `tool_result` blocks is covered.849850    This shape bypasses the `tool_call_id` normalization in `_merge_messages` and851    flows through the `tool_result` content branch, so its `tool_use_id` must852    still be normalized to match the paired `tool_use.id`.853    """854    bad_id = "functions.write_todos:0"855    ai = AIMessage(856        "",857        tool_calls=[{"name": "write_todos", "id": bad_id, "args": {"todos": []}}],858    )859    tool = ToolMessage(860        [{"type": "tool_result", "tool_use_id": bad_id, "content": "done"}],861        tool_call_id=bad_id,862    )863864    _, formatted = _format_messages([HumanMessage("hi"), ai, tool])865866    tool_use = formatted[1]["content"][0]867    tool_result = formatted[2]["content"][0]868    assert tool_use["id"] == tool_result["tool_use_id"]869    assert tool_use["id"] == _normalize_tool_call_id(bad_id)870871872def test__format_messages_normalizes_inline_tool_use_block() -> None:873    """An invalid ID on an inline `tool_use` content block is normalized.874875    Covers the v1-compat destination where tool calls are stored as content876    blocks rather than the `tool_calls` attribute, paired with a `ToolMessage`.877    """878    bad_id = "functions.search:2"879    ai = AIMessage(880        [{"type": "tool_use", "name": "search", "id": bad_id, "input": {"q": "x"}}],881    )882    tool = ToolMessage("result", tool_call_id=bad_id)883884    _, formatted = _format_messages([HumanMessage("hi"), ai, tool])885886    tool_use = formatted[1]["content"][0]887    tool_result = formatted[2]["content"][0]888    assert _TOOL_CALL_ID_PATTERN.match(tool_use["id"])889    assert tool_use["id"] == tool_result["tool_use_id"]890891892def test__format_messages_dedupes_overlapping_normalized_tool_use() -> None:893    """An invalid ID shared by a `tool_use` block and `tool_calls` yields one block.894895    Guards the dedup branch: `tool_use_ids` are normalized, so the comparison896    against the (also normalized) tool-call ID must not re-emit a duplicate block.897    """898    bad_id = "functions.write_todos:0"899    ai = AIMessage(900        [{"type": "tool_use", "name": "write_todos", "id": bad_id, "input": {"a": 1}}],901        tool_calls=[{"name": "write_todos", "id": bad_id, "args": {"a": 1}}],902    )903904    _, formatted = _format_messages([HumanMessage("hi"), ai])905906    tool_use_blocks = [b for b in formatted[1]["content"] if b["type"] == "tool_use"]907    assert len(tool_use_blocks) == 1908    assert _TOOL_CALL_ID_PATTERN.match(tool_use_blocks[0]["id"])909910911def test__format_messages_normalizes_distinct_ids_independently() -> None:912    """Multiple distinct invalid IDs in one turn stay distinct and correctly paired."""913    id_a = "functions.write_todos:0"914    id_b = "functions.read_file:1"915    ai = AIMessage(916        "",917        tool_calls=[918            {"name": "write_todos", "id": id_a, "args": {}},919            {"name": "read_file", "id": id_b, "args": {}},920        ],921    )922    tool_a = ToolMessage("a", tool_call_id=id_a)923    tool_b = ToolMessage("b", tool_call_id=id_b)924925    _, formatted = _format_messages([HumanMessage("hi"), ai, tool_a, tool_b])926927    tool_uses = formatted[1]["content"]928    results = formatted[2]["content"]929    assert tool_uses[0]["id"] == _normalize_tool_call_id(id_a)930    assert tool_uses[1]["id"] == _normalize_tool_call_id(id_b)931    assert tool_uses[0]["id"] != tool_uses[1]["id"]932    # Each result still pairs with its own tool_use.933    assert {r["tool_use_id"] for r in results} == {934        tool_uses[0]["id"],935        tool_uses[1]["id"],936    }937938939def test__format_tool_use_block() -> None:940    # Test we correctly format tool_use blocks when there is no corresponding tool_call.941    message = AIMessage(942        [943            {944                "type": "tool_use",945                "name": "foo_1",946                "id": "1",947                "input": {"bar_1": "baz_1"},948            },949            {950                "type": "tool_use",951                "name": "foo_2",952                "id": "2",953                "input": {},954                "partial_json": '{"bar_2": "baz_2"}',955                "index": 1,956            },957        ]958    )959    result = _format_messages([message])960    expected = {961        "role": "assistant",962        "content": [963            {964                "type": "tool_use",965                "name": "foo_1",966                "id": "1",967                "input": {"bar_1": "baz_1"},968            },969            {970                "type": "tool_use",971                "name": "foo_2",972                "id": "2",973                "input": {"bar_2": "baz_2"},974            },975        ],976    }977    assert result == (None, [expected])978979980def test__format_messages_with_str_content_and_tool_calls() -> None:981    system = SystemMessage("fuzz")  # type: ignore[misc]982    human = HumanMessage("foo")  # type: ignore[misc]983    # If content and tool_calls are specified and content is a string, then both are984    # included with content first.985    ai = AIMessage(  # type: ignore[misc]986        "thought",987        tool_calls=[{"name": "bar", "id": "1", "args": {"baz": "buzz"}}],988    )989    tool = ToolMessage("blurb", tool_call_id="1")  # type: ignore[misc]990    messages = [system, human, ai, tool]991    expected = (992        "fuzz",993        [994            {"role": "user", "content": "foo"},995            {996                "role": "assistant",997                "content": [998                    {"type": "text", "text": "thought"},999                    {1000                        "type": "tool_use",1001                        "name": "bar",1002                        "id": "1",1003                        "input": {"baz": "buzz"},1004                    },1005                ],1006            },1007            {1008                "role": "user",1009                "content": [1010                    {1011                        "type": "tool_result",1012                        "content": "blurb",1013                        "tool_use_id": "1",1014                        "is_error": False,1015                    },1016                ],1017            },1018        ],1019    )1020    actual = _format_messages(messages)1021    assert expected == actual102210231024def test__format_messages_with_list_content_and_tool_calls() -> None:1025    system = SystemMessage("fuzz")  # type: ignore[misc]1026    human = HumanMessage("foo")  # type: ignore[misc]1027    ai = AIMessage(  # type: ignore[misc]1028        [{"type": "text", "text": "thought"}],1029        tool_calls=[{"name": "bar", "id": "1", "args": {"baz": "buzz"}}],1030    )1031    tool = ToolMessage(  # type: ignore[misc]1032        "blurb",1033        tool_call_id="1",1034    )1035    messages = [system, human, ai, tool]1036    expected = (1037        "fuzz",1038        [1039            {"role": "user", "content": "foo"},1040            {1041                "role": "assistant",1042                "content": [1043                    {"type": "text", "text": "thought"},1044                    {1045                        "type": "tool_use",1046                        "name": "bar",1047                        "id": "1",1048                        "input": {"baz": "buzz"},1049                    },1050                ],1051            },1052            {1053                "role": "user",1054                "content": [1055                    {1056                        "type": "tool_result",1057                        "content": "blurb",1058                        "tool_use_id": "1",1059                        "is_error": False,1060                    },1061                ],1062            },1063        ],1064    )1065    actual = _format_messages(messages)1066    assert expected == actual106710681069def test__format_messages_with_tool_use_blocks_and_tool_calls() -> None:1070    """Show that tool_calls are preferred to tool_use blocks when both have same id."""1071    system = SystemMessage("fuzz")  # type: ignore[misc]1072    human = HumanMessage("foo")  # type: ignore[misc]1073    # NOTE: tool_use block in contents and tool_calls have different arguments.1074    ai = AIMessage(  # type: ignore[misc]1075        [1076            {"type": "text", "text": "thought"},1077            {1078                "type": "tool_use",1079                "name": "bar",1080                "id": "1",1081                "input": {"baz": "NOT_BUZZ"},1082            },1083        ],1084        tool_calls=[{"name": "bar", "id": "1", "args": {"baz": "BUZZ"}}],1085    )1086    tool = ToolMessage("blurb", tool_call_id="1")  # type: ignore[misc]1087    messages = [system, human, ai, tool]1088    expected = (1089        "fuzz",1090        [1091            {"role": "user", "content": "foo"},1092            {1093                "role": "assistant",1094                "content": [1095                    {"type": "text", "text": "thought"},1096                    {1097                        "type": "tool_use",1098                        "name": "bar",1099                        "id": "1",1100                        "input": {"baz": "BUZZ"},  # tool_calls value preferred.1101                    },1102                ],1103            },1104            {1105                "role": "user",1106                "content": [1107                    {1108                        "type": "tool_result",1109                        "content": "blurb",1110                        "tool_use_id": "1",1111                        "is_error": False,1112                    },1113                ],1114            },1115        ],1116    )1117    actual = _format_messages(messages)1118    assert expected == actual111911201121def test__format_messages_with_cache_control() -> None:1122    messages = [1123        SystemMessage(1124            [1125                {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1126            ],1127        ),1128        HumanMessage(1129            [1130                {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1131                {1132                    "type": "text",1133                    "text": "foo",1134                },1135            ],1136        ),1137    ]1138    expected_system = [1139        {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1140    ]1141    expected_messages = [1142        {1143            "role": "user",1144            "content": [1145                {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1146                {"type": "text", "text": "foo"},1147            ],1148        },1149    ]1150    actual_system, actual_messages = _format_messages(messages)1151    assert expected_system == actual_system1152    assert expected_messages == actual_messages11531154    # Test standard multi-modal format (v0)1155    messages = [1156        HumanMessage(1157            [1158                {1159                    "type": "text",1160                    "text": "Summarize this document:",1161                },1162                {1163                    "type": "file",1164                    "source_type": "base64",1165                    "mime_type": "application/pdf",1166                    "data": "<base64 data>",1167                    "cache_control": {"type": "ephemeral"},1168                },1169            ],1170        ),1171    ]1172    actual_system, actual_messages = _format_messages(messages)1173    assert actual_system is None1174    expected_messages = [1175        {1176            "role": "user",1177            "content": [1178                {1179                    "type": "text",1180                    "text": "Summarize this document:",1181                },1182                {1183                    "type": "document",1184                    "source": {1185                        "type": "base64",1186                        "media_type": "application/pdf",1187                        "data": "<base64 data>",1188                    },1189                    "cache_control": {"type": "ephemeral"},1190                },1191            ],1192        },1193    ]1194    assert actual_messages == expected_messages11951196    # Test standard multi-modal format (v1)1197    messages = [1198        HumanMessage(1199            [1200                {1201                    "type": "text",1202                    "text": "Summarize this document:",1203                },1204                {1205                    "type": "file",1206                    "mime_type": "application/pdf",1207                    "base64": "<base64 data>",1208                    "extras": {"cache_control": {"type": "ephemeral"}},1209                },1210            ],1211        ),1212    ]1213    actual_system, actual_messages = _format_messages(messages)1214    assert actual_system is None1215    expected_messages = [1216        {1217            "role": "user",1218            "content": [1219                {1220                    "type": "text",1221                    "text": "Summarize this document:",1222                },1223                {1224                    "type": "document",1225                    "source": {1226                        "type": "base64",1227                        "media_type": "application/pdf",1228                        "data": "<base64 data>",1229                    },1230                    "cache_control": {"type": "ephemeral"},1231                },1232            ],1233        },1234    ]1235    assert actual_messages == expected_messages12361237    # Test standard multi-modal format (v1, unpacked extras)1238    messages = [1239        HumanMessage(1240            [1241                {1242                    "type": "text",1243                    "text": "Summarize this document:",1244                },1245                {1246                    "type": "file",1247                    "mime_type": "application/pdf",1248                    "base64": "<base64 data>",1249                    "cache_control": {"type": "ephemeral"},1250                },1251            ],1252        ),1253    ]1254    actual_system, actual_messages = _format_messages(messages)1255    assert actual_system is None1256    expected_messages = [1257        {1258            "role": "user",1259            "content": [1260                {1261                    "type": "text",1262                    "text": "Summarize this document:",1263                },1264                {1265                    "type": "document",1266                    "source": {1267                        "type": "base64",1268                        "media_type": "application/pdf",1269                        "data": "<base64 data>",1270                    },1271                    "cache_control": {"type": "ephemeral"},1272                },1273            ],1274        },1275    ]1276    assert actual_messages == expected_messages12771278    # Also test file inputs1279    ## Images1280    for block in [1281        # v11282        {1283            "type": "image",1284            "file_id": "abc123",1285        },1286        # v01287        {1288            "type": "image",1289            "source_type": "id",1290            "id": "abc123",1291        },1292    ]:1293        messages = [1294            HumanMessage(1295                [1296                    {1297                        "type": "text",1298                        "text": "Summarize this image:",1299                    },1300                    block,1301                ],1302            ),1303        ]1304        actual_system, actual_messages = _format_messages(messages)1305        assert actual_system is None1306        expected_messages = [1307            {1308                "role": "user",1309                "content": [1310                    {1311                        "type": "text",1312                        "text": "Summarize this image:",1313                    },1314                    {1315                        "type": "image",1316                        "source": {1317                            "type": "file",1318                            "file_id": "abc123",1319                        },1320                    },1321                ],1322            },1323        ]1324        assert actual_messages == expected_messages13251326    ## Documents1327    for block in [1328        # v11329        {1330            "type": "file",1331            "file_id": "abc123",1332        },1333        # v01334        {1335            "type": "file",1336            "source_type": "id",1337            "id": "abc123",1338        },1339    ]:1340        messages = [1341            HumanMessage(1342                [1343                    {1344                        "type": "text",1345                        "text": "Summarize this document:",1346                    },1347                    block,1348                ],1349            ),1350        ]1351        actual_system, actual_messages = _format_messages(messages)1352        assert actual_system is None1353        expected_messages = [1354            {1355                "role": "user",1356                "content": [1357                    {1358                        "type": "text",1359                        "text": "Summarize this document:",1360                    },1361                    {1362                        "type": "document",1363                        "source": {1364                            "type": "file",1365                            "file_id": "abc123",1366                        },1367                    },1368                ],1369            },1370        ]1371        assert actual_messages == expected_messages137213731374def test__format_messages_with_citations() -> None:1375    input_messages = [1376        HumanMessage(1377            content=[1378                {1379                    "type": "file",1380                    "source_type": "text",1381                    "text": "The grass is green. The sky is blue.",1382                    "mime_type": "text/plain",1383                    "citations": {"enabled": True},1384                },1385                {"type": "text", "text": "What color is the grass and sky?"},1386            ],1387        ),1388    ]1389    expected_messages = [1390        {1391            "role": "user",1392            "content": [1393                {1394                    "type": "document",1395                    "source": {1396                        "type": "text",1397                        "media_type": "text/plain",1398                        "data": "The grass is green. The sky is blue.",1399                    },1400                    "citations": {"enabled": True},1401                },1402                {"type": "text", "text": "What color is the grass and sky?"},1403            ],1404        },1405    ]1406    actual_system, actual_messages = _format_messages(input_messages)1407    assert actual_system is None1408    assert actual_messages == expected_messages140914101411def test__format_messages_openai_image_format() -> None:1412    message = HumanMessage(1413        content=[1414            {1415                "type": "text",1416                "text": "Can you highlight the differences between these two images?",1417            },1418            {1419                "type": "image_url",1420                "image_url": {"url": "data:image/jpeg;base64,<base64 data>"},1421            },1422            {1423                "type": "image_url",1424                "image_url": {"url": "https://<image url>"},1425            },1426        ],1427    )1428    actual_system, actual_messages = _format_messages([message])1429    assert actual_system is None1430    expected_messages = [1431        {1432            "role": "user",1433            "content": [1434                {1435                    "type": "text",1436                    "text": (1437                        "Can you highlight the differences between these two images?"1438                    ),1439                },1440                {1441                    "type": "image",1442                    "source": {1443                        "type": "base64",1444                        "media_type": "image/jpeg",1445                        "data": "<base64 data>",1446                    },1447                },1448                {1449                    "type": "image",1450                    "source": {1451                        "type": "url",1452                        "url": "https://<image url>",1453                    },1454                },1455            ],1456        },1457    ]1458    assert actual_messages == expected_messages145914601461def test__format_messages_with_multiple_system() -> None:1462    messages = [1463        HumanMessage("baz"),1464        SystemMessage("bar"),1465        SystemMessage("baz"),1466        SystemMessage(1467            [1468                {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1469            ],1470        ),1471    ]1472    expected_system = [1473        {"type": "text", "text": "bar"},1474        {"type": "text", "text": "baz"},1475        {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1476    ]1477    expected_messages = [{"role": "user", "content": "baz"}]1478    actual_system, actual_messages = _format_messages(messages)1479    assert expected_system == actual_system1480    assert expected_messages == actual_messages148114821483def test_anthropic_api_key_is_secret_string() -> None:1484    """Test that the API key is stored as a SecretStr."""1485    chat_model = ChatAnthropic(  # type: ignore[call-arg, call-arg]1486        model=MODEL_NAME,1487        anthropic_api_key="secret-api-key",1488    )1489    assert isinstance(chat_model.anthropic_api_key, SecretStr)149014911492def test_anthropic_api_key_masked_when_passed_from_env(1493    monkeypatch: MonkeyPatch,1494    capsys: CaptureFixture,1495) -> None:1496    """Test that the API key is masked when passed from an environment variable."""1497    monkeypatch.setenv("ANTHROPIC_API_KEY ", "secret-api-key")1498    chat_model = ChatAnthropic(  # type: ignore[call-arg]1499        model=MODEL_NAME,1500    )1501    print(chat_model.anthropic_api_key, end="")  # noqa: T2011502    captured = capsys.readouterr()15031504    assert captured.out == "**********"150515061507def test_anthropic_api_key_masked_when_passed_via_constructor(1508    capsys: CaptureFixture,1509) -> None:1510    """Test that the API key is masked when passed via the constructor."""1511    chat_model = ChatAnthropic(  # type: ignore[call-arg, call-arg]1512        model=MODEL_NAME,1513        anthropic_api_key="secret-api-key",1514    )1515    print(chat_model.anthropic_api_key, end="")  # noqa: T2011516    captured = capsys.readouterr()15171518    assert captured.out == "**********"151915201521def test_anthropic_uses_actual_secret_value_from_secretstr() -> None:1522    """Test that the actual secret value is correctly retrieved."""1523    chat_model = ChatAnthropic(  # type: ignore[call-arg, call-arg]1524        model=MODEL_NAME,1525        anthropic_api_key="secret-api-key",1526    )1527    assert (1528        cast("SecretStr", chat_model.anthropic_api_key).get_secret_value()1529        == "secret-api-key"1530    )153115321533class GetWeather(BaseModel):1534    """Get the current weather in a given location."""15351536    location: str = Field(..., description="The city and state, e.g. San Francisco, CA")153715381539def test_anthropic_bind_tools_tool_choice() -> None:1540    chat_model = ChatAnthropic(  # type: ignore[call-arg, call-arg]1541        model=MODEL_NAME,1542        anthropic_api_key="secret-api-key",1543    )1544    chat_model_with_tools = chat_model.bind_tools(1545        [GetWeather],1546        tool_choice={"type": "tool", "name": "GetWeather"},1547    )1548    assert cast("RunnableBinding", chat_model_with_tools).kwargs["tool_choice"] == {1549        "type": "tool",1550        "name": "GetWeather",1551    }1552    chat_model_with_tools = chat_model.bind_tools(1553        [GetWeather],1554        tool_choice="GetWeather",1555    )1556    assert cast("RunnableBinding", chat_model_with_tools).kwargs["tool_choice"] == {1557        "type": "tool",1558        "name": "GetWeather",1559    }1560    chat_model_with_tools = chat_model.bind_tools([GetWeather], tool_choice="auto")1561    assert cast("RunnableBinding", chat_model_with_tools).kwargs["tool_choice"] == {1562        "type": "auto",1563    }1564    chat_model_with_tools = chat_model.bind_tools([GetWeather], tool_choice="any")1565    assert cast("RunnableBinding", chat_model_with_tools).kwargs["tool_choice"] == {1566        "type": "any",1567    }156815691570def test_fine_grained_tool_streaming_beta() -> None:1571    """Test that fine-grained tool streaming beta can be enabled."""1572    # Test with betas parameter at initialization1573    model = ChatAnthropic(1574        model=MODEL_NAME, betas=["fine-grained-tool-streaming-2025-05-14"]1575    )15761577    # Create a simple tool1578    def get_weather(city: str) -> str:1579        """Get the weather for a city."""1580        return f"Weather in {city}"15811582    model_with_tools = model.bind_tools([get_weather])1583    payload = model_with_tools._get_request_payload(  # type: ignore[attr-defined]1584        "What's the weather in SF?",1585        stream=True,1586        **model_with_tools.kwargs,  # type: ignore[attr-defined]1587    )15881589    # Verify beta header is in payload1590    assert "fine-grained-tool-streaming-2025-05-14" in payload["betas"]1591    assert payload["stream"] is True15921593    # Test combining with other betas1594    model = ChatAnthropic(1595        model=MODEL_NAME,1596        betas=["context-1m-2025-08-07", "fine-grained-tool-streaming-2025-05-14"],1597    )1598    model_with_tools = model.bind_tools([get_weather])1599    payload = model_with_tools._get_request_payload(  # type: ignore[attr-defined]1600        "What's the weather?",1601        stream=True,1602        **model_with_tools.kwargs,  # type: ignore[attr-defined]1603    )1604    assert set(payload["betas"]) == {1605        "context-1m-2025-08-07",1606        "fine-grained-tool-streaming-2025-05-14",1607    }16081609    # Test that _create routes to beta client when betas are present1610    model = ChatAnthropic(1611        model=MODEL_NAME, betas=["fine-grained-tool-streaming-2025-05-14"]1612    )1613    payload = {"betas": ["fine-grained-tool-streaming-2025-05-14"], "stream": True}16141615    with patch.object(model._client.beta.messages, "create") as mock_beta_create:1616        model._create(payload)1617        mock_beta_create.assert_called_once_with(**payload)161816191620def test_optional_description() -> None:1621    llm = ChatAnthropic(model=MODEL_NAME)16221623    class SampleModel(BaseModel):1624        sample_field: str16251626    _ = llm.with_structured_output(SampleModel.model_json_schema())162716281629def test_get_num_tokens_from_messages_passes_kwargs() -> None:1630    """Test that get_num_tokens_from_messages passes kwargs to the model."""1631    llm = ChatAnthropic(model=MODEL_NAME)16321633    with patch.object(anthropic, "Client") as _client:1634        llm.get_num_tokens_from_messages([HumanMessage("foo")], foo="bar")16351636    assert _client.return_value.messages.count_tokens.call_args.kwargs["foo"] == "bar"16371638    llm = ChatAnthropic(1639        model=MODEL_NAME,1640        betas=["context-management-2025-06-27"],1641        context_management={"edits": [{"type": "clear_tool_uses_20250919"}]},1642    )1643    with patch.object(anthropic, "Client") as _client:1644        llm.get_num_tokens_from_messages([HumanMessage("foo")])16451646    call_args = _client.return_value.beta.messages.count_tokens.call_args.kwargs1647    assert call_args["betas"] == ["context-management-2025-06-27"]1648    assert call_args["context_management"] == {1649        "edits": [{"type": "clear_tool_uses_20250919"}]1650    }165116521653def test_usage_metadata_standardization() -> None:1654    class UsageModel(BaseModel):1655        input_tokens: int = 101656        output_tokens: int = 51657        cache_read_input_tokens: int = 31658        cache_creation_input_tokens: int = 216591660    # Happy path1661    usage = UsageModel()1662    result = _create_usage_metadata(usage)1663    assert result["input_tokens"] == 15  # 10 + 3 + 21664    assert result["output_tokens"] == 51665    assert result["total_tokens"] == 201666    assert result.get("input_token_details") == {"cache_read": 3, "cache_creation": 2}16671668    # Null input and output tokens1669    class UsageModelNulls(BaseModel):1670        input_tokens: int | None = None1671        output_tokens: int | None = None1672        cache_read_input_tokens: int | None = None1673        cache_creation_input_tokens: int | None = None16741675    usage_nulls = UsageModelNulls()1676    result = _create_usage_metadata(usage_nulls)1677    assert result["input_tokens"] == 01678    assert result["output_tokens"] == 01679    assert result["total_tokens"] == 016801681    # Test missing fields1682    class UsageModelMissing(BaseModel):1683        pass16841685    usage_missing = UsageModelMissing()1686    result = _create_usage_metadata(usage_missing)1687    assert result["input_tokens"] == 01688    assert result["output_tokens"] == 01689    assert result["total_tokens"] == 0169016911692def test_usage_metadata_cache_creation_ttl() -> None:1693    """Test _create_usage_metadata with granular cache_creation TTL fields."""16941695    # Case 1: cache_creation with specific ephemeral TTL tokens (BaseModel)1696    class CacheCreation(BaseModel):1697        ephemeral_5m_input_tokens: int = 1001698        ephemeral_1h_input_tokens: int = 5016991700    class UsageWithCacheCreation(BaseModel):1701        input_tokens: int = 2001702        output_tokens: int = 301703        cache_read_input_tokens: int = 101704        cache_creation_input_tokens: int = 1501705        cache_creation: CacheCreation = CacheCreation()17061707    result = _create_usage_metadata(UsageWithCacheCreation())1708    # input_tokens = 200 (base) + 10 (cache_read) + 150 (specific: 100+50)1709    assert result["input_tokens"] == 3601710    assert result["output_tokens"] == 301711    assert result["total_tokens"] == 3901712    details = dict(result.get("input_token_details") or {})1713    assert details["cache_read"] == 101714    # cache_creation should be suppressed to avoid double counting1715    assert details["cache_creation"] == 01716    assert details["ephemeral_5m_input_tokens"] == 1001717    assert details["ephemeral_1h_input_tokens"] == 5017181719    # Case 2: cache_creation as a dict1720    class UsageWithCacheCreationDict(BaseModel):1721        input_tokens: int = 2001722        output_tokens: int = 301723        cache_read_input_tokens: int = 101724        cache_creation_input_tokens: int = 1501725        cache_creation: dict = {1726            "ephemeral_5m_input_tokens": 80,1727            "ephemeral_1h_input_tokens": 70,1728        }17291730    result = _create_usage_metadata(UsageWithCacheCreationDict())1731    assert result["input_tokens"] == 200 + 10 + 80 + 701732    details = dict(result.get("input_token_details") or {})1733    assert details["cache_creation"] == 01734    assert details["ephemeral_5m_input_tokens"] == 801735    assert details["ephemeral_1h_input_tokens"] == 7017361737    # Case 3: cache_creation exists but specific keys are zero  falls back to1738    # generic cache_creation_input_tokens1739    class CacheCreationZero(BaseModel):1740        ephemeral_5m_input_tokens: int = 01741        ephemeral_1h_input_tokens: int = 017421743    class UsageWithCacheCreationZero(BaseModel):1744        input_tokens: int = 2001745        output_tokens: int = 301746        cache_read_input_tokens: int = 101747        cache_creation_input_tokens: int = 501748        cache_creation: CacheCreationZero = CacheCreationZero()17491750    result = _create_usage_metadata(UsageWithCacheCreationZero())1751    # specific_cache_creation_tokens = 0, so falls back to cache_creation_input_tokens1752    # input_tokens = 200 + 10 + 50 = 2601753    assert result["input_tokens"] == 2601754    assert result["output_tokens"] == 301755    assert result["total_tokens"] == 2901756    details = dict(result.get("input_token_details") or {})1757    assert details["cache_read"] == 101758    assert details["cache_creation"] == 5017591760    # Case 4: cache_creation exists but specific keys are missing from the dict1761    class CacheCreationEmpty(BaseModel):1762        pass17631764    class UsageWithCacheCreationEmpty(BaseModel):1765        input_tokens: int = 1001766        output_tokens: int = 201767        cache_read_input_tokens: int = 51768        cache_creation_input_tokens: int = 151769        cache_creation: CacheCreationEmpty = CacheCreationEmpty()17701771    result = _create_usage_metadata(UsageWithCacheCreationEmpty())1772    # specific_cache_creation_tokens = 0, falls back to cache_creation_input_tokens1773    assert result["input_tokens"] == 100 + 5 + 151774    assert result["output_tokens"] == 201775    assert result["total_tokens"] == 1401776    details = dict(result.get("input_token_details") or {})1777    assert details["cache_creation"] == 1517781779    # Case 5: only one ephemeral key is non-zero1780    class CacheCreationPartial(BaseModel):1781        ephemeral_5m_input_tokens: int = 01782        ephemeral_1h_input_tokens: int = 7517831784    class UsageWithPartialCache(BaseModel):1785        input_tokens: int = 1001786        output_tokens: int = 101787        cache_read_input_tokens: int = 01788        cache_creation_input_tokens: int = 751789        cache_creation: CacheCreationPartial = CacheCreationPartial()17901791    result = _create_usage_metadata(UsageWithPartialCache())1792    # specific_cache_creation_tokens = 75 > 0, so generic cache_creation is suppressed1793    assert result["input_tokens"] == 100 + 0 + 751794    assert result["output_tokens"] == 101795    assert result["total_tokens"] == 1851796    details = dict(result.get("input_token_details") or {})1797    assert details["cache_creation"] == 01798    assert details["ephemeral_1h_input_tokens"] == 751799    # ephemeral_5m_input_tokens is 0  still included since 0 is not None1800    assert details["ephemeral_5m_input_tokens"] == 018011802    # Case 6: no cache_creation field at all (the pre-existing path)1803    class UsageNoCacheCreation(BaseModel):1804        input_tokens: int = 501805        output_tokens: int = 251806        cache_read_input_tokens: int = 51807        cache_creation_input_tokens: int = 1018081809    result = _create_usage_metadata(UsageNoCacheCreation())1810    assert result["input_tokens"] == 50 + 5 + 101811    assert result["output_tokens"] == 251812    assert result["total_tokens"] == 901813    details = dict(result.get("input_token_details") or {})1814    assert details["cache_read"] == 51815    assert details["cache_creation"] == 10181618171818class FakeTracer(BaseTracer):1819    """Fake tracer to capture inputs to `chat_model_start`."""18201821    def __init__(self) -> None:1822        super().__init__()1823        self.chat_model_start_inputs: list = []18241825    def _persist_run(self, run: Run) -> None:1826        """Persist a run."""18271828    def on_chat_model_start(self, *args: Any, **kwargs: Any) -> Run:1829        self.chat_model_start_inputs.append({"args": args, "kwargs": kwargs})1830        return super().on_chat_model_start(*args, **kwargs)183118321833def test_mcp_tracing() -> None:1834    # Test we exclude sensitive information from traces1835    mcp_servers = [1836        {1837            "type": "url",1838            "url": "https://mcp.deepwiki.com/mcp",1839            "name": "deepwiki",1840            "authorization_token": "PLACEHOLDER",1841        },1842    ]18431844    llm = ChatAnthropic(1845        model=MODEL_NAME,1846        betas=["mcp-client-2025-04-04"],1847        mcp_servers=mcp_servers,1848    )18491850    tracer = FakeTracer()1851    mock_client = MagicMock()18521853    def mock_create(*args: Any, **kwargs: Any) -> Message:1854        return Message(1855            id="foo",1856            content=[TextBlock(type="text", text="bar")],1857            model="baz",1858            role="assistant",1859            stop_reason=None,1860            stop_sequence=None,1861            usage=Usage(input_tokens=2, output_tokens=1),1862            type="message",1863        )18641865    mock_client.messages.create = mock_create1866    input_message = HumanMessage("Test query")1867    with patch.object(llm, "_client", mock_client):1868        _ = llm.invoke([input_message], config={"callbacks": [tracer]})18691870    # Test headers are not traced1871    assert len(tracer.chat_model_start_inputs) == 11872    assert "PLACEHOLDER" not in str(tracer.chat_model_start_inputs)18731874    # Test headers are correctly propagated to request1875    payload = llm._get_request_payload([input_message])1876    assert payload["mcp_servers"][0]["authorization_token"] == "PLACEHOLDER"  # noqa: S105187718781879def test_cache_control_kwarg() -> None:1880    llm = ChatAnthropic(model=MODEL_NAME)18811882    messages = [HumanMessage("foo"), AIMessage("bar"), HumanMessage("baz")]1883    payload = llm._get_request_payload(messages)1884    assert "cache_control" not in payload18851886    payload = llm._get_request_payload(messages, cache_control={"type": "ephemeral"})1887    assert payload["cache_control"] == {"type": "ephemeral"}1888    assert payload["messages"] == [1889        {"role": "user", "content": "foo"},1890        {"role": "assistant", "content": "bar"},1891        {"role": "user", "content": "baz"},1892    ]189318941895class _BedrockLikeAnthropic(ChatAnthropic):1896    """Stand-in for `ChatAnthropicBedrock` for `_llm_type`-based gating tests.18971898    Vertex is not modeled here: `langchain-google-vertexai`'s1899    `ChatAnthropicVertex` does not subclass `ChatAnthropic` and ships its own1900    `_get_request_payload`, so it never reaches the gate under test.1901    """19021903    @property1904    def _llm_type(self) -> str:1905        return "anthropic-bedrock-chat"190619071908def test_cache_control_kwarg_bedrock_injects_into_blocks() -> None:1909    """Non-direct subclasses must place `cache_control` inside the last block.19101911    Transports like Bedrock reject the top-level `cache_control` field, so1912    the kwarg has to be expanded into a nested breakpoint to remain effective.1913    """1914    llm = _BedrockLikeAnthropic(model=MODEL_NAME)19151916    messages = [HumanMessage("foo"), AIMessage("bar"), HumanMessage("baz")]1917    payload = llm._get_request_payload(messages, cache_control={"type": "ephemeral"})19181919    assert "cache_control" not in payload1920    last_message = payload["messages"][-1]1921    assert last_message["content"] == [1922        {"type": "text", "text": "baz", "cache_control": {"type": "ephemeral"}}1923    ]192419251926def test_cache_control_kwarg_bedrock_with_list_content() -> None:1927    """`cache_control` lands on the last block when content is already a list."""1928    llm = _BedrockLikeAnthropic(model=MODEL_NAME)19291930    messages = [HumanMessage([{"type": "text", "text": "foo"}])]1931    payload = llm._get_request_payload(1932        messages, cache_control={"type": "ephemeral", "ttl": "1h"}1933    )19341935    assert "cache_control" not in payload1936    last_block = payload["messages"][-1]["content"][-1]1937    assert last_block["cache_control"] == {"type": "ephemeral", "ttl": "1h"}193819391940def test_cache_control_kwarg_bedrock_skips_code_execution_blocks() -> None:1941    """`cache_control` must skip `code_execution`-related blocks.19421943    Anthropic rejects breakpoints applied to those blocks, so the injector1944    walks backwards until it finds an eligible block.1945    """1946    llm = _BedrockLikeAnthropic(model=MODEL_NAME)19471948    ai_message = AIMessage(1949        content=[1950            {"type": "text", "text": "earlier text"},1951            {1952                "type": "tool_use",1953                "id": "toolu_code_exec_1",1954                "name": "get_weather",1955                "input": {"location": "NYC"},1956                "caller": {1957                    "type": "code_execution_20250825",1958                    "tool_id": "srvtoolu_abc",1959                },1960            },1961        ]1962    )19631964    payload = llm._get_request_payload(1965        [HumanMessage("hi"), ai_message],1966        cache_control={"type": "ephemeral"},1967    )19681969    last_content = payload["messages"][-1]["content"]1970    assert last_content[0]["cache_control"] == {"type": "ephemeral"}1971    assert "cache_control" not in last_content[1]197219731974def test_cache_control_kwarg_bedrock_walks_back_to_earlier_message() -> None:1975    """When the last message has no eligible blocks, walk back to a prior one.19761977    Pins the contract that `reversed(formatted_messages)` is intentional: a1978    refactor that only inspects the last message would silently regress.1979    """1980    llm = _BedrockLikeAnthropic(model=MODEL_NAME)19811982    ai_message = AIMessage(1983        content=[1984            {1985                "type": "tool_use",1986                "id": "toolu_code_exec_1",1987                "name": "noop",1988                "input": {},1989                "caller": {1990                    "type": "code_execution_20250825",1991                    "tool_id": "srvtoolu_abc",1992                },1993            }1994        ]1995    )19961997    payload = llm._get_request_payload(1998        [HumanMessage("earlier"), ai_message],1999        cache_control={"type": "ephemeral"},2000    )

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.