From 4ab559d5fb5b44f619cf3c91cedb2804c5aca356 Mon Sep 17 00:00:00 2001 From: Oskar Stark Date: Fri, 12 Sep 2025 10:26:56 +0200 Subject: [PATCH] [AI Bundle] Add `ai:platform:invoke` command --- src/ai-bundle/config/services.php | 6 + src/ai-bundle/doc/index.rst | 21 +++ src/ai-bundle/src/AiBundle.php | 24 ++-- .../src/Command/PlatformInvokeCommand.php | 125 ++++++++++++++++++ .../Command/PlatformInvokeCommandTest.php | 75 +++++++++++ 5 files changed, 239 insertions(+), 12 deletions(-) create mode 100644 src/ai-bundle/src/Command/PlatformInvokeCommand.php create mode 100644 src/ai-bundle/tests/Command/PlatformInvokeCommandTest.php diff --git a/src/ai-bundle/config/services.php b/src/ai-bundle/config/services.php index bb5289196..d3614055f 100644 --- a/src/ai-bundle/config/services.php +++ b/src/ai-bundle/config/services.php @@ -22,6 +22,7 @@ use Symfony\AI\Agent\Toolbox\ToolFactory\ReflectionToolFactory; use Symfony\AI\Agent\Toolbox\ToolResultConverter; use Symfony\AI\AiBundle\Command\AgentCallCommand; +use Symfony\AI\AiBundle\Command\PlatformInvokeCommand; use Symfony\AI\AiBundle\Profiler\DataCollector; use Symfony\AI\AiBundle\Profiler\TraceableToolbox; use Symfony\AI\AiBundle\Security\EventListener\IsGrantedToolAttributeListener; @@ -216,5 +217,10 @@ tagged_locator('ai.indexer', 'name'), ]) ->tag('console.command') + ->set('ai.command.platform_invoke', PlatformInvokeCommand::class) + ->args([ + tagged_locator('ai.platform', 'name'), + ]) + ->tag('console.command') ; }; diff --git a/src/ai-bundle/doc/index.rst b/src/ai-bundle/doc/index.rst index 5019186f9..2d934ddb4 100644 --- a/src/ai-bundle/doc/index.rst +++ b/src/ai-bundle/doc/index.rst @@ -612,6 +612,27 @@ Example: Customer Service Bot product_info: ['features', 'how to', 'tutorial', 'guide', 'documentation'] fallback: 'general_support' # Fallback for general inquiries +Commands +-------- + +The AI Bundle provides several console commands for interacting with AI platforms and agents. + +``ai:platform:invoke`` +~~~~~~~~~~~~~~~~~~~~~~ + +The ``ai:platform:invoke`` command allows you to directly invoke any configured AI platform with a message. +This is useful for testing platform configurations and quick interactions with AI models. + +.. code-block:: terminal + + $ php bin/console ai:platform:invoke "" + + # Using OpenAI + $ php bin/console ai:platform:invoke openai gpt-4o-mini "Hello, world!" + + # Using Anthropic + $ php bin/console ai:platform:invoke anthropic claude-3-5-sonnet-20241022 "Explain quantum physics" + Usage ----- diff --git a/src/ai-bundle/src/AiBundle.php b/src/ai-bundle/src/AiBundle.php index 3a92e5d6b..a77e48fbc 100644 --- a/src/ai-bundle/src/AiBundle.php +++ b/src/ai-bundle/src/AiBundle.php @@ -236,7 +236,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB new Reference('ai.platform.model_catalog.anthropic'), new Reference('ai.platform.contract.anthropic'), ]) - ->addTag('ai.platform'); + ->addTag('ai.platform', ['name' => 'anthropic']); $container->setDefinition($platformId, $definition); @@ -259,7 +259,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB new Reference('ai.platform.model_catalog.azure.openai'), new Reference('ai.platform.contract.openai'), ]) - ->addTag('ai.platform'); + ->addTag('ai.platform', ['name' => 'azure.'.$name]); $container->setDefinition($platformId, $definition); } @@ -280,7 +280,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB new Reference('ai.platform.model_catalog.elevenlabs'), new Reference('ai.platform.contract.default'), ]) - ->addTag('ai.platform'); + ->addTag('ai.platform', ['name' => 'eleven_labs']); $container->setDefinition($platformId, $definition); @@ -299,7 +299,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB new Reference('ai.platform.model_catalog.gemini'), new Reference('ai.platform.contract.gemini'), ]) - ->addTag('ai.platform'); + ->addTag('ai.platform', ['name' => 'gemini']); $container->setDefinition($platformId, $definition); @@ -339,7 +339,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB new Reference('ai.platform.model_catalog.vertexai.gemini'), new Reference('ai.platform.contract.vertexai.gemini'), ]) - ->addTag('ai.platform'); + ->addTag('ai.platform', ['name' => 'vertexai']); $container->setDefinition($platformId, $definition); @@ -359,7 +359,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB new Reference('ai.platform.contract.openai'), $platform['region'] ?? null, ]) - ->addTag('ai.platform'); + ->addTag('ai.platform', ['name' => 'openai']); $container->setDefinition($platformId, $definition); @@ -378,7 +378,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB new Reference('ai.platform.model_catalog.openrouter'), new Reference('ai.platform.contract.default'), ]) - ->addTag('ai.platform'); + ->addTag('ai.platform', ['name' => 'openrouter']); $container->setDefinition($platformId, $definition); @@ -397,7 +397,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB new Reference('ai.platform.model_catalog.mistral'), new Reference('ai.platform.contract.default'), ]) - ->addTag('ai.platform'); + ->addTag('ai.platform', ['name' => 'mistral']); $container->setDefinition($platformId, $definition); @@ -416,7 +416,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB new Reference('ai.platform.model_catalog.lmstudio'), new Reference('ai.platform.contract.default'), ]) - ->addTag('ai.platform'); + ->addTag('ai.platform', ['name' => 'lmstudio']); $container->setDefinition($platformId, $definition); @@ -435,7 +435,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB new Reference('ai.platform.model_catalog.ollama'), new Reference('ai.platform.contract.ollama'), ]) - ->addTag('ai.platform'); + ->addTag('ai.platform', ['name' => 'ollama']); $container->setDefinition($platformId, $definition); @@ -454,7 +454,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB new Reference('ai.platform.model_catalog.cerebras'), new Reference('ai.platform.contract.default'), ]) - ->addTag('ai.platform'); + ->addTag('ai.platform', ['name' => 'cerebras']); $container->setDefinition($platformId, $definition); @@ -473,7 +473,7 @@ private function processPlatformConfig(string $type, array $platform, ContainerB new Reference('ai.platform.model_catalog.voyage'), new Reference('ai.platform.contract.default'), ]) - ->addTag('ai.platform'); + ->addTag('ai.platform', ['name' => 'voyage']); $container->setDefinition($platformId, $definition); diff --git a/src/ai-bundle/src/Command/PlatformInvokeCommand.php b/src/ai-bundle/src/Command/PlatformInvokeCommand.php new file mode 100644 index 000000000..665854036 --- /dev/null +++ b/src/ai-bundle/src/Command/PlatformInvokeCommand.php @@ -0,0 +1,125 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\AiBundle\Command; + +use Symfony\AI\AiBundle\Exception\InvalidArgumentException; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\PlatformInterface; +use Symfony\AI\Platform\Result\TextResult; +use Symfony\Component\Console\Attribute\AsCommand; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; +use Symfony\Component\DependencyInjection\ServiceLocator; + +/** + * @author Oskar Stark + */ +#[AsCommand( + name: 'ai:platform:invoke', + description: 'Invoke an AI platform with a message', +)] +final class PlatformInvokeCommand extends Command +{ + private string $message; + private PlatformInterface $platform; + private string $model; + + /** + * @param ServiceLocator $platforms + */ + public function __construct( + private readonly ServiceLocator $platforms, + ) { + parent::__construct(); + } + + public function complete(CompletionInput $input, CompletionSuggestions $suggestions): void + { + if ($input->mustSuggestArgumentValuesFor('platform')) { + $suggestions->suggestValues(array_keys($this->platforms->getProvidedServices())); + } + } + + protected function configure(): void + { + $this + ->addArgument('platform', InputArgument::REQUIRED, 'The name of the configured platform to invoke') + ->addArgument('model', InputArgument::REQUIRED, 'The model to use for the request') + ->addArgument('message', InputArgument::REQUIRED, 'The message to send to the AI platform') + ->setHelp( + <<<'HELP' + The %command.name% command allows you to invoke configured AI platforms with a message. + + Usage: + %command.full_name% "" + + Examples: + %command.full_name% openai gpt-4o-mini "Hello, world!" + %command.full_name% anthropic claude-3-5-sonnet-20241022 "Explain quantum physics" + + Available platforms depend on your configuration in config/packages/ai.yaml + HELP + ); + } + + protected function initialize(InputInterface $input, OutputInterface $output): void + { + $platformName = trim((string) $input->getArgument('platform')); + + if (!$this->platforms->has($platformName)) { + throw new InvalidArgumentException(\sprintf('Platform "%s" not found. Available platforms: "%s"', $platformName, implode(', ', array_keys($this->platforms->getProvidedServices())))); + } + + $this->platform = $this->platforms->get($platformName); + $this->model = trim((string) $input->getArgument('model')); + $this->message = trim((string) $input->getArgument('message')); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + try { + $messages = new MessageBag(); + $messages->add(Message::ofUser($this->message)); + + $resultPromise = $this->platform->invoke($this->model, $messages); + $result = $resultPromise->getResult(); + + if ($result instanceof TextResult) { + $io->writeln('Response: '.$result->getContent()); + } else { + $io->error('Unexpected response type from platform'); + + return Command::FAILURE; + } + } catch (\Exception $e) { + $io->error(\sprintf('Error: %s', $e->getMessage())); + + if ($output->isVerbose()) { + $io->writeln(''); + $io->writeln('Exception trace:'); + $io->text($e->getTraceAsString()); + } + + return Command::FAILURE; + } + + return Command::SUCCESS; + } +} diff --git a/src/ai-bundle/tests/Command/PlatformInvokeCommandTest.php b/src/ai-bundle/tests/Command/PlatformInvokeCommandTest.php new file mode 100644 index 000000000..c9a087213 --- /dev/null +++ b/src/ai-bundle/tests/Command/PlatformInvokeCommandTest.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\AiBundle\Tests\Command; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\AiBundle\Command\PlatformInvokeCommand; +use Symfony\AI\AiBundle\Exception\InvalidArgumentException; +use Symfony\AI\Platform\PlatformInterface; +use Symfony\AI\Platform\Result\InMemoryRawResult; +use Symfony\AI\Platform\Result\ResultPromise; +use Symfony\AI\Platform\Result\TextResult; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\DependencyInjection\ServiceLocator; + +final class PlatformInvokeCommandTest extends TestCase +{ + public function testExecuteSuccessfully() + { + $textResult = new TextResult('Hello! How can I assist you?'); + $rawResult = new InMemoryRawResult([]); + $promise = new ResultPromise(fn () => $textResult, $rawResult); + + $platform = $this->createMock(PlatformInterface::class); + $platform->method('invoke') + ->with('gpt-4o-mini', $this->anything()) + ->willReturn($promise); + + $platforms = $this->createMock(ServiceLocator::class); + $platforms->method('getProvidedServices')->willReturn(['openai' => 'service_class']); + $platforms->method('has')->with('openai')->willReturn(true); + $platforms->method('get')->with('openai')->willReturn($platform); + + $command = new PlatformInvokeCommand($platforms); + $commandTester = new CommandTester($command); + + $exitCode = $commandTester->execute([ + 'platform' => 'openai', + 'model' => 'gpt-4o-mini', + 'message' => 'Hello!', + ]); + + $this->assertSame(Command::SUCCESS, $exitCode); + $this->assertStringContainsString('Response:', $commandTester->getDisplay()); + $this->assertStringContainsString('Hello! How can I assist you?', $commandTester->getDisplay()); + } + + public function testExecuteWithNonExistentPlatform() + { + $platforms = $this->createMock(ServiceLocator::class); + $platforms->method('getProvidedServices')->willReturn(['openai' => 'service_class']); + $platforms->method('has')->with('invalid')->willReturn(false); + + $command = new PlatformInvokeCommand($platforms); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Platform "invalid" not found. Available platforms: "openai"'); + + $commandTester = new CommandTester($command); + $commandTester->execute([ + 'platform' => 'invalid', + 'model' => 'gpt-4o-mini', + 'message' => 'Test message', + ]); + } +}