diff --git a/examples/openai/responses-stream.php b/examples/openai/responses-stream.php new file mode 100644 index 00000000..1b919f48 --- /dev/null +++ b/examples/openai/responses-stream.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Platform\Bridge\OpenAI\GPT; +use Symfony\AI\Platform\Bridge\OpenAI\PlatformFactory; +use Symfony\AI\Platform\Bridge\OpenAI\Responses; +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); +} + +$platform = PlatformFactory::create($_SERVER['OPENAI_API_KEY']); +$model = new Responses(Responses::GPT_4O_MINI); + +$agent = new Agent($platform, $model); +$messages = new MessageBag( + Message::forSystem('You are a thoughtful philosopher.'), + Message::ofUser('What is the purpose of an ant?'), +); +$response = $agent->call($messages, [ + 'stream' => true, // enable streaming of response text +]); + +foreach ($response->getContent() as $word) { + echo $word; +} +echo \PHP_EOL; diff --git a/examples/openai/responses-structured-output-clock.php b/examples/openai/responses-structured-output-clock.php new file mode 100644 index 00000000..7a2f973d --- /dev/null +++ b/examples/openai/responses-structured-output-clock.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Agent\StructuredOutput\Responses\ResponsesAgentProcessor; +use Symfony\AI\Platform\Bridge\OpenAI\PlatformFactory; +use Symfony\AI\Platform\Bridge\OpenAI\Responses; +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); +} + +$platform = PlatformFactory::create($_SERVER['OPENAI_API_KEY']); +$model = new Responses(Responses::GPT_4O_MINI); + +$structuredOutputProcessor = new ResponsesAgentProcessor(); + +$agent = new Agent($platform, $model, [$structuredOutputProcessor], [$structuredOutputProcessor]); +$messages = new MessageBag( + Message::ofUser('What date and time is it?'), +); +$response = $agent->call($messages, [ + 'text' => [ + 'format' => [ + 'type' => 'json_schema', + 'name' => 'clock', + 'schema' => [ + 'type' => 'object', + 'properties' => [ + 'date' => [ + 'type' => 'string', + 'description' => 'The current date in the format YYYY-MM-DD.', + ], + 'time' => [ + 'type' => 'string', + 'description' => 'The current time in the format HH:MM:SS.', + ], + ], + 'required' => ['date', 'time'], + 'additionalProperties' => false, + ], + ], + ], +]); + +dump($response->getContent()); diff --git a/examples/openai/responses-structured-output-math.php b/examples/openai/responses-structured-output-math.php new file mode 100644 index 00000000..4f3d24c6 --- /dev/null +++ b/examples/openai/responses-structured-output-math.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Agent\StructuredOutput\Responses\ResponsesAgentProcessor; +use Symfony\AI\Fixtures\StructuredOutput\MathReasoning; +use Symfony\AI\Platform\Bridge\OpenAI\PlatformFactory; +use Symfony\AI\Platform\Bridge\OpenAI\Responses; +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); +} + +$platform = PlatformFactory::create($_SERVER['OPENAI_API_KEY']); +$model = new Responses(Responses::GPT_4O_MINI); + +$processor = new ResponsesAgentProcessor(); + +$agent = new Agent($platform, $model, [$processor], [$processor]); +$messages = new MessageBag( + Message::forSystem('You are a helpful math tutor. Guide the user through the solution step by step.'), + Message::ofUser('how can I solve 8x + 7 = -23'), +); +$response = $agent->call($messages, [ + 'output_structure' => MathReasoning::class, +]); + +dump($response->getContent()); diff --git a/examples/openai/responses-toolcall-stream.php b/examples/openai/responses-toolcall-stream.php new file mode 100644 index 00000000..e1af9d83 --- /dev/null +++ b/examples/openai/responses-toolcall-stream.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Platform\Bridge\OpenAI\PlatformFactory; +use Symfony\AI\Platform\Bridge\OpenAI\Responses; +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); +} + +$platform = PlatformFactory::create($_SERVER['OPENAI_API_KEY']); +$model = new Responses(Responses::GPT_4O_MINI); + +$agent = new Agent($platform, $model); +$messages = new MessageBag(Message::ofUser('What was a positive news story from today?')); +$response = $agent->call($messages, [ + 'stream' => true, // enable streaming of response text + 'tools' => [ + [ + 'type' => 'web_search_preview', + ], + ], +]); + +foreach ($response->getContent() as $word) { + echo $word; +} + +echo \PHP_EOL; diff --git a/examples/openai/responses.php b/examples/openai/responses.php new file mode 100644 index 00000000..c41fb021 --- /dev/null +++ b/examples/openai/responses.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Platform\Bridge\OpenAI\PlatformFactory; +use Symfony\AI\Platform\Bridge\OpenAI\Responses; +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); +} + +$platform = PlatformFactory::create($_SERVER['OPENAI_API_KEY']); +$model = new Responses(Responses::GPT_4O_MINI, [ + 'temperature' => 0.5, // default options for the model +]); + +$agent = new Agent($platform, $model); +$messages = new MessageBag( + Message::forSystem('You are a pirate and you write funny.'), + Message::ofUser('What is the Symfony framework?'), +); +$response = $agent->call($messages, [ + 'max_output_tokens' => 500, // specific options just for this call +]); + +echo $response->getContent().\PHP_EOL; diff --git a/src/agent/src/StructuredOutput/Responses/ResponsesAgentProcessor.php b/src/agent/src/StructuredOutput/Responses/ResponsesAgentProcessor.php new file mode 100644 index 00000000..36fe2b57 --- /dev/null +++ b/src/agent/src/StructuredOutput/Responses/ResponsesAgentProcessor.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\StructuredOutput\Responses; + +use Symfony\AI\Agent\Exception\InvalidArgumentException; +use Symfony\AI\Agent\Exception\MissingModelSupportException; +use Symfony\AI\Agent\Input; +use Symfony\AI\Agent\InputProcessorInterface; +use Symfony\AI\Agent\Output; +use Symfony\AI\Agent\OutputProcessorInterface; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Response\ObjectResponse; +use Symfony\Component\PropertyInfo\Extractor\PhpDocExtractor; +use Symfony\Component\PropertyInfo\PropertyInfoExtractor; +use Symfony\Component\Serializer\Encoder\JsonEncoder; +use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer; +use Symfony\Component\Serializer\Normalizer\ObjectNormalizer; +use Symfony\Component\Serializer\Serializer; +use Symfony\Component\Serializer\SerializerInterface; + +/** + * @author Christopher Hertel + */ +final class ResponsesAgentProcessor implements InputProcessorInterface, OutputProcessorInterface +{ + private string $outputStructure; + + public function __construct( + private readonly ResponsesResponseFormatFactoryInterface $responseFormatFactory = new ResponsesResponseFormatFactory(), + private ?SerializerInterface $serializer = null, + ) + { + if (null === $this->serializer) { + $propertyInfo = new PropertyInfoExtractor([], [new PhpDocExtractor()]); + $normalizers = [new ObjectNormalizer(propertyTypeExtractor: $propertyInfo), new ArrayDenormalizer()]; + $this->serializer = new Serializer($normalizers, [new JsonEncoder()]); + } + } + + public function processInput(Input $input): void + { + $options = $input->getOptions(); + + if (!isset($options['output_structure'])) { + return; + } + + if (!$input->model->supports(Capability::OUTPUT_STRUCTURED)) { + throw MissingModelSupportException::forStructuredOutput($input->model::class); + } + + if (true === ($options['stream'] ?? false)) { + throw new InvalidArgumentException('Streamed responses are not supported for structured output'); + } + + $options['text'] = $this->responseFormatFactory->create($options['output_structure']); + + $this->outputStructure = $options['output_structure']; + unset($options['output_structure']); + + $input->setOptions($options); + } + + public function processOutput(Output $output): void + { + $options = $output->options; + + if ($output->response instanceof ObjectResponse) { + return; + } + + if (!isset($options['text']['format'])) { + return; + } + + if (!isset($this->outputStructure)) { + $output->response = new ObjectResponse(json_decode($output->response->getContent(), true)); + + return; + } + + $output->response = new ObjectResponse( + $this->serializer->deserialize($output->response->getContent(), $this->outputStructure, 'json') + ); + } +} diff --git a/src/agent/src/StructuredOutput/Responses/ResponsesResponseFormatFactory.php b/src/agent/src/StructuredOutput/Responses/ResponsesResponseFormatFactory.php new file mode 100644 index 00000000..05644549 --- /dev/null +++ b/src/agent/src/StructuredOutput/Responses/ResponsesResponseFormatFactory.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\StructuredOutput\Responses; + +use Symfony\AI\Platform\Contract\JsonSchema\Factory; +use function Symfony\Component\String\u; + +/** + * @author Christopher Hertel + */ +final readonly class ResponsesResponseFormatFactory implements ResponsesResponseFormatFactoryInterface +{ + public function __construct( + private Factory $schemaFactory = new Factory(), + ) { + } + + public function create(string $responseClass): array + { + return [ + 'format' => [ + 'type' => 'json_schema', + 'name' => u($responseClass)->afterLast('\\')->toString(), + 'strict' => true, + 'schema' => $this->schemaFactory->buildProperties($responseClass), + ], + ]; + } +} diff --git a/src/agent/src/StructuredOutput/Responses/ResponsesResponseFormatFactoryInterface.php b/src/agent/src/StructuredOutput/Responses/ResponsesResponseFormatFactoryInterface.php new file mode 100644 index 00000000..e3388056 --- /dev/null +++ b/src/agent/src/StructuredOutput/Responses/ResponsesResponseFormatFactoryInterface.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\StructuredOutput\Responses; + +/** + * @author Oskar Stark + */ +interface ResponsesResponseFormatFactoryInterface +{ + /** + * @param class-string $responseClass + * + * @return array{ + * format: array{ + * type: 'json_schema|text|json_object', + * name?: string, + * schema?: array, + * strict?: true, + * } + * } + */ + public function create(string $responseClass): array; +} diff --git a/src/platform/src/Bridge/OpenAI/PlatformFactory.php b/src/platform/src/Bridge/OpenAI/PlatformFactory.php index 2de87d58..14ce29fd 100644 --- a/src/platform/src/Bridge/OpenAI/PlatformFactory.php +++ b/src/platform/src/Bridge/OpenAI/PlatformFactory.php @@ -16,6 +16,7 @@ use Symfony\AI\Platform\Bridge\OpenAI\Embeddings\ResponseConverter as EmbeddingsResponseConverter; use Symfony\AI\Platform\Bridge\OpenAI\GPT\ModelClient as GPTModelClient; use Symfony\AI\Platform\Bridge\OpenAI\GPT\ResponseConverter as GPTResponseConverter; +use Symfony\AI\Platform\Bridge\OpenAI\Responses\InputBagNormalizer; use Symfony\AI\Platform\Bridge\OpenAI\Whisper\AudioNormalizer; use Symfony\AI\Platform\Bridge\OpenAI\Whisper\ModelClient as WhisperModelClient; use Symfony\AI\Platform\Bridge\OpenAI\Whisper\ResponseConverter as WhisperResponseConverter; @@ -23,6 +24,8 @@ use Symfony\AI\Platform\Platform; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\AI\Platform\Bridge\OpenAI\Responses\ModelClient as ResponseModelClient; +use Symfony\AI\Platform\Bridge\OpenAI\Responses\ResponseConverter as ResponseResponseConverter; /** * @author Christopher Hertel @@ -45,14 +48,19 @@ public static function create( new EmbeddingsModelClient($httpClient, $apiKey), $dallEModelClient, new WhisperModelClient($httpClient, $apiKey), + new ResponseModelClient($httpClient, $apiKey), ], [ new GPTResponseConverter(), new EmbeddingsResponseConverter(), $dallEModelClient, new WhisperResponseConverter(), + new ResponseResponseConverter(), ], - $contract ?? Contract::create(new AudioNormalizer()), + $contract ?? Contract::create( + new AudioNormalizer(), + new InputBagNormalizer(), + ), ); } } diff --git a/src/platform/src/Bridge/OpenAI/Responses.php b/src/platform/src/Bridge/OpenAI/Responses.php new file mode 100644 index 00000000..5bbcb9e2 --- /dev/null +++ b/src/platform/src/Bridge/OpenAI/Responses.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\OpenAI; + +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Model; + +/** + * @author Christopher Hertel + * @author Oskar Stark + */ +class Responses extends Model +{ + public const GPT_35_TURBO = 'gpt-3.5-turbo'; + public const GPT_35_TURBO_INSTRUCT = 'gpt-3.5-turbo-instruct'; + public const GPT_4 = 'gpt-4'; + public const GPT_4_TURBO = 'gpt-4-turbo'; + public const GPT_4O = 'gpt-4o'; + public const GPT_4O_MINI = 'gpt-4o-mini'; + public const GPT_4O_AUDIO = 'gpt-4o-audio-preview'; + public const O1_MINI = 'o1-mini'; + public const O1_PREVIEW = 'o1-preview'; + public const O3_MINI = 'o3-mini'; + public const O3_MINI_HIGH = 'o3-mini-high'; + public const GPT_45_PREVIEW = 'gpt-4.5-preview'; + public const GPT_41 = 'gpt-4.1'; + public const GPT_41_MINI = 'gpt-4.1-mini'; + public const GPT_41_NANO = 'gpt-4.1-nano'; + + private const IMAGE_SUPPORTING = [ + self::GPT_4_TURBO, + self::GPT_4O, + self::GPT_4O_MINI, + self::O1_MINI, + self::O1_PREVIEW, + self::O3_MINI, + self::GPT_45_PREVIEW, + self::GPT_41, + self::GPT_41_MINI, + self::GPT_41_NANO, + ]; + + private const STRUCTURED_OUTPUT_SUPPORTING = [ + self::GPT_4O, + self::GPT_4O_MINI, + self::O3_MINI, + self::GPT_45_PREVIEW, + self::GPT_41, + self::GPT_41_MINI, + self::GPT_41_NANO, + ]; + + /** + * @param array $options The default options for the model usage + */ + public function __construct( + string $name = self::GPT_4O, + array $options = ['temperature' => 1.0], + ) { + $capabilities = [ + Capability::INPUT_MESSAGES, + Capability::OUTPUT_TEXT, + Capability::OUTPUT_STREAMING, + Capability::TOOL_CALLING, + ]; + + if (self::GPT_4O_AUDIO === $name) { + $capabilities[] = Capability::INPUT_AUDIO; + } + + if (\in_array($name, self::IMAGE_SUPPORTING, true)) { + $capabilities[] = Capability::INPUT_IMAGE; + } + + if (\in_array($name, self::STRUCTURED_OUTPUT_SUPPORTING, true)) { + $capabilities[] = Capability::OUTPUT_STRUCTURED; + } + + parent::__construct($name, $capabilities, $options); + } +} diff --git a/src/platform/src/Bridge/OpenAI/Responses/InputBagNormalizer.php b/src/platform/src/Bridge/OpenAI/Responses/InputBagNormalizer.php new file mode 100644 index 00000000..529576b0 --- /dev/null +++ b/src/platform/src/Bridge/OpenAI/Responses/InputBagNormalizer.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\OpenAI\Responses; + +use Symfony\AI\Platform\Bridge\OpenAI\Responses; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Contract\Normalizer\ModelContractNormalizer; +use Symfony\AI\Platform\Message\InputBagInterface; +use Symfony\AI\Platform\Message\MessageBagInterface; +use Symfony\AI\Platform\Model; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerAwareTrait; + +/** + * @author Christopher Hertel + */ +final class InputBagNormalizer extends ModelContractNormalizer implements NormalizerAwareInterface +{ + use NormalizerAwareTrait; + + public function supportedDataClass(): string + { + return MessageBagInterface::class; + } + + public function supportsModel(Model $model): bool + { + return $model instanceof Responses; + } + + /** + * @param InputBagInterface $data + * + * @return array{ + * input: array, + * model?: string, + * } + */ + public function normalize(mixed $data, ?string $format = null, array $context = []): array + { + $array = [ + 'input' => $this->normalizer->normalize($data->getMessages(), $format, $context), + ]; + + if (isset($context[Contract::CONTEXT_MODEL]) && $context[Contract::CONTEXT_MODEL] instanceof Model) { + $array['model'] = $context[Contract::CONTEXT_MODEL]->getName(); + } + + return $array; + } +} diff --git a/src/platform/src/Bridge/OpenAI/Responses/ModelClient.php b/src/platform/src/Bridge/OpenAI/Responses/ModelClient.php new file mode 100644 index 00000000..5abe22a3 --- /dev/null +++ b/src/platform/src/Bridge/OpenAI/Responses/ModelClient.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\OpenAI\Responses; + +use Symfony\AI\Platform\Bridge\OpenAI\Responses; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface as PlatformResponseFactory; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Christopher Hertel + */ +final readonly class ModelClient implements PlatformResponseFactory +{ + private EventSourceHttpClient $httpClient; + + public function __construct( + HttpClientInterface $httpClient, + #[\SensitiveParameter] + private string $apiKey, + ) { + $this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + '' !== $apiKey || throw new InvalidArgumentException('The API key must not be empty.'); + str_starts_with($apiKey, 'sk-') || throw new InvalidArgumentException('The API key must start with "sk-".'); + } + + public function supports(Model $model): bool + { + return $model instanceof Responses; + } + + public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + { + return $this->httpClient->request('POST', 'https://api.openai.com/v1/responses', [ + 'auth_bearer' => $this->apiKey, + 'json' => array_merge($options, $payload), + ]); + } +} diff --git a/src/platform/src/Bridge/OpenAI/Responses/ResponseConverter.php b/src/platform/src/Bridge/OpenAI/Responses/ResponseConverter.php new file mode 100644 index 00000000..5ef6df89 --- /dev/null +++ b/src/platform/src/Bridge/OpenAI/Responses/ResponseConverter.php @@ -0,0 +1,117 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\OpenAI\Responses; + +use Symfony\AI\Platform\Bridge\OpenAI\Responses; +use Symfony\AI\Platform\Exception\ContentFilterException; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\Output; +use Symfony\AI\Platform\Response\OutputResponse; +use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\StreamResponse; +use Symfony\AI\Platform\Response\TextResponse; +use Symfony\AI\Platform\ResponseConverterInterface as PlatformResponseConverter; +use Symfony\Component\HttpClient\Chunk\ServerSentEvent; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Component\HttpClient\Exception\JsonException; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; + +/** + * @author Christopher Hertel + * @author Denis Zunke + */ +final class ResponseConverter implements PlatformResponseConverter +{ + public function supports(Model $model): bool + { + return $model instanceof Responses; + } + + public function convert(HttpResponse $response, array $options = []): LlmResponse + { + if ($options['stream'] ?? false) { + return new StreamResponse($this->convertStream($response)); + } + + try { + $data = $response->toArray(); + } catch (ClientExceptionInterface $e) { + $data = $response->toArray(throw: false); + + if (isset($data['error']['code']) && 'content_filter' === $data['error']['code']) { + throw new ContentFilterException(message: $data['error']['message'], previous: $e); + } + + throw $e; + } + + if (!isset($data['output'])) { + throw new RuntimeException('Response does not contain output'); + } + + /** @var Output[] $outputs */ + $outputs = array_map($this->convertOutput(...), $data['output']); + + if (1 !== \count($outputs)) { + return new OutputResponse(...$outputs); + } + + return new TextResponse($outputs[0]->getContent()); + } + + private function convertStream(HttpResponse $response): \Generator + { + foreach ((new EventSourceHttpClient())->stream($response) as $chunk) { + if (!$chunk instanceof ServerSentEvent || '[DONE]' === $chunk->getData()) { + continue; + } + + try { + $data = $chunk->getArrayData(); + } catch (JsonException) { + // try catch only needed for Symfony 6.4 + continue; + } + + if (!isset($data['delta'])) { + continue; + } + + yield $data['delta']; + } + } + + /** + * @param array{ + * id: string, + * type: 'message|function_call', + * status: string, + * content: array{ + * type: string, + * annotations: array, + * text: ?string, + * logprobs: array, + * }, + * role: string, + * } $output + */ + private function convertOutput(array $output): Output + { + if (\in_array($output['status'], ['completed'], true)) { + return new Output($output['content'][0]['text']); + } + + throw new RuntimeException(\sprintf('Unsupported finish reason "%s".', $output['incomplete_details']['reason'])); + } +} diff --git a/src/platform/src/Response/Output.php b/src/platform/src/Response/Output.php new file mode 100644 index 00000000..d0a17ae4 --- /dev/null +++ b/src/platform/src/Response/Output.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Response; + +/** + * @author Christopher Hertel + */ +final readonly class Output +{ + /** + * @param ToolCall[] $toolCalls + */ + public function __construct( + private ?string $content = null, + private array $toolCalls = [], + ) { + } + + public function getContent(): ?string + { + return $this->content; + } + + public function hasContent(): bool + { + return null !== $this->content; + } + + /** + * @return ToolCall[] + */ + public function getToolCalls(): array + { + return $this->toolCalls; + } + + public function hasToolCall(): bool + { + return [] !== $this->toolCalls; + } +} diff --git a/src/platform/src/Response/OutputResponse.php b/src/platform/src/Response/OutputResponse.php new file mode 100644 index 00000000..cad1c23b --- /dev/null +++ b/src/platform/src/Response/OutputResponse.php @@ -0,0 +1,42 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Response; + +use Symfony\AI\Platform\Exception\InvalidArgumentException; + +/** + * @author Christopher Hertel + */ +final class OutputResponse extends BaseResponse +{ + /** + * @var Output[] + */ + private readonly array $outputs; + + public function __construct(Output ...$outputs) + { + if ([] === $outputs) { + throw new InvalidArgumentException('Response must have at least one output.'); + } + + $this->outputs = $outputs; + } + + /** + * @return Output[] + */ + public function getContent(): array + { + return $this->outputs; + } +}