diff --git a/examples/scaleway/chat.php b/examples/scaleway/chat.php new file mode 100644 index 000000000..fdf978251 --- /dev/null +++ b/examples/scaleway/chat.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Platform\Bridge\Scaleway\PlatformFactory; +use Symfony\AI\Platform\Bridge\Scaleway\Scaleway; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('SCALEWAY_SECRET_KEY'), http_client()); +$model = new Scaleway(Scaleway::OPENAI_OSS); + +$messages = new MessageBag( + Message::forSystem('You are a pirate and you write funny.'), + Message::ofUser('What is the Symfony framework?'), +); +$result = $platform->invoke($model, $messages); + +echo $result->asText().\PHP_EOL; diff --git a/examples/scaleway/embeddings.php b/examples/scaleway/embeddings.php new file mode 100644 index 000000000..6f474a51f --- /dev/null +++ b/examples/scaleway/embeddings.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Platform\Bridge\Scaleway\Embeddings; +use Symfony\AI\Platform\Bridge\Scaleway\PlatformFactory; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('SCALEWAY_SECRET_KEY'), http_client()); + +$result = $platform->invoke(new Embeddings(), << + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Platform\Bridge\Scaleway\PlatformFactory; +use Symfony\AI\Platform\Bridge\Scaleway\Scaleway; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('SCALEWAY_SECRET_KEY'), http_client()); +$model = new Scaleway(Scaleway::OPENAI_OSS); + +$messages = new MessageBag( + Message::forSystem('You are a pirate and you write funny.'), + Message::ofUser('What is the Symfony framework?'), +); +$result = $platform->invoke($model, $messages, ['stream' => true]); + +foreach ($result->getResult()->getContent() as $word) { + echo $word; +} +echo \PHP_EOL; diff --git a/examples/scaleway/structured-output-math.php b/examples/scaleway/structured-output-math.php new file mode 100644 index 000000000..34ec09d4b --- /dev/null +++ b/examples/scaleway/structured-output-math.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Agent\StructuredOutput\AgentProcessor; +use Symfony\AI\Fixtures\StructuredOutput\MathReasoning; +use Symfony\AI\Platform\Bridge\Scaleway\PlatformFactory; +use Symfony\AI\Platform\Bridge\Scaleway\Scaleway; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('SCALEWAY_SECRET_KEY'), http_client()); +$model = new Scaleway(Scaleway::OPENAI_OSS); + +$processor = new AgentProcessor(); +$agent = new Agent($platform, $model, [$processor], [$processor], logger: logger()); +$messages = new MessageBag( + Message::forSystem('You are a helpful math tutor. Guide the user through the solution step by step.'), + Message::ofUser('how can I solve 8x + 7 = -23'), +); +$result = $agent->call($messages, ['output_structure' => MathReasoning::class]); + +dump($result->getContent()); diff --git a/examples/scaleway/toolcall-stream.php b/examples/scaleway/toolcall-stream.php new file mode 100644 index 000000000..c3a1e3047 --- /dev/null +++ b/examples/scaleway/toolcall-stream.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Agent\Toolbox\AgentProcessor; +use Symfony\AI\Agent\Toolbox\Tool\YouTubeTranscriber; +use Symfony\AI\Agent\Toolbox\Toolbox; +use Symfony\AI\Platform\Bridge\Scaleway\PlatformFactory; +use Symfony\AI\Platform\Bridge\Scaleway\Scaleway; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('SCALEWAY_SECRET_KEY'), http_client()); +$model = new Scaleway(Scaleway::OPENAI_OSS); + +$transcriber = new YouTubeTranscriber(http_client()); +$toolbox = new Toolbox([$transcriber], logger: logger()); +$processor = new AgentProcessor($toolbox); +$agent = new Agent($platform, $model, [$processor], [$processor], logger: logger()); + +$messages = new MessageBag(Message::ofUser('Please summarize this video for me: https://www.youtube.com/watch?v=6uXW-ulpj0s')); +$result = $agent->call($messages, ['stream' => true]); + +foreach ($result->getContent() as $word) { + echo $word; +} +echo \PHP_EOL; diff --git a/examples/scaleway/toolcall.php b/examples/scaleway/toolcall.php new file mode 100644 index 000000000..9943198ad --- /dev/null +++ b/examples/scaleway/toolcall.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Agent\Agent; +use Symfony\AI\Agent\Toolbox\AgentProcessor; +use Symfony\AI\Agent\Toolbox\Tool\YouTubeTranscriber; +use Symfony\AI\Agent\Toolbox\Toolbox; +use Symfony\AI\Platform\Bridge\Scaleway\PlatformFactory; +use Symfony\AI\Platform\Bridge\Scaleway\Scaleway; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('SCALEWAY_SECRET_KEY'), http_client()); +$model = new Scaleway(Scaleway::OPENAI_OSS); + +$transcriber = new YouTubeTranscriber(http_client()); +$toolbox = new Toolbox([$transcriber], logger: logger()); +$processor = new AgentProcessor($toolbox); +$agent = new Agent($platform, $model, [$processor], [$processor], logger: logger()); + +$messages = new MessageBag(Message::ofUser('Please summarize this video for me: https://www.youtube.com/watch?v=6uXW-ulpj0s')); +$result = $agent->call($messages); + +echo $result->getContent().\PHP_EOL; diff --git a/examples/scaleway/vision.php b/examples/scaleway/vision.php new file mode 100644 index 000000000..67e9188a0 --- /dev/null +++ b/examples/scaleway/vision.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +use Symfony\AI\Platform\Bridge\Scaleway\PlatformFactory; +use Symfony\AI\Platform\Bridge\Scaleway\Scaleway; +use Symfony\AI\Platform\Message\Content\Image; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +$platform = PlatformFactory::create(env('SCALEWAY_SECRET_KEY'), http_client()); +$model = new Scaleway(Scaleway::MISTRAL_PIXTRAL); + +$messages = new MessageBag( + Message::ofUser( + 'Describe this image in 1 sentence. What is the object in the image?', + Image::fromFile(dirname(__DIR__, 2).'/fixtures/image.jpg'), + ), +); +$result = $platform->invoke($model, $messages); + +echo $result->asText().\PHP_EOL; diff --git a/src/ai-bundle/config/options.php b/src/ai-bundle/config/options.php index 795a4d49b..b17cfe0ce 100644 --- a/src/ai-bundle/config/options.php +++ b/src/ai-bundle/config/options.php @@ -165,6 +165,15 @@ ->end() ->end() ->end() + ->arrayNode('scaleway') + ->children() + ->scalarNode('api_key')->isRequired()->end() + ->stringNode('http_client') + ->defaultValue('http_client') + ->info('Service ID of the HTTP client to use') + ->end() + ->end() + ->end() ->end() ->end() ->arrayNode('agent') diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index cc656361b..c5fedac5f 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -44,6 +44,7 @@ use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory as OpenAiPlatformFactory; use Symfony\AI\Platform\Bridge\OpenRouter\PlatformFactory as OpenRouterPlatformFactory; use Symfony\AI\Platform\Bridge\Perplexity\PlatformFactory as PerplexityPlatformFactory; +use Symfony\AI\Platform\Bridge\Scaleway\PlatformFactory as ScalewayPlatformFactory; use Symfony\AI\Platform\Bridge\VertexAi\PlatformFactory as VertexAiPlatformFactory; use Symfony\AI\Platform\Bridge\Voyage\PlatformFactory as VoyagePlatformFactory; use Symfony\AI\Platform\Exception\RuntimeException; @@ -492,6 +493,23 @@ private function processPlatformConfig(string $type, array $platform, ContainerB return; } + if ('scaleway' === $type && isset($platform['api_key'])) { + $platformId = 'ai.platform.scaleway'; + $definition = (new Definition(Platform::class)) + ->setFactory(ScalewayPlatformFactory::class.'::create') + ->setLazy(true) + ->addTag('proxy', ['interface' => PlatformInterface::class]) + ->setArguments([ + $platform['api_key'], + new Reference('http_client', ContainerInterface::NULL_ON_INVALID_REFERENCE), + ]) + ->addTag('ai.platform'); + + $container->setDefinition($platformId, $definition); + + return; + } + throw new InvalidArgumentException(\sprintf('Platform "%s" is not supported for configuration via bundle at this point.', $type)); } diff --git a/src/platform/CHANGELOG.md b/src/platform/CHANGELOG.md index 352a911d2..fc194a602 100644 --- a/src/platform/CHANGELOG.md +++ b/src/platform/CHANGELOG.md @@ -25,6 +25,7 @@ CHANGELOG - Perplexity (Sonar models, supporting search results) - AI/ML API (language models and embeddings) - Docker Model Runner (local model hosting) + - Scaleway (language models like OpenAI OSS, Llama 4, Qwen 3, and more) * Add comprehensive message system with role-based messaging: - `UserMessage` for user inputs with multi-modal content - `SystemMessage` for system instructions diff --git a/src/platform/src/Bridge/Scaleway/Embeddings.php b/src/platform/src/Bridge/Scaleway/Embeddings.php new file mode 100644 index 000000000..bf66e5c93 --- /dev/null +++ b/src/platform/src/Bridge/Scaleway/Embeddings.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Scaleway; + +use Symfony\AI\Platform\Model; + +/** + * @author Marcus Stöhr + */ +final class Embeddings extends Model +{ + public const BAAI_BGE = 'bge-multilingual-gemma2'; + + /** + * @param array $options + */ + public function __construct(string $name = self::BAAI_BGE, array $options = []) + { + parent::__construct($name, [], $options); + } +} diff --git a/src/platform/src/Bridge/Scaleway/Embeddings/ModelClient.php b/src/platform/src/Bridge/Scaleway/Embeddings/ModelClient.php new file mode 100644 index 000000000..1338300eb --- /dev/null +++ b/src/platform/src/Bridge/Scaleway/Embeddings/ModelClient.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Scaleway\Embeddings; + +use Symfony\AI\Platform\Bridge\Scaleway\Embeddings; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface as PlatformResponseFactory; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Marcus Stöhr + */ +final readonly class ModelClient implements PlatformResponseFactory +{ + public function __construct( + private HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + ) { + if ('' === $apiKey) { + throw new InvalidArgumentException('The API key must not be empty.'); + } + } + + public function supports(Model $model): bool + { + return $model instanceof Embeddings; + } + + public function request(Model $model, array|string $payload, array $options = []): RawHttpResult + { + return new RawHttpResult($this->httpClient->request('POST', 'https://api.scaleway.ai/v1/embeddings', [ + 'auth_bearer' => $this->apiKey, + 'json' => array_merge($options, [ + 'model' => $model->getName(), + 'input' => $payload, + ]), + ])); + } +} diff --git a/src/platform/src/Bridge/Scaleway/Embeddings/ResultConverter.php b/src/platform/src/Bridge/Scaleway/Embeddings/ResultConverter.php new file mode 100644 index 000000000..6a919afe4 --- /dev/null +++ b/src/platform/src/Bridge/Scaleway/Embeddings/ResultConverter.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Scaleway\Embeddings; + +use Symfony\AI\Platform\Bridge\Scaleway\Embeddings; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\AI\Platform\Result\RawResultInterface; +use Symfony\AI\Platform\Result\VectorResult; +use Symfony\AI\Platform\ResultConverterInterface; +use Symfony\AI\Platform\Vector\Vector; + +/** + * @author Marcus Stöhr + */ +final readonly class ResultConverter implements ResultConverterInterface +{ + public function supports(Model $model): bool + { + return $model instanceof Embeddings; + } + + public function convert(RawResultInterface $result, array $options = []): VectorResult + { + $data = $result->getData(); + + if (!isset($data['data'])) { + if ($result instanceof RawHttpResult) { + throw new RuntimeException(\sprintf('Response from Scaleway API does not contain "data" key. StatusCode: "%s". Response: "%s".', $result->getObject()->getStatusCode(), json_encode($result->getData(), \JSON_THROW_ON_ERROR))); + } + + throw new RuntimeException('Response does not contain data.'); + } + + return new VectorResult( + ...array_map( + static fn (array $item): Vector => new Vector($item['embedding']), + $data['data'], + ), + ); + } +} diff --git a/src/platform/src/Bridge/Scaleway/Llm/ModelClient.php b/src/platform/src/Bridge/Scaleway/Llm/ModelClient.php new file mode 100644 index 000000000..c18f15711 --- /dev/null +++ b/src/platform/src/Bridge/Scaleway/Llm/ModelClient.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Scaleway\Llm; + +use Symfony\AI\Platform\Bridge\Scaleway\Scaleway; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\ModelClientInterface; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Marcus Stöhr + */ +final readonly class ModelClient implements ModelClientInterface +{ + private EventSourceHttpClient $httpClient; + + public function __construct( + HttpClientInterface $httpClient, + #[\SensitiveParameter] private string $apiKey, + ) { + $this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + } + + public function supports(Model $model): bool + { + return $model instanceof Scaleway; + } + + public function request(Model $model, array|string $payload, array $options = []): RawHttpResult + { + return new RawHttpResult($this->httpClient->request('POST', 'https://api.scaleway.ai/v1/chat/completions', [ + 'auth_bearer' => $this->apiKey, + 'json' => array_merge($options, $payload), + ])); + } +} diff --git a/src/platform/src/Bridge/Scaleway/Llm/ResultConverter.php b/src/platform/src/Bridge/Scaleway/Llm/ResultConverter.php new file mode 100644 index 000000000..fc378a2e8 --- /dev/null +++ b/src/platform/src/Bridge/Scaleway/Llm/ResultConverter.php @@ -0,0 +1,187 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Scaleway\Llm; + +use Symfony\AI\Platform\Bridge\Scaleway\Scaleway; +use Symfony\AI\Platform\Exception\ContentFilterException; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Model; +use Symfony\AI\Platform\Result\ChoiceResult; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\AI\Platform\Result\RawResultInterface; +use Symfony\AI\Platform\Result\ResultInterface; +use Symfony\AI\Platform\Result\StreamResult; +use Symfony\AI\Platform\Result\TextResult; +use Symfony\AI\Platform\Result\ToolCall; +use Symfony\AI\Platform\Result\ToolCallResult; +use Symfony\AI\Platform\ResultConverterInterface as PlatformResultConverter; +use Symfony\Component\HttpClient\Chunk\ServerSentEvent; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Component\HttpClient\Exception\JsonException; +use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; + +/** + * @author Marcus Stöhr + */ +final class ResultConverter implements PlatformResultConverter +{ + public function supports(Model $model): bool + { + return $model instanceof Scaleway; + } + + public function convert(RawResultInterface|RawHttpResult $result, array $options = []): ResultInterface + { + if ($options['stream'] ?? false) { + return new StreamResult($this->convertStream($result->getObject())); + } + $data = $result->getData(); + + if (isset($data['error']['code']) && 'content_filter' === $data['error']['code']) { + throw new ContentFilterException($data['error']['message']); + } + + if (!isset($data['choices'])) { + throw new RuntimeException('Result does not contain choices.'); + } + + $choices = array_map($this->convertChoice(...), $data['choices']); + + return 1 === \count($choices) ? $choices[0] : new ChoiceResult(...$choices); + } + + private function convertStream(HttpResponse $result): \Generator + { + $toolCalls = []; + foreach ((new EventSourceHttpClient())->stream($result) as $chunk) { + if (!$chunk instanceof ServerSentEvent || '[DONE]' === $chunk->getData()) { + continue; + } + + try { + $data = $chunk->getArrayData(); + } catch (JsonException) { + // try catch only needed for Symfony 6.4 + continue; + } + + if ($this->streamIsToolCall($data)) { + $toolCalls = $this->convertStreamToToolCalls($toolCalls, $data); + } + + if ([] !== $toolCalls && $this->isToolCallsStreamFinished($data)) { + yield new ToolCallResult(...array_map($this->convertToolCall(...), $toolCalls)); + } + + if (!isset($data['choices'][0]['delta']['content'])) { + continue; + } + + yield $data['choices'][0]['delta']['content']; + } + } + + /** + * @param array $toolCalls + * @param array $data + * + * @return array + */ + private function convertStreamToToolCalls(array $toolCalls, array $data): array + { + if (!isset($data['choices'][0]['delta']['tool_calls'])) { + return $toolCalls; + } + + foreach ($data['choices'][0]['delta']['tool_calls'] as $i => $toolCall) { + if (isset($toolCall['id'])) { + // initialize tool call + $toolCalls[$i] = [ + 'id' => $toolCall['id'], + 'function' => $toolCall['function'], + ]; + continue; + } + + // add arguments delta to tool call + $toolCalls[$i]['function']['arguments'] .= $toolCall['function']['arguments']; + } + + return $toolCalls; + } + + /** + * @param array $data + */ + private function streamIsToolCall(array $data): bool + { + return isset($data['choices'][0]['delta']['tool_calls']); + } + + /** + * @param array $data + */ + private function isToolCallsStreamFinished(array $data): bool + { + return isset($data['choices'][0]['finish_reason']) && 'tool_calls' === $data['choices'][0]['finish_reason']; + } + + /** + * @param array{ + * index: int, + * message: array{ + * role: 'assistant', + * content: ?string, + * tool_calls: array{ + * id: string, + * type: 'function', + * function: array{ + * name: string, + * arguments: string + * }, + * }, + * refusal: ?mixed + * }, + * logprobs: string, + * finish_reason: 'stop'|'length'|'tool_calls'|'content_filter', + * } $choice + */ + private function convertChoice(array $choice): ToolCallResult|TextResult + { + if ('tool_calls' === $choice['finish_reason']) { + return new ToolCallResult(...array_map([$this, 'convertToolCall'], $choice['message']['tool_calls'])); + } + + if (\in_array($choice['finish_reason'], ['stop', 'length'], true)) { + return new TextResult($choice['message']['content']); + } + + throw new RuntimeException(\sprintf('Unsupported finish reason "%s".', $choice['finish_reason'])); + } + + /** + * @param array{ + * id: string, + * type: 'function', + * function: array{ + * name: string, + * arguments: string + * } + * } $toolCall + */ + private function convertToolCall(array $toolCall): ToolCall + { + $arguments = json_decode($toolCall['function']['arguments'], true, \JSON_THROW_ON_ERROR); + + return new ToolCall($toolCall['id'], $toolCall['function']['name'], $arguments); + } +} diff --git a/src/platform/src/Bridge/Scaleway/PlatformFactory.php b/src/platform/src/Bridge/Scaleway/PlatformFactory.php new file mode 100644 index 000000000..15edfa255 --- /dev/null +++ b/src/platform/src/Bridge/Scaleway/PlatformFactory.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Scaleway; + +use Symfony\AI\Platform\Bridge\Scaleway\Embeddings\ModelClient as ScalewayEmbeddingsModelClient; +use Symfony\AI\Platform\Bridge\Scaleway\Embeddings\ResultConverter as ScalewayEmbeddingsResponseConverter; +use Symfony\AI\Platform\Bridge\Scaleway\Llm\ModelClient as ScalewayModelClient; +use Symfony\AI\Platform\Bridge\Scaleway\Llm\ResultConverter as ScalewayResponseConverter; +use Symfony\AI\Platform\Contract; +use Symfony\AI\Platform\Platform; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Contracts\HttpClient\HttpClientInterface; + +/** + * @author Marcus Stöhr + */ +final class PlatformFactory +{ + public static function create( + #[\SensitiveParameter] string $apiKey, + ?HttpClientInterface $httpClient = null, + ?Contract $contract = null, + ): Platform { + $httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient); + + return new Platform( + [ + new ScalewayModelClient($httpClient, $apiKey), + new ScalewayEmbeddingsModelClient($httpClient, $apiKey), + ], + [ + new ScalewayResponseConverter(), + new ScalewayEmbeddingsResponseConverter(), + ], + $contract, + ); + } +} diff --git a/src/platform/src/Bridge/Scaleway/Scaleway.php b/src/platform/src/Bridge/Scaleway/Scaleway.php new file mode 100644 index 000000000..42f9f3050 --- /dev/null +++ b/src/platform/src/Bridge/Scaleway/Scaleway.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Platform\Bridge\Scaleway; + +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Model; + +/** + * @author Marcus Stöhr + */ +final class Scaleway extends Model +{ + public const DEEPSEEK = 'deepseek-r1-distill-llama-70b'; + public const GOOGLE_GEMMA = 'gemma-3-27b-it'; + public const META_LLAMA_8B = 'llama-3.1-8b-instruct'; + public const META_LLAMA_70B = 'llama-3.3-70b-instruct'; + public const MISTRAL_DEVSTRAL = 'devstral-small-2505'; + public const MISTRAL_NEMO = 'mistral-nemo-instruct-2407'; + public const MISTRAL_PIXTRAL = 'pixtral-12b-2409'; + public const MISTRAL_SMALL = 'mistral-small-3.2-24b-instruct-2506'; + public const OPENAI_OSS = 'gpt-oss-120b'; + public const QWEN_CODE = 'qwen3-coder-30b-a3b-instruct'; + public const QWEN_INSTRUCT = 'qwen3-235b-a22b-instruct-2507'; + + /** + * @param array $options + */ + public function __construct( + string $name, + array $options = [], + ) { + $capabilities = [ + Capability::INPUT_IMAGE, + Capability::INPUT_MESSAGES, + Capability::OUTPUT_STREAMING, + Capability::OUTPUT_STRUCTURED, + Capability::OUTPUT_TEXT, + Capability::TOOL_CALLING, + ]; + + parent::__construct($name, $capabilities, $options); + } +} diff --git a/src/platform/tests/Bridge/Scaleway/Embeddings/ModelClientTest.php b/src/platform/tests/Bridge/Scaleway/Embeddings/ModelClientTest.php new file mode 100644 index 000000000..0d093c206 --- /dev/null +++ b/src/platform/tests/Bridge/Scaleway/Embeddings/ModelClientTest.php @@ -0,0 +1,93 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Bridge\Scaleway\Embeddings; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Scaleway\Embeddings; +use Symfony\AI\Platform\Bridge\Scaleway\Embeddings\ModelClient; +use Symfony\AI\Platform\Exception\InvalidArgumentException; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; + +/** + * @author Marcus Stöhr + */ +final class ModelClientTest extends TestCase +{ + public function testItThrowsExceptionWhenApiKeyIsEmpty() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The API key must not be empty.'); + + new ModelClient(new MockHttpClient(), ''); + } + + public function testItAcceptsValidApiKey() + { + $modelClient = new ModelClient(new MockHttpClient(), 'scaleway-valid-api-key'); + + $this->assertInstanceOf(ModelClient::class, $modelClient); + } + + public function testItIsSupportingTheCorrectModel() + { + $modelClient = new ModelClient(new MockHttpClient(), 'scaleway-api-key'); + + $this->assertTrue($modelClient->supports(new Embeddings(Embeddings::BAAI_BGE))); + } + + public function testItIsExecutingTheCorrectRequest() + { + $resultCallback = static function (string $method, string $url, array $options): HttpResponse { + self::assertSame('POST', $method); + self::assertSame('https://api.scaleway.ai/v1/embeddings', $url); + self::assertSame('Authorization: Bearer scaleway-api-key', $options['normalized_headers']['authorization'][0]); + self::assertSame('{"model":"bge-multilingual-gemma2","input":"test text"}', $options['body']); + + return new MockResponse(); + }; + $httpClient = new MockHttpClient([$resultCallback]); + $modelClient = new ModelClient($httpClient, 'scaleway-api-key'); + $modelClient->request(new Embeddings(Embeddings::BAAI_BGE), 'test text', []); + } + + public function testItIsExecutingTheCorrectRequestWithCustomOptions() + { + $resultCallback = static function (string $method, string $url, array $options): HttpResponse { + self::assertSame('POST', $method); + self::assertSame('https://api.scaleway.ai/v1/embeddings', $url); + self::assertSame('Authorization: Bearer scaleway-api-key', $options['normalized_headers']['authorization'][0]); + self::assertSame('{"dimensions":256,"model":"bge-multilingual-gemma2","input":"test text"}', $options['body']); + + return new MockResponse(); + }; + $httpClient = new MockHttpClient([$resultCallback]); + $modelClient = new ModelClient($httpClient, 'scaleway-api-key'); + $modelClient->request(new Embeddings(Embeddings::BAAI_BGE), 'test text', ['dimensions' => 256]); + } + + public function testItIsExecutingTheCorrectRequestWithArrayInput() + { + $resultCallback = static function (string $method, string $url, array $options): HttpResponse { + self::assertSame('POST', $method); + self::assertSame('https://api.scaleway.ai/v1/embeddings', $url); + self::assertSame('Authorization: Bearer scaleway-api-key', $options['normalized_headers']['authorization'][0]); + self::assertSame('{"model":"bge-multilingual-gemma2","input":["text1","text2","text3"]}', $options['body']); + + return new MockResponse(); + }; + $httpClient = new MockHttpClient([$resultCallback]); + $modelClient = new ModelClient($httpClient, 'scaleway-api-key'); + $modelClient->request(new Embeddings(Embeddings::BAAI_BGE), ['text1', 'text2', 'text3'], []); + } +} diff --git a/src/platform/tests/Bridge/Scaleway/Embeddings/ResultConverterTest.php b/src/platform/tests/Bridge/Scaleway/Embeddings/ResultConverterTest.php new file mode 100644 index 000000000..67a91ed89 --- /dev/null +++ b/src/platform/tests/Bridge/Scaleway/Embeddings/ResultConverterTest.php @@ -0,0 +1,60 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Bridge\Scaleway\Embeddings; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Scaleway\Embeddings\ResultConverter; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Marcus Stöhr + */ +final class ResultConverterTest extends TestCase +{ + public function testItConvertsAResponseToAVectorResult() + { + $result = $this->createStub(ResponseInterface::class); + $result + ->method('toArray') + ->willReturn(json_decode($this->getEmbeddingStub(), true)); + + $vectorResult = (new ResultConverter())->convert(new RawHttpResult($result)); + $convertedContent = $vectorResult->getContent(); + + $this->assertCount(2, $convertedContent); + + $this->assertSame([0.3, 0.4, 0.4], $convertedContent[0]->getData()); + $this->assertSame([0.0, 0.0, 0.2], $convertedContent[1]->getData()); + } + + private function getEmbeddingStub(): string + { + return <<<'JSON' + { + "object": "list", + "data": [ + { + "object": "embedding", + "index": 0, + "embedding": [0.3, 0.4, 0.4] + }, + { + "object": "embedding", + "index": 1, + "embedding": [0.0, 0.0, 0.2] + } + ] + } + JSON; + } +} diff --git a/src/platform/tests/Bridge/Scaleway/EmbeddingsTest.php b/src/platform/tests/Bridge/Scaleway/EmbeddingsTest.php new file mode 100644 index 000000000..a5cf3bfb1 --- /dev/null +++ b/src/platform/tests/Bridge/Scaleway/EmbeddingsTest.php @@ -0,0 +1,29 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Bridge\Scaleway; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Scaleway\Embeddings; + +/** + * @author Marcus Stöhr + */ +final class EmbeddingsTest extends TestCase +{ + public function testItCreatesEmbeddingsWithDefaultSettings() + { + $embeddings = new Embeddings(Embeddings::BAAI_BGE); + + $this->assertSame(Embeddings::BAAI_BGE, $embeddings->getName()); + $this->assertSame([], $embeddings->getOptions()); + } +} diff --git a/src/platform/tests/Bridge/Scaleway/Llm/ModelClientTest.php b/src/platform/tests/Bridge/Scaleway/Llm/ModelClientTest.php new file mode 100644 index 000000000..94ae24321 --- /dev/null +++ b/src/platform/tests/Bridge/Scaleway/Llm/ModelClientTest.php @@ -0,0 +1,100 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Bridge\Scaleway\Llm; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Scaleway\Llm\ModelClient; +use Symfony\AI\Platform\Bridge\Scaleway\Scaleway; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Component\HttpClient\MockHttpClient; +use Symfony\Component\HttpClient\Response\MockResponse; +use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse; + +/** + * @author Marcus Stöhr + */ +final class ModelClientTest extends TestCase +{ + public function testItAcceptsValidApiKey() + { + $modelClient = new ModelClient(new MockHttpClient(), 'scaleway-valid-api-key'); + + $this->assertInstanceOf(ModelClient::class, $modelClient); + } + + public function testItWrapsHttpClientInEventSourceHttpClient() + { + $httpClient = new MockHttpClient(); + $modelClient = new ModelClient($httpClient, 'scaleway-valid-api-key'); + + $this->assertInstanceOf(ModelClient::class, $modelClient); + } + + public function testItAcceptsEventSourceHttpClientDirectly() + { + $httpClient = new EventSourceHttpClient(new MockHttpClient()); + $modelClient = new ModelClient($httpClient, 'scaleway-valid-api-key'); + + $this->assertInstanceOf(ModelClient::class, $modelClient); + } + + public function testItIsSupportingTheCorrectModel() + { + $modelClient = new ModelClient(new MockHttpClient(), 'sk-api-key'); + + $this->assertTrue($modelClient->supports(new Scaleway(Scaleway::DEEPSEEK))); + } + + public function testItIsExecutingTheCorrectRequest() + { + $resultCallback = static function (string $method, string $url, array $options): HttpResponse { + self::assertSame('POST', $method); + self::assertSame('https://api.scaleway.ai/v1/chat/completions', $url); + self::assertSame('Authorization: Bearer scaleway-api-key', $options['normalized_headers']['authorization'][0]); + self::assertSame('{"temperature":1,"model":"deepseek-r1-distill-llama-70b","messages":[{"role":"user","content":"test message"}]}', $options['body']); + + return new MockResponse(); + }; + $httpClient = new MockHttpClient([$resultCallback]); + $modelClient = new ModelClient($httpClient, 'scaleway-api-key'); + $modelClient->request(new Scaleway(Scaleway::DEEPSEEK), ['model' => 'deepseek-r1-distill-llama-70b', 'messages' => [['role' => 'user', 'content' => 'test message']]], ['temperature' => 1]); + } + + public function testItIsExecutingTheCorrectRequestWithArrayPayload() + { + $resultCallback = static function (string $method, string $url, array $options): HttpResponse { + self::assertSame('POST', $method); + self::assertSame('https://api.scaleway.ai/v1/chat/completions', $url); + self::assertSame('Authorization: Bearer scaleway-api-key', $options['normalized_headers']['authorization'][0]); + self::assertSame('{"temperature":0.7,"model":"deepseek-r1-distill-llama-70b","messages":[{"role":"user","content":"Hello"}]}', $options['body']); + + return new MockResponse(); + }; + $httpClient = new MockHttpClient([$resultCallback]); + $modelClient = new ModelClient($httpClient, 'scaleway-api-key'); + $modelClient->request(new Scaleway(Scaleway::DEEPSEEK), ['model' => 'deepseek-r1-distill-llama-70b', 'messages' => [['role' => 'user', 'content' => 'Hello']]], ['temperature' => 0.7]); + } + + public function testItUsesCorrectBaseUrl() + { + $resultCallback = static function (string $method, string $url, array $options): HttpResponse { + self::assertSame('POST', $method); + self::assertSame('https://api.scaleway.ai/v1/chat/completions', $url); + self::assertSame('Authorization: Bearer scaleway-api-key', $options['normalized_headers']['authorization'][0]); + + return new MockResponse(); + }; + $httpClient = new MockHttpClient([$resultCallback]); + $modelClient = new ModelClient($httpClient, 'scaleway-api-key'); + $modelClient->request(new Scaleway(Scaleway::DEEPSEEK), ['messages' => []], []); + } +} diff --git a/src/platform/tests/Bridge/Scaleway/Llm/ResultConverterTest.php b/src/platform/tests/Bridge/Scaleway/Llm/ResultConverterTest.php new file mode 100644 index 000000000..4b484ed80 --- /dev/null +++ b/src/platform/tests/Bridge/Scaleway/Llm/ResultConverterTest.php @@ -0,0 +1,184 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Bridge\Scaleway\Llm; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Scaleway\Llm\ResultConverter; +use Symfony\AI\Platform\Exception\ContentFilterException; +use Symfony\AI\Platform\Exception\RuntimeException; +use Symfony\AI\Platform\Result\ChoiceResult; +use Symfony\AI\Platform\Result\RawHttpResult; +use Symfony\AI\Platform\Result\TextResult; +use Symfony\AI\Platform\Result\ToolCallResult; +use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface; +use Symfony\Contracts\HttpClient\ResponseInterface; + +/** + * @author Marcus Stöhr + */ +final class ResultConverterTest extends TestCase +{ + public function testConvertTextResult() + { + $converter = new ResultConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('toArray')->willReturn([ + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Hello world', + ], + 'finish_reason' => 'stop', + ], + ], + ]); + + $result = $converter->convert(new RawHttpResult($httpResponse)); + + $this->assertInstanceOf(TextResult::class, $result); + $this->assertSame('Hello world', $result->getContent()); + } + + public function testConvertToolCallResult() + { + $converter = new ResultConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('toArray')->willReturn([ + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => null, + 'tool_calls' => [ + [ + 'id' => 'call_123', + 'type' => 'function', + 'function' => [ + 'name' => 'test_function', + 'arguments' => '{"arg1": "value1"}', + ], + ], + ], + ], + 'finish_reason' => 'tool_calls', + ], + ], + ]); + + $result = $converter->convert(new RawHttpResult($httpResponse)); + + $this->assertInstanceOf(ToolCallResult::class, $result); + $toolCalls = $result->getContent(); + $this->assertCount(1, $toolCalls); + $this->assertSame('call_123', $toolCalls[0]->id); + $this->assertSame('test_function', $toolCalls[0]->name); + $this->assertSame(['arg1' => 'value1'], $toolCalls[0]->arguments); + } + + public function testConvertMultipleChoices() + { + $converter = new ResultConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('toArray')->willReturn([ + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Choice 1', + ], + 'finish_reason' => 'stop', + ], + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Choice 2', + ], + 'finish_reason' => 'stop', + ], + ], + ]); + + $result = $converter->convert(new RawHttpResult($httpResponse)); + + $this->assertInstanceOf(ChoiceResult::class, $result); + $choices = $result->getContent(); + $this->assertCount(2, $choices); + $this->assertSame('Choice 1', $choices[0]->getContent()); + $this->assertSame('Choice 2', $choices[1]->getContent()); + } + + public function testContentFilterException() + { + $converter = new ResultConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + + $httpResponse->expects($this->exactly(1)) + ->method('toArray') + ->willReturnCallback(function ($throw = true) { + if ($throw) { + throw new class extends \Exception implements ClientExceptionInterface { + public function getResponse(): ResponseInterface + { + throw new RuntimeException('Not implemented'); + } + }; + } + + return [ + 'error' => [ + 'code' => 'content_filter', + 'message' => 'Content was filtered', + ], + ]; + }); + + $this->expectException(ContentFilterException::class); + $this->expectExceptionMessage('Content was filtered'); + + $converter->convert(new RawHttpResult($httpResponse)); + } + + public function testThrowsExceptionWhenNoChoices() + { + $converter = new ResultConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('toArray')->willReturn([]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Result does not contain choices'); + + $converter->convert(new RawHttpResult($httpResponse)); + } + + public function testThrowsExceptionForUnsupportedFinishReason() + { + $converter = new ResultConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('toArray')->willReturn([ + 'choices' => [ + [ + 'message' => [ + 'role' => 'assistant', + 'content' => 'Test content', + ], + 'finish_reason' => 'unsupported_reason', + ], + ], + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unsupported finish reason "unsupported_reason"'); + + $converter->convert(new RawHttpResult($httpResponse)); + } +} diff --git a/src/platform/tests/Bridge/Scaleway/PlatformFactoryTest.php b/src/platform/tests/Bridge/Scaleway/PlatformFactoryTest.php new file mode 100644 index 000000000..c14767fcc --- /dev/null +++ b/src/platform/tests/Bridge/Scaleway/PlatformFactoryTest.php @@ -0,0 +1,47 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Bridge\Scaleway; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Platform\Bridge\Scaleway\PlatformFactory; +use Symfony\AI\Platform\Platform; +use Symfony\Component\HttpClient\EventSourceHttpClient; +use Symfony\Component\HttpClient\MockHttpClient; + +/** + * @author Marcus Stöhr + */ +final class PlatformFactoryTest extends TestCase +{ + public function testItCreatesPlatformWithDefaultSettings() + { + $platform = PlatformFactory::create('scaleway-test-api-key'); + + $this->assertInstanceOf(Platform::class, $platform); + } + + public function testItCreatesPlatformWithCustomHttpClient() + { + $httpClient = new MockHttpClient(); + $platform = PlatformFactory::create('scaleway-test-api-key', $httpClient); + + $this->assertInstanceOf(Platform::class, $platform); + } + + public function testItCreatesPlatformWithEventSourceHttpClient() + { + $httpClient = new EventSourceHttpClient(new MockHttpClient()); + $platform = PlatformFactory::create('scaleway-test-api-key', $httpClient); + + $this->assertInstanceOf(Platform::class, $platform); + } +}