From f912f435e533346dfd91362ffef4fe9ad16e508f Mon Sep 17 00:00:00 2001 From: Christopher Hertel Date: Thu, 17 Jul 2025 02:51:09 +0200 Subject: [PATCH] Remove HTTP awareness from Platform --- examples/bedrock/chat-claude.php | 2 +- examples/bedrock/image-claude-binary.php | 2 +- examples/bedrock/toolcall-claude.php | 2 +- .../Bridge/Albert/EmbeddingsModelClient.php | 9 +- .../src/Bridge/Albert/GPTModelClient.php | 9 +- .../src/Bridge/Anthropic/ModelClient.php | 8 +- .../Bridge/Anthropic/ResponseConverter.php | 14 +-- .../Bridge/Azure/Meta/LlamaModelClient.php | 8 +- .../Azure/Meta/LlamaResponseConverter.php | 7 +- .../Azure/OpenAI/EmbeddingsModelClient.php | 8 +- .../Bridge/Azure/OpenAI/GPTModelClient.php | 8 +- .../Azure/OpenAI/WhisperModelClient.php | 8 +- ...laudeHandler.php => ClaudeModelClient.php} | 9 +- .../Anthropic/ClaudeResponseConverter.php | 58 ++++++++++++ .../src/Bridge/Bedrock/BedrockModelClient.php | 32 ------- .../Bridge/Bedrock/Meta/LlamaModelClient.php | 25 ++--- .../Bedrock/Meta/LlamaResponseConverter.php | 42 +++++++++ .../{NovaHandler.php => NovaModelClient.php} | 39 +------- .../Bedrock/Nova/NovaResponseConverter.php | 57 ++++++++++++ src/platform/src/Bridge/Bedrock/Platform.php | 91 ------------------- .../src/Bridge/Bedrock/PlatformFactory.php | 44 +++++++-- .../Bridge/Google/Embeddings/ModelClient.php | 8 +- .../Google/Embeddings/ResponseConverter.php | 6 +- .../src/Bridge/Google/Gemini/ModelClient.php | 8 +- .../Google/Gemini/ResponseConverter.php | 14 +-- .../src/Bridge/HuggingFace/ModelClient.php | 8 +- .../Bridge/HuggingFace/ResponseConverter.php | 24 ++--- .../LMStudio/Completions/ModelClient.php | 8 +- .../Completions/ResponseConverter.php | 10 +- .../LMStudio/Embeddings/ModelClient.php | 8 +- .../LMStudio/Embeddings/ResponseConverter.php | 10 +- .../Bridge/Mistral/Embeddings/ModelClient.php | 8 +- .../Mistral/Embeddings/ResponseConverter.php | 13 ++- .../src/Bridge/Mistral/Llm/ModelClient.php | 8 +- .../Bridge/Mistral/Llm/ResponseConverter.php | 19 ++-- .../src/Bridge/Ollama/LlamaModelClient.php | 8 +- .../Bridge/Ollama/LlamaResponseConverter.php | 8 +- .../src/Bridge/OpenAI/DallE/ModelClient.php | 8 +- .../Bridge/OpenAI/DallE/ResponseConverter.php | 9 +- .../Bridge/OpenAI/Embeddings/ModelClient.php | 8 +- .../OpenAI/Embeddings/ResponseConverter.php | 10 +- .../src/Bridge/OpenAI/GPT/ModelClient.php | 8 +- .../Bridge/OpenAI/GPT/ResponseConverter.php | 21 ++--- .../src/Bridge/OpenAI/Whisper/ModelClient.php | 8 +- .../OpenAI/Whisper/ResponseConverter.php | 8 +- .../src/Bridge/OpenRouter/ModelClient.php | 8 +- .../Bridge/OpenRouter/ResponseConverter.php | 8 +- .../src/Bridge/Replicate/LlamaModelClient.php | 8 +- .../Replicate/LlamaResponseConverter.php | 8 +- .../Bridge/TransformersPHP/ModelClient.php | 45 +++++++++ .../TransformersPHP/PipelineExecution.php | 9 +- .../src/Bridge/TransformersPHP/Platform.php | 62 ------------- .../TransformersPHP/PlatformFactory.php | 3 +- .../TransformersPHP/ResponseConverter.php | 40 ++++++++ .../src/Bridge/Voyage/ModelClient.php | 8 +- .../src/Bridge/Voyage/ResponseConverter.php | 8 +- src/platform/src/ModelClientInterface.php | 4 +- src/platform/src/Platform.php | 13 +-- src/platform/src/Response/ResponsePromise.php | 2 +- .../src/ResponseConverterInterface.php | 6 +- .../Anthropic/ResponseConverterTest.php | 3 +- .../Google/Embeddings/ModelClientTest.php | 4 +- .../Embeddings/ResponseConverterTest.php | 3 +- .../Embeddings/ResponseConverterTest.php | 5 +- .../OpenAI/DallE/ResponseConverterTest.php | 5 +- .../Embeddings/ResponseConverterTest.php | 3 +- .../OpenAI/GPT/ResponseConverterTest.php | 15 +-- .../tests/Response/ResponsePromiseTest.php | 43 +++++---- .../tests/Double/PlatformTestHandler.php | 10 +- 69 files changed, 555 insertions(+), 498 deletions(-) rename src/platform/src/Bridge/Bedrock/Anthropic/{ClaudeHandler.php => ClaudeModelClient.php} (89%) create mode 100644 src/platform/src/Bridge/Bedrock/Anthropic/ClaudeResponseConverter.php delete mode 100644 src/platform/src/Bridge/Bedrock/BedrockModelClient.php create mode 100644 src/platform/src/Bridge/Bedrock/Meta/LlamaResponseConverter.php rename src/platform/src/Bridge/Bedrock/Nova/{NovaHandler.php => NovaModelClient.php} (53%) create mode 100644 src/platform/src/Bridge/Bedrock/Nova/NovaResponseConverter.php delete mode 100644 src/platform/src/Bridge/Bedrock/Platform.php create mode 100644 src/platform/src/Bridge/TransformersPHP/ModelClient.php delete mode 100644 src/platform/src/Bridge/TransformersPHP/Platform.php create mode 100644 src/platform/src/Bridge/TransformersPHP/ResponseConverter.php diff --git a/examples/bedrock/chat-claude.php b/examples/bedrock/chat-claude.php index 5941978f5..f9ec71d0b 100644 --- a/examples/bedrock/chat-claude.php +++ b/examples/bedrock/chat-claude.php @@ -26,7 +26,7 @@ } $platform = PlatformFactory::create(); -$model = new Claude(); +$model = new Claude('claude-3-7-sonnet-20250219'); $agent = new Agent($platform, $model); $messages = new MessageBag( diff --git a/examples/bedrock/image-claude-binary.php b/examples/bedrock/image-claude-binary.php index c73123b98..7e6b63b2e 100644 --- a/examples/bedrock/image-claude-binary.php +++ b/examples/bedrock/image-claude-binary.php @@ -27,7 +27,7 @@ } $platform = PlatformFactory::create(); -$model = new Claude(); +$model = new Claude('claude-3-7-sonnet-20250219'); $agent = new Agent($platform, $model); $messages = new MessageBag( diff --git a/examples/bedrock/toolcall-claude.php b/examples/bedrock/toolcall-claude.php index aa8a8657c..9922b7709 100644 --- a/examples/bedrock/toolcall-claude.php +++ b/examples/bedrock/toolcall-claude.php @@ -30,7 +30,7 @@ } $platform = PlatformFactory::create(); -$model = new Claude(); +$model = new Claude('claude-3-7-sonnet-20250219'); $wikipedia = new Wikipedia(HttpClient::create()); $toolbox = Toolbox::create($wikipedia); diff --git a/src/platform/src/Bridge/Albert/EmbeddingsModelClient.php b/src/platform/src/Bridge/Albert/EmbeddingsModelClient.php index 580ef0b13..a8be72029 100644 --- a/src/platform/src/Bridge/Albert/EmbeddingsModelClient.php +++ b/src/platform/src/Bridge/Albert/EmbeddingsModelClient.php @@ -15,8 +15,9 @@ use Symfony\AI\Platform\Exception\InvalidArgumentException; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Response\RawHttpResponse; +use Symfony\AI\Platform\Response\RawResponseInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Oskar Stark @@ -37,11 +38,11 @@ public function supports(Model $model): bool return $model instanceof Embeddings; } - public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + public function request(Model $model, array|string $payload, array $options = []): RawResponseInterface { - return $this->httpClient->request('POST', \sprintf('%s/embeddings', $this->baseUrl), [ + return new RawHttpResponse($this->httpClient->request('POST', \sprintf('%s/embeddings', $this->baseUrl), [ 'auth_bearer' => $this->apiKey, 'json' => \is_array($payload) ? array_merge($payload, $options) : $payload, - ]); + ])); } } diff --git a/src/platform/src/Bridge/Albert/GPTModelClient.php b/src/platform/src/Bridge/Albert/GPTModelClient.php index 238d02133..0efebc6fb 100644 --- a/src/platform/src/Bridge/Albert/GPTModelClient.php +++ b/src/platform/src/Bridge/Albert/GPTModelClient.php @@ -15,9 +15,10 @@ use Symfony\AI\Platform\Exception\InvalidArgumentException; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Response\RawHttpResponse; +use Symfony\AI\Platform\Response\RawResponseInterface; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Oskar Stark @@ -42,11 +43,11 @@ public function supports(Model $model): bool return $model instanceof GPT; } - public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + public function request(Model $model, array|string $payload, array $options = []): RawResponseInterface { - return $this->httpClient->request('POST', \sprintf('%s/chat/completions', $this->baseUrl), [ + return new RawHttpResponse($this->httpClient->request('POST', \sprintf('%s/chat/completions', $this->baseUrl), [ 'auth_bearer' => $this->apiKey, 'json' => \is_array($payload) ? array_merge($payload, $options) : $payload, - ]); + ])); } } diff --git a/src/platform/src/Bridge/Anthropic/ModelClient.php b/src/platform/src/Bridge/Anthropic/ModelClient.php index d5c981a15..a8b55c461 100644 --- a/src/platform/src/Bridge/Anthropic/ModelClient.php +++ b/src/platform/src/Bridge/Anthropic/ModelClient.php @@ -13,9 +13,9 @@ use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Response\RawHttpResponse; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Christopher Hertel @@ -37,18 +37,18 @@ public function supports(Model $model): bool return $model instanceof Claude; } - public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + public function request(Model $model, array|string $payload, array $options = []): RawHttpResponse { if (isset($options['tools'])) { $options['tool_choice'] = ['type' => 'auto']; } - return $this->httpClient->request('POST', 'https://api.anthropic.com/v1/messages', [ + return new RawHttpResponse($this->httpClient->request('POST', 'https://api.anthropic.com/v1/messages', [ 'headers' => [ 'x-api-key' => $this->apiKey, 'anthropic-version' => $this->version, ], 'json' => array_merge($options, $payload), - ]); + ])); } } diff --git a/src/platform/src/Bridge/Anthropic/ResponseConverter.php b/src/platform/src/Bridge/Anthropic/ResponseConverter.php index 891ae0690..ab2539e25 100644 --- a/src/platform/src/Bridge/Anthropic/ResponseConverter.php +++ b/src/platform/src/Bridge/Anthropic/ResponseConverter.php @@ -13,7 +13,9 @@ use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; -use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\RawHttpResponse; +use Symfony\AI\Platform\Response\RawResponseInterface; +use Symfony\AI\Platform\Response\ResponseInterface; use Symfony\AI\Platform\Response\StreamResponse; use Symfony\AI\Platform\Response\TextResponse; use Symfony\AI\Platform\Response\ToolCall; @@ -22,7 +24,7 @@ use Symfony\Component\HttpClient\Chunk\ServerSentEvent; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Component\HttpClient\Exception\JsonException; -use Symfony\Contracts\HttpClient\ResponseInterface; +use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; /** * @author Christopher Hertel @@ -34,13 +36,13 @@ public function supports(Model $model): bool return $model instanceof Claude; } - public function convert(ResponseInterface $response, array $options = []): LlmResponse + public function convert(RawHttpResponse|RawResponseInterface $response, array $options = []): ResponseInterface { if ($options['stream'] ?? false) { - return new StreamResponse($this->convertStream($response)); + return new StreamResponse($this->convertStream($response->getRawObject())); } - $data = $response->toArray(); + $data = $response->getRawData(); if (!isset($data['content']) || [] === $data['content']) { throw new RuntimeException('Response does not contain any content'); @@ -64,7 +66,7 @@ public function convert(ResponseInterface $response, array $options = []): LlmRe return new TextResponse($data['content'][0]['text']); } - private function convertStream(ResponseInterface $response): \Generator + private function convertStream(HttpResponse $response): \Generator { foreach ((new EventSourceHttpClient())->stream($response) as $chunk) { if (!$chunk instanceof ServerSentEvent || '[DONE]' === $chunk->getData()) { diff --git a/src/platform/src/Bridge/Azure/Meta/LlamaModelClient.php b/src/platform/src/Bridge/Azure/Meta/LlamaModelClient.php index ce345a969..b31d52ec1 100644 --- a/src/platform/src/Bridge/Azure/Meta/LlamaModelClient.php +++ b/src/platform/src/Bridge/Azure/Meta/LlamaModelClient.php @@ -14,8 +14,8 @@ use Symfony\AI\Platform\Bridge\Meta\Llama; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Response\RawHttpResponse; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Christopher Hertel @@ -34,16 +34,16 @@ public function supports(Model $model): bool return $model instanceof Llama; } - public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + public function request(Model $model, array|string $payload, array $options = []): RawHttpResponse { $url = \sprintf('https://%s/chat/completions', $this->baseUrl); - return $this->httpClient->request('POST', $url, [ + return new RawHttpResponse($this->httpClient->request('POST', $url, [ 'headers' => [ 'Content-Type' => 'application/json', 'Authorization' => $this->apiKey, ], 'json' => array_merge($options, $payload), - ]); + ])); } } diff --git a/src/platform/src/Bridge/Azure/Meta/LlamaResponseConverter.php b/src/platform/src/Bridge/Azure/Meta/LlamaResponseConverter.php index 69db284e6..3b8e1275e 100644 --- a/src/platform/src/Bridge/Azure/Meta/LlamaResponseConverter.php +++ b/src/platform/src/Bridge/Azure/Meta/LlamaResponseConverter.php @@ -14,10 +14,9 @@ use Symfony\AI\Platform\Bridge\Meta\Llama; use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; -use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\RawResponseInterface; use Symfony\AI\Platform\Response\TextResponse; use Symfony\AI\Platform\ResponseConverterInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Christopher Hertel @@ -29,9 +28,9 @@ public function supports(Model $model): bool return $model instanceof Llama; } - public function convert(ResponseInterface $response, array $options = []): LlmResponse + public function convert(RawResponseInterface $response, array $options = []): TextResponse { - $data = $response->toArray(); + $data = $response->getRawData(); if (!isset($data['choices'][0]['message']['content'])) { throw new RuntimeException('Response does not contain output'); diff --git a/src/platform/src/Bridge/Azure/OpenAI/EmbeddingsModelClient.php b/src/platform/src/Bridge/Azure/OpenAI/EmbeddingsModelClient.php index abbd159a7..811eac787 100644 --- a/src/platform/src/Bridge/Azure/OpenAI/EmbeddingsModelClient.php +++ b/src/platform/src/Bridge/Azure/OpenAI/EmbeddingsModelClient.php @@ -15,9 +15,9 @@ use Symfony\AI\Platform\Exception\InvalidArgumentException; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Response\RawHttpResponse; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Christopher Hertel @@ -46,11 +46,11 @@ public function supports(Model $model): bool return $model instanceof Embeddings; } - public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + public function request(Model $model, array|string $payload, array $options = []): RawHttpResponse { $url = \sprintf('https://%s/openai/deployments/%s/embeddings', $this->baseUrl, $this->deployment); - return $this->httpClient->request('POST', $url, [ + return new RawHttpResponse($this->httpClient->request('POST', $url, [ 'headers' => [ 'api-key' => $this->apiKey, ], @@ -59,6 +59,6 @@ public function request(Model $model, array|string $payload, array $options = [] 'model' => $model->getName(), 'input' => $payload, ]), - ]); + ])); } } diff --git a/src/platform/src/Bridge/Azure/OpenAI/GPTModelClient.php b/src/platform/src/Bridge/Azure/OpenAI/GPTModelClient.php index c6e9ffcbb..ddfd33e1d 100644 --- a/src/platform/src/Bridge/Azure/OpenAI/GPTModelClient.php +++ b/src/platform/src/Bridge/Azure/OpenAI/GPTModelClient.php @@ -15,9 +15,9 @@ use Symfony\AI\Platform\Exception\InvalidArgumentException; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Response\RawHttpResponse; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Christopher Hertel @@ -46,16 +46,16 @@ public function supports(Model $model): bool return $model instanceof GPT; } - public function request(Model $model, object|array|string $payload, array $options = []): ResponseInterface + public function request(Model $model, object|array|string $payload, array $options = []): RawHttpResponse { $url = \sprintf('https://%s/openai/deployments/%s/chat/completions', $this->baseUrl, $this->deployment); - return $this->httpClient->request('POST', $url, [ + return new RawHttpResponse($this->httpClient->request('POST', $url, [ 'headers' => [ 'api-key' => $this->apiKey, ], 'query' => ['api-version' => $this->apiVersion], 'json' => array_merge($options, $payload), - ]); + ])); } } diff --git a/src/platform/src/Bridge/Azure/OpenAI/WhisperModelClient.php b/src/platform/src/Bridge/Azure/OpenAI/WhisperModelClient.php index a7bf1b1f4..f488ba1e0 100644 --- a/src/platform/src/Bridge/Azure/OpenAI/WhisperModelClient.php +++ b/src/platform/src/Bridge/Azure/OpenAI/WhisperModelClient.php @@ -16,9 +16,9 @@ use Symfony\AI\Platform\Exception\InvalidArgumentException; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Response\RawHttpResponse; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Christopher Hertel @@ -47,7 +47,7 @@ public function supports(Model $model): bool return $model instanceof Whisper; } - public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + public function request(Model $model, array|string $payload, array $options = []): RawHttpResponse { $task = $options['task'] ?? Task::TRANSCRIPTION; $endpoint = Task::TRANSCRIPTION === $task ? 'transcriptions' : 'translations'; @@ -55,13 +55,13 @@ public function request(Model $model, array|string $payload, array $options = [] unset($options['task']); - return $this->httpClient->request('POST', $url, [ + return new RawHttpResponse($this->httpClient->request('POST', $url, [ 'headers' => [ 'api-key' => $this->apiKey, 'Content-Type' => 'multipart/form-data', ], 'query' => ['api-version' => $this->apiVersion], 'body' => array_merge($options, $payload), - ]); + ])); } } diff --git a/src/platform/src/Bridge/Bedrock/Anthropic/ClaudeHandler.php b/src/platform/src/Bridge/Bedrock/Anthropic/ClaudeModelClient.php similarity index 89% rename from src/platform/src/Bridge/Bedrock/Anthropic/ClaudeHandler.php rename to src/platform/src/Bridge/Bedrock/Anthropic/ClaudeModelClient.php index 138a2390c..d7378eede 100644 --- a/src/platform/src/Bridge/Bedrock/Anthropic/ClaudeHandler.php +++ b/src/platform/src/Bridge/Bedrock/Anthropic/ClaudeModelClient.php @@ -15,9 +15,10 @@ use AsyncAws\BedrockRuntime\Input\InvokeModelRequest; use AsyncAws\BedrockRuntime\Result\InvokeModelResponse; use Symfony\AI\Platform\Bridge\Anthropic\Claude; -use Symfony\AI\Platform\Bridge\Bedrock\BedrockModelClient; +use Symfony\AI\Platform\Bridge\Bedrock\RawBedrockResponse; use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface; use Symfony\AI\Platform\Response\TextResponse; use Symfony\AI\Platform\Response\ToolCall; use Symfony\AI\Platform\Response\ToolCallResponse; @@ -25,7 +26,7 @@ /** * @author Björn Altmann */ -final readonly class ClaudeHandler implements BedrockModelClient +final readonly class ClaudeModelClient implements ModelClientInterface { public function __construct( private BedrockRuntimeClient $bedrockRuntimeClient, @@ -38,7 +39,7 @@ public function supports(Model $model): bool return $model instanceof Claude; } - public function request(Model $model, array|string $payload, array $options = []): InvokeModelResponse + public function request(Model $model, array|string $payload, array $options = []): RawBedrockResponse { unset($payload['model']); @@ -56,7 +57,7 @@ public function request(Model $model, array|string $payload, array $options = [] 'body' => json_encode(array_merge($options, $payload), \JSON_THROW_ON_ERROR), ]; - return $this->bedrockRuntimeClient->invokeModel(new InvokeModelRequest($request)); + return new RawBedrockResponse($this->bedrockRuntimeClient->invokeModel(new InvokeModelRequest($request))); } public function convert(InvokeModelResponse $bedrockResponse): ToolCallResponse|TextResponse diff --git a/src/platform/src/Bridge/Bedrock/Anthropic/ClaudeResponseConverter.php b/src/platform/src/Bridge/Bedrock/Anthropic/ClaudeResponseConverter.php new file mode 100644 index 000000000..faf036e8d --- /dev/null +++ b/src/platform/src/Bridge/Bedrock/Anthropic/ClaudeResponseConverter.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Bedrock\Anthropic; + +use Symfony\AI\Platform\Bridge\Anthropic\Claude; +use Symfony\AI\Platform\Bridge\Bedrock\RawBedrockResponse; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\RawResponseInterface; +use Symfony\AI\Platform\Response\TextResponse; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\AI\Platform\Response\ToolCallResponse; +use Symfony\AI\Platform\ResponseConverterInterface; + +/** + * @author Björn Altmann + */ +final readonly class ClaudeResponseConverter implements ResponseConverterInterface +{ + public function supports(Model $model): bool + { + return $model instanceof Claude; + } + + public function convert(RawResponseInterface|RawBedrockResponse $response, array $options = []): ToolCallResponse|TextResponse + { + $data = $response->getRawData(); + + if (!isset($data['content']) || [] === $data['content']) { + throw new RuntimeException('Response does not contain any content'); + } + + if (!isset($data['content'][0]['text']) && !isset($data['content'][0]['type'])) { + throw new RuntimeException('Response content does not contain any text or type'); + } + + $toolCalls = []; + foreach ($data['content'] as $content) { + if ('tool_use' === $content['type']) { + $toolCalls[] = new ToolCall($content['id'], $content['name'], $content['input']); + } + } + if ([] !== $toolCalls) { + return new ToolCallResponse(...$toolCalls); + } + + return new TextResponse($data['content'][0]['text']); + } +} diff --git a/src/platform/src/Bridge/Bedrock/BedrockModelClient.php b/src/platform/src/Bridge/Bedrock/BedrockModelClient.php deleted file mode 100644 index a9a7daaac..000000000 --- a/src/platform/src/Bridge/Bedrock/BedrockModelClient.php +++ /dev/null @@ -1,32 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\Platform\Bridge\Bedrock; - -use AsyncAws\BedrockRuntime\Result\InvokeModelResponse; -use Symfony\AI\Platform\Model; -use Symfony\AI\Platform\Response\ResponseInterface; - -/** - * @author Björn Altmann - */ -interface BedrockModelClient -{ - public function supports(Model $model): bool; - - /** - * @param array|string $payload - * @param array $options - */ - public function request(Model $model, array|string $payload, array $options = []): InvokeModelResponse; - - public function convert(InvokeModelResponse $bedrockResponse): ResponseInterface; -} diff --git a/src/platform/src/Bridge/Bedrock/Meta/LlamaModelClient.php b/src/platform/src/Bridge/Bedrock/Meta/LlamaModelClient.php index 346c7fbaf..c2253d382 100644 --- a/src/platform/src/Bridge/Bedrock/Meta/LlamaModelClient.php +++ b/src/platform/src/Bridge/Bedrock/Meta/LlamaModelClient.php @@ -13,17 +13,15 @@ use AsyncAws\BedrockRuntime\BedrockRuntimeClient; use AsyncAws\BedrockRuntime\Input\InvokeModelRequest; -use AsyncAws\BedrockRuntime\Result\InvokeModelResponse; -use Symfony\AI\Platform\Bridge\Bedrock\BedrockModelClient; +use Symfony\AI\Platform\Bridge\Bedrock\RawBedrockResponse; use Symfony\AI\Platform\Bridge\Meta\Llama; -use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; -use Symfony\AI\Platform\Response\TextResponse; +use Symfony\AI\Platform\ModelClientInterface; /** * @author Björn Altmann */ -class LlamaModelClient implements BedrockModelClient +class LlamaModelClient implements ModelClientInterface { public function __construct( private readonly BedrockRuntimeClient $bedrockRuntimeClient, @@ -35,24 +33,13 @@ public function supports(Model $model): bool return $model instanceof Llama; } - public function request(Model $model, array|string $payload, array $options = []): InvokeModelResponse + public function request(Model $model, array|string $payload, array $options = []): RawBedrockResponse { - return $this->bedrockRuntimeClient->invokeModel(new InvokeModelRequest([ + return new RawBedrockResponse($this->bedrockRuntimeClient->invokeModel(new InvokeModelRequest([ 'modelId' => $this->getModelId($model), 'contentType' => 'application/json', 'body' => json_encode($payload, \JSON_THROW_ON_ERROR), - ])); - } - - public function convert(InvokeModelResponse $bedrockResponse): TextResponse - { - $responseBody = json_decode($bedrockResponse->getBody(), true, 512, \JSON_THROW_ON_ERROR); - - if (!isset($responseBody['generation'])) { - throw new RuntimeException('Response does not contain any content'); - } - - return new TextResponse($responseBody['generation']); + ]))); } private function getModelId(Model $model): string diff --git a/src/platform/src/Bridge/Bedrock/Meta/LlamaResponseConverter.php b/src/platform/src/Bridge/Bedrock/Meta/LlamaResponseConverter.php new file mode 100644 index 000000000..e9abf1627 --- /dev/null +++ b/src/platform/src/Bridge/Bedrock/Meta/LlamaResponseConverter.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\Bridge\Bedrock\Meta; + +use Symfony\AI\Platform\Bridge\Bedrock\RawBedrockResponse; +use Symfony\AI\Platform\Bridge\Meta\Llama; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\RawResponseInterface; +use Symfony\AI\Platform\Response\TextResponse; +use Symfony\AI\Platform\ResponseConverterInterface; + +/** + * @author Björn Altmann + */ +class LlamaResponseConverter implements ResponseConverterInterface +{ + public function supports(Model $model): bool + { + return $model instanceof Llama; + } + + public function convert(RawResponseInterface|RawBedrockResponse $response, array $options = []): TextResponse + { + $data = $response->getRawData(); + + if (!isset($data['generation'])) { + throw new RuntimeException('Response does not contain any content'); + } + + return new TextResponse($data['generation']); + } +} diff --git a/src/platform/src/Bridge/Bedrock/Nova/NovaHandler.php b/src/platform/src/Bridge/Bedrock/Nova/NovaModelClient.php similarity index 53% rename from src/platform/src/Bridge/Bedrock/Nova/NovaHandler.php rename to src/platform/src/Bridge/Bedrock/Nova/NovaModelClient.php index 602acab5b..9f6a9f0b6 100644 --- a/src/platform/src/Bridge/Bedrock/Nova/NovaHandler.php +++ b/src/platform/src/Bridge/Bedrock/Nova/NovaModelClient.php @@ -13,18 +13,14 @@ use AsyncAws\BedrockRuntime\BedrockRuntimeClient; use AsyncAws\BedrockRuntime\Input\InvokeModelRequest; -use AsyncAws\BedrockRuntime\Result\InvokeModelResponse; -use Symfony\AI\Platform\Bridge\Bedrock\BedrockModelClient; -use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Bridge\Bedrock\RawBedrockResponse; use Symfony\AI\Platform\Model; -use Symfony\AI\Platform\Response\TextResponse; -use Symfony\AI\Platform\Response\ToolCall; -use Symfony\AI\Platform\Response\ToolCallResponse; +use Symfony\AI\Platform\ModelClientInterface; /** * @author Björn Altmann */ -class NovaHandler implements BedrockModelClient +class NovaModelClient implements ModelClientInterface { public function __construct( private readonly BedrockRuntimeClient $bedrockRuntimeClient, @@ -36,7 +32,7 @@ public function supports(Model $model): bool return $model instanceof Nova; } - public function request(Model $model, array|string $payload, array $options = []): InvokeModelResponse + public function request(Model $model, array|string $payload, array $options = []): RawBedrockResponse { $modelOptions = []; if (isset($options['tools'])) { @@ -57,32 +53,7 @@ public function request(Model $model, array|string $payload, array $options = [] 'body' => json_encode(array_merge($payload, $modelOptions), \JSON_THROW_ON_ERROR), ]; - return $this->bedrockRuntimeClient->invokeModel(new InvokeModelRequest($request)); - } - - public function convert(InvokeModelResponse $bedrockResponse): ToolCallResponse|TextResponse - { - $data = json_decode($bedrockResponse->getBody(), true, 512, \JSON_THROW_ON_ERROR); - - if (!isset($data['output']) || [] === $data['output']) { - throw new RuntimeException('Response does not contain any content'); - } - - if (!isset($data['output']['message']['content'][0]['text'])) { - throw new RuntimeException('Response content does not contain any text'); - } - - $toolCalls = []; - foreach ($data['output']['message']['content'] as $content) { - if (isset($content['toolUse'])) { - $toolCalls[] = new ToolCall($content['toolUse']['toolUseId'], $content['toolUse']['name'], $content['toolUse']['input']); - } - } - if ([] !== $toolCalls) { - return new ToolCallResponse(...$toolCalls); - } - - return new TextResponse($data['output']['message']['content'][0]['text']); + return new RawBedrockResponse($this->bedrockRuntimeClient->invokeModel(new InvokeModelRequest($request))); } private function getModelId(Model $model): string diff --git a/src/platform/src/Bridge/Bedrock/Nova/NovaResponseConverter.php b/src/platform/src/Bridge/Bedrock/Nova/NovaResponseConverter.php new file mode 100644 index 000000000..9c276b71c --- /dev/null +++ b/src/platform/src/Bridge/Bedrock/Nova/NovaResponseConverter.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Bedrock\Nova; + +use Symfony\AI\Platform\Bridge\Bedrock\RawBedrockResponse; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\RawResponseInterface; +use Symfony\AI\Platform\Response\TextResponse; +use Symfony\AI\Platform\Response\ToolCall; +use Symfony\AI\Platform\Response\ToolCallResponse; +use Symfony\AI\Platform\ResponseConverterInterface; + +/** + * @author Björn Altmann + */ +class NovaResponseConverter implements ResponseConverterInterface +{ + public function supports(Model $model): bool + { + return $model instanceof Nova; + } + + public function convert(RawResponseInterface|RawBedrockResponse $response, array $options = []): ToolCallResponse|TextResponse + { + $data = $response->getRawData(); + + if (!isset($data['output']) || [] === $data['output']) { + throw new RuntimeException('Response does not contain any content'); + } + + if (!isset($data['output']['message']['content'][0]['text'])) { + throw new RuntimeException('Response content does not contain any text'); + } + + $toolCalls = []; + foreach ($data['output']['message']['content'] as $content) { + if (isset($content['toolUse'])) { + $toolCalls[] = new ToolCall($content['toolUse']['toolUseId'], $content['toolUse']['name'], $content['toolUse']['input']); + } + } + if ([] !== $toolCalls) { + return new ToolCallResponse(...$toolCalls); + } + + return new TextResponse($data['output']['message']['content'][0]['text']); + } +} diff --git a/src/platform/src/Bridge/Bedrock/Platform.php b/src/platform/src/Bridge/Bedrock/Platform.php deleted file mode 100644 index 7f53d86a1..000000000 --- a/src/platform/src/Bridge/Bedrock/Platform.php +++ /dev/null @@ -1,91 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\Platform\Bridge\Bedrock; - -use Symfony\AI\Platform\Bridge\Anthropic\Contract as AnthropicContract; -use Symfony\AI\Platform\Bridge\Bedrock\Nova\Contract as NovaContract; -use Symfony\AI\Platform\Bridge\Meta\Contract as LlamaContract; -use Symfony\AI\Platform\Contract; -use Symfony\AI\Platform\Exception\RuntimeException; -use Symfony\AI\Platform\Model; -use Symfony\AI\Platform\PlatformInterface; -use Symfony\AI\Platform\Response\ResponsePromise; - -/** - * @author Björn Altmann - */ -class Platform implements PlatformInterface -{ - /** - * @var BedrockModelClient[] - */ - private readonly array $modelClients; - - /** - * @param iterable $modelClients - */ - public function __construct( - iterable $modelClients, - private ?Contract $contract = null, - ) { - $this->contract = $contract ?? Contract::create( - new AnthropicContract\AssistantMessageNormalizer(), - new AnthropicContract\DocumentNormalizer(), - new AnthropicContract\DocumentUrlNormalizer(), - new AnthropicContract\ImageNormalizer(), - new AnthropicContract\ImageUrlNormalizer(), - new AnthropicContract\MessageBagNormalizer(), - new AnthropicContract\ToolCallMessageNormalizer(), - new AnthropicContract\ToolNormalizer(), - new LlamaContract\MessageBagNormalizer(), - new NovaContract\AssistantMessageNormalizer(), - new NovaContract\MessageBagNormalizer(), - new NovaContract\ToolCallMessageNormalizer(), - new NovaContract\ToolNormalizer(), - new NovaContract\UserMessageNormalizer(), - ); - $this->modelClients = $modelClients instanceof \Traversable ? iterator_to_array($modelClients) : $modelClients; - } - - public function request(Model $model, array|string|object $input, array $options = []): ResponsePromise - { - $payload = $this->contract->createRequestPayload($model, $input); - $options = array_merge($model->getOptions(), $options); - - if (isset($options['tools'])) { - $options['tools'] = $this->contract->createToolOption($options['tools'], $model); - } - - return $this->doRequest($model, $payload, $options); - } - - /** - * @param array|string $payload - * @param array $options - */ - private function doRequest(Model $model, array|string $payload, array $options = []): ResponsePromise - { - foreach ($this->modelClients as $modelClient) { - if ($modelClient->supports($model)) { - $response = $modelClient->request($model, $payload, $options); - - return new ResponsePromise( - $modelClient->convert(...), - new RawBedrockResponse($response), - $options, - ); - } - } - - throw new RuntimeException('No response factory registered for model "'.$model::class.'" with given input.'); - } -} diff --git a/src/platform/src/Bridge/Bedrock/PlatformFactory.php b/src/platform/src/Bridge/Bedrock/PlatformFactory.php index f006265e7..b53e6b74b 100644 --- a/src/platform/src/Bridge/Bedrock/PlatformFactory.php +++ b/src/platform/src/Bridge/Bedrock/PlatformFactory.php @@ -12,11 +12,18 @@ namespace Symfony\AI\Platform\Bridge\Bedrock; use AsyncAws\BedrockRuntime\BedrockRuntimeClient; -use Symfony\AI\Platform\Bridge\Bedrock\Anthropic\ClaudeHandler; +use Symfony\AI\Platform\Bridge\Anthropic\Contract as AnthropicContract; +use Symfony\AI\Platform\Bridge\Bedrock\Anthropic\ClaudeModelClient; +use Symfony\AI\Platform\Bridge\Bedrock\Anthropic\ClaudeResponseConverter; use Symfony\AI\Platform\Bridge\Bedrock\Meta\LlamaModelClient; -use Symfony\AI\Platform\Bridge\Bedrock\Nova\NovaHandler; +use Symfony\AI\Platform\Bridge\Bedrock\Meta\LlamaResponseConverter; +use Symfony\AI\Platform\Bridge\Bedrock\Nova\Contract as NovaContract; +use Symfony\AI\Platform\Bridge\Bedrock\Nova\NovaModelClient; +use Symfony\AI\Platform\Bridge\Bedrock\Nova\NovaResponseConverter; +use Symfony\AI\Platform\Bridge\Meta\Contract as LlamaContract; use Symfony\AI\Platform\Contract; use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Platform; /** * @author Björn Altmann @@ -31,10 +38,33 @@ public static function create( throw new RuntimeException('For using the Bedrock platform, the async-aws/bedrock-runtime package is required. Try running "composer require async-aws/bedrock-runtime".'); } - $modelClient[] = new ClaudeHandler($bedrockRuntimeClient); - $modelClient[] = new NovaHandler($bedrockRuntimeClient); - $modelClient[] = new LlamaModelClient($bedrockRuntimeClient); - - return new Platform($modelClient, $contract); + return new Platform( + [ + new ClaudeModelClient($bedrockRuntimeClient), + new LlamaModelClient($bedrockRuntimeClient), + new NovaModelClient($bedrockRuntimeClient), + ], + [ + new ClaudeResponseConverter(), + new LlamaResponseConverter(), + new NovaResponseConverter(), + ], + $contract ?? Contract::create( + new AnthropicContract\AssistantMessageNormalizer(), + new AnthropicContract\DocumentNormalizer(), + new AnthropicContract\DocumentUrlNormalizer(), + new AnthropicContract\ImageNormalizer(), + new AnthropicContract\ImageUrlNormalizer(), + new AnthropicContract\MessageBagNormalizer(), + new AnthropicContract\ToolCallMessageNormalizer(), + new AnthropicContract\ToolNormalizer(), + new LlamaContract\MessageBagNormalizer(), + new NovaContract\AssistantMessageNormalizer(), + new NovaContract\MessageBagNormalizer(), + new NovaContract\ToolCallMessageNormalizer(), + new NovaContract\ToolNormalizer(), + new NovaContract\UserMessageNormalizer(), + ) + ); } } diff --git a/src/platform/src/Bridge/Google/Embeddings/ModelClient.php b/src/platform/src/Bridge/Google/Embeddings/ModelClient.php index fab00c9cc..41186ef26 100644 --- a/src/platform/src/Bridge/Google/Embeddings/ModelClient.php +++ b/src/platform/src/Bridge/Google/Embeddings/ModelClient.php @@ -14,8 +14,8 @@ use Symfony\AI\Platform\Bridge\Google\Embeddings; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Response\RawHttpResponse; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Valtteri R @@ -34,12 +34,12 @@ public function supports(Model $model): bool return $model instanceof Embeddings; } - public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + public function request(Model $model, array|string $payload, array $options = []): RawHttpResponse { $url = \sprintf('https://generativelanguage.googleapis.com/v1beta/models/%s:%s', $model->getName(), 'batchEmbedContents'); $modelOptions = $model->getOptions(); - return $this->httpClient->request('POST', $url, [ + return new RawHttpResponse($this->httpClient->request('POST', $url, [ 'headers' => [ 'x-goog-api-key' => $this->apiKey, ], @@ -55,6 +55,6 @@ public function request(Model $model, array|string $payload, array $options = [] \is_array($payload) ? $payload : [$payload], ), ], - ]); + ])); } } diff --git a/src/platform/src/Bridge/Google/Embeddings/ResponseConverter.php b/src/platform/src/Bridge/Google/Embeddings/ResponseConverter.php index 92fa0f686..67bfcb1cb 100644 --- a/src/platform/src/Bridge/Google/Embeddings/ResponseConverter.php +++ b/src/platform/src/Bridge/Google/Embeddings/ResponseConverter.php @@ -14,10 +14,10 @@ use Symfony\AI\Platform\Bridge\Google\Embeddings; use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\RawResponseInterface; use Symfony\AI\Platform\Response\VectorResponse; use Symfony\AI\Platform\ResponseConverterInterface; use Symfony\AI\Platform\Vector\Vector; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Valtteri R @@ -29,9 +29,9 @@ public function supports(Model $model): bool return $model instanceof Embeddings; } - public function convert(ResponseInterface $response, array $options = []): VectorResponse + public function convert(RawResponseInterface $response, array $options = []): VectorResponse { - $data = $response->toArray(); + $data = $response->getRawData(); if (!isset($data['embeddings'])) { throw new RuntimeException('Response does not contain data'); diff --git a/src/platform/src/Bridge/Google/Gemini/ModelClient.php b/src/platform/src/Bridge/Google/Gemini/ModelClient.php index ffca6c039..871fd2b2f 100644 --- a/src/platform/src/Bridge/Google/Gemini/ModelClient.php +++ b/src/platform/src/Bridge/Google/Gemini/ModelClient.php @@ -14,10 +14,10 @@ use Symfony\AI\Platform\Bridge\Google\Gemini; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Response\RawHttpResponse; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Roy Garrido @@ -41,7 +41,7 @@ public function supports(Model $model): bool /** * @throws TransportExceptionInterface */ - public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + public function request(Model $model, array|string $payload, array $options = []): RawHttpResponse { $url = \sprintf( 'https://generativelanguage.googleapis.com/v1beta/models/%s:%s', @@ -73,11 +73,11 @@ public function request(Model $model, array|string $payload, array $options = [] $generationConfig['tools'][] = [$tool => true === $params ? new \ArrayObject() : $params]; } - return $this->httpClient->request('POST', $url, [ + return new RawHttpResponse($this->httpClient->request('POST', $url, [ 'headers' => [ 'x-goog-api-key' => $this->apiKey, ], 'json' => array_merge($generationConfig, $payload), - ]); + ])); } } diff --git a/src/platform/src/Bridge/Google/Gemini/ResponseConverter.php b/src/platform/src/Bridge/Google/Gemini/ResponseConverter.php index e9ad232ef..adc5547e2 100644 --- a/src/platform/src/Bridge/Google/Gemini/ResponseConverter.php +++ b/src/platform/src/Bridge/Google/Gemini/ResponseConverter.php @@ -16,14 +16,16 @@ use Symfony\AI\Platform\Model; use Symfony\AI\Platform\Response\Choice; use Symfony\AI\Platform\Response\ChoiceResponse; -use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\RawHttpResponse; +use Symfony\AI\Platform\Response\RawResponseInterface; +use Symfony\AI\Platform\Response\ResponseInterface; use Symfony\AI\Platform\Response\StreamResponse; use Symfony\AI\Platform\Response\TextResponse; use Symfony\AI\Platform\Response\ToolCall; use Symfony\AI\Platform\Response\ToolCallResponse; use Symfony\AI\Platform\ResponseConverterInterface; use Symfony\Component\HttpClient\EventSourceHttpClient; -use Symfony\Contracts\HttpClient\ResponseInterface; +use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; /** * @author Roy Garrido @@ -35,13 +37,13 @@ public function supports(Model $model): bool return $model instanceof Gemini; } - public function convert(ResponseInterface $response, array $options = []): LlmResponse + public function convert(RawResponseInterface|RawHttpResponse $response, array $options = []): ResponseInterface { if ($options['stream'] ?? false) { - return new StreamResponse($this->convertStream($response)); + return new StreamResponse($this->convertStream($response->getRawObject())); } - $data = $response->toArray(); + $data = $response->getRawData(); if (!isset($data['candidates'][0]['content']['parts'][0])) { throw new RuntimeException('Response does not contain any content'); @@ -61,7 +63,7 @@ public function convert(ResponseInterface $response, array $options = []): LlmRe return new TextResponse($choices[0]->getContent()); } - private function convertStream(ResponseInterface $response): \Generator + private function convertStream(HttpResponse $response): \Generator { foreach ((new EventSourceHttpClient())->stream($response) as $chunk) { if ($chunk->isFirst() || $chunk->isLast()) { diff --git a/src/platform/src/Bridge/HuggingFace/ModelClient.php b/src/platform/src/Bridge/HuggingFace/ModelClient.php index 27fecbfc2..e9bb1f800 100644 --- a/src/platform/src/Bridge/HuggingFace/ModelClient.php +++ b/src/platform/src/Bridge/HuggingFace/ModelClient.php @@ -13,9 +13,9 @@ use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface as PlatformModelClient; +use Symfony\AI\Platform\Response\RawHttpResponse; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Christopher Hertel @@ -41,16 +41,16 @@ public function supports(Model $model): bool /** * The difference in HuggingFace here is that we treat the payload as the options for the request not only the body. */ - public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + public function request(Model $model, array|string $payload, array $options = []): RawHttpResponse { // Extract task from options if provided $task = $options['task'] ?? null; unset($options['task']); - return $this->httpClient->request('POST', $this->getUrl($model, $task), [ + return new RawHttpResponse($this->httpClient->request('POST', $this->getUrl($model, $task), [ 'auth_bearer' => $this->apiKey, ...$this->getPayload($payload, $options), - ]); + ])); } private function getUrl(Model $model, ?string $task): string diff --git a/src/platform/src/Bridge/HuggingFace/ResponseConverter.php b/src/platform/src/Bridge/HuggingFace/ResponseConverter.php index 5607030d2..d4bc10f10 100644 --- a/src/platform/src/Bridge/HuggingFace/ResponseConverter.php +++ b/src/platform/src/Bridge/HuggingFace/ResponseConverter.php @@ -25,12 +25,13 @@ use Symfony\AI\Platform\Model; use Symfony\AI\Platform\Response\BinaryResponse; use Symfony\AI\Platform\Response\ObjectResponse; -use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\RawHttpResponse; +use Symfony\AI\Platform\Response\RawResponseInterface; +use Symfony\AI\Platform\Response\ResponseInterface; use Symfony\AI\Platform\Response\TextResponse; use Symfony\AI\Platform\Response\VectorResponse; use Symfony\AI\Platform\ResponseConverterInterface as PlatformResponseConverter; use Symfony\AI\Platform\Vector\Vector; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Christopher Hertel @@ -42,29 +43,30 @@ public function supports(Model $model): bool return true; } - public function convert(ResponseInterface $response, array $options = []): LlmResponse + public function convert(RawResponseInterface|RawHttpResponse $response, array $options = []): ResponseInterface { - if (503 === $response->getStatusCode()) { + $httpResponse = $response->getRawObject(); + if (503 === $httpResponse->getStatusCode()) { return throw new RuntimeException('Service unavailable.'); } - if (404 === $response->getStatusCode()) { + if (404 === $httpResponse->getStatusCode()) { return throw new InvalidArgumentException('Model, provider or task not found (404).'); } - $headers = $response->getHeaders(false); + $headers = $httpResponse->getHeaders(false); $contentType = $headers['content-type'][0] ?? null; - $content = 'application/json' === $contentType ? $response->toArray(false) : $response->getContent(false); + $content = 'application/json' === $contentType ? $httpResponse->toArray(false) : $httpResponse->getContent(false); - if (str_starts_with((string) $response->getStatusCode(), '4')) { + if (str_starts_with((string) $httpResponse->getStatusCode(), '4')) { $message = \is_string($content) ? $content : (\is_array($content['error']) ? $content['error'][0] : $content['error']); - throw new InvalidArgumentException(\sprintf('API Client Error (%d): %s', $response->getStatusCode(), $message)); + throw new InvalidArgumentException(\sprintf('API Client Error (%d): %s', $httpResponse->getStatusCode(), $message)); } - if (200 !== $response->getStatusCode()) { - throw new RuntimeException('Unhandled response code: '.$response->getStatusCode()); + if (200 !== $httpResponse->getStatusCode()) { + throw new RuntimeException('Unhandled response code: '.$httpResponse->getStatusCode()); } $task = $options['task'] ?? null; diff --git a/src/platform/src/Bridge/LMStudio/Completions/ModelClient.php b/src/platform/src/Bridge/LMStudio/Completions/ModelClient.php index fb44f90b9..d6674592a 100644 --- a/src/platform/src/Bridge/LMStudio/Completions/ModelClient.php +++ b/src/platform/src/Bridge/LMStudio/Completions/ModelClient.php @@ -14,9 +14,9 @@ use Symfony\AI\Platform\Bridge\LMStudio\Completions; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface as PlatformResponseFactory; +use Symfony\AI\Platform\Response\RawHttpResponse; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author André Lubian @@ -37,10 +37,10 @@ public function supports(Model $model): bool return $model instanceof Completions; } - public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + public function request(Model $model, array|string $payload, array $options = []): RawHttpResponse { - return $this->httpClient->request('POST', \sprintf('%s/v1/chat/completions', $this->hostUrl), [ + return new RawHttpResponse($this->httpClient->request('POST', \sprintf('%s/v1/chat/completions', $this->hostUrl), [ 'json' => array_merge($options, $payload), - ]); + ])); } } diff --git a/src/platform/src/Bridge/LMStudio/Completions/ResponseConverter.php b/src/platform/src/Bridge/LMStudio/Completions/ResponseConverter.php index d9da8a485..bfeb69f95 100644 --- a/src/platform/src/Bridge/LMStudio/Completions/ResponseConverter.php +++ b/src/platform/src/Bridge/LMStudio/Completions/ResponseConverter.php @@ -14,14 +14,14 @@ use Symfony\AI\Platform\Bridge\LMStudio\Completions; use Symfony\AI\Platform\Bridge\OpenAI\GPT\ResponseConverter as OpenAIResponseConverter; use Symfony\AI\Platform\Model; -use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; -use Symfony\AI\Platform\ResponseConverterInterface as PlatformResponseConverter; -use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; +use Symfony\AI\Platform\Response\RawResponseInterface; +use Symfony\AI\Platform\Response\ResponseInterface; +use Symfony\AI\Platform\ResponseConverterInterface; /** * @author André Lubian */ -final class ResponseConverter implements PlatformResponseConverter +final class ResponseConverter implements ResponseConverterInterface { public function __construct( private readonly OpenAIResponseConverter $gptResponseConverter = new OpenAIResponseConverter(), @@ -33,7 +33,7 @@ public function supports(Model $model): bool return $model instanceof Completions; } - public function convert(HttpResponse $response, array $options = []): LlmResponse + public function convert(RawResponseInterface $response, array $options = []): ResponseInterface { return $this->gptResponseConverter->convert($response, $options); } diff --git a/src/platform/src/Bridge/LMStudio/Embeddings/ModelClient.php b/src/platform/src/Bridge/LMStudio/Embeddings/ModelClient.php index 9df987707..f00d6183b 100644 --- a/src/platform/src/Bridge/LMStudio/Embeddings/ModelClient.php +++ b/src/platform/src/Bridge/LMStudio/Embeddings/ModelClient.php @@ -14,8 +14,8 @@ use Symfony\AI\Platform\Bridge\LMStudio\Embeddings; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface as PlatformResponseFactory; +use Symfony\AI\Platform\Response\RawHttpResponse; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Christopher Hertel @@ -34,13 +34,13 @@ public function supports(Model $model): bool return $model instanceof Embeddings; } - public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + public function request(Model $model, array|string $payload, array $options = []): RawHttpResponse { - return $this->httpClient->request('POST', \sprintf('%s/v1/embeddings', $this->hostUrl), [ + return new RawHttpResponse($this->httpClient->request('POST', \sprintf('%s/v1/embeddings', $this->hostUrl), [ 'json' => array_merge($options, [ 'model' => $model->getName(), 'input' => $payload, ]), - ]); + ])); } } diff --git a/src/platform/src/Bridge/LMStudio/Embeddings/ResponseConverter.php b/src/platform/src/Bridge/LMStudio/Embeddings/ResponseConverter.php index 1f8fdd76b..5c671a3c2 100644 --- a/src/platform/src/Bridge/LMStudio/Embeddings/ResponseConverter.php +++ b/src/platform/src/Bridge/LMStudio/Embeddings/ResponseConverter.php @@ -14,25 +14,25 @@ use Symfony\AI\Platform\Bridge\LMStudio\Embeddings; use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\RawResponseInterface; use Symfony\AI\Platform\Response\VectorResponse; -use Symfony\AI\Platform\ResponseConverterInterface as PlatformResponseConverter; +use Symfony\AI\Platform\ResponseConverterInterface; use Symfony\AI\Platform\Vector\Vector; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Christopher Hertel * @author André Lubian */ -final class ResponseConverter implements PlatformResponseConverter +final class ResponseConverter implements ResponseConverterInterface { public function supports(Model $model): bool { return $model instanceof Embeddings; } - public function convert(ResponseInterface $response, array $options = []): VectorResponse + public function convert(RawResponseInterface $response, array $options = []): VectorResponse { - $data = $response->toArray(); + $data = $response->getRawData(); if (!isset($data['data'])) { throw new RuntimeException('Response does not contain data'); diff --git a/src/platform/src/Bridge/Mistral/Embeddings/ModelClient.php b/src/platform/src/Bridge/Mistral/Embeddings/ModelClient.php index ec6b06baf..a5cb23857 100644 --- a/src/platform/src/Bridge/Mistral/Embeddings/ModelClient.php +++ b/src/platform/src/Bridge/Mistral/Embeddings/ModelClient.php @@ -14,9 +14,9 @@ use Symfony\AI\Platform\Bridge\Mistral\Embeddings; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Response\RawHttpResponse; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Christopher Hertel @@ -38,9 +38,9 @@ public function supports(Model $model): bool return $model instanceof Embeddings; } - public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + public function request(Model $model, array|string $payload, array $options = []): RawHttpResponse { - return $this->httpClient->request('POST', 'https://api.mistral.ai/v1/embeddings', [ + return new RawHttpResponse($this->httpClient->request('POST', 'https://api.mistral.ai/v1/embeddings', [ 'auth_bearer' => $this->apiKey, 'headers' => [ 'Content-Type' => 'application/json', @@ -49,6 +49,6 @@ public function request(Model $model, array|string $payload, array $options = [] 'model' => $model->getName(), 'input' => $payload, ]), - ]); + ])); } } diff --git a/src/platform/src/Bridge/Mistral/Embeddings/ResponseConverter.php b/src/platform/src/Bridge/Mistral/Embeddings/ResponseConverter.php index cb934d7de..e99029e6b 100644 --- a/src/platform/src/Bridge/Mistral/Embeddings/ResponseConverter.php +++ b/src/platform/src/Bridge/Mistral/Embeddings/ResponseConverter.php @@ -14,10 +14,11 @@ use Symfony\AI\Platform\Bridge\Mistral\Embeddings; use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\RawHttpResponse; +use Symfony\AI\Platform\Response\RawResponseInterface; use Symfony\AI\Platform\Response\VectorResponse; use Symfony\AI\Platform\ResponseConverterInterface; use Symfony\AI\Platform\Vector\Vector; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Christopher Hertel @@ -29,14 +30,16 @@ public function supports(Model $model): bool return $model instanceof Embeddings; } - public function convert(ResponseInterface $response, array $options = []): VectorResponse + public function convert(RawResponseInterface|RawHttpResponse $response, array $options = []): VectorResponse { - $data = $response->toArray(false); + $httpResponse = $response->getRawObject(); - if (200 !== $response->getStatusCode()) { - throw new RuntimeException(\sprintf('Unexpected response code %d: %s', $response->getStatusCode(), $response->getContent(false))); + if (200 !== $httpResponse->getStatusCode()) { + throw new RuntimeException(\sprintf('Unexpected response code %d: %s', $httpResponse->getStatusCode(), $httpResponse->getContent(false))); } + $data = $response->getRawData(); + if (!isset($data['data'])) { throw new RuntimeException('Response does not contain data'); } diff --git a/src/platform/src/Bridge/Mistral/Llm/ModelClient.php b/src/platform/src/Bridge/Mistral/Llm/ModelClient.php index 93924cdc8..b8e3e5f00 100644 --- a/src/platform/src/Bridge/Mistral/Llm/ModelClient.php +++ b/src/platform/src/Bridge/Mistral/Llm/ModelClient.php @@ -14,9 +14,9 @@ use Symfony\AI\Platform\Bridge\Mistral\Mistral; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Response\RawHttpResponse; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Christopher Hertel @@ -38,15 +38,15 @@ public function supports(Model $model): bool return $model instanceof Mistral; } - public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + public function request(Model $model, array|string $payload, array $options = []): RawHttpResponse { - return $this->httpClient->request('POST', 'https://api.mistral.ai/v1/chat/completions', [ + return new RawHttpResponse($this->httpClient->request('POST', 'https://api.mistral.ai/v1/chat/completions', [ 'auth_bearer' => $this->apiKey, 'headers' => [ 'Content-Type' => 'application/json', 'Accept' => 'application/json', ], 'json' => array_merge($options, $payload), - ]); + ])); } } diff --git a/src/platform/src/Bridge/Mistral/Llm/ResponseConverter.php b/src/platform/src/Bridge/Mistral/Llm/ResponseConverter.php index 7ca164b73..de3dbe5bc 100644 --- a/src/platform/src/Bridge/Mistral/Llm/ResponseConverter.php +++ b/src/platform/src/Bridge/Mistral/Llm/ResponseConverter.php @@ -16,7 +16,9 @@ use Symfony\AI\Platform\Model; use Symfony\AI\Platform\Response\Choice; use Symfony\AI\Platform\Response\ChoiceResponse; -use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\RawHttpResponse; +use Symfony\AI\Platform\Response\RawResponseInterface; +use Symfony\AI\Platform\Response\ResponseInterface; use Symfony\AI\Platform\Response\StreamResponse; use Symfony\AI\Platform\Response\TextResponse; use Symfony\AI\Platform\Response\ToolCall; @@ -40,19 +42,20 @@ public function supports(Model $model): bool /** * @param array $options */ - public function convert(HttpResponse $response, array $options = []): LlmResponse + public function convert(RawResponseInterface|RawHttpResponse $response, array $options = []): ResponseInterface { + $httpResponse = $response->getRawObject(); + if ($options['stream'] ?? false) { - return new StreamResponse($this->convertStream($response)); + return new StreamResponse($this->convertStream($httpResponse)); } - $code = $response->getStatusCode(); - $data = $response->toArray(false); - - if (200 !== $code) { - throw new RuntimeException(\sprintf('Unexpected response code %d: %s', $code, $response->getContent(false))); + if (200 !== $code = $httpResponse->getStatusCode()) { + throw new RuntimeException(\sprintf('Unexpected response code %d: %s', $code, $httpResponse->getContent(false))); } + $data = $response->getRawData(); + if (!isset($data['choices'])) { throw new RuntimeException('Response does not contain choices'); } diff --git a/src/platform/src/Bridge/Ollama/LlamaModelClient.php b/src/platform/src/Bridge/Ollama/LlamaModelClient.php index 39fe68aea..2f0e20380 100644 --- a/src/platform/src/Bridge/Ollama/LlamaModelClient.php +++ b/src/platform/src/Bridge/Ollama/LlamaModelClient.php @@ -14,8 +14,8 @@ use Symfony\AI\Platform\Bridge\Meta\Llama; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Response\RawHttpResponse; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Christopher Hertel @@ -33,14 +33,14 @@ public function supports(Model $model): bool return $model instanceof Llama; } - public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + public function request(Model $model, array|string $payload, array $options = []): RawHttpResponse { // Revert Ollama's default streaming behavior $options['stream'] ??= false; - return $this->httpClient->request('POST', \sprintf('%s/api/chat', $this->hostUrl), [ + return new RawHttpResponse($this->httpClient->request('POST', \sprintf('%s/api/chat', $this->hostUrl), [ 'headers' => ['Content-Type' => 'application/json'], 'json' => array_merge($options, $payload), - ]); + ])); } } diff --git a/src/platform/src/Bridge/Ollama/LlamaResponseConverter.php b/src/platform/src/Bridge/Ollama/LlamaResponseConverter.php index 1cfa71b20..0b8145a6f 100644 --- a/src/platform/src/Bridge/Ollama/LlamaResponseConverter.php +++ b/src/platform/src/Bridge/Ollama/LlamaResponseConverter.php @@ -14,10 +14,10 @@ use Symfony\AI\Platform\Bridge\Meta\Llama; use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; -use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\RawResponseInterface; +use Symfony\AI\Platform\Response\ResponseInterface; use Symfony\AI\Platform\Response\TextResponse; use Symfony\AI\Platform\ResponseConverterInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Christopher Hertel @@ -29,9 +29,9 @@ public function supports(Model $model): bool return $model instanceof Llama; } - public function convert(ResponseInterface $response, array $options = []): LlmResponse + public function convert(RawResponseInterface $response, array $options = []): ResponseInterface { - $data = $response->toArray(); + $data = $response->getRawData(); if (!isset($data['message'])) { throw new RuntimeException('Response does not contain message'); diff --git a/src/platform/src/Bridge/OpenAI/DallE/ModelClient.php b/src/platform/src/Bridge/OpenAI/DallE/ModelClient.php index c4cdb05de..4e0153624 100644 --- a/src/platform/src/Bridge/OpenAI/DallE/ModelClient.php +++ b/src/platform/src/Bridge/OpenAI/DallE/ModelClient.php @@ -15,8 +15,8 @@ use Symfony\AI\Platform\Exception\InvalidArgumentException; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Response\RawHttpResponse; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; /** * @see https://platform.openai.com/docs/api-reference/images/create @@ -39,14 +39,14 @@ public function supports(Model $model): bool return $model instanceof DallE; } - public function request(Model $model, array|string $payload, array $options = []): HttpResponse + public function request(Model $model, array|string $payload, array $options = []): RawHttpResponse { - return $this->httpClient->request('POST', 'https://api.openai.com/v1/images/generations', [ + return new RawHttpResponse($this->httpClient->request('POST', 'https://api.openai.com/v1/images/generations', [ 'auth_bearer' => $this->apiKey, 'json' => array_merge($options, [ 'model' => $model->getName(), 'prompt' => $payload, ]), - ]); + ])); } } diff --git a/src/platform/src/Bridge/OpenAI/DallE/ResponseConverter.php b/src/platform/src/Bridge/OpenAI/DallE/ResponseConverter.php index b2069d062..a0bf1e317 100644 --- a/src/platform/src/Bridge/OpenAI/DallE/ResponseConverter.php +++ b/src/platform/src/Bridge/OpenAI/DallE/ResponseConverter.php @@ -14,9 +14,9 @@ use Symfony\AI\Platform\Bridge\OpenAI\DallE; use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; -use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\RawResponseInterface; +use Symfony\AI\Platform\Response\ResponseInterface; use Symfony\AI\Platform\ResponseConverterInterface; -use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; /** * @see https://platform.openai.com/docs/api-reference/images/create @@ -30,9 +30,10 @@ public function supports(Model $model): bool return $model instanceof DallE; } - public function convert(HttpResponse $response, array $options = []): LlmResponse + public function convert(RawResponseInterface $response, array $options = []): ResponseInterface { - $response = $response->toArray(); + $response = $response->getRawData(); + if (!isset($response['data'][0])) { throw new RuntimeException('No image generated.'); } diff --git a/src/platform/src/Bridge/OpenAI/Embeddings/ModelClient.php b/src/platform/src/Bridge/OpenAI/Embeddings/ModelClient.php index 65f33ced0..ecb183268 100644 --- a/src/platform/src/Bridge/OpenAI/Embeddings/ModelClient.php +++ b/src/platform/src/Bridge/OpenAI/Embeddings/ModelClient.php @@ -15,8 +15,8 @@ use Symfony\AI\Platform\Exception\InvalidArgumentException; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface as PlatformResponseFactory; +use Symfony\AI\Platform\Response\RawHttpResponse; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Christopher Hertel @@ -37,14 +37,14 @@ public function supports(Model $model): bool return $model instanceof Embeddings; } - public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + public function request(Model $model, array|string $payload, array $options = []): RawHttpResponse { - return $this->httpClient->request('POST', 'https://api.openai.com/v1/embeddings', [ + return new RawHttpResponse($this->httpClient->request('POST', 'https://api.openai.com/v1/embeddings', [ 'auth_bearer' => $this->apiKey, 'json' => array_merge($options, [ 'model' => $model->getName(), 'input' => $payload, ]), - ]); + ])); } } diff --git a/src/platform/src/Bridge/OpenAI/Embeddings/ResponseConverter.php b/src/platform/src/Bridge/OpenAI/Embeddings/ResponseConverter.php index caf471056..d188ada87 100644 --- a/src/platform/src/Bridge/OpenAI/Embeddings/ResponseConverter.php +++ b/src/platform/src/Bridge/OpenAI/Embeddings/ResponseConverter.php @@ -14,24 +14,24 @@ use Symfony\AI\Platform\Bridge\OpenAI\Embeddings; use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\RawResponseInterface; use Symfony\AI\Platform\Response\VectorResponse; -use Symfony\AI\Platform\ResponseConverterInterface as PlatformResponseConverter; +use Symfony\AI\Platform\ResponseConverterInterface; use Symfony\AI\Platform\Vector\Vector; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Christopher Hertel */ -final class ResponseConverter implements PlatformResponseConverter +final class ResponseConverter implements ResponseConverterInterface { public function supports(Model $model): bool { return $model instanceof Embeddings; } - public function convert(ResponseInterface $response, array $options = []): VectorResponse + public function convert(RawResponseInterface $response, array $options = []): VectorResponse { - $data = $response->toArray(); + $data = $response->getRawData(); if (!isset($data['data'])) { throw new RuntimeException('Response does not contain data'); diff --git a/src/platform/src/Bridge/OpenAI/GPT/ModelClient.php b/src/platform/src/Bridge/OpenAI/GPT/ModelClient.php index 169f6ff27..b40763aa5 100644 --- a/src/platform/src/Bridge/OpenAI/GPT/ModelClient.php +++ b/src/platform/src/Bridge/OpenAI/GPT/ModelClient.php @@ -15,9 +15,9 @@ use Symfony\AI\Platform\Exception\InvalidArgumentException; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface as PlatformResponseFactory; +use Symfony\AI\Platform\Response\RawHttpResponse; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Christopher Hertel @@ -41,11 +41,11 @@ public function supports(Model $model): bool return $model instanceof GPT; } - public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + public function request(Model $model, array|string $payload, array $options = []): RawHttpResponse { - return $this->httpClient->request('POST', 'https://api.openai.com/v1/chat/completions', [ + return new RawHttpResponse($this->httpClient->request('POST', 'https://api.openai.com/v1/chat/completions', [ 'auth_bearer' => $this->apiKey, 'json' => array_merge($options, $payload), - ]); + ])); } } diff --git a/src/platform/src/Bridge/OpenAI/GPT/ResponseConverter.php b/src/platform/src/Bridge/OpenAI/GPT/ResponseConverter.php index ca43dfb35..1f93a6be7 100644 --- a/src/platform/src/Bridge/OpenAI/GPT/ResponseConverter.php +++ b/src/platform/src/Bridge/OpenAI/GPT/ResponseConverter.php @@ -17,7 +17,9 @@ use Symfony\AI\Platform\Model; use Symfony\AI\Platform\Response\Choice; use Symfony\AI\Platform\Response\ChoiceResponse; -use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\RawHttpResponse; +use Symfony\AI\Platform\Response\RawResponseInterface; +use Symfony\AI\Platform\Response\ResponseInterface; use Symfony\AI\Platform\Response\StreamResponse; use Symfony\AI\Platform\Response\TextResponse; use Symfony\AI\Platform\Response\ToolCall; @@ -26,7 +28,6 @@ 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; /** @@ -40,22 +41,16 @@ public function supports(Model $model): bool return $model instanceof GPT; } - public function convert(HttpResponse $response, array $options = []): LlmResponse + public function convert(RawResponseInterface|RawHttpResponse $response, array $options = []): ResponseInterface { if ($options['stream'] ?? false) { - return new StreamResponse($this->convertStream($response)); + return new StreamResponse($this->convertStream($response->getRawObject())); } - try { - $data = $response->toArray(); - } catch (ClientExceptionInterface $e) { - $data = $response->toArray(throw: false); + $data = $response->getRawData(); - if (isset($data['error']['code']) && 'content_filter' === $data['error']['code']) { - throw new ContentFilterException(message: $data['error']['message'], previous: $e); - } - - throw $e; + if (isset($data['error']['code']) && 'content_filter' === $data['error']['code']) { + throw new ContentFilterException($data['error']['message']); } if (!isset($data['choices'])) { diff --git a/src/platform/src/Bridge/OpenAI/Whisper/ModelClient.php b/src/platform/src/Bridge/OpenAI/Whisper/ModelClient.php index 61d6c374b..e6fd02646 100644 --- a/src/platform/src/Bridge/OpenAI/Whisper/ModelClient.php +++ b/src/platform/src/Bridge/OpenAI/Whisper/ModelClient.php @@ -15,8 +15,8 @@ use Symfony\AI\Platform\Exception\InvalidArgumentException; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface as BaseModelClient; +use Symfony\AI\Platform\Response\RawHttpResponse; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Christopher Hertel @@ -36,16 +36,16 @@ public function supports(Model $model): bool return $model instanceof Whisper; } - public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + public function request(Model $model, array|string $payload, array $options = []): RawHttpResponse { $task = $options['task'] ?? Task::TRANSCRIPTION; $endpoint = Task::TRANSCRIPTION === $task ? 'transcriptions' : 'translations'; unset($options['task']); - return $this->httpClient->request('POST', \sprintf('https://api.openai.com/v1/audio/%s', $endpoint), [ + return new RawHttpResponse($this->httpClient->request('POST', \sprintf('https://api.openai.com/v1/audio/%s', $endpoint), [ 'auth_bearer' => $this->apiKey, 'headers' => ['Content-Type' => 'multipart/form-data'], 'body' => array_merge($options, $payload, ['model' => $model->getName()]), - ]); + ])); } } diff --git a/src/platform/src/Bridge/OpenAI/Whisper/ResponseConverter.php b/src/platform/src/Bridge/OpenAI/Whisper/ResponseConverter.php index 094421c2e..6125fd640 100644 --- a/src/platform/src/Bridge/OpenAI/Whisper/ResponseConverter.php +++ b/src/platform/src/Bridge/OpenAI/Whisper/ResponseConverter.php @@ -13,10 +13,10 @@ use Symfony\AI\Platform\Bridge\OpenAI\Whisper; use Symfony\AI\Platform\Model; -use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\RawResponseInterface; +use Symfony\AI\Platform\Response\ResponseInterface; use Symfony\AI\Platform\Response\TextResponse; use Symfony\AI\Platform\ResponseConverterInterface as BaseResponseConverter; -use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; /** * @author Christopher Hertel @@ -28,9 +28,9 @@ public function supports(Model $model): bool return $model instanceof Whisper; } - public function convert(HttpResponse $response, array $options = []): LlmResponse + public function convert(RawResponseInterface $response, array $options = []): ResponseInterface { - $data = $response->toArray(); + $data = $response->getRawData(); return new TextResponse($data['text']); } diff --git a/src/platform/src/Bridge/OpenRouter/ModelClient.php b/src/platform/src/Bridge/OpenRouter/ModelClient.php index 78a3804e0..5933c74de 100644 --- a/src/platform/src/Bridge/OpenRouter/ModelClient.php +++ b/src/platform/src/Bridge/OpenRouter/ModelClient.php @@ -14,9 +14,9 @@ use Symfony\AI\Platform\Exception\InvalidArgumentException; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Response\RawHttpResponse; use Symfony\Component\HttpClient\EventSourceHttpClient; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author rglozman @@ -39,11 +39,11 @@ public function supports(Model $model): bool return true; } - public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + public function request(Model $model, array|string $payload, array $options = []): RawHttpResponse { - return $this->httpClient->request('POST', 'https://openrouter.ai/api/v1/chat/completions', [ + return new RawHttpResponse($this->httpClient->request('POST', 'https://openrouter.ai/api/v1/chat/completions', [ 'auth_bearer' => $this->apiKey, 'json' => array_merge($options, $payload), - ]); + ])); } } diff --git a/src/platform/src/Bridge/OpenRouter/ResponseConverter.php b/src/platform/src/Bridge/OpenRouter/ResponseConverter.php index 8e5dc4d65..6432a975e 100644 --- a/src/platform/src/Bridge/OpenRouter/ResponseConverter.php +++ b/src/platform/src/Bridge/OpenRouter/ResponseConverter.php @@ -13,10 +13,10 @@ use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; -use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\RawResponseInterface; +use Symfony\AI\Platform\Response\ResponseInterface; use Symfony\AI\Platform\Response\TextResponse; use Symfony\AI\Platform\ResponseConverterInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author rglozman @@ -28,9 +28,9 @@ public function supports(Model $model): bool return true; } - public function convert(ResponseInterface $response, array $options = []): LlmResponse + public function convert(RawResponseInterface $response, array $options = []): ResponseInterface { - $data = $response->toArray(); + $data = $response->getRawData(); if (!isset($data['choices'][0]['message'])) { throw new RuntimeException('Response does not contain message'); diff --git a/src/platform/src/Bridge/Replicate/LlamaModelClient.php b/src/platform/src/Bridge/Replicate/LlamaModelClient.php index bc375d9f4..ae7e95c7e 100644 --- a/src/platform/src/Bridge/Replicate/LlamaModelClient.php +++ b/src/platform/src/Bridge/Replicate/LlamaModelClient.php @@ -15,7 +15,7 @@ use Symfony\AI\Platform\Exception\InvalidArgumentException; use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; +use Symfony\AI\Platform\Response\RawHttpResponse; /** * @author Christopher Hertel @@ -32,10 +32,12 @@ public function supports(Model $model): bool return $model instanceof Llama; } - public function request(Model $model, array|string $payload, array $options = []): ResponseInterface + public function request(Model $model, array|string $payload, array $options = []): RawHttpResponse { $model instanceof Llama || throw new InvalidArgumentException(\sprintf('The model must be an instance of "%s".', Llama::class)); - return $this->client->request(\sprintf('meta/meta-%s', $model->getName()), 'predictions', $payload); + return new RawHttpResponse( + $this->client->request(\sprintf('meta/meta-%s', $model->getName()), 'predictions', $payload) + ); } } diff --git a/src/platform/src/Bridge/Replicate/LlamaResponseConverter.php b/src/platform/src/Bridge/Replicate/LlamaResponseConverter.php index 65d1cf19e..8c129bba6 100644 --- a/src/platform/src/Bridge/Replicate/LlamaResponseConverter.php +++ b/src/platform/src/Bridge/Replicate/LlamaResponseConverter.php @@ -14,10 +14,10 @@ use Symfony\AI\Platform\Bridge\Meta\Llama; use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; -use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\RawResponseInterface; +use Symfony\AI\Platform\Response\ResponseInterface; use Symfony\AI\Platform\Response\TextResponse; use Symfony\AI\Platform\ResponseConverterInterface; -use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; /** * @author Christopher Hertel @@ -29,9 +29,9 @@ public function supports(Model $model): bool return $model instanceof Llama; } - public function convert(HttpResponse $response, array $options = []): LlmResponse + public function convert(RawResponseInterface $response, array $options = []): ResponseInterface { - $data = $response->toArray(); + $data = $response->getRawData(); if (!isset($data['output'])) { throw new RuntimeException('Response does not contain output'); diff --git a/src/platform/src/Bridge/TransformersPHP/ModelClient.php b/src/platform/src/Bridge/TransformersPHP/ModelClient.php new file mode 100644 index 000000000..b9e1f8736 --- /dev/null +++ b/src/platform/src/Bridge/TransformersPHP/ModelClient.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\TransformersPHP; + +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface; + +use function Codewithkyrian\Transformers\Pipelines\pipeline; + +final readonly class ModelClient implements ModelClientInterface +{ + public function supports(Model $model): bool + { + return true; + } + + public function request(Model $model, array|string $payload, array $options = []): RawPipelineResponse + { + if (null === $task = $options['task'] ?? null) { + throw new InvalidArgumentException('The task option is required.'); + } + + $pipeline = pipeline( + $task, + $model->getName(), + $options['quantized'] ?? true, + $options['config'] ?? null, + $options['cacheDir'] ?? null, + $options['revision'] ?? 'main', + $options['modelFilename'] ?? null, + ); + + return new RawPipelineResponse(new PipelineExecution($pipeline, $payload)); + } +} diff --git a/src/platform/src/Bridge/TransformersPHP/PipelineExecution.php b/src/platform/src/Bridge/TransformersPHP/PipelineExecution.php index eab0b6e9e..e7e77aca7 100644 --- a/src/platform/src/Bridge/TransformersPHP/PipelineExecution.php +++ b/src/platform/src/Bridge/TransformersPHP/PipelineExecution.php @@ -24,19 +24,14 @@ final class PipelineExecution private ?array $result = null; /** - * @param array|string|object $input + * @param array|string $input */ public function __construct( private readonly Pipeline $pipeline, - private readonly object|array|string $input, + private readonly array|string $input, ) { } - public function getPipeline(): Pipeline - { - return $this->pipeline; - } - /** * @return array */ diff --git a/src/platform/src/Bridge/TransformersPHP/Platform.php b/src/platform/src/Bridge/TransformersPHP/Platform.php deleted file mode 100644 index cba2380c1..000000000 --- a/src/platform/src/Bridge/TransformersPHP/Platform.php +++ /dev/null @@ -1,62 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\AI\Platform\Bridge\TransformersPHP; - -use Codewithkyrian\Transformers\Pipelines\Task; -use Symfony\AI\Platform\Exception\InvalidArgumentException; -use Symfony\AI\Platform\Model; -use Symfony\AI\Platform\PlatformInterface; -use Symfony\AI\Platform\Response\ObjectResponse; -use Symfony\AI\Platform\Response\ResponseInterface; -use Symfony\AI\Platform\Response\ResponsePromise; -use Symfony\AI\Platform\Response\TextResponse; - -use function Codewithkyrian\Transformers\Pipelines\pipeline; - -/** - * @author Christopher Hertel - */ -final class Platform implements PlatformInterface -{ - public function request(Model $model, object|array|string $input, array $options = []): ResponsePromise - { - if (null === $task = $options['task'] ?? null) { - throw new InvalidArgumentException('The task option is required.'); - } - - $pipeline = pipeline( - $task, - $model->getName(), - $options['quantized'] ?? true, - $options['config'] ?? null, - $options['cacheDir'] ?? null, - $options['revision'] ?? 'main', - $options['modelFilename'] ?? null, - ); - $execution = new PipelineExecution($pipeline, $input); - - return new ResponsePromise($this->convertResponse(...), new RawPipelineResponse($execution), $options); - } - - /** - * @param array $options - */ - private function convertResponse(PipelineExecution $pipelineExecution, array $options): ResponseInterface - { - $data = $pipelineExecution->getResult(); - - return match ($options['task']) { - Task::Text2TextGeneration => new TextResponse($data[0]['generated_text']), - default => new ObjectResponse($data), - }; - } -} diff --git a/src/platform/src/Bridge/TransformersPHP/PlatformFactory.php b/src/platform/src/Bridge/TransformersPHP/PlatformFactory.php index 875c2675d..0e122d681 100644 --- a/src/platform/src/Bridge/TransformersPHP/PlatformFactory.php +++ b/src/platform/src/Bridge/TransformersPHP/PlatformFactory.php @@ -13,6 +13,7 @@ use Codewithkyrian\Transformers\Transformers; use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Platform; /** * @author Christopher Hertel @@ -25,6 +26,6 @@ public static function create(): Platform throw new RuntimeException('For using the TransformersPHP with FFI to run models in PHP, the codewithkyrian/transformers package is required. Try running "composer require codewithkyrian/transformers".'); } - return new Platform(); + return new Platform([new ModelClient()], [new ResponseConverter()]); } } diff --git a/src/platform/src/Bridge/TransformersPHP/ResponseConverter.php b/src/platform/src/Bridge/TransformersPHP/ResponseConverter.php new file mode 100644 index 000000000..9b24381e4 --- /dev/null +++ b/src/platform/src/Bridge/TransformersPHP/ResponseConverter.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\TransformersPHP; + +use Codewithkyrian\Transformers\Pipelines\Task; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Response\ObjectResponse; +use Symfony\AI\Platform\Response\RawResponseInterface; +use Symfony\AI\Platform\Response\TextResponse; +use Symfony\AI\Platform\ResponseConverterInterface; + +final readonly class ResponseConverter implements ResponseConverterInterface +{ + public function supports(Model $model): bool + { + return true; + } + + public function convert(RawResponseInterface $response, array $options = []): TextResponse|ObjectResponse + { + $data = $response->getRawData(); + + if (Task::Text2TextGeneration === $options['task']) { + $result = reset($data); + + return new TextResponse($result['generated_text']); + } + + return new ObjectResponse($data); + } +} diff --git a/src/platform/src/Bridge/Voyage/ModelClient.php b/src/platform/src/Bridge/Voyage/ModelClient.php index 4f9c1df58..82584cb3e 100644 --- a/src/platform/src/Bridge/Voyage/ModelClient.php +++ b/src/platform/src/Bridge/Voyage/ModelClient.php @@ -13,8 +13,8 @@ use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Response\RawHttpResponse; use Symfony\Contracts\HttpClient\HttpClientInterface; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Christopher Hertel @@ -32,14 +32,14 @@ public function supports(Model $model): bool return $model instanceof Voyage; } - public function request(Model $model, object|string|array $payload, array $options = []): ResponseInterface + public function request(Model $model, object|string|array $payload, array $options = []): RawHttpResponse { - return $this->httpClient->request('POST', 'https://api.voyageai.com/v1/embeddings', [ + return new RawHttpResponse($this->httpClient->request('POST', 'https://api.voyageai.com/v1/embeddings', [ 'auth_bearer' => $this->apiKey, 'json' => [ 'model' => $model->getName(), 'input' => $payload, ], - ]); + ])); } } diff --git a/src/platform/src/Bridge/Voyage/ResponseConverter.php b/src/platform/src/Bridge/Voyage/ResponseConverter.php index d238ec271..132636119 100644 --- a/src/platform/src/Bridge/Voyage/ResponseConverter.php +++ b/src/platform/src/Bridge/Voyage/ResponseConverter.php @@ -13,11 +13,11 @@ use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Model; -use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; +use Symfony\AI\Platform\Response\RawResponseInterface; +use Symfony\AI\Platform\Response\ResponseInterface; use Symfony\AI\Platform\Response\VectorResponse; use Symfony\AI\Platform\ResponseConverterInterface; use Symfony\AI\Platform\Vector\Vector; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Christopher Hertel @@ -29,9 +29,9 @@ public function supports(Model $model): bool return $model instanceof Voyage; } - public function convert(ResponseInterface $response, array $options = []): LlmResponse + public function convert(RawResponseInterface $response, array $options = []): ResponseInterface { - $response = $response->toArray(); + $response = $response->getRawData(); if (!isset($response['data'])) { throw new RuntimeException('Response does not contain embedding data'); diff --git a/src/platform/src/ModelClientInterface.php b/src/platform/src/ModelClientInterface.php index b727a8674..be3cf0d4a 100644 --- a/src/platform/src/ModelClientInterface.php +++ b/src/platform/src/ModelClientInterface.php @@ -11,7 +11,7 @@ namespace Symfony\AI\Platform; -use Symfony\Contracts\HttpClient\ResponseInterface; +use Symfony\AI\Platform\Response\RawResponseInterface; /** * @author Christopher Hertel @@ -24,5 +24,5 @@ public function supports(Model $model): bool; * @param array $payload * @param array $options */ - public function request(Model $model, array|string $payload, array $options = []): ResponseInterface; + public function request(Model $model, array|string $payload, array $options = []): RawResponseInterface; } diff --git a/src/platform/src/Platform.php b/src/platform/src/Platform.php index 9213ef4cc..5a51bb7ac 100644 --- a/src/platform/src/Platform.php +++ b/src/platform/src/Platform.php @@ -12,9 +12,8 @@ namespace Symfony\AI\Platform; use Symfony\AI\Platform\Exception\RuntimeException; -use Symfony\AI\Platform\Response\RawHttpResponse; +use Symfony\AI\Platform\Response\RawResponseInterface; use Symfony\AI\Platform\Response\ResponsePromise; -use Symfony\Contracts\HttpClient\ResponseInterface; /** * @author Christopher Hertel @@ -63,7 +62,7 @@ public function request(Model $model, array|string|object $input, array $options * @param array $payload * @param array $options */ - private function doRequest(Model $model, array|string $payload, array $options = []): ResponseInterface + private function doRequest(Model $model, array|string $payload, array $options = []): RawResponseInterface { foreach ($this->modelClients as $modelClient) { if ($modelClient->supports($model)) { @@ -77,15 +76,11 @@ private function doRequest(Model $model, array|string $payload, array $options = /** * @param array $options */ - private function convertResponse(Model $model, ResponseInterface $response, array $options): ResponsePromise + private function convertResponse(Model $model, RawResponseInterface $response, array $options): ResponsePromise { foreach ($this->responseConverter as $responseConverter) { if ($responseConverter->supports($model)) { - return new ResponsePromise( - fn (ResponseInterface $response, array $options) => $responseConverter->convert($response, $options), - new RawHttpResponse($response), - $options, - ); + return new ResponsePromise($responseConverter->convert(...), $response, $options); } } diff --git a/src/platform/src/Response/ResponsePromise.php b/src/platform/src/Response/ResponsePromise.php index ccf4c823a..14f77e1a0 100644 --- a/src/platform/src/Response/ResponsePromise.php +++ b/src/platform/src/Response/ResponsePromise.php @@ -45,7 +45,7 @@ public function getRawResponse(): RawResponseInterface public function await(): ResponseInterface { if (!$this->isConverted) { - $this->convertedResponse = ($this->responseConverter)($this->response->getRawObject(), $this->options); + $this->convertedResponse = ($this->responseConverter)($this->response, $this->options); if (null === $this->convertedResponse->getRawResponse()) { // Fallback to set the raw response when it was not handled by the response converter itself diff --git a/src/platform/src/ResponseConverterInterface.php b/src/platform/src/ResponseConverterInterface.php index 9bdfd0551..856c16a38 100644 --- a/src/platform/src/ResponseConverterInterface.php +++ b/src/platform/src/ResponseConverterInterface.php @@ -11,8 +11,8 @@ namespace Symfony\AI\Platform; -use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; -use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; +use Symfony\AI\Platform\Response\RawResponseInterface; +use Symfony\AI\Platform\Response\ResponseInterface; /** * @author Christopher Hertel @@ -24,5 +24,5 @@ public function supports(Model $model): bool; /** * @param array $options */ - public function convert(HttpResponse $response, array $options = []): LlmResponse; + public function convert(RawResponseInterface $response, array $options = []): ResponseInterface; } diff --git a/src/platform/tests/Bridge/Anthropic/ResponseConverterTest.php b/src/platform/tests/Bridge/Anthropic/ResponseConverterTest.php index 58c989719..45717eee2 100644 --- a/src/platform/tests/Bridge/Anthropic/ResponseConverterTest.php +++ b/src/platform/tests/Bridge/Anthropic/ResponseConverterTest.php @@ -16,6 +16,7 @@ use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use Symfony\AI\Platform\Bridge\Anthropic\ResponseConverter; +use Symfony\AI\Platform\Response\RawHttpResponse; use Symfony\AI\Platform\Response\ToolCall; use Symfony\AI\Platform\Response\ToolCallResponse; use Symfony\Component\HttpClient\MockHttpClient; @@ -42,7 +43,7 @@ public function testConvertThrowsExceptionWhenContentIsToolUseAndLacksText(): vo $httpResponse = $httpClient->request('POST', 'https://api.anthropic.com/v1/messages'); $handler = new ResponseConverter(); - $response = $handler->convert($httpResponse); + $response = $handler->convert(new RawHttpResponse($httpResponse)); self::assertInstanceOf(ToolCallResponse::class, $response); self::assertCount(1, $response->getContent()); self::assertSame('toolu_01UM4PcTjC1UDiorSXVHSVFM', $response->getContent()[0]->id); diff --git a/src/platform/tests/Bridge/Google/Embeddings/ModelClientTest.php b/src/platform/tests/Bridge/Google/Embeddings/ModelClientTest.php index c49b625cd..5dfdae811 100644 --- a/src/platform/tests/Bridge/Google/Embeddings/ModelClientTest.php +++ b/src/platform/tests/Bridge/Google/Embeddings/ModelClientTest.php @@ -68,8 +68,8 @@ public function itMakesARequestWithCorrectPayload(): void $model = new Embeddings(Embeddings::GEMINI_EMBEDDING_EXP_03_07, ['dimensions' => 1536, 'task_type' => 'CLASSIFICATION']); - $httpResponse = (new ModelClient($httpClient, 'test'))->request($model, ['payload1', 'payload2']); - self::assertSame(json_decode($this->getEmbeddingStub(), true), $httpResponse->toArray()); + $response = (new ModelClient($httpClient, 'test'))->request($model, ['payload1', 'payload2']); + self::assertSame(json_decode($this->getEmbeddingStub(), true), $response->getRawData()); } private function getEmbeddingStub(): string diff --git a/src/platform/tests/Bridge/Google/Embeddings/ResponseConverterTest.php b/src/platform/tests/Bridge/Google/Embeddings/ResponseConverterTest.php index 05673c025..c97c10b84 100644 --- a/src/platform/tests/Bridge/Google/Embeddings/ResponseConverterTest.php +++ b/src/platform/tests/Bridge/Google/Embeddings/ResponseConverterTest.php @@ -18,6 +18,7 @@ use PHPUnit\Framework\TestCase; use Symfony\AI\Platform\Bridge\Google\Embeddings; use Symfony\AI\Platform\Bridge\Google\Embeddings\ResponseConverter; +use Symfony\AI\Platform\Response\RawHttpResponse; use Symfony\AI\Platform\Response\VectorResponse; use Symfony\AI\Platform\Vector\Vector; use Symfony\Contracts\HttpClient\ResponseInterface; @@ -37,7 +38,7 @@ public function itConvertsAResponseToAVectorResponse(): void ->method('toArray') ->willReturn(json_decode($this->getEmbeddingStub(), true)); - $vectorResponse = (new ResponseConverter())->convert($response); + $vectorResponse = (new ResponseConverter())->convert(new RawHttpResponse($response)); $convertedContent = $vectorResponse->getContent(); self::assertCount(2, $convertedContent); diff --git a/src/platform/tests/Bridge/LMStudio/Embeddings/ResponseConverterTest.php b/src/platform/tests/Bridge/LMStudio/Embeddings/ResponseConverterTest.php index 8f0674db9..af6ef08e2 100644 --- a/src/platform/tests/Bridge/LMStudio/Embeddings/ResponseConverterTest.php +++ b/src/platform/tests/Bridge/LMStudio/Embeddings/ResponseConverterTest.php @@ -19,6 +19,7 @@ use Symfony\AI\Platform\Bridge\LMStudio\Embeddings; use Symfony\AI\Platform\Bridge\LMStudio\Embeddings\ResponseConverter; use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Response\RawHttpResponse; use Symfony\AI\Platform\Response\VectorResponse; use Symfony\AI\Platform\Vector\Vector; use Symfony\Contracts\HttpClient\ResponseInterface; @@ -59,7 +60,7 @@ public function itConvertsAResponseToAVectorResponse(): void ) ); - $vectorResponse = (new ResponseConverter())->convert($response); + $vectorResponse = (new ResponseConverter())->convert(new RawHttpResponse($response)); $convertedContent = $vectorResponse->getContent(); self::assertCount(2, $convertedContent); @@ -79,7 +80,7 @@ public function itThrowsExceptionWhenResponseDoesNotContainData(): void $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Response does not contain data'); - (new ResponseConverter())->convert($response); + (new ResponseConverter())->convert(new RawHttpResponse($response)); } #[Test] diff --git a/src/platform/tests/Bridge/OpenAI/DallE/ResponseConverterTest.php b/src/platform/tests/Bridge/OpenAI/DallE/ResponseConverterTest.php index 5c77572ee..660b7ff85 100644 --- a/src/platform/tests/Bridge/OpenAI/DallE/ResponseConverterTest.php +++ b/src/platform/tests/Bridge/OpenAI/DallE/ResponseConverterTest.php @@ -20,6 +20,7 @@ use Symfony\AI\Platform\Bridge\OpenAI\DallE\ImageResponse; use Symfony\AI\Platform\Bridge\OpenAI\DallE\ResponseConverter; use Symfony\AI\Platform\Bridge\OpenAI\DallE\UrlImage; +use Symfony\AI\Platform\Response\RawHttpResponse; use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; #[CoversClass(ResponseConverter::class)] @@ -40,7 +41,7 @@ public function itIsConvertingTheResponse(): void ]); $responseConverter = new ResponseConverter(); - $response = $responseConverter->convert($httpResponse, ['response_format' => 'url']); + $response = $responseConverter->convert(new RawHttpResponse($httpResponse), ['response_format' => 'url']); self::assertCount(1, $response->getContent()); self::assertInstanceOf(UrlImage::class, $response->getContent()[0]); @@ -60,7 +61,7 @@ public function itIsConvertingTheResponseWithRevisedPrompt(): void ]); $responseConverter = new ResponseConverter(); - $response = $responseConverter->convert($httpResponse, ['response_format' => 'b64_json']); + $response = $responseConverter->convert(new RawHttpResponse($httpResponse), ['response_format' => 'b64_json']); self::assertInstanceOf(ImageResponse::class, $response); self::assertCount(1, $response->getContent()); diff --git a/src/platform/tests/Bridge/OpenAI/Embeddings/ResponseConverterTest.php b/src/platform/tests/Bridge/OpenAI/Embeddings/ResponseConverterTest.php index 48175b06c..f1ccc37bc 100644 --- a/src/platform/tests/Bridge/OpenAI/Embeddings/ResponseConverterTest.php +++ b/src/platform/tests/Bridge/OpenAI/Embeddings/ResponseConverterTest.php @@ -17,6 +17,7 @@ use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; use Symfony\AI\Platform\Bridge\OpenAI\Embeddings\ResponseConverter; +use Symfony\AI\Platform\Response\RawHttpResponse; use Symfony\AI\Platform\Response\VectorResponse; use Symfony\AI\Platform\Vector\Vector; use Symfony\Contracts\HttpClient\ResponseInterface; @@ -35,7 +36,7 @@ public function itConvertsAResponseToAVectorResponse(): void ->method('toArray') ->willReturn(json_decode($this->getEmbeddingStub(), true)); - $vectorResponse = (new ResponseConverter())->convert($response); + $vectorResponse = (new ResponseConverter())->convert(new RawHttpResponse($response)); $convertedContent = $vectorResponse->getContent(); self::assertCount(2, $convertedContent); diff --git a/src/platform/tests/Bridge/OpenAI/GPT/ResponseConverterTest.php b/src/platform/tests/Bridge/OpenAI/GPT/ResponseConverterTest.php index 71478bd64..d5e77f74e 100644 --- a/src/platform/tests/Bridge/OpenAI/GPT/ResponseConverterTest.php +++ b/src/platform/tests/Bridge/OpenAI/GPT/ResponseConverterTest.php @@ -20,6 +20,7 @@ use Symfony\AI\Platform\Exception\RuntimeException; use Symfony\AI\Platform\Response\Choice; use Symfony\AI\Platform\Response\ChoiceResponse; +use Symfony\AI\Platform\Response\RawHttpResponse; use Symfony\AI\Platform\Response\TextResponse; use Symfony\AI\Platform\Response\ToolCall; use Symfony\AI\Platform\Response\ToolCallResponse; @@ -51,7 +52,7 @@ public function testConvertTextResponse(): void ], ]); - $response = $converter->convert($httpResponse); + $response = $converter->convert(new RawHttpResponse($httpResponse)); self::assertInstanceOf(TextResponse::class, $response); self::assertSame('Hello world', $response->getContent()); @@ -83,7 +84,7 @@ public function testConvertToolCallResponse(): void ], ]); - $response = $converter->convert($httpResponse); + $response = $converter->convert(new RawHttpResponse($httpResponse)); self::assertInstanceOf(ToolCallResponse::class, $response); $toolCalls = $response->getContent(); @@ -116,7 +117,7 @@ public function testConvertMultipleChoices(): void ], ]); - $response = $converter->convert($httpResponse); + $response = $converter->convert(new RawHttpResponse($httpResponse)); self::assertInstanceOf(ChoiceResponse::class, $response); $choices = $response->getContent(); @@ -130,7 +131,7 @@ public function testContentFilterException(): void $converter = new ResponseConverter(); $httpResponse = self::createMock(ResponseInterface::class); - $httpResponse->expects($this->exactly(2)) + $httpResponse->expects($this->exactly(1)) ->method('toArray') ->willReturnCallback(function ($throw = true) { if ($throw) { @@ -153,7 +154,7 @@ public function getResponse(): ResponseInterface self::expectException(ContentFilterException::class); self::expectExceptionMessage('Content was filtered'); - $converter->convert($httpResponse); + $converter->convert(new RawHttpResponse($httpResponse)); } public function testThrowsExceptionWhenNoChoices(): void @@ -165,7 +166,7 @@ public function testThrowsExceptionWhenNoChoices(): void self::expectException(RuntimeException::class); self::expectExceptionMessage('Response does not contain choices'); - $converter->convert($httpResponse); + $converter->convert(new RawHttpResponse($httpResponse)); } public function testThrowsExceptionForUnsupportedFinishReason(): void @@ -187,6 +188,6 @@ public function testThrowsExceptionForUnsupportedFinishReason(): void self::expectException(RuntimeException::class); self::expectExceptionMessage('Unsupported finish reason "unsupported_reason"'); - $converter->convert($httpResponse); + $converter->convert(new RawHttpResponse($httpResponse)); } } diff --git a/src/platform/tests/Response/ResponsePromiseTest.php b/src/platform/tests/Response/ResponsePromiseTest.php index 9705858d8..de5997eb7 100644 --- a/src/platform/tests/Response/ResponsePromiseTest.php +++ b/src/platform/tests/Response/ResponsePromiseTest.php @@ -38,15 +38,16 @@ final class ResponsePromiseTest extends TestCase public function itUnwrapsTheResponseWhenGettingContent(): void { $httpResponse = $this->createStub(SymfonyHttpResponse::class); + $rawHttpResponse = new RawHttpResponse($httpResponse); $textResponse = new TextResponse('test content'); $responseConverter = self::createMock(ResponseConverterInterface::class); $responseConverter->expects(self::once()) ->method('convert') - ->with($httpResponse, []) + ->with($rawHttpResponse, []) ->willReturn($textResponse); - $responsePromise = new ResponsePromise($responseConverter->convert(...), new RawHttpResponse($httpResponse)); + $responsePromise = new ResponsePromise($responseConverter->convert(...), $rawHttpResponse); self::assertSame('test content', $responsePromise->getResponse()->getContent()); } @@ -55,15 +56,16 @@ public function itUnwrapsTheResponseWhenGettingContent(): void public function itConvertsTheResponseOnlyOnce(): void { $httpResponse = $this->createStub(SymfonyHttpResponse::class); + $rawHttpResponse = new RawHttpResponse($httpResponse); $textResponse = new TextResponse('test content'); $responseConverter = self::createMock(ResponseConverterInterface::class); $responseConverter->expects(self::once()) ->method('convert') - ->with($httpResponse, []) + ->with($rawHttpResponse, []) ->willReturn($textResponse); - $responsePromise = new ResponsePromise($responseConverter->convert(...), new RawHttpResponse($httpResponse)); + $responsePromise = new ResponsePromise($responseConverter->convert(...), $rawHttpResponse); // Call unwrap multiple times, but the converter should only be called once $responsePromise->await(); @@ -117,6 +119,23 @@ public function itDoesNotSetRawResponseOnUnwrappedResponseWhenAlreadySet(): void self::assertSame($anotherHttpResponse, $unwrappedResponse->getRawResponse()->getRawObject()); } + #[Test] + public function itPassesOptionsToConverter(): void + { + $httpResponse = $this->createStub(SymfonyHttpResponse::class); + $rawHttpResponse = new RawHttpResponse($httpResponse); + $options = ['option1' => 'value1', 'option2' => 'value2']; + + $responseConverter = self::createMock(ResponseConverterInterface::class); + $responseConverter->expects(self::once()) + ->method('convert') + ->with($rawHttpResponse, $options) + ->willReturn($this->createResponse(null)); + + $responsePromise = new ResponsePromise($responseConverter->convert(...), $rawHttpResponse, $options); + $responsePromise->await(); + } + /** * Workaround for low deps because mocking the ResponseInterface leads to an exception with * mock creation "Type Traversable|object|array|string|null contains both object and a class type" @@ -137,20 +156,4 @@ public function getContent(): string } }; } - - #[Test] - public function itPassesOptionsToConverter(): void - { - $httpResponse = $this->createStub(SymfonyHttpResponse::class); - $options = ['option1' => 'value1', 'option2' => 'value2']; - - $responseConverter = self::createMock(ResponseConverterInterface::class); - $responseConverter->expects(self::once()) - ->method('convert') - ->with($httpResponse, $options) - ->willReturn($this->createResponse(null)); - - $responsePromise = new ResponsePromise($responseConverter->convert(...), new RawHttpResponse($httpResponse), $options); - $responsePromise->await(); - } } diff --git a/src/store/tests/Double/PlatformTestHandler.php b/src/store/tests/Double/PlatformTestHandler.php index 7c616421a..9cd08562f 100644 --- a/src/store/tests/Double/PlatformTestHandler.php +++ b/src/store/tests/Double/PlatformTestHandler.php @@ -14,13 +14,13 @@ use Symfony\AI\Platform\Model; use Symfony\AI\Platform\ModelClientInterface; use Symfony\AI\Platform\Platform; +use Symfony\AI\Platform\Response\RawHttpResponse; +use Symfony\AI\Platform\Response\RawResponseInterface; use Symfony\AI\Platform\Response\ResponseInterface; -use Symfony\AI\Platform\Response\ResponseInterface as LlmResponse; use Symfony\AI\Platform\Response\VectorResponse; use Symfony\AI\Platform\ResponseConverterInterface; use Symfony\AI\Platform\Vector\Vector; use Symfony\Component\HttpClient\Response\MockResponse; -use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; final class PlatformTestHandler implements ModelClientInterface, ResponseConverterInterface { @@ -43,14 +43,14 @@ public function supports(Model $model): bool return true; } - public function request(Model $model, array|string|object $payload, array $options = []): HttpResponse + public function request(Model $model, array|string|object $payload, array $options = []): RawHttpResponse { ++$this->createCalls; - return new MockResponse(); + return new RawHttpResponse(new MockResponse()); } - public function convert(HttpResponse $response, array $options = []): LlmResponse + public function convert(RawResponseInterface $response, array $options = []): ResponseInterface { return $this->create ?? new VectorResponse(new Vector([1, 2, 3])); }