From 55711b010ba7700e9073511a1575a4fceea2251e Mon Sep 17 00:00:00 2001 From: ccurme Date: Tue, 17 Mar 2026 10:49:03 -0400 Subject: [PATCH] feat(anthropic): delegate cache_control kwarg to anthropic top-level param (#35967) --- .../langchain_anthropic/chat_models.py | 97 -------- libs/partners/anthropic/pyproject.toml | 2 +- .../middleware/test_prompt_caching.py | 207 +----------------- .../tests/unit_tests/test_chat_models.py | 57 +---- libs/partners/anthropic/uv.lock | 10 +- 5 files changed, 11 insertions(+), 362 deletions(-) diff --git a/libs/partners/anthropic/langchain_anthropic/chat_models.py b/libs/partners/anthropic/langchain_anthropic/chat_models.py index 7c05c0198df..273f184bdf2 100644 --- a/libs/partners/anthropic/langchain_anthropic/chat_models.py +++ b/libs/partners/anthropic/langchain_anthropic/chat_models.py @@ -688,65 +688,6 @@ def _format_messages( return system, formatted_messages -def _collect_code_execution_tool_ids(formatted_messages: list[dict]) -> set[str]: - """Collect tool_use IDs that were called by code_execution. - - These blocks cannot have cache_control applied per Anthropic API requirements. - """ - code_execution_tool_ids: set[str] = set() - - for message in formatted_messages: - if message.get("role") != "assistant": - continue - content = message.get("content", []) - if not isinstance(content, list): - continue - for block in content: - if not isinstance(block, dict): - continue - if block.get("type") != "tool_use": - continue - caller = block.get("caller") - if isinstance(caller, dict): - caller_type = caller.get("type", "") - if caller_type.startswith("code_execution"): - tool_id = block.get("id") - if tool_id: - code_execution_tool_ids.add(tool_id) - - return code_execution_tool_ids - - -def _is_code_execution_related_block( - block: dict, - code_execution_tool_ids: set[str], -) -> bool: - """Check if a content block is related to code_execution. - - Returns True for blocks that should NOT have cache_control applied. - """ - if not isinstance(block, dict): - return False - - block_type = block.get("type") - - # tool_use blocks called by code_execution - if block_type == "tool_use": - caller = block.get("caller") - if isinstance(caller, dict): - caller_type = caller.get("type", "") - if caller_type.startswith("code_execution"): - return True - - # tool_result blocks for code_execution called tools - if block_type == "tool_result": - tool_use_id = block.get("tool_use_id") - if tool_use_id and tool_use_id in code_execution_tool_ids: - return True - - return False - - class AnthropicContextOverflowError(anthropic.BadRequestError, ContextOverflowError): """BadRequestError raised when input exceeds Anthropic's context limit.""" @@ -1127,44 +1068,6 @@ class ChatAnthropic(BaseChatModel): system, formatted_messages = _format_messages(messages) - # If cache_control is provided in kwargs, add it to the last eligible message - # block (Anthropic requires cache_control to be nested within a message block). - # Skip blocks related to code_execution as they cannot have cache_control. - cache_control = kwargs.pop("cache_control", None) - if cache_control and formatted_messages: - # Collect tool IDs called by code_execution - code_execution_tool_ids = _collect_code_execution_tool_ids( - formatted_messages - ) - - cache_applied = False - for formatted_message in reversed(formatted_messages): - if cache_applied: - break - content = formatted_message.get("content") - if isinstance(content, list) and content: - # Find last eligible block (not code_execution related) - for block in reversed(content): - if isinstance(block, dict): - if _is_code_execution_related_block( - block, code_execution_tool_ids - ): - continue - block["cache_control"] = cache_control - cache_applied = True - break - elif isinstance(content, str): - formatted_message["content"] = [ - { - "type": "text", - "text": content, - "cache_control": cache_control, - } - ] - cache_applied = True - # If we didn't find an eligible block we silently drop the control. - # Anthropic would reject a payload with cache_control on - # code_execution blocks. payload = { "model": self.model, "max_tokens": self.max_tokens, diff --git a/libs/partners/anthropic/pyproject.toml b/libs/partners/anthropic/pyproject.toml index 7d5fecb336b..df2250ee79d 100644 --- a/libs/partners/anthropic/pyproject.toml +++ b/libs/partners/anthropic/pyproject.toml @@ -23,7 +23,7 @@ classifiers = [ version = "1.3.5" requires-python = ">=3.10.0,<4.0.0" dependencies = [ - "anthropic>=0.78.0,<1.0.0", + "anthropic>=0.85.0,<1.0.0", "langchain-core>=1.2.19,<2.0.0", "pydantic>=2.7.4,<3.0.0", ] diff --git a/libs/partners/anthropic/tests/unit_tests/middleware/test_prompt_caching.py b/libs/partners/anthropic/tests/unit_tests/middleware/test_prompt_caching.py index 50594a29092..634d8ff91a8 100644 --- a/libs/partners/anthropic/tests/unit_tests/middleware/test_prompt_caching.py +++ b/libs/partners/anthropic/tests/unit_tests/middleware/test_prompt_caching.py @@ -15,11 +15,7 @@ from langchain_core.messages import AIMessage, BaseMessage, HumanMessage from langchain_core.outputs import ChatGeneration, ChatResult from langgraph.runtime import Runtime -from langchain_anthropic.chat_models import ( - ChatAnthropic, - _collect_code_execution_tool_ids, - _is_code_execution_related_block, -) +from langchain_anthropic.chat_models import ChatAnthropic from langchain_anthropic.middleware import AnthropicPromptCachingMiddleware @@ -338,204 +334,3 @@ async def test_anthropic_prompt_caching_middleware_async_default_values() -> Non assert modified_request.model_settings == { "cache_control": {"type": "ephemeral", "ttl": "5m"} } - - -class TestCollectCodeExecutionToolIds: - """Tests for _collect_code_execution_tool_ids function.""" - - def test_empty_messages(self) -> None: - """Test with empty messages list.""" - result = _collect_code_execution_tool_ids([]) - assert result == set() - - def test_no_code_execution_calls(self) -> None: - """Test messages without any code_execution calls.""" - messages = [ - { - "role": "user", - "content": [{"type": "text", "text": "Hello"}], - }, - { - "role": "assistant", - "content": [ - { - "type": "tool_use", - "id": "toolu_regular", - "name": "get_weather", - "input": {"location": "NYC"}, - } - ], - }, - ] - result = _collect_code_execution_tool_ids(messages) - assert result == set() - - def test_single_code_execution_call(self) -> None: - """Test with a single code_execution tool call.""" - messages = [ - { - "role": "assistant", - "content": [ - { - "type": "tool_use", - "id": "toolu_code_exec_1", - "name": "get_weather", - "input": {"location": "NYC"}, - "caller": { - "type": "code_execution_20250825", - "tool_id": "srvtoolu_abc123", - }, - } - ], - }, - ] - result = _collect_code_execution_tool_ids(messages) - assert result == {"toolu_code_exec_1"} - - def test_multiple_code_execution_calls(self) -> None: - """Test with multiple code_execution tool calls.""" - messages = [ - { - "role": "assistant", - "content": [ - { - "type": "tool_use", - "id": "toolu_regular", - "name": "search", - "input": {"query": "test"}, - }, - { - "type": "tool_use", - "id": "toolu_code_exec_1", - "name": "get_weather", - "input": {"location": "NYC"}, - "caller": { - "type": "code_execution_20250825", - "tool_id": "srvtoolu_abc", - }, - }, - { - "type": "tool_use", - "id": "toolu_code_exec_2", - "name": "get_weather", - "input": {"location": "SF"}, - "caller": { - "type": "code_execution_20250825", - "tool_id": "srvtoolu_def", - }, - }, - ], - }, - ] - result = _collect_code_execution_tool_ids(messages) - assert result == {"toolu_code_exec_1", "toolu_code_exec_2"} - assert "toolu_regular" not in result - - def test_future_code_execution_version(self) -> None: - """Test with a hypothetical future code_execution version.""" - messages = [ - { - "role": "assistant", - "content": [ - { - "type": "tool_use", - "id": "toolu_future", - "name": "get_weather", - "input": {}, - "caller": { - "type": "code_execution_20260101", - "tool_id": "srvtoolu_future", - }, - } - ], - }, - ] - result = _collect_code_execution_tool_ids(messages) - assert result == {"toolu_future"} - - def test_ignores_user_messages(self) -> None: - """Test that user messages are ignored.""" - messages = [ - { - "role": "user", - "content": [ - { - "type": "tool_result", - "tool_use_id": "toolu_123", - "content": "result", - } - ], - }, - ] - result = _collect_code_execution_tool_ids(messages) - assert result == set() - - def test_handles_string_content(self) -> None: - """Test that string content is handled gracefully.""" - messages = [ - { - "role": "assistant", - "content": "Just a text response", - }, - ] - result = _collect_code_execution_tool_ids(messages) - assert result == set() - - -class TestIsCodeExecutionRelatedBlock: - """Tests for _is_code_execution_related_block function.""" - - def test_regular_tool_use_block(self) -> None: - """Test regular tool_use block without caller.""" - block = { - "type": "tool_use", - "id": "toolu_regular", - "name": "get_weather", - "input": {"location": "NYC"}, - } - assert not _is_code_execution_related_block(block, set()) - - def test_code_execution_tool_use_block(self) -> None: - """Test tool_use block called by code_execution.""" - block = { - "type": "tool_use", - "id": "toolu_code_exec", - "name": "get_weather", - "input": {"location": "NYC"}, - "caller": { - "type": "code_execution_20250825", - "tool_id": "srvtoolu_abc", - }, - } - assert _is_code_execution_related_block(block, set()) - - def test_regular_tool_result_block(self) -> None: - """Test tool_result block for regular tool.""" - block = { - "type": "tool_result", - "tool_use_id": "toolu_regular", - "content": "Sunny, 72°F", - } - code_exec_ids = {"toolu_code_exec"} - assert not _is_code_execution_related_block(block, code_exec_ids) - - def test_code_execution_tool_result_block(self) -> None: - """Test tool_result block for code_execution called tool.""" - block = { - "type": "tool_result", - "tool_use_id": "toolu_code_exec", - "content": "Sunny, 72°F", - } - code_exec_ids = {"toolu_code_exec"} - assert _is_code_execution_related_block(block, code_exec_ids) - - def test_text_block(self) -> None: - """Test that text blocks are not flagged.""" - block = {"type": "text", "text": "Hello world"} - assert not _is_code_execution_related_block(block, set()) - - def test_non_dict_block(self) -> None: - """Test that non-dict values return False.""" - assert not _is_code_execution_related_block("string", set()) # type: ignore[arg-type] - assert not _is_code_execution_related_block(None, set()) # type: ignore[arg-type] - assert not _is_code_execution_related_block(123, set()) # type: ignore[arg-type] diff --git a/libs/partners/anthropic/tests/unit_tests/test_chat_models.py b/libs/partners/anthropic/tests/unit_tests/test_chat_models.py index 00ef3823627..e1a9d69db0e 100644 --- a/libs/partners/anthropic/tests/unit_tests/test_chat_models.py +++ b/libs/partners/anthropic/tests/unit_tests/test_chat_models.py @@ -1711,65 +1711,16 @@ def test_cache_control_kwarg() -> None: messages = [HumanMessage("foo"), AIMessage("bar"), HumanMessage("baz")] payload = llm._get_request_payload(messages) + assert "cache_control" not in payload + + payload = llm._get_request_payload(messages, cache_control={"type": "ephemeral"}) + assert payload["cache_control"] == {"type": "ephemeral"} assert payload["messages"] == [ {"role": "user", "content": "foo"}, {"role": "assistant", "content": "bar"}, {"role": "user", "content": "baz"}, ] - payload = llm._get_request_payload(messages, cache_control={"type": "ephemeral"}) - assert payload["messages"] == [ - {"role": "user", "content": "foo"}, - {"role": "assistant", "content": "bar"}, - { - "role": "user", - "content": [ - {"type": "text", "text": "baz", "cache_control": {"type": "ephemeral"}} - ], - }, - ] - assert isinstance(messages[-1].content, str) # test no mutation - - messages = [ - HumanMessage("foo"), - AIMessage("bar"), - HumanMessage( - content=[ - {"type": "text", "text": "baz"}, - {"type": "text", "text": "qux"}, - ] - ), - ] - payload = llm._get_request_payload(messages, cache_control={"type": "ephemeral"}) - assert payload["messages"] == [ - {"role": "user", "content": "foo"}, - {"role": "assistant", "content": "bar"}, - { - "role": "user", - "content": [ - {"type": "text", "text": "baz"}, - {"type": "text", "text": "qux", "cache_control": {"type": "ephemeral"}}, - ], - }, - ] - assert "cache_control" not in messages[-1].content[-1] # test no mutation - - -def test_cache_control_kwarg_skips_empty_messages() -> None: - llm = ChatAnthropic(model=MODEL_NAME) - - messages = [HumanMessage("foo"), AIMessage(content=[])] - payload = llm._get_request_payload(messages, cache_control={"type": "ephemeral"}) - assert payload["messages"] == [ - { - "role": "user", - "content": [ - {"type": "text", "text": "foo", "cache_control": {"type": "ephemeral"}} - ], - }, - {"role": "assistant", "content": []}, - ] - def test_context_management_in_payload() -> None: llm = ChatAnthropic( diff --git a/libs/partners/anthropic/uv.lock b/libs/partners/anthropic/uv.lock index 31d6ce5929f..f492005581e 100644 --- a/libs/partners/anthropic/uv.lock +++ b/libs/partners/anthropic/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 3 +revision = 2 requires-python = ">=3.10.0, <4.0.0" resolution-markers = [ "python_full_version >= '3.13' and platform_python_implementation == 'PyPy'", @@ -24,7 +24,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.78.0" +version = "0.85.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -36,9 +36,9 @@ dependencies = [ { name = "sniffio" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ec/51/32849a48f9b1cfe80a508fd269b20bd8f0b1357c70ba092890fde5a6a10b/anthropic-0.78.0.tar.gz", hash = "sha256:55fd978ab9b049c61857463f4c4e9e092b24f892519c6d8078cee1713d8af06e", size = 509136, upload-time = "2026-02-05T17:52:04.986Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/08/c620a0eb8625539a8ea9f5a6e06f13d131be0bc8b5b714c235d4b25dd1b5/anthropic-0.85.0.tar.gz", hash = "sha256:d45b2f38a1efb1a5d15515a426b272179a0d18783efa2bb4c3925fa773eb50b9", size = 542034, upload-time = "2026-03-16T17:00:44.324Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/03/2f50931a942e5e13f80e24d83406714672c57964be593fc046d81369335b/anthropic-0.78.0-py3-none-any.whl", hash = "sha256:2a9887d2e99d1b0f9fe08857a1e9fe5d2d4030455dbf9ac65aab052e2efaeac4", size = 405485, upload-time = "2026-02-05T17:52:03.674Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5a/9d85b85686d5cdd79f5488c8667e668d7920d06a0a1a1beb454a5b77b2db/anthropic-0.85.0-py3-none-any.whl", hash = "sha256:b4f54d632877ed7b7b29c6d9ba7299d5e21c4c92ae8de38947e9d862bff74adf", size = 458237, upload-time = "2026-03-16T17:00:45.877Z" }, ] [[package]] @@ -607,7 +607,7 @@ typing = [ [package.metadata] requires-dist = [ - { name = "anthropic", specifier = ">=0.78.0,<1.0.0" }, + { name = "anthropic", specifier = ">=0.85.0,<1.0.0" }, { name = "langchain-core", editable = "../../core" }, { name = "pydantic", specifier = ">=2.7.4,<3.0.0" }, ]