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