Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions examples/scaleway/responses-toolcall.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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\Toolbox;
use Symfony\AI\Agent\Toolbox\ToolFactory\MemoryToolFactory;
use Symfony\AI\Platform\Bridge\Scaleway\PlatformFactory;
use Symfony\AI\Platform\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;
use Symfony\Component\Clock\Clock;

require_once dirname(__DIR__).'/bootstrap.php';

$platform = PlatformFactory::create(env('SCALEWAY_SECRET_KEY'), http_client());

// Create a simple clock tool for demonstration
$metadataFactory = (new MemoryToolFactory())
->addTool(Clock::class, 'clock', 'Get the current date and time', 'now');
$toolbox = new Toolbox([new Clock()], $metadataFactory, logger: logger());
$processor = new AgentProcessor($toolbox);

// gpt-oss-120b uses Scaleway Responses API which supports function calling
$agent = new Agent($platform, 'gpt-oss-120b', [$processor], [$processor]);

$messages = new MessageBag(Message::ofUser('What date and time is it right now?'));
$result = $agent->call($messages);

echo $result->getContent().\PHP_EOL;
31 changes: 31 additions & 0 deletions examples/scaleway/responses.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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\Message\Message;
use Symfony\AI\Platform\Message\MessageBag;

require_once dirname(__DIR__).'/bootstrap.php';

$platform = PlatformFactory::create(env('SCALEWAY_SECRET_KEY'), http_client());

$messages = new MessageBag(
Message::forSystem('You are concise and respond with two short sentences.'),
Message::ofUser('What is new in Symfony AI?'),
);

// gpt-oss-120b now goes through Scaleway Responses API under the hood.
$result = $platform->invoke('gpt-oss-120b', $messages, [
'reasoning' => ['effort' => 'medium'],
'max_output_tokens' => 200,
]);

echo $result->asText().\PHP_EOL;
1 change: 1 addition & 0 deletions src/platform/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,4 @@ CHANGELOG
* Allow beta feature flags to be passed into Anthropic model options
* Add Ollama streaming output support
* Add multimodal embedding support for Voyage AI
* Use Responses API for Scaleway platform when using gpt-oss-120b
1 change: 1 addition & 0 deletions src/platform/src/Bridge/Scaleway/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ CHANGELOG
---

* Add the bridge
* Add support for Responses API
189 changes: 187 additions & 2 deletions src/platform/src/Bridge/Scaleway/Llm/ModelClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Symfony\AI\Platform\Model;
use Symfony\AI\Platform\ModelClientInterface;
use Symfony\AI\Platform\Result\RawHttpResult;
use Symfony\AI\Platform\StructuredOutput\PlatformSubscriber;
use Symfony\Component\HttpClient\EventSourceHttpClient;
use Symfony\Contracts\HttpClient\HttpClientInterface;

Expand All @@ -23,6 +24,9 @@
*/
final class ModelClient implements ModelClientInterface
{
private const RESPONSES_MODEL = 'gpt-oss-120b';
private const BASE_URL = 'https://api.scaleway.ai/v1';

private readonly EventSourceHttpClient $httpClient;

public function __construct(
Expand All @@ -39,9 +43,190 @@ public function supports(Model $model): bool

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', [
$body = \is_array($payload) ? $payload : ['input' => $payload];
$body = array_merge($options, $body);
$body['model'] = $model->getName();

if (self::RESPONSES_MODEL === $model->getName()) {
$body = $this->convertMessagesToResponsesInput($body);
$body = $this->convertResponseFormat($body);
$body = $this->convertTools($body);
$url = self::BASE_URL.'/responses';
} else {
$url = self::BASE_URL.'/chat/completions';
}

return new RawHttpResult($this->httpClient->request('POST', $url, [
'auth_bearer' => $this->apiKey,
'json' => array_merge($options, $payload),
'json' => $body,
]));
}

/**
* @param array<string, mixed> $body
*
* @return array<string, mixed>
*/
private function convertMessagesToResponsesInput(array $body): array
{
if (!isset($body['messages'])) {
return $body;
}

$input = [];
foreach ($body['messages'] as $message) {
$converted = $this->convertMessage($message);
if (array_is_list($converted) && isset($converted[0]) && \is_array($converted[0])) {
// Multiple items returned (e.g., assistant message with tool calls)
$input = array_merge($input, $converted);
} else {
$input[] = $converted;
}
}

$body['input'] = $input;
unset($body['messages']);

return $body;
}

/**
* @param array<string, mixed> $message
*
* @return array<string, mixed>|list<array<string, mixed>>
*/
private function convertMessage(array $message): array
{
$role = $message['role'] ?? 'user';

// Convert tool result messages to function_call_output format
if ('tool' === $role) {
return [
'type' => 'function_call_output',
'call_id' => $message['tool_call_id'] ?? '',
'output' => $message['content'] ?? '',
];
}

// Convert assistant messages with tool_calls
if ('assistant' === $role && isset($message['tool_calls'])) {
$items = [];

// Add text content if present
if (isset($message['content']) && '' !== $message['content']) {
$items[] = [
'role' => 'assistant',
'content' => [['type' => 'input_text', 'text' => $message['content']]],
];
}

// Add function calls
foreach ($message['tool_calls'] as $toolCall) {
$items[] = [
'type' => 'function_call',
'call_id' => $toolCall['id'] ?? '',
'name' => $toolCall['function']['name'] ?? '',
'arguments' => $toolCall['function']['arguments'] ?? '{}',
];
}

return $items;
}

// Convert regular messages
$content = $message['content'] ?? '';

if (\is_string($content)) {
$content = [['type' => 'input_text', 'text' => $content]];
}

if (\is_array($content)) {
if (!array_is_list($content)) {
$content = [$content];
}

$content = array_map($this->convertContentPart(...), $content);
}

return [
'role' => $role,
'content' => $content,
];
}

/**
* @param array<string, mixed>|string $contentPart
*
* @return array<string, mixed>
*/
private function convertContentPart(array|string $contentPart): array
{
if (\is_string($contentPart)) {
return ['type' => 'input_text', 'text' => $contentPart];
}

return match ($contentPart['type'] ?? null) {
'text' => ['type' => 'input_text', 'text' => $contentPart['text'] ?? ''],
'input_text' => $contentPart,
'input_image', 'image_url' => [
'type' => 'input_image',
'image_url' => \is_array($contentPart['image_url'] ?? null) ? ($contentPart['image_url']['url'] ?? '') : ($contentPart['image_url'] ?? ''),
...isset($contentPart['detail']) ? ['detail' => $contentPart['detail']] : [],
],
default => ['type' => 'input_text', 'text' => $contentPart['text'] ?? ''],
};
}

/**
* @param array<string, mixed> $body
*
* @return array<string, mixed>
*/
private function convertResponseFormat(array $body): array
{
if (!isset($body[PlatformSubscriber::RESPONSE_FORMAT]['json_schema']['schema'])) {
return $body;
}

$schema = $body[PlatformSubscriber::RESPONSE_FORMAT]['json_schema'];
$body['text']['format'] = $schema;
$body['text']['format']['name'] = $schema['name'];
$body['text']['format']['type'] = $body[PlatformSubscriber::RESPONSE_FORMAT]['type'];

unset($body[PlatformSubscriber::RESPONSE_FORMAT]);

return $body;
}

/**
* Converts tools from Chat Completions format to Responses API format.
*
* Chat Completions: {"type": "function", "function": {"name": "...", "description": "...", "parameters": {...}}}
* Responses API: {"type": "function", "name": "...", "description": "...", "parameters": {...}}
*
* @param array<string, mixed> $body
*
* @return array<string, mixed>
*/
private function convertTools(array $body): array
{
if (!isset($body['tools'])) {
return $body;
}

$body['tools'] = array_map(static function (array $tool): array {
if ('function' !== ($tool['type'] ?? null) || !isset($tool['function'])) {
return $tool;
}

return [
'type' => 'function',
'name' => $tool['function']['name'] ?? '',
'description' => $tool['function']['description'] ?? '',
...isset($tool['function']['parameters']) ? ['parameters' => $tool['function']['parameters']] : [],
];
}, $body['tools']);

return $body;
}
}
80 changes: 80 additions & 0 deletions src/platform/src/Bridge/Scaleway/Llm/ResultConverter.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,14 @@ public function convert(RawResultInterface $result, array $options = []): Result
throw new ContentFilterException($data['error']['message']);
}

