From 72a6cbc57eefbd6fe4b74034a828f55b9b105a04 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Tue, 18 Nov 2025 15:34:52 -0800 Subject: [PATCH 1/8] Add LangCache integration tests --- .github/workflows/test.yml | 6 + pyproject.toml | 2 +- ...st_langcache_semantic_cache_integration.py | 303 ++++++++++++++++++ uv.lock | 8 +- 4 files changed, 314 insertions(+), 5 deletions(-) create mode 100644 tests/integration/test_langcache_semantic_cache_integration.py 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/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/tests/integration/test_langcache_semantic_cache_integration.py b/tests/integration/test_langcache_semantic_cache_integration.py new file mode 100644 index 00000000..c69a7571 --- /dev/null +++ b/tests/integration/test_langcache_semantic_cache_integration.py @@ -0,0 +1,303 @@ +"""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 redisvl.extensions.cache.llm.langcache import LangCacheSemanticCache + +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 hits_after_clear + + +@pytest.mark.requires_api_keys +class TestLangCacheSemanticCacheIntegrationWithoutAttributes: + def test_error_on_store_with_metadata_when_no_attributes_configured( + self, langcache_no_attrs: LangCacheSemanticCache + ) -> None: + from langcache.errors import BadRequestErrorResponseContent + + 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) + + def test_attribute_value_with_comma_passes_through_to_api( + self, langcache_with_attrs: LangCacheSemanticCache + ) -> None: + """We currently rely on the LangCache API to validate commas in attribute values. + + This test verifies we do not perform client-side validation and that the + error is raised by the backend. If this behavior changes, this test will + need to be updated. + """ + + from langcache.errors import BadRequestErrorResponseContent + + prompt = "Comma attribute value" + response = "This may fail depending on the remote validation rules." + + with pytest.raises(BadRequestErrorResponseContent): + langcache_with_attrs.store( + prompt=prompt, + response=response, + metadata={"llm_string": "tenant,with,comma"}, + ) 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" }, From 5c9abcd084f0febd6cc21b219a5fae13bc52c2a4 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Wed, 19 Nov 2025 08:44:20 -0800 Subject: [PATCH 2/8] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../integration/test_langcache_semantic_cache_integration.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/test_langcache_semantic_cache_integration.py b/tests/integration/test_langcache_semantic_cache_integration.py index c69a7571..de02fee1 100644 --- a/tests/integration/test_langcache_semantic_cache_integration.py +++ b/tests/integration/test_langcache_semantic_cache_integration.py @@ -232,7 +232,7 @@ async def test_async_delete_variants( hits_after_clear = await langcache_with_attrs.acheck( prompt=prompt, num_results=5 ) - assert not hits_after_clear + assert not any(hit["response"] == response for hit in hits_after_clear) @pytest.mark.requires_api_keys @@ -240,7 +240,7 @@ class TestLangCacheSemanticCacheIntegrationWithoutAttributes: def test_error_on_store_with_metadata_when_no_attributes_configured( self, langcache_no_attrs: LangCacheSemanticCache ) -> None: - from langcache.errors import BadRequestErrorResponseContent + prompt = "Attributes not configured" response = "This should fail due to missing attributes configuration." From 47a447335adf5ddf3e316dda8dcb9b7f7801db01 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Wed, 19 Nov 2025 08:45:32 -0800 Subject: [PATCH 3/8] Move import to top of module --- .../integration/test_langcache_semantic_cache_integration.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/integration/test_langcache_semantic_cache_integration.py b/tests/integration/test_langcache_semantic_cache_integration.py index de02fee1..6ae42a9d 100644 --- a/tests/integration/test_langcache_semantic_cache_integration.py +++ b/tests/integration/test_langcache_semantic_cache_integration.py @@ -17,6 +17,7 @@ from typing import Dict import pytest +from langcache.errors import BadRequestErrorResponseContent from redisvl.extensions.cache.llm.langcache import LangCacheSemanticCache @@ -241,7 +242,6 @@ 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." @@ -289,9 +289,6 @@ def test_attribute_value_with_comma_passes_through_to_api( error is raised by the backend. If this behavior changes, this test will need to be updated. """ - - from langcache.errors import BadRequestErrorResponseContent - prompt = "Comma attribute value" response = "This may fail depending on the remote validation rules." From 9bdcb467258ea20693f854c7b79f447bd48ee289 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Wed, 19 Nov 2025 15:48:16 -0800 Subject: [PATCH 4/8] Resolving review feedback --- ...st_langcache_semantic_cache_integration.py | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/tests/integration/test_langcache_semantic_cache_integration.py b/tests/integration/test_langcache_semantic_cache_integration.py index 6ae42a9d..70e23a9f 100644 --- a/tests/integration/test_langcache_semantic_cache_integration.py +++ b/tests/integration/test_langcache_semantic_cache_integration.py @@ -17,10 +17,13 @@ from typing import Dict import pytest +from dotenv import load_dotenv from langcache.errors import BadRequestErrorResponseContent from redisvl.extensions.cache.llm.langcache import LangCacheSemanticCache +load_dotenv() + REQUIRED_WITH_ATTRS_VARS = ( "LANGCACHE_WITH_ATTRIBUTES_API_KEY", "LANGCACHE_WITH_ATTRIBUTES_CACHE_ID", @@ -235,13 +238,31 @@ async def test_async_delete_variants( ) assert not any(hit["response"] == response for hit in hits_after_clear) + def test_attribute_value_with_comma_passes_through_to_api( + self, langcache_with_attrs: LangCacheSemanticCache + ) -> None: + """We currently rely on the LangCache API to validate commas in attribute values. + + This test verifies we do not perform client-side validation and that the + error is raised by the backend. If this behavior changes, this test will + need to be updated. + """ + prompt = "Comma attribute value" + response = "This may fail depending on the remote validation rules." + + with pytest.raises(BadRequestErrorResponseContent): + langcache_with_attrs.store( + prompt=prompt, + response=response, + metadata={"llm_string": "tenant,with,comma"}, + ) + @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." @@ -279,22 +300,3 @@ def test_basic_store_and_check_works_without_attributes( hits = langcache_no_attrs.check(prompt=prompt) assert hits assert any(hit["response"] == response for hit in hits) - - def test_attribute_value_with_comma_passes_through_to_api( - self, langcache_with_attrs: LangCacheSemanticCache - ) -> None: - """We currently rely on the LangCache API to validate commas in attribute values. - - This test verifies we do not perform client-side validation and that the - error is raised by the backend. If this behavior changes, this test will - need to be updated. - """ - prompt = "Comma attribute value" - response = "This may fail depending on the remote validation rules." - - with pytest.raises(BadRequestErrorResponseContent): - langcache_with_attrs.store( - prompt=prompt, - response=response, - metadata={"llm_string": "tenant,with,comma"}, - ) From cde0f4ab7b770bbdecfb86066ae982d94853aab8 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Thu, 20 Nov 2025 16:19:30 -0800 Subject: [PATCH 5/8] Encode LangCache attributes for safe filtering --- redisvl/extensions/cache/llm/langcache.py | 63 +++++++++++++++++-- ...st_langcache_semantic_cache_integration.py | 38 ++++++----- 2 files changed, 82 insertions(+), 19 deletions(-) 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 index 70e23a9f..f3086d05 100644 --- a/tests/integration/test_langcache_semantic_cache_integration.py +++ b/tests/integration/test_langcache_semantic_cache_integration.py @@ -238,24 +238,34 @@ async def test_async_delete_variants( ) assert not any(hit["response"] == response for hit in hits_after_clear) - def test_attribute_value_with_comma_passes_through_to_api( + def test_attribute_value_with_comma_and_slash_is_encoded_for_llm_string( self, langcache_with_attrs: LangCacheSemanticCache ) -> None: - """We currently rely on the LangCache API to validate commas in attribute values. + """llm_string attribute values with commas/slashes are client-encoded.""" - This test verifies we do not perform client-side validation and that the - error is raised by the backend. If this behavior changes, this test will - need to be updated. - """ - prompt = "Comma attribute value" - response = "This may fail depending on the remote validation rules." + prompt = "Attribute encoding for llm_string" + response = "Response for encoded llm_string." - with pytest.raises(BadRequestErrorResponseContent): - langcache_with_attrs.store( - prompt=prompt, - response=response, - metadata={"llm_string": "tenant,with,comma"}, - ) + raw_llm_string = "tenant,with/slash" + entry_id = langcache_with_attrs.store( + prompt=prompt, + response=response, + metadata={ + "llm_string": raw_llm_string, + "other": "keep_me", + }, + ) + 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 From fc0b28570c304aba0ea74ec6f3d47f76fdf49474 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Thu, 20 Nov 2025 16:25:38 -0800 Subject: [PATCH 6/8] Fix LangCache integration test attributes --- .../integration/test_langcache_semantic_cache_integration.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/integration/test_langcache_semantic_cache_integration.py b/tests/integration/test_langcache_semantic_cache_integration.py index f3086d05..f61105a9 100644 --- a/tests/integration/test_langcache_semantic_cache_integration.py +++ b/tests/integration/test_langcache_semantic_cache_integration.py @@ -250,10 +250,7 @@ def test_attribute_value_with_comma_and_slash_is_encoded_for_llm_string( entry_id = langcache_with_attrs.store( prompt=prompt, response=response, - metadata={ - "llm_string": raw_llm_string, - "other": "keep_me", - }, + metadata={"llm_string": raw_llm_string}, ) assert entry_id From 11fe095b13fa6480c67952487a373f759cf89ca3 Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Thu, 20 Nov 2025 16:31:47 -0800 Subject: [PATCH 7/8] Document pytest --run-api-tests usage --- CLAUDE.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 From 6ada90a3943ef6d5e0efe7cb2869e8db207ac5ee Mon Sep 17 00:00:00 2001 From: Andrew Brookins Date: Thu, 20 Nov 2025 16:32:34 -0800 Subject: [PATCH 8/8] remove unused symbol --- tests/integration/test_langcache_semantic_cache_integration.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/integration/test_langcache_semantic_cache_integration.py b/tests/integration/test_langcache_semantic_cache_integration.py index f61105a9..b224fcf2 100644 --- a/tests/integration/test_langcache_semantic_cache_integration.py +++ b/tests/integration/test_langcache_semantic_cache_integration.py @@ -18,7 +18,6 @@ import pytest from dotenv import load_dotenv -from langcache.errors import BadRequestErrorResponseContent from redisvl.extensions.cache.llm.langcache import LangCacheSemanticCache