Skip to content

Commit 672ae54

Browse files
committed
Introduce generic platform for openai embeddings- and completions-based platforms
1 parent 917cab3 commit 672ae54

File tree

9 files changed

+505
-2
lines changed

9 files changed

+505
-2
lines changed

examples/litellm/chat.php

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,35 @@
99
* file that was distributed with this source code.
1010
*/
1111

12-
use Symfony\AI\Platform\Bridge\LiteLlm\PlatformFactory;
12+
use Symfony\AI\Platform\Bridge\Generic\CompletionsModel;
13+
use Symfony\AI\Platform\Bridge\Generic\ModelCatalog;
14+
use Symfony\AI\Platform\Bridge\Generic\PlatformFactory;
15+
use Symfony\AI\Platform\Capability;
1316
use Symfony\AI\Platform\Message\Message;
1417
use Symfony\AI\Platform\Message\MessageBag;
1518

1619
require_once dirname(__DIR__).'/bootstrap.php';
1720

18-
$platform = PlatformFactory::create(env('LITELLM_HOST_URL'), env('LITELLM_API_KEY'), http_client());
21+
$modelCatalog = new ModelCatalog([
22+
'mistral-small-latest' => [
23+
'class' => CompletionsModel::class,
24+
'capabilities' => [
25+
Capability::INPUT_MESSAGES,
26+
Capability::OUTPUT_TEXT,
27+
Capability::OUTPUT_STREAMING,
28+
Capability::OUTPUT_STRUCTURED,
29+
Capability::INPUT_IMAGE,
30+
Capability::TOOL_CALLING,
31+
],
32+
],
33+
]);
34+
35+
$platform = PlatformFactory::create(
36+
env('LITELLM_HOST_URL'),
37+
env('LITELLM_API_KEY'),
38+
http_client(),
39+
$modelCatalog,
40+
);
1941

