Introduce generic platform for openai embeddings- and completions-based platforms

This commit is contained in:
Christopher Hertel
2025-12-06 23:52:43 +01:00
commit b56925ee9b
8 changed files with 481 additions and 0 deletions

View 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),
]));
}
}

View 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
View 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
{
}

View 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,
]),
]));
}
}

View 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
View 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
View 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
View 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);
}
}