From 2519978b8b4c7521186a3af42224e3f3598b954b Mon Sep 17 00:00:00 2001 From: Brian Sam-Bodden Date: Sat, 13 Dec 2025 05:53:12 -0700 Subject: [PATCH 01/12] feat(vcr): add experimental VCR test system for LLM/embedding recording Implements a VCR (Video Cassette Recorder) test system that enables recording and replaying LLM and embedding API calls for deterministic, fast, and cost-effective testing. Core components: - VCRMode: 6 modes (PLAYBACK, RECORD, RECORD_NEW, RECORD_FAILED, PLAYBACK_OR_RECORD, OFF) with smart mode selection - VCRExtension: JUnit 5 extension with full lifecycle callbacks - VCRContext: Redis container management with AOF/RDB persistence - VCRRegistry: Test recording status tracking with smart mode logic - @VCRTest, @VCRRecord, @VCRDisabled: Annotations for test configuration Key features: - Redis-based cassette storage with persistence to src/test/resources/vcr-data - Testcontainers integration for isolated Redis instances - Call counter management for deterministic key generation - Statistics tracking (cache hits, misses, API calls) - Configurable data directory and Redis image Also includes: - Comprehensive documentation in docs/experimental section - README section with quick start example - Design documents for VCR system and EmbeddingsCache enhancement All 39 VCR tests passing. LLM/embedding interceptor implementation pending. --- README.md | 28 + core/build.gradle.kts | 16 + .../com/redis/vl/test/vcr/VCRContext.java | 352 +++++++ .../com/redis/vl/test/vcr/VCRDisabled.java | 39 + .../com/redis/vl/test/vcr/VCRExtension.java | 152 ++++ .../java/com/redis/vl/test/vcr/VCRMode.java | 64 ++ .../java/com/redis/vl/test/vcr/VCRRecord.java | 39 + .../com/redis/vl/test/vcr/VCRRegistry.java | 189 ++++ .../java/com/redis/vl/test/vcr/VCRTest.java | 81 ++ .../redis/vl/test/vcr/VCRAnnotationsTest.java | 106 +++ .../com/redis/vl/test/vcr/VCRModeTest.java | 81 ++ .../redis/vl/test/vcr/VCRRegistryTest.java | 127 +++ docs/content/modules/ROOT/nav.adoc | 3 + .../modules/ROOT/pages/vcr-testing.adoc | 349 +++++++ docs/design/EMBEDDINGS_CACHE_ENHANCEMENT.md | 356 ++++++++ docs/design/VCR_TEST_SYSTEM.md | 861 ++++++++++++++++++ 16 files changed, 2843 insertions(+) create mode 100644 core/src/main/java/com/redis/vl/test/vcr/VCRContext.java create mode 100644 core/src/main/java/com/redis/vl/test/vcr/VCRDisabled.java create mode 100644 core/src/main/java/com/redis/vl/test/vcr/VCRExtension.java create mode 100644 core/src/main/java/com/redis/vl/test/vcr/VCRMode.java create mode 100644 core/src/main/java/com/redis/vl/test/vcr/VCRRecord.java create mode 100644 core/src/main/java/com/redis/vl/test/vcr/VCRRegistry.java create mode 100644 core/src/main/java/com/redis/vl/test/vcr/VCRTest.java create mode 100644 core/src/test/java/com/redis/vl/test/vcr/VCRAnnotationsTest.java create mode 100644 core/src/test/java/com/redis/vl/test/vcr/VCRModeTest.java create mode 100644 core/src/test/java/com/redis/vl/test/vcr/VCRRegistryTest.java create mode 100644 docs/content/modules/ROOT/pages/vcr-testing.adoc create mode 100644 docs/design/EMBEDDINGS_CACHE_ENHANCEMENT.md create mode 100644 docs/design/VCR_TEST_SYSTEM.md diff --git a/README.md b/README.md index b63b0fc..7b84c8e 100644 --- a/README.md +++ b/README.md @@ -376,6 +376,34 @@ System.out.println(match.getDistance()); // Output: 0.273891836405 > Learn more about [semantic routing](https://redis.github.io/redis-vl-java/redisvl/current/semantic-router.html). +## ๐Ÿงช Experimental: VCR Test System + +RedisVL includes an experimental VCR (Video Cassette Recorder) test system for recording and replaying LLM/embedding API calls. This enables: + +- **Deterministic tests** - Replay recorded responses for consistent results +- **Cost reduction** - Avoid repeated API calls during test runs +- **Speed improvement** - Local Redis playback is faster than API calls +- **Offline testing** - Run tests without network access + +```java +import com.redis.vl.test.vcr.VCRTest; +import com.redis.vl.test.vcr.VCRMode; + +@VCRTest(mode = VCRMode.PLAYBACK_OR_RECORD) +public class MyLLMTest { + + @Test + void testLLMResponse() { + // First run: Records API response to Redis + // Subsequent runs: Replays from Redis cassette + String response = myLLMService.generate("What is Redis?"); + assertNotNull(response); + } +} +``` + +> Learn more about [VCR testing](https://redis.github.io/redis-vl-java/redisvl/current/vcr-testing.html). + ## ๐Ÿš€ Why RedisVL? In the age of GenAI, **vector databases** and **LLMs** are transforming information retrieval systems. With emerging and popular frameworks like [LangChain4J](https://github.com/langchain4j/langchain4j) and [Spring AI](https://spring.io/projects/spring-ai), innovation is rapid. Yet, many organizations face the challenge of delivering AI solutions **quickly** and at **scale**. diff --git a/core/build.gradle.kts b/core/build.gradle.kts index cec90d5..0dade85 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -65,6 +65,16 @@ dependencies { // Cohere Java SDK for reranking compileOnly("com.cohere:cohere-java:1.8.1") + // VCR Test Utilities (optional - users include what they need for testing) + // JUnit 5 for extension development + compileOnly("org.junit.jupiter:junit-jupiter-api:5.10.2") + // Testcontainers for Redis persistence + compileOnly("org.testcontainers:testcontainers:1.19.7") + compileOnly("org.testcontainers:junit-jupiter:1.19.7") + // ByteBuddy for method interception (future LLM interceptor) + compileOnly("net.bytebuddy:byte-buddy:1.14.12") + compileOnly("net.bytebuddy:byte-buddy-agent:1.14.12") + // Test dependencies for LangChain4J (include in tests to verify integration) testImplementation("dev.langchain4j:langchain4j:0.36.2") testImplementation("dev.langchain4j:langchain4j-open-ai:0.36.2") @@ -79,6 +89,12 @@ dependencies { // Additional test dependencies testImplementation("com.squareup.okhttp3:mockwebserver:4.12.0") testImplementation("org.mockito:mockito-core:5.11.0") + + // VCR test dependencies (to test VCR functionality) + testImplementation("org.testcontainers:testcontainers:1.19.7") + testImplementation("org.testcontainers:junit-jupiter:1.19.7") + testImplementation("net.bytebuddy:byte-buddy:1.14.12") + testImplementation("net.bytebuddy:byte-buddy-agent:1.14.12") } // Configure test execution diff --git a/core/src/main/java/com/redis/vl/test/vcr/VCRContext.java b/core/src/main/java/com/redis/vl/test/vcr/VCRContext.java new file mode 100644 index 0000000..150039b --- /dev/null +++ b/core/src/main/java/com/redis/vl/test/vcr/VCRContext.java @@ -0,0 +1,352 @@ +package com.redis.vl.test.vcr; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import org.testcontainers.containers.BindMode; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.utility.DockerImageName; +import redis.clients.jedis.Jedis; +import redis.clients.jedis.JedisPooled; + +/** + * Manages VCR state and resources throughout a test session. + * + *

VCRContext handles: + * + *