2042
$messages = new MessageBag(
2143
Message::forSystem('You are a pirate and you write funny.'),
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\Generic\Completions;
13+
14+
use Symfony\AI\Platform\Bridge\Generic\CompletionsModel;
15+
use Symfony\AI\Platform\Model;
16+
use Symfony\AI\Platform\ModelClientInterface;
17+
use Symfony\AI\Platform\Result\RawHttpResult;
18+
use Symfony\Component\HttpClient\EventSourceHttpClient;
19+
use Symfony\Contracts\HttpClient\HttpClientInterface;
20+
21+
/**
22+
* This default implementation is based on OpenAI's initial completion endpoint, that got later adopted by other
23+
* providers as well. It can be used by any bridge or directly with the default PlatformFactory.
24+
*
25+
* @author Christopher Hertel <mail@christopher-hertel.de>
26+
*/
27+
class ModelClient implements ModelClientInterface
28+
{
29+
private readonly EventSourceHttpClient $httpClient;
30+
31+
public function __construct(
32+
HttpClientInterface $httpClient,
33+
private readonly string $baseUrl,
34+
#[\SensitiveParameter] private readonly ?string $apiKey = null,
35+
private readonly string $path = '/v1/chat/completions',
36+
) {
37+
$this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient);
38+
}
39+
40+
public function supports(Model $model): bool
41+
{
42+
return $model instanceof CompletionsModel;
43+
}
44+
45+
public function request(Model $model, array|string $payload, array $options = []): RawHttpResult
46+
{
47+
return new RawHttpResult($this->httpClient->request('POST', $this->baseUrl.$this->path, [
48+
'auth_bearer' => $this->apiKey,
49+
'headers' => ['Content-Type' => 'application/json'],
50+
'json' => array_merge($options, $payload),
51+
]));
52+
}
53+
}
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
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\Generic\Completions;
13+
14+
use Symfony\AI\Platform\Bridge\Generic\CompletionsModel;
15+
use Symfony\AI\Platform\Exception\AuthenticationException;
16+
use Symfony\AI\Platform\Exception\BadRequestException;
17+
use Symfony\AI\Platform\Exception\ContentFilterException;
18+
use Symfony\AI\Platform\Exception\RateLimitExceededException;
19+
use Symfony\AI\Platform\Exception\RuntimeException;
20+
use Symfony\AI\Platform\Model;
21+
use Symfony\AI\Platform\Result\ChoiceResult;
22+
use Symfony\AI\Platform\Result\RawHttpResult;
23+
use Symfony\AI\Platform\Result\RawResultInterface;
24+
use Symfony\AI\Platform\Result\ResultInterface;
25+
use Symfony\AI\Platform\Result\StreamResult;
26+
use Symfony\AI\Platform\Result\TextResult;
27+
use Symfony\AI\Platform\Result\ToolCall;
28+
use Symfony\AI\Platform\Result\ToolCallResult;
29+
use Symfony\AI\Platform\ResultConverterInterface;
30+
31+
/**
32+
* This default implementation is based on the OpenAI GPT completion API.
33+
*
34+
* @author Christopher Hertel <mail@christopher-hertel.de>
35+
* @author Denis Zunke <denis.zunke@gmail.com>
36+
*/
37+
class ResultConverter implements ResultConverterInterface
38+
{
39+
public function supports(Model $model): bool
40+
{
41+
return $model instanceof CompletionsModel;
42+
}
43+
44+
public function convert(RawResultInterface|RawHttpResult $result, array $options = []): ResultInterface
45+
{
46+
$response = $result->getObject();
47+
48+
if (401 === $response->getStatusCode()) {
49+
$errorMessage = json_decode($response->getContent(false), true)['error']['message'];
50+
throw new AuthenticationException($errorMessage);
51+
}
52+
53+
if (400 === $response->getStatusCode()) {
54+
$errorMessage = json_decode($response->getContent(false), true)['error']['message'] ?? 'Bad Request';
55+
throw new BadRequestException($errorMessage);
56+
}
57+
58+
if (429 === $response->getStatusCode()) {
59+
throw new RateLimitExceededException();
60+
}
61+
62+
if ($options['stream'] ?? false) {
63+
return new StreamResult($this->convertStream($result));
64+
}
65+
66+
$data = $result->getData();
67+
68+
if (isset($data['error']['code']) && 'content_filter' === $data['error']['code']) {
69+
throw new ContentFilterException($data['error']['message']);
70+
}
71+
72+
if (isset($data['error'])) {
73+
throw new RuntimeException(\sprintf('Error "%s"-%s (%s): "%s".', $data['error']['code'] ?? '-', $data['error']['type'] ?? '-', $data['error']['param'] ?? '-', $data['error']['message'] ?? '-'));
74+
}
75+
76+
if (!isset($data['choices'])) {
77+
throw new RuntimeException('Response does not contain choices.');
78+
}
79+
80+
$choices = array_map($this->convertChoice(...), $data['choices']);
81+
82+
return 1 === \count($choices) ? $choices[0] : new ChoiceResult(...$choices);
83+
}
84+
85+
private function convertStream(RawResultInterface|RawHttpResult $result): \Generator
86+
{
87+
$toolCalls = [];
88+
foreach ($result->getDataStream() as $data) {
89+
if ($this->streamIsToolCall($data)) {
90+
$toolCalls = $this->convertStreamToToolCalls($toolCalls, $data);
91+
}
92+
93+
if ([] !== $toolCalls && $this->isToolCallsStreamFinished($data)) {
94+
yield new ToolCallResult(...array_map($this->convertToolCall(...), $toolCalls));
95+
}
96+
97+
if (!isset($data['choices'][0]['delta']['content'])) {
98+
continue;
99+
}
100+
101+
yield $data['choices'][0]['delta']['content'];
102+
}
103+
}
104+
105+
/**
106+
* @param array<string, mixed> $toolCalls
107+
* @param array<string, mixed> $data
108+
*
109+
* @return array<string, mixed>
110+
*/
111+
private function convertStreamToToolCalls(array $toolCalls, array $data): array
112+
{
113+
if (!isset($data['choices'][0]['delta']['tool_calls'])) {
114+
return $toolCalls;
115+
}
116+
117+
foreach ($data['choices'][0]['delta']['tool_calls'] as $i => $toolCall) {
118+
if (isset($toolCall['id'])) {
119+
// initialize tool call
120+
$toolCalls[$i] = [
121+
'id' => $toolCall['id'],
122+
'function' => $toolCall['function'],
123+
];
124+
continue;
125+
}
126+
127+
// add arguments delta to tool call
128+
$toolCalls[$i]['function']['arguments'] .= $toolCall['function']['arguments'];
129+
}
130+
131+
return $toolCalls;
132+
}
133+
134+
/**
135+
* @param array<string, mixed> $data
136+
*/
137+
private function streamIsToolCall(array $data): bool
138+
{
139+
return isset($data['choices'][0]['delta']['tool_calls']);
140+
}
141+
142+
/**
143+
* @param array<string, mixed> $data
144+
*/
145+
private function isToolCallsStreamFinished(array $data): bool
146+
{
147+
return isset($data['choices'][0]['finish_reason']) && 'tool_calls' === $data['choices'][0]['finish_reason'];
148+
}
149+
150+
/**
151+
* @param array{
152+
* index: int,
153+
* message: array{
154+
* role: 'assistant',
155+
* content: ?string,
156+
* tool_calls: list<array{
157+
* id: string,
158+
* type: 'function',
159+
* function: array{
160+
* name: string,
161+
* arguments: string
162+
* },
163+
* }>,
164+
* refusal: ?mixed
165+
* },
166+
* logprobs: string,
167+
* finish_reason: 'stop'|'length'|'tool_calls'|'content_filter',
168+
* } $choice
169+
*/
170+
private function convertChoice(array $choice): ToolCallResult|TextResult
171+
{
172+
if ('tool_calls' === $choice['finish_reason']) {
173+
return new ToolCallResult(...array_map([$this, 'convertToolCall'], $choice['message']['tool_calls']));
174+
}
175+
176+
if (\in_array($choice['finish_reason'], ['stop', 'length'], true)) {
177+
return new TextResult($choice['message']['content']);
178+
}
179+
180+
throw new RuntimeException(\sprintf('Unsupported finish reason "%s".', $choice['finish_reason']));
181+
}
182+
183+
/**
184+
* @param array{
185+
* id: string,
186+
* type: 'function',
187+
* function: array{
188+
* name: string,
189+
* arguments: string
190+
* }
191+
* } $toolCall
192+
*/
193+
private function convertToolCall(array $toolCall): ToolCall
194+
{
195+
$arguments = json_decode($toolCall['function']['arguments'], true, flags: \JSON_THROW_ON_ERROR);
196+
197+
return new ToolCall($toolCall['id'], $toolCall['function']['name'], $arguments);
198+
}
199+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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\Generic;
13+
14+
use Symfony\AI\Platform\Model;
15+
16+
/**
17+
* @author Christopher Hertel <mail@christopher-hertel.de>
18+
*/
19+
class CompletionsModel extends Model
20+
{
21+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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\Generic\Embeddings;
13+
14+
use Symfony\AI\Platform\Bridge\Generic\EmbeddingsModel;
15+
use Symfony\AI\Platform\Model;
16+
use Symfony\AI\Platform\ModelClientInterface;
17+
use Symfony\AI\Platform\Result\RawHttpResult;
18+
use Symfony\Contracts\HttpClient\HttpClientInterface;
19+
20+
/**
21+
* This generic implementation is based on OpenAI's initial embeddings endpoint, that got later adopted by other
22+
* providers as well. It can be used by any bridge or directly with the generic PlatformFactory.
23+
*
24+
* @author Christopher Hertel <mail@christopher-hertel.de>
25+
*/
26+
class ModelClient implements ModelClientInterface
27+
{
28+
public function __construct(
29+
private readonly ?HttpClientInterface $httpClient,
30+
private readonly string $baseUrl,
31+
#[\SensitiveParameter] private readonly ?string $apiKey = null,
32+
private readonly string $path = '/v1/embeddings',
33+
) {
34+
}
35+
36+
public function supports(Model $model): bool
37+
{
38+
return $model instanceof EmbeddingsModel;
39+
}
40+
41+
public function request(Model $model, array|string $payload, array $options = []): RawHttpResult
42+
{
43+
return new RawHttpResult($this->httpClient->request('POST', $this->baseUrl.$this->path, [
44+
'auth_bearer' => $this->apiKey,
45+
'headers' => ['Content-Type' => 'application/json'],
46+
'json' => array_merge($options, [
47+
'model' => $model->getName(),
48+
'input' => $payload,
49+
]),
50+
]));
51+
}
52+
}

0 commit comments

Comments
 (0)