libs/partners/anthropic/tests/unit_tests/test_chat_models.py PYTHON 3,381 lines View on github.com → Search inside
File is large — showing lines 1–2,000 of 3,381.
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 AIMessage, HumanMessage, SystemMessage, ToolMessage18from langchain_core.runnables import RunnableBinding19from langchain_core.tools import BaseTool, tool20from langchain_core.tracers.base import BaseTracer21from langchain_core.tracers.schemas import Run22from pydantic import BaseModel, Field, SecretStr, ValidationError23from pytest import CaptureFixture, MonkeyPatch2425from langchain_anthropic import ChatAnthropic26from langchain_anthropic._version import __version__27from langchain_anthropic.chat_models import (28    _TOOL_CALL_ID_PATTERN,29    _create_usage_metadata,30    _format_image,31    _format_messages,32    _is_builtin_tool,33    _merge_messages,34    _normalize_tool_call_id,35    _thinking_in_params,36    convert_to_anthropic_tool,37)3839os.environ["ANTHROPIC_API_KEY"] = "foo"4041MODEL_NAME = "claude-sonnet-4-5-20250929"424344def test_initialization() -> None:45    """Test chat model initialization."""46    with patch.dict(os.environ, {"ANTHROPIC_API_URL": "https://api.anthropic.com"}):47        for model in [48            ChatAnthropic(model_name=MODEL_NAME, api_key="xyz", timeout=2),  # type: ignore[arg-type, call-arg]49            ChatAnthropic(  # type: ignore[call-arg, call-arg, call-arg]50                model=MODEL_NAME,51                anthropic_api_key="xyz",52                default_request_timeout=2,53                base_url="https://api.anthropic.com",54            ),55        ]:56            assert model.model == MODEL_NAME57            assert (58                cast("SecretStr", model.anthropic_api_key).get_secret_value() == "xyz"59            )60            assert model.default_request_timeout == 2.061            assert model.anthropic_api_url == "https://api.anthropic.com"626364def test_user_agent_header_in_client_params() -> None:65    """Test that _client_params includes a User-Agent header."""66    llm = ChatAnthropic(model=MODEL_NAME, api_key="test-key")  # type: ignore[arg-type]67    params = llm._client_params68    assert "default_headers" in params69    assert "User-Agent" in params["default_headers"]70    assert params["default_headers"]["User-Agent"].startswith("langchain-anthropic/")717273@pytest.mark.parametrize("async_api", [True, False])74def test_streaming_attribute_should_stream(async_api: bool) -> None:  # noqa: FBT00175    llm = ChatAnthropic(model=MODEL_NAME, streaming=True)76    assert llm._should_stream(async_api=async_api)777879def test_anthropic_client_caching() -> None:80    """Test that the OpenAI client is cached."""81    llm1 = ChatAnthropic(model=MODEL_NAME)82    llm2 = ChatAnthropic(model=MODEL_NAME)83    assert llm1._client._client is llm2._client._client8485    llm3 = ChatAnthropic(model=MODEL_NAME, base_url="foo")86    assert llm1._client._client is not llm3._client._client8788    llm4 = ChatAnthropic(model=MODEL_NAME, timeout=None)89    assert llm1._client._client is llm4._client._client9091    llm5 = ChatAnthropic(model=MODEL_NAME, timeout=3)92    assert llm1._client._client is not llm5._client._client939495def test_anthropic_proxy_support() -> None:96    """Test that both sync and async clients support proxy configuration."""97    proxy_url = "http://proxy.example.com:8080"9899    # Test sync client with proxy100    llm_sync = ChatAnthropic(model=MODEL_NAME, anthropic_proxy=proxy_url)101    sync_client = llm_sync._client102    assert sync_client is not None103104    # Test async client with proxy - this should not raise TypeError105    async_client = llm_sync._async_client106    assert async_client is not None107108    # Test that clients with different proxy settings are not cached together109    llm_no_proxy = ChatAnthropic(model=MODEL_NAME)110    llm_with_proxy = ChatAnthropic(model=MODEL_NAME, anthropic_proxy=proxy_url)111112    # Different proxy settings should result in different cached clients113    assert llm_no_proxy._client._client is not llm_with_proxy._client._client114115116def test_anthropic_proxy_from_environment() -> None:117    """Test that proxy can be set from ANTHROPIC_PROXY environment variable."""118    proxy_url = "http://env-proxy.example.com:8080"119120    # Test with environment variable set121    with patch.dict(os.environ, {"ANTHROPIC_PROXY": proxy_url}):122        llm = ChatAnthropic(model=MODEL_NAME)123        assert llm.anthropic_proxy == proxy_url124125        # Should be able to create clients successfully126        sync_client = llm._client127        async_client = llm._async_client128        assert sync_client is not None129        assert async_client is not None130131    # Test that explicit parameter overrides environment variable132    with patch.dict(os.environ, {"ANTHROPIC_PROXY": "http://env-proxy.com"}):133        explicit_proxy = "http://explicit-proxy.com"134        llm = ChatAnthropic(model=MODEL_NAME, anthropic_proxy=explicit_proxy)135        assert llm.anthropic_proxy == explicit_proxy136137138def test_set_default_max_tokens() -> None:139    """Test the set_default_max_tokens function."""140    # Test claude-sonnet-4-5 models141    llm = ChatAnthropic(model="claude-sonnet-4-5-20250929", anthropic_api_key="test")142    assert llm.max_tokens == 64000143144    # Test claude-opus-4 models145    llm = ChatAnthropic(model="claude-opus-4-20250514", anthropic_api_key="test")146    assert llm.max_tokens == 32000147148    # Test claude-sonnet-4 models149    llm = ChatAnthropic(model="claude-sonnet-4-20250514", anthropic_api_key="test")150    assert llm.max_tokens == 64000151152    # Test claude-3-7-sonnet models153    llm = ChatAnthropic(model="claude-3-7-sonnet-20250219", anthropic_api_key="test")154    assert llm.max_tokens == 64000155156    # Test claude-3-5-haiku models157    llm = ChatAnthropic(model="claude-3-5-haiku-20241022", anthropic_api_key="test")158    assert llm.max_tokens == 8192159160    # Test claude-3-haiku models (should default to 4096)161    llm = ChatAnthropic(model="claude-3-haiku-20240307", anthropic_api_key="test")162    assert llm.max_tokens == 4096163164    # Test that existing max_tokens values are preserved165    llm = ChatAnthropic(model=MODEL_NAME, max_tokens=2048, anthropic_api_key="test")166    assert llm.max_tokens == 2048167168    # Test that explicitly set max_tokens values are preserved169    llm = ChatAnthropic(model=MODEL_NAME, max_tokens=4096, anthropic_api_key="test")170    assert llm.max_tokens == 4096171172173@pytest.mark.requires("anthropic")174def test_anthropic_model_name_param() -> None:175    llm = ChatAnthropic(model_name=MODEL_NAME)  # type: ignore[call-arg, call-arg]176    assert llm.model == MODEL_NAME177178179@pytest.mark.requires("anthropic")180def test_anthropic_model_param() -> None:181    llm = ChatAnthropic(model=MODEL_NAME)  # type: ignore[call-arg]182    assert llm.model == MODEL_NAME183184185@pytest.mark.requires("anthropic")186def test_anthropic_model_kwargs() -> None:187    llm = ChatAnthropic(model_name=MODEL_NAME, model_kwargs={"foo": "bar"})  # type: ignore[call-arg, call-arg]188    assert llm.model_kwargs == {"foo": "bar"}189190191@pytest.mark.requires("anthropic")192def test_anthropic_fields_in_model_kwargs() -> None:193    """Test that for backwards compatibility fields can be passed in as model_kwargs."""194    with pytest.warns(195        UserWarning,196        match=(197            "Parameters {'max_tokens_to_sample'} should be specified explicitly. "198            "Instead they were passed in as part of `model_kwargs` parameter."199        ),200    ):201        llm = ChatAnthropic(model=MODEL_NAME, model_kwargs={"max_tokens_to_sample": 5})  # type: ignore[call-arg]202    assert llm.max_tokens == 5203    with pytest.warns(204        UserWarning,205        match=(206            "Parameters {'max_tokens'} should be specified explicitly. Instead they "207            "were passed in as part of `model_kwargs` parameter."208        ),209    ):210        llm = ChatAnthropic(model=MODEL_NAME, model_kwargs={"max_tokens": 5})  # type: ignore[call-arg]211    assert llm.max_tokens == 5212213214@pytest.mark.requires("anthropic")215def test_anthropic_incorrect_field() -> None:216    with pytest.warns(match="not default parameter"):217        llm = ChatAnthropic(model=MODEL_NAME, foo="bar")  # type: ignore[call-arg, call-arg]218    assert llm.model_kwargs == {"foo": "bar"}219220221@pytest.mark.requires("anthropic")222def test_anthropic_initialization() -> None:223    """Test anthropic initialization."""224    # Verify that chat anthropic can be initialized using a secret key provided225    # as a parameter rather than an environment variable.226    ChatAnthropic(model=MODEL_NAME, anthropic_api_key="test")  # type: ignore[call-arg, call-arg]227228229def test__format_output() -> None:230    anthropic_msg = Message(231        id="foo",232        content=[TextBlock(type="text", text="bar")],233        model="baz",234        role="assistant",235        stop_reason=None,236        stop_sequence=None,237        usage=Usage(input_tokens=2, output_tokens=1),238        type="message",239    )240    expected = AIMessage(  # type: ignore[misc]241        "bar",242        usage_metadata={243            "input_tokens": 2,244            "output_tokens": 1,245            "total_tokens": 3,246            "input_token_details": {},247        },248        response_metadata={"model_provider": "anthropic"},249    )250    llm = ChatAnthropic(model=MODEL_NAME, anthropic_api_key="test")  # type: ignore[call-arg, call-arg]251    actual = llm._format_output(anthropic_msg)252    assert actual.generations[0].message == expected253254255def test__format_output_cached() -> None:256    anthropic_msg = Message(257        id="foo",258        content=[TextBlock(type="text", text="bar")],259        model="baz",260        role="assistant",261        stop_reason=None,262        stop_sequence=None,263        usage=Usage(264            input_tokens=2,265            output_tokens=1,266            cache_creation_input_tokens=3,267            cache_read_input_tokens=4,268        ),269        type="message",270    )271    expected = AIMessage(  # type: ignore[misc]272        "bar",273        usage_metadata={274            "input_tokens": 9,275            "output_tokens": 1,276            "total_tokens": 10,277            "input_token_details": {"cache_creation": 3, "cache_read": 4},278        },279        response_metadata={"model_provider": "anthropic"},280    )281282    llm = ChatAnthropic(model=MODEL_NAME, anthropic_api_key="test")  # type: ignore[call-arg, call-arg]283    actual = llm._format_output(anthropic_msg)284    assert actual.generations[0].message == expected285286287def test__merge_messages() -> None:288    messages = [289        SystemMessage("foo"),  # type: ignore[misc]290        HumanMessage("bar"),  # type: ignore[misc]291        AIMessage(  # type: ignore[misc]292            [293                {"text": "baz", "type": "text"},294                {295                    "tool_input": {"a": "b"},296                    "type": "tool_use",297                    "id": "1",298                    "text": None,299                    "name": "buz",300                },301                {"text": "baz", "type": "text"},302                {303                    "tool_input": {"a": "c"},304                    "type": "tool_use",305                    "id": "2",306                    "text": None,307                    "name": "blah",308                },309                {310                    "tool_input": {"a": "c"},311                    "type": "tool_use",312                    "id": "3",313                    "text": None,314                    "name": "blah",315                },316            ],317        ),318        ToolMessage("buz output", tool_call_id="1", status="error"),  # type: ignore[misc]319        ToolMessage(320            content=[321                {322                    "type": "image",323                    "source": {324                        "type": "base64",325                        "media_type": "image/jpeg",326                        "data": "fake_image_data",327                    },328                },329            ],330            tool_call_id="2",331        ),  # type: ignore[misc]332        ToolMessage([], tool_call_id="3"),  # type: ignore[misc]333        HumanMessage("next thing"),  # type: ignore[misc]334    ]335    expected = [336        SystemMessage("foo"),  # type: ignore[misc]337        HumanMessage("bar"),  # type: ignore[misc]338        AIMessage(  # type: ignore[misc]339            [340                {"text": "baz", "type": "text"},341                {342                    "tool_input": {"a": "b"},343                    "type": "tool_use",344                    "id": "1",345                    "text": None,346                    "name": "buz",347                },348                {"text": "baz", "type": "text"},349                {350                    "tool_input": {"a": "c"},351                    "type": "tool_use",352                    "id": "2",353                    "text": None,354                    "name": "blah",355                },356                {357                    "tool_input": {"a": "c"},358                    "type": "tool_use",359                    "id": "3",360                    "text": None,361                    "name": "blah",362                },363            ],364        ),365        HumanMessage(  # type: ignore[misc]366            [367                {368                    "type": "tool_result",369                    "content": "buz output",370                    "tool_use_id": "1",371                    "is_error": True,372                },373                {374                    "type": "tool_result",375                    "content": [376                        {377                            "type": "image",378                            "source": {379                                "type": "base64",380                                "media_type": "image/jpeg",381                                "data": "fake_image_data",382                            },383                        },384                    ],385                    "tool_use_id": "2",386                    "is_error": False,387                },388                {389                    "type": "tool_result",390                    "content": [],391                    "tool_use_id": "3",392                    "is_error": False,393                },394                {"type": "text", "text": "next thing"},395            ],396        ),397    ]398    actual = _merge_messages(messages)399    assert expected == actual400401    # Test tool message case402    messages = [403        ToolMessage("buz output", tool_call_id="1"),  # type: ignore[misc]404        ToolMessage(  # type: ignore[misc]405            content=[406                {"type": "tool_result", "content": "blah output", "tool_use_id": "2"},407            ],408            tool_call_id="2",409        ),410    ]411    expected = [412        HumanMessage(  # type: ignore[misc]413            [414                {415                    "type": "tool_result",416                    "content": "buz output",417                    "tool_use_id": "1",418                    "is_error": False,419                },420                {"type": "tool_result", "content": "blah output", "tool_use_id": "2"},421            ],422        ),423    ]424    actual = _merge_messages(messages)425    assert expected == actual426427428def test__merge_messages_mutation() -> None:429    original_messages = [430        HumanMessage([{"type": "text", "text": "bar"}]),  # type: ignore[misc]431        HumanMessage("next thing"),  # type: ignore[misc]432    ]433    messages = [434        HumanMessage([{"type": "text", "text": "bar"}]),  # type: ignore[misc]435        HumanMessage("next thing"),  # type: ignore[misc]436    ]437    expected = [438        HumanMessage(  # type: ignore[misc]439            [{"type": "text", "text": "bar"}, {"type": "text", "text": "next thing"}],440        ),441    ]442    actual = _merge_messages(messages)443    assert expected == actual444    assert messages == original_messages445446447def test__merge_messages_tool_message_cache_control() -> None:448    """Test that cache_control is hoisted from content blocks to tool_result level."""449    # Test with cache_control in content block450    messages = [451        ToolMessage(452            content=[453                {454                    "type": "text",455                    "text": "tool output",456                    "cache_control": {"type": "ephemeral"},457                }458            ],459            tool_call_id="1",460        )461    ]462    original_messages = [copy.deepcopy(m) for m in messages]463    expected = [464        HumanMessage(465            [466                {467                    "type": "tool_result",468                    "content": [{"type": "text", "text": "tool output"}],469                    "tool_use_id": "1",470                    "is_error": False,471                    "cache_control": {"type": "ephemeral"},472                }473            ]474        )475    ]476    actual = _merge_messages(messages)477    assert expected == actual478    # Verify no mutation479    assert messages == original_messages480481    # Test with multiple content blocks, cache_control on last one482    messages = [483        ToolMessage(484            content=[485                {"type": "text", "text": "first output"},486                {487                    "type": "text",488                    "text": "second output",489                    "cache_control": {"type": "ephemeral"},490                },491            ],492            tool_call_id="2",493        )494    ]495    expected = [496        HumanMessage(497            [498                {499                    "type": "tool_result",500                    "content": [501                        {"type": "text", "text": "first output"},502                        {"type": "text", "text": "second output"},503                    ],504                    "tool_use_id": "2",505                    "is_error": False,506                    "cache_control": {"type": "ephemeral"},507                }508            ]509        )510    ]511    actual = _merge_messages(messages)512    assert expected == actual513514    # Test without cache_control515    messages = [ToolMessage(content="simple output", tool_call_id="3")]516    expected = [517        HumanMessage(518            [519                {520                    "type": "tool_result",521                    "content": "simple output",522                    "tool_use_id": "3",523                    "is_error": False,524                }525            ]526        )527    ]528    actual = _merge_messages(messages)529    assert expected == actual530531532def test__format_image() -> None:533    url = "dummyimage.com/600x400/000/fff"534    with pytest.raises(ValueError):535        _format_image(url)536537538@pytest.fixture539def pydantic() -> type[BaseModel]:540    class dummy_function(BaseModel):  # noqa: N801541        """Dummy function."""542543        arg1: int = Field(..., description="foo")544        arg2: Literal["bar", "baz"] = Field(..., description="one of 'bar', 'baz'")545546    return dummy_function547548549@pytest.fixture550def function() -> Callable:551    def dummy_function(arg1: int, arg2: Literal["bar", "baz"]) -> None:552        """Dummy function.553554        Args:555            arg1: foo556            arg2: one of 'bar', 'baz'557558        """559560    return dummy_function561562563@pytest.fixture564def dummy_tool() -> BaseTool:565    class Schema(BaseModel):566        arg1: int = Field(..., description="foo")567        arg2: Literal["bar", "baz"] = Field(..., description="one of 'bar', 'baz'")568569    class DummyFunction(BaseTool):  # type: ignore[override]570        args_schema: type[BaseModel] = Schema571        name: str = "dummy_function"572        description: str = "Dummy function."573574        def _run(self, *args: Any, **kwargs: Any) -> Any:575            pass576577    return DummyFunction()578579580@pytest.fixture581def json_schema() -> dict:582    return {583        "title": "dummy_function",584        "description": "Dummy function.",585        "type": "object",586        "properties": {587            "arg1": {"description": "foo", "type": "integer"},588            "arg2": {589                "description": "one of 'bar', 'baz'",590                "enum": ["bar", "baz"],591                "type": "string",592            },593        },594        "required": ["arg1", "arg2"],595    }596597598@pytest.fixture599def openai_function() -> dict:600    return {601        "name": "dummy_function",602        "description": "Dummy function.",603        "parameters": {604            "type": "object",605            "properties": {606                "arg1": {"description": "foo", "type": "integer"},607                "arg2": {608                    "description": "one of 'bar', 'baz'",609                    "enum": ["bar", "baz"],610                    "type": "string",611                },612            },613            "required": ["arg1", "arg2"],614        },615    }616617618def test_convert_to_anthropic_tool(619    pydantic: type[BaseModel],620    function: Callable,621    dummy_tool: BaseTool,622    json_schema: dict,623    openai_function: dict,624) -> None:625    expected = {626        "name": "dummy_function",627        "description": "Dummy function.",628        "input_schema": {629            "type": "object",630            "properties": {631                "arg1": {"description": "foo", "type": "integer"},632                "arg2": {633                    "description": "one of 'bar', 'baz'",634                    "enum": ["bar", "baz"],635                    "type": "string",636                },637            },638            "required": ["arg1", "arg2"],639        },640    }641642    for fn in (pydantic, function, dummy_tool, json_schema, expected, openai_function):643        actual = convert_to_anthropic_tool(fn)644        assert actual == expected645646647def test__format_messages_with_tool_calls() -> None:648    system = SystemMessage("fuzz")  # type: ignore[misc]649    human = HumanMessage("foo")  # type: ignore[misc]650    ai = AIMessage(651        "",  # with empty string652        tool_calls=[{"name": "bar", "id": "1", "args": {"baz": "buzz"}}],653    )654    ai2 = AIMessage(655        [],  # with empty list656        tool_calls=[{"name": "bar", "id": "2", "args": {"baz": "buzz"}}],657    )658    tool = ToolMessage(659        "blurb",660        tool_call_id="1",661    )662    tool_image_url = ToolMessage(663        [{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,...."}}],664        tool_call_id="2",665    )666    tool_image = ToolMessage(667        [668            {669                "type": "image",670                "source": {671                    "data": "....",672                    "type": "base64",673                    "media_type": "image/jpeg",674                },675            },676        ],677        tool_call_id="3",678    )679    messages = [system, human, ai, tool, ai2, tool_image_url, tool_image]680    expected = (681        "fuzz",682        [683            {"role": "user", "content": "foo"},684            {685                "role": "assistant",686                "content": [687                    {688                        "type": "tool_use",689                        "name": "bar",690                        "id": "1",691                        "input": {"baz": "buzz"},692                    },693                ],694            },695            {696                "role": "user",697                "content": [698                    {699                        "type": "tool_result",700                        "content": "blurb",701                        "tool_use_id": "1",702                        "is_error": False,703                    },704                ],705            },706            {707                "role": "assistant",708                "content": [709                    {710                        "type": "tool_use",711                        "name": "bar",712                        "id": "2",713                        "input": {"baz": "buzz"},714                    },715                ],716            },717            {718                "role": "user",719                "content": [720                    {721                        "type": "tool_result",722                        "content": [723                            {724                                "type": "image",725                                "source": {726                                    "data": "....",727                                    "type": "base64",728                                    "media_type": "image/jpeg",729                                },730                            },731                        ],732                        "tool_use_id": "2",733                        "is_error": False,734                    },735                    {736                        "type": "tool_result",737                        "content": [738                            {739                                "type": "image",740                                "source": {741                                    "data": "....",742                                    "type": "base64",743                                    "media_type": "image/jpeg",744                                },745                            },746                        ],747                        "tool_use_id": "3",748                        "is_error": False,749                    },750                ],751            },752        ],753    )754    actual = _format_messages(messages)755    assert expected == actual756757    # Check handling of empty AIMessage758    empty_contents: list[str | list[str | dict[str, Any]]] = ["", []]759    for empty_content in empty_contents:760        ## Permit message in final position761        _, anthropic_messages = _format_messages([human, AIMessage(empty_content)])762        expected_messages = [763            {"role": "user", "content": "foo"},764            {"role": "assistant", "content": empty_content},765        ]766        assert expected_messages == anthropic_messages767768        ## Remove message otherwise769        _, anthropic_messages = _format_messages(770            [human, AIMessage(empty_content), human]771        )772        expected_messages = [773            {"role": "user", "content": "foo"},774            {"role": "user", "content": "foo"},775        ]776        assert expected_messages == anthropic_messages777778        actual = _format_messages(779            [system, human, ai, tool, AIMessage(empty_content), human]780        )781        assert actual[0] == "fuzz"782        assert [message["role"] for message in actual[1]] == [783            "user",784            "assistant",785            "user",786            "user",787        ]788789790def test__normalize_tool_call_id() -> None:791    # Already-valid IDs (including native Anthropic and OpenAI styles) pass792    # through unchanged.793    for valid in ("1", "toolu_01abcDEF-_", "call_Ao02pnFYXD6GN1yzc0uXPsvF"):794        assert _normalize_tool_call_id(valid) == valid795796    # Empty and None IDs pass through so a malformed request surfaces a clear797    # error from Anthropic rather than a synthesized ID.798    assert _normalize_tool_call_id("") == ""799    assert _normalize_tool_call_id(None) is None800801    # Foreign IDs with characters Anthropic rejects (e.g. Fireworks/Kimi's802    # `functions.write_todos:0`) are rewritten to a compatible form.803    invalid = "functions.write_todos:0"804    normalized = _normalize_tool_call_id(invalid)805    assert normalized is not None806    assert normalized != invalid807    assert _TOOL_CALL_ID_PATTERN.match(normalized)808809    # Deterministic + idempotent: same input always maps to the same output.810    assert _normalize_tool_call_id(invalid) == normalized811    assert _normalize_tool_call_id(normalized) == normalized812813    # Distinct invalid IDs map to distinct replacements (no collision that814    # would break multi-tool turns).815    other = _normalize_tool_call_id("functions.read_file:1")816    assert other != normalized817818819def test__format_messages_normalizes_cross_provider_tool_call_ids() -> None:820    """A `tool_use.id` and its paired `tool_use_id` must normalize identically.821822    Reproduces the Fireworks/Kimi -> Anthropic 400 from replaying a thread whose823    tool-call IDs were minted by another provider.824    """825    bad_id = "functions.write_todos:0"826    ai = AIMessage(827        "",828        tool_calls=[{"name": "write_todos", "id": bad_id, "args": {"todos": []}}],829    )830    tool = ToolMessage("done", tool_call_id=bad_id)831832    _, formatted = _format_messages([HumanMessage("hi"), ai, tool])833834    tool_use = formatted[1]["content"][0]835    tool_result = formatted[2]["content"][0]836    assert tool_use["type"] == "tool_use"837    assert tool_result["type"] == "tool_result"838839    # The rewritten IDs are valid and still reference each other.840    assert _TOOL_CALL_ID_PATTERN.match(tool_use["id"])841    assert tool_use["id"] == tool_result["tool_use_id"]842    assert tool_use["id"] == _normalize_tool_call_id(bad_id)843844845def test__format_messages_normalizes_prestructured_tool_result_id() -> None:846    """A `ToolMessage` whose content is already `tool_result` blocks is covered.847848    This shape bypasses the `tool_call_id` normalization in `_merge_messages` and849    flows through the `tool_result` content branch, so its `tool_use_id` must850    still be normalized to match the paired `tool_use.id`.851    """852    bad_id = "functions.write_todos:0"853    ai = AIMessage(854        "",855        tool_calls=[{"name": "write_todos", "id": bad_id, "args": {"todos": []}}],856    )857    tool = ToolMessage(858        [{"type": "tool_result", "tool_use_id": bad_id, "content": "done"}],859        tool_call_id=bad_id,860    )861862    _, formatted = _format_messages([HumanMessage("hi"), ai, tool])863864    tool_use = formatted[1]["content"][0]865    tool_result = formatted[2]["content"][0]866    assert tool_use["id"] == tool_result["tool_use_id"]867    assert tool_use["id"] == _normalize_tool_call_id(bad_id)868869870def test__format_messages_normalizes_inline_tool_use_block() -> None:871    """An invalid ID on an inline `tool_use` content block is normalized.872873    Covers the v1-compat destination where tool calls are stored as content874    blocks rather than the `tool_calls` attribute, paired with a `ToolMessage`.875    """876    bad_id = "functions.search:2"877    ai = AIMessage(878        [{"type": "tool_use", "name": "search", "id": bad_id, "input": {"q": "x"}}],879    )880    tool = ToolMessage("result", tool_call_id=bad_id)881882    _, formatted = _format_messages([HumanMessage("hi"), ai, tool])883884    tool_use = formatted[1]["content"][0]885    tool_result = formatted[2]["content"][0]886    assert _TOOL_CALL_ID_PATTERN.match(tool_use["id"])887    assert tool_use["id"] == tool_result["tool_use_id"]888889890def test__format_messages_dedupes_overlapping_normalized_tool_use() -> None:891    """An invalid ID shared by a `tool_use` block and `tool_calls` yields one block.892893    Guards the dedup branch: `tool_use_ids` are normalized, so the comparison894    against the (also normalized) tool-call ID must not re-emit a duplicate block.895    """896    bad_id = "functions.write_todos:0"897    ai = AIMessage(898        [{"type": "tool_use", "name": "write_todos", "id": bad_id, "input": {"a": 1}}],899        tool_calls=[{"name": "write_todos", "id": bad_id, "args": {"a": 1}}],900    )901902    _, formatted = _format_messages([HumanMessage("hi"), ai])903904    tool_use_blocks = [b for b in formatted[1]["content"] if b["type"] == "tool_use"]905    assert len(tool_use_blocks) == 1906    assert _TOOL_CALL_ID_PATTERN.match(tool_use_blocks[0]["id"])907908909def test__format_messages_normalizes_distinct_ids_independently() -> None:910    """Multiple distinct invalid IDs in one turn stay distinct and correctly paired."""911    id_a = "functions.write_todos:0"912    id_b = "functions.read_file:1"913    ai = AIMessage(914        "",915        tool_calls=[916            {"name": "write_todos", "id": id_a, "args": {}},917            {"name": "read_file", "id": id_b, "args": {}},918        ],919    )920    tool_a = ToolMessage("a", tool_call_id=id_a)921    tool_b = ToolMessage("b", tool_call_id=id_b)922923    _, formatted = _format_messages([HumanMessage("hi"), ai, tool_a, tool_b])924925    tool_uses = formatted[1]["content"]926    results = formatted[2]["content"]927    assert tool_uses[0]["id"] == _normalize_tool_call_id(id_a)928    assert tool_uses[1]["id"] == _normalize_tool_call_id(id_b)929    assert tool_uses[0]["id"] != tool_uses[1]["id"]930    # Each result still pairs with its own tool_use.931    assert {r["tool_use_id"] for r in results} == {932        tool_uses[0]["id"],933        tool_uses[1]["id"],934    }935936937def test__format_tool_use_block() -> None:938    # Test we correctly format tool_use blocks when there is no corresponding tool_call.939    message = AIMessage(940        [941            {942                "type": "tool_use",943                "name": "foo_1",944                "id": "1",945                "input": {"bar_1": "baz_1"},946            },947            {948                "type": "tool_use",949                "name": "foo_2",950                "id": "2",951                "input": {},952                "partial_json": '{"bar_2": "baz_2"}',953                "index": 1,954            },955        ]956    )957    result = _format_messages([message])958    expected = {959        "role": "assistant",960        "content": [961            {962                "type": "tool_use",963                "name": "foo_1",964                "id": "1",965                "input": {"bar_1": "baz_1"},966            },967            {968                "type": "tool_use",969                "name": "foo_2",970                "id": "2",971                "input": {"bar_2": "baz_2"},972            },973        ],974    }975    assert result == (None, [expected])976977978def test__format_messages_with_str_content_and_tool_calls() -> None:979    system = SystemMessage("fuzz")  # type: ignore[misc]980    human = HumanMessage("foo")  # type: ignore[misc]981    # If content and tool_calls are specified and content is a string, then both are982    # included with content first.983    ai = AIMessage(  # type: ignore[misc]984        "thought",985        tool_calls=[{"name": "bar", "id": "1", "args": {"baz": "buzz"}}],986    )987    tool = ToolMessage("blurb", tool_call_id="1")  # type: ignore[misc]988    messages = [system, human, ai, tool]989    expected = (990        "fuzz",991        [992            {"role": "user", "content": "foo"},993            {994                "role": "assistant",995                "content": [996                    {"type": "text", "text": "thought"},997                    {998                        "type": "tool_use",999                        "name": "bar",1000                        "id": "1",1001                        "input": {"baz": "buzz"},1002                    },1003                ],1004            },1005            {1006                "role": "user",1007                "content": [1008                    {1009                        "type": "tool_result",1010                        "content": "blurb",1011                        "tool_use_id": "1",1012                        "is_error": False,1013                    },1014                ],1015            },1016        ],1017    )1018    actual = _format_messages(messages)1019    assert expected == actual102010211022def test__format_messages_with_list_content_and_tool_calls() -> None:1023    system = SystemMessage("fuzz")  # type: ignore[misc]1024    human = HumanMessage("foo")  # type: ignore[misc]1025    ai = AIMessage(  # type: ignore[misc]1026        [{"type": "text", "text": "thought"}],1027        tool_calls=[{"name": "bar", "id": "1", "args": {"baz": "buzz"}}],1028    )1029    tool = ToolMessage(  # type: ignore[misc]1030        "blurb",1031        tool_call_id="1",1032    )1033    messages = [system, human, ai, tool]1034    expected = (1035        "fuzz",1036        [1037            {"role": "user", "content": "foo"},1038            {1039                "role": "assistant",1040                "content": [1041                    {"type": "text", "text": "thought"},1042                    {1043                        "type": "tool_use",1044                        "name": "bar",1045                        "id": "1",1046                        "input": {"baz": "buzz"},1047                    },1048                ],1049            },1050            {1051                "role": "user",1052                "content": [1053                    {1054                        "type": "tool_result",1055                        "content": "blurb",1056                        "tool_use_id": "1",1057                        "is_error": False,1058                    },1059                ],1060            },1061        ],1062    )1063    actual = _format_messages(messages)1064    assert expected == actual106510661067def test__format_messages_with_tool_use_blocks_and_tool_calls() -> None:1068    """Show that tool_calls are preferred to tool_use blocks when both have same id."""1069    system = SystemMessage("fuzz")  # type: ignore[misc]1070    human = HumanMessage("foo")  # type: ignore[misc]1071    # NOTE: tool_use block in contents and tool_calls have different arguments.1072    ai = AIMessage(  # type: ignore[misc]1073        [1074            {"type": "text", "text": "thought"},1075            {1076                "type": "tool_use",1077                "name": "bar",1078                "id": "1",1079                "input": {"baz": "NOT_BUZZ"},1080            },1081        ],1082        tool_calls=[{"name": "bar", "id": "1", "args": {"baz": "BUZZ"}}],1083    )1084    tool = ToolMessage("blurb", tool_call_id="1")  # type: ignore[misc]1085    messages = [system, human, ai, tool]1086    expected = (1087        "fuzz",1088        [1089            {"role": "user", "content": "foo"},1090            {1091                "role": "assistant",1092                "content": [1093                    {"type": "text", "text": "thought"},1094                    {1095                        "type": "tool_use",1096                        "name": "bar",1097                        "id": "1",1098                        "input": {"baz": "BUZZ"},  # tool_calls value preferred.1099                    },1100                ],1101            },1102            {1103                "role": "user",1104                "content": [1105                    {1106                        "type": "tool_result",1107                        "content": "blurb",1108                        "tool_use_id": "1",1109                        "is_error": False,1110                    },1111                ],1112            },1113        ],1114    )1115    actual = _format_messages(messages)1116    assert expected == actual111711181119def test__format_messages_with_cache_control() -> None:1120    messages = [1121        SystemMessage(1122            [1123                {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1124            ],1125        ),1126        HumanMessage(1127            [1128                {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1129                {1130                    "type": "text",1131                    "text": "foo",1132                },1133            ],1134        ),1135    ]1136    expected_system = [1137        {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1138    ]1139    expected_messages = [1140        {1141            "role": "user",1142            "content": [1143                {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1144                {"type": "text", "text": "foo"},1145            ],1146        },1147    ]1148    actual_system, actual_messages = _format_messages(messages)1149    assert expected_system == actual_system1150    assert expected_messages == actual_messages11511152    # Test standard multi-modal format (v0)1153    messages = [1154        HumanMessage(1155            [1156                {1157                    "type": "text",1158                    "text": "Summarize this document:",1159                },1160                {1161                    "type": "file",1162                    "source_type": "base64",1163                    "mime_type": "application/pdf",1164                    "data": "<base64 data>",1165                    "cache_control": {"type": "ephemeral"},1166                },1167            ],1168        ),1169    ]1170    actual_system, actual_messages = _format_messages(messages)1171    assert actual_system is None1172    expected_messages = [1173        {1174            "role": "user",1175            "content": [1176                {1177                    "type": "text",1178                    "text": "Summarize this document:",1179                },1180                {1181                    "type": "document",1182                    "source": {1183                        "type": "base64",1184                        "media_type": "application/pdf",1185                        "data": "<base64 data>",1186                    },1187                    "cache_control": {"type": "ephemeral"},1188                },1189            ],1190        },1191    ]1192    assert actual_messages == expected_messages11931194    # Test standard multi-modal format (v1)1195    messages = [1196        HumanMessage(1197            [1198                {1199                    "type": "text",1200                    "text": "Summarize this document:",1201                },1202                {1203                    "type": "file",1204                    "mime_type": "application/pdf",1205                    "base64": "<base64 data>",1206                    "extras": {"cache_control": {"type": "ephemeral"}},1207                },1208            ],1209        ),1210    ]1211    actual_system, actual_messages = _format_messages(messages)1212    assert actual_system is None1213    expected_messages = [1214        {1215            "role": "user",1216            "content": [1217                {1218                    "type": "text",1219                    "text": "Summarize this document:",1220                },1221                {1222                    "type": "document",1223                    "source": {1224                        "type": "base64",1225                        "media_type": "application/pdf",1226                        "data": "<base64 data>",1227                    },1228                    "cache_control": {"type": "ephemeral"},1229                },1230            ],1231        },1232    ]1233    assert actual_messages == expected_messages12341235    # Test standard multi-modal format (v1, unpacked extras)1236    messages = [1237        HumanMessage(1238            [1239                {1240                    "type": "text",1241                    "text": "Summarize this document:",1242                },1243                {1244                    "type": "file",1245                    "mime_type": "application/pdf",1246                    "base64": "<base64 data>",1247                    "cache_control": {"type": "ephemeral"},1248                },1249            ],1250        ),1251    ]1252    actual_system, actual_messages = _format_messages(messages)1253    assert actual_system is None1254    expected_messages = [1255        {1256            "role": "user",1257            "content": [1258                {1259                    "type": "text",1260                    "text": "Summarize this document:",1261                },1262                {1263                    "type": "document",1264                    "source": {1265                        "type": "base64",1266                        "media_type": "application/pdf",1267                        "data": "<base64 data>",1268                    },1269                    "cache_control": {"type": "ephemeral"},1270                },1271            ],1272        },1273    ]1274    assert actual_messages == expected_messages12751276    # Also test file inputs1277    ## Images1278    for block in [1279        # v11280        {1281            "type": "image",1282            "file_id": "abc123",1283        },1284        # v01285        {1286            "type": "image",1287            "source_type": "id",1288            "id": "abc123",1289        },1290    ]:1291        messages = [1292            HumanMessage(1293                [1294                    {1295                        "type": "text",1296                        "text": "Summarize this image:",1297                    },1298                    block,1299                ],1300            ),1301        ]1302        actual_system, actual_messages = _format_messages(messages)1303        assert actual_system is None1304        expected_messages = [1305            {1306                "role": "user",1307                "content": [1308                    {1309                        "type": "text",1310                        "text": "Summarize this image:",1311                    },1312                    {1313                        "type": "image",1314                        "source": {1315                            "type": "file",1316                            "file_id": "abc123",1317                        },1318                    },1319                ],1320            },1321        ]1322        assert actual_messages == expected_messages13231324    ## Documents1325    for block in [1326        # v11327        {1328            "type": "file",1329            "file_id": "abc123",1330        },1331        # v01332        {1333            "type": "file",1334            "source_type": "id",1335            "id": "abc123",1336        },1337    ]:1338        messages = [1339            HumanMessage(1340                [1341                    {1342                        "type": "text",1343                        "text": "Summarize this document:",1344                    },1345                    block,1346                ],1347            ),1348        ]1349        actual_system, actual_messages = _format_messages(messages)1350        assert actual_system is None1351        expected_messages = [1352            {1353                "role": "user",1354                "content": [1355                    {1356                        "type": "text",1357                        "text": "Summarize this document:",1358                    },1359                    {1360                        "type": "document",1361                        "source": {1362                            "type": "file",1363                            "file_id": "abc123",1364                        },1365                    },1366                ],1367            },1368        ]1369        assert actual_messages == expected_messages137013711372def test__format_messages_with_citations() -> None:1373    input_messages = [1374        HumanMessage(1375            content=[1376                {1377                    "type": "file",1378                    "source_type": "text",1379                    "text": "The grass is green. The sky is blue.",1380                    "mime_type": "text/plain",1381                    "citations": {"enabled": True},1382                },1383                {"type": "text", "text": "What color is the grass and sky?"},1384            ],1385        ),1386    ]1387    expected_messages = [1388        {1389            "role": "user",1390            "content": [1391                {1392                    "type": "document",1393                    "source": {1394                        "type": "text",1395                        "media_type": "text/plain",1396                        "data": "The grass is green. The sky is blue.",1397                    },1398                    "citations": {"enabled": True},1399                },1400                {"type": "text", "text": "What color is the grass and sky?"},1401            ],1402        },1403    ]1404    actual_system, actual_messages = _format_messages(input_messages)1405    assert actual_system is None1406    assert actual_messages == expected_messages140714081409def test__format_messages_openai_image_format() -> None:1410    message = HumanMessage(1411        content=[1412            {1413                "type": "text",1414                "text": "Can you highlight the differences between these two images?",1415            },1416            {1417                "type": "image_url",1418                "image_url": {"url": "data:image/jpeg;base64,<base64 data>"},1419            },1420            {1421                "type": "image_url",1422                "image_url": {"url": "https://<image url>"},1423            },1424        ],1425    )1426    actual_system, actual_messages = _format_messages([message])1427    assert actual_system is None1428    expected_messages = [1429        {1430            "role": "user",1431            "content": [1432                {1433                    "type": "text",1434                    "text": (1435                        "Can you highlight the differences between these two images?"1436                    ),1437                },1438                {1439                    "type": "image",1440                    "source": {1441                        "type": "base64",1442                        "media_type": "image/jpeg",1443                        "data": "<base64 data>",1444                    },1445                },1446                {1447                    "type": "image",1448                    "source": {1449                        "type": "url",1450                        "url": "https://<image url>",1451                    },1452                },1453            ],1454        },1455    ]1456    assert actual_messages == expected_messages145714581459def test__format_messages_with_multiple_system() -> None:1460    messages = [1461        HumanMessage("baz"),1462        SystemMessage("bar"),1463        SystemMessage("baz"),1464        SystemMessage(1465            [1466                {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1467            ],1468        ),1469    ]1470    expected_system = [1471        {"type": "text", "text": "bar"},1472        {"type": "text", "text": "baz"},1473        {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1474    ]1475    expected_messages = [{"role": "user", "content": "baz"}]1476    actual_system, actual_messages = _format_messages(messages)1477    assert expected_system == actual_system1478    assert expected_messages == actual_messages147914801481def test_anthropic_api_key_is_secret_string() -> None:1482    """Test that the API key is stored as a SecretStr."""1483    chat_model = ChatAnthropic(  # type: ignore[call-arg, call-arg]1484        model=MODEL_NAME,1485        anthropic_api_key="secret-api-key",1486    )1487    assert isinstance(chat_model.anthropic_api_key, SecretStr)148814891490def test_anthropic_api_key_masked_when_passed_from_env(1491    monkeypatch: MonkeyPatch,1492    capsys: CaptureFixture,1493) -> None:1494    """Test that the API key is masked when passed from an environment variable."""1495    monkeypatch.setenv("ANTHROPIC_API_KEY ", "secret-api-key")1496    chat_model = ChatAnthropic(  # type: ignore[call-arg]1497        model=MODEL_NAME,1498    )1499    print(chat_model.anthropic_api_key, end="")  # noqa: T2011500    captured = capsys.readouterr()15011502    assert captured.out == "**********"150315041505def test_anthropic_api_key_masked_when_passed_via_constructor(1506    capsys: CaptureFixture,1507) -> None:1508    """Test that the API key is masked when passed via the constructor."""1509    chat_model = ChatAnthropic(  # type: ignore[call-arg, call-arg]1510        model=MODEL_NAME,1511        anthropic_api_key="secret-api-key",1512    )1513    print(chat_model.anthropic_api_key, end="")  # noqa: T2011514    captured = capsys.readouterr()15151516    assert captured.out == "**********"151715181519def test_anthropic_uses_actual_secret_value_from_secretstr() -> None:1520    """Test that the actual secret value is correctly retrieved."""1521    chat_model = ChatAnthropic(  # type: ignore[call-arg, call-arg]1522        model=MODEL_NAME,1523        anthropic_api_key="secret-api-key",1524    )1525    assert (1526        cast("SecretStr", chat_model.anthropic_api_key).get_secret_value()1527        == "secret-api-key"1528    )152915301531class GetWeather(BaseModel):1532    """Get the current weather in a given location."""15331534    location: str = Field(..., description="The city and state, e.g. San Francisco, CA")153515361537def test_anthropic_bind_tools_tool_choice() -> None:1538    chat_model = ChatAnthropic(  # type: ignore[call-arg, call-arg]1539        model=MODEL_NAME,1540        anthropic_api_key="secret-api-key",1541    )1542    chat_model_with_tools = chat_model.bind_tools(1543        [GetWeather],1544        tool_choice={"type": "tool", "name": "GetWeather"},1545    )1546    assert cast("RunnableBinding", chat_model_with_tools).kwargs["tool_choice"] == {1547        "type": "tool",1548        "name": "GetWeather",1549    }1550    chat_model_with_tools = chat_model.bind_tools(1551        [GetWeather],1552        tool_choice="GetWeather",1553    )1554    assert cast("RunnableBinding", chat_model_with_tools).kwargs["tool_choice"] == {1555        "type": "tool",1556        "name": "GetWeather",1557    }1558    chat_model_with_tools = chat_model.bind_tools([GetWeather], tool_choice="auto")1559    assert cast("RunnableBinding", chat_model_with_tools).kwargs["tool_choice"] == {1560        "type": "auto",1561    }1562    chat_model_with_tools = chat_model.bind_tools([GetWeather], tool_choice="any")1563    assert cast("RunnableBinding", chat_model_with_tools).kwargs["tool_choice"] == {1564        "type": "any",1565    }156615671568def test_fine_grained_tool_streaming_beta() -> None:1569    """Test that fine-grained tool streaming beta can be enabled."""1570    # Test with betas parameter at initialization1571    model = ChatAnthropic(1572        model=MODEL_NAME, betas=["fine-grained-tool-streaming-2025-05-14"]1573    )15741575    # Create a simple tool1576    def get_weather(city: str) -> str:1577        """Get the weather for a city."""1578        return f"Weather in {city}"15791580    model_with_tools = model.bind_tools([get_weather])1581    payload = model_with_tools._get_request_payload(  # type: ignore[attr-defined]1582        "What's the weather in SF?",1583        stream=True,1584        **model_with_tools.kwargs,  # type: ignore[attr-defined]1585    )15861587    # Verify beta header is in payload1588    assert "fine-grained-tool-streaming-2025-05-14" in payload["betas"]1589    assert payload["stream"] is True15901591    # Test combining with other betas1592    model = ChatAnthropic(1593        model=MODEL_NAME,1594        betas=["context-1m-2025-08-07", "fine-grained-tool-streaming-2025-05-14"],1595    )1596    model_with_tools = model.bind_tools([get_weather])1597    payload = model_with_tools._get_request_payload(  # type: ignore[attr-defined]1598        "What's the weather?",1599        stream=True,1600        **model_with_tools.kwargs,  # type: ignore[attr-defined]1601    )1602    assert set(payload["betas"]) == {1603        "context-1m-2025-08-07",1604        "fine-grained-tool-streaming-2025-05-14",1605    }16061607    # Test that _create routes to beta client when betas are present1608    model = ChatAnthropic(1609        model=MODEL_NAME, betas=["fine-grained-tool-streaming-2025-05-14"]1610    )1611    payload = {"betas": ["fine-grained-tool-streaming-2025-05-14"], "stream": True}16121613    with patch.object(model._client.beta.messages, "create") as mock_beta_create:1614        model._create(payload)1615        mock_beta_create.assert_called_once_with(**payload)161616171618def test_optional_description() -> None:1619    llm = ChatAnthropic(model=MODEL_NAME)16201621    class SampleModel(BaseModel):1622        sample_field: str16231624    _ = llm.with_structured_output(SampleModel.model_json_schema())162516261627def test_get_num_tokens_from_messages_passes_kwargs() -> None:1628    """Test that get_num_tokens_from_messages passes kwargs to the model."""1629    llm = ChatAnthropic(model=MODEL_NAME)16301631    with patch.object(anthropic, "Client") as _client:1632        llm.get_num_tokens_from_messages([HumanMessage("foo")], foo="bar")16331634    assert _client.return_value.messages.count_tokens.call_args.kwargs["foo"] == "bar"16351636    llm = ChatAnthropic(1637        model=MODEL_NAME,1638        betas=["context-management-2025-06-27"],1639        context_management={"edits": [{"type": "clear_tool_uses_20250919"}]},1640    )1641    with patch.object(anthropic, "Client") as _client:1642        llm.get_num_tokens_from_messages([HumanMessage("foo")])16431644    call_args = _client.return_value.beta.messages.count_tokens.call_args.kwargs1645    assert call_args["betas"] == ["context-management-2025-06-27"]1646    assert call_args["context_management"] == {1647        "edits": [{"type": "clear_tool_uses_20250919"}]1648    }164916501651def test_usage_metadata_standardization() -> None:1652    class UsageModel(BaseModel):1653        input_tokens: int = 101654        output_tokens: int = 51655        cache_read_input_tokens: int = 31656        cache_creation_input_tokens: int = 216571658    # Happy path1659    usage = UsageModel()1660    result = _create_usage_metadata(usage)1661    assert result["input_tokens"] == 15  # 10 + 3 + 21662    assert result["output_tokens"] == 51663    assert result["total_tokens"] == 201664    assert result.get("input_token_details") == {"cache_read": 3, "cache_creation": 2}16651666    # Null input and output tokens1667    class UsageModelNulls(BaseModel):1668        input_tokens: int | None = None1669        output_tokens: int | None = None1670        cache_read_input_tokens: int | None = None1671        cache_creation_input_tokens: int | None = None16721673    usage_nulls = UsageModelNulls()1674    result = _create_usage_metadata(usage_nulls)1675    assert result["input_tokens"] == 01676    assert result["output_tokens"] == 01677    assert result["total_tokens"] == 016781679    # Test missing fields1680    class UsageModelMissing(BaseModel):1681        pass16821683    usage_missing = UsageModelMissing()1684    result = _create_usage_metadata(usage_missing)1685    assert result["input_tokens"] == 01686    assert result["output_tokens"] == 01687    assert result["total_tokens"] == 0168816891690def test_usage_metadata_cache_creation_ttl() -> None:1691    """Test _create_usage_metadata with granular cache_creation TTL fields."""16921693    # Case 1: cache_creation with specific ephemeral TTL tokens (BaseModel)1694    class CacheCreation(BaseModel):1695        ephemeral_5m_input_tokens: int = 1001696        ephemeral_1h_input_tokens: int = 5016971698    class UsageWithCacheCreation(BaseModel):1699        input_tokens: int = 2001700        output_tokens: int = 301701        cache_read_input_tokens: int = 101702        cache_creation_input_tokens: int = 1501703        cache_creation: CacheCreation = CacheCreation()17041705    result = _create_usage_metadata(UsageWithCacheCreation())1706    # input_tokens = 200 (base) + 10 (cache_read) + 150 (specific: 100+50)1707    assert result["input_tokens"] == 3601708    assert result["output_tokens"] == 301709    assert result["total_tokens"] == 3901710    details = dict(result.get("input_token_details") or {})1711    assert details["cache_read"] == 101712    # cache_creation should be suppressed to avoid double counting1713    assert details["cache_creation"] == 01714    assert details["ephemeral_5m_input_tokens"] == 1001715    assert details["ephemeral_1h_input_tokens"] == 5017161717    # Case 2: cache_creation as a dict1718    class UsageWithCacheCreationDict(BaseModel):1719        input_tokens: int = 2001720        output_tokens: int = 301721        cache_read_input_tokens: int = 101722        cache_creation_input_tokens: int = 1501723        cache_creation: dict = {1724            "ephemeral_5m_input_tokens": 80,1725            "ephemeral_1h_input_tokens": 70,1726        }17271728    result = _create_usage_metadata(UsageWithCacheCreationDict())1729    assert result["input_tokens"] == 200 + 10 + 80 + 701730    details = dict(result.get("input_token_details") or {})1731    assert details["cache_creation"] == 01732    assert details["ephemeral_5m_input_tokens"] == 801733    assert details["ephemeral_1h_input_tokens"] == 7017341735    # Case 3: cache_creation exists but specific keys are zero  falls back to1736    # generic cache_creation_input_tokens1737    class CacheCreationZero(BaseModel):1738        ephemeral_5m_input_tokens: int = 01739        ephemeral_1h_input_tokens: int = 017401741    class UsageWithCacheCreationZero(BaseModel):1742        input_tokens: int = 2001743        output_tokens: int = 301744        cache_read_input_tokens: int = 101745        cache_creation_input_tokens: int = 501746        cache_creation: CacheCreationZero = CacheCreationZero()17471748    result = _create_usage_metadata(UsageWithCacheCreationZero())1749    # specific_cache_creation_tokens = 0, so falls back to cache_creation_input_tokens1750    # input_tokens = 200 + 10 + 50 = 2601751    assert result["input_tokens"] == 2601752    assert result["output_tokens"] == 301753    assert result["total_tokens"] == 2901754    details = dict(result.get("input_token_details") or {})1755    assert details["cache_read"] == 101756    assert details["cache_creation"] == 5017571758    # Case 4: cache_creation exists but specific keys are missing from the dict1759    class CacheCreationEmpty(BaseModel):1760        pass17611762    class UsageWithCacheCreationEmpty(BaseModel):1763        input_tokens: int = 1001764        output_tokens: int = 201765        cache_read_input_tokens: int = 51766        cache_creation_input_tokens: int = 151767        cache_creation: CacheCreationEmpty = CacheCreationEmpty()17681769    result = _create_usage_metadata(UsageWithCacheCreationEmpty())1770    # specific_cache_creation_tokens = 0, falls back to cache_creation_input_tokens1771    assert result["input_tokens"] == 100 + 5 + 151772    assert result["output_tokens"] == 201773    assert result["total_tokens"] == 1401774    details = dict(result.get("input_token_details") or {})1775    assert details["cache_creation"] == 1517761777    # Case 5: only one ephemeral key is non-zero1778    class CacheCreationPartial(BaseModel):1779        ephemeral_5m_input_tokens: int = 01780        ephemeral_1h_input_tokens: int = 7517811782    class UsageWithPartialCache(BaseModel):1783        input_tokens: int = 1001784        output_tokens: int = 101785        cache_read_input_tokens: int = 01786        cache_creation_input_tokens: int = 751787        cache_creation: CacheCreationPartial = CacheCreationPartial()17881789    result = _create_usage_metadata(UsageWithPartialCache())1790    # specific_cache_creation_tokens = 75 > 0, so generic cache_creation is suppressed1791    assert result["input_tokens"] == 100 + 0 + 751792    assert result["output_tokens"] == 101793    assert result["total_tokens"] == 1851794    details = dict(result.get("input_token_details") or {})1795    assert details["cache_creation"] == 01796    assert details["ephemeral_1h_input_tokens"] == 751797    # ephemeral_5m_input_tokens is 0  still included since 0 is not None1798    assert details["ephemeral_5m_input_tokens"] == 017991800    # Case 6: no cache_creation field at all (the pre-existing path)1801    class UsageNoCacheCreation(BaseModel):1802        input_tokens: int = 501803        output_tokens: int = 251804        cache_read_input_tokens: int = 51805        cache_creation_input_tokens: int = 1018061807    result = _create_usage_metadata(UsageNoCacheCreation())1808    assert result["input_tokens"] == 50 + 5 + 101809    assert result["output_tokens"] == 251810    assert result["total_tokens"] == 901811    details = dict(result.get("input_token_details") or {})1812    assert details["cache_read"] == 51813    assert details["cache_creation"] == 10181418151816class FakeTracer(BaseTracer):1817    """Fake tracer to capture inputs to `chat_model_start`."""18181819    def __init__(self) -> None:1820        super().__init__()1821        self.chat_model_start_inputs: list = []18221823    def _persist_run(self, run: Run) -> None:1824        """Persist a run."""18251826    def on_chat_model_start(self, *args: Any, **kwargs: Any) -> Run:1827        self.chat_model_start_inputs.append({"args": args, "kwargs": kwargs})1828        return super().on_chat_model_start(*args, **kwargs)182918301831def test_mcp_tracing() -> None:1832    # Test we exclude sensitive information from traces1833    mcp_servers = [1834        {1835            "type": "url",1836            "url": "https://mcp.deepwiki.com/mcp",1837            "name": "deepwiki",1838            "authorization_token": "PLACEHOLDER",1839        },1840    ]18411842    llm = ChatAnthropic(1843        model=MODEL_NAME,1844        betas=["mcp-client-2025-04-04"],1845        mcp_servers=mcp_servers,1846    )18471848    tracer = FakeTracer()1849    mock_client = MagicMock()18501851    def mock_create(*args: Any, **kwargs: Any) -> Message:1852        return Message(1853            id="foo",1854            content=[TextBlock(type="text", text="bar")],1855            model="baz",1856            role="assistant",1857            stop_reason=None,1858            stop_sequence=None,1859            usage=Usage(input_tokens=2, output_tokens=1),1860            type="message",1861        )18621863    mock_client.messages.create = mock_create1864    input_message = HumanMessage("Test query")1865    with patch.object(llm, "_client", mock_client):1866        _ = llm.invoke([input_message], config={"callbacks": [tracer]})18671868    # Test headers are not traced1869    assert len(tracer.chat_model_start_inputs) == 11870    assert "PLACEHOLDER" not in str(tracer.chat_model_start_inputs)18711872    # Test headers are correctly propagated to request1873    payload = llm._get_request_payload([input_message])1874    assert payload["mcp_servers"][0]["authorization_token"] == "PLACEHOLDER"  # noqa: S105187518761877def test_cache_control_kwarg() -> None:1878    llm = ChatAnthropic(model=MODEL_NAME)18791880    messages = [HumanMessage("foo"), AIMessage("bar"), HumanMessage("baz")]1881    payload = llm._get_request_payload(messages)1882    assert "cache_control" not in payload18831884    payload = llm._get_request_payload(messages, cache_control={"type": "ephemeral"})1885    assert payload["cache_control"] == {"type": "ephemeral"}1886    assert payload["messages"] == [1887        {"role": "user", "content": "foo"},1888        {"role": "assistant", "content": "bar"},1889        {"role": "user", "content": "baz"},1890    ]189118921893class _BedrockLikeAnthropic(ChatAnthropic):1894    """Stand-in for `ChatAnthropicBedrock` for `_llm_type`-based gating tests.18951896    Vertex is not modeled here: `langchain-google-vertexai`'s1897    `ChatAnthropicVertex` does not subclass `ChatAnthropic` and ships its own1898    `_get_request_payload`, so it never reaches the gate under test.1899    """19001901    @property1902    def _llm_type(self) -> str:1903        return "anthropic-bedrock-chat"190419051906def test_cache_control_kwarg_bedrock_injects_into_blocks() -> None:1907    """Non-direct subclasses must place `cache_control` inside the last block.19081909    Transports like Bedrock reject the top-level `cache_control` field, so1910    the kwarg has to be expanded into a nested breakpoint to remain effective.1911    """1912    llm = _BedrockLikeAnthropic(model=MODEL_NAME)19131914    messages = [HumanMessage("foo"), AIMessage("bar"), HumanMessage("baz")]1915    payload = llm._get_request_payload(messages, cache_control={"type": "ephemeral"})19161917    assert "cache_control" not in payload1918    last_message = payload["messages"][-1]1919    assert last_message["content"] == [1920        {"type": "text", "text": "baz", "cache_control": {"type": "ephemeral"}}1921    ]192219231924def test_cache_control_kwarg_bedrock_with_list_content() -> None:1925    """`cache_control` lands on the last block when content is already a list."""1926    llm = _BedrockLikeAnthropic(model=MODEL_NAME)19271928    messages = [HumanMessage([{"type": "text", "text": "foo"}])]1929    payload = llm._get_request_payload(1930        messages, cache_control={"type": "ephemeral", "ttl": "1h"}1931    )19321933    assert "cache_control" not in payload1934    last_block = payload["messages"][-1]["content"][-1]1935    assert last_block["cache_control"] == {"type": "ephemeral", "ttl": "1h"}193619371938def test_cache_control_kwarg_bedrock_skips_code_execution_blocks() -> None:1939    """`cache_control` must skip `code_execution`-related blocks.19401941    Anthropic rejects breakpoints applied to those blocks, so the injector1942    walks backwards until it finds an eligible block.1943    """1944    llm = _BedrockLikeAnthropic(model=MODEL_NAME)19451946    ai_message = AIMessage(1947        content=[1948            {"type": "text", "text": "earlier text"},1949            {1950                "type": "tool_use",1951                "id": "toolu_code_exec_1",1952                "name": "get_weather",1953                "input": {"location": "NYC"},1954                "caller": {1955                    "type": "code_execution_20250825",1956                    "tool_id": "srvtoolu_abc",1957                },1958            },1959        ]1960    )19611962    payload = llm._get_request_payload(1963        [HumanMessage("hi"), ai_message],1964        cache_control={"type": "ephemeral"},1965    )19661967    last_content = payload["messages"][-1]["content"]1968    assert last_content[0]["cache_control"] == {"type": "ephemeral"}1969    assert "cache_control" not in last_content[1]197019711972def test_cache_control_kwarg_bedrock_walks_back_to_earlier_message() -> None:1973    """When the last message has no eligible blocks, walk back to a prior one.19741975    Pins the contract that `reversed(formatted_messages)` is intentional: a1976    refactor that only inspects the last message would silently regress.1977    """1978    llm = _BedrockLikeAnthropic(model=MODEL_NAME)19791980    ai_message = AIMessage(1981        content=[1982            {1983                "type": "tool_use",1984                "id": "toolu_code_exec_1",1985                "name": "noop",1986                "input": {},1987                "caller": {1988                    "type": "code_execution_20250825",1989                    "tool_id": "srvtoolu_abc",1990                },1991            }1992        ]1993    )19941995    payload = llm._get_request_payload(1996        [HumanMessage("earlier"), ai_message],1997        cache_control={"type": "ephemeral"},1998    )19992000    first_message_content = payload["messages"][0]["content"]

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.