mirror of
https://github.com/symfony/ai-generic-platform.git
synced 2026-03-23 23:42:22 +01:00
Introduce generic platform for openai embeddings- and completions-based platforms
This commit is contained in:
53
Completions/ModelClient.php
Normal file
53
Completions/ModelClient.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Platform\Bridge\Generic\Completions;
|
||||
|
||||
use Symfony\AI\Platform\Bridge\Generic\CompletionsModel;
|
||||
use Symfony\AI\Platform\Model;
|
||||
use Symfony\AI\Platform\ModelClientInterface;
|
||||
use Symfony\AI\Platform\Result\RawHttpResult;
|
||||
use Symfony\Component\HttpClient\EventSourceHttpClient;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* This default implementation is based on OpenAI's initial completion endpoint, that got later adopted by other
|
||||
* providers as well. It can be used by any bridge or directly with the default PlatformFactory.
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
class ModelClient implements ModelClientInterface
|
||||
{
|
||||
private readonly EventSourceHttpClient $httpClient;
|
||||
|
||||
public function __construct(
|
||||
HttpClientInterface $httpClient,
|
||||
private readonly string $baseUrl,
|
||||
#[\SensitiveParameter] private readonly ?string $apiKey = null,
|
||||
private readonly string $path = '/v1/chat/completions',
|
||||
) {
|
||||
$this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient);
|
||||
}
|
||||
|
||||
public function supports(Model $model): bool
|
||||
{
|
||||
return $model instanceof CompletionsModel;
|
||||
}
|
||||
|
||||
public function request(Model $model, array|string $payload, array $options = []): RawHttpResult
|
||||
{
|
||||
return new RawHttpResult($this->httpClient->request('POST', $this->baseUrl.$this->path, [
|
||||
'auth_bearer' => $this->apiKey,
|
||||
'headers' => ['Content-Type' => 'application/json'],
|
||||
'json' => array_merge($options, $payload),
|
||||
]));
|
||||
}
|
||||
}
|
||||
199
Completions/ResultConverter.php
Normal file
199
Completions/ResultConverter.php
Normal file
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Platform\Bridge\Generic\Completions;
|
||||
|
||||
use Symfony\AI\Platform\Bridge\Generic\CompletionsModel;
|
||||
use Symfony\AI\Platform\Exception\AuthenticationException;
|
||||
use Symfony\AI\Platform\Exception\BadRequestException;
|
||||
use Symfony\AI\Platform\Exception\ContentFilterException;
|
||||
use Symfony\AI\Platform\Exception\RateLimitExceededException;
|
||||
use Symfony\AI\Platform\Exception\RuntimeException;
|
||||
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\Result\ToolCall;
|
||||
use Symfony\AI\Platform\Result\ToolCallResult;
|
||||
use Symfony\AI\Platform\ResultConverterInterface;
|
||||
|
||||
/**
|
||||
* This default implementation is based on the OpenAI GPT completion API.
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
* @author Denis Zunke <denis.zunke@gmail.com>
|
||||
*/
|
||||
class ResultConverter implements ResultConverterInterface
|
||||
{
|
||||
public function supports(Model $model): bool
|
||||
{
|
||||
return $model instanceof CompletionsModel;
|
||||
}
|
||||
|
||||
public function convert(RawResultInterface|RawHttpResult $result, array $options = []): ResultInterface
|
||||
{
|
||||
$response = $result->getObject();
|
||||
|
||||
if (401 === $response->getStatusCode()) {
|
||||
$errorMessage = json_decode($response->getContent(false), true)['error']['message'];
|
||||
throw new AuthenticationException($errorMessage);
|
||||
}
|
||||
|
||||
if (400 === $response->getStatusCode()) {
|
||||
$errorMessage = json_decode($response->getContent(false), true)['error']['message'] ?? 'Bad Request';
|
||||
throw new BadRequestException($errorMessage);
|
||||
}
|
||||
|
||||
if (429 === $response->getStatusCode()) {
|
||||
throw new RateLimitExceededException();
|
||||
}
|
||||
|
||||
if ($options['stream'] ?? false) {
|
||||
return new StreamResult($this->convertStream($result));
|
||||
}
|
||||
|
||||
$data = $result->getData();
|
||||
|
||||
if (isset($data['error']['code']) && 'content_filter' === $data['error']['code']) {
|
||||
throw new ContentFilterException($data['error']['message']);
|
||||
}
|
||||
|
||||
if (isset($data['error'])) {
|
||||
throw new RuntimeException(\sprintf('Error "%s"-%s (%s): "%s".', $data['error']['code'] ?? '-', $data['error']['type'] ?? '-', $data['error']['param'] ?? '-', $data['error']['message'] ?? '-'));
|
||||
}
|
||||
|
||||
if (!isset($data['choices'])) {
|
||||
throw new RuntimeException('Response does not contain choices.');
|
||||
}
|
||||
|
||||
$choices = array_map($this->convertChoice(...), $data['choices']);
|
||||
|
||||
return 1 === \count($choices) ? $choices[0] : new ChoiceResult(...$choices);
|
||||
}
|
||||
|
||||
private function convertStream(RawResultInterface|RawHttpResult $result): \Generator
|
||||
{
|
||||
$toolCalls = [];
|
||||
foreach ($result->getDataStream() as $data) {
|
||||
if ($this->streamIsToolCall($data)) {
|
||||
$toolCalls = $this->convertStreamToToolCalls($toolCalls, $data);
|
||||
}
|
||||
|
||||
if ([] !== $toolCalls && $this->isToolCallsStreamFinished($data)) {
|
||||
yield new ToolCallResult(...array_map($this->convertToolCall(...), $toolCalls));
|
||||
}
|
||||
|
||||
if (!isset($data['choices'][0]['delta']['content'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
yield $data['choices'][0]['delta']['content'];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $toolCalls
|
||||
* @param array<string, mixed> $data
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function convertStreamToToolCalls(array $toolCalls, array $data): array
|
||||
{
|
||||
if (!isset($data['choices'][0]['delta']['tool_calls'])) {
|
||||
return $toolCalls;
|
||||
}
|
||||
|
||||
foreach ($data['choices'][0]['delta']['tool_calls'] as $i => $toolCall) {
|
||||
if (isset($toolCall['id'])) {
|
||||
// initialize tool call
|
||||
$toolCalls[$i] = [
|
||||
'id' => $toolCall['id'],
|
||||
'function' => $toolCall['function'],
|
||||
];
|
||||
continue;
|
||||
}
|
||||
|
||||
// add arguments delta to tool call
|
||||
$toolCalls[$i]['function']['arguments'] .= $toolCall['function']['arguments'];
|
||||
}
|
||||
|
||||
return $toolCalls;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function streamIsToolCall(array $data): bool
|
||||
{
|
||||
return isset($data['choices'][0]['delta']['tool_calls']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private function isToolCallsStreamFinished(array $data): bool
|
||||
{
|
||||
return isset($data['choices'][0]['finish_reason']) && 'tool_calls' === $data['choices'][0]['finish_reason'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* index: int,
|
||||
* message: array{
|
||||
* role: 'assistant',
|
||||
* content: ?string,
|
||||
* tool_calls: list<array{
|
||||
* id: string,
|
||||
* type: 'function',
|
||||
* function: array{
|
||||
* name: string,
|
||||
* arguments: string
|
||||
* },
|
||||
* }>,
|
||||
* refusal: ?mixed
|
||||
* },
|
||||
* logprobs: string,
|
||||
* finish_reason: 'stop'|'length'|'tool_calls'|'content_filter',
|
||||
* } $choice
|
||||
*/
|
||||
private function convertChoice(array $choice): ToolCallResult|TextResult
|
||||
{
|
||||
if ('tool_calls' === $choice['finish_reason']) {
|
||||
return new ToolCallResult(...array_map([$this, 'convertToolCall'], $choice['message']['tool_calls']));
|
||||
}
|
||||
|
||||
if (\in_array($choice['finish_reason'], ['stop', 'length'], true)) {
|
||||
return new TextResult($choice['message']['content']);
|
||||
}
|
||||
|
||||
throw new RuntimeException(\sprintf('Unsupported finish reason "%s".', $choice['finish_reason']));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{
|
||||
* id: string,
|
||||
* type: 'function',
|
||||
* function: array{
|
||||
* name: string,
|
||||
* arguments: string
|
||||
* }
|
||||
* } $toolCall
|
||||
*/
|
||||
private function convertToolCall(array $toolCall): ToolCall
|
||||
{
|
||||
$arguments = json_decode($toolCall['function']['arguments'], true, flags: \JSON_THROW_ON_ERROR);
|
||||
|
||||
return new ToolCall($toolCall['id'], $toolCall['function']['name'], $arguments);
|
||||
}
|
||||
}
|
||||
21
CompletionsModel.php
Normal file
21
CompletionsModel.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Platform\Bridge\Generic;
|
||||
|
||||
use Symfony\AI\Platform\Model;
|
||||
|
||||
/**
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
class CompletionsModel extends Model
|
||||
{
|
||||
}
|
||||
52
Embeddings/ModelClient.php
Normal file
52
Embeddings/ModelClient.php
Normal file
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Platform\Bridge\Generic\Embeddings;
|
||||
|
||||
use Symfony\AI\Platform\Bridge\Generic\EmbeddingsModel;
|
||||
use Symfony\AI\Platform\Model;
|
||||
use Symfony\AI\Platform\ModelClientInterface;
|
||||
use Symfony\AI\Platform\Result\RawHttpResult;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* This generic implementation is based on OpenAI's initial embeddings endpoint, that got later adopted by other
|
||||
* providers as well. It can be used by any bridge or directly with the generic PlatformFactory.
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
class ModelClient implements ModelClientInterface
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ?HttpClientInterface $httpClient,
|
||||
private readonly string $baseUrl,
|
||||
#[\SensitiveParameter] private readonly ?string $apiKey = null,
|
||||
private readonly string $path = '/v1/embeddings',
|
||||
) {
|
||||
}
|
||||
|
||||
public function supports(Model $model): bool
|
||||
{
|
||||
return $model instanceof EmbeddingsModel;
|
||||
}
|
||||
|
||||
public function request(Model $model, array|string $payload, array $options = []): RawHttpResult
|
||||
{
|
||||
return new RawHttpResult($this->httpClient->request('POST', $this->baseUrl.$this->path, [
|
||||
'auth_bearer' => $this->apiKey,
|
||||
'headers' => ['Content-Type' => 'application/json'],
|
||||
'json' => array_merge($options, [
|
||||
'model' => $model->getName(),
|
||||
'input' => $payload,
|
||||
]),
|
||||
]));
|
||||
}
|
||||
}
|
||||
49
Embeddings/ResultConverter.php
Normal file
49
Embeddings/ResultConverter.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Platform\Bridge\Generic\Embeddings;
|
||||
|
||||
use Symfony\AI\Platform\Bridge\Generic\EmbeddingsModel;
|
||||
use Symfony\AI\Platform\Exception\RuntimeException;
|
||||
use Symfony\AI\Platform\Model;
|
||||
use Symfony\AI\Platform\Result\RawResultInterface;
|
||||
use Symfony\AI\Platform\Result\VectorResult;
|
||||
use Symfony\AI\Platform\ResultConverterInterface;
|
||||
use Symfony\AI\Platform\Vector\Vector;
|
||||
|
||||
/**
|
||||
* This default result converter assumes the same response format as OpenAI's embeddings endpoint.
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
class ResultConverter implements ResultConverterInterface
|
||||
{
|
||||
public function supports(Model $model): bool
|
||||
{
|
||||
return $model instanceof EmbeddingsModel;
|
||||
}
|
||||
|
||||
public function convert(RawResultInterface $result, array $options = []): VectorResult
|
||||
{
|
||||
$data = $result->getData();
|
||||
|
||||
if (!isset($data['data'])) {
|
||||
throw new RuntimeException('Response does not contain data.');
|
||||
}
|
||||
|
||||
return new VectorResult(
|
||||
...array_map(
|
||||
static fn (array $item): Vector => new Vector($item['embedding']),
|
||||
$data['data']
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
21
EmbeddingsModel.php
Normal file
21
EmbeddingsModel.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Platform\Bridge\Generic;
|
||||
|
||||
use Symfony\AI\Platform\Model;
|
||||
|
||||
/**
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
class EmbeddingsModel extends Model
|
||||
{
|
||||
}
|
||||
32
ModelCatalog.php
Normal file
32
ModelCatalog.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Platform\Bridge\Generic;
|
||||
|
||||
use Symfony\AI\Platform\Capability;
|
||||
use Symfony\AI\Platform\ModelCatalog\AbstractModelCatalog;
|
||||
|
||||
/**
|
||||
* Models need to be registered explicitly here to be routed to the correct ModelClient and ResultConverter
|
||||
* implementations.
|
||||
*
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
final class ModelCatalog extends AbstractModelCatalog
|
||||
{
|
||||
/**
|
||||
* @param array<string, array{class: class-string, capabilities: list<Capability>}> $models
|
||||
*/
|
||||
public function __construct(
|
||||
protected array $models = [],
|
||||
) {
|
||||
}
|
||||
}
|
||||
54
PlatformFactory.php
Normal file
54
PlatformFactory.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Symfony package.
|
||||
*
|
||||
* (c) Fabien Potencier <fabien@symfony.com>
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace Symfony\AI\Platform\Bridge\Generic;
|
||||
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\AI\Platform\Contract;
|
||||
use Symfony\AI\Platform\ModelCatalog\FallbackModelCatalog;
|
||||
use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface;
|
||||
use Symfony\AI\Platform\Platform;
|
||||
use Symfony\Component\HttpClient\EventSourceHttpClient;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
/**
|
||||
* @author Christopher Hertel <mail@christopher-hertel.de>
|
||||
*/
|
||||
class PlatformFactory
|
||||
{
|
||||
public static function create(
|
||||
string $baseUrl,
|
||||
?string $apiKey = null,
|
||||
?HttpClientInterface $httpClient = null,
|
||||
ModelCatalogInterface $modelCatalog = new FallbackModelCatalog(),
|
||||
?Contract $contract = null,
|
||||
?EventDispatcherInterface $eventDispatcher = null,
|
||||
bool $supportsCompletions = true,
|
||||
bool $supportsEmbeddings = true,
|
||||
string $completionsPath = '/v1/chat/completions',
|
||||
string $embeddingsPath = '/v1/embeddings',
|
||||
): Platform {
|
||||
$httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient);
|
||||
|
||||
$modelClients = [];
|
||||
$resultConverters = [];
|
||||
if ($supportsCompletions) {
|
||||
$modelClients[] = new Completions\ModelClient($httpClient, $baseUrl, $apiKey, $completionsPath);
|
||||
$resultConverters[] = new Completions\ResultConverter();
|
||||
}
|
||||
if ($supportsEmbeddings) {
|
||||
$modelClients[] = new Embeddings\ModelClient($httpClient, $baseUrl, $apiKey, $embeddingsPath);
|
||||
$resultConverters[] = new Embeddings\ResultConverter();
|
||||
}
|
||||
|
||||
return new Platform($modelClients, $resultConverters, $modelCatalog, $contract, $eventDispatcher);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user