Skip to content

Commit ffbdb00

Browse files
committed
feat(platform): add scaleway platform
1 parent 17450c7 commit ffbdb00

14 files changed

+979
-0
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ CHANGELOG
2525
- Perplexity (Sonar models, supporting search results)
2626
- AI/ML API (language models and embeddings)
2727
- Docker Model Runner (local model hosting)
28+
- Scaleway (language models like OpenAI OSS, Llama 4, Qwen 3, and more)
2829
* Add comprehensive message system with role-based messaging:
2930
- `UserMessage` for user inputs with multi-modal content
3031
- `SystemMessage` for system instructions

src/Bridge/Scaleway/Embeddings.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Platform\Bridge\Scaleway;
13+
14+
use Symfony\AI\Platform\Model;
15+
16+
/**
17+
* @author Marcus Stöhr <[email protected]>
18+
*/
19+
final class Embeddings extends Model
20+
{
21+
public const BAAI_BGE = 'bge-multilingual-gemma2';
22+
23+
/**
24+
* @param array<string, mixed> $options
25+
*/
26+
public function __construct(string $name = self::BAAI_BGE, array $options = [])
27+
{
28+
parent::__construct($name, [], $options);
29+
}
30+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Platform\Bridge\Scaleway\Embeddings;
13+
14+
use Symfony\AI\Platform\Bridge\Scaleway\Embeddings;
15+
use Symfony\AI\Platform\Exception\InvalidArgumentException;
16+
use Symfony\AI\Platform\Model;
17+
use Symfony\AI\Platform\ModelClientInterface as PlatformResponseFactory;
18+
use Symfony\AI\Platform\Result\RawHttpResult;
19+
use Symfony\Contracts\HttpClient\HttpClientInterface;
20+
21+
/**
22+
* @author Marcus Stöhr <[email protected]>
23+
*/
24+
final readonly class ModelClient implements PlatformResponseFactory
25+
{
26+
public function __construct(
27+
private HttpClientInterface $httpClient,
28+
#[\SensitiveParameter] private string $apiKey,
29+
) {
30+
if ('' === $apiKey) {
31+
throw new InvalidArgumentException('The API key must not be empty.');
32+
}
33+
}
34+
35+
public function supports(Model $model): bool
36+
{
37+
return $model instanceof Embeddings;
38+
}
39+
40+
public function request(Model $model, array|string $payload, array $options = []): RawHttpResult
41+
{
42+
return new RawHttpResult($this->httpClient->request('POST', 'https://api.scaleway.ai/v1/embeddings', [
43+
'auth_bearer' => $this->apiKey,
44+
'json' => array_merge($options, [
45+
'model' => $model->getName(),
46+
'input' => $payload,
47+
]),
48+
]));
49+
}
50+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Platform\Bridge\Scaleway\Embeddings;
13+
14+
use Symfony\AI\Platform\Bridge\Scaleway\Embeddings;
15+
use Symfony\AI\Platform\Exception\RuntimeException;
16+
use Symfony\AI\Platform\Model;
17+
use Symfony\AI\Platform\Result\RawHttpResult;
18+
use Symfony\AI\Platform\Result\RawResultInterface;
19+
use Symfony\AI\Platform\Result\VectorResult;
20+
use Symfony\AI\Platform\ResultConverterInterface;
21+
use Symfony\AI\Platform\Vector\Vector;
22+
23+
/**
24+
* @author Marcus Stöhr <[email protected]>
25+
*/
26+
final readonly class ResultConverter implements ResultConverterInterface
27+
{
28+
public function supports(Model $model): bool
29+
{
30+
return $model instanceof Embeddings;
31+
}
32+
33+
public function convert(RawResultInterface $result, array $options = []): VectorResult
34+
{
35+
$data = $result->getData();
36+
37+
if (!isset($data['data'])) {
38+
if ($result instanceof RawHttpResult) {
39+
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)));
40+
}
41+
42+
throw new RuntimeException('Response does not contain data.');
43+
}
44+
45+
return new VectorResult(
46+
...array_map(
47+
static fn (array $item): Vector => new Vector($item['embedding']),
48+
$data['data'],
49+
),
50+
);
51+
}
52+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Platform\Bridge\Scaleway\Llm;
13+
14+
use Symfony\AI\Platform\Bridge\Scaleway\Scaleway;
15+
use Symfony\AI\Platform\Model;
16+
use Symfony\AI\Platform\ModelClientInterface;
17+
use Symfony\AI\Platform\Result\RawHttpResult;
18+
use Symfony\Component\HttpClient\EventSourceHttpClient;
19+
use Symfony\Contracts\HttpClient\HttpClientInterface;
20+
21+
/**
22+
* @author Marcus Stöhr <[email protected]>
23+
*/
24+
final readonly class ModelClient implements ModelClientInterface
25+
{
26+
private EventSourceHttpClient $httpClient;
27+
28+
public function __construct(
29+
HttpClientInterface $httpClient,
30+
#[\SensitiveParameter] private string $apiKey,
31+
) {
32+
$this->httpClient = $httpClient instanceof EventSourceHttpClient ? $httpClient : new EventSourceHttpClient($httpClient);
33+
}
34+
35+
public function supports(Model $model): bool
36+
{
37+
return $model instanceof Scaleway;
38+
}
39+
40+
public function request(Model $model, array|string $payload, array $options = []): RawHttpResult
41+
{
42+
return new RawHttpResult($this->httpClient->request('POST', 'https://api.scaleway.ai/v1/chat/completions', [
43+
'auth_bearer' => $this->apiKey,
44+
'json' => array_merge($options, $payload),
45+
]));
46+
}
47+
}
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\AI\Platform\Bridge\Scaleway\Llm;
13+
14+
use Symfony\AI\Platform\Bridge\Scaleway\Scaleway;
15+
use Symfony\AI\Platform\Exception\ContentFilterException;
16+
use Symfony\AI\Platform\Exception\RuntimeException;
17+
use Symfony\AI\Platform\Model;
18+
use Symfony\AI\Platform\Result\ChoiceResult;
19+
use Symfony\AI\Platform\Result\RawHttpResult;
20+
use Symfony\AI\Platform\Result\RawResultInterface;
21+
use Symfony\AI\Platform\Result\ResultInterface;
22+
use Symfony\AI\Platform\Result\StreamResult;
23+
use Symfony\AI\Platform\Result\TextResult;
24+
use Symfony\AI\Platform\Result\ToolCall;
25+
use Symfony\AI\Platform\Result\ToolCallResult;
26+
use Symfony\AI\Platform\ResultConverterInterface as PlatformResultConverter;
27+
use Symfony\Component\HttpClient\Chunk\ServerSentEvent;
28+
use Symfony\Component\HttpClient\EventSourceHttpClient;
29+
use Symfony\Component\HttpClient\Exception\JsonException;
30+
use Symfony\Contracts\HttpClient\ResponseInterface as HttpResponse;
31+
32+
/**
33+
* @author Marcus Stöhr <[email protected]>
34+
*/
35+
final class ResultConverter implements PlatformResultConverter
36+
{
37+
public function supports(Model $model): bool
38+
{
39+
return $model instanceof Scaleway;
40+
}
41+
42+
public function convert(RawResultInterface|RawHttpResult $result, array $options = []): ResultInterface
43+
{
44+
if ($options['stream'] ?? false) {
45+
return new StreamResult($this->convertStream($result->getObject()));
46+
}
47+
$data = $result->getData();
48+
49+
if (isset($data['error']['code']) && 'content_filter' === $data['error']['code']) {
50+
throw new ContentFilterException($data['error']['message']);
51+
}
52+
53+
if (!isset($data['choices'])) {
54+
throw new RuntimeException('Result does not contain choices.');
55+
}
56+
57+
$choices = array_map($this->convertChoice(...), $data['choices']);
58+
59+
return 1 === \count($choices) ? $choices[0] : new ChoiceResult(...$choices);
60+
}
61+
62+
private function convertStream(HttpResponse $result): \Generator
63+
{
64+
$toolCalls = [];
65+
foreach ((new EventSourceHttpClient())->stream($result) as $chunk) {
66+
if (!$chunk instanceof ServerSentEvent || '[DONE]' === $chunk->getData()) {
67+
continue;
68+
}
69+
70+
try {
71+
$data = $chunk->getArrayData();
72+
} catch (JsonException) {
73+
// try catch only needed for Symfony 6.4
74+
continue;
75+
}
76+
77+
if ($this->streamIsToolCall($data)) {
78+
$toolCalls = $this->convertStreamToToolCalls($toolCalls, $data);
79+
}
80+
81+
if ([] !== $toolCalls && $this->isToolCallsStreamFinished($data)) {
82+
yield new ToolCallResult(...array_map($this->convertToolCall(...), $toolCalls));
83+
}
84+
85+
if (!isset($data['choices'][0]['delta']['content'])) {
86+
continue;
87+
}
88+
89+
yield $data['choices'][0]['delta']['content'];
90+
}
91+
}
92+
93+
/**
94+
* @param array<string, mixed> $toolCalls
95+
* @param array<string, mixed> $data
96+
*
97+
* @return array<string, mixed>
98+
*/
99+
private function convertStreamToToolCalls(array $toolCalls, array $data): array
100+
{
101+
if (!isset($data['choices'][0]['delta']['tool_calls'])) {
102+
return $toolCalls;
103+
}
104+
105+
foreach ($data['choices'][0]['delta']['tool_calls'] as $i => $toolCall) {
106+
if (isset($toolCall['id'])) {
107+
// initialize tool call
108+
$toolCalls[$i] = [
109+
'id' => $toolCall['id'],
110+
'function' => $toolCall['function'],
111+
];
112+
continue;
113+
}
114+
115+
// add arguments delta to tool call
116+
$toolCalls[$i]['function']['arguments'] .= $toolCall['function']['arguments'];
117+
}
118+
119+
return $toolCalls;
120+
}
121+
122+
/**
123+
* @param array<string, mixed> $data
124+
*/
125+
private function streamIsToolCall(array $data): bool
126+
{
127+
return isset($data['choices'][0]['delta']['tool_calls']);
128+
}
129+
130+
/**
131+
* @param array<string, mixed> $data
132+
*/
133+
private function isToolCallsStreamFinished(array $data): bool
134+
{
135+
return isset($data['choices'][0]['finish_reason']) && 'tool_calls' === $data['choices'][0]['finish_reason'];
136+
}
137+
138+
/**
139+
* @param array{
140+
* index: int,
141+
* message: array{
142+
* role: 'assistant',
143+
* content: ?string,
144+
* tool_calls: array{
145+
* id: string,
146+
* type: 'function',
147+
* function: array{
148+
* name: string,
149+
* arguments: string
150+
* },
151+
* },
152+
* refusal: ?mixed
153+
* },
154+
* logprobs: string,
155+
* finish_reason: 'stop'|'length'|'tool_calls'|'content_filter',
156+
* } $choice
157+
*/
158+
private function convertChoice(array $choice): ToolCallResult|TextResult
159+
{
160+
if ('tool_calls' === $choice['finish_reason']) {
161+
return new ToolCallResult(...array_map([$this, 'convertToolCall'], $choice['message']['tool_calls']));
162+
}
163+
164+
if (\in_array($choice['finish_reason'], ['stop', 'length'], true)) {
165+
return new TextResult($choice['message']['content']);
166+
}
167+
168+
throw new RuntimeException(\sprintf('Unsupported finish reason "%s".', $choice['finish_reason']));
169+
}
170+
171+
/**
172+
* @param array{
173+
* id: string,
174+
* type: 'function',
175+
* function: array{
176+
* name: string,
177+
* arguments: string
178+
* }
179+
* } $toolCall
180+
*/
181+
private function convertToolCall(array $toolCall): ToolCall
182+
{
183+
$arguments = json_decode($toolCall['function']['arguments'], true, \JSON_THROW_ON_ERROR);
184+
185+
return new ToolCall($toolCall['id'], $toolCall['function']['name'], $arguments);
186+
}
187+
}

0 commit comments

Comments
 (0)