diff --git a/examples/openai/connector.php b/examples/openai/connector.php new file mode 100644 index 000000000..9884d7fb5 --- /dev/null +++ b/examples/openai/connector.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Platform\Bridge\OpenAI\Connector; +use Symfony\AI\Platform\Bridge\OpenAI\GPT; +use Symfony\AI\Platform\ConnectorPlatform; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\Component\Dotenv\Dotenv; + +require_once dirname(__DIR__).'/vendor/autoload.php'; +(new Dotenv())->loadEnv(dirname(__DIR__).'/.env'); + +if (!isset($_SERVER['OPENAI_API_KEY'])) { + echo 'Please set the OPENAI_API_KEY environment variable.'.\PHP_EOL; + exit(1); +} + +$connector = new Connector($_SERVER['OPENAI_API_KEY']); +$model = new GPT(GPT::GPT_4O_MINI, [ + 'temperature' => 0.5, // default options for the model +]); + +$platform = new ConnectorPlatform($connector); + +$result = $platform->call($model, new MessageBag( + Message::forSystem('You are a pirate and you write funny.'), + Message::ofUser('What is the Symfony framework?'), +)); + +echo $result->asText().\PHP_EOL; diff --git a/src/platform/src/Bridge/OpenAi/Connector.php b/src/platform/src/Bridge/OpenAi/Connector.php new file mode 100644 index 000000000..ac881165e --- /dev/null +++ b/src/platform/src/Bridge/OpenAi/Connector.php @@ -0,0 +1,95 @@ +httpClient instanceof EventSourceHttpClient + ? $this->httpClient : new EventSourceHttpClient($this->httpClient); + + return $httpClient->withOptions(['auth_bearer' => $this->apiKey]); + } + + protected function getEndpoint(Model $model): string + { + $baseUrl = match ($this->region) { + null => 'https://api.openai.com', + PlatformFactory::REGION_EU => 'https://eu.api.openai.com', + PlatformFactory::REGION_US => 'https://us.api.openai.com', + default => throw new InvalidArgumentException(\sprintf('Invalid region "%s". Valid options are: "%s", "%s", or null.', $this->region, PlatformFactory::REGION_EU, PlatformFactory::REGION_US)), + }; + + return match (get_class($model)) { + Gpt::class => $baseUrl.'/chat/completions', + DallE::class => $baseUrl.'/images/generations', + Embeddings::class => $baseUrl.'/embeddings', + Whisper::class => $baseUrl.'/audio/transcriptions', + default => throw new InvalidArgumentException('Unsupported model type.'), + }; + } + + public function isError(ResultInterface $result): bool + { + return false; + } + + public function handleStream(Model $model, HttpResult|ResultInterface $result, array $options): StreamResult + { + return match (get_class($model)) { + Gpt::class => (new Gpt\ResultConverter())->convert($result->getRawObject(), $options), + default => throw new InvalidArgumentException('Unsupported model type for streaming.'), + }; + } + + public function handleError(Model $model, ResultInterface $result): never + { + // TODO: Implement handleError() method. + } + + public function handleResult(Model $model, HttpResult|ResultInterface $result, array $options): ConverterResult + { + return match (get_class($model)) { + Gpt::class => (new Gpt\ResultConverter())->convert($result->getRawObject(), $options), + DallE::class => (new DallEModelClient())->convert($result->getRawObject(), $options), + Embeddings::class => (new EmbeddingsResponseConverter())->convert($result->getRawObject(), $options), + Whisper::class => (new WhisperResponseConverter())->convert($result->getRawObject(), $options), + default => throw new InvalidArgumentException('Unsupported model type for streaming.'), + }; + } +} diff --git a/src/platform/src/Connector/ConnectorInterface.php b/src/platform/src/Connector/ConnectorInterface.php new file mode 100644 index 000000000..24a10ea70 --- /dev/null +++ b/src/platform/src/Connector/ConnectorInterface.php @@ -0,0 +1,35 @@ + + */ +interface ConnectorInterface +{ + public function getContract(): Contract; + + /** + * @param array|string $payload + * @param array $options + * + * @return array + */ + public function call(Model $model, array|string $payload, array $options): ResultPromise; + + public function isError(ResultInterface $result): bool; + + public function handleStream(Model $model, ResultInterface $result, array $options): StreamResult; + + /** + * @throws ConnectorException + */ + public function handleError(Model $model, ResultInterface $result): never; + + public function handleResult(Model $model, ResultInterface $result, array $options): ConverterResult; +} diff --git a/src/platform/src/Connector/HttpConnector.php b/src/platform/src/Connector/HttpConnector.php new file mode 100644 index 000000000..dd8477e82 --- /dev/null +++ b/src/platform/src/Connector/HttpConnector.php @@ -0,0 +1,31 @@ + + */ +abstract class HttpConnector implements ConnectorInterface +{ + public function getContract(): Contract + { + return Contract::create(); + } + + public function call(Model $model, array|string $payload, array $options): ResultPromise + { + $response = $this->initHttpClient()->request('POST', $this->getEndpoint($model), [ + 'json' => $payload, + ]); + + return new ResultPromise(new HttpResult($response), $options); + } + + abstract protected function initHttpClient(): EventSourceHttpClient; + + abstract protected function getEndpoint(Model $model): string; +} diff --git a/src/platform/src/Connector/HttpResult.php b/src/platform/src/Connector/HttpResult.php new file mode 100644 index 000000000..f8ddd21fd --- /dev/null +++ b/src/platform/src/Connector/HttpResult.php @@ -0,0 +1,26 @@ +response->toArray(false); + } + + public function getRawObject(): HttpResponseInterface + { + return $this->response; + } +} diff --git a/src/platform/src/Connector/ResultInterface.php b/src/platform/src/Connector/ResultInterface.php new file mode 100644 index 000000000..bbf6ca8cd --- /dev/null +++ b/src/platform/src/Connector/ResultInterface.php @@ -0,0 +1,23 @@ + + */ +interface ResultInterface +{ + /** + * Returns an array representation of the raw result data. + * + * @return array + */ + public function getRawData(): array; + + /** + * Returns the raw result object. + * + * @return object + */ + public function getRawObject(): object; +} diff --git a/src/platform/src/Connector/ResultPromise.php b/src/platform/src/Connector/ResultPromise.php new file mode 100644 index 000000000..e0b965205 --- /dev/null +++ b/src/platform/src/Connector/ResultPromise.php @@ -0,0 +1,141 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Connector; + +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Exception\UnexpectedResultTypeException; +use Symfony\AI\Platform\Result\BinaryResult; +use Symfony\AI\Platform\Result\ObjectResult; +use Symfony\AI\Platform\Result\ResultInterface as ConverterResult; +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\Result\VectorResult; +use Symfony\AI\Platform\Vector\Vector; + +/** + * @author Christopher Hertel + */ +final class ResultPromise +{ + private \Closure $resultConverter; + private bool $isConverted = false; + private ConverterResult $convertedResult; + + /** + * @param array $options + */ + public function __construct( + private readonly ResultInterface $result, + private readonly array $options = [], + ) { + } + + public function registerConverter(\Closure $resultConverter): void + { + if (isset($this->resultConverter)) { + throw new RuntimeException('A result converter has already been registered for this promise.'); + } + + $this->resultConverter = $resultConverter; + } + + public function getResult(): ConverterResult + { + return $this->await(); + } + + public function getRawResponse(): ResultInterface + { + return $this->result; + } + + public function await(): ConverterResult + { + if (!$this->isConverted) { + if (!isset($this->resultConverter)) { + throw new RuntimeException('No result converter registered to handle the raw result.'); + } + + $this->convertedResult = ($this->resultConverter)($this->result, $this->options); + + if (null === $this->convertedResult->getRawResponse()) { + // Fallback to set the raw response when it was not handled by the response converter itself + $this->convertedResult->setRawResponse($this->result); + } + + $this->isConverted = true; + } + + return $this->convertedResult; + } + + public function asText(): string + { + return $this->as(TextResult::class)->getContent(); + } + + public function asObject(): object + { + return $this->as(ObjectResult::class)->getContent(); + } + + public function asBinary(): string + { + return $this->as(BinaryResult::class)->getContent(); + } + + public function asBase64(): string + { + $response = $this->as(BinaryResult::class); + + \assert($response instanceof BinaryResult); + + return $response->toDataUri(); + } + + /** + * @return Vector[] + */ + public function asVectors(): array + { + return $this->as(VectorResult::class)->getContent(); + } + + public function asStream(): \Generator + { + yield from $this->as(StreamResult::class)->getContent(); + } + + /** + * @return ToolCall[] + */ + public function asToolCalls(): array + { + return $this->as(ToolCallResult::class)->getContent(); + } + + /** + * @param class-string $type + */ + private function as(string $type): ConverterResult + { + $response = $this->getResult(); + + if (!$response instanceof $type) { + throw new UnexpectedResultTypeException($type, $response::class); + } + + return $response; + } +} diff --git a/src/platform/src/ConnectorPlatform.php b/src/platform/src/ConnectorPlatform.php new file mode 100644 index 000000000..0a4af5c0f --- /dev/null +++ b/src/platform/src/ConnectorPlatform.php @@ -0,0 +1,47 @@ +connector->getContract(); + $payload = $contract->createRequestPayload($model, $input); + $options = array_merge($model->getOptions(), $options); + + if (isset($options['tools'])) { + $options['tools'] = $contract->createToolOption($options['tools'], $model); + } + + $options['model'] = $model; + + $promise = $this->connector->call($model, $payload, $options); + $promise->registerConverter($this->convertResult(...)); + + return $promise; + } + + private function convertResult(RawResultInterface $result, array $options): ResultInterface + { + if ($options['stream'] ?? false) { + return $this->connector->handleStream($options['model'], $result, $options); + } + + if ($this->connector->isError($result)) { + $this->connector->handleError($options['model'], $result); + } + + return $this->connector->handleResult($options['model'], $result, $options); + } +} diff --git a/src/platform/src/Exception/ConnectorException.php b/src/platform/src/Exception/ConnectorException.php new file mode 100644 index 000000000..0b9c735f8 --- /dev/null +++ b/src/platform/src/Exception/ConnectorException.php @@ -0,0 +1,7 @@ +