fix(caching): stand default cache points down when extra_body hides a direct client mark (#43341)

* fix(caching): stand default cache points down when extra_body hides a direct client mark

On native /v1/messages the extra_body envelope is dropped, so a client tool mark or root cache_control reaches Anthropic even when extra_body overrides it. The stand-down check only counted the envelope-merged view and injected two default marks on top of the client's.

* fix(caching): keep chat completions on the envelope-merged mark count for the default stand-down

Chat completions merge extra_body over the request, so a direct tool mark that extra_body replaces never reaches the provider there. Only /v1/messages, where the native transforms drop the envelope, needs to count marks on both sides.
This commit is contained in:
yuneng-jiang 2026-09-26 16:44:30 -07:00 • committed by GitHub
parent 849f3037b4
commit b2e82cf3be
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 50 additions and 5 deletions

View file

@ -695,6 +695,7 @@ class AnthropicCacheControlHook(CustomPromptManagement):
tools: list | None = None,
cache_control: object = None,
request_kwargs: object = None,
on_messages_route: bool = False,
) -> bool:
"""Return True if the request already carries any client-supplied cache_control.
@ -704,10 +705,14 @@ class AnthropicCacheControlHook(CustomPromptManagement):
envelope. Configured injection points are an explicit instruction and are
applied alongside the client's marks, bounded by the provider cap.
"""
return (
AnthropicCacheControlHook.count_request_cache_breakpoints(messages, system)
+ AnthropicCacheControlHook.count_external_cache_breakpoints(tools, cache_control, request_kwargs)
) > 0
external_breakpoints: Final = (
AnthropicCacheControlHook.count_external_cache_breakpoints_on_messages_route(
tools, cache_control, request_kwargs
)
if on_messages_route
else AnthropicCacheControlHook.count_external_cache_breakpoints(tools, cache_control, request_kwargs)
)
return AnthropicCacheControlHook.count_request_cache_breakpoints(messages, system) + external_breakpoints > 0
@staticmethod
def get_default_injection_points(
@ -719,6 +724,7 @@ class AnthropicCacheControlHook(CustomPromptManagement):
enable_prompt_caching: bool | None = None,
cache_control: object = None,
request_kwargs: object = None,
on_messages_route: bool = False,
) -> list[CacheControlInjectionPoint]:
"""Default breakpoints when ``litellm.enable_anthropic_prompt_caching`` is on.
@ -739,7 +745,9 @@ class AnthropicCacheControlHook(CustomPromptManagement):
if not supports_anthropic_cache_control(model, custom_llm_provider):
return []
if AnthropicCacheControlHook._request_has_cache_control(messages, system, tools, cache_control, request_kwargs):
if AnthropicCacheControlHook._request_has_cache_control(
messages, system, tools, cache_control, request_kwargs, on_messages_route
):
return []
if is_claude_code_one_shot_subagent_request(
@ -968,6 +976,7 @@ class AnthropicCacheControlHook(CustomPromptManagement):
enable_prompt_caching=enable_prompt_caching,
cache_control=cache_control,
request_kwargs=kwargs,
on_messages_route=True,
)
if model is not None
else ()

View file

@ -2633,6 +2633,42 @@ class TestConfiguredInjectionPointsSurviveClientMarks:
assert kwargs["cache_control"] is root_cache_control
assert "litellm_gateway_injected_cache" not in kwargs["litellm_metadata"]
@pytest.mark.parametrize(
"tools,kwargs,injected",
[
([MARKED_V1_TOOL], {"extra_body": {"tools": [UNMARKED_V1_TOOL]}}, False),
(None, {"cache_control": EPHEMERAL, "extra_body": {"cache_control": None}}, False),
([UNMARKED_V1_TOOL], {"extra_body": {"tools": [UNMARKED_V1_TOOL]}}, True),
],
ids=["extra_body_unmarks_direct_tool", "extra_body_nulls_root_cache_control", "no_client_mark_anywhere"],
)
def test_v1_messages_automatic_defaults_stand_down_for_a_direct_mark_extra_body_hides(
self, monkeypatch, tools, kwargs, injected
):
monkeypatch.setattr(litellm, "enable_anthropic_prompt_caching", True)
request_kwargs = {**copy.deepcopy(kwargs), "litellm_metadata": {}}
result_messages, result_system = self._inject(
copy.deepcopy(self.V1_MESSAGES), request_kwargs, tools=copy.deepcopy(tools)
)
assert AnthropicCacheControlHook.count_request_cache_breakpoints(result_messages, result_system) == (
2 if injected else 0
)
assert ("litellm_gateway_injected_cache" in request_kwargs["litellm_metadata"]) is injected
def test_chat_automatic_defaults_apply_when_extra_body_drops_the_only_client_mark(self, monkeypatch):
monkeypatch.setattr(litellm, "enable_anthropic_prompt_caching", True)
params = {"extra_body": {"tools": [self.UNMARKED_TOOL]}}
self._seed(params, copy.deepcopy(self.CLEAN_MESSAGES), tools=[self.MARKED_TOOL_TOP_LEVEL])
affinity = AnthropicCacheControlHook.messages_with_default_injections(
copy.deepcopy(self.CLEAN_MESSAGES), ["claude-sonnet-4-5"], tools=[self.MARKED_TOOL_TOP_LEVEL], request_kwargs=params
)
assert [p["index"] for p in params["cache_control_injection_points"]] == [None, -1]
assert AnthropicCacheControlHook.count_request_cache_breakpoints(affinity) == 2
@pytest.mark.parametrize(
"marked_turns,expected_system",
[(2, [{"type": "text", "text": "sys", "cache_control": {"type": "ephemeral"}}]), (3, "sys")],