From c9357c364567265abcfacbcbb245c610957c3b8d Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Fri, 31 Oct 2025 13:25:24 -0700 Subject: [PATCH 1/2] Rename LangCacheWrapper to LangCacheSemanticCache The name "LangCacheWrapper" just doesn't hit as deep. Also, it doesn't follow the same pattern as "SemanticCache," the Redis-based cache in this library. Seeing "Cache" twice feels better than "Wrapper." --- redisvl/extensions/cache/llm/__init__.py | 4 +- redisvl/extensions/cache/llm/langcache.py | 12 +++--- ...er.py => test_langcache_semantic_cache.py} | 42 +++++++++---------- 3 files changed, 29 insertions(+), 29 deletions(-) rename tests/unit/{test_langcache_wrapper.py => test_langcache_semantic_cache.py} (93%) diff --git a/redisvl/extensions/cache/llm/__init__.py b/redisvl/extensions/cache/llm/__init__.py index b3a7eefa..81eab021 100644 --- a/redisvl/extensions/cache/llm/__init__.py +++ b/redisvl/extensions/cache/llm/__init__.py @@ -4,7 +4,7 @@ This module provides LLM cache implementations for RedisVL. """ -from redisvl.extensions.cache.llm.langcache import LangCacheWrapper +from redisvl.extensions.cache.llm.langcache import LangCacheSemanticCache from redisvl.extensions.cache.llm.schema import ( CacheEntry, CacheHit, @@ -14,7 +14,7 @@ __all__ = [ "SemanticCache", - "LangCacheWrapper", + "LangCacheSemanticCache", "CacheEntry", "CacheHit", "SemanticCacheIndexSchema", diff --git a/redisvl/extensions/cache/llm/langcache.py b/redisvl/extensions/cache/llm/langcache.py index 5916bdab..ecf0b18d 100644 --- a/redisvl/extensions/cache/llm/langcache.py +++ b/redisvl/extensions/cache/llm/langcache.py @@ -15,7 +15,7 @@ logger = get_logger(__name__) -class LangCacheWrapper(BaseLLMCache): +class LangCacheSemanticCache(BaseLLMCache): """LLM Cache implementation using the LangCache managed service. This cache uses the LangCache API service for semantic caching of LLM @@ -24,9 +24,9 @@ class LangCacheWrapper(BaseLLMCache): Example: .. code-block:: python - from redisvl.extensions.cache.llm import LangCacheWrapper + from redisvl.extensions.cache.llm import LangCacheSemanticCache - cache = LangCacheWrapper( + cache = LangCacheSemanticCache( name="my_cache", server_url="https://api.langcache.com", cache_id="your-cache-id", @@ -79,9 +79,9 @@ def __init__( self._distance_scale = distance_scale if not cache_id: - raise ValueError("cache_id is required for LangCacheWrapper") + raise ValueError("cache_id is required for LangCacheSemanticCache") if not api_key: - raise ValueError("api_key is required for LangCacheWrapper") + raise ValueError("api_key is required for LangCacheSemanticCache") super().__init__(name=name, ttl=ttl, **kwargs) @@ -117,7 +117,7 @@ def _create_client(self): from langcache import LangCacheClient except ImportError as e: raise ImportError( - "The langcache package is required to use LangCacheWrapper. " + "The langcache package is required to use LangCacheSemanticCache. " "Install it with: pip install langcache" ) from e diff --git a/tests/unit/test_langcache_wrapper.py b/tests/unit/test_langcache_semantic_cache.py similarity index 93% rename from tests/unit/test_langcache_wrapper.py rename to tests/unit/test_langcache_semantic_cache.py index a0684c09..934b7907 100644 --- a/tests/unit/test_langcache_wrapper.py +++ b/tests/unit/test_langcache_semantic_cache.py @@ -1,17 +1,17 @@ -"""Unit tests for LangCacheWrapper.""" +"""Unit tests for LangCacheSemanticCache.""" import importlib.util from unittest.mock import AsyncMock, MagicMock, patch import pytest -from redisvl.extensions.cache.llm.langcache import LangCacheWrapper +from redisvl.extensions.cache.llm.langcache import LangCacheSemanticCache @pytest.fixture def mock_langcache_client(): """Create a mock LangCache client via the wrapper factory method.""" - with patch.object(LangCacheWrapper, "_create_client") as mock_create_client: + with patch.object(LangCacheSemanticCache, "_create_client") as mock_create_client: mock_client = MagicMock() mock_create_client.return_value = mock_client @@ -28,13 +28,13 @@ def mock_langcache_client(): importlib.util.find_spec("langcache") is None, reason="langcache package not installed", ) -class TestLangCacheWrapper: - """Test suite for LangCacheWrapper.""" +class TestLangCacheSemanticCache: + """Test suite for LangCacheSemanticCache.""" def test_init_requires_cache_id(self): """Test that cache_id is required.""" with pytest.raises(ValueError, match="cache_id is required"): - LangCacheWrapper( + LangCacheSemanticCache( name="test", server_url="https://api.example.com", cache_id="", @@ -44,7 +44,7 @@ def test_init_requires_cache_id(self): def test_init_requires_api_key(self): """Test that api_key is required.""" with pytest.raises(ValueError, match="api_key is required"): - LangCacheWrapper( + LangCacheSemanticCache( name="test", server_url="https://api.example.com", cache_id="test-cache", @@ -54,7 +54,7 @@ def test_init_requires_api_key(self): def test_init_requires_at_least_one_search_strategy(self): """Test that at least one search strategy must be enabled.""" with pytest.raises(ValueError, match="At least one of use_exact_search"): - LangCacheWrapper( + LangCacheSemanticCache( name="test", server_url="https://api.example.com", cache_id="test-cache", @@ -67,7 +67,7 @@ def test_init_success(self, mock_langcache_client): """Test successful initialization.""" mock_create_client, _ = mock_langcache_client - cache = LangCacheWrapper( + cache = LangCacheSemanticCache( name="test_cache", server_url="https://api.example.com", cache_id="test-cache-id", @@ -93,7 +93,7 @@ def test_store(self, mock_langcache_client): mock_response.entry_id = "entry-123" mock_client.set.return_value = mock_response - cache = LangCacheWrapper( + cache = LangCacheSemanticCache( name="test", server_url="https://api.example.com", cache_id="test-cache", @@ -123,7 +123,7 @@ async def test_astore(self, mock_langcache_client): mock_response.entry_id = "entry-456" mock_client.set_async = AsyncMock(return_value=mock_response) - cache = LangCacheWrapper( + cache = LangCacheSemanticCache( name="test", server_url="https://api.example.com", cache_id="test-cache", @@ -158,7 +158,7 @@ def test_check(self, mock_langcache_client): mock_response.data = [mock_entry] mock_client.search.return_value = mock_response - cache = LangCacheWrapper( + cache = LangCacheSemanticCache( name="test", server_url="https://api.example.com", cache_id="test-cache", @@ -198,7 +198,7 @@ async def test_acheck(self, mock_langcache_client): mock_response.data = [mock_entry] mock_client.search_async = AsyncMock(return_value=mock_response) - cache = LangCacheWrapper( + cache = LangCacheSemanticCache( name="test", server_url="https://api.example.com", cache_id="test-cache", @@ -232,7 +232,7 @@ def test_check_with_distance_threshold(self, mock_langcache_client): mock_response.data = [mock_entry] mock_client.search.return_value = mock_response - cache = LangCacheWrapper( + cache = LangCacheSemanticCache( name="test", server_url="https://api.example.com", cache_id="test-cache", @@ -268,7 +268,7 @@ def test_check_with_attributes(self, mock_langcache_client): mock_response.data = [mock_entry] mock_client.search.return_value = mock_response - cache = LangCacheWrapper( + cache = LangCacheSemanticCache( name="test", server_url="https://api.example.com", cache_id="test-cache", @@ -296,7 +296,7 @@ def test_delete(self, mock_langcache_client): """Test deleting the entire cache.""" _, mock_client = mock_langcache_client - cache = LangCacheWrapper( + cache = LangCacheSemanticCache( name="test", server_url="https://api.example.com", cache_id="test-cache", @@ -314,7 +314,7 @@ async def test_adelete(self, mock_langcache_client): mock_client.delete_query_async = AsyncMock() - cache = LangCacheWrapper( + cache = LangCacheSemanticCache( name="test", server_url="https://api.example.com", cache_id="test-cache", @@ -329,7 +329,7 @@ def test_delete_by_id(self, mock_langcache_client): """Test deleting a single entry by ID.""" _, mock_client = mock_langcache_client - cache = LangCacheWrapper( + cache = LangCacheSemanticCache( name="test", server_url="https://api.example.com", cache_id="test-cache", @@ -342,7 +342,7 @@ def test_delete_by_id(self, mock_langcache_client): def test_update_not_supported(self, mock_langcache_client): """Test that update raises NotImplementedError.""" - cache = LangCacheWrapper( + cache = LangCacheSemanticCache( name="test", server_url="https://api.example.com", cache_id="test-cache", @@ -355,7 +355,7 @@ def test_update_not_supported(self, mock_langcache_client): @pytest.mark.asyncio async def test_aupdate_not_supported(self, mock_langcache_client): """Test that async update raises NotImplementedError.""" - cache = LangCacheWrapper( + cache = LangCacheSemanticCache( name="test", server_url="https://api.example.com", cache_id="test-cache", @@ -373,7 +373,7 @@ def test_import_error_when_langcache_not_installed(): pytest.skip("langcache package is installed") with pytest.raises(ImportError, match="langcache package is required"): - LangCacheWrapper( + LangCacheSemanticCache( name="test", server_url="https://api.example.com", cache_id="test-cache", From 596dbf079d680a78dc7d023c8b0a97fe9e2e6078 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Sat, 1 Nov 2025 09:25:57 -0700 Subject: [PATCH 2/2] LangCache: avoid sending empty attributes/metadata; add tests --- redisvl/extensions/cache/llm/langcache.py | 121 +++++++++++++++----- tests/unit/test_langcache_semantic_cache.py | 62 ++++++++++ 2 files changed, 154 insertions(+), 29 deletions(-) diff --git a/redisvl/extensions/cache/llm/langcache.py b/redisvl/extensions/cache/llm/langcache.py index ecf0b18d..a35b7f99 100644 --- a/redisvl/extensions/cache/llm/langcache.py +++ b/redisvl/extensions/cache/llm/langcache.py @@ -47,7 +47,7 @@ class LangCacheSemanticCache(BaseLLMCache): def __init__( self, name: str = "langcache", - server_url: str = "https://api.langcache.com", + server_url: str = "https://aws-us-east-1.langcache.redis.io", cache_id: str = "", api_key: str = "", ttl: Optional[int] = None, @@ -56,7 +56,7 @@ def __init__( distance_scale: Literal["normalized", "redis"] = "normalized", **kwargs, ): - """Initialize a LangCache wrapper. + """Initialize a LangCache semantic cache. Args: name (str): The name of the cache. Defaults to "langcache". @@ -108,20 +108,20 @@ def _create_client(self): Initialize the LangCache client. Returns: - LangCacheClient: The LangCache client. + LangCache: The LangCache client. Raises: ImportError: If the langcache package is not installed. """ try: - from langcache import LangCacheClient + from langcache import LangCache except ImportError as e: raise ImportError( "The langcache package is required to use LangCacheSemanticCache. " "Install it with: pip install langcache" ) from e - return LangCacheClient( + return LangCache( server_url=self._server_url, cache_id=self._cache_id, api_key=self._api_key, @@ -155,6 +155,8 @@ def _build_search_kwargs( SearchStrategy.EXACT if "exact" in self._search_strategies else None, SearchStrategy.SEMANTIC if "semantic" in self._search_strategies else None, ] + # Filter out Nones to avoid sending invalid enum values + search_strategies = [s for s in search_strategies if s is not None] kwargs: Dict[str, Any] = { "prompt": prompt, "search_strategies": search_strategies, @@ -253,15 +255,31 @@ def check( if distance_threshold is not None: similarity_threshold = self._similarity_threshold(distance_threshold) - # Search using the LangCache client - # The client itself is the context manager + # Build kwargs search_kwargs = self._build_search_kwargs( prompt=prompt, similarity_threshold=similarity_threshold, attributes=attributes, ) - response = self._client.search(**search_kwargs) + try: + response = self._client.search(**search_kwargs) + except Exception as e: + try: + from langcache.errors import BadRequestErrorResponseContent + except Exception: + raise + if ( + isinstance(e, BadRequestErrorResponseContent) + and "no attributes are configured" in str(e).lower() + and attributes + ): + raise RuntimeError( + "LangCache reported attributes are not configured for this cache, " + "but attributes were provided to check(). Remove attributes or configure them on the cache." + ) from e + else: + raise # Convert results to cache hits return self._hits_from_response(response, num_results) @@ -321,7 +339,24 @@ async def acheck( # Add attributes if provided (already handled by builder) - response = await self._client.search_async(**search_kwargs) + try: + response = await self._client.search_async(**search_kwargs) + except Exception as e: + try: + from langcache.errors import BadRequestErrorResponseContent + except Exception: + raise + if ( + isinstance(e, BadRequestErrorResponseContent) + and "no attributes are configured" in str(e).lower() + and attributes + ): + raise RuntimeError( + "LangCache reported attributes are not configured for this cache, " + "but attributes were provided to acheck(). Remove attributes or configure them on the cache." + ) from e + else: + raise # Convert results to cache hits return self._hits_from_response(response, num_results) @@ -365,16 +400,30 @@ def store( if ttl is not None: logger.warning("LangCache does not support per-entry TTL") - # Store using the LangCache client - # The client itself is the context manager - # Only pass attributes if metadata is provided - # Some caches may not have attributes configured - if metadata: - result = self._client.set( - prompt=prompt, response=response, attributes=metadata - ) - else: - result = self._client.set(prompt=prompt, response=response) + # Store using the LangCache client; only send attributes if provided (non-empty) + try: + if metadata: + result = self._client.set( + prompt=prompt, response=response, attributes=metadata + ) + else: + result = self._client.set(prompt=prompt, response=response) + except Exception as e: # narrow for known SDK error when possible + try: + from langcache.errors import BadRequestErrorResponseContent + except Exception: + raise + if ( + isinstance(e, BadRequestErrorResponseContent) + and "no attributes are configured" in str(e).lower() + and metadata + ): + raise RuntimeError( + "LangCache reported attributes are not configured for this cache, " + "but metadata was provided to store(). Remove metadata or configure attributes on the cache." + ) from e + else: + raise # Return the entry ID # Result is a SetResponse Pydantic model with entry_id attribute @@ -419,16 +468,30 @@ async def astore( if ttl is not None: logger.warning("LangCache does not support per-entry TTL") - # Store using the LangCache client (async) - # The client itself is the context manager - # Only pass attributes if metadata is provided - # Some caches may not have attributes configured - if metadata: - result = await self._client.set_async( - prompt=prompt, response=response, attributes=metadata - ) - else: - result = await self._client.set_async(prompt=prompt, response=response) + # Store using the LangCache client (async); only send attributes if provided (non-empty) + try: + if metadata: + result = await self._client.set_async( + prompt=prompt, response=response, attributes=metadata + ) + else: + result = await self._client.set_async(prompt=prompt, response=response) + except Exception as e: + try: + from langcache.errors import BadRequestErrorResponseContent + except Exception: + raise + if ( + isinstance(e, BadRequestErrorResponseContent) + and "no attributes are configured" in str(e).lower() + and metadata + ): + raise RuntimeError( + "LangCache reported attributes are not configured for this cache, " + "but metadata was provided to astore(). Remove metadata or configure attributes on the cache." + ) from e + else: + raise # Return the entry ID # Result is a SetResponse Pydantic model with entry_id attribute diff --git a/tests/unit/test_langcache_semantic_cache.py b/tests/unit/test_langcache_semantic_cache.py index 934b7907..8aa884a4 100644 --- a/tests/unit/test_langcache_semantic_cache.py +++ b/tests/unit/test_langcache_semantic_cache.py @@ -292,6 +292,68 @@ def test_check_with_attributes(self, mock_langcache_client): "topic": "programming", } + def test_store_with_empty_metadata_does_not_send_attributes( + self, mock_langcache_client + ): + """Empty metadata {} should not be forwarded as attributes to the SDK.""" + _, mock_client = mock_langcache_client + + mock_response = MagicMock() + mock_response.entry_id = "entry-empty" + mock_client.set.return_value = mock_response + + cache = LangCacheSemanticCache( + name="test", + server_url="https://api.example.com", + cache_id="test-cache", + api_key="test-key", + ) + + entry_id = cache.store( + prompt="Q?", + response="A", + metadata={}, # should be ignored + ) + + assert entry_id == "entry-empty" + # Ensure attributes kwarg was NOT sent when metadata is {} + _, call_kwargs = mock_client.set.call_args + assert "attributes" not in call_kwargs + + def test_check_with_empty_attributes_does_not_send_attributes( + self, mock_langcache_client + ): + """Empty attributes {} should not be forwarded to the SDK search call.""" + _, mock_client = mock_langcache_client + + mock_entry = MagicMock() + mock_entry.model_dump.return_value = { + "id": "e1", + "prompt": "Q?", + "response": "A", + "similarity": 1.0, + "created_at": 0.0, + "updated_at": 0.0, + "attributes": {}, + } + mock_response = MagicMock() + mock_response.data = [mock_entry] + mock_client.search.return_value = mock_response + + cache = LangCacheSemanticCache( + name="test", + server_url="https://api.example.com", + cache_id="test-cache", + api_key="test-key", + ) + + results = cache.check(prompt="Q?", attributes={}) # should be ignored + assert results and results[0]["entry_id"] == "e1" + + # Ensure attributes kwarg was NOT sent when attributes is {} + _, call_kwargs = mock_client.search.call_args + assert "attributes" not in call_kwargs + def test_delete(self, mock_langcache_client): """Test deleting the entire cache.""" _, mock_client = mock_langcache_client