libs/partners/anthropic/tests/unit_tests/test_chat_models.py PYTHON 6,315 lines View on github.com → Search inside
File is large — showing lines 1–2,000 of 6,315.
1"""Test chat model integration."""23from __future__ import annotations45import copy6import json7import os8import warnings9from collections.abc import Callable10from types import SimpleNamespace11from typing import Any, Literal, cast12from unittest.mock import MagicMock, patch1314import anthropic15import pytest16from anthropic.types import Message, TextBlock, Usage17from blockbuster import blockbuster_ctx18from langchain_core.exceptions import (19    ContextOverflowError,20    ModelAPIError,21    ModelAuthenticationError,22    ModelConnectionError,23    ModelError,24    ModelInvalidRequestError,25    ModelNotFoundError,26    ModelPermissionDeniedError,27    ModelRateLimitError,28    ModelTimeoutError,29)30from langchain_core.messages import (31    AIMessage,32    AIMessageChunk,33    BaseMessage,34    HumanMessage,35    SystemMessage,36    ToolMessage,37)38from langchain_core.messages.content import create_text_block39from langchain_core.runnables import RunnableBinding40from langchain_core.tools import BaseTool, tool41from langchain_core.tracers.base import BaseTracer42from langchain_core.tracers.schemas import Run43from langchain_core.utils._gateway import GATEWAY_METADATA_RESPONSE_KEY44from pydantic import BaseModel, Field, RootModel, SecretStr, ValidationError45from pytest import CaptureFixture, MonkeyPatch4647from langchain_anthropic import ChatAnthropic48from langchain_anthropic._sdk_compat import _unsupported_sampling_params49from langchain_anthropic._version import __version__50from langchain_anthropic.chat_models import (51    _TOOL_CALL_ID_PATTERN,52    _create_usage_metadata,53    _drop_unsupported_root_composition_tools,54    _format_image,55    _format_messages,56    _is_builtin_tool,57    _merge_messages,58    _normalize_tool_call_id,59    _thinking_in_params,60    convert_to_anthropic_tool,61)62from tests.unit_tests._httpx_compat import httpx6364os.environ["ANTHROPIC_API_KEY"] = "foo"6566MODEL_NAME = "claude-sonnet-4-5-20250929"6768MID_CONVERSATION_SYSTEM_MODEL = "claude-opus-5"697071class _GatewayMetadataTracer(BaseTracer):72    """Captures gateway metadata promoted onto completed LLM runs."""7374    def __init__(self) -> None:75        super().__init__()76        self.gateway_metadata: dict[str, Any] | None = None7778    def _persist_run(self, run: Run) -> None:79        """No-op; runs are inspected as they complete."""8081    def _on_llm_end(self, run: Run) -> None:82        metadata = run.extra.get("metadata", {})83        gateway_metadata = metadata.get("ls_gateway_info")84        if isinstance(gateway_metadata, dict):85            self.gateway_metadata = gateway_metadata868788_GATEWAY_METADATA = {"provider": "anthropic"}89_MESSAGE_RESPONSE = {90    "id": "msg_123",91    "content": [{"type": "text", "text": "Bar Baz", "citations": None}],92    "model": MODEL_NAME,93    "role": "assistant",94    "stop_reason": "end_turn",95    "stop_sequence": None,96    "usage": {"input_tokens": 2, "output_tokens": 1},97    "type": "message",98}99_STREAM_EVENTS: list[dict[str, Any]] = [100    {101        "type": "message_start",102        "message": {103            **_MESSAGE_RESPONSE,104            "content": [],105            "stop_reason": None,106            "usage": {"input_tokens": 2, "output_tokens": 0},107        },108    },109    {110        "type": "content_block_delta",111        "index": 0,112        "delta": {"type": "text_delta", "text": "Bar Baz"},113    },114    {115        "type": "message_delta",116        "delta": {"stop_reason": "end_turn", "stop_sequence": None},117        "usage": {"input_tokens": 2, "output_tokens": 1},118    },119    {"type": "message_stop"},120]121122123def _gateway_handler(124    expected_beta: str | None,125) -> Callable[[Any], Any]:126    # Annotated `Any`: the concrete request/response classes come from `httpx`127    # or `httpx2` depending on the installed anthropic SDK.128    def handler(request: Any) -> Any:129        assert request.headers.get("anthropic-beta") == expected_beta130        headers = {"x-langsmith-gateway-metadata": json.dumps(_GATEWAY_METADATA)}131        if json.loads(request.content).get("stream"):132            stream = "".join(133                f"event: {event['type']}\ndata: {json.dumps(event)}\n\n"134                for event in _STREAM_EVENTS135            )136            return httpx.Response(137                200,138                text=stream,139                headers={**headers, "content-type": "text/event-stream"},140            )141        return httpx.Response(200, json=_MESSAGE_RESPONSE, headers=headers)142143    return handler144145146def _sync_gateway_client(betas: list[str] | None) -> anthropic.Client:147    return anthropic.Client(148        api_key="lsv2_pt_example",149        http_client=httpx.Client(150            transport=httpx.MockTransport(151                _gateway_handler(",".join(betas) if betas else None)152            )153        ),154    )155156157def _async_gateway_client(betas: list[str] | None) -> anthropic.AsyncClient:158    return anthropic.AsyncClient(159        api_key="lsv2_pt_example",160        http_client=httpx.AsyncClient(161            transport=httpx.MockTransport(162                _gateway_handler(",".join(betas) if betas else None)163            )164        ),165    )166167168@pytest.mark.parametrize("betas", [None, ["test-beta"]])169def test_anthropic_invoke_surfaces_gateway_metadata(170    betas: list[str] | None,171) -> None:172    """Gateway metadata header is surfaced on `generation_info`, not the message."""173    llm = ChatAnthropic(174        model=MODEL_NAME,175        api_key="lsv2_pt_example",176        betas=betas,177        max_tokens=10,178    )179    client = _sync_gateway_client(betas)180    tracer = _GatewayMetadataTracer()181    try:182        with patch.object(llm, "_client", client):183            result = llm.invoke("bar", config={"callbacks": [tracer]})184    finally:185        client.close()186187    assert tracer.gateway_metadata == _GATEWAY_METADATA188    assert GATEWAY_METADATA_RESPONSE_KEY not in result.response_metadata189190191@pytest.mark.parametrize("betas", [None, ["test-beta"]])192async def test_anthropic_ainvoke_surfaces_gateway_metadata(193    betas: list[str] | None,194) -> None:195    """Async gateway responses surface metadata on `generation_info`."""196    llm = ChatAnthropic(197        model=MODEL_NAME,198        api_key="lsv2_pt_example",199        betas=betas,200        max_tokens=10,201    )202    client = _async_gateway_client(betas)203    tracer = _GatewayMetadataTracer()204    try:205        with patch.object(llm, "_async_client", client):206            result = await llm.ainvoke("bar", config={"callbacks": [tracer]})207    finally:208        await client.close()209210    assert tracer.gateway_metadata == _GATEWAY_METADATA211    assert GATEWAY_METADATA_RESPONSE_KEY not in result.response_metadata212213214@pytest.mark.parametrize("betas", [None, ["test-beta"]])215def test_anthropic_stream_surfaces_gateway_metadata(216    betas: list[str] | None,217) -> None:218    """Gateway metadata is attached to the first streaming generation chunk."""219    llm = ChatAnthropic(220        model=MODEL_NAME,221        api_key="lsv2_pt_example",222        betas=betas,223        max_tokens=10,224    )225    client = _sync_gateway_client(betas)226    try:227        with patch.object(llm, "_client", client):228            chunks = list(llm._stream([HumanMessage("bar")]))229    finally:230        client.close()231232    assert [chunk.generation_info for chunk in chunks] == [233        {GATEWAY_METADATA_RESPONSE_KEY: _GATEWAY_METADATA},234        None,235        None,236    ]237238239@pytest.mark.parametrize("betas", [None, ["test-beta"]])240async def test_anthropic_astream_surfaces_gateway_metadata(241    betas: list[str] | None,242) -> None:243    """Async gateway metadata is attached to the first streaming chunk."""244    llm = ChatAnthropic(245        model=MODEL_NAME,246        api_key="lsv2_pt_example",247        betas=betas,248        max_tokens=10,249    )250    client = _async_gateway_client(betas)251    try:252        with patch.object(llm, "_async_client", client):253            chunks = [chunk async for chunk in llm._astream([HumanMessage("bar")])]254    finally:255        await client.close()256257    assert [chunk.generation_info for chunk in chunks] == [258        {GATEWAY_METADATA_RESPONSE_KEY: _GATEWAY_METADATA},259        None,260        None,261    ]262263264def test_initialization() -> None:265    """Test chat model initialization."""266    with patch.dict(os.environ, {"ANTHROPIC_API_URL": "https://api.anthropic.com"}):267        for model in [268            ChatAnthropic(model_name=MODEL_NAME, api_key="xyz", timeout=2),  # type: ignore[arg-type, call-arg]269            ChatAnthropic(  # type: ignore[call-arg, call-arg, call-arg]270                model=MODEL_NAME,271                anthropic_api_key="xyz",272                default_request_timeout=2,273                base_url="https://api.anthropic.com",274            ),275        ]:276            assert model.model == MODEL_NAME277            assert (278                cast("SecretStr", model.anthropic_api_key).get_secret_value() == "xyz"279            )280            assert model.default_request_timeout == 2.0281            assert model.anthropic_api_url == "https://api.anthropic.com"282283284def test_user_agent_header_in_client_params() -> None:285    """Test that _client_params includes a User-Agent header."""286    llm = ChatAnthropic(model=MODEL_NAME, api_key="test-key")  # type: ignore[arg-type]287    params = llm._client_params288    assert "default_headers" in params289    assert "User-Agent" in params["default_headers"]290    assert params["default_headers"]["User-Agent"].startswith("langchain-anthropic/")291292293@pytest.mark.parametrize("async_api", [True, False])294def test_streaming_attribute_should_stream(async_api: bool) -> None:  # noqa: FBT001295    llm = ChatAnthropic(model=MODEL_NAME, streaming=True)296    assert llm._should_stream(async_api=async_api)297298299def test_anthropic_client_caching() -> None:300    """Test that the OpenAI client is cached."""301    llm1 = ChatAnthropic(model=MODEL_NAME)302    llm2 = ChatAnthropic(model=MODEL_NAME)303    assert llm1._client._client is llm2._client._client304305    llm3 = ChatAnthropic(model=MODEL_NAME, base_url="foo")306    assert llm1._client._client is not llm3._client._client307308    llm4 = ChatAnthropic(model=MODEL_NAME, timeout=None)309    assert llm1._client._client is llm4._client._client310311    llm5 = ChatAnthropic(model=MODEL_NAME, timeout=3)312    assert llm1._client._client is not llm5._client._client313314315def test_anthropic_proxy_support() -> None:316    """Test that both sync and async clients support proxy configuration."""317    proxy_url = "http://proxy.example.com:8080"318319    # Test sync client with proxy320    llm_sync = ChatAnthropic(model=MODEL_NAME, anthropic_proxy=proxy_url)321    sync_client = llm_sync._client322    assert sync_client is not None323324    # Test async client with proxy - this should not raise TypeError325    async_client = llm_sync._async_client326    assert async_client is not None327328    # Test that clients with different proxy settings are not cached together329    llm_no_proxy = ChatAnthropic(model=MODEL_NAME)330    llm_with_proxy = ChatAnthropic(model=MODEL_NAME, anthropic_proxy=proxy_url)331332    # Different proxy settings should result in different cached clients333    assert llm_no_proxy._client._client is not llm_with_proxy._client._client334335336def test_anthropic_proxy_from_environment() -> None:337    """Test that proxy can be set from ANTHROPIC_PROXY environment variable."""338    proxy_url = "http://env-proxy.example.com:8080"339340    # Test with environment variable set341    with patch.dict(os.environ, {"ANTHROPIC_PROXY": proxy_url}):342        llm = ChatAnthropic(model=MODEL_NAME)343        assert llm.anthropic_proxy == proxy_url344345        # Should be able to create clients successfully346        sync_client = llm._client347        async_client = llm._async_client348        assert sync_client is not None349        assert async_client is not None350351    # Test that explicit parameter overrides environment variable352    with patch.dict(os.environ, {"ANTHROPIC_PROXY": "http://env-proxy.com"}):353        explicit_proxy = "http://explicit-proxy.com"354        llm = ChatAnthropic(model=MODEL_NAME, anthropic_proxy=explicit_proxy)355        assert llm.anthropic_proxy == explicit_proxy356357358def test_set_default_max_tokens() -> None:359    """Test the set_default_max_tokens function."""360    # Test claude-sonnet-4-5 models361    llm = ChatAnthropic(model="claude-sonnet-4-5-20250929", anthropic_api_key="test")362    assert llm.max_tokens == 64000363364    # Test claude-haiku-4-5 models365    llm = ChatAnthropic(model="claude-haiku-4-5-20251001", anthropic_api_key="test")366    assert llm.max_tokens == 64000367368    # Test claude-3-5-haiku models (profile removed, should fall back to 4096)369    llm = ChatAnthropic(model="claude-3-5-haiku-20241022", anthropic_api_key="test")370    assert llm.max_tokens == 4096371372    # Test claude-3-haiku models (should default to 4096)373    llm = ChatAnthropic(model="claude-3-haiku-20240307", anthropic_api_key="test")374    assert llm.max_tokens == 4096375376    # Test that existing max_tokens values are preserved377    llm = ChatAnthropic(model=MODEL_NAME, max_tokens=2048, anthropic_api_key="test")378    assert llm.max_tokens == 2048379380    # Test that explicitly set max_tokens values are preserved381    llm = ChatAnthropic(model=MODEL_NAME, max_tokens=4096, anthropic_api_key="test")382    assert llm.max_tokens == 4096383384385@pytest.mark.requires("anthropic")386def test_anthropic_model_name_param() -> None:387    llm = ChatAnthropic(model_name=MODEL_NAME)  # type: ignore[call-arg, call-arg]388    assert llm.model == MODEL_NAME389390391@pytest.mark.requires("anthropic")392def test_anthropic_model_param() -> None:393    llm = ChatAnthropic(model=MODEL_NAME)  # type: ignore[call-arg]394    assert llm.model == MODEL_NAME395396397@pytest.mark.requires("anthropic")398def test_anthropic_model_kwargs() -> None:399    llm = ChatAnthropic(model_name=MODEL_NAME, model_kwargs={"foo": "bar"})  # type: ignore[call-arg, call-arg]400    assert llm.model_kwargs == {"foo": "bar"}401402403@pytest.mark.requires("anthropic")404def test_anthropic_fields_in_model_kwargs() -> None:405    """Test that for backwards compatibility fields can be passed in as model_kwargs."""406    with pytest.warns(407        UserWarning,408        match=(409            "Parameters {'max_tokens_to_sample'} should be specified explicitly. "410            "Instead they were passed in as part of `model_kwargs` parameter."411        ),412    ):413        llm = ChatAnthropic(model=MODEL_NAME, model_kwargs={"max_tokens_to_sample": 5})  # type: ignore[call-arg]414    assert llm.max_tokens == 5415    with pytest.warns(416        UserWarning,417        match=(418            "Parameters {'max_tokens'} should be specified explicitly. Instead they "419            "were passed in as part of `model_kwargs` parameter."420        ),421    ):422        llm = ChatAnthropic(model=MODEL_NAME, model_kwargs={"max_tokens": 5})  # type: ignore[call-arg]423    assert llm.max_tokens == 5424425426@pytest.mark.requires("anthropic")427def test_anthropic_incorrect_field() -> None:428    with pytest.warns(match="not default parameter"):429        llm = ChatAnthropic(model=MODEL_NAME, foo="bar")  # type: ignore[call-arg, call-arg]430    assert llm.model_kwargs == {"foo": "bar"}431432433@pytest.mark.requires("anthropic")434def test_anthropic_initialization() -> None:435    """Test anthropic initialization."""436    # Verify that chat anthropic can be initialized using a secret key provided437    # as a parameter rather than an environment variable.438    ChatAnthropic(model=MODEL_NAME, anthropic_api_key="test")  # type: ignore[call-arg, call-arg]439440441def test__format_output() -> None:442    anthropic_msg = Message(443        id="foo",444        content=[TextBlock(type="text", text="bar")],445        model="baz",446        role="assistant",447        stop_reason=None,448        stop_sequence=None,449        usage=Usage(input_tokens=2, output_tokens=1),450        type="message",451    )452    expected = AIMessage(  # type: ignore[misc]453        "bar",454        usage_metadata={455            "input_tokens": 2,456            "output_tokens": 1,457            "total_tokens": 3,458            "input_token_details": {},459        },460        response_metadata={"model_provider": "anthropic"},461    )462    llm = ChatAnthropic(model=MODEL_NAME, anthropic_api_key="test")  # type: ignore[call-arg, call-arg]463    actual = llm._format_output(anthropic_msg)464    assert actual.generations[0].message == expected465466467def test__format_output_cached() -> None:468    anthropic_msg = Message(469        id="foo",470        content=[TextBlock(type="text", text="bar")],471        model="baz",472        role="assistant",473        stop_reason=None,474        stop_sequence=None,475        usage=Usage(476            input_tokens=2,477            output_tokens=1,478            cache_creation_input_tokens=3,479            cache_read_input_tokens=4,480        ),481        type="message",482    )483    expected = AIMessage(  # type: ignore[misc]484        "bar",485        usage_metadata={486            "input_tokens": 9,487            "output_tokens": 1,488            "total_tokens": 10,489            "input_token_details": {"cache_creation": 3, "cache_read": 4},490        },491        response_metadata={"model_provider": "anthropic"},492    )493494    llm = ChatAnthropic(model=MODEL_NAME, anthropic_api_key="test")  # type: ignore[call-arg, call-arg]495    actual = llm._format_output(anthropic_msg)496    assert actual.generations[0].message == expected497498499def test__merge_messages() -> None:500    messages = [501        SystemMessage("foo"),  # type: ignore[misc]502        HumanMessage("bar"),  # type: ignore[misc]503        AIMessage(  # type: ignore[misc]504            [505                {"text": "baz", "type": "text"},506                {507                    "tool_input": {"a": "b"},508                    "type": "tool_use",509                    "id": "1",510                    "text": None,511                    "name": "buz",512                },513                {"text": "baz", "type": "text"},514                {515                    "tool_input": {"a": "c"},516                    "type": "tool_use",517                    "id": "2",518                    "text": None,519                    "name": "blah",520                },521                {522                    "tool_input": {"a": "c"},523                    "type": "tool_use",524                    "id": "3",525                    "text": None,526                    "name": "blah",527                },528            ],529        ),530        ToolMessage("buz output", tool_call_id="1", status="error"),  # type: ignore[misc]531        ToolMessage(532            content=[533                {534                    "type": "image",535                    "source": {536                        "type": "base64",537                        "media_type": "image/jpeg",538                        "data": "fake_image_data",539                    },540                },541            ],542            tool_call_id="2",543        ),  # type: ignore[misc]544        ToolMessage([], tool_call_id="3"),  # type: ignore[misc]545        HumanMessage("next thing"),  # type: ignore[misc]546    ]547    expected = [548        SystemMessage("foo"),  # type: ignore[misc]549        HumanMessage("bar"),  # type: ignore[misc]550        AIMessage(  # type: ignore[misc]551            [552                {"text": "baz", "type": "text"},553                {554                    "tool_input": {"a": "b"},555                    "type": "tool_use",556                    "id": "1",557                    "text": None,558                    "name": "buz",559                },560                {"text": "baz", "type": "text"},561                {562                    "tool_input": {"a": "c"},563                    "type": "tool_use",564                    "id": "2",565                    "text": None,566                    "name": "blah",567                },568                {569                    "tool_input": {"a": "c"},570                    "type": "tool_use",571                    "id": "3",572                    "text": None,573                    "name": "blah",574                },575            ],576        ),577        HumanMessage(  # type: ignore[misc]578            [579                {580                    "type": "tool_result",581                    "content": "buz output",582                    "tool_use_id": "1",583                    "is_error": True,584                },585                {586                    "type": "tool_result",587                    "content": [588                        {589                            "type": "image",590                            "source": {591                                "type": "base64",592                                "media_type": "image/jpeg",593                                "data": "fake_image_data",594                            },595                        },596                    ],597                    "tool_use_id": "2",598                    "is_error": False,599                },600                {601                    "type": "tool_result",602                    "content": [],603                    "tool_use_id": "3",604                    "is_error": False,605                },606                {"type": "text", "text": "next thing"},607            ],608        ),609    ]610    actual = _merge_messages(messages)611    assert expected == actual612613    # Test tool message case614    messages = [615        ToolMessage("buz output", tool_call_id="1"),  # type: ignore[misc]616        ToolMessage(  # type: ignore[misc]617            content=[618                {"type": "tool_result", "content": "blah output", "tool_use_id": "2"},619            ],620            tool_call_id="2",621        ),622    ]623    expected = [624        HumanMessage(  # type: ignore[misc]625            [626                {627                    "type": "tool_result",628                    "content": "buz output",629                    "tool_use_id": "1",630                    "is_error": False,631                },632                {"type": "tool_result", "content": "blah output", "tool_use_id": "2"},633            ],634        ),635    ]636    actual = _merge_messages(messages)637    assert expected == actual638639640def test__merge_messages_coalesces_adjacent_system_messages() -> None:641    """Test adjacent system messages are merged."""642    messages = [643        SystemMessage("bar"),  # type: ignore[misc]644        SystemMessage("baz"),  # type: ignore[misc]645        SystemMessage(  # type: ignore[misc]646            [647                {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},648            ],649        ),650        HumanMessage("hi"),  # type: ignore[misc]651    ]652    expected = [653        SystemMessage(  # type: ignore[misc]654            [655                {"type": "text", "text": "bar"},656                {"type": "text", "text": "baz"},657                {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},658            ],659        ),660        HumanMessage("hi"),  # type: ignore[misc]661    ]662    assert _merge_messages(messages) == expected663664665def test__merge_messages_mutation() -> None:666    original_messages = [667        HumanMessage([{"type": "text", "text": "bar"}]),  # type: ignore[misc]668        HumanMessage("next thing"),  # type: ignore[misc]669    ]670    messages = [671        HumanMessage([{"type": "text", "text": "bar"}]),  # type: ignore[misc]672        HumanMessage("next thing"),  # type: ignore[misc]673    ]674    expected = [675        HumanMessage(  # type: ignore[misc]676            [{"type": "text", "text": "bar"}, {"type": "text", "text": "next thing"}],677        ),678    ]679    actual = _merge_messages(messages)680    assert expected == actual681    assert messages == original_messages682683684def test__merge_messages_tool_message_cache_control() -> None:685    """Test that cache_control is hoisted from content blocks to tool_result level."""686    # Test with cache_control in content block687    messages = [688        ToolMessage(689            content=[690                {691                    "type": "text",692                    "text": "tool output",693                    "cache_control": {"type": "ephemeral"},694                }695            ],696            tool_call_id="1",697        )698    ]699    original_messages = [copy.deepcopy(m) for m in messages]700    expected = [701        HumanMessage(702            [703                {704                    "type": "tool_result",705                    "content": [{"type": "text", "text": "tool output"}],706                    "tool_use_id": "1",707                    "is_error": False,708                    "cache_control": {"type": "ephemeral"},709                }710            ]711        )712    ]713    actual = _merge_messages(messages)714    assert expected == actual715    # Verify no mutation716    assert messages == original_messages717718    # Test with multiple content blocks, cache_control on last one719    messages = [720        ToolMessage(721            content=[722                {"type": "text", "text": "first output"},723                {724                    "type": "text",725                    "text": "second output",726                    "cache_control": {"type": "ephemeral"},727                },728            ],729            tool_call_id="2",730        )731    ]732    expected = [733        HumanMessage(734            [735                {736                    "type": "tool_result",737                    "content": [738                        {"type": "text", "text": "first output"},739                        {"type": "text", "text": "second output"},740                    ],741                    "tool_use_id": "2",742                    "is_error": False,743                    "cache_control": {"type": "ephemeral"},744                }745            ]746        )747    ]748    actual = _merge_messages(messages)749    assert expected == actual750751    # Test without cache_control752    messages = [ToolMessage(content="simple output", tool_call_id="3")]753    expected = [754        HumanMessage(755            [756                {757                    "type": "tool_result",758                    "content": "simple output",759                    "tool_use_id": "3",760                    "is_error": False,761                }762            ]763        )764    ]765    actual = _merge_messages(messages)766    assert expected == actual767768769def test__format_image() -> None:770    url = "dummyimage.com/600x400/000/fff"771    with pytest.raises(ValueError):772        _format_image(url)773774775@pytest.fixture776def pydantic() -> type[BaseModel]:777    class dummy_function(BaseModel):  # noqa: N801778        """Dummy function."""779780        arg1: int = Field(..., description="foo")781        arg2: Literal["bar", "baz"] = Field(..., description="one of 'bar', 'baz'")782783    return dummy_function784785786@pytest.fixture787def function() -> Callable:788    def dummy_function(arg1: int, arg2: Literal["bar", "baz"]) -> None:789        """Dummy function.790791        Args:792            arg1: foo793            arg2: one of 'bar', 'baz'794795        """796797    return dummy_function798799800@pytest.fixture801def dummy_tool() -> BaseTool:802    class Schema(BaseModel):803        arg1: int = Field(..., description="foo")804        arg2: Literal["bar", "baz"] = Field(..., description="one of 'bar', 'baz'")805806    class DummyFunction(BaseTool):  # type: ignore[override]807        args_schema: type[BaseModel] = Schema808        name: str = "dummy_function"809        description: str = "Dummy function."810811        def _run(self, *args: Any, **kwargs: Any) -> Any:812            pass813814    return DummyFunction()815816817@pytest.fixture818def json_schema() -> dict:819    return {820        "title": "dummy_function",821        "description": "Dummy function.",822        "type": "object",823        "properties": {824            "arg1": {"description": "foo", "type": "integer"},825            "arg2": {826                "description": "one of 'bar', 'baz'",827                "enum": ["bar", "baz"],828                "type": "string",829            },830        },831        "required": ["arg1", "arg2"],832    }833834835@pytest.fixture836def openai_function() -> dict:837    return {838        "name": "dummy_function",839        "description": "Dummy function.",840        "parameters": {841            "type": "object",842            "properties": {843                "arg1": {"description": "foo", "type": "integer"},844                "arg2": {845                    "description": "one of 'bar', 'baz'",846                    "enum": ["bar", "baz"],847                    "type": "string",848                },849            },850            "required": ["arg1", "arg2"],851        },852    }853854855def test_convert_to_anthropic_tool(856    pydantic: type[BaseModel],857    function: Callable,858    dummy_tool: BaseTool,859    json_schema: dict,860    openai_function: dict,861) -> None:862    expected = {863        "name": "dummy_function",864        "description": "Dummy function.",865        "input_schema": {866            "type": "object",867            "properties": {868                "arg1": {"description": "foo", "type": "integer"},869                "arg2": {870                    "description": "one of 'bar', 'baz'",871                    "enum": ["bar", "baz"],872                    "type": "string",873                },874            },875            "required": ["arg1", "arg2"],876        },877    }878879    for fn in (pydantic, function, dummy_tool, json_schema, expected, openai_function):880        actual = convert_to_anthropic_tool(fn)881        assert actual == expected882883884def test__format_messages_with_tool_calls() -> None:885    system = SystemMessage("fuzz")  # type: ignore[misc]886    human = HumanMessage("foo")  # type: ignore[misc]887    ai = AIMessage(888        "",  # with empty string889        tool_calls=[{"name": "bar", "id": "1", "args": {"baz": "buzz"}}],890    )891    ai2 = AIMessage(892        [],  # with empty list893        tool_calls=[{"name": "bar", "id": "2", "args": {"baz": "buzz"}}],894    )895    tool = ToolMessage(896        "blurb",897        tool_call_id="1",898    )899    tool_image_url = ToolMessage(900        [{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,...."}}],901        tool_call_id="2",902    )903    tool_image = ToolMessage(904        [905            {906                "type": "image",907                "source": {908                    "data": "....",909                    "type": "base64",910                    "media_type": "image/jpeg",911                },912            },913        ],914        tool_call_id="3",915    )916    messages = [system, human, ai, tool, ai2, tool_image_url, tool_image]917    expected = (918        "fuzz",919        [920            {"role": "user", "content": "foo"},921            {922                "role": "assistant",923                "content": [924                    {925                        "type": "tool_use",926                        "name": "bar",927                        "id": "1",928                        "input": {"baz": "buzz"},929                    },930                ],931            },932            {933                "role": "user",934                "content": [935                    {936                        "type": "tool_result",937                        "content": "blurb",938                        "tool_use_id": "1",939                        "is_error": False,940                    },941                ],942            },943            {944                "role": "assistant",945                "content": [946                    {947                        "type": "tool_use",948                        "name": "bar",949                        "id": "2",950                        "input": {"baz": "buzz"},951                    },952                ],953            },954            {955                "role": "user",956                "content": [957                    {958                        "type": "tool_result",959                        "content": [960                            {961                                "type": "image",962                                "source": {963                                    "data": "....",964                                    "type": "base64",965                                    "media_type": "image/jpeg",966                                },967                            },968                        ],969                        "tool_use_id": "2",970                        "is_error": False,971                    },972                    {973                        "type": "tool_result",974                        "content": [975                            {976                                "type": "image",977                                "source": {978                                    "data": "....",979                                    "type": "base64",980                                    "media_type": "image/jpeg",981                                },982                            },983                        ],984                        "tool_use_id": "3",985                        "is_error": False,986                    },987                ],988            },989        ],990    )991    actual = _format_messages(messages, model=MODEL_NAME)992    assert expected == actual993994    # Check handling of empty AIMessage995    empty_contents: list[str | list[str | dict[str, Any]]] = ["", []]996    for empty_content in empty_contents:997        ## Permit message in final position998        _, anthropic_messages = _format_messages(999            [human, AIMessage(empty_content)], model=MODEL_NAME1000        )1001        expected_messages = [1002            {"role": "user", "content": "foo"},1003            {"role": "assistant", "content": empty_content},1004        ]1005        assert expected_messages == anthropic_messages10061007        ## Remove message otherwise1008        _, anthropic_messages = _format_messages(1009            [human, AIMessage(empty_content), human], model=MODEL_NAME1010        )1011        expected_messages = [1012            {"role": "user", "content": "foo"},1013            {"role": "user", "content": "foo"},1014        ]1015        assert expected_messages == anthropic_messages10161017        actual = _format_messages(1018            [system, human, ai, tool, AIMessage(empty_content), human], model=MODEL_NAME1019        )1020        assert actual[0] == "fuzz"1021        assert [message["role"] for message in actual[1]] == [1022            "user",1023            "assistant",1024            "user",1025            "user",1026        ]102710281029def test__normalize_tool_call_id() -> None:1030    # Already-valid IDs (including native Anthropic and OpenAI styles) pass1031    # through unchanged.1032    for valid in ("1", "toolu_01abcDEF-_", "call_Ao02pnFYXD6GN1yzc0uXPsvF"):1033        assert _normalize_tool_call_id(valid) == valid10341035    # Empty and None IDs pass through so a malformed request surfaces a clear1036    # error from Anthropic rather than a synthesized ID.1037    assert _normalize_tool_call_id("") == ""1038    assert _normalize_tool_call_id(None) is None10391040    # Foreign IDs with characters Anthropic rejects (e.g. Fireworks/Kimi's1041    # `functions.write_todos:0`) are rewritten to a compatible form.1042    invalid = "functions.write_todos:0"1043    normalized = _normalize_tool_call_id(invalid)1044    assert normalized is not None1045    assert normalized != invalid1046    assert _TOOL_CALL_ID_PATTERN.match(normalized)10471048    # Deterministic + idempotent: same input always maps to the same output.1049    assert _normalize_tool_call_id(invalid) == normalized1050    assert _normalize_tool_call_id(normalized) == normalized10511052    # Distinct invalid IDs map to distinct replacements (no collision that1053    # would break multi-tool turns).1054    other = _normalize_tool_call_id("functions.read_file:1")1055    assert other != normalized105610571058def test__format_messages_normalizes_cross_provider_tool_call_ids() -> None:1059    """A `tool_use.id` and its paired `tool_use_id` must normalize identically.10601061    Reproduces the Fireworks/Kimi -> Anthropic 400 from replaying a thread whose1062    tool-call IDs were minted by another provider.1063    """1064    bad_id = "functions.write_todos:0"1065    ai = AIMessage(1066        "",1067        tool_calls=[{"name": "write_todos", "id": bad_id, "args": {"todos": []}}],1068    )1069    tool = ToolMessage("done", tool_call_id=bad_id)10701071    _, formatted = _format_messages([HumanMessage("hi"), ai, tool], model=MODEL_NAME)10721073    tool_use = formatted[1]["content"][0]1074    tool_result = formatted[2]["content"][0]1075    assert tool_use["type"] == "tool_use"1076    assert tool_result["type"] == "tool_result"10771078    # The rewritten IDs are valid and still reference each other.1079    assert _TOOL_CALL_ID_PATTERN.match(tool_use["id"])1080    assert tool_use["id"] == tool_result["tool_use_id"]1081    assert tool_use["id"] == _normalize_tool_call_id(bad_id)108210831084def test__format_messages_normalizes_prestructured_tool_result_id() -> None:1085    """A `ToolMessage` whose content is already `tool_result` blocks is covered.10861087    This shape bypasses the `tool_call_id` normalization in `_merge_messages` and1088    flows through the `tool_result` content branch, so its `tool_use_id` must1089    still be normalized to match the paired `tool_use.id`.1090    """1091    bad_id = "functions.write_todos:0"1092    ai = AIMessage(1093        "",1094        tool_calls=[{"name": "write_todos", "id": bad_id, "args": {"todos": []}}],1095    )1096    tool = ToolMessage(1097        [{"type": "tool_result", "tool_use_id": bad_id, "content": "done"}],1098        tool_call_id=bad_id,1099    )11001101    _, formatted = _format_messages([HumanMessage("hi"), ai, tool], model=MODEL_NAME)11021103    tool_use = formatted[1]["content"][0]1104    tool_result = formatted[2]["content"][0]1105    assert tool_use["id"] == tool_result["tool_use_id"]1106    assert tool_use["id"] == _normalize_tool_call_id(bad_id)110711081109def test__format_messages_normalizes_inline_tool_use_block() -> None:1110    """An invalid ID on an inline `tool_use` content block is normalized.11111112    Covers the v1-compat destination where tool calls are stored as content1113    blocks rather than the `tool_calls` attribute, paired with a `ToolMessage`.1114    """1115    bad_id = "functions.search:2"1116    ai = AIMessage(1117        [{"type": "tool_use", "name": "search", "id": bad_id, "input": {"q": "x"}}],1118    )1119    tool = ToolMessage("result", tool_call_id=bad_id)11201121    _, formatted = _format_messages([HumanMessage("hi"), ai, tool], model=MODEL_NAME)11221123    tool_use = formatted[1]["content"][0]1124    tool_result = formatted[2]["content"][0]1125    assert _TOOL_CALL_ID_PATTERN.match(tool_use["id"])1126    assert tool_use["id"] == tool_result["tool_use_id"]112711281129def test__format_messages_dedupes_overlapping_normalized_tool_use() -> None:1130    """An invalid ID shared by a `tool_use` block and `tool_calls` yields one block.11311132    Guards the dedup branch: `tool_use_ids` are normalized, so the comparison1133    against the (also normalized) tool-call ID must not re-emit a duplicate block.1134    """1135    bad_id = "functions.write_todos:0"1136    ai = AIMessage(1137        [{"type": "tool_use", "name": "write_todos", "id": bad_id, "input": {"a": 1}}],1138        tool_calls=[{"name": "write_todos", "id": bad_id, "args": {"a": 1}}],1139    )11401141    _, formatted = _format_messages([HumanMessage("hi"), ai], model=MODEL_NAME)11421143    tool_use_blocks = [b for b in formatted[1]["content"] if b["type"] == "tool_use"]1144    assert len(tool_use_blocks) == 11145    assert _TOOL_CALL_ID_PATTERN.match(tool_use_blocks[0]["id"])114611471148def test__format_messages_normalizes_distinct_ids_independently() -> None:1149    """Multiple distinct invalid IDs in one turn stay distinct and correctly paired."""1150    id_a = "functions.write_todos:0"1151    id_b = "functions.read_file:1"1152    ai = AIMessage(1153        "",1154        tool_calls=[1155            {"name": "write_todos", "id": id_a, "args": {}},1156            {"name": "read_file", "id": id_b, "args": {}},1157        ],1158    )1159    tool_a = ToolMessage("a", tool_call_id=id_a)1160    tool_b = ToolMessage("b", tool_call_id=id_b)11611162    _, formatted = _format_messages(1163        [HumanMessage("hi"), ai, tool_a, tool_b], model=MODEL_NAME1164    )11651166    tool_uses = formatted[1]["content"]1167    results = formatted[2]["content"]1168    assert tool_uses[0]["id"] == _normalize_tool_call_id(id_a)1169    assert tool_uses[1]["id"] == _normalize_tool_call_id(id_b)1170    assert tool_uses[0]["id"] != tool_uses[1]["id"]1171    # Each result still pairs with its own tool_use.1172    assert {r["tool_use_id"] for r in results} == {1173        tool_uses[0]["id"],1174        tool_uses[1]["id"],1175    }117611771178def test__format_tool_use_block() -> None:1179    # Test we correctly format tool_use blocks when there is no corresponding tool_call.1180    message = AIMessage(1181        [1182            {1183                "type": "tool_use",1184                "name": "foo_1",1185                "id": "1",1186                "input": {"bar_1": "baz_1"},1187            },1188            {1189                "type": "tool_use",1190                "name": "foo_2",1191                "id": "2",1192                "input": {},1193                "partial_json": '{"bar_2": "baz_2"}',1194                "index": 1,1195            },1196        ]1197    )1198    result = _format_messages([message], model=MODEL_NAME)1199    expected = {1200        "role": "assistant",1201        "content": [1202            {1203                "type": "tool_use",1204                "name": "foo_1",1205                "id": "1",1206                "input": {"bar_1": "baz_1"},1207            },1208            {1209                "type": "tool_use",1210                "name": "foo_2",1211                "id": "2",1212                "input": {"bar_2": "baz_2"},1213            },1214        ],1215    }1216    assert result == (None, [expected])121712181219def test__format_messages_with_str_content_and_tool_calls() -> None:1220    system = SystemMessage("fuzz")  # type: ignore[misc]1221    human = HumanMessage("foo")  # type: ignore[misc]1222    # If content and tool_calls are specified and content is a string, then both are1223    # included with content first.1224    ai = AIMessage(  # type: ignore[misc]1225        "thought",1226        tool_calls=[{"name": "bar", "id": "1", "args": {"baz": "buzz"}}],1227    )1228    tool = ToolMessage("blurb", tool_call_id="1")  # type: ignore[misc]1229    messages = [system, human, ai, tool]1230    expected = (1231        "fuzz",1232        [1233            {"role": "user", "content": "foo"},1234            {1235                "role": "assistant",1236                "content": [1237                    {"type": "text", "text": "thought"},1238                    {1239                        "type": "tool_use",1240                        "name": "bar",1241                        "id": "1",1242                        "input": {"baz": "buzz"},1243                    },1244                ],1245            },1246            {1247                "role": "user",1248                "content": [1249                    {1250                        "type": "tool_result",1251                        "content": "blurb",1252                        "tool_use_id": "1",1253                        "is_error": False,1254                    },1255                ],1256            },1257        ],1258    )1259    actual = _format_messages(messages, model=MODEL_NAME)1260    assert expected == actual126112621263def test__format_messages_with_list_content_and_tool_calls() -> None:1264    system = SystemMessage("fuzz")  # type: ignore[misc]1265    human = HumanMessage("foo")  # type: ignore[misc]1266    ai = AIMessage(  # type: ignore[misc]1267        [{"type": "text", "text": "thought"}],1268        tool_calls=[{"name": "bar", "id": "1", "args": {"baz": "buzz"}}],1269    )1270    tool = ToolMessage(  # type: ignore[misc]1271        "blurb",1272        tool_call_id="1",1273    )1274    messages = [system, human, ai, tool]1275    expected = (1276        "fuzz",1277        [1278            {"role": "user", "content": "foo"},1279            {1280                "role": "assistant",1281                "content": [1282                    {"type": "text", "text": "thought"},1283                    {1284                        "type": "tool_use",1285                        "name": "bar",1286                        "id": "1",1287                        "input": {"baz": "buzz"},1288                    },1289                ],1290            },1291            {1292                "role": "user",1293                "content": [1294                    {1295                        "type": "tool_result",1296                        "content": "blurb",1297                        "tool_use_id": "1",1298                        "is_error": False,1299                    },1300                ],1301            },1302        ],1303    )1304    actual = _format_messages(messages, model=MODEL_NAME)1305    assert expected == actual130613071308def test__format_messages_with_tool_use_blocks_and_tool_calls() -> None:1309    """Show that tool_calls are preferred to tool_use blocks when both have same id."""1310    system = SystemMessage("fuzz")  # type: ignore[misc]1311    human = HumanMessage("foo")  # type: ignore[misc]1312    # NOTE: tool_use block in contents and tool_calls have different arguments.1313    ai = AIMessage(  # type: ignore[misc]1314        [1315            {"type": "text", "text": "thought"},1316            {1317                "type": "tool_use",1318                "name": "bar",1319                "id": "1",1320                "input": {"baz": "NOT_BUZZ"},1321            },1322        ],1323        tool_calls=[{"name": "bar", "id": "1", "args": {"baz": "BUZZ"}}],1324    )1325    tool = ToolMessage("blurb", tool_call_id="1")  # type: ignore[misc]1326    messages = [system, human, ai, tool]1327    expected = (1328        "fuzz",1329        [1330            {"role": "user", "content": "foo"},1331            {1332                "role": "assistant",1333                "content": [1334                    {"type": "text", "text": "thought"},1335                    {1336                        "type": "tool_use",1337                        "name": "bar",1338                        "id": "1",1339                        "input": {"baz": "BUZZ"},  # tool_calls value preferred.1340                    },1341                ],1342            },1343            {1344                "role": "user",1345                "content": [1346                    {1347                        "type": "tool_result",1348                        "content": "blurb",1349                        "tool_use_id": "1",1350                        "is_error": False,1351                    },1352                ],1353            },1354        ],1355    )1356    actual = _format_messages(messages, model=MODEL_NAME)1357    assert expected == actual135813591360def test__format_messages_with_cache_control() -> None:1361    messages = [1362        SystemMessage(1363            [1364                {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1365            ],1366        ),1367        HumanMessage(1368            [1369                {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1370                {1371                    "type": "text",1372                    "text": "foo",1373                },1374            ],1375        ),1376    ]1377    expected_system = [1378        {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1379    ]1380    expected_messages = [1381        {1382            "role": "user",1383            "content": [1384                {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1385                {"type": "text", "text": "foo"},1386            ],1387        },1388    ]1389    actual_system, actual_messages = _format_messages(messages, model=MODEL_NAME)1390    assert expected_system == actual_system1391    assert expected_messages == actual_messages13921393    # Test standard multi-modal format (v0)1394    messages = [1395        HumanMessage(1396            [1397                {1398                    "type": "text",1399                    "text": "Summarize this document:",1400                },1401                {1402                    "type": "file",1403                    "source_type": "base64",1404                    "mime_type": "application/pdf",1405                    "data": "<base64 data>",1406                    "cache_control": {"type": "ephemeral"},1407                },1408            ],1409        ),1410    ]1411    actual_system, actual_messages = _format_messages(messages, model=MODEL_NAME)1412    assert actual_system is None1413    expected_messages = [1414        {1415            "role": "user",1416            "content": [1417                {1418                    "type": "text",1419                    "text": "Summarize this document:",1420                },1421                {1422                    "type": "document",1423                    "source": {1424                        "type": "base64",1425                        "media_type": "application/pdf",1426                        "data": "<base64 data>",1427                    },1428                    "cache_control": {"type": "ephemeral"},1429                },1430            ],1431        },1432    ]1433    assert actual_messages == expected_messages14341435    # Test standard multi-modal format (v1)1436    messages = [1437        HumanMessage(1438            [1439                {1440                    "type": "text",1441                    "text": "Summarize this document:",1442                },1443                {1444                    "type": "file",1445                    "mime_type": "application/pdf",1446                    "base64": "<base64 data>",1447                    "extras": {"cache_control": {"type": "ephemeral"}},1448                },1449            ],1450        ),1451    ]1452    actual_system, actual_messages = _format_messages(messages, model=MODEL_NAME)1453    assert actual_system is None1454    expected_messages = [1455        {1456            "role": "user",1457            "content": [1458                {1459                    "type": "text",1460                    "text": "Summarize this document:",1461                },1462                {1463                    "type": "document",1464                    "source": {1465                        "type": "base64",1466                        "media_type": "application/pdf",1467                        "data": "<base64 data>",1468                    },1469                    "cache_control": {"type": "ephemeral"},1470                },1471            ],1472        },1473    ]1474    assert actual_messages == expected_messages14751476    # Test standard multi-modal format (v1, unpacked extras)1477    messages = [1478        HumanMessage(1479            [1480                {1481                    "type": "text",1482                    "text": "Summarize this document:",1483                },1484                {1485                    "type": "file",1486                    "mime_type": "application/pdf",1487                    "base64": "<base64 data>",1488                    "cache_control": {"type": "ephemeral"},1489                },1490            ],1491        ),1492    ]1493    actual_system, actual_messages = _format_messages(messages, model=MODEL_NAME)1494    assert actual_system is None1495    expected_messages = [1496        {1497            "role": "user",1498            "content": [1499                {1500                    "type": "text",1501                    "text": "Summarize this document:",1502                },1503                {1504                    "type": "document",1505                    "source": {1506                        "type": "base64",1507                        "media_type": "application/pdf",1508                        "data": "<base64 data>",1509                    },1510                    "cache_control": {"type": "ephemeral"},1511                },1512            ],1513        },1514    ]1515    assert actual_messages == expected_messages15161517    # Also test file inputs1518    ## Images1519    for block in [1520        # v11521        {1522            "type": "image",1523            "file_id": "abc123",1524        },1525        # v01526        {1527            "type": "image",1528            "source_type": "id",1529            "id": "abc123",1530        },1531    ]:1532        messages = [1533            HumanMessage(1534                [1535                    {1536                        "type": "text",1537                        "text": "Summarize this image:",1538                    },1539                    block,1540                ],1541            ),1542        ]1543        actual_system, actual_messages = _format_messages(messages, model=MODEL_NAME)1544        assert actual_system is None1545        expected_messages = [1546            {1547                "role": "user",1548                "content": [1549                    {1550                        "type": "text",1551                        "text": "Summarize this image:",1552                    },1553                    {1554                        "type": "image",1555                        "source": {1556                            "type": "file",1557                            "file_id": "abc123",1558                        },1559                    },1560                ],1561            },1562        ]1563        assert actual_messages == expected_messages15641565    ## Documents1566    for block in [1567        # v11568        {1569            "type": "file",1570            "file_id": "abc123",1571        },1572        # v01573        {1574            "type": "file",1575            "source_type": "id",1576            "id": "abc123",1577        },1578    ]:1579        messages = [1580            HumanMessage(1581                [1582                    {1583                        "type": "text",1584                        "text": "Summarize this document:",1585                    },1586                    block,1587                ],1588            ),1589        ]1590        actual_system, actual_messages = _format_messages(messages, model=MODEL_NAME)1591        assert actual_system is None1592        expected_messages = [1593            {1594                "role": "user",1595                "content": [1596                    {1597                        "type": "text",1598                        "text": "Summarize this document:",1599                    },1600                    {1601                        "type": "document",1602                        "source": {1603                            "type": "file",1604                            "file_id": "abc123",1605                        },1606                    },1607                ],1608            },1609        ]1610        assert actual_messages == expected_messages161116121613def test__format_messages_with_citations() -> None:1614    input_messages = [1615        HumanMessage(1616            content=[1617                {1618                    "type": "file",1619                    "source_type": "text",1620                    "text": "The grass is green. The sky is blue.",1621                    "mime_type": "text/plain",1622                    "citations": {"enabled": True},1623                },1624                {"type": "text", "text": "What color is the grass and sky?"},1625            ],1626        ),1627    ]1628    expected_messages = [1629        {1630            "role": "user",1631            "content": [1632                {1633                    "type": "document",1634                    "source": {1635                        "type": "text",1636                        "media_type": "text/plain",1637                        "data": "The grass is green. The sky is blue.",1638                    },1639                    "citations": {"enabled": True},1640                },1641                {"type": "text", "text": "What color is the grass and sky?"},1642            ],1643        },1644    ]1645    actual_system, actual_messages = _format_messages(input_messages, model=MODEL_NAME)1646    assert actual_system is None1647    assert actual_messages == expected_messages164816491650def test__format_messages_openai_image_format() -> None:1651    message = HumanMessage(1652        content=[1653            {1654                "type": "text",1655                "text": "Can you highlight the differences between these two images?",1656            },1657            {1658                "type": "image_url",1659                "image_url": {"url": "data:image/jpeg;base64,<base64 data>"},1660            },1661            {1662                "type": "image_url",1663                "image_url": {"url": "https://<image url>"},1664            },1665        ],1666    )1667    actual_system, actual_messages = _format_messages([message], model=MODEL_NAME)1668    assert actual_system is None1669    expected_messages = [1670        {1671            "role": "user",1672            "content": [1673                {1674                    "type": "text",1675                    "text": (1676                        "Can you highlight the differences between these two images?"1677                    ),1678                },1679                {1680                    "type": "image",1681                    "source": {1682                        "type": "base64",1683                        "media_type": "image/jpeg",1684                        "data": "<base64 data>",1685                    },1686                },1687                {1688                    "type": "image",1689                    "source": {1690                        "type": "url",1691                        "url": "https://<image url>",1692                    },1693                },1694            ],1695        },1696    ]1697    assert actual_messages == expected_messages169816991700def test__format_messages_with_multiple_system() -> None:1701    """Test a trailing run of system messages is sent in place."""1702    messages = [1703        HumanMessage("baz"),1704        SystemMessage("bar"),1705        SystemMessage("baz"),1706        SystemMessage(1707            [1708                {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1709            ],1710        ),1711    ]1712    expected_messages = [1713        {"role": "user", "content": "baz"},1714        {1715            "role": "system",1716            "content": [1717                {"type": "text", "text": "bar"},1718                {"type": "text", "text": "baz"},1719                {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1720            ],1721        },1722    ]1723    actual_system, actual_messages = _format_messages(1724        messages,1725        model=MID_CONVERSATION_SYSTEM_MODEL,1726        mid_conversation_system_messages=True,1727    )1728    assert actual_system is None1729    assert expected_messages == actual_messages173017311732def test__format_messages_with_multiple_leading_system() -> None:1733    """A leading run of system messages is hoisted as one block array."""1734    messages = [1735        SystemMessage("bar"),1736        SystemMessage("baz"),1737        SystemMessage(1738            [1739                {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1740            ],1741        ),1742        HumanMessage("baz"),1743    ]1744    expected_system = [1745        {"type": "text", "text": "bar"},1746        {"type": "text", "text": "baz"},1747        {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1748    ]1749    expected_messages = [{"role": "user", "content": "baz"}]1750    actual_system, actual_messages = _format_messages(messages, model=MODEL_NAME)1751    assert expected_system == actual_system1752    assert expected_messages == actual_messages175317541755def test__format_messages_system_v1_content_blocks_drop_id() -> None:1756    """System text blocks from `create_text_block` must not leak the `id` field.17571758    See https://github.com/langchain-ai/langchain/issues/391001759    """1760    messages = [1761        SystemMessage(content_blocks=[create_text_block("You are helpful.")]),1762        HumanMessage("hi"),1763    ]1764    actual_system, actual_messages = _format_messages(messages, model=MODEL_NAME)1765    assert actual_system == [{"type": "text", "text": "You are helpful."}]1766    assert actual_messages == [{"role": "user", "content": "hi"}]176717681769def test__format_messages_system_text_block_preserves_supported_fields() -> None:1770    """Sanitizing system text blocks keeps Anthropic-supported fields."""1771    messages = [1772        SystemMessage(1773            [1774                {1775                    "type": "text",1776                    "text": "foo",1777                    "id": "lc_abc123",1778                    "cache_control": {"type": "ephemeral"},1779                },1780            ],1781        ),1782        HumanMessage("hi"),1783    ]1784    actual_system, _ = _format_messages(messages, model=MODEL_NAME)1785    assert actual_system == [1786        {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1787    ]178817891790_HOIST_WARNING = "A non-leading `SystemMessage` was moved"179117921793def test__format_messages_leading_system_string_content_unchanged() -> None:1794    """Test leading string system content stays a string."""1795    messages = [1796        SystemMessage("You are a code reviewer."),1797        HumanMessage("Review foo()"),1798    ]1799    actual_system, actual_messages = _format_messages(1800        messages,1801        model=MID_CONVERSATION_SYSTEM_MODEL,1802        mid_conversation_system_messages=True,1803    )1804    assert actual_system == "You are a code reviewer."1805    assert actual_messages == [{"role": "user", "content": "Review foo()"}]180618071808def test__format_messages_trailing_system_sent_in_place() -> None:1809    """A trailing system message on a supporting model is sent at its position."""1810    messages = [1811        SystemMessage("You are a code reviewer."),1812        HumanMessage("Review foo()"),1813        AIMessage("Looks fine."),1814        HumanMessage("Review bar()"),1815        SystemMessage("Every suggestion must include type annotations."),1816    ]1817    actual_system, actual_messages = _format_messages(1818        messages,1819        model=MID_CONVERSATION_SYSTEM_MODEL,1820        mid_conversation_system_messages=True,1821    )1822    assert actual_system == "You are a code reviewer."1823    assert actual_messages == [1824        {"role": "user", "content": "Review foo()"},1825        {"role": "assistant", "content": "Looks fine."},1826        {"role": "user", "content": "Review bar()"},1827        {1828            "role": "system",1829            "content": "Every suggestion must include type annotations.",1830        },1831    ]183218331834def test__format_messages_non_leading_system_only_run_sent_in_place() -> None:1835    """With nothing hoisted, a legal non-leading run still goes in place."""1836    messages = [1837        HumanMessage("Review foo()"),1838        SystemMessage("Be concise."),1839    ]1840    actual_system, actual_messages = _format_messages(1841        messages,1842        model=MID_CONVERSATION_SYSTEM_MODEL,1843        mid_conversation_system_messages=True,1844    )1845    assert actual_system is None1846    assert actual_messages == [1847        {"role": "user", "content": "Review foo()"},1848        {"role": "system", "content": "Be concise."},1849    ]185018511852def test__format_messages_system_between_user_and_ai_sent_in_place() -> None:1853    """A system message followed by an assistant turn is a legal position."""1854    messages = [1855        HumanMessage("Review foo()"),1856        SystemMessage("Be concise."),1857        AIMessage("Looks fine."),1858    ]1859    actual_system, actual_messages = _format_messages(1860        messages,1861        model=MID_CONVERSATION_SYSTEM_MODEL,1862        mid_conversation_system_messages=True,1863    )1864    assert actual_system is None1865    assert actual_messages == [1866        {"role": "user", "content": "Review foo()"},1867        {"role": "system", "content": "Be concise."},1868        {"role": "assistant", "content": "Looks fine."},1869    ]187018711872def test__format_messages_system_after_tool_message_sent_in_place() -> None:1873    """Test a system message follows the folded tool-result turn."""1874    ai = AIMessage(1875        "",1876        tool_calls=[{"name": "search", "args": {"q": "foo"}, "id": "toolu_1"}],1877    )1878    messages = [1879        HumanMessage("Review foo()"),1880        ai,1881        ToolMessage("results", tool_call_id="toolu_1"),1882        SystemMessage("Be concise."),1883    ]1884    actual_system, actual_messages = _format_messages(1885        messages,1886        model=MID_CONVERSATION_SYSTEM_MODEL,1887        mid_conversation_system_messages=True,1888    )1889    assert actual_system is None1890    assert [message["role"] for message in actual_messages] == [1891        "user",1892        "assistant",1893        "user",1894        "system",1895    ]1896    assert actual_messages[-1]["content"] == "Be concise."189718981899@pytest.mark.parametrize(1900    "block_type",1901    [1902        "web_search_tool_result",1903        "code_execution_tool_result",1904        "mcp_tool_result",1905        "future_server_tool_result",1906    ],1907)1908def test__format_messages_system_after_server_tool_result_sent_in_place(1909    block_type: str,1910) -> None:1911    """An assistant turn ending in a server tool result is a legal predecessor."""1912    block = {1913        "type": block_type,1914        "tool_use_id": "srvtoolu_1",1915        "content": [{"type": "text", "text": "results"}],1916    }1917    ai = AIMessage([block], response_metadata={"model_provider": "anthropic"})1918    messages = [1919        HumanMessage("Review foo()"),1920        ai,1921        SystemMessage("Be concise."),1922    ]1923    actual_system, actual_messages = _format_messages(1924        messages,1925        model=MID_CONVERSATION_SYSTEM_MODEL,1926        mid_conversation_system_messages=True,1927    )1928    assert actual_system is None1929    assert [message["role"] for message in actual_messages] == [1930        "user",1931        "assistant",1932        "system",1933    ]1934    # Forward compatibility: an unrecognized server tool result block reaches the1935    # wire untouched, which is what makes it a legal predecessor in the first place.1936    assert actual_messages[1]["content"] == [block]193719381939def test__format_messages_system_after_client_tool_result_hoisted() -> None:1940    """Test client-side tool results are not server tool results."""1941    messages = [1942        HumanMessage("Review foo()"),1943        AIMessage(1944            [{"type": "tool_result", "tool_use_id": "toolu_1", "content": "results"}],1945        ),1946        SystemMessage("Be concise."),1947    ]1948    with pytest.warns(UserWarning, match=_HOIST_WARNING):1949        actual_system, actual_messages = _format_messages(1950            messages,1951            model=MID_CONVERSATION_SYSTEM_MODEL,1952            mid_conversation_system_messages=True,1953        )1954    assert actual_system == "Be concise."1955    assert [message["role"] for message in actual_messages] == ["user", "assistant"]195619571958def test__format_messages_system_after_plain_ai_turn_hoisted() -> None:1959    """An assistant turn that is not a server tool result is an illegal predecessor."""1960    messages = [1961        HumanMessage("Review foo()"),1962        AIMessage("Looks fine."),1963        SystemMessage("Be concise."),1964    ]1965    with pytest.warns(UserWarning, match=_HOIST_WARNING):1966        actual_system, actual_messages = _format_messages(1967            messages,1968            model=MID_CONVERSATION_SYSTEM_MODEL,1969            mid_conversation_system_messages=True,1970        )1971    assert actual_system == "Be concise."1972    assert actual_messages == [1973        {"role": "user", "content": "Review foo()"},1974        {"role": "assistant", "content": "Looks fine."},1975    ]197619771978def test__format_messages_system_followed_by_user_turn_hoisted() -> None:1979    """A system message followed by a user turn is an illegal position."""1980    messages = [1981        HumanMessage("Review foo()"),1982        SystemMessage("Be concise."),1983        HumanMessage("Review bar()"),1984    ]1985    with pytest.warns(UserWarning, match=_HOIST_WARNING):1986        actual_system, actual_messages = _format_messages(1987            messages,1988            model=MID_CONVERSATION_SYSTEM_MODEL,1989            mid_conversation_system_messages=True,1990        )1991    assert actual_system == "Be concise."1992    assert actual_messages == [1993        {"role": "user", "content": "Review foo()"},1994        {"role": "user", "content": "Review bar()"},1995    ]199619971998def test__format_messages_leading_run_hoisted_and_later_run_in_place() -> None:1999    """A leading run is hoisted while a later legal run goes in place."""2000    messages = [

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.

Generated by searchcode.com · powered by code_get_file + code_get_findings