Skip to content

Commit 42c40dc

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

File tree

9 files changed

+530
-2
lines changed

9 files changed

+530
-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: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
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+
$headers = $response->getHeaders(false);
60+
$resetTime = $headers['x-ratelimit-reset-requests'][0]
61+
?? $headers['x-ratelimit-reset-tokens'][0]
62+
?? null;
63+
64+
throw new RateLimitExceededException($resetTime ? self::parseResetTime($resetTime) : null);
65+
}
66+
67+
if ($options['stream'] ?? false) {
68+
return new StreamResult($this->convertStream($result));
69+
}
70+
71+
$data = $result->getData();
72+
73+
if (isset($data['error']['code']) && 'content_filter' === $data['error']['code']) {
74+
throw new ContentFilterException($data['error']['message']);
75+
}
76+
77+
if (isset($data['error'])) {
78+
throw new RuntimeException(\sprintf('Error "%s"-%s (%s): "%s".', $data['error']['code'] ?? '-', $data['error']['type'] ?? '-', $data['error']['param'] ?? '-', $data['error']['message'] ?? '-'));
79+
}
80+
81+
if (!isset($data['choices'])) {
82+
throw new RuntimeException('Response does not contain choices.');
83+
}
84+
85+
$choices = array_map($this->convertChoice(...), $data['choices']);
86+
87+
return 1 === \count($choices) ? $choices[0] : new ChoiceResult(...$choices);
88+
}
89+
90+
private function convertStream(RawResultInterface|RawHttpResult $result): \Generator
91+
{
92+
$toolCalls = [];
93+
foreach ($result->getDataStream() as $data) {
94+
if ($this->streamIsToolCall($data)) {
95+
$toolCalls = $this->convertStreamToToolCalls($toolCalls, $data);
96+
}
97+
98+
if ([] !== $toolCalls && $this->isToolCallsStreamFinished($data)) {
99+
yield new ToolCallResult(...array_map($this->convertToolCall(...), $toolCalls));
100+
}
101+
102+
if (!isset($data['choices'][0]['delta']['content'])) {
103+
continue;
104+
}
105+
106+
yield $data['choices'][0]['delta']['content'];
107+
}
108+
}
109+
110+
/**
111+
* @param array<string, mixed> $toolCalls
112+
* @param array<string, mixed> $data
113+
*
114+
* @return array<string, mixed>
115+
*/
116+
private function convertStreamToToolCalls(array $toolCalls, array $data): array
117+
{
118+
if (!isset($data['choices'][0]['delta']['tool_calls'])) {
119+
return $toolCalls;
120+
}
121+
122+
foreach ($data['choices'][0]['delta']['tool_calls'] as $i => $toolCall) {
123+
if (isset($toolCall['id'])) {
124+
// initialize tool call
125+
$toolCalls[$i] = [
126+
'id' => $toolCall['id'],
127+
'function' => $toolCall['function'],
128+
];
129+
continue;
130+
}
131+
132+
// add arguments delta to tool call
133+
$toolCalls[$i]['function']['arguments'] .= $toolCall['function']['arguments'];
134+
}
135+
136+
return $toolCalls;
137+
}
138+
139+
/**
140+
* @param array<string, mixed> $data
141+
*/
142+
private function streamIsToolCall(array $data): bool
143+
{
144+
return isset($data['choices'][0]['delta']['tool_calls']);
145+
}
146+
147+
/**
148+
* @param array<string, mixed> $data
149+
*/
150+
private function isToolCallsStreamFinished(array $data): bool
151+
{
152+
return isset($data['choices'][0]['finish_reason']) && 'tool_calls' === $data['choices'][0]['finish_reason'];
153+
}
154+
155+
/**
156+
* @param array{
157+
* index: int,
158+
* message: array{
159+
* role: 'assistant',
160+
* content: ?string,
161+
* tool_calls: list<array{
162+
* id: string,
163+
* type: 'function',
164+
* function: array{
165+
* name: string,
166+
* arguments: string
167+
* },
168+
* }>,
169+
* refusal: ?mixed
170+
* },
171+
* logprobs: string,
172+
* finish_reason: 'stop'|'length'|'tool_calls'|'content_filter',
173+
* } $choice
174+
*/
175+
private function convertChoice(array $choice): ToolCallResult|TextResult
176+
{
177+
if ('tool_calls' === $choice['finish_reason']) {
178+
return new ToolCallResult(...array_map([$this, 'convertToolCall'], $choice['message']['tool_calls']));
179+
}
180+
181+
if (\in_array($choice['finish_reason'], ['stop', 'length'], true)) {
182+
return new TextResult($choice['message']['content']);
183+
}
184+
185+
throw new RuntimeException(\sprintf('Unsupported finish reason "%s".', $choice['finish_reason']));
186+
}
187+
188+
/**
189+
* @param array{
190+
* id: string,
191+
* type: 'function',
192+
* function: array{
193+
* name: string,
194+
* arguments: string
195+
* }
196+
* } $toolCall
197+
*/
198+
private function convertToolCall(array $toolCall): ToolCall
199+
{
200+
$arguments = json_decode($toolCall['function']['arguments'], true, flags: \JSON_THROW_ON_ERROR);
201+
202+
return new ToolCall($toolCall['id'], $toolCall['function']['name'], $arguments);
203+
}
204+
205+
/**
206+
* Converts OpenAI's reset time format (e.g. "1s", "6m0s", "2m30s") into seconds.
207+
*
208+
* Supported formats:
209+
* - "1s"
210+
* - "6m0s"
211+
* - "2m30s"
212+
*/
213+
private static function parseResetTime(string $resetTime): ?int
214+
{
215+
if (preg_match('/^(?:(\d+)m)?(?:(\d+)s)?$/', $resetTime, $matches)) {
216+
$minutes = isset($matches[1]) ? (int) $matches[1] : 0;
217+
$secs = isset($matches[2]) ? (int) $matches[2] : 0;
218+
219+
return ($minutes * 60) + $secs;
220+
}
221+
222+
return null;
223+
}
224+
}
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)