Skip to content

Commit 2700d86

Browse files
abrookinsCopilot
andauthored
Add LangCache integration tests (#429)
This PR adds integration tests for our new integration with LangCache, the managed cache service. --------- Co-authored-by: Copilot <[email protected]>
1 parent 62dc045 commit 2700d86

File tree

6 files changed

+381
-11
lines changed

6 files changed

+381
-11
lines changed

.github/workflows/test.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ jobs:
7676
OPENAI_API_VERSION: ${{ secrets.OPENAI_API_VERSION }}
7777
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
7878
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
79+
LANGCACHE_WITH_ATTRIBUTES_API_KEY: ${{ secrets.LANGCACHE_WITH_ATTRIBUTES_API_KEY }}
80+
LANGCACHE_WITH_ATTRIBUTES_CACHE_ID: ${{ secrets.LANGCACHE_WITH_ATTRIBUTES_CACHE_ID }}
81+
LANGCACHE_WITH_ATTRIBUTES_URL: ${{ secrets.LANGCACHE_WITH_ATTRIBUTES_URL }}
82+
LANGCACHE_NO_ATTRIBUTES_API_KEY: ${{ secrets.LANGCACHE_NO_ATTRIBUTES_API_KEY }}
83+
LANGCACHE_NO_ATTRIBUTES_CACHE_ID: ${{ secrets.LANGCACHE_NO_ATTRIBUTES_CACHE_ID }}
84+
LANGCACHE_NO_ATTRIBUTES_URL: ${{ secrets.LANGCACHE_NO_ATTRIBUTES_URL }}
7985
run: |
8086
make test-all
8187

CLAUDE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,10 @@ index = SearchIndex(schema, redis_url="redis://localhost:6379")
6767
RedisVL uses `pytest` with `testcontainers` for testing.
6868

6969
- `make test` - unit tests only (no external APIs)
70-
- `make test-all` - includes integration tests requiring API keys
70+
- `make test-all` - run the full suite, including tests that call external APIs
71+
- `pytest --run-api-tests` - explicitly run API-dependent tests (e.g., LangCache,
72+
external vectorizer/reranker providers). These require the appropriate API
73+
keys and environment variables to be set.
7174

7275
## Project Structure
7376

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ nltk = ["nltk>=3.8.1,<4"]
3939
cohere = ["cohere>=4.44"]
4040
voyageai = ["voyageai>=0.2.2"]
4141
sentence-transformers = ["sentence-transformers>=3.4.0,<4"]
42-
langcache = ["langcache>=0.9.0"]
42+
langcache = ["langcache>=0.11.0"]
4343
vertexai = [
4444
"google-cloud-aiplatform>=1.26,<2.0.0",
4545
"protobuf>=5.28.0,<6.0.0",

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 {}

0 commit comments

Comments
 (0)