Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ jobs:
OPENAI_API_VERSION: ${{ secrets.OPENAI_API_VERSION }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
LANGCACHE_WITH_ATTRIBUTES_API_KEY: ${{ secrets.LANGCACHE_WITH_ATTRIBUTES_API_KEY }}
LANGCACHE_WITH_ATTRIBUTES_CACHE_ID: ${{ secrets.LANGCACHE_WITH_ATTRIBUTES_CACHE_ID }}
LANGCACHE_WITH_ATTRIBUTES_URL: ${{ secrets.LANGCACHE_WITH_ATTRIBUTES_URL }}
LANGCACHE_NO_ATTRIBUTES_API_KEY: ${{ secrets.LANGCACHE_NO_ATTRIBUTES_API_KEY }}
LANGCACHE_NO_ATTRIBUTES_CACHE_ID: ${{ secrets.LANGCACHE_NO_ATTRIBUTES_CACHE_ID }}
LANGCACHE_NO_ATTRIBUTES_URL: ${{ secrets.LANGCACHE_NO_ATTRIBUTES_URL }}
run: |
make test-all

Expand Down
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,10 @@ index = SearchIndex(schema, redis_url="redis://localhost:6379")
RedisVL uses `pytest` with `testcontainers` for testing.

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

## Project Structure

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ nltk = ["nltk>=3.8.1,<4"]
cohere = ["cohere>=4.44"]
voyageai = ["voyageai>=0.2.2"]
sentence-transformers = ["sentence-transformers>=3.4.0,<4"]
langcache = ["langcache>=0.9.0"]
langcache = ["langcache>=0.11.0"]
vertexai = [
"google-cloud-aiplatform>=1.26,<2.0.0",
"protobuf>=5.28.0,<6.0.0",
Expand Down
63 changes: 58 additions & 5 deletions redisvl/extensions/cache/llm/langcache.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,53 @@
logger = get_logger(__name__)


_LANGCACHE_ATTR_ENCODE_TRANS = str.maketrans(
{
",": ",", # U+FF0C FULLWIDTH COMMA
"/": "∕", # U+2215 DIVISION SLASH
}
)


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.

Only top-level string values are encoded; non-string values are left
unchanged. If no values require encoding, the original dict is returned
unchanged.
"""

if not attributes:
return attributes

changed = False
safe_attributes: Dict[str, Any] = dict(attributes)
for key, value in attributes.items():
if isinstance(value, str):
encoded = _encode_attribute_value_for_langcache(value)
if encoded != value:
safe_attributes[key] = encoded
changed = True

return safe_attributes if changed else attributes


class LangCacheSemanticCache(BaseLLMCache):
"""LLM Cache implementation using the LangCache managed service.

Expand Down Expand Up @@ -163,7 +210,9 @@ def _build_search_kwargs(
"similarity_threshold": similarity_threshold,
}
if attributes:
kwargs["attributes"] = attributes
# Encode all string attribute values so they are accepted by the
# LangCache service and remain filterable.
kwargs["attributes"] = _encode_attributes_for_langcache(attributes)
return kwargs

def _hits_from_response(
Expand Down Expand Up @@ -403,8 +452,9 @@ def store(
# Store using the LangCache client; only send attributes if provided (non-empty)
try:
if metadata:
safe_metadata = _encode_attributes_for_langcache(metadata)
result = self._client.set(
prompt=prompt, response=response, attributes=metadata
prompt=prompt, response=response, attributes=safe_metadata
)
else:
result = self._client.set(prompt=prompt, response=response)
Expand Down Expand Up @@ -471,8 +521,9 @@ async def astore(
# Store using the LangCache client (async); only send attributes if provided (non-empty)
try:
if metadata:
safe_metadata = _encode_attributes_for_langcache(metadata)
result = await self._client.set_async(
prompt=prompt, response=response, attributes=metadata
prompt=prompt, response=response, attributes=safe_metadata
)
else:
result = await self._client.set_async(prompt=prompt, response=response)
Expand Down Expand Up @@ -594,7 +645,8 @@ def delete_by_attributes(self, attributes: Dict[str, Any]) -> Dict[str, Any]:
raise ValueError(
"Cannot delete by attributes with an empty attributes dictionary."
)
result = self._client.delete_query(attributes=attributes)
safe_attributes = _encode_attributes_for_langcache(attributes)
result = self._client.delete_query(attributes=safe_attributes)
# Convert DeleteQueryResponse to dict
return result.model_dump() if hasattr(result, "model_dump") else {}

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