diff --git a/examples/router/01-vision-simple.php b/examples/router/01-vision-simple.php new file mode 100644 index 000000000..84b2fd306 --- /dev/null +++ b/examples/router/01-vision-simple.php @@ -0,0 +1,63 @@ + + * + * 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\InputProcessor\ModelRouterInputProcessor; +use Symfony\AI\Agent\Router\Result\RoutingResult; +use Symfony\AI\Agent\Router\SimpleRouter; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +// Create platform +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client()); + +// Create simple vision router: if message contains image → use gpt-4-vision +$visionRouter = new SimpleRouter( + fn ($input, $ctx) => + $input->getMessageBag()->containsImage() + ? new RoutingResult('gpt-4o', reason: 'Image detected') + : null +); + +// Create agent with router +$agent = new Agent( + platform: $platform, + model: 'gpt-4o-mini', // Default model + inputProcessors: [ + new ModelRouterInputProcessor($visionRouter), + ], +); + +echo "Example 1: Simple Vision Routing\n"; +echo "=================================\n\n"; + +// Test 1: Text only - should use default model (gpt-4o-mini) +echo "Test 1: Text only message\n"; +$result = $agent->call(new MessageBag( + Message::ofUser('What is PHP?') +)); +echo 'Response: '.$result->asText()."\n\n"; + +// Test 2: With image - should automatically route to gpt-4o +echo "Test 2: Message with image\n"; +$imagePath = realpath(__DIR__.'/../../fixtures/assets/image-sample.png'); +if (!file_exists($imagePath)) { + echo "Image file not found: {$imagePath}\n"; + exit(1); +} + +$result = $agent->call(new MessageBag( + Message::ofUser('What is in this image?')->withImage($imagePath) +)); +echo 'Response: '.$result->asText()."\n"; diff --git a/examples/router/02-vision-with-fallback.php b/examples/router/02-vision-with-fallback.php new file mode 100644 index 000000000..df2f22148 --- /dev/null +++ b/examples/router/02-vision-with-fallback.php @@ -0,0 +1,62 @@ + + * + * 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\InputProcessor\ModelRouterInputProcessor; +use Symfony\AI\Agent\Router\Result\RoutingResult; +use Symfony\AI\Agent\Router\SimpleRouter; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +// Create platform +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client()); + +// Router that uses vision model for images, default for text +$router = new SimpleRouter( + fn ($input, $ctx) => $input->getMessageBag()->containsImage() + ? new RoutingResult('gpt-4o', reason: 'Vision model for images') + : new RoutingResult($ctx->getDefaultModel(), reason: 'Default model for text') +); + +// Create agent with router +$agent = new Agent( + platform: $platform, + model: 'gpt-4o-mini', // Default model + inputProcessors: [ + new ModelRouterInputProcessor($router), + ], +); + +echo "Example 2: Vision with Fallback\n"; +echo "================================\n\n"; + +// Test 1: Text query +echo "Test 1: Text query → gpt-4o-mini (default)\n"; +$result = $agent->call(new MessageBag( + Message::ofUser('Explain quantum computing in simple terms') +)); +echo 'Response: '.$result->asText()."\n\n"; + +// Test 2: Image query +echo "Test 2: Image query → gpt-4o (vision)\n"; +$imagePath = realpath(__DIR__.'/../../fixtures/assets/image-sample.png'); +if (!file_exists($imagePath)) { + echo "Image file not found: {$imagePath}\n"; + exit(1); +} + +$result = $agent->call(new MessageBag( + Message::ofUser('Describe this image')->withImage($imagePath) +)); +echo 'Response: '.$result->asText()."\n"; diff --git a/examples/router/03-multi-provider.php b/examples/router/03-multi-provider.php new file mode 100644 index 000000000..de54d56b7 --- /dev/null +++ b/examples/router/03-multi-provider.php @@ -0,0 +1,82 @@ + + * + * 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\InputProcessor\ModelRouterInputProcessor; +use Symfony\AI\Agent\Router\ChainRouter; +use Symfony\AI\Agent\Router\Result\RoutingResult; +use Symfony\AI\Agent\Router\SimpleRouter; +use Symfony\AI\Platform\Bridge\Anthropic\PlatformFactory as AnthropicFactory; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory as OpenAiFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +// For this example, we'll use OpenAI platform, but show routing to different models +// In a real scenario, you might have multiple platform instances +$platform = OpenAiFactory::create(env('OPENAI_API_KEY'), http_client()); + +// Create chain router that tries multiple strategies +$router = new ChainRouter([ + // Strategy 1: Try gpt-4o for images + new SimpleRouter( + fn ($input) => + $input->getMessageBag()->containsImage() + ? new RoutingResult('gpt-4o', reason: 'OpenAI GPT-4o for vision') + : null + ), + + // Strategy 2: Fallback to gpt-4o-mini for simple text + new SimpleRouter( + fn ($input) => + !$input->getMessageBag()->containsImage() + ? new RoutingResult('gpt-4o-mini', reason: 'OpenAI GPT-4o-mini for text') + : null + ), + + // Strategy 3: Default fallback + new SimpleRouter( + fn ($input, $ctx) => new RoutingResult($ctx->getDefaultModel(), reason: 'Default') + ), +]); + +// Create agent with chain router +$agent = new Agent( + platform: $platform, + model: 'gpt-4o-mini', // Default model + inputProcessors: [ + new ModelRouterInputProcessor($router), + ], +); + +echo "Example 3: Multi-Provider Routing\n"; +echo "==================================\n\n"; + +// Test 1: Simple text +echo "Test 1: Simple text → gpt-4o-mini\n"; +$result = $agent->call(new MessageBag( + Message::ofUser('What is 2 + 2?') +)); +echo 'Response: '.$result->asText()."\n\n"; + +// Test 2: Image +echo "Test 2: Image → gpt-4o\n"; +$imagePath = realpath(__DIR__.'/../../fixtures/assets/image-sample.png'); +if (!file_exists($imagePath)) { + echo "Image file not found: {$imagePath}\n"; + exit(1); +} + +$result = $agent->call(new MessageBag( + Message::ofUser('What is in this image?')->withImage($imagePath) +)); +echo 'Response: '.$result->asText()."\n"; diff --git a/examples/router/04-content-detection.php b/examples/router/04-content-detection.php new file mode 100644 index 000000000..d687f84c7 --- /dev/null +++ b/examples/router/04-content-detection.php @@ -0,0 +1,89 @@ + + * + * 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\InputProcessor\ModelRouterInputProcessor; +use Symfony\AI\Agent\Router\Result\RoutingResult; +use Symfony\AI\Agent\Router\SimpleRouter; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +// Create platform +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client()); + +// Router that handles multiple content types +$router = new SimpleRouter( + fn ($input, $ctx) => match (true) { + $input->getMessageBag()->containsImage() => new RoutingResult( + 'gpt-4o', + reason: 'Image detected - using vision model' + ), + $input->getMessageBag()->containsAudio() => new RoutingResult( + 'whisper-1', + reason: 'Audio detected - using speech-to-text model' + ), + $input->getMessageBag()->containsPdf() => new RoutingResult( + 'gpt-4o', + reason: 'PDF detected - using advanced model' + ), + default => new RoutingResult( + $ctx->getDefaultModel(), + reason: 'Text only - using default model' + ), + } +); + +// Create agent with router +$agent = new Agent( + platform: $platform, + model: 'gpt-4o-mini', // Default model + inputProcessors: [ + new ModelRouterInputProcessor($router), + ], +); + +echo "Example 4: Content Type Detection\n"; +echo "==================================\n\n"; + +// Test 1: Plain text +echo "Test 1: Plain text\n"; +$result = $agent->call(new MessageBag( + Message::ofUser('What is machine learning?') +)); +echo 'Response: '.$result->asText()."\n\n"; + +// Test 2: Image +echo "Test 2: Image content\n"; +$imagePath = realpath(__DIR__.'/../../fixtures/assets/image-sample.png'); +if (!file_exists($imagePath)) { + echo "Image file not found: {$imagePath}\n"; + exit(1); +} + +$result = $agent->call(new MessageBag( + Message::ofUser('Analyze this image')->withImage($imagePath) +)); +echo 'Response: '.$result->asText()."\n\n"; + +// Test 3: PDF (if available) +$pdfPath = realpath(__DIR__.'/../../fixtures/assets/pdf-sample.pdf'); +if (file_exists($pdfPath)) { + echo "Test 3: PDF content\n"; + $result = $agent->call(new MessageBag( + Message::ofUser('Summarize this PDF')->withPdf($pdfPath) + )); + echo 'Response: '.$result->asText()."\n"; +} else { + echo "Test 3: PDF content - skipped (PDF file not found)\n"; +} diff --git a/examples/router/05-cost-optimized.php b/examples/router/05-cost-optimized.php new file mode 100644 index 000000000..eeea44edd --- /dev/null +++ b/examples/router/05-cost-optimized.php @@ -0,0 +1,98 @@ + + * + * 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\InputProcessor\ModelRouterInputProcessor; +use Symfony\AI\Agent\Router\Result\RoutingResult; +use Symfony\AI\Agent\Router\SimpleRouter; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +// Helper function to estimate tokens +function estimateTokens(MessageBag $messageBag): int +{ + $text = ''; + foreach ($messageBag->getMessages() as $message) { + $content = $message->getContent(); + if (\is_string($content)) { + $text .= $content; + } + } + + // Rough estimate: 1 token ≈ 4 characters + return (int) (\strlen($text) / 4); +} + +// Create platform +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client()); + +// Cost-optimized router: use cheaper models for simple queries +$router = new SimpleRouter( + function ($input, $ctx) { + $tokenCount = estimateTokens($input->getMessageBag()); + + if ($tokenCount < 100) { + return new RoutingResult( + 'gpt-4o-mini', + reason: "Low cost for short query ({$tokenCount} tokens)" + ); + } + + if ($tokenCount < 500) { + return new RoutingResult( + 'gpt-4o-mini', + reason: "Balanced cost for medium query ({$tokenCount} tokens)" + ); + } + + return new RoutingResult( + 'gpt-4o', + reason: "Full model for complex query ({$tokenCount} tokens)" + ); + } +); + +// Create agent with router +$agent = new Agent( + platform: $platform, + model: 'gpt-4o', // Default model + inputProcessors: [ + new ModelRouterInputProcessor($router), + ], +); + +echo "Example 5: Cost-Optimized Routing\n"; +echo "==================================\n\n"; + +// Test 1: Short query +echo "Test 1: Short query (< 100 tokens)\n"; +$result = $agent->call(new MessageBag( + Message::ofUser('What is 2 + 2?') +)); +echo 'Response: '.$result->asText()."\n\n"; + +// Test 2: Medium query +echo "Test 2: Medium query (100-500 tokens)\n"; +$result = $agent->call(new MessageBag( + Message::ofUser('Explain the concept of object-oriented programming and give me a few examples.') +)); +echo 'Response: '.$result->asText()."\n\n"; + +// Test 3: Long query +echo "Test 3: Long query (> 500 tokens)\n"; +$longText = str_repeat('This is a longer text that requires more processing. ', 50); +$result = $agent->call(new MessageBag( + Message::ofUser("Analyze this text and provide insights: {$longText}") +)); +echo 'Response: '.$result->asText()."\n"; diff --git a/examples/router/06-capability-check.php b/examples/router/06-capability-check.php new file mode 100644 index 000000000..240e9ff78 --- /dev/null +++ b/examples/router/06-capability-check.php @@ -0,0 +1,102 @@ + + * + * 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\InputProcessor\ModelRouterInputProcessor; +use Symfony\AI\Agent\Router\Result\RoutingResult; +use Symfony\AI\Agent\Router\SimpleRouter; +use Symfony\AI\Platform\Bridge\OpenAi\PlatformFactory; +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; + +require_once dirname(__DIR__).'/bootstrap.php'; + +// Create platform +$platform = PlatformFactory::create(env('OPENAI_API_KEY'), http_client()); + +// Router that checks model capabilities and routes accordingly +$router = new SimpleRouter( + function ($input, $ctx) { + $catalog = $ctx->getCatalog(); + $currentModel = $input->getModel(); + + // If no catalog available, keep current model + if ($catalog === null) { + return null; + } + + // Check if input contains image + if ($input->getMessageBag()->containsImage()) { + try { + $model = $catalog->getModel($currentModel); + + // Check if current model supports vision + if (!$model->supports(Capability::INPUT_IMAGE)) { + // Find a model that supports vision + $visionModels = $ctx->findModelsWithCapabilities(Capability::INPUT_IMAGE); + + if (empty($visionModels)) { + throw new \RuntimeException('No vision-capable model found'); + } + + return new RoutingResult( + $visionModels[0], + reason: "Current model '{$currentModel}' doesn't support vision - switching to '{$visionModels[0]}'" + ); + } + } catch (\Exception $e) { + // Model not found in catalog, try to find a vision model + $visionModels = $ctx->findModelsWithCapabilities(Capability::INPUT_IMAGE); + if (!empty($visionModels)) { + return new RoutingResult( + $visionModels[0], + reason: "Switching to vision-capable model '{$visionModels[0]}'" + ); + } + } + } + + return null; // Keep current model + } +); + +// Create agent with router +$agent = new Agent( + platform: $platform, + model: 'gpt-4o-mini', // Default model (supports vision) + inputProcessors: [ + new ModelRouterInputProcessor($router), + ], +); + +echo "Example 6: Capability-Based Routing\n"; +echo "====================================\n\n"; + +// Test 1: Text query +echo "Test 1: Text query (no special capabilities needed)\n"; +$result = $agent->call(new MessageBag( + Message::ofUser('What is artificial intelligence?') +)); +echo 'Response: '.$result->asText()."\n\n"; + +// Test 2: Image query +echo "Test 2: Image query (requires vision capability)\n"; +$imagePath = realpath(__DIR__.'/../../fixtures/assets/image-sample.png'); +if (!file_exists($imagePath)) { + echo "Image file not found: {$imagePath}\n"; + exit(1); +} + +$result = $agent->call(new MessageBag( + Message::ofUser('What do you see in this image?')->withImage($imagePath) +)); +echo 'Response: '.$result->asText()."\n"; diff --git a/src/agent/src/Agent.php b/src/agent/src/Agent.php index 7b505effb..287af6734 100644 --- a/src/agent/src/Agent.php +++ b/src/agent/src/Agent.php @@ -68,7 +68,7 @@ public function getName(): string */ public function call(MessageBag $messages, array $options = []): ResultInterface { - $input = new Input($this->getModel(), $messages, $options); + $input = new Input($this->getModel(), $messages, $options, $this->platform); array_map(static fn (InputProcessorInterface $processor) => $processor->processInput($input), $this->inputProcessors); $model = $input->getModel(); diff --git a/src/agent/src/Input.php b/src/agent/src/Input.php index 6648b0d5c..8ad09efa7 100644 --- a/src/agent/src/Input.php +++ b/src/agent/src/Input.php @@ -12,6 +12,7 @@ namespace Symfony\AI\Agent; use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\PlatformInterface; /** * @author Christopher Hertel @@ -25,6 +26,7 @@ public function __construct( private string $model, private MessageBag $messageBag, private array $options = [], + private ?PlatformInterface $platform = null, ) { } @@ -63,4 +65,14 @@ public function setOptions(array $options): void { $this->options = $options; } + + public function getPlatform(): ?PlatformInterface + { + return $this->platform; + } + + public function setPlatform(PlatformInterface $platform): void + { + $this->platform = $platform; + } } diff --git a/src/agent/src/InputProcessor/ModelRouterInputProcessor.php b/src/agent/src/InputProcessor/ModelRouterInputProcessor.php new file mode 100644 index 000000000..83be6cb20 --- /dev/null +++ b/src/agent/src/InputProcessor/ModelRouterInputProcessor.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\InputProcessor; + +use Symfony\AI\Agent\Input; +use Symfony\AI\Agent\InputProcessorInterface; +use Symfony\AI\Agent\Router\RouterContext; +use Symfony\AI\Agent\Router\RouterInterface; + +/** + * Input processor that routes requests to appropriate models based on input characteristics. + * + * @author Johannes Wachter + */ +final class ModelRouterInputProcessor implements InputProcessorInterface +{ + public function __construct( + private readonly RouterInterface $router, + ) { + } + + public function processInput(Input $input): void + { + // Get platform from input + $platform = $input->getPlatform(); + + if (null === $platform) { + return; // No platform available, skip routing + } + + // Build context with platform and default model from input + $context = new RouterContext( + platform: $platform, + catalog: $platform->getModelCatalog(), + metadata: [ + 'default_model' => $input->getModel(), // Agent's default model + ], + ); + + // Route + $result = $this->router->route($input, $context); + + if (null === $result) { + return; // Router couldn't handle, keep existing model + } + + // Apply transformation if specified + if ($transformer = $result->getTransformer()) { + $transformedInput = $transformer->transform($input, $context); + $input->setMessageBag($transformedInput->getMessageBag()); + $input->setOptions($transformedInput->getOptions()); + } + + // Apply routing decision + $input->setModel($result->getModelName()); + } +} diff --git a/src/agent/src/Router/ChainRouter.php b/src/agent/src/Router/ChainRouter.php new file mode 100644 index 000000000..63fdb4522 --- /dev/null +++ b/src/agent/src/Router/ChainRouter.php @@ -0,0 +1,43 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Router; + +use Symfony\AI\Agent\Input; +use Symfony\AI\Agent\Router\Result\RoutingResult; + +/** + * Composite router that tries multiple routers in sequence. + * + * @author Johannes Wachter + */ +final class ChainRouter implements RouterInterface +{ + /** + * @param iterable $routers + */ + public function __construct( + private readonly iterable $routers, + ) { + } + + public function route(Input $input, RouterContext $context): ?RoutingResult + { + foreach ($this->routers as $router) { + $result = $router->route($input, $context); + if (null !== $result) { + return $result; // First match wins + } + } + + return null; // None matched + } +} diff --git a/src/agent/src/Router/Result/RoutingResult.php b/src/agent/src/Router/Result/RoutingResult.php new file mode 100644 index 000000000..95460082d --- /dev/null +++ b/src/agent/src/Router/Result/RoutingResult.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\Agent\Router\Result; + +use Symfony\AI\Agent\Router\Transformer\TransformerInterface; + +/** + * Result of a routing decision, including optional transformation. + * + * @author Johannes Wachter + */ +final class RoutingResult +{ + public function __construct( + private readonly string $modelName, + private readonly ?TransformerInterface $transformer = null, + private readonly string $reason = '', + private readonly int $confidence = 100, + ) { + } + + public function getModelName(): string + { + return $this->modelName; + } + + public function getTransformer(): ?TransformerInterface + { + return $this->transformer; + } + + public function getReason(): string + { + return $this->reason; + } + + public function getConfidence(): int + { + return $this->confidence; + } +} diff --git a/src/agent/src/Router/RouterContext.php b/src/agent/src/Router/RouterContext.php new file mode 100644 index 000000000..373787225 --- /dev/null +++ b/src/agent/src/Router/RouterContext.php @@ -0,0 +1,89 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Router; + +use Symfony\AI\Platform\Capability; +use Symfony\AI\Platform\ModelCatalog\ModelCatalogInterface; +use Symfony\AI\Platform\PlatformInterface; + +/** + * Context for routing decisions, providing access to platform and catalog. + * + * @author Johannes Wachter + */ +final class RouterContext +{ + /** + * @param array $metadata + */ + public function __construct( + private readonly PlatformInterface $platform, + private readonly ?ModelCatalogInterface $catalog = null, + private readonly array $metadata = [], + ) { + } + + public function getPlatform(): PlatformInterface + { + return $this->platform; + } + + public function getCatalog(): ?ModelCatalogInterface + { + return $this->catalog; + } + + /** + * @return array + */ + public function getMetadata(): array + { + return $this->metadata; + } + + /** + * Get the agent's default model from metadata. + */ + public function getDefaultModel(): ?string + { + return $this->metadata['default_model'] ?? null; + } + + /** + * Find models supporting specific capabilities. + * + * @return array Model names + */ + public function findModelsWithCapabilities(Capability ...$capabilities): array + { + if (null === $this->catalog) { + return []; + } + + $matchingModels = []; + foreach ($this->catalog->getModels() as $modelName => $modelInfo) { + $supportsAll = true; + foreach ($capabilities as $capability) { + if (!\in_array($capability, $modelInfo['capabilities'], true)) { + $supportsAll = false; + break; + } + } + + if ($supportsAll) { + $matchingModels[] = $modelName; + } + } + + return $matchingModels; + } +} diff --git a/src/agent/src/Router/RouterInterface.php b/src/agent/src/Router/RouterInterface.php new file mode 100644 index 000000000..db9e8837e --- /dev/null +++ b/src/agent/src/Router/RouterInterface.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Router; + +use Symfony\AI\Agent\Input; +use Symfony\AI\Agent\Router\Result\RoutingResult; + +/** + * Routes requests to appropriate AI models based on input characteristics. + * + * @author Johannes Wachter + */ +interface RouterInterface +{ + /** + * Routes request to appropriate model, optionally with transformation. + * + * @param Input $input The input containing messages and current model + * @param RouterContext $context Context for routing (platform, catalog) + * + * @return RoutingResult|null Returns null if router cannot handle + */ + public function route(Input $input, RouterContext $context): ?RoutingResult; +} diff --git a/src/agent/src/Router/SimpleRouter.php b/src/agent/src/Router/SimpleRouter.php new file mode 100644 index 000000000..12651896b --- /dev/null +++ b/src/agent/src/Router/SimpleRouter.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Router; + +use Symfony\AI\Agent\Input; +use Symfony\AI\Agent\Router\Result\RoutingResult; + +/** + * Simple callable-based router for flexible routing logic. + * + * @author Johannes Wachter + */ +final class SimpleRouter implements RouterInterface +{ + /** + * @param callable(Input, RouterContext): ?RoutingResult $routingFunction + */ + public function __construct( + private readonly mixed $routingFunction, + ) { + } + + public function route(Input $input, RouterContext $context): ?RoutingResult + { + return ($this->routingFunction)($input, $context); + } +} diff --git a/src/agent/src/Router/Transformer/TransformerInterface.php b/src/agent/src/Router/Transformer/TransformerInterface.php new file mode 100644 index 000000000..fed7b312e --- /dev/null +++ b/src/agent/src/Router/Transformer/TransformerInterface.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\Agent\Router\Transformer; + +use Symfony\AI\Agent\Input; +use Symfony\AI\Agent\Router\RouterContext; + +/** + * Transforms input before routing to a model. + * + * @author Johannes Wachter + */ +interface TransformerInterface +{ + /** + * Transforms the input before routing. + * + * @return Input New input with transformed messages/options + */ + public function transform(Input $input, RouterContext $context): Input; +} diff --git a/src/agent/tests/InputProcessor/ModelRouterInputProcessorTest.php b/src/agent/tests/InputProcessor/ModelRouterInputProcessorTest.php new file mode 100644 index 000000000..4ebde50e0 --- /dev/null +++ b/src/agent/tests/InputProcessor/ModelRouterInputProcessorTest.php @@ -0,0 +1,158 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\InputProcessor; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Input; +use Symfony\AI\Agent\InputProcessor\ModelRouterInputProcessor; +use Symfony\AI\Agent\Router\Result\RoutingResult; +use Symfony\AI\Agent\Router\RouterContext; +use Symfony\AI\Agent\Router\RouterInterface; +use Symfony\AI\Agent\Router\SimpleRouter; +use Symfony\AI\Agent\Router\Transformer\TransformerInterface; +use Symfony\AI\Platform\Message\Content\Text; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\PlatformInterface; + +final class ModelRouterInputProcessorTest extends TestCase +{ + public function testProcessesInputAndChangesModel(): void + { + $router = new SimpleRouter( + fn (Input $input, RouterContext $context) => new RoutingResult('gpt-4-vision') + ); + + $processor = new ModelRouterInputProcessor($router); + + $platform = $this->createMock(PlatformInterface::class); + + $input = new Input( + 'gpt-4', + new MessageBag(Message::ofUser('test')), + [], + $platform + ); + + $processor->processInput($input); + + $this->assertSame('gpt-4-vision', $input->getModel()); + } + + public function testDoesNothingWhenRouterReturnsNull(): void + { + $router = new SimpleRouter( + fn () => null + ); + + $processor = new ModelRouterInputProcessor($router); + + $platform = $this->createMock(PlatformInterface::class); + + $input = new Input( + 'gpt-4', + new MessageBag(Message::ofUser('test')), + [], + $platform + ); + + $processor->processInput($input); + + $this->assertSame('gpt-4', $input->getModel()); + } + + public function testDoesNothingWhenPlatformIsNull(): void + { + $router = $this->createMock(RouterInterface::class); + $router->expects($this->never()) + ->method('route'); + + $processor = new ModelRouterInputProcessor($router); + + $input = new Input( + 'gpt-4', + new MessageBag(Message::ofUser('test')), + [] + ); + + $processor->processInput($input); + + $this->assertSame('gpt-4', $input->getModel()); + } + + public function testAppliesTransformer(): void + { + $transformer = $this->createMock(TransformerInterface::class); + $transformer->expects($this->once()) + ->method('transform') + ->willReturnCallback(function (Input $input, RouterContext $context) { + return new Input( + $input->getModel(), + new MessageBag(Message::ofUser('transformed')), + ['transformed' => true] + ); + }); + + $router = new SimpleRouter( + fn () => new RoutingResult('gpt-4-vision', transformer: $transformer) + ); + + $processor = new ModelRouterInputProcessor($router); + + $platform = $this->createMock(PlatformInterface::class); + + $input = new Input( + 'gpt-4', + new MessageBag(Message::ofUser('test')), + [], + $platform + ); + + $processor->processInput($input); + + $this->assertSame('gpt-4-vision', $input->getModel()); + $content = $input->getMessageBag()->getMessages()[0]->getContent(); + $this->assertIsArray($content); + $this->assertInstanceOf(Text::class, $content[0]); + $this->assertSame('transformed', $content[0]->getText()); + $this->assertSame(['transformed' => true], $input->getOptions()); + } + + public function testPassesDefaultModelInContext(): void + { + $capturedContext = null; + + $router = new SimpleRouter( + function (Input $input, RouterContext $context) use (&$capturedContext) { + $capturedContext = $context; + + return new RoutingResult('gpt-4-vision'); + } + ); + + $processor = new ModelRouterInputProcessor($router); + + $platform = $this->createMock(PlatformInterface::class); + + $input = new Input( + 'gpt-4-default', + new MessageBag(Message::ofUser('test')), + [], + $platform + ); + + $processor->processInput($input); + + $this->assertInstanceOf(RouterContext::class, $capturedContext); + $this->assertSame('gpt-4-default', $capturedContext->getDefaultModel()); + } +} diff --git a/src/agent/tests/Router/ChainRouterTest.php b/src/agent/tests/Router/ChainRouterTest.php new file mode 100644 index 000000000..9a1e8d068 --- /dev/null +++ b/src/agent/tests/Router/ChainRouterTest.php @@ -0,0 +1,136 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\Router; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Input; +use Symfony\AI\Agent\Router\ChainRouter; +use Symfony\AI\Agent\Router\Result\RoutingResult; +use Symfony\AI\Agent\Router\RouterContext; +use Symfony\AI\Agent\Router\RouterInterface; +use Symfony\AI\Agent\Router\SimpleRouter; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\PlatformInterface; + +final class ChainRouterTest extends TestCase +{ + public function testReturnsFirstMatchingResult(): void + { + $router1 = new SimpleRouter(fn () => null); + $router2 = new SimpleRouter(fn () => new RoutingResult('gpt-4-vision')); + $router3 = new SimpleRouter(fn () => new RoutingResult('gpt-4')); + + $chainRouter = new ChainRouter([$router1, $router2, $router3]); + + $platform = $this->createMock(PlatformInterface::class); + $context = new RouterContext($platform); + + $input = new Input( + 'gpt-4', + new MessageBag(Message::ofUser('test')), + [] + ); + + $result = $chainRouter->route($input, $context); + $this->assertInstanceOf(RoutingResult::class, $result); + $this->assertSame('gpt-4-vision', $result->getModelName()); + } + + public function testReturnsNullWhenNoRouterMatches(): void + { + $router1 = new SimpleRouter(fn () => null); + $router2 = new SimpleRouter(fn () => null); + + $chainRouter = new ChainRouter([$router1, $router2]); + + $platform = $this->createMock(PlatformInterface::class); + $context = new RouterContext($platform); + + $input = new Input( + 'gpt-4', + new MessageBag(Message::ofUser('test')), + [] + ); + + $result = $chainRouter->route($input, $context); + $this->assertNull($result); + } + + public function testSkipsNullReturningRouters(): void + { + $router1 = new SimpleRouter(fn () => null); + $router2 = new SimpleRouter(fn () => null); + $router3 = new SimpleRouter(fn () => new RoutingResult('gpt-4')); + + $chainRouter = new ChainRouter([$router1, $router2, $router3]); + + $platform = $this->createMock(PlatformInterface::class); + $context = new RouterContext($platform); + + $input = new Input( + 'gpt-4', + new MessageBag(Message::ofUser('test')), + [] + ); + + $result = $chainRouter->route($input, $context); + $this->assertInstanceOf(RoutingResult::class, $result); + $this->assertSame('gpt-4', $result->getModelName()); + } + + public function testWorksWithDifferentRouterImplementations(): void + { + $mockRouter = $this->createMock(RouterInterface::class); + $mockRouter->expects($this->once()) + ->method('route') + ->willReturn(new RoutingResult('custom-model')); + + $chainRouter = new ChainRouter([$mockRouter]); + + $platform = $this->createMock(PlatformInterface::class); + $context = new RouterContext($platform); + + $input = new Input( + 'gpt-4', + new MessageBag(Message::ofUser('test')), + [] + ); + + $result = $chainRouter->route($input, $context); + $this->assertInstanceOf(RoutingResult::class, $result); + $this->assertSame('custom-model', $result->getModelName()); + } + + public function testAcceptsIterableOfRouters(): void + { + $routers = new \ArrayObject([ + new SimpleRouter(fn () => null), + new SimpleRouter(fn () => new RoutingResult('gpt-4-vision')), + ]); + + $chainRouter = new ChainRouter($routers); + + $platform = $this->createMock(PlatformInterface::class); + $context = new RouterContext($platform); + + $input = new Input( + 'gpt-4', + new MessageBag(Message::ofUser('test')), + [] + ); + + $result = $chainRouter->route($input, $context); + $this->assertInstanceOf(RoutingResult::class, $result); + $this->assertSame('gpt-4-vision', $result->getModelName()); + } +} diff --git a/src/agent/tests/Router/SimpleRouterTest.php b/src/agent/tests/Router/SimpleRouterTest.php new file mode 100644 index 000000000..263e2c270 --- /dev/null +++ b/src/agent/tests/Router/SimpleRouterTest.php @@ -0,0 +1,121 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\AI\Agent\Tests\Router; + +use PHPUnit\Framework\TestCase; +use Symfony\AI\Agent\Input; +use Symfony\AI\Agent\Router\Result\RoutingResult; +use Symfony\AI\Agent\Router\RouterContext; +use Symfony\AI\Agent\Router\SimpleRouter; +use Symfony\AI\Platform\Message\Content\ImageUrl; +use Symfony\AI\Platform\Message\Message; +use Symfony\AI\Platform\Message\MessageBag; +use Symfony\AI\Platform\PlatformInterface; + +final class SimpleRouterTest extends TestCase +{ + public function testRoutesBasedOnCallableLogic(): void + { + $router = new SimpleRouter( + fn (Input $input, RouterContext $context) => $input->getMessageBag()->containsImage() + ? new RoutingResult('gpt-4-vision') + : null + ); + + $platform = $this->createMock(PlatformInterface::class); + $context = new RouterContext($platform); + + // Test with image + $inputWithImage = new Input( + 'gpt-4', + new MessageBag(Message::ofUser('test', new ImageUrl(__FILE__))), + [] + ); + $result = $router->route($inputWithImage, $context); + $this->assertInstanceOf(RoutingResult::class, $result); + $this->assertSame('gpt-4-vision', $result->getModelName()); + + // Test without image + $inputWithoutImage = new Input( + 'gpt-4', + new MessageBag(Message::ofUser('test')), + [] + ); + $result = $router->route($inputWithoutImage, $context); + $this->assertNull($result); + } + + public function testCanAccessContextInCallable(): void + { + $router = new SimpleRouter( + fn (Input $input, RouterContext $context) => new RoutingResult($context->getDefaultModel() ?? 'fallback') + ); + + $platform = $this->createMock(PlatformInterface::class); + $context = new RouterContext($platform, metadata: ['default_model' => 'gpt-4']); + + $input = new Input( + 'gpt-4', + new MessageBag(Message::ofUser('test')), + [] + ); + + $result = $router->route($input, $context); + $this->assertInstanceOf(RoutingResult::class, $result); + $this->assertSame('gpt-4', $result->getModelName()); + } + + public function testCanReturnNull(): void + { + $router = new SimpleRouter( + fn (Input $input, RouterContext $context) => null + ); + + $platform = $this->createMock(PlatformInterface::class); + $context = new RouterContext($platform); + + $input = new Input( + 'gpt-4', + new MessageBag(Message::ofUser('test')), + [] + ); + + $result = $router->route($input, $context); + $this->assertNull($result); + } + + public function testCanIncludeReasonAndConfidence(): void + { + $router = new SimpleRouter( + fn () => new RoutingResult( + 'gpt-4-vision', + reason: 'Vision model selected', + confidence: 95 + ) + ); + + $platform = $this->createMock(PlatformInterface::class); + $context = new RouterContext($platform); + + $input = new Input( + 'gpt-4', + new MessageBag(Message::ofUser('test')), + [] + ); + + $result = $router->route($input, $context); + $this->assertInstanceOf(RoutingResult::class, $result); + $this->assertSame('gpt-4-vision', $result->getModelName()); + $this->assertSame('Vision model selected', $result->getReason()); + $this->assertSame(95, $result->getConfidence()); + } +}