262 lines
8.1 KiB
PHP
262 lines
8.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace ContextPaging\Tests;
|
|
|
|
use ContextPaging\CacheInterface;
|
|
use ContextPaging\ContextPaging;
|
|
use ContextPaging\InMemoryCache;
|
|
use ContextPaging\LLMSummarizer;
|
|
use ContextPaging\OpenAICompatibleClient;
|
|
use ContextPaging\RedisCache;
|
|
use ContextPaging\TokenCounter;
|
|
use ContextPaging\ToolCallMode;
|
|
use GuzzleHttp\Psr7\ServerRequest;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* Tests for Redis-backed caching.
|
|
*
|
|
* Requires REDIS_URL environment variable. Tests will skip if not set.
|
|
*/
|
|
class RedisCacheTest extends TestCase
|
|
{
|
|
private CacheInterface $redisCache;
|
|
private ?string $redisUrl = null;
|
|
|
|
protected function setUp(): void
|
|
{
|
|
$this->redisUrl = getenv('REDIS_URL') ?: null;
|
|
|
|
if ($this->redisUrl === null) {
|
|
$this->markTestSkipped('REDIS_URL not set, skipping Redis tests');
|
|
}
|
|
|
|
$this->redisCache = RedisCache::fromUrl($this->redisUrl, 'test_ctx:');
|
|
}
|
|
|
|
protected function tearDown(): void
|
|
{
|
|
// Clean up test keys
|
|
$this->redisCache->clear();
|
|
}
|
|
|
|
public function testSetAndGet(): void
|
|
{
|
|
$this->redisCache->set('foo', ['bar' => 'baz']);
|
|
|
|
$value = $this->redisCache->get('foo');
|
|
|
|
$this->assertIsArray($value);
|
|
$this->assertEquals('baz', $value['bar']);
|
|
}
|
|
|
|
public function testHasReturnsTrueForExistingKey(): void
|
|
{
|
|
$this->redisCache->set('exists', 'value');
|
|
|
|
$this->assertTrue($this->redisCache->has('exists'));
|
|
$this->assertFalse($this->redisCache->has('nonexistent'));
|
|
}
|
|
|
|
public function testDelete(): void
|
|
{
|
|
$this->redisCache->set('to_delete', 'value');
|
|
$this->assertTrue($this->redisCache->has('to_delete'));
|
|
|
|
$this->redisCache->delete('to_delete');
|
|
$this->assertFalse($this->redisCache->has('to_delete'));
|
|
}
|
|
|
|
public function testGetReturnsNullForMissingKey(): void
|
|
{
|
|
$this->assertNull($this->redisCache->get('missing_key'));
|
|
}
|
|
|
|
public function testTtl(): void
|
|
{
|
|
// Set with 1 second TTL
|
|
$this->redisCache->set('expires_soon', 'value', 1);
|
|
|
|
$this->assertTrue($this->redisCache->has('expires_soon'));
|
|
$this->assertEquals('value', $this->redisCache->get('expires_soon'));
|
|
|
|
// Wait for expiry
|
|
sleep(2);
|
|
|
|
$this->assertFalse($this->redisCache->has('expires_soon'));
|
|
}
|
|
|
|
public function testContextPagingWithRedisCache(): void
|
|
{
|
|
$messageStore = RedisCache::fromUrl($this->redisUrl, 'test_msg:');
|
|
$summaryCache = RedisCache::fromUrl($this->redisUrl, 'test_sum:');
|
|
|
|
$contextPaging = new ContextPaging(
|
|
tokenCounter: new TokenCounter(),
|
|
messageStore: $messageStore,
|
|
summaryCache: $summaryCache
|
|
);
|
|
|
|
$contextPaging->setMaxContextTokens(100)->setResponseReserve(20);
|
|
|
|
$longContent = str_repeat('This is a long message that will be summarized. ', 30);
|
|
$md5 = md5($longContent);
|
|
|
|
$messages = [
|
|
['role' => 'user', 'content' => $longContent],
|
|
['role' => 'user', 'content' => 'Short question'],
|
|
];
|
|
|
|
$request = $this->createRequest($messages);
|
|
$fitted = $contextPaging->fit($request);
|
|
|
|
// Verify message was stored in Redis
|
|
$storedMessage = $messageStore->get("msg:{$md5}");
|
|
$this->assertNotNull($storedMessage, 'Message should be stored in Redis');
|
|
$this->assertEquals($longContent, $storedMessage['content']);
|
|
|
|
// Verify summary was cached
|
|
$summaryKey = "summary:{$md5}";
|
|
$this->assertTrue($summaryCache->has($summaryKey), 'Summary should be cached');
|
|
|
|
// Clean up
|
|
$messageStore->clear();
|
|
$summaryCache->clear();
|
|
}
|
|
|
|
public function testSummaryPersistsBetweenRequests(): void
|
|
{
|
|
$messageStore = RedisCache::fromUrl($this->redisUrl, 'test_msg2:');
|
|
$summaryCache = RedisCache::fromUrl($this->redisUrl, 'test_sum2:');
|
|
|
|
$longContent = str_repeat('Persist test content. ', 50);
|
|
$md5 = md5($longContent);
|
|
|
|
// First request: create summary
|
|
$contextPaging1 = new ContextPaging(
|
|
tokenCounter: new TokenCounter(),
|
|
messageStore: $messageStore,
|
|
summaryCache: $summaryCache
|
|
);
|
|
$contextPaging1->setMaxContextTokens(100)->setResponseReserve(20);
|
|
|
|
$request1 = $this->createRequest([
|
|
['role' => 'user', 'content' => $longContent],
|
|
['role' => 'user', 'content' => 'Short'],
|
|
]);
|
|
|
|
$fitted1 = $contextPaging1->fit($request1);
|
|
$fittedMessages1 = $fitted1->getParsedBody()['messages'];
|
|
|
|
// Get the summary from cache
|
|
$cachedSummary = $summaryCache->get("summary:{$md5}");
|
|
|
|
// Second request: should use cached summary
|
|
$contextPaging2 = new ContextPaging(
|
|
tokenCounter: new TokenCounter(),
|
|
messageStore: $messageStore,
|
|
summaryCache: $summaryCache
|
|
);
|
|
$contextPaging2->setMaxContextTokens(100)->setResponseReserve(20);
|
|
|
|
$request2 = $this->createRequest([
|
|
['role' => 'user', 'content' => $longContent],
|
|
['role' => 'user', 'content' => 'Short'],
|
|
]);
|
|
|
|
$fitted2 = $contextPaging2->fit($request2);
|
|
$fittedMessages2 = $fitted2->getParsedBody()['messages'];
|
|
|
|
// Summaries should be identical (from cache)
|
|
$this->assertEquals(
|
|
$fittedMessages1[0]['content'],
|
|
$fittedMessages2[0]['content'],
|
|
'Summary should be identical from cache'
|
|
);
|
|
|
|
// Clean up
|
|
$messageStore->clear();
|
|
$summaryCache->clear();
|
|
}
|
|
|
|
public function testInMemoryVsRedisParity(): void
|
|
{
|
|
$content = 'Test content for parity check';
|
|
$md5 = md5($content);
|
|
|
|
// In-memory
|
|
$inMemory = new InMemoryCache();
|
|
$inMemory->set("msg:{$md5}", ['role' => 'user', 'content' => $content]);
|
|
|
|
// Redis
|
|
$redis = RedisCache::fromUrl($this->redisUrl, 'test_parity:');
|
|
$redis->set("msg:{$md5}", ['role' => 'user', 'content' => $content]);
|
|
|
|
// Both should return same data
|
|
$this->assertEquals(
|
|
$inMemory->get("msg:{$md5}"),
|
|
$redis->get("msg:{$md5}"),
|
|
'In-memory and Redis should return same data'
|
|
);
|
|
|
|
$redis->clear();
|
|
}
|
|
|
|
public function testMessageStorePersistsAcrossInstances(): void
|
|
{
|
|
$messageStore = RedisCache::fromUrl($this->redisUrl, 'test_msg3:');
|
|
$summaryCache = RedisCache::fromUrl($this->redisUrl, 'test_sum3:');
|
|
|
|
$longContent = str_repeat('Cross-instance test. ', 40);
|
|
$md5 = md5($longContent);
|
|
|
|
// First instance: fit and store
|
|
$instance1 = new ContextPaging(
|
|
tokenCounter: new TokenCounter(),
|
|
messageStore: $messageStore,
|
|
summaryCache: $summaryCache
|
|
);
|
|
$instance1->setMaxContextTokens(100)->setResponseReserve(20);
|
|
|
|
$request = $this->createRequest([
|
|
['role' => 'user', 'content' => $longContent],
|
|
['role' => 'user', 'content' => 'Query'],
|
|
]);
|
|
|
|
$fitted = $instance1->fit($request);
|
|
|
|
// Second instance: should be able to dereference from shared Redis
|
|
$instance2 = new ContextPaging(
|
|
tokenCounter: new TokenCounter(),
|
|
messageStore: $messageStore,
|
|
summaryCache: $summaryCache
|
|
);
|
|
|
|
// Access the message store directly
|
|
$retrievedMessage = $instance2->getMessageStore()->get("msg:{$md5}");
|
|
|
|
$this->assertNotNull($retrievedMessage, 'Second instance should see stored message');
|
|
$this->assertEquals($longContent, $retrievedMessage['content']);
|
|
|
|
// Clean up
|
|
$messageStore->clear();
|
|
$summaryCache->clear();
|
|
}
|
|
|
|
private function createRequest(array $messages): ServerRequest
|
|
{
|
|
$body = ['messages' => $messages];
|
|
|
|
return new ServerRequest(
|
|
method: 'POST',
|
|
uri: 'test://localhost',
|
|
headers: ['Content-Type' => 'application/json'],
|
|
body: json_encode($body),
|
|
version: '1.1',
|
|
serverParams: []
|
|
)->withParsedBody($body);
|
|
}
|
|
}
|