Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
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
302 changes: 302 additions & 0 deletions tests/integration/test_langcache_semantic_cache_integration.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,302 @@
"""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 langcache.errors import BadRequestErrorResponseContent
Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import of 'BadRequestErrorResponseContent' is not used.

Suggested change
from langcache.errors import BadRequestErrorResponseContent

Copilot uses AI. Check for mistakes.

Copy link

Copilot AI Nov 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The BadRequestErrorResponseContent import is unused in this test file. The actual error handling in the implementation file (redisvl/extensions/cache/llm/langcache.py) imports this conditionally within exception handlers, and the tests only verify the wrapped RuntimeError exceptions. Consider removing this unused import.

Copilot uses AI. Check for mistakes.
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_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."

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)
8 changes: 4 additions & 4 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading