[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:
Hannes Kandulla
2026-03-13 15:00:17 +01:00
parent 34fb1cd2f7
commit d8b1d36832
2 changed files with 152 additions and 0 deletions

View File

@@ -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.
*

View 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]);
}
}