diff --git a/examples/scaleway/responses-toolcall.php b/examples/scaleway/responses-toolcall.php new file mode 100644 index 000000000..e429c08e2 --- /dev/null +++ b/examples/scaleway/responses-toolcall.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\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; diff --git a/examples/scaleway/responses.php b/examples/scaleway/responses.php new file mode 100644 index 000000000..eeb868e56 --- /dev/null +++ b/examples/scaleway/responses.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\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; diff --git a/src/platform/CHANGELOG.md b/src/platform/CHANGELOG.md index 3bf4557d3..4fd8f6411 100644 --- a/src/platform/CHANGELOG.md +++ b/src/platform/CHANGELOG.md @@ -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 diff --git a/src/platform/src/Bridge/Scaleway/CHANGELOG.md b/src/platform/src/Bridge/Scaleway/CHANGELOG.md index 0915f3546..14809ef03 100644 --- a/src/platform/src/Bridge/Scaleway/CHANGELOG.md +++ b/src/platform/src/Bridge/Scaleway/CHANGELOG.md @@ -5,3 +5,4 @@ CHANGELOG --- * Add the bridge + * Add support for Responses API diff --git a/src/platform/src/Bridge/Scaleway/Llm/ModelClient.php b/src/platform/src/Bridge/Scaleway/Llm/ModelClient.php index f6d59c1c2..0086ea34c 100644 --- a/src/platform/src/Bridge/Scaleway/Llm/ModelClient.php +++ b/src/platform/src/Bridge/Scaleway/Llm/ModelClient.php @@ -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; @@ -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( @@ -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 $body + * + * @return array + */ + 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 $message + * + * @return array|list> + */ + 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 $contentPart + * + * @return array + */ + 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 $body + * + * @return array + */ + 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 $body + * + * @return array + */ + 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; + } } diff --git a/src/platform/src/Bridge/Scaleway/Llm/ResultConverter.php b/src/platform/src/Bridge/Scaleway/Llm/ResultConverter.php index 4cb6689aa..1910cf97a 100644 --- a/src/platform/src/Bridge/Scaleway/Llm/ResultConverter.php +++ b/src/platform/src/Bridge/Scaleway/Llm/ResultConverter.php @@ -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.'); } @@ -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); } @@ -157,6 +171,33 @@ private function convertChoice(array $choice): ToolCallResult|TextResult throw new RuntimeException(\sprintf('Unsupported finish reason "%s".', $choice['finish_reason'])); } + /** + * @param array> $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, @@ -173,4 +214,43 @@ private function convertToolCall(array $toolCall): ToolCall return new ToolCall($toolCall['id'], $toolCall['function']['name'], $arguments); } + + /** + * @param array $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> $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']; + } + } + } } diff --git a/src/platform/src/Bridge/Scaleway/Tests/Llm/ModelClientTest.php b/src/platform/src/Bridge/Scaleway/Tests/Llm/ModelClientTest.php index fe5b0d8fa..94420e566 100644 --- a/src/platform/src/Bridge/Scaleway/Tests/Llm/ModelClientTest.php +++ b/src/platform/src/Bridge/Scaleway/Tests/Llm/ModelClientTest.php @@ -60,7 +60,13 @@ public function testItIsExecutingTheCorrectRequest() 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']); + self::assertSame([ + 'temperature' => 1, + 'model' => 'deepseek-r1-distill-llama-70b', + 'messages' => [ + ['role' => 'user', 'content' => 'test message'], + ], + ], json_decode($options['body'], true)); return new MockResponse(); }; @@ -75,7 +81,13 @@ public function testItIsExecutingTheCorrectRequestWithArrayPayload() 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']); + self::assertSame([ + 'temperature' => 0.7, + 'model' => 'deepseek-r1-distill-llama-70b', + 'messages' => [ + ['role' => 'user', 'content' => 'Hello'], + ], + ], json_decode($options['body'], true)); return new MockResponse(); }; @@ -97,4 +109,146 @@ public function testItUsesCorrectBaseUrl() $modelClient = new ModelClient($httpClient, 'scaleway-api-key'); $modelClient->request(new Scaleway('deepseek-r1-distill-llama-70b'), ['messages' => []], []); } + + public function testItUsesResponsesApiForGptOssModel() + { + $resultCallback = static function (string $method, string $url, array $options): HttpResponse { + self::assertSame('POST', $method); + self::assertSame('https://api.scaleway.ai/v1/responses', $url); + self::assertSame('Authorization: Bearer scaleway-api-key', $options['normalized_headers']['authorization'][0]); + self::assertSame([ + 'model' => 'gpt-oss-120b', + 'input' => [ + [ + 'role' => 'user', + 'content' => [ + [ + 'type' => 'input_text', + 'text' => 'Hello', + ], + ], + ], + ], + ], json_decode($options['body'], true)); + + return new MockResponse(); + }; + $httpClient = new MockHttpClient([$resultCallback]); + $modelClient = new ModelClient($httpClient, 'scaleway-api-key'); + $modelClient->request(new Scaleway('gpt-oss-120b'), ['messages' => [['role' => 'user', 'content' => 'Hello']]], []); + } + + public function testItConvertsToolsForResponsesApi() + { + $resultCallback = static function (string $method, string $url, array $options): HttpResponse { + self::assertSame('POST', $method); + self::assertSame('https://api.scaleway.ai/v1/responses', $url); + + $body = json_decode($options['body'], true); + + // Verify tools are converted from Chat Completions format to Responses API format + self::assertSame([ + [ + 'type' => 'function', + 'name' => 'get_weather', + 'description' => 'Get weather for a location', + 'parameters' => [ + 'type' => 'object', + 'properties' => [ + 'location' => ['type' => 'string'], + ], + ], + ], + ], $body['tools']); + + return new MockResponse(); + }; + + $httpClient = new MockHttpClient([$resultCallback]); + $modelClient = new ModelClient($httpClient, 'scaleway-api-key'); + + // Send tools in Chat Completions format + $modelClient->request(new Scaleway('gpt-oss-120b'), [ + 'messages' => [['role' => 'user', 'content' => 'What is the weather?']], + ], [ + 'tools' => [ + [ + 'type' => 'function', + 'function' => [ + 'name' => 'get_weather', + 'description' => 'Get weather for a location', + 'parameters' => [ + 'type' => 'object', + 'properties' => [ + 'location' => ['type' => 'string'], + ], + ], + ], + ], + ], + ]); + } + + public function testItConvertsToolMessagesForResponsesApi() + { + $resultCallback = static function (string $method, string $url, array $options): HttpResponse { + self::assertSame('POST', $method); + self::assertSame('https://api.scaleway.ai/v1/responses', $url); + + $body = json_decode($options['body'], true); + + // Verify the conversation with tool calls is converted correctly + self::assertSame([ + // User message + [ + 'role' => 'user', + 'content' => [['type' => 'input_text', 'text' => 'What time is it?']], + ], + // Assistant's function call (from tool_calls) + [ + 'type' => 'function_call', + 'call_id' => 'call_123', + 'name' => 'get_time', + 'arguments' => '{}', + ], + // Tool result converted to function_call_output + [ + 'type' => 'function_call_output', + 'call_id' => 'call_123', + 'output' => '2025-12-17T12:00:00Z', + ], + ], $body['input']); + + return new MockResponse(); + }; + + $httpClient = new MockHttpClient([$resultCallback]); + $modelClient = new ModelClient($httpClient, 'scaleway-api-key'); + + // Send a conversation with tool calls in Chat Completions format + $modelClient->request(new Scaleway('gpt-oss-120b'), [ + 'messages' => [ + ['role' => 'user', 'content' => 'What time is it?'], + [ + 'role' => 'assistant', + 'content' => '', + 'tool_calls' => [ + [ + 'id' => 'call_123', + 'type' => 'function', + 'function' => [ + 'name' => 'get_time', + 'arguments' => '{}', + ], + ], + ], + ], + [ + 'role' => 'tool', + 'tool_call_id' => 'call_123', + 'content' => '2025-12-17T12:00:00Z', + ], + ], + ], []); + } } diff --git a/src/platform/src/Bridge/Scaleway/Tests/Llm/ResultConverterTest.php b/src/platform/src/Bridge/Scaleway/Tests/Llm/ResultConverterTest.php index fe0404d8b..98e19acff 100644 --- a/src/platform/src/Bridge/Scaleway/Tests/Llm/ResultConverterTest.php +++ b/src/platform/src/Bridge/Scaleway/Tests/Llm/ResultConverterTest.php @@ -85,6 +85,57 @@ public function testConvertToolCallResult() $this->assertSame(['arg1' => 'value1'], $toolCalls[0]->getArguments()); } + public function testConvertResponseOutputText() + { + $converter = new ResultConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('toArray')->willReturn([ + 'output' => [ + [ + 'role' => 'assistant', + 'type' => 'message', + 'content' => [ + [ + 'type' => 'output_text', + 'text' => 'Hello', + ], + ], + ], + ], + ]); + + $result = $converter->convert(new RawHttpResult($httpResponse)); + + $this->assertInstanceOf(TextResult::class, $result); + $this->assertSame('Hello', $result->getContent()); + } + + public function testConvertResponseFunctionCall() + { + $converter = new ResultConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('toArray')->willReturn([ + 'output' => [ + [ + 'role' => 'assistant', + 'type' => 'function_call', + 'call_id' => 'call_123', + 'name' => 'test_function', + 'arguments' => '{"arg1": "value1"}', + ], + ], + ]); + + $result = $converter->convert(new RawHttpResult($httpResponse)); + + $this->assertInstanceOf(ToolCallResult::class, $result); + $toolCalls = $result->getContent(); + $this->assertCount(1, $toolCalls); + $this->assertSame('call_123', $toolCalls[0]->getId()); + $this->assertSame('test_function', $toolCalls[0]->getName()); + $this->assertSame(['arg1' => 'value1'], $toolCalls[0]->getArguments()); + } + public function testConvertMultipleChoices() { $converter = new ResultConverter(); @@ -148,6 +199,23 @@ public function getResponse(): ResponseInterface $converter->convert(new RawHttpResult($httpResponse)); } + public function testThrowsExceptionOnApiError() + { + $converter = new ResultConverter(); + $httpResponse = self::createMock(ResponseInterface::class); + $httpResponse->method('toArray')->willReturn([ + 'error' => [ + 'type' => 'invalid_request_error', + 'message' => 'Invalid model specified', + ], + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Error "invalid_request_error": "Invalid model specified"'); + + $converter->convert(new RawHttpResult($httpResponse)); + } + public function testThrowsExceptionWhenNoChoices() { $converter = new ResultConverter();