mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
Merge pull request #42069 from BerriAI/litellm_redacted_thinking_prompt_caching_pin
fix(token_counter): count replayed redacted_thinking blocks so prompt_caching keeps pinning
This commit is contained in:
commit
0e7cf5113e
3 changed files with 79 additions and 3 deletions
|
|
@ -46,6 +46,8 @@ from litellm.types.llms.openai import (
|
|||
AllMessageValues,
|
||||
ChatCompletionDocumentObject,
|
||||
ChatCompletionNamedToolChoiceParam,
|
||||
ChatCompletionRedactedThinkingBlock,
|
||||
ChatCompletionThinkingBlock,
|
||||
ChatCompletionToolParam,
|
||||
OpenAIMessageContentListBlock,
|
||||
)
|
||||
|
|
@ -854,6 +856,8 @@ def _count_content_list(
|
|||
content_list: str
|
||||
| Iterable[
|
||||
OpenAIMessageContentListBlock
|
||||
| ChatCompletionThinkingBlock
|
||||
| ChatCompletionRedactedThinkingBlock
|
||||
| AnthropicMessagesTextParam
|
||||
| AnthropicMessagesImageParam
|
||||
| AnthropicMessagesDocumentParam
|
||||
|
|
@ -898,9 +902,9 @@ def _count_content_list(
|
|||
use_default_image_token_count,
|
||||
default_token_count,
|
||||
)
|
||||
elif c["type"] == "thinking":
|
||||
elif c["type"] in ("thinking", "redacted_thinking"):
|
||||
# Claude extended thinking content block
|
||||
# Count the thinking text and skip signature (opaque signature blob)
|
||||
# Count the thinking text and skip the opaque blobs (signature, redacted data)
|
||||
thinking_text = str(c.get("thinking", ""))
|
||||
if thinking_text:
|
||||
num_tokens += count_function(thinking_text)
|
||||
|
|
@ -920,7 +924,8 @@ def _count_content_list(
|
|||
raise ValueError(
|
||||
f"Invalid content item type: {content_type}. "
|
||||
f"Expected str or dict with 'type' field "
|
||||
f"(text, image_url, image, document, file, tool_use, tool_result, thinking, tool_reference)."
|
||||
f"(text, image_url, image, document, file, tool_use, tool_result, thinking, redacted_thinking, "
|
||||
f"tool_reference)."
|
||||
)
|
||||
return num_tokens
|
||||
except Exception as e:
|
||||
|
|
|
|||
|
|
@ -1257,6 +1257,25 @@ def test_token_counter_with_thinking_content():
|
|||
), f"Expected minimal token count for empty thinking block, got {tokens_no_thinking}"
|
||||
|
||||
|
||||
|
||||
def test_token_counter_with_redacted_thinking_content():
|
||||
"""
|
||||
A replayed redacted_thinking block (Anthropic redacted reasoning, or the /v1/messages bridge's stand-in
|
||||
for a reasoning item with no summary) counts zero tokens for its encrypted payload, like a thinking
|
||||
block with no text. It used to raise, which made is_prompt_caching_valid_prompt return False and the
|
||||
prompt_caching pre-call check stop pinning the deployment that held the cached prefix.
|
||||
"""
|
||||
model = "anthropic/claude-sonnet-4-5-20250929"
|
||||
reply = {"type": "text", "text": "Draw from the box labeled Mixed, because that label must be wrong."}
|
||||
redacted_block = {"type": "redacted_thinking", "data": "EqQBCkYIBRgCKkBjZ2xhc3M" * 30}
|
||||
user_turn = {"role": "user", "content": [{"type": "text", "text": "Which box do you draw from?"}]}
|
||||
follow_up = {"role": "user", "content": [{"type": "text", "text": "Restate that in one sentence."}]}
|
||||
|
||||
without_block = [user_turn, {"role": "assistant", "content": [reply]}, follow_up]
|
||||
with_block = [user_turn, {"role": "assistant", "content": [redacted_block, reply]}, follow_up]
|
||||
|
||||
assert token_counter(model=model, messages=with_block) == token_counter(model=model, messages=without_block)
|
||||
|
||||
def test_token_counter_with_tool_reference_block():
|
||||
"""
|
||||
Regression test: a message containing an Anthropic tool-search
|
||||
|
|
|
|||
|
|
@ -210,6 +210,58 @@ async def test_async_filter_deployments_narrows_for_group_whose_model_minimum_is
|
|||
AUTO_CACHING_MODEL = "anthropic/claude-sonnet-4-5"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replayed_redacted_thinking_block_still_records_and_pins():
|
||||
"""
|
||||
A model that returns no reasoning summary (gpt-5.x through the /v1/messages bridge, Anthropic with
|
||||
redacted reasoning) hands the client a `redacted_thinking` block, and the client replays it on every
|
||||
later turn. The token count behind `is_prompt_caching_valid_prompt` raised on that block, the helper
|
||||
swallowed it to False, and the check neither recorded the serving deployment nor pinned it, so the
|
||||
conversation bounced across the group and paid a cache write on each deployment.
|
||||
"""
|
||||
cache = DualCache()
|
||||
check = PromptCachingDeploymentCheck(cache=cache)
|
||||
model = "openai/gpt-5.6-sol"
|
||||
deployments = _deployments(model, model, model)
|
||||
messages = cast(
|
||||
list[AllMessageValues],
|
||||
[
|
||||
*_messages(word_count=3000),
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "redacted_thinking", "data": "litellm_encrypted_reasoning:" + "Z" * 400},
|
||||
{"type": "text", "text": "Draw from the box labeled Mixed."},
|
||||
],
|
||||
},
|
||||
{"role": "user", "content": "Restate that in one sentence."},
|
||||
],
|
||||
)
|
||||
|
||||
assert is_prompt_caching_valid_prompt(model=model, messages=messages) is True
|
||||
|
||||
await check.async_log_success_event(
|
||||
kwargs={
|
||||
"standard_logging_object": {
|
||||
"call_type": "anthropic_messages",
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"model_id": "dep-2",
|
||||
}
|
||||
},
|
||||
response_obj=None,
|
||||
start_time=None,
|
||||
end_time=None,
|
||||
)
|
||||
filtered = await check.async_filter_deployments(
|
||||
model=MODEL_GROUP_ALIAS,
|
||||
healthy_deployments=deployments,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
assert filtered == [deployments[1]]
|
||||
|
||||
|
||||
def _auto_caching_messages() -> list[AllMessageValues]:
|
||||
"""A prompt over the model minimum that carries no client cache_control."""
|
||||
return cast(
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue