mirror of
https://github.com/symfony/ai.git
synced 2026-03-23 23:42:18 +01:00
[Platform][Anthropic] Add prompt caching for tool definitions
Add cache_control marker to the last tool definition, creating an additional cache breakpoint between system prompt and messages. Tool definitions are identical across requests, making them an effective caching target that reduces input token costs. Respects the existing cacheRetention setting (none/short/long).
This commit is contained in:
@@ -63,6 +63,7 @@ final class ModelClient implements ModelClientInterface
|
||||
|
||||
if (isset($options['tools'])) {
|
||||
$options['tool_choice'] = ['type' => 'auto'];
|
||||
$options['tools'] = $this->injectToolsCacheControl($options['tools']);
|
||||
}
|
||||
|
||||
if (isset($options['thinking'])) {
|
||||
@@ -90,6 +91,33 @@ final class ModelClient implements ModelClientInterface
|
||||
]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects a prompt-caching marker on the last tool definition.
|
||||
*
|
||||
* This creates an additional cache breakpoint after all tool definitions,
|
||||
* so the prefix "system → tools" can be cached independently of the
|
||||
* messages that follow. Tool definitions are typically identical across
|
||||
* requests, making this a very effective caching target.
|
||||
*
|
||||
* @param list<array<string, mixed>> $tools Normalised tool definitions
|
||||
*
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function injectToolsCacheControl(array $tools): array
|
||||
{
|
||||
if ('none' === $this->cacheRetention || [] === $tools) {
|
||||
return $tools;
|
||||
}
|
||||
|
||||
$cacheControl = 'long' === $this->cacheRetention
|
||||
? ['type' => 'ephemeral', 'ttl' => '1h']
|
||||
: ['type' => 'ephemeral'];
|
||||
|
||||
$tools[\count($tools) - 1]['cache_control'] = $cacheControl;
|
||||
|
||||
return $tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects prompt-caching markers into the normalised message payload.
|
||||
*
|
||||
|
||||
124
src/platform/tests/Bridge/Anthropic/ModelClientTest.php
Normal file
124
src/platform/tests/Bridge/Anthropic/ModelClientTest.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Platform\Tests\Bridge\Anthropic;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\AI\Platform\Bridge\Anthropic\Claude;
|
||||
use Symfony\AI\Platform\Bridge\Anthropic\ModelClient;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
|
||||
/**
|
||||
* @author Hannes Kandulla <hannes@faibl.org>
|
||||
*/
|
||||
final class ModelClientTest extends TestCase
|
||||
{
|
||||
public function testToolsCacheControlIsInjectedWithShortRetention()
|
||||
{
|
||||
$capturedBody = null;
|
||||
|
||||
$httpClient = new MockHttpClient(static function ($method, $url, $options) use (&$capturedBody) {
|
||||
$capturedBody = json_decode($options['body'], true);
|
||||
|
||||
return new MockResponse(json_encode([
|
||||
'type' => 'message',
|
||||
'content' => [['type' => 'text', 'text' => 'Hello']],
|
||||
'usage' => ['input_tokens' => 10, 'output_tokens' => 5],
|
||||
]));
|
||||
});
|
||||
|
||||
$client = new ModelClient($httpClient, 'test-api-key', 'short');
|
||||
|
||||
$tools = [
|
||||
['name' => 'tool_a', 'description' => 'First tool', 'input_schema' => ['type' => 'object']],
|
||||
['name' => 'tool_b', 'description' => 'Second tool', 'input_schema' => ['type' => 'object']],
|
||||
];
|
||||
|
||||
$payload = [
|
||||
'model' => Claude::SONNET_4,
|
||||
'messages' => [['role' => 'user', 'content' => 'Hello']],
|
||||
];
|
||||
|
||||
$client->request(new Claude(Claude::SONNET_4), $payload, ['tools' => $tools]);
|
||||
|
||||
$this->assertNotNull($capturedBody);
|
||||
|
||||
// Last tool should have cache_control
|
||||
$this->assertArrayHasKey('cache_control', $capturedBody['tools'][1]);
|
||||
$this->assertSame(['type' => 'ephemeral'], $capturedBody['tools'][1]['cache_control']);
|
||||
|
||||
// First tool should NOT have cache_control
|
||||
$this->assertArrayNotHasKey('cache_control', $capturedBody['tools'][0]);
|
||||
}
|
||||
|
||||
public function testToolsCacheControlIsInjectedWithLongRetention()
|
||||
{
|
||||
$capturedBody = null;
|
||||
|
||||
$httpClient = new MockHttpClient(static function ($method, $url, $options) use (&$capturedBody) {
|
||||
$capturedBody = json_decode($options['body'], true);
|
||||
|
||||
return new MockResponse(json_encode([
|
||||
'type' => 'message',
|
||||
'content' => [['type' => 'text', 'text' => 'Hello']],
|
||||
'usage' => ['input_tokens' => 10, 'output_tokens' => 5],
|
||||
]));
|
||||
});
|
||||
|
||||
$client = new ModelClient($httpClient, 'test-api-key', 'long');
|
||||
|
||||
$tools = [
|
||||
['name' => 'tool_a', 'description' => 'A tool', 'input_schema' => ['type' => 'object']],
|
||||
];
|
||||
|
||||
$payload = [
|
||||
'model' => Claude::SONNET_4,
|
||||
'messages' => [['role' => 'user', 'content' => 'Hello']],
|
||||
];
|
||||
|
||||
$client->request(new Claude(Claude::SONNET_4), $payload, ['tools' => $tools]);
|
||||
|
||||
$this->assertNotNull($capturedBody);
|
||||
$this->assertSame(['type' => 'ephemeral', 'ttl' => '1h'], $capturedBody['tools'][0]['cache_control']);
|
||||
}
|
||||
|
||||
public function testToolsCacheControlIsNotInjectedWithNoneRetention()
|
||||
{
|
||||
$capturedBody = null;
|
||||
|
||||
$httpClient = new MockHttpClient(static function ($method, $url, $options) use (&$capturedBody) {
|
||||
$capturedBody = json_decode($options['body'], true);
|
||||
|
||||
return new MockResponse(json_encode([
|
||||
'type' => 'message',
|
||||
'content' => [['type' => 'text', 'text' => 'Hello']],
|
||||
'usage' => ['input_tokens' => 10, 'output_tokens' => 5],
|
||||
]));
|
||||
});
|
||||
|
||||
$client = new ModelClient($httpClient, 'test-api-key', 'none');
|
||||
|
||||
$tools = [
|
||||
['name' => 'tool_a', 'description' => 'A tool', 'input_schema' => ['type' => 'object']],
|
||||
];
|
||||
|
||||
$payload = [
|
||||
'model' => Claude::SONNET_4,
|
||||
'messages' => [['role' => 'user', 'content' => 'Hello']],
|
||||
];
|
||||
|
||||
$client->request(new Claude(Claude::SONNET_4), $payload, ['tools' => $tools]);
|
||||
|
||||
$this->assertNotNull($capturedBody);
|
||||
$this->assertArrayNotHasKey('cache_control', $capturedBody['tools'][0]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user