From 75014255cf011ae612d9e351a32ec267ee98a024 Mon Sep 17 00:00:00 2001 From: lkshrk Date: Sat, 19 Sep 2026 13:26:02 +0200 Subject: [PATCH] fix(anthropic): keep auto-injected cache_control on the tool_result block The default trailing-turn breakpoint from enable_anthropic_prompt_caching marked the last content block of a tool message. convert_to_anthropic_tool_result copies block-level markers into tool_result.content, which the Anthropic API rejects: "cache_control may not be specified within `tool_result.content`". Mark tool messages at message level instead; the transformation maps that onto the tool_result block itself. Fixes #41954 --- .../anthropic_cache_control_hook.py | 6 +- .../test_anthropic_cache_control_hook.py | 56 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/litellm/integrations/anthropic_cache_control_hook.py b/litellm/integrations/anthropic_cache_control_hook.py index 4487aa31e3b..80d59feb009 100644 --- a/litellm/integrations/anthropic_cache_control_hook.py +++ b/litellm/integrations/anthropic_cache_control_hook.py @@ -531,8 +531,10 @@ class AnthropicCacheControlHook(CustomPromptManagement): message_content: Final = message.get("content", None) - # 1. if string, insert cache control in the message - if isinstance(message_content, str): + # 1. string content, or a tool message: insert cache control at message level. + # Anthropic only accepts cache_control on the tool_result block itself, never + # inside tool_result.content; the message-level marker maps onto that block. + if message.get("role") == "tool" or isinstance(message_content, str): message["cache_control"] = control # 2. list of objects - only apply to last item per Anthropic spec elif isinstance(message_content, list): diff --git a/tests/unit/integrations/test_anthropic_cache_control_hook.py b/tests/unit/integrations/test_anthropic_cache_control_hook.py index 4c9fd3cfdf2..b7356c15867 100644 --- a/tests/unit/integrations/test_anthropic_cache_control_hook.py +++ b/tests/unit/integrations/test_anthropic_cache_control_hook.py @@ -3890,3 +3890,59 @@ class TestRecordGatewayInjection: custom_llm_provider="anthropic", ) assert self.KEY not in kwargs["litellm_metadata"] + + +def test_tool_message_injection_lands_on_tool_result_not_inside_its_content(): + """ + Anthropic rejects cache_control inside tool_result.content + ("cache_control may not be specified within `tool_result.content`"). + The default trailing-turn breakpoint must therefore mark a tool message + at message level, which the Anthropic transformation maps onto the + tool_result block. + """ + from litellm.litellm_core_utils.prompt_templates.factory import ( + anthropic_messages_pt, + ) + + hook = AnthropicCacheControlHook() + messages: List[AllMessageValues] = [ + {"role": "user", "content": [{"type": "text", "text": "list files"}]}, + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "toolu_01", + "type": "function", + "function": {"name": "ls", "arguments": "{}"}, + } + ], + }, + { + "role": "tool", + "tool_call_id": "toolu_01", + "content": [ + {"type": "text", "text": "file1"}, + {"type": "text", "text": "file2"}, + ], + }, + ] + + hook._safe_insert_cache_control_in_message( + message=messages[-1], + control={"type": "ephemeral"}, + ) + + assert messages[-1]["cache_control"] == {"type": "ephemeral"} + assert all("cache_control" not in block for block in messages[-1]["content"]) + assert hook._message_has_cache_control(messages[-1]) is True + + anthropic_messages = anthropic_messages_pt( + model="claude-sonnet-4-5", + messages=messages, + llm_provider="anthropic", + ) + tool_result = anthropic_messages[-1]["content"][0] + assert tool_result["type"] == "tool_result" + assert tool_result["cache_control"] == {"type": "ephemeral"} + assert all("cache_control" not in block for block in tool_result["content"])