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