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 (18 AIMessage,19 AIMessageChunk,20 HumanMessage,21 SystemMessage,22 ToolMessage,23)24from langchain_core.runnables import RunnableBinding25from langchain_core.tools import BaseTool, tool26from langchain_core.tracers.base import BaseTracer27from langchain_core.tracers.schemas import Run28from pydantic import BaseModel, Field, SecretStr, ValidationError29from pytest import CaptureFixture, MonkeyPatch3031from langchain_anthropic import ChatAnthropic32from langchain_anthropic._version import __version__33from langchain_anthropic.chat_models import (34 _TOOL_CALL_ID_PATTERN,35 _create_usage_metadata,36 _format_image,37 _format_messages,38 _is_builtin_tool,39 _merge_messages,40 _normalize_tool_call_id,41 _thinking_in_params,42 convert_to_anthropic_tool,43)4445os.environ["ANTHROPIC_API_KEY"] = "foo"4647MODEL_NAME = "claude-sonnet-4-5-20250929"484950def test_initialization() -> None:51 """Test chat model initialization."""52 with patch.dict(os.environ, {"ANTHROPIC_API_URL": "https://api.anthropic.com"}):53 for model in [54 ChatAnthropic(model_name=MODEL_NAME, api_key="xyz", timeout=2), # type: ignore[arg-type, call-arg]55 ChatAnthropic( # type: ignore[call-arg, call-arg, call-arg]56 model=MODEL_NAME,57 anthropic_api_key="xyz",58 default_request_timeout=2,59 base_url="https://api.anthropic.com",60 ),61 ]:62 assert model.model == MODEL_NAME63 assert (64 cast("SecretStr", model.anthropic_api_key).get_secret_value() == "xyz"65 )66 assert model.default_request_timeout == 2.067 assert model.anthropic_api_url == "https://api.anthropic.com"686970def test_user_agent_header_in_client_params() -> None:71 """Test that _client_params includes a User-Agent header."""72 llm = ChatAnthropic(model=MODEL_NAME, api_key="test-key") # type: ignore[arg-type]73 params = llm._client_params74 assert "default_headers" in params75 assert "User-Agent" in params["default_headers"]76 assert params["default_headers"]["User-Agent"].startswith("langchain-anthropic/")777879@pytest.mark.parametrize("async_api", [True, False])80def test_streaming_attribute_should_stream(async_api: bool) -> None: # noqa: FBT00181 llm = ChatAnthropic(model=MODEL_NAME, streaming=True)82 assert llm._should_stream(async_api=async_api)838485def test_anthropic_client_caching() -> None:86 """Test that the OpenAI client is cached."""87 llm1 = ChatAnthropic(model=MODEL_NAME)88 llm2 = ChatAnthropic(model=MODEL_NAME)89 assert llm1._client._client is llm2._client._client9091 llm3 = ChatAnthropic(model=MODEL_NAME, base_url="foo")92 assert llm1._client._client is not llm3._client._client9394 llm4 = ChatAnthropic(model=MODEL_NAME, timeout=None)95 assert llm1._client._client is llm4._client._client9697 llm5 = ChatAnthropic(model=MODEL_NAME, timeout=3)98 assert llm1._client._client is not llm5._client._client99100101def test_anthropic_proxy_support() -> None:102 """Test that both sync and async clients support proxy configuration."""103 proxy_url = "http://proxy.example.com:8080"104105 # Test sync client with proxy106 llm_sync = ChatAnthropic(model=MODEL_NAME, anthropic_proxy=proxy_url)107 sync_client = llm_sync._client108 assert sync_client is not None109110 # Test async client with proxy - this should not raise TypeError111 async_client = llm_sync._async_client112 assert async_client is not None113114 # Test that clients with different proxy settings are not cached together115 llm_no_proxy = ChatAnthropic(model=MODEL_NAME)116 llm_with_proxy = ChatAnthropic(model=MODEL_NAME, anthropic_proxy=proxy_url)117118 # Different proxy settings should result in different cached clients119 assert llm_no_proxy._client._client is not llm_with_proxy._client._client120121122def test_anthropic_proxy_from_environment() -> None:123 """Test that proxy can be set from ANTHROPIC_PROXY environment variable."""124 proxy_url = "http://env-proxy.example.com:8080"125126 # Test with environment variable set127 with patch.dict(os.environ, {"ANTHROPIC_PROXY": proxy_url}):128 llm = ChatAnthropic(model=MODEL_NAME)129 assert llm.anthropic_proxy == proxy_url130131 # Should be able to create clients successfully132 sync_client = llm._client133 async_client = llm._async_client134 assert sync_client is not None135 assert async_client is not None136137 # Test that explicit parameter overrides environment variable138 with patch.dict(os.environ, {"ANTHROPIC_PROXY": "http://env-proxy.com"}):139 explicit_proxy = "http://explicit-proxy.com"140 llm = ChatAnthropic(model=MODEL_NAME, anthropic_proxy=explicit_proxy)141 assert llm.anthropic_proxy == explicit_proxy142143144def test_set_default_max_tokens() -> None:145 """Test the set_default_max_tokens function."""146 # Test claude-sonnet-4-5 models147 llm = ChatAnthropic(model="claude-sonnet-4-5-20250929", anthropic_api_key="test")148 assert llm.max_tokens == 64000149150 # Test claude-opus-4-1 models151 llm = ChatAnthropic(model="claude-opus-4-1-20250805", anthropic_api_key="test")152 assert llm.max_tokens == 32000153154 # Test claude-haiku-4-5 models155 llm = ChatAnthropic(model="claude-haiku-4-5-20251001", anthropic_api_key="test")156 assert llm.max_tokens == 64000157158 # Test claude-3-5-haiku models (profile removed, should fall back to 4096)159 llm = ChatAnthropic(model="claude-3-5-haiku-20241022", anthropic_api_key="test")160 assert llm.max_tokens == 4096161162 # Test claude-3-haiku models (should default to 4096)163 llm = ChatAnthropic(model="claude-3-haiku-20240307", anthropic_api_key="test")164 assert llm.max_tokens == 4096165166 # Test that existing max_tokens values are preserved167 llm = ChatAnthropic(model=MODEL_NAME, max_tokens=2048, anthropic_api_key="test")168 assert llm.max_tokens == 2048169170 # Test that explicitly set max_tokens values are preserved171 llm = ChatAnthropic(model=MODEL_NAME, max_tokens=4096, anthropic_api_key="test")172 assert llm.max_tokens == 4096173174175@pytest.mark.requires("anthropic")176def test_anthropic_model_name_param() -> None:177 llm = ChatAnthropic(model_name=MODEL_NAME) # type: ignore[call-arg, call-arg]178 assert llm.model == MODEL_NAME179180181@pytest.mark.requires("anthropic")182def test_anthropic_model_param() -> None:183 llm = ChatAnthropic(model=MODEL_NAME) # type: ignore[call-arg]184 assert llm.model == MODEL_NAME185186187@pytest.mark.requires("anthropic")188def test_anthropic_model_kwargs() -> None:189 llm = ChatAnthropic(model_name=MODEL_NAME, model_kwargs={"foo": "bar"}) # type: ignore[call-arg, call-arg]190 assert llm.model_kwargs == {"foo": "bar"}191192193@pytest.mark.requires("anthropic")194def test_anthropic_fields_in_model_kwargs() -> None:195 """Test that for backwards compatibility fields can be passed in as model_kwargs."""196 with pytest.warns(197 UserWarning,198 match=(199 "Parameters {'max_tokens_to_sample'} should be specified explicitly. "200 "Instead they were passed in as part of `model_kwargs` parameter."201 ),202 ):203 llm = ChatAnthropic(model=MODEL_NAME, model_kwargs={"max_tokens_to_sample": 5}) # type: ignore[call-arg]204 assert llm.max_tokens == 5205 with pytest.warns(206 UserWarning,207 match=(208 "Parameters {'max_tokens'} should be specified explicitly. Instead they "209 "were passed in as part of `model_kwargs` parameter."210 ),211 ):212 llm = ChatAnthropic(model=MODEL_NAME, model_kwargs={"max_tokens": 5}) # type: ignore[call-arg]213 assert llm.max_tokens == 5214215216@pytest.mark.requires("anthropic")217def test_anthropic_incorrect_field() -> None:218 with pytest.warns(match="not default parameter"):219 llm = ChatAnthropic(model=MODEL_NAME, foo="bar") # type: ignore[call-arg, call-arg]220 assert llm.model_kwargs == {"foo": "bar"}221222223@pytest.mark.requires("anthropic")224def test_anthropic_initialization() -> None:225 """Test anthropic initialization."""226 # Verify that chat anthropic can be initialized using a secret key provided227 # as a parameter rather than an environment variable.228 ChatAnthropic(model=MODEL_NAME, anthropic_api_key="test") # type: ignore[call-arg, call-arg]229230231def test__format_output() -> None:232 anthropic_msg = Message(233 id="foo",234 content=[TextBlock(type="text", text="bar")],235 model="baz",236 role="assistant",237 stop_reason=None,238 stop_sequence=None,239 usage=Usage(input_tokens=2, output_tokens=1),240 type="message",241 )242 expected = AIMessage( # type: ignore[misc]243 "bar",244 usage_metadata={245 "input_tokens": 2,246 "output_tokens": 1,247 "total_tokens": 3,248 "input_token_details": {},249 },250 response_metadata={"model_provider": "anthropic"},251 )252 llm = ChatAnthropic(model=MODEL_NAME, anthropic_api_key="test") # type: ignore[call-arg, call-arg]253 actual = llm._format_output(anthropic_msg)254 assert actual.generations[0].message == expected255256257def test__format_output_cached() -> None:258 anthropic_msg = Message(259 id="foo",260 content=[TextBlock(type="text", text="bar")],261 model="baz",262 role="assistant",263 stop_reason=None,264 stop_sequence=None,265 usage=Usage(266 input_tokens=2,267 output_tokens=1,268 cache_creation_input_tokens=3,269 cache_read_input_tokens=4,270 ),271 type="message",272 )273 expected = AIMessage( # type: ignore[misc]274 "bar",275 usage_metadata={276 "input_tokens": 9,277 "output_tokens": 1,278 "total_tokens": 10,279 "input_token_details": {"cache_creation": 3, "cache_read": 4},280 },281 response_metadata={"model_provider": "anthropic"},282 )283284 llm = ChatAnthropic(model=MODEL_NAME, anthropic_api_key="test") # type: ignore[call-arg, call-arg]285 actual = llm._format_output(anthropic_msg)286 assert actual.generations[0].message == expected287288289def test__merge_messages() -> None:290 messages = [291 SystemMessage("foo"), # type: ignore[misc]292 HumanMessage("bar"), # type: ignore[misc]293 AIMessage( # type: ignore[misc]294 [295 {"text": "baz", "type": "text"},296 {297 "tool_input": {"a": "b"},298 "type": "tool_use",299 "id": "1",300 "text": None,301 "name": "buz",302 },303 {"text": "baz", "type": "text"},304 {305 "tool_input": {"a": "c"},306 "type": "tool_use",307 "id": "2",308 "text": None,309 "name": "blah",310 },311 {312 "tool_input": {"a": "c"},313 "type": "tool_use",314 "id": "3",315 "text": None,316 "name": "blah",317 },318 ],319 ),320 ToolMessage("buz output", tool_call_id="1", status="error"), # type: ignore[misc]321 ToolMessage(322 content=[323 {324 "type": "image",325 "source": {326 "type": "base64",327 "media_type": "image/jpeg",328 "data": "fake_image_data",329 },330 },331 ],332 tool_call_id="2",333 ), # type: ignore[misc]334 ToolMessage([], tool_call_id="3"), # type: ignore[misc]335 HumanMessage("next thing"), # type: ignore[misc]336 ]337 expected = [338 SystemMessage("foo"), # type: ignore[misc]339 HumanMessage("bar"), # type: ignore[misc]340 AIMessage( # type: ignore[misc]341 [342 {"text": "baz", "type": "text"},343 {344 "tool_input": {"a": "b"},345 "type": "tool_use",346 "id": "1",347 "text": None,348 "name": "buz",349 },350 {"text": "baz", "type": "text"},351 {352 "tool_input": {"a": "c"},353 "type": "tool_use",354 "id": "2",355 "text": None,356 "name": "blah",357 },358 {359 "tool_input": {"a": "c"},360 "type": "tool_use",361 "id": "3",362 "text": None,363 "name": "blah",364 },365 ],366 ),367 HumanMessage( # type: ignore[misc]368 [369 {370 "type": "tool_result",371 "content": "buz output",372 "tool_use_id": "1",373 "is_error": True,374 },375 {376 "type": "tool_result",377 "content": [378 {379 "type": "image",380 "source": {381 "type": "base64",382 "media_type": "image/jpeg",383 "data": "fake_image_data",384 },385 },386 ],387 "tool_use_id": "2",388 "is_error": False,389 },390 {391 "type": "tool_result",392 "content": [],393 "tool_use_id": "3",394 "is_error": False,395 },396 {"type": "text", "text": "next thing"},397 ],398 ),399 ]400 actual = _merge_messages(messages)401 assert expected == actual402403 # Test tool message case404 messages = [405 ToolMessage("buz output", tool_call_id="1"), # type: ignore[misc]406 ToolMessage( # type: ignore[misc]407 content=[408 {"type": "tool_result", "content": "blah output", "tool_use_id": "2"},409 ],410 tool_call_id="2",411 ),412 ]413 expected = [414 HumanMessage( # type: ignore[misc]415 [416 {417 "type": "tool_result",418 "content": "buz output",419 "tool_use_id": "1",420 "is_error": False,421 },422 {"type": "tool_result", "content": "blah output", "tool_use_id": "2"},423 ],424 ),425 ]426 actual = _merge_messages(messages)427 assert expected == actual428429430def test__merge_messages_mutation() -> None:431 original_messages = [432 HumanMessage([{"type": "text", "text": "bar"}]), # type: ignore[misc]433 HumanMessage("next thing"), # type: ignore[misc]434 ]435 messages = [436 HumanMessage([{"type": "text", "text": "bar"}]), # type: ignore[misc]437 HumanMessage("next thing"), # type: ignore[misc]438 ]439 expected = [440 HumanMessage( # type: ignore[misc]441 [{"type": "text", "text": "bar"}, {"type": "text", "text": "next thing"}],442 ),443 ]444 actual = _merge_messages(messages)445 assert expected == actual446 assert messages == original_messages447448449def test__merge_messages_tool_message_cache_control() -> None:450 """Test that cache_control is hoisted from content blocks to tool_result level."""451 # Test with cache_control in content block452 messages = [453 ToolMessage(454 content=[455 {456 "type": "text",457 "text": "tool output",458 "cache_control": {"type": "ephemeral"},459 }460 ],461 tool_call_id="1",462 )463 ]464 original_messages = [copy.deepcopy(m) for m in messages]465 expected = [466 HumanMessage(467 [468 {469 "type": "tool_result",470 "content": [{"type": "text", "text": "tool output"}],471 "tool_use_id": "1",472 "is_error": False,473 "cache_control": {"type": "ephemeral"},474 }475 ]476 )477 ]478 actual = _merge_messages(messages)479 assert expected == actual480 # Verify no mutation481 assert messages == original_messages482483 # Test with multiple content blocks, cache_control on last one484 messages = [485 ToolMessage(486 content=[487 {"type": "text", "text": "first output"},488 {489 "type": "text",490 "text": "second output",491 "cache_control": {"type": "ephemeral"},492 },493 ],494 tool_call_id="2",495 )496 ]497 expected = [498 HumanMessage(499 [500 {501 "type": "tool_result",502 "content": [503 {"type": "text", "text": "first output"},504 {"type": "text", "text": "second output"},505 ],506 "tool_use_id": "2",507 "is_error": False,508 "cache_control": {"type": "ephemeral"},509 }510 ]511 )512 ]513 actual = _merge_messages(messages)514 assert expected == actual515516 # Test without cache_control517 messages = [ToolMessage(content="simple output", tool_call_id="3")]518 expected = [519 HumanMessage(520 [521 {522 "type": "tool_result",523 "content": "simple output",524 "tool_use_id": "3",525 "is_error": False,526 }527 ]528 )529 ]530 actual = _merge_messages(messages)531 assert expected == actual532533534def test__format_image() -> None:535 url = "dummyimage.com/600x400/000/fff"536 with pytest.raises(ValueError):537 _format_image(url)538539540@pytest.fixture541def pydantic() -> type[BaseModel]:542 class dummy_function(BaseModel): # noqa: N801543 """Dummy function."""544545 arg1: int = Field(..., description="foo")546 arg2: Literal["bar", "baz"] = Field(..., description="one of 'bar', 'baz'")547548 return dummy_function549550551@pytest.fixture552def function() -> Callable:553 def dummy_function(arg1: int, arg2: Literal["bar", "baz"]) -> None:554 """Dummy function.555556 Args:557 arg1: foo558 arg2: one of 'bar', 'baz'559560 """561562 return dummy_function563564565@pytest.fixture566def dummy_tool() -> BaseTool:567 class Schema(BaseModel):568 arg1: int = Field(..., description="foo")569 arg2: Literal["bar", "baz"] = Field(..., description="one of 'bar', 'baz'")570571 class DummyFunction(BaseTool): # type: ignore[override]572 args_schema: type[BaseModel] = Schema573 name: str = "dummy_function"574 description: str = "Dummy function."575576 def _run(self, *args: Any, **kwargs: Any) -> Any:577 pass578579 return DummyFunction()580581582@pytest.fixture583def json_schema() -> dict:584 return {585 "title": "dummy_function",586 "description": "Dummy function.",587 "type": "object",588 "properties": {589 "arg1": {"description": "foo", "type": "integer"},590 "arg2": {591 "description": "one of 'bar', 'baz'",592 "enum": ["bar", "baz"],593 "type": "string",594 },595 },596 "required": ["arg1", "arg2"],597 }598599600@pytest.fixture601def openai_function() -> dict:602 return {603 "name": "dummy_function",604 "description": "Dummy function.",605 "parameters": {606 "type": "object",607 "properties": {608 "arg1": {"description": "foo", "type": "integer"},609 "arg2": {610 "description": "one of 'bar', 'baz'",611 "enum": ["bar", "baz"],612 "type": "string",613 },614 },615 "required": ["arg1", "arg2"],616 },617 }618619620def test_convert_to_anthropic_tool(621 pydantic: type[BaseModel],622 function: Callable,623 dummy_tool: BaseTool,624 json_schema: dict,625 openai_function: dict,626) -> None:627 expected = {628 "name": "dummy_function",629 "description": "Dummy function.",630 "input_schema": {631 "type": "object",632 "properties": {633 "arg1": {"description": "foo", "type": "integer"},634 "arg2": {635 "description": "one of 'bar', 'baz'",636 "enum": ["bar", "baz"],637 "type": "string",638 },639 },640 "required": ["arg1", "arg2"],641 },642 }643644 for fn in (pydantic, function, dummy_tool, json_schema, expected, openai_function):645 actual = convert_to_anthropic_tool(fn)646 assert actual == expected647648649def test__format_messages_with_tool_calls() -> None:650 system = SystemMessage("fuzz") # type: ignore[misc]651 human = HumanMessage("foo") # type: ignore[misc]652 ai = AIMessage(653 "", # with empty string654 tool_calls=[{"name": "bar", "id": "1", "args": {"baz": "buzz"}}],655 )656 ai2 = AIMessage(657 [], # with empty list658 tool_calls=[{"name": "bar", "id": "2", "args": {"baz": "buzz"}}],659 )660 tool = ToolMessage(661 "blurb",662 tool_call_id="1",663 )664 tool_image_url = ToolMessage(665 [{"type": "image_url", "image_url": {"url": "data:image/jpeg;base64,...."}}],666 tool_call_id="2",667 )668 tool_image = ToolMessage(669 [670 {671 "type": "image",672 "source": {673 "data": "....",674 "type": "base64",675 "media_type": "image/jpeg",676 },677 },678 ],679 tool_call_id="3",680 )681 messages = [system, human, ai, tool, ai2, tool_image_url, tool_image]682 expected = (683 "fuzz",684 [685 {"role": "user", "content": "foo"},686 {687 "role": "assistant",688 "content": [689 {690 "type": "tool_use",691 "name": "bar",692 "id": "1",693 "input": {"baz": "buzz"},694 },695 ],696 },697 {698 "role": "user",699 "content": [700 {701 "type": "tool_result",702 "content": "blurb",703 "tool_use_id": "1",704 "is_error": False,705 },706 ],707 },708 {709 "role": "assistant",710 "content": [711 {712 "type": "tool_use",713 "name": "bar",714 "id": "2",715 "input": {"baz": "buzz"},716 },717 ],718 },719 {720 "role": "user",721 "content": [722 {723 "type": "tool_result",724 "content": [725 {726 "type": "image",727 "source": {728 "data": "....",729 "type": "base64",730 "media_type": "image/jpeg",731 },732 },733 ],734 "tool_use_id": "2",735 "is_error": False,736 },737 {738 "type": "tool_result",739 "content": [740 {741 "type": "image",742 "source": {743 "data": "....",744 "type": "base64",745 "media_type": "image/jpeg",746 },747 },748 ],749 "tool_use_id": "3",750 "is_error": False,751 },752 ],753 },754 ],755 )756 actual = _format_messages(messages)757 assert expected == actual758759 # Check handling of empty AIMessage760 empty_contents: list[str | list[str | dict[str, Any]]] = ["", []]761 for empty_content in empty_contents:762 ## Permit message in final position763 _, anthropic_messages = _format_messages([human, AIMessage(empty_content)])764 expected_messages = [765 {"role": "user", "content": "foo"},766 {"role": "assistant", "content": empty_content},767 ]768 assert expected_messages == anthropic_messages769770 ## Remove message otherwise771 _, anthropic_messages = _format_messages(772 [human, AIMessage(empty_content), human]773 )774 expected_messages = [775 {"role": "user", "content": "foo"},776 {"role": "user", "content": "foo"},777 ]778 assert expected_messages == anthropic_messages779780 actual = _format_messages(781 [system, human, ai, tool, AIMessage(empty_content), human]782 )783 assert actual[0] == "fuzz"784 assert [message["role"] for message in actual[1]] == [785 "user",786 "assistant",787 "user",788 "user",789 ]790791792def test__normalize_tool_call_id() -> None:793 # Already-valid IDs (including native Anthropic and OpenAI styles) pass794 # through unchanged.795 for valid in ("1", "toolu_01abcDEF-_", "call_Ao02pnFYXD6GN1yzc0uXPsvF"):796 assert _normalize_tool_call_id(valid) == valid797798 # Empty and None IDs pass through so a malformed request surfaces a clear799 # error from Anthropic rather than a synthesized ID.800 assert _normalize_tool_call_id("") == ""801 assert _normalize_tool_call_id(None) is None802803 # Foreign IDs with characters Anthropic rejects (e.g. Fireworks/Kimi's804 # `functions.write_todos:0`) are rewritten to a compatible form.805 invalid = "functions.write_todos:0"806 normalized = _normalize_tool_call_id(invalid)807 assert normalized is not None808 assert normalized != invalid809 assert _TOOL_CALL_ID_PATTERN.match(normalized)810811 # Deterministic + idempotent: same input always maps to the same output.812 assert _normalize_tool_call_id(invalid) == normalized813 assert _normalize_tool_call_id(normalized) == normalized814815 # Distinct invalid IDs map to distinct replacements (no collision that816 # would break multi-tool turns).817 other = _normalize_tool_call_id("functions.read_file:1")818 assert other != normalized819820821def test__format_messages_normalizes_cross_provider_tool_call_ids() -> None:822 """A `tool_use.id` and its paired `tool_use_id` must normalize identically.823824 Reproduces the Fireworks/Kimi -> Anthropic 400 from replaying a thread whose825 tool-call IDs were minted by another provider.826 """827 bad_id = "functions.write_todos:0"828 ai = AIMessage(829 "",830 tool_calls=[{"name": "write_todos", "id": bad_id, "args": {"todos": []}}],831 )832 tool = ToolMessage("done", tool_call_id=bad_id)833834 _, formatted = _format_messages([HumanMessage("hi"), ai, tool])835836 tool_use = formatted[1]["content"][0]837 tool_result = formatted[2]["content"][0]838 assert tool_use["type"] == "tool_use"839 assert tool_result["type"] == "tool_result"840841 # The rewritten IDs are valid and still reference each other.842 assert _TOOL_CALL_ID_PATTERN.match(tool_use["id"])843 assert tool_use["id"] == tool_result["tool_use_id"]844 assert tool_use["id"] == _normalize_tool_call_id(bad_id)845846847def test__format_messages_normalizes_prestructured_tool_result_id() -> None:848 """A `ToolMessage` whose content is already `tool_result` blocks is covered.849850 This shape bypasses the `tool_call_id` normalization in `_merge_messages` and851 flows through the `tool_result` content branch, so its `tool_use_id` must852 still be normalized to match the paired `tool_use.id`.853 """854 bad_id = "functions.write_todos:0"855 ai = AIMessage(856 "",857 tool_calls=[{"name": "write_todos", "id": bad_id, "args": {"todos": []}}],858 )859 tool = ToolMessage(860 [{"type": "tool_result", "tool_use_id": bad_id, "content": "done"}],861 tool_call_id=bad_id,862 )863864 _, formatted = _format_messages([HumanMessage("hi"), ai, tool])865866 tool_use = formatted[1]["content"][0]867 tool_result = formatted[2]["content"][0]868 assert tool_use["id"] == tool_result["tool_use_id"]869 assert tool_use["id"] == _normalize_tool_call_id(bad_id)870871872def test__format_messages_normalizes_inline_tool_use_block() -> None:873 """An invalid ID on an inline `tool_use` content block is normalized.874875 Covers the v1-compat destination where tool calls are stored as content876 blocks rather than the `tool_calls` attribute, paired with a `ToolMessage`.877 """878 bad_id = "functions.search:2"879 ai = AIMessage(880 [{"type": "tool_use", "name": "search", "id": bad_id, "input": {"q": "x"}}],881 )882 tool = ToolMessage("result", tool_call_id=bad_id)883884 _, formatted = _format_messages([HumanMessage("hi"), ai, tool])885886 tool_use = formatted[1]["content"][0]887 tool_result = formatted[2]["content"][0]888 assert _TOOL_CALL_ID_PATTERN.match(tool_use["id"])889 assert tool_use["id"] == tool_result["tool_use_id"]890891892def test__format_messages_dedupes_overlapping_normalized_tool_use() -> None:893 """An invalid ID shared by a `tool_use` block and `tool_calls` yields one block.894895 Guards the dedup branch: `tool_use_ids` are normalized, so the comparison896 against the (also normalized) tool-call ID must not re-emit a duplicate block.897 """898 bad_id = "functions.write_todos:0"899 ai = AIMessage(900 [{"type": "tool_use", "name": "write_todos", "id": bad_id, "input": {"a": 1}}],901 tool_calls=[{"name": "write_todos", "id": bad_id, "args": {"a": 1}}],902 )903904 _, formatted = _format_messages([HumanMessage("hi"), ai])905906 tool_use_blocks = [b for b in formatted[1]["content"] if b["type"] == "tool_use"]907 assert len(tool_use_blocks) == 1908 assert _TOOL_CALL_ID_PATTERN.match(tool_use_blocks[0]["id"])909910911def test__format_messages_normalizes_distinct_ids_independently() -> None:912 """Multiple distinct invalid IDs in one turn stay distinct and correctly paired."""913 id_a = "functions.write_todos:0"914 id_b = "functions.read_file:1"915 ai = AIMessage(916 "",917 tool_calls=[918 {"name": "write_todos", "id": id_a, "args": {}},919 {"name": "read_file", "id": id_b, "args": {}},920 ],921 )922 tool_a = ToolMessage("a", tool_call_id=id_a)923 tool_b = ToolMessage("b", tool_call_id=id_b)924925 _, formatted = _format_messages([HumanMessage("hi"), ai, tool_a, tool_b])926927 tool_uses = formatted[1]["content"]928 results = formatted[2]["content"]929 assert tool_uses[0]["id"] == _normalize_tool_call_id(id_a)930 assert tool_uses[1]["id"] == _normalize_tool_call_id(id_b)931 assert tool_uses[0]["id"] != tool_uses[1]["id"]932 # Each result still pairs with its own tool_use.933 assert {r["tool_use_id"] for r in results} == {934 tool_uses[0]["id"],935 tool_uses[1]["id"],936 }937938939def test__format_tool_use_block() -> None:940 # Test we correctly format tool_use blocks when there is no corresponding tool_call.941 message = AIMessage(942 [943 {944 "type": "tool_use",945 "name": "foo_1",946 "id": "1",947 "input": {"bar_1": "baz_1"},948 },949 {950 "type": "tool_use",951 "name": "foo_2",952 "id": "2",953 "input": {},954 "partial_json": '{"bar_2": "baz_2"}',955 "index": 1,956 },957 ]958 )959 result = _format_messages([message])960 expected = {961 "role": "assistant",962 "content": [963 {964 "type": "tool_use",965 "name": "foo_1",966 "id": "1",967 "input": {"bar_1": "baz_1"},968 },969 {970 "type": "tool_use",971 "name": "foo_2",972 "id": "2",973 "input": {"bar_2": "baz_2"},974 },975 ],976 }977 assert result == (None, [expected])978979980def test__format_messages_with_str_content_and_tool_calls() -> None:981 system = SystemMessage("fuzz") # type: ignore[misc]982 human = HumanMessage("foo") # type: ignore[misc]983 # If content and tool_calls are specified and content is a string, then both are984 # included with content first.985 ai = AIMessage( # type: ignore[misc]986 "thought",987 tool_calls=[{"name": "bar", "id": "1", "args": {"baz": "buzz"}}],988 )989 tool = ToolMessage("blurb", tool_call_id="1") # type: ignore[misc]990 messages = [system, human, ai, tool]991 expected = (992 "fuzz",993 [994 {"role": "user", "content": "foo"},995 {996 "role": "assistant",997 "content": [998 {"type": "text", "text": "thought"},999 {1000 "type": "tool_use",1001 "name": "bar",1002 "id": "1",1003 "input": {"baz": "buzz"},1004 },1005 ],1006 },1007 {1008 "role": "user",1009 "content": [1010 {1011 "type": "tool_result",1012 "content": "blurb",1013 "tool_use_id": "1",1014 "is_error": False,1015 },1016 ],1017 },1018 ],1019 )1020 actual = _format_messages(messages)1021 assert expected == actual102210231024def test__format_messages_with_list_content_and_tool_calls() -> None:1025 system = SystemMessage("fuzz") # type: ignore[misc]1026 human = HumanMessage("foo") # type: ignore[misc]1027 ai = AIMessage( # type: ignore[misc]1028 [{"type": "text", "text": "thought"}],1029 tool_calls=[{"name": "bar", "id": "1", "args": {"baz": "buzz"}}],1030 )1031 tool = ToolMessage( # type: ignore[misc]1032 "blurb",1033 tool_call_id="1",1034 )1035 messages = [system, human, ai, tool]1036 expected = (1037 "fuzz",1038 [1039 {"role": "user", "content": "foo"},1040 {1041 "role": "assistant",1042 "content": [1043 {"type": "text", "text": "thought"},1044 {1045 "type": "tool_use",1046 "name": "bar",1047 "id": "1",1048 "input": {"baz": "buzz"},1049 },1050 ],1051 },1052 {1053 "role": "user",1054 "content": [1055 {1056 "type": "tool_result",1057 "content": "blurb",1058 "tool_use_id": "1",1059 "is_error": False,1060 },1061 ],1062 },1063 ],1064 )1065 actual = _format_messages(messages)1066 assert expected == actual106710681069def test__format_messages_with_tool_use_blocks_and_tool_calls() -> None:1070 """Show that tool_calls are preferred to tool_use blocks when both have same id."""1071 system = SystemMessage("fuzz") # type: ignore[misc]1072 human = HumanMessage("foo") # type: ignore[misc]1073 # NOTE: tool_use block in contents and tool_calls have different arguments.1074 ai = AIMessage( # type: ignore[misc]1075 [1076 {"type": "text", "text": "thought"},1077 {1078 "type": "tool_use",1079 "name": "bar",1080 "id": "1",1081 "input": {"baz": "NOT_BUZZ"},1082 },1083 ],1084 tool_calls=[{"name": "bar", "id": "1", "args": {"baz": "BUZZ"}}],1085 )1086 tool = ToolMessage("blurb", tool_call_id="1") # type: ignore[misc]1087 messages = [system, human, ai, tool]1088 expected = (1089 "fuzz",1090 [1091 {"role": "user", "content": "foo"},1092 {1093 "role": "assistant",1094 "content": [1095 {"type": "text", "text": "thought"},1096 {1097 "type": "tool_use",1098 "name": "bar",1099 "id": "1",1100 "input": {"baz": "BUZZ"}, # tool_calls value preferred.1101 },1102 ],1103 },1104 {1105 "role": "user",1106 "content": [1107 {1108 "type": "tool_result",1109 "content": "blurb",1110 "tool_use_id": "1",1111 "is_error": False,1112 },1113 ],1114 },1115 ],1116 )1117 actual = _format_messages(messages)1118 assert expected == actual111911201121def test__format_messages_with_cache_control() -> None:1122 messages = [1123 SystemMessage(1124 [1125 {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1126 ],1127 ),1128 HumanMessage(1129 [1130 {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1131 {1132 "type": "text",1133 "text": "foo",1134 },1135 ],1136 ),1137 ]1138 expected_system = [1139 {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1140 ]1141 expected_messages = [1142 {1143 "role": "user",1144 "content": [1145 {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1146 {"type": "text", "text": "foo"},1147 ],1148 },1149 ]1150 actual_system, actual_messages = _format_messages(messages)1151 assert expected_system == actual_system1152 assert expected_messages == actual_messages11531154 # Test standard multi-modal format (v0)1155 messages = [1156 HumanMessage(1157 [1158 {1159 "type": "text",1160 "text": "Summarize this document:",1161 },1162 {1163 "type": "file",1164 "source_type": "base64",1165 "mime_type": "application/pdf",1166 "data": "<base64 data>",1167 "cache_control": {"type": "ephemeral"},1168 },1169 ],1170 ),1171 ]1172 actual_system, actual_messages = _format_messages(messages)1173 assert actual_system is None1174 expected_messages = [1175 {1176 "role": "user",1177 "content": [1178 {1179 "type": "text",1180 "text": "Summarize this document:",1181 },1182 {1183 "type": "document",1184 "source": {1185 "type": "base64",1186 "media_type": "application/pdf",1187 "data": "<base64 data>",1188 },1189 "cache_control": {"type": "ephemeral"},1190 },1191 ],1192 },1193 ]1194 assert actual_messages == expected_messages11951196 # Test standard multi-modal format (v1)1197 messages = [1198 HumanMessage(1199 [1200 {1201 "type": "text",1202 "text": "Summarize this document:",1203 },1204 {1205 "type": "file",1206 "mime_type": "application/pdf",1207 "base64": "<base64 data>",1208 "extras": {"cache_control": {"type": "ephemeral"}},1209 },1210 ],1211 ),1212 ]1213 actual_system, actual_messages = _format_messages(messages)1214 assert actual_system is None1215 expected_messages = [1216 {1217 "role": "user",1218 "content": [1219 {1220 "type": "text",1221 "text": "Summarize this document:",1222 },1223 {1224 "type": "document",1225 "source": {1226 "type": "base64",1227 "media_type": "application/pdf",1228 "data": "<base64 data>",1229 },1230 "cache_control": {"type": "ephemeral"},1231 },1232 ],1233 },1234 ]1235 assert actual_messages == expected_messages12361237 # Test standard multi-modal format (v1, unpacked extras)1238 messages = [1239 HumanMessage(1240 [1241 {1242 "type": "text",1243 "text": "Summarize this document:",1244 },1245 {1246 "type": "file",1247 "mime_type": "application/pdf",1248 "base64": "<base64 data>",1249 "cache_control": {"type": "ephemeral"},1250 },1251 ],1252 ),1253 ]1254 actual_system, actual_messages = _format_messages(messages)1255 assert actual_system is None1256 expected_messages = [1257 {1258 "role": "user",1259 "content": [1260 {1261 "type": "text",1262 "text": "Summarize this document:",1263 },1264 {1265 "type": "document",1266 "source": {1267 "type": "base64",1268 "media_type": "application/pdf",1269 "data": "<base64 data>",1270 },1271 "cache_control": {"type": "ephemeral"},1272 },1273 ],1274 },1275 ]1276 assert actual_messages == expected_messages12771278 # Also test file inputs1279 ## Images1280 for block in [1281 # v11282 {1283 "type": "image",1284 "file_id": "abc123",1285 },1286 # v01287 {1288 "type": "image",1289 "source_type": "id",1290 "id": "abc123",1291 },1292 ]:1293 messages = [1294 HumanMessage(1295 [1296 {1297 "type": "text",1298 "text": "Summarize this image:",1299 },1300 block,1301 ],1302 ),1303 ]1304 actual_system, actual_messages = _format_messages(messages)1305 assert actual_system is None1306 expected_messages = [1307 {1308 "role": "user",1309 "content": [1310 {1311 "type": "text",1312 "text": "Summarize this image:",1313 },1314 {1315 "type": "image",1316 "source": {1317 "type": "file",1318 "file_id": "abc123",1319 },1320 },1321 ],1322 },1323 ]1324 assert actual_messages == expected_messages13251326 ## Documents1327 for block in [1328 # v11329 {1330 "type": "file",1331 "file_id": "abc123",1332 },1333 # v01334 {1335 "type": "file",1336 "source_type": "id",1337 "id": "abc123",1338 },1339 ]:1340 messages = [1341 HumanMessage(1342 [1343 {1344 "type": "text",1345 "text": "Summarize this document:",1346 },1347 block,1348 ],1349 ),1350 ]1351 actual_system, actual_messages = _format_messages(messages)1352 assert actual_system is None1353 expected_messages = [1354 {1355 "role": "user",1356 "content": [1357 {1358 "type": "text",1359 "text": "Summarize this document:",1360 },1361 {1362 "type": "document",1363 "source": {1364 "type": "file",1365 "file_id": "abc123",1366 },1367 },1368 ],1369 },1370 ]1371 assert actual_messages == expected_messages137213731374def test__format_messages_with_citations() -> None:1375 input_messages = [1376 HumanMessage(1377 content=[1378 {1379 "type": "file",1380 "source_type": "text",1381 "text": "The grass is green. The sky is blue.",1382 "mime_type": "text/plain",1383 "citations": {"enabled": True},1384 },1385 {"type": "text", "text": "What color is the grass and sky?"},1386 ],1387 ),1388 ]1389 expected_messages = [1390 {1391 "role": "user",1392 "content": [1393 {1394 "type": "document",1395 "source": {1396 "type": "text",1397 "media_type": "text/plain",1398 "data": "The grass is green. The sky is blue.",1399 },1400 "citations": {"enabled": True},1401 },1402 {"type": "text", "text": "What color is the grass and sky?"},1403 ],1404 },1405 ]1406 actual_system, actual_messages = _format_messages(input_messages)1407 assert actual_system is None1408 assert actual_messages == expected_messages140914101411def test__format_messages_openai_image_format() -> None:1412 message = HumanMessage(1413 content=[1414 {1415 "type": "text",1416 "text": "Can you highlight the differences between these two images?",1417 },1418 {1419 "type": "image_url",1420 "image_url": {"url": "data:image/jpeg;base64,<base64 data>"},1421 },1422 {1423 "type": "image_url",1424 "image_url": {"url": "https://<image url>"},1425 },1426 ],1427 )1428 actual_system, actual_messages = _format_messages([message])1429 assert actual_system is None1430 expected_messages = [1431 {1432 "role": "user",1433 "content": [1434 {1435 "type": "text",1436 "text": (1437 "Can you highlight the differences between these two images?"1438 ),1439 },1440 {1441 "type": "image",1442 "source": {1443 "type": "base64",1444 "media_type": "image/jpeg",1445 "data": "<base64 data>",1446 },1447 },1448 {1449 "type": "image",1450 "source": {1451 "type": "url",1452 "url": "https://<image url>",1453 },1454 },1455 ],1456 },1457 ]1458 assert actual_messages == expected_messages145914601461def test__format_messages_with_multiple_system() -> None:1462 messages = [1463 HumanMessage("baz"),1464 SystemMessage("bar"),1465 SystemMessage("baz"),1466 SystemMessage(1467 [1468 {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1469 ],1470 ),1471 ]1472 expected_system = [1473 {"type": "text", "text": "bar"},1474 {"type": "text", "text": "baz"},1475 {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}},1476 ]1477 expected_messages = [{"role": "user", "content": "baz"}]1478 actual_system, actual_messages = _format_messages(messages)1479 assert expected_system == actual_system1480 assert expected_messages == actual_messages148114821483def test_anthropic_api_key_is_secret_string() -> None:1484 """Test that the API key is stored as a SecretStr."""1485 chat_model = ChatAnthropic( # type: ignore[call-arg, call-arg]1486 model=MODEL_NAME,1487 anthropic_api_key="secret-api-key",1488 )1489 assert isinstance(chat_model.anthropic_api_key, SecretStr)149014911492def test_anthropic_api_key_masked_when_passed_from_env(1493 monkeypatch: MonkeyPatch,1494 capsys: CaptureFixture,1495) -> None:1496 """Test that the API key is masked when passed from an environment variable."""1497 monkeypatch.setenv("ANTHROPIC_API_KEY ", "secret-api-key")1498 chat_model = ChatAnthropic( # type: ignore[call-arg]1499 model=MODEL_NAME,1500 )1501 print(chat_model.anthropic_api_key, end="") # noqa: T2011502 captured = capsys.readouterr()15031504 assert captured.out == "**********"150515061507def test_anthropic_api_key_masked_when_passed_via_constructor(1508 capsys: CaptureFixture,1509) -> None:1510 """Test that the API key is masked when passed via the constructor."""1511 chat_model = ChatAnthropic( # type: ignore[call-arg, call-arg]1512 model=MODEL_NAME,1513 anthropic_api_key="secret-api-key",1514 )1515 print(chat_model.anthropic_api_key, end="") # noqa: T2011516 captured = capsys.readouterr()15171518 assert captured.out == "**********"151915201521def test_anthropic_uses_actual_secret_value_from_secretstr() -> None:1522 """Test that the actual secret value is correctly retrieved."""1523 chat_model = ChatAnthropic( # type: ignore[call-arg, call-arg]1524 model=MODEL_NAME,1525 anthropic_api_key="secret-api-key",1526 )1527 assert (1528 cast("SecretStr", chat_model.anthropic_api_key).get_secret_value()1529 == "secret-api-key"1530 )153115321533class GetWeather(BaseModel):1534 """Get the current weather in a given location."""15351536 location: str = Field(..., description="The city and state, e.g. San Francisco, CA")153715381539def test_anthropic_bind_tools_tool_choice() -> None:1540 chat_model = ChatAnthropic( # type: ignore[call-arg, call-arg]1541 model=MODEL_NAME,1542 anthropic_api_key="secret-api-key",1543 )1544 chat_model_with_tools = chat_model.bind_tools(1545 [GetWeather],1546 tool_choice={"type": "tool", "name": "GetWeather"},1547 )1548 assert cast("RunnableBinding", chat_model_with_tools).kwargs["tool_choice"] == {1549 "type": "tool",1550 "name": "GetWeather",1551 }1552 chat_model_with_tools = chat_model.bind_tools(1553 [GetWeather],1554 tool_choice="GetWeather",1555 )1556 assert cast("RunnableBinding", chat_model_with_tools).kwargs["tool_choice"] == {1557 "type": "tool",1558 "name": "GetWeather",1559 }1560 chat_model_with_tools = chat_model.bind_tools([GetWeather], tool_choice="auto")1561 assert cast("RunnableBinding", chat_model_with_tools).kwargs["tool_choice"] == {1562 "type": "auto",1563 }1564 chat_model_with_tools = chat_model.bind_tools([GetWeather], tool_choice="any")1565 assert cast("RunnableBinding", chat_model_with_tools).kwargs["tool_choice"] == {1566 "type": "any",1567 }156815691570def test_fine_grained_tool_streaming_beta() -> None:1571 """Test that fine-grained tool streaming beta can be enabled."""1572 # Test with betas parameter at initialization1573 model = ChatAnthropic(1574 model=MODEL_NAME, betas=["fine-grained-tool-streaming-2025-05-14"]1575 )15761577 # Create a simple tool1578 def get_weather(city: str) -> str:1579 """Get the weather for a city."""1580 return f"Weather in {city}"15811582 model_with_tools = model.bind_tools([get_weather])1583 payload = model_with_tools._get_request_payload( # type: ignore[attr-defined]1584 "What's the weather in SF?",1585 stream=True,1586 **model_with_tools.kwargs, # type: ignore[attr-defined]1587 )15881589 # Verify beta header is in payload1590 assert "fine-grained-tool-streaming-2025-05-14" in payload["betas"]1591 assert payload["stream"] is True15921593 # Test combining with other betas1594 model = ChatAnthropic(1595 model=MODEL_NAME,1596 betas=["context-1m-2025-08-07", "fine-grained-tool-streaming-2025-05-14"],1597 )1598 model_with_tools = model.bind_tools([get_weather])1599 payload = model_with_tools._get_request_payload( # type: ignore[attr-defined]1600 "What's the weather?",1601 stream=True,1602 **model_with_tools.kwargs, # type: ignore[attr-defined]1603 )1604 assert set(payload["betas"]) == {1605 "context-1m-2025-08-07",1606 "fine-grained-tool-streaming-2025-05-14",1607 }16081609 # Test that _create routes to beta client when betas are present1610 model = ChatAnthropic(1611 model=MODEL_NAME, betas=["fine-grained-tool-streaming-2025-05-14"]1612 )1613 payload = {"betas": ["fine-grained-tool-streaming-2025-05-14"], "stream": True}16141615 with patch.object(model._client.beta.messages, "create") as mock_beta_create:1616 model._create(payload)1617 mock_beta_create.assert_called_once_with(**payload)161816191620def test_optional_description() -> None:1621 llm = ChatAnthropic(model=MODEL_NAME)16221623 class SampleModel(BaseModel):1624 sample_field: str16251626 _ = llm.with_structured_output(SampleModel.model_json_schema())162716281629def test_get_num_tokens_from_messages_passes_kwargs() -> None:1630 """Test that get_num_tokens_from_messages passes kwargs to the model."""1631 llm = ChatAnthropic(model=MODEL_NAME)16321633 with patch.object(anthropic, "Client") as _client:1634 llm.get_num_tokens_from_messages([HumanMessage("foo")], foo="bar")16351636 assert _client.return_value.messages.count_tokens.call_args.kwargs["foo"] == "bar"16371638 llm = ChatAnthropic(1639 model=MODEL_NAME,1640 betas=["context-management-2025-06-27"],1641 context_management={"edits": [{"type": "clear_tool_uses_20250919"}]},1642 )1643 with patch.object(anthropic, "Client") as _client:1644 llm.get_num_tokens_from_messages([HumanMessage("foo")])16451646 call_args = _client.return_value.beta.messages.count_tokens.call_args.kwargs1647 assert call_args["betas"] == ["context-management-2025-06-27"]1648 assert call_args["context_management"] == {1649 "edits": [{"type": "clear_tool_uses_20250919"}]1650 }165116521653def test_usage_metadata_standardization() -> None:1654 class UsageModel(BaseModel):1655 input_tokens: int = 101656 output_tokens: int = 51657 cache_read_input_tokens: int = 31658 cache_creation_input_tokens: int = 216591660 # Happy path1661 usage = UsageModel()1662 result = _create_usage_metadata(usage)1663 assert result["input_tokens"] == 15 # 10 + 3 + 21664 assert result["output_tokens"] == 51665 assert result["total_tokens"] == 201666 assert result.get("input_token_details") == {"cache_read": 3, "cache_creation": 2}16671668 # Null input and output tokens1669 class UsageModelNulls(BaseModel):1670 input_tokens: int | None = None1671 output_tokens: int | None = None1672 cache_read_input_tokens: int | None = None1673 cache_creation_input_tokens: int | None = None16741675 usage_nulls = UsageModelNulls()1676 result = _create_usage_metadata(usage_nulls)1677 assert result["input_tokens"] == 01678 assert result["output_tokens"] == 01679 assert result["total_tokens"] == 016801681 # Test missing fields1682 class UsageModelMissing(BaseModel):1683 pass16841685 usage_missing = UsageModelMissing()1686 result = _create_usage_metadata(usage_missing)1687 assert result["input_tokens"] == 01688 assert result["output_tokens"] == 01689 assert result["total_tokens"] == 0169016911692def test_usage_metadata_cache_creation_ttl() -> None:1693 """Test _create_usage_metadata with granular cache_creation TTL fields."""16941695 # Case 1: cache_creation with specific ephemeral TTL tokens (BaseModel)1696 class CacheCreation(BaseModel):1697 ephemeral_5m_input_tokens: int = 1001698 ephemeral_1h_input_tokens: int = 5016991700 class UsageWithCacheCreation(BaseModel):1701 input_tokens: int = 2001702 output_tokens: int = 301703 cache_read_input_tokens: int = 101704 cache_creation_input_tokens: int = 1501705 cache_creation: CacheCreation = CacheCreation()17061707 result = _create_usage_metadata(UsageWithCacheCreation())1708 # input_tokens = 200 (base) + 10 (cache_read) + 150 (specific: 100+50)1709 assert result["input_tokens"] == 3601710 assert result["output_tokens"] == 301711 assert result["total_tokens"] == 3901712 details = dict(result.get("input_token_details") or {})1713 assert details["cache_read"] == 101714 # cache_creation should be suppressed to avoid double counting1715 assert details["cache_creation"] == 01716 assert details["ephemeral_5m_input_tokens"] == 1001717 assert details["ephemeral_1h_input_tokens"] == 5017181719 # Case 2: cache_creation as a dict1720 class UsageWithCacheCreationDict(BaseModel):1721 input_tokens: int = 2001722 output_tokens: int = 301723 cache_read_input_tokens: int = 101724 cache_creation_input_tokens: int = 1501725 cache_creation: dict = {1726 "ephemeral_5m_input_tokens": 80,1727 "ephemeral_1h_input_tokens": 70,1728 }17291730 result = _create_usage_metadata(UsageWithCacheCreationDict())1731 assert result["input_tokens"] == 200 + 10 + 80 + 701732 details = dict(result.get("input_token_details") or {})1733 assert details["cache_creation"] == 01734 assert details["ephemeral_5m_input_tokens"] == 801735 assert details["ephemeral_1h_input_tokens"] == 7017361737 # Case 3: cache_creation exists but specific keys are zero — falls back to1738 # generic cache_creation_input_tokens1739 class CacheCreationZero(BaseModel):1740 ephemeral_5m_input_tokens: int = 01741 ephemeral_1h_input_tokens: int = 017421743 class UsageWithCacheCreationZero(BaseModel):1744 input_tokens: int = 2001745 output_tokens: int = 301746 cache_read_input_tokens: int = 101747 cache_creation_input_tokens: int = 501748 cache_creation: CacheCreationZero = CacheCreationZero()17491750 result = _create_usage_metadata(UsageWithCacheCreationZero())1751 # specific_cache_creation_tokens = 0, so falls back to cache_creation_input_tokens1752 # input_tokens = 200 + 10 + 50 = 2601753 assert result["input_tokens"] == 2601754 assert result["output_tokens"] == 301755 assert result["total_tokens"] == 2901756 details = dict(result.get("input_token_details") or {})1757 assert details["cache_read"] == 101758 assert details["cache_creation"] == 5017591760 # Case 4: cache_creation exists but specific keys are missing from the dict1761 class CacheCreationEmpty(BaseModel):1762 pass17631764 class UsageWithCacheCreationEmpty(BaseModel):1765 input_tokens: int = 1001766 output_tokens: int = 201767 cache_read_input_tokens: int = 51768 cache_creation_input_tokens: int = 151769 cache_creation: CacheCreationEmpty = CacheCreationEmpty()17701771 result = _create_usage_metadata(UsageWithCacheCreationEmpty())1772 # specific_cache_creation_tokens = 0, falls back to cache_creation_input_tokens1773 assert result["input_tokens"] == 100 + 5 + 151774 assert result["output_tokens"] == 201775 assert result["total_tokens"] == 1401776 details = dict(result.get("input_token_details") or {})1777 assert details["cache_creation"] == 1517781779 # Case 5: only one ephemeral key is non-zero1780 class CacheCreationPartial(BaseModel):1781 ephemeral_5m_input_tokens: int = 01782 ephemeral_1h_input_tokens: int = 7517831784 class UsageWithPartialCache(BaseModel):1785 input_tokens: int = 1001786 output_tokens: int = 101787 cache_read_input_tokens: int = 01788 cache_creation_input_tokens: int = 751789 cache_creation: CacheCreationPartial = CacheCreationPartial()17901791 result = _create_usage_metadata(UsageWithPartialCache())1792 # specific_cache_creation_tokens = 75 > 0, so generic cache_creation is suppressed1793 assert result["input_tokens"] == 100 + 0 + 751794 assert result["output_tokens"] == 101795 assert result["total_tokens"] == 1851796 details = dict(result.get("input_token_details") or {})1797 assert details["cache_creation"] == 01798 assert details["ephemeral_1h_input_tokens"] == 751799 # ephemeral_5m_input_tokens is 0 — still included since 0 is not None1800 assert details["ephemeral_5m_input_tokens"] == 018011802 # Case 6: no cache_creation field at all (the pre-existing path)1803 class UsageNoCacheCreation(BaseModel):1804 input_tokens: int = 501805 output_tokens: int = 251806 cache_read_input_tokens: int = 51807 cache_creation_input_tokens: int = 1018081809 result = _create_usage_metadata(UsageNoCacheCreation())1810 assert result["input_tokens"] == 50 + 5 + 101811 assert result["output_tokens"] == 251812 assert result["total_tokens"] == 901813 details = dict(result.get("input_token_details") or {})1814 assert details["cache_read"] == 51815 assert details["cache_creation"] == 10181618171818class FakeTracer(BaseTracer):1819 """Fake tracer to capture inputs to `chat_model_start`."""18201821 def __init__(self) -> None:1822 super().__init__()1823 self.chat_model_start_inputs: list = []18241825 def _persist_run(self, run: Run) -> None:1826 """Persist a run."""18271828 def on_chat_model_start(self, *args: Any, **kwargs: Any) -> Run:1829 self.chat_model_start_inputs.append({"args": args, "kwargs": kwargs})1830 return super().on_chat_model_start(*args, **kwargs)183118321833def test_mcp_tracing() -> None:1834 # Test we exclude sensitive information from traces1835 mcp_servers = [1836 {1837 "type": "url",1838 "url": "https://mcp.deepwiki.com/mcp",1839 "name": "deepwiki",1840 "authorization_token": "PLACEHOLDER",1841 },1842 ]18431844 llm = ChatAnthropic(1845 model=MODEL_NAME,1846 betas=["mcp-client-2025-04-04"],1847 mcp_servers=mcp_servers,1848 )18491850 tracer = FakeTracer()1851 mock_client = MagicMock()18521853 def mock_create(*args: Any, **kwargs: Any) -> Message:1854 return Message(1855 id="foo",1856 content=[TextBlock(type="text", text="bar")],1857 model="baz",1858 role="assistant",1859 stop_reason=None,1860 stop_sequence=None,1861 usage=Usage(input_tokens=2, output_tokens=1),1862 type="message",1863 )18641865 mock_client.messages.create = mock_create1866 input_message = HumanMessage("Test query")1867 with patch.object(llm, "_client", mock_client):1868 _ = llm.invoke([input_message], config={"callbacks": [tracer]})18691870 # Test headers are not traced1871 assert len(tracer.chat_model_start_inputs) == 11872 assert "PLACEHOLDER" not in str(tracer.chat_model_start_inputs)18731874 # Test headers are correctly propagated to request1875 payload = llm._get_request_payload([input_message])1876 assert payload["mcp_servers"][0]["authorization_token"] == "PLACEHOLDER" # noqa: S105187718781879def test_cache_control_kwarg() -> None:1880 llm = ChatAnthropic(model=MODEL_NAME)18811882 messages = [HumanMessage("foo"), AIMessage("bar"), HumanMessage("baz")]1883 payload = llm._get_request_payload(messages)1884 assert "cache_control" not in payload18851886 payload = llm._get_request_payload(messages, cache_control={"type": "ephemeral"})1887 assert payload["cache_control"] == {"type": "ephemeral"}1888 assert payload["messages"] == [1889 {"role": "user", "content": "foo"},1890 {"role": "assistant", "content": "bar"},1891 {"role": "user", "content": "baz"},1892 ]189318941895class _BedrockLikeAnthropic(ChatAnthropic):1896 """Stand-in for `ChatAnthropicBedrock` for `_llm_type`-based gating tests.18971898 Vertex is not modeled here: `langchain-google-vertexai`'s1899 `ChatAnthropicVertex` does not subclass `ChatAnthropic` and ships its own1900 `_get_request_payload`, so it never reaches the gate under test.1901 """19021903 @property1904 def _llm_type(self) -> str:1905 return "anthropic-bedrock-chat"190619071908def test_cache_control_kwarg_bedrock_injects_into_blocks() -> None:1909 """Non-direct subclasses must place `cache_control` inside the last block.19101911 Transports like Bedrock reject the top-level `cache_control` field, so1912 the kwarg has to be expanded into a nested breakpoint to remain effective.1913 """1914 llm = _BedrockLikeAnthropic(model=MODEL_NAME)19151916 messages = [HumanMessage("foo"), AIMessage("bar"), HumanMessage("baz")]1917 payload = llm._get_request_payload(messages, cache_control={"type": "ephemeral"})19181919 assert "cache_control" not in payload1920 last_message = payload["messages"][-1]1921 assert last_message["content"] == [1922 {"type": "text", "text": "baz", "cache_control": {"type": "ephemeral"}}1923 ]192419251926def test_cache_control_kwarg_bedrock_with_list_content() -> None:1927 """`cache_control` lands on the last block when content is already a list."""1928 llm = _BedrockLikeAnthropic(model=MODEL_NAME)19291930 messages = [HumanMessage([{"type": "text", "text": "foo"}])]1931 payload = llm._get_request_payload(1932 messages, cache_control={"type": "ephemeral", "ttl": "1h"}1933 )19341935 assert "cache_control" not in payload1936 last_block = payload["messages"][-1]["content"][-1]1937 assert last_block["cache_control"] == {"type": "ephemeral", "ttl": "1h"}193819391940def test_cache_control_kwarg_bedrock_skips_code_execution_blocks() -> None:1941 """`cache_control` must skip `code_execution`-related blocks.19421943 Anthropic rejects breakpoints applied to those blocks, so the injector1944 walks backwards until it finds an eligible block.1945 """1946 llm = _BedrockLikeAnthropic(model=MODEL_NAME)19471948 ai_message = AIMessage(1949 content=[1950 {"type": "text", "text": "earlier text"},1951 {1952 "type": "tool_use",1953 "id": "toolu_code_exec_1",1954 "name": "get_weather",1955 "input": {"location": "NYC"},1956 "caller": {1957 "type": "code_execution_20250825",1958 "tool_id": "srvtoolu_abc",1959 },1960 },1961 ]1962 )19631964 payload = llm._get_request_payload(1965 [HumanMessage("hi"), ai_message],1966 cache_control={"type": "ephemeral"},1967 )19681969 last_content = payload["messages"][-1]["content"]1970 assert last_content[0]["cache_control"] == {"type": "ephemeral"}1971 assert "cache_control" not in last_content[1]197219731974def test_cache_control_kwarg_bedrock_walks_back_to_earlier_message() -> None:1975 """When the last message has no eligible blocks, walk back to a prior one.19761977 Pins the contract that `reversed(formatted_messages)` is intentional: a1978 refactor that only inspects the last message would silently regress.1979 """1980 llm = _BedrockLikeAnthropic(model=MODEL_NAME)19811982 ai_message = AIMessage(1983 content=[1984 {1985 "type": "tool_use",1986 "id": "toolu_code_exec_1",1987 "name": "noop",1988 "input": {},1989 "caller": {1990 "type": "code_execution_20250825",1991 "tool_id": "srvtoolu_abc",1992 },1993 }1994 ]1995 )19961997 payload = llm._get_request_payload(1998 [HumanMessage("earlier"), ai_message],1999 cache_control={"type": "ephemeral"},2000 )
Findings
✓ No findings reported for this file.