diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b6ba0250..ded2f84f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 385a4164..5133160b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 30576ed0..038895e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/redisvl/extensions/cache/llm/langcache.py b/redisvl/extensions/cache/llm/langcache.py index bfefad63..0560d846 100644 --- a/redisvl/extensions/cache/llm/langcache.py +++ b/redisvl/extensions/cache/llm/langcache.py @@ -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. @@ -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( @@ -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) @@ -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) @@ -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 {} @@ -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 {} diff --git a/tests/integration/test_langcache_semantic_cache_integration.py b/tests/integration/test_langcache_semantic_cache_integration.py new file mode 100644 index 00000000..b224fcf2 --- /dev/null +++ b/tests/integration/test_langcache_semantic_cache_integration.py @@ -0,0 +1,308 @@ +"""Integration tests for LangCacheSemanticCache against the LangCache managed service. + +These tests exercise the real LangCache API using two configured caches: +- One with attributes configured +- One without attributes configured + +Env vars (loaded from .env locally, injected via CI): +- LANGCACHE_WITH_ATTRIBUTES_API_KEY +- LANGCACHE_WITH_ATTRIBUTES_CACHE_ID +- LANGCACHE_WITH_ATTRIBUTES_URL +- LANGCACHE_NO_ATTRIBUTES_API_KEY +- LANGCACHE_NO_ATTRIBUTES_CACHE_ID +- LANGCACHE_NO_ATTRIBUTES_URL +""" + +import os +from typing import Dict + +import pytest +from dotenv import load_dotenv + +from redisvl.extensions.cache.llm.langcache import LangCacheSemanticCache + +load_dotenv() + +REQUIRED_WITH_ATTRS_VARS = ( + "LANGCACHE_WITH_ATTRIBUTES_API_KEY", + "LANGCACHE_WITH_ATTRIBUTES_CACHE_ID", + "LANGCACHE_WITH_ATTRIBUTES_URL", +) + +REQUIRED_NO_ATTRS_VARS = ( + "LANGCACHE_NO_ATTRIBUTES_API_KEY", + "LANGCACHE_NO_ATTRIBUTES_CACHE_ID", + "LANGCACHE_NO_ATTRIBUTES_URL", +) + + +def _require_env_vars(var_names: tuple[str, ...]) -> Dict[str, str]: + missing = [name for name in var_names if not os.getenv(name)] + if missing: + pytest.skip( + f"Missing required LangCache env vars: {', '.join(missing)}. " + "Set them locally (e.g., via .env) or in CI secrets to run these tests." + ) + + return {name: os.environ[name] for name in var_names} + + +@pytest.fixture +def langcache_with_attrs() -> LangCacheSemanticCache: + """LangCacheSemanticCache instance bound to a cache with attributes configured.""" + + env = _require_env_vars(REQUIRED_WITH_ATTRS_VARS) + + return LangCacheSemanticCache( + name="langcache_with_attributes", + server_url=env["LANGCACHE_WITH_ATTRIBUTES_URL"], + cache_id=env["LANGCACHE_WITH_ATTRIBUTES_CACHE_ID"], + api_key=env["LANGCACHE_WITH_ATTRIBUTES_API_KEY"], + ) + + +@pytest.fixture +def langcache_no_attrs() -> LangCacheSemanticCache: + """LangCacheSemanticCache instance bound to a cache with NO attributes configured.""" + + env = _require_env_vars(REQUIRED_NO_ATTRS_VARS) + + return LangCacheSemanticCache( + name="langcache_no_attributes", + server_url=env["LANGCACHE_NO_ATTRIBUTES_URL"], + cache_id=env["LANGCACHE_NO_ATTRIBUTES_CACHE_ID"], + api_key=env["LANGCACHE_NO_ATTRIBUTES_API_KEY"], + ) + + +@pytest.mark.requires_api_keys +class TestLangCacheSemanticCacheIntegrationWithAttributes: + def test_store_and_check_sync( + self, langcache_with_attrs: LangCacheSemanticCache + ) -> None: + prompt = "What is Redis?" + response = "Redis is an in-memory data store." + + entry_id = langcache_with_attrs.store(prompt=prompt, response=response) + assert entry_id + + hits = langcache_with_attrs.check(prompt=prompt, num_results=1) + assert hits + assert hits[0]["response"] == response + assert hits[0]["prompt"] == prompt + + @pytest.mark.asyncio + async def test_store_and_check_async( + self, langcache_with_attrs: LangCacheSemanticCache + ) -> None: + prompt = "What is Redis async?" + response = "Redis is an in-memory data store (async)." + + entry_id = await langcache_with_attrs.astore(prompt=prompt, response=response) + assert entry_id + + hits = await langcache_with_attrs.acheck(prompt=prompt, num_results=1) + assert hits + assert hits[0]["response"] == response + assert hits[0]["prompt"] == prompt + + def test_store_with_metadata_and_check_with_attributes( + self, langcache_with_attrs: LangCacheSemanticCache + ) -> None: + prompt = "Explain Redis search." + response = "Redis provides full-text search via RediSearch." + # Use attribute names that are actually configured on this cache. + metadata = {"user_id": "tenant_a"} + + entry_id = langcache_with_attrs.store( + prompt=prompt, + response=response, + metadata=metadata, + ) + assert entry_id + + hits = langcache_with_attrs.check( + prompt=prompt, + attributes={"user_id": "tenant_a"}, + num_results=3, + ) + assert hits + assert any(hit["response"] == response for hit in hits) + + def test_delete_and_clear_alias( + self, langcache_with_attrs: LangCacheSemanticCache + ) -> None: + """delete() and clear() should flush the whole cache.""" + + prompt = "Delete me" + response = "You won't see me again." + + langcache_with_attrs.store(prompt=prompt, response=response) + hits_before = langcache_with_attrs.check(prompt=prompt, num_results=5) + assert hits_before + + # delete() and clear() both flush the whole cache + langcache_with_attrs.delete() + hits_after_delete = langcache_with_attrs.check(prompt=prompt, num_results=5) + + # It is possible for other tests or data to exist; we only assert that + # the original response is no longer present if any hits are returned. + assert not any(hit["response"] == response for hit in hits_after_delete) + + langcache_with_attrs.store(prompt=prompt, response=response) + langcache_with_attrs.clear() + hits_after_clear = langcache_with_attrs.check(prompt=prompt, num_results=5) + assert not any(hit["response"] == response for hit in hits_after_clear) + + def test_delete_by_id_and_by_attributes( + self, langcache_with_attrs: LangCacheSemanticCache + ) -> None: + prompt = "Delete by id" + response = "Entry to delete by id." + metadata = {"user_id": "tenant_delete"} + + entry_id = langcache_with_attrs.store( + prompt=prompt, + response=response, + metadata=metadata, + ) + assert entry_id + + hits = langcache_with_attrs.check( + prompt=prompt, attributes=metadata, num_results=1 + ) + assert hits + assert hits[0]["entry_id"] == entry_id + + # delete by id + langcache_with_attrs.delete_by_id(entry_id) + hits_after_id_delete = langcache_with_attrs.check( + prompt=prompt, attributes=metadata, num_results=3 + ) + assert not any(hit["entry_id"] == entry_id for hit in hits_after_id_delete) + + # store multiple entries and delete by attributes + for i in range(3): + langcache_with_attrs.store( + prompt=f"{prompt} {i}", + response=f"{response} {i}", + metadata=metadata, + ) + + delete_result = langcache_with_attrs.delete_by_attributes(attributes=metadata) + assert isinstance(delete_result, dict) + assert delete_result.get("deleted_entries_count", 0) >= 1 + + @pytest.mark.asyncio + async def test_async_delete_variants( + self, langcache_with_attrs: LangCacheSemanticCache + ) -> None: + prompt = "Async delete by attributes" + response = "Async delete candidate" + metadata = {"user_id": "tenant_async"} + + entry_id = await langcache_with_attrs.astore( + prompt=prompt, + response=response, + metadata=metadata, + ) + assert entry_id + + hits = await langcache_with_attrs.acheck(prompt=prompt, attributes=metadata) + assert hits + + await langcache_with_attrs.adelete_by_id(entry_id) + hits_after_id_delete = await langcache_with_attrs.acheck( + prompt=prompt, attributes=metadata + ) + assert not any(hit["entry_id"] == entry_id for hit in hits_after_id_delete) + + for i in range(2): + await langcache_with_attrs.astore( + prompt=f"{prompt} {i}", + response=f"{response} {i}", + metadata=metadata, + ) + + delete_result = await langcache_with_attrs.adelete_by_attributes( + attributes=metadata + ) + assert isinstance(delete_result, dict) + assert delete_result.get("deleted_entries_count", 0) >= 1 + + # Finally, aclear() should flush the cache. + await langcache_with_attrs.aclear() + hits_after_clear = await langcache_with_attrs.acheck( + prompt=prompt, num_results=5 + ) + assert not any(hit["response"] == response for hit in hits_after_clear) + + def test_attribute_value_with_comma_and_slash_is_encoded_for_llm_string( + self, langcache_with_attrs: LangCacheSemanticCache + ) -> None: + """llm_string attribute values with commas/slashes are client-encoded.""" + + prompt = "Attribute encoding for llm_string" + response = "Response for encoded llm_string." + + raw_llm_string = "tenant,with/slash" + entry_id = langcache_with_attrs.store( + prompt=prompt, + response=response, + metadata={"llm_string": raw_llm_string}, + ) + assert entry_id + + # When we search using the *raw* llm_string value, the client should + # transparently encode it before sending it to LangCache. + hits = langcache_with_attrs.check( + prompt=prompt, + attributes={"llm_string": raw_llm_string}, + num_results=3, + ) + assert hits + assert any(hit["response"] == response for hit in hits) + + +@pytest.mark.requires_api_keys +class TestLangCacheSemanticCacheIntegrationWithoutAttributes: + def test_error_on_store_with_metadata_when_no_attributes_configured( + self, langcache_no_attrs: LangCacheSemanticCache + ) -> None: + prompt = "Attributes not configured" + response = "This should fail due to missing attributes configuration." + + with pytest.raises(RuntimeError) as exc: + langcache_no_attrs.store( + prompt=prompt, + response=response, + metadata={"tenant": "tenant_without_attrs"}, + ) + + assert "attributes are not configured for this cache" in str(exc.value).lower() + + def test_error_on_check_with_attributes_when_no_attributes_configured( + self, langcache_no_attrs: LangCacheSemanticCache + ) -> None: + prompt = "Attributes not configured on check" + + with pytest.raises(RuntimeError) as exc: + langcache_no_attrs.check( + prompt=prompt, + attributes={"tenant": "tenant_without_attrs"}, + ) + + assert "attributes are not configured for this cache" in str(exc.value).lower() + + def test_basic_store_and_check_works_without_attributes( + self, langcache_no_attrs: LangCacheSemanticCache + ) -> None: + prompt = "Plain cache without attributes" + response = "This should be cached successfully." + + entry_id = langcache_no_attrs.store(prompt=prompt, response=response) + assert entry_id + + hits = langcache_no_attrs.check(prompt=prompt) + assert hits + assert any(hit["response"] == response for hit in hits) diff --git a/uv.lock b/uv.lock index 03e50db9..3aae2fcb 100644 --- a/uv.lock +++ b/uv.lock @@ -2114,16 +2114,16 @@ wheels = [ [[package]] name = "langcache" -version = "0.10.1" +version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpcore" }, { name = "httpx" }, { name = "pydantic" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2b/01/c4d41f05d86c7838032114037d8c1c449bd0a0e32a1b62fc64b9c63ecdeb/langcache-0.10.1.tar.gz", hash = "sha256:a60f5974712a9be7c249bea97bb412ef2b8d8fe8509623c8fbbaf183ee7a441f", size = 38945 } +sdist = { url = "https://files.pythonhosted.org/packages/ed/1d/f0b306e5c710956bd32521da87bb4f01836f8ea6ebd447160a41055dd6ee/langcache-0.11.0.tar.gz", hash = "sha256:91fa5a3f2758a6a1ba00c2733f95c2dd625b511f3ea728e89bf32646472abd77", size = 39676 } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/2a/1d221efcff30e418f1e14fc7959023f0f3156ab9b87ff5a42e44170ce96d/langcache-0.10.1-py3-none-any.whl", hash = "sha256:a8d54bb5478d4a079f0705aa3a178af1250d9edfc75fbd5abd17fadb3f3baa74", size = 63191 }, + { url = "https://files.pythonhosted.org/packages/0d/8f/3e2299dafa43f831b5fdaaa6ce329b4050a118faa3e4134fe331be5b0101/langcache-0.11.0-py3-none-any.whl", hash = "sha256:9373e9f0a00de06ebc0fb5942b4cd82281979ba8f3126b580030e23fc18dd947", size = 64336 }, ] [[package]] @@ -4278,7 +4278,7 @@ requires-dist = [ { name = "cohere", marker = "extra == 'cohere'", specifier = ">=4.44" }, { name = "google-cloud-aiplatform", marker = "extra == 'vertexai'", specifier = ">=1.26,<2.0.0" }, { name = "jsonpath-ng", specifier = ">=1.5.0" }, - { name = "langcache", marker = "extra == 'langcache'", specifier = ">=0.9.0" }, + { name = "langcache", marker = "extra == 'langcache'", specifier = ">=0.11.0" }, { name = "mistralai", marker = "extra == 'mistralai'", specifier = ">=1.0.0" }, { name = "ml-dtypes", specifier = ">=0.4.0,<1.0.0" }, { name = "nltk", marker = "extra == 'nltk'", specifier = ">=3.8.1,<4" },