From ec59078ad99d14e9c4b596f89b83c88d607586b8 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 04:17:38 -0700 Subject: [PATCH 1/7] fix: apply configured cache_control_injection_points beside client cache_control marks Configured injection points were dropped whenever the request already carried a client-set cache_control anywhere, so an operator's rolling tail checkpoint silently never landed once a caller marked its own system prompt. Only the automatic defaults stand down now. Configured points skip a target the client already marked and stay under the provider's 4-block cap, counting the client's marks on messages, system, tools and the root cache_control first. The chat path carries the tool count as a stamp on the points because the prompt-management hook never receives tools. Fixes #40675 --- .../anthropic_cache_control_hook.py | 212 ++++++++-------- .../anthropic_cache_control_hook.py | 4 +- .../test_anthropic_cache_control_hook.py | 234 +++++++++++++----- 3 files changed, 270 insertions(+), 180 deletions(-) diff --git a/litellm/integrations/anthropic_cache_control_hook.py b/litellm/integrations/anthropic_cache_control_hook.py index 4f9b18713d0..b06372baa78 100644 --- a/litellm/integrations/anthropic_cache_control_hook.py +++ b/litellm/integrations/anthropic_cache_control_hook.py @@ -121,6 +121,12 @@ def _carries_cache_breakpoint(block: object) -> bool: return isinstance(block, dict) and any(block.get(key) is not None for key in CACHE_BREAKPOINT_KEYS) +def _tool_carries_cache_breakpoint(tool: object) -> bool: + return _carries_cache_breakpoint(tool) or ( + isinstance(tool, dict) and _carries_cache_breakpoint(tool.get("function")) + ) + + def _accepts_prompt_cache_breakpoint(block: object) -> bool: return isinstance(block, dict) and block.get("type") in OPENAI_PROMPT_CACHE_BREAKPOINT_BLOCK_TYPES @@ -131,6 +137,8 @@ def _accepts_prompt_cache_breakpoint(block: object) -> bool: # rather than spending them on a list that is still missing some of their targets. CARRY_UNMATCHED_MESSAGE_POINTS: Final = "_litellm_carry_unmatched_cache_control_points" +EXTERNAL_BREAKPOINTS_STAMP: Final = "_litellm_external_breakpoints" + class AnthropicCacheControlHook(CustomPromptManagement): @staticmethod @@ -205,10 +213,6 @@ class AnthropicCacheControlHook(CustomPromptManagement): else: remaining_points.append(point) - # Non-message points (currently Bedrock tool_config) are handled in the - # provider transform, where each tool_config point appends at most one - # cachePoint to the tools. That block also counts toward Anthropic's - # limit, so reserve a slot for it here to leave room. stamped_dialect: Final = injection_points[0].get("_litellm_openai_dialect") openai_dialect: Final = ( stamped_dialect @@ -233,8 +237,11 @@ class AnthropicCacheControlHook(CustomPromptManagement): if carry_unmatched else tuple(message_points) ) - reserved_blocks: Final = ( - 1 if not openai_dialect and any(p.get("location") == "tool_config" for p in remaining_points) else 0 + stamped_external: Final = injection_points[0].get(EXTERNAL_BREAKPOINTS_STAMP) + reserved_blocks: Final = AnthropicCacheControlHook._blocks_reserved_outside_messages( + remaining_points, + stamped_external if isinstance(stamped_external, int) else 0, + openai_dialect, ) breakpoints_before: Final = AnthropicCacheControlHook.count_request_cache_breakpoints(processed_messages) processed_messages = self._apply_message_injections( @@ -251,14 +258,12 @@ class AnthropicCacheControlHook(CustomPromptManagement): # Points this pass did not place: non-message ones for the provider transform, and # the deferred role-targeted ones. Deferring is what reaches the Responses API's - # `instructions`, which is only a system message once the bridge builds one. The - # judged stamp is what makes it safe: the next pass must not re-judge points - # against messages this pass already marked (see `_should_stand_down`). + # `instructions`, which is only a system message once the bridge builds one. A later + # pass re-applies them safely: a target that already carries a mark is skipped and + # the census counts every mark on the wire, litellm's own included. carried_points: Final[Sequence[CacheControlInjectionPoint]] = (*remaining_points, *carried_message_points) if carried_points: - non_default_params["cache_control_injection_points"] = AnthropicCacheControlHook._stamped_as_judged( - carried_points - ) + non_default_params["cache_control_injection_points"] = list(carried_points) return model, processed_messages, non_default_params @@ -293,6 +298,34 @@ class AnthropicCacheControlHook(CustomPromptManagement): ) return system_blocks + sum(AnthropicCacheControlHook._count_cache_control_blocks(msg) for msg in messages) + @staticmethod + def count_external_cache_breakpoints(tools: Iterable[object] | None, cache_control: object = None) -> int: + """Client breakpoints outside messages and system that the provider cap still counts. + + A tool carries its mark at the top level (Anthropic shape) or under ``function`` + (OpenAI shape); the Anthropic chat transform forwards both. A top-level + ``cache_control`` is Anthropic's automatic caching, which places one breakpoint + of its own on top of the explicit ones. + """ + automatic_blocks: Final = 1 if cache_control is not None else 0 + tool_blocks: Final = sum(1 for tool in tools if _tool_carries_cache_breakpoint(tool)) if tools else 0 + return automatic_blocks + tool_blocks + + @staticmethod + def _blocks_reserved_outside_messages( + remaining_points: Sequence[CacheControlInjectionPoint], external_breakpoints: int, openai_dialect: bool + ) -> int: + """Slots of the provider cap that the message census cannot see. + + The client's breakpoints on tools and its automatic top-level ``cache_control`` + are already on the wire, and a ``tool_config`` point becomes one more cachePoint + in the Bedrock converse transform. OpenAI's cap counts only its own block markers. + """ + if openai_dialect: + return 0 + tool_config_blocks: Final = 1 if any(p.get("location") == "tool_config" for p in remaining_points) else 0 + return external_breakpoints + tool_config_blocks + @staticmethod def _apply_message_injections( points: Sequence[CacheControlMessageInjectionPoint], @@ -473,11 +506,16 @@ class AnthropicCacheControlHook(CustomPromptManagement): def apply_to_anthropic_messages_request( messages: list[dict], system: str | list | None, - injection_points: list[CacheControlInjectionPoint], + injection_points: Sequence[CacheControlInjectionPoint], openai_dialect: bool = False, + external_breakpoints: int = 0, ) -> tuple[list[dict], str | list | None, list[CacheControlInjectionPoint]]: """Apply cache control injection for the Anthropic-native v1/messages endpoint. + ``external_breakpoints`` is the client's breakpoint count outside ``messages`` and + ``system`` (see ``count_external_cache_breakpoints``); it shrinks the budget so + the request never exceeds the provider cap. + Returns (messages, system, remaining_non_message_points). """ if not injection_points: @@ -500,8 +538,8 @@ class AnthropicCacheControlHook(CustomPromptManagement): else: remaining_points.append(point) - reserved_blocks: Final = ( - 1 if not openai_dialect and any(p.get("location") == "tool_config" for p in remaining_points) else 0 + reserved_blocks: Final = AnthropicCacheControlHook._blocks_reserved_outside_messages( + remaining_points, external_breakpoints, openai_dialect ) max_blocks: Final = MAX_CACHE_CONTROL_BLOCKS - reserved_blocks @@ -556,30 +594,26 @@ class AnthropicCacheControlHook(CustomPromptManagement): return ChatCompletionCachedContent(type="ephemeral") @staticmethod - def _stamped_as_judged(points: Sequence[CacheControlInjectionPoint]) -> Sequence[Mapping[str, object]]: - """Mark written-back points as having passed the client cache_control judgment. - - Builds copies because config-owned point dicts are shared across - requests; mutating them would leak the stamp into future requests. - """ - return AnthropicCacheControlHook._stamped(points, "_litellm_judged", True) - - @staticmethod - def _judged_configured_points( + def _stamped_for_prompt_hook( points: Sequence[CacheControlInjectionPoint], - messages: list[AllMessageValues], - tools: list[object] | None, - cache_control: object, + external_breakpoints: int, model: str, custom_llm_provider: str | None, api_base: object, prompt_cache_options: object, - ) -> Sequence[Mapping[str, object]] | None: - if AnthropicCacheControlHook._should_stand_down(points, messages, None, tools, cache_control): - return None - return AnthropicCacheControlHook._stamped_with_dialect( + ) -> Sequence[Mapping[str, object]]: + """Carry onto the points what the prompt-management hook never receives. + + The hook sees neither the tools nor the request kwargs, so the target dialect + and the client's breakpoint count outside the message list ride on the points. + Builds copies because config-owned point dicts are shared across requests. + """ + with_dialect: Final = AnthropicCacheControlHook._stamped_with_dialect( points, model, custom_llm_provider, api_base, prompt_cache_options ) + if external_breakpoints == 0: + return with_dialect + return AnthropicCacheControlHook._stamped(with_dialect, EXTERNAL_BREAKPOINTS_STAMP, external_breakpoints) @staticmethod def _stamped_with_dialect( @@ -600,32 +634,9 @@ class AnthropicCacheControlHook(CustomPromptManagement): ) @staticmethod - def _stamped( - points: Sequence[CacheControlInjectionPoint], key: str, value: object - ) -> Sequence[Mapping[str, object]]: + def _stamped(points: Sequence[Mapping[str, object]], key: str, value: object) -> Sequence[Mapping[str, object]]: return [{**point, key: value} for point in points] - @staticmethod - def _should_stand_down( - points: Sequence[CacheControlInjectionPoint], - messages: list[AllMessageValues], - system: str | list | None, - tools: list | None, - cache_control: object = None, - ) -> bool: - """Whether configured injection points must yield to client-set cache_control. - - Points that a prior pass over this request already judged and wrote - back carry the internal judged stamp; any re-entry (acompletion - re-entering completion, the async-to-sync /v1/messages dispatch, - interceptor sub-calls reusing the request kwargs) must not re-judge - them, because by then the messages carry litellm's own injected marks - and the judgment would misread those as client breakpoints. - """ - if all(point.get("_litellm_judged") for point in points): - return False - return AnthropicCacheControlHook._request_has_cache_control(messages, system, tools, cache_control) - @staticmethod def _request_has_cache_control( messages: list[AllMessageValues], @@ -635,28 +646,15 @@ class AnthropicCacheControlHook(CustomPromptManagement): ) -> bool: """Return True if the request already carries any client-supplied cache_control. - When the client (e.g. Claude Code) already marks its own breakpoints we - stand down entirely rather than add more, per the auto-caching contract. - Tools count: they are a breakpoint the client can mark, they count toward - the provider's four-block limit, and caching only the tool definitions is - a common pattern, so injecting alongside them can exceed the cap. Tools - carry the mark either at the top level (Anthropic shape) or nested under - ``function`` (OpenAI shape); the Anthropic chat transform accepts both. + Only the automatic defaults stand down on it: a client that marks its own + breakpoints (Claude Code does) has a caching strategy the defaults would + clash with. Configured injection points are an explicit instruction and are + applied alongside the client's marks, bounded by the provider cap. """ - if cache_control is not None: - return True - if AnthropicCacheControlHook.count_request_cache_breakpoints(messages, system) > 0: - return True - if tools is not None: - return any( - isinstance(tool, dict) - and ( - tool.get("cache_control") is not None - or (isinstance(tool.get("function"), dict) and tool["function"].get("cache_control") is not None) - ) - for tool in tools - ) - return False + return ( + AnthropicCacheControlHook.count_request_cache_breakpoints(messages, system) + + AnthropicCacheControlHook.count_external_cache_breakpoints(tools, cache_control) + ) > 0 @staticmethod def get_default_injection_points( @@ -779,31 +777,25 @@ class AnthropicCacheControlHook(CustomPromptManagement): ) -> None: """For /chat/completions: resolve the injection points the request should carry. - Configured injection points win over the automatic defaults, but stand - down entirely when the client already marked its own cache_control - breakpoints (messages or tools): injecting alongside them clashes with - the client's caching strategy and can exceed the provider's four-block - limit. The judgment happens once per request; points a prior pass - wrote back carry the judged stamp and are never re-judged (see - ``_should_stand_down``). Seeding the param lets the existing - prompt-management gate and the AnthropicCacheControlHook run - unchanged. + Configured injection points win over the automatic defaults and are applied + even when the client marked its own cache_control elsewhere in the request; + the provider's four-block cap bounds them, counting the client's marks on + messages, tools and the top-level ``cache_control``. Only the defaults stand + down on client marks. Seeding the param lets the existing prompt-management + gate and the AnthropicCacheControlHook run unchanged. """ - if non_default_params.get("cache_control_injection_points"): - judged: Final = AnthropicCacheControlHook._judged_configured_points( - non_default_params["cache_control_injection_points"], - messages, - tools, - non_default_params.get("cache_control"), + configured: Final = non_default_params.get("cache_control_injection_points") + if configured: + non_default_params["cache_control_injection_points"] = AnthropicCacheControlHook._stamped_for_prompt_hook( + configured, + AnthropicCacheControlHook.count_external_cache_breakpoints( + tools, non_default_params.get("cache_control") + ), model, custom_llm_provider, api_base, non_default_params.get("prompt_cache_options"), ) - if judged is None: - non_default_params.pop("cache_control_injection_points") - else: - non_default_params["cache_control_injection_points"] = judged return points: Final = AnthropicCacheControlHook.get_default_injection_points( messages=messages, @@ -904,15 +896,14 @@ class AnthropicCacheControlHook(CustomPromptManagement): ) -> tuple[list[dict], str | list | None]: """Extract cache_control_injection_points from kwargs and apply if present. - Configured points stand down entirely when the client already marked - its own cache_control breakpoints anywhere in the request. The - judgment happens once per request; points a prior pass wrote back - carry the judged stamp and are never re-judged (see - ``_should_stand_down``). When none are configured but + Configured points are applied even when the client marked its own + cache_control elsewhere in the request, bounded by the provider cap, + which counts the client's marks on messages, system, tools and the + top-level ``cache_control``. When none are configured but ``litellm.enable_anthropic_prompt_caching`` or the per-request ``enable_prompt_caching`` kwarg (stamped from key metadata) is on, - synthesize default breakpoints for the native /v1/messages path. Pops - both keys from kwargs; + synthesize default breakpoints for the native /v1/messages path; those + defaults alone stand down on client marks. Pops both keys from kwargs; if remaining (non-message) points exist they are written back so downstream transforms can handle them. """ @@ -924,13 +915,8 @@ class AnthropicCacheControlHook(CustomPromptManagement): configured: Final = cast( # cast-ok: kwargs is untyped; this key only holds the documented injection-point list list[CacheControlInjectionPoint] | None, kwargs.pop("cache_control_injection_points", None) ) - if configured and AnthropicCacheControlHook._should_stand_down( - configured, typed_messages, system, tools, cache_control - ): - return messages, system - injection_points: list[CacheControlInjectionPoint] = configured or [] - if not injection_points and model is not None: - injection_points = AnthropicCacheControlHook.get_default_injection_points( + injection_points: Final[Sequence[CacheControlInjectionPoint]] = configured or ( + AnthropicCacheControlHook.get_default_injection_points( messages=typed_messages, system=system, tools=tools, @@ -940,6 +926,9 @@ class AnthropicCacheControlHook(CustomPromptManagement): cache_control=cache_control, request_kwargs=kwargs, ) + if model is not None + else () + ) if not injection_points: return messages, system @@ -952,6 +941,7 @@ class AnthropicCacheControlHook(CustomPromptManagement): system=system, injection_points=injection_points, openai_dialect=openai_dialect, + external_breakpoints=AnthropicCacheControlHook.count_external_cache_breakpoints(tools, cache_control), ) breakpoints_added: Final = ( AnthropicCacheControlHook.count_request_cache_breakpoints(messages, system) - breakpoints_before @@ -960,7 +950,7 @@ class AnthropicCacheControlHook(CustomPromptManagement): if openai_dialect and breakpoints_added > 0: kwargs.setdefault("prompt_cache_options", PromptCacheOptions(mode="explicit")) if remaining: - kwargs["cache_control_injection_points"] = AnthropicCacheControlHook._stamped_as_judged(remaining) + kwargs["cache_control_injection_points"] = remaining return messages, system @property diff --git a/litellm/types/integrations/anthropic_cache_control_hook.py b/litellm/types/integrations/anthropic_cache_control_hook.py index ef414f22c3b..20e7885a2bf 100644 --- a/litellm/types/integrations/anthropic_cache_control_hook.py +++ b/litellm/types/integrations/anthropic_cache_control_hook.py @@ -17,8 +17,8 @@ class CacheControlMessageInjectionPoint(TypedDict): role: Literal["user", "system", "assistant"] | None # Optional: target by role (user, system, assistant) index: int | str | None # Optional: target by specific index control: ChatCompletionCachedContent | None - _litellm_judged: NotRequired[bool] # Internal: written back by litellm once the client cache_control judgment ran _litellm_openai_dialect: NotRequired[ReadOnly[bool]] + _litellm_external_breakpoints: NotRequired[ReadOnly[int]] class CacheControlToolConfigInjectionPoint(TypedDict): @@ -26,8 +26,8 @@ class CacheControlToolConfigInjectionPoint(TypedDict): location: Literal["tool_config"] control: ChatCompletionCachedContent | None - _litellm_judged: NotRequired[bool] # Internal: written back by litellm once the client cache_control judgment ran _litellm_openai_dialect: NotRequired[ReadOnly[bool]] + _litellm_external_breakpoints: NotRequired[ReadOnly[int]] CacheControlInjectionPoint = CacheControlMessageInjectionPoint | CacheControlToolConfigInjectionPoint diff --git a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py index 92b1185e542..3424cc5fed6 100644 --- a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py +++ b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py @@ -1276,11 +1276,7 @@ def test_cache_control_hook_reserves_slot_for_tool_config_point(): ) assert _count_cache_control(processed) == 3 - # The tool_config point is passed through for the provider transform, - # stamped so re-entries never re-judge it against litellm's own marks. - assert non_default_params["cache_control_injection_points"] == [ - {"location": "tool_config", "_litellm_judged": True} - ] + assert non_default_params["cache_control_injection_points"] == [{"location": "tool_config"}] @pytest.mark.asyncio @@ -2085,13 +2081,17 @@ class TestPerKeyEnablePromptCaching: assert result_msgs == messages -class TestConfiguredInjectionPointsStandDown: - """Configured cache_control_injection_points must stand down entirely when the - client already set its own cache_control anywhere in the request (LIT-4582); - injecting alongside client breakpoints clashes with the client's caching - strategy and can push the request past Anthropic's four-block limit.""" +class TestConfiguredInjectionPointsSurviveClientMarks: + """Configured cache_control_injection_points are an explicit instruction, so they + apply alongside the client's own cache_control marks (LIT-7586, #40675) instead of + standing down on them. What bounds them is Anthropic's four-block cap, which has to + count the client's marks on messages, system, tools and the root ``cache_control`` + (LIT-4582: a client-marked tool the cap could not see produced "Found 5" 400s). + Only the automatic defaults stand down on client marks.""" CONFIGURED = [{"location": "message", "role": "system"}] + TAIL_POINT = [{"location": "message", "index": -1}] + EPHEMERAL = {"type": "ephemeral"} CLEAN_MESSAGES: List[AllMessageValues] = [ {"role": "system", "content": "sys"}, @@ -2105,6 +2105,23 @@ class TestConfiguredInjectionPointsStandDown: V1_MESSAGES = [{"role": "user", "content": [{"type": "text", "text": "hi"}]}] + MARKED_TOOL_TOP_LEVEL = { + "type": "function", + "function": {"name": "t", "parameters": {}}, + "cache_control": {"type": "ephemeral"}, + } + MARKED_TOOL_NESTED = {"type": "function", "function": {"name": "t", "parameters": {}, "cache_control": {"type": "ephemeral"}}} + UNMARKED_TOOL = {"type": "function", "function": {"name": "t", "parameters": {}}} + MARKED_V1_TOOL = {"name": "t", "input_schema": {}, "cache_control": {"type": "ephemeral"}} + UNMARKED_V1_TOOL = {"name": "t", "input_schema": {}} + + @staticmethod + def _marked_user_turns(count): + return [ + {"role": "user", "content": [{"type": "text", "text": f"turn {i}", "cache_control": {"type": "ephemeral"}}]} + for i in range(count) + ] + def _seed(self, params, messages, tools=None): AnthropicCacheControlHook.maybe_seed_default_injection_points( non_default_params=params, @@ -2114,6 +2131,17 @@ class TestConfiguredInjectionPointsStandDown: tools=tools, ) + def _chat(self, params, messages): + _, processed, _ = AnthropicCacheControlHook().get_chat_completion_prompt( + model="claude-sonnet-4-5", + messages=messages, + non_default_params=params, + prompt_id=None, + prompt_variables=None, + dynamic_callback_params={}, + ) + return processed + def _inject(self, messages, kwargs, system="sys", tools=None): return AnthropicCacheControlHook.maybe_inject_cache_control( messages, @@ -2124,23 +2152,64 @@ class TestConfiguredInjectionPointsStandDown: tools=tools, ) - def test_configured_points_dropped_when_messages_carry_cache_control(self): + def test_chat_tail_point_applies_when_client_marked_the_system_block(self): + """The issue's shape: the client caches its system prompt, the deployment is + configured to cache the trailing turn, and both marks must reach the provider.""" + messages: List[AllMessageValues] = [ + {"role": "system", "content": [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}]}, + {"role": "user", "content": "history"}, + {"role": "assistant", "content": "reply"}, + {"role": "user", "content": "question"}, + ] + params = {"cache_control_injection_points": copy.deepcopy(self.TAIL_POINT)} + self._seed(params, messages) + processed = self._chat(params, messages) + assert processed[0] == messages[0] + assert processed[-1] == {"role": "user", "content": "question", "cache_control": self.EPHEMERAL} + assert _count_cache_control(processed) == 2 + + def test_chat_configured_points_apply_when_messages_carry_cache_control(self): params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)} self._seed(params, copy.deepcopy(self.MARKED_MESSAGES)) - assert "cache_control_injection_points" not in params + processed = self._chat(params, copy.deepcopy(self.MARKED_MESSAGES)) + assert processed[0] == {"role": "system", "content": "sys", "cache_control": self.EPHEMERAL} + assert processed[1] == self.MARKED_MESSAGES[1] @pytest.mark.parametrize( - "tool", - [ - {"type": "function", "function": {"name": "t", "parameters": {}}, "cache_control": {"type": "ephemeral"}}, - {"type": "function", "function": {"name": "t", "parameters": {}, "cache_control": {"type": "ephemeral"}}}, - ], - ids=["top_level", "nested_in_function"], + "tool", [MARKED_TOOL_TOP_LEVEL, MARKED_TOOL_NESTED], ids=["top_level", "nested_in_function"] ) - def test_configured_points_dropped_when_tools_carry_cache_control(self, tool): + def test_chat_configured_points_apply_when_tools_carry_cache_control(self, tool): params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)} self._seed(params, copy.deepcopy(self.CLEAN_MESSAGES), tools=[tool]) - assert "cache_control_injection_points" not in params + processed = self._chat(params, copy.deepcopy(self.CLEAN_MESSAGES)) + assert processed[0] == {"role": "system", "content": "sys", "cache_control": self.EPHEMERAL} + + @pytest.mark.parametrize( + "tool,injected", + [(MARKED_TOOL_TOP_LEVEL, 0), (MARKED_TOOL_NESTED, 0), (UNMARKED_TOOL, 1)], + ids=["marked_top_level", "marked_nested_in_function", "unmarked"], + ) + def test_chat_cap_counts_client_marked_tools(self, tool, injected): + """LIT-4582 regression: the prompt-management hook never sees the tools, so the + seeding pass has to carry the client's tool marks into the cap or a configured + point lands as a fifth block.""" + messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(3)] + params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)} + self._seed(params, copy.deepcopy(messages), tools=[tool]) + processed = self._chat(params, copy.deepcopy(messages)) + assert _count_cache_control(processed) == 3 + injected + + @pytest.mark.parametrize("marked_turns,injected", [(2, 1), (3, 0)]) + def test_chat_root_cache_control_reserves_a_slot(self, marked_turns, injected): + """Anthropic's automatic caching (a top-level ``cache_control``) places one + breakpoint of its own, so it counts toward the cap like a client mark.""" + messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(marked_turns)] + root_cache_control = {"type": "ephemeral"} + params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED), "cache_control": root_cache_control} + self._seed(params, copy.deepcopy(messages)) + processed = self._chat(params, copy.deepcopy(messages)) + assert _count_cache_control(processed) == marked_turns + injected + assert params["cache_control"] is root_cache_control def test_configured_points_kept_when_request_is_unmarked(self): configured = copy.deepcopy(self.CONFIGURED) @@ -2148,60 +2217,68 @@ class TestConfiguredInjectionPointsStandDown: self._seed(params, copy.deepcopy(self.CLEAN_MESSAGES)) assert params["cache_control_injection_points"] is configured - def test_judged_remainder_survives_reentry_despite_injected_marks(self): - """acompletion() re-enters completion() after injection ran, with only the - stamped non-message points written back; the re-entry must not misread - litellm's own marks as client ones and drop that remainder.""" - remainder = [{"location": "tool_config", "_litellm_judged": True}] - params = {"cache_control_injection_points": remainder} - self._seed(params, copy.deepcopy(self.MARKED_MESSAGES)) - assert params["cache_control_injection_points"] is remainder + def test_chat_reentry_over_injected_messages_adds_no_duplicate_marks(self): + """acompletion() re-enters completion() and interceptor sub-calls reuse the + request kwargs, so the same configured points meet messages that already carry + litellm's own marks; the second pass must leave them as they are.""" + points = [{"location": "message", "role": "system"}, {"location": "tool_config"}] + first_params = {"cache_control_injection_points": copy.deepcopy(points)} + self._seed(first_params, copy.deepcopy(self.MARKED_MESSAGES)) + first = self._chat(first_params, copy.deepcopy(self.MARKED_MESSAGES)) + assert _count_cache_control(first) == 2 + assert first_params["cache_control_injection_points"] == [{"location": "tool_config"}] - def test_v1_messages_stand_down_when_content_block_marked(self): + second_params = {"cache_control_injection_points": copy.deepcopy(points)} + self._seed(second_params, copy.deepcopy(first)) + second = self._chat(second_params, copy.deepcopy(first)) + assert second == first + assert second_params["cache_control_injection_points"] == [{"location": "tool_config"}] + + def test_v1_messages_configured_point_applies_when_content_block_marked(self): messages = [ {"role": "user", "content": [{"type": "text", "text": "hi", "cache_control": {"type": "ephemeral"}}]} ] kwargs = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)} result_msgs, result_sys = self._inject(copy.deepcopy(messages), kwargs) assert result_msgs == messages - assert result_sys == "sys" + assert result_sys == [{"type": "text", "text": "sys", "cache_control": self.EPHEMERAL}] assert "cache_control_injection_points" not in kwargs - def test_v1_messages_stand_down_when_system_block_marked(self): - """A configured point targeting a message must not fire when the client - marked the system prompt; the old behavior injected into the message - because only the exact targeted position was guarded.""" + def test_v1_messages_tail_point_applies_when_system_block_marked(self): system = [{"type": "text", "text": "s", "cache_control": {"type": "ephemeral"}}] - kwargs = {"cache_control_injection_points": [{"location": "message", "role": "user"}]} + kwargs = {"cache_control_injection_points": copy.deepcopy(self.TAIL_POINT)} result_msgs, result_sys = self._inject(copy.deepcopy(self.V1_MESSAGES), kwargs, system=system) - assert result_msgs == self.V1_MESSAGES + assert result_msgs == [{"role": "user", "content": [{"type": "text", "text": "hi", "cache_control": self.EPHEMERAL}]}] assert result_sys == system - assert "cache_control_injection_points" not in kwargs - def test_v1_messages_stand_down_when_tools_marked(self): - tools = [{"name": "t", "input_schema": {}, "cache_control": {"type": "ephemeral"}}] + def test_v1_messages_configured_point_applies_when_tools_marked(self): kwargs = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)} - result_msgs, result_sys = self._inject(copy.deepcopy(self.V1_MESSAGES), kwargs, tools=tools) + result_msgs, result_sys = self._inject(copy.deepcopy(self.V1_MESSAGES), kwargs, tools=[self.MARKED_V1_TOOL]) assert result_msgs == self.V1_MESSAGES - assert result_sys == "sys" - assert "cache_control_injection_points" not in kwargs + assert result_sys == [{"type": "text", "text": "sys", "cache_control": self.EPHEMERAL}] + + @pytest.mark.parametrize( + "tool,expected_system", + [ + (MARKED_V1_TOOL, "sys"), + (UNMARKED_V1_TOOL, [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}]), + ], + ids=["marked", "unmarked"], + ) + def test_v1_messages_cap_counts_client_marked_tools(self, tool, expected_system): + kwargs = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)} + _, result_sys = self._inject(self._marked_user_turns(3), kwargs, tools=[tool]) + assert result_sys == expected_system def test_v1_messages_configured_points_apply_when_unmarked(self): kwargs = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)} _, result_sys = self._inject(copy.deepcopy(self.V1_MESSAGES), kwargs) assert result_sys == [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}] - @pytest.mark.parametrize( - "configured", - [None, CONFIGURED], - ids=["automatic_defaults", "configured_points"], - ) - def test_v1_messages_stands_down_for_root_cache_control(self, monkeypatch, configured): + def test_v1_messages_automatic_defaults_stand_down_for_root_cache_control(self, monkeypatch): monkeypatch.setattr(litellm, "enable_anthropic_prompt_caching", True) root_cache_control = {"type": "ephemeral"} kwargs = {"cache_control": root_cache_control, "litellm_metadata": {}} - if configured is not None: - kwargs["cache_control_injection_points"] = copy.deepcopy(configured) result_messages, result_system = self._inject(copy.deepcopy(self.V1_MESSAGES), kwargs) @@ -2210,17 +2287,33 @@ class TestConfiguredInjectionPointsStandDown: assert kwargs["cache_control"] is root_cache_control assert "litellm_gateway_injected_cache" not in kwargs["litellm_metadata"] + @pytest.mark.parametrize( + "marked_turns,expected_system", + [(2, [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}]), (3, "sys")], + ) + def test_v1_messages_configured_points_apply_with_root_cache_control_reserving_a_slot( + self, marked_turns, expected_system + ): + root_cache_control = {"type": "ephemeral"} + kwargs = { + "cache_control": root_cache_control, + "cache_control_injection_points": copy.deepcopy(self.CONFIGURED), + } + _, result_system = self._inject(self._marked_user_turns(marked_turns), kwargs) + assert result_system == expected_system + assert kwargs["cache_control"] is root_cache_control + def test_v1_messages_reentry_flow_preserves_tool_config_remainder(self): """The advisor interceptor re-enters anthropic_messages() with the outer request's kwargs and post-injection messages. The first pass applies the - message point and writes back a stamped tool_config remainder; the - re-entry must keep that remainder even though the messages and system - now carry litellm's own marks.""" + message point and writes back the tool_config remainder; the re-entry must + keep that remainder and add no mark even though the messages and system + now carry litellm's own.""" points = [{"location": "message", "role": "system"}, {"location": "tool_config"}] kwargs = {"cache_control_injection_points": copy.deepcopy(points)} msgs1, sys1 = self._inject(copy.deepcopy(self.V1_MESSAGES), kwargs) assert sys1[0]["cache_control"] == {"type": "ephemeral"} - expected_remainder = [{"location": "tool_config", "_litellm_judged": True}] + expected_remainder = [{"location": "tool_config"}] assert kwargs["cache_control_injection_points"] == expected_remainder msgs2, sys2 = self._inject(msgs1, kwargs, system=sys1) @@ -2459,22 +2552,22 @@ class TestOpenAIPromptCacheBreakpoint: assert system == [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}] assert kwargs == {} - def test_v1_messages_client_content_breakpoint_makes_configured_points_stand_down(self): + def test_v1_messages_configured_points_apply_beside_client_content_breakpoint(self): messages = [{"role": "user", "content": [{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}]}] kwargs = {"cache_control_injection_points": copy.deepcopy(self.SYSTEM_POINT)} result, system = self._inject(messages, "sys", kwargs) assert result == messages - assert system == "sys" - assert kwargs == {} + assert system == [{"type": "text", "text": "sys", "prompt_cache_breakpoint": self.EXPLICIT}] + assert kwargs == {"prompt_cache_options": self.EXPLICIT} - def test_v1_messages_client_system_breakpoint_makes_configured_points_stand_down(self): + def test_v1_messages_tail_point_applies_beside_client_system_breakpoint(self): system = [{"type": "text", "text": "sys", "prompt_cache_breakpoint": self.EXPLICIT}] messages = [{"role": "user", "content": [{"type": "text", "text": "hi"}]}] kwargs = {"cache_control_injection_points": [{"location": "message", "index": -1}]} result, result_system = self._inject(messages, system, kwargs) - assert result == messages + assert result == [{"role": "user", "content": [{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}]}] assert result_system == system - assert kwargs == {} + assert kwargs == {"prompt_cache_options": self.EXPLICIT} def test_chat_system_string_wrapped_with_block_breakpoint(self): params = {"cache_control_injection_points": copy.deepcopy(self.SYSTEM_POINT)} @@ -2538,18 +2631,25 @@ class TestOpenAIPromptCacheBreakpoint: assert processed[0] == {"role": "system", "content": "sys", "cache_control": {"type": "ephemeral"}} assert params == {} - def test_chat_client_breakpoint_makes_seeded_points_stand_down(self): + def test_chat_seeded_points_apply_beside_client_breakpoint(self): params = {"cache_control_injection_points": copy.deepcopy(self.SYSTEM_POINT)} + messages = [ + {"role": "system", "content": "sys"}, + {"role": "user", "content": [{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}]}, + ] AnthropicCacheControlHook.maybe_seed_default_injection_points( non_default_params=params, - messages=[ - {"role": "system", "content": "sys"}, - {"role": "user", "content": [{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}]}, - ], + messages=messages, model="openai/gpt-5.6", custom_llm_provider="openai", ) - assert params == {} + assert params["cache_control_injection_points"] == [ + {"location": "message", "role": "system", "_litellm_openai_dialect": True} + ] + _, processed, _ = self._chat(messages, params) + assert processed[0]["content"] == [{"type": "text", "text": "sys", "prompt_cache_breakpoint": self.EXPLICIT}] + assert processed[1] == messages[1] + assert params["prompt_cache_options"] == self.EXPLICIT def test_cap_counts_client_breakpoints_of_both_kinds(self): messages = [ @@ -3143,7 +3243,7 @@ class TestRecordGatewayInjection: assert kwargs["litellm_metadata"][self.KEY] == self.DEPLOYMENT def test_configured_points_skipping_a_marked_target_record_nothing(self): - """Configured injection stands down on client breakpoints, so no marker lands.""" + """A configured point whose target the client already marked places nothing, so no marker lands.""" kwargs: dict = { "litellm_metadata": {}, "cache_control_injection_points": [{"location": "message", "role": "system", "index": None}], From 171b33abfedf8e6ccded1bef7e5f9ce60081ad32 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 04:53:15 -0700 Subject: [PATCH 2/7] fix: leave tool-search tool marks out of the chat-path cache breakpoint census --- .../anthropic_cache_control_hook.py | 16 +++++++++--- litellm/types/llms/anthropic.py | 4 +++ .../test_anthropic_cache_control_hook.py | 25 ++++++++++++++++++- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/litellm/integrations/anthropic_cache_control_hook.py b/litellm/integrations/anthropic_cache_control_hook.py index b06372baa78..6f90acade10 100644 --- a/litellm/integrations/anthropic_cache_control_hook.py +++ b/litellm/integrations/anthropic_cache_control_hook.py @@ -33,6 +33,7 @@ from litellm.types.integrations.anthropic_cache_control_hook import ( CacheControlMessageInjectionPoint, ) from litellm.types.llms.anthropic import ( + ANTHROPIC_TOOL_SEARCH_TOOL_TYPES, AllAnthropicToolsValues, AnthropicSystemMessageContent, ) @@ -127,6 +128,10 @@ def _tool_carries_cache_breakpoint(tool: object) -> bool: ) +def _chat_transform_drops_tool_cache_control(tool: object) -> bool: + return isinstance(tool, dict) and tool.get("type") in ANTHROPIC_TOOL_SEARCH_TOOL_TYPES + + def _accepts_prompt_cache_breakpoint(block: object) -> bool: return isinstance(block, dict) and block.get("type") in OPENAI_PROMPT_CACHE_BREAKPOINT_BLOCK_TYPES @@ -303,9 +308,9 @@ class AnthropicCacheControlHook(CustomPromptManagement): """Client breakpoints outside messages and system that the provider cap still counts. A tool carries its mark at the top level (Anthropic shape) or under ``function`` - (OpenAI shape); the Anthropic chat transform forwards both. A top-level - ``cache_control`` is Anthropic's automatic caching, which places one breakpoint - of its own on top of the explicit ones. + (OpenAI shape). A top-level ``cache_control`` is Anthropic's automatic caching, + which places one breakpoint of its own on top of the explicit ones. Callers + pass only the tools whose mark reaches the provider on their path. """ automatic_blocks: Final = 1 if cache_control is not None else 0 tool_blocks: Final = sum(1 for tool in tools if _tool_carries_cache_breakpoint(tool)) if tools else 0 @@ -786,10 +791,13 @@ class AnthropicCacheControlHook(CustomPromptManagement): """ configured: Final = non_default_params.get("cache_control_injection_points") if configured: + tools_keeping_marks: Final = tuple( + tool for tool in tools or () if not _chat_transform_drops_tool_cache_control(tool) + ) non_default_params["cache_control_injection_points"] = AnthropicCacheControlHook._stamped_for_prompt_hook( configured, AnthropicCacheControlHook.count_external_cache_breakpoints( - tools, non_default_params.get("cache_control") + tools_keeping_marks, non_default_params.get("cache_control") ), model, custom_llm_provider, diff --git a/litellm/types/llms/anthropic.py b/litellm/types/llms/anthropic.py index bcd24695f25..43a7b0e0e9c 100644 --- a/litellm/types/llms/anthropic.py +++ b/litellm/types/llms/anthropic.py @@ -753,6 +753,10 @@ class ANTHROPIC_BETA_HEADER_VALUES(str, Enum): # Tool search beta header constant (for Anthropic direct API and Microsoft Foundry) ANTHROPIC_TOOL_SEARCH_BETA_HEADER: Final = "advanced-tool-use-2025-11-20" +ANTHROPIC_TOOL_SEARCH_TOOL_TYPES: Final = frozenset( + {"tool_search_tool_regex_20251119", "tool_search_tool_bm25_20251119"} +) + # Effort beta header constant ANTHROPIC_EFFORT_BETA_HEADER: Final = "effort-2025-11-24" diff --git a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py index 3424cc5fed6..1dfd9cf619b 100644 --- a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py +++ b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py @@ -2114,6 +2114,16 @@ class TestConfiguredInjectionPointsSurviveClientMarks: UNMARKED_TOOL = {"type": "function", "function": {"name": "t", "parameters": {}}} MARKED_V1_TOOL = {"name": "t", "input_schema": {}, "cache_control": {"type": "ephemeral"}} UNMARKED_V1_TOOL = {"name": "t", "input_schema": {}} + MARKED_TOOL_SEARCH_REGEX = { + "type": "tool_search_tool_regex_20251119", + "name": "tool_search", + "cache_control": {"type": "ephemeral"}, + } + MARKED_TOOL_SEARCH_BM25 = { + "type": "tool_search_tool_bm25_20251119", + "name": "tool_search", + "cache_control": {"type": "ephemeral"}, + } @staticmethod def _marked_user_turns(count): @@ -2199,6 +2209,17 @@ class TestConfiguredInjectionPointsSurviveClientMarks: processed = self._chat(params, copy.deepcopy(messages)) assert _count_cache_control(processed) == 3 + injected + @pytest.mark.parametrize("tool", [MARKED_TOOL_SEARCH_REGEX, MARKED_TOOL_SEARCH_BM25], ids=["regex", "bm25"]) + def test_chat_cap_ignores_marked_tool_search_tools(self, tool): + """The chat transform strips cache_control from tool-search tools before the + request leaves, so a client mark there never reaches the provider's cap and + must not cost the configured point its fourth slot.""" + messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(3)] + params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)} + self._seed(params, copy.deepcopy(messages), tools=[tool]) + processed = self._chat(params, copy.deepcopy(messages)) + assert _count_cache_control(processed) == 4 + @pytest.mark.parametrize("marked_turns,injected", [(2, 1), (3, 0)]) def test_chat_root_cache_control_reserves_a_slot(self, marked_turns, injected): """Anthropic's automatic caching (a top-level ``cache_control``) places one @@ -2261,9 +2282,11 @@ class TestConfiguredInjectionPointsSurviveClientMarks: "tool,expected_system", [ (MARKED_V1_TOOL, "sys"), + (MARKED_TOOL_SEARCH_REGEX, "sys"), + (MARKED_TOOL_SEARCH_BM25, "sys"), (UNMARKED_V1_TOOL, [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}]), ], - ids=["marked", "unmarked"], + ids=["marked", "marked_tool_search_regex", "marked_tool_search_bm25", "unmarked"], ) def test_v1_messages_cap_counts_client_marked_tools(self, tool, expected_system): kwargs = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)} From 752092592482299d6785970ccde6c289815082b3 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 06:28:16 -0700 Subject: [PATCH 3/7] fix: forward a tool_config point only while the cap has a slot left --- .../anthropic_cache_control_hook.py | 67 +++++++---- .../test_anthropic_cache_control_hook.py | 112 ++++++++++++++++-- 2 files changed, 142 insertions(+), 37 deletions(-) diff --git a/litellm/integrations/anthropic_cache_control_hook.py b/litellm/integrations/anthropic_cache_control_hook.py index 6f90acade10..cff7d23935c 100644 --- a/litellm/integrations/anthropic_cache_control_hook.py +++ b/litellm/integrations/anthropic_cache_control_hook.py @@ -209,14 +209,12 @@ class AnthropicCacheControlHook(CustomPromptManagement): # Create a deep copy of messages to avoid modifying the original list processed_messages = copy.deepcopy(messages) - # Separate message-level and non-message-level injection points - message_points: Final[list[CacheControlMessageInjectionPoint]] = [] - remaining_points: Final[list[CacheControlInjectionPoint]] = [] - for point in injection_points: - if point.get("location") == "message": - message_points.append(cast(CacheControlMessageInjectionPoint, point)) - else: - remaining_points.append(point) + message_points: Final = tuple( + cast(CacheControlMessageInjectionPoint, point) + for point in injection_points + if point.get("location") == "message" + ) + remaining_points: Final = tuple(point for point in injection_points if point.get("location") != "message") stamped_dialect: Final = injection_points[0].get("_litellm_openai_dialect") openai_dialect: Final = ( @@ -243,10 +241,9 @@ class AnthropicCacheControlHook(CustomPromptManagement): else tuple(message_points) ) stamped_external: Final = injection_points[0].get(EXTERNAL_BREAKPOINTS_STAMP) + external_breakpoints: Final = stamped_external if isinstance(stamped_external, int) else 0 reserved_blocks: Final = AnthropicCacheControlHook._blocks_reserved_outside_messages( - remaining_points, - stamped_external if isinstance(stamped_external, int) else 0, - openai_dialect, + remaining_points, external_breakpoints, openai_dialect ) breakpoints_before: Final = AnthropicCacheControlHook.count_request_cache_breakpoints(processed_messages) processed_messages = self._apply_message_injections( @@ -266,7 +263,14 @@ class AnthropicCacheControlHook(CustomPromptManagement): # `instructions`, which is only a system message once the bridge builds one. A later # pass re-applies them safely: a target that already carries a mark is skipped and # the census counts every mark on the wire, litellm's own included. - carried_points: Final[Sequence[CacheControlInjectionPoint]] = (*remaining_points, *carried_message_points) + carried_points: Final[Sequence[CacheControlInjectionPoint]] = ( + *AnthropicCacheControlHook._points_with_a_slot_left( + remaining_points, + AnthropicCacheControlHook.count_request_cache_breakpoints(processed_messages) + external_breakpoints, + openai_dialect, + ), + *carried_message_points, + ) if carried_points: non_default_params["cache_control_injection_points"] = list(carried_points) @@ -331,6 +335,16 @@ class AnthropicCacheControlHook(CustomPromptManagement): tool_config_blocks: Final = 1 if any(p.get("location") == "tool_config" for p in remaining_points) else 0 return external_breakpoints + tool_config_blocks + @staticmethod + def _points_with_a_slot_left( + remaining_points: Sequence[CacheControlInjectionPoint], breakpoints_on_wire: int, openai_dialect: bool + ) -> tuple[CacheControlInjectionPoint, ...]: + """A ``tool_config`` point becomes a cachePoint the Bedrock converse transform never + counts against the cap, so it is forwarded only while the wire still has a slot.""" + if openai_dialect or breakpoints_on_wire < MAX_CACHE_CONTROL_BLOCKS: + return tuple(remaining_points) + return tuple(point for point in remaining_points if point.get("location") != "tool_config") + @staticmethod def _apply_message_injections( points: Sequence[CacheControlMessageInjectionPoint], @@ -529,19 +543,14 @@ class AnthropicCacheControlHook(CustomPromptManagement): processed_messages: list[dict] = copy.deepcopy(messages) processed_system = copy.deepcopy(system) if system is not None else None - message_points: Final[list[CacheControlMessageInjectionPoint]] = [] - system_points: Final[list[CacheControlMessageInjectionPoint]] = [] - remaining_points: Final[list[CacheControlInjectionPoint]] = [] - - for point in injection_points: - if point.get("location") == "message": - msg_point = cast(CacheControlMessageInjectionPoint, point) - if msg_point.get("role") == "system": - system_points.append(msg_point) - else: - message_points.append(msg_point) - else: - remaining_points.append(point) + role_points: Final = tuple( + cast(CacheControlMessageInjectionPoint, point) + for point in injection_points + if point.get("location") == "message" + ) + system_points: Final = tuple(point for point in role_points if point.get("role") == "system") + message_points: Final = tuple(point for point in role_points if point.get("role") != "system") + remaining_points: Final = tuple(point for point in injection_points if point.get("location") != "message") reserved_blocks: Final = AnthropicCacheControlHook._blocks_reserved_outside_messages( remaining_points, external_breakpoints, openai_dialect @@ -581,8 +590,14 @@ class AnthropicCacheControlHook(CustomPromptManagement): max_blocks=max_blocks - system_blocks, openai_dialect=openai_dialect, ) + forwarded_points: Final = AnthropicCacheControlHook._points_with_a_slot_left( + remaining_points, + AnthropicCacheControlHook.count_request_cache_breakpoints(processed_messages, processed_system) + + external_breakpoints, + openai_dialect, + ) - return processed_messages, processed_system, remaining_points + return processed_messages, processed_system, list(forwarded_points) @staticmethod def _default_control() -> ChatCompletionCachedContent: diff --git a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py index 1dfd9cf619b..2723526ae6b 100644 --- a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py +++ b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py @@ -1335,17 +1335,7 @@ async def test_cache_control_hook_bedrock_payload_caps_with_tool_config_point(mo ) request_body = json.loads(mock_post.call_args.kwargs["data"]) - - cache_points = sum( - 1 for block in request_body.get("system", []) if isinstance(block, dict) and "cachePoint" in block - ) - for msg in request_body.get("messages", []): - content = msg.get("content", []) - if isinstance(content, list): - cache_points += sum(1 for block in content if isinstance(block, dict) and "cachePoint" in block) - for tool in request_body.get("toolConfig", {}).get("tools", []): - if isinstance(tool, dict) and "cachePoint" in tool: - cache_points += 1 + cache_points = _count_converse_cache_points(request_body) assert cache_points <= 4, ( f"Bedrock payload exceeded Anthropic's 4 cache_control block limit " @@ -1353,6 +1343,89 @@ async def test_cache_control_hook_bedrock_payload_caps_with_tool_config_point(mo ) +def _count_converse_cache_points(request_body: dict) -> int: + system_points = sum( + 1 for block in request_body.get("system", []) if isinstance(block, dict) and "cachePoint" in block + ) + message_points = sum( + 1 + for msg in request_body.get("messages", []) + if isinstance(msg.get("content"), list) + for block in msg["content"] + if isinstance(block, dict) and "cachePoint" in block + ) + tool_points = sum( + 1 + for tool in request_body.get("toolConfig", {}).get("tools", []) + if isinstance(tool, dict) and "cachePoint" in tool + ) + return system_points + message_points + tool_points + + +@pytest.mark.asyncio +async def test_cache_control_hook_bedrock_tool_config_point_stands_down_when_client_marks_fill_the_cap( + monkeypatch: pytest.MonkeyPatch, +): + """The client's own four marks fill the cap, so the configured tool_config point must + not land as a fifth cachePoint in the converse payload.""" + with patch.dict( + os.environ, + { + "AWS_ACCESS_KEY_ID": "fake_access_key_id", + "AWS_SECRET_ACCESS_KEY": "fake_secret_access_key", + "AWS_REGION_NAME": "us-east-1", + }, + ): + monkeypatch.setattr(litellm, "callbacks", [AnthropicCacheControlHook()]) + + mock_response = MagicMock() + mock_response.json.return_value = { + "output": {"message": {"role": "assistant", "content": "ok"}}, + "stopReason": "end_turn", + "usage": {"inputTokens": 100, "outputTokens": 4, "totalTokens": 104}, + } + mock_response.status_code = 200 + + client = AsyncHTTPHandler() + with patch.object(client, "post", return_value=mock_response) as mock_post: + marked = {"type": "ephemeral"} + messages = [ + {"role": "system", "content": [{"type": "text", "text": "sys", "cache_control": marked}]}, + *( + {"role": "user", "content": [{"type": "text", "text": f"turn {i}", "cache_control": marked}]} + for i in range(3) + ), + {"role": "user", "content": "What is the weather?"}, + ] + + await litellm.acompletion( + model="bedrock/us.anthropic.claude-opus-4-6-v1:0", + messages=messages, + max_tokens=32, + tools=[ + { + "type": "function", + "function": { + "name": "get_weather", + "description": "Get weather for a location", + "parameters": { + "type": "object", + "properties": {"location": {"type": "string"}}, + "required": ["location"], + }, + }, + } + ], + cache_control_injection_points=[{"location": "tool_config"}], + client=client, + ) + + request_body = json.loads(mock_post.call_args.kwargs["data"]) + + assert _count_converse_cache_points(request_body) == 4 + assert not any("cachePoint" in tool for tool in request_body["toolConfig"]["tools"]) + + class TestApplyToAnthropicMessagesRequest: """Tests for apply_to_anthropic_messages_request (v1/messages cache control).""" @@ -2091,6 +2164,7 @@ class TestConfiguredInjectionPointsSurviveClientMarks: CONFIGURED = [{"location": "message", "role": "system"}] TAIL_POINT = [{"location": "message", "index": -1}] + TOOL_CONFIG_POINT = [{"location": "tool_config"}] EPHEMERAL = {"type": "ephemeral"} CLEAN_MESSAGES: List[AllMessageValues] = [ @@ -2220,6 +2294,22 @@ class TestConfiguredInjectionPointsSurviveClientMarks: processed = self._chat(params, copy.deepcopy(messages)) assert _count_cache_control(processed) == 4 + @pytest.mark.parametrize("marked_turns,forwarded", [(3, ["tool_config"]), (4, [])], ids=["slot_left", "cap_full"]) + def test_chat_forwards_tool_config_point_only_while_a_slot_is_left(self, marked_turns, forwarded): + """A forwarded tool_config point becomes a Bedrock cachePoint unconditionally, so + it stands down once the client's own marks fill the cap.""" + messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(marked_turns)] + params = {"cache_control_injection_points": copy.deepcopy(self.TOOL_CONFIG_POINT)} + self._seed(params, copy.deepcopy(messages), tools=[self.UNMARKED_TOOL]) + self._chat(params, copy.deepcopy(messages)) + assert [p["location"] for p in params.get("cache_control_injection_points", [])] == forwarded + + @pytest.mark.parametrize("marked_turns,forwarded", [(3, ["tool_config"]), (4, [])], ids=["slot_left", "cap_full"]) + def test_v1_messages_forwards_tool_config_point_only_while_a_slot_is_left(self, marked_turns, forwarded): + kwargs = {"cache_control_injection_points": copy.deepcopy(self.TOOL_CONFIG_POINT)} + self._inject(self._marked_user_turns(marked_turns), kwargs, tools=[self.UNMARKED_V1_TOOL]) + assert [p["location"] for p in kwargs.get("cache_control_injection_points", [])] == forwarded + @pytest.mark.parametrize("marked_turns,injected", [(2, 1), (3, 0)]) def test_chat_root_cache_control_reserves_a_slot(self, marked_turns, injected): """Anthropic's automatic caching (a top-level ``cache_control``) places one From b0971ee0bac259d278313eedd9e43bd6835da671 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:22:41 -0700 Subject: [PATCH 4/7] fix: count extra_body tools and cache_control in place of the direct ones --- .../anthropic_cache_control_hook.py | 22 +++++------- .../test_anthropic_cache_control_hook.py | 36 +++++++++++++++++++ 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/litellm/integrations/anthropic_cache_control_hook.py b/litellm/integrations/anthropic_cache_control_hook.py index f9b9238b181..036b9d033cd 100644 --- a/litellm/integrations/anthropic_cache_control_hook.py +++ b/litellm/integrations/anthropic_cache_control_hook.py @@ -318,26 +318,22 @@ class AnthropicCacheControlHook(CustomPromptManagement): A tool carries its mark at the top level (Anthropic shape) or under ``function`` (OpenAI shape). A top-level ``cache_control`` is Anthropic's automatic caching, - which places one breakpoint of its own on top of the explicit ones. Marks the - client sends through the ``extra_body`` envelope of ``request_kwargs`` reach the - wire too and count the same way. Callers pass only the tools whose mark reaches - the provider on their path. + which places one breakpoint of its own on top of the explicit ones. The + ``extra_body`` envelope of ``request_kwargs`` is merged over the request on the + wire, so a ``tools`` or ``cache_control`` it carries replaces the direct value + and is counted in its place. Callers pass only the tools whose mark reaches the + provider on their path. """ extra_body: Final = ( _validated_object_mapping(AnthropicCacheControlHook._request_value(request_kwargs, "extra_body")) or {} ) - automatic_blocks: Final = sum( - 1 for control in (cache_control, extra_body.get("cache_control")) if control is not None - ) - tool_blocks: Final = sum( - 1 - for tool in (*(tools or ()), *(_validated_object_list(extra_body.get("tools")) or ())) - if _tool_carries_cache_breakpoint(tool) - ) + wire_cache_control: Final = extra_body.get("cache_control", cache_control) + wire_tools: Final = _validated_object_list(extra_body["tools"]) if "tools" in extra_body else tools + tool_blocks: Final = sum(1 for tool in wire_tools or () if _tool_carries_cache_breakpoint(tool)) envelope_blocks: Final = AnthropicCacheControlHook.count_request_cache_breakpoints( _validated_object_list(extra_body.get("messages")) or (), extra_body.get("system") ) - return automatic_blocks + tool_blocks + envelope_blocks + return int(wire_cache_control is not None) + tool_blocks + envelope_blocks @staticmethod def _blocks_reserved_outside_messages( diff --git a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py index 78aee3048ca..041b00c6c70 100644 --- a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py +++ b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py @@ -2596,6 +2596,42 @@ class TestConfiguredInjectionPointsSurviveClientMarks: _, result_sys = self._inject(self._marked_user_turns(3), kwargs) assert result_sys == expected_system + @pytest.mark.parametrize( + "params,tools,marked_turns,injected", + [ + ({"extra_body": {"tools": [MARKED_TOOL_TOP_LEVEL]}}, [MARKED_TOOL_TOP_LEVEL], 2, 1), + ({"extra_body": {"tools": [UNMARKED_TOOL]}}, [MARKED_TOOL_TOP_LEVEL], 3, 1), + ({"extra_body": {"tools": [MARKED_TOOL_TOP_LEVEL]}}, [UNMARKED_TOOL], 3, 0), + ({"extra_body": {"cache_control": EPHEMERAL}, "cache_control": EPHEMERAL}, None, 2, 1), + ], + ids=["same_marked_tool_both_ways", "extra_body_unmarks", "extra_body_marks", "root_cache_control_both_ways"], + ) + def test_chat_cap_counts_extra_body_fields_in_place_of_the_direct_ones(self, params, tools, marked_turns, injected): + """``extra_body`` is merged over the request on the wire, so its ``tools`` and + ``cache_control`` replace the direct ones rather than adding to them.""" + messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(marked_turns)] + params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED), **copy.deepcopy(params)} + self._seed(params, copy.deepcopy(messages), tools=tools) + processed = self._chat(params, copy.deepcopy(messages)) + assert _count_cache_control(processed) == marked_turns + injected + + @pytest.mark.parametrize( + "kwargs,tools,marked_turns,expected_system", + [ + ({"extra_body": {"tools": [MARKED_V1_TOOL]}}, [MARKED_V1_TOOL], 2, [{"type": "text", "text": "sys", "cache_control": EPHEMERAL}]), + ({"extra_body": {"tools": [UNMARKED_V1_TOOL]}}, [MARKED_V1_TOOL], 3, [{"type": "text", "text": "sys", "cache_control": EPHEMERAL}]), + ({"extra_body": {"tools": [MARKED_V1_TOOL]}}, [UNMARKED_V1_TOOL], 3, "sys"), + ({"extra_body": {"cache_control": EPHEMERAL}, "cache_control": EPHEMERAL}, None, 2, [{"type": "text", "text": "sys", "cache_control": EPHEMERAL}]), + ], + ids=["same_marked_tool_both_ways", "extra_body_unmarks", "extra_body_marks", "root_cache_control_both_ways"], + ) + def test_v1_messages_cap_counts_extra_body_fields_in_place_of_the_direct_ones( + self, kwargs, tools, marked_turns, expected_system + ): + kwargs = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED), **copy.deepcopy(kwargs)} + _, result_sys = self._inject(self._marked_user_turns(marked_turns), kwargs, tools=tools) + assert result_sys == expected_system + def test_v1_messages_automatic_defaults_stand_down_for_root_cache_control(self, monkeypatch): monkeypatch.setattr(litellm, "enable_anthropic_prompt_caching", True) root_cache_control = {"type": "ephemeral"} From 2c3fc4cbff831a389baa76019c1380d1827f9b11 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:34:23 -0700 Subject: [PATCH 5/7] test: drop narrating docstrings and wrap long lines in the cache hook tests --- .../test_anthropic_cache_control_hook.py | 58 ++++++------------- 1 file changed, 17 insertions(+), 41 deletions(-) diff --git a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py index 041b00c6c70..5f9d9e5bd9f 100644 --- a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py +++ b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py @@ -1366,8 +1366,6 @@ def _count_converse_cache_points(request_body: dict) -> int: async def test_cache_control_hook_bedrock_tool_config_point_stands_down_when_client_marks_fill_the_cap( monkeypatch: pytest.MonkeyPatch, ): - """The client's own four marks fill the cap, so the configured tool_config point must - not land as a fifth cachePoint in the converse payload.""" with patch.dict( os.environ, { @@ -2331,13 +2329,6 @@ class TestPerKeyEnablePromptCaching: class TestConfiguredInjectionPointsSurviveClientMarks: - """Configured cache_control_injection_points are an explicit instruction, so they - apply alongside the client's own cache_control marks (LIT-7586, #40675) instead of - standing down on them. What bounds them is Anthropic's four-block cap, which has to - count the client's marks on messages, system, tools and the root ``cache_control`` - (LIT-4582: a client-marked tool the cap could not see produced "Found 5" 400s). - Only the automatic defaults stand down on client marks.""" - CONFIGURED = [{"location": "message", "role": "system"}] TAIL_POINT = [{"location": "message", "index": -1}] TOOL_CONFIG_POINT = [{"location": "tool_config"}] @@ -2360,10 +2351,14 @@ class TestConfiguredInjectionPointsSurviveClientMarks: "function": {"name": "t", "parameters": {}}, "cache_control": {"type": "ephemeral"}, } - MARKED_TOOL_NESTED = {"type": "function", "function": {"name": "t", "parameters": {}, "cache_control": {"type": "ephemeral"}}} + MARKED_TOOL_NESTED = { + "type": "function", + "function": {"name": "t", "parameters": {}, "cache_control": {"type": "ephemeral"}}, + } UNMARKED_TOOL = {"type": "function", "function": {"name": "t", "parameters": {}}} MARKED_V1_TOOL = {"name": "t", "input_schema": {}, "cache_control": {"type": "ephemeral"}} UNMARKED_V1_TOOL = {"name": "t", "input_schema": {}} + MARKED_SYSTEM = [{"type": "text", "text": "sys", "cache_control": EPHEMERAL}] MARKED_TOOL_SEARCH_REGEX = { "type": "tool_search_tool_regex_20251119", "name": "tool_search", @@ -2413,8 +2408,6 @@ class TestConfiguredInjectionPointsSurviveClientMarks: ) def test_chat_tail_point_applies_when_client_marked_the_system_block(self): - """The issue's shape: the client caches its system prompt, the deployment is - configured to cache the trailing turn, and both marks must reach the provider.""" messages: List[AllMessageValues] = [ {"role": "system", "content": [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}]}, {"role": "user", "content": "history"}, @@ -2450,9 +2443,6 @@ class TestConfiguredInjectionPointsSurviveClientMarks: ids=["marked_top_level", "marked_nested_in_function", "unmarked"], ) def test_chat_cap_counts_client_marked_tools(self, tool, injected): - """LIT-4582 regression: the prompt-management hook never sees the tools, so the - seeding pass has to carry the client's tool marks into the cap or a configured - point lands as a fifth block.""" messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(3)] params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)} self._seed(params, copy.deepcopy(messages), tools=[tool]) @@ -2461,9 +2451,6 @@ class TestConfiguredInjectionPointsSurviveClientMarks: @pytest.mark.parametrize("tool", [MARKED_TOOL_SEARCH_REGEX, MARKED_TOOL_SEARCH_BM25], ids=["regex", "bm25"]) def test_chat_cap_ignores_marked_tool_search_tools(self, tool): - """The chat transform strips cache_control from tool-search tools before the - request leaves, so a client mark there never reaches the provider's cap and - must not cost the configured point its fourth slot.""" messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(3)] params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED)} self._seed(params, copy.deepcopy(messages), tools=[tool]) @@ -2472,8 +2459,6 @@ class TestConfiguredInjectionPointsSurviveClientMarks: @pytest.mark.parametrize("marked_turns,forwarded", [(3, ["tool_config"]), (4, [])], ids=["slot_left", "cap_full"]) def test_chat_forwards_tool_config_point_only_while_a_slot_is_left(self, marked_turns, forwarded): - """A forwarded tool_config point becomes a Bedrock cachePoint unconditionally, so - it stands down once the client's own marks fill the cap.""" messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(marked_turns)] params = {"cache_control_injection_points": copy.deepcopy(self.TOOL_CONFIG_POINT)} self._seed(params, copy.deepcopy(messages), tools=[self.UNMARKED_TOOL]) @@ -2488,8 +2473,6 @@ class TestConfiguredInjectionPointsSurviveClientMarks: @pytest.mark.parametrize("marked_turns,injected", [(2, 1), (3, 0)]) def test_chat_root_cache_control_reserves_a_slot(self, marked_turns, injected): - """Anthropic's automatic caching (a top-level ``cache_control``) places one - breakpoint of its own, so it counts toward the cap like a client mark.""" messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(marked_turns)] root_cache_control = {"type": "ephemeral"} params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED), "cache_control": root_cache_control} @@ -2505,9 +2488,6 @@ class TestConfiguredInjectionPointsSurviveClientMarks: assert params["cache_control_injection_points"] is configured def test_chat_reentry_over_injected_messages_adds_no_duplicate_marks(self): - """acompletion() re-enters completion() and interceptor sub-calls reuse the - request kwargs, so the same configured points meet messages that already carry - litellm's own marks; the second pass must leave them as they are.""" points = [{"location": "message", "role": "system"}, {"location": "tool_config"}] first_params = {"cache_control_injection_points": copy.deepcopy(points)} self._seed(first_params, copy.deepcopy(self.MARKED_MESSAGES)) @@ -2535,7 +2515,9 @@ class TestConfiguredInjectionPointsSurviveClientMarks: system = [{"type": "text", "text": "s", "cache_control": {"type": "ephemeral"}}] kwargs = {"cache_control_injection_points": copy.deepcopy(self.TAIL_POINT)} result_msgs, result_sys = self._inject(copy.deepcopy(self.V1_MESSAGES), kwargs, system=system) - assert result_msgs == [{"role": "user", "content": [{"type": "text", "text": "hi", "cache_control": self.EPHEMERAL}]}] + assert result_msgs == [ + {"role": "user", "content": [{"type": "text", "text": "hi", "cache_control": self.EPHEMERAL}]} + ] assert result_sys == system def test_v1_messages_configured_point_applies_when_tools_marked(self): @@ -2574,8 +2556,6 @@ class TestConfiguredInjectionPointsSurviveClientMarks: ids=["marked_tool", "root_cache_control", "unmarked_tool"], ) def test_chat_cap_counts_client_marks_sent_through_extra_body(self, extra_body, injected): - """Marks a client sends inside ``extra_body`` reach the wire like any other, so - the seeding pass has to count them or a configured point lands as a fifth block.""" messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(3)] params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED), "extra_body": extra_body} self._seed(params, copy.deepcopy(messages)) @@ -2607,8 +2587,6 @@ class TestConfiguredInjectionPointsSurviveClientMarks: ids=["same_marked_tool_both_ways", "extra_body_unmarks", "extra_body_marks", "root_cache_control_both_ways"], ) def test_chat_cap_counts_extra_body_fields_in_place_of_the_direct_ones(self, params, tools, marked_turns, injected): - """``extra_body`` is merged over the request on the wire, so its ``tools`` and - ``cache_control`` replace the direct ones rather than adding to them.""" messages = [{"role": "system", "content": "sys"}, *self._marked_user_turns(marked_turns)] params = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED), **copy.deepcopy(params)} self._seed(params, copy.deepcopy(messages), tools=tools) @@ -2618,10 +2596,10 @@ class TestConfiguredInjectionPointsSurviveClientMarks: @pytest.mark.parametrize( "kwargs,tools,marked_turns,expected_system", [ - ({"extra_body": {"tools": [MARKED_V1_TOOL]}}, [MARKED_V1_TOOL], 2, [{"type": "text", "text": "sys", "cache_control": EPHEMERAL}]), - ({"extra_body": {"tools": [UNMARKED_V1_TOOL]}}, [MARKED_V1_TOOL], 3, [{"type": "text", "text": "sys", "cache_control": EPHEMERAL}]), + ({"extra_body": {"tools": [MARKED_V1_TOOL]}}, [MARKED_V1_TOOL], 2, MARKED_SYSTEM), + ({"extra_body": {"tools": [UNMARKED_V1_TOOL]}}, [MARKED_V1_TOOL], 3, MARKED_SYSTEM), ({"extra_body": {"tools": [MARKED_V1_TOOL]}}, [UNMARKED_V1_TOOL], 3, "sys"), - ({"extra_body": {"cache_control": EPHEMERAL}, "cache_control": EPHEMERAL}, None, 2, [{"type": "text", "text": "sys", "cache_control": EPHEMERAL}]), + ({"extra_body": {"cache_control": EPHEMERAL}, "cache_control": EPHEMERAL}, None, 2, MARKED_SYSTEM), ], ids=["same_marked_tool_both_ways", "extra_body_unmarks", "extra_body_marks", "root_cache_control_both_ways"], ) @@ -2661,11 +2639,6 @@ class TestConfiguredInjectionPointsSurviveClientMarks: assert kwargs["cache_control"] is root_cache_control def test_v1_messages_reentry_flow_preserves_tool_config_remainder(self): - """The advisor interceptor re-enters anthropic_messages() with the outer - request's kwargs and post-injection messages. The first pass applies the - message point and writes back the tool_config remainder; the re-entry must - keep that remainder and add no mark even though the messages and system - now carry litellm's own.""" points = [{"location": "message", "role": "system"}, {"location": "tool_config"}] kwargs = {"cache_control_injection_points": copy.deepcopy(points)} msgs1, sys1 = self._inject(copy.deepcopy(self.V1_MESSAGES), kwargs) @@ -2910,7 +2883,9 @@ class TestOpenAIPromptCacheBreakpoint: assert kwargs == {} def test_v1_messages_configured_points_apply_beside_client_content_breakpoint(self): - messages = [{"role": "user", "content": [{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}]}] + messages = [ + {"role": "user", "content": [{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}]} + ] kwargs = {"cache_control_injection_points": copy.deepcopy(self.SYSTEM_POINT)} result, system = self._inject(messages, "sys", kwargs) assert result == messages @@ -2922,7 +2897,9 @@ class TestOpenAIPromptCacheBreakpoint: messages = [{"role": "user", "content": [{"type": "text", "text": "hi"}]}] kwargs = {"cache_control_injection_points": [{"location": "message", "index": -1}]} result, result_system = self._inject(messages, system, kwargs) - assert result == [{"role": "user", "content": [{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}]}] + assert result == [ + {"role": "user", "content": [{"type": "text", "text": "hi", "prompt_cache_breakpoint": self.EXPLICIT}]} + ] assert result_system == system assert kwargs == {"prompt_cache_options": self.EXPLICIT} @@ -3600,7 +3577,6 @@ class TestRecordGatewayInjection: assert kwargs["litellm_metadata"][self.KEY] == self.DEPLOYMENT def test_configured_points_skipping_a_marked_target_record_nothing(self): - """A configured point whose target the client already marked places nothing, so no marker lands.""" kwargs: dict = { "litellm_metadata": {}, "cache_control_injection_points": [{"location": "message", "role": "system", "index": None}], From 827d1c99a08d4809ddbea9047cbaa1191d0730e4 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Sat, 19 Sep 2026 18:48:39 -0700 Subject: [PATCH 6/7] test: type the cache hook test helpers --- .../test_anthropic_cache_control_hook.py | 55 +++++++++++-------- 1 file changed, 33 insertions(+), 22 deletions(-) diff --git a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py index 5f9d9e5bd9f..fd62a26c354 100644 --- a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py +++ b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py @@ -4,10 +4,11 @@ import os import subprocess import sys import textwrap -from typing import List, Optional, Tuple +from typing import Final, List, Optional, Tuple from unittest.mock import MagicMock, patch import pytest +from pydantic import BaseModel, ConfigDict import litellm from litellm.integrations.anthropic_cache_control_hook import ( @@ -1334,7 +1335,7 @@ async def test_cache_control_hook_bedrock_payload_caps_with_tool_config_point(mo client=client, ) - request_body = json.loads(mock_post.call_args.kwargs["data"]) + request_body = _ConverseBody.model_validate_json(mock_post.call_args.kwargs["data"]) cache_points = _count_converse_cache_points(request_body) assert cache_points <= 4, ( @@ -1343,23 +1344,33 @@ async def test_cache_control_hook_bedrock_payload_caps_with_tool_config_point(mo ) -def _count_converse_cache_points(request_body: dict) -> int: - system_points = sum( - 1 for block in request_body.get("system", []) if isinstance(block, dict) and "cachePoint" in block +class _ConverseMessage(BaseModel): + model_config = ConfigDict(frozen=True) + + content: tuple[dict[str, object], ...] = () + + +class _ConverseToolConfig(BaseModel): + model_config = ConfigDict(frozen=True) + + tools: tuple[dict[str, object], ...] = () + + +class _ConverseBody(BaseModel): + model_config = ConfigDict(frozen=True) + + system: tuple[dict[str, object], ...] = () + messages: tuple[_ConverseMessage, ...] = () + toolConfig: _ConverseToolConfig = _ConverseToolConfig() + + +def _count_converse_cache_points(request_body: _ConverseBody) -> int: + blocks: Final = ( + *request_body.system, + *(block for message in request_body.messages for block in message.content), + *request_body.toolConfig.tools, ) - message_points = sum( - 1 - for msg in request_body.get("messages", []) - if isinstance(msg.get("content"), list) - for block in msg["content"] - if isinstance(block, dict) and "cachePoint" in block - ) - tool_points = sum( - 1 - for tool in request_body.get("toolConfig", {}).get("tools", []) - if isinstance(tool, dict) and "cachePoint" in tool - ) - return system_points + message_points + tool_points + return sum(1 for block in blocks if "cachePoint" in block) @pytest.mark.asyncio @@ -1418,10 +1429,10 @@ async def test_cache_control_hook_bedrock_tool_config_point_stands_down_when_cli client=client, ) - request_body = json.loads(mock_post.call_args.kwargs["data"]) + request_body = _ConverseBody.model_validate_json(mock_post.call_args.kwargs["data"]) assert _count_converse_cache_points(request_body) == 4 - assert not any("cachePoint" in tool for tool in request_body["toolConfig"]["tools"]) + assert not any("cachePoint" in tool for tool in request_body.toolConfig.tools) class TestApplyToAnthropicMessagesRequest: @@ -2371,7 +2382,7 @@ class TestConfiguredInjectionPointsSurviveClientMarks: } @staticmethod - def _marked_user_turns(count): + def _marked_user_turns(count: int) -> List[AllMessageValues]: return [ {"role": "user", "content": [{"type": "text", "text": f"turn {i}", "cache_control": {"type": "ephemeral"}}]} for i in range(count) @@ -2386,7 +2397,7 @@ class TestConfiguredInjectionPointsSurviveClientMarks: tools=tools, ) - def _chat(self, params, messages): + def _chat(self, params: dict[str, object], messages: List[AllMessageValues]) -> List[AllMessageValues]: _, processed, _ = AnthropicCacheControlHook().get_chat_completion_prompt( model="claude-sonnet-4-5", messages=messages, From f567fe230edcf41a900ad2f6cdf1e89cc6b09a07 Mon Sep 17 00:00:00 2001 From: mateo-berri <277851410+mateo-berri@users.noreply.github.com> Date: Mon, 21 Sep 2026 11:39:25 -0700 Subject: [PATCH 7/7] fix: reserve cap slots for direct marks on /v1/messages when extra_body unmarks them --- .../anthropic_cache_control_hook.py | 18 +++++++++++++++++- .../test_anthropic_cache_control_hook.py | 4 ++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/litellm/integrations/anthropic_cache_control_hook.py b/litellm/integrations/anthropic_cache_control_hook.py index 036b9d033cd..0d6cbc2232e 100644 --- a/litellm/integrations/anthropic_cache_control_hook.py +++ b/litellm/integrations/anthropic_cache_control_hook.py @@ -335,6 +335,22 @@ class AnthropicCacheControlHook(CustomPromptManagement): ) return int(wire_cache_control is not None) + tool_blocks + envelope_blocks + @staticmethod + def count_external_cache_breakpoints_on_messages_route( + tools: Iterable[object] | None, cache_control: object, request_kwargs: object + ) -> int: + """The /v1/messages census before the route splits. + + The native messages transforms drop the ``extra_body`` envelope while the + chat bridge merges it, so the cap reserves for whichever census is larger + rather than letting an envelope that unmarks a direct tool free a slot the + provider still counts. + """ + return max( + AnthropicCacheControlHook.count_external_cache_breakpoints(tools, cache_control), + AnthropicCacheControlHook.count_external_cache_breakpoints(tools, cache_control, request_kwargs), + ) + @staticmethod def _blocks_reserved_outside_messages( remaining_points: Sequence[CacheControlInjectionPoint], external_breakpoints: int, openai_dialect: bool @@ -968,7 +984,7 @@ class AnthropicCacheControlHook(CustomPromptManagement): system=system, injection_points=injection_points, openai_dialect=openai_dialect, - external_breakpoints=AnthropicCacheControlHook.count_external_cache_breakpoints( + external_breakpoints=AnthropicCacheControlHook.count_external_cache_breakpoints_on_messages_route( tools, cache_control, kwargs ), ) diff --git a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py index fd62a26c354..7bf4533979a 100644 --- a/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py +++ b/tests/test_litellm/integrations/test_anthropic_cache_control_hook.py @@ -2608,13 +2608,13 @@ class TestConfiguredInjectionPointsSurviveClientMarks: "kwargs,tools,marked_turns,expected_system", [ ({"extra_body": {"tools": [MARKED_V1_TOOL]}}, [MARKED_V1_TOOL], 2, MARKED_SYSTEM), - ({"extra_body": {"tools": [UNMARKED_V1_TOOL]}}, [MARKED_V1_TOOL], 3, MARKED_SYSTEM), + ({"extra_body": {"tools": [UNMARKED_V1_TOOL]}}, [MARKED_V1_TOOL], 3, "sys"), ({"extra_body": {"tools": [MARKED_V1_TOOL]}}, [UNMARKED_V1_TOOL], 3, "sys"), ({"extra_body": {"cache_control": EPHEMERAL}, "cache_control": EPHEMERAL}, None, 2, MARKED_SYSTEM), ], ids=["same_marked_tool_both_ways", "extra_body_unmarks", "extra_body_marks", "root_cache_control_both_ways"], ) - def test_v1_messages_cap_counts_extra_body_fields_in_place_of_the_direct_ones( + def test_v1_messages_cap_reserves_for_the_larger_of_direct_and_extra_body_marks( self, kwargs, tools, marked_turns, expected_system ): kwargs = {"cache_control_injection_points": copy.deepcopy(self.CONFIGURED), **copy.deepcopy(kwargs)}