Skip to content

Commit dbc1abf

Browse files
committed
WIP add perplexity platform
1 parent c0266db commit dbc1abf

File tree

5 files changed

+373
-0
lines changed

5 files changed

+373
-0
lines changed

examples/perplexity/chat.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
use Symfony\AI\Agent\Agent;
13+
use Symfony\AI\Platform\Bridge\Perplexity\Perplexity;
14+
use Symfony\AI\Platform\Bridge\Perplexity\PlatformFactory;
15+
use Symfony\AI\Platform\Message\Message;
16+
use Symfony\AI\Platform\Message\MessageBag;
17+
use Symfony\Component\Dotenv\Dotenv;
18+
19+
require_once dirname(__DIR__).'/vendor/autoload.php';
20+
(new Dotenv())->loadEnv(dirname(__DIR__).'/.env');
21+
22+
if (!isset($_SERVER['PERPLEXITY_API_KEY'])) {
23+
echo 'Please set the PERPLEXITY_API_KEY environment variable.'.\PHP_EOL;
24+
exit(1);
25+
}
26+
27+
$platform = PlatformFactory::create($_SERVER['PERPLEXITY_API_KEY']);
28+
$model = new Perplexity();
29+
$agent = new Agent($platform, $model);
30+
31+
$messages = new MessageBag(Message::ofUser('What is the best French cheese?'));
32+
$response = $agent->call($messages);
33+
34+
echo $response->getContent().\PHP_EOL;
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Platform\Bridge\Perplexity\Llm;
13+
14+
use Symfony\AI\Platform\Bridge\Mistral\Mistral;
15+
use Symfony\AI\Platform\Bridge\Perplexity\Perplexity;
16+
use Symfony\AI\Platform\Model;
17+
use Symfony\AI\Platform\ModelClientInterface;
18+
use Symfony\AI\Platform\Response\RawHttpResponse;
19+
use Symfony\Component\HttpClient\EventSourceHttpClient;
20+
use Symfony\Contracts\HttpClient\HttpClientInterface;
21+
22+
/**
23+
* @author Mathieu Santostefano <msantostefano@proton.me>
24+
*/
25+
final readonly class ModelClient implements ModelClientInterface
26+
{
27+
private EventSourceHttpClient $httpClient;
28+
29+
public function __construct(
30+
HttpClientInterface $httpClient,
31+
#[\SensitiveParameter]
32+
private string $apiKey,
33+
) {
34+
$this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient);
35+
}
36+
37+
public function supports(Model $model): bool
38+
{
39+
return $model instanceof Perplexity;
40+
}
41+
42+
public function request(Model $model, array|string $payload, array $options = []): RawHttpResponse
43+
{
44+
return new RawHttpResponse($this->httpClient->request('POST', 'https://api.perplexity.ai/chat/completions', [
45+
'auth_bearer' => $this->apiKey,
46+
'headers' => [
47+
'Content-Type' => 'application/json',
48+
'Accept' => 'application/json',
49+
],
50+
'json' => array_merge($options, $payload),
51+
]));
52+
}
53+
}
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Platform\Bridge\Perplexity\Llm;
13+
14+
use Symfony\AI\Platform\Bridge\OpenAI\GPT;
15+
use Symfony\AI\Platform\Bridge\Perplexity\Perplexity;
16+
use Symfony\AI\Platform\Exception\ContentFilterException;
17+
use Symfony\AI\Platform\Exception\RuntimeException;
18+
use Symfony\AI\Platform\Model;
19+
use Symfony\AI\Platform\Response\Choice;
20+
use Symfony\AI\Platform\Response\ChoiceResponse;
21+
use Symfony\AI\Platform\Response\RawHttpResponse;
22+
use Symfony\AI\Platform\Response\RawResponseInterface;
23+
use Symfony\AI\Platform\Response\ResponseInterface;
24+
use Symfony\AI\Platform\Response\StreamResponse;
25+
use Symfony\AI\Platform\Response\TextResponse;
26+
use Symfony\AI\Platform\Response\ToolCall;
27+
use Symfony\AI\Platform\Response\ToolCallResponse;
28+
use Symfony\AI\Platform\ResponseConverterInterface as PlatformResponseConverter;
29+
use Symfony\Component\HttpClient\Chunk\ServerSentEvent;
30+
use Symfony\Component\HttpClient\EventSourceHttpClient;
31+
use Symfony\Component\HttpClient\Exception\JsonException;
32+
use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse;
33+
34+
/**
35+
* @author Mathieu Santostefano <msantostefano@proton.me>
36+
*/
37+
final class ResponseConverter implements PlatformResponseConverter
38+
{
39+
public function supports(Model $model): bool
40+
{
41+
return $model instanceof Perplexity;
42+
}
43+
44+
public function convert(RawResponseInterface|RawHttpResponse $response, array $options = []): ResponseInterface
45+
{
46+
if ($options['stream'] ?? false) {
47+
return new StreamResponse($this->convertStream($response->getRawObject()));
48+
}
49+
$data = $response->getRawData();
50+
51+
if (isset($data['error']['code']) && 'content_filter' === $data['error']['code']) {
52+
throw new ContentFilterException($data['error']['message']);
53+
}
54+
55+
if (!isset($data['choices'])) {
56+
throw new RuntimeException('Response does not contain choices');
57+
}
58+
59+
/** @var Choice[] $choices */
60+
$choices = array_map($this->convertChoice(...), $data['choices']);
61+
62+
if (1 !== \count($choices)) {
63+
return new ChoiceResponse(...$choices);
64+
}
65+
66+
if ($choices[0]->hasToolCall()) {
67+
return new ToolCallResponse(...$choices[0]->getToolCalls());
68+
}
69+
70+
return new TextResponse($choices[0]->getContent());
71+
}
72+
73+
private function convertStream(HttpResponse $response): \Generator
74+
{
75+
$toolCalls = [];
76+
foreach ((new EventSourceHttpClient())->stream($response) as $chunk) {
77+
if (!$chunk instanceof ServerSentEvent || '[DONE]' === $chunk->getData()) {
78+
continue;
79+
}
80+
81+
try {
82+
$data = $chunk->getArrayData();
83+
} catch (JsonException) {
84+
// try catch only needed for Symfony 6.4
85+
continue;
86+
}
87+
88+
if ($this->streamIsToolCall($data)) {
89+
$toolCalls = $this->convertStreamToToolCalls($toolCalls, $data);
90+
}
91+
92+
if ([] !== $toolCalls && $this->isToolCallsStreamFinished($data)) {
93+
yield new ToolCallResponse(...array_map($this->convertToolCall(...), $toolCalls));
94+
}
95+
96+
if (!isset($data['choices'][0]['delta']['content'])) {
97+
continue;
98+
}
99+
100+
yield $data['choices'][0]['delta']['content'];
101+
}
102+
}
103+
104+
/**
105+
* @param array<string, mixed> $toolCalls
106+
* @param array<string, mixed> $data
107+
*
108+
* @return array<string, mixed>
109+
*/
110+
private function convertStreamToToolCalls(array $toolCalls, array $data): array
111+
{
112+
if (!isset($data['choices'][0]['delta']['tool_calls'])) {
113+
return $toolCalls;
114+
}
115+
116+
foreach ($data['choices'][0]['delta']['tool_calls'] as $i => $toolCall) {
117+
if (isset($toolCall['id'])) {
118+
// initialize tool call
119+
$toolCalls[$i] = [
120+
'id' => $toolCall['id'],
121+
'function' => $toolCall['function'],
122+
];
123+
continue;
124+
}
125+
126+
// add arguments delta to tool call
127+
$toolCalls[$i]['function']['arguments'] .= $toolCall['function']['arguments'];
128+
}
129+
130+
return $toolCalls;
131+
}
132+
133+
/**
134+
* @param array<string, mixed> $data
135+
*/
136+
private function streamIsToolCall(array $data): bool
137+
{
138+
return isset($data['choices'][0]['delta']['tool_calls']);
139+
}
140+
141+
/**
142+
* @param array<string, mixed> $data
143+
*/
144+
private function isToolCallsStreamFinished(array $data): bool
145+
{
146+
return isset($data['choices'][0]['finish_reason']) && 'tool_calls' === $data['choices'][0]['finish_reason'];
147+
}
148+
149+
/**
150+
* @param array{
151+
* index: integer,
152+
* message: array{
153+
* role: 'assistant',
154+
* content: ?string,
155+
* tool_calls: array{
156+
* id: string,
157+
* type: 'function',
158+
* function: array{
159+
* name: string,
160+
* arguments: string
161+
* },
162+
* },
163+
* refusal: ?mixed
164+
* },
165+
* logprobs: string,
166+
* finish_reason: 'stop'|'length'|'tool_calls'|'content_filter',
167+
* } $choice
168+
*/
169+
private function convertChoice(array $choice): Choice
170+
{
171+
if ('tool_calls' === $choice['finish_reason']) {
172+
return new Choice(toolCalls: array_map([$this, 'convertToolCall'], $choice['message']['tool_calls']));
173+
}
174+
175+
if (\in_array($choice['finish_reason'], ['stop', 'length'], true)) {
176+
return new Choice($choice['message']['content']);
177+
}
178+
179+
throw new RuntimeException(\sprintf('Unsupported finish reason "%s".', $choice['finish_reason']));
180+
}
181+
182+
/**
183+
* @param array{
184+
* id: string,
185+
* type: 'function',
186+
* function: array{
187+
* name: string,
188+
* arguments: string
189+
* }
190+
* } $toolCall
191+
*/
192+
private function convertToolCall(array $toolCall): ToolCall
193+
{
194+
$arguments = json_decode($toolCall['function']['arguments'], true, \JSON_THROW_ON_ERROR);
195+
196+
return new ToolCall($toolCall['id'], $toolCall['function']['name'], $arguments);
197+
}
198+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Platform\Bridge\Perplexity;
13+
14+
use Symfony\AI\Platform\Capability;
15+
use Symfony\AI\Platform\Model;
16+
17+
/**
18+
* @author Mathieu Santostefano <msantostefano@proton.me>
19+
*/
20+
final class Perplexity extends Model
21+
{
22+
public const SONAR = 'sonar';
23+
public const SONAR_PRO = 'sonar-pro';
24+
public const SONAR_REASONING = 'sonar-reasoning';
25+
public const SONAR_REASONING_PRO = 'sonar-reasoning-pro';
26+
public const SONAR_DEEP_RESEARCH = 'sonar-deep-research';
27+
28+
/**
29+
* @param array<string, mixed> $options
30+
*/
31+
public function __construct(
32+
string $name = self::SONAR,
33+
array $options = [],
34+
) {
35+
$capabilities = [
36+
Capability::INPUT_MESSAGES,
37+
Capability::INPUT_IMAGE,
38+
Capability::INPUT_PDF,
39+
Capability::INPUT_MULTIPLE,
40+
Capability::OUTPUT_TEXT,
41+
Capability::OUTPUT_STREAMING,
42+
Capability::OUTPUT_STRUCTURED,
43+
Capability::TOOL_CALLING,
44+
];
45+
46+
parent::__construct($name, $capabilities, $options);
47+
}
48+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Platform\Bridge\Perplexity;
13+
14+
use Symfony\AI\Platform\Bridge\Perplexity\Llm\ModelClient as PerplexityModelClient;
15+
use Symfony\AI\Platform\Bridge\Perplexity\Llm\ResponseConverter as PerplexityResponseConverter;
16+
use Symfony\AI\Platform\Contract;
17+
use Symfony\AI\Platform\Platform;
18+
use Symfony\Component\HttpClient\EventSourceHttpClient;
19+
use Symfony\Contracts\HttpClient\HttpClientInterface;
20+
21+
/**
22+
* @author Mathieu Santostefano <msantostefano@proton.me>
23+
*/
24+
final class PlatformFactory
25+
{
26+
public static function create(
27+
#[\SensitiveParameter]
28+
string $apiKey,
29+
?HttpClientInterface $httpClient = null,
30+
?Contract $contract = null,
31+
): Platform {
32+
$httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient);
33+
34+
return new Platform(
35+
[new PerplexityModelClient($httpClient, $apiKey)],
36+
[new PerplexityResponseConverter()],
37+
$contract,
38+
);
39+
}
40+
}

0 commit comments

Comments
 (0)