Skip to content

Commit 708e7f5

Browse files
abrookinsCopilot
andauthored
Add support for item-specific TTLs; quote() and unquote() attributes (#442)
This PR adds support for LangCache supports item-specific TTLs. We also begin using quote() and unquote() to manage the variety of characters that can disrupt queries that use attributes. --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 32698e9 commit 708e7f5

File tree

3 files changed

+182
-82
lines changed

3 files changed

+182
-82
lines changed

redisvl/extensions/cache/llm/langcache.py

Lines changed: 28 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
from typing import Any, Dict, List, Literal, Optional
8+
from urllib.parse import quote, unquote
89

910
from redisvl.extensions.cache.llm.base import BaseLLMCache
1011
from redisvl.extensions.cache.llm.schema import CacheHit
@@ -15,37 +16,6 @@
1516
logger = get_logger(__name__)
1617

1718

18-
_LANGCACHE_ATTR_ENCODE_TRANS = str.maketrans(
19-
{
20-
",": ",", # U+FF0C FULLWIDTH COMMA
21-
"/": "∕", # U+2215 DIVISION SLASH
22-
"\\": "\", # U+FF3C FULLWIDTH REVERSE SOLIDUS (backslash)
23-
"?": "?", # U+FF1F FULLWIDTH QUESTION MARK
24-
}
25-
)
26-
27-
28-
_LANGCACHE_ATTR_DECODE_TRANS = str.maketrans(
29-
{v: k for k, v in _LANGCACHE_ATTR_ENCODE_TRANS.items()}
30-
)
31-
32-
33-
def _encode_attribute_value_for_langcache(value: str) -> str:
34-
"""Encode a string attribute value for use with the LangCache service.
35-
36-
LangCache applies validation and matching rules to attribute values. In
37-
particular, the managed service can reject values containing commas (",")
38-
and may not reliably match filters on values containing slashes ("/").
39-
40-
To keep attribute values round-trippable *and* usable for attribute
41-
filtering, we replace these characters with visually similar Unicode
42-
variants that the service accepts. A precomputed ``str.translate`` table is
43-
used so values are scanned only once.
44-
"""
45-
46-
return value.translate(_LANGCACHE_ATTR_ENCODE_TRANS)
47-
48-
4919
def _encode_attributes_for_langcache(attributes: Dict[str, Any]) -> Dict[str, Any]:
5020
"""Return a copy of *attributes* with string values safely encoded.
5121
@@ -61,25 +31,17 @@ def _encode_attributes_for_langcache(attributes: Dict[str, Any]) -> Dict[str, An
6131
safe_attributes: Dict[str, Any] = dict(attributes)
6232
for key, value in attributes.items():
6333
if isinstance(value, str):
64-
encoded = _encode_attribute_value_for_langcache(value)
34+
# Percent-encode all characters (no ``safe`` set) so punctuation and
35+
# other special characters cannot interfere with LangCache's
36+
# underlying query/tokenization rules.
37+
encoded = quote(value, safe="")
6538
if encoded != value:
6639
safe_attributes[key] = encoded
6740
changed = True
6841

6942
return safe_attributes if changed else attributes
7043

7144

72-
def _decode_attribute_value_from_langcache(value: str) -> str:
73-
"""Decode a string attribute value returned from the LangCache service.
74-
75-
This reverses :func:`_encode_attribute_value_for_langcache`, translating the
76-
fullwidth comma and division slash characters back to their ASCII
77-
counterparts so callers see the original values they stored.
78-
"""
79-
80-
return value.translate(_LANGCACHE_ATTR_DECODE_TRANS)
81-
82-
8345
def _decode_attributes_from_langcache(attributes: Dict[str, Any]) -> Dict[str, Any]:
8446
"""Return a copy of *attributes* with string values safely decoded.
8547
@@ -95,7 +57,7 @@ def _decode_attributes_from_langcache(attributes: Dict[str, Any]) -> Dict[str, A
9557
decoded_attributes: Dict[str, Any] = dict(attributes)
9658
for key, value in attributes.items():
9759
if isinstance(value, str):
98-
decoded = _decode_attribute_value_from_langcache(value)
60+
decoded = unquote(value)
9961
if decoded != value:
10062
decoded_attributes[key] = decoded
10163
changed = True
@@ -472,7 +434,7 @@ def store(
472434
vector (Optional[List[float]]): Not supported by LangCache API.
473435
metadata (Optional[Dict[str, Any]]): Optional metadata (stored as attributes).
474436
filters (Optional[Dict[str, Any]]): Not supported.
475-
ttl (Optional[int]): Optional TTL override (not supported by LangCache).
437+
ttl (Optional[int]): Optional TTL override in seconds.
476438
477439
Returns:
478440
str: The entry ID for the cached entry.
@@ -491,18 +453,22 @@ def store(
491453
if filters is not None:
492454
logger.warning("LangCache does not support filters")
493455

494-
if ttl is not None:
495-
logger.warning("LangCache does not support per-entry TTL")
496-
497-
# Store using the LangCache client; only send attributes if provided (non-empty)
498456
try:
457+
ttl_millis = round(ttl * 1000) if ttl is not None else None
499458
if metadata:
500459
safe_metadata = _encode_attributes_for_langcache(metadata)
501460
result = self._client.set(
502-
prompt=prompt, response=response, attributes=safe_metadata
461+
prompt=prompt,
462+
response=response,
463+
attributes=safe_metadata,
464+
ttl_millis=ttl_millis,
503465
)
504466
else:
505-
result = self._client.set(prompt=prompt, response=response)
467+
result = self._client.set(
468+
prompt=prompt,
469+
response=response,
470+
ttl_millis=ttl_millis,
471+
)
506472
except Exception as e: # narrow for known SDK error when possible
507473
try:
508474
from langcache.errors import BadRequestErrorResponseContent
@@ -541,7 +507,7 @@ async def astore(
541507
vector (Optional[List[float]]): Not supported by LangCache API.
542508
metadata (Optional[Dict[str, Any]]): Optional metadata (stored as attributes).
543509
filters (Optional[Dict[str, Any]]): Not supported.
544-
ttl (Optional[int]): Optional TTL override (not supported by LangCache).
510+
ttl (Optional[int]): Optional TTL override in seconds.
545511
546512
Returns:
547513
str: The entry ID for the cached entry.
@@ -560,18 +526,22 @@ async def astore(
560526
if filters is not None:
561527
logger.warning("LangCache does not support filters")
562528

563-
if ttl is not None:
564-
logger.warning("LangCache does not support per-entry TTL")
565-
566-
# Store using the LangCache client (async); only send attributes if provided (non-empty)
567529
try:
530+
ttl_millis = round(ttl * 1000) if ttl is not None else None
568531
if metadata:
569532
safe_metadata = _encode_attributes_for_langcache(metadata)
570533
result = await self._client.set_async(
571-
prompt=prompt, response=response, attributes=safe_metadata
534+
prompt=prompt,
535+
response=response,
536+
attributes=safe_metadata,
537+
ttl_millis=ttl_millis,
572538
)
573539
else:
574-
result = await self._client.set_async(prompt=prompt, response=response)
540+
result = await self._client.set_async(
541+
prompt=prompt,
542+
response=response,
543+
ttl_millis=ttl_millis,
544+
)
575545
except Exception as e:
576546
try:
577547
from langcache.errors import BadRequestErrorResponseContent

tests/integration/test_langcache_semantic_cache_integration.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,33 @@ def test_store_and_check_sync(
9191
assert hits[0]["response"] == response
9292
assert hits[0]["prompt"] == prompt
9393

94+
def test_store_with_per_entry_ttl_expires(
95+
self, langcache_with_attrs: LangCacheSemanticCache
96+
) -> None:
97+
"""Per-entry TTL should cause individual entries to expire."""
98+
99+
prompt = "Per-entry TTL test"
100+
response = "This entry should expire quickly."
101+
102+
entry_id = langcache_with_attrs.store(
103+
prompt=prompt,
104+
response=response,
105+
ttl=2,
106+
)
107+
assert entry_id
108+
109+
# Immediately after storing, the entry should be retrievable.
110+
hits = langcache_with_attrs.check(prompt=prompt, num_results=5)
111+
assert any(hit["response"] == response for hit in hits)
112+
113+
# Wait for TTL to elapse and confirm the entry is no longer returned.
114+
import time
115+
116+
time.sleep(3)
117+
118+
hits_after_ttl = langcache_with_attrs.check(prompt=prompt, num_results=5)
119+
assert not any(hit["response"] == response for hit in hits_after_ttl)
120+
94121
@pytest.mark.asyncio
95122
async def test_store_and_check_async(
96123
self, langcache_with_attrs: LangCacheSemanticCache
@@ -106,6 +133,35 @@ async def test_store_and_check_async(
106133
assert hits[0]["response"] == response
107134
assert hits[0]["prompt"] == prompt
108135

136+
@pytest.mark.asyncio
137+
async def test_astore_with_per_entry_ttl_expires(
138+
self, langcache_with_attrs: LangCacheSemanticCache
139+
) -> None:
140+
"""Async per-entry TTL should cause individual entries to expire."""
141+
142+
prompt = "Async per-entry TTL test"
143+
response = "This async entry should expire quickly."
144+
145+
entry_id = await langcache_with_attrs.astore(
146+
prompt=prompt,
147+
response=response,
148+
ttl=2,
149+
)
150+
assert entry_id
151+
152+
hits = await langcache_with_attrs.acheck(prompt=prompt, num_results=5)
153+
assert any(hit["response"] == response for hit in hits)
154+
155+
import asyncio
156+
157+
await asyncio.sleep(3)
158+
159+
hits_after_ttl = await langcache_with_attrs.acheck(
160+
prompt=prompt,
161+
num_results=5,
162+
)
163+
assert not any(hit["response"] == response for hit in hits_after_ttl)
164+
109165
def test_store_with_metadata_and_check_with_attributes(
110166
self, langcache_with_attrs: LangCacheSemanticCache
111167
) -> None:
@@ -321,7 +377,7 @@ def test_attribute_values_with_special_chars_round_trip_and_filter(
321377
"""Backslash and question-mark values should round-trip via filters.
322378
323379
These values previously failed attribute filtering on this LangCache
324-
instance; with client-side encoding/decoding they should now be
380+
instance; with URL-style percent encoding they should now be
325381
filterable and round-trip correctly.
326382
"""
327383

0 commit comments

Comments
 (0)