litellm/tests/unit/router_utils/test_prompt_caching_cache.py
devin-ai-integration[bot] e136e91273
Some checks are pending
CodSpeed Benchmarks / benchmarks (push) Waiting to run
Deploy main to gateway-dev / dispatch (push) Waiting to run
Publish lint base counts / publish (basedpyright, scripts/type_check_gate.py) (push) Waiting to run
Publish lint base counts / publish (openapi-docs, scripts/openapi_docs_gate.py) (push) Waiting to run
Publish lint base counts / publish (ruff-strict, scripts/ruff_strict_gate.py) (push) Waiting to run
Publish lint base counts / publish (test-quality, scripts/test_quality_gate.py) (push) Waiting to run
Publish lint base counts / publish (type-discipline, scripts/type_discipline_gate.py) (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
unit / lens-python-310 (push) Blocked by required conditions
unit / caching-local (push) Blocked by required conditions
unit / core-utils (push) Blocked by required conditions
unit / endpoints (push) Blocked by required conditions
unit / integrations (push) Blocked by required conditions
unit / llms-bedrock (push) Blocked by required conditions
unit / llms-providers (push) Blocked by required conditions
unit / mcp-elicitation (push) Blocked by required conditions
unit / proxy-feature-endpoints (push) Blocked by required conditions
unit / router (push) Blocked by required conditions
unit / endpoints-and-responses (push) Blocked by required conditions
unit / enterprise-routing (push) Blocked by required conditions
unit / guardrails-hooks (push) Blocked by required conditions
unit / logging-misc (push) Blocked by required conditions
unit / root (push) Blocked by required conditions
unit / proxy-utils (push) Blocked by required conditions
unit / assert-shard-coverage (push) Waiting to run
unit / enterprise-managed-files (push) Blocked by required conditions
unit / proxy-server (push) Blocked by required conditions
unit / rust-bridge (push) Waiting to run
unit / helm (push) Waiting to run
unit / OpenAI 2.20.0 compatibility (push) Waiting to run
unit / OpenAI 3.0.0 compatibility (push) Waiting to run
unit / coverage (push) Blocked by required conditions
unit / unit passed (push) Blocked by required conditions
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
LiteLLM Rust / rust-wheel (push) Waiting to run
Terraform Provider / gofmt, vet, build, test (push) Waiting to run
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Waiting to run
unit / enterprise-package (push) Blocked by required conditions
unit / enterprise-repositories-secrets (push) Blocked by required conditions
unit / llms-anthropic (push) Blocked by required conditions
unit / llms-openai-meta (push) Blocked by required conditions
unit / llms-vertex-ai (push) Blocked by required conditions
unit / mcp-server (push) Blocked by required conditions
unit / proxy-auth (push) Blocked by required conditions
unit / proxy-endpoints (push) Blocked by required conditions
unit / proxy-extras (push) Blocked by required conditions
unit / proxy-hooks-client (push) Blocked by required conditions
unit / proxy-infra (push) Blocked by required conditions
unit / proxy-infra-root (push) Blocked by required conditions
unit / responses-caching-types (push) Blocked by required conditions
unit / rust-bridge-harness (push) Blocked by required conditions
unit / auth-checks (push) Blocked by required conditions
unit / budgets (push) Blocked by required conditions
unit / custom-logging (push) Blocked by required conditions
unit / db-and-spend (push) Blocked by required conditions
unit / jwt-and-keys (push) Blocked by required conditions
unit / key-generation (push) Blocked by required conditions
unit / mcp-oauth (push) Blocked by required conditions
unit / proxy-runtime (push) Blocked by required conditions
unit / proxy-server-core (push) Blocked by required conditions
unit / ui-unit (push) Waiting to run
unit / docs (push) Waiting to run
unit / OpenAI 3.25.0 compatibility (push) Waiting to run
unit / redis-py 5.3.1 compatibility (push) Blocked by required conditions
unit / redis-py 6.4.0 compatibility (push) Blocked by required conditions
unit / redis-py 7.4.1 compatibility (push) Blocked by required conditions
unit / redis-py 8.0.1 compatibility (push) Blocked by required conditions
GitHub Actions Security Analysis / zizmor (push) Waiting to run
fix(router): keep the prompt caching affinity pin alive as long as the prefix's cache_control ttl (#46084)
* fix(router): keep the prompt caching affinity pin alive as long as the prefix's cache_control ttl

A prefix marked cache_control ttl 1h lost its pinned deployment after the fixed 300 s, so the next request went to another deployment and paid a cold cache. The pin TTL now follows the longest ephemeral breakpoint in the cacheable prefix, tools included: 3600 s for a 1h breakpoint and 300 s otherwise.

* fix(router): give prompt cache pins their own tier and honour any declared ttl

Pins now live in a DualCache of their own instead of the router's 200-slot
in-memory tier, so an hour-long pin no longer evicts cooldowns and rate windows.
The pin lifetime follows any <n>s|m|h cache_control ttl, floored at 300 s, not
only the literal "1h".

* fix(router): hash the pin ttl off the event loop and flush prompt cache pins with the router cache

Compute the cache_control ttl scan through offload_token_count beside the
prefix hash, clear the dedicated pin cache in Router.flush_cache, and point
the order-fallback and priority-group pin tests at the pin cache they read

* fix(router): pin 1h prefixes only on 1h-capable models, cap the pin at 1h, and give pins 10k in-memory slots

* fix(router): match the 1h pin gate to what Bedrock forwards

Bedrock deployments now decide the 1h pin with is_claude_4_5_on_bedrock,
the same check that decides whether the 1h ttl reaches Bedrock, so an
application inference profile ARN keeps a 5 minute pin whatever its
base_model prices

* test(router): give the anthropic_messages pin fixture its provider

* fix(router): let each pin write set its own lifetime and require a positive 1h price

In a group mixing 1h and 5m deployments the in-memory pin kept the lifetime of its first write, so a 5m deployment could hold an hour-long pin. Each write now clears the in-memory entry first, matching Redis. The Bedrock gate uses supports_bedrock_prompt_cache_ttl after #46058, and a zero 1h price no longer counts as 1h support.

* test(router): integration cells for the one-hour prompt cache pin lifetime

Cover every surface the pin lifetime touches: chat and /v1/messages through
both SDKs, the responses bridge, response-cache hits, each provider family's
1h gate, malformed and duplicated ttl values, rewrites of one prefix, mixed
and cross-group routing, concurrency, every Redis attach path, the in-memory
slot pressure without Redis reads, a Redis outage and a killed worker

* fix(caching): bound the in-memory expiry heap and count prompt cache pins in the memory debug routes

* test(integration): cover prompt cache pins in the memory debug routes and the bounded expiration heap

* fix(caching): serialize in-memory cache writes so a heap rebuild cannot lose another thread's expiry entry

* fix(caching): keep the in-memory cache bounded when entries have no usable expiry

* test(caching): cover the response cache bound when cached entries have no usable ttl

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-10-14 21:10:09 -07:00

452 lines
16 KiB
Python

import asyncio
from typing import Final
import pytest
from litellm.caching.dual_cache import DualCache
from litellm.caching.in_memory_cache import InMemoryCache
from litellm.router_utils.prompt_caching_cache import (
PROMPT_CACHE_ONE_HOUR_PIN_TTL_SECONDS,
PROMPT_CACHE_PIN_TTL_SECONDS,
PromptCachingCache,
)
from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam
class _ManualClock:
def __init__(self) -> None:
self.now = 1_700_000_000.0
def __call__(self) -> float:
return self.now
def advance(self, seconds: float) -> None:
self.now = self.now + seconds
def _pin_cache(clock: _ManualClock) -> PromptCachingCache:
return PromptCachingCache(cache=DualCache(in_memory_cache=InMemoryCache(clock=clock)))
def _system_block_messages(
cache_control: dict[str, str], document: str = "a complex legal agreement"
) -> list[AllMessageValues]:
return [
{
"role": "system",
"content": [
{
"type": "text",
"text": f"Here is the full text of {document}" * 200,
"cache_control": cache_control,
}
],
},
{"role": "user", "content": "Reply with the single word ok."},
]
def _message_level_messages(cache_control: dict[str, str]) -> list[AllMessageValues]:
return [
{
"role": "system",
"content": "Here is the full text of a complex legal agreement" * 200,
"cache_control": cache_control,
},
{"role": "user", "content": "Reply with the single word ok."},
]
def _tool(cache_control: dict[str, str] | None) -> ChatCompletionToolParam:
tool: ChatCompletionToolParam = {
"type": "function",
"function": {"name": "lookup_clause", "parameters": {"type": "object", "properties": {}}},
}
return {**tool, "cache_control": cache_control} if cache_control else tool
@pytest.mark.parametrize(
("messages", "tools", "expected_ttl"),
[
pytest.param(
_system_block_messages({"type": "ephemeral", "ttl": "1h"}),
None,
PROMPT_CACHE_ONE_HOUR_PIN_TTL_SECONDS,
id="content-block-1h",
),
pytest.param(
_message_level_messages({"type": "ephemeral", "ttl": "1h"}),
None,
PROMPT_CACHE_ONE_HOUR_PIN_TTL_SECONDS,
id="message-level-1h",
),
pytest.param(
_system_block_messages({"type": "ephemeral", "ttl": "5m"}),
None,
PROMPT_CACHE_PIN_TTL_SECONDS,
id="explicit-5m",
),
pytest.param(
_system_block_messages({"type": "ephemeral"}),
None,
PROMPT_CACHE_PIN_TTL_SECONDS,
id="default-ttl",
),
pytest.param(
[
*_system_block_messages({"type": "ephemeral", "ttl": "1h"}),
{"role": "assistant", "content": "ok"},
{"role": "user", "content": "Again.", "cache_control": {"type": "ephemeral", "ttl": "5m"}},
],
None,
PROMPT_CACHE_ONE_HOUR_PIN_TTL_SECONDS,
id="1h-system-then-5m-turn",
),
pytest.param(
_system_block_messages({"type": "ephemeral"}),
[_tool({"type": "ephemeral", "ttl": "1h"})],
PROMPT_CACHE_ONE_HOUR_PIN_TTL_SECONDS,
id="1h-on-tools-only",
),
pytest.param(
_system_block_messages({"type": "ephemeral"}),
[_tool(None)],
PROMPT_CACHE_PIN_TTL_SECONDS,
id="unmarked-tools",
),
pytest.param(
[
*_system_block_messages({"type": "ephemeral", "ttl": "5m"}),
{"role": "assistant", "content": "ok"},
{"role": "user", "content": "Again.", "cache_control": {"type": "something_else", "ttl": "1h"}},
],
None,
PROMPT_CACHE_PIN_TTL_SECONDS,
id="non-ephemeral-1h-ignored",
),
pytest.param(
_system_block_messages({"type": "ephemeral", "ttl": "3600s"}),
None,
PROMPT_CACHE_PIN_TTL_SECONDS,
id="seconds-form-keeps-the-floor",
),
pytest.param(
_system_block_messages({"type": "ephemeral", "ttl": "2h"}),
None,
PROMPT_CACHE_PIN_TTL_SECONDS,
id="longer-than-1h-keeps-the-floor",
),
pytest.param(
_system_block_messages({"type": "ephemeral", "ttl": "99999999h"}),
None,
PROMPT_CACHE_PIN_TTL_SECONDS,
id="huge-ttl-keeps-the-floor",
),
pytest.param(
_system_block_messages({"type": "ephemeral", "ttl": "9" * 400 + "h"}),
None,
PROMPT_CACHE_PIN_TTL_SECONDS,
id="overflowing-ttl-keeps-the-floor",
),
pytest.param(
_system_block_messages({"type": "ephemeral", "ttl": "90s"}),
None,
PROMPT_CACHE_PIN_TTL_SECONDS,
id="shorter-than-5m-keeps-the-floor",
),
pytest.param(
_system_block_messages({"type": "ephemeral", "ttl": "soon"}),
None,
PROMPT_CACHE_PIN_TTL_SECONDS,
id="unparseable-ttl-keeps-the-floor",
),
],
)
def test_pin_ttl_follows_the_longest_breakpoint_ttl(
messages: list[AllMessageValues],
tools: list[ChatCompletionToolParam] | None,
expected_ttl: int,
) -> None:
assert PromptCachingCache.pin_ttl_seconds(messages, tools) == expected_ttl
@pytest.mark.asyncio
async def test_one_hour_prefix_pin_outlives_the_five_minute_window() -> None:
clock: Final = _ManualClock()
cache: Final = _pin_cache(clock)
one_hour: Final = _system_block_messages({"type": "ephemeral", "ttl": "1h"}, document="a lease")
five_minutes: Final = _system_block_messages({"type": "ephemeral"}, document="a will")
await cache.async_add_model_id(model_id="deployment-1h", messages=one_hour, tools=None, honors_one_hour_ttl=True)
await cache.async_add_model_id(
model_id="deployment-5m", messages=five_minutes, tools=None, honors_one_hour_ttl=True
)
clock.advance(PROMPT_CACHE_PIN_TTL_SECONDS + 1)
assert await cache.async_get_model_id(messages=five_minutes, tools=None) is None
pinned_after_five_minutes: Final = await cache.async_get_model_id(messages=one_hour, tools=None)
assert pinned_after_five_minutes is not None
assert pinned_after_five_minutes["model_id"] == "deployment-1h"
clock.advance(PROMPT_CACHE_ONE_HOUR_PIN_TTL_SECONDS - PROMPT_CACHE_PIN_TTL_SECONDS)
assert await cache.async_get_model_id(messages=one_hour, tools=None) is None
@pytest.mark.asyncio
async def test_one_hour_prefix_on_a_deployment_without_one_hour_caching_keeps_the_five_minute_pin() -> None:
clock: Final = _ManualClock()
cache: Final = _pin_cache(clock)
one_hour: Final = _system_block_messages({"type": "ephemeral", "ttl": "1h"})
await cache.async_add_model_id(
model_id="deployment-5m-only", messages=one_hour, tools=None, honors_one_hour_ttl=False
)
clock.advance(PROMPT_CACHE_PIN_TTL_SECONDS + 1)
assert await cache.async_get_model_id(messages=one_hour, tools=None) is None
@pytest.mark.asyncio
@pytest.mark.parametrize(
("first_write", "second_write", "pinned_past_five_minutes"),
[
pytest.param(
("deployment-1h", True),
("deployment-5m-only", False),
None,
id="five-minute-deployment-after-a-one-hour-pin",
),
pytest.param(
("deployment-5m-only", False),
("deployment-1h", True),
{"model_id": "deployment-1h"},
id="one-hour-deployment-after-a-five-minute-pin",
),
],
)
async def test_latest_write_sets_the_pin_lifetime_in_a_mixed_group(
first_write: tuple[str, bool],
second_write: tuple[str, bool],
pinned_past_five_minutes: dict[str, str] | None,
) -> None:
clock: Final = _ManualClock()
cache: Final = _pin_cache(clock)
one_hour: Final = _system_block_messages({"type": "ephemeral", "ttl": "1h"})
await cache.async_add_model_id(
model_id=first_write[0], messages=one_hour, tools=None, honors_one_hour_ttl=first_write[1]
)
await cache.async_add_model_id(
model_id=second_write[0], messages=one_hour, tools=None, honors_one_hour_ttl=second_write[1]
)
clock.advance(PROMPT_CACHE_PIN_TTL_SECONDS + 1)
assert await cache.async_get_model_id(messages=one_hour, tools=None) == pinned_past_five_minutes
def test_sync_five_minute_deployment_after_a_one_hour_pin_keeps_the_five_minute_pin() -> None:
clock: Final = _ManualClock()
cache: Final = _pin_cache(clock)
one_hour: Final = _message_level_messages({"type": "ephemeral", "ttl": "1h"})
cache.add_model_id(model_id="deployment-1h", messages=one_hour, tools=None, honors_one_hour_ttl=True)
cache.add_model_id(model_id="deployment-5m-only", messages=one_hour, tools=None, honors_one_hour_ttl=False)
clock.advance(PROMPT_CACHE_PIN_TTL_SECONDS + 1)
assert cache.get_model_id(messages=one_hour, tools=None) is None
def test_sync_one_hour_message_level_pin_outlives_the_five_minute_window() -> None:
clock: Final = _ManualClock()
cache: Final = _pin_cache(clock)
one_hour: Final = _message_level_messages({"type": "ephemeral", "ttl": "1h"})
cache.add_model_id(model_id="deployment-1h", messages=one_hour, tools=None, honors_one_hour_ttl=True)
clock.advance(PROMPT_CACHE_PIN_TTL_SECONDS + 1)
pinned: Final = cache.get_model_id(messages=one_hour, tools=None)
assert pinned is not None
assert pinned["model_id"] == "deployment-1h"
clock.advance(PROMPT_CACHE_ONE_HOUR_PIN_TTL_SECONDS - PROMPT_CACHE_PIN_TTL_SECONDS)
assert cache.get_model_id(messages=one_hour, tools=None) is None
async def _first_non_none(read):
while True:
value = await read()
if value is not None:
return value
await asyncio.sleep(0)
def test_extract_cacheable_prefix_with_string_content_and_message_level_cache_control():
"""
Test that extract_cacheable_prefix correctly handles messages where:
- content is a string (not a list of content blocks)
- cache_control is a sibling key at the message level
This is a valid message format per LiteLLM's ChatCompletionUserMessage type:
{"role": "user", "content": "...", "cache_control": {"type": "ephemeral"}}
Regression test for issue #19228.
"""
# Test case 1: Single message with string content and message-level cache_control
messages_string_content = [
{"role": "system", "content": "You are a helpful assistant"},
{
"role": "user",
"content": "This is a large message that should be cached",
"cache_control": {"type": "ephemeral", "ttl": "5m"},
},
]
result = PromptCachingCache.extract_cacheable_prefix(messages_string_content)
# Should return both messages (system + user with cache_control)
assert len(result) == 2, f"Expected 2 messages, got {len(result)}"
assert result[0]["role"] == "system"
assert result[1]["role"] == "user"
assert result[1]["content"] == "This is a large message that should be cached"
assert result[1].get("cache_control") == {"type": "ephemeral", "ttl": "5m"}
def test_extract_cacheable_prefix_with_string_content_no_cache_control():
"""
Test that extract_cacheable_prefix returns empty list when:
- content is a string
- no cache_control is present
"""
messages_no_cache = [
{"role": "system", "content": "You are a helpful assistant"},
{"role": "user", "content": "Hello"},
]
result = PromptCachingCache.extract_cacheable_prefix(messages_no_cache)
# Should return empty list (no cacheable content)
assert len(result) == 0, f"Expected 0 messages, got {len(result)}"
def test_extract_cacheable_prefix_mixed_string_and_list_content():
"""
Test that extract_cacheable_prefix handles messages with a mix of:
- String content with message-level cache_control
- List content with block-level cache_control
The last cache_control (regardless of format) should determine the cacheable prefix.
"""
# Message with string content + cache_control, followed by message with list content + cache_control
messages_mixed = [
{"role": "system", "content": "You are a helpful assistant"},
{
"role": "user",
"content": "First cached message",
"cache_control": {"type": "ephemeral"},
},
{
"role": "user",
"content": [
{
"type": "text",
"text": "Second cached message in list format",
"cache_control": {"type": "ephemeral"},
}
],
},
{"role": "user", "content": "This should not be in the prefix"},
]
result = PromptCachingCache.extract_cacheable_prefix(messages_mixed)
# Should include first 3 messages (up to and including the last cache_control)
assert len(result) == 3, f"Expected 3 messages, got {len(result)}"
assert result[0]["role"] == "system"
assert result[1]["content"] == "First cached message"
assert isinstance(result[2]["content"], list)
@pytest.mark.asyncio
async def test_same_cacheable_prefix_routes_to_same_deployment() -> None:
from litellm import Router
def _messages(user_content: str):
return [
{
"role": "system",
"content": [
{
"type": "text",
"text": "You are an AI assistant tasked with analyzing legal documents.",
},
{
"type": "text",
"text": "Here is the full text of a complex legal agreement" * 400,
"cache_control": {"type": "ephemeral"},
},
],
},
{"role": "user", "content": user_content},
]
router: Final = Router(
model_list=[
{
"model_name": "test-model",
"litellm_params": {
"model": "gpt-5-mini",
"api_key": f"test-key-{i}",
},
"model_info": {"id": f"deployment-{i}"},
}
for i in range(1, 4)
],
routing_strategy="simple-shuffle",
optional_pre_call_checks=["prompt_caching"],
)
messages1: Final = _messages("what are the key terms and conditions in this agreement?")
messages2: Final = _messages("how many words are there?")
messages3: Final = _messages("how many sentences are there?")
key1: Final = PromptCachingCache.get_prompt_caching_cache_key(messages1, None)
key2: Final = PromptCachingCache.get_prompt_caching_cache_key(messages2, None)
key3: Final = PromptCachingCache.get_prompt_caching_cache_key(messages3, None)
assert key1 is not None
assert key1 == key2 == key3
response1: Final = await router.acompletion(
model="test-model", messages=messages1, mock_response="ok"
)
model_id_1: Final = response1._hidden_params["model_id"]
assert model_id_1 in {"deployment-1", "deployment-2", "deployment-3"}
from litellm.integrations.anthropic_cache_control_hook import (
AnthropicCacheControlHook,
)
affinity_messages: Final = AnthropicCacheControlHook.messages_with_default_injections(
messages=messages2,
models=("gpt-5-mini",),
tools=None,
enable_prompt_caching=None,
request_kwargs={},
)
cache: Final = PromptCachingCache(cache=router.prompt_caching_pin_cache)
cached_2: Final = await asyncio.wait_for(
_first_non_none(lambda: cache.async_get_model_id(affinity_messages, None)),
timeout=10,
)
assert cached_2 == {"model_id": model_id_1}
response2: Final = await router.acompletion(
model="test-model", messages=messages2, mock_response="ok"
)
response3: Final = await router.acompletion(
model="test-model", messages=messages3, mock_response="ok"
)
model_id_2: Final = response2._hidden_params["model_id"]
model_id_3: Final = response3._hidden_params["model_id"]
assert model_id_1 == model_id_2 == model_id_3