diff --git a/src/Providers/ProviderRegistry.php b/src/Providers/ProviderRegistry.php new file mode 100644 index 00000000..8fc39c76 --- /dev/null +++ b/src/Providers/ProviderRegistry.php @@ -0,0 +1,231 @@ +> Mapping of provider IDs to class names. + */ + private array $providerClassNames = []; + + /** + * @var array, true> Set of registered class names for fast lookup. + */ + private array $registeredClassNames = []; + + + /** + * Registers a provider class with the registry. + * + * @since n.e.x.t + * + * @param class-string $className The fully qualified provider class name implementing the + * ProviderInterface + * @throws InvalidArgumentException If the class doesn't exist or implement the required interface. + */ + public function registerProvider(string $className): void + { + if (!class_exists($className)) { + throw new InvalidArgumentException( + sprintf('Provider class does not exist: %s', $className) + ); + } + + // Validate that class implements ProviderInterface + if (!is_subclass_of($className, ProviderInterface::class)) { + throw new InvalidArgumentException( + sprintf('Provider class must implement %s: %s', ProviderInterface::class, $className) + ); + } + + $metadata = $className::metadata(); + + if (!$metadata instanceof ProviderMetadata) { + throw new InvalidArgumentException( + sprintf('Provider must return ProviderMetadata from metadata() method: %s', $className) + ); + } + + $this->providerClassNames[$metadata->getId()] = $className; + $this->registeredClassNames[$className] = true; + } + + /** + * Checks if a provider is registered. + * + * @since n.e.x.t + * + * @param string|class-string $idOrClassName The provider ID or class name to check. + * @return bool True if the provider is registered. + */ + public function hasProvider(string $idOrClassName): bool + { + return isset($this->providerClassNames[$idOrClassName]) || + isset($this->registeredClassNames[$idOrClassName]); + } + + /** + * Gets the class name for a registered provider. + * + * @since n.e.x.t + * + * @param string $id The provider ID. + * @return string The provider class name. + * @throws InvalidArgumentException If the provider is not registered. + */ + public function getProviderClassName(string $id): string + { + if (!isset($this->providerClassNames[$id])) { + throw new InvalidArgumentException( + sprintf('Provider not registered: %s', $id) + ); + } + + return $this->providerClassNames[$id]; + } + + /** + * Checks if a provider is properly configured. + * + * @since n.e.x.t + * + * @param string|class-string $idOrClassName The provider ID or class name. + * @return bool True if the provider is configured and ready to use. + */ + public function isProviderConfigured(string $idOrClassName): bool + { + try { + $className = $this->resolveProviderClassName($idOrClassName); + + // Use static method from ProviderInterface + /** @var class-string $className */ + $availability = $className::availability(); + + return $availability->isConfigured(); + } catch (InvalidArgumentException $e) { + return false; + } + } + + /** + * Finds models across all providers that support the given requirements. + * + * @since n.e.x.t + * + * @param ModelRequirements $modelRequirements The requirements to match against. + * @return list List of provider models metadata that match requirements. + */ + public function findModelsMetadataForSupport(ModelRequirements $modelRequirements): array + { + $results = []; + + foreach ($this->providerClassNames as $providerId => $className) { + $providerResults = $this->findProviderModelsMetadataForSupport($providerId, $modelRequirements); + if (!empty($providerResults)) { + // Use static method from ProviderInterface + /** @var class-string $className */ + $providerMetadata = $className::metadata(); + + $results[] = new ProviderModelsMetadata( + $providerMetadata, + $providerResults + ); + } + } + + return $results; + } + + /** + * Finds models within a specific provider that support the given requirements. + * + * @since n.e.x.t + * + * @param string $idOrClassName The provider ID or class name. + * @param ModelRequirements $modelRequirements The requirements to match against. + * @return list List of model metadata that match requirements. + */ + public function findProviderModelsMetadataForSupport( + string $idOrClassName, + ModelRequirements $modelRequirements + ): array { + $className = $this->resolveProviderClassName($idOrClassName); + + $modelMetadataDirectory = $className::modelMetadataDirectory(); + + // Filter models that meet requirements + $matchingModels = []; + foreach ($modelMetadataDirectory->listModelMetadata() as $modelMetadata) { + if ($modelMetadata->meetsRequirements($modelRequirements)) { + $matchingModels[] = $modelMetadata; + } + } + + return $matchingModels; + } + + /** + * Gets a configured model instance from a provider. + * + * @since n.e.x.t + * + * @param string|class-string $idOrClassName The provider ID or class name. + * @param string $modelId The model identifier. + * @param ModelConfig|null $modelConfig The model configuration. + * @return ModelInterface The configured model instance. + * @throws InvalidArgumentException If provider or model is not found. + */ + public function getProviderModel( + string $idOrClassName, + string $modelId, + ?ModelConfig $modelConfig = null + ): ModelInterface { + $className = $this->resolveProviderClassName($idOrClassName); + + // Use static method from ProviderInterface + /** @var class-string $className */ + return $className::model($modelId, $modelConfig); + } + + /** + * Gets the class name for a registered provider (handles both ID and class name input). + * + * @param string|class-string $idOrClassName The provider ID or class name. + * @return class-string The provider class name. + * @throws InvalidArgumentException If provider is not registered. + */ + private function resolveProviderClassName(string $idOrClassName): string + { + // Handle both ID and class name + $className = $this->providerClassNames[$idOrClassName] ?? $idOrClassName; + + if (!$this->hasProvider($idOrClassName)) { + throw new InvalidArgumentException( + sprintf('Provider not registered: %s', $idOrClassName) + ); + } + + // @phpstan-ignore-next-line return.type (Interface implementation guaranteed by registration validation) + return $className; + } +} diff --git a/tests/mocks/MockModel.php b/tests/mocks/MockModel.php new file mode 100644 index 00000000..57541368 --- /dev/null +++ b/tests/mocks/MockModel.php @@ -0,0 +1,63 @@ +metadata = $metadata; + $this->config = $config; + } + + /** + * {@inheritDoc} + */ + public function metadata(): ModelMetadata + { + return $this->metadata; + } + + /** + * {@inheritDoc} + */ + public function getConfig(): ModelConfig + { + return $this->config; + } + + /** + * {@inheritDoc} + */ + public function setConfig(ModelConfig $config): void + { + $this->config = $config; + } +} diff --git a/tests/mocks/MockModelMetadataDirectory.php b/tests/mocks/MockModelMetadataDirectory.php new file mode 100644 index 00000000..e7e19e3a --- /dev/null +++ b/tests/mocks/MockModelMetadataDirectory.php @@ -0,0 +1,62 @@ + Available models. + */ + private array $models = []; + + /** + * Constructor. + * + * @param array $models Available models. + */ + public function __construct(array $models = []) + { + $this->models = $models; + } + + /** + * {@inheritDoc} + */ + public function listModelMetadata(): array + { + return array_values($this->models); + } + + /** + * {@inheritDoc} + */ + public function hasModelMetadata(string $modelId): bool + { + return isset($this->models[$modelId]); + } + + /** + * {@inheritDoc} + */ + public function getModelMetadata(string $modelId): ModelMetadata + { + if (!isset($this->models[$modelId])) { + throw new InvalidArgumentException( + sprintf('Model not found: %s', $modelId) + ); + } + + return $this->models[$modelId]; + } +} diff --git a/tests/mocks/MockProvider.php b/tests/mocks/MockProvider.php new file mode 100644 index 00000000..7b0a07cc --- /dev/null +++ b/tests/mocks/MockProvider.php @@ -0,0 +1,119 @@ +getModelMetadata($modelId); + + $config = $modelConfig ?? new ModelConfig(); + + return new MockModel($modelMetadata, $config); + } + + /** + * {@inheritDoc} + */ + public static function availability(): ProviderAvailabilityInterface + { + if (static::$availability === null) { + static::$availability = new MockProviderAvailability(true); + } + + return static::$availability; + } + + /** + * {@inheritDoc} + */ + public static function modelMetadataDirectory(): ModelMetadataDirectoryInterface + { + if (static::$modelMetadataDirectory === null) { + // Create some mock models for testing + $mockModels = [ + 'mock-text-model' => new \WordPress\AiClient\Providers\Models\DTO\ModelMetadata( + 'mock-text-model', + 'Mock Text Model', + [CapabilityEnum::textGeneration()], + [] + ) + ]; + + static::$modelMetadataDirectory = new MockModelMetadataDirectory($mockModels); + } + + return static::$modelMetadataDirectory; + } + + /** + * Sets the availability checker for testing. + * + * @param MockProviderAvailability $availability The availability checker. + */ + public static function setAvailability(MockProviderAvailability $availability): void + { + static::$availability = $availability; + } + + /** + * Sets the model metadata directory for testing. + * + * @param MockModelMetadataDirectory $directory The model metadata directory. + */ + public static function setModelMetadataDirectory(MockModelMetadataDirectory $directory): void + { + static::$modelMetadataDirectory = $directory; + } + + /** + * Resets static state for testing. + */ + public static function reset(): void + { + static::$availability = null; + static::$modelMetadataDirectory = null; + } +} diff --git a/tests/mocks/MockProviderAvailability.php b/tests/mocks/MockProviderAvailability.php new file mode 100644 index 00000000..805aa3df --- /dev/null +++ b/tests/mocks/MockProviderAvailability.php @@ -0,0 +1,38 @@ +configured = $configured; + } + + /** + * {@inheritDoc} + */ + public function isConfigured(): bool + { + return $this->configured; + } +} diff --git a/tests/unit/Providers/ProviderRegistryTest.php b/tests/unit/Providers/ProviderRegistryTest.php new file mode 100644 index 00000000..832980d4 --- /dev/null +++ b/tests/unit/Providers/ProviderRegistryTest.php @@ -0,0 +1,212 @@ +registry = new ProviderRegistry(); + } + + /** + * Tests provider registration with valid provider. + * + * @return void + */ + public function testRegisterProviderWithValidProvider(): void + { + $this->registry->registerProvider(MockProvider::class); + + $this->assertTrue($this->registry->hasProvider('mock')); + $this->assertTrue($this->registry->hasProvider(MockProvider::class)); + $this->assertEquals(MockProvider::class, $this->registry->getProviderClassName('mock')); + } + + /** + * Tests provider registration with non-existent class. + * + * @return void + */ + public function testRegisterProviderWithNonExistentClass(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Provider class does not exist: NonExistentProvider'); + + $this->registry->registerProvider('NonExistentProvider'); + } + + /** + * Tests hasProvider with unregistered provider. + * + * @return void + */ + public function testHasProviderWithUnregisteredProvider(): void + { + $this->assertFalse($this->registry->hasProvider('nonexistent')); + $this->assertFalse($this->registry->hasProvider('NonExistentClass')); + } + + /** + * Tests getProviderClassName with unregistered provider. + * + * @return void + */ + public function testGetProviderClassNameWithUnregisteredProvider(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Provider not registered: nonexistent'); + + $this->registry->getProviderClassName('nonexistent'); + } + + /** + * Tests isProviderConfigured with registered provider. + * + * @return void + */ + public function testIsProviderConfiguredWithRegisteredProvider(): void + { + $this->registry->registerProvider(MockProvider::class); + + $this->assertTrue($this->registry->isProviderConfigured('mock')); + $this->assertTrue($this->registry->isProviderConfigured(MockProvider::class)); + } + + /** + * Tests isProviderConfigured with unregistered provider. + * + * @return void + */ + public function testIsProviderConfiguredWithUnregisteredProvider(): void + { + $this->assertFalse($this->registry->isProviderConfigured('nonexistent')); + } + + /** + * Tests findModelsMetadataForSupport with no registered providers. + * + * @return void + */ + public function testFindModelsMetadataForSupportWithNoProviders(): void + { + $requirements = new ModelRequirements([CapabilityEnum::textGeneration()], []); + $results = $this->registry->findModelsMetadataForSupport($requirements); + + $this->assertIsArray($results); + $this->assertEmpty($results); + } + + /** + * Tests findModelsMetadataForSupport with registered provider. + * + * @return void + */ + public function testFindModelsMetadataForSupportWithRegisteredProvider(): void + { + $this->registry->registerProvider(MockProvider::class); + + $requirements = new ModelRequirements([CapabilityEnum::textGeneration()], []); + $results = $this->registry->findModelsMetadataForSupport($requirements); + + $this->assertIsArray($results); + // Should now find models that match the text generation requirement + $this->assertNotEmpty($results); + $this->assertCount(1, $results); + } + + /** + * Tests findProviderModelsMetadataForSupport with registered provider. + * + * @return void + */ + public function testFindProviderModelsMetadataForSupportWithRegisteredProvider(): void + { + $this->registry->registerProvider(MockProvider::class); + + $requirements = new ModelRequirements([CapabilityEnum::textGeneration()], []); + $results = $this->registry->findProviderModelsMetadataForSupport('mock', $requirements); + + $this->assertIsArray($results); + // Should now find models that match the text generation requirement + $this->assertNotEmpty($results); + $this->assertCount(1, $results); + } + + /** + * Tests findProviderModelsMetadataForSupport with unregistered provider. + * + * @return void + */ + public function testFindProviderModelsMetadataForSupportWithUnregisteredProvider(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Provider not registered: nonexistent'); + + $requirements = new ModelRequirements([CapabilityEnum::textGeneration()], []); + $this->registry->findProviderModelsMetadataForSupport('nonexistent', $requirements); + } + + /** + * Tests getProviderModel throws exception for non-existent model. + * + * @return void + */ + public function testGetProviderModelThrowsException(): void + { + $this->registry->registerProvider(MockProvider::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Model not found: test-model'); + + $modelConfig = new \WordPress\AiClient\Providers\Models\DTO\ModelConfig([]); + $this->registry->getProviderModel('mock', 'test-model', $modelConfig); + } + + /** + * Tests multiple provider registration. + * + * @return void + */ + public function testMultipleProviderRegistration(): void + { + $this->registry->registerProvider(MockProvider::class); + + // Register another instance of the same provider (should update) + $this->registry->registerProvider(MockProvider::class); + + $this->assertTrue($this->registry->hasProvider('mock')); + $this->assertEquals(MockProvider::class, $this->registry->getProviderClassName('mock')); + } + + /** + * Tests provider instance caching. + * + * @return void + */ + public function testProviderInstanceCaching(): void + { + $this->registry->registerProvider(MockProvider::class); + + // Call methods that create instances + $this->assertTrue($this->registry->isProviderConfigured('mock')); + $this->assertTrue($this->registry->isProviderConfigured('mock')); + + // Should not throw any errors and should reuse cached instance + $this->addToAssertionCount(1); + } +}