Initial commit
This commit is contained in:
226
tests/ContextPagingTest.php
Normal file
226
tests/ContextPagingTest.php
Normal file
@@ -0,0 +1,226 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ContextPaging\Tests;
|
||||
|
||||
use ContextPaging\ContextPaging;
|
||||
use ContextPaging\TokenCounter;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use GuzzleHttp\Psr7\ServerRequest;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ContextPagingTest extends TestCase
|
||||
{
|
||||
private ContextPaging $contextPaging;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->contextPaging = new ContextPaging();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test basic fit with a small payload that doesn't need summarization.
|
||||
*/
|
||||
public function testFitWithSmallPayload(): void
|
||||
{
|
||||
$messages = [
|
||||
['role' => 'user', 'content' => 'Hello, how are you?'],
|
||||
];
|
||||
|
||||
$request = $this->createRequest($messages);
|
||||
|
||||
$fitted = $this->contextPaging->fit($request);
|
||||
|
||||
$this->assertTrue($fitted->getAttribute('context_fitted'));
|
||||
|
||||
$fittedMessages = $fitted->getParsedBody()['messages'];
|
||||
$this->assertCount(1, $fittedMessages);
|
||||
$this->assertEquals('Hello, how are you?', $fittedMessages[0]['content']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test fit with a larger payload that exceeds context limit.
|
||||
*/
|
||||
public function testFitWithLargePayloadTriggersSummarization(): void
|
||||
{
|
||||
// Set a limit low enough to force summarization but high enough for last message
|
||||
$this->contextPaging->setMaxContextTokens(100)->setResponseReserve(20);
|
||||
|
||||
$messages = [
|
||||
['role' => 'user', 'content' => str_repeat('This is a long message that should be summarized. ', 50)],
|
||||
['role' => 'assistant', 'content' => 'I understand your message.'],
|
||||
['role' => 'user', 'content' => 'Short question'],
|
||||
];
|
||||
|
||||
$request = $this->createRequest($messages);
|
||||
|
||||
$fitted = $this->contextPaging->fit($request);
|
||||
|
||||
$fittedMessages = $fitted->getParsedBody()['messages'];
|
||||
|
||||
// First message should be summarized
|
||||
$this->assertTrue($fittedMessages[0]['_summarized'] ?? false);
|
||||
$this->assertStringContainsString('[md5:', $fittedMessages[0]['content']);
|
||||
|
||||
// Last message should NOT be summarized
|
||||
$lastIndex = count($fittedMessages) - 1;
|
||||
$this->assertFalse($fittedMessages[$lastIndex]['_summarized'] ?? false);
|
||||
|
||||
// Should be under budget
|
||||
$tokens = $fitted->getAttribute('context_tokens');
|
||||
$this->assertLessThanOrEqual(80, $tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test execute with no tool calls returns response as-is.
|
||||
*/
|
||||
public function testExecuteWithNoToolCalls(): void
|
||||
{
|
||||
$messages = [
|
||||
['role' => 'user', 'content' => 'Hello!'],
|
||||
];
|
||||
|
||||
$request = $this->createRequest($messages);
|
||||
|
||||
$response = $this->contextPaging->execute($request, function (array $msgs, $req) {
|
||||
return new Response(200, [], json_encode(['choices' => [[
|
||||
'message' => ['role' => 'assistant', 'content' => 'Hi there!'],
|
||||
]]]));
|
||||
});
|
||||
|
||||
$body = json_decode($response->getBody()->getContents(), true);
|
||||
$this->assertEquals('Hi there!', $body['choices'][0]['message']['content']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that original messages are stored for dereferencing.
|
||||
*/
|
||||
public function testOriginalMessagesStoredForDereferencing(): void
|
||||
{
|
||||
$longContent = str_repeat('This is a long message. ', 100);
|
||||
$messages = [
|
||||
['role' => 'user', 'content' => $longContent],
|
||||
['role' => 'user', 'content' => 'Short question'],
|
||||
];
|
||||
|
||||
$this->contextPaging->setMaxContextTokens(100)->setResponseReserve(20);
|
||||
|
||||
$request = $this->createRequest($messages);
|
||||
$fitted = $this->contextPaging->fit($request);
|
||||
|
||||
// The MD5 hash in the summarized message should reference the original
|
||||
$fittedMessages = $fitted->getParsedBody()['messages'];
|
||||
$this->assertMatchesRegularExpression('/\[md5:([a-f0-9]{32})\]/', $fittedMessages[0]['content']);
|
||||
|
||||
// Extract MD5
|
||||
preg_match('/\[md5:([a-f0-9]{32})\]/', $fittedMessages[0]['content'], $matches);
|
||||
$md5 = $matches[1];
|
||||
|
||||
// Verify the message store has the original
|
||||
// (We'd need to expose this or use reflection in a real test)
|
||||
$this->assertNotEmpty($md5);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that last message is never summarized.
|
||||
*/
|
||||
public function testLastMessageNeverSummarized(): void
|
||||
{
|
||||
$this->contextPaging->setMaxContextTokens(80)->setResponseReserve(15);
|
||||
|
||||
$messages = [
|
||||
['role' => 'user', 'content' => 'First message that is quite long and should be summarized'],
|
||||
['role' => 'user', 'content' => 'Second message that is also quite long'],
|
||||
['role' => 'user', 'content' => 'Short'],
|
||||
];
|
||||
|
||||
$request = $this->createRequest($messages);
|
||||
$fitted = $this->contextPaging->fit($request);
|
||||
|
||||
$fittedMessages = $fitted->getParsedBody()['messages'];
|
||||
$lastIndex = count($fittedMessages) - 1;
|
||||
|
||||
// Last message must not be summarized
|
||||
$this->assertFalse($fittedMessages[$lastIndex]['_summarized'] ?? false);
|
||||
$this->assertEquals('Short', $fittedMessages[$lastIndex]['content']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test error when last message itself exceeds context.
|
||||
*/
|
||||
public function testErrorWhenLastMessageTooLarge(): void
|
||||
{
|
||||
$this->contextPaging->setMaxContextTokens(20)->setResponseReserve(5);
|
||||
|
||||
// Single message that's way too big
|
||||
$messages = [
|
||||
['role' => 'user', 'content' => str_repeat('This is a massive message that will never fit. ', 100)],
|
||||
];
|
||||
|
||||
$request = $this->createRequest($messages);
|
||||
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('too large');
|
||||
|
||||
$this->contextPaging->fit($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that max_tokens in request is used for budget calculation.
|
||||
*/
|
||||
public function testMaxTokensUsedForBudgetCalculation(): void
|
||||
{
|
||||
$this->contextPaging->setMaxContextTokens(65536);
|
||||
|
||||
$messages = [
|
||||
['role' => 'user', 'content' => 'Hello'],
|
||||
];
|
||||
|
||||
// Request with max_tokens: 8000
|
||||
$request = $this->createRequest($messages, ['max_tokens' => 8000]);
|
||||
|
||||
$fitted = $this->contextPaging->fit($request);
|
||||
|
||||
// Budget should be 65536 - 8000 = 57536
|
||||
$this->assertEquals(57536, $fitted->getAttribute('context_budget'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Test fallback to responseReserve when max_tokens not provided.
|
||||
*/
|
||||
public function testFallbackToResponseReserveWhenNoMaxTokens(): void
|
||||
{
|
||||
$this->contextPaging->setMaxContextTokens(65536)->setResponseReserve(4096);
|
||||
|
||||
$messages = [
|
||||
['role' => 'user', 'content' => 'Hello'],
|
||||
];
|
||||
|
||||
// Request WITHOUT max_tokens
|
||||
$request = $this->createRequest($messages);
|
||||
|
||||
$fitted = $this->contextPaging->fit($request);
|
||||
|
||||
// Budget should be 65536 - 4096 = 61440
|
||||
$this->assertEquals(61440, $fitted->getAttribute('context_budget'));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Helpers
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
private function createRequest(array $messages, array $extraBody = []): ServerRequest
|
||||
{
|
||||
$body = array_merge(['messages' => $messages], $extraBody);
|
||||
|
||||
return new ServerRequest(
|
||||
method: 'POST',
|
||||
uri: 'test://localhost',
|
||||
headers: ['Content-Type' => 'application/json'],
|
||||
body: json_encode($body),
|
||||
version: '1.1',
|
||||
serverParams: []
|
||||
)->withParsedBody($body);
|
||||
}
|
||||
}
|
||||
156
tests/OpenAICompatibleClientTest.php
Normal file
156
tests/OpenAICompatibleClientTest.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ContextPaging\Tests;
|
||||
|
||||
use ContextPaging\OpenAICompatibleClient;
|
||||
use ContextPaging\ToolCallMode;
|
||||
use ContextPaging\ToolFormatter;
|
||||
use ContextPaging\ToolCallParser;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests for OpenAICompatibleClient using a real SmolLM3 endpoint.
|
||||
*/
|
||||
class OpenAICompatibleClientTest extends TestCase
|
||||
{
|
||||
private OpenAICompatibleClient $client;
|
||||
private string $model = 'HuggingFaceTB/SmolLM3-3B';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = new OpenAICompatibleClient(
|
||||
baseUrl: 'http://95.179.247.150/v1',
|
||||
apiKey: null,
|
||||
timeout: 120,
|
||||
verifySsl: false
|
||||
);
|
||||
}
|
||||
|
||||
public function testBasicChatCompletion(): void
|
||||
{
|
||||
$messages = [
|
||||
['role' => 'user', 'content' => 'Say "test successful" exactly.'],
|
||||
];
|
||||
|
||||
$response = $this->client->chat($messages, ['model' => $this->model, 'max_tokens' => 20]);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
|
||||
$body = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
$this->assertArrayHasKey('choices', $body);
|
||||
$this->assertCount(1, $body['choices']);
|
||||
$this->assertArrayHasKey('message', $body['choices'][0]);
|
||||
$this->assertEquals('assistant', $body['choices'][0]['message']['role']);
|
||||
$this->assertNotEmpty($body['choices'][0]['message']['content']);
|
||||
}
|
||||
|
||||
public function testUsageStatsReturned(): void
|
||||
{
|
||||
$messages = [
|
||||
['role' => 'user', 'content' => 'Hello'],
|
||||
];
|
||||
|
||||
$response = $this->client->chat($messages, ['model' => $this->model, 'max_tokens' => 20]);
|
||||
$body = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
$this->assertArrayHasKey('usage', $body);
|
||||
$this->assertArrayHasKey('prompt_tokens', $body['usage']);
|
||||
$this->assertArrayHasKey('completion_tokens', $body['usage']);
|
||||
$this->assertArrayHasKey('total_tokens', $body['usage']);
|
||||
|
||||
$this->assertGreaterThan(0, $body['usage']['prompt_tokens']);
|
||||
$this->assertGreaterThan(0, $body['usage']['completion_tokens']);
|
||||
}
|
||||
|
||||
public function testMultiTurnConversation(): void
|
||||
{
|
||||
$messages = [
|
||||
['role' => 'user', 'content' => 'My name is TestBot.'],
|
||||
['role' => 'assistant', 'content' => 'Nice to meet you, TestBot!'],
|
||||
['role' => 'user', 'content' => 'What is my name?'],
|
||||
];
|
||||
|
||||
$response = $this->client->chat($messages, ['model' => $this->model, 'max_tokens' => 50]);
|
||||
$body = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$content = strtolower($body['choices'][0]['message']['content']);
|
||||
$this->assertStringContainsString('testbot', $content);
|
||||
}
|
||||
|
||||
public function testListModels(): void
|
||||
{
|
||||
$response = $this->client->listModels();
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
$body = json_decode($response->getBody()->getContents(), true);
|
||||
$this->assertArrayHasKey('data', $body);
|
||||
$this->assertIsArray($body['data']);
|
||||
}
|
||||
|
||||
public function testRawToolFormatting(): void
|
||||
{
|
||||
$formatter = new ToolFormatter(ToolCallMode::RAW);
|
||||
|
||||
$messages = [
|
||||
['role' => 'system', 'content' => 'You are a helpful assistant.'],
|
||||
['role' => 'user', 'content' => 'Test'],
|
||||
];
|
||||
|
||||
$tools = [ToolFormatter::FETCH_MESSAGE_TOOL];
|
||||
|
||||
$payload = $formatter->buildPayload($messages, ['model' => $this->model], $tools, ToolCallMode::RAW);
|
||||
|
||||
$this->assertArrayNotHasKey('tools', $payload);
|
||||
|
||||
$this->assertEquals('system', $payload['messages'][0]['role']);
|
||||
$systemContent = $payload['messages'][0]['content'];
|
||||
|
||||
$this->assertStringContainsString('<tools>', $systemContent);
|
||||
$this->assertStringContainsString('fetch_message', $systemContent);
|
||||
}
|
||||
|
||||
public function testToolCallParserDetectsRawMode(): void
|
||||
{
|
||||
$parser = new ToolCallParser(ToolCallMode::AUTO);
|
||||
|
||||
$response = [
|
||||
'choices' => [
|
||||
[
|
||||
'message' => [
|
||||
'role' => 'assistant',
|
||||
'content' => '<tool_call>{"name": "fetch_message", "arguments": {"md5": "abc123"}}</tool_call>',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$detected = $parser->detectMode($response);
|
||||
$this->assertEquals(ToolCallMode::RAW, $detected);
|
||||
}
|
||||
|
||||
public function testToolCallParserExtractsRawToolCall(): void
|
||||
{
|
||||
$parser = new ToolCallParser(ToolCallMode::RAW);
|
||||
|
||||
$response = [
|
||||
'choices' => [
|
||||
[
|
||||
'message' => [
|
||||
'role' => 'assistant',
|
||||
'content' => '<tool_call>{"name": "fetch_message", "arguments": {"md5": "abc123def456"}}</tool_call>',
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$toolCalls = $parser->extract($response);
|
||||
|
||||
$this->assertNotNull($toolCalls);
|
||||
$this->assertCount(1, $toolCalls);
|
||||
$this->assertEquals('fetch_message', $toolCalls[0]['name']);
|
||||
$this->assertEquals('abc123def456', $toolCalls[0]['arguments']['md5']);
|
||||
}
|
||||
}
|
||||
253
tests/RedisCacheTest.php
Normal file
253
tests/RedisCacheTest.php
Normal file
@@ -0,0 +1,253 @@
|
||||
<?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.
|
||||
*/
|
||||
class RedisCacheTest extends TestCase
|
||||
{
|
||||
private CacheInterface $redisCache;
|
||||
private string $redisUrl = 'rediss://default:AVNS_HwDuEERQhl1L2cirYGC@vultr-prod-779ce310-b845-4091-97a5-96750a5b8c80-vultr-prod-a12a.vultrdb.com:16752';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$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);
|
||||
}
|
||||
}
|
||||
218
tests/SummarizerTest.php
Normal file
218
tests/SummarizerTest.php
Normal file
@@ -0,0 +1,218 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ContextPaging\Tests;
|
||||
|
||||
use ContextPaging\OpenAICompatibleClient;
|
||||
use ContextPaging\TokenCounter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* Tests for summarization using SmolLM3.
|
||||
*
|
||||
* Success criterion: output tokens < input tokens.
|
||||
*/
|
||||
class SummarizerTest extends TestCase
|
||||
{
|
||||
private OpenAICompatibleClient $client;
|
||||
private TokenCounter $tokenCounter;
|
||||
private string $model = 'HuggingFaceTB/SmolLM3-3B';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->client = new OpenAICompatibleClient(
|
||||
baseUrl: 'http://95.179.247.150/v1',
|
||||
apiKey: null,
|
||||
timeout: 120,
|
||||
verifySsl: false
|
||||
);
|
||||
|
||||
$this->tokenCounter = new TokenCounter();
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that summarization reduces token count.
|
||||
*/
|
||||
public function testSummarizationReducesTokens(): void
|
||||
{
|
||||
// Load the fluff article
|
||||
$fluffPath = __DIR__ . '/fluff.md';
|
||||
$this->assertFileExists($fluffPath, 'fluff.md should exist in tests/');
|
||||
|
||||
$fluffContent = file_get_contents($fluffPath);
|
||||
$this->assertNotEmpty($fluffContent);
|
||||
|
||||
// Count input tokens
|
||||
$inputTokens = $this->tokenCounter->count($fluffContent, 'cl100k_base');
|
||||
$this->assertGreaterThan(0, $inputTokens, 'Input should have tokens');
|
||||
|
||||
// Create summarization request
|
||||
$messages = [
|
||||
[
|
||||
'role' => 'system',
|
||||
'content' => 'You are a summarization assistant. Summarize the given text concisely. Preserve the key points but be brief.'
|
||||
],
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => "Summarize this article in 2-3 sentences:\n\n" . $fluffContent
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->client->chat($messages, [
|
||||
'model' => $this->model,
|
||||
'max_tokens' => 200,
|
||||
'temperature' => 0.3,
|
||||
]);
|
||||
|
||||
$this->assertEquals(200, $response->getStatusCode());
|
||||
|
||||
$body = json_decode($response->getBody()->getContents(), true);
|
||||
$this->assertArrayHasKey('choices', $body);
|
||||
|
||||
$summary = $body['choices'][0]['message']['content'];
|
||||
$this->assertNotEmpty($summary, 'Summary should not be empty');
|
||||
|
||||
// Count output tokens
|
||||
$outputTokens = $this->tokenCounter->count($summary, 'cl100k_base');
|
||||
|
||||
// SUCCESS: output tokens < input tokens
|
||||
$this->assertLessThan(
|
||||
$inputTokens,
|
||||
$outputTokens,
|
||||
"Summary ({$outputTokens} tokens) should be shorter than input ({$inputTokens} tokens)"
|
||||
);
|
||||
|
||||
// Log for visibility
|
||||
echo "\n[Summarization Test]\n";
|
||||
echo " Input tokens: {$inputTokens}\n";
|
||||
echo " Output tokens: {$outputTokens}\n";
|
||||
echo " Reduction: " . round((1 - $outputTokens / $inputTokens) * 100, 1) . "%\n";
|
||||
echo " Summary: " . substr($summary, 0, 100) . "...\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Test summarization preserves key information.
|
||||
*/
|
||||
public function testSummarizationPreservesKeyInfo(): void
|
||||
{
|
||||
$fluffPath = __DIR__ . '/fluff.md';
|
||||
$fluffContent = file_get_contents($fluffPath);
|
||||
|
||||
$messages = [
|
||||
[
|
||||
'role' => 'system',
|
||||
'content' => 'You are a summarization assistant. Summarize the given text concisely.'
|
||||
],
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => "Summarize this article in 2-3 sentences:\n\n" . $fluffContent
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->client->chat($messages, [
|
||||
'model' => $this->model,
|
||||
'max_tokens' => 200,
|
||||
'temperature' => 0.3,
|
||||
]);
|
||||
|
||||
$body = json_decode($response->getBody()->getContents(), true);
|
||||
$summary = strtolower($body['choices'][0]['message']['content']);
|
||||
|
||||
// Key entities that should appear in summary
|
||||
$this->assertStringContainsString('cloudflare', $summary, 'Summary should mention Cloudflare');
|
||||
$this->assertStringContainsString('just-bash', $summary, 'Summary should mention just-bash');
|
||||
}
|
||||
|
||||
/**
|
||||
* Test multi-article summarization.
|
||||
*/
|
||||
public function testMultiArticleSummarization(): void
|
||||
{
|
||||
$fluffPath = __DIR__ . '/fluff.md';
|
||||
$fluffContent = file_get_contents($fluffPath);
|
||||
|
||||
// Split into two chunks
|
||||
$midpoint = (int)(strlen($fluffContent) / 2);
|
||||
$part1 = substr($fluffContent, 0, $midpoint);
|
||||
$part2 = substr($fluffContent, $midpoint);
|
||||
|
||||
$inputTokens = $this->tokenCounter->count($part1 . $part2, 'cl100k_base');
|
||||
|
||||
$messages = [
|
||||
[
|
||||
'role' => 'system',
|
||||
'content' => 'You are a summarization assistant. Summarize multiple texts into one concise summary.'
|
||||
],
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => "Summarize these two parts into a single 2-3 sentence summary:\n\nPART 1:\n{$part1}\n\nPART 2:\n{$part2}"
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->client->chat($messages, [
|
||||
'model' => $this->model,
|
||||
'max_tokens' => 200,
|
||||
'temperature' => 0.3,
|
||||
]);
|
||||
|
||||
$body = json_decode($response->getBody()->getContents(), true);
|
||||
$summary = $body['choices'][0]['message']['content'];
|
||||
|
||||
$outputTokens = $this->tokenCounter->count($summary, 'cl100k_base');
|
||||
|
||||
$this->assertLessThan(
|
||||
$inputTokens,
|
||||
$outputTokens,
|
||||
"Combined summary ({$outputTokens}) should be shorter than combined input ({$inputTokens})"
|
||||
);
|
||||
|
||||
echo "\n[Multi-Article Summarization]\n";
|
||||
echo " Input tokens: {$inputTokens}\n";
|
||||
echo " Output tokens: {$outputTokens}\n";
|
||||
echo " Reduction: " . round((1 - $outputTokens / $inputTokens) * 100, 1) . "%\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Test usage stats are accurate.
|
||||
*/
|
||||
public function testUsageStatsAccuracy(): void
|
||||
{
|
||||
$fluffPath = __DIR__ . '/fluff.md';
|
||||
$fluffContent = file_get_contents($fluffPath);
|
||||
|
||||
$inputTokens = $this->tokenCounter->count($fluffContent, 'cl100k_base');
|
||||
|
||||
$messages = [
|
||||
[
|
||||
'role' => 'system',
|
||||
'content' => 'Summarize concisely.'
|
||||
],
|
||||
[
|
||||
'role' => 'user',
|
||||
'content' => "Summarize:\n\n" . $fluffContent
|
||||
],
|
||||
];
|
||||
|
||||
$response = $this->client->chat($messages, [
|
||||
'model' => $this->model,
|
||||
'max_tokens' => 150,
|
||||
]);
|
||||
|
||||
$body = json_decode($response->getBody()->getContents(), true);
|
||||
|
||||
// The API should report prompt tokens close to our count
|
||||
// (not exact because we add system prompt, but should be ballpark)
|
||||
$reportedPromptTokens = $body['usage']['prompt_tokens'];
|
||||
$reportedCompletionTokens = $body['usage']['completion_tokens'];
|
||||
|
||||
echo "\n[Usage Stats]\n";
|
||||
echo " Our input count: {$inputTokens}\n";
|
||||
echo " API prompt: {$reportedPromptTokens}\n";
|
||||
echo " API completion: {$reportedCompletionTokens}\n";
|
||||
|
||||
// The reported prompt should be > our raw count (includes system message)
|
||||
$this->assertGreaterThan($inputTokens, $reportedPromptTokens);
|
||||
$this->assertGreaterThan(0, $reportedCompletionTokens);
|
||||
}
|
||||
}
|
||||
155
tests/ToolCallParserTest.php
Normal file
155
tests/ToolCallParserTest.php
Normal file
@@ -0,0 +1,155 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ContextPaging\Tests;
|
||||
|
||||
use ContextPaging\ToolCallParser;
|
||||
use ContextPaging\ToolCallMode;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ToolCallParserTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* Test extracting native OpenAI-style tool calls.
|
||||
*/
|
||||
public function testExtractNativeToolCalls(): void
|
||||
{
|
||||
$parser = new ToolCallParser(ToolCallMode::NATIVE);
|
||||
|
||||
$response = [
|
||||
"choices" => [
|
||||
[
|
||||
"message" => [
|
||||
"role" => "assistant",
|
||||
"content" => null,
|
||||
"tool_calls" => [
|
||||
[
|
||||
"id" => "call_123",
|
||||
"function" => [
|
||||
"name" => "fetch_message",
|
||||
"arguments" => "{\"md5\":\"abc123\"}",
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$toolCalls = $parser->extract($response);
|
||||
|
||||
$this->assertNotNull($toolCalls);
|
||||
$this->assertCount(1, $toolCalls);
|
||||
$this->assertEquals("fetch_message", $toolCalls[0]["name"]);
|
||||
$this->assertEquals(["md5" => "abc123"], $toolCalls[0]["arguments"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test extracting raw tool calls from content.
|
||||
*/
|
||||
public function testExtractRawToolCalls(): void
|
||||
{
|
||||
$parser = new ToolCallParser(ToolCallMode::RAW);
|
||||
|
||||
// The actual content from SmolLM3 response
|
||||
$rawContent = chr(0xD9) . chr(0xA7) . "{\"name\": \"web_search\", \"arguments\": {\"query\": \"nvidia stock\"}}" . chr(0xD9) . chr(0xA7);
|
||||
|
||||
$response = [
|
||||
"choices" => [
|
||||
[
|
||||
"message" => [
|
||||
"role" => "assistant",
|
||||
"content" => $rawContent,
|
||||
"tool_calls" => [],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$toolCalls = $parser->extract($response);
|
||||
|
||||
$this->assertNotNull($toolCalls);
|
||||
$this->assertGreaterThanOrEqual(1, count($toolCalls));
|
||||
$this->assertEquals("web_search", $toolCalls[0]["name"]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test auto-detect mode picks native tool calls.
|
||||
*/
|
||||
public function testAutoDetectNative(): void
|
||||
{
|
||||
$parser = new ToolCallParser(ToolCallMode::AUTO);
|
||||
|
||||
$response = [
|
||||
"choices" => [
|
||||
[
|
||||
"message" => [
|
||||
"role" => "assistant",
|
||||
"content" => null,
|
||||
"tool_calls" => [
|
||||
[
|
||||
"id" => "call_456",
|
||||
"function" => [
|
||||
"name" => "fetch_message",
|
||||
"arguments" => "{\"md5\":\"def456\"}",
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$detectedMode = $parser->detectMode($response);
|
||||
$this->assertEquals(ToolCallMode::NATIVE, $detectedMode);
|
||||
|
||||
$toolCalls = $parser->extract($response);
|
||||
$this->assertNotNull($toolCalls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test no tool calls returns null.
|
||||
*/
|
||||
public function testNoToolCalls(): void
|
||||
{
|
||||
$parser = new ToolCallParser(ToolCallMode::AUTO);
|
||||
|
||||
$response = [
|
||||
"choices" => [
|
||||
[
|
||||
"message" => [
|
||||
"role" => "assistant",
|
||||
"content" => "Hello! How can I help you?",
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$toolCalls = $parser->extract($response);
|
||||
$this->assertNull($toolCalls);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test hasToolCalls helper.
|
||||
*/
|
||||
public function testHasToolCalls(): void
|
||||
{
|
||||
$parser = new ToolCallParser(ToolCallMode::NATIVE);
|
||||
|
||||
$withTools = [
|
||||
"choices" => [
|
||||
["message" => ["tool_calls" => [["function" => ["name" => "test"]]]]],
|
||||
],
|
||||
];
|
||||
|
||||
$withoutTools = [
|
||||
"choices" => [
|
||||
["message" => ["content" => "Hello"]],
|
||||
],
|
||||
];
|
||||
|
||||
$this->assertTrue($parser->hasToolCalls($withTools));
|
||||
$this->assertFalse($parser->hasToolCalls($withoutTools));
|
||||
}
|
||||
}
|
||||
139
tests/ToolFormatterTest.php
Normal file
139
tests/ToolFormatterTest.php
Normal file
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace ContextPaging\Tests;
|
||||
|
||||
use ContextPaging\ToolFormatter;
|
||||
use ContextPaging\ToolCallMode;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ToolFormatterTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* Test native format produces tools array.
|
||||
*/
|
||||
public function testFormatNative(): void
|
||||
{
|
||||
$formatter = new ToolFormatter(ToolCallMode::NATIVE);
|
||||
|
||||
$tools = [
|
||||
[
|
||||
'name' => 'fetch_message',
|
||||
'description' => 'Retrieve a message',
|
||||
'parameters' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'md5' => ['type' => 'string'],
|
||||
],
|
||||
'required' => ['md5'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = $formatter->formatForRequest($tools);
|
||||
|
||||
$this->assertArrayHasKey('tools', $result);
|
||||
$this->assertArrayHasKey('tool_choice', $result);
|
||||
$this->assertEquals('auto', $result['tool_choice']);
|
||||
$this->assertCount(1, $result['tools']);
|
||||
$this->assertEquals('function', $result['tools'][0]['type']);
|
||||
$this->assertEquals('fetch_message', $result['tools'][0]['function']['name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test raw format returns empty array (tools go in system prompt).
|
||||
*/
|
||||
public function testFormatRaw(): void
|
||||
{
|
||||
$formatter = new ToolFormatter(ToolCallMode::RAW);
|
||||
|
||||
$tools = [
|
||||
[
|
||||
'name' => 'test_tool',
|
||||
'description' => 'A test tool',
|
||||
'parameters' => [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'query' => ['type' => 'string', 'description' => 'Search query'],
|
||||
],
|
||||
'required' => ['query'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
$result = $formatter->formatForRequest($tools);
|
||||
|
||||
// Raw format returns empty array - tools are injected via buildPayload
|
||||
$this->assertEmpty($result);
|
||||
|
||||
// Verify tools get injected into system prompt via buildPayload
|
||||
$messages = [
|
||||
['role' => 'system', 'content' => 'You are a helpful assistant.'],
|
||||
];
|
||||
$modifiedMessages = $formatter->injectToolsIntoMessages($messages, $tools);
|
||||
|
||||
$this->assertStringContainsString('## Tools', $modifiedMessages[0]['content']);
|
||||
$this->assertStringContainsString('test_tool', $modifiedMessages[0]['content']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test buildPayload with native mode.
|
||||
*/
|
||||
public function testBuildPayloadNative(): void
|
||||
{
|
||||
$formatter = new ToolFormatter(ToolCallMode::NATIVE);
|
||||
|
||||
$messages = [
|
||||
['role' => 'user', 'content' => 'Hello'],
|
||||
];
|
||||
|
||||
$options = ['model' => 'gpt-4'];
|
||||
|
||||
$tools = [ToolFormatter::FETCH_MESSAGE_TOOL];
|
||||
|
||||
$payload = $formatter->buildPayload($messages, $options, $tools);
|
||||
|
||||
$this->assertEquals($messages, $payload['messages']);
|
||||
$this->assertEquals('gpt-4', $payload['model']);
|
||||
$this->assertArrayHasKey('tools', $payload);
|
||||
$this->assertEquals('fetch_message', $payload['tools'][0]['function']['name']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test buildPayload with raw mode injects into system prompt.
|
||||
*/
|
||||
public function testBuildPayloadRaw(): void
|
||||
{
|
||||
$formatter = new ToolFormatter(ToolCallMode::RAW);
|
||||
|
||||
$messages = [
|
||||
['role' => 'system', 'content' => 'You are helpful.'],
|
||||
['role' => 'user', 'content' => 'Hello'],
|
||||
];
|
||||
|
||||
$options = ['model' => 'test-model'];
|
||||
|
||||
$tools = [ToolFormatter::FETCH_MESSAGE_TOOL];
|
||||
|
||||
$payload = $formatter->buildPayload($messages, $options, $tools);
|
||||
|
||||
// Raw mode does NOT add tools key
|
||||
$this->assertArrayNotHasKey('tools', $payload);
|
||||
// Instead, tools are injected into the system message
|
||||
$this->assertStringContainsString('fetch_message', $payload['messages'][0]['content']);
|
||||
$this->assertStringContainsString('You are helpful.', $payload['messages'][0]['content']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test FETCH_MESSAGE_TOOL constant.
|
||||
*/
|
||||
public function testFetchMessageToolDefinition(): void
|
||||
{
|
||||
$tool = ToolFormatter::FETCH_MESSAGE_TOOL;
|
||||
|
||||
$this->assertEquals('fetch_message', $tool['name']);
|
||||
$this->assertArrayHasKey('md5', $tool['parameters']['properties']);
|
||||
$this->assertEquals(['md5'], $tool['parameters']['required']);
|
||||
}
|
||||
}
|
||||
27
tests/fluff.md
Normal file
27
tests/fluff.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Cloudflare forked just-bash and they really, really should not have
|
||||
|
||||
Sunil and I talked on the phone about this. He's sorry that he didn't ping me before publishing. I'm sorry that I didn't ping him before posting. It's all good.
|
||||
|
||||
My colleague noticed yesterday that Cloudflare forked just-bash and published it as @cloudflare/shell. Now, this is completely in their rights to do. Just-bash is published under Apache 2.0 and everybody can do with the source as they please under the permissive license. That said, it is worth having a conversation as to whether you should fork an open-source project and when you should not.
|
||||
|
||||
I think there is something like open-source "etiquette" and "community-spirit" where forks are kind of a last resort. Because, by default, it is better to make the shared thing better than only getting your changes in. As far as I know, there have been no attempts by Cloudflare to contribute to just-bash and I'm the sole maintainer, so I should know.
|
||||
|
||||
On top of this, projects are in different states. If I have a super stable half-abandoned project, then a fork can be very sustainable: I do the change I need and few changes from upstream are ever expected to need to land in my fork.
|
||||
|
||||
## Don't fork things before they are stable
|
||||
|
||||
This is where it comes to why forking just-bash is such a bad idea at this stage. Just-bash is not that. It's new, under heavy-development, and to some extent exploring the frontier of the category of "sandbox-ish thing for agents". The security model is evolving and the code base as to evolve accordingly.
|
||||
|
||||
Cloudflare's fork removed the disclaimer that this is a beta project and it removed several reference in the README for optional features that present additional security surface.
|
||||
|
||||
This is particularly egregious because Cloudflare replace the python3 implementation with one that will immediately get you owned. They document to use pyodide which, by default, allows the python program full access to the JS host environment.
|
||||
|
||||
Just-bash used to use pyodide, but it tried to use it in a secure way. Cloudflare removed the security relevant code entirely 🤯. Additionally, just-bash migrated away because pyodide really cannot be made secure under just-bash's threat model, but this likely happened after Cloudflare's fork. Again, don't fork early projects.
|
||||
|
||||
## Defense-in-depth layers got removed
|
||||
|
||||
Just-bash has several defense-in-depth layers that introduce additional defenses against host break out. Cloudflare either forked before they got introduced or simply deleted them.
|
||||
|
||||
For example, just-bash has DefenseInDepthBox which deactivates `eval`, the function constructor, and other ways to eval code in JS while just-bash is executing. Cloudflare's own Worker system does not need this part but they advertise as cross-platform and Node.js, and Deno very much need this. Additionally, this also prevents access to global objects that lay leak secrets, WASM and other dangerous APIs which are very much available in Workers. Again, this is just gone.
|
||||
|
||||
Just-bash also ships with deep checks that prevent code patterns which are prone to prototype pollution which is the most worrying vector for host-escape
|
||||
Reference in New Issue
Block a user