+ */ +public class VCRContext { + + private final VCRTest config; + private final Path dataDir; + + private GenericContainer redisContainer; + private JedisPooled jedis; + private VCRRegistry registry; + + private String currentTestId; + private VCRMode effectiveMode; + private final List currentCassetteKeys = new ArrayList<>(); + private final Map callCounters = new ConcurrentHashMap<>(); + + // Statistics + private final AtomicLong cacheHits = new AtomicLong(); + private final AtomicLong cacheMisses = new AtomicLong(); + private final AtomicLong apiCalls = new AtomicLong(); + + /** + * Creates a new VCR context with the given configuration. + * + * @param config the VCR test configuration + */ + public VCRContext(VCRTest config) { + this.config = config; + this.dataDir = Path.of(config.dataDir()); + this.effectiveMode = config.mode(); + } + + /** + * Initializes the VCR context, starting Redis and loading existing cassettes. + * + * @throws Exception if initialization fails + */ + public void initialize() throws Exception { + // Ensure data directory exists + Files.createDirectories(dataDir); + + // Start Redis container with persistence + startRedis(); + + // Initialize registry + registry = new VCRRegistry(jedis); + } + + /** Starts the Redis container with appropriate persistence configuration. */ + @SuppressWarnings("resource") + private void startRedis() { + String redisCommand = buildRedisCommand(); + + redisContainer = + new GenericContainer<>(DockerImageName.parse(config.redisImage())) + .withExposedPorts(6379) + .withFileSystemBind(dataDir.toAbsolutePath().toString(), "/data", BindMode.READ_WRITE) + .withCommand(redisCommand); + + redisContainer.start(); + + String host = redisContainer.getHost(); + Integer port = redisContainer.getFirstMappedPort(); + jedis = new JedisPooled(host, port); + + // Wait for Redis to be ready and load existing data + waitForRedis(); + } + + private String buildRedisCommand() { + StringBuilder cmd = new StringBuilder("redis-stack-server"); + cmd.append(" --appendonly yes"); + cmd.append(" --appendfsync everysec"); + cmd.append(" --dir /data"); + cmd.append(" --dbfilename dump.rdb"); + + if (effectiveMode.isRecordMode()) { + // Enable periodic saves in record mode + cmd.append(" --save 60 1 --save 300 10"); + } else { + // Disable saves in playback mode for speed + cmd.append(" --save \"\""); + } + + return cmd.toString(); + } + + private void waitForRedis() { + for (int i = 0; i < 30; i++) { + try { + jedis.ping(); + long dbSize = jedis.dbSize(); + if (dbSize > 0) { + System.out.println("VCR: Loaded " + dbSize + " keys from persisted data"); + } + return; + } catch (Exception e) { + try { + Thread.sleep(100); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while waiting for Redis", ie); + } + } + } + throw new RuntimeException("Timeout waiting for Redis to be ready"); + } + + /** Shuts down the VCR context and releases resources. */ + public void shutdown() { + if (jedis != null) { + jedis.close(); + jedis = null; + } + if (redisContainer != null) { + redisContainer.stop(); + redisContainer = null; + } + } + + /** Resets call counters for a new test. */ + public void resetCallCounters() { + callCounters.clear(); + currentCassetteKeys.clear(); + } + + /** + * Sets the current test context. + * + * @param testId the unique test identifier + */ + public void setCurrentTest(String testId) { + this.currentTestId = testId; + } + + /** + * Gets the current test ID. + * + * @return the current test ID + */ + public String getCurrentTestId() { + return currentTestId; + } + + /** + * Generates a unique cassette key for the current test and call type. + * + * @param type the type of call (e.g., "llm", "embedding") + * @return the generated cassette key + */ + public String generateCassetteKey(String type) { + String counterKey = currentTestId + ":" + type; + int callIndex = + callCounters.computeIfAbsent(counterKey, k -> new AtomicInteger()).incrementAndGet(); + + String key = String.format("vcr:%s:%s:%04d", type, currentTestId, callIndex); + currentCassetteKeys.add(key); + return key; + } + + /** + * Gets the cassette keys generated for the current test. + * + * @return list of cassette keys + */ + public List getCurrentCassetteKeys() { + return new ArrayList<>(currentCassetteKeys); + } + + /** + * Deletes the specified cassettes. + * + * @param keys the cassette keys to delete + */ + public void deleteCassettes(List keys) { + if (jedis != null && keys != null && !keys.isEmpty()) { + jedis.del(keys.toArray(new String[0])); + } + } + + /** Persists cassettes by triggering a Redis BGSAVE. */ + public void persistCassettes() { + if (jedis == null) { + return; + } + + // Use a separate Jedis connection for BGSAVE since JedisPooled doesn't expose it directly + String host = redisContainer.getHost(); + Integer port = redisContainer.getFirstMappedPort(); + try (Jedis directJedis = new Jedis(host, port)) { + directJedis.bgsave(); + + // Wait for save to complete + long lastSave = directJedis.lastsave(); + for (int i = 0; i < 100; i++) { + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + if (directJedis.lastsave() != lastSave) { + System.out.println("VCR: Persisted cassettes to disk"); + return; + } + } + System.err.println("VCR: Warning - BGSAVE may not have completed"); + } + } + + /** + * Gets the VCR registry. + * + * @return the registry + */ + public VCRRegistry getRegistry() { + return registry; + } + + /** + * Gets the configured VCR mode. + * + * @return the configured mode + */ + public VCRMode getConfiguredMode() { + return config.mode(); + } + + /** + * Gets the effective VCR mode for the current test. + * + * @return the effective mode + */ + public VCRMode getEffectiveMode() { + return effectiveMode; + } + + /** + * Sets the effective VCR mode. + * + * @param mode the mode to set + */ + public void setEffectiveMode(VCRMode mode) { + this.effectiveMode = mode; + } + + /** + * Gets the Redis client. + * + * @return the Jedis client + */ + @SuppressFBWarnings( + value = "EI_EXPOSE_REP", + justification = "Callers need direct access to shared Redis connection pool") + public JedisPooled getJedis() { + return jedis; + } + + /** + * Gets the data directory path. + * + * @return the data directory + */ + public Path getDataDir() { + return dataDir; + } + + // Statistics methods + + /** Records a cache hit. */ + public void recordCacheHit() { + cacheHits.incrementAndGet(); + } + + /** Records a cache miss. */ + public void recordCacheMiss() { + cacheMisses.incrementAndGet(); + } + + /** Records an API call. */ + public void recordApiCall() { + apiCalls.incrementAndGet(); + } + + /** Prints VCR statistics to stdout. */ + public void printStatistics() { + long hits = cacheHits.get(); + long misses = cacheMisses.get(); + long total = hits + misses; + double hitRate = total > 0 ? (double) hits / total * 100 : 0; + + System.out.println("=== VCR Statistics ==="); + System.out.printf("Cache Hits: %d%n", hits); + System.out.printf("Cache Misses: %d%n", misses); + System.out.printf("API Calls: %d%n", apiCalls.get()); + System.out.printf("Hit Rate: %.1f%%%n", hitRate); + } + + /** + * Gets the cache hit count. + * + * @return number of cache hits + */ + public long getCacheHits() { + return cacheHits.get(); + } + + /** + * Gets the cache miss count. + * + * @return number of cache misses + */ + public long getCacheMisses() { + return cacheMisses.get(); + } + + /** + * Gets the API call count. + * + * @return number of API calls + */ + public long getApiCalls() { + return apiCalls.get(); + } +} diff --git a/core/src/main/java/com/redis/vl/test/vcr/VCRDisabled.java b/core/src/main/java/com/redis/vl/test/vcr/VCRDisabled.java new file mode 100644 index 0000000..21d52ae --- /dev/null +++ b/core/src/main/java/com/redis/vl/test/vcr/VCRDisabled.java @@ -0,0 +1,39 @@ +package com.redis.vl.test.vcr; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Disables VCR functionality for a specific test method. + * + *

When applied to a test method, this annotation completely disables VCR for that test. All LLM + * calls will go to real APIs and nothing will be recorded or played back. + * + *

Example usage: + * + *

{@code
+ * @VCRTest(mode = VCRMode.PLAYBACK)
+ * class MyLLMIntegrationTest {
+ *
+ *     @Test
+ *     void usesPlayback() {
+ *         // Uses cached responses
+ *     }
+ *
+ *     @Test
+ *     @VCRDisabled
+ *     void bypassesVCR() {
+ *         // VCR is completely disabled - real API calls, no caching
+ *     }
+ * }
+ * }
+ * + * @see VCRTest + * @see VCRRecord + * @see VCRMode#OFF + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface VCRDisabled {} diff --git a/core/src/main/java/com/redis/vl/test/vcr/VCRExtension.java b/core/src/main/java/com/redis/vl/test/vcr/VCRExtension.java new file mode 100644 index 0000000..2b3669d --- /dev/null +++ b/core/src/main/java/com/redis/vl/test/vcr/VCRExtension.java @@ -0,0 +1,152 @@ +package com.redis.vl.test.vcr; + +import org.junit.jupiter.api.extension.AfterAllCallback; +import org.junit.jupiter.api.extension.AfterEachCallback; +import org.junit.jupiter.api.extension.BeforeAllCallback; +import org.junit.jupiter.api.extension.BeforeEachCallback; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.api.extension.TestWatcher; + +/** + * JUnit 5 extension that provides VCR (Video Cassette Recorder) functionality for recording and + * playing back LLM API calls during tests. + * + *

This extension manages: + * + *

    + *
  • Redis container lifecycle with AOF/RDB persistence + *
  • Cassette storage and retrieval + *
  • Test context and call counter management + *
  • LLM call interception via ByteBuddy + *
+ * + *

Usage: + * + *

{@code
+ * @VCRTest(mode = VCRMode.PLAYBACK)
+ * class MyLLMTest {
+ *     @Test
+ *     void testLLMCall() {
+ *         // LLM calls are automatically recorded/replayed
+ *     }
+ * }
+ * }
+ */ +public class VCRExtension + implements BeforeAllCallback, + AfterAllCallback, + BeforeEachCallback, + AfterEachCallback, + TestWatcher { + + private static final ExtensionContext.Namespace NAMESPACE = + ExtensionContext.Namespace.create(VCRExtension.class); + + private VCRContext context; + + @Override + public void beforeAll(ExtensionContext extensionContext) throws Exception { + // Get VCR configuration from @VCRTest annotation + VCRTest config = extensionContext.getRequiredTestClass().getAnnotation(VCRTest.class); + + if (config == null) { + // No @VCRTest annotation, use defaults + config = DefaultVCRTest.INSTANCE; + } + + // Create and initialize context + context = new VCRContext(config); + context.initialize(); + + // Store in extension context for access in other callbacks + extensionContext.getStore(NAMESPACE).put("vcr-context", context); + } + + @Override + public void beforeEach(ExtensionContext extensionContext) throws Exception { + if (context == null) { + return; + } + + // Get test identifier + String testId = getTestId(extensionContext); + + // Reset call counters for new test + context.resetCallCounters(); + + // Set current test context + context.setCurrentTest(testId); + + // Check for method-level mode overrides + var method = extensionContext.getRequiredTestMethod(); + + if (method.isAnnotationPresent(VCRDisabled.class)) { + context.setEffectiveMode(VCRMode.OFF); + } else if (method.isAnnotationPresent(VCRRecord.class)) { + context.setEffectiveMode(VCRMode.RECORD); + } else { + // Use class-level or default mode + context.setEffectiveMode(context.getConfiguredMode()); + } + } + + @Override + public void afterEach(ExtensionContext extensionContext) throws Exception { + // Test result handling is done via TestWatcher callbacks + } + + @Override + public void testSuccessful(ExtensionContext extensionContext) { + if (context == null) { + return; + } + + String testId = getTestId(extensionContext); + context.getRegistry().registerSuccess(testId, context.getCurrentCassetteKeys()); + } + + @Override + public void testFailed(ExtensionContext extensionContext, Throwable cause) { + if (context == null) { + return; + } + + String testId = getTestId(extensionContext); + context.getRegistry().registerFailure(testId, cause.getMessage()); + + // Optionally delete cassettes for failed tests in RECORD mode + if (context.getEffectiveMode() == VCRMode.RECORD) { + context.deleteCassettes(context.getCurrentCassetteKeys()); + } + } + + @Override + public void afterAll(ExtensionContext extensionContext) throws Exception { + if (context == null) { + return; + } + + try { + // Persist cassettes if in record mode + if (context.getEffectiveMode().isRecordMode()) { + context.persistCassettes(); + } + + // Print statistics + context.printStatistics(); + } finally { + // Clean up + context.shutdown(); + } + } + + private String getTestId(ExtensionContext ctx) { + return ctx.getRequiredTestClass().getName() + ":" + ctx.getRequiredTestMethod().getName(); + } + + /** Default VCRTest annotation values for when no annotation is present. */ + @VCRTest + private static class DefaultVCRTest { + static final VCRTest INSTANCE = DefaultVCRTest.class.getAnnotation(VCRTest.class); + } +} diff --git a/core/src/main/java/com/redis/vl/test/vcr/VCRMode.java b/core/src/main/java/com/redis/vl/test/vcr/VCRMode.java new file mode 100644 index 0000000..fcb8aeb --- /dev/null +++ b/core/src/main/java/com/redis/vl/test/vcr/VCRMode.java @@ -0,0 +1,64 @@ +package com.redis.vl.test.vcr; + +/** + * VCR operating modes that determine how LLM calls are handled during tests. + * + *

Inspired by the Python VCR implementation in maestro-langgraph, this enum provides flexible + * options for recording and playing back LLM responses. + */ +public enum VCRMode { + + /** + * Use cached responses only. Fails if no cassette exists for a call. This is the default mode for + * CI/CD environments where API calls should not be made. + */ + PLAYBACK, + + /** + * Always make real API calls and overwrite any existing cassettes. Use this mode when + * re-recording all tests. + */ + RECORD, + + /** + * Only record tests that are not already in the registry. Existing cassettes are played back, new + * tests are recorded. + */ + RECORD_NEW, + + /** Re-record only tests that previously failed. Successful tests use existing cassettes. */ + RECORD_FAILED, + + /** + * Smart mode: use cache if it exists, otherwise record. Good for development when you want + * automatic recording of new tests. + */ + PLAYBACK_OR_RECORD, + + /** + * Disable VCR entirely. All calls go to real APIs, nothing is cached. Use this mode when testing + * real API behavior. + */ + OFF; + + /** + * Checks if this mode can potentially make real API calls and record responses. + * + * @return true if this mode can record new cassettes + */ + public boolean isRecordMode() { + return this == RECORD + || this == RECORD_NEW + || this == RECORD_FAILED + || this == PLAYBACK_OR_RECORD; + } + + /** + * Checks if this mode can use cached responses. + * + * @return true if this mode can play back existing cassettes + */ + public boolean isPlaybackMode() { + return this == PLAYBACK || this == PLAYBACK_OR_RECORD; + } +} diff --git a/core/src/main/java/com/redis/vl/test/vcr/VCRRecord.java b/core/src/main/java/com/redis/vl/test/vcr/VCRRecord.java new file mode 100644 index 0000000..0df0d1d --- /dev/null +++ b/core/src/main/java/com/redis/vl/test/vcr/VCRRecord.java @@ -0,0 +1,39 @@ +package com.redis.vl.test.vcr; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Forces a specific test method to use RECORD mode, overriding the class-level VCR mode. + * + *

When applied to a test method, this annotation forces that specific test to always make real + * API calls and record the responses, regardless of the class-level {@link VCRTest#mode()} setting. + * + *

Example usage: + * + *

{@code
+ * @VCRTest(mode = VCRMode.PLAYBACK)
+ * class MyLLMIntegrationTest {
+ *
+ *     @Test
+ *     void usesPlayback() {
+ *         // Uses cached responses
+ *     }
+ *
+ *     @Test
+ *     @VCRRecord
+ *     void forcesRecording() {
+ *         // Always makes real API calls and updates cache
+ *     }
+ * }
+ * }
+ * + * @see VCRTest + * @see VCRDisabled + * @see VCRMode#RECORD + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface VCRRecord {} diff --git a/core/src/main/java/com/redis/vl/test/vcr/VCRRegistry.java b/core/src/main/java/com/redis/vl/test/vcr/VCRRegistry.java new file mode 100644 index 0000000..92d53eb --- /dev/null +++ b/core/src/main/java/com/redis/vl/test/vcr/VCRRegistry.java @@ -0,0 +1,189 @@ +package com.redis.vl.test.vcr; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import redis.clients.jedis.JedisPooled; + +/** + * Tracks which tests have been recorded and their status. + * + *

The registry maintains metadata about each test in Redis, including: + * + *

    + *
  • Recording status (RECORDED, FAILED, MISSING) + *
  • Timestamp of last recording + *
  • Associated cassette keys + *
  • Error messages for failed tests + *
+ */ +public class VCRRegistry { + + private static final String REGISTRY_KEY = "vcr:registry"; + private static final String TESTS_KEY = "vcr:registry:tests"; + + private final JedisPooled jedis; + private final Map localCache = new ConcurrentHashMap<>(); + + /** Recording status for a test. */ + public enum RecordingStatus { + /** Test has been successfully recorded */ + RECORDED, + /** Test recording failed */ + FAILED, + /** Test has no recording */ + MISSING, + /** Test recording is outdated */ + OUTDATED + } + + /** + * Creates a new VCR registry. + * + * @param jedis the Redis client + */ + @SuppressFBWarnings( + value = "EI_EXPOSE_REP2", + justification = "JedisPooled is intentionally shared for connection pooling") + public VCRRegistry(JedisPooled jedis) { + this.jedis = jedis; + } + + /** + * Registers a successful test recording. + * + * @param testId the unique test identifier + * @param cassetteKeys the keys of cassettes recorded for this test + */ + public void registerSuccess(String testId, List cassetteKeys) { + if (jedis == null) { + localCache.put(testId, RecordingStatus.RECORDED); + return; + } + + String testKey = "vcr:test:" + testId; + + Map data = new HashMap<>(); + data.put("status", RecordingStatus.RECORDED.name()); + data.put("recorded_at", Instant.now().toString()); + data.put("cassette_count", String.valueOf(cassetteKeys != null ? cassetteKeys.size() : 0)); + + jedis.hset(testKey, data); + jedis.sadd(TESTS_KEY, testId); + + // Store cassette keys + if (cassetteKeys != null && !cassetteKeys.isEmpty()) { + jedis.sadd(testKey + ":cassettes", cassetteKeys.toArray(new String[0])); + } + + localCache.put(testId, RecordingStatus.RECORDED); + } + + /** + * Registers a failed test recording. + * + * @param testId the unique test identifier + * @param error the error message + */ + public void registerFailure(String testId, String error) { + if (jedis == null) { + localCache.put(testId, RecordingStatus.FAILED); + return; + } + + String testKey = "vcr:test:" + testId; + + Map data = new HashMap<>(); + data.put("status", RecordingStatus.FAILED.name()); + data.put("recorded_at", Instant.now().toString()); + data.put("error", error != null ? error : "Unknown error"); + + jedis.hset(testKey, data); + jedis.sadd(TESTS_KEY, testId); + + localCache.put(testId, RecordingStatus.FAILED); + } + + /** + * Gets the recording status of a test. + * + * @param testId the unique test identifier + * @return the recording status + */ + public RecordingStatus getTestStatus(String testId) { + // Check local cache first + RecordingStatus cached = localCache.get(testId); + if (cached != null) { + return cached; + } + + if (jedis == null) { + return RecordingStatus.MISSING; + } + + String testKey = "vcr:test:" + testId; + String status = jedis.hget(testKey, "status"); + + if (status == null) { + return RecordingStatus.MISSING; + } + + RecordingStatus result = RecordingStatus.valueOf(status); + localCache.put(testId, result); + return result; + } + + /** + * Determines the effective VCR mode for a test based on registry status. + * + * @param testId the unique test identifier + * @param globalMode the global VCR mode + * @return the effective mode to use for this test + */ + public VCRMode determineEffectiveMode(String testId, VCRMode globalMode) { + RecordingStatus status = getTestStatus(testId); + + return switch (globalMode) { + case RECORD_NEW -> status == RecordingStatus.MISSING ? VCRMode.RECORD : VCRMode.PLAYBACK; + + case RECORD_FAILED -> + status == RecordingStatus.FAILED || status == RecordingStatus.MISSING + ? VCRMode.RECORD + : VCRMode.PLAYBACK; + + case PLAYBACK_OR_RECORD -> + status == RecordingStatus.RECORDED ? VCRMode.PLAYBACK : VCRMode.RECORD; + + default -> globalMode; + }; + } + + /** + * Gets all recorded test IDs. + * + * @return set of test IDs + */ + public Set getAllRecordedTests() { + if (jedis == null) { + return localCache.keySet(); + } + return jedis.smembers(TESTS_KEY); + } + + /** + * Gets the cassette keys for a test. + * + * @param testId the unique test identifier + * @return set of cassette keys + */ + public Set getCassetteKeys(String testId) { + if (jedis == null) { + return Set.of(); + } + return jedis.smembers("vcr:test:" + testId + ":cassettes"); + } +} diff --git a/core/src/main/java/com/redis/vl/test/vcr/VCRTest.java b/core/src/main/java/com/redis/vl/test/vcr/VCRTest.java new file mode 100644 index 0000000..262be39 --- /dev/null +++ b/core/src/main/java/com/redis/vl/test/vcr/VCRTest.java @@ -0,0 +1,81 @@ +package com.redis.vl.test.vcr; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Enables VCR (Video Cassette Recorder) functionality for a test class. + * + *

When applied to a test class, this annotation enables automatic recording and playback of LLM + * API calls. This allows tests to run without making actual API calls after initial recording, + * reducing costs and providing deterministic test execution. + * + *

Example usage: + * + *

{@code
+ * @VCRTest(mode = VCRMode.PLAYBACK)
+ * class MyLLMIntegrationTest {
+ *
+ *     @Test
+ *     void testLLMResponse() {
+ *         // LLM calls are automatically intercepted
+ *         String response = chatModel.generate("Hello, world!");
+ *         assertThat(response).isNotEmpty();
+ *     }
+ * }
+ * }
+ * + *

Configuration options: + * + *

    + *
  • {@link #mode()} - The VCR operating mode (default: PLAYBACK) + *
  • {@link #dataDir()} - Directory for storing cassettes (default: src/test/resources/vcr-data) + *
  • {@link #redisImage()} - Docker image for Redis container (default: + * redis/redis-stack:latest) + *
+ * + * @see VCRMode + * @see VCRRecord + * @see VCRDisabled + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@ExtendWith(VCRExtension.class) +public @interface VCRTest { + + /** + * The VCR operating mode for this test class. + * + * @return the VCR mode to use (default: PLAYBACK) + */ + VCRMode mode() default VCRMode.PLAYBACK; + + /** + * The directory where VCR cassettes (recorded responses) are stored. + * + *

This directory will contain: + * + *

    + *
  • dump.rdb - Redis RDB snapshot + *
  • appendonlydir/ - Redis AOF segments + *
+ * + *

The directory is relative to the project root unless an absolute path is provided. + * + * @return the data directory path (default: src/test/resources/vcr-data) + */ + String dataDir() default "src/test/resources/vcr-data"; + + /** + * The Docker image to use for the Redis container. + * + *

The image should be a Redis Stack image that includes RediSearch and RedisJSON modules for + * optimal functionality. + * + * @return the Redis Docker image name (default: redis/redis-stack:latest) + */ + String redisImage() default "redis/redis-stack:latest"; +} diff --git a/core/src/test/java/com/redis/vl/test/vcr/VCRAnnotationsTest.java b/core/src/test/java/com/redis/vl/test/vcr/VCRAnnotationsTest.java new file mode 100644 index 0000000..0f4cdd4 --- /dev/null +++ b/core/src/test/java/com/redis/vl/test/vcr/VCRAnnotationsTest.java @@ -0,0 +1,106 @@ +package com.redis.vl.test.vcr; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +/** + * Tests for VCR annotations - TDD RED phase. These tests will fail until we implement the + * annotations. + */ +class VCRAnnotationsTest { + + @Test + void vcrTestAnnotationShouldExist() { + assertThat(VCRTest.class).isAnnotation(); + } + + @Test + void vcrTestShouldHaveRuntimeRetention() { + Retention retention = VCRTest.class.getAnnotation(Retention.class); + assertThat(retention).isNotNull(); + assertThat(retention.value()).isEqualTo(RetentionPolicy.RUNTIME); + } + + @Test + void vcrTestShouldTargetTypes() { + Target target = VCRTest.class.getAnnotation(Target.class); + assertThat(target).isNotNull(); + assertThat(target.value()).contains(ElementType.TYPE); + } + + @Test + void vcrTestShouldHaveModeAttribute() throws NoSuchMethodException { + var method = VCRTest.class.getMethod("mode"); + assertThat(method.getReturnType()).isEqualTo(VCRMode.class); + } + + @Test + void vcrTestShouldHaveDefaultModeOfPlayback() throws NoSuchMethodException { + var method = VCRTest.class.getMethod("mode"); + VCRMode defaultValue = (VCRMode) method.getDefaultValue(); + assertThat(defaultValue).isEqualTo(VCRMode.PLAYBACK); + } + + @Test + void vcrTestShouldHaveDataDirAttribute() throws NoSuchMethodException { + var method = VCRTest.class.getMethod("dataDir"); + assertThat(method.getReturnType()).isEqualTo(String.class); + } + + @Test + void vcrTestShouldHaveDefaultDataDir() throws NoSuchMethodException { + var method = VCRTest.class.getMethod("dataDir"); + String defaultValue = (String) method.getDefaultValue(); + assertThat(defaultValue).isEqualTo("src/test/resources/vcr-data"); + } + + @Test + void vcrTestShouldHaveRedisImageAttribute() throws NoSuchMethodException { + var method = VCRTest.class.getMethod("redisImage"); + assertThat(method.getReturnType()).isEqualTo(String.class); + } + + @Test + void vcrTestShouldHaveDefaultRedisImage() throws NoSuchMethodException { + var method = VCRTest.class.getMethod("redisImage"); + String defaultValue = (String) method.getDefaultValue(); + assertThat(defaultValue).isEqualTo("redis/redis-stack:latest"); + } + + @Test + void vcrTestShouldBeExtendedWithVCRExtension() { + ExtendWith extendWith = VCRTest.class.getAnnotation(ExtendWith.class); + assertThat(extendWith).isNotNull(); + assertThat(extendWith.value()).contains(VCRExtension.class); + } + + @Test + void vcrRecordAnnotationShouldExist() { + assertThat(VCRRecord.class).isAnnotation(); + } + + @Test + void vcrRecordShouldTargetMethods() { + Target target = VCRRecord.class.getAnnotation(Target.class); + assertThat(target).isNotNull(); + assertThat(target.value()).contains(ElementType.METHOD); + } + + @Test + void vcrDisabledAnnotationShouldExist() { + assertThat(VCRDisabled.class).isAnnotation(); + } + + @Test + void vcrDisabledShouldTargetMethods() { + Target target = VCRDisabled.class.getAnnotation(Target.class); + assertThat(target).isNotNull(); + assertThat(target.value()).contains(ElementType.METHOD); + } +} diff --git a/core/src/test/java/com/redis/vl/test/vcr/VCRModeTest.java b/core/src/test/java/com/redis/vl/test/vcr/VCRModeTest.java new file mode 100644 index 0000000..540869c --- /dev/null +++ b/core/src/test/java/com/redis/vl/test/vcr/VCRModeTest.java @@ -0,0 +1,81 @@ +package com.redis.vl.test.vcr; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +/** + * Tests for VCRMode enum - TDD RED phase. These tests will fail until we implement the VCRMode + * enum. + */ +class VCRModeTest { + + @Test + void shouldHavePlaybackMode() { + assertThat(VCRMode.PLAYBACK).isNotNull(); + assertThat(VCRMode.PLAYBACK.name()).isEqualTo("PLAYBACK"); + } + + @Test + void shouldHaveRecordMode() { + assertThat(VCRMode.RECORD).isNotNull(); + assertThat(VCRMode.RECORD.name()).isEqualTo("RECORD"); + } + + @Test + void shouldHaveRecordNewMode() { + assertThat(VCRMode.RECORD_NEW).isNotNull(); + assertThat(VCRMode.RECORD_NEW.name()).isEqualTo("RECORD_NEW"); + } + + @Test + void shouldHaveRecordFailedMode() { + assertThat(VCRMode.RECORD_FAILED).isNotNull(); + assertThat(VCRMode.RECORD_FAILED.name()).isEqualTo("RECORD_FAILED"); + } + + @Test + void shouldHavePlaybackOrRecordMode() { + assertThat(VCRMode.PLAYBACK_OR_RECORD).isNotNull(); + assertThat(VCRMode.PLAYBACK_OR_RECORD.name()).isEqualTo("PLAYBACK_OR_RECORD"); + } + + @Test + void shouldHaveOffMode() { + assertThat(VCRMode.OFF).isNotNull(); + assertThat(VCRMode.OFF.name()).isEqualTo("OFF"); + } + + @Test + void shouldHaveSixModes() { + assertThat(VCRMode.values()).hasSize(6); + } + + @Test + void isRecordModeShouldReturnTrueForRecordModes() { + assertThat(VCRMode.RECORD.isRecordMode()).isTrue(); + assertThat(VCRMode.RECORD_NEW.isRecordMode()).isTrue(); + assertThat(VCRMode.RECORD_FAILED.isRecordMode()).isTrue(); + assertThat(VCRMode.PLAYBACK_OR_RECORD.isRecordMode()).isTrue(); + } + + @Test + void isRecordModeShouldReturnFalseForNonRecordModes() { + assertThat(VCRMode.PLAYBACK.isRecordMode()).isFalse(); + assertThat(VCRMode.OFF.isRecordMode()).isFalse(); + } + + @Test + void isPlaybackModeShouldReturnTrueForPlaybackModes() { + assertThat(VCRMode.PLAYBACK.isPlaybackMode()).isTrue(); + assertThat(VCRMode.PLAYBACK_OR_RECORD.isPlaybackMode()).isTrue(); + } + + @Test + void isPlaybackModeShouldReturnFalseForNonPlaybackModes() { + assertThat(VCRMode.RECORD.isPlaybackMode()).isFalse(); + assertThat(VCRMode.RECORD_NEW.isPlaybackMode()).isFalse(); + assertThat(VCRMode.RECORD_FAILED.isPlaybackMode()).isFalse(); + assertThat(VCRMode.OFF.isPlaybackMode()).isFalse(); + } +} diff --git a/core/src/test/java/com/redis/vl/test/vcr/VCRRegistryTest.java b/core/src/test/java/com/redis/vl/test/vcr/VCRRegistryTest.java new file mode 100644 index 0000000..7837890 --- /dev/null +++ b/core/src/test/java/com/redis/vl/test/vcr/VCRRegistryTest.java @@ -0,0 +1,127 @@ +package com.redis.vl.test.vcr; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** Unit tests for VCRRegistry with null Redis (local cache only mode). */ +class VCRRegistryTest { + + private VCRRegistry registry; + + @BeforeEach + void setUp() { + // Use null jedis for local-only mode (no Redis required for unit tests) + registry = new VCRRegistry(null); + } + + @Test + void shouldReturnMissingStatusForUnknownTest() { + VCRRegistry.RecordingStatus status = registry.getTestStatus("unknown:test"); + assertThat(status).isEqualTo(VCRRegistry.RecordingStatus.MISSING); + } + + @Test + void shouldRegisterSuccessfulTest() { + String testId = "MyTest:testMethod"; + List cassettes = List.of("vcr:llm:MyTest:testMethod:0001"); + + registry.registerSuccess(testId, cassettes); + + assertThat(registry.getTestStatus(testId)).isEqualTo(VCRRegistry.RecordingStatus.RECORDED); + } + + @Test + void shouldRegisterFailedTest() { + String testId = "MyTest:failingMethod"; + + registry.registerFailure(testId, "Test failed with NPE"); + + assertThat(registry.getTestStatus(testId)).isEqualTo(VCRRegistry.RecordingStatus.FAILED); + } + + @Test + void shouldTrackAllRecordedTests() { + registry.registerSuccess("Test1:method1", List.of()); + registry.registerSuccess("Test2:method2", List.of()); + registry.registerFailure("Test3:method3", "error"); + + assertThat(registry.getAllRecordedTests()) + .containsExactlyInAnyOrder("Test1:method1", "Test2:method2", "Test3:method3"); + } + + // Tests for determineEffectiveMode + + @Test + void recordNewShouldRecordMissingTests() { + VCRMode effective = registry.determineEffectiveMode("missing:test", VCRMode.RECORD_NEW); + assertThat(effective).isEqualTo(VCRMode.RECORD); + } + + @Test + void recordNewShouldPlaybackRecordedTests() { + registry.registerSuccess("recorded:test", List.of()); + + VCRMode effective = registry.determineEffectiveMode("recorded:test", VCRMode.RECORD_NEW); + assertThat(effective).isEqualTo(VCRMode.PLAYBACK); + } + + @Test + void recordFailedShouldRecordFailedTests() { + registry.registerFailure("failed:test", "error"); + + VCRMode effective = registry.determineEffectiveMode("failed:test", VCRMode.RECORD_FAILED); + assertThat(effective).isEqualTo(VCRMode.RECORD); + } + + @Test + void recordFailedShouldRecordMissingTests() { + VCRMode effective = registry.determineEffectiveMode("missing:test", VCRMode.RECORD_FAILED); + assertThat(effective).isEqualTo(VCRMode.RECORD); + } + + @Test + void recordFailedShouldPlaybackRecordedTests() { + registry.registerSuccess("recorded:test", List.of()); + + VCRMode effective = registry.determineEffectiveMode("recorded:test", VCRMode.RECORD_FAILED); + assertThat(effective).isEqualTo(VCRMode.PLAYBACK); + } + + @Test + void playbackOrRecordShouldPlaybackRecordedTests() { + registry.registerSuccess("recorded:test", List.of()); + + VCRMode effective = + registry.determineEffectiveMode("recorded:test", VCRMode.PLAYBACK_OR_RECORD); + assertThat(effective).isEqualTo(VCRMode.PLAYBACK); + } + + @Test + void playbackOrRecordShouldRecordMissingTests() { + VCRMode effective = registry.determineEffectiveMode("missing:test", VCRMode.PLAYBACK_OR_RECORD); + assertThat(effective).isEqualTo(VCRMode.RECORD); + } + + @Test + void playbackModeShouldAlwaysReturnPlayback() { + VCRMode effective = registry.determineEffectiveMode("any:test", VCRMode.PLAYBACK); + assertThat(effective).isEqualTo(VCRMode.PLAYBACK); + } + + @Test + void recordModeShouldAlwaysReturnRecord() { + registry.registerSuccess("recorded:test", List.of()); + + VCRMode effective = registry.determineEffectiveMode("recorded:test", VCRMode.RECORD); + assertThat(effective).isEqualTo(VCRMode.RECORD); + } + + @Test + void offModeShouldAlwaysReturnOff() { + VCRMode effective = registry.determineEffectiveMode("any:test", VCRMode.OFF); + assertThat(effective).isEqualTo(VCRMode.OFF); + } +} diff --git a/docs/content/modules/ROOT/nav.adoc b/docs/content/modules/ROOT/nav.adoc index 221c0b5..a946fcc 100644 --- a/docs/content/modules/ROOT/nav.adoc +++ b/docs/content/modules/ROOT/nav.adoc @@ -17,6 +17,9 @@ .API Reference * xref:api-reference.adoc[Javadoc API Reference] +.Experimental +* xref:vcr-testing.adoc[VCR Test System] + .Resources * https://github.com/redis/redis-vl-java[GitHub^] * https://github.com/redis/redis-vl-python[Python Version^] diff --git a/docs/content/modules/ROOT/pages/vcr-testing.adoc b/docs/content/modules/ROOT/pages/vcr-testing.adoc new file mode 100644 index 0000000..c07d661 --- /dev/null +++ b/docs/content/modules/ROOT/pages/vcr-testing.adoc @@ -0,0 +1,349 @@ += VCR Test System +:navtitle: VCR Testing +:description: Record and replay LLM/embedding API calls for deterministic, fast, and cost-effective testing +:page-toclevels: 3 +:experimental: + +CAUTION: This feature is experimental and the API may change in future releases. + +== Overview + +The VCR (Video Cassette Recorder) test system provides a way to record and replay LLM and embedding API calls during testing. This approach offers several benefits: + +* **Deterministic tests** - Replay recorded responses for consistent test results +* **Cost reduction** - Avoid repeated API calls during test runs +* **Speed improvement** - Playback from local Redis is much faster than API calls +* **Offline testing** - Run tests without network access after initial recording + +The VCR system uses Redis for cassette storage with AOF/RDB persistence, allowing recorded data to be committed to version control and shared across team members. + +== Quick Start + +=== Add Dependencies + +To use the VCR test utilities, add the following test dependencies alongside RedisVL: + +[source,xml] +---- + + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + + + org.testcontainers + testcontainers + 1.19.7 + test + + + org.testcontainers + junit-jupiter + 1.19.7 + test + +---- + +[source,groovy] +---- +// Gradle +testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' +testImplementation 'org.testcontainers:testcontainers:1.19.7' +testImplementation 'org.testcontainers:junit-jupiter:1.19.7' +---- + +=== Basic Usage + +Annotate your test class with `@VCRTest` to enable VCR functionality: + +[source,java] +---- +import com.redis.vl.test.vcr.VCRTest; +import com.redis.vl.test.vcr.VCRMode; +import org.junit.jupiter.api.Test; + +@VCRTest(mode = VCRMode.PLAYBACK_OR_RECORD) +public class MyLLMTest { + + @Test + void testLLMResponse() { + // First run: Records API response to Redis + // Subsequent runs: Replays from Redis cassette + String response = myLLMService.generate("What is Redis?"); + assertNotNull(response); + } +} +---- + +== VCR Modes + +The VCR system supports six modes to control recording and playback behavior: + +[cols="1,3"] +|=== +|Mode |Description + +|`PLAYBACK` +|Always replay from cassettes. Fails if cassette not found. + +|`RECORD` +|Always record new cassettes, overwriting existing ones. + +|`RECORD_NEW` +|Record only if cassette is missing; otherwise replay. + +|`RECORD_FAILED` +|Re-record cassettes for previously failed tests; replay successful ones. + +|`PLAYBACK_OR_RECORD` +|Replay if cassette exists; record if not. Best for general use. + +|`OFF` +|Disable VCR entirely - all API calls go through normally. +|=== + +=== Recommended Modes + +* **Development**: Use `PLAYBACK_OR_RECORD` for convenience +* **CI/CD**: Use `PLAYBACK` to ensure tests are deterministic +* **Initial setup**: Use `RECORD` to capture all cassettes + +== Configuration + +=== Data Directory + +Configure where cassette data is stored: + +[source,java] +---- +@VCRTest( + mode = VCRMode.PLAYBACK_OR_RECORD, + dataDir = "src/test/resources/vcr-data" // default +) +public class MyTest { + // ... +} +---- + +The data directory contains Redis persistence files (RDB/AOF) that can be committed to version control. + +=== Redis Image + +Specify a custom Redis image: + +[source,java] +---- +@VCRTest( + mode = VCRMode.PLAYBACK, + redisImage = "redis/redis-stack:7.2.0-v6" // default +) +public class MyTest { + // ... +} +---- + +== Method-Level Overrides + +=== @VCRRecord + +Force recording for a specific test method: + +[source,java] +---- +@VCRTest(mode = VCRMode.PLAYBACK) +public class MyTest { + + @Test + void normalTest() { + // Uses PLAYBACK mode from class annotation + } + + @Test + @VCRRecord + void alwaysRecordThisTest() { + // Forces RECORD mode for this test only + } +} +---- + +=== @VCRDisabled + +Disable VCR for a specific test: + +[source,java] +---- +@VCRTest(mode = VCRMode.PLAYBACK_OR_RECORD) +public class MyTest { + + @Test + @VCRDisabled + void testWithRealAPI() { + // VCR bypassed - makes real API calls + } +} +---- + +== VCR Registry + +The VCR Registry tracks the recording status of each test: + +[cols="1,3"] +|=== +|Status |Description + +|`RECORDED` +|Test has a valid cassette recording + +|`FAILED` +|Previous recording attempt failed + +|`MISSING` +|No cassette exists for this test + +|`OUTDATED` +|Cassette exists but may need re-recording +|=== + +Smart modes like `RECORD_NEW` and `RECORD_FAILED` use registry status to make intelligent decisions about when to record. + +== Architecture + +The VCR system consists of several components: + +* **VCRExtension** - JUnit 5 extension that manages the VCR lifecycle +* **VCRContext** - Manages Redis container, call counters, and statistics +* **VCRRegistry** - Tracks recording status for each test +* **VCRCassetteStore** - Stores/retrieves cassettes in Redis (coming soon) + +=== Cassette Key Format + +Cassettes are stored in Redis with keys following this pattern: + +---- +vcr:{type}:{testId}:{callIndex} +---- + +For example: +---- +vcr:llm:MyTest.testGeneration:0001 +vcr:embedding:MyTest.testEmbedding:0001 +---- + +== Statistics + +The VCR system tracks statistics during test execution: + +* **Cache Hits** - Number of successful cassette replays +* **Cache Misses** - Number of cassettes not found (triggering API calls in record mode) +* **API Calls** - Number of actual API calls made + +== Best Practices + +=== Version Control + +Commit your `vcr-data/` directory to version control: + +[source] +---- +src/test/resources/vcr-data/ +โ”œโ”€โ”€ dump.rdb # RDB snapshot +โ””โ”€โ”€ appendonlydir/ # AOF segments +---- + +=== CI/CD Integration + +Use strict `PLAYBACK` mode in CI to ensure deterministic tests: + +[source,java] +---- +@VCRTest(mode = VCRMode.PLAYBACK) +public class CITest { + // Tests will fail if cassettes are missing +} +---- + +=== Updating Cassettes + +When API responses change, re-record cassettes: + +[source,bash] +---- +# Run tests in RECORD mode to update all cassettes +./gradlew test -Dvcr.mode=RECORD +---- + +=== Sensitive Data + +Be mindful of what gets recorded: + +* API responses may contain sensitive information +* Consider filtering or redacting sensitive data +* Use `.gitignore` patterns if needed + +== Example: Testing with SemanticCache + +[source,java] +---- +import com.redis.vl.test.vcr.VCRTest; +import com.redis.vl.test.vcr.VCRMode; +import com.redis.vl.extensions.cache.llm.SemanticCache; +import org.junit.jupiter.api.Test; + +@VCRTest(mode = VCRMode.PLAYBACK_OR_RECORD) +public class SemanticCacheVCRTest { + + @Test + void testCacheWithRecordedEmbeddings() { + // Embeddings are recorded on first run + SemanticCache cache = new SemanticCache(config, jedis); + + cache.store("What is Redis?", "Redis is an in-memory data store..."); + + CacheResult result = cache.check("Tell me about Redis"); + assertTrue(result.isHit()); + } +} +---- + +== Troubleshooting + +=== Cassette Not Found + +If you see "cassette not found" errors in PLAYBACK mode: + +1. Ensure cassettes were recorded (run in RECORD mode first) +2. Check the data directory path matches +3. Verify Redis persistence files exist + +=== Inconsistent Results + +If test results vary between runs: + +1. Ensure you're using a fixed VCR mode +2. Check for non-deterministic test logic +3. Verify cassette data wasn't corrupted + +=== Container Issues + +If Redis container fails to start: + +1. Ensure Docker is running +2. Check port availability +3. Verify the Redis image exists + +== Future Enhancements + +The following features are planned for future releases: + +* **LLM Interceptor** - Automatic interception of LangChain4J LLM calls +* **Embedding Interceptor** - Integration with EmbeddingsCache +* **Cassette Management CLI** - Tools for managing recorded cassettes +* **Selective Recording** - Fine-grained control over what gets recorded + +== See Also + +* xref:llmcache.adoc[LLM Cache] +* xref:vectorizers.adoc[Vectorizers] +* xref:getting-started.adoc[Getting Started] diff --git a/docs/design/EMBEDDINGS_CACHE_ENHANCEMENT.md b/docs/design/EMBEDDINGS_CACHE_ENHANCEMENT.md new file mode 100644 index 0000000..dd54eef --- /dev/null +++ b/docs/design/EMBEDDINGS_CACHE_ENHANCEMENT.md @@ -0,0 +1,356 @@ +# EmbeddingsCache Enhancement Plan + +## Overview + +This document outlines the plan to enhance the Java `EmbeddingsCache` to achieve feature parity with the Python `redis-vl-python` implementation. + +## Current State Analysis + +### Python Implementation (redis-vl-python) + +| Feature | Implementation | +|---------|----------------| +| **Key Generation** | `SHA256(text:model_name)` - combined hash | +| **Storage** | Redis HASH with structured fields | +| **Fields Stored** | text, model_name, embedding, inserted_at, metadata | +| **TTL Behavior** | Refreshed on retrieval (LRU-like) | +| **Metadata** | Full JSON serialization support | +| **Batch Returns** | Ordered lists matching input order | +| **Async Support** | Full async/await variants | + +### Java Implementation (Current) + +| Feature | Implementation | +|---------|----------------| +| **Key Generation** | `model:SHA256(text)` - separate hash | +| **Storage** | Raw byte array via SET/GET | +| **Fields Stored** | embedding vector only | +| **TTL Behavior** | Set once at write, no refresh | +| **Metadata** | None | +| **Batch Returns** | Unordered Maps | +| **Async Support** | Synchronous only | + +## Enhancement Plan + +### Phase 1: Core Data Model + +#### 1.1 Create CacheEntry Class + +```java +package com.redis.vl.extensions.cache; + +import java.time.Instant; +import java.util.Map; + +@Data +@Builder +public class EmbeddingCacheEntry { + private String entryId; // Unique identifier (hash) + private String text; // Original text that was embedded + private String modelName; // Embedding model name + private float[] embedding; // The embedding vector + private Instant insertedAt; // When the entry was cached + private Map metadata; // Optional user metadata +} +``` + +#### 1.2 Update Key Generation + +Change from `model:hash(text)` to `hash(text:model)` for Python compatibility: + +```java +private String generateKey(String text, String modelName) { + String combined = text + ":" + modelName; + byte[] hash = MessageDigest.getInstance("SHA-256") + .digest(combined.getBytes(StandardCharsets.UTF_8)); + String entryId = bytesToHex(hash); + return cacheName + ":" + entryId; +} +``` + +### Phase 2: Storage Enhancement + +#### 2.1 Switch to Redis HASH Storage + +Replace `SET`/`GET` with `HSET`/`HGETALL`: + +```java +public void set(String text, String modelName, float[] embedding, + Map metadata) { + String key = generateKey(text, modelName); + + Map fields = new HashMap<>(); + fields.put("entry_id", extractEntryId(key)); + fields.put("text", text); + fields.put("model_name", modelName); + fields.put("embedding", serializeEmbedding(embedding)); + fields.put("inserted_at", String.valueOf(Instant.now().toEpochMilli())); + + if (metadata != null && !metadata.isEmpty()) { + fields.put("metadata", objectMapper.writeValueAsString(metadata)); + } + + jedis.hset(key, fields); + + if (ttl > 0) { + jedis.expire(key, ttl); + } +} +``` + +#### 2.2 Implement Structured Retrieval + +```java +public Optional get(String text, String modelName) { + String key = generateKey(text, modelName); + Map fields = jedis.hgetAll(key); + + if (fields.isEmpty()) { + return Optional.empty(); + } + + // Refresh TTL on access (LRU behavior) + if (ttl > 0) { + jedis.expire(key, ttl); + } + + return Optional.of(deserializeEntry(fields)); +} + +private EmbeddingCacheEntry deserializeEntry(Map fields) { + return EmbeddingCacheEntry.builder() + .entryId(fields.get("entry_id")) + .text(fields.get("text")) + .modelName(fields.get("model_name")) + .embedding(deserializeEmbedding(fields.get("embedding"))) + .insertedAt(Instant.ofEpochMilli(Long.parseLong(fields.get("inserted_at")))) + .metadata(fields.containsKey("metadata") + ? objectMapper.readValue(fields.get("metadata"), Map.class) + : null) + .build(); +} +``` + +### Phase 3: TTL Enhancement + +#### 3.1 TTL Refresh on Access + +Add TTL refresh in all retrieval methods: + +```java +public Optional get(String text, String modelName) { + String key = generateKey(text, modelName); + Map fields = jedis.hgetAll(key); + + if (fields.isEmpty()) { + return Optional.empty(); + } + + // LRU-like behavior: refresh TTL on access + refreshTTL(key); + + return Optional.of(deserializeEntry(fields)); +} + +private void refreshTTL(String key) { + if (ttl > 0) { + jedis.expire(key, ttl); + } +} +``` + +### Phase 4: Batch Operations Enhancement + +#### 4.1 Ordered Batch Retrieval + +Return `List>` to preserve order: + +```java +public List> mget(List texts, String modelName) { + List keys = texts.stream() + .map(text -> generateKey(text, modelName)) + .collect(Collectors.toList()); + + List> results = new ArrayList<>(texts.size()); + + try (Pipeline pipeline = jedis.pipelined()) { + List>> responses = new ArrayList<>(); + + for (String key : keys) { + responses.add(pipeline.hgetAll(key)); + } + pipeline.sync(); + + // Refresh TTL for hits + for (int i = 0; i < keys.size(); i++) { + Map fields = responses.get(i).get(); + if (!fields.isEmpty()) { + refreshTTL(keys.get(i)); + results.add(Optional.of(deserializeEntry(fields))); + } else { + results.add(Optional.empty()); + } + } + } + + return results; +} +``` + +#### 4.2 Batch Existence Check + +```java +public List mexists(List texts, String modelName) { + List keys = texts.stream() + .map(text -> generateKey(text, modelName)) + .collect(Collectors.toList()); + + try (Pipeline pipeline = jedis.pipelined()) { + List> responses = keys.stream() + .map(pipeline::exists) + .collect(Collectors.toList()); + pipeline.sync(); + + return responses.stream() + .map(Response::get) + .collect(Collectors.toList()); + } +} +``` + +### Phase 5: Backward Compatibility + +#### 5.1 Simple API Methods + +Maintain simple methods for users who don't need full features: + +```java +// Simple API (returns just embedding) +public Optional getEmbedding(String text, String modelName) { + return get(text, modelName).map(EmbeddingCacheEntry::getEmbedding); +} + +// Simple API (no metadata) +public void set(String text, String modelName, float[] embedding) { + set(text, modelName, embedding, null); +} + +// Legacy Map-based batch (for backward compat) +public Map mgetAsMap(List texts, String modelName) { + List> results = mget(texts, modelName); + Map map = new LinkedHashMap<>(); + for (int i = 0; i < texts.size(); i++) { + results.get(i).ifPresent(entry -> map.put(texts.get(i), entry.getEmbedding())); + } + return map; +} +``` + +### Phase 6: Embedding Serialization + +#### 6.1 JSON-Compatible Float Array Serialization + +```java +private String serializeEmbedding(float[] embedding) { + // Store as JSON array for cross-language compatibility + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < embedding.length; i++) { + if (i > 0) sb.append(","); + sb.append(embedding[i]); + } + sb.append("]"); + return sb.toString(); +} + +private float[] deserializeEmbedding(String serialized) { + // Parse JSON array + String content = serialized.substring(1, serialized.length() - 1); + String[] parts = content.split(","); + float[] result = new float[parts.length]; + for (int i = 0; i < parts.length; i++) { + result[i] = Float.parseFloat(parts[i].trim()); + } + return result; +} +``` + +## API Summary + +### Enhanced EmbeddingsCache API + +```java +public class EmbeddingsCache { + // Constructor + public EmbeddingsCache(String name, JedisPooled client, long ttlSeconds); + + // Full API (with metadata) + public void set(String text, String modelName, float[] embedding, + Map metadata); + public void set(String text, String modelName, float[] embedding, + Map metadata, long ttlOverride); + public Optional get(String text, String modelName); + + // Simple API (just embeddings) + public void set(String text, String modelName, float[] embedding); + public Optional getEmbedding(String text, String modelName); + + // Batch operations (ordered) + public List> mget(List texts, String modelName); + public List mexists(List texts, String modelName); + public void mdrop(List texts, String modelName); + + // Legacy batch (unordered map - backward compat) + public Map mgetAsMap(List texts, String modelName); + + // Management + public boolean exists(String text, String modelName); + public boolean drop(String text, String modelName); + public void clear(); + + // Configuration + public void setTTL(long ttlSeconds); + public long getTTL(); +} +``` + +## Testing Strategy + +### Unit Tests + +1. Test key generation matches Python format +2. Test serialization/deserialization of embeddings +3. Test metadata JSON handling +4. Test TTL refresh behavior + +### Integration Tests + +1. Test HASH storage structure in Redis +2. Test batch operation ordering +3. Test TTL expiration and refresh +4. Test cross-language compatibility (read Python-cached entries) + +## Migration Path + +1. **v1.0**: Add new methods alongside existing ones +2. **v1.1**: Deprecate old methods +3. **v2.0**: Remove deprecated methods + +## Implementation Priority + +| Priority | Feature | Effort | +|----------|---------|--------| +| P0 | CacheEntry class | Low | +| P0 | HASH storage | Medium | +| P1 | TTL refresh | Low | +| P1 | Ordered batch returns | Medium | +| P2 | Metadata support | Low | +| P2 | Cross-language key compat | Low | + +## Success Criteria + +- [ ] All Python API methods have Java equivalents +- [ ] TTL refreshed on cache access +- [ ] Batch operations preserve input order +- [ ] Metadata can be stored and retrieved +- [ ] Existing tests continue to pass +- [ ] New tests cover all enhanced functionality diff --git a/docs/design/VCR_TEST_SYSTEM.md b/docs/design/VCR_TEST_SYSTEM.md new file mode 100644 index 0000000..5b6db4f --- /dev/null +++ b/docs/design/VCR_TEST_SYSTEM.md @@ -0,0 +1,861 @@ +# VCR Test System Design for RedisVL Java + +## Overview + +This document outlines a design for implementing a VCR (Video Cassette Recorder) test system for JUnit 5 (and potentially TestNG) that enables recording and replaying LLM API calls and embedding computations during test execution. + +The design is inspired by the Python implementation in `maestro-langgraph` and adapted for Java idioms, leveraging RedisVL's existing `EmbeddingsCache` infrastructure. + +## Goals + +1. **Zero API Costs in CI** - Tests run without LLM API calls after initial recording +2. **Deterministic Tests** - Same responses every run for reproducibility +3. **Fast Execution** - No network latency (cached responses) +4. **Transparent Integration** - Minimal test code changes via annotations +5. **Redis-Native Storage** - Leverage Redis AOF/RDB for persistent cassettes +6. **Framework Agnostic Core** - Support JUnit 5 primarily, TestNG optionally + +## Architecture Overview + +``` +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Test Execution โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ @VCRTest(mode = PLAYBACK) โ”‚ +โ”‚ class MyIntegrationTest { โ”‚ +โ”‚ @Test void testRAGQuery() { ... } โ”‚ +โ”‚ } โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ VCRExtension (JUnit 5 Extension) โ”‚ +โ”‚ โ€ข BeforeAllCallback: Start Redis, load cassettes โ”‚ +โ”‚ โ€ข BeforeEachCallback: Set test context, reset counters โ”‚ +โ”‚ โ€ข AfterEachCallback: Register test result, persist cassettes โ”‚ +โ”‚ โ€ข AfterAllCallback: BGSAVE, stop Redis โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ VCR Interceptor Layer โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚ +โ”‚ โ”‚ LLMInterceptor โ”‚ โ”‚EmbeddingIntercepโ”‚ โ”‚ RedisVCRStore โ”‚ โ”‚ +โ”‚ โ”‚ (ByteBuddy/ โ”‚ โ”‚tor (ByteBuddy) โ”‚ โ”‚ (Redis + RDB) โ”‚ โ”‚ +โ”‚ โ”‚ MockServer) โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚ +โ”‚ โ”‚ โ”‚ โ”‚ โ”‚ +โ”‚ โ–ผ โ–ผ โ–ผ โ”‚ +โ”‚ โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”โ”‚ +โ”‚ โ”‚ VCRCassette โ”‚โ”‚ +โ”‚ โ”‚ โ€ข generateKey(testId, callIndex) โ”‚โ”‚ +โ”‚ โ”‚ โ€ข store(key, response) โ”‚โ”‚ +โ”‚ โ”‚ โ€ข retrieve(key) โ†’ Optional โ”‚โ”‚ +โ”‚ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Redis Storage Layer โ”‚ +โ”œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ค +โ”‚ Keys: โ”‚ +โ”‚ vcr:cassette:{testClass}:{testMethod}:{callIndex} โ”‚ +โ”‚ vcr:registry:{testClass}:{testMethod} โ”‚ +โ”‚ vcr:embedding:{testClass}:{testMethod}:{model}:{callIndex} โ”‚ +โ”‚ โ”‚ +โ”‚ Persistence: โ”‚ +โ”‚ tests/vcr-data/dump.rdb (RDB snapshot) โ”‚ +โ”‚ tests/vcr-data/appendonly/ (AOF segments) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +## Component Design + +### 1. VCR Annotations + +```java +package com.redis.vl.test.vcr; + +/** + * Class-level annotation to enable VCR for all tests in the class. + */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +@ExtendWith(VCRExtension.class) +public @interface VCRTest { + VCRMode mode() default VCRMode.PLAYBACK; + String dataDir() default "src/test/resources/vcr-data"; +} + +/** + * Method-level annotation to override class-level VCR mode. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface VCRMode { + VCRMode value(); +} + +/** + * Skip VCR for specific test. + */ +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface VCRDisabled { +} + +/** + * VCR operating modes. + */ +public enum VCRMode { + /** Use cached only, fail if missing */ + PLAYBACK, + + /** Always call API, overwrite cache */ + RECORD, + + /** Only record tests not in registry */ + RECORD_NEW, + + /** Re-record only failed tests */ + RECORD_FAILED, + + /** Use cache if exists, else record */ + PLAYBACK_OR_RECORD, + + /** Disable VCR entirely */ + OFF +} +``` + +### 2. JUnit 5 Extension + +```java +package com.redis.vl.test.vcr; + +import org.junit.jupiter.api.extension.*; + +public class VCRExtension implements + BeforeAllCallback, + AfterAllCallback, + BeforeEachCallback, + AfterEachCallback, + TestWatcher { + + private static final ExtensionContext.Namespace NAMESPACE = + ExtensionContext.Namespace.create(VCRExtension.class); + + private VCRContext context; + + @Override + public void beforeAll(ExtensionContext ctx) throws Exception { + // 1. Get VCR configuration from @VCRTest annotation + VCRTest config = ctx.getRequiredTestClass() + .getAnnotation(VCRTest.class); + + // 2. Start Redis container with persistence volume + context = new VCRContext(config); + context.startRedis(); + + // 3. Load existing cassettes from RDB/AOF + context.loadCassettes(); + + // 4. Install interceptors + context.installInterceptors(); + + // 5. Store in extension context + ctx.getStore(NAMESPACE).put("vcr-context", context); + } + + @Override + public void beforeEach(ExtensionContext ctx) throws Exception { + // 1. Get test identifier + String testId = getTestId(ctx); + + // 2. Reset call counters + context.resetCallCounters(); + + // 3. Set current test context (for key generation) + context.setCurrentTest(testId); + + // 4. Check for method-level mode override + VCRMode methodMode = ctx.getRequiredTestMethod() + .getAnnotation(VCRMode.class); + if (methodMode != null) { + context.setEffectiveMode(methodMode.value()); + } + } + + @Override + public void afterEach(ExtensionContext ctx) throws Exception { + // Handled by TestWatcher methods + } + + @Override + public void testSuccessful(ExtensionContext ctx) { + String testId = getTestId(ctx); + context.getRegistry().registerSuccess(testId, + context.getCurrentCassetteKeys()); + } + + @Override + public void testFailed(ExtensionContext ctx, Throwable cause) { + String testId = getTestId(ctx); + context.getRegistry().registerFailure(testId, cause.getMessage()); + + // Optionally delete cassettes for failed tests in RECORD mode + if (context.getEffectiveMode() == VCRMode.RECORD) { + context.deleteCassettes(context.getCurrentCassetteKeys()); + } + } + + @Override + public void afterAll(ExtensionContext ctx) throws Exception { + // 1. Trigger Redis BGSAVE + if (context.isRecordMode()) { + context.persistCassettes(); + } + + // 2. Print statistics + context.printStatistics(); + + // 3. Restore original methods + context.uninstallInterceptors(); + + // 4. Stop Redis + context.stopRedis(); + } + + private String getTestId(ExtensionContext ctx) { + return ctx.getRequiredTestClass().getName() + ":" + + ctx.getRequiredTestMethod().getName(); + } +} +``` + +### 3. VCR Context (State Management) + +```java +package com.redis.vl.test.vcr; + +public class VCRContext { + private final VCRTest config; + private final Path dataDir; + private RedisContainer redisContainer; + private JedisPooled jedis; + private VCRRegistry registry; + private LLMInterceptor llmInterceptor; + private EmbeddingInterceptor embeddingInterceptor; + + private String currentTestId; + private VCRMode effectiveMode; + private List currentCassetteKeys = new ArrayList<>(); + private Map callCounters = new ConcurrentHashMap<>(); + + // Statistics + private AtomicLong cacheHits = new AtomicLong(); + private AtomicLong cacheMisses = new AtomicLong(); + private AtomicLong apiCalls = new AtomicLong(); + + public VCRContext(VCRTest config) { + this.config = config; + this.dataDir = Path.of(config.dataDir()); + this.effectiveMode = config.mode(); + } + + public void startRedis() { + // Ensure data directory exists + Files.createDirectories(dataDir); + + // Start Redis with volume mount + redisContainer = new RedisContainer(DockerImageName.parse("redis/redis-stack:latest")) + .withFileSystemBind(dataDir.toAbsolutePath().toString(), "/data", + BindMode.READ_WRITE) + .withCommand(buildRedisCommand()); + + redisContainer.start(); + + jedis = new JedisPooled(redisContainer.getHost(), + redisContainer.getFirstMappedPort()); + } + + private String buildRedisCommand() { + StringBuilder cmd = new StringBuilder("redis-stack-server"); + cmd.append(" --appendonly yes"); + cmd.append(" --appendfsync everysec"); + cmd.append(" --dir /data"); + cmd.append(" --dbfilename dump.rdb"); + + if (isRecordMode()) { + cmd.append(" --save '60 1' --save '300 10'"); + } else { + cmd.append(" --save ''"); // Disable saves in playback + } + + return cmd.toString(); + } + + public void installInterceptors() { + llmInterceptor = new LLMInterceptor(this); + llmInterceptor.install(); + + embeddingInterceptor = new EmbeddingInterceptor(this); + embeddingInterceptor.install(); + } + + public String generateCassetteKey(String type) { + int callIndex = callCounters + .computeIfAbsent(currentTestId + ":" + type, k -> new AtomicInteger()) + .incrementAndGet(); + + String key = String.format("vcr:%s:%s:%04d", type, currentTestId, callIndex); + currentCassetteKeys.add(key); + return key; + } + + public void persistCassettes() { + // Trigger BGSAVE and wait for completion + jedis.bgsave(); + + long lastSave = jedis.lastsave(); + while (jedis.lastsave() == lastSave) { + Thread.sleep(100); + } + } + + public void printStatistics() { + long hits = cacheHits.get(); + long misses = cacheMisses.get(); + long total = hits + misses; + double hitRate = total > 0 ? (double) hits / total * 100 : 0; + + System.out.println("=== VCR Statistics ==="); + System.out.printf("Cache Hits: %d%n", hits); + System.out.printf("Cache Misses: %d%n", misses); + System.out.printf("API Calls: %d%n", apiCalls.get()); + System.out.printf("Hit Rate: %.1f%%%n", hitRate); + } + + // Getters, setters... +} +``` + +### 4. LLM Interceptor (ByteBuddy) + +```java +package com.redis.vl.test.vcr; + +import net.bytebuddy.ByteBuddy; +import net.bytebuddy.agent.ByteBuddyAgent; +import net.bytebuddy.asm.Advice; +import net.bytebuddy.dynamic.loading.ClassReloadingStrategy; + +/** + * Intercepts LLM API calls using ByteBuddy instrumentation. + * Supports LangChain4J ChatLanguageModel and similar interfaces. + */ +public class LLMInterceptor { + private final VCRContext context; + private static VCRContext staticContext; // For Advice access + + public LLMInterceptor(VCRContext context) { + this.context = context; + } + + public void install() { + staticContext = context; + ByteBuddyAgent.install(); + + // Intercept LangChain4J ChatLanguageModel.generate() + new ByteBuddy() + .redefine(OpenAiChatModel.class) + .visit(Advice.to(ChatModelAdvice.class) + .on(named("generate"))) + .make() + .load(OpenAiChatModel.class.getClassLoader(), + ClassReloadingStrategy.fromInstalledAgent()); + + // Similarly for other LLM providers... + } + + public void uninstall() { + // Restore original classes + staticContext = null; + } + + public static class ChatModelAdvice { + + @Advice.OnMethodEnter(skipOn = Advice.OnNonDefaultValue.class) + public static Object onEnter( + @Advice.AllArguments Object[] args, + @Advice.Origin String method) { + + if (staticContext == null) return null; + + String key = staticContext.generateCassetteKey("llm"); + + // Try to get from cache + Optional cached = staticContext.getCassette(key); + if (cached.isPresent()) { + staticContext.recordCacheHit(); + // Return cached response (skip original method) + return deserializeResponse(cached.get()); + } + + if (staticContext.getEffectiveMode() == VCRMode.PLAYBACK) { + throw new VCRCassetteMissingException( + "No cassette found for key: " + key); + } + + return null; // Proceed with original method + } + + @Advice.OnMethodExit + public static void onExit( + @Advice.Enter Object cachedResult, + @Advice.Return(readOnly = false) Object result) { + + if (staticContext == null) return; + + if (cachedResult != null) { + // Use cached result + result = cachedResult; + } else { + // Store new result + String key = staticContext.getLastGeneratedKey(); + staticContext.storeCassette(key, serializeResponse(result)); + staticContext.recordApiCall(); + } + } + } +} +``` + +### 5. Embedding Interceptor + +```java +package com.redis.vl.test.vcr; + +/** + * Intercepts embedding generation calls. + * Integrates with RedisVL's EmbeddingsCache for storage. + */ +public class EmbeddingInterceptor { + private final VCRContext context; + private final EmbeddingsCache embeddingsCache; + + public EmbeddingInterceptor(VCRContext context) { + this.context = context; + this.embeddingsCache = new EmbeddingsCache( + "vcr-embeddings", + context.getJedis(), + TimeUnit.DAYS.toSeconds(7) // 7-day TTL + ); + } + + public void install() { + // Intercept SentenceTransformers, LangChain4J EmbeddingModel, etc. + // Similar ByteBuddy pattern as LLMInterceptor + } + + /** + * Called by advice when embedding is requested. + */ + public float[] intercept(String text, String modelName) { + String key = context.generateCassetteKey("embedding"); + + // Check cache first + Optional cached = embeddingsCache.getEmbedding(text, modelName); + if (cached.isPresent()) { + context.recordCacheHit(); + return cached.get(); + } + + if (context.getEffectiveMode() == VCRMode.PLAYBACK) { + throw new VCRCassetteMissingException( + "No embedding cassette for: " + text.substring(0, 50) + "..."); + } + + // Will be called after actual embedding is computed + return null; + } + + public void storeEmbedding(String text, String modelName, float[] embedding) { + embeddingsCache.set(text, modelName, embedding); + context.recordApiCall(); + } +} +``` + +### 6. VCR Registry + +```java +package com.redis.vl.test.vcr; + +/** + * Tracks which tests have been recorded and their status. + */ +public class VCRRegistry { + private final JedisPooled jedis; + private static final String REGISTRY_KEY = "vcr:registry"; + private static final String TESTS_KEY = "vcr:registry:tests"; + + public enum RecordingStatus { + RECORDED, FAILED, MISSING, OUTDATED + } + + public void registerSuccess(String testId, List cassetteKeys) { + String testKey = "vcr:test:" + testId; + + Map data = Map.of( + "status", RecordingStatus.RECORDED.name(), + "recorded_at", Instant.now().toString(), + "cassette_count", String.valueOf(cassetteKeys.size()) + ); + + jedis.hset(testKey, data); + jedis.sadd(TESTS_KEY, testId); + + // Store cassette keys + if (!cassetteKeys.isEmpty()) { + jedis.sadd(testKey + ":cassettes", cassetteKeys.toArray(new String[0])); + } + } + + public void registerFailure(String testId, String error) { + String testKey = "vcr:test:" + testId; + + Map data = Map.of( + "status", RecordingStatus.FAILED.name(), + "recorded_at", Instant.now().toString(), + "error", error != null ? error : "Unknown error" + ); + + jedis.hset(testKey, data); + jedis.sadd(TESTS_KEY, testId); + } + + public RecordingStatus getTestStatus(String testId) { + String testKey = "vcr:test:" + testId; + String status = jedis.hget(testKey, "status"); + + if (status == null) { + return RecordingStatus.MISSING; + } + return RecordingStatus.valueOf(status); + } + + public VCRMode determineEffectiveMode(String testId, VCRMode globalMode) { + RecordingStatus status = getTestStatus(testId); + + return switch (globalMode) { + case RECORD_NEW -> status == RecordingStatus.MISSING + ? VCRMode.RECORD : VCRMode.PLAYBACK; + + case RECORD_FAILED -> status == RecordingStatus.FAILED || + status == RecordingStatus.MISSING + ? VCRMode.RECORD : VCRMode.PLAYBACK; + + case PLAYBACK_OR_RECORD -> status == RecordingStatus.RECORDED + ? VCRMode.PLAYBACK : VCRMode.RECORD; + + default -> globalMode; + }; + } + + public Set getAllRecordedTests() { + return jedis.smembers(TESTS_KEY); + } +} +``` + +### 7. Cassette Storage + +```java +package com.redis.vl.test.vcr; + +/** + * Stores and retrieves cassette data from Redis. + */ +public class VCRCassetteStore { + private final JedisPooled jedis; + private final ObjectMapper objectMapper; + + public void store(String key, Object response) { + // Serialize response to JSON + Map cassette = new HashMap<>(); + cassette.put("type", response.getClass().getName()); + cassette.put("timestamp", Instant.now().toString()); + cassette.put("data", serializeResponse(response)); + + jedis.jsonSet(key, Path2.ROOT_PATH, cassette); + } + + public Optional retrieve(String key, Class responseType) { + Object data = jedis.jsonGet(key); + if (data == null) { + return Optional.empty(); + } + + Map cassette = (Map) data; + return Optional.of(deserializeResponse( + (Map) cassette.get("data"), + responseType + )); + } + + private Map serializeResponse(Object response) { + // Handle different response types: + // - LangChain4J AiMessage + // - OpenAI ChatCompletionResponse + // - Anthropic Message + // etc. + + if (response instanceof AiMessage aiMsg) { + return Map.of( + "content", aiMsg.text(), + "toolExecutionRequests", serializeToolRequests(aiMsg) + ); + } + + // Generic fallback + return objectMapper.convertValue(response, Map.class); + } +} +``` + +## Usage Examples + +### Basic Usage + +```java +@VCRTest(mode = VCRMode.PLAYBACK) +class RAGServiceIntegrationTest { + + @Test + void testQueryWithContext() { + // LLM calls are automatically intercepted + RAGService rag = new RAGService(embedder, chatModel, index); + + String response = rag.query("What is Redis?"); + + assertThat(response).contains("in-memory"); + // This test uses cached LLM responses - no API calls! + } +} +``` + +### Recording New Tests + +```java +@VCRTest(mode = VCRMode.RECORD_NEW) +class NewFeatureTest { + + @Test + void testNewFeature() { + // First run: makes real API calls and records them + // Subsequent runs: uses cached responses + ChatLanguageModel llm = createChatModel(); + String response = llm.generate("Hello, world!"); + + assertThat(response).isNotEmpty(); + } +} +``` + +### Method-Level Override + +```java +@VCRTest(mode = VCRMode.PLAYBACK) +class MixedModeTest { + + @Test + void usesPlayback() { + // Uses cached responses + } + + @Test + @VCRMode(VCRMode.RECORD) + void forcesRecording() { + // Always makes real API calls and updates cache + } + + @Test + @VCRDisabled + void noVCR() { + // VCR completely disabled - real API calls, no caching + } +} +``` + +### Programmatic Control + +```java +@VCRTest +class AdvancedTest { + + @RegisterExtension + static VCRExtension vcr = VCRExtension.builder() + .mode(VCRMode.PLAYBACK_OR_RECORD) + .dataDir("src/test/resources/custom-vcr-data") + .redisImage("redis/redis-stack:7.4") + .enableInteractionLogging(true) + .build(); + + @Test + void testWithCustomConfig() { + // Test code... + } +} +``` + +## Redis Data Structure + +### Key Patterns + +``` +vcr:llm:{testClass}:{testMethod}:{callIndex} โ†’ JSON (cassette) +vcr:embedding:{testClass}:{testMethod}:{model}:{callIndex} โ†’ HASH (embedding) +vcr:test:{testId} โ†’ HASH (test metadata) +vcr:test:{testId}:cassettes โ†’ SET (cassette keys) +vcr:registry:tests โ†’ SET (all test IDs) +``` + +### Cassette JSON Format + +```json +{ + "type": "dev.langchain4j.data.message.AiMessage", + "timestamp": "2025-12-13T10:30:00Z", + "data": { + "content": "Redis is an in-memory data structure store...", + "toolExecutionRequests": [] + } +} +``` + +### Persistence Files + +``` +src/test/resources/vcr-data/ +โ”œโ”€โ”€ dump.rdb # RDB snapshot +โ””โ”€โ”€ appendonlydir/ + โ”œโ”€โ”€ appendonly.aof.manifest + โ”œโ”€โ”€ appendonly.aof.1.base.rdb + โ””โ”€โ”€ appendonly.aof.1.incr.aof +``` + +## Build Configuration + +### Gradle Dependencies + +```kotlin +// build.gradle.kts +dependencies { + testImplementation("com.redis.vl:redisvl-vcr:0.12.0") + + // Required for ByteBuddy instrumentation + testImplementation("net.bytebuddy:byte-buddy:1.14.+") + testImplementation("net.bytebuddy:byte-buddy-agent:1.14.+") + + // Testcontainers for Redis + testImplementation("org.testcontainers:testcontainers:1.19.+") +} +``` + +### JVM Arguments (for ByteBuddy agent) + +```kotlin +tasks.test { + jvmArgs("-XX:+AllowRedefinitionToAddDeleteMethods") +} +``` + +## CLI / Management Tools + +```bash +# View VCR statistics +./gradlew vcrStats + +# List unrecorded tests +./gradlew vcrListUnrecorded + +# Clean failed test cassettes +./gradlew vcrCleanFailed + +# Export cassettes to JSON (for backup/migration) +./gradlew vcrExport --output=vcr-backup.json + +# Import cassettes from JSON +./gradlew vcrImport --input=vcr-backup.json +``` + +## Alternative: MockServer Approach + +Instead of ByteBuddy instrumentation, use WireMock/MockServer for HTTP interception: + +```java +@VCRTest(strategy = InterceptionStrategy.MOCK_SERVER) +class HttpBasedVCRTest { + // Intercepts at HTTP level + // Simpler but only works for HTTP-based LLM APIs +} +``` + +**Pros:** +- No bytecode manipulation +- Works with any HTTP client +- Easier debugging + +**Cons:** +- Doesn't work with local models (ONNX, etc.) +- More complex URL matching + +## TestNG Support + +```java +package com.redis.vl.test.vcr.testng; + +import org.testng.annotations.*; + +@Listeners(VCRTestNGListener.class) +@VCRTest(mode = VCRMode.PLAYBACK) +public class TestNGExample { + + @Test + public void testWithVCR() { + // Same functionality as JUnit 5 + } +} +``` + +## Implementation Phases + +| Phase | Scope | Effort | +|-------|-------|--------| +| **1** | Core VCRContext + Redis persistence | 2 days | +| **2** | JUnit 5 Extension + annotations | 2 days | +| **3** | LLM Interceptor (LangChain4J) | 3 days | +| **4** | Embedding Interceptor (integration with EmbeddingsCache) | 1 day | +| **5** | Registry + smart modes | 2 days | +| **6** | CLI tools + statistics | 1 day | +| **7** | TestNG support (optional) | 1 day | +| **8** | Documentation + examples | 1 day | + +## Success Criteria + +- [ ] Tests run in CI without API keys +- [ ] 95%+ cache hit rate after initial recording +- [ ] Test execution <100ms per cached LLM call +- [ ] Cassettes persist across CI runs (via committed vcr-data/) +- [ ] Seamless integration with existing RedisVL tests +- [ ] Clear error messages when cassettes are missing + +## References + +- [maestro-langgraph VCR Implementation](../../../maestro-langgraph/tests/utils/) +- [VCR.py (Python HTTP recording)](https://vcrpy.readthedocs.io/) +- [WireMock](https://wiremock.org/) +- [ByteBuddy](https://bytebuddy.net/) +- [JUnit 5 Extension Model](https://junit.org/junit5/docs/current/user-guide/#extensions) From 5087e3dd50d18e68dc034762af465e1c88056635 Mon Sep 17 00:00:00 2001 From: Brian Sam-Bodden Date: Sat, 13 Dec 2025 09:31:32 -0700 Subject: [PATCH 02/12] feat(vcr): add Redis cassette store for VCR persistence Implements VCRCassetteStore for storing and retrieving recorded API responses in Redis: - Store cassettes as Redis JSON documents with structured keys - Support for single and batch embedding cassettes - Handle both array and object JSON responses from Jedis - Add VCRCassetteMissingException for strict playback mode - Unit tests for cassette store operations Cassette key format: vcr:{type}:{testId}:{callIndex} --- .../test/vcr/VCRCassetteMissingException.java | 49 ++++ .../redis/vl/test/vcr/VCRCassetteStore.java | 249 ++++++++++++++++++ .../vl/test/vcr/VCRCassetteStoreTest.java | 193 ++++++++++++++ 3 files changed, 491 insertions(+) create mode 100644 core/src/main/java/com/redis/vl/test/vcr/VCRCassetteMissingException.java create mode 100644 core/src/main/java/com/redis/vl/test/vcr/VCRCassetteStore.java create mode 100644 core/src/test/java/com/redis/vl/test/vcr/VCRCassetteStoreTest.java diff --git a/core/src/main/java/com/redis/vl/test/vcr/VCRCassetteMissingException.java b/core/src/main/java/com/redis/vl/test/vcr/VCRCassetteMissingException.java new file mode 100644 index 0000000..655583d --- /dev/null +++ b/core/src/main/java/com/redis/vl/test/vcr/VCRCassetteMissingException.java @@ -0,0 +1,49 @@ +package com.redis.vl.test.vcr; + +/** + * Exception thrown when a VCR cassette is not found during playback mode. + * + *

This exception indicates that the test expected to find a recorded cassette but none was + * available. To fix this, run the test in RECORD or PLAYBACK_OR_RECORD mode first. + */ +public class VCRCassetteMissingException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final String cassetteKey; + private final String testId; + + /** + * Creates a new exception. + * + * @param cassetteKey the key that was not found + * @param testId the test identifier + */ + public VCRCassetteMissingException(String cassetteKey, String testId) { + super( + String.format( + "VCR cassette not found for test '%s'%nCassette key: %s%n" + + "Run with VCRMode.RECORD or VCRMode.PLAYBACK_OR_RECORD to record this interaction", + testId, cassetteKey)); + this.cassetteKey = cassetteKey; + this.testId = testId; + } + + /** + * Gets the cassette key that was not found. + * + * @return the cassette key + */ + public String getCassetteKey() { + return cassetteKey; + } + + /** + * Gets the test identifier. + * + * @return the test ID + */ + public String getTestId() { + return testId; + } +} diff --git a/core/src/main/java/com/redis/vl/test/vcr/VCRCassetteStore.java b/core/src/main/java/com/redis/vl/test/vcr/VCRCassetteStore.java new file mode 100644 index 0000000..f7e9ead --- /dev/null +++ b/core/src/main/java/com/redis/vl/test/vcr/VCRCassetteStore.java @@ -0,0 +1,249 @@ +package com.redis.vl.test.vcr; + +import com.google.gson.Gson; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.Objects; +import redis.clients.jedis.JedisPooled; +import redis.clients.jedis.json.Path2; + +/** + * Stores and retrieves VCR cassettes (recorded API responses) in Redis. + * + *

Cassettes are stored as Redis JSON documents with the following key format: + * + *

vcr:{type}:{testId}:{callIndex}
+ * + * Where: + * + *
    + *
  • {type} - The type of cassette (e.g., "embedding", "llm", "chat") + *
  • {testId} - The unique test identifier (e.g., "MyTest.testMethod") + *
  • {callIndex} - Zero-padded call index within the test (e.g., "0001") + *
+ */ +public class VCRCassetteStore { + + private static final String KEY_PREFIX = "vcr"; + private static final Gson GSON = new Gson(); + + private final JedisPooled jedis; + + /** + * Creates a new cassette store. + * + * @param jedis the Redis client + */ + @SuppressFBWarnings( + value = "EI_EXPOSE_REP2", + justification = "JedisPooled is intentionally shared for connection pooling") + public VCRCassetteStore(JedisPooled jedis) { + this.jedis = jedis; + } + + /** + * Formats a cassette key. + * + * @param type the cassette type + * @param testId the test identifier + * @param callIndex the call index (1-based) + * @return the formatted key + */ + public static String formatKey(String type, String testId, int callIndex) { + return String.format("%s:%s:%s:%04d", KEY_PREFIX, type, testId, callIndex); + } + + /** + * Parses a cassette key into its components. + * + * @param key the key to parse + * @return array of [prefix, type, testId, callIndex] or null if invalid + */ + public static String[] parseKey(String key) { + if (key == null) { + return null; + } + String[] parts = key.split(":"); + if (parts.length != 4 || !KEY_PREFIX.equals(parts[0])) { + return null; + } + return parts; + } + + /** + * Creates a cassette JSON object for an embedding. + * + * @param embedding the embedding vector + * @param testId the test identifier + * @param model the model name + * @return the cassette JSON object + */ + public static JsonObject createEmbeddingCassette(float[] embedding, String testId, String model) { + Objects.requireNonNull(embedding, "embedding cannot be null"); + Objects.requireNonNull(testId, "testId cannot be null"); + Objects.requireNonNull(model, "model cannot be null"); + + JsonObject cassette = new JsonObject(); + cassette.addProperty("type", "embedding"); + cassette.addProperty("testId", testId); + cassette.addProperty("model", model); + cassette.addProperty("timestamp", System.currentTimeMillis()); + + JsonArray embeddingArray = new JsonArray(); + for (float value : embedding) { + embeddingArray.add(value); + } + cassette.add("embedding", embeddingArray); + + return cassette; + } + + /** + * Creates a cassette JSON object for batch embeddings. + * + * @param embeddings the embedding vectors + * @param testId the test identifier + * @param model the model name + * @return the cassette JSON object + */ + public static JsonObject createBatchEmbeddingCassette( + float[][] embeddings, String testId, String model) { + Objects.requireNonNull(embeddings, "embeddings cannot be null"); + Objects.requireNonNull(testId, "testId cannot be null"); + Objects.requireNonNull(model, "model cannot be null"); + + JsonObject cassette = new JsonObject(); + cassette.addProperty("type", "batch_embedding"); + cassette.addProperty("testId", testId); + cassette.addProperty("model", model); + cassette.addProperty("timestamp", System.currentTimeMillis()); + + JsonArray embeddingsArray = new JsonArray(); + for (float[] embedding : embeddings) { + JsonArray embeddingArray = new JsonArray(); + for (float value : embedding) { + embeddingArray.add(value); + } + embeddingsArray.add(embeddingArray); + } + cassette.add("embeddings", embeddingsArray); + + return cassette; + } + + /** + * Extracts embedding from a cassette JSON object. + * + * @param cassette the cassette object + * @return the embedding vector or null if not present + */ + public static float[] extractEmbedding(JsonObject cassette) { + if (cassette == null || !cassette.has("embedding")) { + return null; + } + + JsonArray embeddingArray = cassette.getAsJsonArray("embedding"); + float[] embedding = new float[embeddingArray.size()]; + for (int i = 0; i < embeddingArray.size(); i++) { + embedding[i] = embeddingArray.get(i).getAsFloat(); + } + return embedding; + } + + /** + * Extracts batch embeddings from a cassette JSON object. + * + * @param cassette the cassette object + * @return the embedding vectors or null if not present + */ + public static float[][] extractBatchEmbeddings(JsonObject cassette) { + if (cassette == null || !cassette.has("embeddings")) { + return null; + } + + JsonArray embeddingsArray = cassette.getAsJsonArray("embeddings"); + float[][] embeddings = new float[embeddingsArray.size()][]; + + for (int i = 0; i < embeddingsArray.size(); i++) { + JsonArray embeddingArray = embeddingsArray.get(i).getAsJsonArray(); + embeddings[i] = new float[embeddingArray.size()]; + for (int j = 0; j < embeddingArray.size(); j++) { + embeddings[i][j] = embeddingArray.get(j).getAsFloat(); + } + } + return embeddings; + } + + /** + * Stores a cassette in Redis. + * + * @param key the cassette key + * @param cassette the cassette data + */ + public void store(String key, JsonObject cassette) { + if (jedis == null) { + throw new IllegalStateException("Redis client not initialized"); + } + jedis.jsonSet(key, Path2.ROOT_PATH, GSON.toJson(cassette)); + } + + /** + * Retrieves a cassette from Redis. + * + * @param key the cassette key + * @return the cassette data or null if not found + */ + public JsonObject retrieve(String key) { + if (jedis == null) { + return null; + } + Object result = jedis.jsonGet(key, Path2.ROOT_PATH); + if (result == null) { + return null; + } + + // Handle both array and object responses from jsonGet + // Some Jedis versions/configurations return arrays when using ROOT_PATH + String jsonString = result.toString(); + com.google.gson.JsonElement element = + GSON.fromJson(jsonString, com.google.gson.JsonElement.class); + + if (element.isJsonArray()) { + com.google.gson.JsonArray array = element.getAsJsonArray(); + if (array.isEmpty()) { + return null; + } + // Return the first element if it's wrapped in an array + return array.get(0).getAsJsonObject(); + } else if (element.isJsonObject()) { + return element.getAsJsonObject(); + } + + return null; + } + + /** + * Checks if a cassette exists. + * + * @param key the cassette key + * @return true if the cassette exists + */ + public boolean exists(String key) { + if (jedis == null) { + return false; + } + return jedis.exists(key); + } + + /** + * Deletes a cassette. + * + * @param key the cassette key + */ + public void delete(String key) { + if (jedis != null) { + jedis.del(key); + } + } +} diff --git a/core/src/test/java/com/redis/vl/test/vcr/VCRCassetteStoreTest.java b/core/src/test/java/com/redis/vl/test/vcr/VCRCassetteStoreTest.java new file mode 100644 index 0000000..71af7fc --- /dev/null +++ b/core/src/test/java/com/redis/vl/test/vcr/VCRCassetteStoreTest.java @@ -0,0 +1,193 @@ +package com.redis.vl.test.vcr; + +import static org.junit.jupiter.api.Assertions.*; + +import com.google.gson.JsonObject; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for VCRCassetteStore. Tests the cassette storage abstraction without requiring a Redis + * instance. + */ +@DisplayName("VCRCassetteStore") +class VCRCassetteStoreTest { + + @Nested + @DisplayName("Cassette Key Format") + class CassetteKeyFormat { + + @Test + @DisplayName("should generate proper cassette key format") + void shouldGenerateProperKeyFormat() { + String key = VCRCassetteStore.formatKey("embedding", "MyTest.testMethod", 1); + assertEquals("vcr:embedding:MyTest.testMethod:0001", key); + } + + @Test + @DisplayName("should pad call index with zeros") + void shouldPadCallIndex() { + assertEquals("vcr:llm:Test.method:0001", VCRCassetteStore.formatKey("llm", "Test.method", 1)); + assertEquals( + "vcr:llm:Test.method:0042", VCRCassetteStore.formatKey("llm", "Test.method", 42)); + assertEquals( + "vcr:llm:Test.method:9999", VCRCassetteStore.formatKey("llm", "Test.method", 9999)); + } + + @Test + @DisplayName("should handle different cassette types") + void shouldHandleDifferentTypes() { + assertEquals("vcr:llm:Test:0001", VCRCassetteStore.formatKey("llm", "Test", 1)); + assertEquals("vcr:embedding:Test:0001", VCRCassetteStore.formatKey("embedding", "Test", 1)); + assertEquals("vcr:chat:Test:0001", VCRCassetteStore.formatKey("chat", "Test", 1)); + } + + @Test + @DisplayName("should parse key components") + void shouldParseKeyComponents() { + String key = "vcr:embedding:MyTest.testMethod:0042"; + String[] parts = VCRCassetteStore.parseKey(key); + + assertEquals(4, parts.length); + assertEquals("vcr", parts[0]); + assertEquals("embedding", parts[1]); + assertEquals("MyTest.testMethod", parts[2]); + assertEquals("0042", parts[3]); + } + } + + @Nested + @DisplayName("Cassette Data Serialization") + class CassetteDataSerialization { + + @Test + @DisplayName("should serialize embedding data") + void shouldSerializeEmbeddingData() { + float[] embedding = new float[] {0.1f, 0.2f, 0.3f, 0.4f, 0.5f}; + String testId = "TestClass.testMethod"; + + JsonObject cassette = VCRCassetteStore.createEmbeddingCassette(embedding, testId, "model-1"); + + assertEquals("embedding", cassette.get("type").getAsString()); + assertEquals(testId, cassette.get("testId").getAsString()); + assertEquals("model-1", cassette.get("model").getAsString()); + assertEquals(5, cassette.getAsJsonArray("embedding").size()); + } + + @Test + @DisplayName("should deserialize embedding data") + void shouldDeserializeEmbeddingData() { + float[] original = new float[] {0.1f, 0.2f, 0.3f, 0.4f, 0.5f}; + JsonObject cassette = + VCRCassetteStore.createEmbeddingCassette(original, "test", "all-minilm-l6-v2"); + + float[] retrieved = VCRCassetteStore.extractEmbedding(cassette); + + assertNotNull(retrieved); + assertEquals(original.length, retrieved.length); + for (int i = 0; i < original.length; i++) { + assertEquals(original[i], retrieved[i], 0.0001f); + } + } + + @Test + @DisplayName("should serialize batch embedding data") + void shouldSerializeBatchEmbeddingData() { + float[][] embeddings = + new float[][] {{0.1f, 0.2f, 0.3f}, {0.4f, 0.5f, 0.6f}, {0.7f, 0.8f, 0.9f}}; + + JsonObject cassette = + VCRCassetteStore.createBatchEmbeddingCassette(embeddings, "test", "model-1"); + + assertEquals("batch_embedding", cassette.get("type").getAsString()); + assertEquals(3, cassette.getAsJsonArray("embeddings").size()); + } + + @Test + @DisplayName("should deserialize batch embedding data") + void shouldDeserializeBatchEmbeddingData() { + float[][] original = + new float[][] {{0.1f, 0.2f, 0.3f}, {0.4f, 0.5f, 0.6f}, {0.7f, 0.8f, 0.9f}}; + + JsonObject cassette = + VCRCassetteStore.createBatchEmbeddingCassette(original, "test", "model-1"); + float[][] retrieved = VCRCassetteStore.extractBatchEmbeddings(cassette); + + assertNotNull(retrieved); + assertEquals(original.length, retrieved.length); + for (int i = 0; i < original.length; i++) { + assertArrayEquals(original[i], retrieved[i], 0.0001f); + } + } + + @Test + @DisplayName("should handle empty embedding array") + void shouldHandleEmptyEmbeddingArray() { + float[] empty = new float[0]; + JsonObject cassette = VCRCassetteStore.createEmbeddingCassette(empty, "test", "model"); + + float[] retrieved = VCRCassetteStore.extractEmbedding(cassette); + assertNotNull(retrieved); + assertEquals(0, retrieved.length); + } + + @Test + @DisplayName("should include metadata in cassette") + void shouldIncludeMetadata() { + float[] embedding = new float[] {0.1f, 0.2f}; + JsonObject cassette = + VCRCassetteStore.createEmbeddingCassette(embedding, "test", "all-minilm-l6-v2"); + + assertTrue(cassette.has("timestamp")); + assertTrue(cassette.get("timestamp").getAsLong() > 0); + } + } + + @Nested + @DisplayName("Null and Edge Cases") + class NullAndEdgeCases { + + @Test + @DisplayName("should throw on null embedding") + void shouldThrowOnNullEmbedding() { + assertThrows( + NullPointerException.class, + () -> VCRCassetteStore.createEmbeddingCassette(null, "test", "model")); + } + + @Test + @DisplayName("should throw on null testId") + void shouldThrowOnNullTestId() { + assertThrows( + NullPointerException.class, + () -> VCRCassetteStore.createEmbeddingCassette(new float[] {0.1f}, null, "model")); + } + + @Test + @DisplayName("should throw on null model") + void shouldThrowOnNullModel() { + assertThrows( + NullPointerException.class, + () -> VCRCassetteStore.createEmbeddingCassette(new float[] {0.1f}, "test", null)); + } + + @Test + @DisplayName("should return null for invalid key format") + void shouldReturnNullForInvalidKey() { + String[] parts = VCRCassetteStore.parseKey("invalid"); + assertNull(parts); + } + + @Test + @DisplayName("should extract null from malformed cassette") + void shouldExtractNullFromMalformedCassette() { + JsonObject malformed = new JsonObject(); + malformed.addProperty("type", "embedding"); + // Missing embedding field + + float[] result = VCRCassetteStore.extractEmbedding(malformed); + assertNull(result); + } + } +} From 4917e6d0f65fd90e7d78523be862864c278cf532 Mon Sep 17 00:00:00 2001 From: Brian Sam-Bodden Date: Sat, 13 Dec 2025 09:31:49 -0700 Subject: [PATCH 03/12] feat(vcr): add LangChain4J model wrappers for VCR Implements VCR wrappers for LangChain4J models: - VCREmbeddingModel: wraps EmbeddingModel for recording/replaying embeddings - VCRChatModel: wraps ChatLanguageModel for recording/replaying chat responses - VCREmbeddingInterceptor: low-level interceptor for embedding operations Features: - Support for all VCR modes (PLAYBACK, RECORD, PLAYBACK_OR_RECORD, OFF) - Redis-backed cassette storage integration - In-memory cassette cache for testing - Call counter tracking for unique cassette keys - Statistics tracking (cache hits, misses, recorded count) Unit tests verify recording and playback behavior. --- .../com/redis/vl/test/vcr/VCRChatModel.java | 264 ++++++++++++ .../vl/test/vcr/VCREmbeddingInterceptor.java | 334 ++++++++++++++++ .../redis/vl/test/vcr/VCREmbeddingModel.java | 376 ++++++++++++++++++ .../redis/vl/test/vcr/VCRChatModelTest.java | 303 ++++++++++++++ .../test/vcr/VCREmbeddingInterceptorTest.java | 258 ++++++++++++ .../vl/test/vcr/VCREmbeddingModelTest.java | 312 +++++++++++++++ 6 files changed, 1847 insertions(+) create mode 100644 core/src/main/java/com/redis/vl/test/vcr/VCRChatModel.java create mode 100644 core/src/main/java/com/redis/vl/test/vcr/VCREmbeddingInterceptor.java create mode 100644 core/src/main/java/com/redis/vl/test/vcr/VCREmbeddingModel.java create mode 100644 core/src/test/java/com/redis/vl/test/vcr/VCRChatModelTest.java create mode 100644 core/src/test/java/com/redis/vl/test/vcr/VCREmbeddingInterceptorTest.java create mode 100644 core/src/test/java/com/redis/vl/test/vcr/VCREmbeddingModelTest.java diff --git a/core/src/main/java/com/redis/vl/test/vcr/VCRChatModel.java b/core/src/main/java/com/redis/vl/test/vcr/VCRChatModel.java new file mode 100644 index 0000000..1a36b35 --- /dev/null +++ b/core/src/main/java/com/redis/vl/test/vcr/VCRChatModel.java @@ -0,0 +1,264 @@ +package com.redis.vl.test.vcr; + +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.model.chat.ChatLanguageModel; +import dev.langchain4j.model.output.Response; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * VCR wrapper for LangChain4J ChatLanguageModel that records and replays LLM responses. + * + *

This class implements the ChatLanguageModel interface, allowing it to be used as a drop-in + * replacement for any LangChain4J chat model. It provides VCR (Video Cassette Recorder) + * functionality to record LLM responses during test execution and replay them in subsequent runs. + * + *

Usage: + * + *

{@code
+ * ChatLanguageModel openAiModel = OpenAiChatModel.builder()
+ *     .apiKey(System.getenv("OPENAI_API_KEY"))
+ *     .build();
+ *
+ * VCRChatModel vcrModel = new VCRChatModel(openAiModel);
+ * vcrModel.setMode(VCRMode.PLAYBACK_OR_RECORD);
+ * vcrModel.setTestId("MyTest.testMethod");
+ *
+ * // Use exactly like the original model
+ * Response response = vcrModel.generate(UserMessage.from("Hello"));
+ * }
+ */ +@SuppressFBWarnings( + value = "EI_EXPOSE_REP2", + justification = "Delegate is intentionally stored and exposed for VCR functionality") +public final class VCRChatModel implements ChatLanguageModel { + + private final ChatLanguageModel delegate; + private VCRCassetteStore cassetteStore; + private VCRMode mode = VCRMode.PLAYBACK_OR_RECORD; + private String testId = "unknown"; + private final AtomicInteger callCounter = new AtomicInteger(0); + + // In-memory cassette storage for unit tests + private final Map cassettes = new HashMap<>(); + + // Statistics + private int cacheHits = 0; + private int cacheMisses = 0; + private int recordedCount = 0; + + /** + * Creates a new VCRChatModel wrapping the given delegate. + * + * @param delegate The actual ChatLanguageModel to wrap + */ + public VCRChatModel(ChatLanguageModel delegate) { + this.delegate = delegate; + } + + /** + * Creates a new VCRChatModel wrapping the given delegate with Redis storage. + * + * @param delegate The actual ChatLanguageModel to wrap + * @param cassetteStore The cassette store for persistence + */ + @SuppressFBWarnings( + value = "EI_EXPOSE_REP2", + justification = "VCRCassetteStore is intentionally shared") + public VCRChatModel(ChatLanguageModel delegate, VCRCassetteStore cassetteStore) { + this.delegate = delegate; + this.cassetteStore = cassetteStore; + } + + /** + * Sets the VCR mode. + * + * @param mode The VCR mode to use + */ + public void setMode(VCRMode mode) { + this.mode = mode; + } + + /** + * Gets the current VCR mode. + * + * @return The current VCR mode + */ + public VCRMode getMode() { + return mode; + } + + /** + * Sets the test identifier for cassette key generation. + * + * @param testId The test identifier (typically ClassName.methodName) + */ + public void setTestId(String testId) { + this.testId = testId; + } + + /** + * Gets the current test identifier. + * + * @return The current test identifier + */ + public String getTestId() { + return testId; + } + + /** Resets the call counter. Useful when starting a new test method. */ + public void resetCallCounter() { + callCounter.set(0); + } + + /** + * Gets the underlying delegate model. + * + * @return The wrapped ChatLanguageModel + */ + @SuppressFBWarnings( + value = "EI_EXPOSE_REP", + justification = "Intentional exposure of delegate for advanced use cases") + public ChatLanguageModel getDelegate() { + return delegate; + } + + /** + * Preloads a cassette for testing purposes. + * + * @param key The cassette key + * @param response The response text to cache + */ + public void preloadCassette(String key, String response) { + cassettes.put(key, response); + } + + /** + * Gets the number of cache hits. + * + * @return Cache hit count + */ + public int getCacheHits() { + return cacheHits; + } + + /** + * Gets the number of cache misses. + * + * @return Cache miss count + */ + public int getCacheMisses() { + return cacheMisses; + } + + /** + * Gets the number of recorded responses. + * + * @return Recorded count + */ + public int getRecordedCount() { + return recordedCount; + } + + /** Resets all statistics. */ + public void resetStatistics() { + cacheHits = 0; + cacheMisses = 0; + recordedCount = 0; + } + + @Override + public Response generate(List messages) { + return generateInternal(messages); + } + + @Override + public String generate(String userMessage) { + Response response = generateInternal(userMessage); + return response.content().text(); + } + + private Response generateInternal(Object input) { + if (mode == VCRMode.OFF) { + return callDelegate(input); + } + + String key = formatKey(); + + if (mode.isPlaybackMode()) { + String cached = loadCassette(key); + if (cached != null) { + cacheHits++; + return Response.from(AiMessage.from(cached)); + } + + if (mode == VCRMode.PLAYBACK) { + throw new VCRCassetteMissingException(key, testId); + } + + // PLAYBACK_OR_RECORD - fall through to record + } + + // Record mode or cache miss in PLAYBACK_OR_RECORD + cacheMisses++; + Response response = callDelegate(input); + String responseText = response.content().text(); + saveCassette(key, responseText); + recordedCount++; + + return response; + } + + private String loadCassette(String key) { + // Check in-memory first + String inMemory = cassettes.get(key); + if (inMemory != null) { + return inMemory; + } + + // Check Redis if available + if (cassetteStore != null) { + com.google.gson.JsonObject cassette = cassetteStore.retrieve(key); + if (cassette != null && cassette.has("response")) { + return cassette.get("response").getAsString(); + } + } + + return null; + } + + private void saveCassette(String key, String response) { + // Save to in-memory + cassettes.put(key, response); + + // Save to Redis if available + if (cassetteStore != null) { + com.google.gson.JsonObject cassette = new com.google.gson.JsonObject(); + cassette.addProperty("response", response); + cassette.addProperty("testId", testId); + cassette.addProperty("type", "chat"); + cassetteStore.store(key, cassette); + } + } + + @SuppressWarnings("unchecked") + private Response callDelegate(Object input) { + if (input instanceof List) { + return delegate.generate((List) input); + } else if (input instanceof String) { + String text = delegate.generate((String) input); + return Response.from(AiMessage.from(text)); + } else { + throw new IllegalArgumentException("Unsupported input type: " + input.getClass()); + } + } + + private String formatKey() { + int index = callCounter.incrementAndGet(); + return String.format("vcr:chat:%s:%04d", testId, index); + } +} diff --git a/core/src/main/java/com/redis/vl/test/vcr/VCREmbeddingInterceptor.java b/core/src/main/java/com/redis/vl/test/vcr/VCREmbeddingInterceptor.java new file mode 100644 index 0000000..66f0ce9 --- /dev/null +++ b/core/src/main/java/com/redis/vl/test/vcr/VCREmbeddingInterceptor.java @@ -0,0 +1,334 @@ +package com.redis.vl.test.vcr; + +import com.google.gson.JsonObject; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Intercepts embedding calls for VCR recording and playback. + * + *

This interceptor captures embedding API calls during test recording and replays them during + * playback, enabling deterministic and fast tests without actual API calls. + * + *

Usage: + * + *

{@code
+ * VCREmbeddingInterceptor interceptor = new VCREmbeddingInterceptor(cassetteStore);
+ * interceptor.setMode(VCRMode.PLAYBACK_OR_RECORD);
+ * interceptor.setTestId("MyTest.testMethod");
+ *
+ * // In your test
+ * float[] embedding = interceptor.embed("text to embed");
+ * }
+ */ +public abstract class VCREmbeddingInterceptor { + + private static final String CASSETTE_TYPE = "embedding"; + + private VCRMode mode = VCRMode.OFF; + private String testId; + private String modelName = "default"; + private final AtomicInteger callCounter = new AtomicInteger(0); + + // In-memory cassette storage for unit tests (null = use Redis) + private VCRCassetteStore cassetteStore; + private final Map inMemoryCassettes = new ConcurrentHashMap<>(); + private final Map inMemoryBatchCassettes = new ConcurrentHashMap<>(); + private final List recordedKeys = new ArrayList<>(); + + // Statistics + private final AtomicLong cacheHits = new AtomicLong(0); + private final AtomicLong cacheMisses = new AtomicLong(0); + + /** Creates a new interceptor without Redis (for unit testing). */ + protected VCREmbeddingInterceptor() { + this.cassetteStore = null; + } + + /** + * Creates a new interceptor with Redis cassette storage. + * + * @param cassetteStore the cassette store + */ + @SuppressFBWarnings( + value = "EI_EXPOSE_REP2", + justification = "VCRCassetteStore is intentionally shared for Redis connection pooling") + public VCREmbeddingInterceptor(VCRCassetteStore cassetteStore) { + this.cassetteStore = cassetteStore; + } + + /** + * Sets the VCR mode. + * + * @param mode the mode + */ + public void setMode(VCRMode mode) { + this.mode = mode; + } + + /** + * Gets the current VCR mode. + * + * @return the mode + */ + public VCRMode getMode() { + return mode; + } + + /** + * Sets the current test identifier. + * + * @param testId the test ID + */ + public void setTestId(String testId) { + this.testId = testId; + this.callCounter.set(0); + } + + /** + * Sets the model name for cassette metadata. + * + * @param modelName the model name + */ + public void setModelName(String modelName) { + this.modelName = modelName; + } + + /** + * Generates a single embedding, intercepting based on VCR mode. + * + * @param text the text to embed + * @return the embedding vector + */ + public float[] embed(String text) { + if (mode == VCRMode.OFF) { + return callRealEmbedding(text); + } + + String cassetteKey = generateCassetteKey(); + + // Try to load from cache in playback modes + if (mode.isPlaybackMode()) { + float[] cached = loadCassette(cassetteKey); + if (cached != null) { + cacheHits.incrementAndGet(); + return cached; + } + + // Strict playback mode - throw if not found + if (mode == VCRMode.PLAYBACK) { + throw new VCRCassetteMissingException(cassetteKey, testId); + } + } + + // Cache miss or record mode - call real API + cacheMisses.incrementAndGet(); + float[] embedding = callRealEmbedding(text); + + // Record if in recording mode + if (mode.isRecordMode() || mode == VCRMode.PLAYBACK_OR_RECORD) { + saveCassette(cassetteKey, embedding); + } + + return embedding; + } + + /** + * Generates batch embeddings, intercepting based on VCR mode. + * + * @param texts the texts to embed + * @return list of embedding vectors + */ + public List embedBatch(List texts) { + if (mode == VCRMode.OFF) { + return callRealBatchEmbedding(texts); + } + + String cassetteKey = generateCassetteKey(); + + // Try to load from cache in playback modes + if (mode.isPlaybackMode()) { + float[][] cached = loadBatchCassette(cassetteKey); + if (cached != null) { + cacheHits.incrementAndGet(); + List result = new ArrayList<>(); + for (float[] embedding : cached) { + result.add(embedding); + } + return result; + } + + // Strict playback mode - throw if not found + if (mode == VCRMode.PLAYBACK) { + throw new VCRCassetteMissingException(cassetteKey, testId); + } + } + + // Cache miss or record mode - call real API + cacheMisses.incrementAndGet(); + List embeddings = callRealBatchEmbedding(texts); + + // Record if in recording mode + if (mode.isRecordMode() || mode == VCRMode.PLAYBACK_OR_RECORD) { + saveBatchCassette(cassetteKey, embeddings.toArray(new float[0][])); + } + + return embeddings; + } + + /** + * Preloads a cassette into the in-memory cache (for testing). + * + * @param key the cassette key + * @param embedding the embedding to cache + */ + public void preloadCassette(String key, float[] embedding) { + inMemoryCassettes.put(key, embedding); + } + + /** + * Preloads a batch cassette into the in-memory cache (for testing). + * + * @param key the cassette key + * @param embeddings the embeddings to cache + */ + public void preloadBatchCassette(String key, float[][] embeddings) { + inMemoryBatchCassettes.put(key, embeddings); + } + + /** + * Gets the number of recorded cassettes. + * + * @return the count + */ + public int getRecordedCount() { + return recordedKeys.size(); + } + + /** + * Gets the recorded cassette keys. + * + * @return list of keys + */ + public List getRecordedKeys() { + return new ArrayList<>(recordedKeys); + } + + /** + * Gets the cache hit count. + * + * @return number of cache hits + */ + public long getCacheHits() { + return cacheHits.get(); + } + + /** + * Gets the cache miss count. + * + * @return number of cache misses + */ + public long getCacheMisses() { + return cacheMisses.get(); + } + + /** Resets statistics. */ + public void resetStatistics() { + cacheHits.set(0); + cacheMisses.set(0); + } + + /** Resets the call counter (call at start of each test). */ + public void resetCallCounter() { + callCounter.set(0); + } + + /** + * Called to perform the actual embedding call. Subclasses must implement this. + * + * @param text the text to embed + * @return the embedding vector + */ + protected abstract float[] callRealEmbedding(String text); + + /** + * Called to perform actual batch embedding call. Subclasses must implement this. + * + * @param texts the texts to embed + * @return list of embedding vectors + */ + protected abstract List callRealBatchEmbedding(List texts); + + private String generateCassetteKey() { + int index = callCounter.incrementAndGet(); + return VCRCassetteStore.formatKey(CASSETTE_TYPE, testId, index); + } + + private float[] loadCassette(String key) { + // Check in-memory first + float[] inMemory = inMemoryCassettes.get(key); + if (inMemory != null) { + return inMemory; + } + + // Check Redis + if (cassetteStore != null) { + JsonObject cassette = cassetteStore.retrieve(key); + if (cassette != null) { + return VCRCassetteStore.extractEmbedding(cassette); + } + } + + return null; + } + + private float[][] loadBatchCassette(String key) { + // Check in-memory first + float[][] inMemory = inMemoryBatchCassettes.get(key); + if (inMemory != null) { + return inMemory; + } + + // Check Redis + if (cassetteStore != null) { + JsonObject cassette = cassetteStore.retrieve(key); + if (cassette != null) { + return VCRCassetteStore.extractBatchEmbeddings(cassette); + } + } + + return null; + } + + private void saveCassette(String key, float[] embedding) { + recordedKeys.add(key); + + // Save to in-memory + inMemoryCassettes.put(key, embedding); + + // Save to Redis if available + if (cassetteStore != null) { + JsonObject cassette = VCRCassetteStore.createEmbeddingCassette(embedding, testId, modelName); + cassetteStore.store(key, cassette); + } + } + + private void saveBatchCassette(String key, float[][] embeddings) { + recordedKeys.add(key); + + // Save to in-memory + inMemoryBatchCassettes.put(key, embeddings); + + // Save to Redis if available + if (cassetteStore != null) { + JsonObject cassette = + VCRCassetteStore.createBatchEmbeddingCassette(embeddings, testId, modelName); + cassetteStore.store(key, cassette); + } + } +} diff --git a/core/src/main/java/com/redis/vl/test/vcr/VCREmbeddingModel.java b/core/src/main/java/com/redis/vl/test/vcr/VCREmbeddingModel.java new file mode 100644 index 0000000..1391dd7 --- /dev/null +++ b/core/src/main/java/com/redis/vl/test/vcr/VCREmbeddingModel.java @@ -0,0 +1,376 @@ +package com.redis.vl.test.vcr; + +import com.google.gson.JsonObject; +import dev.langchain4j.data.embedding.Embedding; +import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.model.embedding.EmbeddingModel; +import dev.langchain4j.model.output.Response; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.Collectors; + +/** + * VCR-enabled wrapper around a LangChain4J EmbeddingModel. + * + *

This wrapper intercepts embedding calls and routes them through the VCR system for recording + * and playback. + * + *

Usage: + * + *

{@code
+ * // In your test
+ * EmbeddingModel realModel = new AllMiniLmL6V2EmbeddingModel();
+ * VCREmbeddingModel vcrModel = new VCREmbeddingModel(realModel);
+ * vcrModel.setMode(VCRMode.PLAYBACK_OR_RECORD);
+ * vcrModel.setTestId("MyTest.testMethod");
+ *
+ * // Use vcrModel instead of realModel
+ * Response response = vcrModel.embed("text");
+ * }
+ */ +public class VCREmbeddingModel implements EmbeddingModel { + + private static final String CASSETTE_TYPE = "embedding"; + + private final EmbeddingModel delegate; + private final int dimensions; + + private VCRMode mode = VCRMode.OFF; + private String testId; + private String modelName = "default"; + private final AtomicInteger callCounter = new AtomicInteger(0); + + // In-memory cassette storage for unit tests (null = use Redis) + private VCRCassetteStore cassetteStore; + private final Map inMemoryCassettes = new ConcurrentHashMap<>(); + private final Map inMemoryBatchCassettes = new ConcurrentHashMap<>(); + private final List recordedKeys = new ArrayList<>(); + + // Statistics + private final AtomicLong cacheHits = new AtomicLong(0); + private final AtomicLong cacheMisses = new AtomicLong(0); + + /** + * Creates a VCR-enabled embedding model wrapper. + * + * @param delegate the real embedding model + */ + @SuppressFBWarnings( + value = "EI_EXPOSE_REP2", + justification = "EmbeddingModel delegate is intentionally shared") + public VCREmbeddingModel(EmbeddingModel delegate) { + this.delegate = delegate; + this.dimensions = detectDimensions(delegate); + } + + /** + * Creates a VCR-enabled embedding model wrapper with Redis storage. + * + * @param delegate the real embedding model + * @param cassetteStore the cassette store + */ + @SuppressFBWarnings( + value = "EI_EXPOSE_REP2", + justification = "EmbeddingModel and VCRCassetteStore are intentionally shared") + public VCREmbeddingModel(EmbeddingModel delegate, VCRCassetteStore cassetteStore) { + this.delegate = delegate; + this.cassetteStore = cassetteStore; + this.dimensions = detectDimensions(delegate); + } + + /** + * Sets the VCR mode. + * + * @param mode the mode + */ + public void setMode(VCRMode mode) { + this.mode = mode; + } + + /** + * Gets the current VCR mode. + * + * @return the mode + */ + public VCRMode getMode() { + return mode; + } + + /** + * Sets the current test identifier. + * + * @param testId the test ID + */ + public void setTestId(String testId) { + this.testId = testId; + this.callCounter.set(0); + } + + /** + * Sets the model name for cassette metadata. + * + * @param modelName the model name + */ + public void setModelName(String modelName) { + this.modelName = modelName; + } + + /** Resets the call counter (call at start of each test). */ + public void resetCallCounter() { + callCounter.set(0); + } + + @Override + public Response embed(String text) { + float[] embedding = embedInternal(text); + return Response.from(Embedding.from(embedding)); + } + + @Override + public Response embed(TextSegment textSegment) { + return embed(textSegment.text()); + } + + @Override + public Response> embedAll(List textSegments) { + List texts = textSegments.stream().map(TextSegment::text).collect(Collectors.toList()); + + List embeddings = embedBatchInternal(texts); + + List result = embeddings.stream().map(Embedding::from).collect(Collectors.toList()); + + return Response.from(result); + } + + @Override + public int dimension() { + return dimensions; + } + + /** + * Gets the cache hit count. + * + * @return number of cache hits + */ + public long getCacheHits() { + return cacheHits.get(); + } + + /** + * Gets the cache miss count. + * + * @return number of cache misses + */ + public long getCacheMisses() { + return cacheMisses.get(); + } + + /** Resets statistics. */ + public void resetStatistics() { + cacheHits.set(0); + cacheMisses.set(0); + } + + /** + * Gets the number of recorded cassettes. + * + * @return the count + */ + public int getRecordedCount() { + return recordedKeys.size(); + } + + /** + * Gets the underlying delegate model. + * + * @return the delegate + */ + public EmbeddingModel getDelegate() { + return delegate; + } + + // Preload methods for testing + + /** + * Preloads a cassette into the in-memory cache (for testing). + * + * @param key the cassette key + * @param embedding the embedding to cache + */ + public void preloadCassette(String key, float[] embedding) { + inMemoryCassettes.put(key, embedding); + } + + /** + * Preloads a batch cassette into the in-memory cache (for testing). + * + * @param key the cassette key + * @param embeddings the embeddings to cache + */ + public void preloadBatchCassette(String key, float[][] embeddings) { + inMemoryBatchCassettes.put(key, embeddings); + } + + // Internal methods + + private float[] embedInternal(String text) { + if (mode == VCRMode.OFF) { + return delegate.embed(text).content().vector(); + } + + String cassetteKey = generateCassetteKey(); + + // Try to load from cache in playback modes + if (mode.isPlaybackMode()) { + float[] cached = loadCassette(cassetteKey); + if (cached != null) { + cacheHits.incrementAndGet(); + return cached; + } + + // Strict playback mode - throw if not found + if (mode == VCRMode.PLAYBACK) { + throw new VCRCassetteMissingException(cassetteKey, testId); + } + } + + // Cache miss or record mode - call real API + cacheMisses.incrementAndGet(); + float[] embedding = delegate.embed(text).content().vector(); + + // Record if in recording mode + if (mode.isRecordMode() || mode == VCRMode.PLAYBACK_OR_RECORD) { + saveCassette(cassetteKey, embedding); + } + + return embedding; + } + + private List embedBatchInternal(List texts) { + if (mode == VCRMode.OFF) { + List segments = + texts.stream().map(TextSegment::from).collect(Collectors.toList()); + return delegate.embedAll(segments).content().stream() + .map(Embedding::vector) + .collect(Collectors.toList()); + } + + String cassetteKey = generateCassetteKey(); + + // Try to load from cache in playback modes + if (mode.isPlaybackMode()) { + float[][] cached = loadBatchCassette(cassetteKey); + if (cached != null) { + cacheHits.incrementAndGet(); + List result = new ArrayList<>(); + for (float[] embedding : cached) { + result.add(embedding); + } + return result; + } + + // Strict playback mode - throw if not found + if (mode == VCRMode.PLAYBACK) { + throw new VCRCassetteMissingException(cassetteKey, testId); + } + } + + // Cache miss or record mode - call real API + cacheMisses.incrementAndGet(); + List segments = texts.stream().map(TextSegment::from).collect(Collectors.toList()); + List embeddings = + delegate.embedAll(segments).content().stream() + .map(Embedding::vector) + .collect(Collectors.toList()); + + // Record if in recording mode + if (mode.isRecordMode() || mode == VCRMode.PLAYBACK_OR_RECORD) { + saveBatchCassette(cassetteKey, embeddings.toArray(new float[0][])); + } + + return embeddings; + } + + private String generateCassetteKey() { + int index = callCounter.incrementAndGet(); + return VCRCassetteStore.formatKey(CASSETTE_TYPE, testId, index); + } + + private float[] loadCassette(String key) { + // Check in-memory first + float[] inMemory = inMemoryCassettes.get(key); + if (inMemory != null) { + return inMemory; + } + + // Check Redis + if (cassetteStore != null) { + JsonObject cassette = cassetteStore.retrieve(key); + if (cassette != null) { + return VCRCassetteStore.extractEmbedding(cassette); + } + } + + return null; + } + + private float[][] loadBatchCassette(String key) { + // Check in-memory first + float[][] inMemory = inMemoryBatchCassettes.get(key); + if (inMemory != null) { + return inMemory; + } + + // Check Redis + if (cassetteStore != null) { + JsonObject cassette = cassetteStore.retrieve(key); + if (cassette != null) { + return VCRCassetteStore.extractBatchEmbeddings(cassette); + } + } + + return null; + } + + private void saveCassette(String key, float[] embedding) { + recordedKeys.add(key); + + // Save to in-memory + inMemoryCassettes.put(key, embedding); + + // Save to Redis if available + if (cassetteStore != null) { + JsonObject cassette = VCRCassetteStore.createEmbeddingCassette(embedding, testId, modelName); + cassetteStore.store(key, cassette); + } + } + + private void saveBatchCassette(String key, float[][] embeddings) { + recordedKeys.add(key); + + // Save to in-memory + inMemoryBatchCassettes.put(key, embeddings); + + // Save to Redis if available + if (cassetteStore != null) { + JsonObject cassette = + VCRCassetteStore.createBatchEmbeddingCassette(embeddings, testId, modelName); + cassetteStore.store(key, cassette); + } + } + + private int detectDimensions(EmbeddingModel model) { + // Try to detect dimensions from the model + try { + return model.dimension(); + } catch (Exception e) { + // Some models don't implement dimension() + return -1; + } + } +} diff --git a/core/src/test/java/com/redis/vl/test/vcr/VCRChatModelTest.java b/core/src/test/java/com/redis/vl/test/vcr/VCRChatModelTest.java new file mode 100644 index 0000000..2b290f6 --- /dev/null +++ b/core/src/test/java/com/redis/vl/test/vcr/VCRChatModelTest.java @@ -0,0 +1,303 @@ +package com.redis.vl.test.vcr; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.ChatMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.chat.ChatLanguageModel; +import dev.langchain4j.model.output.Response; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for VCRChatModel. + * + *

These tests demonstrate standalone VCR usage with LangChain4J ChatLanguageModel, without + * requiring any RedisVL components. + */ +@DisplayName("VCRChatModel") +class VCRChatModelTest { + + private VCRChatModel vcrModel; + private MockChatLanguageModel mockDelegate; + + @BeforeEach + void setUp() { + mockDelegate = new MockChatLanguageModel(); + vcrModel = new VCRChatModel(mockDelegate); + vcrModel.setTestId("VCRChatModelTest.test"); + } + + @Nested + @DisplayName("OFF Mode - Direct passthrough") + class OffMode { + + @BeforeEach + void setUp() { + vcrModel.setMode(VCRMode.OFF); + } + + @Test + @DisplayName("should call delegate directly when VCR is OFF") + void shouldCallDelegateDirectly() { + Response response = vcrModel.generate(UserMessage.from("Hello")); + + assertNotNull(response); + assertNotNull(response.content()); + assertTrue(response.content().text().contains("Mock response")); + assertEquals(1, mockDelegate.callCount.get()); + } + + @Test + @DisplayName("should not record when VCR is OFF") + void shouldNotRecordWhenOff() { + vcrModel.generate(UserMessage.from("Hello")); + vcrModel.generate(UserMessage.from("World")); + + assertEquals(2, mockDelegate.callCount.get()); + assertEquals(0, vcrModel.getRecordedCount()); + } + } + + @Nested + @DisplayName("RECORD Mode") + class RecordMode { + + @BeforeEach + void setUp() { + vcrModel.setMode(VCRMode.RECORD); + } + + @Test + @DisplayName("should call delegate and record result") + void shouldCallDelegateAndRecord() { + Response response = vcrModel.generate(UserMessage.from("Test message")); + + assertNotNull(response); + assertEquals(1, mockDelegate.callCount.get()); + assertEquals(1, vcrModel.getRecordedCount()); + } + + @Test + @DisplayName("should record multiple calls with incrementing indices") + void shouldRecordMultipleCalls() { + vcrModel.generate(UserMessage.from("Message 1")); + vcrModel.generate(UserMessage.from("Message 2")); + vcrModel.generate(UserMessage.from("Message 3")); + + assertEquals(3, mockDelegate.callCount.get()); + assertEquals(3, vcrModel.getRecordedCount()); + } + } + + @Nested + @DisplayName("PLAYBACK Mode") + class PlaybackMode { + + @BeforeEach + void setUp() { + vcrModel.setMode(VCRMode.PLAYBACK); + } + + @Test + @DisplayName("should return cached response without calling delegate") + void shouldReturnCachedResponse() { + // Pre-load cassette + String cachedResponse = "This is a cached LLM response"; + vcrModel.preloadCassette("vcr:chat:VCRChatModelTest.test:0001", cachedResponse); + + Response response = vcrModel.generate(UserMessage.from("Test")); + + assertNotNull(response); + assertEquals(cachedResponse, response.content().text()); + assertEquals(0, mockDelegate.callCount.get()); + } + + @Test + @DisplayName("should throw when cassette not found in strict PLAYBACK mode") + void shouldThrowWhenCassetteNotFound() { + assertThrows( + VCRCassetteMissingException.class, () -> vcrModel.generate(UserMessage.from("Unknown"))); + } + + @Test + @DisplayName("should track cache hits") + void shouldTrackCacheHits() { + vcrModel.preloadCassette("vcr:chat:VCRChatModelTest.test:0001", "Cached"); + + vcrModel.generate(UserMessage.from("Test")); + + assertEquals(1, vcrModel.getCacheHits()); + assertEquals(0, vcrModel.getCacheMisses()); + } + } + + @Nested + @DisplayName("PLAYBACK_OR_RECORD Mode") + class PlaybackOrRecordMode { + + @BeforeEach + void setUp() { + vcrModel.setMode(VCRMode.PLAYBACK_OR_RECORD); + } + + @Test + @DisplayName("should return cached response when available") + void shouldReturnCachedWhenAvailable() { + String cachedResponse = "Cached LLM answer"; + vcrModel.preloadCassette("vcr:chat:VCRChatModelTest.test:0001", cachedResponse); + + Response response = vcrModel.generate(UserMessage.from("Test")); + + assertEquals(cachedResponse, response.content().text()); + assertEquals(0, mockDelegate.callCount.get()); + assertEquals(1, vcrModel.getCacheHits()); + } + + @Test + @DisplayName("should call delegate and record on cache miss") + void shouldCallDelegateAndRecordOnMiss() { + Response response = vcrModel.generate(UserMessage.from("Uncached")); + + assertNotNull(response); + assertEquals(1, mockDelegate.callCount.get()); + assertEquals(1, vcrModel.getRecordedCount()); + assertEquals(1, vcrModel.getCacheMisses()); + } + + @Test + @DisplayName("should allow subsequent cache hits after recording") + void shouldAllowSubsequentCacheHits() { + // First call - cache miss, records + vcrModel.generate(UserMessage.from("Question")); + assertEquals(1, mockDelegate.callCount.get()); + assertEquals(1, vcrModel.getCacheMisses()); + + // Reset counter for second call + vcrModel.resetCallCounter(); + + // Second call - cache hit from recorded value + vcrModel.generate(UserMessage.from("Question")); + assertEquals(1, mockDelegate.callCount.get()); // Still 1, not 2 + assertEquals(1, vcrModel.getCacheHits()); + } + } + + @Nested + @DisplayName("List of Messages") + class ListOfMessages { + + @Test + @DisplayName("should handle list of messages in RECORD mode") + void shouldHandleListOfMessagesInRecordMode() { + vcrModel.setMode(VCRMode.RECORD); + + List messages = List.of(UserMessage.from("First"), UserMessage.from("Second")); + + Response response = vcrModel.generate(messages); + + assertNotNull(response); + assertEquals(1, mockDelegate.callCount.get()); + assertEquals(1, vcrModel.getRecordedCount()); + } + + @Test + @DisplayName("should return cached response for list of messages") + void shouldReturnCachedForListOfMessages() { + vcrModel.setMode(VCRMode.PLAYBACK); + vcrModel.preloadCassette("vcr:chat:VCRChatModelTest.test:0001", "List cached response"); + + List messages = List.of(UserMessage.from("Hello")); + + Response response = vcrModel.generate(messages); + + assertEquals("List cached response", response.content().text()); + assertEquals(0, mockDelegate.callCount.get()); + } + } + + @Nested + @DisplayName("String Convenience Method") + class StringConvenienceMethod { + + @Test + @DisplayName("should handle string input in RECORD mode") + void shouldHandleStringInputInRecordMode() { + vcrModel.setMode(VCRMode.RECORD); + + String response = vcrModel.generate("Simple string input"); + + assertNotNull(response); + assertTrue(response.contains("Mock response")); + assertEquals(1, mockDelegate.callCount.get()); + assertEquals(1, vcrModel.getRecordedCount()); + } + + @Test + @DisplayName("should return cached response for string input") + void shouldReturnCachedForStringInput() { + vcrModel.setMode(VCRMode.PLAYBACK); + vcrModel.preloadCassette("vcr:chat:VCRChatModelTest.test:0001", "String cached response"); + + String response = vcrModel.generate("Test string"); + + assertEquals("String cached response", response); + assertEquals(0, mockDelegate.callCount.get()); + } + } + + @Nested + @DisplayName("Delegate Access") + class DelegateAccess { + + @Test + @DisplayName("should provide access to underlying delegate") + void shouldProvideAccessToDelegate() { + ChatLanguageModel delegate = vcrModel.getDelegate(); + + assertSame(mockDelegate, delegate); + } + } + + @Nested + @DisplayName("Statistics Reset") + class StatisticsReset { + + @Test + @DisplayName("should reset statistics") + void shouldResetStatistics() { + vcrModel.setMode(VCRMode.PLAYBACK_OR_RECORD); + vcrModel.generate(UserMessage.from("Test")); // Cache miss + + assertEquals(1, vcrModel.getCacheMisses()); + + vcrModel.resetStatistics(); + + assertEquals(0, vcrModel.getCacheHits()); + assertEquals(0, vcrModel.getCacheMisses()); + } + } + + /** Mock ChatLanguageModel for testing. */ + static class MockChatLanguageModel implements ChatLanguageModel { + AtomicInteger callCount = new AtomicInteger(0); + + @Override + public Response generate(List messages) { + callCount.incrementAndGet(); + String lastMessage = ""; + if (!messages.isEmpty()) { + ChatMessage last = messages.get(messages.size() - 1); + if (last instanceof UserMessage um) { + lastMessage = um.singleText(); + } + } + return Response.from(AiMessage.from("Mock response to: " + lastMessage)); + } + } +} diff --git a/core/src/test/java/com/redis/vl/test/vcr/VCREmbeddingInterceptorTest.java b/core/src/test/java/com/redis/vl/test/vcr/VCREmbeddingInterceptorTest.java new file mode 100644 index 0000000..35fd613 --- /dev/null +++ b/core/src/test/java/com/redis/vl/test/vcr/VCREmbeddingInterceptorTest.java @@ -0,0 +1,258 @@ +package com.redis.vl.test.vcr; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for VCREmbeddingInterceptor. Tests interception logic without requiring a Redis + * instance or actual embedding model. + */ +@DisplayName("VCREmbeddingInterceptor") +class VCREmbeddingInterceptorTest { + + private TestVCREmbeddingInterceptor interceptor; + private MockEmbeddingProvider mockProvider; + + @BeforeEach + void setUp() { + mockProvider = new MockEmbeddingProvider(); + interceptor = new TestVCREmbeddingInterceptor(mockProvider); + } + + @Nested + @DisplayName("Recording Mode") + class RecordingMode { + + @BeforeEach + void setUp() { + interceptor.setMode(VCRMode.RECORD); + interceptor.setTestId("TestClass.testMethod"); + } + + @Test + @DisplayName("should call real provider and record result in RECORD mode") + void shouldCallRealProviderAndRecord() { + float[] result = interceptor.embed("test text"); + + assertNotNull(result); + assertEquals(1, mockProvider.callCount.get()); + assertEquals(1, interceptor.getRecordedCount()); + } + + @Test + @DisplayName("should record multiple calls with incrementing indices") + void shouldRecordMultipleCallsWithIndices() { + interceptor.embed("text 1"); + interceptor.embed("text 2"); + interceptor.embed("text 3"); + + assertEquals(3, mockProvider.callCount.get()); + assertEquals(3, interceptor.getRecordedCount()); + + // Verify keys are sequential + List keys = interceptor.getRecordedKeys(); + assertTrue(keys.get(0).endsWith(":0001")); + assertTrue(keys.get(1).endsWith(":0002")); + assertTrue(keys.get(2).endsWith(":0003")); + } + + @Test + @DisplayName("should record batch embeddings") + void shouldRecordBatchEmbeddings() { + List results = interceptor.embedBatch(List.of("text 1", "text 2")); + + assertNotNull(results); + assertEquals(2, results.size()); + assertEquals(1, interceptor.getRecordedCount()); // Batch recorded as one cassette + } + } + + @Nested + @DisplayName("Playback Mode") + class PlaybackMode { + + @BeforeEach + void setUp() { + interceptor.setMode(VCRMode.PLAYBACK); + interceptor.setTestId("TestClass.testMethod"); + } + + @Test + @DisplayName("should return cached result without calling provider") + void shouldReturnCachedWithoutCallingProvider() { + // Pre-load cache + float[] expected = new float[] {0.1f, 0.2f, 0.3f}; + interceptor.preloadCassette("vcr:embedding:TestClass.testMethod:0001", expected); + + float[] result = interceptor.embed("test text"); + + assertArrayEquals(expected, result); + assertEquals(0, mockProvider.callCount.get()); + } + + @Test + @DisplayName("should throw when cassette not found in strict PLAYBACK mode") + void shouldThrowWhenCassetteNotFound() { + assertThrows( + VCRCassetteMissingException.class, () -> interceptor.embed("text with no cassette")); + } + + @Test + @DisplayName("should return cached batch embeddings") + void shouldReturnCachedBatch() { + // Pre-load batch cassette + float[][] expected = new float[][] {{0.1f, 0.2f}, {0.3f, 0.4f}}; + interceptor.preloadBatchCassette("vcr:embedding:TestClass.testMethod:0001", expected); + + List results = interceptor.embedBatch(List.of("text 1", "text 2")); + + assertEquals(2, results.size()); + assertArrayEquals(expected[0], results.get(0)); + assertArrayEquals(expected[1], results.get(1)); + assertEquals(0, mockProvider.callCount.get()); + } + } + + @Nested + @DisplayName("Playback or Record Mode") + class PlaybackOrRecordMode { + + @BeforeEach + void setUp() { + interceptor.setMode(VCRMode.PLAYBACK_OR_RECORD); + interceptor.setTestId("TestClass.testMethod"); + } + + @Test + @DisplayName("should return cached result when available") + void shouldReturnCachedWhenAvailable() { + float[] cached = new float[] {0.1f, 0.2f, 0.3f}; + interceptor.preloadCassette("vcr:embedding:TestClass.testMethod:0001", cached); + + float[] result = interceptor.embed("test text"); + + assertArrayEquals(cached, result); + assertEquals(0, mockProvider.callCount.get()); + } + + @Test + @DisplayName("should call provider and record when cache miss") + void shouldCallProviderAndRecordOnCacheMiss() { + float[] result = interceptor.embed("uncached text"); + + assertNotNull(result); + assertEquals(1, mockProvider.callCount.get()); + assertEquals(1, interceptor.getRecordedCount()); + } + } + + @Nested + @DisplayName("Off Mode") + class OffMode { + + @Test + @DisplayName("should bypass VCR completely when OFF") + void shouldBypassVCRWhenOff() { + interceptor.setMode(VCRMode.OFF); + + float[] result = interceptor.embed("test"); + + assertEquals(1, mockProvider.callCount.get()); + assertEquals(0, interceptor.getRecordedCount()); + } + } + + @Nested + @DisplayName("Statistics") + class Statistics { + + @BeforeEach + void setUp() { + interceptor.setMode(VCRMode.PLAYBACK_OR_RECORD); + interceptor.setTestId("TestClass.testMethod"); + } + + @Test + @DisplayName("should track cache hits") + void shouldTrackCacheHits() { + interceptor.preloadCassette("vcr:embedding:TestClass.testMethod:0001", new float[] {0.1f}); + interceptor.preloadCassette("vcr:embedding:TestClass.testMethod:0002", new float[] {0.2f}); + + interceptor.embed("text 1"); + interceptor.embed("text 2"); + + assertEquals(2, interceptor.getCacheHits()); + assertEquals(0, interceptor.getCacheMisses()); + } + + @Test + @DisplayName("should track cache misses") + void shouldTrackCacheMisses() { + interceptor.embed("uncached 1"); + interceptor.embed("uncached 2"); + + assertEquals(0, interceptor.getCacheHits()); + assertEquals(2, interceptor.getCacheMisses()); + } + + @Test + @DisplayName("should reset statistics") + void shouldResetStatistics() { + interceptor.embed("text"); + interceptor.resetStatistics(); + + assertEquals(0, interceptor.getCacheHits()); + assertEquals(0, interceptor.getCacheMisses()); + } + } + + // Test helper classes + + /** Mock embedding provider for testing. */ + static class MockEmbeddingProvider { + AtomicInteger callCount = new AtomicInteger(0); + int dimensions = 384; + + float[] embed(String text) { + callCount.incrementAndGet(); + // Return deterministic embedding based on text hash + float[] embedding = new float[dimensions]; + int hash = text.hashCode(); + for (int i = 0; i < dimensions; i++) { + embedding[i] = (float) Math.sin(hash + i) * 0.1f; + } + return embedding; + } + + List embedBatch(List texts) { + callCount.incrementAndGet(); + return texts.stream().map(this::embed).toList(); + } + } + + /** Test implementation with in-memory cassette storage. */ + static class TestVCREmbeddingInterceptor extends VCREmbeddingInterceptor { + private final MockEmbeddingProvider provider; + + TestVCREmbeddingInterceptor(MockEmbeddingProvider provider) { + super(); + this.provider = provider; + } + + @Override + protected float[] callRealEmbedding(String text) { + return provider.embed(text); + } + + @Override + protected List callRealBatchEmbedding(List texts) { + return provider.embedBatch(texts); + } + } +} diff --git a/core/src/test/java/com/redis/vl/test/vcr/VCREmbeddingModelTest.java b/core/src/test/java/com/redis/vl/test/vcr/VCREmbeddingModelTest.java new file mode 100644 index 0000000..c1c0b9b --- /dev/null +++ b/core/src/test/java/com/redis/vl/test/vcr/VCREmbeddingModelTest.java @@ -0,0 +1,312 @@ +package com.redis.vl.test.vcr; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.langchain4j.data.embedding.Embedding; +import dev.langchain4j.data.segment.TextSegment; +import dev.langchain4j.model.embedding.EmbeddingModel; +import dev.langchain4j.model.output.Response; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for VCREmbeddingModel. + * + *

These tests demonstrate standalone VCR usage with LangChain4J EmbeddingModel, without + * requiring any RedisVL vectorizers or other RedisVL components. + */ +@DisplayName("VCREmbeddingModel") +class VCREmbeddingModelTest { + + private VCREmbeddingModel vcrModel; + private MockLangChain4JEmbeddingModel mockDelegate; + + @BeforeEach + void setUp() { + mockDelegate = new MockLangChain4JEmbeddingModel(); + vcrModel = new VCREmbeddingModel(mockDelegate); + vcrModel.setTestId("VCREmbeddingModelTest.test"); + } + + @Nested + @DisplayName("OFF Mode - Direct passthrough") + class OffMode { + + @BeforeEach + void setUp() { + vcrModel.setMode(VCRMode.OFF); + } + + @Test + @DisplayName("should call delegate directly when VCR is OFF") + void shouldCallDelegateDirectly() { + Response response = vcrModel.embed("hello world"); + + assertNotNull(response); + assertNotNull(response.content()); + assertEquals(384, response.content().vector().length); + assertEquals(1, mockDelegate.callCount.get()); + } + + @Test + @DisplayName("should not record when VCR is OFF") + void shouldNotRecordWhenOff() { + vcrModel.embed("hello"); + vcrModel.embed("world"); + + assertEquals(2, mockDelegate.callCount.get()); + assertEquals(0, vcrModel.getRecordedCount()); + } + } + + @Nested + @DisplayName("RECORD Mode") + class RecordMode { + + @BeforeEach + void setUp() { + vcrModel.setMode(VCRMode.RECORD); + } + + @Test + @DisplayName("should call delegate and record result") + void shouldCallDelegateAndRecord() { + Response response = vcrModel.embed("test text"); + + assertNotNull(response); + assertEquals(1, mockDelegate.callCount.get()); + assertEquals(1, vcrModel.getRecordedCount()); + } + + @Test + @DisplayName("should record multiple calls with incrementing indices") + void shouldRecordMultipleCalls() { + vcrModel.embed("text 1"); + vcrModel.embed("text 2"); + vcrModel.embed("text 3"); + + assertEquals(3, mockDelegate.callCount.get()); + assertEquals(3, vcrModel.getRecordedCount()); + } + } + + @Nested + @DisplayName("PLAYBACK Mode") + class PlaybackMode { + + @BeforeEach + void setUp() { + vcrModel.setMode(VCRMode.PLAYBACK); + } + + @Test + @DisplayName("should return cached embedding without calling delegate") + void shouldReturnCachedEmbedding() { + // Pre-load cassette + float[] cached = new float[] {0.1f, 0.2f, 0.3f}; + vcrModel.preloadCassette("vcr:embedding:VCREmbeddingModelTest.test:0001", cached); + + Response response = vcrModel.embed("test text"); + + assertNotNull(response); + assertArrayEquals(cached, response.content().vector()); + assertEquals(0, mockDelegate.callCount.get()); + } + + @Test + @DisplayName("should throw when cassette not found in strict PLAYBACK mode") + void shouldThrowWhenCassetteNotFound() { + assertThrows(VCRCassetteMissingException.class, () -> vcrModel.embed("uncached text")); + } + + @Test + @DisplayName("should track cache hits") + void shouldTrackCacheHits() { + vcrModel.preloadCassette("vcr:embedding:VCREmbeddingModelTest.test:0001", new float[] {0.1f}); + + vcrModel.embed("text"); + + assertEquals(1, vcrModel.getCacheHits()); + assertEquals(0, vcrModel.getCacheMisses()); + } + } + + @Nested + @DisplayName("PLAYBACK_OR_RECORD Mode") + class PlaybackOrRecordMode { + + @BeforeEach + void setUp() { + vcrModel.setMode(VCRMode.PLAYBACK_OR_RECORD); + } + + @Test + @DisplayName("should return cached embedding when available") + void shouldReturnCachedWhenAvailable() { + float[] cached = new float[] {0.5f, 0.6f, 0.7f}; + vcrModel.preloadCassette("vcr:embedding:VCREmbeddingModelTest.test:0001", cached); + + Response response = vcrModel.embed("test"); + + assertArrayEquals(cached, response.content().vector()); + assertEquals(0, mockDelegate.callCount.get()); + assertEquals(1, vcrModel.getCacheHits()); + } + + @Test + @DisplayName("should call delegate and record on cache miss") + void shouldCallDelegateAndRecordOnMiss() { + Response response = vcrModel.embed("uncached"); + + assertNotNull(response); + assertEquals(1, mockDelegate.callCount.get()); + assertEquals(1, vcrModel.getRecordedCount()); + assertEquals(1, vcrModel.getCacheMisses()); + } + + @Test + @DisplayName("should allow subsequent cache hits after recording") + void shouldAllowSubsequentCacheHits() { + // First call - cache miss, records + vcrModel.embed("text"); + assertEquals(1, mockDelegate.callCount.get()); + assertEquals(1, vcrModel.getCacheMisses()); + + // Reset counter for second test + vcrModel.resetCallCounter(); + + // Second call - cache hit from recorded value + vcrModel.embed("text"); + assertEquals(1, mockDelegate.callCount.get()); // Still 1, not 2 + assertEquals(1, vcrModel.getCacheHits()); + } + } + + @Nested + @DisplayName("Batch Embedding") + class BatchEmbedding { + + @Test + @DisplayName("should handle batch embeddings in RECORD mode") + void shouldHandleBatchInRecordMode() { + vcrModel.setMode(VCRMode.RECORD); + + List segments = + List.of( + TextSegment.from("text 1"), TextSegment.from("text 2"), TextSegment.from("text 3")); + + Response> response = vcrModel.embedAll(segments); + + assertNotNull(response); + assertEquals(3, response.content().size()); + assertEquals(1, vcrModel.getRecordedCount()); + } + + @Test + @DisplayName("should return cached batch embeddings") + void shouldReturnCachedBatch() { + vcrModel.setMode(VCRMode.PLAYBACK); + + float[][] cached = new float[][] {{0.1f, 0.2f}, {0.3f, 0.4f}}; + vcrModel.preloadBatchCassette("vcr:embedding:VCREmbeddingModelTest.test:0001", cached); + + List segments = List.of(TextSegment.from("text 1"), TextSegment.from("text 2")); + + Response> response = vcrModel.embedAll(segments); + + assertEquals(2, response.content().size()); + assertArrayEquals(cached[0], response.content().get(0).vector()); + assertArrayEquals(cached[1], response.content().get(1).vector()); + assertEquals(0, mockDelegate.callCount.get()); + } + } + + @Nested + @DisplayName("Model Dimension") + class ModelDimension { + + @Test + @DisplayName("should return delegate dimension") + void shouldReturnDelegateDimension() { + assertEquals(384, vcrModel.dimension()); + } + } + + @Nested + @DisplayName("Delegate Access") + class DelegateAccess { + + @Test + @DisplayName("should provide access to underlying delegate") + void shouldProvideAccessToDelegate() { + EmbeddingModel delegate = vcrModel.getDelegate(); + + assertSame(mockDelegate, delegate); + } + } + + @Nested + @DisplayName("Statistics Reset") + class StatisticsReset { + + @Test + @DisplayName("should reset statistics") + void shouldResetStatistics() { + vcrModel.setMode(VCRMode.PLAYBACK_OR_RECORD); + vcrModel.embed("text"); // Cache miss + + assertEquals(1, vcrModel.getCacheMisses()); + + vcrModel.resetStatistics(); + + assertEquals(0, vcrModel.getCacheHits()); + assertEquals(0, vcrModel.getCacheMisses()); + } + } + + /** Mock LangChain4J EmbeddingModel for testing. */ + static class MockLangChain4JEmbeddingModel implements EmbeddingModel { + AtomicInteger callCount = new AtomicInteger(0); + int dimensions = 384; + + @Override + public Response embed(String text) { + callCount.incrementAndGet(); + float[] vector = generateVector(text); + return Response.from(Embedding.from(vector)); + } + + @Override + public Response embed(TextSegment textSegment) { + return embed(textSegment.text()); + } + + @Override + public Response> embedAll(List textSegments) { + callCount.incrementAndGet(); + List embeddings = + textSegments.stream() + .map(segment -> Embedding.from(generateVector(segment.text()))) + .toList(); + return Response.from(embeddings); + } + + @Override + public int dimension() { + return dimensions; + } + + private float[] generateVector(String text) { + float[] vector = new float[dimensions]; + int hash = text.hashCode(); + for (int i = 0; i < dimensions; i++) { + vector[i] = (float) Math.sin(hash + i) * 0.1f; + } + return vector; + } + } +} From 908069050c4875f1ffc7b89aca359b2b0b35d183 Mon Sep 17 00:00:00 2001 From: Brian Sam-Bodden Date: Sat, 13 Dec 2025 09:32:01 -0700 Subject: [PATCH 04/12] feat(vcr): add Spring AI model wrappers for VCR Implements VCR wrappers for Spring AI models: - VCRSpringAIEmbeddingModel: wraps EmbeddingModel for recording/replaying - VCRSpringAIChatModel: wraps ChatModel for recording/replaying chat Features: - Full Spring AI interface implementation - Support for embedForResponse() and call() methods - Redis-backed cassette storage integration - In-memory cassette cache for unit testing - Statistics tracking for cache hits, misses, and recordings Unit tests verify recording and playback behavior. --- .../vl/test/vcr/VCRSpringAIChatModel.java | 262 ++++++++++++ .../test/vcr/VCRSpringAIEmbeddingModel.java | 380 +++++++++++++++++ .../vl/test/vcr/VCRSpringAIChatModelTest.java | 310 ++++++++++++++ .../vcr/VCRSpringAIEmbeddingModelTest.java | 384 ++++++++++++++++++ 4 files changed, 1336 insertions(+) create mode 100644 core/src/main/java/com/redis/vl/test/vcr/VCRSpringAIChatModel.java create mode 100644 core/src/main/java/com/redis/vl/test/vcr/VCRSpringAIEmbeddingModel.java create mode 100644 core/src/test/java/com/redis/vl/test/vcr/VCRSpringAIChatModelTest.java create mode 100644 core/src/test/java/com/redis/vl/test/vcr/VCRSpringAIEmbeddingModelTest.java diff --git a/core/src/main/java/com/redis/vl/test/vcr/VCRSpringAIChatModel.java b/core/src/main/java/com/redis/vl/test/vcr/VCRSpringAIChatModel.java new file mode 100644 index 0000000..4fb9a01 --- /dev/null +++ b/core/src/main/java/com/redis/vl/test/vcr/VCRSpringAIChatModel.java @@ -0,0 +1,262 @@ +package com.redis.vl.test.vcr; + +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; + +/** + * VCR wrapper for Spring AI ChatModel that records and replays LLM responses. + * + *

This class implements the ChatModel interface, allowing it to be used as a drop-in replacement + * for any Spring AI chat model. It provides VCR (Video Cassette Recorder) functionality to record + * LLM responses during test execution and replay them in subsequent runs. + * + *

Usage: + * + *

{@code
+ * ChatModel openAiModel = new OpenAiChatModel(openAiApi);
+ *
+ * VCRSpringAIChatModel vcrModel = new VCRSpringAIChatModel(openAiModel);
+ * vcrModel.setMode(VCRMode.PLAYBACK_OR_RECORD);
+ * vcrModel.setTestId("MyTest.testMethod");
+ *
+ * // Use exactly like the original model
+ * String response = vcrModel.call("Hello");
+ * }
+ */ +@SuppressFBWarnings( + value = "EI_EXPOSE_REP2", + justification = "Delegate is intentionally stored and exposed for VCR functionality") +public final class VCRSpringAIChatModel implements ChatModel { + + private final ChatModel delegate; + private VCRCassetteStore cassetteStore; + private VCRMode mode = VCRMode.PLAYBACK_OR_RECORD; + private String testId = "unknown"; + private final AtomicInteger callCounter = new AtomicInteger(0); + + // In-memory cassette storage for unit tests + private final Map cassettes = new HashMap<>(); + + // Statistics + private int cacheHits = 0; + private int cacheMisses = 0; + private int recordedCount = 0; + + /** + * Creates a new VCRSpringAIChatModel wrapping the given delegate. + * + * @param delegate The actual ChatModel to wrap + */ + public VCRSpringAIChatModel(ChatModel delegate) { + this.delegate = delegate; + } + + /** + * Creates a new VCRSpringAIChatModel wrapping the given delegate with Redis storage. + * + * @param delegate The actual ChatModel to wrap + * @param cassetteStore The cassette store for persistence + */ + @SuppressFBWarnings( + value = "EI_EXPOSE_REP2", + justification = "VCRCassetteStore is intentionally shared") + public VCRSpringAIChatModel(ChatModel delegate, VCRCassetteStore cassetteStore) { + this.delegate = delegate; + this.cassetteStore = cassetteStore; + } + + /** + * Sets the VCR mode. + * + * @param mode The VCR mode to use + */ + public void setMode(VCRMode mode) { + this.mode = mode; + } + + /** + * Gets the current VCR mode. + * + * @return The current VCR mode + */ + public VCRMode getMode() { + return mode; + } + + /** + * Sets the test identifier for cassette key generation. + * + * @param testId The test identifier (typically ClassName.methodName) + */ + public void setTestId(String testId) { + this.testId = testId; + } + + /** + * Gets the current test identifier. + * + * @return The current test identifier + */ + public String getTestId() { + return testId; + } + + /** Resets the call counter. Useful when starting a new test method. */ + public void resetCallCounter() { + callCounter.set(0); + } + + /** + * Gets the underlying delegate model. + * + * @return The wrapped ChatModel + */ + @SuppressFBWarnings( + value = "EI_EXPOSE_REP", + justification = "Intentional exposure of delegate for advanced use cases") + public ChatModel getDelegate() { + return delegate; + } + + /** + * Preloads a cassette for testing purposes. + * + * @param key The cassette key + * @param response The response text to cache + */ + public void preloadCassette(String key, String response) { + cassettes.put(key, response); + } + + /** + * Gets the number of cache hits. + * + * @return Cache hit count + */ + public int getCacheHits() { + return cacheHits; + } + + /** + * Gets the number of cache misses. + * + * @return Cache miss count + */ + public int getCacheMisses() { + return cacheMisses; + } + + /** + * Gets the number of recorded responses. + * + * @return Recorded count + */ + public int getRecordedCount() { + return recordedCount; + } + + /** Resets all statistics. */ + public void resetStatistics() { + cacheHits = 0; + cacheMisses = 0; + recordedCount = 0; + } + + @Override + public ChatResponse call(Prompt prompt) { + String responseText = + callInternal( + () -> { + ChatResponse response = delegate.call(prompt); + return response.getResult().getOutput().getText(); + }); + Generation generation = new Generation(new AssistantMessage(responseText)); + return new ChatResponse(List.of(generation)); + } + + @Override + public String call(String message) { + return callInternal(() -> delegate.call(message)); + } + + @Override + public String call(Message... messages) { + return callInternal(() -> delegate.call(messages)); + } + + private String callInternal(java.util.function.Supplier delegateCall) { + if (mode == VCRMode.OFF) { + return delegateCall.get(); + } + + String key = formatKey(); + + if (mode.isPlaybackMode()) { + String cached = loadCassette(key); + if (cached != null) { + cacheHits++; + return cached; + } + + if (mode == VCRMode.PLAYBACK) { + throw new VCRCassetteMissingException(key, testId); + } + + // PLAYBACK_OR_RECORD - fall through to record + } + + // Record mode or cache miss in PLAYBACK_OR_RECORD + cacheMisses++; + String response = delegateCall.get(); + saveCassette(key, response); + recordedCount++; + + return response; + } + + private String loadCassette(String key) { + // Check in-memory first + String inMemory = cassettes.get(key); + if (inMemory != null) { + return inMemory; + } + + // Check Redis if available + if (cassetteStore != null) { + com.google.gson.JsonObject cassette = cassetteStore.retrieve(key); + if (cassette != null && cassette.has("response")) { + return cassette.get("response").getAsString(); + } + } + + return null; + } + + private void saveCassette(String key, String response) { + // Save to in-memory + cassettes.put(key, response); + + // Save to Redis if available + if (cassetteStore != null) { + com.google.gson.JsonObject cassette = new com.google.gson.JsonObject(); + cassette.addProperty("response", response); + cassette.addProperty("testId", testId); + cassette.addProperty("type", "chat"); + cassetteStore.store(key, cassette); + } + } + + private String formatKey() { + int index = callCounter.incrementAndGet(); + return String.format("vcr:chat:%s:%04d", testId, index); + } +} diff --git a/core/src/main/java/com/redis/vl/test/vcr/VCRSpringAIEmbeddingModel.java b/core/src/main/java/com/redis/vl/test/vcr/VCRSpringAIEmbeddingModel.java new file mode 100644 index 0000000..f1fcffd --- /dev/null +++ b/core/src/main/java/com/redis/vl/test/vcr/VCRSpringAIEmbeddingModel.java @@ -0,0 +1,380 @@ +package com.redis.vl.test.vcr; + +import com.google.gson.JsonObject; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; +import java.util.stream.IntStream; +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.Embedding; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.embedding.EmbeddingRequest; +import org.springframework.ai.embedding.EmbeddingResponse; + +/** + * VCR-enabled wrapper around a Spring AI EmbeddingModel. + * + *

This wrapper intercepts embedding calls and routes them through the VCR system for recording + * and playback. + * + *

Usage: + * + *

{@code
+ * // In your test
+ * EmbeddingModel realModel = new OpenAiEmbeddingModel(...);
+ * VCRSpringAIEmbeddingModel vcrModel = new VCRSpringAIEmbeddingModel(realModel);
+ * vcrModel.setMode(VCRMode.PLAYBACK_OR_RECORD);
+ * vcrModel.setTestId("MyTest.testMethod");
+ *
+ * // Use vcrModel instead of realModel
+ * float[] embedding = vcrModel.embed("text");
+ * }
+ */ +public class VCRSpringAIEmbeddingModel implements EmbeddingModel { + + private static final String CASSETTE_TYPE = "embedding"; + + private final EmbeddingModel delegate; + private final int dimensionSize; + + private VCRMode mode = VCRMode.OFF; + private String testId; + private String modelName = "default"; + private final AtomicInteger callCounter = new AtomicInteger(0); + + // In-memory cassette storage for unit tests (null = use Redis) + private VCRCassetteStore cassetteStore; + private final Map inMemoryCassettes = new ConcurrentHashMap<>(); + private final Map inMemoryBatchCassettes = new ConcurrentHashMap<>(); + private final List recordedKeys = new ArrayList<>(); + + // Statistics + private final AtomicLong cacheHits = new AtomicLong(0); + private final AtomicLong cacheMisses = new AtomicLong(0); + + /** + * Creates a VCR-enabled embedding model wrapper. + * + * @param delegate the real embedding model + */ + @SuppressFBWarnings( + value = "EI_EXPOSE_REP2", + justification = "EmbeddingModel delegate is intentionally shared") + public VCRSpringAIEmbeddingModel(EmbeddingModel delegate) { + this.delegate = delegate; + this.dimensionSize = detectDimensions(delegate); + } + + /** + * Creates a VCR-enabled embedding model wrapper with Redis storage. + * + * @param delegate the real embedding model + * @param cassetteStore the cassette store + */ + @SuppressFBWarnings( + value = "EI_EXPOSE_REP2", + justification = "EmbeddingModel and VCRCassetteStore are intentionally shared") + public VCRSpringAIEmbeddingModel(EmbeddingModel delegate, VCRCassetteStore cassetteStore) { + this.delegate = delegate; + this.cassetteStore = cassetteStore; + this.dimensionSize = detectDimensions(delegate); + } + + /** + * Sets the VCR mode. + * + * @param mode the mode + */ + public void setMode(VCRMode mode) { + this.mode = mode; + } + + /** + * Gets the current VCR mode. + * + * @return the mode + */ + public VCRMode getMode() { + return mode; + } + + /** + * Sets the current test identifier. + * + * @param testId the test ID + */ + public void setTestId(String testId) { + this.testId = testId; + this.callCounter.set(0); + } + + /** + * Sets the model name for cassette metadata. + * + * @param modelName the model name + */ + public void setModelName(String modelName) { + this.modelName = modelName; + } + + /** Resets the call counter (call at start of each test). */ + public void resetCallCounter() { + callCounter.set(0); + } + + @Override + public EmbeddingResponse call(EmbeddingRequest request) { + List texts = request.getInstructions(); + List embeddings = embedBatchInternal(texts); + + List results = + IntStream.range(0, embeddings.size()) + .mapToObj(i -> new Embedding(embeddings.get(i), i)) + .toList(); + + return new EmbeddingResponse(results); + } + + @Override + public float[] embed(String text) { + return embedInternal(text); + } + + @Override + public float[] embed(Document document) { + return embedInternal(document.getText()); + } + + @Override + public List embed(List texts) { + return embedBatchInternal(texts); + } + + @Override + public EmbeddingResponse embedForResponse(List texts) { + return call(new EmbeddingRequest(texts, null)); + } + + @Override + public int dimensions() { + return dimensionSize; + } + + /** + * Gets the cache hit count. + * + * @return number of cache hits + */ + public long getCacheHits() { + return cacheHits.get(); + } + + /** + * Gets the cache miss count. + * + * @return number of cache misses + */ + public long getCacheMisses() { + return cacheMisses.get(); + } + + /** Resets statistics. */ + public void resetStatistics() { + cacheHits.set(0); + cacheMisses.set(0); + } + + /** + * Gets the number of recorded cassettes. + * + * @return the count + */ + public int getRecordedCount() { + return recordedKeys.size(); + } + + /** + * Gets the underlying delegate model. + * + * @return the delegate + */ + public EmbeddingModel getDelegate() { + return delegate; + } + + // Preload methods for testing + + /** + * Preloads a cassette into the in-memory cache (for testing). + * + * @param key the cassette key + * @param embedding the embedding to cache + */ + public void preloadCassette(String key, float[] embedding) { + inMemoryCassettes.put(key, embedding); + } + + /** + * Preloads a batch cassette into the in-memory cache (for testing). + * + * @param key the cassette key + * @param embeddings the embeddings to cache + */ + public void preloadBatchCassette(String key, float[][] embeddings) { + inMemoryBatchCassettes.put(key, embeddings); + } + + // Internal methods + + private float[] embedInternal(String text) { + if (mode == VCRMode.OFF) { + return delegate.embed(text); + } + + String cassetteKey = generateCassetteKey(); + + // Try to load from cache in playback modes + if (mode.isPlaybackMode()) { + float[] cached = loadCassette(cassetteKey); + if (cached != null) { + cacheHits.incrementAndGet(); + return cached; + } + + // Strict playback mode - throw if not found + if (mode == VCRMode.PLAYBACK) { + throw new VCRCassetteMissingException(cassetteKey, testId); + } + } + + // Cache miss or record mode - call real API + cacheMisses.incrementAndGet(); + float[] embedding = delegate.embed(text); + + // Record if in recording mode + if (mode.isRecordMode() || mode == VCRMode.PLAYBACK_OR_RECORD) { + saveCassette(cassetteKey, embedding); + } + + return embedding; + } + + private List embedBatchInternal(List texts) { + if (mode == VCRMode.OFF) { + return delegate.embed(texts); + } + + String cassetteKey = generateCassetteKey(); + + // Try to load from cache in playback modes + if (mode.isPlaybackMode()) { + float[][] cached = loadBatchCassette(cassetteKey); + if (cached != null) { + cacheHits.incrementAndGet(); + List result = new ArrayList<>(); + for (float[] embedding : cached) { + result.add(embedding); + } + return result; + } + + // Strict playback mode - throw if not found + if (mode == VCRMode.PLAYBACK) { + throw new VCRCassetteMissingException(cassetteKey, testId); + } + } + + // Cache miss or record mode - call real API + cacheMisses.incrementAndGet(); + List embeddings = delegate.embed(texts); + + // Record if in recording mode + if (mode.isRecordMode() || mode == VCRMode.PLAYBACK_OR_RECORD) { + saveBatchCassette(cassetteKey, embeddings.toArray(new float[0][])); + } + + return embeddings; + } + + private String generateCassetteKey() { + int index = callCounter.incrementAndGet(); + return VCRCassetteStore.formatKey(CASSETTE_TYPE, testId, index); + } + + private float[] loadCassette(String key) { + // Check in-memory first + float[] inMemory = inMemoryCassettes.get(key); + if (inMemory != null) { + return inMemory; + } + + // Check Redis + if (cassetteStore != null) { + JsonObject cassette = cassetteStore.retrieve(key); + if (cassette != null) { + return VCRCassetteStore.extractEmbedding(cassette); + } + } + + return null; + } + + private float[][] loadBatchCassette(String key) { + // Check in-memory first + float[][] inMemory = inMemoryBatchCassettes.get(key); + if (inMemory != null) { + return inMemory; + } + + // Check Redis + if (cassetteStore != null) { + JsonObject cassette = cassetteStore.retrieve(key); + if (cassette != null) { + return VCRCassetteStore.extractBatchEmbeddings(cassette); + } + } + + return null; + } + + private void saveCassette(String key, float[] embedding) { + recordedKeys.add(key); + + // Save to in-memory + inMemoryCassettes.put(key, embedding); + + // Save to Redis if available + if (cassetteStore != null) { + JsonObject cassette = VCRCassetteStore.createEmbeddingCassette(embedding, testId, modelName); + cassetteStore.store(key, cassette); + } + } + + private void saveBatchCassette(String key, float[][] embeddings) { + recordedKeys.add(key); + + // Save to in-memory + inMemoryBatchCassettes.put(key, embeddings); + + // Save to Redis if available + if (cassetteStore != null) { + JsonObject cassette = + VCRCassetteStore.createBatchEmbeddingCassette(embeddings, testId, modelName); + cassetteStore.store(key, cassette); + } + } + + private int detectDimensions(EmbeddingModel model) { + // Try to detect dimensions from the model + try { + return model.dimensions(); + } catch (Exception e) { + // Some models don't implement dimensions() + return -1; + } + } +} diff --git a/core/src/test/java/com/redis/vl/test/vcr/VCRSpringAIChatModelTest.java b/core/src/test/java/com/redis/vl/test/vcr/VCRSpringAIChatModelTest.java new file mode 100644 index 0000000..7a45a10 --- /dev/null +++ b/core/src/test/java/com/redis/vl/test/vcr/VCRSpringAIChatModelTest.java @@ -0,0 +1,310 @@ +package com.redis.vl.test.vcr; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.messages.AssistantMessage; +import org.springframework.ai.chat.messages.Message; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.model.Generation; +import org.springframework.ai.chat.prompt.Prompt; + +/** + * Unit tests for VCRSpringAIChatModel. + * + *

These tests demonstrate standalone VCR usage with Spring AI ChatModel, without requiring any + * RedisVL components. + */ +@DisplayName("VCRSpringAIChatModel") +class VCRSpringAIChatModelTest { + + private VCRSpringAIChatModel vcrModel; + private MockSpringAIChatModel mockDelegate; + + @BeforeEach + void setUp() { + mockDelegate = new MockSpringAIChatModel(); + vcrModel = new VCRSpringAIChatModel(mockDelegate); + vcrModel.setTestId("VCRSpringAIChatModelTest.test"); + } + + @Nested + @DisplayName("OFF Mode - Direct passthrough") + class OffMode { + + @BeforeEach + void setUp() { + vcrModel.setMode(VCRMode.OFF); + } + + @Test + @DisplayName("should call delegate directly when VCR is OFF") + void shouldCallDelegateDirectly() { + String response = vcrModel.call("Hello"); + + assertNotNull(response); + assertTrue(response.contains("Mock response")); + assertEquals(1, mockDelegate.callCount.get()); + } + + @Test + @DisplayName("should not record when VCR is OFF") + void shouldNotRecordWhenOff() { + vcrModel.call("Hello"); + vcrModel.call("World"); + + assertEquals(2, mockDelegate.callCount.get()); + assertEquals(0, vcrModel.getRecordedCount()); + } + } + + @Nested + @DisplayName("RECORD Mode") + class RecordMode { + + @BeforeEach + void setUp() { + vcrModel.setMode(VCRMode.RECORD); + } + + @Test + @DisplayName("should call delegate and record result") + void shouldCallDelegateAndRecord() { + String response = vcrModel.call("Test message"); + + assertNotNull(response); + assertEquals(1, mockDelegate.callCount.get()); + assertEquals(1, vcrModel.getRecordedCount()); + } + + @Test + @DisplayName("should record multiple calls with incrementing indices") + void shouldRecordMultipleCalls() { + vcrModel.call("Message 1"); + vcrModel.call("Message 2"); + vcrModel.call("Message 3"); + + assertEquals(3, mockDelegate.callCount.get()); + assertEquals(3, vcrModel.getRecordedCount()); + } + } + + @Nested + @DisplayName("PLAYBACK Mode") + class PlaybackMode { + + @BeforeEach + void setUp() { + vcrModel.setMode(VCRMode.PLAYBACK); + } + + @Test + @DisplayName("should return cached response without calling delegate") + void shouldReturnCachedResponse() { + // Pre-load cassette + String cachedResponse = "This is a cached LLM response"; + vcrModel.preloadCassette("vcr:chat:VCRSpringAIChatModelTest.test:0001", cachedResponse); + + String response = vcrModel.call("Test"); + + assertNotNull(response); + assertEquals(cachedResponse, response); + assertEquals(0, mockDelegate.callCount.get()); + } + + @Test + @DisplayName("should throw when cassette not found in strict PLAYBACK mode") + void shouldThrowWhenCassetteNotFound() { + assertThrows(VCRCassetteMissingException.class, () -> vcrModel.call("Unknown")); + } + + @Test + @DisplayName("should track cache hits") + void shouldTrackCacheHits() { + vcrModel.preloadCassette("vcr:chat:VCRSpringAIChatModelTest.test:0001", "Cached"); + + vcrModel.call("Test"); + + assertEquals(1, vcrModel.getCacheHits()); + assertEquals(0, vcrModel.getCacheMisses()); + } + } + + @Nested + @DisplayName("PLAYBACK_OR_RECORD Mode") + class PlaybackOrRecordMode { + + @BeforeEach + void setUp() { + vcrModel.setMode(VCRMode.PLAYBACK_OR_RECORD); + } + + @Test + @DisplayName("should return cached response when available") + void shouldReturnCachedWhenAvailable() { + String cachedResponse = "Cached LLM answer"; + vcrModel.preloadCassette("vcr:chat:VCRSpringAIChatModelTest.test:0001", cachedResponse); + + String response = vcrModel.call("Test"); + + assertEquals(cachedResponse, response); + assertEquals(0, mockDelegate.callCount.get()); + assertEquals(1, vcrModel.getCacheHits()); + } + + @Test + @DisplayName("should call delegate and record on cache miss") + void shouldCallDelegateAndRecordOnMiss() { + String response = vcrModel.call("Uncached"); + + assertNotNull(response); + assertEquals(1, mockDelegate.callCount.get()); + assertEquals(1, vcrModel.getRecordedCount()); + assertEquals(1, vcrModel.getCacheMisses()); + } + + @Test + @DisplayName("should allow subsequent cache hits after recording") + void shouldAllowSubsequentCacheHits() { + // First call - cache miss, records + vcrModel.call("Question"); + assertEquals(1, mockDelegate.callCount.get()); + assertEquals(1, vcrModel.getCacheMisses()); + + // Reset counter for second call + vcrModel.resetCallCounter(); + + // Second call - cache hit from recorded value + vcrModel.call("Question"); + assertEquals(1, mockDelegate.callCount.get()); // Still 1, not 2 + assertEquals(1, vcrModel.getCacheHits()); + } + } + + @Nested + @DisplayName("Prompt API") + class PromptApi { + + @Test + @DisplayName("should handle Prompt in RECORD mode") + void shouldHandlePromptInRecordMode() { + vcrModel.setMode(VCRMode.RECORD); + + Prompt prompt = new Prompt(List.of(new UserMessage("Hello from Prompt"))); + + ChatResponse response = vcrModel.call(prompt); + + assertNotNull(response); + assertNotNull(response.getResult()); + assertEquals(1, mockDelegate.callCount.get()); + assertEquals(1, vcrModel.getRecordedCount()); + } + + @Test + @DisplayName("should return cached ChatResponse for Prompt") + void shouldReturnCachedChatResponse() { + vcrModel.setMode(VCRMode.PLAYBACK); + vcrModel.preloadCassette( + "vcr:chat:VCRSpringAIChatModelTest.test:0001", "Prompt cached response"); + + Prompt prompt = new Prompt(List.of(new UserMessage("Test"))); + + ChatResponse response = vcrModel.call(prompt); + + assertEquals("Prompt cached response", response.getResult().getOutput().getText()); + assertEquals(0, mockDelegate.callCount.get()); + } + } + + @Nested + @DisplayName("Message Varargs") + class MessageVarargs { + + @Test + @DisplayName("should handle Message varargs in RECORD mode") + void shouldHandleMessageVarargsInRecordMode() { + vcrModel.setMode(VCRMode.RECORD); + + String response = vcrModel.call(new UserMessage("First"), new UserMessage("Second")); + + assertNotNull(response); + assertEquals(1, mockDelegate.callCount.get()); + assertEquals(1, vcrModel.getRecordedCount()); + } + + @Test + @DisplayName("should return cached response for Message varargs") + void shouldReturnCachedForMessageVarargs() { + vcrModel.setMode(VCRMode.PLAYBACK); + vcrModel.preloadCassette("vcr:chat:VCRSpringAIChatModelTest.test:0001", "Varargs cached"); + + String response = vcrModel.call(new UserMessage("Test")); + + assertEquals("Varargs cached", response); + assertEquals(0, mockDelegate.callCount.get()); + } + } + + @Nested + @DisplayName("Delegate Access") + class DelegateAccess { + + @Test + @DisplayName("should provide access to underlying delegate") + void shouldProvideAccessToDelegate() { + ChatModel delegate = vcrModel.getDelegate(); + + assertSame(mockDelegate, delegate); + } + } + + @Nested + @DisplayName("Statistics Reset") + class StatisticsReset { + + @Test + @DisplayName("should reset statistics") + void shouldResetStatistics() { + vcrModel.setMode(VCRMode.PLAYBACK_OR_RECORD); + vcrModel.call("Test"); // Cache miss + + assertEquals(1, vcrModel.getCacheMisses()); + + vcrModel.resetStatistics(); + + assertEquals(0, vcrModel.getCacheHits()); + assertEquals(0, vcrModel.getCacheMisses()); + } + } + + /** Mock Spring AI ChatModel for testing. */ + static class MockSpringAIChatModel implements ChatModel { + AtomicInteger callCount = new AtomicInteger(0); + + @Override + public ChatResponse call(Prompt prompt) { + callCount.incrementAndGet(); + String lastMessage = ""; + if (prompt.getInstructions() != null && !prompt.getInstructions().isEmpty()) { + Message last = prompt.getInstructions().get(prompt.getInstructions().size() - 1); + lastMessage = last.getText(); + } + Generation generation = + new Generation(new AssistantMessage("Mock response to: " + lastMessage)); + return new ChatResponse(List.of(generation)); + } + + @Override + public String call(String message) { + callCount.incrementAndGet(); + return "Mock response to: " + message; + } + } +} diff --git a/core/src/test/java/com/redis/vl/test/vcr/VCRSpringAIEmbeddingModelTest.java b/core/src/test/java/com/redis/vl/test/vcr/VCRSpringAIEmbeddingModelTest.java new file mode 100644 index 0000000..127564b --- /dev/null +++ b/core/src/test/java/com/redis/vl/test/vcr/VCRSpringAIEmbeddingModelTest.java @@ -0,0 +1,384 @@ +package com.redis.vl.test.vcr; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.Embedding; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.embedding.EmbeddingRequest; +import org.springframework.ai.embedding.EmbeddingResponse; + +/** + * Unit tests for VCRSpringAIEmbeddingModel. + * + *

These tests demonstrate standalone VCR usage with Spring AI EmbeddingModel, without requiring + * any RedisVL vectorizers or other RedisVL components. + */ +@DisplayName("VCRSpringAIEmbeddingModel") +class VCRSpringAIEmbeddingModelTest { + + private VCRSpringAIEmbeddingModel vcrModel; + private MockSpringAIEmbeddingModel mockDelegate; + + @BeforeEach + void setUp() { + mockDelegate = new MockSpringAIEmbeddingModel(); + vcrModel = new VCRSpringAIEmbeddingModel(mockDelegate); + vcrModel.setTestId("VCRSpringAIEmbeddingModelTest.test"); + } + + @Nested + @DisplayName("OFF Mode - Direct passthrough") + class OffMode { + + @BeforeEach + void setUp() { + vcrModel.setMode(VCRMode.OFF); + } + + @Test + @DisplayName("should call delegate directly when VCR is OFF") + void shouldCallDelegateDirectly() { + float[] embedding = vcrModel.embed("hello world"); + + assertNotNull(embedding); + assertEquals(384, embedding.length); + assertEquals(1, mockDelegate.callCount.get()); + } + + @Test + @DisplayName("should not record when VCR is OFF") + void shouldNotRecordWhenOff() { + vcrModel.embed("hello"); + vcrModel.embed("world"); + + assertEquals(2, mockDelegate.callCount.get()); + assertEquals(0, vcrModel.getRecordedCount()); + } + } + + @Nested + @DisplayName("RECORD Mode") + class RecordMode { + + @BeforeEach + void setUp() { + vcrModel.setMode(VCRMode.RECORD); + } + + @Test + @DisplayName("should call delegate and record result") + void shouldCallDelegateAndRecord() { + float[] embedding = vcrModel.embed("test text"); + + assertNotNull(embedding); + assertEquals(1, mockDelegate.callCount.get()); + assertEquals(1, vcrModel.getRecordedCount()); + } + + @Test + @DisplayName("should record multiple calls with incrementing indices") + void shouldRecordMultipleCalls() { + vcrModel.embed("text 1"); + vcrModel.embed("text 2"); + vcrModel.embed("text 3"); + + assertEquals(3, mockDelegate.callCount.get()); + assertEquals(3, vcrModel.getRecordedCount()); + } + } + + @Nested + @DisplayName("PLAYBACK Mode") + class PlaybackMode { + + @BeforeEach + void setUp() { + vcrModel.setMode(VCRMode.PLAYBACK); + } + + @Test + @DisplayName("should return cached embedding without calling delegate") + void shouldReturnCachedEmbedding() { + // Pre-load cassette + float[] cached = new float[] {0.1f, 0.2f, 0.3f}; + vcrModel.preloadCassette("vcr:embedding:VCRSpringAIEmbeddingModelTest.test:0001", cached); + + float[] embedding = vcrModel.embed("test text"); + + assertNotNull(embedding); + assertArrayEquals(cached, embedding); + assertEquals(0, mockDelegate.callCount.get()); + } + + @Test + @DisplayName("should throw when cassette not found in strict PLAYBACK mode") + void shouldThrowWhenCassetteNotFound() { + assertThrows(VCRCassetteMissingException.class, () -> vcrModel.embed("uncached text")); + } + + @Test + @DisplayName("should track cache hits") + void shouldTrackCacheHits() { + vcrModel.preloadCassette( + "vcr:embedding:VCRSpringAIEmbeddingModelTest.test:0001", new float[] {0.1f}); + + vcrModel.embed("text"); + + assertEquals(1, vcrModel.getCacheHits()); + assertEquals(0, vcrModel.getCacheMisses()); + } + } + + @Nested + @DisplayName("PLAYBACK_OR_RECORD Mode") + class PlaybackOrRecordMode { + + @BeforeEach + void setUp() { + vcrModel.setMode(VCRMode.PLAYBACK_OR_RECORD); + } + + @Test + @DisplayName("should return cached embedding when available") + void shouldReturnCachedWhenAvailable() { + float[] cached = new float[] {0.5f, 0.6f, 0.7f}; + vcrModel.preloadCassette("vcr:embedding:VCRSpringAIEmbeddingModelTest.test:0001", cached); + + float[] embedding = vcrModel.embed("test"); + + assertArrayEquals(cached, embedding); + assertEquals(0, mockDelegate.callCount.get()); + assertEquals(1, vcrModel.getCacheHits()); + } + + @Test + @DisplayName("should call delegate and record on cache miss") + void shouldCallDelegateAndRecordOnMiss() { + float[] embedding = vcrModel.embed("uncached"); + + assertNotNull(embedding); + assertEquals(1, mockDelegate.callCount.get()); + assertEquals(1, vcrModel.getRecordedCount()); + assertEquals(1, vcrModel.getCacheMisses()); + } + + @Test + @DisplayName("should allow subsequent cache hits after recording") + void shouldAllowSubsequentCacheHits() { + // First call - cache miss, records + vcrModel.embed("text"); + assertEquals(1, mockDelegate.callCount.get()); + assertEquals(1, vcrModel.getCacheMisses()); + + // Reset counter for second test + vcrModel.resetCallCounter(); + + // Second call - cache hit from recorded value + vcrModel.embed("text"); + assertEquals(1, mockDelegate.callCount.get()); // Still 1, not 2 + assertEquals(1, vcrModel.getCacheHits()); + } + } + + @Nested + @DisplayName("Batch Embedding") + class BatchEmbedding { + + @Test + @DisplayName("should handle batch embeddings in RECORD mode") + void shouldHandleBatchInRecordMode() { + vcrModel.setMode(VCRMode.RECORD); + + List texts = List.of("text 1", "text 2", "text 3"); + + List embeddings = vcrModel.embed(texts); + + assertNotNull(embeddings); + assertEquals(3, embeddings.size()); + assertEquals(1, vcrModel.getRecordedCount()); + } + + @Test + @DisplayName("should return cached batch embeddings") + void shouldReturnCachedBatch() { + vcrModel.setMode(VCRMode.PLAYBACK); + + float[][] cached = new float[][] {{0.1f, 0.2f}, {0.3f, 0.4f}}; + vcrModel.preloadBatchCassette( + "vcr:embedding:VCRSpringAIEmbeddingModelTest.test:0001", cached); + + List texts = List.of("text 1", "text 2"); + + List embeddings = vcrModel.embed(texts); + + assertEquals(2, embeddings.size()); + assertArrayEquals(cached[0], embeddings.get(0)); + assertArrayEquals(cached[1], embeddings.get(1)); + assertEquals(0, mockDelegate.callCount.get()); + } + } + + @Nested + @DisplayName("EmbeddingResponse API") + class EmbeddingResponseApi { + + @Test + @DisplayName("should handle embedForResponse in RECORD mode") + void shouldHandleEmbedForResponse() { + vcrModel.setMode(VCRMode.RECORD); + + EmbeddingResponse response = vcrModel.embedForResponse(List.of("text 1", "text 2")); + + assertNotNull(response); + assertEquals(2, response.getResults().size()); + assertEquals(1, vcrModel.getRecordedCount()); + } + + @Test + @DisplayName("should return cached batch via call() method") + void shouldReturnCachedBatchViaCall() { + vcrModel.setMode(VCRMode.PLAYBACK); + + float[][] cached = new float[][] {{0.1f, 0.2f}, {0.3f, 0.4f}}; + vcrModel.preloadBatchCassette( + "vcr:embedding:VCRSpringAIEmbeddingModelTest.test:0001", cached); + + EmbeddingRequest request = new EmbeddingRequest(List.of("text 1", "text 2"), null); + EmbeddingResponse response = vcrModel.call(request); + + assertEquals(2, response.getResults().size()); + assertArrayEquals(cached[0], response.getResults().get(0).getOutput()); + assertEquals(0, mockDelegate.callCount.get()); + } + } + + @Nested + @DisplayName("Document Embedding") + class DocumentEmbedding { + + @Test + @DisplayName("should embed document content") + void shouldEmbedDocument() { + vcrModel.setMode(VCRMode.RECORD); + + Document document = new Document("This is a test document"); + float[] embedding = vcrModel.embed(document); + + assertNotNull(embedding); + assertEquals(384, embedding.length); + assertEquals(1, mockDelegate.callCount.get()); + assertEquals(1, vcrModel.getRecordedCount()); + } + } + + @Nested + @DisplayName("Model Dimension") + class ModelDimension { + + @Test + @DisplayName("should return delegate dimension") + void shouldReturnDelegateDimension() { + assertEquals(384, vcrModel.dimensions()); + } + } + + @Nested + @DisplayName("Delegate Access") + class DelegateAccess { + + @Test + @DisplayName("should provide access to underlying delegate") + void shouldProvideAccessToDelegate() { + EmbeddingModel delegate = vcrModel.getDelegate(); + + assertSame(mockDelegate, delegate); + } + } + + @Nested + @DisplayName("Statistics Reset") + class StatisticsReset { + + @Test + @DisplayName("should reset statistics") + void shouldResetStatistics() { + vcrModel.setMode(VCRMode.PLAYBACK_OR_RECORD); + vcrModel.embed("text"); // Cache miss + + assertEquals(1, vcrModel.getCacheMisses()); + + vcrModel.resetStatistics(); + + assertEquals(0, vcrModel.getCacheHits()); + assertEquals(0, vcrModel.getCacheMisses()); + } + } + + /** Mock Spring AI EmbeddingModel for testing. */ + static class MockSpringAIEmbeddingModel implements EmbeddingModel { + AtomicInteger callCount = new AtomicInteger(0); + int embeddingDimensions = 384; + + @Override + public EmbeddingResponse call(EmbeddingRequest request) { + callCount.incrementAndGet(); + List embeddings = + request.getInstructions().stream() + .map(text -> new Embedding(generateVector(text), 0)) + .toList(); + return new EmbeddingResponse(embeddings); + } + + @Override + public float[] embed(String text) { + callCount.incrementAndGet(); + return generateVector(text); + } + + @Override + public float[] embed(Document document) { + return embed(document.getText()); + } + + @Override + public List embed(List texts) { + callCount.incrementAndGet(); + return texts.stream().map(this::generateVectorWithoutCount).toList(); + } + + @Override + public EmbeddingResponse embedForResponse(List texts) { + return call(new EmbeddingRequest(texts, null)); + } + + @Override + public int dimensions() { + return embeddingDimensions; + } + + private float[] generateVector(String text) { + float[] vector = new float[embeddingDimensions]; + int hash = text.hashCode(); + for (int i = 0; i < embeddingDimensions; i++) { + vector[i] = (float) Math.sin(hash + i) * 0.1f; + } + return vector; + } + + private float[] generateVectorWithoutCount(String text) { + float[] vector = new float[embeddingDimensions]; + int hash = text.hashCode(); + for (int i = 0; i < embeddingDimensions; i++) { + vector[i] = (float) Math.sin(hash + i) * 0.1f; + } + return vector; + } + } +} From fa187715cca27087d3b8dfa024f178adeeba988c Mon Sep 17 00:00:00 2001 From: Brian Sam-Bodden Date: Sat, 13 Dec 2025 09:32:17 -0700 Subject: [PATCH 05/12] feat(vcr): add JUnit 5 annotation-based VCR integration Implements declarative VCR support via JUnit 5 annotations: - @VCRModel: marks model fields for automatic VCR wrapping - VCRModelWrapper: wraps LangChain4J and Spring AI models automatically - VCRContext: manages Redis container, cassette store, and test state - VCRExtension: JUnit 5 extension for lifecycle management Features: - VCR_MODE environment variable support for runtime mode override - Automatic model detection (LangChain4J/Spring AI embedding/chat) - Cassette store integration for Redis persistence - Test isolation with per-test call counters - Statistics tracking across test session Example usage: @VCRTest(mode = VCRMode.PLAYBACK_OR_RECORD) class MyTest { @VCRModel private EmbeddingModel model = createModel(); } --- .../com/redis/vl/test/vcr/VCRContext.java | 58 +++++- .../com/redis/vl/test/vcr/VCRExtension.java | 119 ++++++++++- .../java/com/redis/vl/test/vcr/VCRModel.java | 59 ++++++ .../redis/vl/test/vcr/VCRModelWrapper.java | 186 ++++++++++++++++++ .../java/com/redis/vl/test/vcr/VCRTest.java | 6 +- .../redis/vl/test/vcr/VCRAnnotationsTest.java | 4 +- 6 files changed, 419 insertions(+), 13 deletions(-) create mode 100644 core/src/main/java/com/redis/vl/test/vcr/VCRModel.java create mode 100644 core/src/main/java/com/redis/vl/test/vcr/VCRModelWrapper.java diff --git a/core/src/main/java/com/redis/vl/test/vcr/VCRContext.java b/core/src/main/java/com/redis/vl/test/vcr/VCRContext.java index 150039b..b5786e0 100644 --- a/core/src/main/java/com/redis/vl/test/vcr/VCRContext.java +++ b/core/src/main/java/com/redis/vl/test/vcr/VCRContext.java @@ -35,6 +35,7 @@ public class VCRContext { private GenericContainer redisContainer; private JedisPooled jedis; private VCRRegistry registry; + private VCRCassetteStore cassetteStore; private String currentTestId; private VCRMode effectiveMode; @@ -46,15 +47,55 @@ public class VCRContext { private final AtomicLong cacheMisses = new AtomicLong(); private final AtomicLong apiCalls = new AtomicLong(); + /** Environment variable name for overriding VCR mode. */ + public static final String VCR_MODE_ENV = "VCR_MODE"; + /** * Creates a new VCR context with the given configuration. * + *

The VCR mode can be overridden via the {@code VCR_MODE} environment variable. Valid values + * are: PLAYBACK, PLAYBACK_OR_RECORD, RECORD, OFF. If the environment variable is set, it takes + * precedence over the annotation's mode setting. + * * @param config the VCR test configuration */ public VCRContext(VCRTest config) { this.config = config; this.dataDir = Path.of(config.dataDir()); - this.effectiveMode = config.mode(); + this.effectiveMode = resolveMode(config.mode()); + } + + /** + * Resolves the effective VCR mode, checking the environment variable first. + * + * @param annotationMode the mode specified in the annotation + * @return the effective mode (env var takes precedence) + */ + private static VCRMode resolveMode(VCRMode annotationMode) { + String envMode = System.getenv(VCR_MODE_ENV); + if (envMode != null && !envMode.isEmpty()) { + try { + VCRMode mode = VCRMode.valueOf(envMode.toUpperCase()); + System.out.println( + "VCR: Using mode from " + + VCR_MODE_ENV + + " environment variable: " + + mode + + " (annotation was: " + + annotationMode + + ")"); + return mode; + } catch (IllegalArgumentException e) { + System.err.println( + "VCR: Invalid " + + VCR_MODE_ENV + + " value '" + + envMode + + "'. Valid values: PLAYBACK, PLAYBACK_OR_RECORD, RECORD, OFF. Using annotation value: " + + annotationMode); + } + } + return annotationMode; } /** @@ -69,8 +110,21 @@ public void initialize() throws Exception { // Start Redis container with persistence startRedis(); - // Initialize registry + // Initialize registry and cassette store registry = new VCRRegistry(jedis); + cassetteStore = new VCRCassetteStore(jedis); + } + + /** + * Gets the cassette store for storing/retrieving cassettes. + * + * @return the cassette store + */ + @SuppressFBWarnings( + value = "EI_EXPOSE_REP", + justification = "Callers need direct access to shared cassette store") + public VCRCassetteStore getCassetteStore() { + return cassetteStore; } /** Starts the Redis container with appropriate persistence configuration. */ diff --git a/core/src/main/java/com/redis/vl/test/vcr/VCRExtension.java b/core/src/main/java/com/redis/vl/test/vcr/VCRExtension.java index 2b3669d..557789c 100644 --- a/core/src/main/java/com/redis/vl/test/vcr/VCRExtension.java +++ b/core/src/main/java/com/redis/vl/test/vcr/VCRExtension.java @@ -1,11 +1,16 @@ package com.redis.vl.test.vcr; +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.List; import org.junit.jupiter.api.extension.AfterAllCallback; import org.junit.jupiter.api.extension.AfterEachCallback; import org.junit.jupiter.api.extension.BeforeAllCallback; import org.junit.jupiter.api.extension.BeforeEachCallback; import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.api.extension.TestWatcher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** * JUnit 5 extension that provides VCR (Video Cassette Recorder) functionality for recording and @@ -17,17 +22,33 @@ *

  • Redis container lifecycle with AOF/RDB persistence *
  • Cassette storage and retrieval *
  • Test context and call counter management - *
  • LLM call interception via ByteBuddy + *
  • Automatic wrapping of {@code @VCRModel} annotated fields * * - *

    Usage: + *

    Usage with declarative field wrapping: * *

    {@code
    - * @VCRTest(mode = VCRMode.PLAYBACK)
    + * @VCRTest(mode = VCRMode.PLAYBACK_OR_RECORD)
      * class MyLLMTest {
    + *
    + *     @VCRModel
    + *     private EmbeddingModel embeddingModel;
    + *
    + *     @VCRModel
    + *     private ChatModel chatModel;
    + *
    + *     @BeforeEach
    + *     void setup() {
    + *         // Initialize models normally - VCR wraps them automatically
    + *         embeddingModel = new OpenAiEmbeddingModel(...);
    + *         chatModel = new OpenAiChatModel(...);
    + *     }
    + *
      *     @Test
      *     void testLLMCall() {
      *         // LLM calls are automatically recorded/replayed
    + *         embeddingModel.embed("Hello");
    + *         chatModel.generate("What is Redis?");
      *     }
      * }
      * }
    @@ -39,6 +60,8 @@ public class VCRExtension AfterEachCallback, TestWatcher { + private static final Logger LOG = LoggerFactory.getLogger(VCRExtension.class); + private static final ExtensionContext.Namespace NAMESPACE = ExtensionContext.Namespace.create(VCRExtension.class); @@ -80,13 +103,97 @@ public void beforeEach(ExtensionContext extensionContext) throws Exception { // Check for method-level mode overrides var method = extensionContext.getRequiredTestMethod(); + VCRMode effectiveMode; if (method.isAnnotationPresent(VCRDisabled.class)) { - context.setEffectiveMode(VCRMode.OFF); + effectiveMode = VCRMode.OFF; } else if (method.isAnnotationPresent(VCRRecord.class)) { - context.setEffectiveMode(VCRMode.RECORD); + effectiveMode = VCRMode.RECORD; } else { // Use class-level or default mode - context.setEffectiveMode(context.getConfiguredMode()); + effectiveMode = context.getConfiguredMode(); + } + context.setEffectiveMode(effectiveMode); + + // Wrap @VCRModel annotated fields with cassette store + wrapAnnotatedFields(extensionContext, testId, effectiveMode, context.getCassetteStore()); + } + + /** + * Scans the test instance for fields annotated with {@code @VCRModel} and wraps them with VCR + * interceptors. + */ + private void wrapAnnotatedFields( + ExtensionContext extensionContext, + String testId, + VCRMode mode, + VCRCassetteStore cassetteStore) { + + if (mode == VCRMode.OFF) { + return; // Don't wrap if VCR is disabled + } + + Object testInstance = extensionContext.getRequiredTestInstance(); + Class testClass = testInstance.getClass(); + + // Collect all fields including from parent classes + List allFields = new ArrayList<>(); + Class currentClass = testClass; + while (currentClass != null && currentClass != Object.class) { + for (Field field : currentClass.getDeclaredFields()) { + allFields.add(field); + } + currentClass = currentClass.getSuperclass(); + } + + // Also check fields from nested test classes + Class enclosingClass = testClass.getEnclosingClass(); + if (enclosingClass != null) { + // For nested classes, we need to access the outer instance + for (Field field : enclosingClass.getDeclaredFields()) { + if (field.isAnnotationPresent(VCRModel.class)) { + wrapFieldInEnclosingInstance( + testInstance, enclosingClass, field, testId, mode, cassetteStore); + } + } + } + + // Wrap fields in the test instance + for (Field field : allFields) { + if (field.isAnnotationPresent(VCRModel.class)) { + VCRModel annotation = field.getAnnotation(VCRModel.class); + VCRModelWrapper.wrapField( + testInstance, field, testId, mode, annotation.modelName(), cassetteStore); + } + } + } + + /** Wraps a field in the enclosing instance of a nested test class. */ + @SuppressWarnings("java:S3011") // Reflection access is intentional + private void wrapFieldInEnclosingInstance( + Object nestedInstance, + Class enclosingClass, + Field field, + String testId, + VCRMode mode, + VCRCassetteStore cassetteStore) { + try { + // Find the synthetic field that holds reference to the enclosing instance + for (Field syntheticField : nestedInstance.getClass().getDeclaredFields()) { + if (syntheticField.getName().startsWith("this$") + && syntheticField.getType().equals(enclosingClass)) { + syntheticField.setAccessible(true); + Object enclosingInstance = syntheticField.get(nestedInstance); + + if (enclosingInstance != null) { + VCRModel annotation = field.getAnnotation(VCRModel.class); + VCRModelWrapper.wrapField( + enclosingInstance, field, testId, mode, annotation.modelName(), cassetteStore); + } + break; + } + } + } catch (IllegalAccessException e) { + LOG.warn("Failed to access enclosing instance: {}", e.getMessage()); } } diff --git a/core/src/main/java/com/redis/vl/test/vcr/VCRModel.java b/core/src/main/java/com/redis/vl/test/vcr/VCRModel.java new file mode 100644 index 0000000..29057ba --- /dev/null +++ b/core/src/main/java/com/redis/vl/test/vcr/VCRModel.java @@ -0,0 +1,59 @@ +package com.redis.vl.test.vcr; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a field to be automatically wrapped with VCR recording/playback functionality. + * + *

    When applied to an {@code EmbeddingModel} or {@code ChatModel} field, the VCR extension will + * automatically wrap the model with the appropriate VCR wrapper after it's initialized. + * + *

    Usage: + * + *

    {@code
    + * @VCRTest(mode = VCRMode.PLAYBACK_OR_RECORD)
    + * class MyLLMTest {
    + *
    + *     @VCRModel
    + *     private EmbeddingModel embeddingModel;
    + *
    + *     @VCRModel
    + *     private ChatModel chatModel;
    + *
    + *     @BeforeEach
    + *     void setup() {
    + *         // Initialize your models normally - VCR will wrap them automatically
    + *         embeddingModel = new OpenAiEmbeddingModel(...);
    + *         chatModel = new OpenAiChatModel(...);
    + *     }
    + *
    + *     @Test
    + *     void testEmbedding() {
    + *         // Use the model - calls are recorded/replayed transparently
    + *         float[] embedding = embeddingModel.embed("Hello").content();
    + *     }
    + * }
    + * }
    + * + *

    Supported model types: + * + *

      + *
    • LangChain4J: {@code dev.langchain4j.model.embedding.EmbeddingModel}, {@code + * dev.langchain4j.model.chat.ChatLanguageModel} + *
    • Spring AI: {@code org.springframework.ai.embedding.EmbeddingModel}, {@code + * org.springframework.ai.chat.model.ChatModel} + *
    + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface VCRModel { + + /** + * Optional model name for embedding cache key generation. If not specified, the field name will + * be used. + */ + String modelName() default ""; +} diff --git a/core/src/main/java/com/redis/vl/test/vcr/VCRModelWrapper.java b/core/src/main/java/com/redis/vl/test/vcr/VCRModelWrapper.java new file mode 100644 index 0000000..0667dad --- /dev/null +++ b/core/src/main/java/com/redis/vl/test/vcr/VCRModelWrapper.java @@ -0,0 +1,186 @@ +package com.redis.vl.test.vcr; + +import java.lang.reflect.Field; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Utility class for wrapping model instances with VCR interceptors. + * + *

    This class provides methods to wrap various LLM model types (LangChain4J, Spring AI) with + * their corresponding VCR wrappers. + */ +public final class VCRModelWrapper { + + private static final Logger LOG = LoggerFactory.getLogger(VCRModelWrapper.class); + + // Class names for type checking (avoid compile-time dependencies) + private static final String LANGCHAIN4J_EMBEDDING_MODEL = + "dev.langchain4j.model.embedding.EmbeddingModel"; + private static final String LANGCHAIN4J_CHAT_MODEL = + "dev.langchain4j.model.chat.ChatLanguageModel"; + private static final String SPRING_AI_EMBEDDING_MODEL = + "org.springframework.ai.embedding.EmbeddingModel"; + private static final String SPRING_AI_CHAT_MODEL = "org.springframework.ai.chat.model.ChatModel"; + + private VCRModelWrapper() { + // Utility class + } + + /** + * Wraps a model field with the appropriate VCR interceptor. + * + * @param testInstance the test instance containing the field + * @param field the field to wrap + * @param testId the current test identifier + * @param mode the VCR mode + * @param modelName optional model name for the wrapper + * @param cassetteStore the cassette store for persistence + * @return true if the field was wrapped, false otherwise + */ + @SuppressWarnings({"unchecked", "java:S3011"}) // Reflection access is intentional + public static boolean wrapField( + Object testInstance, + Field field, + String testId, + VCRMode mode, + String modelName, + VCRCassetteStore cassetteStore) { + + try { + field.setAccessible(true); + Object model = field.get(testInstance); + + if (model == null) { + LOG.debug("Field {} is null, skipping VCR wrapping", field.getName()); + return false; + } + + String effectiveModelName = modelName.isEmpty() ? field.getName() : modelName; + Object wrapped = wrapModel(model, testId, mode, effectiveModelName, cassetteStore); + + if (wrapped != null && wrapped != model) { + field.set(testInstance, wrapped); + LOG.info("Wrapped field {} with VCR interceptor (mode: {})", field.getName(), mode); + return true; + } + + return false; + } catch (IllegalAccessException e) { + LOG.warn("Failed to wrap field {}: {}", field.getName(), e.getMessage()); + return false; + } + } + + /** + * Wraps a model with the appropriate VCR interceptor based on its type. + * + * @param model the model to wrap + * @param testId the test identifier + * @param mode the VCR mode + * @param modelName the model name for cache keys + * @param cassetteStore the cassette store for persistence + * @return the wrapped model, or null if the model type is not supported + */ + public static Object wrapModel( + Object model, String testId, VCRMode mode, String modelName, VCRCassetteStore cassetteStore) { + Class modelClass = model.getClass(); + + // Check LangChain4J EmbeddingModel + if (implementsInterface(modelClass, LANGCHAIN4J_EMBEDDING_MODEL)) { + return wrapLangChain4JEmbeddingModel(model, testId, mode, modelName, cassetteStore); + } + + // Check LangChain4J ChatLanguageModel + if (implementsInterface(modelClass, LANGCHAIN4J_CHAT_MODEL)) { + return wrapLangChain4JChatModel(model, testId, mode, cassetteStore); + } + + // Check Spring AI EmbeddingModel + if (implementsInterface(modelClass, SPRING_AI_EMBEDDING_MODEL)) { + return wrapSpringAIEmbeddingModel(model, testId, mode, modelName, cassetteStore); + } + + // Check Spring AI ChatModel + if (implementsInterface(modelClass, SPRING_AI_CHAT_MODEL)) { + return wrapSpringAIChatModel(model, testId, mode, cassetteStore); + } + + LOG.warn("Unsupported model type for VCR wrapping: {}", modelClass.getName()); + return null; + } + + @SuppressWarnings("unchecked") + private static Object wrapLangChain4JEmbeddingModel( + Object model, String testId, VCRMode mode, String modelName, VCRCassetteStore cassetteStore) { + try { + var wrapper = + new VCREmbeddingModel( + (dev.langchain4j.model.embedding.EmbeddingModel) model, cassetteStore); + wrapper.setTestId(testId); + wrapper.setMode(mode); + wrapper.setModelName(modelName); + return wrapper; + } catch (NoClassDefFoundError e) { + LOG.debug("LangChain4J not available: {}", e.getMessage()); + return null; + } + } + + @SuppressWarnings("unchecked") + private static Object wrapLangChain4JChatModel( + Object model, String testId, VCRMode mode, VCRCassetteStore cassetteStore) { + try { + var wrapper = + new VCRChatModel((dev.langchain4j.model.chat.ChatLanguageModel) model, cassetteStore); + wrapper.setTestId(testId); + wrapper.setMode(mode); + return wrapper; + } catch (NoClassDefFoundError e) { + LOG.debug("LangChain4J not available: {}", e.getMessage()); + return null; + } + } + + @SuppressWarnings("unchecked") + private static Object wrapSpringAIEmbeddingModel( + Object model, String testId, VCRMode mode, String modelName, VCRCassetteStore cassetteStore) { + try { + var wrapper = + new VCRSpringAIEmbeddingModel( + (org.springframework.ai.embedding.EmbeddingModel) model, cassetteStore); + wrapper.setTestId(testId); + wrapper.setMode(mode); + wrapper.setModelName(modelName); + return wrapper; + } catch (NoClassDefFoundError e) { + LOG.debug("Spring AI not available: {}", e.getMessage()); + return null; + } + } + + @SuppressWarnings("unchecked") + private static Object wrapSpringAIChatModel( + Object model, String testId, VCRMode mode, VCRCassetteStore cassetteStore) { + try { + var wrapper = + new VCRSpringAIChatModel( + (org.springframework.ai.chat.model.ChatModel) model, cassetteStore); + wrapper.setTestId(testId); + wrapper.setMode(mode); + return wrapper; + } catch (NoClassDefFoundError e) { + LOG.debug("Spring AI not available: {}", e.getMessage()); + return null; + } + } + + private static boolean implementsInterface(Class clazz, String interfaceName) { + try { + Class iface = Class.forName(interfaceName); + return iface.isAssignableFrom(clazz); + } catch (ClassNotFoundException e) { + return false; + } + } +} diff --git a/core/src/main/java/com/redis/vl/test/vcr/VCRTest.java b/core/src/main/java/com/redis/vl/test/vcr/VCRTest.java index 262be39..3464109 100644 --- a/core/src/main/java/com/redis/vl/test/vcr/VCRTest.java +++ b/core/src/main/java/com/redis/vl/test/vcr/VCRTest.java @@ -31,7 +31,7 @@ *

    Configuration options: * *

      - *
    • {@link #mode()} - The VCR operating mode (default: PLAYBACK) + *
    • {@link #mode()} - The VCR operating mode (default: PLAYBACK_OR_RECORD) *
    • {@link #dataDir()} - Directory for storing cassettes (default: src/test/resources/vcr-data) *
    • {@link #redisImage()} - Docker image for Redis container (default: * redis/redis-stack:latest) @@ -49,9 +49,9 @@ /** * The VCR operating mode for this test class. * - * @return the VCR mode to use (default: PLAYBACK) + * @return the VCR mode to use (default: PLAYBACK_OR_RECORD) */ - VCRMode mode() default VCRMode.PLAYBACK; + VCRMode mode() default VCRMode.PLAYBACK_OR_RECORD; /** * The directory where VCR cassettes (recorded responses) are stored. diff --git a/core/src/test/java/com/redis/vl/test/vcr/VCRAnnotationsTest.java b/core/src/test/java/com/redis/vl/test/vcr/VCRAnnotationsTest.java index 0f4cdd4..fbc1762 100644 --- a/core/src/test/java/com/redis/vl/test/vcr/VCRAnnotationsTest.java +++ b/core/src/test/java/com/redis/vl/test/vcr/VCRAnnotationsTest.java @@ -41,10 +41,10 @@ void vcrTestShouldHaveModeAttribute() throws NoSuchMethodException { } @Test - void vcrTestShouldHaveDefaultModeOfPlayback() throws NoSuchMethodException { + void vcrTestShouldHaveDefaultModeOfPlaybackOrRecord() throws NoSuchMethodException { var method = VCRTest.class.getMethod("mode"); VCRMode defaultValue = (VCRMode) method.getDefaultValue(); - assertThat(defaultValue).isEqualTo(VCRMode.PLAYBACK); + assertThat(defaultValue).isEqualTo(VCRMode.PLAYBACK_OR_RECORD); } @Test From 21431ace90d155e2df99c9d00b8449bc2060f27c Mon Sep 17 00:00:00 2001 From: Brian Sam-Bodden Date: Sat, 13 Dec 2025 09:32:31 -0700 Subject: [PATCH 06/12] feat(demo): add LangChain4J VCR demo project Adds a complete demo project showing VCR usage with LangChain4J: - LangChain4JVCRDemoTest: demonstrates embedding and chat model recording - Pre-recorded cassettes in src/test/resources/vcr-data/ - README with usage instructions and best practices Demo tests: - Single and batch text embedding - Chat completion with various prompts - Combined RAG-style workflow (embed + generate) Run without API key using pre-recorded cassettes: ./gradlew :demos:langchain4j-vcr:test --- demos/langchain4j-vcr/README.md | 177 +++ demos/langchain4j-vcr/build.gradle.kts | 65 + .../vl/demo/vcr/LangChain4JVCRDemoTest.java | 179 +++ .../appendonlydir/appendonly.aof.1.base.rdb | Bin 0 -> 131 bytes .../appendonlydir/appendonly.aof.1.incr.aof | 1323 +++++++++++++++++ .../appendonlydir/appendonly.aof.manifest | 2 + .../src/test/resources/vcr-data/dump.rdb | Bin 0 -> 70192 bytes 7 files changed, 1746 insertions(+) create mode 100644 demos/langchain4j-vcr/README.md create mode 100644 demos/langchain4j-vcr/build.gradle.kts create mode 100644 demos/langchain4j-vcr/src/test/java/com/redis/vl/demo/vcr/LangChain4JVCRDemoTest.java create mode 100644 demos/langchain4j-vcr/src/test/resources/vcr-data/appendonlydir/appendonly.aof.1.base.rdb create mode 100644 demos/langchain4j-vcr/src/test/resources/vcr-data/appendonlydir/appendonly.aof.1.incr.aof create mode 100644 demos/langchain4j-vcr/src/test/resources/vcr-data/appendonlydir/appendonly.aof.manifest create mode 100644 demos/langchain4j-vcr/src/test/resources/vcr-data/dump.rdb diff --git a/demos/langchain4j-vcr/README.md b/demos/langchain4j-vcr/README.md new file mode 100644 index 0000000..36cfb55 --- /dev/null +++ b/demos/langchain4j-vcr/README.md @@ -0,0 +1,177 @@ +# LangChain4J VCR Demo + +This demo shows how to use the VCR (Video Cassette Recorder) test system with LangChain4J models. VCR records LLM/embedding API responses to Redis and replays them in subsequent test runs, enabling fast, deterministic, and cost-effective testing. + +## Features + +- Record and replay LangChain4J `EmbeddingModel` responses +- Record and replay LangChain4J `ChatLanguageModel` responses +- Declarative `@VCRTest` and `@VCRModel` annotations +- Automatic model wrapping via JUnit 5 extension +- Redis-backed persistence with automatic test isolation + +## Quick Start + +### 1. Annotate Your Test Class + +```java +import com.redis.vl.test.vcr.VCRMode; +import com.redis.vl.test.vcr.VCRModel; +import com.redis.vl.test.vcr.VCRTest; + +@VCRTest(mode = VCRMode.PLAYBACK_OR_RECORD) +class MyLangChain4JTest { + + @VCRModel(modelName = "text-embedding-3-small") + private EmbeddingModel embeddingModel = createEmbeddingModel(); + + @VCRModel + private ChatLanguageModel chatModel = createChatModel(); + + // Models must be initialized at field declaration time, + // not in @BeforeEach (VCR wrapping happens before @BeforeEach) +} +``` + +### 2. Use Models Normally + +```java +@Test +void shouldEmbedText() { + // First run: calls real API and records response + // Subsequent runs: replays from Redis cassette + Response response = embeddingModel.embed("What is Redis?"); + + assertNotNull(response.content()); +} + +@Test +void shouldGenerateResponse() { + String response = chatModel.generate("Explain Redis in one sentence."); + + assertNotNull(response); +} +``` + +## VCR Modes + +| Mode | Description | API Key Required | +|------|-------------|------------------| +| `PLAYBACK` | Only use recorded cassettes. Fails if cassette missing. | No | +| `PLAYBACK_OR_RECORD` | Use cassette if available, record if not. | Only for first run | +| `RECORD` | Always call real API and record response. | Yes | +| `OFF` | Bypass VCR, always call real API. | Yes | + +### Setting Mode via Environment Variable + +Override the annotation mode at runtime without changing code: + +```bash +# Record new cassettes +VCR_MODE=RECORD ./gradlew :demos:langchain4j-vcr:test + +# Playback only (CI/CD, no API key needed) +VCR_MODE=PLAYBACK ./gradlew :demos:langchain4j-vcr:test + +# Default behavior from annotation +./gradlew :demos:langchain4j-vcr:test +``` + +## Running the Demo + +### With Pre-recorded Cassettes (No API Key) + +The demo includes pre-recorded cassettes in `src/test/resources/vcr-data/`. Run tests without an API key: + +```bash +./gradlew :demos:langchain4j-vcr:test +``` + +### Recording New Cassettes + +To record fresh cassettes, set your OpenAI API key: + +```bash +OPENAI_API_KEY=your-key VCR_MODE=RECORD ./gradlew :demos:langchain4j-vcr:test +``` + +## How It Works + +1. **Test Setup**: `@VCRTest` annotation triggers the VCR JUnit 5 extension +2. **Container Start**: A Redis Stack container is started with persistence enabled +3. **Model Wrapping**: Fields annotated with `@VCRModel` are wrapped with VCR proxies +4. **Recording**: When a model is called, VCR checks for existing cassette: + - **Cache hit**: Returns recorded response + - **Cache miss**: Calls real API, stores response as cassette +5. **Persistence**: Cassettes are saved to `vcr-data/` directory via Redis persistence +6. **Cleanup**: Container stops, data persists for next run + +## Cassette Storage + +Cassettes are stored in Redis JSON format with keys like: + +``` +vcr:embedding:MyTest.testMethod:0001 +vcr:chat:MyTest.testMethod:0001 +``` + +Data persists to `src/test/resources/vcr-data/` via Redis AOF/RDB. + +## Test Structure + +``` +demos/langchain4j-vcr/ +โ”œโ”€โ”€ src/test/java/ +โ”‚ โ””โ”€โ”€ com/redis/vl/demo/vcr/ +โ”‚ โ””โ”€โ”€ LangChain4JVCRDemoTest.java +โ””โ”€โ”€ src/test/resources/ + โ””โ”€โ”€ vcr-data/ # Persisted cassettes + โ”œโ”€โ”€ appendonly.aof + โ””โ”€โ”€ dump.rdb +``` + +## Configuration Options + +### @VCRTest Annotation + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `mode` | `PLAYBACK_OR_RECORD` | VCR operating mode | +| `dataDir` | `src/test/resources/vcr-data` | Cassette storage directory | +| `redisImage` | `redis/redis-stack:latest` | Redis Docker image | + +### @VCRModel Annotation + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `modelName` | `""` | Optional model identifier for logging | + +## Best Practices + +1. **Initialize models at field declaration** - Not in `@BeforeEach` +2. **Use dummy API key in PLAYBACK mode** - VCR will use cached responses +3. **Commit cassettes to version control** - Enables reproducible tests +4. **Use specific test names** - Cassette keys include test class and method names +5. **Re-record periodically** - API responses may change over time + +## Troubleshooting + +### Tests fail with "Cassette missing" + +- Ensure cassettes exist in `src/test/resources/vcr-data/` +- Run once with `VCR_MODE=RECORD` and API key to generate cassettes + +### API key required error + +- In `PLAYBACK` mode, use a dummy key: `"vcr-playback-mode"` +- VCR won't call the real API when cassettes exist + +### Tests pass but call real API + +- Verify models are initialized at field declaration, not `@BeforeEach` +- Check that `@VCRModel` annotation is present on model fields + +## See Also + +- [Spring AI VCR Demo](../spring-ai-vcr/README.md) +- [VCR Test System Documentation](../../README.md#-experimental-vcr-test-system) diff --git a/demos/langchain4j-vcr/build.gradle.kts b/demos/langchain4j-vcr/build.gradle.kts new file mode 100644 index 0000000..5d6c572 --- /dev/null +++ b/demos/langchain4j-vcr/build.gradle.kts @@ -0,0 +1,65 @@ +plugins { + java +} + +group = "com.redis.vl.demo" +version = "0.12.0" + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + // RedisVL Core (includes VCR support) + implementation(project(":core")) + + // SpotBugs annotations + compileOnly("com.github.spotbugs:spotbugs-annotations:4.8.3") + + // LangChain4J + implementation("dev.langchain4j:langchain4j:0.36.2") + implementation("dev.langchain4j:langchain4j-open-ai:0.36.2") + + // Redis + implementation("redis.clients:jedis:5.2.0") + + // Logging + implementation("org.slf4j:slf4j-api:2.0.16") + runtimeOnly("ch.qos.logback:logback-classic:1.5.15") + + // Testing + testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testCompileOnly("com.github.spotbugs:spotbugs-annotations:4.8.3") + + // TestContainers for integration tests + testImplementation("org.testcontainers:testcontainers:1.19.3") + testImplementation("org.testcontainers:junit-jupiter:1.19.3") +} + +tasks.withType { + options.encoding = "UTF-8" + options.compilerArgs.addAll(listOf( + "-parameters", + "-Xlint:all", + "-Xlint:-processing" + )) +} + +tasks.withType { + useJUnitPlatform() + testLogging { + events("passed", "skipped", "failed") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } + // Pass environment variables to tests + environment("OPENAI_API_KEY", System.getenv("OPENAI_API_KEY") ?: "") +} diff --git a/demos/langchain4j-vcr/src/test/java/com/redis/vl/demo/vcr/LangChain4JVCRDemoTest.java b/demos/langchain4j-vcr/src/test/java/com/redis/vl/demo/vcr/LangChain4JVCRDemoTest.java new file mode 100644 index 0000000..fc51a77 --- /dev/null +++ b/demos/langchain4j-vcr/src/test/java/com/redis/vl/demo/vcr/LangChain4JVCRDemoTest.java @@ -0,0 +1,179 @@ +package com.redis.vl.demo.vcr; + +import static org.junit.jupiter.api.Assertions.*; + +import com.redis.vl.test.vcr.VCRMode; +import com.redis.vl.test.vcr.VCRModel; +import com.redis.vl.test.vcr.VCRTest; +import dev.langchain4j.data.embedding.Embedding; +import dev.langchain4j.model.chat.ChatLanguageModel; +import dev.langchain4j.model.embedding.EmbeddingModel; +import dev.langchain4j.model.openai.OpenAiChatModel; +import dev.langchain4j.model.openai.OpenAiEmbeddingModel; +import dev.langchain4j.model.output.Response; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * VCR Demo Tests for LangChain4J. + * + *

      This demo shows how to use VCR (Video Cassette Recorder) functionality to record and replay + * LLM API calls with LangChain4J. + * + *

      How to Use VCR in Your Tests:

      + * + *
        + *
      1. Annotate your test class with {@code @VCRTest} + *
      2. Annotate your model fields with {@code @VCRModel} + *
      3. Initialize your models in {@code @BeforeEach} - VCR wraps them automatically + *
      4. Use the models normally - first run records, subsequent runs replay + *
      + * + *

      Benefits:

      + * + *
        + *
      • Fast, deterministic tests that don't call real LLM APIs after recording + *
      • Cost savings by avoiding repeated API calls + *
      • Offline development and CI/CD without API keys + *
      • Recorded data persists across test runs + *
      + */ +// VCR mode choices: +// - PLAYBACK: Uses pre-recorded cassettes only (requires recorded data, no API key needed) +// - PLAYBACK_OR_RECORD: Uses cassettes if available, records if not (needs API key for first run) +// - RECORD: Always records fresh data (always needs API key) +// This demo uses PLAYBACK since cassettes are pre-recorded and committed to the repo +@VCRTest(mode = VCRMode.PLAYBACK) +@DisplayName("LangChain4J VCR Demo") +class LangChain4JVCRDemoTest { + + // Annotate model fields with @VCRModel - VCR wraps them automatically! + // NOTE: Models must be initialized at field declaration time or in @BeforeAll, + // not in @BeforeEach, because VCR wrapping happens before @BeforeEach runs. + @VCRModel(modelName = "text-embedding-3-small") + private EmbeddingModel embeddingModel = createEmbeddingModel(); + + @VCRModel private ChatLanguageModel chatModel = createChatModel(); + + private static String getApiKey() { + String key = System.getenv("OPENAI_API_KEY"); + // In PLAYBACK mode, use a dummy key if none provided (VCR will use cached responses) + return (key == null || key.isEmpty()) ? "vcr-playback-mode" : key; + } + + private static EmbeddingModel createEmbeddingModel() { + return OpenAiEmbeddingModel.builder() + .apiKey(getApiKey()) + .modelName("text-embedding-3-small") + .build(); + } + + private static ChatLanguageModel createChatModel() { + return OpenAiChatModel.builder() + .apiKey(getApiKey()) + .modelName("gpt-4o-mini") + .temperature(0.0) + .build(); + } + + @Nested + @DisplayName("Embedding Model VCR Tests") + class EmbeddingModelTests { + + @Test + @DisplayName("should embed a single text about Redis") + void shouldEmbedSingleText() { + // Use the model - calls are recorded/replayed transparently + Response response = embeddingModel.embed("Redis is an in-memory data store"); + + assertNotNull(response); + assertNotNull(response.content()); + float[] vector = response.content().vector(); + assertNotNull(vector); + assertTrue(vector.length > 0, "Embedding should have dimensions"); + } + + @Test + @DisplayName("should embed text about vector search") + void shouldEmbedVectorSearchText() { + Response response = + embeddingModel.embed("Vector similarity search enables semantic retrieval"); + + assertNotNull(response); + float[] vector = response.content().vector(); + assertTrue(vector.length > 0); + } + + @Test + @DisplayName("should embed multiple related texts") + void shouldEmbedMultipleTexts() { + // Multiple calls - each gets its own cassette key + Response response1 = embeddingModel.embed("What is Redis?"); + Response response2 = embeddingModel.embed("Redis is a database"); + Response response3 = embeddingModel.embed("How does caching work?"); + + assertNotNull(response1.content()); + assertNotNull(response2.content()); + assertNotNull(response3.content()); + + // All embeddings should have the same dimensions + assertEquals(response1.content().vector().length, response2.content().vector().length); + assertEquals(response2.content().vector().length, response3.content().vector().length); + } + } + + @Nested + @DisplayName("Chat Model VCR Tests") + class ChatModelTests { + + @Test + @DisplayName("should answer a question about Redis") + void shouldAnswerRedisQuestion() { + // Use the chat model - calls are recorded/replayed transparently + String response = chatModel.generate("What is Redis in one sentence?"); + + assertNotNull(response); + assertFalse(response.isEmpty()); + } + + @Test + @DisplayName("should explain vector databases") + void shouldExplainVectorDatabases() { + String response = + chatModel.generate("Explain vector databases in two sentences for a developer."); + + assertNotNull(response); + assertTrue(response.length() > 20, "Response should be substantive"); + } + + @Test + @DisplayName("should provide code example") + void shouldProvideCodeExample() { + String response = chatModel.generate("Show a one-line Redis SET command example in Python."); + + assertNotNull(response); + } + } + + @Nested + @DisplayName("Combined RAG-style VCR Tests") + class CombinedTests { + + @Test + @DisplayName("should simulate RAG: embed query then generate answer") + void shouldSimulateRAG() { + // Step 1: Embed the user query (as you would to find relevant documents) + Response queryEmbedding = embeddingModel.embed("How do I use Redis for caching?"); + + assertNotNull(queryEmbedding.content()); + + // Step 2: Generate an answer (simulating after retrieval) + String answer = + chatModel.generate("Based on Redis documentation, explain caching in one sentence."); + + assertNotNull(answer); + assertFalse(answer.isEmpty()); + } + } +} diff --git a/demos/langchain4j-vcr/src/test/resources/vcr-data/appendonlydir/appendonly.aof.1.base.rdb b/demos/langchain4j-vcr/src/test/resources/vcr-data/appendonlydir/appendonly.aof.1.base.rdb new file mode 100644 index 0000000000000000000000000000000000000000..e426f2aec34b57b5e0b9c78a82903f9127c67024 GIT binary patch literal 131 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 Date: Sat, 13 Dec 2025 09:32:42 -0700 Subject: [PATCH 07/12] feat(demo): add Spring AI VCR demo project Adds a complete demo project showing VCR usage with Spring AI: - SpringAIVCRDemoTest: demonstrates embedding and chat model recording - Pre-recorded cassettes in src/test/resources/vcr-data/ - README with usage instructions and best practices Demo tests: - Single and batch text embedding - Chat completion with various prompts - Combined RAG-style workflow (embed + generate) Run without API key using pre-recorded cassettes: ./gradlew :demos:spring-ai-vcr:test --- demos/spring-ai-vcr/README.md | 223 +++++ demos/spring-ai-vcr/build.gradle.kts | 68 ++ .../vl/demo/vcr/SpringAIVCRDemoTest.java | 198 +++++ .../appendonlydir/appendonly.aof.1.base.rdb | Bin 0 -> 131 bytes .../appendonlydir/appendonly.aof.1.incr.aof | 809 ++++++++++++++++++ .../appendonlydir/appendonly.aof.manifest | 2 + .../src/test/resources/vcr-data/dump.rdb | Bin 0 -> 69771 bytes 7 files changed, 1300 insertions(+) create mode 100644 demos/spring-ai-vcr/README.md create mode 100644 demos/spring-ai-vcr/build.gradle.kts create mode 100644 demos/spring-ai-vcr/src/test/java/com/redis/vl/demo/vcr/SpringAIVCRDemoTest.java create mode 100644 demos/spring-ai-vcr/src/test/resources/vcr-data/appendonlydir/appendonly.aof.1.base.rdb create mode 100644 demos/spring-ai-vcr/src/test/resources/vcr-data/appendonlydir/appendonly.aof.1.incr.aof create mode 100644 demos/spring-ai-vcr/src/test/resources/vcr-data/appendonlydir/appendonly.aof.manifest create mode 100644 demos/spring-ai-vcr/src/test/resources/vcr-data/dump.rdb diff --git a/demos/spring-ai-vcr/README.md b/demos/spring-ai-vcr/README.md new file mode 100644 index 0000000..576d6a3 --- /dev/null +++ b/demos/spring-ai-vcr/README.md @@ -0,0 +1,223 @@ +# Spring AI VCR Demo + +This demo shows how to use the VCR (Video Cassette Recorder) test system with Spring AI models. VCR records LLM/embedding API responses to Redis and replays them in subsequent test runs, enabling fast, deterministic, and cost-effective testing. + +## Features + +- Record and replay Spring AI `EmbeddingModel` responses +- Record and replay Spring AI `ChatModel` responses +- Declarative `@VCRTest` and `@VCRModel` annotations +- Automatic model wrapping via JUnit 5 extension +- Redis-backed persistence with automatic test isolation + +## Quick Start + +### 1. Annotate Your Test Class + +```java +import com.redis.vl.test.vcr.VCRMode; +import com.redis.vl.test.vcr.VCRModel; +import com.redis.vl.test.vcr.VCRTest; + +@VCRTest(mode = VCRMode.PLAYBACK_OR_RECORD) +class MySpringAITest { + + @VCRModel(modelName = "text-embedding-3-small") + private EmbeddingModel embeddingModel = createEmbeddingModel(); + + @VCRModel + private ChatModel chatModel = createChatModel(); + + // Models must be initialized at field declaration time, + // not in @BeforeEach (VCR wrapping happens before @BeforeEach) +} +``` + +### 2. Use Models Normally + +```java +@Test +void shouldEmbedText() { + // First run: calls real API and records response + // Subsequent runs: replays from Redis cassette + EmbeddingResponse response = embeddingModel.embedForResponse( + List.of("What is Redis?") + ); + + assertNotNull(response.getResults().get(0)); +} + +@Test +void shouldGenerateResponse() { + String response = chatModel.call("Explain Redis in one sentence."); + + assertNotNull(response); +} +``` + +## VCR Modes + +| Mode | Description | API Key Required | +|------|-------------|------------------| +| `PLAYBACK` | Only use recorded cassettes. Fails if cassette missing. | No | +| `PLAYBACK_OR_RECORD` | Use cassette if available, record if not. | Only for first run | +| `RECORD` | Always call real API and record response. | Yes | +| `OFF` | Bypass VCR, always call real API. | Yes | + +### Setting Mode via Environment Variable + +Override the annotation mode at runtime without changing code: + +```bash +# Record new cassettes +VCR_MODE=RECORD ./gradlew :demos:spring-ai-vcr:test + +# Playback only (CI/CD, no API key needed) +VCR_MODE=PLAYBACK ./gradlew :demos:spring-ai-vcr:test + +# Default behavior from annotation +./gradlew :demos:spring-ai-vcr:test +``` + +## Running the Demo + +### With Pre-recorded Cassettes (No API Key) + +The demo includes pre-recorded cassettes in `src/test/resources/vcr-data/`. Run tests without an API key: + +```bash +./gradlew :demos:spring-ai-vcr:test +``` + +### Recording New Cassettes + +To record fresh cassettes, set your OpenAI API key: + +```bash +OPENAI_API_KEY=your-key VCR_MODE=RECORD ./gradlew :demos:spring-ai-vcr:test +``` + +## How It Works + +1. **Test Setup**: `@VCRTest` annotation triggers the VCR JUnit 5 extension +2. **Container Start**: A Redis Stack container is started with persistence enabled +3. **Model Wrapping**: Fields annotated with `@VCRModel` are wrapped with VCR proxies +4. **Recording**: When a model is called, VCR checks for existing cassette: + - **Cache hit**: Returns recorded response + - **Cache miss**: Calls real API, stores response as cassette +5. **Persistence**: Cassettes are saved to `vcr-data/` directory via Redis persistence +6. **Cleanup**: Container stops, data persists for next run + +## Cassette Storage + +Cassettes are stored in Redis JSON format with keys like: + +``` +vcr:embedding:MyTest.testMethod:0001 +vcr:chat:MyTest.testMethod:0001 +``` + +Data persists to `src/test/resources/vcr-data/` via Redis AOF/RDB. + +## Test Structure + +``` +demos/spring-ai-vcr/ +โ”œโ”€โ”€ src/test/java/ +โ”‚ โ””โ”€โ”€ com/redis/vl/demo/vcr/ +โ”‚ โ””โ”€โ”€ SpringAIVCRDemoTest.java +โ””โ”€โ”€ src/test/resources/ + โ””โ”€โ”€ vcr-data/ # Persisted cassettes + โ”œโ”€โ”€ appendonly.aof + โ””โ”€โ”€ dump.rdb +``` + +## Configuration Options + +### @VCRTest Annotation + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `mode` | `PLAYBACK_OR_RECORD` | VCR operating mode | +| `dataDir` | `src/test/resources/vcr-data` | Cassette storage directory | +| `redisImage` | `redis/redis-stack:latest` | Redis Docker image | + +### @VCRModel Annotation + +| Parameter | Default | Description | +|-----------|---------|-------------| +| `modelName` | `""` | Optional model identifier for logging | + +## Spring AI Specifics + +### Supported Model Types + +- `org.springframework.ai.embedding.EmbeddingModel` +- `org.springframework.ai.chat.model.ChatModel` + +### Creating Models for VCR + +```java +private static String getApiKey() { + String key = System.getenv("OPENAI_API_KEY"); + // In PLAYBACK mode, use a dummy key (VCR will use cached responses) + return (key == null || key.isEmpty()) ? "vcr-playback-mode" : key; +} + +private static EmbeddingModel createEmbeddingModel() { + OpenAiApi api = OpenAiApi.builder().apiKey(getApiKey()).build(); + OpenAiEmbeddingOptions options = OpenAiEmbeddingOptions.builder() + .model("text-embedding-3-small") + .build(); + return new OpenAiEmbeddingModel(api, MetadataMode.EMBED, options, + RetryUtils.DEFAULT_RETRY_TEMPLATE); +} + +private static ChatModel createChatModel() { + OpenAiApi api = OpenAiApi.builder().apiKey(getApiKey()).build(); + OpenAiChatOptions options = OpenAiChatOptions.builder() + .model("gpt-4o-mini") + .temperature(0.0) + .build(); + return OpenAiChatModel.builder() + .openAiApi(api) + .defaultOptions(options) + .build(); +} +``` + +## Best Practices + +1. **Initialize models at field declaration** - Not in `@BeforeEach` +2. **Use dummy API key in PLAYBACK mode** - VCR will use cached responses +3. **Commit cassettes to version control** - Enables reproducible tests +4. **Use specific test names** - Cassette keys include test class and method names +5. **Re-record periodically** - API responses may change over time +6. **Set temperature to 0** - For deterministic LLM responses during recording + +## Troubleshooting + +### Tests fail with "Cassette missing" + +- Ensure cassettes exist in `src/test/resources/vcr-data/` +- Run once with `VCR_MODE=RECORD` and API key to generate cassettes + +### API key required error + +- In `PLAYBACK` mode, use a dummy key: `"vcr-playback-mode"` +- VCR won't call the real API when cassettes exist + +### Tests pass but call real API + +- Verify models are initialized at field declaration, not `@BeforeEach` +- Check that `@VCRModel` annotation is present on model fields + +### Spring AI version compatibility + +- VCR wrappers implement Spring AI interfaces +- Test with your specific Spring AI version for compatibility + +## See Also + +- [LangChain4J VCR Demo](../langchain4j-vcr/README.md) +- [VCR Test System Documentation](../../README.md#-experimental-vcr-test-system) diff --git a/demos/spring-ai-vcr/build.gradle.kts b/demos/spring-ai-vcr/build.gradle.kts new file mode 100644 index 0000000..57f5f20 --- /dev/null +++ b/demos/spring-ai-vcr/build.gradle.kts @@ -0,0 +1,68 @@ +plugins { + java +} + +group = "com.redis.vl.demo" +version = "0.12.0" + +java { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() + maven { + url = uri("https://repo.spring.io/milestone") + } +} + +dependencies { + // RedisVL Core (includes VCR support) + implementation(project(":core")) + + // SpotBugs annotations + compileOnly("com.github.spotbugs:spotbugs-annotations:4.8.3") + + // Spring AI 1.1.0 + implementation(platform("org.springframework.ai:spring-ai-bom:1.1.0")) + implementation("org.springframework.ai:spring-ai-openai") + + // Redis + implementation("redis.clients:jedis:5.2.0") + + // Logging + implementation("org.slf4j:slf4j-api:2.0.16") + runtimeOnly("ch.qos.logback:logback-classic:1.5.15") + + // Testing + testImplementation("org.junit.jupiter:junit-jupiter:5.11.4") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + testCompileOnly("com.github.spotbugs:spotbugs-annotations:4.8.3") + + // TestContainers for integration tests + testImplementation("org.testcontainers:testcontainers:1.19.3") + testImplementation("org.testcontainers:junit-jupiter:1.19.3") +} + +tasks.withType { + options.encoding = "UTF-8" + options.compilerArgs.addAll(listOf( + "-parameters", + "-Xlint:all", + "-Xlint:-processing" + )) +} + +tasks.withType { + useJUnitPlatform() + testLogging { + events("passed", "skipped", "failed") + exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL + } + // Pass environment variables to tests + environment("OPENAI_API_KEY", System.getenv("OPENAI_API_KEY") ?: "") +} diff --git a/demos/spring-ai-vcr/src/test/java/com/redis/vl/demo/vcr/SpringAIVCRDemoTest.java b/demos/spring-ai-vcr/src/test/java/com/redis/vl/demo/vcr/SpringAIVCRDemoTest.java new file mode 100644 index 0000000..81c8f93 --- /dev/null +++ b/demos/spring-ai-vcr/src/test/java/com/redis/vl/demo/vcr/SpringAIVCRDemoTest.java @@ -0,0 +1,198 @@ +package com.redis.vl.demo.vcr; + +import static org.junit.jupiter.api.Assertions.*; + +import com.redis.vl.test.vcr.VCRMode; +import com.redis.vl.test.vcr.VCRModel; +import com.redis.vl.test.vcr.VCRTest; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.openai.OpenAiChatModel; +import org.springframework.ai.openai.OpenAiChatOptions; +import org.springframework.ai.openai.OpenAiEmbeddingModel; +import org.springframework.ai.openai.OpenAiEmbeddingOptions; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.ai.retry.RetryUtils; + +/** + * VCR Demo Tests for Spring AI. + * + *

      This demo shows how to use VCR (Video Cassette Recorder) functionality to record and replay + * LLM API calls with Spring AI. + * + *

      How to Use VCR in Your Tests:

      + * + *
        + *
      1. Annotate your test class with {@code @VCRTest} + *
      2. Annotate your model fields with {@code @VCRModel} + *
      3. Initialize your models in {@code @BeforeEach} - VCR wraps them automatically + *
      4. Use the models normally - first run records, subsequent runs replay + *
      + * + *

      Benefits:

      + * + *
        + *
      • Fast, deterministic tests that don't call real LLM APIs after recording + *
      • Cost savings by avoiding repeated API calls + *
      • Offline development and CI/CD without API keys + *
      • Recorded data persists across test runs + *
      + */ +// VCR mode choices: +// - PLAYBACK: Uses pre-recorded cassettes only (requires recorded data, no API key needed) +// - PLAYBACK_OR_RECORD: Uses cassettes if available, records if not (needs API key for first run) +// - RECORD: Always records fresh data (always needs API key) +// This demo uses PLAYBACK since cassettes are pre-recorded and committed to the repo +@VCRTest(mode = VCRMode.PLAYBACK) +@DisplayName("Spring AI VCR Demo") +class SpringAIVCRDemoTest { + + // Annotate model fields with @VCRModel - VCR wraps them automatically! + // NOTE: Models must be initialized at field declaration time or in @BeforeAll, + // not in @BeforeEach, because VCR wrapping happens before @BeforeEach runs. + @VCRModel(modelName = "text-embedding-3-small") + private EmbeddingModel embeddingModel = createEmbeddingModel(); + + @VCRModel private ChatModel chatModel = createChatModel(); + + private static String getApiKey() { + String key = System.getenv("OPENAI_API_KEY"); + // In PLAYBACK mode, use a dummy key if none provided (VCR will use cached responses) + return (key == null || key.isEmpty()) ? "vcr-playback-mode" : key; + } + + private static OpenAiApi createOpenAiApi() { + return OpenAiApi.builder().apiKey(getApiKey()).build(); + } + + private static EmbeddingModel createEmbeddingModel() { + OpenAiEmbeddingOptions embeddingOptions = + OpenAiEmbeddingOptions.builder().model("text-embedding-3-small").build(); + return new OpenAiEmbeddingModel( + createOpenAiApi(), MetadataMode.EMBED, embeddingOptions, RetryUtils.DEFAULT_RETRY_TEMPLATE); + } + + private static ChatModel createChatModel() { + OpenAiChatOptions chatOptions = + OpenAiChatOptions.builder().model("gpt-4o-mini").temperature(0.0).build(); + return OpenAiChatModel.builder() + .openAiApi(createOpenAiApi()) + .defaultOptions(chatOptions) + .build(); + } + + @Nested + @DisplayName("Embedding Model VCR Tests") + class EmbeddingModelTests { + + @Test + @DisplayName("should embed a single text about Redis") + void shouldEmbedSingleText() { + // Use the model - calls are recorded/replayed transparently + EmbeddingResponse response = + embeddingModel.embedForResponse(List.of("Redis is an in-memory data store")); + + assertNotNull(response); + assertNotNull(response.getResults()); + assertFalse(response.getResults().isEmpty()); + float[] vector = response.getResults().get(0).getOutput(); + assertNotNull(vector); + assertTrue(vector.length > 0, "Embedding should have dimensions"); + } + + @Test + @DisplayName("should embed text about vector search") + void shouldEmbedVectorSearchText() { + EmbeddingResponse response = + embeddingModel.embedForResponse( + List.of("Vector similarity search enables semantic retrieval")); + + assertNotNull(response); + float[] vector = response.getResults().get(0).getOutput(); + assertTrue(vector.length > 0); + } + + @Test + @DisplayName("should embed multiple related texts") + void shouldEmbedMultipleTexts() { + // Multiple calls - each gets its own cassette key + EmbeddingResponse response1 = embeddingModel.embedForResponse(List.of("What is Redis?")); + EmbeddingResponse response2 = embeddingModel.embedForResponse(List.of("Redis is a database")); + EmbeddingResponse response3 = + embeddingModel.embedForResponse(List.of("How does caching work?")); + + assertNotNull(response1.getResults().get(0)); + assertNotNull(response2.getResults().get(0)); + assertNotNull(response3.getResults().get(0)); + + // All embeddings should have the same dimensions + assertEquals( + response1.getResults().get(0).getOutput().length, + response2.getResults().get(0).getOutput().length); + assertEquals( + response2.getResults().get(0).getOutput().length, + response3.getResults().get(0).getOutput().length); + } + } + + @Nested + @DisplayName("Chat Model VCR Tests") + class ChatModelTests { + + @Test + @DisplayName("should answer a question about Redis") + void shouldAnswerRedisQuestion() { + // Use the chat model - calls are recorded/replayed transparently + String response = chatModel.call("What is Redis in one sentence?"); + + assertNotNull(response); + assertFalse(response.isEmpty()); + } + + @Test + @DisplayName("should explain vector databases") + void shouldExplainVectorDatabases() { + String response = + chatModel.call("Explain vector databases in two sentences for a developer."); + + assertNotNull(response); + assertTrue(response.length() > 20, "Response should be substantive"); + } + + @Test + @DisplayName("should provide code example") + void shouldProvideCodeExample() { + String response = chatModel.call("Show a one-line Redis SET command example in Python."); + + assertNotNull(response); + } + } + + @Nested + @DisplayName("Combined RAG-style VCR Tests") + class CombinedTests { + + @Test + @DisplayName("should simulate RAG: embed query then generate answer") + void shouldSimulateRAG() { + // Step 1: Embed the user query (as you would to find relevant documents) + EmbeddingResponse queryEmbedding = + embeddingModel.embedForResponse(List.of("How do I use Redis for caching?")); + + assertNotNull(queryEmbedding.getResults().get(0)); + + // Step 2: Generate an answer (simulating after retrieval) + String answer = + chatModel.call("Based on Redis documentation, explain caching in one sentence."); + + assertNotNull(answer); + assertFalse(answer.isEmpty()); + } + } +} diff --git a/demos/spring-ai-vcr/src/test/resources/vcr-data/appendonlydir/appendonly.aof.1.base.rdb b/demos/spring-ai-vcr/src/test/resources/vcr-data/appendonlydir/appendonly.aof.1.base.rdb new file mode 100644 index 0000000000000000000000000000000000000000..4df9689eb7204cc81f9a6c2835163c44372e0714 GIT binary patch literal 131 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 literal 0 HcmV?d00001 From a778be85f67e16ec1c693b17c23b36ab08504343 Mon Sep 17 00:00:00 2001 From: Brian Sam-Bodden Date: Sat, 13 Dec 2025 09:32:54 -0700 Subject: [PATCH 08/12] build: add VCR demo projects to multi-project build Updates build configuration: - settings.gradle.kts: include langchain4j-vcr and spring-ai-vcr demos - build.gradle.kts: configure demo subprojects with dependencies - core/build.gradle.kts: add testcontainers dependency for VCR Demo projects inherit common configuration and add their specific AI framework dependencies (LangChain4J or Spring AI with OpenAI). --- build.gradle.kts | 3 +++ core/build.gradle.kts | 15 +++++++++++++++ settings.gradle.kts | 4 ++++ 3 files changed, 22 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index 8ca1e4e..a04d73e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -16,6 +16,9 @@ allprojects { maven { url = uri("https://repo.spring.io/milestone") } + maven { + url = uri("https://repo.spring.io/snapshot") + } } } diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 0dade85..7bba8d8 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -2,6 +2,7 @@ plugins { java `java-library` `maven-publish` + id("io.spring.dependency-management") version "1.1.7" } description = "RedisVL - Vector Library for Java" @@ -83,6 +84,11 @@ dependencies { testImplementation("dev.langchain4j:langchain4j-embeddings-all-minilm-l6-v2:0.36.2") testImplementation("dev.langchain4j:langchain4j-hugging-face:0.36.2") + // Spring AI for VCR testing (optional - users include what they need) + // Version managed by spring-ai-bom (spring-ai-model contains EmbeddingModel interface) + compileOnly("org.springframework.ai:spring-ai-model") + testImplementation("org.springframework.ai:spring-ai-model") + // Cohere for integration tests testImplementation("com.cohere:cohere-java:1.8.1") @@ -97,6 +103,15 @@ dependencies { testImplementation("net.bytebuddy:byte-buddy-agent:1.14.12") } +// Spring AI 1.1.0 - BOM for dependency management +val springAiVersion = "1.1.0" + +dependencyManagement { + imports { + mavenBom("org.springframework.ai:spring-ai-bom:$springAiVersion") + } +} + // Configure test execution tasks.test { // Exclude slow and integration tests by default diff --git a/settings.gradle.kts b/settings.gradle.kts index d2e4526..26cfa26 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -6,11 +6,15 @@ include("docs") // Demos include("demos:rag-multimodal") +include("demos:langchain4j-vcr") +include("demos:spring-ai-vcr") // Configure module locations project(":core").projectDir = file("core") project(":docs").projectDir = file("docs") project(":demos:rag-multimodal").projectDir = file("demos/rag-multimodal") +project(":demos:langchain4j-vcr").projectDir = file("demos/langchain4j-vcr") +project(":demos:spring-ai-vcr").projectDir = file("demos/spring-ai-vcr") // Enable build cache for faster builds buildCache { From 8b8370848275e618b6806e11d347cce53aa74458 Mon Sep 17 00:00:00 2001 From: Brian Sam-Bodden Date: Sat, 13 Dec 2025 09:33:06 -0700 Subject: [PATCH 09/12] docs(vcr): update VCR testing documentation Updates the AsciiDoc documentation for VCR testing: - Add VCR_MODE environment variable documentation - Document annotation-based approach with @VCRModel - Add troubleshooting section - Include demo project references --- .../modules/ROOT/pages/vcr-testing.adoc | 276 +++++++++++++++++- 1 file changed, 270 insertions(+), 6 deletions(-) diff --git a/docs/content/modules/ROOT/pages/vcr-testing.adoc b/docs/content/modules/ROOT/pages/vcr-testing.adoc index c07d661..e8a9208 100644 --- a/docs/content/modules/ROOT/pages/vcr-testing.adoc +++ b/docs/content/modules/ROOT/pages/vcr-testing.adoc @@ -333,14 +333,278 @@ If Redis container fails to start: 2. Check port availability 3. Verify the Redis image exists -== Future Enhancements +== Framework Integration -The following features are planned for future releases: +The VCR system provides drop-in wrappers for popular AI frameworks. These wrappers work standalone without requiring any other RedisVL components. -* **LLM Interceptor** - Automatic interception of LangChain4J LLM calls -* **Embedding Interceptor** - Integration with EmbeddingsCache -* **Cassette Management CLI** - Tools for managing recorded cassettes -* **Selective Recording** - Fine-grained control over what gets recorded +=== LangChain4J + +==== Embedding Model + +Use `VCREmbeddingModel` to wrap any LangChain4J `EmbeddingModel`: + +[source,java] +---- +import com.redis.vl.test.vcr.VCREmbeddingModel; +import com.redis.vl.test.vcr.VCRMode; +import dev.langchain4j.model.embedding.EmbeddingModel; +import dev.langchain4j.model.openai.OpenAiEmbeddingModel; +import dev.langchain4j.data.embedding.Embedding; +import dev.langchain4j.model.output.Response; + +// Create your LangChain4J embedding model +EmbeddingModel openAiModel = OpenAiEmbeddingModel.builder() + .apiKey(System.getenv("OPENAI_API_KEY")) + .modelName("text-embedding-3-small") + .build(); + +// Wrap with VCR for recording/playback +VCREmbeddingModel vcrModel = new VCREmbeddingModel(openAiModel); +vcrModel.setMode(VCRMode.PLAYBACK_OR_RECORD); +vcrModel.setTestId("MyTest.testEmbedding"); + +// Use exactly like the original model - VCR handles caching transparently +Response response = vcrModel.embed("What is Redis?"); +float[] vector = response.content().vector(); + +// Batch embeddings also supported +List segments = List.of( + TextSegment.from("Document chunk 1"), + TextSegment.from("Document chunk 2") +); +Response> batchResponse = vcrModel.embedAll(segments); +---- + +The `VCREmbeddingModel` implements the full `dev.langchain4j.model.embedding.EmbeddingModel` interface, so it can be used anywhere a LangChain4J embedding model is expected. + +==== Supported Methods + +* `embed(String text)` - Single text embedding +* `embed(TextSegment segment)` - TextSegment embedding +* `embedAll(List segments)` - Batch embedding +* `dimension()` - Returns embedding dimensions + +==== Chat Model + +Use `VCRChatModel` to wrap any LangChain4J `ChatLanguageModel`: + +[source,java] +---- +import com.redis.vl.test.vcr.VCRChatModel; +import com.redis.vl.test.vcr.VCRMode; +import dev.langchain4j.model.chat.ChatLanguageModel; +import dev.langchain4j.model.openai.OpenAiChatModel; +import dev.langchain4j.data.message.AiMessage; +import dev.langchain4j.data.message.UserMessage; +import dev.langchain4j.model.output.Response; + +// Create your LangChain4J chat model +ChatLanguageModel openAiModel = OpenAiChatModel.builder() + .apiKey(System.getenv("OPENAI_API_KEY")) + .modelName("gpt-4o-mini") + .build(); + +// Wrap with VCR for recording/playback +VCRChatModel vcrModel = new VCRChatModel(openAiModel); +vcrModel.setMode(VCRMode.PLAYBACK_OR_RECORD); +vcrModel.setTestId("MyTest.testChat"); + +// Use exactly like the original model - VCR handles caching transparently +Response response = vcrModel.generate(UserMessage.from("What is Redis?")); +String answer = response.content().text(); + +// Simple string convenience method +String simpleAnswer = vcrModel.generate("Tell me about Redis Vector Library"); + +// Multiple messages +Response chatResponse = vcrModel.generate( + UserMessage.from("What is Redis?"), + UserMessage.from("How does it handle vectors?") +); +---- + +The `VCRChatModel` implements the full `dev.langchain4j.model.chat.ChatLanguageModel` interface, so it can be used anywhere a LangChain4J chat model is expected. + +==== Supported Chat Methods + +* `generate(ChatMessage... messages)` - Generate from varargs messages +* `generate(List messages)` - Generate from list of messages +* `generate(String text)` - Simple string convenience method + +=== Spring AI + +==== Embedding Model + +Use `VCRSpringAIEmbeddingModel` to wrap any Spring AI `EmbeddingModel`: + +[source,java] +---- +import com.redis.vl.test.vcr.VCRSpringAIEmbeddingModel; +import com.redis.vl.test.vcr.VCRMode; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.openai.OpenAiEmbeddingModel; +import org.springframework.ai.document.Document; + +// Create your Spring AI embedding model +EmbeddingModel openAiModel = new OpenAiEmbeddingModel(openAiApi); + +// Wrap with VCR for recording/playback +VCRSpringAIEmbeddingModel vcrModel = new VCRSpringAIEmbeddingModel(openAiModel); +vcrModel.setMode(VCRMode.PLAYBACK_OR_RECORD); +vcrModel.setTestId("MyTest.testSpringAIEmbedding"); + +// Use exactly like the original model +float[] vector = vcrModel.embed("What is Redis?"); + +// Batch embeddings +List vectors = vcrModel.embed(List.of("text 1", "text 2")); + +// Document embedding +Document doc = new Document("Document content here"); +float[] docVector = vcrModel.embed(doc); + +// Full EmbeddingResponse API +EmbeddingResponse response = vcrModel.embedForResponse(List.of("query text")); +---- + +The `VCRSpringAIEmbeddingModel` implements the full `org.springframework.ai.embedding.EmbeddingModel` interface for seamless integration with Spring AI applications. + +==== Supported Methods + +* `embed(String text)` - Single text embedding returning `float[]` +* `embed(Document document)` - Document embedding +* `embed(List texts)` - Batch embedding returning `List` +* `embedForResponse(List texts)` - Full `EmbeddingResponse` +* `call(EmbeddingRequest request)` - Standard Spring AI call pattern +* `dimensions()` - Returns embedding dimensions + +==== Chat Model + +Use `VCRSpringAIChatModel` to wrap any Spring AI `ChatModel`: + +[source,java] +---- +import com.redis.vl.test.vcr.VCRSpringAIChatModel; +import com.redis.vl.test.vcr.VCRMode; +import org.springframework.ai.chat.model.ChatModel; +import org.springframework.ai.chat.model.ChatResponse; +import org.springframework.ai.chat.prompt.Prompt; +import org.springframework.ai.chat.messages.UserMessage; +import org.springframework.ai.openai.OpenAiChatModel; + +// Create your Spring AI chat model +ChatModel openAiModel = new OpenAiChatModel(openAiApi); + +// Wrap with VCR for recording/playback +VCRSpringAIChatModel vcrModel = new VCRSpringAIChatModel(openAiModel); +vcrModel.setMode(VCRMode.PLAYBACK_OR_RECORD); +vcrModel.setTestId("MyTest.testChat"); + +// Use exactly like the original model - VCR handles caching transparently +String response = vcrModel.call("What is Redis?"); + +// With Prompt object +Prompt prompt = new Prompt(List.of(new UserMessage("Explain vector search"))); +ChatResponse chatResponse = vcrModel.call(prompt); +String answer = chatResponse.getResult().getOutput().getText(); + +// Multiple messages +String multiResponse = vcrModel.call( + new UserMessage("What is Redis?"), + new UserMessage("How does it handle vectors?") +); +---- + +The `VCRSpringAIChatModel` implements the full `org.springframework.ai.chat.model.ChatModel` interface for seamless integration with Spring AI applications. + +==== Supported Chat Methods + +* `call(String message)` - Simple string convenience method +* `call(Message... messages)` - Generate from varargs messages +* `call(Prompt prompt)` - Full Prompt/ChatResponse API + +=== Common VCR Operations + +Both wrappers share common VCR functionality: + +[source,java] +---- +// Set VCR mode +vcrModel.setMode(VCRMode.PLAYBACK_OR_RECORD); + +// Set test identifier (for cassette key generation) +vcrModel.setTestId("MyTestClass.testMethod"); + +// Reset call counter between tests +vcrModel.resetCallCounter(); + +// Get statistics +int hits = vcrModel.getCacheHits(); +int misses = vcrModel.getCacheMisses(); +int recorded = vcrModel.getRecordedCount(); + +// Reset statistics +vcrModel.resetStatistics(); + +// Access underlying delegate +EmbeddingModel delegate = vcrModel.getDelegate(); +---- + +=== Using with JUnit 5 + +Combine VCR wrappers with the `@VCRTest` annotation for a complete testing solution: + +[source,java] +---- +import com.redis.vl.test.vcr.VCRTest; +import com.redis.vl.test.vcr.VCRMode; +import com.redis.vl.test.vcr.VCREmbeddingModel; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +@VCRTest(mode = VCRMode.PLAYBACK_OR_RECORD) +public class EmbeddingServiceTest { + + private VCREmbeddingModel vcrModel; + + @BeforeEach + void setUp() { + EmbeddingModel realModel = OpenAiEmbeddingModel.builder() + .apiKey(System.getenv("OPENAI_API_KEY")) + .build(); + vcrModel = new VCREmbeddingModel(realModel); + vcrModel.setMode(VCRMode.PLAYBACK_OR_RECORD); + } + + @Test + void testSemanticSearch() { + vcrModel.setTestId("EmbeddingServiceTest.testSemanticSearch"); + + // First run: calls OpenAI API and records response + // Subsequent runs: replays from Redis cassette + Response response = vcrModel.embed("search query"); + + assertNotNull(response); + assertEquals(1536, response.content().vector().length); + } + + @Test + void testBatchEmbedding() { + vcrModel.setTestId("EmbeddingServiceTest.testBatchEmbedding"); + + List docs = List.of( + TextSegment.from("Document 1"), + TextSegment.from("Document 2"), + TextSegment.from("Document 3") + ); + + Response> response = vcrModel.embedAll(docs); + + assertEquals(3, response.content().size()); + } +} +---- == See Also From 934dad365d4689eb8920b7f4c3ca74bf86d93a8c Mon Sep 17 00:00:00 2001 From: Brian Sam-Bodden Date: Sat, 13 Dec 2025 09:33:19 -0700 Subject: [PATCH 10/12] docs: expand VCR test system documentation in README Enhances the README VCR section with: - Quick start with JUnit 5 annotation example - VCR modes table with API key requirements - Environment variable override instructions (VCR_MODE) - Improved code examples for LangChain4J and Spring AI - How it works explanation - Links to demo projects Makes it easier for users to understand and adopt the VCR test system. --- README.md | 107 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 102 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7b84c8e..3102b2f 100644 --- a/README.md +++ b/README.md @@ -383,25 +383,122 @@ RedisVL includes an experimental VCR (Video Cassette Recorder) test system for r - **Deterministic tests** - Replay recorded responses for consistent results - **Cost reduction** - Avoid repeated API calls during test runs - **Speed improvement** - Local Redis playback is faster than API calls -- **Offline testing** - Run tests without network access +- **Offline testing** - Run tests without network access or API keys + +### Quick Start with JUnit 5 + +The simplest way to use VCR is with the declarative annotations: ```java -import com.redis.vl.test.vcr.VCRTest; import com.redis.vl.test.vcr.VCRMode; +import com.redis.vl.test.vcr.VCRModel; +import com.redis.vl.test.vcr.VCRTest; @VCRTest(mode = VCRMode.PLAYBACK_OR_RECORD) -public class MyLLMTest { +class MyLLMTest { + + // Models are automatically wrapped by VCR + @VCRModel(modelName = "text-embedding-3-small") + private EmbeddingModel embeddingModel = createEmbeddingModel(); + + @VCRModel + private ChatLanguageModel chatModel = createChatModel(); @Test - void testLLMResponse() { + void testEmbedding() { // First run: Records API response to Redis // Subsequent runs: Replays from Redis cassette - String response = myLLMService.generate("What is Redis?"); + Response response = embeddingModel.embed("What is Redis?"); + assertNotNull(response.content()); + } + + @Test + void testChat() { + String response = chatModel.generate("Explain Redis in one sentence."); assertNotNull(response); } } ``` +### VCR Modes + +| Mode | Description | API Key Required | +|------|-------------|------------------| +| `PLAYBACK` | Only use recorded cassettes. Fails if missing. | No | +| `PLAYBACK_OR_RECORD` | Use cassette if available, record if not. | Only for first run | +| `RECORD` | Always call real API and record response. | Yes | +| `OFF` | Bypass VCR, always call real API. | Yes | + +### Environment Variable Override + +Override the VCR mode at runtime without changing code: + +```bash +# Record new cassettes +VCR_MODE=RECORD OPENAI_API_KEY=your-key ./gradlew test + +# Playback only (CI/CD, no API key needed) +VCR_MODE=PLAYBACK ./gradlew test +``` + +### LangChain4J Integration + +```java +import com.redis.vl.test.vcr.VCREmbeddingModel; +import com.redis.vl.test.vcr.VCRChatModel; +import com.redis.vl.test.vcr.VCRMode; + +// Wrap any LangChain4J EmbeddingModel +VCREmbeddingModel vcrEmbedding = new VCREmbeddingModel(openAiEmbeddingModel); +vcrEmbedding.setMode(VCRMode.PLAYBACK_OR_RECORD); +Response response = vcrEmbedding.embed("What is Redis?"); + +// Wrap any LangChain4J ChatLanguageModel +VCRChatModel vcrChat = new VCRChatModel(openAiChatModel); +vcrChat.setMode(VCRMode.PLAYBACK_OR_RECORD); +String response = vcrChat.generate("What is Redis?"); +``` + +### Spring AI Integration + +```java +import com.redis.vl.test.vcr.VCRSpringAIEmbeddingModel; +import com.redis.vl.test.vcr.VCRSpringAIChatModel; +import com.redis.vl.test.vcr.VCRMode; + +// Wrap any Spring AI EmbeddingModel +VCRSpringAIEmbeddingModel vcrEmbedding = new VCRSpringAIEmbeddingModel(openAiEmbeddingModel); +vcrEmbedding.setMode(VCRMode.PLAYBACK_OR_RECORD); +EmbeddingResponse response = vcrEmbedding.embedForResponse(List.of("What is Redis?")); + +// Wrap any Spring AI ChatModel +VCRSpringAIChatModel vcrChat = new VCRSpringAIChatModel(openAiChatModel); +vcrChat.setMode(VCRMode.PLAYBACK_OR_RECORD); +String response = vcrChat.call("What is Redis?"); +``` + +### How It Works + +1. **Container Management**: VCR starts a Redis Stack container with persistence +2. **Model Wrapping**: `@VCRModel` fields are wrapped with VCR proxies +3. **Cassette Storage**: Responses stored as Redis JSON documents +4. **Persistence**: Data saved to `src/test/resources/vcr-data/` via Redis AOF/RDB +5. **Playback**: Subsequent runs load cassettes from persistent storage + +### Demo Projects + +Complete working examples are available: + +- **[LangChain4J VCR Demo](demos/langchain4j-vcr/)** - LangChain4J embedding and chat models +- **[Spring AI VCR Demo](demos/spring-ai-vcr/)** - Spring AI embedding and chat models + +Run the demos without an API key (uses pre-recorded cassettes): + +```bash +./gradlew :demos:langchain4j-vcr:test +./gradlew :demos:spring-ai-vcr:test +``` + > Learn more about [VCR testing](https://redis.github.io/redis-vl-java/redisvl/current/vcr-testing.html). ## ๐Ÿš€ Why RedisVL? From 9dcf021b918a02379bd0f0e4875602b78ef71d79 Mon Sep 17 00:00:00 2001 From: Brian Sam-Bodden Date: Sat, 13 Dec 2025 12:01:45 -0700 Subject: [PATCH 11/12] fix(vcr): prevent vcr-data modification in PLAYBACK mode Fixes three bugs that caused vcr-data files to be modified even when tests were configured with @VCRTest(mode = VCRMode.PLAYBACK): 1. Fixed @Nested test class annotation lookup - The VCRExtension was not finding @VCRTest annotations on enclosing classes for nested tests, causing it to fall back to the default PLAYBACK_OR_RECORD mode. Added findVCRTestAnnotation() to walk up the class hierarchy. 2. Added temp directory copy for playback mode - In PLAYBACK mode, the vcr-data directory is now copied to a temp directory before mounting to Docker, preventing any modifications to the source files. 3. Fixed registry writes in playback mode - testSuccessful() and testFailed() now check the mode before writing to the registry, only updating it when recording. Additional changes: - Disabled Redis AOF persistence in playback mode (--appendonly no) - Disabled Redis RDB saves in playback mode (--save "") --- .../com/redis/vl/test/vcr/VCRContext.java | 64 +++++++++++++++++-- .../com/redis/vl/test/vcr/VCRExtension.java | 42 ++++++++++-- 2 files changed, 94 insertions(+), 12 deletions(-) diff --git a/core/src/main/java/com/redis/vl/test/vcr/VCRContext.java b/core/src/main/java/com/redis/vl/test/vcr/VCRContext.java index b5786e0..675a73d 100644 --- a/core/src/main/java/com/redis/vl/test/vcr/VCRContext.java +++ b/core/src/main/java/com/redis/vl/test/vcr/VCRContext.java @@ -1,8 +1,13 @@ package com.redis.vl.test.vcr; import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import java.io.IOException; +import java.nio.file.FileVisitResult; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.BasicFileAttributes; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -132,10 +137,22 @@ public VCRCassetteStore getCassetteStore() { private void startRedis() { String redisCommand = buildRedisCommand(); + // In playback mode, copy data to temp directory to prevent modifications to source files + Path mountPath = dataDir; + if (!effectiveMode.isRecordMode()) { + try { + mountPath = copyDataToTemp(); + } catch (Exception e) { + System.err.println( + "VCR: Failed to copy data to temp directory, using original: " + e.getMessage()); + mountPath = dataDir; + } + } + redisContainer = new GenericContainer<>(DockerImageName.parse(config.redisImage())) .withExposedPorts(6379) - .withFileSystemBind(dataDir.toAbsolutePath().toString(), "/data", BindMode.READ_WRITE) + .withFileSystemBind(mountPath.toAbsolutePath().toString(), "/data", BindMode.READ_WRITE) .withCommand(redisCommand); redisContainer.start(); @@ -150,22 +167,59 @@ private void startRedis() { private String buildRedisCommand() { StringBuilder cmd = new StringBuilder("redis-stack-server"); - cmd.append(" --appendonly yes"); - cmd.append(" --appendfsync everysec"); cmd.append(" --dir /data"); cmd.append(" --dbfilename dump.rdb"); if (effectiveMode.isRecordMode()) { - // Enable periodic saves in record mode + // Enable AOF and periodic saves in record mode + cmd.append(" --appendonly yes"); + cmd.append(" --appendfsync everysec"); cmd.append(" --save 60 1 --save 300 10"); } else { - // Disable saves in playback mode for speed + // Disable all persistence in playback mode (read-only) + cmd.append(" --appendonly no"); cmd.append(" --save \"\""); } return cmd.toString(); } + /** + * Copies VCR data to a temporary directory to prevent modifications to source files. Used in + * playback mode to ensure cassette files are not modified. + * + * @return path to the temporary directory containing the copied data + * @throws IOException if copying fails + */ + private Path copyDataToTemp() throws IOException { + Path tempDir = Files.createTempDirectory("vcr-playback-"); + tempDir.toFile().deleteOnExit(); + + Files.walkFileTree( + dataDir, + new SimpleFileVisitor<>() { + @Override + public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) + throws IOException { + Path targetDir = tempDir.resolve(dataDir.relativize(dir)); + Files.createDirectories(targetDir); + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) + throws IOException { + Path targetFile = tempDir.resolve(dataDir.relativize(file)); + Files.copy(file, targetFile, StandardCopyOption.REPLACE_EXISTING); + return FileVisitResult.CONTINUE; + } + }); + + System.out.println( + "VCR: Using temporary copy at " + tempDir + " for playback (read-only protection)"); + return tempDir; + } + private void waitForRedis() { for (int i = 0; i < 30; i++) { try { diff --git a/core/src/main/java/com/redis/vl/test/vcr/VCRExtension.java b/core/src/main/java/com/redis/vl/test/vcr/VCRExtension.java index 557789c..f9e03e7 100644 --- a/core/src/main/java/com/redis/vl/test/vcr/VCRExtension.java +++ b/core/src/main/java/com/redis/vl/test/vcr/VCRExtension.java @@ -70,7 +70,8 @@ public class VCRExtension @Override public void beforeAll(ExtensionContext extensionContext) throws Exception { // Get VCR configuration from @VCRTest annotation - VCRTest config = extensionContext.getRequiredTestClass().getAnnotation(VCRTest.class); + // Walk up the class hierarchy to find the annotation (handles @Nested test classes) + VCRTest config = findVCRTestAnnotation(extensionContext.getRequiredTestClass()); if (config == null) { // No @VCRTest annotation, use defaults @@ -208,8 +209,11 @@ public void testSuccessful(ExtensionContext extensionContext) { return; } - String testId = getTestId(extensionContext); - context.getRegistry().registerSuccess(testId, context.getCurrentCassetteKeys()); + // Only update registry when recording, not in playback mode + if (context.getEffectiveMode().isRecordMode()) { + String testId = getTestId(extensionContext); + context.getRegistry().registerSuccess(testId, context.getCurrentCassetteKeys()); + } } @Override @@ -218,11 +222,12 @@ public void testFailed(ExtensionContext extensionContext, Throwable cause) { return; } - String testId = getTestId(extensionContext); - context.getRegistry().registerFailure(testId, cause.getMessage()); + // Only update registry and delete cassettes when recording + if (context.getEffectiveMode().isRecordMode()) { + String testId = getTestId(extensionContext); + context.getRegistry().registerFailure(testId, cause.getMessage()); - // Optionally delete cassettes for failed tests in RECORD mode - if (context.getEffectiveMode() == VCRMode.RECORD) { + // Delete cassettes for failed tests in RECORD mode context.deleteCassettes(context.getCurrentCassetteKeys()); } } @@ -251,6 +256,29 @@ private String getTestId(ExtensionContext ctx) { return ctx.getRequiredTestClass().getName() + ":" + ctx.getRequiredTestMethod().getName(); } + /** + * Finds the @VCRTest annotation on the given class or any of its enclosing classes. This is + * needed to properly handle @Nested test classes where the annotation is on the outer class. + * + * @param testClass the test class to search + * @return the VCRTest annotation if found, null otherwise + */ + private VCRTest findVCRTestAnnotation(Class testClass) { + Class currentClass = testClass; + + // Walk up the class hierarchy (enclosing classes for nested classes) + while (currentClass != null) { + VCRTest annotation = currentClass.getAnnotation(VCRTest.class); + if (annotation != null) { + return annotation; + } + // Check enclosing class for @Nested test classes + currentClass = currentClass.getEnclosingClass(); + } + + return null; + } + /** Default VCRTest annotation values for when no annotation is present. */ @VCRTest private static class DefaultVCRTest { From 4b19413a1eac8c257e0aa316b45d04681ceb40d0 Mon Sep 17 00:00:00 2001 From: Brian Sam-Bodden Date: Sat, 13 Dec 2025 12:11:43 -0700 Subject: [PATCH 12/12] docs(vcr): improve VCR documentation with @VCRModel annotation, env var override - Updated Basic Usage section to show declarative @VCRModel annotation approach - Added VCR_MODE environment variable override documentation - Fixed outdated references (VCRCassetteStore now implemented, not 'coming soon') - Changed -Dvcr.mode system property reference to VCR_MODE env var - Added IMPORTANT note about model initialization timing requirement --- .../modules/ROOT/pages/vcr-testing.adoc | 66 +++++++++++++++++-- 1 file changed, 60 insertions(+), 6 deletions(-) diff --git a/docs/content/modules/ROOT/pages/vcr-testing.adoc b/docs/content/modules/ROOT/pages/vcr-testing.adoc index e8a9208..132f9c1 100644 --- a/docs/content/modules/ROOT/pages/vcr-testing.adoc +++ b/docs/content/modules/ROOT/pages/vcr-testing.adoc @@ -56,27 +56,63 @@ testImplementation 'org.testcontainers:junit-jupiter:1.19.7' === Basic Usage -Annotate your test class with `@VCRTest` to enable VCR functionality: +The recommended approach uses `@VCRTest` on the test class and `@VCRModel` on model fields for automatic wrapping: [source,java] ---- import com.redis.vl.test.vcr.VCRTest; import com.redis.vl.test.vcr.VCRMode; +import com.redis.vl.test.vcr.VCRModel; +import dev.langchain4j.model.embedding.EmbeddingModel; +import dev.langchain4j.model.chat.ChatLanguageModel; import org.junit.jupiter.api.Test; @VCRTest(mode = VCRMode.PLAYBACK_OR_RECORD) public class MyLLMTest { + // Models are automatically wrapped by VCR - initialize at field declaration + @VCRModel(modelName = "text-embedding-3-small") + private EmbeddingModel embeddingModel = createEmbeddingModel(); + + @VCRModel + private ChatLanguageModel chatModel = createChatModel(); + @Test - void testLLMResponse() { + void testEmbedding() { // First run: Records API response to Redis // Subsequent runs: Replays from Redis cassette - String response = myLLMService.generate("What is Redis?"); + Response response = embeddingModel.embed("What is Redis?"); + assertNotNull(response.content()); + } + + @Test + void testChat() { + String response = chatModel.generate("Explain Redis in one sentence."); assertNotNull(response); } + + private static EmbeddingModel createEmbeddingModel() { + String key = System.getenv("OPENAI_API_KEY"); + if (key == null) key = "vcr-playback-mode"; // Dummy key for playback + return OpenAiEmbeddingModel.builder() + .apiKey(key) + .modelName("text-embedding-3-small") + .build(); + } + + private static ChatLanguageModel createChatModel() { + String key = System.getenv("OPENAI_API_KEY"); + if (key == null) key = "vcr-playback-mode"; + return OpenAiChatModel.builder() + .apiKey(key) + .modelName("gpt-4o-mini") + .build(); + } } ---- +IMPORTANT: Models must be initialized at field declaration time, not in `@BeforeEach`. The VCR extension wraps `@VCRModel` fields before `@BeforeEach` methods run. + == VCR Modes The VCR system supports six modes to control recording and playback behavior: @@ -110,6 +146,24 @@ The VCR system supports six modes to control recording and playback behavior: * **CI/CD**: Use `PLAYBACK` to ensure tests are deterministic * **Initial setup**: Use `RECORD` to capture all cassettes +=== Environment Variable Override + +Override the VCR mode at runtime using the `VCR_MODE` environment variable. This takes precedence over the annotation mode: + +[source,bash] +---- +# Force RECORD mode to capture new cassettes +VCR_MODE=RECORD OPENAI_API_KEY=your-key ./gradlew test + +# Force PLAYBACK mode (no API key required) +VCR_MODE=PLAYBACK ./gradlew test + +# Force OFF mode to bypass VCR +VCR_MODE=OFF OPENAI_API_KEY=your-key ./gradlew test +---- + +Valid values: `PLAYBACK`, `PLAYBACK_OR_RECORD`, `RECORD`, `RECORD_NEW`, `RECORD_FAILED`, `OFF` + == Configuration === Data Directory @@ -215,7 +269,7 @@ The VCR system consists of several components: * **VCRExtension** - JUnit 5 extension that manages the VCR lifecycle * **VCRContext** - Manages Redis container, call counters, and statistics * **VCRRegistry** - Tracks recording status for each test -* **VCRCassetteStore** - Stores/retrieves cassettes in Redis (coming soon) +* **VCRCassetteStore** - Stores/retrieves cassettes in Redis using JSON format === Cassette Key Format @@ -266,12 +320,12 @@ public class CITest { === Updating Cassettes -When API responses change, re-record cassettes: +When API responses change, re-record cassettes using the `VCR_MODE` environment variable: [source,bash] ---- # Run tests in RECORD mode to update all cassettes -./gradlew test -Dvcr.mode=RECORD +VCR_MODE=RECORD ./gradlew test ---- === Sensitive Data