From 36952e6e3e9bc3c190247d92909d39f497d117d7 Mon Sep 17 00:00:00 2001 From: Johannes Wachter Date: Mon, 8 Dec 2025 21:08:05 +0100 Subject: [PATCH] [Agent] Add Model Router system for intelligent model selection Implements Phase 1 of the Model Router architecture, enabling intelligent model selection based on input characteristics. This allows agents to automatically route requests to appropriate models (e.g., vision models for images, speech models for audio). Core Infrastructure: - RouterInterface: Universal contract for all router types - RoutingResult: Result with model name, optional transformer, reason, and confidence - TransformerInterface: Contract for input transformation - RouterContext: Provides platform access, model catalog, and metadata - SimpleRouter: Callable-based router for flexible routing logic - ChainRouter: Composite router for trying multiple routers in sequence - ModelRouterInputProcessor: Integration with Agent's input processing pipeline Input Enhancement: - Add optional platform property to Input class (backward compatible) - Update Agent to pass platform when creating Input - Platform available to input processors via Input.getPlatform() Includes 6 examples demonstrating different routing patterns and comprehensive test coverage (14 router tests, all 187 agent tests passing). Key Features: - No breaking changes - all changes are backward compatible - Extensible design - custom routers in <20 lines - Multi-provider support - works with any Platform implementation - Transformation support - routers can transform input before invocation - Default model fallback - routers can access agent's default model - Capability-based routing - find models supporting specific capabilities Architecture Decisions: - Single RouterInterface for all router types enables composition - Transformer in RoutingResult (router decides both model and transformation) - Platform passed via Input (no breaking changes to interfaces) - RouterContext provides metadata including default model - Start simple with SimpleRouter, add complexity gradually --- examples/router/01-vision-simple.php | 63 +++++++ examples/router/02-vision-with-fallback.php | 62 +++++++ examples/router/03-multi-provider.php | 82 +++++++++ examples/router/04-content-detection.php | 89 ++++++++++ examples/router/05-cost-optimized.php | 98 +++++++++++ examples/router/06-capability-check.php | 102 +++++++++++ src/agent/src/Agent.php | 2 +- src/agent/src/Input.php | 12 ++ .../ModelRouterInputProcessor.php | 66 ++++++++ src/agent/src/Router/ChainRouter.php | 43 +++++ src/agent/src/Router/Result/RoutingResult.php | 50 ++++++ src/agent/src/Router/RouterContext.php | 89 ++++++++++ src/agent/src/Router/RouterInterface.php | 33 ++++ src/agent/src/Router/SimpleRouter.php | 36 ++++ .../Transformer/TransformerInterface.php | 30 ++++ .../ModelRouterInputProcessorTest.php | 158 ++++++++++++++++++ src/agent/tests/Router/ChainRouterTest.php | 136 +++++++++++++++ src/agent/tests/Router/SimpleRouterTest.php | 121 ++++++++++++++ 18 files changed, 1271 insertions(+), 1 deletion(-) create mode 100644 examples/router/01-vision-simple.php create mode 100644 examples/router/02-vision-with-fallback.php create mode 100644 examples/router/03-multi-provider.php create mode 100644 examples/router/04-content-detection.php create mode 100644 examples/router/05-cost-optimized.php create mode 100644 examples/router/06-capability-check.php create mode 100644 src/agent/src/InputProcessor/ModelRouterInputProcessor.php create mode 100644 src/agent/src/Router/ChainRouter.php create mode 100644 src/agent/src/Router/Result/RoutingResult.php create mode 100644 src/agent/src/Router/RouterContext.php create mode 100644 src/agent/src/Router/RouterInterface.php create mode 100644 src/agent/src/Router/SimpleRouter.php create mode 100644 src/agent/src/Router/Transformer/TransformerInterface.php create mode 100644 src/agent/tests/InputProcessor/ModelRouterInputProcessorTest.php create mode 100644 src/agent/tests/Router/ChainRouterTest.php create mode 100644 src/agent/tests/Router/SimpleRouterTest.php 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()); + } +}