diff --git a/examples/bootstrap.php b/examples/bootstrap.php index de21386cf..0966390ed 100644 --- a/examples/bootstrap.php +++ b/examples/bootstrap.php @@ -80,3 +80,53 @@ function print_vectors(ResultPromise $result): void echo 'Dimensions: '.$result->asVectors()[0]->getDimensions().\PHP_EOL; } + +function perplexity_print_search_results(Metadata $metadata): void +{ + $searchResults = $metadata->get('search_results'); + + if (null === $searchResults) { + return; + } + + echo 'Search results:'.\PHP_EOL; + + if (0 === count($searchResults)) { + echo 'No search results.'.\PHP_EOL; + + return; + } + + foreach ($searchResults as $i => $searchResult) { + echo 'Result #'.($i + 1).':'.\PHP_EOL; + echo $searchResult['title'].\PHP_EOL; + echo $searchResult['url'].\PHP_EOL; + echo $searchResult['date'].\PHP_EOL; + echo $searchResult['last_updated'] ? $searchResult['last_updated'].\PHP_EOL : ''; + echo $searchResult['snippet'] ? $searchResult['snippet'].\PHP_EOL : ''; + echo \PHP_EOL; + } +} + +function perplexity_print_citations(Metadata $metadata): void +{ + $citations = $metadata->get('citations'); + + if (null === $citations) { + return; + } + + echo 'Citations:'.\PHP_EOL; + + if (0 === count($citations)) { + echo 'No citations.'.\PHP_EOL; + + return; + } + + foreach ($citations as $i => $citation) { + echo 'Citation #'.($i + 1).':'.\PHP_EOL; + echo $citation.\PHP_EOL; + echo \PHP_EOL; + } +} diff --git a/examples/perplexity/academic-search.php b/examples/perplexity/academic-search.php new file mode 100644 index 000000000..8e7149c26 --- /dev/null +++ b/examples/perplexity/academic-search.php @@ -0,0 +1,36 @@ + + * + * 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\Perplexity\Perplexity; +use Symfony\AI\Platform\Bridge\Perplexity\PlatformFactory; +use Symfony\AI\Platform\Bridge\Perplexity\SearchResultProcessor; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('PERPLEXITY_API_KEY'), http_client()); +$model = new Perplexity(); +$agent = new Agent($platform, $model, outputProcessors: [new SearchResultProcessor()], logger: logger()); + +$messages = new MessageBag(Message::ofUser('What is the best French cheese of the first quarter-century of 21st century?')); +$response = $agent->call($messages, [ + 'search_mode' => 'academic', + 'search_after_date_filter' => '01/01/2000', + 'search_before_date_filter' => '01/01/2025', +]); + +echo $response->getContent().\PHP_EOL; +echo \PHP_EOL; + +perplexity_print_search_results($response->getMetadata()); +perplexity_print_citations($response->getMetadata()); diff --git a/examples/perplexity/chat.php b/examples/perplexity/chat.php new file mode 100644 index 000000000..a6c1716ab --- /dev/null +++ b/examples/perplexity/chat.php @@ -0,0 +1,27 @@ + + * + * 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\Perplexity\Perplexity; +use Symfony\AI\Platform\Bridge\Perplexity\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('PERPLEXITY_API_KEY'), http_client()); +$model = new Perplexity(); +$agent = new Agent($platform, $model); + +$messages = new MessageBag(Message::ofUser('What is the best French cheese?')); +$response = $agent->call($messages); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/perplexity/disable-search.php b/examples/perplexity/disable-search.php new file mode 100644 index 000000000..bf252c774 --- /dev/null +++ b/examples/perplexity/disable-search.php @@ -0,0 +1,29 @@ + + * + * 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\Perplexity\Perplexity; +use Symfony\AI\Platform\Bridge\Perplexity\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('PERPLEXITY_API_KEY'), http_client()); +$model = new Perplexity(); +$agent = new Agent($platform, $model, logger: logger()); + +$messages = new MessageBag(Message::ofUser('What is 2 + 2?')); +$response = $agent->call($messages, [ + 'disable_search' => true, +]); + +echo $response->getContent().\PHP_EOL; diff --git a/examples/perplexity/image-input-url.php b/examples/perplexity/image-input-url.php new file mode 100644 index 000000000..ea8c08432 --- /dev/null +++ b/examples/perplexity/image-input-url.php @@ -0,0 +1,38 @@ + + * + * 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\Perplexity\Perplexity; +use Symfony\AI\Platform\Bridge\Perplexity\PlatformFactory; +use Symfony\AI\Platform\Bridge\Perplexity\SearchResultProcessor; +use Symfony\AI\Platform\Message\Content\ImageUrl; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('PERPLEXITY_API_KEY'), http_client()); +$model = new Perplexity(); +$agent = new Agent($platform, $model, outputProcessors: [new SearchResultProcessor()], logger: logger()); + +$messages = new MessageBag( + Message::forSystem('You are an image analyzer bot that helps identify the content of images.'), + Message::ofUser( + 'Describe the image as a comedian would do it.', + new ImageUrl('https://upload.wikimedia.org/wikipedia/commons/thumb/3/31/Webysther_20160423_-_Elephpant.svg/350px-Webysther_20160423_-_Elephpant.svg.png'), + ), +); +$result = $agent->call($messages); + +echo $result->getContent().\PHP_EOL; + +perplexity_print_search_results($result->getMetadata()); +perplexity_print_citations($result->getMetadata()); diff --git a/examples/perplexity/pdf-input-url.php b/examples/perplexity/pdf-input-url.php new file mode 100644 index 000000000..6454498c0 --- /dev/null +++ b/examples/perplexity/pdf-input-url.php @@ -0,0 +1,40 @@ + + * + * 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\Perplexity\Perplexity; +use Symfony\AI\Platform\Bridge\Perplexity\PlatformFactory; +use Symfony\AI\Platform\Bridge\Perplexity\SearchResultProcessor; +use Symfony\AI\Platform\Message\Content\DocumentUrl; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('PERPLEXITY_API_KEY'), http_client()); +$model = new Perplexity(); +$agent = new Agent($platform, $model, outputProcessors: [new SearchResultProcessor()], logger: logger()); + +$messages = new MessageBag( + Message::ofUser( + new DocumentUrl('https://upload.wikimedia.org/wikipedia/commons/2/20/Re_example.pdf'), + 'What is this document about?', + ), +); +$result = $agent->call($messages); + +echo $result->getContent().\PHP_EOL; +$result = $agent->call($messages); + +echo $result->getContent().\PHP_EOL; + +perplexity_print_search_results($result->getMetadata()); +perplexity_print_citations($result->getMetadata()); diff --git a/examples/perplexity/stream.php b/examples/perplexity/stream.php new file mode 100644 index 000000000..5f2bb526a --- /dev/null +++ b/examples/perplexity/stream.php @@ -0,0 +1,39 @@ + + * + * 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\Perplexity\Perplexity; +use Symfony\AI\Platform\Bridge\Perplexity\PlatformFactory; +use Symfony\AI\Platform\Bridge\Perplexity\SearchResultProcessor; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('PERPLEXITY_API_KEY'), http_client()); +$model = new Perplexity(); +$agent = new Agent($platform, $model, outputProcessors: [new SearchResultProcessor()], logger: logger()); + +$messages = new MessageBag( + Message::forSystem('You are a thoughtful philosopher.'), + Message::ofUser('What is the purpose of an ant?'), +); +$result = $agent->call($messages, [ + 'stream' => true, +]); + +foreach ($result->getContent() as $word) { + echo $word; +} +echo \PHP_EOL; + +perplexity_print_search_results($result->getMetadata()); +perplexity_print_citations($result->getMetadata()); diff --git a/examples/perplexity/token-metadata.php b/examples/perplexity/token-metadata.php new file mode 100644 index 000000000..3f32e2597 --- /dev/null +++ b/examples/perplexity/token-metadata.php @@ -0,0 +1,34 @@ + + * + * 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\Perplexity\Perplexity; +use Symfony\AI\Platform\Bridge\Perplexity\PlatformFactory; +use Symfony\AI\Platform\Bridge\Perplexity\TokenOutputProcessor; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('PERPLEXITY_API_KEY'), http_client()); +$model = new Perplexity(); +$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, [ + 'model' => Perplexity::SONAR_DEEP_RESEARCH, + 'max_tokens' => 500, // specific options just for this call +]); + +print_token_usage($result->getMetadata()); diff --git a/examples/perplexity/web-search.php b/examples/perplexity/web-search.php new file mode 100644 index 000000000..eae2178f3 --- /dev/null +++ b/examples/perplexity/web-search.php @@ -0,0 +1,34 @@ + + * + * 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\Perplexity\Perplexity; +use Symfony\AI\Platform\Bridge\Perplexity\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('PERPLEXITY_API_KEY'), http_client()); +$model = new Perplexity(); +$agent = new Agent($platform, $model, logger: logger()); + +$messages = new MessageBag(Message::ofUser('What is the best French cheese?')); +$response = $agent->call($messages, [ + 'search_domain_filter' => [ + 'https://en.wikipedia.org/wiki/Cheese', + ], + 'search_mode' => 'web', + 'enable_search_classifier' => true, + 'search_recency_filter' => 'week', +]); + +echo $response->getContent().\PHP_EOL; diff --git a/src/platform/CHANGELOG.md b/src/platform/CHANGELOG.md index 4c8160791..56fd91e9b 100644 --- a/src/platform/CHANGELOG.md +++ b/src/platform/CHANGELOG.md @@ -6,7 +6,7 @@ CHANGELOG * Add support for Albert API for French/EU data sovereignty * Add unified abstraction layer for interacting with various AI models and providers - * Add support for 13+ AI providers: + * Add support for 15+ AI providers: - OpenAI (GPT-4, GPT-3.5, DALLĀ·E, Whisper) - Anthropic (Claude models via native API and AWS Bedrock) - Google (VertexAi and Gemini models with server-side tools support) @@ -22,6 +22,7 @@ CHANGELOG - TransformersPHP (local PHP-based transformer models) - LM Studio (local model hosting) - Cerebras (language models like Llama 4, Qwen 3, and more) + - Perplexity (Sonar models, supporting search results) * Add comprehensive message system with role-based messaging: - `UserMessage` for user inputs with multi-modal content - `SystemMessage` for system instructions diff --git a/src/platform/src/Bridge/Perplexity/Contract/FileUrlNormalizer.php b/src/platform/src/Bridge/Perplexity/Contract/FileUrlNormalizer.php new file mode 100644 index 000000000..895f5c1bb --- /dev/null +++ b/src/platform/src/Bridge/Perplexity/Contract/FileUrlNormalizer.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Perplexity\Contract; + +use Symfony\AI\Platform\Bridge\Perplexity\Perplexity; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\Content\DocumentUrl; +use Symfony\AI\Platform\Model; + +/** + * @author Mathieu Santostefano + */ +final class FileUrlNormalizer extends ModelContractNormalizer +{ + /** + * @param DocumentUrl $data + * + * @return array{type: 'file_url', file_url: array{url: string}} + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + return [ + 'type' => 'file_url', + 'file_url' => [ + 'url' => $data->url, + ], + ]; + } + + protected function supportedDataClass(): string + { + return DocumentUrl::class; + } + + protected function supportsModel(Model $model): bool + { + return $model instanceof Perplexity; + } +} diff --git a/src/platform/src/Bridge/Perplexity/Contract/PerplexityContract.php b/src/platform/src/Bridge/Perplexity/Contract/PerplexityContract.php new file mode 100644 index 000000000..d25cbe8f0 --- /dev/null +++ b/src/platform/src/Bridge/Perplexity/Contract/PerplexityContract.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Perplexity\Contract; + +use Symfony\AI\Platform\Contract; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * @author Mathieu Santostefano + */ +final readonly class PerplexityContract extends Contract +{ + public static function create(NormalizerInterface ...$normalizer): Contract + { + return parent::create( + new FileUrlNormalizer(), + ...$normalizer + ); + } +} diff --git a/src/platform/src/Bridge/Perplexity/ModelClient.php b/src/platform/src/Bridge/Perplexity/ModelClient.php new file mode 100644 index 000000000..8c11a94f6 --- /dev/null +++ b/src/platform/src/Bridge/Perplexity/ModelClient.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Perplexity; + +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\AI\Platform\Result\RawResultInterface; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Santostefano + */ +final readonly class ModelClient implements ModelClientInterface +{ + private EventSourceHttpClient $httpClient; + + public function __construct( + HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + ) { + $this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + + if ('' === $apiKey) { + throw new InvalidArgumentException('The API key must not be empty.'); + } + + if (!str_starts_with($apiKey, 'pplx-')) { + throw new InvalidArgumentException('The API key must start with "pplx-".'); + } + } + + public function supports(Model $model): bool + { + return $model instanceof Perplexity; + } + + public function request(Model $model, array|string $payload, array $options = []): RawResultInterface + { + return new RawHttpResult($this->httpClient->request('POST', 'https://api.perplexity.ai/chat/completions', [ + 'auth_bearer' => $this->apiKey, + 'json' => array_merge($options, $payload), + ])); + } +} diff --git a/src/platform/src/Bridge/Perplexity/Perplexity.php b/src/platform/src/Bridge/Perplexity/Perplexity.php new file mode 100644 index 000000000..93a7a20d5 --- /dev/null +++ b/src/platform/src/Bridge/Perplexity/Perplexity.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Perplexity; + +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Model; + +/** + * @author Mathieu Santostefano + */ +final class Perplexity extends Model +{ + public const SONAR = 'sonar'; + public const SONAR_PRO = 'sonar-pro'; + public const SONAR_REASONING = 'sonar-reasoning'; + public const SONAR_REASONING_PRO = 'sonar-reasoning-pro'; + public const SONAR_DEEP_RESEARCH = 'sonar-deep-research'; + + /** + * @param array $options + */ + public function __construct( + string $name = self::SONAR, + array $options = [], + ) { + $capabilities = [ + Capability::INPUT_MESSAGES, + Capability::INPUT_PDF, + Capability::OUTPUT_TEXT, + Capability::OUTPUT_STREAMING, + Capability::OUTPUT_STRUCTURED, + ]; + + if (self::SONAR_DEEP_RESEARCH !== $name) { + $capabilities[] = Capability::INPUT_IMAGE; + } + + parent::__construct($name, $capabilities, $options); + } +} diff --git a/src/platform/src/Bridge/Perplexity/PlatformFactory.php b/src/platform/src/Bridge/Perplexity/PlatformFactory.php new file mode 100644 index 000000000..2e8b03799 --- /dev/null +++ b/src/platform/src/Bridge/Perplexity/PlatformFactory.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Perplexity; + +use Symfony\AI\Platform\Bridge\Perplexity\Contract\PerplexityContract; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Platform; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Mathieu Santostefano + */ +final class PlatformFactory +{ + public static function create( + #[\SensitiveParameter] string $apiKey, + ?HttpClientInterface $httpClient = null, + ?Contract $contract = null, + ): Platform { + $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + + return new Platform( + [new ModelClient($httpClient, $apiKey)], + [new ResultConverter()], + $contract ?? PerplexityContract::create(), + ); + } +} diff --git a/src/platform/src/Bridge/Perplexity/ResultConverter.php b/src/platform/src/Bridge/Perplexity/ResultConverter.php new file mode 100644 index 000000000..0a5cc7695 --- /dev/null +++ b/src/platform/src/Bridge/Perplexity/ResultConverter.php @@ -0,0 +1,115 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Perplexity; + +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Metadata\Metadata; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Result\ChoiceResult; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\AI\Platform\Result\RawResultInterface; +use Symfony\AI\Platform\Result\ResultInterface; +use Symfony\AI\Platform\Result\StreamResult; +use Symfony\AI\Platform\Result\TextResult; +use Symfony\AI\Platform\ResultConverterInterface as PlatformResponseConverter; +use Symfony\Component\HttpClient\Chunk\ServerSentEvent; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Component\HttpClient\Exception\JsonException; +use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; + +/** + * @author Mathieu Santostefano + */ +final class ResultConverter implements PlatformResponseConverter +{ + public function supports(Model $model): bool + { + return $model instanceof Perplexity; + } + + public function convert(RawResultInterface|RawHttpResult $result, array $options = []): ResultInterface + { + if ($options['stream'] ?? false) { + return new StreamResult($this->convertStream($result->getObject())); + } + + $data = $result->getData(); + + if (!isset($data['choices'])) { + throw new RuntimeException('Response does not contain choices.'); + } + + $choices = array_map($this->convertChoice(...), $data['choices']); + + $result = 1 === \count($choices) ? $choices[0] : new ChoiceResult(...$choices); + + return $result; + } + + private function convertStream(HttpResponse $result): \Generator + { + $searchResults = $citations = []; + /** @var Metadata $metadata */ + $metadata = yield; + + foreach ((new EventSourceHttpClient())->stream($result) as $chunk) { + if (!$chunk instanceof ServerSentEvent || '[DONE]' === $chunk->getData()) { + continue; + } + + try { + $data = $chunk->getArrayData(); + } catch (JsonException) { + // try catch only needed for Symfony 6.4 + continue; + } + + if (isset($data['choices'][0]['delta']['content'])) { + yield $data['choices'][0]['delta']['content']; + } + + if (isset($data['search_results'])) { + $searchResults = $data['search_results']; + } + + if (isset($data['citations'])) { + $citations = $data['citations']; + } + } + + $metadata->add('search_results', $searchResults); + $metadata->add('citations', $citations); + } + + /** + * @param array{ + * index: int, + * message: array{ + * role: 'assistant', + * content: ?string + * }, + * delta: array{ + * role: 'assistant', + * content: string, + * }, + * finish_reason: 'stop'|'length', + * } $choice + */ + private function convertChoice(array $choice): TextResult + { + if (!\in_array($choice['finish_reason'], ['stop', 'length'], true)) { + throw new RuntimeException(\sprintf('Unsupported finish reason "%s".', $choice['finish_reason'])); + } + + return new TextResult($choice['message']['content']); + } +} diff --git a/src/platform/src/Bridge/Perplexity/SearchResultProcessor.php b/src/platform/src/Bridge/Perplexity/SearchResultProcessor.php new file mode 100644 index 000000000..217be96c1 --- /dev/null +++ b/src/platform/src/Bridge/Perplexity/SearchResultProcessor.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Perplexity; + +use Symfony\AI\Agent\Output; +use Symfony\AI\Agent\OutputProcessorInterface; +use Symfony\AI\Platform\Result\StreamResult; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Mathieu Santostefano + */ +final class SearchResultProcessor implements OutputProcessorInterface +{ + public function processOutput(Output $output): void + { + $metadata = $output->result->getMetadata(); + + if ($output->result instanceof StreamResult) { + $generator = $output->result->getContent(); + // Makes $metadata accessible in the stream loop. + $generator->send($metadata); + + return; + } + + $rawResponse = $output->result->getRawResult()?->getObject(); + if (!$rawResponse instanceof ResponseInterface) { + return; + } + + $content = $rawResponse->toArray(false); + + if (\array_key_exists('search_results', $content)) { + $metadata->add('search_results', $content['search_results']); + } + + if (\array_key_exists('citations', $content)) { + $metadata->add('citations', $content['citations']); + } + } +} diff --git a/src/platform/src/Bridge/Perplexity/TokenOutputProcessor.php b/src/platform/src/Bridge/Perplexity/TokenOutputProcessor.php new file mode 100644 index 000000000..3a8d624fc --- /dev/null +++ b/src/platform/src/Bridge/Perplexity/TokenOutputProcessor.php @@ -0,0 +1,54 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Perplexity; + +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; + +/** + * @author Mathieu Santostefano + */ +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; + } + + $content = $rawResponse->toArray(false); + + if (!\array_key_exists('usage', $content)) { + return; + } + + $metadata = $output->result->getMetadata(); + $tokenUsage = new TokenUsage(); + $usage = $content['usage']; + + $tokenUsage->promptTokens = $usage['prompt_tokens'] ?? null; + $tokenUsage->completionTokens = $usage['completion_tokens'] ?? null; + $tokenUsage->thinkingTokens = $usage['reasoning_tokens'] ?? null; + $tokenUsage->totalTokens = $usage['total_tokens'] ?? null; + + $metadata->add('token_usage', $tokenUsage); + } +} diff --git a/src/platform/tests/Bridge/Perplexity/Contract/FileUrlNormalizerTest.php b/src/platform/tests/Bridge/Perplexity/Contract/FileUrlNormalizerTest.php new file mode 100644 index 000000000..7dafe4a2c --- /dev/null +++ b/src/platform/tests/Bridge/Perplexity/Contract/FileUrlNormalizerTest.php @@ -0,0 +1,72 @@ + + * + * 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\Perplexity\Contract; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Medium; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Perplexity\Contract\FileUrlNormalizer; +use Symfony\AI\Platform\Bridge\Perplexity\Perplexity; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Contract\Normalizer\Message\MessageBagNormalizer; +use Symfony\AI\Platform\Message\Content\DocumentUrl; + +#[Medium] +#[CoversClass(FileUrlNormalizer::class)] +#[CoversClass(MessageBagNormalizer::class)] +final class FileUrlNormalizerTest extends TestCase +{ + public function testSupportsNormalization() + { + $normalizer = new FileUrlNormalizer(); + + $this->assertTrue($normalizer->supportsNormalization(new DocumentUrl(\dirname(__DIR__, 6).'/fixtures/not-a-document.pdf'), context: [ + Contract::CONTEXT_MODEL => new Perplexity(), + ])); + $this->assertFalse($normalizer->supportsNormalization('not a document')); + } + + public function testGetSupportedTypes() + { + $normalizer = new FileUrlNormalizer(); + + $expected = [ + DocumentUrl::class => true, + ]; + + $this->assertSame($expected, $normalizer->getSupportedTypes(null)); + } + + #[DataProvider('normalizeDataProvider')] + public function testNormalize(DocumentUrl $document, array $expected) + { + $normalizer = new FileUrlNormalizer(); + + $normalized = $normalizer->normalize($document); + + $this->assertEquals($expected, $normalized); + } + + public static function normalizeDataProvider(): iterable + { + yield 'document from file url' => [ + new DocumentUrl(\dirname(__DIR__, 6).'/fixtures/document.pdf'), + [ + 'type' => 'file_url', + 'file_url' => [ + 'url' => \dirname(__DIR__, 6).'/fixtures/document.pdf', + ], + ], + ]; + } +} diff --git a/src/platform/tests/Bridge/Perplexity/ModelClientTest.php b/src/platform/tests/Bridge/Perplexity/ModelClientTest.php new file mode 100644 index 000000000..b04a5af3e --- /dev/null +++ b/src/platform/tests/Bridge/Perplexity/ModelClientTest.php @@ -0,0 +1,116 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Bridge\Perplexity; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\TestWith; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Perplexity\ModelClient; +use Symfony\AI\Platform\Bridge\Perplexity\Perplexity; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; + +/** + * @author Mathieu Santostefano + */ +#[CoversClass(ModelClient::class)] +#[UsesClass(Perplexity::class)] +#[Small] +final class ModelClientTest extends TestCase +{ + public function testItThrowsExceptionWhenApiKeyIsEmpty() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The API key must not be empty.'); + + new ModelClient(new MockHttpClient(), ''); + } + + #[TestWith(['api-key-without-prefix'])] + #[TestWith(['plx-api-key'])] + #[TestWith(['PPLX-api-key'])] + #[TestWith(['pplxapikey'])] + #[TestWith(['pplx api-key'])] + #[TestWith(['pplx'])] + public function testItThrowsExceptionWhenApiKeyDoesNotStartWithPplx(string $invalidApiKey) + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The API key must start with "pplx-".'); + + new ModelClient(new MockHttpClient(), $invalidApiKey); + } + + public function testItAcceptsValidApiKey() + { + $modelClient = new ModelClient(new MockHttpClient(), 'pplx-valid-api-key'); + + $this->assertInstanceOf(ModelClient::class, $modelClient); + } + + public function testItWrapsHttpClientInEventSourceHttpClient() + { + $httpClient = new MockHttpClient(); + $modelClient = new ModelClient($httpClient, 'pplx-valid-api-key'); + + $this->assertInstanceOf(ModelClient::class, $modelClient); + } + + public function testItAcceptsEventSourceHttpClientDirectly() + { + $httpClient = new EventSourceHttpClient(new MockHttpClient()); + $modelClient = new ModelClient($httpClient, 'pplx-valid-api-key'); + + $this->assertInstanceOf(ModelClient::class, $modelClient); + } + + public function testItIsSupportingTheCorrectModel() + { + $modelClient = new ModelClient(new MockHttpClient(), 'pplx-api-key'); + + $this->assertTrue($modelClient->supports(new Perplexity())); + } + + public function testItIsExecutingTheCorrectRequest() + { + $resultCallback = static function (string $method, string $url, array $options): HttpResponse { + self::assertSame('POST', $method); + self::assertSame('https://api.perplexity.ai/chat/completions', $url); + self::assertSame('Authorization: Bearer pplx-api-key', $options['normalized_headers']['authorization'][0]); + self::assertSame('{"model":"sonar","messages":[{"role":"user","content":"test message"}]}', $options['body']); + + return new MockResponse(); + }; + $httpClient = new MockHttpClient([$resultCallback]); + $modelClient = new ModelClient($httpClient, 'pplx-api-key'); + $modelClient->request(new Perplexity(), ['model' => 'sonar', 'messages' => [['role' => 'user', 'content' => 'test message']]]); + } + + public function testItIsExecutingTheCorrectRequestWithArrayPayload() + { + $resultCallback = static function (string $method, string $url, array $options): HttpResponse { + self::assertSame('POST', $method); + self::assertSame('https://api.perplexity.ai/chat/completions', $url); + self::assertSame('Authorization: Bearer pplx-api-key', $options['normalized_headers']['authorization'][0]); + self::assertSame('{"model":"sonar","messages":[{"role":"user","content":"Hello"}]}', $options['body']); + + return new MockResponse(); + }; + $httpClient = new MockHttpClient([$resultCallback]); + $modelClient = new ModelClient($httpClient, 'pplx-api-key'); + $modelClient->request(new Perplexity(), ['model' => 'sonar', 'messages' => [['role' => 'user', 'content' => 'Hello']]]); + } +} diff --git a/src/platform/tests/Bridge/Perplexity/PerplexityTest.php b/src/platform/tests/Bridge/Perplexity/PerplexityTest.php new file mode 100644 index 000000000..31b7aa3f2 --- /dev/null +++ b/src/platform/tests/Bridge/Perplexity/PerplexityTest.php @@ -0,0 +1,41 @@ + + * + * 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\Perplexity; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Perplexity\Perplexity; + +/** + * @author Mathieu Santostefano + */ +#[CoversClass(Perplexity::class)] +#[Small] +final class PerplexityTest extends TestCase +{ + public function testItCreatesPerplexityWithDefaultSettings() + { + $perplexity = new Perplexity(); + + $this->assertSame(Perplexity::SONAR, $perplexity->getName()); + $this->assertSame([], $perplexity->getOptions()); + } + + public function testItCreatesPerplexityWithCustomSettings() + { + $perplexity = new Perplexity(Perplexity::SONAR_PRO, ['temperature' => 0.5, 'max_tokens' => 1000]); + + $this->assertSame(Perplexity::SONAR_PRO, $perplexity->getName()); + $this->assertSame(['temperature' => 0.5, 'max_tokens' => 1000], $perplexity->getOptions()); + } +} diff --git a/src/platform/tests/Bridge/Perplexity/PlatformFactoryTest.php b/src/platform/tests/Bridge/Perplexity/PlatformFactoryTest.php new file mode 100644 index 000000000..fa4524348 --- /dev/null +++ b/src/platform/tests/Bridge/Perplexity/PlatformFactoryTest.php @@ -0,0 +1,51 @@ + + * + * 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\Perplexity; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Perplexity\PlatformFactory; +use Symfony\AI\Platform\Platform; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Component\HttpClient\MockHttpClient; + +/** + * @author Mathieu Santostefano + */ +#[CoversClass(PlatformFactory::class)] +#[Small] +final class PlatformFactoryTest extends TestCase +{ + public function testItCreatesPlatformWithDefaultSettings() + { + $platform = PlatformFactory::create('pplx-test-api-key'); + + $this->assertInstanceOf(Platform::class, $platform); + } + + public function testItCreatesPlatformWithCustomHttpClient() + { + $httpClient = new MockHttpClient(); + $platform = PlatformFactory::create('pplx-test-api-key', $httpClient); + + $this->assertInstanceOf(Platform::class, $platform); + } + + public function testItCreatesPlatformWithEventSourceHttpClient() + { + $httpClient = new EventSourceHttpClient(new MockHttpClient()); + $platform = PlatformFactory::create('pplx-test-api-key', $httpClient); + + $this->assertInstanceOf(Platform::class, $platform); + } +} diff --git a/src/platform/tests/Bridge/Perplexity/ResultConverterTest.php b/src/platform/tests/Bridge/Perplexity/ResultConverterTest.php new file mode 100644 index 000000000..6d6bdb740 --- /dev/null +++ b/src/platform/tests/Bridge/Perplexity/ResultConverterTest.php @@ -0,0 +1,122 @@ + + * + * 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\Perplexity; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Small; +use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Perplexity\ResultConverter; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Result\ChoiceResult; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\AI\Platform\Result\TextResult; +use Symfony\AI\Platform\Result\ToolCall; +use Symfony\AI\Platform\Result\ToolCallResult; +use Symfony\Contracts\HttpClient\ResponseInterface; + +#[CoversClass(ResultConverter::class)] +#[Small] +#[UsesClass(ChoiceResult::class)] +#[UsesClass(TextResult::class)] +#[UsesClass(ToolCall::class)] +#[UsesClass(ToolCallResult::class)] +class ResultConverterTest extends TestCase +{ + public function testConvertTextResult() + { + $converter = new ResultConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('toArray')->willReturn([ + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello world', + ], + 'finish_reason' => 'stop', + ], + ], + ]); + + $result = $converter->convert(new RawHttpResult($httpResponse)); + + $this->assertInstanceOf(TextResult::class, $result); + $this->assertSame('Hello world', $result->getContent()); + } + + public function testConvertMultipleChoices() + { + $converter = new ResultConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('toArray')->willReturn([ + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Choice 1', + ], + 'finish_reason' => 'stop', + ], + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Choice 2', + ], + 'finish_reason' => 'stop', + ], + ], + ]); + + $result = $converter->convert(new RawHttpResult($httpResponse)); + + $this->assertInstanceOf(ChoiceResult::class, $result); + $choices = $result->getContent(); + $this->assertCount(2, $choices); + $this->assertSame('Choice 1', $choices[0]->getContent()); + $this->assertSame('Choice 2', $choices[1]->getContent()); + } + + public function testThrowsExceptionWhenNoChoices() + { + $converter = new ResultConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('toArray')->willReturn([]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Response does not contain choices'); + + $converter->convert(new RawHttpResult($httpResponse)); + } + + public function testThrowsExceptionForUnsupportedFinishReason() + { + $converter = new ResultConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('toArray')->willReturn([ + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Test content', + ], + 'finish_reason' => 'unsupported_reason', + ], + ], + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unsupported finish reason "unsupported_reason"'); + + $converter->convert(new RawHttpResult($httpResponse)); + } +} diff --git a/src/platform/tests/Bridge/Perplexity/TokenOutputProcessorTest.php b/src/platform/tests/Bridge/Perplexity/TokenOutputProcessorTest.php new file mode 100644 index 000000000..c0a284bab --- /dev/null +++ b/src/platform/tests/Bridge/Perplexity/TokenOutputProcessorTest.php @@ -0,0 +1,141 @@ + + * + * 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\Perplexity; + +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\Perplexity\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; + +/** + * @author Mathieu Santostefano + */ +#[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 testItAddsUsageTokensToMetadata() + { + $processor = new TokenOutputProcessor(); + $textResult = new TextResult('test'); + + $rawResult = $this->createRawResult([ + 'usage' => [ + 'prompt_tokens' => 10, + 'completion_tokens' => 20, + 'total_tokens' => 50, + 'reasoning_tokens' => 20, + ], + ]); + + $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(20, $tokenUsage->completionTokens); + $this->assertSame(20, $tokenUsage->thinkingTokens); + $this->assertSame(50, $tokenUsage->totalTokens); + } + + public function testItHandlesMissingUsageFields() + { + $processor = new TokenOutputProcessor(); + $textResult = new TextResult('test'); + + $rawResult = $this->createRawResult([ + 'usage' => [ + // Missing some fields + 'prompt_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->completionTokens); + $this->assertNull($tokenUsage->thinkingTokens); + $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, + $this->createStub(MessageBag::class), + [], + ); + } +}