Skip to content

Commit cde0f4a

Browse files
committed
Encode LangCache attributes for safe filtering
1 parent 9bdcb46 commit cde0f4a

File tree

2 files changed

+82
-19
lines changed

2 files changed

+82
-19
lines changed

redisvl/extensions/cache/llm/langcache.py

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,53 @@
1515
logger = get_logger(__name__)
1616

1717

18+
_LANGCACHE_ATTR_ENCODE_TRANS = str.maketrans(
19+
{
20+
",": ",", # U+FF0C FULLWIDTH COMMA
21+
"/": "∕", # U+2215 DIVISION SLASH
22+
}
23+
)
24+
25+
26+
def _encode_attribute_value_for_langcache(value: str) -> str:
27+
"""Encode a string attribute value for use with the LangCache service.
28+
29+
LangCache applies validation and matching rules to attribute values. In
30+
particular, the managed service can reject values containing commas (",")
31+
and may not reliably match filters on values containing slashes ("/").
32+
33+
To keep attribute values round-trippable *and* usable for attribute
34+
filtering, we replace these characters with visually similar Unicode
35+
variants that the service accepts. A precomputed ``str.translate`` table is
36+
used so values are scanned only once.
37+
"""
38+
39+
return value.translate(_LANGCACHE_ATTR_ENCODE_TRANS)
40+
41+
42+
def _encode_attributes_for_langcache(attributes: Dict[str, Any]) -> Dict[str, Any]:
43+
"""Return a copy of *attributes* with string values safely encoded.
44+
45+
Only top-level string values are encoded; non-string values are left
46+
unchanged. If no values require encoding, the original dict is returned
47+
unchanged.
48+
"""
49+
50+
if not attributes:
51+
return attributes
52+
53+
changed = False
54+
safe_attributes: Dict[str, Any] = dict(attributes)
55+
for key, value in attributes.items():
56+
if isinstance(value, str):
57+
encoded = _encode_attribute_value_for_langcache(value)
58+
if encoded != value:
59+
safe_attributes[key] = encoded
60+
changed = True
61+
62+
return safe_attributes if changed else attributes
63+
64+
1865
class LangCacheSemanticCache(BaseLLMCache):
1966
"""LLM Cache implementation using the LangCache managed service.
2067
@@ -163,7 +210,9 @@ def _build_search_kwargs(
163210
"similarity_threshold": similarity_threshold,
164211
}
165212
if attributes:
166-
kwargs["attributes"] = attributes
213+
# Encode all string attribute values so they are accepted by the
214+
# LangCache service and remain filterable.
215+
kwargs["attributes"] = _encode_attributes_for_langcache(attributes)
167216
return kwargs
168217

169218
def _hits_from_response(
@@ -403,8 +452,9 @@ def store(
403452
# Store using the LangCache client; only send attributes if provided (non-empty)
404453
try:
405454
if metadata:
455+
safe_metadata = _encode_attributes_for_langcache(metadata)
406456
result = self._client.set(
407-
prompt=prompt, response=response, attributes=metadata
457+
prompt=prompt, response=response, attributes=safe_metadata
408458
)
409459
else:
410460
result = self._client.set(prompt=prompt, response=response)
@@ -471,8 +521,9 @@ async def astore(
471521
# Store using the LangCache client (async); only send attributes if provided (non-empty)
472522
try:
473523
if metadata:
524+
safe_metadata = _encode_attributes_for_langcache(metadata)
474525
result = await self._client.set_async(
475-
prompt=prompt, response=response, attributes=metadata
526+
prompt=prompt, response=response, attributes=safe_metadata
476527
)
477528
else:
478529
result = await self._client.set_async(prompt=prompt, response=response)
@@ -594,7 +645,8 @@ def delete_by_attributes(self, attributes: Dict[str, Any]) -> Dict[str, Any]:
594645
raise ValueError(
595646
"Cannot delete by attributes with an empty attributes dictionary."
596647
)
597-
result = self._client.delete_query(attributes=attributes)
648+
safe_attributes = _encode_attributes_for_langcache(attributes)
649+
result = self._client.delete_query(attributes=safe_attributes)
598650
# Convert DeleteQueryResponse to dict
599651
return result.model_dump() if hasattr(result, "model_dump") else {}
600652

@@ -615,6 +667,7 @@ async def adelete_by_attributes(self, attributes: Dict[str, Any]) -> Dict[str, A
615667
raise ValueError(
616668
"Cannot delete by attributes with an empty attributes dictionary."
617669
)
618-
result = await self._client.delete_query_async(attributes=attributes)
670+
safe_attributes = _encode_attributes_for_langcache(attributes)
671+
result = await self._client.delete_query_async(attributes=safe_attributes)
619672
# Convert DeleteQueryResponse to dict
620673
return result.model_dump() if hasattr(result, "model_dump") else {}

tests/integration/test_langcache_semantic_cache_integration.py

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -238,24 +238,34 @@ async def test_async_delete_variants(
238238
)
239239
assert not any(hit["response"] == response for hit in hits_after_clear)
240240

241-
def test_attribute_value_with_comma_passes_through_to_api(
241+
def test_attribute_value_with_comma_and_slash_is_encoded_for_llm_string(
242242
self, langcache_with_attrs: LangCacheSemanticCache
243243
) -> None:
244-
"""We currently rely on the LangCache API to validate commas in attribute values.
244+
"""llm_string attribute values with commas/slashes are client-encoded."""
245245

246-
This test verifies we do not perform client-side validation and that the
247-
error is raised by the backend. If this behavior changes, this test will
248-
need to be updated.
249-
"""
250-
prompt = "Comma attribute value"
251-
response = "This may fail depending on the remote validation rules."
246+
prompt = "Attribute encoding for llm_string"
247+
response = "Response for encoded llm_string."
252248

253-
with pytest.raises(BadRequestErrorResponseContent):
254-
langcache_with_attrs.store(
255-
prompt=prompt,
256-
response=response,
257-
metadata={"llm_string": "tenant,with,comma"},
258-
)
249+
raw_llm_string = "tenant,with/slash"
250+
entry_id = langcache_with_attrs.store(
251+
prompt=prompt,
252+
response=response,
253+
metadata={
254+
"llm_string": raw_llm_string,
255+
"other": "keep_me",
256+
},
257+
)
258+
assert entry_id
259+
260+
# When we search using the *raw* llm_string value, the client should
261+
# transparently encode it before sending it to LangCache.
262+
hits = langcache_with_attrs.check(
263+
prompt=prompt,
264+
attributes={"llm_string": raw_llm_string},
265+
num_results=3,
266+
)
267+
assert hits
268+
assert any(hit["response"] == response for hit in hits)
259269

260270

261271
@pytest.mark.requires_api_keys

0 commit comments

Comments
 (0)