fix(caching): forward bridged cache controls only to a semantic cache, and only a caller's opt-outs on A2A
Some checks failed
LiteLLM Rust / rust-lint (push) Has been cancelled
LiteLLM Rust / rust-test (push) Has been cancelled
LiteLLM Rust / rust-wheel (push) Has been cancelled

The generateContent and A2A completion bridges forwarded the request's cache
controls for every cache type, so an exact-match cache started honoring them.
Both now forward only when the proxy's cache is semantic.

The A2A bridge merged every request control over the agent's own cache
setting, so a caller could name another agent's namespace and read its
entries. It now forwards only a no-cache or no-store the request sets.

The generateContent cache test waits on the pending cache writes instead of
sleeping, and the pre-call gate test runs the real request helpers.
This commit is contained in:
mateo-berri 2026-10-21 04:08:31 -07:00
parent ee3ef6b4de
commit d0a9e2181c
5 changed files with 113 additions and 35 deletions

View file

@ -69,7 +69,7 @@ class GenerateContentToCompletionHandler:
completion_kwargs["proxy_server_request"] = extra_kwargs["proxy_server_request"]
if "litellm_metadata" in extra_kwargs:
completion_kwargs["litellm_metadata"] = extra_kwargs["litellm_metadata"]
if "cache" in extra_kwargs:
if "cache" in extra_kwargs and litellm.cache is not None and litellm.cache.is_semantic_cache():
completion_kwargs["cache"] = extra_kwargs["cache"]
if stream:

View file

