From 7b12f5772d528da391241371a44281064986ddfc Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Tue, 25 Nov 2025 08:04:04 -0800 Subject: [PATCH 1/3] Add support for item-specific TTLs; quote() and unquote() attributes --- redisvl/extensions/cache/llm/langcache.py | 86 ++++--------- ...st_langcache_semantic_cache_integration.py | 58 ++++++++- tests/unit/test_langcache_semantic_cache.py | 120 ++++++++++++++---- 3 files changed, 182 insertions(+), 82 deletions(-) diff --git a/redisvl/extensions/cache/llm/langcache.py b/redisvl/extensions/cache/llm/langcache.py index 3b030bff..870abf58 100644 --- a/redisvl/extensions/cache/llm/langcache.py +++ b/redisvl/extensions/cache/llm/langcache.py @@ -5,6 +5,7 @@ """ from typing import Any, Dict, List, Literal, Optional +from urllib.parse import quote, unquote from redisvl.extensions.cache.llm.base import BaseLLMCache from redisvl.extensions.cache.llm.schema import CacheHit @@ -15,37 +16,6 @@ logger = get_logger(__name__) -_LANGCACHE_ATTR_ENCODE_TRANS = str.maketrans( - { - ",": ",", # U+FF0C FULLWIDTH COMMA - "/": "∕", # U+2215 DIVISION SLASH - "\\": "\", # U+FF3C FULLWIDTH REVERSE SOLIDUS (backslash) - "?": "?", # U+FF1F FULLWIDTH QUESTION MARK - } -) - - -_LANGCACHE_ATTR_DECODE_TRANS = str.maketrans( - {v: k for k, v in _LANGCACHE_ATTR_ENCODE_TRANS.items()} -) - - -def _encode_attribute_value_for_langcache(value: str) -> str: - """Encode a string attribute value for use with the LangCache service. - - LangCache applies validation and matching rules to attribute values. In - particular, the managed service can reject values containing commas (",") - and may not reliably match filters on values containing slashes ("/"). - - To keep attribute values round-trippable *and* usable for attribute - filtering, we replace these characters with visually similar Unicode - variants that the service accepts. A precomputed ``str.translate`` table is - used so values are scanned only once. - """ - - return value.translate(_LANGCACHE_ATTR_ENCODE_TRANS) - - def _encode_attributes_for_langcache(attributes: Dict[str, Any]) -> Dict[str, Any]: """Return a copy of *attributes* with string values safely encoded. @@ -61,7 +31,10 @@ def _encode_attributes_for_langcache(attributes: Dict[str, Any]) -> Dict[str, An safe_attributes: Dict[str, Any] = dict(attributes) for key, value in attributes.items(): if isinstance(value, str): - encoded = _encode_attribute_value_for_langcache(value) + # Percent-encode all characters (no ``safe`` set) so punctuation and + # other special characters cannot interfere with LangCache's + # underlying query/tokenization rules. + encoded = quote(value, safe="") if encoded != value: safe_attributes[key] = encoded changed = True @@ -69,17 +42,6 @@ def _encode_attributes_for_langcache(attributes: Dict[str, Any]) -> Dict[str, An return safe_attributes if changed else attributes -def _decode_attribute_value_from_langcache(value: str) -> str: - """Decode a string attribute value returned from the LangCache service. - - This reverses :func:`_encode_attribute_value_for_langcache`, translating the - fullwidth comma and division slash characters back to their ASCII - counterparts so callers see the original values they stored. - """ - - return value.translate(_LANGCACHE_ATTR_DECODE_TRANS) - - def _decode_attributes_from_langcache(attributes: Dict[str, Any]) -> Dict[str, Any]: """Return a copy of *attributes* with string values safely decoded. @@ -95,7 +57,7 @@ def _decode_attributes_from_langcache(attributes: Dict[str, Any]) -> Dict[str, A decoded_attributes: Dict[str, Any] = dict(attributes) for key, value in attributes.items(): if isinstance(value, str): - decoded = _decode_attribute_value_from_langcache(value) + decoded = unquote(value) if decoded != value: decoded_attributes[key] = decoded changed = True @@ -472,7 +434,7 @@ def store( vector (Optional[List[float]]): Not supported by LangCache API. metadata (Optional[Dict[str, Any]]): Optional metadata (stored as attributes). filters (Optional[Dict[str, Any]]): Not supported. - ttl (Optional[int]): Optional TTL override (not supported by LangCache). + ttl (Optional[int]): Optional TTL override in seconds. Returns: str: The entry ID for the cached entry. @@ -491,18 +453,22 @@ def store( if filters is not None: logger.warning("LangCache does not support filters") - if ttl is not None: - logger.warning("LangCache does not support per-entry TTL") - - # Store using the LangCache client; only send attributes if provided (non-empty) try: + ttl_millis = int(ttl * 1000) if ttl is not None else None if metadata: safe_metadata = _encode_attributes_for_langcache(metadata) result = self._client.set( - prompt=prompt, response=response, attributes=safe_metadata + prompt=prompt, + response=response, + attributes=safe_metadata, + ttl_millis=ttl_millis, ) else: - result = self._client.set(prompt=prompt, response=response) + result = self._client.set( + prompt=prompt, + response=response, + ttl_millis=ttl_millis, + ) except Exception as e: # narrow for known SDK error when possible try: from langcache.errors import BadRequestErrorResponseContent @@ -541,7 +507,7 @@ async def astore( vector (Optional[List[float]]): Not supported by LangCache API. metadata (Optional[Dict[str, Any]]): Optional metadata (stored as attributes). filters (Optional[Dict[str, Any]]): Not supported. - ttl (Optional[int]): Optional TTL override (not supported by LangCache). + ttl (Optional[int]): Optional TTL override in seconds. Returns: str: The entry ID for the cached entry. @@ -560,18 +526,22 @@ async def astore( if filters is not None: logger.warning("LangCache does not support filters") - if ttl is not None: - logger.warning("LangCache does not support per-entry TTL") - - # Store using the LangCache client (async); only send attributes if provided (non-empty) try: + ttl_millis = int(ttl * 1000) if ttl is not None else None if metadata: safe_metadata = _encode_attributes_for_langcache(metadata) result = await self._client.set_async( - prompt=prompt, response=response, attributes=safe_metadata + prompt=prompt, + response=response, + attributes=safe_metadata, + ttl_millis=ttl_millis, ) else: - result = await self._client.set_async(prompt=prompt, response=response) + result = await self._client.set_async( + prompt=prompt, + response=response, + ttl_millis=ttl_millis, + ) except Exception as e: try: from langcache.errors import BadRequestErrorResponseContent diff --git a/tests/integration/test_langcache_semantic_cache_integration.py b/tests/integration/test_langcache_semantic_cache_integration.py index 5519fca4..3b2f11fc 100644 --- a/tests/integration/test_langcache_semantic_cache_integration.py +++ b/tests/integration/test_langcache_semantic_cache_integration.py @@ -91,6 +91,33 @@ def test_store_and_check_sync( assert hits[0]["response"] == response assert hits[0]["prompt"] == prompt + def test_store_with_per_entry_ttl_expires( + self, langcache_with_attrs: LangCacheSemanticCache + ) -> None: + """Per-entry TTL should cause individual entries to expire.""" + + prompt = "Per-entry TTL test" + response = "This entry should expire quickly." + + entry_id = langcache_with_attrs.store( + prompt=prompt, + response=response, + ttl=2, + ) + assert entry_id + + # Immediately after storing, the entry should be retrievable. + hits = langcache_with_attrs.check(prompt=prompt, num_results=5) + assert any(hit["response"] == response for hit in hits) + + # Wait for TTL to elapse and confirm the entry is no longer returned. + import time + + time.sleep(3) + + hits_after_ttl = langcache_with_attrs.check(prompt=prompt, num_results=5) + assert not any(hit["response"] == response for hit in hits_after_ttl) + @pytest.mark.asyncio async def test_store_and_check_async( self, langcache_with_attrs: LangCacheSemanticCache @@ -106,6 +133,35 @@ async def test_store_and_check_async( assert hits[0]["response"] == response assert hits[0]["prompt"] == prompt + @pytest.mark.asyncio + async def test_astore_with_per_entry_ttl_expires( + self, langcache_with_attrs: LangCacheSemanticCache + ) -> None: + """Async per-entry TTL should cause individual entries to expire.""" + + prompt = "Async per-entry TTL test" + response = "This async entry should expire quickly." + + entry_id = await langcache_with_attrs.astore( + prompt=prompt, + response=response, + ttl=2, + ) + assert entry_id + + hits = await langcache_with_attrs.acheck(prompt=prompt, num_results=5) + assert any(hit["response"] == response for hit in hits) + + import asyncio + + await asyncio.sleep(3) + + hits_after_ttl = await langcache_with_attrs.acheck( + prompt=prompt, + num_results=5, + ) + assert not any(hit["response"] == response for hit in hits_after_ttl) + def test_store_with_metadata_and_check_with_attributes( self, langcache_with_attrs: LangCacheSemanticCache ) -> None: @@ -321,7 +377,7 @@ def test_attribute_values_with_special_chars_round_trip_and_filter( """Backslash and question-mark values should round-trip via filters. These values previously failed attribute filtering on this LangCache - instance; with client-side encoding/decoding they should now be + instance; with URL-style percent encoding they should now be filterable and round-trip correctly. """ diff --git a/tests/unit/test_langcache_semantic_cache.py b/tests/unit/test_langcache_semantic_cache.py index 06bc4344..aa874429 100644 --- a/tests/unit/test_langcache_semantic_cache.py +++ b/tests/unit/test_langcache_semantic_cache.py @@ -1,5 +1,6 @@ """Unit tests for LangCacheSemanticCache.""" +import builtins import importlib.util from unittest.mock import AsyncMock, MagicMock, patch @@ -107,11 +108,13 @@ def test_store(self, mock_langcache_client): ) assert entry_id == "entry-123" - mock_client.set.assert_called_once_with( - prompt="What is Python?", - response="Python is a programming language.", - attributes={"topic": "programming"}, - ) + mock_client.set.assert_called_once() + call_kwargs = mock_client.set.call_args.kwargs + assert call_kwargs["prompt"] == "What is Python?" + assert call_kwargs["response"] == "Python is a programming language." + assert call_kwargs["attributes"] == {"topic": "programming"} + # No per-entry TTL was provided, so ttl_millis should be None or absent. + assert call_kwargs.get("ttl_millis") is None @pytest.mark.asyncio async def test_astore(self, mock_langcache_client): @@ -137,6 +140,66 @@ async def test_astore(self, mock_langcache_client): assert entry_id == "entry-456" mock_client.set_async.assert_called_once() + call_kwargs = mock_client.set_async.call_args.kwargs + assert call_kwargs["prompt"] == "What is Redis?" + assert call_kwargs["response"] == "Redis is an in-memory database." + assert call_kwargs.get("ttl_millis") is None + + def test_store_with_per_entry_ttl(self, mock_langcache_client): + """store() should pass per-entry TTL as ttl_millis to LangCache client.""" + _, mock_client = mock_langcache_client + + cache = LangCacheSemanticCache( + name="test", + server_url="https://api.example.com", + cache_id="test-cache", + api_key="test-key", + ) + + entry_id = cache.store( + prompt="p", + response="r", + metadata=None, + ttl=5, + ) + assert entry_id == mock_client.set.return_value.entry_id + + mock_client.set.assert_called_once() + call_kwargs = mock_client.set.call_args.kwargs + assert call_kwargs["prompt"] == "p" + assert call_kwargs["response"] == "r" + assert call_kwargs["ttl_millis"] == 5000 + + @pytest.mark.asyncio + async def test_astore_with_per_entry_ttl(self, mock_langcache_client): + """astore() should pass per-entry TTL as ttl_millis to LangCache client.""" + _, mock_client = mock_langcache_client + + # Ensure set_async is an AsyncMock so it can be awaited. + mock_response = MagicMock() + mock_response.entry_id = "entry-999" + mock_client.set_async = AsyncMock(return_value=mock_response) + + cache = LangCacheSemanticCache( + name="test", + server_url="https://api.example.com", + cache_id="test-cache", + api_key="test-key", + ) + + entry_id = await cache.astore( + prompt="p", + response="r", + metadata=None, + ttl=5, + ) + assert entry_id == mock_response.entry_id + + mock_client.set_async.assert_awaited_once() + call_kwargs = mock_client.set_async.call_args.kwargs + assert call_kwargs["prompt"] == "p" + assert call_kwargs["response"] == "r" + assert call_kwargs["ttl_millis"] == 5000 def test_check(self, mock_langcache_client): """Test checking the cache.""" @@ -257,6 +320,8 @@ def test_check_with_attributes(self, mock_langcache_client): # Mock search results mock_entry = MagicMock() + # Attributes are percent-encoded on the wire; we expect the client to + # decode them back to their original values. mock_entry.model_dump.return_value = { "id": "entry-123", "prompt": "What is Python?", @@ -264,11 +329,10 @@ def test_check_with_attributes(self, mock_langcache_client): "similarity": 0.95, "created_at": 1234567890.0, "updated_at": 1234567890.0, - # Attributes come back from LangCache already encoded; the client - # should decode them before exposing them to callers. "attributes": { "language": "python", - "topic": "programming,with∕encoding\and?", + # URL-encoded form of "programming,with/encoding\\and?" + "topic": "programming%2Cwith%2Fencoding%5Cand%3F", }, } @@ -296,14 +360,14 @@ def test_check_with_attributes(self, mock_langcache_client): assert len(results) == 1 assert results[0]["entry_id"] == "entry-123" - # Verify attributes were passed to search (encoded by the client) + # Verify attributes were passed to search (percent-encoded by the client) mock_client.search.assert_called_once() call_kwargs = mock_client.search.call_args.kwargs assert call_kwargs["attributes"] == { "language": "python", - # The comma, slash, backslash, and question mark should be encoded - # for LangCache. - "topic": "programming,with∕encoding\and?", + # The comma, slash, backslash, and question mark should be + # percent-encoded for LangCache. + "topic": "programming%2Cwith%2Fencoding%5Cand%3F", } # And the decoded, original values should appear in metadata @@ -562,15 +626,25 @@ async def test_aupdate_not_supported(self, mock_langcache_client): def test_import_error_when_langcache_not_installed(): - """Test that ImportError is raised when langcache is not installed.""" - # If langcache is installed in this environment, this test is not applicable - if importlib.util.find_spec("langcache") is not None: - pytest.skip("langcache package is installed") + """Test that ImportError is raised when langcache is not installed. - with pytest.raises(ImportError, match="langcache package is required"): - LangCacheSemanticCache( - name="test", - server_url="https://api.example.com", - cache_id="test-cache", - api_key="test-key", - ) + This test simulates the langcache package being missing even if it is + installed in the environment by patching ``__import__`` to raise + ImportError specifically for ``"langcache"``. + """ + + real_import = builtins.__import__ + + def fake_import(name, *args, **kwargs): + if name == "langcache" or name.startswith("langcache."): + raise ImportError("No module named 'langcache'") + return real_import(name, *args, **kwargs) + + with patch("builtins.__import__", side_effect=fake_import): + with pytest.raises(ImportError, match="langcache package is required"): + LangCacheSemanticCache( + name="test", + server_url="https://api.example.com", + cache_id="test-cache", + api_key="test-key", + ) From 1d6b8b1df84b99c867b41a0205603fea78eb2fd0 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Tue, 25 Nov 2025 15:54:17 -0800 Subject: [PATCH 2/3] Update redisvl/extensions/cache/llm/langcache.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- redisvl/extensions/cache/llm/langcache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisvl/extensions/cache/llm/langcache.py b/redisvl/extensions/cache/llm/langcache.py index 870abf58..8800ae27 100644 --- a/redisvl/extensions/cache/llm/langcache.py +++ b/redisvl/extensions/cache/llm/langcache.py @@ -454,7 +454,7 @@ def store( logger.warning("LangCache does not support filters") try: - ttl_millis = int(ttl * 1000) if ttl is not None else None + ttl_millis = round(ttl * 1000) if ttl is not None else None if metadata: safe_metadata = _encode_attributes_for_langcache(metadata) result = self._client.set( From 966dce69fbacb21d65a428ee93c4549a59b6ae42 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Tue, 25 Nov 2025 15:54:25 -0800 Subject: [PATCH 3/3] Update redisvl/extensions/cache/llm/langcache.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- redisvl/extensions/cache/llm/langcache.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redisvl/extensions/cache/llm/langcache.py b/redisvl/extensions/cache/llm/langcache.py index 8800ae27..9b215b2b 100644 --- a/redisvl/extensions/cache/llm/langcache.py +++ b/redisvl/extensions/cache/llm/langcache.py @@ -527,7 +527,7 @@ async def astore( logger.warning("LangCache does not support filters") try: - ttl_millis = int(ttl * 1000) if ttl is not None else None + ttl_millis = round(ttl * 1000) if ttl is not None else None if metadata: safe_metadata = _encode_attributes_for_langcache(metadata) result = await self._client.set_async(