fix(router): key prompt caching affinity on tools and the /v1/messages system prompt (#46411)

* fix(router): key prompt caching affinity on tools and the /v1/messages system prompt

Co-authored-by: nuernber <benjamin.nuernberger@jpl.nasa.gov>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(router): read a 1h tool cache_control nested under function for the pin ttl

---------

Co-authored-by: nuernber <benjamin.nuernberger@jpl.nasa.gov>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Yassin Kortam 2026-10-19 19:59:09 -07:00 • committed by GitHub
parent fb555b00dc
commit d56ff281de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 545 additions and 30 deletions

View file

@ -4,7 +4,7 @@ Check if prompt caching is valid for a given deployment
Route to previously cached model id, if valid
"""
from collections.abc import Callable, Mapping
from collections.abc import Callable, Mapping, Sequence
from typing import Final, TypedDict, cast
from typing_extensions import ReadOnly
@ -19,7 +19,7 @@ from litellm.integrations.anthropic_cache_control_hook import (
from litellm.integrations.custom_logger import CustomLogger, Span
from litellm.litellm_core_utils.token_counter import offload_token_count
from litellm.llms.bedrock.common_utils import bedrock_cache_point_ttl_supported, bedrock_capability_model
from litellm.types.llms.openai import AllMessageValues
from litellm.types.llms.openai import AllMessageValues, ChatCompletionToolParam
from litellm.types.utils import CallTypes, StandardLoggingPayload
from litellm.utils import (
get_base_model_from_metadata,
@ -94,10 +94,35 @@ class PromptCachingDeploymentCheck(CustomLogger):
if not healthy_deployments[1:]:
return healthy_deployments
if messages is not None and await offload_token_count(is_prompt_caching_valid_prompt)(
messages=messages,
request_data: Final = (
cast( # cast-ok: request_kwargs is a bare dict; this only reads its keys
Mapping[str, object], request_kwargs
)
if request_kwargs is not None
else None
)
request_tools: Final = (
cast( # cast-ok: request_kwargs is untyped; the stand-down scan duck-types every tool it reads
list[AllToolParamValues] | None, request_data.get("tools")
)
if request_data is not None
else None
)
prompt_messages: Final = (
PromptCachingCache.prepend_system_prompt(
messages, request_data.get("system") if request_data is not None else None
)
if messages is not None
else None
)
if prompt_messages is not None and await offload_token_count(is_prompt_caching_valid_prompt)(
messages=prompt_messages,
model=model,
min_token_count=_get_min_token_count_for_deployments(healthy_deployments),
tools=cast( # cast-ok: AllToolParamValues widens the union for Anthropic tool shapes the counter duck-types
list[ChatCompletionToolParam] | None,
request_tools,
),
):
prompt_cache: Final = PromptCachingCache(
cache=self.cache,
@ -107,19 +132,13 @@ class PromptCachingDeploymentCheck(CustomLogger):
## `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(
messages=cast(list[AllMessageValues], messages),
messages=prompt_messages,
models=(
deployment["litellm_params"]["model"]
for deployment in healthy_deployments
if isinstance(deployment.get("litellm_params"), dict) and deployment["litellm_params"].get("model")
),
tools=(
cast( # cast-ok: request_kwargs is untyped; the stand-down scan duck-types every tool it reads
list[AllToolParamValues] | None, request_kwargs.get("tools")
)
if request_kwargs is not None
else None
),
tools=request_tools,
enable_prompt_caching=(
request_kwargs.get("enable_prompt_caching") is True if request_kwargs is not None else None
),
@ -128,7 +147,10 @@ class PromptCachingDeploymentCheck(CustomLogger):
model_id_dict: Final = await prompt_cache.async_get_model_id(
messages=affinity_messages,
tools=None,
tools=cast( # cast-ok: AllToolParamValues widens the union for Anthropic tool shapes
Sequence[ChatCompletionToolParam] | None,
request_tools,
),
)
if model_id_dict is not None:
model_id: Final = model_id_dict["model_id"]
@ -177,11 +199,23 @@ class PromptCachingDeploymentCheck(CustomLogger):
)
return
prompt_messages: Final = cast(list[AllMessageValues], messages)
event_data: Final = cast( # cast-ok: success-event kwargs are an untyped dict
Mapping[str, object], kwargs
)
tools: Final = cast(list[AllToolParamValues] | None, event_data.get("tools"))
prompt_messages: Final = PromptCachingCache.prepend_system_prompt(
cast(list[AllMessageValues], messages), # cast-ok: isinstance(list) checked above
event_data.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=prompt_messages,
tools=cast( # cast-ok: AllToolParamValues widens the union for Anthropic tool shapes the counter duck-types
list[ChatCompletionToolParam] | None,
tools,
),
):
cache: Final = PromptCachingCache(
cache=self.cache,
@ -193,7 +227,10 @@ class PromptCachingDeploymentCheck(CustomLogger):
await cache.async_add_model_id(
model_id=model_id,
messages=prompt_messages,
tools=None, # [TODO]: add tools once standard_logging_object supports it
tools=cast( # cast-ok: AllToolParamValues widens the union for Anthropic tool shapes
Sequence[ChatCompletionToolParam] | None,
tools,
),
honors_one_hour_ttl=_honors_one_hour_cache_ttl(standard_logging_object, litellm_params),
)

View file

@ -85,16 +85,77 @@ def _chain_digest(digest: bytes, unit: bytes) -> bytes:
return hashlib.sha256(digest + unit).digest()
def _is_ephemeral_mark(mark: object) -> bool:
if not isinstance(mark, dict):
return False
mark_map: Final = cast( # cast-ok: cache_control marks are plain JSON dicts
Mapping[str, object], mark
)
return mark_map.get("type") == "ephemeral"
def _tool_carries_ephemeral_mark(tool: ChatCompletionToolParam) -> bool:
tool_map: Final = cast( # cast-ok: TypedDict.get rejects keys the type does not declare
Mapping[str, object], tool
)
if _is_ephemeral_mark(tool_map.get("cache_control")):
return True
function: Final = tool_map.get("function")
if not isinstance(function, dict):
return False
return _is_ephemeral_mark(
cast(Mapping[str, object], function).get("cache_control") # cast-ok: function is a plain JSON dict
)
def _strip_tool_cache_control(tool: JsonValue) -> JsonValue:
if not isinstance(tool, dict):
return tool
function: Final = tool.get("function")
stripped_function: Final = (
{key: value for key, value in function.items() if key != "cache_control"}
if isinstance(function, dict) and "cache_control" in function
else function
)
return {
key: (stripped_function if key == "function" else value)
for key, value in tool.items()
if key != "cache_control"
}
def _pin_cache_key(digest: bytes) -> str:
return f"deployment:{digest.hex()}:prompt_caching"
def _seed(tools: Sequence[ChatCompletionToolParam] | None) -> bytes:
if tools is None:
if not tools:
return hashlib.sha256(b"").digest()
return hashlib.sha256(
_canonical_bytes(
_TOOLS_ADAPTER.validate_python(to_jsonable_python(tools, serialize_unknown=True, bytes_mode="base64"))
tuple(
_strip_tool_cache_control(tool)
for tool in _TOOLS_ADAPTER.validate_python(
to_jsonable_python(tools, serialize_unknown=True, bytes_mode="base64")
)
)
)
).digest()
def _tool_boundary_position(tools: Sequence[ChatCompletionToolParam] | None) -> tuple[PrefixPosition, ...]:
if not tools:
return ()
last_marked: Final = max(
(index for index, tool in enumerate(tools) if _tool_carries_ephemeral_mark(tool)),
default=None,
)
if last_marked is None:
return ()
digest: Final = _seed(tools[: last_marked + 1])
return (PrefixPosition(cache_key=_pin_cache_key(digest), position=0),)
def _positions_of(
prefix: tuple[Mapping[str, JsonValue], ...], tools: Sequence[ChatCompletionToolParam] | None
) -> tuple[PrefixPosition, ...]:
@ -106,7 +167,7 @@ def _positions_of(
for run_type, previous in zip(run_types, (None, *run_types[:-1]))
)
return tuple(
PrefixPosition(cache_key=f"deployment:{digest.hex()}:prompt_caching", position=position)
PrefixPosition(cache_key=_pin_cache_key(digest), position=position)
for digest, position in zip(digests, positions)
)
@ -118,10 +179,16 @@ def _lookback_keys(positions: tuple[PrefixPosition, ...]) -> tuple[str, ...]:
return tuple(entry.cache_key for entry in reversed(positions) if entry.position > oldest_probed_position)
def _carrier_cache_control(carrier: Mapping[str, JsonValue]) -> JsonValue | None:
function: Final = carrier.get("function")
nested: Final = function.get("cache_control") if isinstance(function, dict) else None
return carrier.get("cache_control", nested)
def _breakpoint_pin_ttl_seconds(carrier: JsonValue | Mapping[str, JsonValue]) -> int | None:
if not isinstance(carrier, Mapping):
return None
cache_control: Final = carrier.get("cache_control")
cache_control: Final = _carrier_cache_control(carrier)
if not isinstance(cache_control, dict) or cache_control.get("type") != "ephemeral":
return None
return PROMPT_CACHE_ONE_HOUR_PIN_TTL_SECONDS if cache_control.get("ttl") == "1h" else PROMPT_CACHE_PIN_TTL_SECONDS
@ -238,6 +305,22 @@ class PromptCachingCache:
return cacheable_prefix
@staticmethod
def prepend_system_prompt(
messages: list[AllMessageValues], # mutable-ok: callers pass the request messages list through
system: object,
) -> list[AllMessageValues]: # mutable-ok: callers bind the result back into list-typed slots
if not isinstance(system, (str, list)) or not system:
return messages
first: Final = cast( # cast-ok: every AllMessageValues member is a dict
Mapping[str, object], messages[0] if messages else {}
)
if first.get("role") == "system" and first.get("content") == system:
return messages
return cast( # cast-ok: the prepended system message is a plain dict the AllMessageValues union covers
list[AllMessageValues], [{"role": "system", "content": system}, *messages]
)
@staticmethod
def prefix_positions(
messages: list[AllMessageValues] | None,
@ -256,17 +339,21 @@ class PromptCachingCache:
already replaced by their size placeholder, so a request carrying the raw image bytes
derives the same keys the write side stored.
"""
boundary: Final = _tool_boundary_position(tools)
if not messages:
return ()
return _positions_of(
_PREFIX_ADAPTER.validate_python(
to_jsonable_python(
truncate_base64_in_messages(PromptCachingCache.extract_cacheable_prefix(messages)),
serialize_unknown=True,
bytes_mode="base64",
)
return boundary
return (
*boundary,
*_positions_of(
_PREFIX_ADAPTER.validate_python(
to_jsonable_python(
truncate_base64_in_messages(PromptCachingCache.extract_cacheable_prefix(messages)),
serialize_unknown=True,
bytes_mode="base64",
)
),
tools,
),
tools,
)
@staticmethod
@ -274,7 +361,7 @@ class PromptCachingCache:
messages: list[AllMessageValues] | None,
tools: Sequence[ChatCompletionToolParam] | None,
) -> tuple[PrefixPosition, ...]:
if not messages:
if not messages and not _tool_boundary_position(tools):
return ()
return await offload_token_count(PromptCachingCache.prefix_positions)(messages, tools)

View file

@ -13,6 +13,7 @@ from litellm.caching.in_memory_cache import InMemoryCache
from litellm.constants import DEFAULT_MINIMUM_PROMPT_CACHE_TOKEN_COUNT, PROMPT_CACHE_LOOKBACK_POSITIONS
from litellm.integrations.anthropic_cache_control_hook import AnthropicCacheControlHook
from litellm.integrations.custom_logger import CustomLogger
from litellm.litellm_core_utils.litellm_logging import StandardLoggingPayloadSetup
from litellm.router_utils.pre_call_checks.prompt_caching_deployment_check import (
PromptCachingDeploymentCheck,
_honors_one_hour_cache_ttl,
@ -1236,3 +1237,225 @@ async def test_router_with_prompt_caching(anthropic_messages):
print("response=", response)
assert response._hidden_params["model_id"] == initial_model_id
_OPENAI_TOOL: Final = {
"type": "function",
"function": {
"name": "get_weather",
"description": "word " * 3000,
"parameters": {"type": "object", "properties": {}},
},
"cache_control": {"type": "ephemeral"},
}
_ANTHROPIC_TOOL: Final = {
"name": "get_weather",
"description": "word " * 3000,
"input_schema": {"type": "object", "properties": {}},
"cache_control": {"type": "ephemeral"},
}
def _success_kwargs(
*,
call_type: str,
model: str,
model_id: str,
request_messages: list,
request_kwargs: dict,
) -> dict:
"""The success event shape: `messages` is what standard logging produced, `system`/`tools` stay raw."""
return {
"standard_logging_object": {
"call_type": call_type,
"model": model,
"model_id": model_id,
"custom_llm_provider": "anthropic",
"model_map_information": {"model_map_key": model, "model_map_value": None},
"messages": StandardLoggingPayloadSetup.append_system_prompt_messages(
kwargs=request_kwargs, messages=copy.deepcopy(request_messages)
),
},
"system": request_kwargs.get("system"),
"tools": request_kwargs.get("tools"),
}
@pytest.mark.asyncio
@pytest.mark.parametrize(
"call_type,request_messages,request_kwargs",
[
pytest.param(
"acompletion",
[{"role": "user", "content": "weather?"}],
{"tools": [_OPENAI_TOOL]},
id="chat_tool_breakpoint_only",
),
pytest.param(
"anthropic_messages",
[
{
"role": "user",
"content": [
{"type": "text", "text": "word " * 3000, "cache_control": {"type": "ephemeral"}},
],
}
],
{"system": "you are terse"},
id="anthropic_messages_string_system_and_marked_user_block",
),
pytest.param(
"anthropic_messages",
[{"role": "user", "content": "hi"}],
{
"system": [
{
"type": "text",
"text": "word " * 3000,
"cache_control": {"type": "ephemeral", "ttl": "1h"},
}
]
},
id="anthropic_messages_list_system_breakpoint_only",
),
pytest.param(
"anthropic_messages",
[{"role": "user", "content": "weather?"}],
{"tools": [_ANTHROPIC_TOOL]},
id="anthropic_messages_tool_breakpoint_only",
),
pytest.param(
"acompletion",
[{"role": "user", "content": "weather?"}],
{
"tools": [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "word " * 3000,
"parameters": {"type": "object", "properties": {}},
"cache_control": {"type": "ephemeral"},
},
}
]
},
id="chat_nested_function_cache_control_only",
),
pytest.param(
"anthropic_messages",
[
{
"role": "user",
"content": [
{"type": "text", "text": "word " * 3000, "cache_control": {"type": "ephemeral"}},
],
}
],
{},
id="control_message_breakpoint_no_system",
),
],
)
async def test_affinity_key_covers_the_tools_and_system_prompt_the_provider_sees(
call_type, request_messages, request_kwargs
):
"""
The write side keys the pin on what standard logging logged plus the raw `system`/`tools`
kwargs, and the read side must rebuild the same prefix from the raw request. Anything the
provider caches that the key ignores (tools, the /v1/messages system prompt) either writes no
pin or writes one the lookup can never compute.
"""
cache = DualCache()
check = PromptCachingDeploymentCheck(cache=cache)
deployments = _deployments(AUTO_CACHING_MODEL, AUTO_CACHING_MODEL)
await check.async_log_success_event(
kwargs=_success_kwargs(
call_type=call_type,
model=AUTO_CACHING_MODEL,
model_id="dep-1",
request_messages=request_messages,
request_kwargs=request_kwargs,
),
response_obj=None,
start_time=None,
end_time=None,
)
filtered = await check.async_filter_deployments(
model=MODEL_GROUP_ALIAS,
healthy_deployments=deployments,
messages=cast(list[AllMessageValues], request_messages),
request_kwargs=request_kwargs,
)
assert filtered == [deployments[0]]
@pytest.mark.asyncio
async def test_a_different_tool_set_does_not_share_the_pin():
"""Tools hash into the prefix, so a same-messages request with other tools must not read the pin."""
cache = DualCache()
check = PromptCachingDeploymentCheck(cache=cache)
deployments = _deployments(AUTO_CACHING_MODEL, AUTO_CACHING_MODEL)
request_messages = [{"role": "user", "content": "weather?"}]
other_tool = {**_OPENAI_TOOL, "function": {**_OPENAI_TOOL["function"], "name": "get_time"}}
await check.async_log_success_event(
kwargs=_success_kwargs(
call_type="acompletion",
model=AUTO_CACHING_MODEL,
model_id="dep-1",
request_messages=request_messages,
request_kwargs={"tools": [_OPENAI_TOOL]},
),
response_obj=None,
start_time=None,
end_time=None,
)
filtered = await check.async_filter_deployments(
model=MODEL_GROUP_ALIAS,
healthy_deployments=deployments,
messages=cast(list[AllMessageValues], request_messages),
request_kwargs={"tools": [other_tool]},
)
assert filtered == deployments
@pytest.mark.asyncio
async def test_a_pin_written_without_tools_is_read_when_tools_is_an_empty_list():
"""`tools=None` on the write and `tools=[]` on the read are the same empty seed."""
cache = DualCache()
check = PromptCachingDeploymentCheck(cache=cache)
deployments = _deployments(AUTO_CACHING_MODEL, AUTO_CACHING_MODEL)
request_messages = [
{
"role": "user",
"content": [{"type": "text", "text": "word " * 3000, "cache_control": {"type": "ephemeral"}}],
}
]
await check.async_log_success_event(
kwargs=_success_kwargs(
call_type="anthropic_messages",
model=AUTO_CACHING_MODEL,
model_id="dep-1",
request_messages=request_messages,
request_kwargs={},
),
response_obj=None,
start_time=None,
end_time=None,
)
filtered = await check.async_filter_deployments(
model=MODEL_GROUP_ALIAS,
healthy_deployments=deployments,
messages=cast(list[AllMessageValues], request_messages),
request_kwargs={"tools": []},
)
assert filtered == [deployments[0]]

View file

@ -1,5 +1,5 @@
import asyncio
from typing import Final
from typing import Final, cast
import pytest
@ -65,6 +65,11 @@ def _tool(cache_control: dict[str, str] | None) -> ChatCompletionToolParam:
return {**tool, "cache_control": cache_control} if cache_control else tool
def _function_marked_tool(cache_control: dict[str, str]) -> ChatCompletionToolParam:
tool: Final = _tool(None)
return {**tool, "function": {**tool["function"], "cache_control": cache_control}}
@pytest.mark.parametrize(
("messages", "tools", "expected_ttl"),
[
@ -108,6 +113,12 @@ def _tool(cache_control: dict[str, str] | None) -> ChatCompletionToolParam:
PROMPT_CACHE_ONE_HOUR_PIN_TTL_SECONDS,
id="1h-on-tools-only",
),
pytest.param(
_system_block_messages({"type": "ephemeral"}),
[_function_marked_tool({"type": "ephemeral", "ttl": "1h"})],
PROMPT_CACHE_ONE_HOUR_PIN_TTL_SECONDS,
id="1h-nested-under-function",
),
pytest.param(
_system_block_messages({"type": "ephemeral"}),
[_tool(None)],
@ -450,3 +461,160 @@ async def test_same_cacheable_prefix_routes_to_same_deployment() -> None:
model_id_3: Final = response3._hidden_params["model_id"]
assert model_id_1 == model_id_2 == model_id_3
def _marked_tool(
name: str, cache_control: dict | None = None, *, nested: bool = False
) -> ChatCompletionToolParam:
tool: Final = {
"type": "function",
"function": {"name": name, "description": "word " * 40, "parameters": {"type": "object", "properties": {}}},
}
if cache_control is not None and nested:
return cast(
ChatCompletionToolParam,
{**tool, "function": {**tool["function"], "cache_control": cache_control}},
)
if cache_control is not None:
return cast(ChatCompletionToolParam, {**tool, "cache_control": cache_control})
return cast(ChatCompletionToolParam, tool)
def test_prepend_system_prompt_dedupes_the_string_standard_logging_prepends():
"""
The success event logs the string system prompt already prepended as a system message, and the
request still carries it in `system`. Prepending unconditionally would hash the prefix twice on
one side, so the copy already sitting at messages[0] must be recognized as the same prompt.
"""
messages = cast(
list[AllMessageValues],
[{"role": "system", "content": "be brief"}, {"role": "user", "content": "hi"}],
)
assert PromptCachingCache.prepend_system_prompt(messages, "be brief") is messages
def test_prepend_system_prompt_prepends_a_list_system_prompt():
"""
Standard logging only prepends a string system, so a /v1/messages list system never reaches the
write side's messages. Both sides have to rebuild the same list the provider caches.
"""
system: Final = [{"type": "text", "text": "be brief", "cache_control": {"type": "ephemeral", "ttl": "1h"}}]
messages = cast(list[AllMessageValues], [{"role": "user", "content": "hi"}])
result = PromptCachingCache.prepend_system_prompt(messages, system)
assert result == [{"role": "system", "content": system}, *messages]
@pytest.mark.parametrize("system", [None, "", [], 0, {"type": "text"}])
def test_prepend_system_prompt_leaves_unsystem_values_untouched(system):
messages = cast(list[AllMessageValues], [{"role": "user", "content": "hi"}])
assert PromptCachingCache.prepend_system_prompt(messages, system) is messages
def test_prepend_system_prompt_never_mutates_the_input_messages():
system: Final = [{"type": "text", "text": "be brief"}]
user_message: Final = {"role": "user", "content": "hi"}
messages = cast(list[AllMessageValues], [user_message])
result = PromptCachingCache.prepend_system_prompt(messages, system)
assert result is not messages
assert messages == [user_message]
def test_a_tool_breakpoint_emits_a_position_zero_key_even_without_messages():
"""
A request that marks only its tools still hits the provider prompt cache on the tool prefix, so
the pin has to exist at a position before the first message block.
"""
tools: Final = [_marked_tool("read", {"type": "ephemeral"})]
positions = PromptCachingCache.prefix_positions([], tools)
assert len(positions) == 1
assert positions[0].position == 0
with_messages = PromptCachingCache.prefix_positions(
cast(
list[AllMessageValues],
[{"role": "user", "content": [{"type": "text", "text": "hi", "cache_control": {"type": "ephemeral"}}]}],
),
tools,
)
assert [position.position for position in with_messages] == [0, 1]
def test_a_retimed_or_moved_tool_breakpoint_keeps_the_same_key():
"""
cache_control on a tool is a boundary marker, not part of the cached content, so a ttl change
or a breakpoint that leaves an earlier tool unmarked hashes to the same prefix key.
"""
marked: Final = {"type": "ephemeral"}
retimed: Final = {"type": "ephemeral", "ttl": "1h"}
tools_5m: Final = [_marked_tool("read", marked)]
tools_1h: Final = [_marked_tool("read", retimed)]
key_5m: Final = PromptCachingCache.get_prompt_caching_cache_key([], tools_5m)
key_1h: Final = PromptCachingCache.get_prompt_caching_cache_key([], tools_1h)
assert key_5m is not None
assert key_5m == key_1h
both_marked: Final = [_marked_tool("read", marked), _marked_tool("write", marked)]
last_marked: Final = [_marked_tool("read"), _marked_tool("write", retimed)]
key_both: Final = PromptCachingCache.get_prompt_caching_cache_key([], both_marked)
key_last: Final = PromptCachingCache.get_prompt_caching_cache_key([], last_marked)
assert key_both == key_last
def test_a_nested_function_cache_control_marks_a_boundary_and_strips_from_the_key():
"""
LiteLLM also honors the OpenAI-style mark nested inside `function` (the Anthropic transform
maps it to the tool's cache_control), so it has to mark a boundary and hash away exactly like
the top-level form, or a client that marks that way loses affinity to a breakpoint that moved.
"""
marked: Final = {"type": "ephemeral"}
retimed: Final = {"type": "ephemeral", "ttl": "1h"}
positions = PromptCachingCache.prefix_positions([], [_marked_tool("read", marked, nested=True)])
assert len(positions) == 1
assert positions[0].position == 0
key_nested: Final = PromptCachingCache.get_prompt_caching_cache_key(
[], [_marked_tool("read", marked, nested=True)]
)
key_nested_retimed: Final = PromptCachingCache.get_prompt_caching_cache_key(
[], [_marked_tool("read", retimed, nested=True)]
)
key_toplevel: Final = PromptCachingCache.get_prompt_caching_cache_key([], [_marked_tool("read", marked)])
assert key_nested is not None
assert key_nested == key_nested_retimed
assert key_nested == key_toplevel
def test_unmarked_tools_change_the_key_marked_tools_do_not_share():
"""Tools are provider-cached content, so a different tool set must never read another request's pin."""
tools_a: Final = [_marked_tool("read", {"type": "ephemeral"})]
tools_b: Final = [_marked_tool("write", {"type": "ephemeral"})]
key_a: Final = PromptCachingCache.get_prompt_caching_cache_key([], tools_a)
key_b: Final = PromptCachingCache.get_prompt_caching_cache_key([], tools_b)
assert key_a is not None
assert key_a != key_b
def test_empty_and_missing_tools_seed_the_same_key():
"""`tools=[]` and no tools at all are the same request to the provider, so they must key alike."""
messages = cast(
list[AllMessageValues],
[{"role": "user", "content": [{"type": "text", "text": "hi", "cache_control": {"type": "ephemeral"}}]}],
)
key_none: Final = PromptCachingCache.get_prompt_caching_cache_key(messages, None)
key_empty: Final = PromptCachingCache.get_prompt_caching_cache_key(messages, [])
assert key_none == key_empty