From 8c3ed4edef9ad178a096ecfe6d54b3deb291dc02 Mon Sep 17 00:00:00 2001 From: Nicolas Touillet Date: Fri, 12 Sep 2025 14:39:52 +0200 Subject: [PATCH] [Platform][Anthropic] Add TokenOutputProcessor --- examples/anthropic/token-metadata.php | 31 ++++ src/ai-bundle/config/services.php | 2 + .../Bridge/Anthropic/TokenOutputProcessor.php | 59 +++++++ .../Anthropic/TokenOutputProcessorTest.php | 163 ++++++++++++++++++ 4 files changed, 255 insertions(+) create mode 100644 examples/anthropic/token-metadata.php create mode 100644 src/platform/src/Bridge/Anthropic/TokenOutputProcessor.php create mode 100644 src/platform/tests/Bridge/Anthropic/TokenOutputProcessorTest.php diff --git a/examples/anthropic/token-metadata.php b/examples/anthropic/token-metadata.php new file mode 100644 index 000000000..cf1c37c43 --- /dev/null +++ b/examples/anthropic/token-metadata.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Platform\Bridge\Anthropic\Claude; +use Symfony\AI\Platform\Bridge\Anthropic\PlatformFactory; +use Symfony\AI\Platform\Bridge\Anthropic\TokenOutputProcessor; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('ANTHROPIC_API_KEY'), http_client()); +$model = new Claude(Claude::SONNET_37); + +$agent = new Agent($platform, $model, outputProcessors: [new TokenOutputProcessor()], logger: logger()); +$messages = new MessageBag( + Message::forSystem('You are a pirate and you write funny.'), + Message::ofUser('What is the Symfony framework?'), +); +$result = $agent->call($messages); + +print_token_usage($result->getMetadata()); diff --git a/src/ai-bundle/config/services.php b/src/ai-bundle/config/services.php index 06b17bc0d..2437ef8d4 100644 --- a/src/ai-bundle/config/services.php +++ b/src/ai-bundle/config/services.php @@ -26,6 +26,7 @@ use Symfony\AI\AiBundle\Profiler\TraceableToolbox; use Symfony\AI\AiBundle\Security\EventListener\IsGrantedToolAttributeListener; use Symfony\AI\Platform\Bridge\Anthropic\Contract\AnthropicContract; +use Symfony\AI\Platform\Bridge\Anthropic\TokenOutputProcessor as AnthropicTokenOutputProcessor; use Symfony\AI\Platform\Bridge\Gemini\Contract\GeminiContract; use Symfony\AI\Platform\Bridge\Gemini\TokenOutputProcessor as GeminiTokenOutputProcessor; use Symfony\AI\Platform\Bridge\Mistral\TokenOutputProcessor as MistralTokenOutputProcessor; @@ -142,6 +143,7 @@ ->tag('ai.traceable_toolbox') // token usage processors + ->set('ai.platform.token_usage_processor.anthropic', AnthropicTokenOutputProcessor::class) ->set('ai.platform.token_usage_processor.gemini', GeminiTokenOutputProcessor::class) ->set('ai.platform.token_usage_processor.mistral', MistralTokenOutputProcessor::class) ->set('ai.platform.token_usage_processor.openai', OpenAiTokenOutputProcessor::class) diff --git a/src/platform/src/Bridge/Anthropic/TokenOutputProcessor.php b/src/platform/src/Bridge/Anthropic/TokenOutputProcessor.php new file mode 100644 index 000000000..b9c27922a --- /dev/null +++ b/src/platform/src/Bridge/Anthropic/TokenOutputProcessor.php @@ -0,0 +1,59 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Anthropic; + +use Symfony\AI\Agent\Output; +use Symfony\AI\Agent\OutputProcessorInterface; +use Symfony\AI\Platform\Metadata\TokenUsage; +use Symfony\AI\Platform\Result\StreamResult; +use Symfony\Contracts\HttpClient\ResponseInterface; + +final class TokenOutputProcessor implements OutputProcessorInterface +{ + public function processOutput(Output $output): void + { + if ($output->result instanceof StreamResult) { + // Streams have to be handled manually as the tokens are part of the streamed chunks + return; + } + + $rawResponse = $output->result->getRawResult()?->getObject(); + if (!$rawResponse instanceof ResponseInterface) { + return; + } + + $metadata = $output->result->getMetadata(); + + $tokenUsage = new TokenUsage(); + + $content = $rawResponse->toArray(false); + if (!\array_key_exists('usage', $content)) { + $metadata->add('token_usage', $tokenUsage); + + return; + } + + $usage = $content['usage']; + + $tokenUsage->promptTokens = $usage['input_tokens'] ?? null; + $tokenUsage->completionTokens = $usage['output_tokens'] ?? null; + $tokenUsage->toolTokens = $usage['server_tool_use']['web_search_requests'] ?? null; + + $cachedTokens = null; + if (\array_key_exists('cache_creation_input_tokens', $usage) || \array_key_exists('cache_read_input_tokens', $usage)) { + $cachedTokens = ($usage['cache_creation_input_tokens'] ?? 0) + ($usage['cache_read_input_tokens'] ?? 0); + } + $tokenUsage->cachedTokens = $cachedTokens; + + $metadata->add('token_usage', $tokenUsage); + } +} diff --git a/src/platform/tests/Bridge/Anthropic/TokenOutputProcessorTest.php b/src/platform/tests/Bridge/Anthropic/TokenOutputProcessorTest.php new file mode 100644 index 000000000..6310704a1 --- /dev/null +++ b/src/platform/tests/Bridge/Anthropic/TokenOutputProcessorTest.php @@ -0,0 +1,163 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Bridge\Anthropic; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Output; +use Symfony\AI\Platform\Bridge\Anthropic\TokenOutputProcessor; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\Metadata\Metadata; +use Symfony\AI\Platform\Metadata\TokenUsage; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\AI\Platform\Result\ResultInterface; +use Symfony\AI\Platform\Result\StreamResult; +use Symfony\AI\Platform\Result\TextResult; +use Symfony\Contracts\HttpClient\ResponseInterface; + +#[CoversClass(TokenOutputProcessor::class)] +#[UsesClass(Output::class)] +#[UsesClass(TextResult::class)] +#[UsesClass(StreamResult::class)] +#[UsesClass(Metadata::class)] +#[UsesClass(TokenUsage::class)] +#[Small] +final class TokenOutputProcessorTest extends TestCase +{ + public function testItHandlesStreamResponsesWithoutProcessing() + { + $processor = new TokenOutputProcessor(); + $streamResult = new StreamResult((static function () { yield 'test'; })()); + $output = $this->createOutput($streamResult); + + $processor->processOutput($output); + + $metadata = $output->result->getMetadata(); + $this->assertCount(0, $metadata); + } + + public function testItDoesNothingWithoutRawResponse() + { + $processor = new TokenOutputProcessor(); + $textResult = new TextResult('test'); + $output = $this->createOutput($textResult); + + $processor->processOutput($output); + + $metadata = $output->result->getMetadata(); + $this->assertCount(0, $metadata); + } + + public function testItAddsRemainingTokensToMetadata() + { + $processor = new TokenOutputProcessor(); + $textResult = new TextResult('test'); + + $textResult->setRawResult($this->createRawResult()); + + $output = $this->createOutput($textResult); + + $processor->processOutput($output); + + $metadata = $output->result->getMetadata(); + $tokenUsage = $metadata->get('token_usage'); + + $this->assertCount(1, $metadata); + $this->assertInstanceOf(TokenUsage::class, $tokenUsage); + $this->assertNull($tokenUsage->remainingTokens); + } + + public function testItAddsUsageTokensToMetadata() + { + $processor = new TokenOutputProcessor(); + $textResult = new TextResult('test'); + + $rawResult = $this->createRawResult([ + 'usage' => [ + 'input_tokens' => 10, + 'output_tokens' => 20, + 'server_tool_use' => [ + 'web_search_requests' => 30, + ], + 'cache_creation_input_tokens' => 40, + 'cache_read_input_tokens' => 50, + ], + ]); + + $textResult->setRawResult($rawResult); + + $output = $this->createOutput($textResult); + + $processor->processOutput($output); + + $metadata = $output->result->getMetadata(); + $tokenUsage = $metadata->get('token_usage'); + + $this->assertInstanceOf(TokenUsage::class, $tokenUsage); + $this->assertSame(10, $tokenUsage->promptTokens); + $this->assertSame(30, $tokenUsage->toolTokens); + $this->assertSame(20, $tokenUsage->completionTokens); + $this->assertNull($tokenUsage->remainingTokens); + $this->assertNull($tokenUsage->thinkingTokens); + $this->assertSame(90, $tokenUsage->cachedTokens); + $this->assertNull($tokenUsage->totalTokens); + } + + public function testItHandlesMissingUsageFields() + { + $processor = new TokenOutputProcessor(); + $textResult = new TextResult('test'); + + $rawResult = $this->createRawResult([ + 'usage' => [ + // Missing some fields + 'input_tokens' => 10, + ], + ]); + + $textResult->setRawResult($rawResult); + + $output = $this->createOutput($textResult); + + $processor->processOutput($output); + + $metadata = $output->result->getMetadata(); + $tokenUsage = $metadata->get('token_usage'); + + $this->assertInstanceOf(TokenUsage::class, $tokenUsage); + $this->assertSame(10, $tokenUsage->promptTokens); + $this->assertNull($tokenUsage->remainingTokens); + $this->assertNull($tokenUsage->completionTokens); + $this->assertNull($tokenUsage->totalTokens); + } + + private function createRawResult(array $data = []): RawHttpResult + { + $rawResponse = $this->createStub(ResponseInterface::class); + $rawResponse->method('toArray')->willReturn($data); + + return new RawHttpResult($rawResponse); + } + + private function createOutput(ResultInterface $result): Output + { + return new Output( + $this->createStub(Model::class), + $result, + new MessageBag(), + [], + ); + } +}