if (isset($data['error'])) {
throw new RuntimeException(\sprintf('Error "%s": "%s".', $data['error']['type'] ?? $data['error']['code'] ?? 'unknown', $data['error']['message'] ?? 'Unknown error'));
}

if (isset($data['output'])) {
return $this->convertResponseOutput($data['output']);
}

if (!isset($data['choices'])) {
throw new RuntimeException('Result does not contain choices.');
}
Expand All @@ -63,6 +71,12 @@ private function convertStream(RawResultInterface $result): \Generator
{
$toolCalls = [];
foreach ($result->getDataStream() as $data) {
if (isset($data['output'])) {
yield from $this->convertResponsesStreamChunk($data['output']);

continue;
}

if ($this->streamIsToolCall($data)) {
$toolCalls = $this->convertStreamToToolCalls($toolCalls, $data);
}
Expand Down Expand Up @@ -157,6 +171,33 @@ private function convertChoice(array $choice): ToolCallResult|TextResult
throw new RuntimeException(\sprintf('Unsupported finish reason "%s".', $choice['finish_reason']));
}

/**
* @param array<int, array<string, mixed>> $output
*/
private function convertResponseOutput(array $output): ResultInterface
{
$toolCalls = array_filter($output, static fn (array $item): bool => 'function_call' === ($item['type'] ?? null));

if ([] !== $toolCalls) {
return new ToolCallResult(...array_map($this->convertResponseFunctionCall(...), $toolCalls));
}

$messages = [];
foreach ($output as $outputItem) {
foreach ($outputItem['content'] ?? [] as $content) {
if ('output_text' === ($content['type'] ?? null) && isset($content['text'])) {
$messages[] = new TextResult($content['text']);
}
}
}

if ([] === $messages) {
throw new RuntimeException('Result does not contain output content.');
}

return 1 === \count($messages) ? $messages[0] : new ChoiceResult(...$messages);
}

/**
* @param array{
* id: string,
Expand All @@ -173,4 +214,43 @@ private function convertToolCall(array $toolCall): ToolCall

return new ToolCall($toolCall['id'], $toolCall['function']['name'], $arguments);
}

/**
* @param array<string, mixed> $toolCall
*/
private function convertResponseFunctionCall(array $toolCall): ToolCall
{
return $this->convertToolCall([
'id' => $toolCall['call_id'] ?? $toolCall['id'],
'type' => 'function',
'function' => [
'name' => $toolCall['name'],
'arguments' => $toolCall['arguments'] ?? '{}',
],
]);
}

/**
* @param array<int, array<string, mixed>> $output
*/
private function convertResponsesStreamChunk(array $output): \Generator
{
$toolCalls = array_filter($output, static fn (array $item): bool => 'function_call' === ($item['type'] ?? null));

if ([] !== $toolCalls) {
yield new ToolCallResult(...array_map($this->convertResponseFunctionCall(...), $toolCalls));

return;
}

foreach ($output as $outputItem) {
foreach ($outputItem['content'] ?? [] as $content) {
if ('output_text' !== ($content['type'] ?? null) || !isset($content['text'])) {
continue;
}

yield $content['text'];
}
}
}
}
Loading