litellm/tests/unit/caching/test_caching.py
devin-ai-integration[bot] 77fc3315e5
fix(caching): skip the cache past max_messages and keep tool_result text in semantic prompts (#43878)
* fix(caching): keep tool calls and tool results in semantic cache prompts

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(caching): keep semantic tool prompt helpers within lint budgets

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(caching): keep structured function_call_output text in semantic prompts

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(caching): split Responses text-field collection to stay within complexity budget

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(caching): tag each tool result with the position of the call it answers

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(caching): encode tool result position and output together so tool text cannot forge result tags

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(caching): expect encoded tool result record in qdrant semantic prompt parity case

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(caching): cover tool result arrangements, SDK clients, concurrency and qdrant outage for semantic cache

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* feat(caching): embed every semantic cache prompt field except volatile ones

Replace the per-shape allowlist in the Python and Rust semantic cache prompt
walkers with one include-by-default walker. Plain text keeps its old
concatenation; any other block or message is embedded as compact JSON with
call ids mapped to ordinals, cache_control dropped, and signatures, encrypted
content and base64 data replaced with a short sha256 digest.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(code-quality): allow the bounded semantic cache prompt walkers in the recursion check

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(rust): expect structured JSON for unknown fields in redis and valkey semantic prompts

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* Revert "test(rust): expect structured JSON for unknown fields in redis and valkey semantic prompts"

This reverts commit 86c82b949b.

* Revert "test(code-quality): allow the bounded semantic cache prompt walkers in the recursion check"

This reverts commit 39efb5d9da.

* Revert "feat(caching): embed every semantic cache prompt field except volatile ones"

This reverts commit 5aed3ab3de.

* refactor(caching): rename get_str_from_messages_with_tools to get_semantic_cache_prompt_from_messages

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(caching): split semantic cache prompt extraction by API format

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(caching): drop TypeIs guard and register Responses prompt walker with the recursion check

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(caching): pick the Responses text field without a Final inside a loop

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(caching): walk semantic cache prompts as plain dicts, dumping pydantic items once up front

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(caching): write the semantic cache prompt builders as plain loops

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(caching): skip the cache past max_messages and keep tool_result text in semantic prompts

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(caching): drop formatting-only churn from the redis semantic cache tests

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ci(integration): drop the caching group wiring that main already carries

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(caching): recurse into tool_result content in the semantic cache prompt helper

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* test(integration): check the max_messages cap on the shared exact-cache proxy

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix(caching): read list-form function_call_output text in semantic cache prompts

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* refactor(caching): extract nested Responses input lookup to keep walker under complexity limit

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* Revert "refactor(caching): extract nested Responses input lookup to keep walker under complexity limit"

This reverts commit 0665296bf1.

* style(caching): suppress C901 on the Responses input walker instead of splitting it

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: kerry <kerry@berri.ai>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-07 17:32:39 +00:00

538 lines
23 KiB
Python

import asyncio
import logging
import re
import uuid
from typing import Final
from unittest.mock import MagicMock
import pytest
import litellm
import litellm.caching.redis_cache as redis_cache_module
from litellm._internal_context import current_service_target
from litellm.caching.caching import Cache, CacheMode, response_cache_phase
from litellm.caching.caching_handler import _PENDING_CACHE_WRITES
from litellm.caching.in_memory_cache import InMemoryCache
from litellm.caching.redis_cache import RedisCache, _RedisTimeoutLogThrottle
from litellm.types.caching import EMBEDDING_CACHE_FORMAT_VERSION, LiteLLMCacheType, SemanticCacheScope
from litellm.types.utils import Embedding, EmbeddingResponse, Usage
def test_cache_key_debug_log_does_not_include_prompt_material(caplog):
cache = Cache(type=LiteLLMCacheType.LOCAL)
prompt_marker = "secret prompt material "
with caplog.at_level(logging.DEBUG, logger="LiteLLM"):
cache_key = cache.get_cache_key(
model="gpt-4.1-mini",
messages=[
{"role": "system", "content": prompt_marker * 100},
{"role": "user", "content": "hello"},
],
tools=[
{
"type": "function",
"function": {
"name": "lookup",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
},
},
}
],
response_format={
"type": "json_schema",
"json_schema": {
"name": "lookup_response",
"schema": {"type": "object"},
},
},
stream=True,
)
assert re.fullmatch(r"[0-9a-f]{64}", cache_key)
created_cache_key_logs = [
record.getMessage() for record in caplog.records if "Created cache key:" in record.getMessage()
]
assert created_cache_key_logs
assert all(prompt_marker not in message for message in created_cache_key_logs)
assert any(cache_key in message for message in created_cache_key_logs)
@pytest.mark.parametrize(
("backend", "expected_level"),
[
pytest.param(MagicMock(spec=RedisCache), logging.DEBUG, id="redis_backend_is_throttled"),
pytest.param(MagicMock(), logging.ERROR, id="other_backend_logs_every_timeout"),
],
)
def test_add_cache_timeout_only_joins_redis_throttle_for_redis_backends(backend, expected_level, caplog, monkeypatch):
throttle = _RedisTimeoutLogThrottle(interval=5.0, clock=MagicMock(return_value=1_000.0))
assert throttle.admit() == 0
monkeypatch.setattr(redis_cache_module, "_redis_timeout_log_throttle", throttle)
cache = Cache(type=LiteLLMCacheType.LOCAL)
backend.set_cache.side_effect = TimeoutError("lit7520 backend timed out")
cache.cache = backend
with caplog.at_level(logging.DEBUG, logger="LiteLLM"):
cache.add_cache("result", model="gpt-4.1-mini", messages=[{"role": "user", "content": "hi"}])
records = [r for r in caplog.records if "lit7520 backend timed out" in r.getMessage()]
assert [r.levelno for r in records] == [expected_level]
def _embedding_response(prompt_tokens, num_items):
return EmbeddingResponse(
model="amazon.titan-embed-image-v1",
data=[Embedding(embedding=[0.0], index=i, object="embedding") for i in range(num_items)],
usage=Usage(prompt_tokens=prompt_tokens, completion_tokens=0, total_tokens=prompt_tokens),
)
def test_get_per_item_prompt_tokens_single_item_returns_full_value():
cache = Cache(type=LiteLLMCacheType.LOCAL)
result = _embedding_response(prompt_tokens=0, num_items=1)
assert cache._get_per_item_prompt_tokens(result, 0) == 0
def test_get_per_item_prompt_tokens_distributes_with_remainder():
cache = Cache(type=LiteLLMCacheType.LOCAL)
result = _embedding_response(prompt_tokens=10, num_items=3)
per_item = [cache._get_per_item_prompt_tokens(result, i) for i in range(3)]
assert sum(per_item) == 10 # 4 + 3 + 3
assert per_item == [4, 3, 3]
def _semantic_cache(**cache_kwargs):
return Cache(
type=LiteLLMCacheType.VALKEY_SEMANTIC,
host="localhost",
port="6379",
similarity_threshold=0.8,
**cache_kwargs,
)
@pytest.mark.parametrize(
"cache_type",
[LiteLLMCacheType.REDIS_SEMANTIC, LiteLLMCacheType.VALKEY_SEMANTIC],
)
def test_semantic_cache_embedding_max_input_tokens_reaches_backend(cache_type):
cache = Cache(
type=cache_type,
redis_url="redis://localhost:6379",
similarity_threshold=0.8,
semantic_cache_embedding_max_input_tokens=2048,
)
assert cache.cache.embedding_max_input_tokens == 2048
def test_semantic_cache_key_excludes_prompt_so_paraphrases_share_a_bucket():
cache = _semantic_cache()
tenant = {"user_api_key": "hash-abc"}
key_a = cache.get_cache_key(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "What color is the sky?"}],
metadata=dict(tenant),
)
key_b = cache.get_cache_key(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Tell me the colour of the daytime sky."}],
metadata=dict(tenant),
)
assert key_a == key_b
def test_semantic_cache_key_isolates_tenants():
messages = [{"role": "user", "content": "What color is the sky?"}]
cache = _semantic_cache()
key_a = cache.get_cache_key(model="gpt-4o-mini", messages=messages, metadata={"user_api_key": "hash-A"})
key_b = cache.get_cache_key(model="gpt-4o-mini", messages=messages, metadata={"user_api_key": "hash-B"})
key_team = cache.get_cache_key(
model="gpt-4o-mini",
messages=messages,
metadata={"user_api_key": "hash-A", "user_api_key_team_id": "team-1"},
)
assert key_a != key_b
assert key_a != key_team
_SEMANTICALLY_IDENTICAL_PROMPTS = (
[{"role": "user", "content": "What color is the sky?"}],
[{"role": "user", "content": "Tell me the colour of the daytime sky."}],
)
def _end_user_keys(cache, metadata_field, *end_user_ids):
return [
cache.get_cache_key(
model="gpt-4o-mini",
messages=messages,
**{metadata_field: {"user_api_key": "hash-A", "user_api_key_end_user_id": end_user_id}},
)
for messages, end_user_id in zip(_SEMANTICALLY_IDENTICAL_PROMPTS, end_user_ids)
]
@pytest.mark.parametrize("metadata_field", ["metadata", "litellm_metadata"])
def test_semantic_cache_key_shares_bucket_across_end_users_by_default(metadata_field):
key_alice, key_bob = _end_user_keys(_semantic_cache(), metadata_field, "alice", "bob")
assert key_alice == key_bob
@pytest.mark.parametrize("metadata_field", ["metadata", "litellm_metadata"])
def test_semantic_cache_key_isolates_end_users_under_end_user_scope(metadata_field):
cache = _semantic_cache(semantic_cache_scope="end_user")
key_alice, key_bob = _end_user_keys(cache, metadata_field, "alice", "bob")
key_alice_again, _ = _end_user_keys(cache, metadata_field, "alice", "alice")
assert key_alice != key_bob
assert key_alice == key_alice_again
def test_semantic_cache_key_end_user_scope_without_end_user_falls_back_to_key_scope():
cache = _semantic_cache(semantic_cache_scope=SemanticCacheScope.END_USER)
messages = [{"role": "user", "content": "What color is the sky?"}]
key_scope_only = cache.get_cache_key(model="gpt-4o-mini", messages=messages, metadata={"user_api_key": "hash-A"})
end_user_absent = cache.get_cache_key(
model="gpt-4o-mini",
messages=messages,
metadata={"user_api_key": "hash-A", "user_api_key_end_user_id": None},
)
other_key = cache.get_cache_key(model="gpt-4o-mini", messages=messages, metadata={"user_api_key": "hash-B"})
key_alice, _ = _end_user_keys(cache, "metadata", "alice", "alice")
default_scope_key = _semantic_cache().get_cache_key(
model="gpt-4o-mini", messages=messages, metadata={"user_api_key": "hash-A"}
)
assert key_scope_only == end_user_absent == default_scope_key
assert key_scope_only != other_key
assert key_scope_only != key_alice
def test_semantic_cache_key_reads_tenant_identity_from_litellm_metadata():
cache = _semantic_cache()
messages = [{"role": "user", "content": "What color is the sky?"}]
key_a = cache.get_cache_key(model="gpt-4o-mini", messages=messages, litellm_metadata={"user_api_key": "hash-A"})
key_b = cache.get_cache_key(model="gpt-4o-mini", messages=messages, litellm_metadata={"user_api_key": "hash-B"})
key_a_in_litellm_params = cache.get_cache_key(
model="gpt-4o-mini",
messages=messages,
litellm_params={"litellm_metadata": {"user_api_key": "hash-A"}},
)
assert key_a != key_b
assert key_a == key_a_in_litellm_params
def test_semantic_cache_scope_rejects_unknown_value():
with pytest.raises(ValueError, match="'team' is not a valid SemanticCacheScope"):
_semantic_cache(semantic_cache_scope="team")
def test_semantic_cache_key_still_separates_models_and_params():
cache = _semantic_cache()
messages = [{"role": "user", "content": "hi"}]
tenant = {"user_api_key": "hash-A"}
assert cache.get_cache_key(model="gpt-4o-mini", messages=messages, metadata=dict(tenant)) != cache.get_cache_key(
model="gpt-4o", messages=messages, metadata=dict(tenant)
)
assert cache.get_cache_key(
model="gpt-4o-mini", messages=messages, temperature=0, metadata=dict(tenant)
) != cache.get_cache_key(model="gpt-4o-mini", messages=messages, temperature=1, metadata=dict(tenant))
def test_exact_cache_key_still_includes_prompt():
cache = Cache(type=LiteLLMCacheType.LOCAL)
key_a = cache.get_cache_key(model="gpt-4o-mini", messages=[{"role": "user", "content": "a"}])
key_b = cache.get_cache_key(model="gpt-4o-mini", messages=[{"role": "user", "content": "b"}])
assert key_a != key_b
@pytest.mark.parametrize(
"anthropic_param",
[
{"system": "answer ALPHA"},
{"top_k": 5},
{"stop_sequences": ["STOP"]},
],
)
def test_exact_cache_key_includes_anthropic_messages_params(anthropic_param):
"""Anthropic /v1/messages params with no OpenAI equivalent must still key the
cache; without them two requests that differ only by system prompt collide."""
cache = Cache(type=LiteLLMCacheType.LOCAL)
messages = [{"role": "user", "content": "which greek letter?"}]
baseline = cache.get_cache_key(model="claude-sonnet-4-5", messages=messages)
assert baseline != cache.get_cache_key(model="claude-sonnet-4-5", messages=messages, **anthropic_param)
@pytest.mark.asyncio
async def test_embedding_cache_skips_write_when_one_input_yields_many_embeddings(monkeypatch):
"""A cross-encoder behind /embeddings returns one score per document for a single
input string; caching data[0] per input would make the second call return 1 score."""
import litellm
from litellm import CustomLLM
class ScoreEveryDocument(CustomLLM):
provider_calls: int = 0
async def aembedding(self, model, input, model_response, **kwargs) -> EmbeddingResponse:
self.provider_calls += 1
return EmbeddingResponse(
model=model,
data=[Embedding(embedding=[float(i)], index=i, object="embedding") for i in range(5)],
)
scorer = ScoreEveryDocument()
monkeypatch.setattr(litellm, "custom_provider_map", [{"provider": "score-every-doc", "custom_handler": scorer}])
monkeypatch.setattr(litellm, "provider_list", [*litellm.provider_list, "score-every-doc"])
monkeypatch.setattr(litellm, "_custom_providers", [*litellm._custom_providers, "score-every-doc"])
monkeypatch.setattr(litellm, "cache", Cache(type=LiteLLMCacheType.LOCAL))
batch = '{"query": "q", "documents": ["a", "b", "c", "d", "e"]}'
first = await litellm.aembedding(model="score-every-doc/m", input=[batch])
await asyncio.gather(*_PENDING_CACHE_WRITES)
second = await litellm.aembedding(model="score-every-doc/m", input=[batch])
assert scorer.provider_calls == 2
assert [len(first.data), len(second.data)] == [5, 5]
@pytest.mark.asyncio
async def test_embedding_cache_refetches_entries_written_without_format_version(monkeypatch):
import litellm
from litellm import CustomLLM
class EmbedLength(CustomLLM):
provider_calls: int = 0
async def aembedding(self, model, input, model_response, **kwargs) -> EmbeddingResponse:
self.provider_calls += 1
return EmbeddingResponse(
model=model,
data=[
Embedding(embedding=[float(len(text))], index=idx, object="embedding")
for idx, text in enumerate(input)
],
)
embedder = EmbedLength()
monkeypatch.setattr(litellm, "custom_provider_map", [{"provider": "embed-length", "custom_handler": embedder}])
monkeypatch.setattr(litellm, "provider_list", [*litellm.provider_list, "embed-length"])
monkeypatch.setattr(litellm, "_custom_providers", [*litellm._custom_providers, "embed-length"])
monkeypatch.setattr(litellm, "cache", Cache(type=LiteLLMCacheType.LOCAL))
await litellm.aembedding(model="embed-length/m", input=["abcd"])
await asyncio.gather(*_PENDING_CACHE_WRITES)
store = litellm.cache.cache.cache_dict
stored = [entry["response"] for entry in store.values()]
assert [entry["format_version"] for entry in stored] == [EMBEDDING_CACHE_FORMAT_VERSION], stored
legacy_store = {
key: {
**entry,
"response": {
field: value
for field, value in {**entry["response"], "embedding": [-1.0]}.items()
if field != "format_version"
},
}
for key, entry in store.items()
}
monkeypatch.setattr(litellm.cache.cache, "cache_dict", legacy_store)
refetched = await litellm.aembedding(model="embed-length/m", input=["abcd"])
assert embedder.provider_calls == 2, "an entry written without format_version must be a cache miss"
assert [item["embedding"] for item in refetched.data] == [[4.0]]
@pytest.mark.asyncio
async def test_embedding_cache_serves_base64_string_embeddings_on_repeat(monkeypatch):
import litellm
from litellm import CustomLLM
class Base64Embedder(CustomLLM):
provider_calls: int = 0
async def aembedding(self, model, input, model_response, **kwargs) -> EmbeddingResponse:
self.provider_calls += 1
return EmbeddingResponse(
model=model,
data=[
Embedding(embedding="AACAPwAAAEA=", index=idx, object="embedding") for idx, _ in enumerate(input)
],
)
embedder = Base64Embedder()
monkeypatch.setattr(litellm, "custom_provider_map", [{"provider": "embed-b64", "custom_handler": embedder}])
monkeypatch.setattr(litellm, "provider_list", [*litellm.provider_list, "embed-b64"])
monkeypatch.setattr(litellm, "_custom_providers", [*litellm._custom_providers, "embed-b64"])
monkeypatch.setattr(litellm, "cache", Cache(type=LiteLLMCacheType.LOCAL))
first = await litellm.aembedding(model="embed-b64/m", input=["abcd"])
await asyncio.gather(*_PENDING_CACHE_WRITES)
second = await litellm.aembedding(model="embed-b64/m", input=["abcd"])
assert embedder.provider_calls == 1, "a string embedding written to the cache must be served on repeat"
assert [item["embedding"] for item in second.data] == [item["embedding"] for item in first.data] == ["AACAPwAAAEA="]
def test_provider_specific_cache_key_ignores_litellm_owned_kwargs(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(litellm, "enable_caching_on_provider_specific_optional_params", True)
cache: Final = Cache(type=LiteLLMCacheType.LOCAL)
request: Final = {"model": "gpt-4.1-mini", "messages": [{"role": "user", "content": "hi"}], "top_k": 5}
base_key: Final = cache.get_cache_key(**request)
assert cache.get_cache_key(**request, _litellm_control={"stream_chunk_size": 64}) == base_key
assert cache.get_cache_key(**request, litellm_trace_id="trace-1") == base_key
assert cache.get_cache_key(**{**request, "top_k": 6}) != base_key
class PhaseRecordingCache(InMemoryCache):
"""Records the target and the active span each read / write ran under, as a Redis span would."""
def __init__(self) -> None:
super().__init__()
self.seen: list[tuple[str | None, str]] = []
def _record(self) -> None:
from opentelemetry import trace
span = trace.get_current_span()
self.seen.append((current_service_target(), getattr(span, "name", "")))
def get_cache(self, key, **kwargs):
self._record()
return super().get_cache(key, **kwargs)
def set_cache(self, key, value, **kwargs):
self._record()
super().set_cache(key, value, **kwargs)
@pytest.fixture
def v2_span_exporter(monkeypatch):
from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter
from litellm.integrations.otel import OpenTelemetryV2Config
from litellm.integrations.otel.logger import OpenTelemetryV2
from litellm.integrations.otel.plumbing import providers
from litellm.proxy import proxy_server
config = OpenTelemetryV2Config(exporter="in_memory")
exporter = InMemorySpanExporter()
logger = OpenTelemetryV2(config=config, tracer_provider=providers.build_tracer_provider(config, exporter=exporter))
monkeypatch.setattr(proxy_server, "open_telemetry_logger", logger)
return exporter
_REQUEST: Final = {"model": "gpt-5.4-mini", "messages": [{"role": "user", "content": "phase me"}]}
@pytest.mark.asyncio
async def test_facade_lookup_and_store_run_inside_the_response_cache_phases(v2_span_exporter):
"""The native bridge calls ``Cache.async_get_cache`` / ``async_add_cache`` straight, never through
``caching_handler``, so the ``cache.get llm_response`` / ``cache.set llm_response`` phase and the
``llm_response`` target come from the facade: the store runs under them too, and a hit reads back."""
cache = Cache(type=LiteLLMCacheType.LOCAL)
backend = PhaseRecordingCache()
assert await cache.async_get_cache(dynamic_cache_object=backend, **_REQUEST) is None
await cache.async_add_cache({"id": "resp-1"}, dynamic_cache_object=backend, **_REQUEST)
assert await cache.async_get_cache(dynamic_cache_object=backend, **_REQUEST) == {"id": "resp-1"}
assert backend.seen == [
("llm_response", "cache.get llm_response"),
("llm_response", "cache.set llm_response"),
("llm_response", "cache.get llm_response"),
]
assert [s.name for s in v2_span_exporter.get_finished_spans()] == [
"cache.get llm_response",
"cache.set llm_response",
"cache.get llm_response",
]
assert current_service_target() is None
def test_sync_facade_lookup_and_store_run_inside_the_response_cache_phases(v2_span_exporter):
cache = Cache(type=LiteLLMCacheType.LOCAL)
backend = PhaseRecordingCache()
assert cache.get_cache(dynamic_cache_object=backend, **_REQUEST) is None
cache.add_cache({"id": "resp-1"}, **_REQUEST)
assert backend.seen == [("llm_response", "cache.get llm_response")]
assert [s.name for s in v2_span_exporter.get_finished_spans()] == [
"cache.get llm_response",
"cache.set llm_response",
]
@pytest.mark.asyncio
async def test_a_lookup_already_inside_the_phase_does_not_open_a_second_one(v2_span_exporter):
"""``caching_handler`` opens the phase around the facade call; the facade joins it."""
cache = Cache(type=LiteLLMCacheType.LOCAL)
backend = PhaseRecordingCache()
with response_cache_phase("get"):
await cache.async_get_cache(dynamic_cache_object=backend, **_REQUEST)
assert backend.seen == [("llm_response", "cache.get llm_response")]
assert [s.name for s in v2_span_exporter.get_finished_spans()] == ["cache.get llm_response"]
_TOOL_TURN_ITEM: Final = {"role": "user", "content": "hi"}
@pytest.mark.parametrize(
("kwargs", "expected"),
[
pytest.param({"messages": [_TOOL_TURN_ITEM] * 4}, True, id="four-messages-are-cached"),
pytest.param({"messages": [_TOOL_TURN_ITEM] * 5}, False, id="five-messages-skip-the-cache"),
pytest.param({"input": [_TOOL_TURN_ITEM] * 4}, True, id="four-responses-items-are-cached"),
pytest.param({"input": [_TOOL_TURN_ITEM] * 5}, False, id="five-responses-items-skip-the-cache"),
pytest.param({"input": "one prompt"}, True, id="string-input-is-one-message"),
pytest.param({"input": ["a", "b", "c", "d", "e"]}, True, id="embedding-strings-are-not-messages"),
],
)
def test_should_use_cache_stops_past_the_default_max_messages(kwargs: dict[str, object], expected: bool) -> None:
assert Cache(type=LiteLLMCacheType.LOCAL).should_use_cache(**kwargs) is expected
def test_responses_sdk_items_count_toward_max_messages() -> None:
from openai.types.responses import ResponseFunctionToolCall
call: Final = ResponseFunctionToolCall(type="function_call", call_id="c1", name="ls", arguments="{}")
assert Cache(type=LiteLLMCacheType.LOCAL).should_use_cache(input=[_TOOL_TURN_ITEM, call, call, call, call]) is False
def test_max_messages_is_configurable_and_none_disables_it() -> None:
three: Final = [_TOOL_TURN_ITEM] * 3
assert Cache(type=LiteLLMCacheType.LOCAL, max_messages=2).should_use_cache(messages=three) is False
assert Cache(type=LiteLLMCacheType.LOCAL, max_messages=3).should_use_cache(messages=three) is True
assert Cache(type=LiteLLMCacheType.LOCAL, max_messages=None).should_use_cache(messages=three * 50) is True
def test_max_messages_beats_an_explicit_use_cache_opt_in() -> None:
cache: Final = Cache(type=LiteLLMCacheType.LOCAL, mode=CacheMode.default_off)
assert cache.should_use_cache(messages=[_TOOL_TURN_ITEM] * 4, cache={"use-cache": True}) is True
assert cache.should_use_cache(messages=[_TOOL_TURN_ITEM] * 5, cache={"use-cache": True}) is False
def test_completion_past_max_messages_is_neither_served_from_nor_written_to_the_cache(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(litellm, "cache", Cache(type=LiteLLMCacheType.LOCAL))
tag: Final = uuid.uuid4().hex
four: Final = [{"role": "user", "content": f"{tag} turn {index}"} for index in range(4)]
five: Final = [*four, {"role": "user", "content": f"{tag} turn 4"}]
def answer(messages: list[dict[str, str]], mock_response: str) -> str:
response: Final = litellm.completion(model="gpt-4o-mini", messages=messages, mock_response=mock_response)
assert isinstance(response, litellm.ModelResponse), response
choice: Final = response.choices[0]
assert isinstance(choice, litellm.Choices), choice
return str(choice.message.content)
assert answer(four, "four first") == "four first"
assert answer(four, "four second") == "four first", "a 4-message repeat missed the cache"
assert answer(five, "five first") == "five first"
assert answer(five, "five second") == "five second", "a 5-message repeat was served from the cache"