From 2c189e093f0afc8ec866a111a424b0c3bdff8f05 Mon Sep 17 00:00:00 2001 From: Gianpiero Fasulo Date: Wed, 30 Sep 2026 10:46:53 +0200 Subject: [PATCH 1/2] feat(router): add load balancing support and unit tests --- src/Router/RouterProvider.php | 407 ++++++++++++++++++ src/Router/Rules/RoundRobinRule.php | 128 ++++++ src/Router/Rules/RoutingRuleInterface.php | 19 + tests/Router/RoundRobinRuleTest.php | 56 +++ .../RouterProviderLoadBalancingTest.php | 66 +++ 5 files changed, 676 insertions(+) create mode 100644 src/Router/RouterProvider.php create mode 100644 src/Router/Rules/RoundRobinRule.php create mode 100644 src/Router/Rules/RoutingRuleInterface.php create mode 100644 tests/Router/RoundRobinRuleTest.php create mode 100644 tests/Router/RouterProviderLoadBalancingTest.php diff --git a/src/Router/RouterProvider.php b/src/Router/RouterProvider.php new file mode 100644 index 0000000..c769f75 --- /dev/null +++ b/src/Router/RouterProvider.php @@ -0,0 +1,407 @@ + + */ + protected array $providers = []; + + /** + * @var list + */ + protected array $fallbackOrder = []; + + /** + * @var Closure(Throwable): bool|null + */ + protected ?Closure $fallbackStrategy = null; + + protected RoutingRuleInterface $rule; + + protected ?string $systemPrompt = null; + + protected ?AIProviderInterface $resolvedProvider = null; + + /** + * @var array + */ + protected array $tools = []; + + /** + * @var array + */ + protected array $providerRoutingConfig = []; + + public function addProvider( + string $name, + AIProviderInterface $provider, + bool $useLoadBalancing = false, + ?int $utilizationRate = null, + ): self { + if ($useLoadBalancing && ($utilizationRate === null || $utilizationRate <= 0)) { + throw new ProviderException( + "RouterProvider: provider '{$name}' has load balancing enabled but utilizationRate is missing or invalid.", + ); + } + + $this->providers[$name] = $provider; + $this->providerRoutingConfig[$name] = [ + 'useLoadBalancing' => $useLoadBalancing, + 'utilizationRate' => $utilizationRate, + ]; + + return $this; + } + + public function setRule(RoutingRuleInterface $rule): self + { + if ($rule instanceof RoundRobinRule) { + $this->configureRoundRobinLoadBalancing($rule); + } + + $this->rule = $rule; + return $this; + } + + /** + * @throws ProviderException + */ + public function setDefaultProvider(string $name): self + { + if (!isset($this->providers[$name])) { + throw new ProviderException( + "RouterProvider: unknown provider '{$name}'. Available: " . implode(', ', array_keys($this->providers)), + ); + } + $this->resolvedProvider = $this->providers[$name]; + return $this; + } + + /** + * @throws ProviderException + */ + public function setFallbackOrder(string ...$names): self + { + foreach ($names as $name) { + if (!isset($this->providers[$name])) { + throw new ProviderException( + "RouterProvider: unknown fallback provider '{$name}'. Available: " . implode(', ', array_keys($this->providers)), + ); + } + } + $this->fallbackOrder = array_values($names); + return $this; + } + + /** + * @param callable(Throwable): bool $strategy + */ + public function setFallbackStrategy(callable $strategy): self + { + $this->fallbackStrategy = static fn (Throwable $throwable): bool => $strategy($throwable); + return $this; + } + + public function systemPrompt(?string $prompt): AIProviderInterface + { + $this->systemPrompt = $prompt; + return $this; + } + + public function setTools(array $tools): AIProviderInterface + { + $this->tools = $tools; + return $this; + } + + /** + * @throws ProviderException + * @throws Throwable + */ + public function chat(Message ...$messages): Message + { + return $this->withFallback( + 'chat', + $messages, + fn (AIProviderInterface $provider): Message => $provider->chat(...$messages), + ); + } + + /** + * @return Generator + * @throws ProviderException + * @throws Throwable + */ + public function stream(Message ...$messages): Generator + { + $candidates = $this->candidates('stream', $messages); + + foreach ($candidates as $index => $name) { + $isLast = $index === array_key_last($candidates); + $this->resolvedProvider = $this->providers[$name]; + $generator = $this->prepare($name)->stream(...$messages); + + try { + $generator->rewind(); + } catch (Throwable $e) { + if (!$this->canFallback($e) || $isLast) { + throw $e; + } + continue; + } + + return $this->replay($generator); + } + + throw new ProviderException('RouterProvider: all providers failed.'); + } + + /** + * @param Generator $primed + * @return Generator + */ + protected function replay(Generator $primed): Generator + { + while ($primed->valid()) { + yield $primed->current(); + $primed->next(); + } + + return $primed->getReturn(); + } + + /** + * @param Message|Message[] $messages + * @param array $response_schema + * @throws ProviderException + * @throws Throwable + */ + public function structured(array|Message $messages, string $class, array $response_schema): Message + { + return $this->withFallback( + 'structured', + is_array($messages) ? $messages : [$messages], + fn (AIProviderInterface $provider): Message => $provider->structured($messages, $class, $response_schema), + ); + } + + /** + * @throws ProviderException + */ + public function messageMapper(): MessageMapperInterface + { + if (!$this->resolvedProvider instanceof AIProviderInterface) { + throw new ProviderException( + 'RouterProvider: no provider available for delegation. Call setDefaultProvider() or make an inference call first.', + ); + } + return $this->resolvedProvider->messageMapper(); + } + + /** + * @throws ProviderException + */ + public function toolPayloadMapper(): ToolMapperInterface + { + if (!$this->resolvedProvider instanceof AIProviderInterface) { + throw new ProviderException( + 'RouterProvider: no provider available for delegation. Call setDefaultProvider() or make an inference call first.', + ); + } + return $this->resolvedProvider->toolPayloadMapper(); + } + + public function setHttpClient(HttpClientInterface $client): AIProviderInterface + { + foreach ($this->providers as $provider) { + $provider->setHttpClient($client); + } + return $this; + } + + /** + * @param Message[] $messages + * @return list + * @throws ProviderException + */ + protected function candidates(string $method, array $messages): array + { + if ($this->providers === []) { + throw new ProviderException( + 'RouterProvider: no providers registered. Call addProvider() to add one.', + ); + } + + if (!isset($this->rule)) { + if ($this->fallbackOrder === []) { + throw new ProviderException( + 'RouterProvider: no routing strategy configured. Call setRule() to set one, or setFallbackOrder() to route without a rule.', + ); + } + + return $this->fallbackOrder; + } + + $primary = $this->resolveProviderName($method, $messages); + $candidates = [$primary]; + + foreach ($this->fallbackOrder as $name) { + if (!in_array($name, $candidates, true)) { + $candidates[] = $name; + } + } + + return $candidates; + } + + /** + * @param Message[] $messages + * @throws ProviderException + */ + protected function resolveProviderName(string $method, array $messages): string + { + $name = $this->rule->resolveProvider($method, $messages, $this->tools); + + if (!isset($this->providers[$name])) { + throw new ProviderException( + "RouterProvider: unknown provider '{$name}'. Available: " . implode(', ', array_keys($this->providers)), + ); + } + + return $name; + } + + /** + * @param Message[] $messages + * @param callable(AIProviderInterface): Message $callback + * @throws Throwable + */ + protected function withFallback(string $method, array $messages, callable $callback): Message + { + $candidates = $this->candidates($method, $messages); + + foreach ($candidates as $index => $name) { + $isLast = $index === array_key_last($candidates); + $this->resolvedProvider = $this->providers[$name]; + + try { + return $callback($this->prepare($name)); + } catch (Throwable $e) { + if (!$this->canFallback($e) || $isLast) { + throw $e; + } + } + } + + throw new ProviderException('RouterProvider: all providers failed.'); + } + + protected function prepare(string $name): AIProviderInterface + { + return $this->providers[$name] + ->systemPrompt($this->systemPrompt) + ->setTools($this->tools); + } + + protected function canFallback(Throwable $e): bool + { + if ($this->fallbackStrategy instanceof Closure) { + return ($this->fallbackStrategy)($e); + } + + return $this->defaultFallbackStrategy($e); + } + + protected function defaultFallbackStrategy(Throwable $e): bool + { + if (!$e instanceof HttpException) { + return false; + } + + $status = $e->response?->statusCode; + + return $status === null || $status === 429 || $status >= 500; + } + + /** + * Reads per-provider routing flags and, when present for all providers in the + * round-robin rule, upgrades it to weighted load balancing. + * + * @throws ProviderException + */ + protected function configureRoundRobinLoadBalancing(RoundRobinRule $rule): void + { + $providers = $rule->getProviders(); + $weights = []; + + foreach ($providers as $name) { + if (!isset($this->providers[$name])) { + throw new ProviderException( + "RouterProvider: unknown provider '{$name}' in RoundRobinRule. Available: " . implode(', ', array_keys($this->providers)), + ); + } + + $config = $this->providerRoutingConfig[$name] ?? [ + 'useLoadBalancing' => false, + 'utilizationRate' => null, + ]; + + if ($config['useLoadBalancing']) { + if ($config['utilizationRate'] === null || $config['utilizationRate'] <= 0) { + throw new ProviderException( + "RouterProvider: provider '{$name}' has invalid utilizationRate for load balancing.", + ); + } + + $weights[$name] = $config['utilizationRate']; + } + } + + if ($weights === []) { + $rule->setUseLoadBalancing(false); + return; + } + + if (count($weights) !== count($providers)) { + throw new ProviderException( + 'RouterProvider: when enabling load balancing in RoundRobinRule, all listed providers must define useLoadBalancing=true and utilizationRate.', + ); + } + + $rule + ->setUseLoadBalancing(true) + ->setProviderWeights($weights); + } +} diff --git a/src/Router/Rules/RoundRobinRule.php b/src/Router/Rules/RoundRobinRule.php new file mode 100644 index 0000000..0499c11 --- /dev/null +++ b/src/Router/Rules/RoundRobinRule.php @@ -0,0 +1,128 @@ + */ + protected array $providerWeights = []; + + /** @var string[] */ + protected array $weightedProviders = []; + + /** + * @param string[] $providers + * @param array $providerWeights + */ + public function __construct(array $providers, bool $useLoadBalancing = false, array $providerWeights = []) + { + $this->providers = array_values($providers); + $this->useLoadBalancing = $useLoadBalancing; + + if ($providerWeights !== []) { + $this->setProviderWeights($providerWeights); + } + } + + /** + * @return string[] + */ + public function getProviders(): array + { + return $this->providers; + } + + public function setUseLoadBalancing(bool $enabled): self + { + $this->useLoadBalancing = $enabled; + $this->index = 0; + + if (!$enabled) { + $this->weightedProviders = []; + } + + return $this; + } + + /** + * @param array $providerWeights + */ + public function setProviderWeights(array $providerWeights): self + { + $this->assertValidWeights($providerWeights); + + $weightedProviders = []; + + foreach ($this->providers as $name) { + $weight = $providerWeights[$name]; + $weightedProviders = array_merge($weightedProviders, array_fill(0, $weight, $name)); + } + + $this->providerWeights = $providerWeights; + $this->weightedProviders = $weightedProviders; + $this->index = 0; + + return $this; + } + + public function resolveProvider(string $method, array $messages, array $tools): string + { + $pool = $this->useLoadBalancing && $this->weightedProviders !== [] + ? $this->weightedProviders + : $this->providers; + + if ($pool === []) { + throw new InvalidArgumentException('RoundRobinRule: providers list cannot be empty.'); + } + + $name = $pool[$this->index]; + $this->index = ($this->index + 1) % count($pool); + + return $name; + } + + /** + * @param array $providerWeights + */ + protected function assertValidWeights(array $providerWeights): void + { + if ($providerWeights === []) { + throw new InvalidArgumentException('RoundRobinRule: providerWeights cannot be empty when load balancing is enabled.'); + } + + foreach ($this->providers as $name) { + if (!array_key_exists($name, $providerWeights)) { + throw new InvalidArgumentException("RoundRobinRule: missing weight for provider '{$name}'."); + } + + if (!is_int($providerWeights[$name]) || $providerWeights[$name] <= 0) { + throw new InvalidArgumentException("RoundRobinRule: weight for provider '{$name}' must be a positive integer."); + } + } + + if (array_sum($providerWeights) !== 100) { + throw new InvalidArgumentException('RoundRobinRule: provider weights must sum to 100.'); + } + } +} diff --git a/src/Router/Rules/RoutingRuleInterface.php b/src/Router/Rules/RoutingRuleInterface.php new file mode 100644 index 0000000..db6aa12 --- /dev/null +++ b/src/Router/Rules/RoutingRuleInterface.php @@ -0,0 +1,19 @@ +assertSame('a', $rule->resolveProvider('chat', [], [])); + $this->assertSame('b', $rule->resolveProvider('chat', [], [])); + $this->assertSame('a', $rule->resolveProvider('chat', [], [])); + } + + public function test_weighted_round_robin_respects_percentages(): void + { + $rule = new RoundRobinRule( + providers: ['anthropic', 'openai'], + useLoadBalancing: true, + providerWeights: ['anthropic' => 30, 'openai' => 70], + ); + + $counts = [ + 'anthropic' => 0, + 'openai' => 0, + ]; + + for ($i = 0; $i < 100; $i++) { + $counts[$rule->resolveProvider('chat', [], [])]++; + } + + $this->assertSame(30, $counts['anthropic']); + $this->assertSame(70, $counts['openai']); + } + + public function test_weighted_round_robin_throws_when_weights_do_not_sum_to_100(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must sum to 100'); + + new RoundRobinRule( + providers: ['anthropic', 'openai'], + useLoadBalancing: true, + providerWeights: ['anthropic' => 40, 'openai' => 40], + ); + } +} diff --git a/tests/Router/RouterProviderLoadBalancingTest.php b/tests/Router/RouterProviderLoadBalancingTest.php new file mode 100644 index 0000000..36019cd --- /dev/null +++ b/tests/Router/RouterProviderLoadBalancingTest.php @@ -0,0 +1,66 @@ +fakeProviderWithResponses('anthropic', 100); + $openai = $this->fakeProviderWithResponses('openai', 100); + + $router = RouterProvider::make() + ->addProvider('anthropic', $anthropic, useLoadBalancing: true, utilizationRate: 30) + ->addProvider('openai', $openai, useLoadBalancing: true, utilizationRate: 70) + ->setRule(new RoundRobinRule(['anthropic', 'openai'])); + + foreach (range(1, 100) as $_) { + $router->chat(UserMessage::make('hello')); + } + + $this->assertSame(30, $anthropic->getCallCount()); + $this->assertSame(70, $openai->getCallCount()); + } + + public function test_router_provider_keeps_standard_round_robin_when_not_configured(): void + { + $providerA = $this->fakeProviderWithResponses('a', 20); + $providerB = $this->fakeProviderWithResponses('b', 20); + + $router = RouterProvider::make() + ->addProvider('a', $providerA) + ->addProvider('b', $providerB) + ->setRule(new RoundRobinRule(['a', 'b'])); + + foreach (range(1, 10) as $_) { + $router->chat(UserMessage::make('hi')); + } + + $this->assertSame(5, $providerA->getCallCount()); + $this->assertSame(5, $providerB->getCallCount()); + } + + protected function fakeProviderWithResponses(string $label, int $count): FakeAIProvider + { + $messages = []; + + foreach (range(1, $count) as $index) { + $messages[] = new AssistantMessage("{$label}-{$index}"); + } + + return new FakeAIProvider(...$messages); + } +} From 370ec77eb21fe8c8b288f8ede393835792ff8b33 Mon Sep 17 00:00:00 2001 From: Gianpiero Fasulo Date: Fri, 2 Oct 2026 09:27:30 +0200 Subject: [PATCH 2/2] refactor: align package structure with PSR-4 autoload directly under src --- src/Router/RouterProvider.php | 407 ------------------ src/Router/Rules/RoundRobinRule.php | 128 ------ src/Router/Rules/RoutingRuleInterface.php | 19 - src/RouterProvider.php | 102 ++++- src/Rules/RoundRobinRule.php | 158 +++++-- src/Rules/RoutingRuleInterface.php | 34 +- tests/{Router => }/RoundRobinRuleTest.php | 4 +- .../RouterProviderLoadBalancingTest.php | 6 +- 8 files changed, 240 insertions(+), 618 deletions(-) delete mode 100644 src/Router/RouterProvider.php delete mode 100644 src/Router/Rules/RoundRobinRule.php delete mode 100644 src/Router/Rules/RoutingRuleInterface.php rename tests/{Router => }/RoundRobinRuleTest.php (92%) rename tests/{Router => }/RouterProviderLoadBalancingTest.php (92%) diff --git a/src/Router/RouterProvider.php b/src/Router/RouterProvider.php deleted file mode 100644 index c769f75..0000000 --- a/src/Router/RouterProvider.php +++ /dev/null @@ -1,407 +0,0 @@ - - */ - protected array $providers = []; - - /** - * @var list - */ - protected array $fallbackOrder = []; - - /** - * @var Closure(Throwable): bool|null - */ - protected ?Closure $fallbackStrategy = null; - - protected RoutingRuleInterface $rule; - - protected ?string $systemPrompt = null; - - protected ?AIProviderInterface $resolvedProvider = null; - - /** - * @var array - */ - protected array $tools = []; - - /** - * @var array - */ - protected array $providerRoutingConfig = []; - - public function addProvider( - string $name, - AIProviderInterface $provider, - bool $useLoadBalancing = false, - ?int $utilizationRate = null, - ): self { - if ($useLoadBalancing && ($utilizationRate === null || $utilizationRate <= 0)) { - throw new ProviderException( - "RouterProvider: provider '{$name}' has load balancing enabled but utilizationRate is missing or invalid.", - ); - } - - $this->providers[$name] = $provider; - $this->providerRoutingConfig[$name] = [ - 'useLoadBalancing' => $useLoadBalancing, - 'utilizationRate' => $utilizationRate, - ]; - - return $this; - } - - public function setRule(RoutingRuleInterface $rule): self - { - if ($rule instanceof RoundRobinRule) { - $this->configureRoundRobinLoadBalancing($rule); - } - - $this->rule = $rule; - return $this; - } - - /** - * @throws ProviderException - */ - public function setDefaultProvider(string $name): self - { - if (!isset($this->providers[$name])) { - throw new ProviderException( - "RouterProvider: unknown provider '{$name}'. Available: " . implode(', ', array_keys($this->providers)), - ); - } - $this->resolvedProvider = $this->providers[$name]; - return $this; - } - - /** - * @throws ProviderException - */ - public function setFallbackOrder(string ...$names): self - { - foreach ($names as $name) { - if (!isset($this->providers[$name])) { - throw new ProviderException( - "RouterProvider: unknown fallback provider '{$name}'. Available: " . implode(', ', array_keys($this->providers)), - ); - } - } - $this->fallbackOrder = array_values($names); - return $this; - } - - /** - * @param callable(Throwable): bool $strategy - */ - public function setFallbackStrategy(callable $strategy): self - { - $this->fallbackStrategy = static fn (Throwable $throwable): bool => $strategy($throwable); - return $this; - } - - public function systemPrompt(?string $prompt): AIProviderInterface - { - $this->systemPrompt = $prompt; - return $this; - } - - public function setTools(array $tools): AIProviderInterface - { - $this->tools = $tools; - return $this; - } - - /** - * @throws ProviderException - * @throws Throwable - */ - public function chat(Message ...$messages): Message - { - return $this->withFallback( - 'chat', - $messages, - fn (AIProviderInterface $provider): Message => $provider->chat(...$messages), - ); - } - - /** - * @return Generator - * @throws ProviderException - * @throws Throwable - */ - public function stream(Message ...$messages): Generator - { - $candidates = $this->candidates('stream', $messages); - - foreach ($candidates as $index => $name) { - $isLast = $index === array_key_last($candidates); - $this->resolvedProvider = $this->providers[$name]; - $generator = $this->prepare($name)->stream(...$messages); - - try { - $generator->rewind(); - } catch (Throwable $e) { - if (!$this->canFallback($e) || $isLast) { - throw $e; - } - continue; - } - - return $this->replay($generator); - } - - throw new ProviderException('RouterProvider: all providers failed.'); - } - - /** - * @param Generator $primed - * @return Generator - */ - protected function replay(Generator $primed): Generator - { - while ($primed->valid()) { - yield $primed->current(); - $primed->next(); - } - - return $primed->getReturn(); - } - - /** - * @param Message|Message[] $messages - * @param array $response_schema - * @throws ProviderException - * @throws Throwable - */ - public function structured(array|Message $messages, string $class, array $response_schema): Message - { - return $this->withFallback( - 'structured', - is_array($messages) ? $messages : [$messages], - fn (AIProviderInterface $provider): Message => $provider->structured($messages, $class, $response_schema), - ); - } - - /** - * @throws ProviderException - */ - public function messageMapper(): MessageMapperInterface - { - if (!$this->resolvedProvider instanceof AIProviderInterface) { - throw new ProviderException( - 'RouterProvider: no provider available for delegation. Call setDefaultProvider() or make an inference call first.', - ); - } - return $this->resolvedProvider->messageMapper(); - } - - /** - * @throws ProviderException - */ - public function toolPayloadMapper(): ToolMapperInterface - { - if (!$this->resolvedProvider instanceof AIProviderInterface) { - throw new ProviderException( - 'RouterProvider: no provider available for delegation. Call setDefaultProvider() or make an inference call first.', - ); - } - return $this->resolvedProvider->toolPayloadMapper(); - } - - public function setHttpClient(HttpClientInterface $client): AIProviderInterface - { - foreach ($this->providers as $provider) { - $provider->setHttpClient($client); - } - return $this; - } - - /** - * @param Message[] $messages - * @return list - * @throws ProviderException - */ - protected function candidates(string $method, array $messages): array - { - if ($this->providers === []) { - throw new ProviderException( - 'RouterProvider: no providers registered. Call addProvider() to add one.', - ); - } - - if (!isset($this->rule)) { - if ($this->fallbackOrder === []) { - throw new ProviderException( - 'RouterProvider: no routing strategy configured. Call setRule() to set one, or setFallbackOrder() to route without a rule.', - ); - } - - return $this->fallbackOrder; - } - - $primary = $this->resolveProviderName($method, $messages); - $candidates = [$primary]; - - foreach ($this->fallbackOrder as $name) { - if (!in_array($name, $candidates, true)) { - $candidates[] = $name; - } - } - - return $candidates; - } - - /** - * @param Message[] $messages - * @throws ProviderException - */ - protected function resolveProviderName(string $method, array $messages): string - { - $name = $this->rule->resolveProvider($method, $messages, $this->tools); - - if (!isset($this->providers[$name])) { - throw new ProviderException( - "RouterProvider: unknown provider '{$name}'. Available: " . implode(', ', array_keys($this->providers)), - ); - } - - return $name; - } - - /** - * @param Message[] $messages - * @param callable(AIProviderInterface): Message $callback - * @throws Throwable - */ - protected function withFallback(string $method, array $messages, callable $callback): Message - { - $candidates = $this->candidates($method, $messages); - - foreach ($candidates as $index => $name) { - $isLast = $index === array_key_last($candidates); - $this->resolvedProvider = $this->providers[$name]; - - try { - return $callback($this->prepare($name)); - } catch (Throwable $e) { - if (!$this->canFallback($e) || $isLast) { - throw $e; - } - } - } - - throw new ProviderException('RouterProvider: all providers failed.'); - } - - protected function prepare(string $name): AIProviderInterface - { - return $this->providers[$name] - ->systemPrompt($this->systemPrompt) - ->setTools($this->tools); - } - - protected function canFallback(Throwable $e): bool - { - if ($this->fallbackStrategy instanceof Closure) { - return ($this->fallbackStrategy)($e); - } - - return $this->defaultFallbackStrategy($e); - } - - protected function defaultFallbackStrategy(Throwable $e): bool - { - if (!$e instanceof HttpException) { - return false; - } - - $status = $e->response?->statusCode; - - return $status === null || $status === 429 || $status >= 500; - } - - /** - * Reads per-provider routing flags and, when present for all providers in the - * round-robin rule, upgrades it to weighted load balancing. - * - * @throws ProviderException - */ - protected function configureRoundRobinLoadBalancing(RoundRobinRule $rule): void - { - $providers = $rule->getProviders(); - $weights = []; - - foreach ($providers as $name) { - if (!isset($this->providers[$name])) { - throw new ProviderException( - "RouterProvider: unknown provider '{$name}' in RoundRobinRule. Available: " . implode(', ', array_keys($this->providers)), - ); - } - - $config = $this->providerRoutingConfig[$name] ?? [ - 'useLoadBalancing' => false, - 'utilizationRate' => null, - ]; - - if ($config['useLoadBalancing']) { - if ($config['utilizationRate'] === null || $config['utilizationRate'] <= 0) { - throw new ProviderException( - "RouterProvider: provider '{$name}' has invalid utilizationRate for load balancing.", - ); - } - - $weights[$name] = $config['utilizationRate']; - } - } - - if ($weights === []) { - $rule->setUseLoadBalancing(false); - return; - } - - if (count($weights) !== count($providers)) { - throw new ProviderException( - 'RouterProvider: when enabling load balancing in RoundRobinRule, all listed providers must define useLoadBalancing=true and utilizationRate.', - ); - } - - $rule - ->setUseLoadBalancing(true) - ->setProviderWeights($weights); - } -} diff --git a/src/Router/Rules/RoundRobinRule.php b/src/Router/Rules/RoundRobinRule.php deleted file mode 100644 index 0499c11..0000000 --- a/src/Router/Rules/RoundRobinRule.php +++ /dev/null @@ -1,128 +0,0 @@ - */ - protected array $providerWeights = []; - - /** @var string[] */ - protected array $weightedProviders = []; - - /** - * @param string[] $providers - * @param array $providerWeights - */ - public function __construct(array $providers, bool $useLoadBalancing = false, array $providerWeights = []) - { - $this->providers = array_values($providers); - $this->useLoadBalancing = $useLoadBalancing; - - if ($providerWeights !== []) { - $this->setProviderWeights($providerWeights); - } - } - - /** - * @return string[] - */ - public function getProviders(): array - { - return $this->providers; - } - - public function setUseLoadBalancing(bool $enabled): self - { - $this->useLoadBalancing = $enabled; - $this->index = 0; - - if (!$enabled) { - $this->weightedProviders = []; - } - - return $this; - } - - /** - * @param array $providerWeights - */ - public function setProviderWeights(array $providerWeights): self - { - $this->assertValidWeights($providerWeights); - - $weightedProviders = []; - - foreach ($this->providers as $name) { - $weight = $providerWeights[$name]; - $weightedProviders = array_merge($weightedProviders, array_fill(0, $weight, $name)); - } - - $this->providerWeights = $providerWeights; - $this->weightedProviders = $weightedProviders; - $this->index = 0; - - return $this; - } - - public function resolveProvider(string $method, array $messages, array $tools): string - { - $pool = $this->useLoadBalancing && $this->weightedProviders !== [] - ? $this->weightedProviders - : $this->providers; - - if ($pool === []) { - throw new InvalidArgumentException('RoundRobinRule: providers list cannot be empty.'); - } - - $name = $pool[$this->index]; - $this->index = ($this->index + 1) % count($pool); - - return $name; - } - - /** - * @param array $providerWeights - */ - protected function assertValidWeights(array $providerWeights): void - { - if ($providerWeights === []) { - throw new InvalidArgumentException('RoundRobinRule: providerWeights cannot be empty when load balancing is enabled.'); - } - - foreach ($this->providers as $name) { - if (!array_key_exists($name, $providerWeights)) { - throw new InvalidArgumentException("RoundRobinRule: missing weight for provider '{$name}'."); - } - - if (!is_int($providerWeights[$name]) || $providerWeights[$name] <= 0) { - throw new InvalidArgumentException("RoundRobinRule: weight for provider '{$name}' must be a positive integer."); - } - } - - if (array_sum($providerWeights) !== 100) { - throw new InvalidArgumentException('RoundRobinRule: provider weights must sum to 100.'); - } - } -} diff --git a/src/Router/Rules/RoutingRuleInterface.php b/src/Router/Rules/RoutingRuleInterface.php deleted file mode 100644 index db6aa12..0000000 --- a/src/Router/Rules/RoutingRuleInterface.php +++ /dev/null @@ -1,19 +0,0 @@ - + */ + protected array $providerRoutingConfig = []; + + public function addProvider( + string $name, + AIProviderInterface $provider, + bool $useLoadBalancing = false, + ?int $utilizationRate = null, + ): self { + if ($useLoadBalancing && ($utilizationRate === null || $utilizationRate <= 0)) { + throw new ProviderException( + "RouterProvider: provider '{$name}' has load balancing enabled but utilizationRate is missing or invalid.", + ); + } + $this->providers[$name] = $provider; + $this->providerRoutingConfig[$name] = [ + 'useLoadBalancing' => $useLoadBalancing, + 'utilizationRate' => $utilizationRate, + ]; + return $this; } public function setRule(RoutingRuleInterface $rule): self { + if ($rule instanceof RoundRobinRule) { + $this->configureRoundRobinLoadBalancing($rule); + } + $this->rule = $rule; return $this; } @@ -141,7 +169,7 @@ public function chat(Message ...$messages): Message return $this->withFallback( 'chat', $messages, - fn (AIProviderInterface $provider): \NeuronAI\Chat\Messages\Message => $provider->chat(...$messages), + fn (AIProviderInterface $provider): Message => $provider->chat(...$messages), ); } @@ -213,7 +241,7 @@ public function structured(array|Message $messages, string $class, array $respon return $this->withFallback( 'structured', is_array($messages) ? $messages : [$messages], - fn (AIProviderInterface $provider): \NeuronAI\Chat\Messages\Message => $provider->structured($messages, $class, $response_schema), + fn (AIProviderInterface $provider): Message => $provider->structured($messages, $class, $response_schema), ); } @@ -222,7 +250,7 @@ public function structured(array|Message $messages, string $class, array $respon */ public function messageMapper(): MessageMapperInterface { - if (!$this->resolvedProvider instanceof \NeuronAI\Providers\AIProviderInterface) { + if (!$this->resolvedProvider instanceof AIProviderInterface) { throw new ProviderException( 'RouterProvider: no provider available for delegation. Call setDefaultProvider() or make an inference call first.', ); @@ -235,7 +263,7 @@ public function messageMapper(): MessageMapperInterface */ public function toolPayloadMapper(): ToolMapperInterface { - if (!$this->resolvedProvider instanceof \NeuronAI\Providers\AIProviderInterface) { + if (!$this->resolvedProvider instanceof AIProviderInterface) { throw new ProviderException( 'RouterProvider: no provider available for delegation. Call setDefaultProvider() or make an inference call first.', ); @@ -378,4 +406,54 @@ protected function defaultFallbackStrategy(Throwable $e): bool return $status === null || $status === 429 || $status >= 500; } + + /** + * Reads per-provider routing flags and, when present for all providers in the + * round-robin rule, upgrades it to weighted load balancing. + * + * @throws ProviderException + */ + protected function configureRoundRobinLoadBalancing(RoundRobinRule $rule): void + { + $providers = $rule->getProviders(); + $weights = []; + + foreach ($providers as $name) { + if (!isset($this->providers[$name])) { + throw new ProviderException( + "RouterProvider: unknown provider '{$name}' in RoundRobinRule. Available: " . implode(', ', array_keys($this->providers)), + ); + } + + $config = $this->providerRoutingConfig[$name] ?? [ + 'useLoadBalancing' => false, + 'utilizationRate' => null, + ]; + + if ($config['useLoadBalancing']) { + if ($config['utilizationRate'] === null || $config['utilizationRate'] <= 0) { + throw new ProviderException( + "RouterProvider: provider '{$name}' has invalid utilizationRate for load balancing.", + ); + } + + $weights[$name] = $config['utilizationRate']; + } + } + + if ($weights === []) { + $rule->setUseLoadBalancing(false); + return; + } + + if (count($weights) !== count($providers)) { + throw new ProviderException( + 'RouterProvider: when enabling load balancing in RoundRobinRule, all listed providers must define useLoadBalancing=true and utilizationRate.', + ); + } + + $rule + ->setUseLoadBalancing(true) + ->setProviderWeights($weights); + } } diff --git a/src/Rules/RoundRobinRule.php b/src/Rules/RoundRobinRule.php index 07d30d1..72d373c 100644 --- a/src/Rules/RoundRobinRule.php +++ b/src/Rules/RoundRobinRule.php @@ -1,30 +1,128 @@ -providers = array_values($providers); - } - - public function resolveProvider(string $method, array $messages, array $tools): string - { - $name = $this->providers[$this->index]; - - $this->index = ($this->index + 1) % count($this->providers); - - return $name; - } -} + */ + protected array $providerWeights = []; + + /** @var string[] */ + protected array $weightedProviders = []; + + /** + * @param string[] $providers + * @param array $providerWeights + */ + public function __construct(array $providers, bool $useLoadBalancing = false, array $providerWeights = []) + { + $this->providers = array_values($providers); + $this->useLoadBalancing = $useLoadBalancing; + + if ($providerWeights !== []) { + $this->setProviderWeights($providerWeights); + } + } + + /** + * @return string[] + */ + public function getProviders(): array + { + return $this->providers; + } + + public function setUseLoadBalancing(bool $enabled): self + { + $this->useLoadBalancing = $enabled; + $this->index = 0; + + if (!$enabled) { + $this->weightedProviders = []; + } + + return $this; + } + + /** + * @param array $providerWeights + */ + public function setProviderWeights(array $providerWeights): self + { + $this->assertValidWeights($providerWeights); + + $weightedProviders = []; + + foreach ($this->providers as $name) { + $weight = $providerWeights[$name]; + $weightedProviders = array_merge($weightedProviders, array_fill(0, $weight, $name)); + } + + $this->providerWeights = $providerWeights; + $this->weightedProviders = $weightedProviders; + $this->index = 0; + + return $this; + } + + public function resolveProvider(string $method, array $messages, array $tools): string + { + $pool = $this->useLoadBalancing && $this->weightedProviders !== [] + ? $this->weightedProviders + : $this->providers; + + if ($pool === []) { + throw new InvalidArgumentException('RoundRobinRule: providers list cannot be empty.'); + } + + $name = $pool[$this->index]; + $this->index = ($this->index + 1) % count($pool); + + return $name; + } + + /** + * @param array $providerWeights + */ + protected function assertValidWeights(array $providerWeights): void + { + if ($providerWeights === []) { + throw new InvalidArgumentException('RoundRobinRule: providerWeights cannot be empty when load balancing is enabled.'); + } + + foreach ($this->providers as $name) { + if (!array_key_exists($name, $providerWeights)) { + throw new InvalidArgumentException("RoundRobinRule: missing weight for provider '{$name}'."); + } + + if (!is_int($providerWeights[$name]) || $providerWeights[$name] <= 0) { + throw new InvalidArgumentException("RoundRobinRule: weight for provider '{$name}' must be a positive integer."); + } + } + + if (array_sum($providerWeights) !== 100) { + throw new InvalidArgumentException('RoundRobinRule: provider weights must sum to 100.'); + } + } +} diff --git a/src/Rules/RoutingRuleInterface.php b/src/Rules/RoutingRuleInterface.php index a216462..49b075b 100644 --- a/src/Rules/RoutingRuleInterface.php +++ b/src/Rules/RoutingRuleInterface.php @@ -1,17 +1,17 @@ -