fix(anthropic): skip empty text and tool_reference blocks in main's cache_control walk back

main walks back past thinking and redacted_thinking blocks when it picks the block
that carries a message's cache_control marker. An empty text block and a
tool_reference block lose the marker too: the first is replaced by a placeholder
before the request goes out, unless it sits on a tool message, and the tool_result
conversion rebuilds the second from its type and tool_name alone.

Extend _accepts_cache_control to refuse both, and drop this branch's own walk-back
helper so one function decides where the marker goes. The thinking-block test now
expects the redacted_thinking block the transform keeps since that change.
This commit is contained in:
Tan Nguyen 2026-10-22 21:23:34 +07:00
parent 4aab8e524b
commit 5dbc8de52b
2 changed files with 21 additions and 36 deletions

View file

@ -84,12 +84,6 @@ _O_SERIES_PATTERN: Final = re.compile(r"^o\d+(?:-|$)")
OPENAI_PROMPT_CACHE_BREAKPOINT_BLOCK_TYPES: Final = frozenset(
{"text", "image", "image_url", "file", "input_audio", "input_text", "input_image", "input_file"}
)
# Block types a marker never reaches the provider on. Anthropic accepts one on text,
# image, tool_use, tool_result and document, so a thinking block is refused there; a
# tool_reference is rebuilt from its type and tool_name alone by the tool_result
# conversion, which drops everything else. A list of refused types rather than
# accepted ones, so a block type this code has not been told about still takes one.
ANTHROPIC_BLOCK_TYPES_WITHOUT_CACHE_CONTROL: Final = frozenset({"thinking", "redacted_thinking", "tool_reference"})
OPENAI_API_HOST: Final = "api.openai.com"
OPENAI_API_BASE_ENV_VARS: Final = ("OPENAI_BASE_URL", "OPENAI_API_BASE")
_OBJECT_MAPPING_ADAPTER: Final = TypeAdapter(dict[object, object])
@ -773,25 +767,6 @@ def _message_dropping_created_cache_controls_between(
)
def _index_of_block_accepting_cache_control(content: Sequence[object], on_a_tool_message: bool) -> int | None:
"""Last block a marker written here reaches the provider on, searched from the end.
An empty text block is replaced by a placeholder unless it sits on a tool message,
where it goes out inside the tool_result.
"""
for index in range(len(content) - 1, -1, -1):
block = content[index]
if not isinstance(block, dict):
continue
fields = cast(dict[str, object], block) # cast-ok: a runtime dict whose value types are not known here
if fields.get("type") in ANTHROPIC_BLOCK_TYPES_WITHOUT_CACHE_CONTROL:
continue
if fields.get("type") == "text" and not fields.get("text") and not on_a_tool_message:
continue
return index
return None
def _role_of(message: object) -> object:
"""The ``role`` of a message dict, or None for anything else."""
if not isinstance(message, dict):
@ -810,7 +785,7 @@ def _message_accepts_cache_control(message: object) -> bool:
return content != "" or on_a_tool_message
if isinstance(content, list):
blocks: Final = cast(Sequence[object], content) # cast-ok: a runtime list whose block types are not known here
return _index_of_block_accepting_cache_control(blocks, on_a_tool_message) is not None
return _last_index_accepting_cache_control(blocks, on_a_tool_message) is not None
return False
@ -824,18 +799,28 @@ EXTERNAL_BREAKPOINTS_STAMP: Final = "_litellm_external_breakpoints"
CUSTOM_LLM_PROVIDER_STAMP: Final = "_litellm_custom_llm_provider"
def _accepts_cache_control(part: object) -> bool:
def _accepts_cache_control(part: object, on_a_tool_message: bool) -> bool:
# A tool_reference is rebuilt from its type and tool_name alone by the tool_result
# conversion, and an empty text block is replaced by a placeholder unless it sits on
# a tool message, where it goes out inside the tool_result: a marker on either is lost.
match part:
case {"type": "thinking" | "redacted_thinking"}:
case {"type": "thinking" | "redacted_thinking" | "tool_reference"}:
return False
case {"type": "text", "text": str(text)} if text:
return True
case {"type": "text"}:
return on_a_tool_message
case dict():
return True
case _:
return False
def _last_index_accepting_cache_control(parts: Sequence[object]) -> int | None:
return next((index for index in reversed(range(len(parts))) if _accepts_cache_control(parts[index])), None)
def _last_index_accepting_cache_control(parts: Sequence[object], on_a_tool_message: bool) -> int | None:
return next(
(index for index in reversed(range(len(parts))) if _accepts_cache_control(parts[index], on_a_tool_message)),
None,
)
class AnthropicCacheControlHook(CustomPromptManagement):
@ -1284,14 +1269,13 @@ class AnthropicCacheControlHook(CustomPromptManagement):
# 1. if string, insert cache control in the message
if isinstance(message_content, str):
message["cache_control"] = control
# 2. list of objects - the last block that accepts a marker, per Anthropic spec
# 2. list of objects - only apply to the last item Claude accepts a breakpoint on
elif isinstance(message_content, list):
blocks: Final = cast(Sequence[object], message_content) # cast-ok: TypedDict blocks read as objects
target_index: Final = _index_of_block_accepting_cache_control(
blocks, on_a_tool_message=_role_of(message) == "tool"
markable_index: Final = _last_index_accepting_cache_control(
message_content, on_a_tool_message=_role_of(message) == "tool"
)
if target_index is not None:
message_content[target_index]["cache_control"] = control # pyright: ignore[reportGeneralTypeIssues] # loose runtime dict
if markable_index is not None:
message_content[markable_index]["cache_control"] = control # pyright: ignore[reportGeneralTypeIssues] # loose runtime dict
return message
@staticmethod

View file

@ -5916,6 +5916,7 @@ async def test_anthropic_cache_control_hook_skips_a_thinking_block(monkeypatch:
"content": [
{"type": "text", "text": "The answer is 4.", "cache_control": {"type": "ephemeral"}},
{"type": "thinking", "thinking": "Adding two and two.", "signature": "sig"},
{"type": "redacted_thinking", "data": "redacted"},
],
}