@ -23,6 +23,7 @@ from pydantic import TypeAdapter, ValidationError
import litellm
from litellm._logging import verbose_proxy_logger
from litellm.caching.caching import Cache
from litellm.litellm_core_utils.url_utils import SSRFError, validate_url
from litellm.llms.a2a.common_utils import resolve_a2a_hop_auth_header
from litellm.proxy._types import UserAPIKeyAuth
@ -40,6 +41,7 @@ from litellm.proxy.agent_endpoints.databricks_oauth import (
)
from litellm.proxy.agent_endpoints.utils import merge_agent_headers
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.semantic_cache_gate import SKIP_CACHE_CONTROLS
from litellm.proxy.common_utils.sse_keepalive import (
SSE_COMMENT_PING,
coerce_keepalive_interval,
@ -180,12 +182,17 @@ async def _resolve_backend_auth(
)
def _request_cache_controls(agent_params: Mapping[str, object], data: Mapping[str, object]) -> Mapping[str, object]:
def _request_cache_controls(
agent_params: Mapping[str, object], data: Mapping[str, object], cache: Cache | None
) -> Mapping[str, object]:
request_cache: Final = data.get("cache")
if not isinstance(request_cache, Mapping):
if cache is None or not cache.is_semantic_cache() or not isinstance(request_cache, Mapping):
return {}
bypass: Final = {key: value for key, value in SKIP_CACHE_CONTROLS.items() if request_cache.get(key) is value}
if not bypass:
return {}
agent_cache: Final = agent_params.get("cache")
return {"cache": {**(agent_cache if isinstance(agent_cache, Mapping) else {}), **request_cache}}
return {"cache": {**(agent_cache if isinstance(agent_cache, Mapping) else {}), **bypass}}
def _forwarding_headers(
@ -848,7 +855,7 @@ async def invoke_agent_a2a(
)
forwarded_litellm_params: Final = {
**backend_auth.litellm_params,
**_request_cache_controls(backend_auth.litellm_params, data),
**_request_cache_controls(backend_auth.litellm_params, data, litellm.cache),
}
# Merge agent-level guardrails into data so post_call_success_hook and

View file

@ -5,8 +5,11 @@ Test to verify the Google GenAI generate_content adapter functionality
import asyncio
import json
import unittest
from collections.abc import Mapping
from typing import Final
import pytest
import respx
from litellm.google_genai.main import agenerate_content
@ -1645,15 +1648,26 @@ async def test_generate_content_sends_response_schema_and_tool_parameters_to_the
@pytest.mark.asyncio
async def test_generate_content_bridge_honors_the_request_cache_controls(respx_mock, monkeypatch):
@pytest.mark.parametrize(
("first_cache_controls", "second_cache_controls"),
[({"no-store": True}, None), (None, {"no-cache": True})],
ids=["no-store", "no-cache"],
)
async def test_generate_content_bridge_leaves_an_exact_match_cache_as_it_was(
respx_mock: respx.MockRouter,
monkeypatch: pytest.MonkeyPatch,
first_cache_controls: Mapping[str, bool] | None,
second_cache_controls: Mapping[str, bool] | None,
) -> None:
import httpx
from litellm.caching.caching import Cache
from litellm.caching.caching_handler import _PENDING_CACHE_WRITES
from litellm.types.caching import LiteLLMCacheType
monkeypatch.setenv("DISABLE_AIOHTTP_TRANSPORT", "True")
monkeypatch.setattr(litellm, "cache", Cache(type=LiteLLMCacheType.LOCAL))
route = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
route: Final = respx_mock.post("https://api.openai.com/v1/chat/completions").mock(
return_value=httpx.Response(
200,
json={
@ -1666,16 +1680,41 @@ async def test_generate_content_bridge_honors_the_request_cache_controls(respx_m
},
)
)
request = {
request: Final = {
"model": "openai/gpt-4.1",
"contents": [{"role": "user", "parts": [{"text": "What is the capital of France?"}]}],
"cache": {"no-cache": True, "no-store": True},
"api_key": "sk-test",
}
await agenerate_content(**request)
await asyncio.sleep(0.5)
await agenerate_content(**request)
await agenerate_content(**request, **({} if first_cache_controls is None else {"cache": first_cache_controls}))
await asyncio.gather(*_PENDING_CACHE_WRITES)
await agenerate_content(**request, **({} if second_cache_controls is None else {"cache": second_cache_controls}))
assert route.call_count == 2
assert route.call_count == 1
@pytest.mark.parametrize(
("semantic", "expected_cache"),
[(True, {"no-cache": True, "no-store": True}), (False, None)],
ids=["semantic", "exact-match"],
)
def test_generate_content_bridge_forwards_the_request_cache_controls_only_to_a_semantic_cache(
monkeypatch: pytest.MonkeyPatch, semantic: bool, expected_cache: Mapping[str, bool] | None
) -> None:
from litellm.caching.caching import Cache
from litellm.google_genai.adapters.handler import GenerateContentToCompletionHandler
from litellm.types.caching import LiteLLMCacheType
cache_type: Final = LiteLLMCacheType.VALKEY_SEMANTIC if semantic else LiteLLMCacheType.LOCAL
monkeypatch.setattr(
litellm, "cache", Cache(type=cache_type, host="localhost", port="6379", similarity_threshold=0.8)
)
completion_kwargs: Final = GenerateContentToCompletionHandler._prepare_completion_kwargs(
model="openai/gpt-4.1",
contents=[{"role": "user", "parts": [{"text": "What is the capital of France?"}]}],
extra_kwargs={"cache": {"no-cache": True, "no-store": True}},
)
assert completion_kwargs.get("cache") == expected_cache

View file

@ -386,7 +386,12 @@ def _make_agent_mock(url: str = "http://backend-agent:10001") -> MagicMock:
return agent
def _make_request_mock(method: str, params: Mapping[str, object], request_id: object = "req-1") -> MagicMock:
def _make_request_mock(
method: str,
params: Mapping[str, object],
request_id: object = "req-1",
cache: Mapping[str, object] | None = None,
) -> MagicMock:
req = MagicMock()
req.headers = {}
req.json = AsyncMock(
@ -395,6 +400,7 @@ def _make_request_mock(method: str, params: Mapping[str, object], request_id: ob
"id": request_id,
"method": method,
"params": params,
**({} if cache is None else {"cache": cache}),
}
)
return req
@ -662,6 +668,38 @@ async def test_message_methods_hand_the_semantic_cache_check_result_to_the_agent
assert (captured.litellm_params or {}).get("cache") == {"ttl": 60, "no-cache": True, "no-store": True}
@pytest.mark.asyncio
@pytest.mark.parametrize("method", ["message/send", "message/stream"])
@pytest.mark.parametrize(
("cache_type", "expected_agent_cache"),
[
(LiteLLMCacheType.VALKEY_SEMANTIC, {"namespace": "team-a", "no-store": True}),
(LiteLLMCacheType.LOCAL, {"namespace": "team-a"}),
],
ids=["semantic", "exact-match"],
)
async def test_message_methods_forward_only_the_callers_semantic_cache_opt_outs_to_the_agent_call(
method: str, cache_type: LiteLLMCacheType, expected_agent_cache: Mapping[str, object]
) -> None:
agent: Final = _make_agent_mock()
agent.litellm_params = {"cache": {"namespace": "team-a"}}
mock_request: Final = _make_request_mock(
method, _HELLO_MESSAGE_PARAMS, cache={"namespace": "team-b", "use-cache": True, "ttl": 1, "no-store": True}
)
cache: Final = Cache(type=cache_type, host="localhost", port="6379", similarity_threshold=0.8)
with patch.object(litellm, "cache", cache):
captured: Final = await _invoke_message_method(
method,
mock_request,
UserAPIKeyAuth(api_key="sk-test", team_id="team-a"),
_add_proxy_data,
agent=agent,
)
assert (captured.litellm_params or {}).get("cache") == expected_agent_cache
@pytest.mark.asyncio
@pytest.mark.parametrize("method", ["message/send", "message/stream"])
async def test_message_methods_forward_caller_identity_headers(method: str):

View file

@ -525,6 +525,7 @@ class TestProxyBaseLLMRequestProcessing:
expected_cache_controls: dict[str, object],
) -> None:
from litellm.caching.caching import Cache
from litellm.proxy.proxy_server import ProxyConfig as RealProxyConfig
from litellm.types.caching import LiteLLMCacheType
class _Gate(CustomLogger):
@ -533,37 +534,30 @@ class TestProxyBaseLLMRequestProcessing:
) -> bool:
return gate_allows
async def passthrough_pre_call_hook(
user_api_key_dict: ProxyUserAPIKeyAuth,
data: dict[str, object],
call_type: str,
skip_guardrails: bool = False,
) -> dict[str, object]:
return data
monkeypatch.setattr(litellm, "callbacks", [_Gate()])
monkeypatch.setattr(
litellm,
"cache",
Cache(type=LiteLLMCacheType.VALKEY_SEMANTIC, host="localhost", port="6379", similarity_threshold=0.8),
)
body: Final = {"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "hi"}], "cache": {"ttl": 60}}
monkeypatch.setattr(
litellm.proxy.common_request_processing,
"add_litellm_data_to_request",
AsyncMock(return_value={**body, "metadata": {}, "proxy_server_request": {"body": body}}),
request: Final = Request(
{
"type": "http",
"method": "POST",
"path": "/v1/chat/completions",
"headers": [(b"content-type", b"application/json")],
"query_string": b"",
}
)
mock_request: Final = MagicMock(spec=Request)
mock_request.headers = {}
mock_proxy_logging_obj: Final = MagicMock(spec=ProxyLogging)
mock_proxy_logging_obj.pre_call_hook = AsyncMock(side_effect=passthrough_pre_call_hook)
returned_data, _ = await ProxyBaseLLMRequestProcessing(data={}).common_processing_pre_call_logic(
request=mock_request,
returned_data, _ = await ProxyBaseLLMRequestProcessing(
data={"model": "gpt-4o-mini", "messages": [{"role": "user", "content": "hi"}], "cache": {"ttl": 60}}
).common_processing_pre_call_logic(
request=request,
general_settings={},
user_api_key_dict=ProxyUserAPIKeyAuth(api_key="sk-hashed", team_id="team-other"),
proxy_logging_obj=mock_proxy_logging_obj,
proxy_config=MagicMock(spec=ProxyConfig),
proxy_logging_obj=ProxyLogging(user_api_key_cache=DualCache()),
proxy_config=RealProxyConfig(),
route_type="acompletion",
)