From 08f348bebd52d51deb96fb026d43d908621a1a98 Mon Sep 17 00:00:00 2001 From: nuernber Date: Fri, 11 Sep 2026 12:19:08 -0700 Subject: [PATCH 01/11] feat(prompt-caching): match affinity TTL to cache control TTL Add logic to extract TTL from cache_control blocks and use 1 hour (3600s) for "1h" TTL or default 5 minutes (300s) otherwise. Apply the extracted TTL when storing model affinity in both sync and async add_model_id methods. --- litellm/router_utils/prompt_caching_cache.py | 29 ++++++++++++- .../test_prompt_caching_deployment_check.py | 41 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/litellm/router_utils/prompt_caching_cache.py b/litellm/router_utils/prompt_caching_cache.py index 39708e168f5..171a086de81 100644 --- a/litellm/router_utils/prompt_caching_cache.py +++ b/litellm/router_utils/prompt_caching_cache.py @@ -139,6 +139,27 @@ class PromptCachingCache: return cacheable_prefix + @staticmethod + def get_prompt_caching_ttl(messages: list[AllMessageValues] | None) -> int: + if messages is None: + return 300 + + cacheable_prefix: Final = PromptCachingCache.extract_cacheable_prefix(messages) + cache_control_ttls: Final = tuple( + cache_control.get("ttl") + for message in cacheable_prefix + for cache_control in ( + message.get("cache_control"), + *( + content_block.get("cache_control") + for content_block in (message.get("content") if isinstance(message.get("content"), list) else ()) + if isinstance(content_block, dict) + ), + ) + if isinstance(cache_control, dict) and cache_control.get("type") == "ephemeral" + ) + return 3600 if "1h" in cache_control_ttls else 300 + @staticmethod def get_prompt_caching_cache_key( messages: list[AllMessageValues] | None, @@ -189,7 +210,11 @@ class PromptCachingCache: if cache_key is None: return - self.cache.set_cache(cache_key, PromptCachingCacheValue(model_id=model_id), ttl=300) + self.cache.set_cache( + cache_key, + PromptCachingCacheValue(model_id=model_id), + ttl=PromptCachingCache.get_prompt_caching_ttl(messages), + ) return async def async_add_model_id( @@ -209,7 +234,7 @@ class PromptCachingCache: await self.cache.async_set_cache( cache_key, PromptCachingCacheValue(model_id=model_id), - ttl=300, # store for 5 minutes + ttl=PromptCachingCache.get_prompt_caching_ttl(messages), ) return diff --git a/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py b/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py index 333e7b2ff31..fde779b3feb 100644 --- a/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py +++ b/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py @@ -1,6 +1,7 @@ import asyncio import copy from typing import List, cast +from unittest.mock import AsyncMock import pytest @@ -60,6 +61,46 @@ def _messages(word_count: int) -> List[AllMessageValues]: ) +@pytest.mark.parametrize( + ("ttl", "expected_affinity_ttl"), + ((None, 300), ("5m", 300), ("1h", 3600)), +) +def test_prompt_caching_affinity_ttl_matches_cache_control(ttl: str | None, expected_affinity_ttl: int): + cache_control: dict[str, str] = {"type": "ephemeral", **({"ttl": ttl} if ttl is not None else {})} + messages = cast( + List[AllMessageValues], + [{"role": "system", "content": [{"type": "text", "text": "cached", "cache_control": cache_control}]}], + ) + + assert PromptCachingCache.get_prompt_caching_ttl(messages) == expected_affinity_ttl + + +@pytest.mark.asyncio +async def test_async_add_model_id_uses_one_hour_affinity_ttl(): + cache = DualCache() + async_set_cache = AsyncMock() + cache.async_set_cache = async_set_cache + messages = cast( + List[AllMessageValues], + [ + { + "role": "system", + "content": [ + { + "type": "text", + "text": "cached", + "cache_control": {"type": "ephemeral", "ttl": "1h"}, + } + ], + } + ], + ) + + await PromptCachingCache(cache=cache).async_add_model_id("dep-1", messages, None) + + assert async_set_cache.call_args.kwargs["ttl"] == 3600 + + def test_get_min_token_count_for_deployments_takes_min_across_mixed_group(): """ A group may legally mix models whose real minimums differ, and one gate decides for every From c04501065b3dfab6614154fafea03158cb820c23 Mon Sep 17 00:00:00 2001 From: nuernber Date: Fri, 11 Sep 2026 12:52:40 -0700 Subject: [PATCH 02/11] refactor(prompt-caching): extract TTL and cache key logic from prefix computation Split get_prompt_caching_ttl into get_prompt_caching_ttl_from_prefix to separate prefix extraction from TTL calculation. Split get_prompt_caching_cache_key into get_prompt_caching_cache_key_from_prefix similarly. Update add_model_id and async_add_model_id to extract cacheable prefix once and pass it to both cache key and TTL methods. Check tools for cache_control TTL values in addition to messages. Add test coverage for sync --- litellm/router_utils/prompt_caching_cache.py | 61 ++++++++++++------- .../test_prompt_caching_deployment_check.py | 40 +++++++++++- type-discipline-budget.json | 2 +- 3 files changed, 77 insertions(+), 26 deletions(-) diff --git a/litellm/router_utils/prompt_caching_cache.py b/litellm/router_utils/prompt_caching_cache.py index 171a086de81..20af344ea3c 100644 --- a/litellm/router_utils/prompt_caching_cache.py +++ b/litellm/router_utils/prompt_caching_cache.py @@ -140,13 +140,20 @@ class PromptCachingCache: return cacheable_prefix @staticmethod - def get_prompt_caching_ttl(messages: list[AllMessageValues] | None) -> int: - if messages is None: - return 300 + def get_prompt_caching_ttl( + messages: list[AllMessageValues] | None, + tools: list[ChatCompletionToolParam] | None = None, + ) -> int: + cacheable_prefix: Final = PromptCachingCache.extract_cacheable_prefix(messages) if messages is not None else [] + return PromptCachingCache.get_prompt_caching_ttl_from_prefix(cacheable_prefix, tools) - cacheable_prefix: Final = PromptCachingCache.extract_cacheable_prefix(messages) - cache_control_ttls: Final = tuple( - cache_control.get("ttl") + @staticmethod + def get_prompt_caching_ttl_from_prefix( + cacheable_prefix: list[AllMessageValues], + tools: list[ChatCompletionToolParam] | None, + ) -> int: + cache_control_values: Final = tuple( + cache_control for message in cacheable_prefix for cache_control in ( message.get("cache_control"), @@ -157,24 +164,28 @@ class PromptCachingCache: ), ) if isinstance(cache_control, dict) and cache_control.get("type") == "ephemeral" - ) - return 3600 if "1h" in cache_control_ttls else 300 + ) + tuple(tool.get("cache_control") for tool in (tools or ()) if isinstance(tool.get("cache_control"), dict)) + return 3600 if any(value.get("ttl") == "1h" for value in cache_control_values) else 300 @staticmethod def get_prompt_caching_cache_key( messages: list[AllMessageValues] | None, tools: list[ChatCompletionToolParam] | None, ) -> str | None: - if messages is None and tools is None: - return None + cacheable_messages: Final = ( + PromptCachingCache.extract_cacheable_prefix(messages) if messages is not None else None + ) + return PromptCachingCache.get_prompt_caching_cache_key_from_prefix(cacheable_messages, tools) - # Extract cacheable prefix from messages (only include up to last cache_control block) - cacheable_messages = None - if messages is not None: - cacheable_messages = PromptCachingCache.extract_cacheable_prefix(messages) - # If no cacheable prefix found, return None (can't cache) - if not cacheable_messages: - return None + @staticmethod + def get_prompt_caching_cache_key_from_prefix( + cacheable_messages: list[AllMessageValues] | None, + tools: list[ChatCompletionToolParam] | None, + ) -> str | None: + if cacheable_messages is None and tools is None: + return None + if cacheable_messages is not None and not cacheable_messages: + return None # Use serialize_object for consistent and stable serialization data_to_hash: Final = {} @@ -205,15 +216,17 @@ class PromptCachingCache: if messages is None and tools is None: return - cache_key: Final = PromptCachingCache.get_prompt_caching_cache_key(messages, tools) - # If no cacheable prefix found, don't cache (can't generate cache key) + cacheable_prefix: Final = ( + PromptCachingCache.extract_cacheable_prefix(messages) if messages is not None else None + ) + cache_key: Final = PromptCachingCache.get_prompt_caching_cache_key_from_prefix(cacheable_prefix, tools) if cache_key is None: return self.cache.set_cache( cache_key, PromptCachingCacheValue(model_id=model_id), - ttl=PromptCachingCache.get_prompt_caching_ttl(messages), + ttl=PromptCachingCache.get_prompt_caching_ttl_from_prefix(cacheable_prefix or [], tools), ) return @@ -226,15 +239,17 @@ class PromptCachingCache: if messages is None and tools is None: return - cache_key: Final = PromptCachingCache.get_prompt_caching_cache_key(messages, tools) - # If no cacheable prefix found, don't cache (can't generate cache key) + cacheable_prefix: Final = ( + PromptCachingCache.extract_cacheable_prefix(messages) if messages is not None else None + ) + cache_key: Final = PromptCachingCache.get_prompt_caching_cache_key_from_prefix(cacheable_prefix, tools) if cache_key is None: return await self.cache.async_set_cache( cache_key, PromptCachingCacheValue(model_id=model_id), - ttl=PromptCachingCache.get_prompt_caching_ttl(messages), + ttl=PromptCachingCache.get_prompt_caching_ttl_from_prefix(cacheable_prefix or [], tools), ) return diff --git a/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py b/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py index fde779b3feb..20db54d6645 100644 --- a/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py +++ b/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py @@ -1,7 +1,7 @@ import asyncio import copy from typing import List, cast -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, Mock import pytest @@ -16,7 +16,7 @@ from litellm.router_utils.pre_call_checks.prompt_caching_deployment_check import _get_min_token_count_for_deployments, ) from litellm.router_utils.prompt_caching_cache import PromptCachingCache -from litellm.types.llms.openai import AllMessageValues +from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam from litellm.utils import get_prompt_cache_min_tokens, is_prompt_caching_valid_prompt, token_counter MODEL_GROUP_ALIAS = "my-claude-group" @@ -75,6 +75,20 @@ def test_prompt_caching_affinity_ttl_matches_cache_control(ttl: str | None, expe assert PromptCachingCache.get_prompt_caching_ttl(messages) == expected_affinity_ttl +def test_add_model_id_uses_one_hour_affinity_ttl(): + cache = DualCache() + set_cache = Mock() + cache.set_cache = set_cache + messages = cast( + List[AllMessageValues], + [{"role": "system", "content": [{"type": "text", "text": "cached", "cache_control": {"type": "ephemeral", "ttl": "1h"}}]}], + ) + + PromptCachingCache(cache=cache).add_model_id("dep-1", messages, None) + + assert set_cache.call_args.kwargs["ttl"] == 3600 + + @pytest.mark.asyncio async def test_async_add_model_id_uses_one_hour_affinity_ttl(): cache = DualCache() @@ -101,6 +115,28 @@ async def test_async_add_model_id_uses_one_hour_affinity_ttl(): assert async_set_cache.call_args.kwargs["ttl"] == 3600 +@pytest.mark.asyncio +async def test_async_add_model_id_uses_one_hour_tool_affinity_ttl(): + cache = DualCache() + async_set_cache = AsyncMock() + cache.async_set_cache = async_set_cache + messages = _messages(word_count=1400) + tools = cast( + list[ChatCompletionToolParam], + [ + { + "type": "function", + "function": {"name": "lookup", "parameters": {}}, + "cache_control": {"type": "ephemeral", "ttl": "1h"}, + } + ], + ) + + await PromptCachingCache(cache=cache).async_add_model_id("dep-1", messages, tools) + + assert async_set_cache.call_args.kwargs["ttl"] == 3600 + + def test_get_min_token_count_for_deployments_takes_min_across_mixed_group(): """ A group may legally mix models whose real minimums differ, and one gate decides for every diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 0c0952289e2..3fb6b12fa08 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -27,7 +27,7 @@ "limit": 0 }, "LIT010": { - "limit": 16398 + "limit": 16396 }, "LIT011": { "limit": 5504 From 971037577cff750b72e797a396d7c6f00a01e60a Mon Sep 17 00:00:00 2001 From: nuernber Date: Fri, 11 Sep 2026 13:03:18 -0700 Subject: [PATCH 03/11] feat(prompt-caching): include system parameter in cache affinity key Add prepend_system_prompt helper to merge system parameter into messages for cache key computation. Update async_filter_deployments to prepend system prompt before computing affinity and pass tools to async_get_model_id. Update async_log_success_event to prepend system prompt and pass tools to async_add_model_id. Add test coverage for system parameter in cache affinity. --- .../prompt_caching_deployment_check.py | 23 +++++++++++++++---- litellm/router_utils/prompt_caching_cache.py | 12 ++++++++++ .../test_prompt_caching_deployment_check.py | 21 +++++++++++++++++ type-discipline-budget.json | 2 +- 4 files changed, 52 insertions(+), 6 deletions(-) diff --git a/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py b/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py index 0589e290b47..da74788c92b 100644 --- a/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py +++ b/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py @@ -74,7 +74,7 @@ class PromptCachingDeploymentCheck(CustomLogger): ## AUTO PROMPT CACHING - the breakpoints this request will carry are injected inside ## `litellm.acompletion`, after a deployment has been picked, so the affinity key has to ## be derived from the messages as they will be sent, not as they arrive here. - affinity_messages: Final = AnthropicCacheControlHook.messages_with_default_injections( + injected_messages: Final = AnthropicCacheControlHook.messages_with_default_injections( messages=cast(list[AllMessageValues], messages), models=( deployment["litellm_params"]["model"] @@ -93,10 +93,18 @@ class PromptCachingDeploymentCheck(CustomLogger): ), request_kwargs=request_kwargs, ) + affinity_messages: Final = PromptCachingCache.prepend_system_prompt( + injected_messages, + request_kwargs.get("system") if request_kwargs is not None else None, + ) model_id_dict: Final = await prompt_cache.async_get_model_id( messages=affinity_messages, - tools=None, + tools=( + cast(list[AllToolParamValues] | None, request_kwargs.get("tools")) + if request_kwargs is not None + else None + ), ) if model_id_dict is not None: model_id: Final = model_id_dict["model_id"] @@ -139,18 +147,23 @@ class PromptCachingDeploymentCheck(CustomLogger): ) return + logged_messages: Final = PromptCachingCache.prepend_system_prompt( + cast(list[AllMessageValues], messages), + kwargs.get("system"), + ) + ## PROMPT CACHING - cache model id, if prompt caching valid prompt + provider if await offload_token_count(is_prompt_caching_valid_prompt)( model=model, - messages=cast(list[AllMessageValues], messages), + messages=logged_messages, ): cache: Final = PromptCachingCache( cache=self.cache, ) await cache.async_add_model_id( model_id=model_id, - messages=messages, - tools=None, # [TODO]: add tools once standard_logging_object supports it + messages=logged_messages, + tools=cast(list[AllToolParamValues] | None, kwargs.get("tools")), ) return diff --git a/litellm/router_utils/prompt_caching_cache.py b/litellm/router_utils/prompt_caching_cache.py index 20af344ea3c..c5c89afc6ae 100644 --- a/litellm/router_utils/prompt_caching_cache.py +++ b/litellm/router_utils/prompt_caching_cache.py @@ -139,6 +139,18 @@ class PromptCachingCache: return cacheable_prefix + @staticmethod + def prepend_system_prompt( + messages: list[AllMessageValues], + system: object | None, + ) -> list[AllMessageValues]: + if system is None: + return messages + return cast( + list[AllMessageValues], + [{"role": "system", "content": system}, *messages], + ) + @staticmethod def get_prompt_caching_ttl( messages: list[AllMessageValues] | None, diff --git a/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py b/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py index 20db54d6645..eac5f5ec0ae 100644 --- a/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py +++ b/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py @@ -61,6 +61,27 @@ def _messages(word_count: int) -> List[AllMessageValues]: ) +@pytest.mark.asyncio +async def test_system_parameter_is_part_of_prompt_cache_affinity(): + cache = DualCache() + check = PromptCachingDeploymentCheck(cache=cache) + deployments = _deployments("anthropic/claude-opus-4-6", "anthropic/claude-opus-4-6") + messages = _messages(word_count=5000) + system = [{"type": "text", "text": "system", "cache_control": {"type": "ephemeral", "ttl": "1h"}}] + cached_messages = PromptCachingCache.prepend_system_prompt(messages, system) + + await PromptCachingCache(cache=cache).async_add_model_id("dep-2", cached_messages, None) + + filtered = await check.async_filter_deployments( + model=MODEL_GROUP_ALIAS, + healthy_deployments=deployments, + messages=messages, + request_kwargs={"system": system}, + ) + + assert filtered == [deployments[1]] + + @pytest.mark.parametrize( ("ttl", "expected_affinity_ttl"), ((None, 300), ("5m", 300), ("1h", 3600)), diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 3fb6b12fa08..d21f4882147 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -27,7 +27,7 @@ "limit": 0 }, "LIT010": { - "limit": 16396 + "limit": 16394 }, "LIT011": { "limit": 5504 From 17fc935ed60c5e55c00b4da27a55abc8ea1a00ec Mon Sep 17 00:00:00 2001 From: nuernber Date: Fri, 11 Sep 2026 13:28:57 -0700 Subject: [PATCH 04/11] feat(prompt-caching): use shortest TTL for mixed cache control blocks and exclude trailing uncached tools from affinity key Extract cacheable_tools helper to slice tools at the last cache_control breakpoint. Update get_prompt_caching_ttl to use all() instead of any() so mixed TTL values default to the shorter 5 minute affinity. Update get_prompt_caching_cache_key to serialize only cacheable tools. Add test coverage for prepend_system_prompt preserving messages without system parameter, tool affinity ignoring trailing --- litellm/router_utils/prompt_caching_cache.py | 25 +++++++-- .../test_prompt_caching_deployment_check.py | 51 ++++++++++++++++++- type-discipline-budget.json | 2 +- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/litellm/router_utils/prompt_caching_cache.py b/litellm/router_utils/prompt_caching_cache.py index c5c89afc6ae..e8ef3d0d3fd 100644 --- a/litellm/router_utils/prompt_caching_cache.py +++ b/litellm/router_utils/prompt_caching_cache.py @@ -139,6 +139,22 @@ class PromptCachingCache: return cacheable_prefix + @staticmethod + def extract_cacheable_tools( + tools: list[ChatCompletionToolParam], + ) -> list[ChatCompletionToolParam]: + cacheable_tool_index: Final = next( + ( + index + for index in range(len(tools) - 1, -1, -1) + if isinstance(tools[index].get("cache_control"), dict) + and tools[index]["cache_control"].get("type") == "ephemeral" + ), + None, + ) + # Match the provider prefix exactly instead of pinning on uncached trailing tools + return tools[: cacheable_tool_index + 1] if cacheable_tool_index is not None else [] + @staticmethod def prepend_system_prompt( messages: list[AllMessageValues], @@ -164,6 +180,7 @@ class PromptCachingCache: cacheable_prefix: list[AllMessageValues], tools: list[ChatCompletionToolParam] | None, ) -> int: + cacheable_tools: Final = PromptCachingCache.extract_cacheable_tools(tools or []) cache_control_values: Final = tuple( cache_control for message in cacheable_prefix @@ -176,8 +193,9 @@ class PromptCachingCache: ), ) if isinstance(cache_control, dict) and cache_control.get("type") == "ephemeral" - ) + tuple(tool.get("cache_control") for tool in (tools or ()) if isinstance(tool.get("cache_control"), dict)) - return 3600 if any(value.get("ttl") == "1h" for value in cache_control_values) else 300 + ) + tuple(tool.get("cache_control") for tool in cacheable_tools if isinstance(tool.get("cache_control"), dict)) + # Prefer the shortest provider lifetime so affinity never outlives a cached segment + return 3600 if cache_control_values and all(value.get("ttl") == "1h" for value in cache_control_values) else 300 @staticmethod def get_prompt_caching_cache_key( @@ -205,7 +223,8 @@ class PromptCachingCache: serialized_messages: Final = PromptCachingCache.serialize_object(cacheable_messages) data_to_hash["messages"] = serialized_messages if tools is not None: - serialized_tools: Final = PromptCachingCache.serialize_object(tools) + cacheable_tools: Final = PromptCachingCache.extract_cacheable_tools(tools) + serialized_tools: Final = PromptCachingCache.serialize_object(cacheable_tools) data_to_hash["tools"] = serialized_tools # Combine serialized data into a single string diff --git a/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py b/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py index eac5f5ec0ae..cabd41c1943 100644 --- a/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py +++ b/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py @@ -61,6 +61,29 @@ def _messages(word_count: int) -> List[AllMessageValues]: ) +def test_prepend_system_prompt_without_system_preserves_messages(): + messages = _messages(word_count=10) + + assert PromptCachingCache.prepend_system_prompt(messages, None) is messages + + +def test_tool_affinity_ignores_tools_after_cache_breakpoint(): + messages = _messages(word_count=10) + cached_tools = cast( + list[ChatCompletionToolParam], + [ + {"type": "function", "function": {"name": "stable", "parameters": {}}, "cache_control": {"type": "ephemeral"}}, + {"type": "function", "function": {"name": "first-trailing", "parameters": {}}}, + ], + ) + changed_trailing_tools = [*cached_tools[:-1], {"type": "function", "function": {"name": "second-trailing", "parameters": {}}}] + + assert PromptCachingCache.extract_cacheable_tools(cached_tools) == cached_tools[:1] + assert PromptCachingCache.get_prompt_caching_cache_key(messages, cached_tools) == PromptCachingCache.get_prompt_caching_cache_key( + messages, changed_trailing_tools + ) + + @pytest.mark.asyncio async def test_system_parameter_is_part_of_prompt_cache_affinity(): cache = DualCache() @@ -96,6 +119,24 @@ def test_prompt_caching_affinity_ttl_matches_cache_control(ttl: str | None, expe assert PromptCachingCache.get_prompt_caching_ttl(messages) == expected_affinity_ttl +def test_mixed_cache_ttls_use_the_shortest_affinity_ttl(): + messages = cast( + List[AllMessageValues], + [ + { + "role": "system", + "content": [{"type": "text", "text": "system", "cache_control": {"type": "ephemeral", "ttl": "1h"}}], + }, + { + "role": "user", + "content": [{"type": "text", "text": "user", "cache_control": {"type": "ephemeral", "ttl": "5m"}}], + }, + ], + ) + + assert PromptCachingCache.get_prompt_caching_ttl(messages) == 300 + + def test_add_model_id_uses_one_hour_affinity_ttl(): cache = DualCache() set_cache = Mock() @@ -141,7 +182,15 @@ async def test_async_add_model_id_uses_one_hour_tool_affinity_ttl(): cache = DualCache() async_set_cache = AsyncMock() cache.async_set_cache = async_set_cache - messages = _messages(word_count=1400) + messages = cast( + List[AllMessageValues], + [ + { + "role": "user", + "content": [{"type": "text", "text": "cached", "cache_control": {"type": "ephemeral", "ttl": "1h"}}], + } + ], + ) tools = cast( list[ChatCompletionToolParam], [ diff --git a/type-discipline-budget.json b/type-discipline-budget.json index d21f4882147..9456cf2327e 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -27,7 +27,7 @@ "limit": 0 }, "LIT010": { - "limit": 16394 + "limit": 16392 }, "LIT011": { "limit": 5504 From 00a9877a5c0272f5276b543da953851d8718a7a0 Mon Sep 17 00:00:00 2001 From: nuernber Date: Fri, 11 Sep 2026 13:31:14 -0700 Subject: [PATCH 05/11] feat(prompt-caching): add comments clarifying cache miss and partial match behavior Add comment explaining cache miss must leave all healthy deployments eligible. Add comment clarifying only exact cached-prefix matches establish deployment affinity while partial matches fall back to normal routing. --- .../pre_call_checks/prompt_caching_deployment_check.py | 1 + litellm/router_utils/prompt_caching_cache.py | 2 ++ 2 files changed, 3 insertions(+) diff --git a/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py b/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py index da74788c92b..514fbd18e8c 100644 --- a/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py +++ b/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py @@ -98,6 +98,7 @@ class PromptCachingDeploymentCheck(CustomLogger): request_kwargs.get("system") if request_kwargs is not None else None, ) + # A cache miss must leave all healthy deployments eligible. model_id_dict: Final = await prompt_cache.async_get_model_id( messages=affinity_messages, tools=( diff --git a/litellm/router_utils/prompt_caching_cache.py b/litellm/router_utils/prompt_caching_cache.py index e8ef3d0d3fd..5ef5f050deb 100644 --- a/litellm/router_utils/prompt_caching_cache.py +++ b/litellm/router_utils/prompt_caching_cache.py @@ -217,6 +217,8 @@ class PromptCachingCache: if cacheable_messages is not None and not cacheable_messages: return None + # Only exact cached-prefix matches can establish deployment affinity. + # Partial matches must fall back to normal routing. # Use serialize_object for consistent and stable serialization data_to_hash: Final = {} if cacheable_messages is not None: From f5cd76b047cfb64aa2e0e0250423305c09a79abd Mon Sep 17 00:00:00 2001 From: nuernber Date: Fri, 11 Sep 2026 14:04:01 -0700 Subject: [PATCH 06/11] fix(prompt-caching): suppress LIT lint violations with proper reason comments Swap ineffective `# noqa: LIT002` comments for `# mutable-ok` (the rule LIT002 actually checks for) and add `# cast-ok` reasons on the casts in the deployment check, clearing the type-discipline gate. --- .../prompt_caching_deployment_check.py | 10 ++++++--- litellm/router_utils/prompt_caching_cache.py | 22 +++++++++++++------ 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py b/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py index 514fbd18e8c..8892b4efdb3 100644 --- a/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py +++ b/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py @@ -102,7 +102,9 @@ class PromptCachingDeploymentCheck(CustomLogger): model_id_dict: Final = await prompt_cache.async_get_model_id( messages=affinity_messages, tools=( - cast(list[AllToolParamValues] | None, request_kwargs.get("tools")) + cast( # cast-ok: request kwargs are untyped + list[AllToolParamValues] | None, request_kwargs.get("tools") + ) # cast-ok: request kwargs are untyped if request_kwargs is not None else None ), @@ -149,7 +151,7 @@ class PromptCachingDeploymentCheck(CustomLogger): return logged_messages: Final = PromptCachingCache.prepend_system_prompt( - cast(list[AllMessageValues], messages), + cast(list[AllMessageValues], messages), # cast-ok: standard logging payload is partially typed kwargs.get("system"), ) @@ -164,7 +166,9 @@ class PromptCachingDeploymentCheck(CustomLogger): await cache.async_add_model_id( model_id=model_id, messages=logged_messages, - tools=cast(list[AllToolParamValues] | None, kwargs.get("tools")), + tools=cast( # cast-ok: callback kwargs are untyped + list[AllToolParamValues] | None, kwargs.get("tools") + ), ) return diff --git a/litellm/router_utils/prompt_caching_cache.py b/litellm/router_utils/prompt_caching_cache.py index 5ef5f050deb..ef55d4e2735 100644 --- a/litellm/router_utils/prompt_caching_cache.py +++ b/litellm/router_utils/prompt_caching_cache.py @@ -153,7 +153,7 @@ class PromptCachingCache: None, ) # Match the provider prefix exactly instead of pinning on uncached trailing tools - return tools[: cacheable_tool_index + 1] if cacheable_tool_index is not None else [] + return tools[: cacheable_tool_index + 1] if cacheable_tool_index is not None else tools[:0] @staticmethod def prepend_system_prompt( @@ -162,9 +162,9 @@ class PromptCachingCache: ) -> list[AllMessageValues]: if system is None: return messages - return cast( + return cast( # cast-ok: system content is validated by the provider payload list[AllMessageValues], - [{"role": "system", "content": system}, *messages], + [{"role": "system", "content": system}, *messages], # mutable-ok: cast target requires a concrete list ) @staticmethod @@ -172,7 +172,9 @@ class PromptCachingCache: messages: list[AllMessageValues] | None, tools: list[ChatCompletionToolParam] | None = None, ) -> int: - cacheable_prefix: Final = PromptCachingCache.extract_cacheable_prefix(messages) if messages is not None else [] + cacheable_prefix: Final = ( + PromptCachingCache.extract_cacheable_prefix(messages) if messages is not None else [] # mutable-ok: TTL helper requires a concrete list + ) return PromptCachingCache.get_prompt_caching_ttl_from_prefix(cacheable_prefix, tools) @staticmethod @@ -180,7 +182,7 @@ class PromptCachingCache: cacheable_prefix: list[AllMessageValues], tools: list[ChatCompletionToolParam] | None, ) -> int: - cacheable_tools: Final = PromptCachingCache.extract_cacheable_tools(tools or []) + cacheable_tools: Final = PromptCachingCache.extract_cacheable_tools(tools or []) # mutable-ok: tool API requires a concrete list cache_control_values: Final = tuple( cache_control for message in cacheable_prefix @@ -259,7 +261,10 @@ class PromptCachingCache: self.cache.set_cache( cache_key, PromptCachingCacheValue(model_id=model_id), - ttl=PromptCachingCache.get_prompt_caching_ttl_from_prefix(cacheable_prefix or [], tools), + ttl=PromptCachingCache.get_prompt_caching_ttl_from_prefix( + cacheable_prefix or [], # mutable-ok: TTL helper requires a concrete list + tools, + ), ) return @@ -282,7 +287,10 @@ class PromptCachingCache: await self.cache.async_set_cache( cache_key, PromptCachingCacheValue(model_id=model_id), - ttl=PromptCachingCache.get_prompt_caching_ttl_from_prefix(cacheable_prefix or [], tools), + ttl=PromptCachingCache.get_prompt_caching_ttl_from_prefix( + cacheable_prefix or [], # mutable-ok: TTL helper requires a concrete list + tools, + ), ) return From c5fe741d88b0f7c118a956802e22c4d4a181a7ee Mon Sep 17 00:00:00 2001 From: nuernber Date: Fri, 11 Sep 2026 14:32:55 -0700 Subject: [PATCH 07/11] fix lint formating errors --- litellm/router_utils/prompt_caching_cache.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/litellm/router_utils/prompt_caching_cache.py b/litellm/router_utils/prompt_caching_cache.py index ef55d4e2735..ffe3a13becd 100644 --- a/litellm/router_utils/prompt_caching_cache.py +++ b/litellm/router_utils/prompt_caching_cache.py @@ -173,7 +173,9 @@ class PromptCachingCache: tools: list[ChatCompletionToolParam] | None = None, ) -> int: cacheable_prefix: Final = ( - PromptCachingCache.extract_cacheable_prefix(messages) if messages is not None else [] # mutable-ok: TTL helper requires a concrete list + PromptCachingCache.extract_cacheable_prefix(messages) + if messages is not None + else [] # mutable-ok: TTL helper requires a concrete list ) return PromptCachingCache.get_prompt_caching_ttl_from_prefix(cacheable_prefix, tools) @@ -182,7 +184,9 @@ class PromptCachingCache: cacheable_prefix: list[AllMessageValues], tools: list[ChatCompletionToolParam] | None, ) -> int: - cacheable_tools: Final = PromptCachingCache.extract_cacheable_tools(tools or []) # mutable-ok: tool API requires a concrete list + cacheable_tools: Final = PromptCachingCache.extract_cacheable_tools( + tools or [] # mutable-ok: tool API requires a concrete list + ) cache_control_values: Final = tuple( cache_control for message in cacheable_prefix From 3db12630700abfd2878324bed559d9422ee07fd7 Mon Sep 17 00:00:00 2001 From: nuernber Date: Fri, 11 Sep 2026 16:00:27 -0700 Subject: [PATCH 08/11] fix lint errors again --- litellm/router_utils/prompt_caching_cache.py | 38 +++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/litellm/router_utils/prompt_caching_cache.py b/litellm/router_utils/prompt_caching_cache.py index ffe3a13becd..869bfecf85b 100644 --- a/litellm/router_utils/prompt_caching_cache.py +++ b/litellm/router_utils/prompt_caching_cache.py @@ -10,7 +10,7 @@ from typing_extensions import TypedDict from litellm.caching.caching import DualCache from litellm.caching.in_memory_cache import InMemoryCache -from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam +from litellm.types.llms.openai import AllMessageValues, ChatCompletionCachedContent, ChatCompletionToolParam if TYPE_CHECKING: from opentelemetry.trace import Span as _Span @@ -147,8 +147,8 @@ class PromptCachingCache: ( index for index in range(len(tools) - 1, -1, -1) - if isinstance(tools[index].get("cache_control"), dict) - and tools[index]["cache_control"].get("type") == "ephemeral" + if isinstance(cache_control := tools[index].get("cache_control"), dict) + and cache_control.get("type") == "ephemeral" ), None, ) @@ -179,6 +179,22 @@ class PromptCachingCache: ) return PromptCachingCache.get_prompt_caching_ttl_from_prefix(cacheable_prefix, tools) + @staticmethod + def _ephemeral_cache_controls( + message: AllMessageValues, + ) -> tuple[ChatCompletionCachedContent | dict[str, object], ...]: + content: Final = message.get("content") + content_cache_controls: Final = ( + tuple(content_block.get("cache_control") for content_block in content if isinstance(content_block, dict)) + if isinstance(content, list) + else () + ) + return tuple( + cache_control + for cache_control in (message.get("cache_control"), *content_cache_controls) + if isinstance(cache_control, dict) and cache_control.get("type") == "ephemeral" + ) + @staticmethod def get_prompt_caching_ttl_from_prefix( cacheable_prefix: list[AllMessageValues], @@ -190,16 +206,12 @@ class PromptCachingCache: cache_control_values: Final = tuple( cache_control for message in cacheable_prefix - for cache_control in ( - message.get("cache_control"), - *( - content_block.get("cache_control") - for content_block in (message.get("content") if isinstance(message.get("content"), list) else ()) - if isinstance(content_block, dict) - ), - ) - if isinstance(cache_control, dict) and cache_control.get("type") == "ephemeral" - ) + tuple(tool.get("cache_control") for tool in cacheable_tools if isinstance(tool.get("cache_control"), dict)) + for cache_control in PromptCachingCache._ephemeral_cache_controls(message) + ) + tuple( + cache_control + for tool in cacheable_tools + if isinstance(cache_control := tool.get("cache_control"), dict) and cache_control.get("type") == "ephemeral" + ) # Prefer the shortest provider lifetime so affinity never outlives a cached segment return 3600 if cache_control_values and all(value.get("ttl") == "1h" for value in cache_control_values) else 300 From 2c4db79e3863558ebdf4a1cb6e82b3e829550cfa Mon Sep 17 00:00:00 2001 From: nuernber Date: Fri, 11 Sep 2026 16:04:49 -0700 Subject: [PATCH 09/11] test(prompt-caching): add coverage for message-level cache_control on string content Add test verifying get_prompt_caching_ttl extracts TTL from cache_control when it appears as a sibling to a string content field rather than inside a content-block list. --- .../test_prompt_caching_deployment_check.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py b/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py index cabd41c1943..08a5f037474 100644 --- a/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py +++ b/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py @@ -119,6 +119,18 @@ def test_prompt_caching_affinity_ttl_matches_cache_control(ttl: str | None, expe assert PromptCachingCache.get_prompt_caching_ttl(messages) == expected_affinity_ttl +def test_message_level_cache_control_on_string_content_sets_affinity_ttl(): + """cache_control can sit as a sibling of a string `content` field rather than inside a + content-block list, e.g. {"role": "system", "content": "...", "cache_control": {...}}. + get_prompt_caching_ttl must still pick it up.""" + messages = cast( + List[AllMessageValues], + [{"role": "system", "content": "cached", "cache_control": {"type": "ephemeral", "ttl": "1h"}}], + ) + + assert PromptCachingCache.get_prompt_caching_ttl(messages) == 3600 + + def test_mixed_cache_ttls_use_the_shortest_affinity_ttl(): messages = cast( List[AllMessageValues], From 5217ffea3437693a268d47656a598a1cbd700e93 Mon Sep 17 00:00:00 2001 From: nuernber Date: Fri, 11 Sep 2026 16:45:19 -0700 Subject: [PATCH 10/11] fix(prompt-caching): prevent duplicate system prompt in affinity key and include all tools when no cache breakpoint exists Update prepend_system_prompt to skip prepending when system message already exists at messages[0] to avoid duplicating system prompts that standard logging already added. Update extract_cacheable_tools to return full tool list when no cache_control breakpoint exists since all tools precede the messages-side cached prefix. Add test coverage for system prompt deduplication an --- litellm/router_utils/prompt_caching_cache.py | 11 +++- .../test_prompt_caching_deployment_check.py | 58 +++++++++++++++++++ type-discipline-budget.json | 2 +- 3 files changed, 68 insertions(+), 3 deletions(-) diff --git a/litellm/router_utils/prompt_caching_cache.py b/litellm/router_utils/prompt_caching_cache.py index 869bfecf85b..77ee9a6d386 100644 --- a/litellm/router_utils/prompt_caching_cache.py +++ b/litellm/router_utils/prompt_caching_cache.py @@ -152,8 +152,10 @@ class PromptCachingCache: ), None, ) - # Match the provider prefix exactly instead of pinning on uncached trailing tools - return tools[: cacheable_tool_index + 1] if cacheable_tool_index is not None else tools[:0] + # A breakpoint truncates trailing, uncached tools out of the key. With no breakpoint, + # every tool still precedes whatever the real provider prefix caches on, so the full + # list has to stay in the key or distinct tool sets would collide on the same key. + return tools[: cacheable_tool_index + 1] if cacheable_tool_index is not None else tools[:] @staticmethod def prepend_system_prompt( @@ -162,6 +164,11 @@ class PromptCachingCache: ) -> list[AllMessageValues]: if system is None: return messages + # Standard logging already prepends a string system prompt onto `messages` before this + # runs, so re-prepending here would double it up and produce a different affinity key + # than the one computed from the raw request messages. + if messages and messages[0].get("role") == "system" and messages[0].get("content") == system: + return messages return cast( # cast-ok: system content is validated by the provider payload list[AllMessageValues], [{"role": "system", "content": system}, *messages], # mutable-ok: cast target requires a concrete list diff --git a/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py b/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py index 08a5f037474..7fb7907c230 100644 --- a/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py +++ b/tests/test_litellm/router_utils/pre_call_checks/test_prompt_caching_deployment_check.py @@ -84,6 +84,64 @@ def test_tool_affinity_ignores_tools_after_cache_breakpoint(): ) +def test_tool_affinity_key_differs_for_uncached_tools_with_no_breakpoint(): + """No tool carries a cache breakpoint, but the tools still precede whatever the messages-side + breakpoint caches on the provider, so distinct tool lists must not collide on the same key.""" + messages = _messages(word_count=10) + tools_a = cast(list[ChatCompletionToolParam], [{"type": "function", "function": {"name": "alpha", "parameters": {}}}]) + tools_b = cast(list[ChatCompletionToolParam], [{"type": "function", "function": {"name": "beta", "parameters": {}}}]) + + assert PromptCachingCache.extract_cacheable_tools(tools_a) == tools_a + assert PromptCachingCache.get_prompt_caching_cache_key(messages, tools_a) != PromptCachingCache.get_prompt_caching_cache_key( + messages, tools_b + ) + + +def test_prepend_system_prompt_does_not_duplicate_an_already_logged_system_message(): + """Standard logging's append_system_prompt_messages already prepends a string system prompt + onto `messages` before async_log_success_event runs, so prepend_system_prompt must not add it + a second time or the write-path affinity key would never match the read-path key.""" + system = "You are a helpful assistant" + messages = cast(List[AllMessageValues], [{"role": "system", "content": system}, {"role": "user", "content": "hi"}]) + + assert PromptCachingCache.prepend_system_prompt(messages, system) == messages + + +@pytest.mark.asyncio +async def test_string_system_parameter_does_not_double_up_the_affinity_key(): + """Regression: kwargs["system"] as a string is already baked into `messages` by standard + logging before async_log_success_event runs. Re-prepending it there produced a different + (duplicated) affinity key than async_filter_deployments computes from the raw request + messages, so a pin written on success was never read back.""" + cache = DualCache() + check = PromptCachingDeploymentCheck(cache=cache) + deployments = _deployments("anthropic/claude-opus-4-6", "anthropic/claude-opus-4-6") + messages = _messages(word_count=5000) + system = "cached system prompt" + standard_logging_object = { + "call_type": "acompletion", + "model": "anthropic/claude-opus-4-6", + "messages": [{"role": "system", "content": system}, *messages], + "model_id": "dep-2", + } + + await check.async_log_success_event( + kwargs={"standard_logging_object": standard_logging_object, "system": system}, + response_obj=None, + start_time=None, + end_time=None, + ) + + filtered = await check.async_filter_deployments( + model=MODEL_GROUP_ALIAS, + healthy_deployments=deployments, + messages=messages, + request_kwargs={"system": system}, + ) + + assert filtered == [deployments[1]] + + @pytest.mark.asyncio async def test_system_parameter_is_part_of_prompt_cache_affinity(): cache = DualCache() diff --git a/type-discipline-budget.json b/type-discipline-budget.json index 9456cf2327e..0c0952289e2 100644 --- a/type-discipline-budget.json +++ b/type-discipline-budget.json @@ -27,7 +27,7 @@ "limit": 0 }, "LIT010": { - "limit": 16392 + "limit": 16398 }, "LIT011": { "limit": 5504 From 147e2452b65b3418a319998515b6fb444507661c Mon Sep 17 00:00:00 2001 From: nuernber Date: Fri, 11 Sep 2026 17:34:37 -0700 Subject: [PATCH 11/11] Remove Unnecessary Control-Flow Comment --- .../pre_call_checks/prompt_caching_deployment_check.py | 1 - 1 file changed, 1 deletion(-) diff --git a/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py b/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py index 8892b4efdb3..a628e0194a8 100644 --- a/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py +++ b/litellm/router_utils/pre_call_checks/prompt_caching_deployment_check.py @@ -98,7 +98,6 @@ class PromptCachingDeploymentCheck(CustomLogger): request_kwargs.get("system") if request_kwargs is not None else None, ) - # A cache miss must leave all healthy deployments eligible. model_id_dict: Final = await prompt_cache.async_get_model_id( messages=affinity_messages, tools=(