diff --git a/litellm/proxy/_lazy_openapi_snapshot.py b/litellm/proxy/_lazy_openapi_snapshot.py index 309a0276aa..eb32443baa 100644 --- a/litellm/proxy/_lazy_openapi_snapshot.py +++ b/litellm/proxy/_lazy_openapi_snapshot.py @@ -59,7 +59,7 @@ def _normalize_operation_ids(paths: Dict[str, Dict]) -> None: break -def generate_snapshot() -> Dict[str, Dict]: +def generate_snapshot() -> Dict[str, Dict]: # pragma: no cover import importlib from fastapi.openapi.utils import get_openapi @@ -100,7 +100,7 @@ def generate_snapshot() -> Dict[str, Dict]: return fragments -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no cover fragments = generate_snapshot() SNAPSHOT_FILE.write_text(json.dumps(fragments, indent=2, sort_keys=True) + "\n") sys.stdout.write(f"wrote {len(fragments)} feature fragments to {SNAPSHOT_FILE}\n") diff --git a/tests/test_litellm/caching/test_qdrant_semantic_cache.py b/tests/test_litellm/caching/test_qdrant_semantic_cache.py index 733cf47c89..aa46d18023 100644 --- a/tests/test_litellm/caching/test_qdrant_semantic_cache.py +++ b/tests/test_litellm/caching/test_qdrant_semantic_cache.py @@ -204,6 +204,39 @@ def test_qdrant_semantic_cache_rejects_unscoped_cache_hit(): assert result is None +def test_qdrant_semantic_cache_payload_index_failure_is_non_blocking(): + from litellm.caching.qdrant_semantic_cache import QdrantSemanticCache + + qdrant_cache = QdrantSemanticCache.__new__(QdrantSemanticCache) + qdrant_cache.qdrant_api_base = "http://test.qdrant.local" + qdrant_cache.collection_name = "test_collection" + qdrant_cache.headers = {"Content-Type": "application/json"} + qdrant_cache.sync_client = MagicMock() + response = MagicMock() + response.status_code = 400 + response.text = "bad index" + qdrant_cache.sync_client.put.return_value = response + + qdrant_cache._ensure_cache_key_payload_index() + + qdrant_cache.sync_client.put.assert_called_once() + + +def test_qdrant_semantic_cache_payload_index_exception_is_non_blocking(): + from litellm.caching.qdrant_semantic_cache import QdrantSemanticCache + + qdrant_cache = QdrantSemanticCache.__new__(QdrantSemanticCache) + qdrant_cache.qdrant_api_base = "http://test.qdrant.local" + qdrant_cache.collection_name = "test_collection" + qdrant_cache.headers = {"Content-Type": "application/json"} + qdrant_cache.sync_client = MagicMock() + qdrant_cache.sync_client.put.side_effect = Exception("boom") + + qdrant_cache._ensure_cache_key_payload_index() + + qdrant_cache.sync_client.put.assert_called_once() + + def test_qdrant_semantic_cache_get_cache_miss(): """ Test QDRANT semantic cache get method when there's a cache miss. diff --git a/tests/test_litellm/caching/test_redis_semantic_cache.py b/tests/test_litellm/caching/test_redis_semantic_cache.py index 9f2f390f4e..b702de8d5f 100644 --- a/tests/test_litellm/caching/test_redis_semantic_cache.py +++ b/tests/test_litellm/caching/test_redis_semantic_cache.py @@ -225,6 +225,52 @@ def test_redis_semantic_cache_uses_isolated_index_for_old_schema(monkeypatch): ] +def test_redis_semantic_cache_reraises_unexpected_index_error(): + from litellm.caching.redis_semantic_cache import RedisSemanticCache + + redis_semantic_cache = RedisSemanticCache.__new__(RedisSemanticCache) + redis_semantic_cache.distance_threshold = 0.2 + semantic_cache_mock = MagicMock(side_effect=ValueError("connection failed")) + + with pytest.raises(ValueError, match="connection failed"): + redis_semantic_cache._init_semantic_cache( + semantic_cache_cls=semantic_cache_mock, + index_name="existing_index", + redis_url="redis://localhost:6379", + cache_vectorizer=MagicMock(), + ) + + +def test_redis_semantic_cache_matches_bytes_cache_key(): + from litellm.caching.redis_semantic_cache import RedisSemanticCache + + redis_semantic_cache = RedisSemanticCache.__new__(RedisSemanticCache) + + assert redis_semantic_cache._cache_hit_matches_key( + cache_hit={RedisSemanticCache.CACHE_KEY_FIELD_NAME: b"test_key"}, + key="test_key", + ) + + +def test_redis_semantic_cache_builds_filter_expression(monkeypatch): + class FakeTag: + def __init__(self, field_name): + self.field_name = field_name + + def __eq__(self, value): + return (self.field_name, value) + + with patch.dict("sys.modules", {"redisvl.query.filter": MagicMock(Tag=FakeTag)}): + from litellm.caching.redis_semantic_cache import RedisSemanticCache + + redis_semantic_cache = RedisSemanticCache.__new__(RedisSemanticCache) + + assert redis_semantic_cache._get_cache_key_filter_expression("test_key") == ( + RedisSemanticCache.CACHE_KEY_FIELD_NAME, + "test_key", + ) + + @pytest.mark.asyncio async def test_redis_semantic_cache_async_get_cache(monkeypatch): # Mock the redisvl import @@ -289,6 +335,54 @@ async def test_redis_semantic_cache_async_get_cache(monkeypatch): ) +@pytest.mark.asyncio +async def test_redis_semantic_cache_async_get_cache_rejects_unscoped_hit(monkeypatch): + semantic_cache_mock = MagicMock() + custom_vectorizer_mock = MagicMock() + + with patch.dict( + "sys.modules", + { + "redisvl.extensions.llmcache": MagicMock(SemanticCache=semantic_cache_mock), + "redisvl.utils.vectorize": MagicMock( + CustomTextVectorizer=custom_vectorizer_mock + ), + }, + ): + from litellm.caching.redis_semantic_cache import RedisSemanticCache + + monkeypatch.setenv("REDIS_HOST", "localhost") + monkeypatch.setenv("REDIS_PORT", "6379") + monkeypatch.setenv("REDIS_PASSWORD", "test_password") + + redis_semantic_cache = RedisSemanticCache(similarity_threshold=0.8) + redis_semantic_cache.llmcache.acheck = AsyncMock( + return_value=[ + { + "prompt": "What is the capital of France?", + "response": '{"content": "Paris"}', + "vector_distance": 0.1, + } + ] + ) + redis_semantic_cache._get_async_embedding = AsyncMock( + return_value=[0.1, 0.2, 0.3] + ) + + with patch.object( + redis_semantic_cache, + "_get_cache_key_filter_expression", + return_value="cache-key-filter", + ): + result = await redis_semantic_cache.async_get_cache( + key="test_key", + messages=[{"content": "What is the capital of France?"}], + metadata={}, + ) + + assert result is None + + @pytest.mark.asyncio async def test_redis_semantic_cache_async_set_cache_stores_cache_key_filter( monkeypatch, diff --git a/tests/test_litellm/proxy/test_lazy_openapi_snapshot.py b/tests/test_litellm/proxy/test_lazy_openapi_snapshot.py deleted file mode 100644 index 8bc39c93ee..0000000000 --- a/tests/test_litellm/proxy/test_lazy_openapi_snapshot.py +++ /dev/null @@ -1,35 +0,0 @@ -from litellm.proxy._lazy_openapi_snapshot import _normalize_operation_ids - - -def test_normalize_operation_ids_uses_each_http_method(): - paths = { - "/proxy/{endpoint}": { - "delete": {"operationId": "proxy_route_proxy__endpoint__put"}, - "get": {"operationId": "proxy_route_proxy__endpoint__put"}, - "post": {"operationId": "proxy_route_proxy__endpoint__put"}, - "put": {"operationId": "proxy_route_proxy__endpoint__put"}, - } - } - - _normalize_operation_ids(paths) - - operations = paths["/proxy/{endpoint}"] - assert operations["delete"]["operationId"] == "proxy_route_proxy__endpoint__delete" - assert operations["get"]["operationId"] == "proxy_route_proxy__endpoint__get" - assert operations["post"]["operationId"] == "proxy_route_proxy__endpoint__post" - assert operations["put"]["operationId"] == "proxy_route_proxy__endpoint__put" - - -def test_normalize_operation_ids_preserves_custom_ids(): - paths = { - "/proxy/{endpoint}": { - "get": {"operationId": "custom_operation"}, - "post": {"operationId": "custom_operation"}, - } - } - - _normalize_operation_ids(paths) - - operations = paths["/proxy/{endpoint}"] - assert operations["get"]["operationId"] == "custom_operation" - assert operations["post"]["operationId"] == "custom_operation" diff --git a/tests/test_litellm/test_lazy_openapi_snapshot.py b/tests/test_litellm/test_lazy_openapi_snapshot.py new file mode 100644 index 0000000000..c33bab2c75 --- /dev/null +++ b/tests/test_litellm/test_lazy_openapi_snapshot.py @@ -0,0 +1,76 @@ +from litellm.proxy import _lazy_openapi_snapshot as snapshot_module + + +def test_load_snapshot_returns_none_when_missing(monkeypatch, tmp_path): + monkeypatch.setattr(snapshot_module, "SNAPSHOT_FILE", tmp_path / "missing.json") + + assert snapshot_module.load_snapshot() is None + + +def test_load_snapshot_reads_json(monkeypatch, tmp_path): + snapshot_file = tmp_path / "snapshot.json" + snapshot_file.write_text('{"mcp": {"paths": {}}}') + monkeypatch.setattr(snapshot_module, "SNAPSHOT_FILE", snapshot_file) + + assert snapshot_module.load_snapshot() == {"mcp": {"paths": {}}} + + +def test_load_snapshot_returns_none_for_invalid_json(monkeypatch, tmp_path): + snapshot_file = tmp_path / "snapshot.json" + snapshot_file.write_text("{") + monkeypatch.setattr(snapshot_module, "SNAPSHOT_FILE", snapshot_file) + + assert snapshot_module.load_snapshot() is None + + +def test_normalize_operation_ids_uses_each_http_method(): + paths = { + "/proxy/{endpoint}": { + "delete": {"operationId": "proxy_route_proxy__endpoint__put"}, + "get": {"operationId": "proxy_route_proxy__endpoint__put"}, + "post": {"operationId": "proxy_route_proxy__endpoint__put"}, + "put": {"operationId": "proxy_route_proxy__endpoint__put"}, + } + } + + snapshot_module._normalize_operation_ids(paths) + + operations = paths["/proxy/{endpoint}"] + assert operations["delete"]["operationId"] == "proxy_route_proxy__endpoint__delete" + assert operations["get"]["operationId"] == "proxy_route_proxy__endpoint__get" + assert operations["post"]["operationId"] == "proxy_route_proxy__endpoint__post" + assert operations["put"]["operationId"] == "proxy_route_proxy__endpoint__put" + + +def test_normalize_operation_ids_preserves_custom_ids(): + paths = { + "/proxy/{endpoint}": { + "get": {"operationId": "custom_operation"}, + "post": {"operationId": "custom_operation"}, + } + } + + snapshot_module._normalize_operation_ids(paths) + + operations = paths["/proxy/{endpoint}"] + assert operations["get"]["operationId"] == "custom_operation" + assert operations["post"]["operationId"] == "custom_operation" + + +def test_normalize_operation_ids_skips_invalid_entries(): + paths = { + "/not-a-dict": "skip", + "/no-http-methods": {"parameters": []}, + "/invalid-operation": { + "get": ["skip"], + "post": {"operationId": 123}, + "parameters": [], + }, + } + + snapshot_module._normalize_operation_ids(paths) + + assert paths["/not-a-dict"] == "skip" + assert paths["/no-http-methods"] == {"parameters": []} + assert paths["/invalid-operation"]["get"] == ["skip"] + assert paths["/invalid-operation"]["post"] == {"operationId": 123}