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.