From d44cdad891bb4c74dfd1dd180e9212cbbbec0e86 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Fri, 15 Aug 2025 12:38:31 +0300 Subject: [PATCH 01/69] feat: first draft of AiClient --- src/AiClient.php | 279 ++++++++++++++++++++ tests/mocks/MockImageGenerationModel.php | 80 ++++++ tests/mocks/MockTextGenerationModel.php | 90 +++++++ tests/unit/AiClientTest.php | 312 +++++++++++++++++++++++ 4 files changed, 761 insertions(+) create mode 100644 src/AiClient.php create mode 100644 tests/mocks/MockImageGenerationModel.php create mode 100644 tests/mocks/MockTextGenerationModel.php create mode 100644 tests/unit/AiClientTest.php diff --git a/src/AiClient.php b/src/AiClient.php new file mode 100644 index 00000000..f44c8ab9 --- /dev/null +++ b/src/AiClient.php @@ -0,0 +1,279 @@ +generateTextResult($messages); + } + + /** + * Generates an image using the traditional API approach. + * + * @since n.e.x.t + * + * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. + * @param ModelInterface|null $model Optional specific model to use. + * @return GenerativeAiResult The generation result. + * + * @throws \InvalidArgumentException If the prompt format is invalid. + * @throws \RuntimeException If no suitable model is found. + */ + public static function generateImageResult($prompt, ModelInterface $model = null): GenerativeAiResult + { + // Convert prompt to standardized Message array format + $messages = self::normalizePromptToMessages($prompt); + + // Get model - either provided or auto-discovered + $resolvedModel = $model ?? self::findSuitableImageModel(); + + // Ensure the model supports image generation + if (!$resolvedModel instanceof ImageGenerationModelInterface) { + throw new \InvalidArgumentException( + 'Model must implement ImageGenerationModelInterface for image generation' + ); + } + + // Generate the result using the model + return $resolvedModel->generateImageResult($messages); + } + + /** + * Creates a generation operation for async processing. + * + * @since n.e.x.t + * + * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. + * @param ModelInterface $model The model to use for generation. + * @return GenerativeAiOperation The operation for async processing. + * + * @throws \InvalidArgumentException If the prompt format is invalid. + */ + public static function generateOperation($prompt, ModelInterface $model): GenerativeAiOperation + { + // Convert prompt to standardized Message array format + $messages = self::normalizePromptToMessages($prompt); + + // Create and return the operation (starting state, no result yet) + return new GenerativeAiOperation( + uniqid('op_', true), + OperationStateEnum::starting(), + null + ); + } + + /** + * Normalizes various prompt formats into a standardized Message array. + * + * @since n.e.x.t + * + * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt to normalize. + * @return list Array of Message objects. + * + * @throws \InvalidArgumentException If the prompt format is invalid. + */ + private static function normalizePromptToMessages($prompt): array + { + if (is_string($prompt)) { + // Convert string to UserMessage with single text MessagePart + return [new UserMessage([new MessagePart($prompt)])]; + } + + if ($prompt instanceof Message) { + return [$prompt]; + } + + if ($prompt instanceof MessagePart) { + // Convert MessagePart to UserMessage + return [new UserMessage([$prompt])]; + } + + if (is_array($prompt)) { + // Handle array of Messages or MessageParts + $messages = []; + foreach ($prompt as $item) { + if ($item instanceof Message) { + $messages[] = $item; + } elseif ($item instanceof MessagePart) { + $messages[] = new UserMessage([$item]); + } else { + throw new \InvalidArgumentException( + 'Array must contain only Message or MessagePart objects' + ); + } + } + return $messages; + } + + throw new \InvalidArgumentException('Invalid prompt format provided'); + } + + /** + * Finds a suitable text generation model. + * + * @since n.e.x.t + * + * @return ModelInterface A suitable text generation model. + * + * @throws \RuntimeException If no suitable model is found. + */ + private static function findSuitableTextModel(): ModelInterface + { + $requirements = new ModelRequirements([CapabilityEnum::textGeneration()], []); + $providerModelsMetadata = self::defaultRegistry()->findModelsMetadataForSupport($requirements); + + if (empty($providerModelsMetadata)) { + throw new \RuntimeException('No text generation models available'); + } + + // Get the first suitable provider and model + $providerMetadata = $providerModelsMetadata[0]; + $models = $providerMetadata->getModels(); + + if (empty($models)) { + throw new \RuntimeException('No models available in provider'); + } + + return self::defaultRegistry()->getProviderModel( + $providerMetadata->getProvider()->getId(), + $models[0]->getId() + ); + } + + /** + * Finds a suitable image generation model. + * + * @since n.e.x.t + * + * @return ModelInterface A suitable image generation model. + * + * @throws \RuntimeException If no suitable model is found. + */ + private static function findSuitableImageModel(): ModelInterface + { + $requirements = new ModelRequirements([CapabilityEnum::imageGeneration()], []); + $providerModelsMetadata = self::defaultRegistry()->findModelsMetadataForSupport($requirements); + + if (empty($providerModelsMetadata)) { + throw new \RuntimeException('No image generation models available'); + } + + // Get the first suitable provider and model + $providerMetadata = $providerModelsMetadata[0]; + $models = $providerMetadata->getModels(); + + if (empty($models)) { + throw new \RuntimeException('No models available in provider'); + } + + return self::defaultRegistry()->getProviderModel( + $providerMetadata->getProvider()->getId(), + $models[0]->getId() + ); + } +} diff --git a/tests/mocks/MockImageGenerationModel.php b/tests/mocks/MockImageGenerationModel.php new file mode 100644 index 00000000..a043de37 --- /dev/null +++ b/tests/mocks/MockImageGenerationModel.php @@ -0,0 +1,80 @@ +metadata = $metadata ?? new ModelMetadata( + 'mock-image-model', + 'Mock Image Model', + [CapabilityEnum::imageGeneration()], + [] + ); + $this->config = $config ?? new ModelConfig(); + } + + /** + * {@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; + } + + /** + * {@inheritDoc} + */ + public function generateImageResult(array $prompt): GenerativeAiResult + { + // Return a mock result + throw new \RuntimeException('Mock implementation - should be mocked in tests'); + } +} diff --git a/tests/mocks/MockTextGenerationModel.php b/tests/mocks/MockTextGenerationModel.php new file mode 100644 index 00000000..80d43744 --- /dev/null +++ b/tests/mocks/MockTextGenerationModel.php @@ -0,0 +1,90 @@ +metadata = $metadata ?? new ModelMetadata( + 'mock-text-model', + 'Mock Text Model', + [CapabilityEnum::textGeneration()], + [] + ); + $this->config = $config ?? new ModelConfig(); + } + + /** + * {@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; + } + + /** + * {@inheritDoc} + */ + public function generateTextResult(array $prompt): GenerativeAiResult + { + // Return a mock result + throw new \RuntimeException('Mock implementation - should be mocked in tests'); + } + + /** + * {@inheritDoc} + */ + public function streamGenerateTextResult(array $prompt): Generator + { + // Return a mock generator + throw new \RuntimeException('Mock implementation - should be mocked in tests'); + } +} diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php new file mode 100644 index 00000000..990f673b --- /dev/null +++ b/tests/unit/AiClientTest.php @@ -0,0 +1,312 @@ +registry = new ProviderRegistry(); + + // Create mock models that implement both base and generation interfaces + $this->mockTextModel = $this->createMock(MockTextGenerationModel::class); + $this->mockImageModel = $this->createMock(MockImageGenerationModel::class); + + // Set the test registry as the default + AiClient::setDefaultRegistry($this->registry); + } + + protected function tearDown(): void + { + // Reset the default registry + AiClient::setDefaultRegistry(new ProviderRegistry()); + } + + /** + * Tests default registry getter and setter. + */ + public function testDefaultRegistry(): void + { + $registry = AiClient::defaultRegistry(); + $this->assertInstanceOf(ProviderRegistry::class, $registry); + + $newRegistry = new ProviderRegistry(); + AiClient::setDefaultRegistry($newRegistry); + + $this->assertSame($newRegistry, AiClient::defaultRegistry()); + } + + /** + * Tests prompt method throws exception when PromptBuilder is not available. + */ + public function testPromptThrowsException(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('PromptBuilder is not yet available. This method depends on PR #49.'); + + AiClient::prompt('Test prompt'); + } + + /** + * Tests generateTextResult with string prompt and provided model. + */ + public function testGenerateTextResultWithStringAndModel(): void + { + $prompt = 'Generate text'; + $mockResult = $this->createMock(GenerativeAiResult::class); + + $this->mockTextModel + ->expects($this->once()) + ->method('generateTextResult') + ->with($this->callback(function ($messages) { + return is_array($messages) && + count($messages) === 1 && + $messages[0] instanceof UserMessage; + })) + ->willReturn($mockResult); + + $result = AiClient::generateTextResult($prompt, $this->mockTextModel); + + $this->assertSame($mockResult, $result); + } + + /** + * Tests generateTextResult throws exception for model without text generation interface. + */ + public function testGenerateTextResultWithInvalidModel(): void + { + $prompt = 'Generate text'; + $invalidModel = $this->createMock(ModelInterface::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Model must implement TextGenerationModelInterface for text generation'); + + AiClient::generateTextResult($prompt, $invalidModel); + } + + /** + * Tests generateImageResult with string prompt and provided model. + */ + public function testGenerateImageResultWithStringAndModel(): void + { + $prompt = 'Generate image'; + $mockResult = $this->createMock(GenerativeAiResult::class); + + $this->mockImageModel + ->expects($this->once()) + ->method('generateImageResult') + ->with($this->callback(function ($messages) { + return is_array($messages) && + count($messages) === 1 && + $messages[0] instanceof UserMessage; + })) + ->willReturn($mockResult); + + $result = AiClient::generateImageResult($prompt, $this->mockImageModel); + + $this->assertSame($mockResult, $result); + } + + /** + * Tests generateImageResult throws exception for model without image generation interface. + */ + public function testGenerateImageResultWithInvalidModel(): void + { + $prompt = 'Generate image'; + $invalidModel = $this->createMock(ModelInterface::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Model must implement ImageGenerationModelInterface for image generation'); + + AiClient::generateImageResult($prompt, $invalidModel); + } + + /** + * Tests generateOperation with valid model. + */ + public function testGenerateOperation(): void + { + $prompt = 'Generate content'; + + $operation = AiClient::generateOperation($prompt, $this->mockTextModel); + + $this->assertInstanceOf(GenerativeAiOperation::class, $operation); + $this->assertNotEmpty($operation->getId()); + } + + /** + * Tests generateTextResult with Message object. + */ + public function testGenerateTextResultWithMessage(): void + { + $messagePart = new MessagePart('Test message'); + $message = new UserMessage([$messagePart]); + $mockResult = $this->createMock(GenerativeAiResult::class); + + $this->mockTextModel + ->expects($this->once()) + ->method('generateTextResult') + ->with($this->callback(function ($messages) use ($message) { + return is_array($messages) && + count($messages) === 1 && + $messages[0] === $message; + })) + ->willReturn($mockResult); + + $result = AiClient::generateTextResult($message, $this->mockTextModel); + + $this->assertSame($mockResult, $result); + } + + /** + * Tests generateTextResult with MessagePart object. + */ + public function testGenerateTextResultWithMessagePart(): void + { + $messagePart = new MessagePart('Test message part'); + $mockResult = $this->createMock(GenerativeAiResult::class); + + $this->mockTextModel + ->expects($this->once()) + ->method('generateTextResult') + ->with($this->callback(function ($messages) { + return is_array($messages) && + count($messages) === 1 && + $messages[0] instanceof UserMessage; + })) + ->willReturn($mockResult); + + $result = AiClient::generateTextResult($messagePart, $this->mockTextModel); + + $this->assertSame($mockResult, $result); + } + + /** + * Tests generateTextResult with array of Messages. + */ + public function testGenerateTextResultWithMessageArray(): void + { + $messagePart1 = new MessagePart('First message'); + $messagePart2 = new MessagePart('Second message'); + $message1 = new UserMessage([$messagePart1]); + $message2 = new UserMessage([$messagePart2]); + $messages = [$message1, $message2]; + + $mockResult = $this->createMock(GenerativeAiResult::class); + + $this->mockTextModel + ->expects($this->once()) + ->method('generateTextResult') + ->with($this->callback(function ($result) use ($messages) { + return is_array($result) && + count($result) === 2 && + $result[0] === $messages[0] && + $result[1] === $messages[1]; + })) + ->willReturn($mockResult); + + $result = AiClient::generateTextResult($messages, $this->mockTextModel); + + $this->assertSame($mockResult, $result); + } + + /** + * Tests generateTextResult with array of MessageParts. + */ + public function testGenerateTextResultWithMessagePartArray(): void + { + $messagePart1 = new MessagePart('First part'); + $messagePart2 = new MessagePart('Second part'); + $messageParts = [$messagePart1, $messagePart2]; + + $mockResult = $this->createMock(GenerativeAiResult::class); + + $this->mockTextModel + ->expects($this->once()) + ->method('generateTextResult') + ->with($this->callback(function ($messages) { + return is_array($messages) && + count($messages) === 2 && + $messages[0] instanceof UserMessage && + $messages[1] instanceof UserMessage; + })) + ->willReturn($mockResult); + + $result = AiClient::generateTextResult($messageParts, $this->mockTextModel); + + $this->assertSame($mockResult, $result); + } + + /** + * Tests prompt normalization throws exception for invalid array content. + */ + public function testNormalizePromptWithInvalidArrayContent(): void + { + $invalidArray = ['string', 123, new \stdClass()]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Array must contain only Message or MessagePart objects'); + + AiClient::generateTextResult($invalidArray, $this->mockTextModel); + } + + /** + * Tests prompt normalization throws exception for completely invalid input. + */ + public function testNormalizePromptWithInvalidInput(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid prompt format provided'); + + AiClient::generateTextResult(123, $this->mockTextModel); + } + + /** + * Tests automatic model discovery when no model is provided (would throw RuntimeException in current implementation). + */ + public function testAutoModelDiscoveryThrowsException(): void + { + $prompt = 'Generate text'; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No text generation models available'); + + AiClient::generateTextResult($prompt); + } + + /** + * Tests automatic image model discovery when no model is provided (would throw RuntimeException in current implementation). + */ + public function testAutoImageModelDiscoveryThrowsException(): void + { + $prompt = 'Generate image'; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No image generation models available'); + + AiClient::generateImageResult($prompt); + } +} From 634a18a91943fbb907c5e471421a1cc747709d07 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Fri, 15 Aug 2025 12:50:38 +0300 Subject: [PATCH 02/69] fix: reduce comment line length in test file --- tests/unit/AiClientTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index 990f673b..41223f44 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -285,7 +285,7 @@ public function testNormalizePromptWithInvalidInput(): void } /** - * Tests automatic model discovery when no model is provided (would throw RuntimeException in current implementation). + * Tests automatic model discovery when no model is provided (throws RuntimeException in current implementation). */ public function testAutoModelDiscoveryThrowsException(): void { @@ -298,7 +298,7 @@ public function testAutoModelDiscoveryThrowsException(): void } /** - * Tests automatic image model discovery when no model is provided (would throw RuntimeException in current implementation). + * Tests automatic image model discovery when no model is provided (throws RuntimeException in current implementation). */ public function testAutoImageModelDiscoveryThrowsException(): void { From 5fda9682a97284dce2c43cf2eababf92ff7dacce Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Fri, 15 Aug 2025 12:53:33 +0300 Subject: [PATCH 03/69] fix: shorten final comment line for code style --- tests/unit/AiClientTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index 41223f44..28a88471 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -298,7 +298,7 @@ public function testAutoModelDiscoveryThrowsException(): void } /** - * Tests automatic image model discovery when no model is provided (throws RuntimeException in current implementation). + * Tests automatic image model discovery when no model is provided (throws RuntimeException currently). */ public function testAutoImageModelDiscoveryThrowsException(): void { From 282bcbebc2adee5ca263c808d380d00075e26f8f Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Sun, 17 Aug 2025 17:44:14 +0300 Subject: [PATCH 04/69] Implement isConfigured() method in AiClient Add provider availability checking functionality to complete architecture specification. - Add isConfigured(ProviderAvailabilityInterface $availability): bool method - Delegate to ProviderAvailabilityInterface::isConfigured() for consistency --- src/AiClient.php | 14 ++++++++++++++ tests/unit/AiClientTest.php | 31 +++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/src/AiClient.php b/src/AiClient.php index f44c8ab9..eef944ea 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -9,6 +9,7 @@ use WordPress\AiClient\Messages\DTO\UserMessage; use WordPress\AiClient\Operations\DTO\GenerativeAiOperation; use WordPress\AiClient\Operations\Enums\OperationStateEnum; +use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelRequirements; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; @@ -62,6 +63,19 @@ public static function setDefaultRegistry(ProviderRegistry $registry): void self::$defaultRegistry = $registry; } + /** + * Checks if a provider is configured and available for use. + * + * @since n.e.x.t + * + * @param ProviderAvailabilityInterface $availability The provider availability instance to check. + * @return bool True if the provider is configured and available, false otherwise. + */ + public static function isConfigured(ProviderAvailabilityInterface $availability): bool + { + return $availability->isConfigured(); + } + /** * Creates a new prompt builder for fluent API usage. * diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index 28a88471..36e3bd7c 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -11,6 +11,7 @@ use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\UserMessage; use WordPress\AiClient\Operations\DTO\GenerativeAiOperation; +use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Results\DTO\GenerativeAiResult; @@ -309,4 +310,34 @@ public function testAutoImageModelDiscoveryThrowsException(): void AiClient::generateImageResult($prompt); } + + /** + * Tests isConfigured method returns true when provider availability is configured. + */ + public function testIsConfiguredReturnsTrueWhenProviderIsConfigured(): void + { + $mockAvailability = $this->createMock(ProviderAvailabilityInterface::class); + $mockAvailability->expects($this->once()) + ->method('isConfigured') + ->willReturn(true); + + $result = AiClient::isConfigured($mockAvailability); + + $this->assertTrue($result); + } + + /** + * Tests isConfigured method returns false when provider availability is not configured. + */ + public function testIsConfiguredReturnsFalseWhenProviderIsNotConfigured(): void + { + $mockAvailability = $this->createMock(ProviderAvailabilityInterface::class); + $mockAvailability->expects($this->once()) + ->method('isConfigured') + ->willReturn(false); + + $result = AiClient::isConfigured($mockAvailability); + + $this->assertFalse($result); + } } From 8188228788f4bba862f9f58891470f69a60153a0 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Sun, 17 Aug 2025 17:54:51 +0300 Subject: [PATCH 05/69] Implement generateResult() unified generation method Add base generation method that automatically detects model capabilities and delegates to appropriate generation interfaces. - Add generateResult(prompt, ModelInterface): GenerativeAiResult method - Smart delegation to generateTextResult() or generateImageResult() based on model interface - Comprehensive error handling for unsupported model types - 3 new tests covering text generation, image generation, and error cases --- src/AiClient.php | 32 ++++++++++++++++++ tests/unit/AiClientTest.php | 66 +++++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+) diff --git a/src/AiClient.php b/src/AiClient.php index eef944ea..6cb5f342 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -95,6 +95,38 @@ public static function prompt($text = null) ); } + /** + * Generates content using a unified API that delegates to specific generation methods. + * + * This method automatically detects the model's capabilities and routes to the + * appropriate generation method (text, image, etc.). + * + * @since n.e.x.t + * + * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. + * @param ModelInterface $model The model to use for generation. + * @return GenerativeAiResult The generation result. + * + * @throws \InvalidArgumentException If the prompt format is invalid or model type is unsupported. + */ + public static function generateResult($prompt, ModelInterface $model): GenerativeAiResult + { + // Delegate to text generation if model supports it + if ($model instanceof TextGenerationModelInterface) { + return self::generateTextResult($prompt, $model); + } + + // Delegate to image generation if model supports it + if ($model instanceof ImageGenerationModelInterface) { + return self::generateImageResult($prompt, $model); + } + + // If no supported interface is found, throw an exception + throw new \InvalidArgumentException( + 'Model must implement at least one supported generation interface (TextGeneration, ImageGeneration)' + ); + } + /** * Generates text using the traditional API approach. * diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index 36e3bd7c..6136e194 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -9,12 +9,16 @@ use RuntimeException; use WordPress\AiClient\AiClient; use WordPress\AiClient\Messages\DTO\MessagePart; +use WordPress\AiClient\Messages\DTO\ModelMessage; use WordPress\AiClient\Messages\DTO\UserMessage; use WordPress\AiClient\Operations\DTO\GenerativeAiOperation; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; +use WordPress\AiClient\Results\DTO\Candidate; use WordPress\AiClient\Results\DTO\GenerativeAiResult; +use WordPress\AiClient\Results\DTO\TokenUsage; +use WordPress\AiClient\Results\Enums\FinishReasonEnum; use WordPress\AiClient\Tests\mocks\MockImageGenerationModel; use WordPress\AiClient\Tests\mocks\MockTextGenerationModel; @@ -40,6 +44,20 @@ protected function setUp(): void AiClient::setDefaultRegistry($this->registry); } + /** + * Creates a test GenerativeAiResult for testing purposes. + */ + private function createTestResult(): GenerativeAiResult + { + $candidate = new Candidate( + new ModelMessage([new MessagePart('Test response')]), + FinishReasonEnum::stop() + ); + $tokenUsage = new TokenUsage(10, 20, 30); + + return new GenerativeAiResult('test-result-id', [$candidate], $tokenUsage); + } + protected function tearDown(): void { // Reset the default registry @@ -340,4 +358,52 @@ public function testIsConfiguredReturnsFalseWhenProviderIsNotConfigured(): void $this->assertFalse($result); } + + /** + * Tests generateResult delegates to generateTextResult when model supports text generation. + */ + public function testGenerateResultDelegatesToTextGeneration(): void + { + $prompt = 'Test prompt'; + $expectedResult = $this->createTestResult(); + + $this->mockTextModel->expects($this->once()) + ->method('generateTextResult') + ->willReturn($expectedResult); + + $result = AiClient::generateResult($prompt, $this->mockTextModel); + + $this->assertSame($expectedResult, $result); + } + + /** + * Tests generateResult delegates to generateImageResult when model supports image generation. + */ + public function testGenerateResultDelegatesToImageGeneration(): void + { + $prompt = 'Generate image prompt'; + $expectedResult = $this->createTestResult(); + + $this->mockImageModel->expects($this->once()) + ->method('generateImageResult') + ->willReturn($expectedResult); + + $result = AiClient::generateResult($prompt, $this->mockImageModel); + + $this->assertSame($expectedResult, $result); + } + + /** + * Tests generateResult throws exception when model doesn't support any generation interface. + */ + public function testGenerateResultThrowsExceptionForUnsupportedModel(): void + { + $prompt = 'Test prompt'; + $unsupportedModel = $this->createMock(ModelInterface::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Model must implement at least one supported generation interface (TextGeneration, ImageGeneration)'); + + AiClient::generateResult($prompt, $unsupportedModel); + } } From b4cc84b729ce0c3ae90956b501bd9b3d9cc91c42 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Sun, 17 Aug 2025 17:59:18 +0300 Subject: [PATCH 06/69] Implement streamGenerateTextResult() streaming support Add streaming text generation using PHP Generator pattern. - Add streamGenerateTextResult() method with Generator return type - Delegate to model's streaming method using yield from - Support model auto-discovery and comprehensive error handling --- src/AiClient.php | 32 +++++++++++++++++++ tests/unit/AiClientTest.php | 62 +++++++++++++++++++++++++++++++++++-- 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index 6cb5f342..5c85d7d4 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -4,6 +4,7 @@ namespace WordPress\AiClient; +use Generator; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\UserMessage; @@ -158,6 +159,37 @@ public static function generateTextResult($prompt, ModelInterface $model = null) return $resolvedModel->generateTextResult($messages); } + /** + * Streams text generation using the traditional API approach. + * + * @since n.e.x.t + * + * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. + * @param ModelInterface|null $model Optional specific model to use. + * @return Generator Generator yielding partial text generation results. + * + * @throws \InvalidArgumentException If the prompt format is invalid. + * @throws \RuntimeException If no suitable model is found. + */ + public static function streamGenerateTextResult($prompt, ModelInterface $model = null): Generator + { + // Convert prompt to standardized Message array format + $messages = self::normalizePromptToMessages($prompt); + + // Get model - either provided or auto-discovered + $resolvedModel = $model ?? self::findSuitableTextModel(); + + // Ensure the model supports text generation + if (!$resolvedModel instanceof TextGenerationModelInterface) { + throw new \InvalidArgumentException( + 'Model must implement TextGenerationModelInterface for text generation' + ); + } + + // Stream the results using the model + yield from $resolvedModel->streamGenerateTextResult($messages); + } + /** * Generates an image using the traditional API approach. * diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index 6136e194..381b25fa 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -54,7 +54,7 @@ private function createTestResult(): GenerativeAiResult FinishReasonEnum::stop() ); $tokenUsage = new TokenUsage(10, 20, 30); - + return new GenerativeAiResult('test-result-id', [$candidate], $tokenUsage); } @@ -402,8 +402,66 @@ public function testGenerateResultThrowsExceptionForUnsupportedModel(): void $unsupportedModel = $this->createMock(ModelInterface::class); $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Model must implement at least one supported generation interface (TextGeneration, ImageGeneration)'); + $this->expectExceptionMessage( + 'Model must implement at least one supported generation interface (TextGeneration, ImageGeneration)' + ); AiClient::generateResult($prompt, $unsupportedModel); } + + /** + * Tests streamGenerateTextResult delegates to model's streaming method. + */ + public function testStreamGenerateTextResultDelegatesToModel(): void + { + $prompt = 'Stream this text'; + $result1 = $this->createTestResult(); + $result2 = $this->createTestResult(); + + // Create a generator that yields test results + $generator = (function () use ($result1, $result2) { + yield $result1; + yield $result2; + })(); + + $this->mockTextModel->expects($this->once()) + ->method('streamGenerateTextResult') + ->willReturn($generator); + + $streamResults = AiClient::streamGenerateTextResult($prompt, $this->mockTextModel); + + // Convert generator to array for testing + $results = iterator_to_array($streamResults); + + $this->assertCount(2, $results); + $this->assertSame($result1, $results[0]); + $this->assertSame($result2, $results[1]); + } + + /** + * Tests streamGenerateTextResult with model auto-discovery. + */ + public function testStreamGenerateTextResultWithAutoDiscovery(): void + { + $prompt = 'Auto-discover and stream'; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No text generation models available'); + + iterator_to_array(AiClient::streamGenerateTextResult($prompt)); + } + + /** + * Tests streamGenerateTextResult throws exception when model doesn't support text generation. + */ + public function testStreamGenerateTextResultThrowsExceptionForNonTextModel(): void + { + $prompt = 'Test prompt'; + $nonTextModel = $this->createMock(ModelInterface::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Model must implement TextGenerationModelInterface for text generation'); + + iterator_to_array(AiClient::streamGenerateTextResult($prompt, $nonTextModel)); + } } From f659d6f5a456b3a05e3fbebe3c39e849d5dab3ca Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Sun, 17 Aug 2025 18:10:38 +0300 Subject: [PATCH 07/69] Implement generateTextOperation() & generateImageOperation() Add properly typed async operations with model interface validation. - Add generateTextOperation() with TextGenerationModelInterface validation - Add generateImageOperation() with ImageGenerationModelInterface validation - Prefixed operation IDs for better tracking (text_op_, image_op_) - 4 comprehensive tests covering validation and error cases --- src/AiClient.php | 62 ++++++++++++++++++++++++++++++++++++ tests/unit/AiClientTest.php | 63 +++++++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+) diff --git a/src/AiClient.php b/src/AiClient.php index 5c85d7d4..b30a2644 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -245,6 +245,68 @@ public static function generateOperation($prompt, ModelInterface $model): Genera ); } + /** + * Creates a text generation operation for async processing. + * + * @since n.e.x.t + * + * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. + * @param ModelInterface $model The model to use for text generation. + * @return GenerativeAiOperation The operation for async text processing. + * + * @throws \InvalidArgumentException If the prompt format is invalid or model doesn't support text generation. + */ + public static function generateTextOperation($prompt, ModelInterface $model): GenerativeAiOperation + { + // Convert prompt to standardized Message array format + $messages = self::normalizePromptToMessages($prompt); + + // Ensure the model supports text generation + if (!$model instanceof TextGenerationModelInterface) { + throw new \InvalidArgumentException( + 'Model must implement TextGenerationModelInterface for text generation operations' + ); + } + + // Create and return the operation (starting state, no result yet) + return new GenerativeAiOperation( + uniqid('text_op_', true), + OperationStateEnum::starting(), + null + ); + } + + /** + * Creates an image generation operation for async processing. + * + * @since n.e.x.t + * + * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. + * @param ModelInterface $model The model to use for image generation. + * @return GenerativeAiOperation The operation for async image processing. + * + * @throws \InvalidArgumentException If the prompt format is invalid or model doesn't support image generation. + */ + public static function generateImageOperation($prompt, ModelInterface $model): GenerativeAiOperation + { + // Convert prompt to standardized Message array format + $messages = self::normalizePromptToMessages($prompt); + + // Ensure the model supports image generation + if (!$model instanceof ImageGenerationModelInterface) { + throw new \InvalidArgumentException( + 'Model must implement ImageGenerationModelInterface for image generation operations' + ); + } + + // Create and return the operation (starting state, no result yet) + return new GenerativeAiOperation( + uniqid('image_op_', true), + OperationStateEnum::starting(), + null + ); + } + /** * Normalizes various prompt formats into a standardized Message array. * diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index 381b25fa..dc9963c8 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -12,6 +12,7 @@ use WordPress\AiClient\Messages\DTO\ModelMessage; use WordPress\AiClient\Messages\DTO\UserMessage; use WordPress\AiClient\Operations\DTO\GenerativeAiOperation; +use WordPress\AiClient\Operations\Enums\OperationStateEnum; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; @@ -464,4 +465,66 @@ public function testStreamGenerateTextResultThrowsExceptionForNonTextModel(): vo iterator_to_array(AiClient::streamGenerateTextResult($prompt, $nonTextModel)); } + + /** + * Tests generateTextOperation creates operation with text model validation. + */ + public function testGenerateTextOperationWithValidTextModel(): void + { + $prompt = 'Text operation prompt'; + + $operation = AiClient::generateTextOperation($prompt, $this->mockTextModel); + + $this->assertInstanceOf(GenerativeAiOperation::class, $operation); + $this->assertStringStartsWith('text_op_', $operation->getId()); + $this->assertEquals(OperationStateEnum::starting(), $operation->getState()); + $this->assertNull($operation->getResult()); + } + + /** + * Tests generateTextOperation throws exception for non-text model. + */ + public function testGenerateTextOperationThrowsExceptionForNonTextModel(): void + { + $prompt = 'Text operation prompt'; + $nonTextModel = $this->createMock(ModelInterface::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Model must implement TextGenerationModelInterface for text generation operations' + ); + + AiClient::generateTextOperation($prompt, $nonTextModel); + } + + /** + * Tests generateImageOperation creates operation with image model validation. + */ + public function testGenerateImageOperationWithValidImageModel(): void + { + $prompt = 'Image operation prompt'; + + $operation = AiClient::generateImageOperation($prompt, $this->mockImageModel); + + $this->assertInstanceOf(GenerativeAiOperation::class, $operation); + $this->assertStringStartsWith('image_op_', $operation->getId()); + $this->assertEquals(OperationStateEnum::starting(), $operation->getState()); + $this->assertNull($operation->getResult()); + } + + /** + * Tests generateImageOperation throws exception for non-image model. + */ + public function testGenerateImageOperationThrowsExceptionForNonImageModel(): void + { + $prompt = 'Image operation prompt'; + $nonImageModel = $this->createMock(ModelInterface::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Model must implement ImageGenerationModelInterface for image generation operations' + ); + + AiClient::generateImageOperation($prompt, $nonImageModel); + } } From c52357dff88b53485ec6bc50684472eecb80df8a Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Sun, 17 Aug 2025 18:20:19 +0300 Subject: [PATCH 08/69] Fix code style: Remove trailing whitespace --- tests/unit/AiClientTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index dc9963c8..5b37dc3a 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -472,7 +472,7 @@ public function testStreamGenerateTextResultThrowsExceptionForNonTextModel(): vo public function testGenerateTextOperationWithValidTextModel(): void { $prompt = 'Text operation prompt'; - + $operation = AiClient::generateTextOperation($prompt, $this->mockTextModel); $this->assertInstanceOf(GenerativeAiOperation::class, $operation); @@ -503,7 +503,7 @@ public function testGenerateTextOperationThrowsExceptionForNonTextModel(): void public function testGenerateImageOperationWithValidImageModel(): void { $prompt = 'Image operation prompt'; - + $operation = AiClient::generateImageOperation($prompt, $this->mockImageModel); $this->assertInstanceOf(GenerativeAiOperation::class, $operation); From ca0d419724690bd5d2f3a3c2a9012a2d7782ccdc Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Sun, 17 Aug 2025 18:32:20 +0300 Subject: [PATCH 09/69] Implement text-to-speech conversion capabilities in AiClient - Add convertTextToSpeechResult() method with model auto-discovery - Add convertTextToSpeechOperation() method for async processing - Enhance generateResult() to support TextToSpeechConversionModelInterface - Add findSuitableTextToSpeechModel() helper method - Update test expectations for extended generation interface support --- src/AiClient.php | 105 +++++++++++++++++++++++++++- src/Contracts/AiClientInterface.php | 31 ++++++++ tests/unit/AiClientTest.php | 3 +- 3 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 src/Contracts/AiClientInterface.php diff --git a/src/AiClient.php b/src/AiClient.php index b30a2644..1f0049e8 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -16,6 +16,8 @@ use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; +use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionModelInterface; +use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionOperationModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Results\DTO\GenerativeAiResult; @@ -122,9 +124,15 @@ public static function generateResult($prompt, ModelInterface $model): Generativ return self::generateImageResult($prompt, $model); } + // Delegate to text-to-speech conversion if model supports it + if ($model instanceof TextToSpeechConversionModelInterface) { + return self::convertTextToSpeechResult($prompt, $model); + } + // If no supported interface is found, throw an exception throw new \InvalidArgumentException( - 'Model must implement at least one supported generation interface (TextGeneration, ImageGeneration)' + 'Model must implement at least one supported generation interface ' . + '(TextGeneration, ImageGeneration, TextToSpeechConversion)' ); } @@ -221,6 +229,37 @@ public static function generateImageResult($prompt, ModelInterface $model = null return $resolvedModel->generateImageResult($messages); } + /** + * Converts text to speech using the traditional API approach. + * + * @since n.e.x.t + * + * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. + * @param ModelInterface|null $model Optional specific model to use. + * @return GenerativeAiResult The generation result. + * + * @throws \InvalidArgumentException If the prompt format is invalid. + * @throws \RuntimeException If no suitable model is found. + */ + public static function convertTextToSpeechResult($prompt, ModelInterface $model = null): GenerativeAiResult + { + // Convert prompt to standardized Message array format + $messages = self::normalizePromptToMessages($prompt); + + // Get model - either provided or auto-discovered + $resolvedModel = $model ?? self::findSuitableTextToSpeechModel(); + + // Ensure the model supports text-to-speech conversion + if (!$resolvedModel instanceof TextToSpeechConversionModelInterface) { + throw new \InvalidArgumentException( + 'Model must implement TextToSpeechConversionModelInterface for text-to-speech conversion' + ); + } + + // Generate the result using the model + return $resolvedModel->convertTextToSpeechResult($messages); + } + /** * Creates a generation operation for async processing. * @@ -307,6 +346,38 @@ public static function generateImageOperation($prompt, ModelInterface $model): G ); } + /** + * Creates a text-to-speech conversion operation for async processing. + * + * @since n.e.x.t + * + * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. + * @param ModelInterface $model The model to use for text-to-speech conversion. + * @return GenerativeAiOperation The operation for async text-to-speech processing. + * + * @throws \InvalidArgumentException If the prompt format is invalid or model doesn't support text-to-speech. + */ + public static function convertTextToSpeechOperation($prompt, ModelInterface $model): GenerativeAiOperation + { + // Convert prompt to standardized Message array format + $messages = self::normalizePromptToMessages($prompt); + + // Ensure the model supports text-to-speech conversion operations + if (!$model instanceof TextToSpeechConversionOperationModelInterface) { + throw new \InvalidArgumentException( + 'Model must implement TextToSpeechConversionOperationModelInterface ' . + 'for text-to-speech conversion operations' + ); + } + + // Create and return the operation (starting state, no result yet) + return new GenerativeAiOperation( + uniqid('tts_op_', true), + OperationStateEnum::starting(), + null + ); + } + /** * Normalizes various prompt formats into a standardized Message array. * @@ -416,4 +487,36 @@ private static function findSuitableImageModel(): ModelInterface $models[0]->getId() ); } + + /** + * Finds a suitable text-to-speech conversion model. + * + * @since n.e.x.t + * + * @return ModelInterface A suitable text-to-speech conversion model. + * + * @throws \RuntimeException If no suitable model is found. + */ + private static function findSuitableTextToSpeechModel(): ModelInterface + { + $requirements = new ModelRequirements([CapabilityEnum::textToSpeechConversion()], []); + $providerModelsMetadata = self::defaultRegistry()->findModelsMetadataForSupport($requirements); + + if (empty($providerModelsMetadata)) { + throw new \RuntimeException('No text-to-speech conversion models available'); + } + + // Get the first suitable provider and model + $providerMetadata = $providerModelsMetadata[0]; + $models = $providerMetadata->getModels(); + + if (empty($models)) { + throw new \RuntimeException('No models available in provider'); + } + + return self::defaultRegistry()->getProviderModel( + $providerMetadata->getProvider()->getId(), + $models[0]->getId() + ); + } } diff --git a/src/Contracts/AiClientInterface.php b/src/Contracts/AiClientInterface.php new file mode 100644 index 00000000..2002bb78 --- /dev/null +++ b/src/Contracts/AiClientInterface.php @@ -0,0 +1,31 @@ +expectException(InvalidArgumentException::class); $this->expectExceptionMessage( - 'Model must implement at least one supported generation interface (TextGeneration, ImageGeneration)' + 'Model must implement at least one supported generation interface ' . + '(TextGeneration, ImageGeneration, TextToSpeechConversion)' ); AiClient::generateResult($prompt, $unsupportedModel); From 5b1d829d084d707e41af18269bfba4f06877ae4e Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Sun, 17 Aug 2025 18:43:35 +0300 Subject: [PATCH 10/69] Implement speech generation for complete PromptBuilder readiness - Add generateSpeechResult() method with model auto-discovery - Add generateSpeechOperation() method for async speech processing - Enhance generateResult() to support SpeechGenerationModelInterface - Add findSuitableSpeechModel() helper with speechGeneration capability - Update prompt() method with comprehensive PromptBuilder integration docs --- src/AiClient.php | 113 +++++++++++++++++++++++++++++++++++- tests/unit/AiClientTest.php | 7 ++- 2 files changed, 116 insertions(+), 4 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index 1f0049e8..d7363052 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -15,6 +15,8 @@ use WordPress\AiClient\Providers\Models\DTO\ModelRequirements; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; +use WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts\SpeechGenerationModelInterface; +use WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts\SpeechGenerationOperationModelInterface; use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionModelInterface; use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionOperationModelInterface; @@ -83,6 +85,12 @@ public static function isConfigured(ProviderAvailabilityInterface $availability) * Creates a new prompt builder for fluent API usage. * * This method will be implemented once PromptBuilder is available from PR #49. + * When available, PromptBuilder will support all generation types including: + * - Text generation via generateTextResult() + * - Image generation via generateImageResult() + * - Text-to-speech via convertTextToSpeechResult() + * - Speech generation via generateSpeechResult() + * - Embedding generation via generateEmbeddingsResult() * * @since n.e.x.t * @@ -94,7 +102,8 @@ public static function isConfigured(ProviderAvailabilityInterface $availability) public static function prompt($text = null) { throw new \RuntimeException( - 'PromptBuilder is not yet available. This method depends on PR #49.' + 'PromptBuilder is not yet available. This method depends on PR #49. ' . + 'All generation methods (text, image, text-to-speech, speech) are ready for integration.' ); } @@ -129,10 +138,15 @@ public static function generateResult($prompt, ModelInterface $model): Generativ return self::convertTextToSpeechResult($prompt, $model); } + // Delegate to speech generation if model supports it + if ($model instanceof SpeechGenerationModelInterface) { + return self::generateSpeechResult($prompt, $model); + } + // If no supported interface is found, throw an exception throw new \InvalidArgumentException( 'Model must implement at least one supported generation interface ' . - '(TextGeneration, ImageGeneration, TextToSpeechConversion)' + '(TextGeneration, ImageGeneration, TextToSpeechConversion, SpeechGeneration)' ); } @@ -260,6 +274,37 @@ public static function convertTextToSpeechResult($prompt, ModelInterface $model return $resolvedModel->convertTextToSpeechResult($messages); } + /** + * Generates speech using the traditional API approach. + * + * @since n.e.x.t + * + * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. + * @param ModelInterface|null $model Optional specific model to use. + * @return GenerativeAiResult The generation result. + * + * @throws \InvalidArgumentException If the prompt format is invalid. + * @throws \RuntimeException If no suitable model is found. + */ + public static function generateSpeechResult($prompt, ModelInterface $model = null): GenerativeAiResult + { + // Convert prompt to standardized Message array format + $messages = self::normalizePromptToMessages($prompt); + + // Get model - either provided or auto-discovered + $resolvedModel = $model ?? self::findSuitableSpeechModel(); + + // Ensure the model supports speech generation + if (!$resolvedModel instanceof SpeechGenerationModelInterface) { + throw new \InvalidArgumentException( + 'Model must implement SpeechGenerationModelInterface for speech generation' + ); + } + + // Generate the result using the model + return $resolvedModel->generateSpeechResult($messages); + } + /** * Creates a generation operation for async processing. * @@ -378,6 +423,38 @@ public static function convertTextToSpeechOperation($prompt, ModelInterface $mod ); } + /** + * Creates a speech generation operation for async processing. + * + * @since n.e.x.t + * + * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. + * @param ModelInterface $model The model to use for speech generation. + * @return GenerativeAiOperation The operation for async speech processing. + * + * @throws \InvalidArgumentException If the prompt format is invalid or model doesn't support speech generation. + */ + public static function generateSpeechOperation($prompt, ModelInterface $model): GenerativeAiOperation + { + // Convert prompt to standardized Message array format + $messages = self::normalizePromptToMessages($prompt); + + // Ensure the model supports speech generation operations + if (!$model instanceof SpeechGenerationOperationModelInterface) { + throw new \InvalidArgumentException( + 'Model must implement SpeechGenerationOperationModelInterface ' . + 'for speech generation operations' + ); + } + + // Create and return the operation (starting state, no result yet) + return new GenerativeAiOperation( + uniqid('speech_op_', true), + OperationStateEnum::starting(), + null + ); + } + /** * Normalizes various prompt formats into a standardized Message array. * @@ -519,4 +596,36 @@ private static function findSuitableTextToSpeechModel(): ModelInterface $models[0]->getId() ); } + + /** + * Finds a suitable speech generation model. + * + * @since n.e.x.t + * + * @return ModelInterface A suitable speech generation model. + * + * @throws \RuntimeException If no suitable model is found. + */ + private static function findSuitableSpeechModel(): ModelInterface + { + $requirements = new ModelRequirements([CapabilityEnum::speechGeneration()], []); + $providerModelsMetadata = self::defaultRegistry()->findModelsMetadataForSupport($requirements); + + if (empty($providerModelsMetadata)) { + throw new \RuntimeException('No speech generation models available'); + } + + // Get the first suitable provider and model + $providerMetadata = $providerModelsMetadata[0]; + $models = $providerMetadata->getModels(); + + if (empty($models)) { + throw new \RuntimeException('No models available in provider'); + } + + return self::defaultRegistry()->getProviderModel( + $providerMetadata->getProvider()->getId(), + $models[0]->getId() + ); + } } diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index b0b0c7c8..96231e09 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -85,7 +85,10 @@ public function testDefaultRegistry(): void public function testPromptThrowsException(): void { $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('PromptBuilder is not yet available. This method depends on PR #49.'); + $this->expectExceptionMessage( + 'PromptBuilder is not yet available. This method depends on PR #49. ' . + 'All generation methods (text, image, text-to-speech, speech) are ready for integration.' + ); AiClient::prompt('Test prompt'); } @@ -405,7 +408,7 @@ public function testGenerateResultThrowsExceptionForUnsupportedModel(): void $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage( 'Model must implement at least one supported generation interface ' . - '(TextGeneration, ImageGeneration, TextToSpeechConversion)' + '(TextGeneration, ImageGeneration, TextToSpeechConversion, SpeechGeneration)' ); AiClient::generateResult($prompt, $unsupportedModel); From 68b4f1757bc99d7b7b71cc7d2ab6634811ea4de8 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Sun, 17 Aug 2025 18:59:50 +0300 Subject: [PATCH 11/69] Implement complete embedding generation infrastructure - Add Embedding DTO with vector data representation and dimension tracking - Add EmbeddingResult class extending ResultInterface for embedding results - Add EmbeddingOperation class for async embedding operations - Add EmbeddingGenerationModelInterface for synchronous embedding generation - Add EmbeddingGenerationOperationModelInterface for async embedding operations - Implement generateEmbeddingsResult() method with model auto-discovery - Implement generateEmbeddingsOperation() method for async processing - Add findSuitableEmbeddingModel() helper with embeddingGeneration capability - Support both string[] and Message[] input formats for embeddings - Update PromptBuilder integration documentation to include embeddings --- src/AiClient.php | 113 +++++++++- src/Embeddings/DTO/Embedding.php | 130 ++++++++++++ src/Operations/DTO/EmbeddingOperation.php | 170 +++++++++++++++ .../EmbeddingGenerationModelInterface.php | 30 +++ ...ddingGenerationOperationModelInterface.php | 29 +++ src/Results/DTO/EmbeddingResult.php | 195 ++++++++++++++++++ tests/unit/AiClientTest.php | 2 +- 7 files changed, 667 insertions(+), 2 deletions(-) create mode 100644 src/Embeddings/DTO/Embedding.php create mode 100644 src/Operations/DTO/EmbeddingOperation.php create mode 100644 src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php create mode 100644 src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationOperationModelInterface.php create mode 100644 src/Results/DTO/EmbeddingResult.php diff --git a/src/AiClient.php b/src/AiClient.php index d7363052..1a7f5f42 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -8,11 +8,14 @@ use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\UserMessage; +use WordPress\AiClient\Operations\DTO\EmbeddingOperation; use WordPress\AiClient\Operations\DTO\GenerativeAiOperation; use WordPress\AiClient\Operations\Enums\OperationStateEnum; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelRequirements; +use WordPress\AiClient\Providers\Models\EmbeddingGeneration\Contracts\EmbeddingGenerationModelInterface; +use WordPress\AiClient\Providers\Models\EmbeddingGeneration\Contracts\EmbeddingGenerationOperationModelInterface; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; use WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts\SpeechGenerationModelInterface; @@ -21,6 +24,7 @@ use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionModelInterface; use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionOperationModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; +use WordPress\AiClient\Results\DTO\EmbeddingResult; use WordPress\AiClient\Results\DTO\GenerativeAiResult; /** @@ -103,7 +107,7 @@ public static function prompt($text = null) { throw new \RuntimeException( 'PromptBuilder is not yet available. This method depends on PR #49. ' . - 'All generation methods (text, image, text-to-speech, speech) are ready for integration.' + 'All generation methods (text, image, text-to-speech, speech, embeddings) are ready for integration.' ); } @@ -305,6 +309,43 @@ public static function generateSpeechResult($prompt, ModelInterface $model = nul return $resolvedModel->generateSpeechResult($messages); } + /** + * Generates embeddings using the traditional API approach. + * + * @since n.e.x.t + * + * @param string[]|Message[] $input The input data to generate embeddings for. + * @param ModelInterface|null $model Optional specific model to use. + * @return EmbeddingResult The generation result. + * + * @throws \InvalidArgumentException If the input format is invalid. + * @throws \RuntimeException If no suitable model is found. + */ + public static function generateEmbeddingsResult($input, ModelInterface $model = null): EmbeddingResult + { + // Convert input to standardized Message array format + if (is_array($input) && !empty($input) && is_string($input[0])) { + /** @var string[] $stringArray */ + $stringArray = $input; + $messages = array_map(fn(string $text) => new UserMessage([new MessagePart($text)]), $stringArray); + } else { + $messages = self::normalizePromptToMessages($input); + } + + // Get model - either provided or auto-discovered + $resolvedModel = $model ?? self::findSuitableEmbeddingModel(); + + // Ensure the model supports embedding generation + if (!$resolvedModel instanceof EmbeddingGenerationModelInterface) { + throw new \InvalidArgumentException( + 'Model must implement EmbeddingGenerationModelInterface for embedding generation' + ); + } + + // Generate the result using the model + return $resolvedModel->generateEmbeddingsResult($messages); + } + /** * Creates a generation operation for async processing. * @@ -455,6 +496,44 @@ public static function generateSpeechOperation($prompt, ModelInterface $model): ); } + /** + * Creates an embedding generation operation for async processing. + * + * @since n.e.x.t + * + * @param string[]|Message[] $input The input data to generate embeddings for. + * @param ModelInterface $model The model to use for embedding generation. + * @return EmbeddingOperation The operation for async embedding processing. + * + * @throws \InvalidArgumentException If the input format is invalid or model doesn't support embedding generation. + */ + public static function generateEmbeddingsOperation($input, ModelInterface $model): EmbeddingOperation + { + // Convert input to standardized Message array format + if (is_array($input) && !empty($input) && is_string($input[0])) { + /** @var string[] $stringArray */ + $stringArray = $input; + $messages = array_map(fn(string $text) => new UserMessage([new MessagePart($text)]), $stringArray); + } else { + $messages = self::normalizePromptToMessages($input); + } + + // Ensure the model supports embedding generation operations + if (!$model instanceof EmbeddingGenerationOperationModelInterface) { + throw new \InvalidArgumentException( + 'Model must implement EmbeddingGenerationOperationModelInterface ' . + 'for embedding generation operations' + ); + } + + // Create and return the operation (starting state, no result yet) + return new EmbeddingOperation( + uniqid('embed_op_', true), + OperationStateEnum::starting(), + null + ); + } + /** * Normalizes various prompt formats into a standardized Message array. * @@ -628,4 +707,36 @@ private static function findSuitableSpeechModel(): ModelInterface $models[0]->getId() ); } + + /** + * Finds a suitable embedding generation model. + * + * @since n.e.x.t + * + * @return ModelInterface A suitable embedding generation model. + * + * @throws \RuntimeException If no suitable model is found. + */ + private static function findSuitableEmbeddingModel(): ModelInterface + { + $requirements = new ModelRequirements([CapabilityEnum::embeddingGeneration()], []); + $providerModelsMetadata = self::defaultRegistry()->findModelsMetadataForSupport($requirements); + + if (empty($providerModelsMetadata)) { + throw new \RuntimeException('No embedding generation models available'); + } + + // Get the first suitable provider and model + $providerMetadata = $providerModelsMetadata[0]; + $models = $providerMetadata->getModels(); + + if (empty($models)) { + throw new \RuntimeException('No models available in provider'); + } + + return self::defaultRegistry()->getProviderModel( + $providerMetadata->getProvider()->getId(), + $models[0]->getId() + ); + } } diff --git a/src/Embeddings/DTO/Embedding.php b/src/Embeddings/DTO/Embedding.php new file mode 100644 index 00000000..48b8c992 --- /dev/null +++ b/src/Embeddings/DTO/Embedding.php @@ -0,0 +1,130 @@ + + */ +class Embedding extends AbstractDataTransferObject +{ + public const KEY_VECTOR = 'vector'; + public const KEY_DIMENSION = 'dimension'; + + /** + * @var float[] The embedding vector values. + */ + private array $vector; + + /** + * @var int The dimension (length) of the embedding vector. + */ + private int $dimension; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param float[] $vector The embedding vector values. + */ + public function __construct(array $vector) + { + $this->vector = $vector; + $this->dimension = count($vector); + } + + /** + * Gets the embedding vector values. + * + * @since n.e.x.t + * + * @return float[] The vector values. + */ + public function getVector(): array + { + return $this->vector; + } + + /** + * Gets the dimension (length) of the embedding vector. + * + * @since n.e.x.t + * + * @return int The vector dimension. + */ + public function getDimension(): int + { + return $this->dimension; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + self::KEY_VECTOR => [ + 'type' => 'array', + 'items' => [ + 'type' => 'number', + ], + 'description' => 'The embedding vector values.', + ], + self::KEY_DIMENSION => [ + 'type' => 'integer', + 'description' => 'The dimension (length) of the embedding vector.', + ], + ], + 'required' => [self::KEY_VECTOR, self::KEY_DIMENSION], + ]; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + * + * @return EmbeddingArrayShape + */ + public function toArray(): array + { + return [ + self::KEY_VECTOR => $this->vector, + self::KEY_DIMENSION => $this->dimension, + ]; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [ + self::KEY_VECTOR, + ]); + + return new self($array[self::KEY_VECTOR]); + } +} \ No newline at end of file diff --git a/src/Operations/DTO/EmbeddingOperation.php b/src/Operations/DTO/EmbeddingOperation.php new file mode 100644 index 00000000..677b71cc --- /dev/null +++ b/src/Operations/DTO/EmbeddingOperation.php @@ -0,0 +1,170 @@ + + */ +class EmbeddingOperation extends AbstractDataTransferObject implements OperationInterface +{ + public const KEY_ID = 'id'; + public const KEY_STATE = 'state'; + public const KEY_RESULT = 'result'; + + /** + * @var string Unique identifier for this operation. + */ + private string $id; + + /** + * @var OperationStateEnum The current state of the operation. + */ + private OperationStateEnum $state; + + /** + * @var EmbeddingResult|null The result once the operation completes. + */ + private ?EmbeddingResult $result; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param string $id Unique identifier for this operation. + * @param OperationStateEnum $state The current state of the operation. + * @param EmbeddingResult|null $result The result once the operation completes. + */ + public function __construct(string $id, OperationStateEnum $state, ?EmbeddingResult $result = null) + { + $this->id = $id; + $this->state = $state; + $this->result = $result; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public function getId(): string + { + return $this->id; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public function getState(): OperationStateEnum + { + return $this->state; + } + + /** + * Gets the embedding operation result. + * + * @since n.e.x.t + * + * @return EmbeddingResult|null The result once the operation completes. + */ + public function getResult(): ?EmbeddingResult + { + return $this->result; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + self::KEY_ID => [ + 'type' => 'string', + 'description' => 'Unique identifier for this operation.', + ], + self::KEY_STATE => [ + 'type' => 'string', + 'enum' => OperationStateEnum::getAllValues(), + 'description' => 'The current state of the operation.', + ], + self::KEY_RESULT => [ + '$ref' => '#/definitions/EmbeddingResult', + 'description' => 'The result once the operation completes.', + ], + ], + 'required' => [self::KEY_ID, self::KEY_STATE], + 'definitions' => [ + 'EmbeddingResult' => EmbeddingResult::getJsonSchema(), + ], + ]; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + * + * @return EmbeddingOperationArrayShape + */ + public function toArray(): array + { + $array = [ + self::KEY_ID => $this->id, + self::KEY_STATE => $this->state->getValue(), + ]; + + if ($this->result !== null) { + $array[self::KEY_RESULT] = $this->result->toArray(); + } + + return $array; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [ + self::KEY_ID, + self::KEY_STATE, + ]); + + $result = null; + if (isset($array[self::KEY_RESULT])) { + $result = EmbeddingResult::fromArray($array[self::KEY_RESULT]); + } + + return new self( + $array[self::KEY_ID], + OperationStateEnum::fromValue($array[self::KEY_STATE]), + $result + ); + } +} \ No newline at end of file diff --git a/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php b/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php new file mode 100644 index 00000000..71c829a0 --- /dev/null +++ b/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php @@ -0,0 +1,30 @@ + $input Array of messages containing the input data to generate embeddings for. + * @return EmbeddingResult Result containing generated embeddings. + */ + public function generateEmbeddingsResult(array $input): EmbeddingResult; +} \ No newline at end of file diff --git a/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationOperationModelInterface.php b/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationOperationModelInterface.php new file mode 100644 index 00000000..874b6e15 --- /dev/null +++ b/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationOperationModelInterface.php @@ -0,0 +1,29 @@ + $input Array of messages containing the input data to generate embeddings for. + * @return EmbeddingOperation The initiated embedding generation operation. + */ + public function generateEmbeddingsOperation(array $input): EmbeddingOperation; +} \ No newline at end of file diff --git a/src/Results/DTO/EmbeddingResult.php b/src/Results/DTO/EmbeddingResult.php new file mode 100644 index 00000000..871a5486 --- /dev/null +++ b/src/Results/DTO/EmbeddingResult.php @@ -0,0 +1,195 @@ +, + * tokenUsage: TokenUsageArrayShape, + * providerMetadata?: array + * } + * + * @extends AbstractDataTransferObject + */ +class EmbeddingResult extends AbstractDataTransferObject implements ResultInterface +{ + public const KEY_ID = 'id'; + public const KEY_EMBEDDINGS = 'embeddings'; + public const KEY_TOKEN_USAGE = 'tokenUsage'; + public const KEY_PROVIDER_METADATA = 'providerMetadata'; + + /** + * @var string Unique identifier for this result. + */ + private string $id; + + /** + * @var Embedding[] The generated embeddings. + */ + private array $embeddings; + + /** + * @var TokenUsage Token usage statistics. + */ + private TokenUsage $tokenUsage; + + /** + * @var array Provider-specific metadata. + */ + private array $providerMetadata; + + /** + * Constructor. + * + * @since n.e.x.t + * + * @param string $id Unique identifier for this result. + * @param Embedding[] $embeddings The generated embeddings. + * @param TokenUsage $tokenUsage Token usage statistics. + * @param array $providerMetadata Provider-specific metadata. + * @throws InvalidArgumentException If no embeddings provided. + */ + public function __construct(string $id, array $embeddings, TokenUsage $tokenUsage, array $providerMetadata = []) + { + if (empty($embeddings)) { + throw new InvalidArgumentException('At least one embedding must be provided'); + } + + $this->id = $id; + $this->embeddings = $embeddings; + $this->tokenUsage = $tokenUsage; + $this->providerMetadata = $providerMetadata; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public function getId(): string + { + return $this->id; + } + + /** + * Gets the generated embeddings. + * + * @since n.e.x.t + * + * @return Embedding[] The embeddings. + */ + public function getEmbeddings(): array + { + return $this->embeddings; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public function getTokenUsage(): TokenUsage + { + return $this->tokenUsage; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public function getProviderMetadata(): array + { + return $this->providerMetadata; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function getJsonSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + self::KEY_ID => [ + 'type' => 'string', + 'description' => 'Unique identifier for this result.', + ], + self::KEY_EMBEDDINGS => [ + 'type' => 'array', + 'items' => Embedding::getJsonSchema(), + 'description' => 'The generated embeddings.', + ], + self::KEY_TOKEN_USAGE => TokenUsage::getJsonSchema(), + self::KEY_PROVIDER_METADATA => [ + 'type' => 'object', + 'description' => 'Provider-specific metadata.', + ], + ], + 'required' => [self::KEY_ID, self::KEY_EMBEDDINGS, self::KEY_TOKEN_USAGE], + ]; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + * + * @return EmbeddingResultArrayShape + */ + public function toArray(): array + { + return [ + self::KEY_ID => $this->id, + self::KEY_EMBEDDINGS => array_map(fn(Embedding $embedding) => $embedding->toArray(), $this->embeddings), + self::KEY_TOKEN_USAGE => $this->tokenUsage->toArray(), + self::KEY_PROVIDER_METADATA => $this->providerMetadata, + ]; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + public static function fromArray(array $array): self + { + static::validateFromArrayData($array, [ + self::KEY_ID, + self::KEY_EMBEDDINGS, + self::KEY_TOKEN_USAGE, + ]); + + $embeddings = array_map( + fn(array $embeddingArray) => Embedding::fromArray($embeddingArray), + $array[self::KEY_EMBEDDINGS] + ); + + return new self( + $array[self::KEY_ID], + $embeddings, + TokenUsage::fromArray($array[self::KEY_TOKEN_USAGE]), + $array[self::KEY_PROVIDER_METADATA] ?? [] + ); + } +} \ No newline at end of file diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index 96231e09..54f02498 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -87,7 +87,7 @@ public function testPromptThrowsException(): void $this->expectException(RuntimeException::class); $this->expectExceptionMessage( 'PromptBuilder is not yet available. This method depends on PR #49. ' . - 'All generation methods (text, image, text-to-speech, speech) are ready for integration.' + 'All generation methods (text, image, text-to-speech, speech, embeddings) are ready for integration.' ); AiClient::prompt('Test prompt'); From 69168ae214a5e2b170c92c120a05e2237517486b Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Sun, 17 Aug 2025 19:16:11 +0300 Subject: [PATCH 12/69] Build comprehensive embedding test infrastructure - Create EmbeddingTest.php with 9 test methods covering vector operations and transformations - Create EmbeddingResultTest.php with 12 test methods covering result validation and serialization - Create EmbeddingOperationTest.php with 14 test methods covering all operation states - Create MockEmbeddingGenerationModel.php and MockEmbeddingGenerationOperationModel.php for testing - Add 5 embedding test methods to AiClientTest.php with proper mock integration --- src/AiClient.php | 8 +- src/Operations/DTO/EmbeddingOperation.php | 65 +++-- tests/mocks/MockEmbeddingGenerationModel.php | 96 +++++++ .../MockEmbeddingGenerationOperationModel.php | 85 ++++++ tests/unit/AiClientTest.php | 123 +++++++++ tests/unit/Embeddings/DTO/EmbeddingTest.php | 147 +++++++++++ .../Operations/DTO/EmbeddingOperationTest.php | 244 ++++++++++++++++++ .../unit/Results/DTO/EmbeddingResultTest.php | 226 ++++++++++++++++ 8 files changed, 966 insertions(+), 28 deletions(-) create mode 100644 tests/mocks/MockEmbeddingGenerationModel.php create mode 100644 tests/mocks/MockEmbeddingGenerationOperationModel.php create mode 100644 tests/unit/Embeddings/DTO/EmbeddingTest.php create mode 100644 tests/unit/Operations/DTO/EmbeddingOperationTest.php create mode 100644 tests/unit/Results/DTO/EmbeddingResultTest.php diff --git a/src/AiClient.php b/src/AiClient.php index 1a7f5f42..26319081 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -526,12 +526,8 @@ public static function generateEmbeddingsOperation($input, ModelInterface $model ); } - // Create and return the operation (starting state, no result yet) - return new EmbeddingOperation( - uniqid('embed_op_', true), - OperationStateEnum::starting(), - null - ); + // Delegate to the model's operation method + return $model->generateEmbeddingsOperation($input); } /** diff --git a/src/Operations/DTO/EmbeddingOperation.php b/src/Operations/DTO/EmbeddingOperation.php index 677b71cc..e6126875 100644 --- a/src/Operations/DTO/EmbeddingOperation.php +++ b/src/Operations/DTO/EmbeddingOperation.php @@ -100,25 +100,46 @@ public function getResult(): ?EmbeddingResult public static function getJsonSchema(): array { return [ - 'type' => 'object', - 'properties' => [ - self::KEY_ID => [ - 'type' => 'string', - 'description' => 'Unique identifier for this operation.', + 'oneOf' => [ + // Succeeded state - has result + [ + 'type' => 'object', + 'properties' => [ + self::KEY_ID => [ + 'type' => 'string', + 'description' => 'Unique identifier for this operation.', + ], + self::KEY_STATE => [ + 'type' => 'string', + 'const' => OperationStateEnum::succeeded()->value, + ], + self::KEY_RESULT => EmbeddingResult::getJsonSchema(), + ], + 'required' => [self::KEY_ID, self::KEY_STATE, self::KEY_RESULT], + 'additionalProperties' => false, ], - self::KEY_STATE => [ - 'type' => 'string', - 'enum' => OperationStateEnum::getAllValues(), - 'description' => 'The current state of the operation.', + // All other states - no result + [ + 'type' => 'object', + 'properties' => [ + self::KEY_ID => [ + 'type' => 'string', + 'description' => 'Unique identifier for this operation.', + ], + self::KEY_STATE => [ + 'type' => 'string', + 'enum' => [ + OperationStateEnum::starting()->value, + OperationStateEnum::processing()->value, + OperationStateEnum::failed()->value, + OperationStateEnum::canceled()->value, + ], + 'description' => 'The current state of the operation.', + ], + ], + 'required' => [self::KEY_ID, self::KEY_STATE], + 'additionalProperties' => false, ], - self::KEY_RESULT => [ - '$ref' => '#/definitions/EmbeddingResult', - 'description' => 'The result once the operation completes.', - ], - ], - 'required' => [self::KEY_ID, self::KEY_STATE], - 'definitions' => [ - 'EmbeddingResult' => EmbeddingResult::getJsonSchema(), ], ]; } @@ -132,16 +153,16 @@ public static function getJsonSchema(): array */ public function toArray(): array { - $array = [ + $data = [ self::KEY_ID => $this->id, - self::KEY_STATE => $this->state->getValue(), + self::KEY_STATE => $this->state->value, ]; if ($this->result !== null) { - $array[self::KEY_RESULT] = $this->result->toArray(); + $data[self::KEY_RESULT] = $this->result->toArray(); } - return $array; + return $data; } /** @@ -163,7 +184,7 @@ public static function fromArray(array $array): self return new self( $array[self::KEY_ID], - OperationStateEnum::fromValue($array[self::KEY_STATE]), + OperationStateEnum::from($array[self::KEY_STATE]), $result ); } diff --git a/tests/mocks/MockEmbeddingGenerationModel.php b/tests/mocks/MockEmbeddingGenerationModel.php new file mode 100644 index 00000000..0a582196 --- /dev/null +++ b/tests/mocks/MockEmbeddingGenerationModel.php @@ -0,0 +1,96 @@ +metadata = $metadata ?? new ModelMetadata( + 'mock-embedding-model', + 'Mock Embedding Model', + [CapabilityEnum::embeddingGeneration()], + [] + ); + $this->config = $config ?? new ModelConfig(); + } + + /** + * {@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; + } + + /** + * {@inheritDoc} + */ + public function generateEmbeddingsResult(array $input): EmbeddingResult + { + // Generate mock embeddings based on input length + $embeddings = []; + foreach ($input as $index => $message) { + // Create a simple mock embedding vector based on message index + $vector = array_fill(0, 3, 0.1 + ($index * 0.1)); + $embeddings[] = new Embedding($vector); + } + + $tokenUsage = new TokenUsage(count($input) * 5, 0, count($input) * 5); + + return new EmbeddingResult( + 'mock-embedding-result-' . uniqid(), + $embeddings, + $tokenUsage, + ['model' => 'mock-embedding-model'] + ); + } +} \ No newline at end of file diff --git a/tests/mocks/MockEmbeddingGenerationOperationModel.php b/tests/mocks/MockEmbeddingGenerationOperationModel.php new file mode 100644 index 00000000..d72083a6 --- /dev/null +++ b/tests/mocks/MockEmbeddingGenerationOperationModel.php @@ -0,0 +1,85 @@ +metadata = $metadata ?? new ModelMetadata( + 'mock-embedding-operation-model', + 'Mock Embedding Operation Model', + [CapabilityEnum::embeddingGeneration()], + [] + ); + $this->config = $config ?? new ModelConfig(); + } + + /** + * {@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; + } + + /** + * {@inheritDoc} + */ + public function generateEmbeddingsOperation(array $input): EmbeddingOperation + { + // Create a mock embedding operation in starting state + return new EmbeddingOperation( + 'mock-embedding-op-' . uniqid(), + OperationStateEnum::starting(), + null + ); + } +} \ No newline at end of file diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index 54f02498..bcb23113 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -18,10 +18,14 @@ use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Results\DTO\Candidate; use WordPress\AiClient\Results\DTO\GenerativeAiResult; +use WordPress\AiClient\Results\DTO\EmbeddingResult; use WordPress\AiClient\Results\DTO\TokenUsage; +use WordPress\AiClient\Operations\DTO\EmbeddingOperation; use WordPress\AiClient\Results\Enums\FinishReasonEnum; use WordPress\AiClient\Tests\mocks\MockImageGenerationModel; use WordPress\AiClient\Tests\mocks\MockTextGenerationModel; +use WordPress\AiClient\Tests\mocks\MockEmbeddingGenerationModel; +use WordPress\AiClient\Tests\mocks\MockEmbeddingGenerationOperationModel; /** * @covers \WordPress\AiClient\AiClient @@ -31,6 +35,8 @@ class AiClientTest extends TestCase private ProviderRegistry $registry; private MockTextGenerationModel $mockTextModel; private MockImageGenerationModel $mockImageModel; + private MockEmbeddingGenerationModel $mockEmbeddingModel; + private MockEmbeddingGenerationOperationModel $mockEmbeddingOperationModel; protected function setUp(): void { @@ -40,6 +46,8 @@ protected function setUp(): void // Create mock models that implement both base and generation interfaces $this->mockTextModel = $this->createMock(MockTextGenerationModel::class); $this->mockImageModel = $this->createMock(MockImageGenerationModel::class); + $this->mockEmbeddingModel = $this->createMock(MockEmbeddingGenerationModel::class); + $this->mockEmbeddingOperationModel = $this->createMock(MockEmbeddingGenerationOperationModel::class); // Set the test registry as the default AiClient::setDefaultRegistry($this->registry); @@ -531,4 +539,119 @@ public function testGenerateImageOperationThrowsExceptionForNonImageModel(): voi AiClient::generateImageOperation($prompt, $nonImageModel); } + + /** + * Tests generateEmbeddingsResult delegates to model's generateEmbeddingsResult method. + */ + public function testGenerateEmbeddingsResultDelegatesToModel(): void + { + $input = ['test input text', 'another text']; + $expectedResult = $this->createTestEmbeddingResult(); + + $this->mockEmbeddingModel + ->expects($this->once()) + ->method('generateEmbeddingsResult') + ->willReturn($expectedResult); + + $result = AiClient::generateEmbeddingsResult($input, $this->mockEmbeddingModel); + + $this->assertEquals($expectedResult, $result); + } + + /** + * Tests generateEmbeddingsResult with Message array input. + */ + public function testGenerateEmbeddingsResultWithMessageInput(): void + { + $input = [new UserMessage([new MessagePart('test message')])]; + $expectedResult = $this->createTestEmbeddingResult(); + + $this->mockEmbeddingModel + ->expects($this->once()) + ->method('generateEmbeddingsResult') + ->willReturn($expectedResult); + + $result = AiClient::generateEmbeddingsResult($input, $this->mockEmbeddingModel); + + $this->assertEquals($expectedResult, $result); + } + + /** + * Tests generateEmbeddingsResult throws exception for non-embedding model. + */ + public function testGenerateEmbeddingsResultThrowsExceptionForNonEmbeddingModel(): void + { + $input = ['test input']; + $nonEmbeddingModel = $this->createMock(ModelInterface::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Model must implement EmbeddingGenerationModelInterface for embedding generation' + ); + + AiClient::generateEmbeddingsResult($input, $nonEmbeddingModel); + } + + /** + * Tests generateEmbeddingsOperation delegates to model's generateEmbeddingsOperation method. + */ + public function testGenerateEmbeddingsOperationDelegatesToModel(): void + { + $input = ['test input text']; + $expectedOperation = $this->createTestEmbeddingOperation(); + + $this->mockEmbeddingOperationModel + ->expects($this->once()) + ->method('generateEmbeddingsOperation') + ->willReturn($expectedOperation); + + $result = AiClient::generateEmbeddingsOperation($input, $this->mockEmbeddingOperationModel); + + $this->assertEquals($expectedOperation, $result); + } + + /** + * Tests generateEmbeddingsOperation throws exception for non-embedding operation model. + */ + public function testGenerateEmbeddingsOperationThrowsExceptionForNonEmbeddingOperationModel(): void + { + $input = ['test input']; + $nonEmbeddingOperationModel = $this->createMock(ModelInterface::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Model must implement EmbeddingGenerationOperationModelInterface ' . + 'for embedding generation operations' + ); + + AiClient::generateEmbeddingsOperation($input, $nonEmbeddingOperationModel); + } + + /** + * Creates a test EmbeddingResult for testing purposes. + */ + private function createTestEmbeddingResult(): EmbeddingResult + { + $embedding = new \WordPress\AiClient\Embeddings\DTO\Embedding([0.1, 0.2, 0.3]); + $tokenUsage = new TokenUsage(5, 0, 5); + + return new EmbeddingResult( + 'test-embedding-result', + [$embedding], + $tokenUsage, + ['model' => 'test-embedding-model'] + ); + } + + /** + * Creates a test EmbeddingOperation for testing purposes. + */ + private function createTestEmbeddingOperation(): EmbeddingOperation + { + return new EmbeddingOperation( + 'test-embedding-operation', + OperationStateEnum::starting(), + null + ); + } } diff --git a/tests/unit/Embeddings/DTO/EmbeddingTest.php b/tests/unit/Embeddings/DTO/EmbeddingTest.php new file mode 100644 index 00000000..96af3d67 --- /dev/null +++ b/tests/unit/Embeddings/DTO/EmbeddingTest.php @@ -0,0 +1,147 @@ +assertEquals($vector, $embedding->getVector()); + $this->assertEquals(5, $embedding->getDimension()); + } + + /** + * Tests creating Embedding with empty vector. + */ + public function testCreateWithEmptyVector(): void + { + $vector = []; + $embedding = new Embedding($vector); + + $this->assertEquals($vector, $embedding->getVector()); + $this->assertEquals(0, $embedding->getDimension()); + } + + /** + * Tests creating Embedding with large vector. + */ + public function testCreateWithLargeVector(): void + { + $vector = array_fill(0, 1536, 0.1); // Common OpenAI embedding size + $embedding = new Embedding($vector); + + $this->assertEquals($vector, $embedding->getVector()); + $this->assertEquals(1536, $embedding->getDimension()); + } + + /** + * Tests creating Embedding with negative values. + */ + public function testCreateWithNegativeValues(): void + { + $vector = [-0.5, -0.3, 0.0, 0.3, 0.5]; + $embedding = new Embedding($vector); + + $this->assertEquals($vector, $embedding->getVector()); + $this->assertEquals(5, $embedding->getDimension()); + } + + /** + * Tests toArray conversion. + */ + public function testToArray(): void + { + $vector = [0.1, 0.2, 0.3]; + $embedding = new Embedding($vector); + + $expected = [ + 'vector' => $vector, + 'dimension' => 3, + ]; + + $this->assertEquals($expected, $embedding->toArray()); + } + + /** + * Tests fromArray creation. + */ + public function testFromArray(): void + { + $vector = [0.4, 0.5, 0.6]; + $array = [ + 'vector' => $vector, + 'dimension' => 3, + ]; + + $embedding = Embedding::fromArray($array); + + $this->assertEquals($vector, $embedding->getVector()); + $this->assertEquals(3, $embedding->getDimension()); + } + + /** + * Tests fromArray with missing vector throws exception. + */ + public function testFromArrayWithMissingVectorThrowsException(): void + { + $array = [ + 'dimension' => 3, + ]; + + $this->expectException(\InvalidArgumentException::class); + Embedding::fromArray($array); + } + + /** + * Tests JSON schema generation. + */ + public function testGetJsonSchema(): void + { + $schema = Embedding::getJsonSchema(); + + $this->assertArrayHasKey('type', $schema); + $this->assertEquals('object', $schema['type']); + + $this->assertArrayHasKey('properties', $schema); + $this->assertArrayHasKey('vector', $schema['properties']); + $this->assertArrayHasKey('dimension', $schema['properties']); + + $this->assertArrayHasKey('required', $schema); + $this->assertContains('vector', $schema['required']); + $this->assertContains('dimension', $schema['required']); + } + + /** + * Tests that dimension calculation is accurate. + */ + public function testDimensionCalculation(): void + { + // Test various vector sizes + $testCases = [ + 'empty' => [[], 0], + 'single' => [[1.0], 1], + 'double' => [[1.0, 2.0], 2], + 'hundred' => [array_fill(0, 100, 0.1), 100], + 'large' => [array_fill(0, 1024, 0.1), 1024], + ]; + + foreach ($testCases as $testName => [$vector, $expectedDimension]) { + $embedding = new Embedding($vector); + $this->assertEquals($expectedDimension, $embedding->getDimension(), "Failed for test case: $testName"); + } + } +} \ No newline at end of file diff --git a/tests/unit/Operations/DTO/EmbeddingOperationTest.php b/tests/unit/Operations/DTO/EmbeddingOperationTest.php new file mode 100644 index 00000000..9836c105 --- /dev/null +++ b/tests/unit/Operations/DTO/EmbeddingOperationTest.php @@ -0,0 +1,244 @@ +tokenUsage = new TokenUsage(10, 0, 10); + $embedding = new Embedding([0.1, 0.2, 0.3]); + $this->embeddingResult = new EmbeddingResult('result-id', [$embedding], $this->tokenUsage); + } + + /** + * Tests creating EmbeddingOperation with starting state. + */ + public function testCreateWithStartingState(): void + { + $operation = new EmbeddingOperation('op-id', OperationStateEnum::starting()); + + $this->assertEquals('op-id', $operation->getId()); + $this->assertEquals(OperationStateEnum::starting(), $operation->getState()); + $this->assertNull($operation->getResult()); + } + + /** + * Tests creating EmbeddingOperation with processing state. + */ + public function testCreateWithProcessingState(): void + { + $operation = new EmbeddingOperation('op-id', OperationStateEnum::processing()); + + $this->assertEquals('op-id', $operation->getId()); + $this->assertEquals(OperationStateEnum::processing(), $operation->getState()); + $this->assertNull($operation->getResult()); + } + + /** + * Tests creating EmbeddingOperation with succeeded state and result. + */ + public function testCreateWithSucceededStateAndResult(): void + { + $operation = new EmbeddingOperation('op-id', OperationStateEnum::succeeded(), $this->embeddingResult); + + $this->assertEquals('op-id', $operation->getId()); + $this->assertEquals(OperationStateEnum::succeeded(), $operation->getState()); + $this->assertEquals($this->embeddingResult, $operation->getResult()); + } + + /** + * Tests creating EmbeddingOperation with failed state. + */ + public function testCreateWithFailedState(): void + { + $operation = new EmbeddingOperation('op-id', OperationStateEnum::failed()); + + $this->assertEquals('op-id', $operation->getId()); + $this->assertEquals(OperationStateEnum::failed(), $operation->getState()); + $this->assertNull($operation->getResult()); + } + + /** + * Tests creating EmbeddingOperation with canceled state. + */ + public function testCreateWithCanceledState(): void + { + $operation = new EmbeddingOperation('op-id', OperationStateEnum::canceled()); + + $this->assertEquals('op-id', $operation->getId()); + $this->assertEquals(OperationStateEnum::canceled(), $operation->getState()); + $this->assertNull($operation->getResult()); + } + + /** + * Tests toArray conversion without result. + */ + public function testToArrayWithoutResult(): void + { + $operation = new EmbeddingOperation('op-id', OperationStateEnum::starting()); + + $expected = [ + 'id' => 'op-id', + 'state' => 'starting', + ]; + + $this->assertEquals($expected, $operation->toArray()); + } + + /** + * Tests toArray conversion with result. + */ + public function testToArrayWithResult(): void + { + $operation = new EmbeddingOperation('op-id', OperationStateEnum::succeeded(), $this->embeddingResult); + + $expected = [ + 'id' => 'op-id', + 'state' => 'succeeded', + 'result' => $this->embeddingResult->toArray(), + ]; + + $this->assertEquals($expected, $operation->toArray()); + } + + /** + * Tests fromArray creation without result. + */ + public function testFromArrayWithoutResult(): void + { + $array = [ + 'id' => 'op-id', + 'state' => 'processing', + ]; + + $operation = EmbeddingOperation::fromArray($array); + + $this->assertEquals('op-id', $operation->getId()); + $this->assertEquals(OperationStateEnum::processing(), $operation->getState()); + $this->assertNull($operation->getResult()); + } + + /** + * Tests fromArray creation with result. + */ + public function testFromArrayWithResult(): void + { + $array = [ + 'id' => 'op-id', + 'state' => 'succeeded', + 'result' => $this->embeddingResult->toArray(), + ]; + + $operation = EmbeddingOperation::fromArray($array); + + $this->assertEquals('op-id', $operation->getId()); + $this->assertEquals(OperationStateEnum::succeeded(), $operation->getState()); + $this->assertNotNull($operation->getResult()); + $this->assertEquals($this->embeddingResult->getId(), $operation->getResult()->getId()); + } + + /** + * Tests fromArray with missing required field throws exception. + */ + public function testFromArrayWithMissingRequiredFieldThrowsException(): void + { + $array = [ + 'id' => 'op-id', + // Missing state + ]; + + $this->expectException(\InvalidArgumentException::class); + EmbeddingOperation::fromArray($array); + } + + /** + * Tests JSON schema generation. + */ + public function testGetJsonSchema(): void + { + $schema = EmbeddingOperation::getJsonSchema(); + + $this->assertArrayHasKey('oneOf', $schema); + $this->assertCount(2, $schema['oneOf']); + + // Test succeeded state schema (with result) + $succeededSchema = $schema['oneOf'][0]; + $this->assertEquals('object', $succeededSchema['type']); + $this->assertArrayHasKey('properties', $succeededSchema); + $this->assertArrayHasKey('id', $succeededSchema['properties']); + $this->assertArrayHasKey('state', $succeededSchema['properties']); + $this->assertArrayHasKey('result', $succeededSchema['properties']); + $this->assertContains('result', $succeededSchema['required']); + + // Test other states schema (without result) + $otherStatesSchema = $schema['oneOf'][1]; + $this->assertEquals('object', $otherStatesSchema['type']); + $this->assertArrayHasKey('properties', $otherStatesSchema); + $this->assertArrayHasKey('id', $otherStatesSchema['properties']); + $this->assertArrayHasKey('state', $otherStatesSchema['properties']); + $this->assertArrayNotHasKey('result', $otherStatesSchema['properties']); + $this->assertNotContains('result', $otherStatesSchema['required']); + } + + /** + * Tests that EmbeddingOperation implements OperationInterface. + */ + public function testImplementsOperationInterface(): void + { + $operation = new EmbeddingOperation('op-id', OperationStateEnum::starting()); + + $this->assertInstanceOf(\WordPress\AiClient\Operations\Contracts\OperationInterface::class, $operation); + } + + /** + * Tests operation state transitions. + */ + public function testOperationStateTransitions(): void + { + // Test typical operation lifecycle + $startingOp = new EmbeddingOperation('op-id', OperationStateEnum::starting()); + $this->assertTrue($startingOp->getState()->isStarting()); + + $processingOp = new EmbeddingOperation('op-id', OperationStateEnum::processing()); + $this->assertTrue($processingOp->getState()->isProcessing()); + + $succeededOp = new EmbeddingOperation('op-id', OperationStateEnum::succeeded(), $this->embeddingResult); + $this->assertTrue($succeededOp->getState()->isSucceeded()); + $this->assertNotNull($succeededOp->getResult()); + + $failedOp = new EmbeddingOperation('op-id', OperationStateEnum::failed()); + $this->assertTrue($failedOp->getState()->isFailed()); + $this->assertNull($failedOp->getResult()); + } + + /** + * Tests round-trip conversion (toArray -> fromArray). + */ + public function testRoundTripConversion(): void + { + $originalOperation = new EmbeddingOperation('op-id', OperationStateEnum::succeeded(), $this->embeddingResult); + + $array = $originalOperation->toArray(); + $reconstructedOperation = EmbeddingOperation::fromArray($array); + + $this->assertEquals($originalOperation->getId(), $reconstructedOperation->getId()); + $this->assertEquals($originalOperation->getState()->value, $reconstructedOperation->getState()->value); + $this->assertEquals($originalOperation->getResult()->getId(), $reconstructedOperation->getResult()->getId()); + } +} \ No newline at end of file diff --git a/tests/unit/Results/DTO/EmbeddingResultTest.php b/tests/unit/Results/DTO/EmbeddingResultTest.php new file mode 100644 index 00000000..b864e979 --- /dev/null +++ b/tests/unit/Results/DTO/EmbeddingResultTest.php @@ -0,0 +1,226 @@ +tokenUsage = new TokenUsage(10, 0, 10); + $this->embedding1 = new Embedding([0.1, 0.2, 0.3]); + $this->embedding2 = new Embedding([0.4, 0.5, 0.6]); + } + + /** + * Tests creating EmbeddingResult with valid data. + */ + public function testCreateWithValidData(): void + { + $embeddings = [$this->embedding1, $this->embedding2]; + $metadata = ['model' => 'text-embedding-ada-002']; + + $result = new EmbeddingResult('test-id', $embeddings, $this->tokenUsage, $metadata); + + $this->assertEquals('test-id', $result->getId()); + $this->assertEquals($embeddings, $result->getEmbeddings()); + $this->assertEquals($this->tokenUsage, $result->getTokenUsage()); + $this->assertEquals($metadata, $result->getProviderMetadata()); + } + + /** + * Tests creating EmbeddingResult with empty metadata. + */ + public function testCreateWithEmptyMetadata(): void + { + $embeddings = [$this->embedding1]; + + $result = new EmbeddingResult('test-id', $embeddings, $this->tokenUsage); + + $this->assertEquals('test-id', $result->getId()); + $this->assertEquals($embeddings, $result->getEmbeddings()); + $this->assertEquals($this->tokenUsage, $result->getTokenUsage()); + $this->assertEquals([], $result->getProviderMetadata()); + } + + /** + * Tests creating EmbeddingResult with empty embeddings throws exception. + */ + public function testCreateWithEmptyEmbeddingsThrowsException(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('At least one embedding must be provided'); + + new EmbeddingResult('test-id', [], $this->tokenUsage); + } + + /** + * Tests creating EmbeddingResult with single embedding. + */ + public function testCreateWithSingleEmbedding(): void + { + $embeddings = [$this->embedding1]; + + $result = new EmbeddingResult('test-id', $embeddings, $this->tokenUsage); + + $this->assertCount(1, $result->getEmbeddings()); + $this->assertEquals($this->embedding1, $result->getEmbeddings()[0]); + } + + /** + * Tests creating EmbeddingResult with multiple embeddings. + */ + public function testCreateWithMultipleEmbeddings(): void + { + $embeddings = [$this->embedding1, $this->embedding2]; + + $result = new EmbeddingResult('test-id', $embeddings, $this->tokenUsage); + + $this->assertCount(2, $result->getEmbeddings()); + $this->assertEquals($this->embedding1, $result->getEmbeddings()[0]); + $this->assertEquals($this->embedding2, $result->getEmbeddings()[1]); + } + + /** + * Tests toArray conversion. + */ + public function testToArray(): void + { + $embeddings = [$this->embedding1, $this->embedding2]; + $metadata = ['model' => 'text-embedding-ada-002']; + + $result = new EmbeddingResult('test-id', $embeddings, $this->tokenUsage, $metadata); + + $expected = [ + 'id' => 'test-id', + 'embeddings' => [ + $this->embedding1->toArray(), + $this->embedding2->toArray(), + ], + 'tokenUsage' => $this->tokenUsage->toArray(), + 'providerMetadata' => $metadata, + ]; + + $this->assertEquals($expected, $result->toArray()); + } + + /** + * Tests fromArray creation. + */ + public function testFromArray(): void + { + $array = [ + 'id' => 'test-id', + 'embeddings' => [ + $this->embedding1->toArray(), + $this->embedding2->toArray(), + ], + 'tokenUsage' => $this->tokenUsage->toArray(), + 'providerMetadata' => ['model' => 'test-model'], + ]; + + $result = EmbeddingResult::fromArray($array); + + $this->assertEquals('test-id', $result->getId()); + $this->assertCount(2, $result->getEmbeddings()); + $this->assertEquals($this->tokenUsage->toArray(), $result->getTokenUsage()->toArray()); + $this->assertEquals(['model' => 'test-model'], $result->getProviderMetadata()); + } + + /** + * Tests fromArray with missing metadata uses empty array. + */ + public function testFromArrayWithMissingMetadata(): void + { + $array = [ + 'id' => 'test-id', + 'embeddings' => [$this->embedding1->toArray()], + 'tokenUsage' => $this->tokenUsage->toArray(), + ]; + + $result = EmbeddingResult::fromArray($array); + + $this->assertEquals([], $result->getProviderMetadata()); + } + + /** + * Tests fromArray with missing required field throws exception. + */ + public function testFromArrayWithMissingRequiredFieldThrowsException(): void + { + $array = [ + 'id' => 'test-id', + 'embeddings' => [$this->embedding1->toArray()], + // Missing tokenUsage + ]; + + $this->expectException(\InvalidArgumentException::class); + EmbeddingResult::fromArray($array); + } + + /** + * Tests JSON schema generation. + */ + public function testGetJsonSchema(): void + { + $schema = EmbeddingResult::getJsonSchema(); + + $this->assertArrayHasKey('type', $schema); + $this->assertEquals('object', $schema['type']); + + $this->assertArrayHasKey('properties', $schema); + $this->assertArrayHasKey('id', $schema['properties']); + $this->assertArrayHasKey('embeddings', $schema['properties']); + $this->assertArrayHasKey('tokenUsage', $schema['properties']); + $this->assertArrayHasKey('providerMetadata', $schema['properties']); + + $this->assertArrayHasKey('required', $schema); + $this->assertContains('id', $schema['required']); + $this->assertContains('embeddings', $schema['required']); + $this->assertContains('tokenUsage', $schema['required']); + } + + /** + * Tests that EmbeddingResult implements ResultInterface. + */ + public function testImplementsResultInterface(): void + { + $embeddings = [$this->embedding1]; + $result = new EmbeddingResult('test-id', $embeddings, $this->tokenUsage); + + $this->assertInstanceOf(\WordPress\AiClient\Results\Contracts\ResultInterface::class, $result); + } + + /** + * Tests embedding result with complex metadata. + */ + public function testWithComplexMetadata(): void + { + $embeddings = [$this->embedding1]; + $metadata = [ + 'model' => 'text-embedding-ada-002', + 'usage' => ['prompt_tokens' => 5], + 'provider' => 'openai', + 'version' => '1.0', + ]; + + $result = new EmbeddingResult('test-id', $embeddings, $this->tokenUsage, $metadata); + + $this->assertEquals($metadata, $result->getProviderMetadata()); + $this->assertEquals('openai', $result->getProviderMetadata()['provider']); + } +} \ No newline at end of file From a5d85b24aa7996785c3646b9d06a3ae73d26bb90 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Sun, 17 Aug 2025 19:25:14 +0300 Subject: [PATCH 13/69] Resolve PHPStan type errors in embedding methods - Fix type errors by ensuring proper list types for embedding interfaces - Convert Message arrays to proper lists using array_values() for interface compliance --- src/AiClient.php | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index 26319081..f58810b8 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -329,9 +329,14 @@ public static function generateEmbeddingsResult($input, ModelInterface $model = $stringArray = $input; $messages = array_map(fn(string $text) => new UserMessage([new MessagePart($text)]), $stringArray); } else { + /** @var string|MessagePart|MessagePart[]|Message|Message[] $input */ $messages = self::normalizePromptToMessages($input); } + // Ensure messages is a proper list (sequential array with numeric keys starting from 0) + /** @var list $messageList */ + $messageList = array_values($messages); + // Get model - either provided or auto-discovered $resolvedModel = $model ?? self::findSuitableEmbeddingModel(); @@ -343,7 +348,7 @@ public static function generateEmbeddingsResult($input, ModelInterface $model = } // Generate the result using the model - return $resolvedModel->generateEmbeddingsResult($messages); + return $resolvedModel->generateEmbeddingsResult($messageList); } /** @@ -515,9 +520,14 @@ public static function generateEmbeddingsOperation($input, ModelInterface $model $stringArray = $input; $messages = array_map(fn(string $text) => new UserMessage([new MessagePart($text)]), $stringArray); } else { + /** @var string|MessagePart|MessagePart[]|Message|Message[] $input */ $messages = self::normalizePromptToMessages($input); } + // Ensure messages is a proper list (sequential array with numeric keys starting from 0) + /** @var list $messageList */ + $messageList = array_values($messages); + // Ensure the model supports embedding generation operations if (!$model instanceof EmbeddingGenerationOperationModelInterface) { throw new \InvalidArgumentException( @@ -526,8 +536,8 @@ public static function generateEmbeddingsOperation($input, ModelInterface $model ); } - // Delegate to the model's operation method - return $model->generateEmbeddingsOperation($input); + // Delegate to the model's operation method with proper list type + return $model->generateEmbeddingsOperation($messageList); } /** From 9d5893d9f648bbcd32c9c4c549b6f593ec1a98c3 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Sun, 17 Aug 2025 19:28:54 +0300 Subject: [PATCH 14/69] Fix code style violations in embedding infrastructure - Add missing newlines at end of files - Remove trailing whitespace - Fix alphabetical import sorting in test files - All PHPCS violations resolved automatically --- src/Embeddings/DTO/Embedding.php | 2 +- src/Operations/DTO/EmbeddingOperation.php | 2 +- .../EmbeddingGenerationModelInterface.php | 2 +- ...eddingGenerationOperationModelInterface.php | 2 +- src/Results/DTO/EmbeddingResult.php | 2 +- tests/mocks/MockEmbeddingGenerationModel.php | 2 +- .../MockEmbeddingGenerationOperationModel.php | 2 +- tests/unit/AiClientTest.php | 8 ++++---- tests/unit/Embeddings/DTO/EmbeddingTest.php | 6 +++--- .../Operations/DTO/EmbeddingOperationTest.php | 4 ++-- tests/unit/Results/DTO/EmbeddingResultTest.php | 18 +++++++++--------- 11 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/Embeddings/DTO/Embedding.php b/src/Embeddings/DTO/Embedding.php index 48b8c992..00fb7d1e 100644 --- a/src/Embeddings/DTO/Embedding.php +++ b/src/Embeddings/DTO/Embedding.php @@ -127,4 +127,4 @@ public static function fromArray(array $array): self return new self($array[self::KEY_VECTOR]); } -} \ No newline at end of file +} diff --git a/src/Operations/DTO/EmbeddingOperation.php b/src/Operations/DTO/EmbeddingOperation.php index e6126875..6b54ccbe 100644 --- a/src/Operations/DTO/EmbeddingOperation.php +++ b/src/Operations/DTO/EmbeddingOperation.php @@ -188,4 +188,4 @@ public static function fromArray(array $array): self $result ); } -} \ No newline at end of file +} diff --git a/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php b/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php index 71c829a0..26e336a4 100644 --- a/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php +++ b/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php @@ -27,4 +27,4 @@ interface EmbeddingGenerationModelInterface * @return EmbeddingResult Result containing generated embeddings. */ public function generateEmbeddingsResult(array $input): EmbeddingResult; -} \ No newline at end of file +} diff --git a/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationOperationModelInterface.php b/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationOperationModelInterface.php index 874b6e15..7b84b15c 100644 --- a/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationOperationModelInterface.php +++ b/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationOperationModelInterface.php @@ -26,4 +26,4 @@ interface EmbeddingGenerationOperationModelInterface * @return EmbeddingOperation The initiated embedding generation operation. */ public function generateEmbeddingsOperation(array $input): EmbeddingOperation; -} \ No newline at end of file +} diff --git a/src/Results/DTO/EmbeddingResult.php b/src/Results/DTO/EmbeddingResult.php index 871a5486..265374c6 100644 --- a/src/Results/DTO/EmbeddingResult.php +++ b/src/Results/DTO/EmbeddingResult.php @@ -192,4 +192,4 @@ public static function fromArray(array $array): self $array[self::KEY_PROVIDER_METADATA] ?? [] ); } -} \ No newline at end of file +} diff --git a/tests/mocks/MockEmbeddingGenerationModel.php b/tests/mocks/MockEmbeddingGenerationModel.php index 0a582196..056d3cb7 100644 --- a/tests/mocks/MockEmbeddingGenerationModel.php +++ b/tests/mocks/MockEmbeddingGenerationModel.php @@ -93,4 +93,4 @@ public function generateEmbeddingsResult(array $input): EmbeddingResult ['model' => 'mock-embedding-model'] ); } -} \ No newline at end of file +} diff --git a/tests/mocks/MockEmbeddingGenerationOperationModel.php b/tests/mocks/MockEmbeddingGenerationOperationModel.php index d72083a6..9f2cdf25 100644 --- a/tests/mocks/MockEmbeddingGenerationOperationModel.php +++ b/tests/mocks/MockEmbeddingGenerationOperationModel.php @@ -82,4 +82,4 @@ public function generateEmbeddingsOperation(array $input): EmbeddingOperation null ); } -} \ No newline at end of file +} diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index bcb23113..dd062a0e 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -11,21 +11,21 @@ use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\ModelMessage; use WordPress\AiClient\Messages\DTO\UserMessage; +use WordPress\AiClient\Operations\DTO\EmbeddingOperation; use WordPress\AiClient\Operations\DTO\GenerativeAiOperation; use WordPress\AiClient\Operations\Enums\OperationStateEnum; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Results\DTO\Candidate; -use WordPress\AiClient\Results\DTO\GenerativeAiResult; use WordPress\AiClient\Results\DTO\EmbeddingResult; +use WordPress\AiClient\Results\DTO\GenerativeAiResult; use WordPress\AiClient\Results\DTO\TokenUsage; -use WordPress\AiClient\Operations\DTO\EmbeddingOperation; use WordPress\AiClient\Results\Enums\FinishReasonEnum; -use WordPress\AiClient\Tests\mocks\MockImageGenerationModel; -use WordPress\AiClient\Tests\mocks\MockTextGenerationModel; use WordPress\AiClient\Tests\mocks\MockEmbeddingGenerationModel; use WordPress\AiClient\Tests\mocks\MockEmbeddingGenerationOperationModel; +use WordPress\AiClient\Tests\mocks\MockImageGenerationModel; +use WordPress\AiClient\Tests\mocks\MockTextGenerationModel; /** * @covers \WordPress\AiClient\AiClient diff --git a/tests/unit/Embeddings/DTO/EmbeddingTest.php b/tests/unit/Embeddings/DTO/EmbeddingTest.php index 96af3d67..2d40dd00 100644 --- a/tests/unit/Embeddings/DTO/EmbeddingTest.php +++ b/tests/unit/Embeddings/DTO/EmbeddingTest.php @@ -115,11 +115,11 @@ public function testGetJsonSchema(): void $this->assertArrayHasKey('type', $schema); $this->assertEquals('object', $schema['type']); - + $this->assertArrayHasKey('properties', $schema); $this->assertArrayHasKey('vector', $schema['properties']); $this->assertArrayHasKey('dimension', $schema['properties']); - + $this->assertArrayHasKey('required', $schema); $this->assertContains('vector', $schema['required']); $this->assertContains('dimension', $schema['required']); @@ -144,4 +144,4 @@ public function testDimensionCalculation(): void $this->assertEquals($expectedDimension, $embedding->getDimension(), "Failed for test case: $testName"); } } -} \ No newline at end of file +} diff --git a/tests/unit/Operations/DTO/EmbeddingOperationTest.php b/tests/unit/Operations/DTO/EmbeddingOperationTest.php index 9836c105..a4c36de2 100644 --- a/tests/unit/Operations/DTO/EmbeddingOperationTest.php +++ b/tests/unit/Operations/DTO/EmbeddingOperationTest.php @@ -233,7 +233,7 @@ public function testOperationStateTransitions(): void public function testRoundTripConversion(): void { $originalOperation = new EmbeddingOperation('op-id', OperationStateEnum::succeeded(), $this->embeddingResult); - + $array = $originalOperation->toArray(); $reconstructedOperation = EmbeddingOperation::fromArray($array); @@ -241,4 +241,4 @@ public function testRoundTripConversion(): void $this->assertEquals($originalOperation->getState()->value, $reconstructedOperation->getState()->value); $this->assertEquals($originalOperation->getResult()->getId(), $reconstructedOperation->getResult()->getId()); } -} \ No newline at end of file +} diff --git a/tests/unit/Results/DTO/EmbeddingResultTest.php b/tests/unit/Results/DTO/EmbeddingResultTest.php index b864e979..ed0c71e5 100644 --- a/tests/unit/Results/DTO/EmbeddingResultTest.php +++ b/tests/unit/Results/DTO/EmbeddingResultTest.php @@ -33,7 +33,7 @@ public function testCreateWithValidData(): void { $embeddings = [$this->embedding1, $this->embedding2]; $metadata = ['model' => 'text-embedding-ada-002']; - + $result = new EmbeddingResult('test-id', $embeddings, $this->tokenUsage, $metadata); $this->assertEquals('test-id', $result->getId()); @@ -48,7 +48,7 @@ public function testCreateWithValidData(): void public function testCreateWithEmptyMetadata(): void { $embeddings = [$this->embedding1]; - + $result = new EmbeddingResult('test-id', $embeddings, $this->tokenUsage); $this->assertEquals('test-id', $result->getId()); @@ -74,7 +74,7 @@ public function testCreateWithEmptyEmbeddingsThrowsException(): void public function testCreateWithSingleEmbedding(): void { $embeddings = [$this->embedding1]; - + $result = new EmbeddingResult('test-id', $embeddings, $this->tokenUsage); $this->assertCount(1, $result->getEmbeddings()); @@ -87,7 +87,7 @@ public function testCreateWithSingleEmbedding(): void public function testCreateWithMultipleEmbeddings(): void { $embeddings = [$this->embedding1, $this->embedding2]; - + $result = new EmbeddingResult('test-id', $embeddings, $this->tokenUsage); $this->assertCount(2, $result->getEmbeddings()); @@ -102,7 +102,7 @@ public function testToArray(): void { $embeddings = [$this->embedding1, $this->embedding2]; $metadata = ['model' => 'text-embedding-ada-002']; - + $result = new EmbeddingResult('test-id', $embeddings, $this->tokenUsage, $metadata); $expected = [ @@ -181,13 +181,13 @@ public function testGetJsonSchema(): void $this->assertArrayHasKey('type', $schema); $this->assertEquals('object', $schema['type']); - + $this->assertArrayHasKey('properties', $schema); $this->assertArrayHasKey('id', $schema['properties']); $this->assertArrayHasKey('embeddings', $schema['properties']); $this->assertArrayHasKey('tokenUsage', $schema['properties']); $this->assertArrayHasKey('providerMetadata', $schema['properties']); - + $this->assertArrayHasKey('required', $schema); $this->assertContains('id', $schema['required']); $this->assertContains('embeddings', $schema['required']); @@ -217,10 +217,10 @@ public function testWithComplexMetadata(): void 'provider' => 'openai', 'version' => '1.0', ]; - + $result = new EmbeddingResult('test-id', $embeddings, $this->tokenUsage, $metadata); $this->assertEquals($metadata, $result->getProviderMetadata()); $this->assertEquals('openai', $result->getProviderMetadata()['provider']); } -} \ No newline at end of file +} From 4ce660b9eb6af8057bdefd0cf09d71f0ddb8bfd0 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Sun, 17 Aug 2025 19:33:33 +0300 Subject: [PATCH 15/69] Add message() method placeholder for complete architecture compliance - Implement message() method with placeholder that throws RuntimeException - Add comprehensive test coverage for message() method --- src/AiClient.php | 22 ++++++++++++++++++++++ tests/unit/AiClientTest.php | 14 ++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/AiClient.php b/src/AiClient.php index f58810b8..ffc72a49 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -154,6 +154,28 @@ public static function generateResult($prompt, ModelInterface $model): Generativ ); } + /** + * Creates a new message builder for fluent API usage. + * + * This method will be implemented once MessageBuilder is available. + * MessageBuilder will provide a fluent interface for constructing complex + * messages with multiple parts, attachments, and metadata. + * + * @since n.e.x.t + * + * @param string|null $text Optional initial message text. + * @return object MessageBuilder instance (type will be updated when MessageBuilder is available). + * + * @throws \RuntimeException When MessageBuilder is not yet available. + */ + public static function message(?string $text = null) + { + throw new \RuntimeException( + 'MessageBuilder is not yet available. This method depends on builder infrastructure. ' . + 'Use direct generation methods (generateTextResult, generateImageResult, etc.) for now.' + ); + } + /** * Generates text using the traditional API approach. * diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index dd062a0e..936054bf 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -101,6 +101,20 @@ public function testPromptThrowsException(): void AiClient::prompt('Test prompt'); } + /** + * Tests message method throws exception when MessageBuilder is not available. + */ + public function testMessageThrowsException(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'MessageBuilder is not yet available. This method depends on builder infrastructure. ' . + 'Use direct generation methods (generateTextResult, generateImageResult, etc.) for now.' + ); + + AiClient::message('Test message'); + } + /** * Tests generateTextResult with string prompt and provided model. */ From 948b3d5d5f1404dadcf437f9710c78e13165f6f4 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Sun, 17 Aug 2025 19:46:53 +0300 Subject: [PATCH 16/69] Refactor AiClient utilities into separate classes for better maintainability - Extract PromptNormalizer utility class for prompt format standardization - Extract ModelDiscovery utility class for capability-based model discovery --- src/AiClient.php | 246 ++-------------------- src/Utils/ModelDiscovery.php | 183 ++++++++++++++++ src/Utils/PromptNormalizer.php | 85 ++++++++ tests/unit/Utils/ModelDiscoveryTest.php | 117 ++++++++++ tests/unit/Utils/PromptNormalizerTest.php | 140 ++++++++++++ 5 files changed, 545 insertions(+), 226 deletions(-) create mode 100644 src/Utils/ModelDiscovery.php create mode 100644 src/Utils/PromptNormalizer.php create mode 100644 tests/unit/Utils/ModelDiscoveryTest.php create mode 100644 tests/unit/Utils/PromptNormalizerTest.php diff --git a/src/AiClient.php b/src/AiClient.php index ffc72a49..a4b05a6b 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -13,10 +13,8 @@ use WordPress\AiClient\Operations\Enums\OperationStateEnum; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; -use WordPress\AiClient\Providers\Models\DTO\ModelRequirements; use WordPress\AiClient\Providers\Models\EmbeddingGeneration\Contracts\EmbeddingGenerationModelInterface; use WordPress\AiClient\Providers\Models\EmbeddingGeneration\Contracts\EmbeddingGenerationOperationModelInterface; -use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; use WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts\SpeechGenerationModelInterface; use WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts\SpeechGenerationOperationModelInterface; @@ -26,6 +24,8 @@ use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Results\DTO\EmbeddingResult; use WordPress\AiClient\Results\DTO\GenerativeAiResult; +use WordPress\AiClient\Utils\ModelDiscovery; +use WordPress\AiClient\Utils\PromptNormalizer; /** * Main AI Client class providing both fluent and traditional APIs for AI operations. @@ -191,10 +191,10 @@ public static function message(?string $text = null) public static function generateTextResult($prompt, ModelInterface $model = null): GenerativeAiResult { // Convert prompt to standardized Message array format - $messages = self::normalizePromptToMessages($prompt); + $messages = PromptNormalizer::normalize($prompt); // Get model - either provided or auto-discovered - $resolvedModel = $model ?? self::findSuitableTextModel(); + $resolvedModel = $model ?? ModelDiscovery::findTextModel(self::defaultRegistry()); // Ensure the model supports text generation if (!$resolvedModel instanceof TextGenerationModelInterface) { @@ -222,10 +222,10 @@ public static function generateTextResult($prompt, ModelInterface $model = null) public static function streamGenerateTextResult($prompt, ModelInterface $model = null): Generator { // Convert prompt to standardized Message array format - $messages = self::normalizePromptToMessages($prompt); + $messages = PromptNormalizer::normalize($prompt); // Get model - either provided or auto-discovered - $resolvedModel = $model ?? self::findSuitableTextModel(); + $resolvedModel = $model ?? ModelDiscovery::findTextModel(self::defaultRegistry()); // Ensure the model supports text generation if (!$resolvedModel instanceof TextGenerationModelInterface) { @@ -253,10 +253,10 @@ public static function streamGenerateTextResult($prompt, ModelInterface $model = public static function generateImageResult($prompt, ModelInterface $model = null): GenerativeAiResult { // Convert prompt to standardized Message array format - $messages = self::normalizePromptToMessages($prompt); + $messages = PromptNormalizer::normalize($prompt); // Get model - either provided or auto-discovered - $resolvedModel = $model ?? self::findSuitableImageModel(); + $resolvedModel = $model ?? ModelDiscovery::findImageModel(self::defaultRegistry()); // Ensure the model supports image generation if (!$resolvedModel instanceof ImageGenerationModelInterface) { @@ -284,10 +284,10 @@ public static function generateImageResult($prompt, ModelInterface $model = null public static function convertTextToSpeechResult($prompt, ModelInterface $model = null): GenerativeAiResult { // Convert prompt to standardized Message array format - $messages = self::normalizePromptToMessages($prompt); + $messages = PromptNormalizer::normalize($prompt); // Get model - either provided or auto-discovered - $resolvedModel = $model ?? self::findSuitableTextToSpeechModel(); + $resolvedModel = $model ?? ModelDiscovery::findTextToSpeechModel(self::defaultRegistry()); // Ensure the model supports text-to-speech conversion if (!$resolvedModel instanceof TextToSpeechConversionModelInterface) { @@ -315,10 +315,10 @@ public static function convertTextToSpeechResult($prompt, ModelInterface $model public static function generateSpeechResult($prompt, ModelInterface $model = null): GenerativeAiResult { // Convert prompt to standardized Message array format - $messages = self::normalizePromptToMessages($prompt); + $messages = PromptNormalizer::normalize($prompt); // Get model - either provided or auto-discovered - $resolvedModel = $model ?? self::findSuitableSpeechModel(); + $resolvedModel = $model ?? ModelDiscovery::findSpeechModel(self::defaultRegistry()); // Ensure the model supports speech generation if (!$resolvedModel instanceof SpeechGenerationModelInterface) { @@ -352,7 +352,7 @@ public static function generateEmbeddingsResult($input, ModelInterface $model = $messages = array_map(fn(string $text) => new UserMessage([new MessagePart($text)]), $stringArray); } else { /** @var string|MessagePart|MessagePart[]|Message|Message[] $input */ - $messages = self::normalizePromptToMessages($input); + $messages = PromptNormalizer::normalize($input); } // Ensure messages is a proper list (sequential array with numeric keys starting from 0) @@ -360,7 +360,7 @@ public static function generateEmbeddingsResult($input, ModelInterface $model = $messageList = array_values($messages); // Get model - either provided or auto-discovered - $resolvedModel = $model ?? self::findSuitableEmbeddingModel(); + $resolvedModel = $model ?? ModelDiscovery::findEmbeddingModel(self::defaultRegistry()); // Ensure the model supports embedding generation if (!$resolvedModel instanceof EmbeddingGenerationModelInterface) { @@ -387,7 +387,7 @@ public static function generateEmbeddingsResult($input, ModelInterface $model = public static function generateOperation($prompt, ModelInterface $model): GenerativeAiOperation { // Convert prompt to standardized Message array format - $messages = self::normalizePromptToMessages($prompt); + $messages = PromptNormalizer::normalize($prompt); // Create and return the operation (starting state, no result yet) return new GenerativeAiOperation( @@ -411,7 +411,7 @@ public static function generateOperation($prompt, ModelInterface $model): Genera public static function generateTextOperation($prompt, ModelInterface $model): GenerativeAiOperation { // Convert prompt to standardized Message array format - $messages = self::normalizePromptToMessages($prompt); + $messages = PromptNormalizer::normalize($prompt); // Ensure the model supports text generation if (!$model instanceof TextGenerationModelInterface) { @@ -442,7 +442,7 @@ public static function generateTextOperation($prompt, ModelInterface $model): Ge public static function generateImageOperation($prompt, ModelInterface $model): GenerativeAiOperation { // Convert prompt to standardized Message array format - $messages = self::normalizePromptToMessages($prompt); + $messages = PromptNormalizer::normalize($prompt); // Ensure the model supports image generation if (!$model instanceof ImageGenerationModelInterface) { @@ -473,7 +473,7 @@ public static function generateImageOperation($prompt, ModelInterface $model): G public static function convertTextToSpeechOperation($prompt, ModelInterface $model): GenerativeAiOperation { // Convert prompt to standardized Message array format - $messages = self::normalizePromptToMessages($prompt); + $messages = PromptNormalizer::normalize($prompt); // Ensure the model supports text-to-speech conversion operations if (!$model instanceof TextToSpeechConversionOperationModelInterface) { @@ -505,7 +505,7 @@ public static function convertTextToSpeechOperation($prompt, ModelInterface $mod public static function generateSpeechOperation($prompt, ModelInterface $model): GenerativeAiOperation { // Convert prompt to standardized Message array format - $messages = self::normalizePromptToMessages($prompt); + $messages = PromptNormalizer::normalize($prompt); // Ensure the model supports speech generation operations if (!$model instanceof SpeechGenerationOperationModelInterface) { @@ -543,7 +543,7 @@ public static function generateEmbeddingsOperation($input, ModelInterface $model $messages = array_map(fn(string $text) => new UserMessage([new MessagePart($text)]), $stringArray); } else { /** @var string|MessagePart|MessagePart[]|Message|Message[] $input */ - $messages = self::normalizePromptToMessages($input); + $messages = PromptNormalizer::normalize($input); } // Ensure messages is a proper list (sequential array with numeric keys starting from 0) @@ -561,210 +561,4 @@ public static function generateEmbeddingsOperation($input, ModelInterface $model // Delegate to the model's operation method with proper list type return $model->generateEmbeddingsOperation($messageList); } - - /** - * Normalizes various prompt formats into a standardized Message array. - * - * @since n.e.x.t - * - * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt to normalize. - * @return list Array of Message objects. - * - * @throws \InvalidArgumentException If the prompt format is invalid. - */ - private static function normalizePromptToMessages($prompt): array - { - if (is_string($prompt)) { - // Convert string to UserMessage with single text MessagePart - return [new UserMessage([new MessagePart($prompt)])]; - } - - if ($prompt instanceof Message) { - return [$prompt]; - } - - if ($prompt instanceof MessagePart) { - // Convert MessagePart to UserMessage - return [new UserMessage([$prompt])]; - } - - if (is_array($prompt)) { - // Handle array of Messages or MessageParts - $messages = []; - foreach ($prompt as $item) { - if ($item instanceof Message) { - $messages[] = $item; - } elseif ($item instanceof MessagePart) { - $messages[] = new UserMessage([$item]); - } else { - throw new \InvalidArgumentException( - 'Array must contain only Message or MessagePart objects' - ); - } - } - return $messages; - } - - throw new \InvalidArgumentException('Invalid prompt format provided'); - } - - /** - * Finds a suitable text generation model. - * - * @since n.e.x.t - * - * @return ModelInterface A suitable text generation model. - * - * @throws \RuntimeException If no suitable model is found. - */ - private static function findSuitableTextModel(): ModelInterface - { - $requirements = new ModelRequirements([CapabilityEnum::textGeneration()], []); - $providerModelsMetadata = self::defaultRegistry()->findModelsMetadataForSupport($requirements); - - if (empty($providerModelsMetadata)) { - throw new \RuntimeException('No text generation models available'); - } - - // Get the first suitable provider and model - $providerMetadata = $providerModelsMetadata[0]; - $models = $providerMetadata->getModels(); - - if (empty($models)) { - throw new \RuntimeException('No models available in provider'); - } - - return self::defaultRegistry()->getProviderModel( - $providerMetadata->getProvider()->getId(), - $models[0]->getId() - ); - } - - /** - * Finds a suitable image generation model. - * - * @since n.e.x.t - * - * @return ModelInterface A suitable image generation model. - * - * @throws \RuntimeException If no suitable model is found. - */ - private static function findSuitableImageModel(): ModelInterface - { - $requirements = new ModelRequirements([CapabilityEnum::imageGeneration()], []); - $providerModelsMetadata = self::defaultRegistry()->findModelsMetadataForSupport($requirements); - - if (empty($providerModelsMetadata)) { - throw new \RuntimeException('No image generation models available'); - } - - // Get the first suitable provider and model - $providerMetadata = $providerModelsMetadata[0]; - $models = $providerMetadata->getModels(); - - if (empty($models)) { - throw new \RuntimeException('No models available in provider'); - } - - return self::defaultRegistry()->getProviderModel( - $providerMetadata->getProvider()->getId(), - $models[0]->getId() - ); - } - - /** - * Finds a suitable text-to-speech conversion model. - * - * @since n.e.x.t - * - * @return ModelInterface A suitable text-to-speech conversion model. - * - * @throws \RuntimeException If no suitable model is found. - */ - private static function findSuitableTextToSpeechModel(): ModelInterface - { - $requirements = new ModelRequirements([CapabilityEnum::textToSpeechConversion()], []); - $providerModelsMetadata = self::defaultRegistry()->findModelsMetadataForSupport($requirements); - - if (empty($providerModelsMetadata)) { - throw new \RuntimeException('No text-to-speech conversion models available'); - } - - // Get the first suitable provider and model - $providerMetadata = $providerModelsMetadata[0]; - $models = $providerMetadata->getModels(); - - if (empty($models)) { - throw new \RuntimeException('No models available in provider'); - } - - return self::defaultRegistry()->getProviderModel( - $providerMetadata->getProvider()->getId(), - $models[0]->getId() - ); - } - - /** - * Finds a suitable speech generation model. - * - * @since n.e.x.t - * - * @return ModelInterface A suitable speech generation model. - * - * @throws \RuntimeException If no suitable model is found. - */ - private static function findSuitableSpeechModel(): ModelInterface - { - $requirements = new ModelRequirements([CapabilityEnum::speechGeneration()], []); - $providerModelsMetadata = self::defaultRegistry()->findModelsMetadataForSupport($requirements); - - if (empty($providerModelsMetadata)) { - throw new \RuntimeException('No speech generation models available'); - } - - // Get the first suitable provider and model - $providerMetadata = $providerModelsMetadata[0]; - $models = $providerMetadata->getModels(); - - if (empty($models)) { - throw new \RuntimeException('No models available in provider'); - } - - return self::defaultRegistry()->getProviderModel( - $providerMetadata->getProvider()->getId(), - $models[0]->getId() - ); - } - - /** - * Finds a suitable embedding generation model. - * - * @since n.e.x.t - * - * @return ModelInterface A suitable embedding generation model. - * - * @throws \RuntimeException If no suitable model is found. - */ - private static function findSuitableEmbeddingModel(): ModelInterface - { - $requirements = new ModelRequirements([CapabilityEnum::embeddingGeneration()], []); - $providerModelsMetadata = self::defaultRegistry()->findModelsMetadataForSupport($requirements); - - if (empty($providerModelsMetadata)) { - throw new \RuntimeException('No embedding generation models available'); - } - - // Get the first suitable provider and model - $providerMetadata = $providerModelsMetadata[0]; - $models = $providerMetadata->getModels(); - - if (empty($models)) { - throw new \RuntimeException('No models available in provider'); - } - - return self::defaultRegistry()->getProviderModel( - $providerMetadata->getProvider()->getId(), - $models[0]->getId() - ); - } } diff --git a/src/Utils/ModelDiscovery.php b/src/Utils/ModelDiscovery.php new file mode 100644 index 00000000..c458c8a7 --- /dev/null +++ b/src/Utils/ModelDiscovery.php @@ -0,0 +1,183 @@ +findModelsMetadataForSupport($requirements); + + if (empty($providerModelsMetadata)) { + throw new \RuntimeException('No text generation models available'); + } + + // Get the first suitable provider and model + $providerMetadata = $providerModelsMetadata[0]; + $models = $providerMetadata->getModels(); + + if (empty($models)) { + throw new \RuntimeException('No models available in provider'); + } + + return $registry->getProviderModel( + $providerMetadata->getProvider()->getId(), + $models[0]->getId() + ); + } + + /** + * Finds a suitable image generation model from the registry. + * + * @since n.e.x.t + * + * @param ProviderRegistry $registry The provider registry to search. + * @return ModelInterface A suitable image generation model. + * + * @throws \RuntimeException If no suitable model is found. + */ + public static function findImageModel(ProviderRegistry $registry): ModelInterface + { + $requirements = new ModelRequirements([CapabilityEnum::imageGeneration()], []); + $providerModelsMetadata = $registry->findModelsMetadataForSupport($requirements); + + if (empty($providerModelsMetadata)) { + throw new \RuntimeException('No image generation models available'); + } + + // Get the first suitable provider and model + $providerMetadata = $providerModelsMetadata[0]; + $models = $providerMetadata->getModels(); + + if (empty($models)) { + throw new \RuntimeException('No models available in provider'); + } + + return $registry->getProviderModel( + $providerMetadata->getProvider()->getId(), + $models[0]->getId() + ); + } + + /** + * Finds a suitable text-to-speech conversion model from the registry. + * + * @since n.e.x.t + * + * @param ProviderRegistry $registry The provider registry to search. + * @return ModelInterface A suitable text-to-speech conversion model. + * + * @throws \RuntimeException If no suitable model is found. + */ + public static function findTextToSpeechModel(ProviderRegistry $registry): ModelInterface + { + $requirements = new ModelRequirements([CapabilityEnum::textToSpeechConversion()], []); + $providerModelsMetadata = $registry->findModelsMetadataForSupport($requirements); + + if (empty($providerModelsMetadata)) { + throw new \RuntimeException('No text-to-speech conversion models available'); + } + + // Get the first suitable provider and model + $providerMetadata = $providerModelsMetadata[0]; + $models = $providerMetadata->getModels(); + + if (empty($models)) { + throw new \RuntimeException('No models available in provider'); + } + + return $registry->getProviderModel( + $providerMetadata->getProvider()->getId(), + $models[0]->getId() + ); + } + + /** + * Finds a suitable speech generation model from the registry. + * + * @since n.e.x.t + * + * @param ProviderRegistry $registry The provider registry to search. + * @return ModelInterface A suitable speech generation model. + * + * @throws \RuntimeException If no suitable model is found. + */ + public static function findSpeechModel(ProviderRegistry $registry): ModelInterface + { + $requirements = new ModelRequirements([CapabilityEnum::speechGeneration()], []); + $providerModelsMetadata = $registry->findModelsMetadataForSupport($requirements); + + if (empty($providerModelsMetadata)) { + throw new \RuntimeException('No speech generation models available'); + } + + // Get the first suitable provider and model + $providerMetadata = $providerModelsMetadata[0]; + $models = $providerMetadata->getModels(); + + if (empty($models)) { + throw new \RuntimeException('No models available in provider'); + } + + return $registry->getProviderModel( + $providerMetadata->getProvider()->getId(), + $models[0]->getId() + ); + } + + /** + * Finds a suitable embedding generation model from the registry. + * + * @since n.e.x.t + * + * @param ProviderRegistry $registry The provider registry to search. + * @return ModelInterface A suitable embedding generation model. + * + * @throws \RuntimeException If no suitable model is found. + */ + public static function findEmbeddingModel(ProviderRegistry $registry): ModelInterface + { + $requirements = new ModelRequirements([CapabilityEnum::embeddingGeneration()], []); + $providerModelsMetadata = $registry->findModelsMetadataForSupport($requirements); + + if (empty($providerModelsMetadata)) { + throw new \RuntimeException('No embedding generation models available'); + } + + // Get the first suitable provider and model + $providerMetadata = $providerModelsMetadata[0]; + $models = $providerMetadata->getModels(); + + if (empty($models)) { + throw new \RuntimeException('No models available in provider'); + } + + return $registry->getProviderModel( + $providerMetadata->getProvider()->getId(), + $models[0]->getId() + ); + } +} diff --git a/src/Utils/PromptNormalizer.php b/src/Utils/PromptNormalizer.php new file mode 100644 index 00000000..396a3493 --- /dev/null +++ b/src/Utils/PromptNormalizer.php @@ -0,0 +1,85 @@ + new UserMessage([$part]), $prompt); + } + + // Invalid array content + throw new \InvalidArgumentException('Array must contain only Message or MessagePart objects'); + } + + // Unsupported type + throw new \InvalidArgumentException('Invalid prompt format provided'); + } +} diff --git a/tests/unit/Utils/ModelDiscoveryTest.php b/tests/unit/Utils/ModelDiscoveryTest.php new file mode 100644 index 00000000..88c00e9d --- /dev/null +++ b/tests/unit/Utils/ModelDiscoveryTest.php @@ -0,0 +1,117 @@ +registry = $this->createMock(ProviderRegistry::class); + } + + /** + * Tests findTextModel throws exception when no models available. + */ + public function testFindTextModelThrowsExceptionWhenNoModelsAvailable(): void + { + $this->registry->expects($this->once()) + ->method('findModelsMetadataForSupport') + ->willReturn([]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No text generation models available'); + + ModelDiscovery::findTextModel($this->registry); + } + + /** + * Tests findImageModel throws exception when no models available. + */ + public function testFindImageModelThrowsExceptionWhenNoModelsAvailable(): void + { + $this->registry->expects($this->once()) + ->method('findModelsMetadataForSupport') + ->willReturn([]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No image generation models available'); + + ModelDiscovery::findImageModel($this->registry); + } + + /** + * Tests findTextToSpeechModel throws exception when no models available. + */ + public function testFindTextToSpeechModelThrowsExceptionWhenNoModelsAvailable(): void + { + $this->registry->expects($this->once()) + ->method('findModelsMetadataForSupport') + ->willReturn([]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No text-to-speech conversion models available'); + + ModelDiscovery::findTextToSpeechModel($this->registry); + } + + /** + * Tests findSpeechModel throws exception when no models available. + */ + public function testFindSpeechModelThrowsExceptionWhenNoModelsAvailable(): void + { + $this->registry->expects($this->once()) + ->method('findModelsMetadataForSupport') + ->willReturn([]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No speech generation models available'); + + ModelDiscovery::findSpeechModel($this->registry); + } + + /** + * Tests findEmbeddingModel throws exception when no models available. + */ + public function testFindEmbeddingModelThrowsExceptionWhenNoModelsAvailable(): void + { + $this->registry->expects($this->once()) + ->method('findModelsMetadataForSupport') + ->willReturn([]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No embedding generation models available'); + + ModelDiscovery::findEmbeddingModel($this->registry); + } + + /** + * Tests that ModelDiscovery properly passes capability requirements to registry. + */ + public function testModelDiscoveryPassesCorrectCapabilityRequirements(): void + { + // Mock registry to capture the ModelRequirements parameter + $this->registry->expects($this->once()) + ->method('findModelsMetadataForSupport') + ->with($this->callback(function ($requirements) { + // Verify that the ModelRequirements contains the expected capability + $capabilities = $requirements->getRequiredCapabilities(); + return count($capabilities) === 1 && + $capabilities[0]->value === 'text_generation'; + })) + ->willReturn([]); + + $this->expectException(\RuntimeException::class); + ModelDiscovery::findTextModel($this->registry); + } +} diff --git a/tests/unit/Utils/PromptNormalizerTest.php b/tests/unit/Utils/PromptNormalizerTest.php new file mode 100644 index 00000000..e13167ab --- /dev/null +++ b/tests/unit/Utils/PromptNormalizerTest.php @@ -0,0 +1,140 @@ +assertCount(1, $result); + $this->assertInstanceOf(UserMessage::class, $result[0]); + $this->assertCount(1, $result[0]->getParts()); + $this->assertEquals('Test prompt', $result[0]->getParts()[0]->getText()); + } + + /** + * Tests normalizing MessagePart input. + */ + public function testNormalizeMessagePart(): void + { + $messagePart = new MessagePart('Test message part'); + $result = PromptNormalizer::normalize($messagePart); + + $this->assertCount(1, $result); + $this->assertInstanceOf(UserMessage::class, $result[0]); + $this->assertCount(1, $result[0]->getParts()); + $this->assertSame($messagePart, $result[0]->getParts()[0]); + } + + /** + * Tests normalizing single Message input. + */ + public function testNormalizeSingleMessage(): void + { + $messagePart = new MessagePart('Test message'); + $message = new UserMessage([$messagePart]); + $result = PromptNormalizer::normalize($message); + + $this->assertCount(1, $result); + $this->assertSame($message, $result[0]); + } + + /** + * Tests normalizing array of Messages. + */ + public function testNormalizeMessageArray(): void + { + $message1 = new UserMessage([new MessagePart('First message')]); + $message2 = new UserMessage([new MessagePart('Second message')]); + $messages = [$message1, $message2]; + + $result = PromptNormalizer::normalize($messages); + + $this->assertCount(2, $result); + $this->assertSame($message1, $result[0]); + $this->assertSame($message2, $result[1]); + } + + /** + * Tests normalizing array of MessageParts. + */ + public function testNormalizeMessagePartArray(): void + { + $part1 = new MessagePart('First part'); + $part2 = new MessagePart('Second part'); + $parts = [$part1, $part2]; + + $result = PromptNormalizer::normalize($parts); + + $this->assertCount(2, $result); + $this->assertInstanceOf(UserMessage::class, $result[0]); + $this->assertInstanceOf(UserMessage::class, $result[1]); + $this->assertSame($part1, $result[0]->getParts()[0]); + $this->assertSame($part2, $result[1]->getParts()[0]); + } + + /** + * Tests empty array throws exception. + */ + public function testNormalizeEmptyArrayThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Prompt array cannot be empty'); + + PromptNormalizer::normalize([]); + } + + /** + * Tests mixed array content throws exception. + */ + public function testNormalizeMixedArrayThrowsException(): void + { + $part = new MessagePart('Test'); + $invalidArray = [$part, 'string', 123]; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Array must contain only Message or MessagePart objects'); + + PromptNormalizer::normalize($invalidArray); + } + + /** + * Tests invalid input type throws exception. + */ + public function testNormalizeInvalidInputThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid prompt format provided'); + + PromptNormalizer::normalize(123); + } + + /** + * Tests array with invalid object types throws exception. + */ + public function testNormalizeArrayWithInvalidObjectsThrowsException(): void + { + $invalidArray = [new \stdClass(), new \DateTime()]; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Array must contain only Message or MessagePart objects'); + + PromptNormalizer::normalize($invalidArray); + } +} From bccc41d8364ca465e08bb106158ffabf6684407d Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Sun, 17 Aug 2025 19:55:27 +0300 Subject: [PATCH 17/69] Fix PHPStan list type errors and remove redundant tests - Fix list type annotations in PromptNormalizer and AiClient - Ensure all Message arrays are converted to proper lists using array_values() - Remove 4 redundant tests from AiClientTest that are now covered by utility tests - Maintain 100% test coverage while eliminating duplicate test logic - All PHPStan errors resolved, all tests passing --- src/AiClient.php | 30 +++++++++++++++++---- src/Utils/PromptNormalizer.php | 10 ++++--- tests/unit/AiClientTest.php | 49 ---------------------------------- 3 files changed, 32 insertions(+), 57 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index a4b05a6b..d7af83c9 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -192,6 +192,8 @@ public static function generateTextResult($prompt, ModelInterface $model = null) { // Convert prompt to standardized Message array format $messages = PromptNormalizer::normalize($prompt); + /** @var list $messageList */ + $messageList = array_values($messages); // Get model - either provided or auto-discovered $resolvedModel = $model ?? ModelDiscovery::findTextModel(self::defaultRegistry()); @@ -204,7 +206,7 @@ public static function generateTextResult($prompt, ModelInterface $model = null) } // Generate the result using the model - return $resolvedModel->generateTextResult($messages); + return $resolvedModel->generateTextResult($messageList); } /** @@ -223,6 +225,8 @@ public static function streamGenerateTextResult($prompt, ModelInterface $model = { // Convert prompt to standardized Message array format $messages = PromptNormalizer::normalize($prompt); + /** @var list $messageList */ + $messageList = array_values($messages); // Get model - either provided or auto-discovered $resolvedModel = $model ?? ModelDiscovery::findTextModel(self::defaultRegistry()); @@ -235,7 +239,7 @@ public static function streamGenerateTextResult($prompt, ModelInterface $model = } // Stream the results using the model - yield from $resolvedModel->streamGenerateTextResult($messages); + yield from $resolvedModel->streamGenerateTextResult($messageList); } /** @@ -254,6 +258,8 @@ public static function generateImageResult($prompt, ModelInterface $model = null { // Convert prompt to standardized Message array format $messages = PromptNormalizer::normalize($prompt); + /** @var list $messageList */ + $messageList = array_values($messages); // Get model - either provided or auto-discovered $resolvedModel = $model ?? ModelDiscovery::findImageModel(self::defaultRegistry()); @@ -266,7 +272,7 @@ public static function generateImageResult($prompt, ModelInterface $model = null } // Generate the result using the model - return $resolvedModel->generateImageResult($messages); + return $resolvedModel->generateImageResult($messageList); } /** @@ -285,6 +291,8 @@ public static function convertTextToSpeechResult($prompt, ModelInterface $model { // Convert prompt to standardized Message array format $messages = PromptNormalizer::normalize($prompt); + /** @var list $messageList */ + $messageList = array_values($messages); // Get model - either provided or auto-discovered $resolvedModel = $model ?? ModelDiscovery::findTextToSpeechModel(self::defaultRegistry()); @@ -297,7 +305,7 @@ public static function convertTextToSpeechResult($prompt, ModelInterface $model } // Generate the result using the model - return $resolvedModel->convertTextToSpeechResult($messages); + return $resolvedModel->convertTextToSpeechResult($messageList); } /** @@ -316,6 +324,8 @@ public static function generateSpeechResult($prompt, ModelInterface $model = nul { // Convert prompt to standardized Message array format $messages = PromptNormalizer::normalize($prompt); + /** @var list $messageList */ + $messageList = array_values($messages); // Get model - either provided or auto-discovered $resolvedModel = $model ?? ModelDiscovery::findSpeechModel(self::defaultRegistry()); @@ -328,7 +338,7 @@ public static function generateSpeechResult($prompt, ModelInterface $model = nul } // Generate the result using the model - return $resolvedModel->generateSpeechResult($messages); + return $resolvedModel->generateSpeechResult($messageList); } /** @@ -388,6 +398,8 @@ public static function generateOperation($prompt, ModelInterface $model): Genera { // Convert prompt to standardized Message array format $messages = PromptNormalizer::normalize($prompt); + /** @var list $messageList */ + $messageList = array_values($messages); // Create and return the operation (starting state, no result yet) return new GenerativeAiOperation( @@ -412,6 +424,8 @@ public static function generateTextOperation($prompt, ModelInterface $model): Ge { // Convert prompt to standardized Message array format $messages = PromptNormalizer::normalize($prompt); + /** @var list $messageList */ + $messageList = array_values($messages); // Ensure the model supports text generation if (!$model instanceof TextGenerationModelInterface) { @@ -443,6 +457,8 @@ public static function generateImageOperation($prompt, ModelInterface $model): G { // Convert prompt to standardized Message array format $messages = PromptNormalizer::normalize($prompt); + /** @var list $messageList */ + $messageList = array_values($messages); // Ensure the model supports image generation if (!$model instanceof ImageGenerationModelInterface) { @@ -474,6 +490,8 @@ public static function convertTextToSpeechOperation($prompt, ModelInterface $mod { // Convert prompt to standardized Message array format $messages = PromptNormalizer::normalize($prompt); + /** @var list $messageList */ + $messageList = array_values($messages); // Ensure the model supports text-to-speech conversion operations if (!$model instanceof TextToSpeechConversionOperationModelInterface) { @@ -506,6 +524,8 @@ public static function generateSpeechOperation($prompt, ModelInterface $model): { // Convert prompt to standardized Message array format $messages = PromptNormalizer::normalize($prompt); + /** @var list $messageList */ + $messageList = array_values($messages); // Ensure the model supports speech generation operations if (!$model instanceof SpeechGenerationOperationModelInterface) { diff --git a/src/Utils/PromptNormalizer.php b/src/Utils/PromptNormalizer.php index 396a3493..b3a5e6ae 100644 --- a/src/Utils/PromptNormalizer.php +++ b/src/Utils/PromptNormalizer.php @@ -21,7 +21,7 @@ class PromptNormalizer * @since n.e.x.t * * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content in various formats. - * @return Message[] Array of Message objects. + * @return list Array of Message objects. * * @throws \InvalidArgumentException If the prompt format is invalid. */ @@ -60,7 +60,9 @@ public static function normalize($prompt): array throw new \InvalidArgumentException('Array must contain only Message or MessagePart objects'); } } - return $prompt; + /** @var Message[] $messages */ + $messages = $prompt; + return array_values($messages); } // Array of MessageParts @@ -72,7 +74,9 @@ public static function normalize($prompt): array } } // Convert each MessagePart to a UserMessage - return array_map(fn(MessagePart $part) => new UserMessage([$part]), $prompt); + /** @var MessagePart[] $messageParts */ + $messageParts = $prompt; + return array_values(array_map(fn(MessagePart $part) => new UserMessage([$part]), $messageParts)); } // Invalid array content diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index 936054bf..15426eaf 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -305,55 +305,6 @@ public function testGenerateTextResultWithMessagePartArray(): void $this->assertSame($mockResult, $result); } - /** - * Tests prompt normalization throws exception for invalid array content. - */ - public function testNormalizePromptWithInvalidArrayContent(): void - { - $invalidArray = ['string', 123, new \stdClass()]; - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Array must contain only Message or MessagePart objects'); - - AiClient::generateTextResult($invalidArray, $this->mockTextModel); - } - - /** - * Tests prompt normalization throws exception for completely invalid input. - */ - public function testNormalizePromptWithInvalidInput(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid prompt format provided'); - - AiClient::generateTextResult(123, $this->mockTextModel); - } - - /** - * Tests automatic model discovery when no model is provided (throws RuntimeException in current implementation). - */ - public function testAutoModelDiscoveryThrowsException(): void - { - $prompt = 'Generate text'; - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('No text generation models available'); - - AiClient::generateTextResult($prompt); - } - - /** - * Tests automatic image model discovery when no model is provided (throws RuntimeException currently). - */ - public function testAutoImageModelDiscoveryThrowsException(): void - { - $prompt = 'Generate image'; - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('No image generation models available'); - - AiClient::generateImageResult($prompt); - } /** * Tests isConfigured method returns true when provider availability is configured. From 23f287b12fe411437235dac5b8160a6212c8095d Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Sun, 17 Aug 2025 20:31:16 +0300 Subject: [PATCH 18/69] Implement comprehensive AiClient refactoring with utility classes Extract interface validation, operation factory, embedding input normalization, and generation strategy resolution into dedicated utility classes. --- src/AiClient.php | 199 ++++------------ src/Operations/OperationFactory.php | 168 ++++++++++++++ src/Utils/EmbeddingInputNormalizer.php | 115 +++++++++ src/Utils/GenerationStrategyResolver.php | 115 +++++++++ src/Utils/InterfaceValidator.php | 219 ++++++++++++++++++ .../unit/Operations/OperationFactoryTest.php | 176 ++++++++++++++ .../Utils/EmbeddingInputNormalizerTest.php | 215 +++++++++++++++++ .../Utils/GenerationStrategyResolverTest.php | 189 +++++++++++++++ tests/unit/Utils/InterfaceValidatorTest.php | 194 ++++++++++++++++ 9 files changed, 1435 insertions(+), 155 deletions(-) create mode 100644 src/Operations/OperationFactory.php create mode 100644 src/Utils/EmbeddingInputNormalizer.php create mode 100644 src/Utils/GenerationStrategyResolver.php create mode 100644 src/Utils/InterfaceValidator.php create mode 100644 tests/unit/Operations/OperationFactoryTest.php create mode 100644 tests/unit/Utils/EmbeddingInputNormalizerTest.php create mode 100644 tests/unit/Utils/GenerationStrategyResolverTest.php create mode 100644 tests/unit/Utils/InterfaceValidatorTest.php diff --git a/src/AiClient.php b/src/AiClient.php index d7af83c9..845c671c 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -7,23 +7,17 @@ use Generator; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; -use WordPress\AiClient\Messages\DTO\UserMessage; use WordPress\AiClient\Operations\DTO\EmbeddingOperation; use WordPress\AiClient\Operations\DTO\GenerativeAiOperation; -use WordPress\AiClient\Operations\Enums\OperationStateEnum; +use WordPress\AiClient\Operations\OperationFactory; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; -use WordPress\AiClient\Providers\Models\EmbeddingGeneration\Contracts\EmbeddingGenerationModelInterface; -use WordPress\AiClient\Providers\Models\EmbeddingGeneration\Contracts\EmbeddingGenerationOperationModelInterface; -use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; -use WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts\SpeechGenerationModelInterface; -use WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts\SpeechGenerationOperationModelInterface; -use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; -use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionModelInterface; -use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionOperationModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Results\DTO\EmbeddingResult; use WordPress\AiClient\Results\DTO\GenerativeAiResult; +use WordPress\AiClient\Utils\EmbeddingInputNormalizer; +use WordPress\AiClient\Utils\GenerationStrategyResolver; +use WordPress\AiClient\Utils\InterfaceValidator; use WordPress\AiClient\Utils\ModelDiscovery; use WordPress\AiClient\Utils\PromptNormalizer; @@ -127,31 +121,11 @@ public static function prompt($text = null) */ public static function generateResult($prompt, ModelInterface $model): GenerativeAiResult { - // Delegate to text generation if model supports it - if ($model instanceof TextGenerationModelInterface) { - return self::generateTextResult($prompt, $model); - } - - // Delegate to image generation if model supports it - if ($model instanceof ImageGenerationModelInterface) { - return self::generateImageResult($prompt, $model); - } - - // Delegate to text-to-speech conversion if model supports it - if ($model instanceof TextToSpeechConversionModelInterface) { - return self::convertTextToSpeechResult($prompt, $model); - } - - // Delegate to speech generation if model supports it - if ($model instanceof SpeechGenerationModelInterface) { - return self::generateSpeechResult($prompt, $model); - } + // Use strategy resolver to determine the appropriate method + $method = GenerationStrategyResolver::resolve($model); - // If no supported interface is found, throw an exception - throw new \InvalidArgumentException( - 'Model must implement at least one supported generation interface ' . - '(TextGeneration, ImageGeneration, TextToSpeechConversion, SpeechGeneration)' - ); + // Call the resolved method dynamically + return self::$method($prompt, $model); } /** @@ -198,12 +172,8 @@ public static function generateTextResult($prompt, ModelInterface $model = null) // Get model - either provided or auto-discovered $resolvedModel = $model ?? ModelDiscovery::findTextModel(self::defaultRegistry()); - // Ensure the model supports text generation - if (!$resolvedModel instanceof TextGenerationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement TextGenerationModelInterface for text generation' - ); - } + // Validate model supports text generation + InterfaceValidator::validateTextGeneration($resolvedModel); // Generate the result using the model return $resolvedModel->generateTextResult($messageList); @@ -231,12 +201,8 @@ public static function streamGenerateTextResult($prompt, ModelInterface $model = // Get model - either provided or auto-discovered $resolvedModel = $model ?? ModelDiscovery::findTextModel(self::defaultRegistry()); - // Ensure the model supports text generation - if (!$resolvedModel instanceof TextGenerationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement TextGenerationModelInterface for text generation' - ); - } + // Validate model supports text generation + InterfaceValidator::validateTextGeneration($resolvedModel); // Stream the results using the model yield from $resolvedModel->streamGenerateTextResult($messageList); @@ -264,12 +230,8 @@ public static function generateImageResult($prompt, ModelInterface $model = null // Get model - either provided or auto-discovered $resolvedModel = $model ?? ModelDiscovery::findImageModel(self::defaultRegistry()); - // Ensure the model supports image generation - if (!$resolvedModel instanceof ImageGenerationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement ImageGenerationModelInterface for image generation' - ); - } + // Validate model supports image generation + InterfaceValidator::validateImageGeneration($resolvedModel); // Generate the result using the model return $resolvedModel->generateImageResult($messageList); @@ -297,12 +259,8 @@ public static function convertTextToSpeechResult($prompt, ModelInterface $model // Get model - either provided or auto-discovered $resolvedModel = $model ?? ModelDiscovery::findTextToSpeechModel(self::defaultRegistry()); - // Ensure the model supports text-to-speech conversion - if (!$resolvedModel instanceof TextToSpeechConversionModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement TextToSpeechConversionModelInterface for text-to-speech conversion' - ); - } + // Validate model supports text-to-speech conversion + InterfaceValidator::validateTextToSpeechConversion($resolvedModel); // Generate the result using the model return $resolvedModel->convertTextToSpeechResult($messageList); @@ -330,12 +288,8 @@ public static function generateSpeechResult($prompt, ModelInterface $model = nul // Get model - either provided or auto-discovered $resolvedModel = $model ?? ModelDiscovery::findSpeechModel(self::defaultRegistry()); - // Ensure the model supports speech generation - if (!$resolvedModel instanceof SpeechGenerationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement SpeechGenerationModelInterface for speech generation' - ); - } + // Validate model supports speech generation + InterfaceValidator::validateSpeechGeneration($resolvedModel); // Generate the result using the model return $resolvedModel->generateSpeechResult($messageList); @@ -355,29 +309,16 @@ public static function generateSpeechResult($prompt, ModelInterface $model = nul */ public static function generateEmbeddingsResult($input, ModelInterface $model = null): EmbeddingResult { - // Convert input to standardized Message array format - if (is_array($input) && !empty($input) && is_string($input[0])) { - /** @var string[] $stringArray */ - $stringArray = $input; - $messages = array_map(fn(string $text) => new UserMessage([new MessagePart($text)]), $stringArray); - } else { - /** @var string|MessagePart|MessagePart[]|Message|Message[] $input */ - $messages = PromptNormalizer::normalize($input); - } - - // Ensure messages is a proper list (sequential array with numeric keys starting from 0) + // Normalize embedding input using specialized normalizer + $messages = EmbeddingInputNormalizer::normalize($input); /** @var list $messageList */ $messageList = array_values($messages); // Get model - either provided or auto-discovered $resolvedModel = $model ?? ModelDiscovery::findEmbeddingModel(self::defaultRegistry()); - // Ensure the model supports embedding generation - if (!$resolvedModel instanceof EmbeddingGenerationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement EmbeddingGenerationModelInterface for embedding generation' - ); - } + // Validate model supports embedding generation + InterfaceValidator::validateEmbeddingGeneration($resolvedModel); // Generate the result using the model return $resolvedModel->generateEmbeddingsResult($messageList); @@ -401,12 +342,8 @@ public static function generateOperation($prompt, ModelInterface $model): Genera /** @var list $messageList */ $messageList = array_values($messages); - // Create and return the operation (starting state, no result yet) - return new GenerativeAiOperation( - uniqid('op_', true), - OperationStateEnum::starting(), - null - ); + // Create operation using factory + return OperationFactory::createGenericOperation($messageList); } /** @@ -427,19 +364,11 @@ public static function generateTextOperation($prompt, ModelInterface $model): Ge /** @var list $messageList */ $messageList = array_values($messages); - // Ensure the model supports text generation - if (!$model instanceof TextGenerationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement TextGenerationModelInterface for text generation operations' - ); - } + // Validate model supports text generation operations + InterfaceValidator::validateTextGenerationOperation($model); - // Create and return the operation (starting state, no result yet) - return new GenerativeAiOperation( - uniqid('text_op_', true), - OperationStateEnum::starting(), - null - ); + // Create operation using factory + return OperationFactory::createTextOperation($messageList); } /** @@ -460,19 +389,11 @@ public static function generateImageOperation($prompt, ModelInterface $model): G /** @var list $messageList */ $messageList = array_values($messages); - // Ensure the model supports image generation - if (!$model instanceof ImageGenerationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement ImageGenerationModelInterface for image generation operations' - ); - } + // Validate model supports image generation operations + InterfaceValidator::validateImageGenerationOperation($model); - // Create and return the operation (starting state, no result yet) - return new GenerativeAiOperation( - uniqid('image_op_', true), - OperationStateEnum::starting(), - null - ); + // Create operation using factory + return OperationFactory::createImageOperation($messageList); } /** @@ -493,20 +414,11 @@ public static function convertTextToSpeechOperation($prompt, ModelInterface $mod /** @var list $messageList */ $messageList = array_values($messages); - // Ensure the model supports text-to-speech conversion operations - if (!$model instanceof TextToSpeechConversionOperationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement TextToSpeechConversionOperationModelInterface ' . - 'for text-to-speech conversion operations' - ); - } + // Validate model supports text-to-speech conversion operations + InterfaceValidator::validateTextToSpeechConversionOperation($model); - // Create and return the operation (starting state, no result yet) - return new GenerativeAiOperation( - uniqid('tts_op_', true), - OperationStateEnum::starting(), - null - ); + // Create operation using factory + return OperationFactory::createTextToSpeechOperation($messageList); } /** @@ -527,20 +439,11 @@ public static function generateSpeechOperation($prompt, ModelInterface $model): /** @var list $messageList */ $messageList = array_values($messages); - // Ensure the model supports speech generation operations - if (!$model instanceof SpeechGenerationOperationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement SpeechGenerationOperationModelInterface ' . - 'for speech generation operations' - ); - } + // Validate model supports speech generation operations + InterfaceValidator::validateSpeechGenerationOperation($model); - // Create and return the operation (starting state, no result yet) - return new GenerativeAiOperation( - uniqid('speech_op_', true), - OperationStateEnum::starting(), - null - ); + // Create operation using factory + return OperationFactory::createSpeechOperation($messageList); } /** @@ -556,27 +459,13 @@ public static function generateSpeechOperation($prompt, ModelInterface $model): */ public static function generateEmbeddingsOperation($input, ModelInterface $model): EmbeddingOperation { - // Convert input to standardized Message array format - if (is_array($input) && !empty($input) && is_string($input[0])) { - /** @var string[] $stringArray */ - $stringArray = $input; - $messages = array_map(fn(string $text) => new UserMessage([new MessagePart($text)]), $stringArray); - } else { - /** @var string|MessagePart|MessagePart[]|Message|Message[] $input */ - $messages = PromptNormalizer::normalize($input); - } - - // Ensure messages is a proper list (sequential array with numeric keys starting from 0) + // Normalize embedding input using specialized normalizer + $messages = EmbeddingInputNormalizer::normalize($input); /** @var list $messageList */ $messageList = array_values($messages); - // Ensure the model supports embedding generation operations - if (!$model instanceof EmbeddingGenerationOperationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement EmbeddingGenerationOperationModelInterface ' . - 'for embedding generation operations' - ); - } + // Validate model supports embedding generation operations + InterfaceValidator::validateEmbeddingGenerationOperation($model); // Delegate to the model's operation method with proper list type return $model->generateEmbeddingsOperation($messageList); diff --git a/src/Operations/OperationFactory.php b/src/Operations/OperationFactory.php new file mode 100644 index 00000000..d4f0e6ad --- /dev/null +++ b/src/Operations/OperationFactory.php @@ -0,0 +1,168 @@ + 'op_', + 'text' => 'text_op_', + 'image' => 'image_op_', + 'textToSpeech' => 'tts_op_', + 'speech' => 'speech_op_', + 'embedding' => 'embedding_op_', + ]; + + /** + * Creates a generic generation operation. + * + * @since n.e.x.t + * + * @param list $messages The normalized messages for the operation. + * @return GenerativeAiOperation The created operation. + */ + public static function createGenericOperation(array $messages): GenerativeAiOperation + { + return new GenerativeAiOperation( + uniqid(self::OPERATION_PREFIXES['generic'], true), + OperationStateEnum::starting(), + null + ); + } + + /** + * Creates a text generation operation. + * + * @since n.e.x.t + * + * @param list $messages The normalized messages for the operation. + * @return GenerativeAiOperation The created operation. + */ + public static function createTextOperation(array $messages): GenerativeAiOperation + { + return new GenerativeAiOperation( + uniqid(self::OPERATION_PREFIXES['text'], true), + OperationStateEnum::starting(), + null + ); + } + + /** + * Creates an image generation operation. + * + * @since n.e.x.t + * + * @param list $messages The normalized messages for the operation. + * @return GenerativeAiOperation The created operation. + */ + public static function createImageOperation(array $messages): GenerativeAiOperation + { + return new GenerativeAiOperation( + uniqid(self::OPERATION_PREFIXES['image'], true), + OperationStateEnum::starting(), + null + ); + } + + /** + * Creates a text-to-speech conversion operation. + * + * @since n.e.x.t + * + * @param list $messages The normalized messages for the operation. + * @return GenerativeAiOperation The created operation. + */ + public static function createTextToSpeechOperation(array $messages): GenerativeAiOperation + { + return new GenerativeAiOperation( + uniqid(self::OPERATION_PREFIXES['textToSpeech'], true), + OperationStateEnum::starting(), + null + ); + } + + /** + * Creates a speech generation operation. + * + * @since n.e.x.t + * + * @param list $messages The normalized messages for the operation. + * @return GenerativeAiOperation The created operation. + */ + public static function createSpeechOperation(array $messages): GenerativeAiOperation + { + return new GenerativeAiOperation( + uniqid(self::OPERATION_PREFIXES['speech'], true), + OperationStateEnum::starting(), + null + ); + } + + /** + * Creates an embedding generation operation. + * + * @since n.e.x.t + * + * @param list $messages The normalized messages for the operation. + * @return EmbeddingOperation The created operation. + */ + public static function createEmbeddingOperation(array $messages): EmbeddingOperation + { + return new EmbeddingOperation( + uniqid(self::OPERATION_PREFIXES['embedding'], true), + OperationStateEnum::starting(), + null + ); + } + + /** + * Gets the operation prefix for a given operation type. + * + * @since n.e.x.t + * + * @param string $operationType The operation type (text, image, etc.). + * @return string The operation prefix. + * + * @throws \InvalidArgumentException If the operation type is not supported. + */ + public static function getOperationPrefix(string $operationType): string + { + if (!isset(self::OPERATION_PREFIXES[$operationType])) { + throw new \InvalidArgumentException( + sprintf('Unsupported operation type: %s', $operationType) + ); + } + + return self::OPERATION_PREFIXES[$operationType]; + } + + /** + * Gets all available operation prefixes. + * + * @since n.e.x.t + * + * @return array Array of operation type => prefix mappings. + */ + public static function getOperationPrefixes(): array + { + return self::OPERATION_PREFIXES; + } +} diff --git a/src/Utils/EmbeddingInputNormalizer.php b/src/Utils/EmbeddingInputNormalizer.php new file mode 100644 index 00000000..1b9f2e00 --- /dev/null +++ b/src/Utils/EmbeddingInputNormalizer.php @@ -0,0 +1,115 @@ + Array of Message objects. + * + * @throws \InvalidArgumentException If the input format is invalid. + */ + public static function normalize($input): array + { + // Handle string array input (most common for embeddings) + if (is_array($input) && !empty($input) && is_string($input[0])) { + /** @var string[] $stringArray */ + $stringArray = $input; + return self::normalizeStringArray($stringArray); + } + + // For all other formats, delegate to PromptNormalizer + /** @var string|MessagePart|MessagePart[]|Message|Message[] $input */ + return PromptNormalizer::normalize($input); + } + + /** + * Normalizes a string array into Message objects. + * + * Each string becomes a UserMessage with a single MessagePart. + * + * @since n.e.x.t + * + * @param string[] $stringArray Array of strings to normalize. + * @return list Array of Message objects. + * + * @throws \InvalidArgumentException If the array contains non-string elements. + */ + private static function normalizeStringArray(array $stringArray): array + { + // Validate all elements are strings + foreach ($stringArray as $index => $item) { + if (!is_string($item)) { + throw new \InvalidArgumentException( + sprintf('Array element at index %d must be a string, %s given', $index, gettype($item)) + ); + } + } + + // Convert each string to a UserMessage + $messages = array_map( + fn(string $text) => new UserMessage([new MessagePart($text)]), + $stringArray + ); + + return array_values($messages); + } + + /** + * Validates that input is suitable for embedding generation. + * + * @since n.e.x.t + * + * @param mixed $input The input to validate. + * @return bool True if the input is valid for embedding generation. + */ + public static function isValidEmbeddingInput($input): bool + { + try { + self::normalize($input); + return true; + } catch (\InvalidArgumentException) { + return false; + } + } + + /** + * Gets the number of input items that will be processed for embedding generation. + * + * Useful for understanding how many embeddings will be generated. + * + * @since n.e.x.t + * + * @param string[]|string|MessagePart|MessagePart[]|Message|Message[] $input The input data. + * @return int The number of items that will be processed. + * + * @throws \InvalidArgumentException If the input format is invalid. + */ + public static function getInputCount($input): int + { + $normalizedMessages = self::normalize($input); + return count($normalizedMessages); + } +} diff --git a/src/Utils/GenerationStrategyResolver.php b/src/Utils/GenerationStrategyResolver.php new file mode 100644 index 00000000..6e816002 --- /dev/null +++ b/src/Utils/GenerationStrategyResolver.php @@ -0,0 +1,115 @@ + 'generateTextResult', + ImageGenerationModelInterface::class => 'generateImageResult', + TextToSpeechConversionModelInterface::class => 'convertTextToSpeechResult', + SpeechGenerationModelInterface::class => 'generateSpeechResult', + ]; + + /** + * Resolves the appropriate generation method for a given model. + * + * @since n.e.x.t + * + * @param ModelInterface $model The model to resolve strategy for. + * @return string The method name to call for generation. + * + * @throws \InvalidArgumentException If no supported generation interface is found. + */ + public static function resolve(ModelInterface $model): string + { + foreach (self::GENERATION_STRATEGIES as $interface => $method) { + if ($model instanceof $interface) { + return $method; + } + } + + throw new \InvalidArgumentException( + 'Model must implement at least one supported generation interface ' . + '(TextGeneration, ImageGeneration, TextToSpeechConversion, SpeechGeneration)' + ); + } + + /** + * Checks if a model supports any generation interface. + * + * @since n.e.x.t + * + * @param ModelInterface $model The model to check. + * @return bool True if the model supports at least one generation interface. + */ + public static function isSupported(ModelInterface $model): bool + { + foreach (self::GENERATION_STRATEGIES as $interface => $method) { + if ($model instanceof $interface) { + return true; + } + } + + return false; + } + + /** + * Gets all supported generation interfaces. + * + * @since n.e.x.t + * + * @return array Array of interface => method mappings. + */ + public static function getSupportedInterfaces(): array + { + return self::GENERATION_STRATEGIES; + } + + /** + * Gets the generation method name for a specific interface. + * + * @since n.e.x.t + * + * @param string $interfaceClass The interface class name. + * @return string|null The method name, or null if interface is not supported. + */ + public static function getMethodForInterface(string $interfaceClass): ?string + { + return self::GENERATION_STRATEGIES[$interfaceClass] ?? null; + } + + /** + * Checks if a specific interface is supported for generation. + * + * @since n.e.x.t + * + * @param string $interfaceClass The interface class name to check. + * @return bool True if the interface is supported. + */ + public static function isInterfaceSupported(string $interfaceClass): bool + { + return isset(self::GENERATION_STRATEGIES[$interfaceClass]); + } +} diff --git a/src/Utils/InterfaceValidator.php b/src/Utils/InterfaceValidator.php new file mode 100644 index 00000000..dc713a0a --- /dev/null +++ b/src/Utils/InterfaceValidator.php @@ -0,0 +1,219 @@ +testMessages = [ + new UserMessage([new MessagePart('Test message 1')]), + new UserMessage([new MessagePart('Test message 2')]) + ]; + } + + /** + * Tests createGenericOperation creates operation with correct prefix. + */ + public function testCreateGenericOperation(): void + { + $operation = OperationFactory::createGenericOperation($this->testMessages); + + $this->assertInstanceOf(GenerativeAiOperation::class, $operation); + $this->assertStringStartsWith('op_', $operation->getId()); + $this->assertEquals(OperationStateEnum::starting(), $operation->getState()); + $this->assertNull($operation->getResult()); + } + + /** + * Tests createTextOperation creates operation with correct prefix. + */ + public function testCreateTextOperation(): void + { + $operation = OperationFactory::createTextOperation($this->testMessages); + + $this->assertInstanceOf(GenerativeAiOperation::class, $operation); + $this->assertStringStartsWith('text_op_', $operation->getId()); + $this->assertEquals(OperationStateEnum::starting(), $operation->getState()); + $this->assertNull($operation->getResult()); + } + + /** + * Tests createImageOperation creates operation with correct prefix. + */ + public function testCreateImageOperation(): void + { + $operation = OperationFactory::createImageOperation($this->testMessages); + + $this->assertInstanceOf(GenerativeAiOperation::class, $operation); + $this->assertStringStartsWith('image_op_', $operation->getId()); + $this->assertEquals(OperationStateEnum::starting(), $operation->getState()); + $this->assertNull($operation->getResult()); + } + + /** + * Tests createTextToSpeechOperation creates operation with correct prefix. + */ + public function testCreateTextToSpeechOperation(): void + { + $operation = OperationFactory::createTextToSpeechOperation($this->testMessages); + + $this->assertInstanceOf(GenerativeAiOperation::class, $operation); + $this->assertStringStartsWith('tts_op_', $operation->getId()); + $this->assertEquals(OperationStateEnum::starting(), $operation->getState()); + $this->assertNull($operation->getResult()); + } + + /** + * Tests createSpeechOperation creates operation with correct prefix. + */ + public function testCreateSpeechOperation(): void + { + $operation = OperationFactory::createSpeechOperation($this->testMessages); + + $this->assertInstanceOf(GenerativeAiOperation::class, $operation); + $this->assertStringStartsWith('speech_op_', $operation->getId()); + $this->assertEquals(OperationStateEnum::starting(), $operation->getState()); + $this->assertNull($operation->getResult()); + } + + /** + * Tests createEmbeddingOperation creates embedding operation with correct prefix. + */ + public function testCreateEmbeddingOperation(): void + { + $operation = OperationFactory::createEmbeddingOperation($this->testMessages); + + $this->assertInstanceOf(EmbeddingOperation::class, $operation); + $this->assertStringStartsWith('embedding_op_', $operation->getId()); + $this->assertEquals(OperationStateEnum::starting(), $operation->getState()); + $this->assertNull($operation->getResult()); + } + + /** + * Tests getOperationPrefix returns correct prefix for known types. + */ + public function testGetOperationPrefixReturnsCorrectPrefix(): void + { + $this->assertEquals('op_', OperationFactory::getOperationPrefix('generic')); + $this->assertEquals('text_op_', OperationFactory::getOperationPrefix('text')); + $this->assertEquals('image_op_', OperationFactory::getOperationPrefix('image')); + $this->assertEquals('tts_op_', OperationFactory::getOperationPrefix('textToSpeech')); + $this->assertEquals('speech_op_', OperationFactory::getOperationPrefix('speech')); + $this->assertEquals('embedding_op_', OperationFactory::getOperationPrefix('embedding')); + } + + /** + * Tests getOperationPrefix throws exception for unknown type. + */ + public function testGetOperationPrefixThrowsExceptionForUnknownType(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported operation type: unknown'); + + OperationFactory::getOperationPrefix('unknown'); + } + + /** + * Tests getOperationPrefixes returns all available prefixes. + */ + public function testGetOperationPrefixesReturnsAllPrefixes(): void + { + $prefixes = OperationFactory::getOperationPrefixes(); + + $expected = [ + 'generic' => 'op_', + 'text' => 'text_op_', + 'image' => 'image_op_', + 'textToSpeech' => 'tts_op_', + 'speech' => 'speech_op_', + 'embedding' => 'embedding_op_', + ]; + + $this->assertEquals($expected, $prefixes); + $this->assertCount(6, $prefixes); + } + + /** + * Tests that operation IDs are unique across multiple calls. + */ + public function testOperationIdsAreUnique(): void + { + $operation1 = OperationFactory::createTextOperation($this->testMessages); + $operation2 = OperationFactory::createTextOperation($this->testMessages); + + $this->assertNotEquals($operation1->getId(), $operation2->getId()); + } + + /** + * Tests that operation IDs contain uniqid entropy. + */ + public function testOperationIdsContainEntropy(): void + { + $operation = OperationFactory::createTextOperation($this->testMessages); + + // Should contain more than just the prefix + $this->assertGreaterThan(strlen('text_op_'), strlen($operation->getId())); + + // Should contain the prefix + $this->assertStringStartsWith('text_op_', $operation->getId()); + } +} diff --git a/tests/unit/Utils/EmbeddingInputNormalizerTest.php b/tests/unit/Utils/EmbeddingInputNormalizerTest.php new file mode 100644 index 00000000..16c0fa87 --- /dev/null +++ b/tests/unit/Utils/EmbeddingInputNormalizerTest.php @@ -0,0 +1,215 @@ +assertCount(3, $result); + + // Check first message + $this->assertInstanceOf(UserMessage::class, $result[0]); + $this->assertCount(1, $result[0]->getParts()); + $this->assertEquals('First text', $result[0]->getParts()[0]->getText()); + + // Check second message + $this->assertInstanceOf(UserMessage::class, $result[1]); + $this->assertEquals('Second text', $result[1]->getParts()[0]->getText()); + + // Check third message + $this->assertInstanceOf(UserMessage::class, $result[2]); + $this->assertEquals('Third text', $result[2]->getParts()[0]->getText()); + } + + /** + * Tests normalizing single string input. + */ + public function testNormalizeSingleString(): void + { + $input = 'Single text input'; + $result = EmbeddingInputNormalizer::normalize($input); + + $this->assertCount(1, $result); + $this->assertInstanceOf(UserMessage::class, $result[0]); + $this->assertEquals('Single text input', $result[0]->getParts()[0]->getText()); + } + + /** + * Tests normalizing MessagePart input. + */ + public function testNormalizeMessagePart(): void + { + $messagePart = new MessagePart('Test message part'); + $result = EmbeddingInputNormalizer::normalize($messagePart); + + $this->assertCount(1, $result); + $this->assertInstanceOf(UserMessage::class, $result[0]); + $this->assertSame($messagePart, $result[0]->getParts()[0]); + } + + /** + * Tests normalizing single Message input. + */ + public function testNormalizeSingleMessage(): void + { + $message = new UserMessage([new MessagePart('Test message')]); + $result = EmbeddingInputNormalizer::normalize($message); + + $this->assertCount(1, $result); + $this->assertSame($message, $result[0]); + } + + /** + * Tests normalizing array of Messages. + */ + public function testNormalizeMessageArray(): void + { + $message1 = new UserMessage([new MessagePart('First message')]); + $message2 = new UserMessage([new MessagePart('Second message')]); + $messages = [$message1, $message2]; + + $result = EmbeddingInputNormalizer::normalize($messages); + + $this->assertCount(2, $result); + $this->assertSame($message1, $result[0]); + $this->assertSame($message2, $result[1]); + } + + /** + * Tests normalizing array of MessageParts. + */ + public function testNormalizeMessagePartArray(): void + { + $part1 = new MessagePart('First part'); + $part2 = new MessagePart('Second part'); + $parts = [$part1, $part2]; + + $result = EmbeddingInputNormalizer::normalize($parts); + + $this->assertCount(2, $result); + $this->assertInstanceOf(UserMessage::class, $result[0]); + $this->assertInstanceOf(UserMessage::class, $result[1]); + $this->assertSame($part1, $result[0]->getParts()[0]); + $this->assertSame($part2, $result[1]->getParts()[0]); + } + + /** + * Tests mixed array with non-string elements throws exception. + */ + public function testNormalizeMixedStringArrayThrowsException(): void + { + $input = ['Valid string', 123, 'Another string']; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Array element at index 1 must be a string, integer given'); + + EmbeddingInputNormalizer::normalize($input); + } + + /** + * Tests empty string array throws exception. + */ + public function testNormalizeEmptyArrayThrowsException(): void + { + $input = []; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Prompt array cannot be empty'); + + EmbeddingInputNormalizer::normalize($input); + } + + /** + * Tests invalid input type throws exception. + */ + public function testNormalizeInvalidInputThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid prompt format provided'); + + EmbeddingInputNormalizer::normalize(123); + } + + /** + * Tests isValidEmbeddingInput returns true for valid inputs. + */ + public function testIsValidEmbeddingInputReturnsTrueForValidInputs(): void + { + $this->assertTrue(EmbeddingInputNormalizer::isValidEmbeddingInput('Single string')); + $this->assertTrue(EmbeddingInputNormalizer::isValidEmbeddingInput(['String array', 'element'])); + $this->assertTrue(EmbeddingInputNormalizer::isValidEmbeddingInput(new MessagePart('Test'))); + $this->assertTrue(EmbeddingInputNormalizer::isValidEmbeddingInput( + new UserMessage([new MessagePart('Test')]) + )); + } + + /** + * Tests isValidEmbeddingInput returns false for invalid inputs. + */ + public function testIsValidEmbeddingInputReturnsFalseForInvalidInputs(): void + { + $this->assertFalse(EmbeddingInputNormalizer::isValidEmbeddingInput(123)); + $this->assertFalse(EmbeddingInputNormalizer::isValidEmbeddingInput([])); + $this->assertFalse(EmbeddingInputNormalizer::isValidEmbeddingInput(['valid', 123])); + $this->assertFalse(EmbeddingInputNormalizer::isValidEmbeddingInput(new \stdClass())); + } + + /** + * Tests getInputCount returns correct count for various inputs. + */ + public function testGetInputCountReturnsCorrectCount(): void + { + $this->assertEquals(1, EmbeddingInputNormalizer::getInputCount('Single string')); + $this->assertEquals(3, EmbeddingInputNormalizer::getInputCount(['One', 'Two', 'Three'])); + $this->assertEquals(1, EmbeddingInputNormalizer::getInputCount(new MessagePart('Test'))); + + $messages = [ + new UserMessage([new MessagePart('First')]), + new UserMessage([new MessagePart('Second')]) + ]; + $this->assertEquals(2, EmbeddingInputNormalizer::getInputCount($messages)); + } + + /** + * Tests getInputCount throws exception for invalid input. + */ + public function testGetInputCountThrowsExceptionForInvalidInput(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid prompt format provided'); + + EmbeddingInputNormalizer::getInputCount(123); + } + + /** + * Tests that string array normalization preserves order. + */ + public function testStringArrayNormalizationPreservesOrder(): void + { + $input = ['First', 'Second', 'Third', 'Fourth']; + $result = EmbeddingInputNormalizer::normalize($input); + + $this->assertCount(4, $result); + $this->assertEquals('First', $result[0]->getParts()[0]->getText()); + $this->assertEquals('Second', $result[1]->getParts()[0]->getText()); + $this->assertEquals('Third', $result[2]->getParts()[0]->getText()); + $this->assertEquals('Fourth', $result[3]->getParts()[0]->getText()); + } +} diff --git a/tests/unit/Utils/GenerationStrategyResolverTest.php b/tests/unit/Utils/GenerationStrategyResolverTest.php new file mode 100644 index 00000000..876fa733 --- /dev/null +++ b/tests/unit/Utils/GenerationStrategyResolverTest.php @@ -0,0 +1,189 @@ +createMock(MockTextGenerationModel::class); + + $method = GenerationStrategyResolver::resolve($model); + + $this->assertEquals('generateTextResult', $method); + } + + /** + * Tests resolve returns correct method for image generation model. + */ + public function testResolveReturnsImageGenerationMethod(): void + { + $model = $this->createMock(MockImageGenerationModel::class); + + $method = GenerationStrategyResolver::resolve($model); + + $this->assertEquals('generateImageResult', $method); + } + + /** + * Tests resolve throws exception for unsupported model. + */ + public function testResolveThrowsExceptionForUnsupportedModel(): void + { + $model = $this->createMock(ModelInterface::class); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Model must implement at least one supported generation interface ' . + '(TextGeneration, ImageGeneration, TextToSpeechConversion, SpeechGeneration)' + ); + + GenerationStrategyResolver::resolve($model); + } + + /** + * Tests isSupported returns true for supported models. + */ + public function testIsSupportedReturnsTrueForSupportedModels(): void + { + $textModel = $this->createMock(MockTextGenerationModel::class); + $imageModel = $this->createMock(MockImageGenerationModel::class); + + $this->assertTrue(GenerationStrategyResolver::isSupported($textModel)); + $this->assertTrue(GenerationStrategyResolver::isSupported($imageModel)); + } + + /** + * Tests isSupported returns false for unsupported models. + */ + public function testIsSupportedReturnsFalseForUnsupportedModels(): void + { + $model = $this->createMock(ModelInterface::class); + + $this->assertFalse(GenerationStrategyResolver::isSupported($model)); + } + + /** + * Tests getSupportedInterfaces returns all supported interfaces. + */ + public function testGetSupportedInterfacesReturnsAllInterfaces(): void + { + $interfaces = GenerationStrategyResolver::getSupportedInterfaces(); + + $expected = [ + TextGenerationModelInterface::class => 'generateTextResult', + ImageGenerationModelInterface::class => 'generateImageResult', + TextToSpeechConversionModelInterface::class => 'convertTextToSpeechResult', + SpeechGenerationModelInterface::class => 'generateSpeechResult', + ]; + + $this->assertEquals($expected, $interfaces); + $this->assertCount(4, $interfaces); + } + + /** + * Tests getMethodForInterface returns correct method for known interface. + */ + public function testGetMethodForInterfaceReturnsCorrectMethod(): void + { + $method = GenerationStrategyResolver::getMethodForInterface( + TextGenerationModelInterface::class + ); + + $this->assertEquals('generateTextResult', $method); + } + + /** + * Tests getMethodForInterface returns null for unknown interface. + */ + public function testGetMethodForInterfaceReturnsNullForUnknownInterface(): void + { + $method = GenerationStrategyResolver::getMethodForInterface('UnknownInterface'); + + $this->assertNull($method); + } + + /** + * Tests isInterfaceSupported returns true for supported interfaces. + */ + public function testIsInterfaceSupportedReturnsTrueForSupportedInterfaces(): void + { + $this->assertTrue(GenerationStrategyResolver::isInterfaceSupported( + TextGenerationModelInterface::class + )); + $this->assertTrue(GenerationStrategyResolver::isInterfaceSupported( + ImageGenerationModelInterface::class + )); + $this->assertTrue(GenerationStrategyResolver::isInterfaceSupported( + TextToSpeechConversionModelInterface::class + )); + $this->assertTrue(GenerationStrategyResolver::isInterfaceSupported( + SpeechGenerationModelInterface::class + )); + } + + /** + * Tests isInterfaceSupported returns false for unsupported interfaces. + */ + public function testIsInterfaceSupportedReturnsFalseForUnsupportedInterfaces(): void + { + $this->assertFalse(GenerationStrategyResolver::isInterfaceSupported('UnknownInterface')); + $this->assertFalse(GenerationStrategyResolver::isInterfaceSupported(ModelInterface::class)); + } + + /** + * Tests that resolve prioritizes text generation when model implements multiple interfaces. + */ + public function testResolvePrioritizesTextGeneration(): void + { + // Create a mock that implements both text and image generation + $model = $this->getMockBuilder(MockTextGenerationModel::class) + ->addMethods([]) + ->getMock(); + + // Should return text generation method (first in the strategy order) + $method = GenerationStrategyResolver::resolve($model); + $this->assertEquals('generateTextResult', $method); + } + + /** + * Tests that all expected interfaces are covered by the resolver. + */ + public function testAllExpectedInterfacesAreCovered(): void + { + $supportedInterfaces = GenerationStrategyResolver::getSupportedInterfaces(); + + // Verify all expected interfaces are present + $this->assertArrayHasKey(TextGenerationModelInterface::class, $supportedInterfaces); + $this->assertArrayHasKey(ImageGenerationModelInterface::class, $supportedInterfaces); + $this->assertArrayHasKey(TextToSpeechConversionModelInterface::class, $supportedInterfaces); + $this->assertArrayHasKey(SpeechGenerationModelInterface::class, $supportedInterfaces); + + // Verify methods are correctly mapped + $this->assertEquals('generateTextResult', $supportedInterfaces[TextGenerationModelInterface::class]); + $this->assertEquals('generateImageResult', $supportedInterfaces[ImageGenerationModelInterface::class]); + $this->assertEquals( + 'convertTextToSpeechResult', + $supportedInterfaces[TextToSpeechConversionModelInterface::class] + ); + $this->assertEquals('generateSpeechResult', $supportedInterfaces[SpeechGenerationModelInterface::class]); + } +} diff --git a/tests/unit/Utils/InterfaceValidatorTest.php b/tests/unit/Utils/InterfaceValidatorTest.php new file mode 100644 index 00000000..928fe162 --- /dev/null +++ b/tests/unit/Utils/InterfaceValidatorTest.php @@ -0,0 +1,194 @@ +createMock(MockTextGenerationModel::class); + + // Should not throw an exception + InterfaceValidator::validateTextGeneration($model); + + // If we reach here, validation passed + $this->assertTrue(true); + } + + /** + * Tests validateTextGeneration with invalid model. + */ + public function testValidateTextGenerationWithInvalidModel(): void + { + $model = $this->createMock(ModelInterface::class); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Model must implement TextGenerationModelInterface for text generation' + ); + + InterfaceValidator::validateTextGeneration($model); + } + + /** + * Tests validateImageGeneration with valid image model. + */ + public function testValidateImageGenerationWithValidModel(): void + { + $model = $this->createMock(MockImageGenerationModel::class); + + // Should not throw an exception + InterfaceValidator::validateImageGeneration($model); + + // If we reach here, validation passed + $this->assertTrue(true); + } + + /** + * Tests validateImageGeneration with invalid model. + */ + public function testValidateImageGenerationWithInvalidModel(): void + { + $model = $this->createMock(ModelInterface::class); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Model must implement ImageGenerationModelInterface for image generation' + ); + + InterfaceValidator::validateImageGeneration($model); + } + + /** + * Tests validateEmbeddingGeneration with valid embedding model. + */ + public function testValidateEmbeddingGenerationWithValidModel(): void + { + $model = $this->createMock(MockEmbeddingGenerationModel::class); + + // Should not throw an exception + InterfaceValidator::validateEmbeddingGeneration($model); + + // If we reach here, validation passed + $this->assertTrue(true); + } + + /** + * Tests validateEmbeddingGeneration with invalid model. + */ + public function testValidateEmbeddingGenerationWithInvalidModel(): void + { + $model = $this->createMock(ModelInterface::class); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Model must implement EmbeddingGenerationModelInterface for embedding generation' + ); + + InterfaceValidator::validateEmbeddingGeneration($model); + } + + /** + * Tests validateTextGenerationOperation with valid text model. + */ + public function testValidateTextGenerationOperationWithValidModel(): void + { + $model = $this->createMock(MockTextGenerationModel::class); + + // Should not throw an exception + InterfaceValidator::validateTextGenerationOperation($model); + + // If we reach here, validation passed + $this->assertTrue(true); + } + + /** + * Tests validateTextGenerationOperation with invalid model. + */ + public function testValidateTextGenerationOperationWithInvalidModel(): void + { + $model = $this->createMock(ModelInterface::class); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Model must implement TextGenerationModelInterface for text generation operations' + ); + + InterfaceValidator::validateTextGenerationOperation($model); + } + + /** + * Tests validateImageGenerationOperation with valid image model. + */ + public function testValidateImageGenerationOperationWithValidModel(): void + { + $model = $this->createMock(MockImageGenerationModel::class); + + // Should not throw an exception + InterfaceValidator::validateImageGenerationOperation($model); + + // If we reach here, validation passed + $this->assertTrue(true); + } + + /** + * Tests validateImageGenerationOperation with invalid model. + */ + public function testValidateImageGenerationOperationWithInvalidModel(): void + { + $model = $this->createMock(ModelInterface::class); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Model must implement ImageGenerationModelInterface for image generation operations' + ); + + InterfaceValidator::validateImageGenerationOperation($model); + } + + /** + * Tests validateEmbeddingGenerationOperation with valid embedding operation model. + */ + public function testValidateEmbeddingGenerationOperationWithValidModel(): void + { + $model = $this->createMock(MockEmbeddingGenerationOperationModel::class); + + // Should not throw an exception + InterfaceValidator::validateEmbeddingGenerationOperation($model); + + // If we reach here, validation passed + $this->assertTrue(true); + } + + /** + * Tests validateEmbeddingGenerationOperation with invalid model. + */ + public function testValidateEmbeddingGenerationOperationWithInvalidModel(): void + { + $model = $this->createMock(ModelInterface::class); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Model must implement EmbeddingGenerationOperationModelInterface ' . + 'for embedding generation operations' + ); + + InterfaceValidator::validateEmbeddingGenerationOperation($model); + } +} From 8cb7875ce75d1041e6f13738ccf1f1c1f5720fa6 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Mon, 18 Aug 2025 10:11:36 +0300 Subject: [PATCH 19/69] Fix PHPStan errors and PHP 7.4 compatibility issues --- src/AiClient.php | 8 ++++++++ src/Utils/EmbeddingInputNormalizer.php | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/AiClient.php b/src/AiClient.php index 845c671c..9a0464f7 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -125,6 +125,7 @@ public static function generateResult($prompt, ModelInterface $model): Generativ $method = GenerationStrategyResolver::resolve($model); // Call the resolved method dynamically + /** @var GenerativeAiResult */ return self::$method($prompt, $model); } @@ -176,6 +177,7 @@ public static function generateTextResult($prompt, ModelInterface $model = null) InterfaceValidator::validateTextGeneration($resolvedModel); // Generate the result using the model + /** @phpstan-ignore-next-line */ return $resolvedModel->generateTextResult($messageList); } @@ -205,6 +207,7 @@ public static function streamGenerateTextResult($prompt, ModelInterface $model = InterfaceValidator::validateTextGeneration($resolvedModel); // Stream the results using the model + /** @phpstan-ignore-next-line */ yield from $resolvedModel->streamGenerateTextResult($messageList); } @@ -234,6 +237,7 @@ public static function generateImageResult($prompt, ModelInterface $model = null InterfaceValidator::validateImageGeneration($resolvedModel); // Generate the result using the model + /** @phpstan-ignore-next-line */ return $resolvedModel->generateImageResult($messageList); } @@ -263,6 +267,7 @@ public static function convertTextToSpeechResult($prompt, ModelInterface $model InterfaceValidator::validateTextToSpeechConversion($resolvedModel); // Generate the result using the model + /** @phpstan-ignore-next-line */ return $resolvedModel->convertTextToSpeechResult($messageList); } @@ -292,6 +297,7 @@ public static function generateSpeechResult($prompt, ModelInterface $model = nul InterfaceValidator::validateSpeechGeneration($resolvedModel); // Generate the result using the model + /** @phpstan-ignore-next-line */ return $resolvedModel->generateSpeechResult($messageList); } @@ -321,6 +327,7 @@ public static function generateEmbeddingsResult($input, ModelInterface $model = InterfaceValidator::validateEmbeddingGeneration($resolvedModel); // Generate the result using the model + /** @phpstan-ignore-next-line */ return $resolvedModel->generateEmbeddingsResult($messageList); } @@ -468,6 +475,7 @@ public static function generateEmbeddingsOperation($input, ModelInterface $model InterfaceValidator::validateEmbeddingGenerationOperation($model); // Delegate to the model's operation method with proper list type + /** @phpstan-ignore-next-line */ return $model->generateEmbeddingsOperation($messageList); } } diff --git a/src/Utils/EmbeddingInputNormalizer.php b/src/Utils/EmbeddingInputNormalizer.php index 1b9f2e00..f94d3e1e 100644 --- a/src/Utils/EmbeddingInputNormalizer.php +++ b/src/Utils/EmbeddingInputNormalizer.php @@ -88,9 +88,10 @@ private static function normalizeStringArray(array $stringArray): array public static function isValidEmbeddingInput($input): bool { try { + /** @phpstan-ignore-next-line */ self::normalize($input); return true; - } catch (\InvalidArgumentException) { + } catch (\InvalidArgumentException $e) { return false; } } From e5cf9c868d5fbd5dbb381e5ca28b6a5e98146b61 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Mon, 18 Aug 2025 10:59:40 +0300 Subject: [PATCH 20/69] Refactor AiClient codebase with template method patterns and consolidation - Implement template method pattern in AiClient for generation methods - Add dynamic validation system to InterfaceValidator with configuration mapping - Consolidate EmbeddingInputNormalizer functionality into PromptNormalizer - Refactor operation methods with factory pattern approach - Update all tests to use consolidated normalizer methods --- src/AiClient.php | 189 +++++++++--------- src/Utils/InterfaceValidator.php | 103 +++++----- src/Utils/PromptNormalizer.php | 96 +++++++++ .../Utils/EmbeddingInputNormalizerTest.php | 52 ++--- 4 files changed, 273 insertions(+), 167 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index 9a0464f7..488fc698 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -15,7 +15,6 @@ use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Results\DTO\EmbeddingResult; use WordPress\AiClient\Results\DTO\GenerativeAiResult; -use WordPress\AiClient\Utils\EmbeddingInputNormalizer; use WordPress\AiClient\Utils\GenerationStrategyResolver; use WordPress\AiClient\Utils\InterfaceValidator; use WordPress\AiClient\Utils\ModelDiscovery; @@ -152,18 +151,19 @@ public static function message(?string $text = null) } /** - * Generates text using the traditional API approach. + * Template method for executing generation operations. * * @since n.e.x.t * * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. * @param ModelInterface|null $model Optional specific model to use. + * @param string $type The generation type (text, image, speech). * @return GenerativeAiResult The generation result. * * @throws \InvalidArgumentException If the prompt format is invalid. * @throws \RuntimeException If no suitable model is found. */ - public static function generateTextResult($prompt, ModelInterface $model = null): GenerativeAiResult + private static function executeGeneration($prompt, ?ModelInterface $model, string $type): GenerativeAiResult { // Convert prompt to standardized Message array format $messages = PromptNormalizer::normalize($prompt); @@ -171,14 +171,34 @@ public static function generateTextResult($prompt, ModelInterface $model = null) $messageList = array_values($messages); // Get model - either provided or auto-discovered - $resolvedModel = $model ?? ModelDiscovery::findTextModel(self::defaultRegistry()); + $discoveryMethod = 'find' . ucfirst($type) . 'Model'; + $resolvedModel = $model ?? ModelDiscovery::$discoveryMethod(self::defaultRegistry()); - // Validate model supports text generation - InterfaceValidator::validateTextGeneration($resolvedModel); + // Validate model supports the generation type + $validationMethod = 'validate' . ucfirst($type) . 'Generation'; + InterfaceValidator::$validationMethod($resolvedModel); // Generate the result using the model + $generationMethod = 'generate' . ucfirst($type) . 'Result'; /** @phpstan-ignore-next-line */ - return $resolvedModel->generateTextResult($messageList); + return $resolvedModel->$generationMethod($messageList); + } + + /** + * Generates text using the traditional API approach. + * + * @since n.e.x.t + * + * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. + * @param ModelInterface|null $model Optional specific model to use. + * @return GenerativeAiResult The generation result. + * + * @throws \InvalidArgumentException If the prompt format is invalid. + * @throws \RuntimeException If no suitable model is found. + */ + public static function generateTextResult($prompt, ModelInterface $model = null): GenerativeAiResult + { + return self::executeGeneration($prompt, $model, 'text'); } /** @@ -225,24 +245,11 @@ public static function streamGenerateTextResult($prompt, ModelInterface $model = */ public static function generateImageResult($prompt, ModelInterface $model = null): GenerativeAiResult { - // Convert prompt to standardized Message array format - $messages = PromptNormalizer::normalize($prompt); - /** @var list $messageList */ - $messageList = array_values($messages); - - // Get model - either provided or auto-discovered - $resolvedModel = $model ?? ModelDiscovery::findImageModel(self::defaultRegistry()); - - // Validate model supports image generation - InterfaceValidator::validateImageGeneration($resolvedModel); - - // Generate the result using the model - /** @phpstan-ignore-next-line */ - return $resolvedModel->generateImageResult($messageList); + return self::executeGeneration($prompt, $model, 'image'); } /** - * Converts text to speech using the traditional API approach. + * Template method for text-to-speech conversion. * * @since n.e.x.t * @@ -253,7 +260,7 @@ public static function generateImageResult($prompt, ModelInterface $model = null * @throws \InvalidArgumentException If the prompt format is invalid. * @throws \RuntimeException If no suitable model is found. */ - public static function convertTextToSpeechResult($prompt, ModelInterface $model = null): GenerativeAiResult + private static function executeTextToSpeechGeneration($prompt, ?ModelInterface $model): GenerativeAiResult { // Convert prompt to standardized Message array format $messages = PromptNormalizer::normalize($prompt); @@ -271,6 +278,23 @@ public static function convertTextToSpeechResult($prompt, ModelInterface $model return $resolvedModel->convertTextToSpeechResult($messageList); } + /** + * Converts text to speech using the traditional API approach. + * + * @since n.e.x.t + * + * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. + * @param ModelInterface|null $model Optional specific model to use. + * @return GenerativeAiResult The generation result. + * + * @throws \InvalidArgumentException If the prompt format is invalid. + * @throws \RuntimeException If no suitable model is found. + */ + public static function convertTextToSpeechResult($prompt, ModelInterface $model = null): GenerativeAiResult + { + return self::executeTextToSpeechGeneration($prompt, $model); + } + /** * Generates speech using the traditional API approach. * @@ -285,20 +309,7 @@ public static function convertTextToSpeechResult($prompt, ModelInterface $model */ public static function generateSpeechResult($prompt, ModelInterface $model = null): GenerativeAiResult { - // Convert prompt to standardized Message array format - $messages = PromptNormalizer::normalize($prompt); - /** @var list $messageList */ - $messageList = array_values($messages); - - // Get model - either provided or auto-discovered - $resolvedModel = $model ?? ModelDiscovery::findSpeechModel(self::defaultRegistry()); - - // Validate model supports speech generation - InterfaceValidator::validateSpeechGeneration($resolvedModel); - - // Generate the result using the model - /** @phpstan-ignore-next-line */ - return $resolvedModel->generateSpeechResult($messageList); + return self::executeGeneration($prompt, $model, 'speech'); } /** @@ -315,8 +326,8 @@ public static function generateSpeechResult($prompt, ModelInterface $model = nul */ public static function generateEmbeddingsResult($input, ModelInterface $model = null): EmbeddingResult { - // Normalize embedding input using specialized normalizer - $messages = EmbeddingInputNormalizer::normalize($input); + // Normalize embedding input using consolidated normalizer + $messages = PromptNormalizer::normalizeEmbeddingInput($input); /** @var list $messageList */ $messageList = array_values($messages); @@ -331,6 +342,42 @@ public static function generateEmbeddingsResult($input, ModelInterface $model = return $resolvedModel->generateEmbeddingsResult($messageList); } + /** + * Template method for creating operations. + * + * @since n.e.x.t + * + * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. + * @param ModelInterface $model The model to use for the operation. + * @param string $type The operation type (text, image, textToSpeech, speech). + * @return GenerativeAiOperation|EmbeddingOperation The operation for async processing. + * + * @throws \InvalidArgumentException If the prompt format is invalid. + */ + private static function createOperation($prompt, ModelInterface $model, string $type): object + { + // Convert prompt to standardized Message array format + $messages = PromptNormalizer::normalize($prompt); + /** @var list $messageList */ + $messageList = array_values($messages); + + // Special handling for embedding operations + if ($type === 'embedding') { + InterfaceValidator::validateEmbeddingGenerationOperation($model); + /** @phpstan-ignore-next-line */ + return $model->generateEmbeddingsOperation($messageList); + } + + // Validate model supports the operation type + $validationMethod = 'validate' . ucfirst($type) . 'GenerationOperation'; + InterfaceValidator::$validationMethod($model); + + // Create operation using factory + $factoryMethod = 'create' . ucfirst($type) . 'Operation'; + /** @var GenerativeAiOperation */ + return OperationFactory::$factoryMethod($messageList); + } + /** * Creates a generation operation for async processing. * @@ -366,16 +413,8 @@ public static function generateOperation($prompt, ModelInterface $model): Genera */ public static function generateTextOperation($prompt, ModelInterface $model): GenerativeAiOperation { - // Convert prompt to standardized Message array format - $messages = PromptNormalizer::normalize($prompt); - /** @var list $messageList */ - $messageList = array_values($messages); - - // Validate model supports text generation operations - InterfaceValidator::validateTextGenerationOperation($model); - - // Create operation using factory - return OperationFactory::createTextOperation($messageList); + /** @var GenerativeAiOperation */ + return self::createOperation($prompt, $model, 'text'); } /** @@ -391,16 +430,8 @@ public static function generateTextOperation($prompt, ModelInterface $model): Ge */ public static function generateImageOperation($prompt, ModelInterface $model): GenerativeAiOperation { - // Convert prompt to standardized Message array format - $messages = PromptNormalizer::normalize($prompt); - /** @var list $messageList */ - $messageList = array_values($messages); - - // Validate model supports image generation operations - InterfaceValidator::validateImageGenerationOperation($model); - - // Create operation using factory - return OperationFactory::createImageOperation($messageList); + /** @var GenerativeAiOperation */ + return self::createOperation($prompt, $model, 'image'); } /** @@ -416,16 +447,8 @@ public static function generateImageOperation($prompt, ModelInterface $model): G */ public static function convertTextToSpeechOperation($prompt, ModelInterface $model): GenerativeAiOperation { - // Convert prompt to standardized Message array format - $messages = PromptNormalizer::normalize($prompt); - /** @var list $messageList */ - $messageList = array_values($messages); - - // Validate model supports text-to-speech conversion operations - InterfaceValidator::validateTextToSpeechConversionOperation($model); - - // Create operation using factory - return OperationFactory::createTextToSpeechOperation($messageList); + /** @var GenerativeAiOperation */ + return self::createOperation($prompt, $model, 'textToSpeech'); } /** @@ -441,16 +464,8 @@ public static function convertTextToSpeechOperation($prompt, ModelInterface $mod */ public static function generateSpeechOperation($prompt, ModelInterface $model): GenerativeAiOperation { - // Convert prompt to standardized Message array format - $messages = PromptNormalizer::normalize($prompt); - /** @var list $messageList */ - $messageList = array_values($messages); - - // Validate model supports speech generation operations - InterfaceValidator::validateSpeechGenerationOperation($model); - - // Create operation using factory - return OperationFactory::createSpeechOperation($messageList); + /** @var GenerativeAiOperation */ + return self::createOperation($prompt, $model, 'speech'); } /** @@ -466,16 +481,10 @@ public static function generateSpeechOperation($prompt, ModelInterface $model): */ public static function generateEmbeddingsOperation($input, ModelInterface $model): EmbeddingOperation { - // Normalize embedding input using specialized normalizer - $messages = EmbeddingInputNormalizer::normalize($input); - /** @var list $messageList */ - $messageList = array_values($messages); - - // Validate model supports embedding generation operations - InterfaceValidator::validateEmbeddingGenerationOperation($model); - - // Delegate to the model's operation method with proper list type - /** @phpstan-ignore-next-line */ - return $model->generateEmbeddingsOperation($messageList); + // Normalize embedding input using consolidated normalizer + $messages = PromptNormalizer::normalizeEmbeddingInput($input); + + /** @var EmbeddingOperation */ + return self::createOperation($messages, $model, 'embedding'); } } diff --git a/src/Utils/InterfaceValidator.php b/src/Utils/InterfaceValidator.php index dc713a0a..1f1cd35a 100644 --- a/src/Utils/InterfaceValidator.php +++ b/src/Utils/InterfaceValidator.php @@ -25,23 +25,61 @@ class InterfaceValidator { /** - * Validates that a model implements TextGenerationModelInterface. + * Validation configuration mapping generation types to their interfaces. + * + * @var array + */ + private const VALIDATION_CONFIG = [ + 'textGeneration' => [TextGenerationModelInterface::class, 'TextGenerationModelInterface', 'text generation'], + 'imageGeneration' => [ImageGenerationModelInterface::class, 'ImageGenerationModelInterface', 'image generation'], + 'textToSpeechConversion' => [TextToSpeechConversionModelInterface::class, 'TextToSpeechConversionModelInterface', 'text-to-speech conversion'], + 'speechGeneration' => [SpeechGenerationModelInterface::class, 'SpeechGenerationModelInterface', 'speech generation'], + 'embeddingGeneration' => [EmbeddingGenerationModelInterface::class, 'EmbeddingGenerationModelInterface', 'embedding generation'], + 'textGenerationOperation' => [TextGenerationModelInterface::class, 'TextGenerationModelInterface', 'text generation operations'], + 'imageGenerationOperation' => [ImageGenerationModelInterface::class, 'ImageGenerationModelInterface', 'image generation operations'], + 'textToSpeechConversionOperation' => [TextToSpeechConversionOperationModelInterface::class, 'TextToSpeechConversionOperationModelInterface', 'text-to-speech conversion operations'], + 'speechGenerationOperation' => [SpeechGenerationOperationModelInterface::class, 'SpeechGenerationOperationModelInterface', 'speech generation operations'], + 'embeddingGenerationOperation' => [EmbeddingGenerationOperationModelInterface::class, 'EmbeddingGenerationOperationModelInterface', 'embedding generation operations'], + ]; + + /** + * Generic interface validation method. * * @since n.e.x.t * * @param ModelInterface $model The model to validate. + * @param string $type The validation type from VALIDATION_CONFIG. * @return void * * @throws \InvalidArgumentException If the model doesn't implement the required interface. */ - public static function validateTextGeneration(ModelInterface $model): void + private static function validateInterface(ModelInterface $model, string $type): void { - if (!$model instanceof TextGenerationModelInterface) { + if (!isset(self::VALIDATION_CONFIG[$type])) { + throw new \InvalidArgumentException("Unknown validation type: {$type}"); + } + + [$interface, $interfaceName, $description] = self::VALIDATION_CONFIG[$type]; + if (!$model instanceof $interface) { throw new \InvalidArgumentException( - 'Model must implement TextGenerationModelInterface for text generation' + "Model must implement {$interfaceName} for {$description}" ); } } + /** + * Validates that a model implements TextGenerationModelInterface. + * + * @since n.e.x.t + * + * @param ModelInterface $model The model to validate. + * @return void + * + * @throws \InvalidArgumentException If the model doesn't implement the required interface. + */ + public static function validateTextGeneration(ModelInterface $model): void + { + self::validateInterface($model, 'textGeneration'); + } /** * Validates that a model implements ImageGenerationModelInterface. @@ -55,11 +93,7 @@ public static function validateTextGeneration(ModelInterface $model): void */ public static function validateImageGeneration(ModelInterface $model): void { - if (!$model instanceof ImageGenerationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement ImageGenerationModelInterface for image generation' - ); - } + self::validateInterface($model, 'imageGeneration'); } /** @@ -74,11 +108,7 @@ public static function validateImageGeneration(ModelInterface $model): void */ public static function validateTextToSpeechConversion(ModelInterface $model): void { - if (!$model instanceof TextToSpeechConversionModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement TextToSpeechConversionModelInterface for text-to-speech conversion' - ); - } + self::validateInterface($model, 'textToSpeechConversion'); } /** @@ -93,11 +123,7 @@ public static function validateTextToSpeechConversion(ModelInterface $model): vo */ public static function validateSpeechGeneration(ModelInterface $model): void { - if (!$model instanceof SpeechGenerationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement SpeechGenerationModelInterface for speech generation' - ); - } + self::validateInterface($model, 'speechGeneration'); } /** @@ -112,11 +138,7 @@ public static function validateSpeechGeneration(ModelInterface $model): void */ public static function validateEmbeddingGeneration(ModelInterface $model): void { - if (!$model instanceof EmbeddingGenerationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement EmbeddingGenerationModelInterface for embedding generation' - ); - } + self::validateInterface($model, 'embeddingGeneration'); } /** @@ -131,11 +153,7 @@ public static function validateEmbeddingGeneration(ModelInterface $model): void */ public static function validateTextGenerationOperation(ModelInterface $model): void { - if (!$model instanceof TextGenerationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement TextGenerationModelInterface for text generation operations' - ); - } + self::validateInterface($model, 'textGenerationOperation'); } /** @@ -150,11 +168,7 @@ public static function validateTextGenerationOperation(ModelInterface $model): v */ public static function validateImageGenerationOperation(ModelInterface $model): void { - if (!$model instanceof ImageGenerationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement ImageGenerationModelInterface for image generation operations' - ); - } + self::validateInterface($model, 'imageGenerationOperation'); } /** @@ -169,12 +183,7 @@ public static function validateImageGenerationOperation(ModelInterface $model): */ public static function validateTextToSpeechConversionOperation(ModelInterface $model): void { - if (!$model instanceof TextToSpeechConversionOperationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement TextToSpeechConversionOperationModelInterface ' . - 'for text-to-speech conversion operations' - ); - } + self::validateInterface($model, 'textToSpeechConversionOperation'); } /** @@ -189,12 +198,7 @@ public static function validateTextToSpeechConversionOperation(ModelInterface $m */ public static function validateSpeechGenerationOperation(ModelInterface $model): void { - if (!$model instanceof SpeechGenerationOperationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement SpeechGenerationOperationModelInterface ' . - 'for speech generation operations' - ); - } + self::validateInterface($model, 'speechGenerationOperation'); } /** @@ -209,11 +213,6 @@ public static function validateSpeechGenerationOperation(ModelInterface $model): */ public static function validateEmbeddingGenerationOperation(ModelInterface $model): void { - if (!$model instanceof EmbeddingGenerationOperationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement EmbeddingGenerationOperationModelInterface ' . - 'for embedding generation operations' - ); - } + self::validateInterface($model, 'embeddingGenerationOperation'); } } diff --git a/src/Utils/PromptNormalizer.php b/src/Utils/PromptNormalizer.php index b3a5e6ae..1452071c 100644 --- a/src/Utils/PromptNormalizer.php +++ b/src/Utils/PromptNormalizer.php @@ -86,4 +86,100 @@ public static function normalize($prompt): array // Unsupported type throw new \InvalidArgumentException('Invalid prompt format provided'); } + + /** + * Normalizes embedding input into a standardized Message array. + * + * Handles both string arrays (common for embeddings) and other + * message formats that can be processed by the main normalize method. + * + * @since n.e.x.t + * + * @param string[]|string|MessagePart|MessagePart[]|Message|Message[] $input The input data in various formats. + * @return list Array of Message objects. + * + * @throws \InvalidArgumentException If the input format is invalid. + */ + public static function normalizeEmbeddingInput($input): array + { + // Handle string array input (most common for embeddings) + if (is_array($input) && !empty($input) && is_string($input[0])) { + /** @var string[] $stringArray */ + $stringArray = $input; + return self::normalizeStringArray($stringArray); + } + + // For all other formats, use the main normalize method + /** @var string|MessagePart|MessagePart[]|Message|Message[] $input */ + return self::normalize($input); + } + + /** + * Normalizes a string array into Message objects. + * + * Each string becomes a UserMessage with a single MessagePart. + * + * @since n.e.x.t + * + * @param string[] $stringArray Array of strings to normalize. + * @return list Array of Message objects. + * + * @throws \InvalidArgumentException If the array contains non-string elements. + */ + private static function normalizeStringArray(array $stringArray): array + { + // Validate all elements are strings + foreach ($stringArray as $index => $item) { + if (!is_string($item)) { + throw new \InvalidArgumentException( + sprintf('Array element at index %d must be a string, %s given', $index, gettype($item)) + ); + } + } + + // Convert each string to a UserMessage + $messages = array_map( + fn(string $text) => new UserMessage([new MessagePart($text)]), + $stringArray + ); + + return array_values($messages); + } + + /** + * Validates that input is suitable for embedding generation. + * + * @since n.e.x.t + * + * @param mixed $input The input to validate. + * @return bool True if the input is valid for embedding generation. + */ + public static function isValidEmbeddingInput($input): bool + { + try { + /** @phpstan-ignore-next-line */ + self::normalizeEmbeddingInput($input); + return true; + } catch (\InvalidArgumentException $e) { + return false; + } + } + + /** + * Gets the number of input items that will be processed for embedding generation. + * + * Useful for understanding how many embeddings will be generated. + * + * @since n.e.x.t + * + * @param string[]|string|MessagePart|MessagePart[]|Message|Message[] $input The input data. + * @return int The number of items that will be processed. + * + * @throws \InvalidArgumentException If the input format is invalid. + */ + public static function getEmbeddingInputCount($input): int + { + $normalizedMessages = self::normalizeEmbeddingInput($input); + return count($normalizedMessages); + } } diff --git a/tests/unit/Utils/EmbeddingInputNormalizerTest.php b/tests/unit/Utils/EmbeddingInputNormalizerTest.php index 16c0fa87..3d36ded9 100644 --- a/tests/unit/Utils/EmbeddingInputNormalizerTest.php +++ b/tests/unit/Utils/EmbeddingInputNormalizerTest.php @@ -7,10 +7,12 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\UserMessage; -use WordPress\AiClient\Utils\EmbeddingInputNormalizer; +use WordPress\AiClient\Utils\PromptNormalizer; /** - * @covers \WordPress\AiClient\Utils\EmbeddingInputNormalizer + * @covers \WordPress\AiClient\Utils\PromptNormalizer::normalizeEmbeddingInput + * @covers \WordPress\AiClient\Utils\PromptNormalizer::isValidEmbeddingInput + * @covers \WordPress\AiClient\Utils\PromptNormalizer::getEmbeddingInputCount */ class EmbeddingInputNormalizerTest extends TestCase { @@ -20,7 +22,7 @@ class EmbeddingInputNormalizerTest extends TestCase public function testNormalizeStringArray(): void { $input = ['First text', 'Second text', 'Third text']; - $result = EmbeddingInputNormalizer::normalize($input); + $result = PromptNormalizer::normalizeEmbeddingInput($input); $this->assertCount(3, $result); @@ -44,7 +46,7 @@ public function testNormalizeStringArray(): void public function testNormalizeSingleString(): void { $input = 'Single text input'; - $result = EmbeddingInputNormalizer::normalize($input); + $result = PromptNormalizer::normalizeEmbeddingInput($input); $this->assertCount(1, $result); $this->assertInstanceOf(UserMessage::class, $result[0]); @@ -57,7 +59,7 @@ public function testNormalizeSingleString(): void public function testNormalizeMessagePart(): void { $messagePart = new MessagePart('Test message part'); - $result = EmbeddingInputNormalizer::normalize($messagePart); + $result = PromptNormalizer::normalizeEmbeddingInput($messagePart); $this->assertCount(1, $result); $this->assertInstanceOf(UserMessage::class, $result[0]); @@ -70,7 +72,7 @@ public function testNormalizeMessagePart(): void public function testNormalizeSingleMessage(): void { $message = new UserMessage([new MessagePart('Test message')]); - $result = EmbeddingInputNormalizer::normalize($message); + $result = PromptNormalizer::normalizeEmbeddingInput($message); $this->assertCount(1, $result); $this->assertSame($message, $result[0]); @@ -85,7 +87,7 @@ public function testNormalizeMessageArray(): void $message2 = new UserMessage([new MessagePart('Second message')]); $messages = [$message1, $message2]; - $result = EmbeddingInputNormalizer::normalize($messages); + $result = PromptNormalizer::normalizeEmbeddingInput($messages); $this->assertCount(2, $result); $this->assertSame($message1, $result[0]); @@ -101,7 +103,7 @@ public function testNormalizeMessagePartArray(): void $part2 = new MessagePart('Second part'); $parts = [$part1, $part2]; - $result = EmbeddingInputNormalizer::normalize($parts); + $result = PromptNormalizer::normalizeEmbeddingInput($parts); $this->assertCount(2, $result); $this->assertInstanceOf(UserMessage::class, $result[0]); @@ -120,7 +122,7 @@ public function testNormalizeMixedStringArrayThrowsException(): void $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Array element at index 1 must be a string, integer given'); - EmbeddingInputNormalizer::normalize($input); + PromptNormalizer::normalizeEmbeddingInput($input); } /** @@ -133,7 +135,7 @@ public function testNormalizeEmptyArrayThrowsException(): void $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Prompt array cannot be empty'); - EmbeddingInputNormalizer::normalize($input); + PromptNormalizer::normalizeEmbeddingInput($input); } /** @@ -144,7 +146,7 @@ public function testNormalizeInvalidInputThrowsException(): void $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid prompt format provided'); - EmbeddingInputNormalizer::normalize(123); + PromptNormalizer::normalizeEmbeddingInput(123); } /** @@ -152,10 +154,10 @@ public function testNormalizeInvalidInputThrowsException(): void */ public function testIsValidEmbeddingInputReturnsTrueForValidInputs(): void { - $this->assertTrue(EmbeddingInputNormalizer::isValidEmbeddingInput('Single string')); - $this->assertTrue(EmbeddingInputNormalizer::isValidEmbeddingInput(['String array', 'element'])); - $this->assertTrue(EmbeddingInputNormalizer::isValidEmbeddingInput(new MessagePart('Test'))); - $this->assertTrue(EmbeddingInputNormalizer::isValidEmbeddingInput( + $this->assertTrue(PromptNormalizer::isValidEmbeddingInput('Single string')); + $this->assertTrue(PromptNormalizer::isValidEmbeddingInput(['String array', 'element'])); + $this->assertTrue(PromptNormalizer::isValidEmbeddingInput(new MessagePart('Test'))); + $this->assertTrue(PromptNormalizer::isValidEmbeddingInput( new UserMessage([new MessagePart('Test')]) )); } @@ -165,10 +167,10 @@ public function testIsValidEmbeddingInputReturnsTrueForValidInputs(): void */ public function testIsValidEmbeddingInputReturnsFalseForInvalidInputs(): void { - $this->assertFalse(EmbeddingInputNormalizer::isValidEmbeddingInput(123)); - $this->assertFalse(EmbeddingInputNormalizer::isValidEmbeddingInput([])); - $this->assertFalse(EmbeddingInputNormalizer::isValidEmbeddingInput(['valid', 123])); - $this->assertFalse(EmbeddingInputNormalizer::isValidEmbeddingInput(new \stdClass())); + $this->assertFalse(PromptNormalizer::isValidEmbeddingInput(123)); + $this->assertFalse(PromptNormalizer::isValidEmbeddingInput([])); + $this->assertFalse(PromptNormalizer::isValidEmbeddingInput(['valid', 123])); + $this->assertFalse(PromptNormalizer::isValidEmbeddingInput(new \stdClass())); } /** @@ -176,15 +178,15 @@ public function testIsValidEmbeddingInputReturnsFalseForInvalidInputs(): void */ public function testGetInputCountReturnsCorrectCount(): void { - $this->assertEquals(1, EmbeddingInputNormalizer::getInputCount('Single string')); - $this->assertEquals(3, EmbeddingInputNormalizer::getInputCount(['One', 'Two', 'Three'])); - $this->assertEquals(1, EmbeddingInputNormalizer::getInputCount(new MessagePart('Test'))); + $this->assertEquals(1, PromptNormalizer::getEmbeddingInputCount('Single string')); + $this->assertEquals(3, PromptNormalizer::getEmbeddingInputCount(['One', 'Two', 'Three'])); + $this->assertEquals(1, PromptNormalizer::getEmbeddingInputCount(new MessagePart('Test'))); $messages = [ new UserMessage([new MessagePart('First')]), new UserMessage([new MessagePart('Second')]) ]; - $this->assertEquals(2, EmbeddingInputNormalizer::getInputCount($messages)); + $this->assertEquals(2, PromptNormalizer::getEmbeddingInputCount($messages)); } /** @@ -195,7 +197,7 @@ public function testGetInputCountThrowsExceptionForInvalidInput(): void $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid prompt format provided'); - EmbeddingInputNormalizer::getInputCount(123); + PromptNormalizer::getEmbeddingInputCount(123); } /** @@ -204,7 +206,7 @@ public function testGetInputCountThrowsExceptionForInvalidInput(): void public function testStringArrayNormalizationPreservesOrder(): void { $input = ['First', 'Second', 'Third', 'Fourth']; - $result = EmbeddingInputNormalizer::normalize($input); + $result = PromptNormalizer::normalizeEmbeddingInput($input); $this->assertCount(4, $result); $this->assertEquals('First', $result[0]->getParts()[0]->getText()); From b8db9ea49cd159daf3c51a22ffc7601c90292db9 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Mon, 18 Aug 2025 11:24:40 +0300 Subject: [PATCH 21/69] Refactor: organize the functionality --- src/AiClient.php | 145 +++++------- src/Utils/EmbeddingInputNormalizer.php | 116 ---------- src/Utils/GenerationStrategyResolver.php | 57 ----- src/Utils/InterfaceValidator.php | 103 +++++---- src/Utils/ModelDiscovery.php | 107 +++------ src/Utils/PromptNormalizer.php | 120 ++-------- .../Utils/EmbeddingInputNormalizerTest.php | 217 ------------------ .../Utils/GenerationStrategyResolverTest.php | 121 ---------- tests/unit/Utils/PromptNormalizerTest.php | 4 +- 9 files changed, 161 insertions(+), 829 deletions(-) delete mode 100644 src/Utils/EmbeddingInputNormalizer.php delete mode 100644 tests/unit/Utils/EmbeddingInputNormalizerTest.php diff --git a/src/AiClient.php b/src/AiClient.php index 488fc698..2a17303b 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -157,7 +157,7 @@ public static function message(?string $text = null) * * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. * @param ModelInterface|null $model Optional specific model to use. - * @param string $type The generation type (text, image, speech). + * @param string $type The generation type. * @return GenerativeAiResult The generation result. * * @throws \InvalidArgumentException If the prompt format is invalid. @@ -170,18 +170,29 @@ private static function executeGeneration($prompt, ?ModelInterface $model, strin /** @var list $messageList */ $messageList = array_values($messages); - // Get model - either provided or auto-discovered - $discoveryMethod = 'find' . ucfirst($type) . 'Model'; - $resolvedModel = $model ?? ModelDiscovery::$discoveryMethod(self::defaultRegistry()); - - // Validate model supports the generation type - $validationMethod = 'validate' . ucfirst($type) . 'Generation'; - InterfaceValidator::$validationMethod($resolvedModel); - - // Generate the result using the model - $generationMethod = 'generate' . ucfirst($type) . 'Result'; - /** @phpstan-ignore-next-line */ - return $resolvedModel->$generationMethod($messageList); + // Map type to specific methods + switch ($type) { + case 'text': + $resolvedModel = $model ?? ModelDiscovery::findTextModel(self::defaultRegistry()); + InterfaceValidator::validateTextGeneration($resolvedModel); + /** @phpstan-ignore-next-line */ + return $resolvedModel->generateTextResult($messageList); + + case 'image': + $resolvedModel = $model ?? ModelDiscovery::findImageModel(self::defaultRegistry()); + InterfaceValidator::validateImageGeneration($resolvedModel); + /** @phpstan-ignore-next-line */ + return $resolvedModel->generateImageResult($messageList); + + case 'speech': + $resolvedModel = $model ?? ModelDiscovery::findSpeechModel(self::defaultRegistry()); + InterfaceValidator::validateSpeechGeneration($resolvedModel); + /** @phpstan-ignore-next-line */ + return $resolvedModel->generateSpeechResult($messageList); + + default: + throw new \InvalidArgumentException("Unsupported generation type: {$type}"); + } } /** @@ -249,7 +260,7 @@ public static function generateImageResult($prompt, ModelInterface $model = null } /** - * Template method for text-to-speech conversion. + * Converts text to speech using the traditional API approach. * * @since n.e.x.t * @@ -260,7 +271,7 @@ public static function generateImageResult($prompt, ModelInterface $model = null * @throws \InvalidArgumentException If the prompt format is invalid. * @throws \RuntimeException If no suitable model is found. */ - private static function executeTextToSpeechGeneration($prompt, ?ModelInterface $model): GenerativeAiResult + public static function convertTextToSpeechResult($prompt, ModelInterface $model = null): GenerativeAiResult { // Convert prompt to standardized Message array format $messages = PromptNormalizer::normalize($prompt); @@ -278,23 +289,6 @@ private static function executeTextToSpeechGeneration($prompt, ?ModelInterface $ return $resolvedModel->convertTextToSpeechResult($messageList); } - /** - * Converts text to speech using the traditional API approach. - * - * @since n.e.x.t - * - * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. - * @param ModelInterface|null $model Optional specific model to use. - * @return GenerativeAiResult The generation result. - * - * @throws \InvalidArgumentException If the prompt format is invalid. - * @throws \RuntimeException If no suitable model is found. - */ - public static function convertTextToSpeechResult($prompt, ModelInterface $model = null): GenerativeAiResult - { - return self::executeTextToSpeechGeneration($prompt, $model); - } - /** * Generates speech using the traditional API approach. * @@ -317,7 +311,7 @@ public static function generateSpeechResult($prompt, ModelInterface $model = nul * * @since n.e.x.t * - * @param string[]|Message[] $input The input data to generate embeddings for. + * @param string[]|string|MessagePart|MessagePart[]|Message|Message[] $input The input data to generate embeddings for. * @param ModelInterface|null $model Optional specific model to use. * @return EmbeddingResult The generation result. * @@ -326,8 +320,8 @@ public static function generateSpeechResult($prompt, ModelInterface $model = nul */ public static function generateEmbeddingsResult($input, ModelInterface $model = null): EmbeddingResult { - // Normalize embedding input using consolidated normalizer - $messages = PromptNormalizer::normalizeEmbeddingInput($input); + // Normalize embedding input (supports string arrays) + $messages = PromptNormalizer::normalize($input); /** @var list $messageList */ $messageList = array_values($messages); @@ -342,41 +336,6 @@ public static function generateEmbeddingsResult($input, ModelInterface $model = return $resolvedModel->generateEmbeddingsResult($messageList); } - /** - * Template method for creating operations. - * - * @since n.e.x.t - * - * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. - * @param ModelInterface $model The model to use for the operation. - * @param string $type The operation type (text, image, textToSpeech, speech). - * @return GenerativeAiOperation|EmbeddingOperation The operation for async processing. - * - * @throws \InvalidArgumentException If the prompt format is invalid. - */ - private static function createOperation($prompt, ModelInterface $model, string $type): object - { - // Convert prompt to standardized Message array format - $messages = PromptNormalizer::normalize($prompt); - /** @var list $messageList */ - $messageList = array_values($messages); - - // Special handling for embedding operations - if ($type === 'embedding') { - InterfaceValidator::validateEmbeddingGenerationOperation($model); - /** @phpstan-ignore-next-line */ - return $model->generateEmbeddingsOperation($messageList); - } - - // Validate model supports the operation type - $validationMethod = 'validate' . ucfirst($type) . 'GenerationOperation'; - InterfaceValidator::$validationMethod($model); - - // Create operation using factory - $factoryMethod = 'create' . ucfirst($type) . 'Operation'; - /** @var GenerativeAiOperation */ - return OperationFactory::$factoryMethod($messageList); - } /** * Creates a generation operation for async processing. @@ -413,8 +372,12 @@ public static function generateOperation($prompt, ModelInterface $model): Genera */ public static function generateTextOperation($prompt, ModelInterface $model): GenerativeAiOperation { - /** @var GenerativeAiOperation */ - return self::createOperation($prompt, $model, 'text'); + $messages = PromptNormalizer::normalize($prompt); + /** @var list $messageList */ + $messageList = array_values($messages); + + InterfaceValidator::validateTextGenerationOperation($model); + return OperationFactory::createTextOperation($messageList); } /** @@ -430,8 +393,12 @@ public static function generateTextOperation($prompt, ModelInterface $model): Ge */ public static function generateImageOperation($prompt, ModelInterface $model): GenerativeAiOperation { - /** @var GenerativeAiOperation */ - return self::createOperation($prompt, $model, 'image'); + $messages = PromptNormalizer::normalize($prompt); + /** @var list $messageList */ + $messageList = array_values($messages); + + InterfaceValidator::validateImageGenerationOperation($model); + return OperationFactory::createImageOperation($messageList); } /** @@ -447,8 +414,12 @@ public static function generateImageOperation($prompt, ModelInterface $model): G */ public static function convertTextToSpeechOperation($prompt, ModelInterface $model): GenerativeAiOperation { - /** @var GenerativeAiOperation */ - return self::createOperation($prompt, $model, 'textToSpeech'); + $messages = PromptNormalizer::normalize($prompt); + /** @var list $messageList */ + $messageList = array_values($messages); + + InterfaceValidator::validateTextToSpeechConversionOperation($model); + return OperationFactory::createTextToSpeechOperation($messageList); } /** @@ -464,8 +435,12 @@ public static function convertTextToSpeechOperation($prompt, ModelInterface $mod */ public static function generateSpeechOperation($prompt, ModelInterface $model): GenerativeAiOperation { - /** @var GenerativeAiOperation */ - return self::createOperation($prompt, $model, 'speech'); + $messages = PromptNormalizer::normalize($prompt); + /** @var list $messageList */ + $messageList = array_values($messages); + + InterfaceValidator::validateSpeechGenerationOperation($model); + return OperationFactory::createSpeechOperation($messageList); } /** @@ -473,7 +448,7 @@ public static function generateSpeechOperation($prompt, ModelInterface $model): * * @since n.e.x.t * - * @param string[]|Message[] $input The input data to generate embeddings for. + * @param string[]|string|MessagePart|MessagePart[]|Message|Message[] $input The input data to generate embeddings for. * @param ModelInterface $model The model to use for embedding generation. * @return EmbeddingOperation The operation for async embedding processing. * @@ -481,10 +456,12 @@ public static function generateSpeechOperation($prompt, ModelInterface $model): */ public static function generateEmbeddingsOperation($input, ModelInterface $model): EmbeddingOperation { - // Normalize embedding input using consolidated normalizer - $messages = PromptNormalizer::normalizeEmbeddingInput($input); - - /** @var EmbeddingOperation */ - return self::createOperation($messages, $model, 'embedding'); + $messages = PromptNormalizer::normalize($input); + /** @var list $messageList */ + $messageList = array_values($messages); + + InterfaceValidator::validateEmbeddingGenerationOperation($model); + /** @phpstan-ignore-next-line */ + return $model->generateEmbeddingsOperation($messageList); } } diff --git a/src/Utils/EmbeddingInputNormalizer.php b/src/Utils/EmbeddingInputNormalizer.php deleted file mode 100644 index f94d3e1e..00000000 --- a/src/Utils/EmbeddingInputNormalizer.php +++ /dev/null @@ -1,116 +0,0 @@ - Array of Message objects. - * - * @throws \InvalidArgumentException If the input format is invalid. - */ - public static function normalize($input): array - { - // Handle string array input (most common for embeddings) - if (is_array($input) && !empty($input) && is_string($input[0])) { - /** @var string[] $stringArray */ - $stringArray = $input; - return self::normalizeStringArray($stringArray); - } - - // For all other formats, delegate to PromptNormalizer - /** @var string|MessagePart|MessagePart[]|Message|Message[] $input */ - return PromptNormalizer::normalize($input); - } - - /** - * Normalizes a string array into Message objects. - * - * Each string becomes a UserMessage with a single MessagePart. - * - * @since n.e.x.t - * - * @param string[] $stringArray Array of strings to normalize. - * @return list Array of Message objects. - * - * @throws \InvalidArgumentException If the array contains non-string elements. - */ - private static function normalizeStringArray(array $stringArray): array - { - // Validate all elements are strings - foreach ($stringArray as $index => $item) { - if (!is_string($item)) { - throw new \InvalidArgumentException( - sprintf('Array element at index %d must be a string, %s given', $index, gettype($item)) - ); - } - } - - // Convert each string to a UserMessage - $messages = array_map( - fn(string $text) => new UserMessage([new MessagePart($text)]), - $stringArray - ); - - return array_values($messages); - } - - /** - * Validates that input is suitable for embedding generation. - * - * @since n.e.x.t - * - * @param mixed $input The input to validate. - * @return bool True if the input is valid for embedding generation. - */ - public static function isValidEmbeddingInput($input): bool - { - try { - /** @phpstan-ignore-next-line */ - self::normalize($input); - return true; - } catch (\InvalidArgumentException $e) { - return false; - } - } - - /** - * Gets the number of input items that will be processed for embedding generation. - * - * Useful for understanding how many embeddings will be generated. - * - * @since n.e.x.t - * - * @param string[]|string|MessagePart|MessagePart[]|Message|Message[] $input The input data. - * @return int The number of items that will be processed. - * - * @throws \InvalidArgumentException If the input format is invalid. - */ - public static function getInputCount($input): int - { - $normalizedMessages = self::normalize($input); - return count($normalizedMessages); - } -} diff --git a/src/Utils/GenerationStrategyResolver.php b/src/Utils/GenerationStrategyResolver.php index 6e816002..750e83d8 100644 --- a/src/Utils/GenerationStrategyResolver.php +++ b/src/Utils/GenerationStrategyResolver.php @@ -55,61 +55,4 @@ public static function resolve(ModelInterface $model): string '(TextGeneration, ImageGeneration, TextToSpeechConversion, SpeechGeneration)' ); } - - /** - * Checks if a model supports any generation interface. - * - * @since n.e.x.t - * - * @param ModelInterface $model The model to check. - * @return bool True if the model supports at least one generation interface. - */ - public static function isSupported(ModelInterface $model): bool - { - foreach (self::GENERATION_STRATEGIES as $interface => $method) { - if ($model instanceof $interface) { - return true; - } - } - - return false; - } - - /** - * Gets all supported generation interfaces. - * - * @since n.e.x.t - * - * @return array Array of interface => method mappings. - */ - public static function getSupportedInterfaces(): array - { - return self::GENERATION_STRATEGIES; - } - - /** - * Gets the generation method name for a specific interface. - * - * @since n.e.x.t - * - * @param string $interfaceClass The interface class name. - * @return string|null The method name, or null if interface is not supported. - */ - public static function getMethodForInterface(string $interfaceClass): ?string - { - return self::GENERATION_STRATEGIES[$interfaceClass] ?? null; - } - - /** - * Checks if a specific interface is supported for generation. - * - * @since n.e.x.t - * - * @param string $interfaceClass The interface class name to check. - * @return bool True if the interface is supported. - */ - public static function isInterfaceSupported(string $interfaceClass): bool - { - return isset(self::GENERATION_STRATEGIES[$interfaceClass]); - } } diff --git a/src/Utils/InterfaceValidator.php b/src/Utils/InterfaceValidator.php index 1f1cd35a..dc713a0a 100644 --- a/src/Utils/InterfaceValidator.php +++ b/src/Utils/InterfaceValidator.php @@ -25,61 +25,23 @@ class InterfaceValidator { /** - * Validation configuration mapping generation types to their interfaces. - * - * @var array - */ - private const VALIDATION_CONFIG = [ - 'textGeneration' => [TextGenerationModelInterface::class, 'TextGenerationModelInterface', 'text generation'], - 'imageGeneration' => [ImageGenerationModelInterface::class, 'ImageGenerationModelInterface', 'image generation'], - 'textToSpeechConversion' => [TextToSpeechConversionModelInterface::class, 'TextToSpeechConversionModelInterface', 'text-to-speech conversion'], - 'speechGeneration' => [SpeechGenerationModelInterface::class, 'SpeechGenerationModelInterface', 'speech generation'], - 'embeddingGeneration' => [EmbeddingGenerationModelInterface::class, 'EmbeddingGenerationModelInterface', 'embedding generation'], - 'textGenerationOperation' => [TextGenerationModelInterface::class, 'TextGenerationModelInterface', 'text generation operations'], - 'imageGenerationOperation' => [ImageGenerationModelInterface::class, 'ImageGenerationModelInterface', 'image generation operations'], - 'textToSpeechConversionOperation' => [TextToSpeechConversionOperationModelInterface::class, 'TextToSpeechConversionOperationModelInterface', 'text-to-speech conversion operations'], - 'speechGenerationOperation' => [SpeechGenerationOperationModelInterface::class, 'SpeechGenerationOperationModelInterface', 'speech generation operations'], - 'embeddingGenerationOperation' => [EmbeddingGenerationOperationModelInterface::class, 'EmbeddingGenerationOperationModelInterface', 'embedding generation operations'], - ]; - - /** - * Generic interface validation method. + * Validates that a model implements TextGenerationModelInterface. * * @since n.e.x.t * * @param ModelInterface $model The model to validate. - * @param string $type The validation type from VALIDATION_CONFIG. * @return void * * @throws \InvalidArgumentException If the model doesn't implement the required interface. */ - private static function validateInterface(ModelInterface $model, string $type): void + public static function validateTextGeneration(ModelInterface $model): void { - if (!isset(self::VALIDATION_CONFIG[$type])) { - throw new \InvalidArgumentException("Unknown validation type: {$type}"); - } - - [$interface, $interfaceName, $description] = self::VALIDATION_CONFIG[$type]; - if (!$model instanceof $interface) { + if (!$model instanceof TextGenerationModelInterface) { throw new \InvalidArgumentException( - "Model must implement {$interfaceName} for {$description}" + 'Model must implement TextGenerationModelInterface for text generation' ); } } - /** - * Validates that a model implements TextGenerationModelInterface. - * - * @since n.e.x.t - * - * @param ModelInterface $model The model to validate. - * @return void - * - * @throws \InvalidArgumentException If the model doesn't implement the required interface. - */ - public static function validateTextGeneration(ModelInterface $model): void - { - self::validateInterface($model, 'textGeneration'); - } /** * Validates that a model implements ImageGenerationModelInterface. @@ -93,7 +55,11 @@ public static function validateTextGeneration(ModelInterface $model): void */ public static function validateImageGeneration(ModelInterface $model): void { - self::validateInterface($model, 'imageGeneration'); + if (!$model instanceof ImageGenerationModelInterface) { + throw new \InvalidArgumentException( + 'Model must implement ImageGenerationModelInterface for image generation' + ); + } } /** @@ -108,7 +74,11 @@ public static function validateImageGeneration(ModelInterface $model): void */ public static function validateTextToSpeechConversion(ModelInterface $model): void { - self::validateInterface($model, 'textToSpeechConversion'); + if (!$model instanceof TextToSpeechConversionModelInterface) { + throw new \InvalidArgumentException( + 'Model must implement TextToSpeechConversionModelInterface for text-to-speech conversion' + ); + } } /** @@ -123,7 +93,11 @@ public static function validateTextToSpeechConversion(ModelInterface $model): vo */ public static function validateSpeechGeneration(ModelInterface $model): void { - self::validateInterface($model, 'speechGeneration'); + if (!$model instanceof SpeechGenerationModelInterface) { + throw new \InvalidArgumentException( + 'Model must implement SpeechGenerationModelInterface for speech generation' + ); + } } /** @@ -138,7 +112,11 @@ public static function validateSpeechGeneration(ModelInterface $model): void */ public static function validateEmbeddingGeneration(ModelInterface $model): void { - self::validateInterface($model, 'embeddingGeneration'); + if (!$model instanceof EmbeddingGenerationModelInterface) { + throw new \InvalidArgumentException( + 'Model must implement EmbeddingGenerationModelInterface for embedding generation' + ); + } } /** @@ -153,7 +131,11 @@ public static function validateEmbeddingGeneration(ModelInterface $model): void */ public static function validateTextGenerationOperation(ModelInterface $model): void { - self::validateInterface($model, 'textGenerationOperation'); + if (!$model instanceof TextGenerationModelInterface) { + throw new \InvalidArgumentException( + 'Model must implement TextGenerationModelInterface for text generation operations' + ); + } } /** @@ -168,7 +150,11 @@ public static function validateTextGenerationOperation(ModelInterface $model): v */ public static function validateImageGenerationOperation(ModelInterface $model): void { - self::validateInterface($model, 'imageGenerationOperation'); + if (!$model instanceof ImageGenerationModelInterface) { + throw new \InvalidArgumentException( + 'Model must implement ImageGenerationModelInterface for image generation operations' + ); + } } /** @@ -183,7 +169,12 @@ public static function validateImageGenerationOperation(ModelInterface $model): */ public static function validateTextToSpeechConversionOperation(ModelInterface $model): void { - self::validateInterface($model, 'textToSpeechConversionOperation'); + if (!$model instanceof TextToSpeechConversionOperationModelInterface) { + throw new \InvalidArgumentException( + 'Model must implement TextToSpeechConversionOperationModelInterface ' . + 'for text-to-speech conversion operations' + ); + } } /** @@ -198,7 +189,12 @@ public static function validateTextToSpeechConversionOperation(ModelInterface $m */ public static function validateSpeechGenerationOperation(ModelInterface $model): void { - self::validateInterface($model, 'speechGenerationOperation'); + if (!$model instanceof SpeechGenerationOperationModelInterface) { + throw new \InvalidArgumentException( + 'Model must implement SpeechGenerationOperationModelInterface ' . + 'for speech generation operations' + ); + } } /** @@ -213,6 +209,11 @@ public static function validateSpeechGenerationOperation(ModelInterface $model): */ public static function validateEmbeddingGenerationOperation(ModelInterface $model): void { - self::validateInterface($model, 'embeddingGenerationOperation'); + if (!$model instanceof EmbeddingGenerationOperationModelInterface) { + throw new \InvalidArgumentException( + 'Model must implement EmbeddingGenerationOperationModelInterface ' . + 'for embedding generation operations' + ); + } } } diff --git a/src/Utils/ModelDiscovery.php b/src/Utils/ModelDiscovery.php index c458c8a7..11128351 100644 --- a/src/Utils/ModelDiscovery.php +++ b/src/Utils/ModelDiscovery.php @@ -17,22 +17,24 @@ class ModelDiscovery { /** - * Finds a suitable text generation model from the registry. + * Generic method to find a model by capability. * * @since n.e.x.t * * @param ProviderRegistry $registry The provider registry to search. - * @return ModelInterface A suitable text generation model. + * @param CapabilityEnum $capability The required capability. + * @param string $errorType The error description type. + * @return ModelInterface A suitable model. * * @throws \RuntimeException If no suitable model is found. */ - public static function findTextModel(ProviderRegistry $registry): ModelInterface + private static function findModelByCapability(ProviderRegistry $registry, CapabilityEnum $capability, string $errorType): ModelInterface { - $requirements = new ModelRequirements([CapabilityEnum::textGeneration()], []); + $requirements = new ModelRequirements([$capability], []); $providerModelsMetadata = $registry->findModelsMetadataForSupport($requirements); if (empty($providerModelsMetadata)) { - throw new \RuntimeException('No text generation models available'); + throw new \RuntimeException("No {$errorType} models available"); } // Get the first suitable provider and model @@ -49,6 +51,21 @@ public static function findTextModel(ProviderRegistry $registry): ModelInterface ); } + /** + * Finds a suitable text generation model from the registry. + * + * @since n.e.x.t + * + * @param ProviderRegistry $registry The provider registry to search. + * @return ModelInterface A suitable text generation model. + * + * @throws \RuntimeException If no suitable model is found. + */ + public static function findTextModel(ProviderRegistry $registry): ModelInterface + { + return self::findModelByCapability($registry, CapabilityEnum::textGeneration(), 'text generation'); + } + /** * Finds a suitable image generation model from the registry. * @@ -61,25 +78,7 @@ public static function findTextModel(ProviderRegistry $registry): ModelInterface */ public static function findImageModel(ProviderRegistry $registry): ModelInterface { - $requirements = new ModelRequirements([CapabilityEnum::imageGeneration()], []); - $providerModelsMetadata = $registry->findModelsMetadataForSupport($requirements); - - if (empty($providerModelsMetadata)) { - throw new \RuntimeException('No image generation models available'); - } - - // Get the first suitable provider and model - $providerMetadata = $providerModelsMetadata[0]; - $models = $providerMetadata->getModels(); - - if (empty($models)) { - throw new \RuntimeException('No models available in provider'); - } - - return $registry->getProviderModel( - $providerMetadata->getProvider()->getId(), - $models[0]->getId() - ); + return self::findModelByCapability($registry, CapabilityEnum::imageGeneration(), 'image generation'); } /** @@ -94,25 +93,7 @@ public static function findImageModel(ProviderRegistry $registry): ModelInterfac */ public static function findTextToSpeechModel(ProviderRegistry $registry): ModelInterface { - $requirements = new ModelRequirements([CapabilityEnum::textToSpeechConversion()], []); - $providerModelsMetadata = $registry->findModelsMetadataForSupport($requirements); - - if (empty($providerModelsMetadata)) { - throw new \RuntimeException('No text-to-speech conversion models available'); - } - - // Get the first suitable provider and model - $providerMetadata = $providerModelsMetadata[0]; - $models = $providerMetadata->getModels(); - - if (empty($models)) { - throw new \RuntimeException('No models available in provider'); - } - - return $registry->getProviderModel( - $providerMetadata->getProvider()->getId(), - $models[0]->getId() - ); + return self::findModelByCapability($registry, CapabilityEnum::textToSpeechConversion(), 'text-to-speech conversion'); } /** @@ -127,25 +108,7 @@ public static function findTextToSpeechModel(ProviderRegistry $registry): ModelI */ public static function findSpeechModel(ProviderRegistry $registry): ModelInterface { - $requirements = new ModelRequirements([CapabilityEnum::speechGeneration()], []); - $providerModelsMetadata = $registry->findModelsMetadataForSupport($requirements); - - if (empty($providerModelsMetadata)) { - throw new \RuntimeException('No speech generation models available'); - } - - // Get the first suitable provider and model - $providerMetadata = $providerModelsMetadata[0]; - $models = $providerMetadata->getModels(); - - if (empty($models)) { - throw new \RuntimeException('No models available in provider'); - } - - return $registry->getProviderModel( - $providerMetadata->getProvider()->getId(), - $models[0]->getId() - ); + return self::findModelByCapability($registry, CapabilityEnum::speechGeneration(), 'speech generation'); } /** @@ -160,24 +123,6 @@ public static function findSpeechModel(ProviderRegistry $registry): ModelInterfa */ public static function findEmbeddingModel(ProviderRegistry $registry): ModelInterface { - $requirements = new ModelRequirements([CapabilityEnum::embeddingGeneration()], []); - $providerModelsMetadata = $registry->findModelsMetadataForSupport($requirements); - - if (empty($providerModelsMetadata)) { - throw new \RuntimeException('No embedding generation models available'); - } - - // Get the first suitable provider and model - $providerMetadata = $providerModelsMetadata[0]; - $models = $providerMetadata->getModels(); - - if (empty($models)) { - throw new \RuntimeException('No models available in provider'); - } - - return $registry->getProviderModel( - $providerMetadata->getProvider()->getId(), - $models[0]->getId() - ); + return self::findModelByCapability($registry, CapabilityEnum::embeddingGeneration(), 'embedding generation'); } } diff --git a/src/Utils/PromptNormalizer.php b/src/Utils/PromptNormalizer.php index 1452071c..cd1b2e5d 100644 --- a/src/Utils/PromptNormalizer.php +++ b/src/Utils/PromptNormalizer.php @@ -20,7 +20,7 @@ class PromptNormalizer * * @since n.e.x.t * - * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content in various formats. + * @param string|string[]|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content in various formats. * @return list Array of Message objects. * * @throws \InvalidArgumentException If the prompt format is invalid. @@ -57,7 +57,7 @@ public static function normalize($prompt): array // Validate all elements are Messages foreach ($prompt as $item) { if (!$item instanceof Message) { - throw new \InvalidArgumentException('Array must contain only Message or MessagePart objects'); + throw new \InvalidArgumentException('Array must contain only Message, MessagePart, or string objects'); } } /** @var Message[] $messages */ @@ -70,7 +70,7 @@ public static function normalize($prompt): array // Validate all elements are MessageParts foreach ($prompt as $item) { if (!$item instanceof MessagePart) { - throw new \InvalidArgumentException('Array must contain only Message or MessagePart objects'); + throw new \InvalidArgumentException('Array must contain only Message, MessagePart, or string objects'); } } // Convert each MessagePart to a UserMessage @@ -79,107 +79,27 @@ public static function normalize($prompt): array return array_values(array_map(fn(MessagePart $part) => new UserMessage([$part]), $messageParts)); } + // Array of strings (common for embeddings) + if (is_string($firstElement)) { + // Validate all elements are strings + foreach ($prompt as $index => $item) { + if (!is_string($item)) { + throw new \InvalidArgumentException( + sprintf('Array element at index %d must be a string, %s given', $index, gettype($item)) + ); + } + } + // Convert each string to a UserMessage + /** @var string[] $stringArray */ + $stringArray = $prompt; + return array_values(array_map(fn(string $text) => new UserMessage([new MessagePart($text)]), $stringArray)); + } + // Invalid array content - throw new \InvalidArgumentException('Array must contain only Message or MessagePart objects'); + throw new \InvalidArgumentException('Array must contain only Message, MessagePart, or string objects'); } // Unsupported type throw new \InvalidArgumentException('Invalid prompt format provided'); } - - /** - * Normalizes embedding input into a standardized Message array. - * - * Handles both string arrays (common for embeddings) and other - * message formats that can be processed by the main normalize method. - * - * @since n.e.x.t - * - * @param string[]|string|MessagePart|MessagePart[]|Message|Message[] $input The input data in various formats. - * @return list Array of Message objects. - * - * @throws \InvalidArgumentException If the input format is invalid. - */ - public static function normalizeEmbeddingInput($input): array - { - // Handle string array input (most common for embeddings) - if (is_array($input) && !empty($input) && is_string($input[0])) { - /** @var string[] $stringArray */ - $stringArray = $input; - return self::normalizeStringArray($stringArray); - } - - // For all other formats, use the main normalize method - /** @var string|MessagePart|MessagePart[]|Message|Message[] $input */ - return self::normalize($input); - } - - /** - * Normalizes a string array into Message objects. - * - * Each string becomes a UserMessage with a single MessagePart. - * - * @since n.e.x.t - * - * @param string[] $stringArray Array of strings to normalize. - * @return list Array of Message objects. - * - * @throws \InvalidArgumentException If the array contains non-string elements. - */ - private static function normalizeStringArray(array $stringArray): array - { - // Validate all elements are strings - foreach ($stringArray as $index => $item) { - if (!is_string($item)) { - throw new \InvalidArgumentException( - sprintf('Array element at index %d must be a string, %s given', $index, gettype($item)) - ); - } - } - - // Convert each string to a UserMessage - $messages = array_map( - fn(string $text) => new UserMessage([new MessagePart($text)]), - $stringArray - ); - - return array_values($messages); - } - - /** - * Validates that input is suitable for embedding generation. - * - * @since n.e.x.t - * - * @param mixed $input The input to validate. - * @return bool True if the input is valid for embedding generation. - */ - public static function isValidEmbeddingInput($input): bool - { - try { - /** @phpstan-ignore-next-line */ - self::normalizeEmbeddingInput($input); - return true; - } catch (\InvalidArgumentException $e) { - return false; - } - } - - /** - * Gets the number of input items that will be processed for embedding generation. - * - * Useful for understanding how many embeddings will be generated. - * - * @since n.e.x.t - * - * @param string[]|string|MessagePart|MessagePart[]|Message|Message[] $input The input data. - * @return int The number of items that will be processed. - * - * @throws \InvalidArgumentException If the input format is invalid. - */ - public static function getEmbeddingInputCount($input): int - { - $normalizedMessages = self::normalizeEmbeddingInput($input); - return count($normalizedMessages); - } } diff --git a/tests/unit/Utils/EmbeddingInputNormalizerTest.php b/tests/unit/Utils/EmbeddingInputNormalizerTest.php deleted file mode 100644 index 3d36ded9..00000000 --- a/tests/unit/Utils/EmbeddingInputNormalizerTest.php +++ /dev/null @@ -1,217 +0,0 @@ -assertCount(3, $result); - - // Check first message - $this->assertInstanceOf(UserMessage::class, $result[0]); - $this->assertCount(1, $result[0]->getParts()); - $this->assertEquals('First text', $result[0]->getParts()[0]->getText()); - - // Check second message - $this->assertInstanceOf(UserMessage::class, $result[1]); - $this->assertEquals('Second text', $result[1]->getParts()[0]->getText()); - - // Check third message - $this->assertInstanceOf(UserMessage::class, $result[2]); - $this->assertEquals('Third text', $result[2]->getParts()[0]->getText()); - } - - /** - * Tests normalizing single string input. - */ - public function testNormalizeSingleString(): void - { - $input = 'Single text input'; - $result = PromptNormalizer::normalizeEmbeddingInput($input); - - $this->assertCount(1, $result); - $this->assertInstanceOf(UserMessage::class, $result[0]); - $this->assertEquals('Single text input', $result[0]->getParts()[0]->getText()); - } - - /** - * Tests normalizing MessagePart input. - */ - public function testNormalizeMessagePart(): void - { - $messagePart = new MessagePart('Test message part'); - $result = PromptNormalizer::normalizeEmbeddingInput($messagePart); - - $this->assertCount(1, $result); - $this->assertInstanceOf(UserMessage::class, $result[0]); - $this->assertSame($messagePart, $result[0]->getParts()[0]); - } - - /** - * Tests normalizing single Message input. - */ - public function testNormalizeSingleMessage(): void - { - $message = new UserMessage([new MessagePart('Test message')]); - $result = PromptNormalizer::normalizeEmbeddingInput($message); - - $this->assertCount(1, $result); - $this->assertSame($message, $result[0]); - } - - /** - * Tests normalizing array of Messages. - */ - public function testNormalizeMessageArray(): void - { - $message1 = new UserMessage([new MessagePart('First message')]); - $message2 = new UserMessage([new MessagePart('Second message')]); - $messages = [$message1, $message2]; - - $result = PromptNormalizer::normalizeEmbeddingInput($messages); - - $this->assertCount(2, $result); - $this->assertSame($message1, $result[0]); - $this->assertSame($message2, $result[1]); - } - - /** - * Tests normalizing array of MessageParts. - */ - public function testNormalizeMessagePartArray(): void - { - $part1 = new MessagePart('First part'); - $part2 = new MessagePart('Second part'); - $parts = [$part1, $part2]; - - $result = PromptNormalizer::normalizeEmbeddingInput($parts); - - $this->assertCount(2, $result); - $this->assertInstanceOf(UserMessage::class, $result[0]); - $this->assertInstanceOf(UserMessage::class, $result[1]); - $this->assertSame($part1, $result[0]->getParts()[0]); - $this->assertSame($part2, $result[1]->getParts()[0]); - } - - /** - * Tests mixed array with non-string elements throws exception. - */ - public function testNormalizeMixedStringArrayThrowsException(): void - { - $input = ['Valid string', 123, 'Another string']; - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Array element at index 1 must be a string, integer given'); - - PromptNormalizer::normalizeEmbeddingInput($input); - } - - /** - * Tests empty string array throws exception. - */ - public function testNormalizeEmptyArrayThrowsException(): void - { - $input = []; - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Prompt array cannot be empty'); - - PromptNormalizer::normalizeEmbeddingInput($input); - } - - /** - * Tests invalid input type throws exception. - */ - public function testNormalizeInvalidInputThrowsException(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid prompt format provided'); - - PromptNormalizer::normalizeEmbeddingInput(123); - } - - /** - * Tests isValidEmbeddingInput returns true for valid inputs. - */ - public function testIsValidEmbeddingInputReturnsTrueForValidInputs(): void - { - $this->assertTrue(PromptNormalizer::isValidEmbeddingInput('Single string')); - $this->assertTrue(PromptNormalizer::isValidEmbeddingInput(['String array', 'element'])); - $this->assertTrue(PromptNormalizer::isValidEmbeddingInput(new MessagePart('Test'))); - $this->assertTrue(PromptNormalizer::isValidEmbeddingInput( - new UserMessage([new MessagePart('Test')]) - )); - } - - /** - * Tests isValidEmbeddingInput returns false for invalid inputs. - */ - public function testIsValidEmbeddingInputReturnsFalseForInvalidInputs(): void - { - $this->assertFalse(PromptNormalizer::isValidEmbeddingInput(123)); - $this->assertFalse(PromptNormalizer::isValidEmbeddingInput([])); - $this->assertFalse(PromptNormalizer::isValidEmbeddingInput(['valid', 123])); - $this->assertFalse(PromptNormalizer::isValidEmbeddingInput(new \stdClass())); - } - - /** - * Tests getInputCount returns correct count for various inputs. - */ - public function testGetInputCountReturnsCorrectCount(): void - { - $this->assertEquals(1, PromptNormalizer::getEmbeddingInputCount('Single string')); - $this->assertEquals(3, PromptNormalizer::getEmbeddingInputCount(['One', 'Two', 'Three'])); - $this->assertEquals(1, PromptNormalizer::getEmbeddingInputCount(new MessagePart('Test'))); - - $messages = [ - new UserMessage([new MessagePart('First')]), - new UserMessage([new MessagePart('Second')]) - ]; - $this->assertEquals(2, PromptNormalizer::getEmbeddingInputCount($messages)); - } - - /** - * Tests getInputCount throws exception for invalid input. - */ - public function testGetInputCountThrowsExceptionForInvalidInput(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid prompt format provided'); - - PromptNormalizer::getEmbeddingInputCount(123); - } - - /** - * Tests that string array normalization preserves order. - */ - public function testStringArrayNormalizationPreservesOrder(): void - { - $input = ['First', 'Second', 'Third', 'Fourth']; - $result = PromptNormalizer::normalizeEmbeddingInput($input); - - $this->assertCount(4, $result); - $this->assertEquals('First', $result[0]->getParts()[0]->getText()); - $this->assertEquals('Second', $result[1]->getParts()[0]->getText()); - $this->assertEquals('Third', $result[2]->getParts()[0]->getText()); - $this->assertEquals('Fourth', $result[3]->getParts()[0]->getText()); - } -} diff --git a/tests/unit/Utils/GenerationStrategyResolverTest.php b/tests/unit/Utils/GenerationStrategyResolverTest.php index 876fa733..0d762d4d 100644 --- a/tests/unit/Utils/GenerationStrategyResolverTest.php +++ b/tests/unit/Utils/GenerationStrategyResolverTest.php @@ -59,131 +59,10 @@ public function testResolveThrowsExceptionForUnsupportedModel(): void GenerationStrategyResolver::resolve($model); } - /** - * Tests isSupported returns true for supported models. - */ - public function testIsSupportedReturnsTrueForSupportedModels(): void - { - $textModel = $this->createMock(MockTextGenerationModel::class); - $imageModel = $this->createMock(MockImageGenerationModel::class); - - $this->assertTrue(GenerationStrategyResolver::isSupported($textModel)); - $this->assertTrue(GenerationStrategyResolver::isSupported($imageModel)); - } - - /** - * Tests isSupported returns false for unsupported models. - */ - public function testIsSupportedReturnsFalseForUnsupportedModels(): void - { - $model = $this->createMock(ModelInterface::class); - - $this->assertFalse(GenerationStrategyResolver::isSupported($model)); - } - - /** - * Tests getSupportedInterfaces returns all supported interfaces. - */ - public function testGetSupportedInterfacesReturnsAllInterfaces(): void - { - $interfaces = GenerationStrategyResolver::getSupportedInterfaces(); - $expected = [ - TextGenerationModelInterface::class => 'generateTextResult', - ImageGenerationModelInterface::class => 'generateImageResult', - TextToSpeechConversionModelInterface::class => 'convertTextToSpeechResult', - SpeechGenerationModelInterface::class => 'generateSpeechResult', - ]; - $this->assertEquals($expected, $interfaces); - $this->assertCount(4, $interfaces); - } - /** - * Tests getMethodForInterface returns correct method for known interface. - */ - public function testGetMethodForInterfaceReturnsCorrectMethod(): void - { - $method = GenerationStrategyResolver::getMethodForInterface( - TextGenerationModelInterface::class - ); - $this->assertEquals('generateTextResult', $method); - } - /** - * Tests getMethodForInterface returns null for unknown interface. - */ - public function testGetMethodForInterfaceReturnsNullForUnknownInterface(): void - { - $method = GenerationStrategyResolver::getMethodForInterface('UnknownInterface'); - $this->assertNull($method); - } - - /** - * Tests isInterfaceSupported returns true for supported interfaces. - */ - public function testIsInterfaceSupportedReturnsTrueForSupportedInterfaces(): void - { - $this->assertTrue(GenerationStrategyResolver::isInterfaceSupported( - TextGenerationModelInterface::class - )); - $this->assertTrue(GenerationStrategyResolver::isInterfaceSupported( - ImageGenerationModelInterface::class - )); - $this->assertTrue(GenerationStrategyResolver::isInterfaceSupported( - TextToSpeechConversionModelInterface::class - )); - $this->assertTrue(GenerationStrategyResolver::isInterfaceSupported( - SpeechGenerationModelInterface::class - )); - } - - /** - * Tests isInterfaceSupported returns false for unsupported interfaces. - */ - public function testIsInterfaceSupportedReturnsFalseForUnsupportedInterfaces(): void - { - $this->assertFalse(GenerationStrategyResolver::isInterfaceSupported('UnknownInterface')); - $this->assertFalse(GenerationStrategyResolver::isInterfaceSupported(ModelInterface::class)); - } - - /** - * Tests that resolve prioritizes text generation when model implements multiple interfaces. - */ - public function testResolvePrioritizesTextGeneration(): void - { - // Create a mock that implements both text and image generation - $model = $this->getMockBuilder(MockTextGenerationModel::class) - ->addMethods([]) - ->getMock(); - - // Should return text generation method (first in the strategy order) - $method = GenerationStrategyResolver::resolve($model); - $this->assertEquals('generateTextResult', $method); - } - - /** - * Tests that all expected interfaces are covered by the resolver. - */ - public function testAllExpectedInterfacesAreCovered(): void - { - $supportedInterfaces = GenerationStrategyResolver::getSupportedInterfaces(); - - // Verify all expected interfaces are present - $this->assertArrayHasKey(TextGenerationModelInterface::class, $supportedInterfaces); - $this->assertArrayHasKey(ImageGenerationModelInterface::class, $supportedInterfaces); - $this->assertArrayHasKey(TextToSpeechConversionModelInterface::class, $supportedInterfaces); - $this->assertArrayHasKey(SpeechGenerationModelInterface::class, $supportedInterfaces); - - // Verify methods are correctly mapped - $this->assertEquals('generateTextResult', $supportedInterfaces[TextGenerationModelInterface::class]); - $this->assertEquals('generateImageResult', $supportedInterfaces[ImageGenerationModelInterface::class]); - $this->assertEquals( - 'convertTextToSpeechResult', - $supportedInterfaces[TextToSpeechConversionModelInterface::class] - ); - $this->assertEquals('generateSpeechResult', $supportedInterfaces[SpeechGenerationModelInterface::class]); - } } diff --git a/tests/unit/Utils/PromptNormalizerTest.php b/tests/unit/Utils/PromptNormalizerTest.php index e13167ab..aa06b08c 100644 --- a/tests/unit/Utils/PromptNormalizerTest.php +++ b/tests/unit/Utils/PromptNormalizerTest.php @@ -109,7 +109,7 @@ public function testNormalizeMixedArrayThrowsException(): void $invalidArray = [$part, 'string', 123]; $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Array must contain only Message or MessagePart objects'); + $this->expectExceptionMessage('Array must contain only Message, MessagePart, or string objects'); PromptNormalizer::normalize($invalidArray); } @@ -133,7 +133,7 @@ public function testNormalizeArrayWithInvalidObjectsThrowsException(): void $invalidArray = [new \stdClass(), new \DateTime()]; $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Array must contain only Message or MessagePart objects'); + $this->expectExceptionMessage('Array must contain only Message, MessagePart, or string objects'); PromptNormalizer::normalize($invalidArray); } From 2c8c79d56d747ef8553b4fded392f5fda3fbacce Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Mon, 18 Aug 2025 11:38:19 +0300 Subject: [PATCH 22/69] Fix code style violations --- src/AiClient.php | 6 ++++-- src/Utils/ModelDiscovery.php | 13 ++++++++++--- src/Utils/PromptNormalizer.php | 13 ++++++++++--- tests/unit/Utils/GenerationStrategyResolverTest.php | 11 ----------- 4 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index 2a17303b..70439337 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -311,7 +311,8 @@ public static function generateSpeechResult($prompt, ModelInterface $model = nul * * @since n.e.x.t * - * @param string[]|string|MessagePart|MessagePart[]|Message|Message[] $input The input data to generate embeddings for. + * @param string[]|string|MessagePart|MessagePart[]|Message|Message[] $input + * The input data to generate embeddings for. * @param ModelInterface|null $model Optional specific model to use. * @return EmbeddingResult The generation result. * @@ -448,7 +449,8 @@ public static function generateSpeechOperation($prompt, ModelInterface $model): * * @since n.e.x.t * - * @param string[]|string|MessagePart|MessagePart[]|Message|Message[] $input The input data to generate embeddings for. + * @param string[]|string|MessagePart|MessagePart[]|Message|Message[] $input + * The input data to generate embeddings for. * @param ModelInterface $model The model to use for embedding generation. * @return EmbeddingOperation The operation for async embedding processing. * diff --git a/src/Utils/ModelDiscovery.php b/src/Utils/ModelDiscovery.php index 11128351..86751d80 100644 --- a/src/Utils/ModelDiscovery.php +++ b/src/Utils/ModelDiscovery.php @@ -28,8 +28,11 @@ class ModelDiscovery * * @throws \RuntimeException If no suitable model is found. */ - private static function findModelByCapability(ProviderRegistry $registry, CapabilityEnum $capability, string $errorType): ModelInterface - { + private static function findModelByCapability( + ProviderRegistry $registry, + CapabilityEnum $capability, + string $errorType + ): ModelInterface { $requirements = new ModelRequirements([$capability], []); $providerModelsMetadata = $registry->findModelsMetadataForSupport($requirements); @@ -93,7 +96,11 @@ public static function findImageModel(ProviderRegistry $registry): ModelInterfac */ public static function findTextToSpeechModel(ProviderRegistry $registry): ModelInterface { - return self::findModelByCapability($registry, CapabilityEnum::textToSpeechConversion(), 'text-to-speech conversion'); + return self::findModelByCapability( + $registry, + CapabilityEnum::textToSpeechConversion(), + 'text-to-speech conversion' + ); } /** diff --git a/src/Utils/PromptNormalizer.php b/src/Utils/PromptNormalizer.php index cd1b2e5d..d02e1e3a 100644 --- a/src/Utils/PromptNormalizer.php +++ b/src/Utils/PromptNormalizer.php @@ -57,7 +57,9 @@ public static function normalize($prompt): array // Validate all elements are Messages foreach ($prompt as $item) { if (!$item instanceof Message) { - throw new \InvalidArgumentException('Array must contain only Message, MessagePart, or string objects'); + throw new \InvalidArgumentException( + 'Array must contain only Message, MessagePart, or string objects' + ); } } /** @var Message[] $messages */ @@ -70,7 +72,9 @@ public static function normalize($prompt): array // Validate all elements are MessageParts foreach ($prompt as $item) { if (!$item instanceof MessagePart) { - throw new \InvalidArgumentException('Array must contain only Message, MessagePart, or string objects'); + throw new \InvalidArgumentException( + 'Array must contain only Message, MessagePart, or string objects' + ); } } // Convert each MessagePart to a UserMessage @@ -92,7 +96,10 @@ public static function normalize($prompt): array // Convert each string to a UserMessage /** @var string[] $stringArray */ $stringArray = $prompt; - return array_values(array_map(fn(string $text) => new UserMessage([new MessagePart($text)]), $stringArray)); + return array_values(array_map( + fn(string $text) => new UserMessage([new MessagePart($text)]), + $stringArray + )); } // Invalid array content diff --git a/tests/unit/Utils/GenerationStrategyResolverTest.php b/tests/unit/Utils/GenerationStrategyResolverTest.php index 0d762d4d..1aac8470 100644 --- a/tests/unit/Utils/GenerationStrategyResolverTest.php +++ b/tests/unit/Utils/GenerationStrategyResolverTest.php @@ -6,10 +6,6 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; -use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; -use WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts\SpeechGenerationModelInterface; -use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; -use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionModelInterface; use WordPress\AiClient\Tests\mocks\MockImageGenerationModel; use WordPress\AiClient\Tests\mocks\MockTextGenerationModel; use WordPress\AiClient\Utils\GenerationStrategyResolver; @@ -58,11 +54,4 @@ public function testResolveThrowsExceptionForUnsupportedModel(): void GenerationStrategyResolver::resolve($model); } - - - - - - - } From b6a8722a876d9f9801d47c98e0b9e9ae152c5ef0 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Tue, 19 Aug 2025 10:38:37 +0300 Subject: [PATCH 23/69] Remove embeddings functionality from AiClient PR Strips out all embeddings-related code to focus PR on core AiClient functionality: - Removed EmbeddingOperation and EmbeddingResult DTOs - Removed embedding provider contracts and interfaces - Removed generateEmbeddingsResult and generateEmbeddingsOperation methods - Cleaned up utility classes (ModelDiscovery, InterfaceValidator, OperationFactory) - Updated test suites to remove embeddings test coverage - Removed embeddings references from documentation comments This creates a cleaner, more focused PR for the core AiClient implementation. Embeddings functionality has been moved to separate draft PR for independent review. --- src/AiClient.php | 57 +--- src/Embeddings/DTO/Embedding.php | 130 ---------- src/Operations/DTO/EmbeddingOperation.php | 191 -------------- src/Operations/OperationFactory.php | 18 -- .../EmbeddingGenerationModelInterface.php | 30 --- ...ddingGenerationOperationModelInterface.php | 29 --- src/Results/DTO/EmbeddingResult.php | 195 -------------- src/Utils/InterfaceValidator.php | 39 --- src/Utils/ModelDiscovery.php | 14 - tests/mocks/MockEmbeddingGenerationModel.php | 96 ------- .../MockEmbeddingGenerationOperationModel.php | 85 ------ tests/unit/AiClientTest.php | 124 +-------- tests/unit/Embeddings/DTO/EmbeddingTest.php | 147 ----------- .../Operations/DTO/EmbeddingOperationTest.php | 244 ------------------ .../unit/Operations/OperationFactoryTest.php | 5 - .../unit/Results/DTO/EmbeddingResultTest.php | 226 ---------------- tests/unit/Utils/InterfaceValidatorTest.php | 18 -- tests/unit/Utils/ModelDiscoveryTest.php | 3 - 18 files changed, 2 insertions(+), 1649 deletions(-) delete mode 100644 src/Embeddings/DTO/Embedding.php delete mode 100644 src/Operations/DTO/EmbeddingOperation.php delete mode 100644 src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php delete mode 100644 src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationOperationModelInterface.php delete mode 100644 src/Results/DTO/EmbeddingResult.php delete mode 100644 tests/mocks/MockEmbeddingGenerationModel.php delete mode 100644 tests/mocks/MockEmbeddingGenerationOperationModel.php delete mode 100644 tests/unit/Embeddings/DTO/EmbeddingTest.php delete mode 100644 tests/unit/Operations/DTO/EmbeddingOperationTest.php delete mode 100644 tests/unit/Results/DTO/EmbeddingResultTest.php diff --git a/src/AiClient.php b/src/AiClient.php index 70439337..833ca320 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -7,13 +7,11 @@ use Generator; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; -use WordPress\AiClient\Operations\DTO\EmbeddingOperation; use WordPress\AiClient\Operations\DTO\GenerativeAiOperation; use WordPress\AiClient\Operations\OperationFactory; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; -use WordPress\AiClient\Results\DTO\EmbeddingResult; use WordPress\AiClient\Results\DTO\GenerativeAiResult; use WordPress\AiClient\Utils\GenerationStrategyResolver; use WordPress\AiClient\Utils\InterfaceValidator; @@ -87,7 +85,6 @@ public static function isConfigured(ProviderAvailabilityInterface $availability) * - Image generation via generateImageResult() * - Text-to-speech via convertTextToSpeechResult() * - Speech generation via generateSpeechResult() - * - Embedding generation via generateEmbeddingsResult() * * @since n.e.x.t * @@ -100,7 +97,7 @@ public static function prompt($text = null) { throw new \RuntimeException( 'PromptBuilder is not yet available. This method depends on PR #49. ' . - 'All generation methods (text, image, text-to-speech, speech, embeddings) are ready for integration.' + 'All generation methods (text, image, text-to-speech, speech) are ready for integration.' ); } @@ -306,36 +303,6 @@ public static function generateSpeechResult($prompt, ModelInterface $model = nul return self::executeGeneration($prompt, $model, 'speech'); } - /** - * Generates embeddings using the traditional API approach. - * - * @since n.e.x.t - * - * @param string[]|string|MessagePart|MessagePart[]|Message|Message[] $input - * The input data to generate embeddings for. - * @param ModelInterface|null $model Optional specific model to use. - * @return EmbeddingResult The generation result. - * - * @throws \InvalidArgumentException If the input format is invalid. - * @throws \RuntimeException If no suitable model is found. - */ - public static function generateEmbeddingsResult($input, ModelInterface $model = null): EmbeddingResult - { - // Normalize embedding input (supports string arrays) - $messages = PromptNormalizer::normalize($input); - /** @var list $messageList */ - $messageList = array_values($messages); - - // Get model - either provided or auto-discovered - $resolvedModel = $model ?? ModelDiscovery::findEmbeddingModel(self::defaultRegistry()); - - // Validate model supports embedding generation - InterfaceValidator::validateEmbeddingGeneration($resolvedModel); - - // Generate the result using the model - /** @phpstan-ignore-next-line */ - return $resolvedModel->generateEmbeddingsResult($messageList); - } /** @@ -444,26 +411,4 @@ public static function generateSpeechOperation($prompt, ModelInterface $model): return OperationFactory::createSpeechOperation($messageList); } - /** - * Creates an embedding generation operation for async processing. - * - * @since n.e.x.t - * - * @param string[]|string|MessagePart|MessagePart[]|Message|Message[] $input - * The input data to generate embeddings for. - * @param ModelInterface $model The model to use for embedding generation. - * @return EmbeddingOperation The operation for async embedding processing. - * - * @throws \InvalidArgumentException If the input format is invalid or model doesn't support embedding generation. - */ - public static function generateEmbeddingsOperation($input, ModelInterface $model): EmbeddingOperation - { - $messages = PromptNormalizer::normalize($input); - /** @var list $messageList */ - $messageList = array_values($messages); - - InterfaceValidator::validateEmbeddingGenerationOperation($model); - /** @phpstan-ignore-next-line */ - return $model->generateEmbeddingsOperation($messageList); - } } diff --git a/src/Embeddings/DTO/Embedding.php b/src/Embeddings/DTO/Embedding.php deleted file mode 100644 index 00fb7d1e..00000000 --- a/src/Embeddings/DTO/Embedding.php +++ /dev/null @@ -1,130 +0,0 @@ - - */ -class Embedding extends AbstractDataTransferObject -{ - public const KEY_VECTOR = 'vector'; - public const KEY_DIMENSION = 'dimension'; - - /** - * @var float[] The embedding vector values. - */ - private array $vector; - - /** - * @var int The dimension (length) of the embedding vector. - */ - private int $dimension; - - /** - * Constructor. - * - * @since n.e.x.t - * - * @param float[] $vector The embedding vector values. - */ - public function __construct(array $vector) - { - $this->vector = $vector; - $this->dimension = count($vector); - } - - /** - * Gets the embedding vector values. - * - * @since n.e.x.t - * - * @return float[] The vector values. - */ - public function getVector(): array - { - return $this->vector; - } - - /** - * Gets the dimension (length) of the embedding vector. - * - * @since n.e.x.t - * - * @return int The vector dimension. - */ - public function getDimension(): int - { - return $this->dimension; - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - */ - public static function getJsonSchema(): array - { - return [ - 'type' => 'object', - 'properties' => [ - self::KEY_VECTOR => [ - 'type' => 'array', - 'items' => [ - 'type' => 'number', - ], - 'description' => 'The embedding vector values.', - ], - self::KEY_DIMENSION => [ - 'type' => 'integer', - 'description' => 'The dimension (length) of the embedding vector.', - ], - ], - 'required' => [self::KEY_VECTOR, self::KEY_DIMENSION], - ]; - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - * - * @return EmbeddingArrayShape - */ - public function toArray(): array - { - return [ - self::KEY_VECTOR => $this->vector, - self::KEY_DIMENSION => $this->dimension, - ]; - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - */ - public static function fromArray(array $array): self - { - static::validateFromArrayData($array, [ - self::KEY_VECTOR, - ]); - - return new self($array[self::KEY_VECTOR]); - } -} diff --git a/src/Operations/DTO/EmbeddingOperation.php b/src/Operations/DTO/EmbeddingOperation.php deleted file mode 100644 index 6b54ccbe..00000000 --- a/src/Operations/DTO/EmbeddingOperation.php +++ /dev/null @@ -1,191 +0,0 @@ - - */ -class EmbeddingOperation extends AbstractDataTransferObject implements OperationInterface -{ - public const KEY_ID = 'id'; - public const KEY_STATE = 'state'; - public const KEY_RESULT = 'result'; - - /** - * @var string Unique identifier for this operation. - */ - private string $id; - - /** - * @var OperationStateEnum The current state of the operation. - */ - private OperationStateEnum $state; - - /** - * @var EmbeddingResult|null The result once the operation completes. - */ - private ?EmbeddingResult $result; - - /** - * Constructor. - * - * @since n.e.x.t - * - * @param string $id Unique identifier for this operation. - * @param OperationStateEnum $state The current state of the operation. - * @param EmbeddingResult|null $result The result once the operation completes. - */ - public function __construct(string $id, OperationStateEnum $state, ?EmbeddingResult $result = null) - { - $this->id = $id; - $this->state = $state; - $this->result = $result; - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - */ - public function getId(): string - { - return $this->id; - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - */ - public function getState(): OperationStateEnum - { - return $this->state; - } - - /** - * Gets the embedding operation result. - * - * @since n.e.x.t - * - * @return EmbeddingResult|null The result once the operation completes. - */ - public function getResult(): ?EmbeddingResult - { - return $this->result; - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - */ - public static function getJsonSchema(): array - { - return [ - 'oneOf' => [ - // Succeeded state - has result - [ - 'type' => 'object', - 'properties' => [ - self::KEY_ID => [ - 'type' => 'string', - 'description' => 'Unique identifier for this operation.', - ], - self::KEY_STATE => [ - 'type' => 'string', - 'const' => OperationStateEnum::succeeded()->value, - ], - self::KEY_RESULT => EmbeddingResult::getJsonSchema(), - ], - 'required' => [self::KEY_ID, self::KEY_STATE, self::KEY_RESULT], - 'additionalProperties' => false, - ], - // All other states - no result - [ - 'type' => 'object', - 'properties' => [ - self::KEY_ID => [ - 'type' => 'string', - 'description' => 'Unique identifier for this operation.', - ], - self::KEY_STATE => [ - 'type' => 'string', - 'enum' => [ - OperationStateEnum::starting()->value, - OperationStateEnum::processing()->value, - OperationStateEnum::failed()->value, - OperationStateEnum::canceled()->value, - ], - 'description' => 'The current state of the operation.', - ], - ], - 'required' => [self::KEY_ID, self::KEY_STATE], - 'additionalProperties' => false, - ], - ], - ]; - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - * - * @return EmbeddingOperationArrayShape - */ - public function toArray(): array - { - $data = [ - self::KEY_ID => $this->id, - self::KEY_STATE => $this->state->value, - ]; - - if ($this->result !== null) { - $data[self::KEY_RESULT] = $this->result->toArray(); - } - - return $data; - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - */ - public static function fromArray(array $array): self - { - static::validateFromArrayData($array, [ - self::KEY_ID, - self::KEY_STATE, - ]); - - $result = null; - if (isset($array[self::KEY_RESULT])) { - $result = EmbeddingResult::fromArray($array[self::KEY_RESULT]); - } - - return new self( - $array[self::KEY_ID], - OperationStateEnum::from($array[self::KEY_STATE]), - $result - ); - } -} diff --git a/src/Operations/OperationFactory.php b/src/Operations/OperationFactory.php index d4f0e6ad..b7c44b2d 100644 --- a/src/Operations/OperationFactory.php +++ b/src/Operations/OperationFactory.php @@ -5,7 +5,6 @@ namespace WordPress\AiClient\Operations; use WordPress\AiClient\Messages\DTO\Message; -use WordPress\AiClient\Operations\DTO\EmbeddingOperation; use WordPress\AiClient\Operations\DTO\GenerativeAiOperation; use WordPress\AiClient\Operations\Enums\OperationStateEnum; @@ -28,7 +27,6 @@ class OperationFactory 'image' => 'image_op_', 'textToSpeech' => 'tts_op_', 'speech' => 'speech_op_', - 'embedding' => 'embedding_op_', ]; /** @@ -116,22 +114,6 @@ public static function createSpeechOperation(array $messages): GenerativeAiOpera ); } - /** - * Creates an embedding generation operation. - * - * @since n.e.x.t - * - * @param list $messages The normalized messages for the operation. - * @return EmbeddingOperation The created operation. - */ - public static function createEmbeddingOperation(array $messages): EmbeddingOperation - { - return new EmbeddingOperation( - uniqid(self::OPERATION_PREFIXES['embedding'], true), - OperationStateEnum::starting(), - null - ); - } /** * Gets the operation prefix for a given operation type. diff --git a/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php b/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php deleted file mode 100644 index 26e336a4..00000000 --- a/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationModelInterface.php +++ /dev/null @@ -1,30 +0,0 @@ - $input Array of messages containing the input data to generate embeddings for. - * @return EmbeddingResult Result containing generated embeddings. - */ - public function generateEmbeddingsResult(array $input): EmbeddingResult; -} diff --git a/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationOperationModelInterface.php b/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationOperationModelInterface.php deleted file mode 100644 index 7b84b15c..00000000 --- a/src/Providers/Models/EmbeddingGeneration/Contracts/EmbeddingGenerationOperationModelInterface.php +++ /dev/null @@ -1,29 +0,0 @@ - $input Array of messages containing the input data to generate embeddings for. - * @return EmbeddingOperation The initiated embedding generation operation. - */ - public function generateEmbeddingsOperation(array $input): EmbeddingOperation; -} diff --git a/src/Results/DTO/EmbeddingResult.php b/src/Results/DTO/EmbeddingResult.php deleted file mode 100644 index 265374c6..00000000 --- a/src/Results/DTO/EmbeddingResult.php +++ /dev/null @@ -1,195 +0,0 @@ -, - * tokenUsage: TokenUsageArrayShape, - * providerMetadata?: array - * } - * - * @extends AbstractDataTransferObject - */ -class EmbeddingResult extends AbstractDataTransferObject implements ResultInterface -{ - public const KEY_ID = 'id'; - public const KEY_EMBEDDINGS = 'embeddings'; - public const KEY_TOKEN_USAGE = 'tokenUsage'; - public const KEY_PROVIDER_METADATA = 'providerMetadata'; - - /** - * @var string Unique identifier for this result. - */ - private string $id; - - /** - * @var Embedding[] The generated embeddings. - */ - private array $embeddings; - - /** - * @var TokenUsage Token usage statistics. - */ - private TokenUsage $tokenUsage; - - /** - * @var array Provider-specific metadata. - */ - private array $providerMetadata; - - /** - * Constructor. - * - * @since n.e.x.t - * - * @param string $id Unique identifier for this result. - * @param Embedding[] $embeddings The generated embeddings. - * @param TokenUsage $tokenUsage Token usage statistics. - * @param array $providerMetadata Provider-specific metadata. - * @throws InvalidArgumentException If no embeddings provided. - */ - public function __construct(string $id, array $embeddings, TokenUsage $tokenUsage, array $providerMetadata = []) - { - if (empty($embeddings)) { - throw new InvalidArgumentException('At least one embedding must be provided'); - } - - $this->id = $id; - $this->embeddings = $embeddings; - $this->tokenUsage = $tokenUsage; - $this->providerMetadata = $providerMetadata; - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - */ - public function getId(): string - { - return $this->id; - } - - /** - * Gets the generated embeddings. - * - * @since n.e.x.t - * - * @return Embedding[] The embeddings. - */ - public function getEmbeddings(): array - { - return $this->embeddings; - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - */ - public function getTokenUsage(): TokenUsage - { - return $this->tokenUsage; - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - */ - public function getProviderMetadata(): array - { - return $this->providerMetadata; - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - */ - public static function getJsonSchema(): array - { - return [ - 'type' => 'object', - 'properties' => [ - self::KEY_ID => [ - 'type' => 'string', - 'description' => 'Unique identifier for this result.', - ], - self::KEY_EMBEDDINGS => [ - 'type' => 'array', - 'items' => Embedding::getJsonSchema(), - 'description' => 'The generated embeddings.', - ], - self::KEY_TOKEN_USAGE => TokenUsage::getJsonSchema(), - self::KEY_PROVIDER_METADATA => [ - 'type' => 'object', - 'description' => 'Provider-specific metadata.', - ], - ], - 'required' => [self::KEY_ID, self::KEY_EMBEDDINGS, self::KEY_TOKEN_USAGE], - ]; - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - * - * @return EmbeddingResultArrayShape - */ - public function toArray(): array - { - return [ - self::KEY_ID => $this->id, - self::KEY_EMBEDDINGS => array_map(fn(Embedding $embedding) => $embedding->toArray(), $this->embeddings), - self::KEY_TOKEN_USAGE => $this->tokenUsage->toArray(), - self::KEY_PROVIDER_METADATA => $this->providerMetadata, - ]; - } - - /** - * {@inheritDoc} - * - * @since n.e.x.t - */ - public static function fromArray(array $array): self - { - static::validateFromArrayData($array, [ - self::KEY_ID, - self::KEY_EMBEDDINGS, - self::KEY_TOKEN_USAGE, - ]); - - $embeddings = array_map( - fn(array $embeddingArray) => Embedding::fromArray($embeddingArray), - $array[self::KEY_EMBEDDINGS] - ); - - return new self( - $array[self::KEY_ID], - $embeddings, - TokenUsage::fromArray($array[self::KEY_TOKEN_USAGE]), - $array[self::KEY_PROVIDER_METADATA] ?? [] - ); - } -} diff --git a/src/Utils/InterfaceValidator.php b/src/Utils/InterfaceValidator.php index dc713a0a..cfe38d0e 100644 --- a/src/Utils/InterfaceValidator.php +++ b/src/Utils/InterfaceValidator.php @@ -5,8 +5,6 @@ namespace WordPress\AiClient\Utils; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; -use WordPress\AiClient\Providers\Models\EmbeddingGeneration\Contracts\EmbeddingGenerationModelInterface; -use WordPress\AiClient\Providers\Models\EmbeddingGeneration\Contracts\EmbeddingGenerationOperationModelInterface; use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; use WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts\SpeechGenerationModelInterface; use WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts\SpeechGenerationOperationModelInterface; @@ -100,24 +98,6 @@ public static function validateSpeechGeneration(ModelInterface $model): void } } - /** - * Validates that a model implements EmbeddingGenerationModelInterface. - * - * @since n.e.x.t - * - * @param ModelInterface $model The model to validate. - * @return void - * - * @throws \InvalidArgumentException If the model doesn't implement the required interface. - */ - public static function validateEmbeddingGeneration(ModelInterface $model): void - { - if (!$model instanceof EmbeddingGenerationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement EmbeddingGenerationModelInterface for embedding generation' - ); - } - } /** * Validates that a model implements TextGenerationModelInterface for operations. @@ -197,23 +177,4 @@ public static function validateSpeechGenerationOperation(ModelInterface $model): } } - /** - * Validates that a model implements EmbeddingGenerationOperationModelInterface for operations. - * - * @since n.e.x.t - * - * @param ModelInterface $model The model to validate. - * @return void - * - * @throws \InvalidArgumentException If the model doesn't implement the required interface. - */ - public static function validateEmbeddingGenerationOperation(ModelInterface $model): void - { - if (!$model instanceof EmbeddingGenerationOperationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement EmbeddingGenerationOperationModelInterface ' . - 'for embedding generation operations' - ); - } - } } diff --git a/src/Utils/ModelDiscovery.php b/src/Utils/ModelDiscovery.php index 86751d80..42c46d79 100644 --- a/src/Utils/ModelDiscovery.php +++ b/src/Utils/ModelDiscovery.php @@ -118,18 +118,4 @@ public static function findSpeechModel(ProviderRegistry $registry): ModelInterfa return self::findModelByCapability($registry, CapabilityEnum::speechGeneration(), 'speech generation'); } - /** - * Finds a suitable embedding generation model from the registry. - * - * @since n.e.x.t - * - * @param ProviderRegistry $registry The provider registry to search. - * @return ModelInterface A suitable embedding generation model. - * - * @throws \RuntimeException If no suitable model is found. - */ - public static function findEmbeddingModel(ProviderRegistry $registry): ModelInterface - { - return self::findModelByCapability($registry, CapabilityEnum::embeddingGeneration(), 'embedding generation'); - } } diff --git a/tests/mocks/MockEmbeddingGenerationModel.php b/tests/mocks/MockEmbeddingGenerationModel.php deleted file mode 100644 index 056d3cb7..00000000 --- a/tests/mocks/MockEmbeddingGenerationModel.php +++ /dev/null @@ -1,96 +0,0 @@ -metadata = $metadata ?? new ModelMetadata( - 'mock-embedding-model', - 'Mock Embedding Model', - [CapabilityEnum::embeddingGeneration()], - [] - ); - $this->config = $config ?? new ModelConfig(); - } - - /** - * {@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; - } - - /** - * {@inheritDoc} - */ - public function generateEmbeddingsResult(array $input): EmbeddingResult - { - // Generate mock embeddings based on input length - $embeddings = []; - foreach ($input as $index => $message) { - // Create a simple mock embedding vector based on message index - $vector = array_fill(0, 3, 0.1 + ($index * 0.1)); - $embeddings[] = new Embedding($vector); - } - - $tokenUsage = new TokenUsage(count($input) * 5, 0, count($input) * 5); - - return new EmbeddingResult( - 'mock-embedding-result-' . uniqid(), - $embeddings, - $tokenUsage, - ['model' => 'mock-embedding-model'] - ); - } -} diff --git a/tests/mocks/MockEmbeddingGenerationOperationModel.php b/tests/mocks/MockEmbeddingGenerationOperationModel.php deleted file mode 100644 index 9f2cdf25..00000000 --- a/tests/mocks/MockEmbeddingGenerationOperationModel.php +++ /dev/null @@ -1,85 +0,0 @@ -metadata = $metadata ?? new ModelMetadata( - 'mock-embedding-operation-model', - 'Mock Embedding Operation Model', - [CapabilityEnum::embeddingGeneration()], - [] - ); - $this->config = $config ?? new ModelConfig(); - } - - /** - * {@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; - } - - /** - * {@inheritDoc} - */ - public function generateEmbeddingsOperation(array $input): EmbeddingOperation - { - // Create a mock embedding operation in starting state - return new EmbeddingOperation( - 'mock-embedding-op-' . uniqid(), - OperationStateEnum::starting(), - null - ); - } -} diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index 15426eaf..c1ede93c 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -11,19 +11,15 @@ use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\ModelMessage; use WordPress\AiClient\Messages\DTO\UserMessage; -use WordPress\AiClient\Operations\DTO\EmbeddingOperation; use WordPress\AiClient\Operations\DTO\GenerativeAiOperation; use WordPress\AiClient\Operations\Enums\OperationStateEnum; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Results\DTO\Candidate; -use WordPress\AiClient\Results\DTO\EmbeddingResult; use WordPress\AiClient\Results\DTO\GenerativeAiResult; use WordPress\AiClient\Results\DTO\TokenUsage; use WordPress\AiClient\Results\Enums\FinishReasonEnum; -use WordPress\AiClient\Tests\mocks\MockEmbeddingGenerationModel; -use WordPress\AiClient\Tests\mocks\MockEmbeddingGenerationOperationModel; use WordPress\AiClient\Tests\mocks\MockImageGenerationModel; use WordPress\AiClient\Tests\mocks\MockTextGenerationModel; @@ -35,8 +31,6 @@ class AiClientTest extends TestCase private ProviderRegistry $registry; private MockTextGenerationModel $mockTextModel; private MockImageGenerationModel $mockImageModel; - private MockEmbeddingGenerationModel $mockEmbeddingModel; - private MockEmbeddingGenerationOperationModel $mockEmbeddingOperationModel; protected function setUp(): void { @@ -46,8 +40,6 @@ protected function setUp(): void // Create mock models that implement both base and generation interfaces $this->mockTextModel = $this->createMock(MockTextGenerationModel::class); $this->mockImageModel = $this->createMock(MockImageGenerationModel::class); - $this->mockEmbeddingModel = $this->createMock(MockEmbeddingGenerationModel::class); - $this->mockEmbeddingOperationModel = $this->createMock(MockEmbeddingGenerationOperationModel::class); // Set the test registry as the default AiClient::setDefaultRegistry($this->registry); @@ -95,7 +87,7 @@ public function testPromptThrowsException(): void $this->expectException(RuntimeException::class); $this->expectExceptionMessage( 'PromptBuilder is not yet available. This method depends on PR #49. ' . - 'All generation methods (text, image, text-to-speech, speech, embeddings) are ready for integration.' + 'All generation methods (text, image, text-to-speech, speech) are ready for integration.' ); AiClient::prompt('Test prompt'); @@ -505,118 +497,4 @@ public function testGenerateImageOperationThrowsExceptionForNonImageModel(): voi AiClient::generateImageOperation($prompt, $nonImageModel); } - /** - * Tests generateEmbeddingsResult delegates to model's generateEmbeddingsResult method. - */ - public function testGenerateEmbeddingsResultDelegatesToModel(): void - { - $input = ['test input text', 'another text']; - $expectedResult = $this->createTestEmbeddingResult(); - - $this->mockEmbeddingModel - ->expects($this->once()) - ->method('generateEmbeddingsResult') - ->willReturn($expectedResult); - - $result = AiClient::generateEmbeddingsResult($input, $this->mockEmbeddingModel); - - $this->assertEquals($expectedResult, $result); - } - - /** - * Tests generateEmbeddingsResult with Message array input. - */ - public function testGenerateEmbeddingsResultWithMessageInput(): void - { - $input = [new UserMessage([new MessagePart('test message')])]; - $expectedResult = $this->createTestEmbeddingResult(); - - $this->mockEmbeddingModel - ->expects($this->once()) - ->method('generateEmbeddingsResult') - ->willReturn($expectedResult); - - $result = AiClient::generateEmbeddingsResult($input, $this->mockEmbeddingModel); - - $this->assertEquals($expectedResult, $result); - } - - /** - * Tests generateEmbeddingsResult throws exception for non-embedding model. - */ - public function testGenerateEmbeddingsResultThrowsExceptionForNonEmbeddingModel(): void - { - $input = ['test input']; - $nonEmbeddingModel = $this->createMock(ModelInterface::class); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Model must implement EmbeddingGenerationModelInterface for embedding generation' - ); - - AiClient::generateEmbeddingsResult($input, $nonEmbeddingModel); - } - - /** - * Tests generateEmbeddingsOperation delegates to model's generateEmbeddingsOperation method. - */ - public function testGenerateEmbeddingsOperationDelegatesToModel(): void - { - $input = ['test input text']; - $expectedOperation = $this->createTestEmbeddingOperation(); - - $this->mockEmbeddingOperationModel - ->expects($this->once()) - ->method('generateEmbeddingsOperation') - ->willReturn($expectedOperation); - - $result = AiClient::generateEmbeddingsOperation($input, $this->mockEmbeddingOperationModel); - - $this->assertEquals($expectedOperation, $result); - } - - /** - * Tests generateEmbeddingsOperation throws exception for non-embedding operation model. - */ - public function testGenerateEmbeddingsOperationThrowsExceptionForNonEmbeddingOperationModel(): void - { - $input = ['test input']; - $nonEmbeddingOperationModel = $this->createMock(ModelInterface::class); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Model must implement EmbeddingGenerationOperationModelInterface ' . - 'for embedding generation operations' - ); - - AiClient::generateEmbeddingsOperation($input, $nonEmbeddingOperationModel); - } - - /** - * Creates a test EmbeddingResult for testing purposes. - */ - private function createTestEmbeddingResult(): EmbeddingResult - { - $embedding = new \WordPress\AiClient\Embeddings\DTO\Embedding([0.1, 0.2, 0.3]); - $tokenUsage = new TokenUsage(5, 0, 5); - - return new EmbeddingResult( - 'test-embedding-result', - [$embedding], - $tokenUsage, - ['model' => 'test-embedding-model'] - ); - } - - /** - * Creates a test EmbeddingOperation for testing purposes. - */ - private function createTestEmbeddingOperation(): EmbeddingOperation - { - return new EmbeddingOperation( - 'test-embedding-operation', - OperationStateEnum::starting(), - null - ); - } } diff --git a/tests/unit/Embeddings/DTO/EmbeddingTest.php b/tests/unit/Embeddings/DTO/EmbeddingTest.php deleted file mode 100644 index 2d40dd00..00000000 --- a/tests/unit/Embeddings/DTO/EmbeddingTest.php +++ /dev/null @@ -1,147 +0,0 @@ -assertEquals($vector, $embedding->getVector()); - $this->assertEquals(5, $embedding->getDimension()); - } - - /** - * Tests creating Embedding with empty vector. - */ - public function testCreateWithEmptyVector(): void - { - $vector = []; - $embedding = new Embedding($vector); - - $this->assertEquals($vector, $embedding->getVector()); - $this->assertEquals(0, $embedding->getDimension()); - } - - /** - * Tests creating Embedding with large vector. - */ - public function testCreateWithLargeVector(): void - { - $vector = array_fill(0, 1536, 0.1); // Common OpenAI embedding size - $embedding = new Embedding($vector); - - $this->assertEquals($vector, $embedding->getVector()); - $this->assertEquals(1536, $embedding->getDimension()); - } - - /** - * Tests creating Embedding with negative values. - */ - public function testCreateWithNegativeValues(): void - { - $vector = [-0.5, -0.3, 0.0, 0.3, 0.5]; - $embedding = new Embedding($vector); - - $this->assertEquals($vector, $embedding->getVector()); - $this->assertEquals(5, $embedding->getDimension()); - } - - /** - * Tests toArray conversion. - */ - public function testToArray(): void - { - $vector = [0.1, 0.2, 0.3]; - $embedding = new Embedding($vector); - - $expected = [ - 'vector' => $vector, - 'dimension' => 3, - ]; - - $this->assertEquals($expected, $embedding->toArray()); - } - - /** - * Tests fromArray creation. - */ - public function testFromArray(): void - { - $vector = [0.4, 0.5, 0.6]; - $array = [ - 'vector' => $vector, - 'dimension' => 3, - ]; - - $embedding = Embedding::fromArray($array); - - $this->assertEquals($vector, $embedding->getVector()); - $this->assertEquals(3, $embedding->getDimension()); - } - - /** - * Tests fromArray with missing vector throws exception. - */ - public function testFromArrayWithMissingVectorThrowsException(): void - { - $array = [ - 'dimension' => 3, - ]; - - $this->expectException(\InvalidArgumentException::class); - Embedding::fromArray($array); - } - - /** - * Tests JSON schema generation. - */ - public function testGetJsonSchema(): void - { - $schema = Embedding::getJsonSchema(); - - $this->assertArrayHasKey('type', $schema); - $this->assertEquals('object', $schema['type']); - - $this->assertArrayHasKey('properties', $schema); - $this->assertArrayHasKey('vector', $schema['properties']); - $this->assertArrayHasKey('dimension', $schema['properties']); - - $this->assertArrayHasKey('required', $schema); - $this->assertContains('vector', $schema['required']); - $this->assertContains('dimension', $schema['required']); - } - - /** - * Tests that dimension calculation is accurate. - */ - public function testDimensionCalculation(): void - { - // Test various vector sizes - $testCases = [ - 'empty' => [[], 0], - 'single' => [[1.0], 1], - 'double' => [[1.0, 2.0], 2], - 'hundred' => [array_fill(0, 100, 0.1), 100], - 'large' => [array_fill(0, 1024, 0.1), 1024], - ]; - - foreach ($testCases as $testName => [$vector, $expectedDimension]) { - $embedding = new Embedding($vector); - $this->assertEquals($expectedDimension, $embedding->getDimension(), "Failed for test case: $testName"); - } - } -} diff --git a/tests/unit/Operations/DTO/EmbeddingOperationTest.php b/tests/unit/Operations/DTO/EmbeddingOperationTest.php deleted file mode 100644 index a4c36de2..00000000 --- a/tests/unit/Operations/DTO/EmbeddingOperationTest.php +++ /dev/null @@ -1,244 +0,0 @@ -tokenUsage = new TokenUsage(10, 0, 10); - $embedding = new Embedding([0.1, 0.2, 0.3]); - $this->embeddingResult = new EmbeddingResult('result-id', [$embedding], $this->tokenUsage); - } - - /** - * Tests creating EmbeddingOperation with starting state. - */ - public function testCreateWithStartingState(): void - { - $operation = new EmbeddingOperation('op-id', OperationStateEnum::starting()); - - $this->assertEquals('op-id', $operation->getId()); - $this->assertEquals(OperationStateEnum::starting(), $operation->getState()); - $this->assertNull($operation->getResult()); - } - - /** - * Tests creating EmbeddingOperation with processing state. - */ - public function testCreateWithProcessingState(): void - { - $operation = new EmbeddingOperation('op-id', OperationStateEnum::processing()); - - $this->assertEquals('op-id', $operation->getId()); - $this->assertEquals(OperationStateEnum::processing(), $operation->getState()); - $this->assertNull($operation->getResult()); - } - - /** - * Tests creating EmbeddingOperation with succeeded state and result. - */ - public function testCreateWithSucceededStateAndResult(): void - { - $operation = new EmbeddingOperation('op-id', OperationStateEnum::succeeded(), $this->embeddingResult); - - $this->assertEquals('op-id', $operation->getId()); - $this->assertEquals(OperationStateEnum::succeeded(), $operation->getState()); - $this->assertEquals($this->embeddingResult, $operation->getResult()); - } - - /** - * Tests creating EmbeddingOperation with failed state. - */ - public function testCreateWithFailedState(): void - { - $operation = new EmbeddingOperation('op-id', OperationStateEnum::failed()); - - $this->assertEquals('op-id', $operation->getId()); - $this->assertEquals(OperationStateEnum::failed(), $operation->getState()); - $this->assertNull($operation->getResult()); - } - - /** - * Tests creating EmbeddingOperation with canceled state. - */ - public function testCreateWithCanceledState(): void - { - $operation = new EmbeddingOperation('op-id', OperationStateEnum::canceled()); - - $this->assertEquals('op-id', $operation->getId()); - $this->assertEquals(OperationStateEnum::canceled(), $operation->getState()); - $this->assertNull($operation->getResult()); - } - - /** - * Tests toArray conversion without result. - */ - public function testToArrayWithoutResult(): void - { - $operation = new EmbeddingOperation('op-id', OperationStateEnum::starting()); - - $expected = [ - 'id' => 'op-id', - 'state' => 'starting', - ]; - - $this->assertEquals($expected, $operation->toArray()); - } - - /** - * Tests toArray conversion with result. - */ - public function testToArrayWithResult(): void - { - $operation = new EmbeddingOperation('op-id', OperationStateEnum::succeeded(), $this->embeddingResult); - - $expected = [ - 'id' => 'op-id', - 'state' => 'succeeded', - 'result' => $this->embeddingResult->toArray(), - ]; - - $this->assertEquals($expected, $operation->toArray()); - } - - /** - * Tests fromArray creation without result. - */ - public function testFromArrayWithoutResult(): void - { - $array = [ - 'id' => 'op-id', - 'state' => 'processing', - ]; - - $operation = EmbeddingOperation::fromArray($array); - - $this->assertEquals('op-id', $operation->getId()); - $this->assertEquals(OperationStateEnum::processing(), $operation->getState()); - $this->assertNull($operation->getResult()); - } - - /** - * Tests fromArray creation with result. - */ - public function testFromArrayWithResult(): void - { - $array = [ - 'id' => 'op-id', - 'state' => 'succeeded', - 'result' => $this->embeddingResult->toArray(), - ]; - - $operation = EmbeddingOperation::fromArray($array); - - $this->assertEquals('op-id', $operation->getId()); - $this->assertEquals(OperationStateEnum::succeeded(), $operation->getState()); - $this->assertNotNull($operation->getResult()); - $this->assertEquals($this->embeddingResult->getId(), $operation->getResult()->getId()); - } - - /** - * Tests fromArray with missing required field throws exception. - */ - public function testFromArrayWithMissingRequiredFieldThrowsException(): void - { - $array = [ - 'id' => 'op-id', - // Missing state - ]; - - $this->expectException(\InvalidArgumentException::class); - EmbeddingOperation::fromArray($array); - } - - /** - * Tests JSON schema generation. - */ - public function testGetJsonSchema(): void - { - $schema = EmbeddingOperation::getJsonSchema(); - - $this->assertArrayHasKey('oneOf', $schema); - $this->assertCount(2, $schema['oneOf']); - - // Test succeeded state schema (with result) - $succeededSchema = $schema['oneOf'][0]; - $this->assertEquals('object', $succeededSchema['type']); - $this->assertArrayHasKey('properties', $succeededSchema); - $this->assertArrayHasKey('id', $succeededSchema['properties']); - $this->assertArrayHasKey('state', $succeededSchema['properties']); - $this->assertArrayHasKey('result', $succeededSchema['properties']); - $this->assertContains('result', $succeededSchema['required']); - - // Test other states schema (without result) - $otherStatesSchema = $schema['oneOf'][1]; - $this->assertEquals('object', $otherStatesSchema['type']); - $this->assertArrayHasKey('properties', $otherStatesSchema); - $this->assertArrayHasKey('id', $otherStatesSchema['properties']); - $this->assertArrayHasKey('state', $otherStatesSchema['properties']); - $this->assertArrayNotHasKey('result', $otherStatesSchema['properties']); - $this->assertNotContains('result', $otherStatesSchema['required']); - } - - /** - * Tests that EmbeddingOperation implements OperationInterface. - */ - public function testImplementsOperationInterface(): void - { - $operation = new EmbeddingOperation('op-id', OperationStateEnum::starting()); - - $this->assertInstanceOf(\WordPress\AiClient\Operations\Contracts\OperationInterface::class, $operation); - } - - /** - * Tests operation state transitions. - */ - public function testOperationStateTransitions(): void - { - // Test typical operation lifecycle - $startingOp = new EmbeddingOperation('op-id', OperationStateEnum::starting()); - $this->assertTrue($startingOp->getState()->isStarting()); - - $processingOp = new EmbeddingOperation('op-id', OperationStateEnum::processing()); - $this->assertTrue($processingOp->getState()->isProcessing()); - - $succeededOp = new EmbeddingOperation('op-id', OperationStateEnum::succeeded(), $this->embeddingResult); - $this->assertTrue($succeededOp->getState()->isSucceeded()); - $this->assertNotNull($succeededOp->getResult()); - - $failedOp = new EmbeddingOperation('op-id', OperationStateEnum::failed()); - $this->assertTrue($failedOp->getState()->isFailed()); - $this->assertNull($failedOp->getResult()); - } - - /** - * Tests round-trip conversion (toArray -> fromArray). - */ - public function testRoundTripConversion(): void - { - $originalOperation = new EmbeddingOperation('op-id', OperationStateEnum::succeeded(), $this->embeddingResult); - - $array = $originalOperation->toArray(); - $reconstructedOperation = EmbeddingOperation::fromArray($array); - - $this->assertEquals($originalOperation->getId(), $reconstructedOperation->getId()); - $this->assertEquals($originalOperation->getState()->value, $reconstructedOperation->getState()->value); - $this->assertEquals($originalOperation->getResult()->getId(), $reconstructedOperation->getResult()->getId()); - } -} diff --git a/tests/unit/Operations/OperationFactoryTest.php b/tests/unit/Operations/OperationFactoryTest.php index c273fa52..c96f3598 100644 --- a/tests/unit/Operations/OperationFactoryTest.php +++ b/tests/unit/Operations/OperationFactoryTest.php @@ -7,7 +7,6 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\UserMessage; -use WordPress\AiClient\Operations\DTO\EmbeddingOperation; use WordPress\AiClient\Operations\DTO\GenerativeAiOperation; use WordPress\AiClient\Operations\Enums\OperationStateEnum; use WordPress\AiClient\Operations\OperationFactory; @@ -93,13 +92,9 @@ public function testCreateSpeechOperation(): void } /** - * Tests createEmbeddingOperation creates embedding operation with correct prefix. */ - public function testCreateEmbeddingOperation(): void { - $operation = OperationFactory::createEmbeddingOperation($this->testMessages); - $this->assertInstanceOf(EmbeddingOperation::class, $operation); $this->assertStringStartsWith('embedding_op_', $operation->getId()); $this->assertEquals(OperationStateEnum::starting(), $operation->getState()); $this->assertNull($operation->getResult()); diff --git a/tests/unit/Results/DTO/EmbeddingResultTest.php b/tests/unit/Results/DTO/EmbeddingResultTest.php deleted file mode 100644 index ed0c71e5..00000000 --- a/tests/unit/Results/DTO/EmbeddingResultTest.php +++ /dev/null @@ -1,226 +0,0 @@ -tokenUsage = new TokenUsage(10, 0, 10); - $this->embedding1 = new Embedding([0.1, 0.2, 0.3]); - $this->embedding2 = new Embedding([0.4, 0.5, 0.6]); - } - - /** - * Tests creating EmbeddingResult with valid data. - */ - public function testCreateWithValidData(): void - { - $embeddings = [$this->embedding1, $this->embedding2]; - $metadata = ['model' => 'text-embedding-ada-002']; - - $result = new EmbeddingResult('test-id', $embeddings, $this->tokenUsage, $metadata); - - $this->assertEquals('test-id', $result->getId()); - $this->assertEquals($embeddings, $result->getEmbeddings()); - $this->assertEquals($this->tokenUsage, $result->getTokenUsage()); - $this->assertEquals($metadata, $result->getProviderMetadata()); - } - - /** - * Tests creating EmbeddingResult with empty metadata. - */ - public function testCreateWithEmptyMetadata(): void - { - $embeddings = [$this->embedding1]; - - $result = new EmbeddingResult('test-id', $embeddings, $this->tokenUsage); - - $this->assertEquals('test-id', $result->getId()); - $this->assertEquals($embeddings, $result->getEmbeddings()); - $this->assertEquals($this->tokenUsage, $result->getTokenUsage()); - $this->assertEquals([], $result->getProviderMetadata()); - } - - /** - * Tests creating EmbeddingResult with empty embeddings throws exception. - */ - public function testCreateWithEmptyEmbeddingsThrowsException(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('At least one embedding must be provided'); - - new EmbeddingResult('test-id', [], $this->tokenUsage); - } - - /** - * Tests creating EmbeddingResult with single embedding. - */ - public function testCreateWithSingleEmbedding(): void - { - $embeddings = [$this->embedding1]; - - $result = new EmbeddingResult('test-id', $embeddings, $this->tokenUsage); - - $this->assertCount(1, $result->getEmbeddings()); - $this->assertEquals($this->embedding1, $result->getEmbeddings()[0]); - } - - /** - * Tests creating EmbeddingResult with multiple embeddings. - */ - public function testCreateWithMultipleEmbeddings(): void - { - $embeddings = [$this->embedding1, $this->embedding2]; - - $result = new EmbeddingResult('test-id', $embeddings, $this->tokenUsage); - - $this->assertCount(2, $result->getEmbeddings()); - $this->assertEquals($this->embedding1, $result->getEmbeddings()[0]); - $this->assertEquals($this->embedding2, $result->getEmbeddings()[1]); - } - - /** - * Tests toArray conversion. - */ - public function testToArray(): void - { - $embeddings = [$this->embedding1, $this->embedding2]; - $metadata = ['model' => 'text-embedding-ada-002']; - - $result = new EmbeddingResult('test-id', $embeddings, $this->tokenUsage, $metadata); - - $expected = [ - 'id' => 'test-id', - 'embeddings' => [ - $this->embedding1->toArray(), - $this->embedding2->toArray(), - ], - 'tokenUsage' => $this->tokenUsage->toArray(), - 'providerMetadata' => $metadata, - ]; - - $this->assertEquals($expected, $result->toArray()); - } - - /** - * Tests fromArray creation. - */ - public function testFromArray(): void - { - $array = [ - 'id' => 'test-id', - 'embeddings' => [ - $this->embedding1->toArray(), - $this->embedding2->toArray(), - ], - 'tokenUsage' => $this->tokenUsage->toArray(), - 'providerMetadata' => ['model' => 'test-model'], - ]; - - $result = EmbeddingResult::fromArray($array); - - $this->assertEquals('test-id', $result->getId()); - $this->assertCount(2, $result->getEmbeddings()); - $this->assertEquals($this->tokenUsage->toArray(), $result->getTokenUsage()->toArray()); - $this->assertEquals(['model' => 'test-model'], $result->getProviderMetadata()); - } - - /** - * Tests fromArray with missing metadata uses empty array. - */ - public function testFromArrayWithMissingMetadata(): void - { - $array = [ - 'id' => 'test-id', - 'embeddings' => [$this->embedding1->toArray()], - 'tokenUsage' => $this->tokenUsage->toArray(), - ]; - - $result = EmbeddingResult::fromArray($array); - - $this->assertEquals([], $result->getProviderMetadata()); - } - - /** - * Tests fromArray with missing required field throws exception. - */ - public function testFromArrayWithMissingRequiredFieldThrowsException(): void - { - $array = [ - 'id' => 'test-id', - 'embeddings' => [$this->embedding1->toArray()], - // Missing tokenUsage - ]; - - $this->expectException(\InvalidArgumentException::class); - EmbeddingResult::fromArray($array); - } - - /** - * Tests JSON schema generation. - */ - public function testGetJsonSchema(): void - { - $schema = EmbeddingResult::getJsonSchema(); - - $this->assertArrayHasKey('type', $schema); - $this->assertEquals('object', $schema['type']); - - $this->assertArrayHasKey('properties', $schema); - $this->assertArrayHasKey('id', $schema['properties']); - $this->assertArrayHasKey('embeddings', $schema['properties']); - $this->assertArrayHasKey('tokenUsage', $schema['properties']); - $this->assertArrayHasKey('providerMetadata', $schema['properties']); - - $this->assertArrayHasKey('required', $schema); - $this->assertContains('id', $schema['required']); - $this->assertContains('embeddings', $schema['required']); - $this->assertContains('tokenUsage', $schema['required']); - } - - /** - * Tests that EmbeddingResult implements ResultInterface. - */ - public function testImplementsResultInterface(): void - { - $embeddings = [$this->embedding1]; - $result = new EmbeddingResult('test-id', $embeddings, $this->tokenUsage); - - $this->assertInstanceOf(\WordPress\AiClient\Results\Contracts\ResultInterface::class, $result); - } - - /** - * Tests embedding result with complex metadata. - */ - public function testWithComplexMetadata(): void - { - $embeddings = [$this->embedding1]; - $metadata = [ - 'model' => 'text-embedding-ada-002', - 'usage' => ['prompt_tokens' => 5], - 'provider' => 'openai', - 'version' => '1.0', - ]; - - $result = new EmbeddingResult('test-id', $embeddings, $this->tokenUsage, $metadata); - - $this->assertEquals($metadata, $result->getProviderMetadata()); - $this->assertEquals('openai', $result->getProviderMetadata()['provider']); - } -} diff --git a/tests/unit/Utils/InterfaceValidatorTest.php b/tests/unit/Utils/InterfaceValidatorTest.php index 928fe162..8e1d96fb 100644 --- a/tests/unit/Utils/InterfaceValidatorTest.php +++ b/tests/unit/Utils/InterfaceValidatorTest.php @@ -6,8 +6,6 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; -use WordPress\AiClient\Tests\mocks\MockEmbeddingGenerationModel; -use WordPress\AiClient\Tests\mocks\MockEmbeddingGenerationOperationModel; use WordPress\AiClient\Tests\mocks\MockImageGenerationModel; use WordPress\AiClient\Tests\mocks\MockTextGenerationModel; use WordPress\AiClient\Utils\InterfaceValidator; @@ -76,32 +74,24 @@ public function testValidateImageGenerationWithInvalidModel(): void } /** - * Tests validateEmbeddingGeneration with valid embedding model. */ - public function testValidateEmbeddingGenerationWithValidModel(): void { - $model = $this->createMock(MockEmbeddingGenerationModel::class); // Should not throw an exception - InterfaceValidator::validateEmbeddingGeneration($model); // If we reach here, validation passed $this->assertTrue(true); } /** - * Tests validateEmbeddingGeneration with invalid model. */ - public function testValidateEmbeddingGenerationWithInvalidModel(): void { $model = $this->createMock(ModelInterface::class); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage( - 'Model must implement EmbeddingGenerationModelInterface for embedding generation' ); - InterfaceValidator::validateEmbeddingGeneration($model); } /** @@ -163,32 +153,24 @@ public function testValidateImageGenerationOperationWithInvalidModel(): void } /** - * Tests validateEmbeddingGenerationOperation with valid embedding operation model. */ - public function testValidateEmbeddingGenerationOperationWithValidModel(): void { - $model = $this->createMock(MockEmbeddingGenerationOperationModel::class); // Should not throw an exception - InterfaceValidator::validateEmbeddingGenerationOperation($model); // If we reach here, validation passed $this->assertTrue(true); } /** - * Tests validateEmbeddingGenerationOperation with invalid model. */ - public function testValidateEmbeddingGenerationOperationWithInvalidModel(): void { $model = $this->createMock(ModelInterface::class); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage( - 'Model must implement EmbeddingGenerationOperationModelInterface ' . 'for embedding generation operations' ); - InterfaceValidator::validateEmbeddingGenerationOperation($model); } } diff --git a/tests/unit/Utils/ModelDiscoveryTest.php b/tests/unit/Utils/ModelDiscoveryTest.php index 88c00e9d..3cdbb576 100644 --- a/tests/unit/Utils/ModelDiscoveryTest.php +++ b/tests/unit/Utils/ModelDiscoveryTest.php @@ -81,9 +81,7 @@ public function testFindSpeechModelThrowsExceptionWhenNoModelsAvailable(): void } /** - * Tests findEmbeddingModel throws exception when no models available. */ - public function testFindEmbeddingModelThrowsExceptionWhenNoModelsAvailable(): void { $this->registry->expects($this->once()) ->method('findModelsMetadataForSupport') @@ -92,7 +90,6 @@ public function testFindEmbeddingModelThrowsExceptionWhenNoModelsAvailable(): vo $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('No embedding generation models available'); - ModelDiscovery::findEmbeddingModel($this->registry); } /** From 99213f27d9bc4bf997871c5d48787ed1258425d0 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Tue, 19 Aug 2025 10:43:51 +0300 Subject: [PATCH 24/69] Fix test files after embeddings removal Clean up remaining embeddings references in test files: - Remove embeddings test methods from OperationFactoryTest - Remove embeddings test methods from ModelDiscoveryTest - Remove embeddings test methods from InterfaceValidatorTest - Update expected counts and arrays to match non-embeddings reality This ensures all tests pass after embeddings functionality removal. --- .../unit/Operations/OperationFactoryTest.php | 12 +----- tests/unit/Utils/InterfaceValidatorTest.php | 41 ------------------- tests/unit/Utils/ModelDiscoveryTest.php | 11 ----- 3 files changed, 1 insertion(+), 63 deletions(-) diff --git a/tests/unit/Operations/OperationFactoryTest.php b/tests/unit/Operations/OperationFactoryTest.php index c96f3598..b33eeb33 100644 --- a/tests/unit/Operations/OperationFactoryTest.php +++ b/tests/unit/Operations/OperationFactoryTest.php @@ -91,14 +91,6 @@ public function testCreateSpeechOperation(): void $this->assertNull($operation->getResult()); } - /** - */ - { - - $this->assertStringStartsWith('embedding_op_', $operation->getId()); - $this->assertEquals(OperationStateEnum::starting(), $operation->getState()); - $this->assertNull($operation->getResult()); - } /** * Tests getOperationPrefix returns correct prefix for known types. @@ -110,7 +102,6 @@ public function testGetOperationPrefixReturnsCorrectPrefix(): void $this->assertEquals('image_op_', OperationFactory::getOperationPrefix('image')); $this->assertEquals('tts_op_', OperationFactory::getOperationPrefix('textToSpeech')); $this->assertEquals('speech_op_', OperationFactory::getOperationPrefix('speech')); - $this->assertEquals('embedding_op_', OperationFactory::getOperationPrefix('embedding')); } /** @@ -137,11 +128,10 @@ public function testGetOperationPrefixesReturnsAllPrefixes(): void 'image' => 'image_op_', 'textToSpeech' => 'tts_op_', 'speech' => 'speech_op_', - 'embedding' => 'embedding_op_', ]; $this->assertEquals($expected, $prefixes); - $this->assertCount(6, $prefixes); + $this->assertCount(5, $prefixes); } /** diff --git a/tests/unit/Utils/InterfaceValidatorTest.php b/tests/unit/Utils/InterfaceValidatorTest.php index 8e1d96fb..fbf0feef 100644 --- a/tests/unit/Utils/InterfaceValidatorTest.php +++ b/tests/unit/Utils/InterfaceValidatorTest.php @@ -73,26 +73,6 @@ public function testValidateImageGenerationWithInvalidModel(): void InterfaceValidator::validateImageGeneration($model); } - /** - */ - { - - // Should not throw an exception - - // If we reach here, validation passed - $this->assertTrue(true); - } - - /** - */ - { - $model = $this->createMock(ModelInterface::class); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage( - ); - - } /** * Tests validateTextGenerationOperation with valid text model. @@ -152,25 +132,4 @@ public function testValidateImageGenerationOperationWithInvalidModel(): void InterfaceValidator::validateImageGenerationOperation($model); } - /** - */ - { - - // Should not throw an exception - - // If we reach here, validation passed - $this->assertTrue(true); - } - - /** - */ - { - $model = $this->createMock(ModelInterface::class); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage( - 'for embedding generation operations' - ); - - } } diff --git a/tests/unit/Utils/ModelDiscoveryTest.php b/tests/unit/Utils/ModelDiscoveryTest.php index 3cdbb576..9dc47a90 100644 --- a/tests/unit/Utils/ModelDiscoveryTest.php +++ b/tests/unit/Utils/ModelDiscoveryTest.php @@ -80,17 +80,6 @@ public function testFindSpeechModelThrowsExceptionWhenNoModelsAvailable(): void ModelDiscovery::findSpeechModel($this->registry); } - /** - */ - { - $this->registry->expects($this->once()) - ->method('findModelsMetadataForSupport') - ->willReturn([]); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('No embedding generation models available'); - - } /** * Tests that ModelDiscovery properly passes capability requirements to registry. From e42d94d2ccea33b3b5d313799fe26e331f5ae28e Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Tue, 19 Aug 2025 10:55:54 +0300 Subject: [PATCH 25/69] Fix PSR-2 code style violations Remove extra blank lines before closing class braces to comply with PSR-2 standards: - AiClient.php - InterfaceValidator.php - ModelDiscovery.php - AiClientTest.php - InterfaceValidatorTest.php All code style violations are now resolved. --- src/AiClient.php | 1 - src/Utils/InterfaceValidator.php | 1 - src/Utils/ModelDiscovery.php | 1 - tests/unit/AiClientTest.php | 1 - tests/unit/Utils/InterfaceValidatorTest.php | 1 - 5 files changed, 5 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index 833ca320..388b9dbc 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -410,5 +410,4 @@ public static function generateSpeechOperation($prompt, ModelInterface $model): InterfaceValidator::validateSpeechGenerationOperation($model); return OperationFactory::createSpeechOperation($messageList); } - } diff --git a/src/Utils/InterfaceValidator.php b/src/Utils/InterfaceValidator.php index cfe38d0e..4f6e95dd 100644 --- a/src/Utils/InterfaceValidator.php +++ b/src/Utils/InterfaceValidator.php @@ -176,5 +176,4 @@ public static function validateSpeechGenerationOperation(ModelInterface $model): ); } } - } diff --git a/src/Utils/ModelDiscovery.php b/src/Utils/ModelDiscovery.php index 42c46d79..b49d9511 100644 --- a/src/Utils/ModelDiscovery.php +++ b/src/Utils/ModelDiscovery.php @@ -117,5 +117,4 @@ public static function findSpeechModel(ProviderRegistry $registry): ModelInterfa { return self::findModelByCapability($registry, CapabilityEnum::speechGeneration(), 'speech generation'); } - } diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index c1ede93c..87d3e847 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -496,5 +496,4 @@ public function testGenerateImageOperationThrowsExceptionForNonImageModel(): voi AiClient::generateImageOperation($prompt, $nonImageModel); } - } diff --git a/tests/unit/Utils/InterfaceValidatorTest.php b/tests/unit/Utils/InterfaceValidatorTest.php index fbf0feef..f5f6e08f 100644 --- a/tests/unit/Utils/InterfaceValidatorTest.php +++ b/tests/unit/Utils/InterfaceValidatorTest.php @@ -131,5 +131,4 @@ public function testValidateImageGenerationOperationWithInvalidModel(): void InterfaceValidator::validateImageGenerationOperation($model); } - } From d7e120342363097a6027c3cddef11a6eb6b45437 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Tue, 19 Aug 2025 10:59:25 +0300 Subject: [PATCH 26/69] Remove embeddings reference from comment in PromptNormalizer Update comment to be more generic by removing specific mention of embeddings use case. This ensures the PR is completely clean of embeddings references. --- src/Utils/PromptNormalizer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Utils/PromptNormalizer.php b/src/Utils/PromptNormalizer.php index d02e1e3a..d86b0bd8 100644 --- a/src/Utils/PromptNormalizer.php +++ b/src/Utils/PromptNormalizer.php @@ -83,7 +83,7 @@ public static function normalize($prompt): array return array_values(array_map(fn(MessagePart $part) => new UserMessage([$part]), $messageParts)); } - // Array of strings (common for embeddings) + // Array of strings if (is_string($firstElement)) { // Validate all elements are strings foreach ($prompt as $index => $item) { From b0b77d64b02c71b9e76d87371da34595384e2985 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 20 Aug 2025 16:19:52 +0300 Subject: [PATCH 27/69] Remove unnecessary AiClientInterface --- src/Contracts/AiClientInterface.php | 31 ----------------------------- 1 file changed, 31 deletions(-) delete mode 100644 src/Contracts/AiClientInterface.php diff --git a/src/Contracts/AiClientInterface.php b/src/Contracts/AiClientInterface.php deleted file mode 100644 index 2002bb78..00000000 --- a/src/Contracts/AiClientInterface.php +++ /dev/null @@ -1,31 +0,0 @@ - Date: Wed, 20 Aug 2025 16:49:09 +0300 Subject: [PATCH 28/69] Replace GenerationStrategyResolver with simple type checking --- src/AiClient.php | 55 +++++++++++------- src/Utils/GenerationStrategyResolver.php | 58 ------------------- tests/unit/AiClientTest.php | 4 +- .../Utils/GenerationStrategyResolverTest.php | 57 ------------------ 4 files changed, 37 insertions(+), 137 deletions(-) delete mode 100644 src/Utils/GenerationStrategyResolver.php delete mode 100644 tests/unit/Utils/GenerationStrategyResolverTest.php diff --git a/src/AiClient.php b/src/AiClient.php index 388b9dbc..10d91f01 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -11,9 +11,12 @@ use WordPress\AiClient\Operations\OperationFactory; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; +use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; +use WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts\SpeechGenerationModelInterface; +use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; +use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Results\DTO\GenerativeAiResult; -use WordPress\AiClient\Utils\GenerationStrategyResolver; use WordPress\AiClient\Utils\InterfaceValidator; use WordPress\AiClient\Utils\ModelDiscovery; use WordPress\AiClient\Utils\PromptNormalizer; @@ -79,33 +82,30 @@ public static function isConfigured(ProviderAvailabilityInterface $availability) /** * Creates a new prompt builder for fluent API usage. * - * This method will be implemented once PromptBuilder is available from PR #49. - * When available, PromptBuilder will support all generation types including: - * - Text generation via generateTextResult() - * - Image generation via generateImageResult() - * - Text-to-speech via convertTextToSpeechResult() - * - Speech generation via generateSpeechResult() + * This method will return an actual PromptBuilder instance once PR #49 is merged. + * The traditional API methods in this class will then delegate to PromptBuilder + * rather than implementing their own generation logic. * * @since n.e.x.t * * @param string|Message|null $text Optional initial prompt text or message. * @return object PromptBuilder instance (type will be updated when PromptBuilder is available). * - * @throws \RuntimeException When PromptBuilder is not yet available. + * @throws \RuntimeException Until PromptBuilder integration is complete. */ public static function prompt($text = null) { throw new \RuntimeException( - 'PromptBuilder is not yet available. This method depends on PR #49. ' . - 'All generation methods (text, image, text-to-speech, speech) are ready for integration.' + 'PromptBuilder integration pending. This method will return an actual PromptBuilder ' . + 'instance once PR #49 is merged, enabling the fluent API pattern.' ); } /** - * Generates content using a unified API that delegates to specific generation methods. + * Generates content using a unified API that automatically detects model capabilities. * - * This method automatically detects the model's capabilities and routes to the - * appropriate generation method (text, image, etc.). + * This method uses simple type checking to route to the appropriate generation method. + * In the future, this will be refactored to delegate to PromptBuilder when PR #49 is merged. * * @since n.e.x.t * @@ -113,16 +113,31 @@ public static function prompt($text = null) * @param ModelInterface $model The model to use for generation. * @return GenerativeAiResult The generation result. * - * @throws \InvalidArgumentException If the prompt format is invalid or model type is unsupported. + * @throws \InvalidArgumentException If the model doesn't support any known generation type. */ public static function generateResult($prompt, ModelInterface $model): GenerativeAiResult { - // Use strategy resolver to determine the appropriate method - $method = GenerationStrategyResolver::resolve($model); - - // Call the resolved method dynamically - /** @var GenerativeAiResult */ - return self::$method($prompt, $model); + // Simple type checking instead of over-engineered resolver + if ($model instanceof TextGenerationModelInterface) { + return self::generateTextResult($prompt, $model); + } + + if ($model instanceof ImageGenerationModelInterface) { + return self::generateImageResult($prompt, $model); + } + + if ($model instanceof TextToSpeechConversionModelInterface) { + return self::convertTextToSpeechResult($prompt, $model); + } + + if ($model instanceof SpeechGenerationModelInterface) { + return self::generateSpeechResult($prompt, $model); + } + + throw new \InvalidArgumentException( + 'Model must implement at least one supported generation interface ' . + '(TextGeneration, ImageGeneration, TextToSpeechConversion, SpeechGeneration)' + ); } /** diff --git a/src/Utils/GenerationStrategyResolver.php b/src/Utils/GenerationStrategyResolver.php deleted file mode 100644 index 750e83d8..00000000 --- a/src/Utils/GenerationStrategyResolver.php +++ /dev/null @@ -1,58 +0,0 @@ - 'generateTextResult', - ImageGenerationModelInterface::class => 'generateImageResult', - TextToSpeechConversionModelInterface::class => 'convertTextToSpeechResult', - SpeechGenerationModelInterface::class => 'generateSpeechResult', - ]; - - /** - * Resolves the appropriate generation method for a given model. - * - * @since n.e.x.t - * - * @param ModelInterface $model The model to resolve strategy for. - * @return string The method name to call for generation. - * - * @throws \InvalidArgumentException If no supported generation interface is found. - */ - public static function resolve(ModelInterface $model): string - { - foreach (self::GENERATION_STRATEGIES as $interface => $method) { - if ($model instanceof $interface) { - return $method; - } - } - - throw new \InvalidArgumentException( - 'Model must implement at least one supported generation interface ' . - '(TextGeneration, ImageGeneration, TextToSpeechConversion, SpeechGeneration)' - ); - } -} diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index 87d3e847..f9e41b3e 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -86,8 +86,8 @@ public function testPromptThrowsException(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage( - 'PromptBuilder is not yet available. This method depends on PR #49. ' . - 'All generation methods (text, image, text-to-speech, speech) are ready for integration.' + 'PromptBuilder integration pending. This method will return an actual PromptBuilder ' . + 'instance once PR #49 is merged, enabling the fluent API pattern.' ); AiClient::prompt('Test prompt'); diff --git a/tests/unit/Utils/GenerationStrategyResolverTest.php b/tests/unit/Utils/GenerationStrategyResolverTest.php deleted file mode 100644 index 1aac8470..00000000 --- a/tests/unit/Utils/GenerationStrategyResolverTest.php +++ /dev/null @@ -1,57 +0,0 @@ -createMock(MockTextGenerationModel::class); - - $method = GenerationStrategyResolver::resolve($model); - - $this->assertEquals('generateTextResult', $method); - } - - /** - * Tests resolve returns correct method for image generation model. - */ - public function testResolveReturnsImageGenerationMethod(): void - { - $model = $this->createMock(MockImageGenerationModel::class); - - $method = GenerationStrategyResolver::resolve($model); - - $this->assertEquals('generateImageResult', $method); - } - - /** - * Tests resolve throws exception for unsupported model. - */ - public function testResolveThrowsExceptionForUnsupportedModel(): void - { - $model = $this->createMock(ModelInterface::class); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Model must implement at least one supported generation interface ' . - '(TextGeneration, ImageGeneration, TextToSpeechConversion, SpeechGeneration)' - ); - - GenerationStrategyResolver::resolve($model); - } -} From f3baee5cdba2f46a3293868b92b9dcc37a77b8b7 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 20 Aug 2025 16:51:42 +0300 Subject: [PATCH 29/69] Fix trailing whitespace in AiClient --- src/AiClient.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index 10d91f01..2e157360 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -121,19 +121,19 @@ public static function generateResult($prompt, ModelInterface $model): Generativ if ($model instanceof TextGenerationModelInterface) { return self::generateTextResult($prompt, $model); } - + if ($model instanceof ImageGenerationModelInterface) { return self::generateImageResult($prompt, $model); } - + if ($model instanceof TextToSpeechConversionModelInterface) { return self::convertTextToSpeechResult($prompt, $model); } - + if ($model instanceof SpeechGenerationModelInterface) { return self::generateSpeechResult($prompt, $model); } - + throw new \InvalidArgumentException( 'Model must implement at least one supported generation interface ' . '(TextGeneration, ImageGeneration, TextToSpeechConversion, SpeechGeneration)' From c75f01acbdfd43d206d793abca64c0487b05495a Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 20 Aug 2025 17:01:19 +0300 Subject: [PATCH 30/69] Add phpstan-assert annotations to eliminate ignore comments --- src/AiClient.php | 5 ----- src/Utils/InterfaceValidator.php | 10 ++++++++++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index 2e157360..f6339ed1 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -187,19 +187,16 @@ private static function executeGeneration($prompt, ?ModelInterface $model, strin case 'text': $resolvedModel = $model ?? ModelDiscovery::findTextModel(self::defaultRegistry()); InterfaceValidator::validateTextGeneration($resolvedModel); - /** @phpstan-ignore-next-line */ return $resolvedModel->generateTextResult($messageList); case 'image': $resolvedModel = $model ?? ModelDiscovery::findImageModel(self::defaultRegistry()); InterfaceValidator::validateImageGeneration($resolvedModel); - /** @phpstan-ignore-next-line */ return $resolvedModel->generateImageResult($messageList); case 'speech': $resolvedModel = $model ?? ModelDiscovery::findSpeechModel(self::defaultRegistry()); InterfaceValidator::validateSpeechGeneration($resolvedModel); - /** @phpstan-ignore-next-line */ return $resolvedModel->generateSpeechResult($messageList); default: @@ -250,7 +247,6 @@ public static function streamGenerateTextResult($prompt, ModelInterface $model = InterfaceValidator::validateTextGeneration($resolvedModel); // Stream the results using the model - /** @phpstan-ignore-next-line */ yield from $resolvedModel->streamGenerateTextResult($messageList); } @@ -297,7 +293,6 @@ public static function convertTextToSpeechResult($prompt, ModelInterface $model InterfaceValidator::validateTextToSpeechConversion($resolvedModel); // Generate the result using the model - /** @phpstan-ignore-next-line */ return $resolvedModel->convertTextToSpeechResult($messageList); } diff --git a/src/Utils/InterfaceValidator.php b/src/Utils/InterfaceValidator.php index 4f6e95dd..f97481ff 100644 --- a/src/Utils/InterfaceValidator.php +++ b/src/Utils/InterfaceValidator.php @@ -11,6 +11,8 @@ use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionModelInterface; use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionOperationModelInterface; +use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationOperationModelInterface; +use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationOperationModelInterface; /** * Utility class for validating model interface implementations. @@ -29,6 +31,7 @@ class InterfaceValidator * * @param ModelInterface $model The model to validate. * @return void + * @phpstan-assert TextGenerationModelInterface $model * * @throws \InvalidArgumentException If the model doesn't implement the required interface. */ @@ -48,6 +51,7 @@ public static function validateTextGeneration(ModelInterface $model): void * * @param ModelInterface $model The model to validate. * @return void + * @phpstan-assert ImageGenerationModelInterface $model * * @throws \InvalidArgumentException If the model doesn't implement the required interface. */ @@ -67,6 +71,7 @@ public static function validateImageGeneration(ModelInterface $model): void * * @param ModelInterface $model The model to validate. * @return void + * @phpstan-assert TextToSpeechConversionModelInterface $model * * @throws \InvalidArgumentException If the model doesn't implement the required interface. */ @@ -86,6 +91,7 @@ public static function validateTextToSpeechConversion(ModelInterface $model): vo * * @param ModelInterface $model The model to validate. * @return void + * @phpstan-assert SpeechGenerationModelInterface $model * * @throws \InvalidArgumentException If the model doesn't implement the required interface. */ @@ -106,6 +112,7 @@ public static function validateSpeechGeneration(ModelInterface $model): void * * @param ModelInterface $model The model to validate. * @return void + * @phpstan-assert TextGenerationModelInterface $model * * @throws \InvalidArgumentException If the model doesn't implement the required interface. */ @@ -125,6 +132,7 @@ public static function validateTextGenerationOperation(ModelInterface $model): v * * @param ModelInterface $model The model to validate. * @return void + * @phpstan-assert ImageGenerationModelInterface $model * * @throws \InvalidArgumentException If the model doesn't implement the required interface. */ @@ -144,6 +152,7 @@ public static function validateImageGenerationOperation(ModelInterface $model): * * @param ModelInterface $model The model to validate. * @return void + * @phpstan-assert TextToSpeechConversionOperationModelInterface $model * * @throws \InvalidArgumentException If the model doesn't implement the required interface. */ @@ -164,6 +173,7 @@ public static function validateTextToSpeechConversionOperation(ModelInterface $m * * @param ModelInterface $model The model to validate. * @return void + * @phpstan-assert SpeechGenerationOperationModelInterface $model * * @throws \InvalidArgumentException If the model doesn't implement the required interface. */ From 79f4eeb051e728e0f3230251820d2f1fcbc5c736 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 20 Aug 2025 17:14:59 +0300 Subject: [PATCH 31/69] Consolidate InterfaceValidator and ModelDiscovery into Models utility --- src/AiClient.php | 31 ++- src/Utils/ModelDiscovery.php | 120 ----------- .../{InterfaceValidator.php => Models.php} | 120 ++++++++++- tests/unit/Utils/InterfaceValidatorTest.php | 134 ------------ tests/unit/Utils/ModelDiscoveryTest.php | 103 ---------- tests/unit/Utils/ModelsTest.php | 192 ++++++++++++++++++ 6 files changed, 319 insertions(+), 381 deletions(-) delete mode 100644 src/Utils/ModelDiscovery.php rename src/Utils/{InterfaceValidator.php => Models.php} (63%) delete mode 100644 tests/unit/Utils/InterfaceValidatorTest.php delete mode 100644 tests/unit/Utils/ModelDiscoveryTest.php create mode 100644 tests/unit/Utils/ModelsTest.php diff --git a/src/AiClient.php b/src/AiClient.php index f6339ed1..1f76229e 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -17,8 +17,7 @@ use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Results\DTO\GenerativeAiResult; -use WordPress\AiClient\Utils\InterfaceValidator; -use WordPress\AiClient\Utils\ModelDiscovery; +use WordPress\AiClient\Utils\Models; use WordPress\AiClient\Utils\PromptNormalizer; /** @@ -185,18 +184,18 @@ private static function executeGeneration($prompt, ?ModelInterface $model, strin // Map type to specific methods switch ($type) { case 'text': - $resolvedModel = $model ?? ModelDiscovery::findTextModel(self::defaultRegistry()); - InterfaceValidator::validateTextGeneration($resolvedModel); + $resolvedModel = $model ?? Models::findTextModel(self::defaultRegistry()); + Models::validateTextGeneration($resolvedModel); return $resolvedModel->generateTextResult($messageList); case 'image': - $resolvedModel = $model ?? ModelDiscovery::findImageModel(self::defaultRegistry()); - InterfaceValidator::validateImageGeneration($resolvedModel); + $resolvedModel = $model ?? Models::findImageModel(self::defaultRegistry()); + Models::validateImageGeneration($resolvedModel); return $resolvedModel->generateImageResult($messageList); case 'speech': - $resolvedModel = $model ?? ModelDiscovery::findSpeechModel(self::defaultRegistry()); - InterfaceValidator::validateSpeechGeneration($resolvedModel); + $resolvedModel = $model ?? Models::findSpeechModel(self::defaultRegistry()); + Models::validateSpeechGeneration($resolvedModel); return $resolvedModel->generateSpeechResult($messageList); default: @@ -241,10 +240,10 @@ public static function streamGenerateTextResult($prompt, ModelInterface $model = $messageList = array_values($messages); // Get model - either provided or auto-discovered - $resolvedModel = $model ?? ModelDiscovery::findTextModel(self::defaultRegistry()); + $resolvedModel = $model ?? Models::findTextModel(self::defaultRegistry()); // Validate model supports text generation - InterfaceValidator::validateTextGeneration($resolvedModel); + Models::validateTextGeneration($resolvedModel); // Stream the results using the model yield from $resolvedModel->streamGenerateTextResult($messageList); @@ -287,10 +286,10 @@ public static function convertTextToSpeechResult($prompt, ModelInterface $model $messageList = array_values($messages); // Get model - either provided or auto-discovered - $resolvedModel = $model ?? ModelDiscovery::findTextToSpeechModel(self::defaultRegistry()); + $resolvedModel = $model ?? Models::findTextToSpeechModel(self::defaultRegistry()); // Validate model supports text-to-speech conversion - InterfaceValidator::validateTextToSpeechConversion($resolvedModel); + Models::validateTextToSpeechConversion($resolvedModel); // Generate the result using the model return $resolvedModel->convertTextToSpeechResult($messageList); @@ -354,7 +353,7 @@ public static function generateTextOperation($prompt, ModelInterface $model): Ge /** @var list $messageList */ $messageList = array_values($messages); - InterfaceValidator::validateTextGenerationOperation($model); + Models::validateTextGenerationOperation($model); return OperationFactory::createTextOperation($messageList); } @@ -375,7 +374,7 @@ public static function generateImageOperation($prompt, ModelInterface $model): G /** @var list $messageList */ $messageList = array_values($messages); - InterfaceValidator::validateImageGenerationOperation($model); + Models::validateImageGenerationOperation($model); return OperationFactory::createImageOperation($messageList); } @@ -396,7 +395,7 @@ public static function convertTextToSpeechOperation($prompt, ModelInterface $mod /** @var list $messageList */ $messageList = array_values($messages); - InterfaceValidator::validateTextToSpeechConversionOperation($model); + Models::validateTextToSpeechConversionOperation($model); return OperationFactory::createTextToSpeechOperation($messageList); } @@ -417,7 +416,7 @@ public static function generateSpeechOperation($prompt, ModelInterface $model): /** @var list $messageList */ $messageList = array_values($messages); - InterfaceValidator::validateSpeechGenerationOperation($model); + Models::validateSpeechGenerationOperation($model); return OperationFactory::createSpeechOperation($messageList); } } diff --git a/src/Utils/ModelDiscovery.php b/src/Utils/ModelDiscovery.php deleted file mode 100644 index b49d9511..00000000 --- a/src/Utils/ModelDiscovery.php +++ /dev/null @@ -1,120 +0,0 @@ -findModelsMetadataForSupport($requirements); - - if (empty($providerModelsMetadata)) { - throw new \RuntimeException("No {$errorType} models available"); - } - - // Get the first suitable provider and model - $providerMetadata = $providerModelsMetadata[0]; - $models = $providerMetadata->getModels(); - - if (empty($models)) { - throw new \RuntimeException('No models available in provider'); - } - - return $registry->getProviderModel( - $providerMetadata->getProvider()->getId(), - $models[0]->getId() - ); - } - - /** - * Finds a suitable text generation model from the registry. - * - * @since n.e.x.t - * - * @param ProviderRegistry $registry The provider registry to search. - * @return ModelInterface A suitable text generation model. - * - * @throws \RuntimeException If no suitable model is found. - */ - public static function findTextModel(ProviderRegistry $registry): ModelInterface - { - return self::findModelByCapability($registry, CapabilityEnum::textGeneration(), 'text generation'); - } - - /** - * Finds a suitable image generation model from the registry. - * - * @since n.e.x.t - * - * @param ProviderRegistry $registry The provider registry to search. - * @return ModelInterface A suitable image generation model. - * - * @throws \RuntimeException If no suitable model is found. - */ - public static function findImageModel(ProviderRegistry $registry): ModelInterface - { - return self::findModelByCapability($registry, CapabilityEnum::imageGeneration(), 'image generation'); - } - - /** - * Finds a suitable text-to-speech conversion model from the registry. - * - * @since n.e.x.t - * - * @param ProviderRegistry $registry The provider registry to search. - * @return ModelInterface A suitable text-to-speech conversion model. - * - * @throws \RuntimeException If no suitable model is found. - */ - public static function findTextToSpeechModel(ProviderRegistry $registry): ModelInterface - { - return self::findModelByCapability( - $registry, - CapabilityEnum::textToSpeechConversion(), - 'text-to-speech conversion' - ); - } - - /** - * Finds a suitable speech generation model from the registry. - * - * @since n.e.x.t - * - * @param ProviderRegistry $registry The provider registry to search. - * @return ModelInterface A suitable speech generation model. - * - * @throws \RuntimeException If no suitable model is found. - */ - public static function findSpeechModel(ProviderRegistry $registry): ModelInterface - { - return self::findModelByCapability($registry, CapabilityEnum::speechGeneration(), 'speech generation'); - } -} diff --git a/src/Utils/InterfaceValidator.php b/src/Utils/Models.php similarity index 63% rename from src/Utils/InterfaceValidator.php rename to src/Utils/Models.php index f97481ff..7c1aed33 100644 --- a/src/Utils/InterfaceValidator.php +++ b/src/Utils/Models.php @@ -5,25 +5,130 @@ namespace WordPress\AiClient\Utils; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; +use WordPress\AiClient\Providers\Models\DTO\ModelRequirements; +use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; +use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationOperationModelInterface; use WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts\SpeechGenerationModelInterface; use WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts\SpeechGenerationOperationModelInterface; use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; +use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationOperationModelInterface; use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionModelInterface; use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionOperationModelInterface; -use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationOperationModelInterface; -use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationOperationModelInterface; +use WordPress\AiClient\Providers\ProviderRegistry; /** - * Utility class for validating model interface implementations. + * Utility class for model discovery and interface validation. * - * Centralizes interface validation logic to reduce code duplication - * and provide consistent error messages across the AI Client. + * Combines model auto-discovery capabilities with interface validation + * to provide a unified model utility service. * * @since n.e.x.t */ -class InterfaceValidator +class Models { + /** + * Generic method to find a model by capability. + * + * @since n.e.x.t + * + * @param ProviderRegistry $registry The provider registry to search. + * @param CapabilityEnum $capability The required capability. + * @param string $errorType The error description type. + * @return ModelInterface A suitable model. + * + * @throws \RuntimeException If no suitable model is found. + */ + private static function findModelByCapability( + ProviderRegistry $registry, + CapabilityEnum $capability, + string $errorType + ): ModelInterface { + $requirements = new ModelRequirements([$capability], []); + $providerModelsMetadata = $registry->findModelsMetadataForSupport($requirements); + + if (empty($providerModelsMetadata)) { + throw new \RuntimeException("No {$errorType} models available"); + } + + // Get the first suitable provider and model + $providerMetadata = $providerModelsMetadata[0]; + $models = $providerMetadata->getModels(); + + if (empty($models)) { + throw new \RuntimeException('No models available in provider'); + } + + return $registry->getProviderModel( + $providerMetadata->getProvider()->getId(), + $models[0]->getId() + ); + } + + /** + * Finds a suitable text generation model from the registry. + * + * @since n.e.x.t + * + * @param ProviderRegistry $registry The provider registry to search. + * @return ModelInterface A suitable text generation model. + * + * @throws \RuntimeException If no suitable model is found. + */ + public static function findTextModel(ProviderRegistry $registry): ModelInterface + { + return self::findModelByCapability($registry, CapabilityEnum::textGeneration(), 'text generation'); + } + + /** + * Finds a suitable image generation model from the registry. + * + * @since n.e.x.t + * + * @param ProviderRegistry $registry The provider registry to search. + * @return ModelInterface A suitable image generation model. + * + * @throws \RuntimeException If no suitable model is found. + */ + public static function findImageModel(ProviderRegistry $registry): ModelInterface + { + return self::findModelByCapability($registry, CapabilityEnum::imageGeneration(), 'image generation'); + } + + /** + * Finds a suitable text-to-speech conversion model from the registry. + * + * @since n.e.x.t + * + * @param ProviderRegistry $registry The provider registry to search. + * @return ModelInterface A suitable text-to-speech conversion model. + * + * @throws \RuntimeException If no suitable model is found. + */ + public static function findTextToSpeechModel(ProviderRegistry $registry): ModelInterface + { + return self::findModelByCapability( + $registry, + CapabilityEnum::textToSpeechConversion(), + 'text-to-speech conversion' + ); + } + + /** + * Finds a suitable speech generation model from the registry. + * + * @since n.e.x.t + * + * @param ProviderRegistry $registry The provider registry to search. + * @return ModelInterface A suitable speech generation model. + * + * @throws \RuntimeException If no suitable model is found. + */ + public static function findSpeechModel(ProviderRegistry $registry): ModelInterface + { + return self::findModelByCapability($registry, CapabilityEnum::speechGeneration(), 'speech generation'); + } + /** * Validates that a model implements TextGenerationModelInterface. * @@ -104,7 +209,6 @@ public static function validateSpeechGeneration(ModelInterface $model): void } } - /** * Validates that a model implements TextGenerationModelInterface for operations. * @@ -186,4 +290,4 @@ public static function validateSpeechGenerationOperation(ModelInterface $model): ); } } -} +} \ No newline at end of file diff --git a/tests/unit/Utils/InterfaceValidatorTest.php b/tests/unit/Utils/InterfaceValidatorTest.php deleted file mode 100644 index f5f6e08f..00000000 --- a/tests/unit/Utils/InterfaceValidatorTest.php +++ /dev/null @@ -1,134 +0,0 @@ -createMock(MockTextGenerationModel::class); - - // Should not throw an exception - InterfaceValidator::validateTextGeneration($model); - - // If we reach here, validation passed - $this->assertTrue(true); - } - - /** - * Tests validateTextGeneration with invalid model. - */ - public function testValidateTextGenerationWithInvalidModel(): void - { - $model = $this->createMock(ModelInterface::class); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Model must implement TextGenerationModelInterface for text generation' - ); - - InterfaceValidator::validateTextGeneration($model); - } - - /** - * Tests validateImageGeneration with valid image model. - */ - public function testValidateImageGenerationWithValidModel(): void - { - $model = $this->createMock(MockImageGenerationModel::class); - - // Should not throw an exception - InterfaceValidator::validateImageGeneration($model); - - // If we reach here, validation passed - $this->assertTrue(true); - } - - /** - * Tests validateImageGeneration with invalid model. - */ - public function testValidateImageGenerationWithInvalidModel(): void - { - $model = $this->createMock(ModelInterface::class); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Model must implement ImageGenerationModelInterface for image generation' - ); - - InterfaceValidator::validateImageGeneration($model); - } - - - /** - * Tests validateTextGenerationOperation with valid text model. - */ - public function testValidateTextGenerationOperationWithValidModel(): void - { - $model = $this->createMock(MockTextGenerationModel::class); - - // Should not throw an exception - InterfaceValidator::validateTextGenerationOperation($model); - - // If we reach here, validation passed - $this->assertTrue(true); - } - - /** - * Tests validateTextGenerationOperation with invalid model. - */ - public function testValidateTextGenerationOperationWithInvalidModel(): void - { - $model = $this->createMock(ModelInterface::class); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Model must implement TextGenerationModelInterface for text generation operations' - ); - - InterfaceValidator::validateTextGenerationOperation($model); - } - - /** - * Tests validateImageGenerationOperation with valid image model. - */ - public function testValidateImageGenerationOperationWithValidModel(): void - { - $model = $this->createMock(MockImageGenerationModel::class); - - // Should not throw an exception - InterfaceValidator::validateImageGenerationOperation($model); - - // If we reach here, validation passed - $this->assertTrue(true); - } - - /** - * Tests validateImageGenerationOperation with invalid model. - */ - public function testValidateImageGenerationOperationWithInvalidModel(): void - { - $model = $this->createMock(ModelInterface::class); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Model must implement ImageGenerationModelInterface for image generation operations' - ); - - InterfaceValidator::validateImageGenerationOperation($model); - } -} diff --git a/tests/unit/Utils/ModelDiscoveryTest.php b/tests/unit/Utils/ModelDiscoveryTest.php deleted file mode 100644 index 9dc47a90..00000000 --- a/tests/unit/Utils/ModelDiscoveryTest.php +++ /dev/null @@ -1,103 +0,0 @@ -registry = $this->createMock(ProviderRegistry::class); - } - - /** - * Tests findTextModel throws exception when no models available. - */ - public function testFindTextModelThrowsExceptionWhenNoModelsAvailable(): void - { - $this->registry->expects($this->once()) - ->method('findModelsMetadataForSupport') - ->willReturn([]); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('No text generation models available'); - - ModelDiscovery::findTextModel($this->registry); - } - - /** - * Tests findImageModel throws exception when no models available. - */ - public function testFindImageModelThrowsExceptionWhenNoModelsAvailable(): void - { - $this->registry->expects($this->once()) - ->method('findModelsMetadataForSupport') - ->willReturn([]); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('No image generation models available'); - - ModelDiscovery::findImageModel($this->registry); - } - - /** - * Tests findTextToSpeechModel throws exception when no models available. - */ - public function testFindTextToSpeechModelThrowsExceptionWhenNoModelsAvailable(): void - { - $this->registry->expects($this->once()) - ->method('findModelsMetadataForSupport') - ->willReturn([]); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('No text-to-speech conversion models available'); - - ModelDiscovery::findTextToSpeechModel($this->registry); - } - - /** - * Tests findSpeechModel throws exception when no models available. - */ - public function testFindSpeechModelThrowsExceptionWhenNoModelsAvailable(): void - { - $this->registry->expects($this->once()) - ->method('findModelsMetadataForSupport') - ->willReturn([]); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('No speech generation models available'); - - ModelDiscovery::findSpeechModel($this->registry); - } - - - /** - * Tests that ModelDiscovery properly passes capability requirements to registry. - */ - public function testModelDiscoveryPassesCorrectCapabilityRequirements(): void - { - // Mock registry to capture the ModelRequirements parameter - $this->registry->expects($this->once()) - ->method('findModelsMetadataForSupport') - ->with($this->callback(function ($requirements) { - // Verify that the ModelRequirements contains the expected capability - $capabilities = $requirements->getRequiredCapabilities(); - return count($capabilities) === 1 && - $capabilities[0]->value === 'text_generation'; - })) - ->willReturn([]); - - $this->expectException(\RuntimeException::class); - ModelDiscovery::findTextModel($this->registry); - } -} diff --git a/tests/unit/Utils/ModelsTest.php b/tests/unit/Utils/ModelsTest.php new file mode 100644 index 00000000..1ead730b --- /dev/null +++ b/tests/unit/Utils/ModelsTest.php @@ -0,0 +1,192 @@ +registry = new ProviderRegistry(); + + $mockMetadata = $this->createMock(ModelMetadata::class); + $mockConfig = $this->createMock(ModelConfig::class); + + $this->mockTextModel = new MockTextGenerationModel(); + $this->mockImageModel = new MockImageGenerationModel(); + $this->mockModel = new MockModel($mockMetadata, $mockConfig); + } + + /** + * Tests that validateTextGeneration passes with valid model. + */ + public function testValidateTextGenerationPassesWithValidModel(): void + { + $this->expectNotToPerformAssertions(); + Models::validateTextGeneration($this->mockTextModel); + } + + /** + * Tests that validateTextGeneration throws exception with invalid model. + */ + public function testValidateTextGenerationThrowsExceptionWithInvalidModel(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Model must implement TextGenerationModelInterface for text generation'); + + Models::validateTextGeneration($this->mockModel); + } + + /** + * Tests that validateImageGeneration passes with valid model. + */ + public function testValidateImageGenerationPassesWithValidModel(): void + { + $this->expectNotToPerformAssertions(); + Models::validateImageGeneration($this->mockImageModel); + } + + /** + * Tests that validateImageGeneration throws exception with invalid model. + */ + public function testValidateImageGenerationThrowsExceptionWithInvalidModel(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Model must implement ImageGenerationModelInterface for image generation'); + + Models::validateImageGeneration($this->mockModel); + } + + /** + * Tests that validateTextToSpeechConversion throws exception with invalid model. + */ + public function testValidateTextToSpeechConversionThrowsExceptionWithInvalidModel(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Model must implement TextToSpeechConversionModelInterface for text-to-speech conversion'); + + Models::validateTextToSpeechConversion($this->mockModel); + } + + /** + * Tests that validateSpeechGeneration throws exception with invalid model. + */ + public function testValidateSpeechGenerationThrowsExceptionWithInvalidModel(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Model must implement SpeechGenerationModelInterface for speech generation'); + + Models::validateSpeechGeneration($this->mockModel); + } + + /** + * Tests that validateTextGenerationOperation throws exception with invalid model. + */ + public function testValidateTextGenerationOperationThrowsExceptionWithInvalidModel(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Model must implement TextGenerationModelInterface for text generation operations'); + + Models::validateTextGenerationOperation($this->mockModel); + } + + /** + * Tests that validateImageGenerationOperation throws exception with invalid model. + */ + public function testValidateImageGenerationOperationThrowsExceptionWithInvalidModel(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Model must implement ImageGenerationModelInterface for image generation operations'); + + Models::validateImageGenerationOperation($this->mockModel); + } + + /** + * Tests that validateTextToSpeechConversionOperation throws exception with invalid model. + */ + public function testValidateTextToSpeechConversionOperationThrowsExceptionWithInvalidModel(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Model must implement TextToSpeechConversionOperationModelInterface for text-to-speech conversion operations'); + + Models::validateTextToSpeechConversionOperation($this->mockModel); + } + + /** + * Tests that validateSpeechGenerationOperation throws exception with invalid model. + */ + public function testValidateSpeechGenerationOperationThrowsExceptionWithInvalidModel(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Model must implement SpeechGenerationOperationModelInterface for speech generation operations'); + + Models::validateSpeechGenerationOperation($this->mockModel); + } + + /** + * Tests that findTextModel throws exception when no models available. + */ + public function testFindTextModelThrowsExceptionWhenNoModelsAvailable(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No text generation models available'); + + Models::findTextModel($this->registry); + } + + /** + * Tests that findImageModel throws exception when no models available. + */ + public function testFindImageModelThrowsExceptionWhenNoModelsAvailable(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No image generation models available'); + + Models::findImageModel($this->registry); + } + + /** + * Tests that findTextToSpeechModel throws exception when no models available. + */ + public function testFindTextToSpeechModelThrowsExceptionWhenNoModelsAvailable(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No text-to-speech conversion models available'); + + Models::findTextToSpeechModel($this->registry); + } + + /** + * Tests that findSpeechModel throws exception when no models available. + */ + public function testFindSpeechModelThrowsExceptionWhenNoModelsAvailable(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No speech generation models available'); + + Models::findSpeechModel($this->registry); + } +} \ No newline at end of file From 8ee4a1f81a6d2813d66e034fdd44638b3d1d3ca8 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 20 Aug 2025 17:15:42 +0300 Subject: [PATCH 32/69] Fix whitespace and remove unused imports in Models utility --- src/Utils/Models.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Utils/Models.php b/src/Utils/Models.php index 7c1aed33..7530eac8 100644 --- a/src/Utils/Models.php +++ b/src/Utils/Models.php @@ -8,11 +8,9 @@ use WordPress\AiClient\Providers\Models\DTO\ModelRequirements; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; -use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationOperationModelInterface; use WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts\SpeechGenerationModelInterface; use WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts\SpeechGenerationOperationModelInterface; use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; -use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationOperationModelInterface; use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionModelInterface; use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionOperationModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; @@ -290,4 +288,4 @@ public static function validateSpeechGenerationOperation(ModelInterface $model): ); } } -} \ No newline at end of file +} From b9c4aa696f6fd16b66f7f8ca397c7571e9d69c0d Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 20 Aug 2025 17:24:38 +0300 Subject: [PATCH 33/69] Simplify PromptNormalizer with cleaner loop-based approach --- src/Utils/PromptNormalizer.php | 112 ++++++++-------------- tests/unit/Utils/PromptNormalizerTest.php | 6 +- 2 files changed, 44 insertions(+), 74 deletions(-) diff --git a/src/Utils/PromptNormalizer.php b/src/Utils/PromptNormalizer.php index d86b0bd8..ce1df058 100644 --- a/src/Utils/PromptNormalizer.php +++ b/src/Utils/PromptNormalizer.php @@ -20,93 +20,63 @@ class PromptNormalizer * * @since n.e.x.t * - * @param string|string[]|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content in various formats. + * @param string|MessagePart|Message|list $prompt The prompt content in various formats. * @return list Array of Message objects. * * @throws \InvalidArgumentException If the prompt format is invalid. */ public static function normalize($prompt): array { - // Handle string input - if (is_string($prompt)) { - return [new UserMessage([new MessagePart($prompt)])]; + // Normalize to array first for consistent processing + if (!is_array($prompt)) { + $prompt = [$prompt]; } - // Handle single MessagePart - if ($prompt instanceof MessagePart) { - return [new UserMessage([$prompt])]; + // Empty array check + if (empty($prompt)) { + throw new \InvalidArgumentException('Prompt array cannot be empty'); } - // Handle single Message - if ($prompt instanceof Message) { - return [$prompt]; + // Process each item individually + $messages = []; + foreach ($prompt as $index => $item) { + $messages[] = self::normalizeItem($item, $index); } - // Handle arrays - if (is_array($prompt)) { - // Empty array - if (empty($prompt)) { - throw new \InvalidArgumentException('Prompt array cannot be empty'); - } - - // Check first element to determine array type - $firstElement = reset($prompt); - - // Array of Messages - if ($firstElement instanceof Message) { - // Validate all elements are Messages - foreach ($prompt as $item) { - if (!$item instanceof Message) { - throw new \InvalidArgumentException( - 'Array must contain only Message, MessagePart, or string objects' - ); - } - } - /** @var Message[] $messages */ - $messages = $prompt; - return array_values($messages); - } + return $messages; + } - // Array of MessageParts - if ($firstElement instanceof MessagePart) { - // Validate all elements are MessageParts - foreach ($prompt as $item) { - if (!$item instanceof MessagePart) { - throw new \InvalidArgumentException( - 'Array must contain only Message, MessagePart, or string objects' - ); - } - } - // Convert each MessagePart to a UserMessage - /** @var MessagePart[] $messageParts */ - $messageParts = $prompt; - return array_values(array_map(fn(MessagePart $part) => new UserMessage([$part]), $messageParts)); - } + /** + * Normalizes a single prompt item to a Message. + * + * @since n.e.x.t + * + * @param string|MessagePart|Message $item The prompt item to normalize. + * @param int $index The array index for error reporting. + * @return Message The normalized message. + * + * @throws \InvalidArgumentException If the item format is invalid. + */ + private static function normalizeItem($item, int $index): Message + { + if (is_string($item)) { + return new UserMessage([new MessagePart($item)]); + } - // Array of strings - if (is_string($firstElement)) { - // Validate all elements are strings - foreach ($prompt as $index => $item) { - if (!is_string($item)) { - throw new \InvalidArgumentException( - sprintf('Array element at index %d must be a string, %s given', $index, gettype($item)) - ); - } - } - // Convert each string to a UserMessage - /** @var string[] $stringArray */ - $stringArray = $prompt; - return array_values(array_map( - fn(string $text) => new UserMessage([new MessagePart($text)]), - $stringArray - )); - } + if ($item instanceof MessagePart) { + return new UserMessage([$item]); + } - // Invalid array content - throw new \InvalidArgumentException('Array must contain only Message, MessagePart, or string objects'); + if ($item instanceof Message) { + return $item; } - // Unsupported type - throw new \InvalidArgumentException('Invalid prompt format provided'); + throw new \InvalidArgumentException( + sprintf( + 'Array element at index %d must be a string, MessagePart, or Message, %s given', + $index, + gettype($item) + ) + ); } } diff --git a/tests/unit/Utils/PromptNormalizerTest.php b/tests/unit/Utils/PromptNormalizerTest.php index aa06b08c..d17507c3 100644 --- a/tests/unit/Utils/PromptNormalizerTest.php +++ b/tests/unit/Utils/PromptNormalizerTest.php @@ -109,7 +109,7 @@ public function testNormalizeMixedArrayThrowsException(): void $invalidArray = [$part, 'string', 123]; $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Array must contain only Message, MessagePart, or string objects'); + $this->expectExceptionMessage('Array element at index 2 must be a string, MessagePart, or Message, integer given'); PromptNormalizer::normalize($invalidArray); } @@ -120,7 +120,7 @@ public function testNormalizeMixedArrayThrowsException(): void public function testNormalizeInvalidInputThrowsException(): void { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid prompt format provided'); + $this->expectExceptionMessage('Array element at index 0 must be a string, MessagePart, or Message, integer given'); PromptNormalizer::normalize(123); } @@ -133,7 +133,7 @@ public function testNormalizeArrayWithInvalidObjectsThrowsException(): void $invalidArray = [new \stdClass(), new \DateTime()]; $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Array must contain only Message, MessagePart, or string objects'); + $this->expectExceptionMessage('Array element at index 0 must be a string, MessagePart, or Message, object given'); PromptNormalizer::normalize($invalidArray); } From ce4c14a8c6ff7dace95cc6976dbd8a334899b659 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 20 Aug 2025 17:34:06 +0300 Subject: [PATCH 34/69] Fix nullable type annotations and prepare for PromptBuilder integration --- src/AiClient.php | 47 ++++++++++++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index 1f76229e..c5c89ac1 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -108,7 +108,7 @@ public static function prompt($text = null) * * @since n.e.x.t * - * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. + * @param string|MessagePart|Message|list $prompt The prompt content. * @param ModelInterface $model The model to use for generation. * @return GenerativeAiResult The generation result. * @@ -164,9 +164,13 @@ public static function message(?string $text = null) /** * Template method for executing generation operations. * + * NOTE: This method currently uses PromptNormalizer directly, but will be refactored + * to delegate to PromptBuilder once PR #49 is merged, following the architectural + * pattern where traditional API methods wrap the fluent builder API. + * * @since n.e.x.t * - * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. + * @param string|MessagePart|Message|list $prompt The prompt content. * @param ModelInterface|null $model Optional specific model to use. * @param string $type The generation type. * @return GenerativeAiResult The generation result. @@ -176,7 +180,8 @@ public static function message(?string $text = null) */ private static function executeGeneration($prompt, ?ModelInterface $model, string $type): GenerativeAiResult { - // Convert prompt to standardized Message array format + // TODO: Replace with PromptBuilder delegation once PR #49 is merged + // This should become: return self::prompt($prompt)->usingModel($model)->generate(); $messages = PromptNormalizer::normalize($prompt); /** @var list $messageList */ $messageList = array_values($messages); @@ -208,14 +213,14 @@ private static function executeGeneration($prompt, ?ModelInterface $model, strin * * @since n.e.x.t * - * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. + * @param string|MessagePart|Message|list $prompt The prompt content. * @param ModelInterface|null $model Optional specific model to use. * @return GenerativeAiResult The generation result. * * @throws \InvalidArgumentException If the prompt format is invalid. * @throws \RuntimeException If no suitable model is found. */ - public static function generateTextResult($prompt, ModelInterface $model = null): GenerativeAiResult + public static function generateTextResult($prompt, ?ModelInterface $model = null): GenerativeAiResult { return self::executeGeneration($prompt, $model, 'text'); } @@ -225,16 +230,16 @@ public static function generateTextResult($prompt, ModelInterface $model = null) * * @since n.e.x.t * - * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. + * @param string|MessagePart|Message|list $prompt The prompt content. * @param ModelInterface|null $model Optional specific model to use. * @return Generator Generator yielding partial text generation results. * * @throws \InvalidArgumentException If the prompt format is invalid. * @throws \RuntimeException If no suitable model is found. */ - public static function streamGenerateTextResult($prompt, ModelInterface $model = null): Generator + public static function streamGenerateTextResult($prompt, ?ModelInterface $model = null): Generator { - // Convert prompt to standardized Message array format + // TODO: Replace with PromptBuilder delegation once PR #49 is merged $messages = PromptNormalizer::normalize($prompt); /** @var list $messageList */ $messageList = array_values($messages); @@ -254,14 +259,14 @@ public static function streamGenerateTextResult($prompt, ModelInterface $model = * * @since n.e.x.t * - * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. + * @param string|MessagePart|Message|list $prompt The prompt content. * @param ModelInterface|null $model Optional specific model to use. * @return GenerativeAiResult The generation result. * * @throws \InvalidArgumentException If the prompt format is invalid. * @throws \RuntimeException If no suitable model is found. */ - public static function generateImageResult($prompt, ModelInterface $model = null): GenerativeAiResult + public static function generateImageResult($prompt, ?ModelInterface $model = null): GenerativeAiResult { return self::executeGeneration($prompt, $model, 'image'); } @@ -271,16 +276,16 @@ public static function generateImageResult($prompt, ModelInterface $model = null * * @since n.e.x.t * - * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. + * @param string|MessagePart|Message|list $prompt The prompt content. * @param ModelInterface|null $model Optional specific model to use. * @return GenerativeAiResult The generation result. * * @throws \InvalidArgumentException If the prompt format is invalid. * @throws \RuntimeException If no suitable model is found. */ - public static function convertTextToSpeechResult($prompt, ModelInterface $model = null): GenerativeAiResult + public static function convertTextToSpeechResult($prompt, ?ModelInterface $model = null): GenerativeAiResult { - // Convert prompt to standardized Message array format + // TODO: Replace with PromptBuilder delegation once PR #49 is merged $messages = PromptNormalizer::normalize($prompt); /** @var list $messageList */ $messageList = array_values($messages); @@ -300,14 +305,14 @@ public static function convertTextToSpeechResult($prompt, ModelInterface $model * * @since n.e.x.t * - * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. + * @param string|MessagePart|Message|list $prompt The prompt content. * @param ModelInterface|null $model Optional specific model to use. * @return GenerativeAiResult The generation result. * * @throws \InvalidArgumentException If the prompt format is invalid. * @throws \RuntimeException If no suitable model is found. */ - public static function generateSpeechResult($prompt, ModelInterface $model = null): GenerativeAiResult + public static function generateSpeechResult($prompt, ?ModelInterface $model = null): GenerativeAiResult { return self::executeGeneration($prompt, $model, 'speech'); } @@ -319,7 +324,7 @@ public static function generateSpeechResult($prompt, ModelInterface $model = nul * * @since n.e.x.t * - * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. + * @param string|MessagePart|Message|list $prompt The prompt content. * @param ModelInterface $model The model to use for generation. * @return GenerativeAiOperation The operation for async processing. * @@ -327,7 +332,7 @@ public static function generateSpeechResult($prompt, ModelInterface $model = nul */ public static function generateOperation($prompt, ModelInterface $model): GenerativeAiOperation { - // Convert prompt to standardized Message array format + // TODO: Replace with PromptBuilder delegation once PR #49 is merged $messages = PromptNormalizer::normalize($prompt); /** @var list $messageList */ $messageList = array_values($messages); @@ -341,7 +346,7 @@ public static function generateOperation($prompt, ModelInterface $model): Genera * * @since n.e.x.t * - * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. + * @param string|MessagePart|Message|list $prompt The prompt content. * @param ModelInterface $model The model to use for text generation. * @return GenerativeAiOperation The operation for async text processing. * @@ -362,7 +367,7 @@ public static function generateTextOperation($prompt, ModelInterface $model): Ge * * @since n.e.x.t * - * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. + * @param string|MessagePart|Message|list $prompt The prompt content. * @param ModelInterface $model The model to use for image generation. * @return GenerativeAiOperation The operation for async image processing. * @@ -383,7 +388,7 @@ public static function generateImageOperation($prompt, ModelInterface $model): G * * @since n.e.x.t * - * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. + * @param string|MessagePart|Message|list $prompt The prompt content. * @param ModelInterface $model The model to use for text-to-speech conversion. * @return GenerativeAiOperation The operation for async text-to-speech processing. * @@ -404,7 +409,7 @@ public static function convertTextToSpeechOperation($prompt, ModelInterface $mod * * @since n.e.x.t * - * @param string|MessagePart|MessagePart[]|Message|Message[] $prompt The prompt content. + * @param string|MessagePart|Message|list $prompt The prompt content. * @param ModelInterface $model The model to use for speech generation. * @return GenerativeAiOperation The operation for async speech processing. * From 3612e5f9bd6eedd44ca71d5aff82242832decb01 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 20 Aug 2025 17:55:52 +0300 Subject: [PATCH 35/69] Build simplified PromptNormalizer using existing fromArray() methods --- src/Utils/PromptNormalizer.php | 174 ++++++++++++++++++++-- tests/unit/Utils/PromptNormalizerTest.php | 109 +++++++++++++- 2 files changed, 271 insertions(+), 12 deletions(-) diff --git a/src/Utils/PromptNormalizer.php b/src/Utils/PromptNormalizer.php index ce1df058..d90aef26 100644 --- a/src/Utils/PromptNormalizer.php +++ b/src/Utils/PromptNormalizer.php @@ -7,6 +7,7 @@ use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\UserMessage; +use WordPress\AiClient\Messages\Enums\MessageRoleEnum; /** * Utility class for normalizing various prompt formats into standardized Message arrays. @@ -18,15 +19,27 @@ class PromptNormalizer /** * Normalizes various prompt formats into a standardized Message array. * + * Supports: + * - Strings: converted to UserMessage with MessagePart + * - MessagePart: wrapped in UserMessage + * - Message: used directly + * - Structured arrays: {'role': 'system', 'parts': [...]} format with role mapping + * - Arrays of any combination of the above + * * @since n.e.x.t * - * @param string|MessagePart|Message|list $prompt The prompt content in various formats. + * @param mixed $prompt The prompt content in various formats. * @return list Array of Message objects. * * @throws \InvalidArgumentException If the prompt format is invalid. */ public static function normalize($prompt): array { + // Handle structured message arrays at the top level + if (is_array($prompt) && self::isStructuredMessageArray($prompt)) { + return [self::normalizeStructuredMessage($prompt, 0)]; + } + // Normalize to array first for consistent processing if (!is_array($prompt)) { $prompt = [$prompt]; @@ -40,7 +53,7 @@ public static function normalize($prompt): array // Process each item individually $messages = []; foreach ($prompt as $index => $item) { - $messages[] = self::normalizeItem($item, $index); + $messages[] = self::normalizeItem($item, is_int($index) ? $index : 0); } return $messages; @@ -51,7 +64,7 @@ public static function normalize($prompt): array * * @since n.e.x.t * - * @param string|MessagePart|Message $item The prompt item to normalize. + * @param string|MessagePart|Message|array $item The prompt item to normalize. * @param int $index The array index for error reporting. * @return Message The normalized message. * @@ -59,24 +72,167 @@ public static function normalize($prompt): array */ private static function normalizeItem($item, int $index): Message { + // Handle Message objects + if ($item instanceof Message) { + return $item; + } + + // Handle structured message arrays: {'role': 'system', 'parts': [...]} + if (is_array($item) && self::isStructuredMessageArray($item)) { + return self::normalizeStructuredMessage($item, $index); + } + + // Handle strings - convert to user message if (is_string($item)) { return new UserMessage([new MessagePart($item)]); } + // Handle MessagePart objects - wrap in user message if ($item instanceof MessagePart) { return new UserMessage([$item]); } - if ($item instanceof Message) { - return $item; - } - throw new \InvalidArgumentException( sprintf( - 'Array element at index %d must be a string, MessagePart, or Message, %s given', + 'Array element at index %d must be a string, MessagePart, Message, or ' . + 'structured message array, %s given', $index, - gettype($item) + is_array($item) ? 'invalid array format' : gettype($item) ) ); } + + /** + * Checks if an array is a structured message format. + * + * @since n.e.x.t + * + * @param array $item The array to check. + * @return bool True if it's a structured message array. + */ + private static function isStructuredMessageArray(array $item): bool + { + return isset($item['role']); + } + + /** + * Normalizes a structured message array using Message::fromArray() with role mapping. + * + * @since n.e.x.t + * + * @param array $item The structured message array. + * @param int $index The array index for error reporting. + * @return Message The normalized message. + * + * @throws \InvalidArgumentException If the structured format is invalid. + */ + private static function normalizeStructuredMessage(array $item, int $index): Message + { + // Validate required keys + if (!isset($item['parts'])) { + throw new \InvalidArgumentException( + sprintf('Structured message at index %d is missing required "parts" field', $index) + ); + } + + // Map role to standard format and let Message::fromArray handle the rest + $normalizedArray = [ + Message::KEY_ROLE => self::mapRole($item['role'], $index), + Message::KEY_PARTS => self::normalizeParts($item['parts'], $index), + ]; + + try { + return Message::fromArray($normalizedArray); + } catch (\Exception $e) { + throw new \InvalidArgumentException( + sprintf('Invalid structured message at index %d: %s', $index, $e->getMessage()), + 0, + $e + ); + } + } + + /** + * Maps role strings to MessageRoleEnum values with support for common aliases. + * + * @since n.e.x.t + * + * @param mixed $role The role value to map. + * @param int $index The array index for error reporting. + * @return string The mapped role value. + * + * @throws \InvalidArgumentException If the role is invalid. + */ + private static function mapRole($role, int $index): string + { + if (!is_string($role)) { + throw new \InvalidArgumentException( + sprintf('Role at index %d must be a string, %s given', $index, gettype($role)) + ); + } + + // Map common role aliases to standard enum values + switch (strtolower($role)) { + case 'system': + return MessageRoleEnum::system()->value; + case 'user': + return MessageRoleEnum::user()->value; + case 'model': + case 'assistant': + return MessageRoleEnum::model()->value; + default: + throw new \InvalidArgumentException( + sprintf( + 'Invalid role "%s" at index %d. Must be "system", "user", "model", or "assistant"', + $role, + $index + ) + ); + } + } + + /** + * Normalizes parts array for Message::fromArray(). + * + * @since n.e.x.t + * + * @param mixed $parts The parts to normalize. + * @param int $index The array index for error reporting. + * @return list|string> The normalized parts. + * + * @throws \InvalidArgumentException If the parts format is invalid. + */ + private static function normalizeParts($parts, int $index): array + { + if (!is_array($parts)) { + throw new \InvalidArgumentException( + sprintf('Parts at index %d must be an array, %s given', $index, gettype($parts)) + ); + } + + $normalizedParts = []; + foreach ($parts as $partIndex => $part) { + if (is_string($part)) { + // Simple text part - Message::fromArray will handle it + $normalizedParts[] = [MessagePart::KEY_TEXT => $part]; + } elseif ($part instanceof MessagePart) { + // Convert MessagePart to array for Message::fromArray + $normalizedParts[] = $part->toArray(); + } elseif (is_array($part)) { + // Assume it's already in the correct format for MessagePart::fromArray + $normalizedParts[] = $part; + } else { + throw new \InvalidArgumentException( + sprintf( + 'Part at index %d[%d] must be a string, MessagePart, or array, %s given', + $index, + $partIndex, + gettype($part) + ) + ); + } + } + + return $normalizedParts; + } } diff --git a/tests/unit/Utils/PromptNormalizerTest.php b/tests/unit/Utils/PromptNormalizerTest.php index d17507c3..73679e30 100644 --- a/tests/unit/Utils/PromptNormalizerTest.php +++ b/tests/unit/Utils/PromptNormalizerTest.php @@ -5,8 +5,10 @@ namespace WordPress\AiClient\Tests\unit\Utils; use PHPUnit\Framework\TestCase; +use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\UserMessage; +use WordPress\AiClient\Messages\Enums\MessageRoleEnum; use WordPress\AiClient\Utils\PromptNormalizer; /** @@ -109,7 +111,7 @@ public function testNormalizeMixedArrayThrowsException(): void $invalidArray = [$part, 'string', 123]; $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Array element at index 2 must be a string, MessagePart, or Message, integer given'); + $this->expectExceptionMessage('Array element at index 2 must be a string, MessagePart, Message, or structured message array, integer given'); PromptNormalizer::normalize($invalidArray); } @@ -120,7 +122,7 @@ public function testNormalizeMixedArrayThrowsException(): void public function testNormalizeInvalidInputThrowsException(): void { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Array element at index 0 must be a string, MessagePart, or Message, integer given'); + $this->expectExceptionMessage('Array element at index 0 must be a string, MessagePart, Message, or structured message array, integer given'); PromptNormalizer::normalize(123); } @@ -133,8 +135,109 @@ public function testNormalizeArrayWithInvalidObjectsThrowsException(): void $invalidArray = [new \stdClass(), new \DateTime()]; $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Array element at index 0 must be a string, MessagePart, or Message, object given'); + $this->expectExceptionMessage('Array element at index 0 must be a string, MessagePart, Message, or structured message array, object given'); PromptNormalizer::normalize($invalidArray); } + + /** + * Tests normalizing structured message array. + */ + public function testNormalizeStructuredMessage(): void + { + $structuredMessage = [ + 'role' => 'system', + 'parts' => ['You are a helpful assistant.', 'Be concise.'] + ]; + + $result = PromptNormalizer::normalize($structuredMessage); + + $this->assertCount(1, $result); + $this->assertInstanceOf(Message::class, $result[0]); + $this->assertTrue($result[0]->getRole()->equals(MessageRoleEnum::system())); + $this->assertCount(2, $result[0]->getParts()); + $this->assertEquals('You are a helpful assistant.', $result[0]->getParts()[0]->getText()); + $this->assertEquals('Be concise.', $result[0]->getParts()[1]->getText()); + } + + /** + * Tests normalizing mixed array with structured messages. + */ + public function testNormalizeMixedWithStructuredMessages(): void + { + $mixed = [ + ['role' => 'system', 'parts' => ['System prompt']], + 'User message', + new MessagePart('Part message') + ]; + + $result = PromptNormalizer::normalize($mixed); + + $this->assertCount(3, $result); + + // First: structured system message + $this->assertTrue($result[0]->getRole()->equals(MessageRoleEnum::system())); + $this->assertEquals('System prompt', $result[0]->getParts()[0]->getText()); + + // Second: user message from string + $this->assertInstanceOf(UserMessage::class, $result[1]); + $this->assertEquals('User message', $result[1]->getParts()[0]->getText()); + + // Third: user message from MessagePart + $this->assertInstanceOf(UserMessage::class, $result[2]); + $this->assertEquals('Part message', $result[2]->getParts()[0]->getText()); + } + + /** + * Tests structured message with invalid role throws exception. + */ + public function testStructuredMessageInvalidRoleThrowsException(): void + { + $structuredMessage = [ + 'role' => 'invalid_role', + 'parts' => ['Some text'] + ]; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid role "invalid_role" at index 0'); + + PromptNormalizer::normalize($structuredMessage); + } + + /** + * Tests structured message with missing parts throws exception. + */ + public function testStructuredMessageMissingPartsThrowsException(): void + { + $structuredMessage = [ + 'role' => 'user' + // Missing 'parts' + ]; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('Structured message at index 0 is missing required "parts" field'); + + PromptNormalizer::normalize($structuredMessage); + } + + /** + * Tests role mapping for different variations. + */ + public function testRoleMapping(): void + { + $messages = [ + ['role' => 'system', 'parts' => ['System']], + ['role' => 'user', 'parts' => ['User']], + ['role' => 'model', 'parts' => ['Model']], + ['role' => 'assistant', 'parts' => ['Assistant']], + ]; + + $result = PromptNormalizer::normalize($messages); + + $this->assertCount(4, $result); + $this->assertTrue($result[0]->getRole()->equals(MessageRoleEnum::system())); + $this->assertTrue($result[1]->getRole()->equals(MessageRoleEnum::user())); + $this->assertTrue($result[2]->getRole()->equals(MessageRoleEnum::model())); + $this->assertTrue($result[3]->getRole()->equals(MessageRoleEnum::model())); // assistant maps to model + } } From 06509fd2029dd914989b2abff1a7722a5c0d716b Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 20 Aug 2025 18:04:02 +0300 Subject: [PATCH 36/69] Add @covers annotation to ModelsTest class --- tests/unit/Utils/ModelsTest.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/unit/Utils/ModelsTest.php b/tests/unit/Utils/ModelsTest.php index 1ead730b..11a361c2 100644 --- a/tests/unit/Utils/ModelsTest.php +++ b/tests/unit/Utils/ModelsTest.php @@ -19,6 +19,7 @@ /** * Test case for Models utility class. * + * @covers \WordPress\AiClient\Utils\Models * @since n.e.x.t */ class ModelsTest extends TestCase @@ -42,6 +43,8 @@ protected function setUp(): void /** * Tests that validateTextGeneration passes with valid model. + * + * @covers \WordPress\AiClient\Utils\Models::validateTextGeneration */ public function testValidateTextGenerationPassesWithValidModel(): void { @@ -51,6 +54,8 @@ public function testValidateTextGenerationPassesWithValidModel(): void /** * Tests that validateTextGeneration throws exception with invalid model. + * + * @covers \WordPress\AiClient\Utils\Models::validateTextGeneration */ public function testValidateTextGenerationThrowsExceptionWithInvalidModel(): void { @@ -62,6 +67,8 @@ public function testValidateTextGenerationThrowsExceptionWithInvalidModel(): voi /** * Tests that validateImageGeneration passes with valid model. + * + * @covers \WordPress\AiClient\Utils\Models::validateImageGeneration */ public function testValidateImageGenerationPassesWithValidModel(): void { @@ -71,6 +78,8 @@ public function testValidateImageGenerationPassesWithValidModel(): void /** * Tests that validateImageGeneration throws exception with invalid model. + * + * @covers \WordPress\AiClient\Utils\Models::validateImageGeneration */ public function testValidateImageGenerationThrowsExceptionWithInvalidModel(): void { @@ -82,6 +91,8 @@ public function testValidateImageGenerationThrowsExceptionWithInvalidModel(): vo /** * Tests that validateTextToSpeechConversion throws exception with invalid model. + * + * @covers \WordPress\AiClient\Utils\Models::validateTextToSpeechConversion */ public function testValidateTextToSpeechConversionThrowsExceptionWithInvalidModel(): void { @@ -93,6 +104,8 @@ public function testValidateTextToSpeechConversionThrowsExceptionWithInvalidMode /** * Tests that validateSpeechGeneration throws exception with invalid model. + * + * @covers \WordPress\AiClient\Utils\Models::validateSpeechGeneration */ public function testValidateSpeechGenerationThrowsExceptionWithInvalidModel(): void { @@ -104,6 +117,8 @@ public function testValidateSpeechGenerationThrowsExceptionWithInvalidModel(): v /** * Tests that validateTextGenerationOperation throws exception with invalid model. + * + * @covers \WordPress\AiClient\Utils\Models::validateTextGenerationOperation */ public function testValidateTextGenerationOperationThrowsExceptionWithInvalidModel(): void { @@ -115,6 +130,8 @@ public function testValidateTextGenerationOperationThrowsExceptionWithInvalidMod /** * Tests that validateImageGenerationOperation throws exception with invalid model. + * + * @covers \WordPress\AiClient\Utils\Models::validateImageGenerationOperation */ public function testValidateImageGenerationOperationThrowsExceptionWithInvalidModel(): void { From b8e689b735d0a717f8489bd29aa323b226687201 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 20 Aug 2025 18:08:05 +0300 Subject: [PATCH 37/69] Fix PHPStan type errors in PromptNormalizer --- src/Utils/PromptNormalizer.php | 85 ++++++++++++++++++++-------------- 1 file changed, 49 insertions(+), 36 deletions(-) diff --git a/src/Utils/PromptNormalizer.php b/src/Utils/PromptNormalizer.php index d90aef26..94a2549c 100644 --- a/src/Utils/PromptNormalizer.php +++ b/src/Utils/PromptNormalizer.php @@ -36,7 +36,7 @@ class PromptNormalizer public static function normalize($prompt): array { // Handle structured message arrays at the top level - if (is_array($prompt) && self::isStructuredMessageArray($prompt)) { + if (is_array($prompt) && self::hasStringKeys($prompt) && self::isStructuredMessageArray($prompt)) { return [self::normalizeStructuredMessage($prompt, 0)]; } @@ -78,7 +78,7 @@ private static function normalizeItem($item, int $index): Message } // Handle structured message arrays: {'role': 'system', 'parts': [...]} - if (is_array($item) && self::isStructuredMessageArray($item)) { + if (is_array($item) && self::hasStringKeys($item) && self::isStructuredMessageArray($item)) { return self::normalizeStructuredMessage($item, $index); } @@ -116,7 +116,7 @@ private static function isStructuredMessageArray(array $item): bool } /** - * Normalizes a structured message array using Message::fromArray() with role mapping. + * Normalizes a structured message array by creating Message directly. * * @since n.e.x.t * @@ -135,35 +135,25 @@ private static function normalizeStructuredMessage(array $item, int $index): Mes ); } - // Map role to standard format and let Message::fromArray handle the rest - $normalizedArray = [ - Message::KEY_ROLE => self::mapRole($item['role'], $index), - Message::KEY_PARTS => self::normalizeParts($item['parts'], $index), - ]; + // Map role and create message parts + $role = self::mapRoleToEnum($item['role'], $index); + $parts = self::createMessageParts($item['parts'], $index); - try { - return Message::fromArray($normalizedArray); - } catch (\Exception $e) { - throw new \InvalidArgumentException( - sprintf('Invalid structured message at index %d: %s', $index, $e->getMessage()), - 0, - $e - ); - } + return new Message($role, $parts); } /** - * Maps role strings to MessageRoleEnum values with support for common aliases. + * Maps role strings to MessageRoleEnum instances with support for common aliases. * * @since n.e.x.t * * @param mixed $role The role value to map. * @param int $index The array index for error reporting. - * @return string The mapped role value. + * @return MessageRoleEnum The mapped role enum. * * @throws \InvalidArgumentException If the role is invalid. */ - private static function mapRole($role, int $index): string + private static function mapRoleToEnum($role, int $index): MessageRoleEnum { if (!is_string($role)) { throw new \InvalidArgumentException( @@ -171,15 +161,15 @@ private static function mapRole($role, int $index): string ); } - // Map common role aliases to standard enum values + // Map common role aliases to enum instances switch (strtolower($role)) { case 'system': - return MessageRoleEnum::system()->value; + return MessageRoleEnum::system(); case 'user': - return MessageRoleEnum::user()->value; + return MessageRoleEnum::user(); case 'model': case 'assistant': - return MessageRoleEnum::model()->value; + return MessageRoleEnum::model(); default: throw new \InvalidArgumentException( sprintf( @@ -192,17 +182,17 @@ private static function mapRole($role, int $index): string } /** - * Normalizes parts array for Message::fromArray(). + * Creates MessagePart objects from various input formats. * * @since n.e.x.t * - * @param mixed $parts The parts to normalize. + * @param mixed $parts The parts to create. * @param int $index The array index for error reporting. - * @return list|string> The normalized parts. + * @return list The created message parts. * * @throws \InvalidArgumentException If the parts format is invalid. */ - private static function normalizeParts($parts, int $index): array + private static function createMessageParts($parts, int $index): array { if (!is_array($parts)) { throw new \InvalidArgumentException( @@ -210,17 +200,27 @@ private static function normalizeParts($parts, int $index): array ); } - $normalizedParts = []; + $messageParts = []; foreach ($parts as $partIndex => $part) { if (is_string($part)) { - // Simple text part - Message::fromArray will handle it - $normalizedParts[] = [MessagePart::KEY_TEXT => $part]; + $messageParts[] = new MessagePart($part); } elseif ($part instanceof MessagePart) { - // Convert MessagePart to array for Message::fromArray - $normalizedParts[] = $part->toArray(); + $messageParts[] = $part; } elseif (is_array($part)) { - // Assume it's already in the correct format for MessagePart::fromArray - $normalizedParts[] = $part; + try { + $messageParts[] = MessagePart::fromArray($part); + } catch (\Exception $e) { + throw new \InvalidArgumentException( + sprintf( + 'Invalid message part at index %d[%d]: %s', + $index, + $partIndex, + $e->getMessage() + ), + 0, + $e + ); + } } else { throw new \InvalidArgumentException( sprintf( @@ -233,6 +233,19 @@ private static function normalizeParts($parts, int $index): array } } - return $normalizedParts; + return $messageParts; + } + + /** + * Checks if an array has string keys (associative array). + * + * @since n.e.x.t + * + * @param array $array The array to check. + * @return bool True if the array has string keys. + */ + private static function hasStringKeys(array $array): bool + { + return array_keys($array) !== range(0, count($array) - 1); } } From 3b00c2618dd622b0612b18dd431ef40144eca935 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 20 Aug 2025 18:13:46 +0300 Subject: [PATCH 38/69] Add phpstan-assert-if-true annotations for type safety --- src/Utils/PromptNormalizer.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Utils/PromptNormalizer.php b/src/Utils/PromptNormalizer.php index 94a2549c..6f4b5f39 100644 --- a/src/Utils/PromptNormalizer.php +++ b/src/Utils/PromptNormalizer.php @@ -64,7 +64,7 @@ public static function normalize($prompt): array * * @since n.e.x.t * - * @param string|MessagePart|Message|array $item The prompt item to normalize. + * @param mixed $item The prompt item to normalize. * @param int $index The array index for error reporting. * @return Message The normalized message. * @@ -243,6 +243,7 @@ private static function createMessageParts($parts, int $index): array * * @param array $array The array to check. * @return bool True if the array has string keys. + * @phpstan-assert-if-true array $array */ private static function hasStringKeys(array $array): bool { From 5a3bde7aa8363b98d80349f5e62a0465b06321dd Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 20 Aug 2025 18:19:35 +0300 Subject: [PATCH 39/69] Follow MessageUtil pattern for PHPStan type handling --- src/Utils/PromptNormalizer.php | 27 +++++---------------------- 1 file changed, 5 insertions(+), 22 deletions(-) diff --git a/src/Utils/PromptNormalizer.php b/src/Utils/PromptNormalizer.php index 6f4b5f39..95fe075e 100644 --- a/src/Utils/PromptNormalizer.php +++ b/src/Utils/PromptNormalizer.php @@ -12,6 +12,7 @@ /** * Utility class for normalizing various prompt formats into standardized Message arrays. * + * @phpstan-import-type MessagePartArrayShape from MessagePart * @since n.e.x.t */ class PromptNormalizer @@ -207,29 +208,11 @@ private static function createMessageParts($parts, int $index): array } elseif ($part instanceof MessagePart) { $messageParts[] = $part; } elseif (is_array($part)) { - try { - $messageParts[] = MessagePart::fromArray($part); - } catch (\Exception $e) { - throw new \InvalidArgumentException( - sprintf( - 'Invalid message part at index %d[%d]: %s', - $index, - $partIndex, - $e->getMessage() - ), - 0, - $e - ); - } + /** @var MessagePartArrayShape $part */ + $messageParts[] = MessagePart::fromArray($part); } else { - throw new \InvalidArgumentException( - sprintf( - 'Part at index %d[%d] must be a string, MessagePart, or array, %s given', - $index, - $partIndex, - gettype($part) - ) - ); + // Fallback like MessageUtil - convert anything else to string + $messageParts[] = new MessagePart($part); } } From 34a682b54d63e7cac2fd03474aef63d37b2f4fbc Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 20 Aug 2025 18:21:34 +0300 Subject: [PATCH 40/69] Fix code style issues in test files --- tests/unit/Utils/ModelsTest.php | 31 +++++++++++------------ tests/unit/Utils/PromptNormalizerTest.php | 26 +++++++++---------- 2 files changed, 28 insertions(+), 29 deletions(-) diff --git a/tests/unit/Utils/ModelsTest.php b/tests/unit/Utils/ModelsTest.php index 11a361c2..42eb99e5 100644 --- a/tests/unit/Utils/ModelsTest.php +++ b/tests/unit/Utils/ModelsTest.php @@ -7,7 +7,6 @@ use InvalidArgumentException; use PHPUnit\Framework\TestCase; use RuntimeException; -use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Providers\ProviderRegistry; @@ -32,10 +31,10 @@ class ModelsTest extends TestCase protected function setUp(): void { $this->registry = new ProviderRegistry(); - + $mockMetadata = $this->createMock(ModelMetadata::class); $mockConfig = $this->createMock(ModelConfig::class); - + $this->mockTextModel = new MockTextGenerationModel(); $this->mockImageModel = new MockImageGenerationModel(); $this->mockModel = new MockModel($mockMetadata, $mockConfig); @@ -61,7 +60,7 @@ public function testValidateTextGenerationThrowsExceptionWithInvalidModel(): voi { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Model must implement TextGenerationModelInterface for text generation'); - + Models::validateTextGeneration($this->mockModel); } @@ -85,7 +84,7 @@ public function testValidateImageGenerationThrowsExceptionWithInvalidModel(): vo { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Model must implement ImageGenerationModelInterface for image generation'); - + Models::validateImageGeneration($this->mockModel); } @@ -98,7 +97,7 @@ public function testValidateTextToSpeechConversionThrowsExceptionWithInvalidMode { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Model must implement TextToSpeechConversionModelInterface for text-to-speech conversion'); - + Models::validateTextToSpeechConversion($this->mockModel); } @@ -111,7 +110,7 @@ public function testValidateSpeechGenerationThrowsExceptionWithInvalidModel(): v { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Model must implement SpeechGenerationModelInterface for speech generation'); - + Models::validateSpeechGeneration($this->mockModel); } @@ -124,7 +123,7 @@ public function testValidateTextGenerationOperationThrowsExceptionWithInvalidMod { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Model must implement TextGenerationModelInterface for text generation operations'); - + Models::validateTextGenerationOperation($this->mockModel); } @@ -137,7 +136,7 @@ public function testValidateImageGenerationOperationThrowsExceptionWithInvalidMo { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Model must implement ImageGenerationModelInterface for image generation operations'); - + Models::validateImageGenerationOperation($this->mockModel); } @@ -148,7 +147,7 @@ public function testValidateTextToSpeechConversionOperationThrowsExceptionWithIn { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Model must implement TextToSpeechConversionOperationModelInterface for text-to-speech conversion operations'); - + Models::validateTextToSpeechConversionOperation($this->mockModel); } @@ -159,7 +158,7 @@ public function testValidateSpeechGenerationOperationThrowsExceptionWithInvalidM { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Model must implement SpeechGenerationOperationModelInterface for speech generation operations'); - + Models::validateSpeechGenerationOperation($this->mockModel); } @@ -170,7 +169,7 @@ public function testFindTextModelThrowsExceptionWhenNoModelsAvailable(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('No text generation models available'); - + Models::findTextModel($this->registry); } @@ -181,7 +180,7 @@ public function testFindImageModelThrowsExceptionWhenNoModelsAvailable(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('No image generation models available'); - + Models::findImageModel($this->registry); } @@ -192,7 +191,7 @@ public function testFindTextToSpeechModelThrowsExceptionWhenNoModelsAvailable(): { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('No text-to-speech conversion models available'); - + Models::findTextToSpeechModel($this->registry); } @@ -203,7 +202,7 @@ public function testFindSpeechModelThrowsExceptionWhenNoModelsAvailable(): void { $this->expectException(RuntimeException::class); $this->expectExceptionMessage('No speech generation models available'); - + Models::findSpeechModel($this->registry); } -} \ No newline at end of file +} diff --git a/tests/unit/Utils/PromptNormalizerTest.php b/tests/unit/Utils/PromptNormalizerTest.php index 73679e30..0fab8584 100644 --- a/tests/unit/Utils/PromptNormalizerTest.php +++ b/tests/unit/Utils/PromptNormalizerTest.php @@ -149,9 +149,9 @@ public function testNormalizeStructuredMessage(): void 'role' => 'system', 'parts' => ['You are a helpful assistant.', 'Be concise.'] ]; - + $result = PromptNormalizer::normalize($structuredMessage); - + $this->assertCount(1, $result); $this->assertInstanceOf(Message::class, $result[0]); $this->assertTrue($result[0]->getRole()->equals(MessageRoleEnum::system())); @@ -170,19 +170,19 @@ public function testNormalizeMixedWithStructuredMessages(): void 'User message', new MessagePart('Part message') ]; - + $result = PromptNormalizer::normalize($mixed); - + $this->assertCount(3, $result); - + // First: structured system message $this->assertTrue($result[0]->getRole()->equals(MessageRoleEnum::system())); $this->assertEquals('System prompt', $result[0]->getParts()[0]->getText()); - + // Second: user message from string $this->assertInstanceOf(UserMessage::class, $result[1]); $this->assertEquals('User message', $result[1]->getParts()[0]->getText()); - + // Third: user message from MessagePart $this->assertInstanceOf(UserMessage::class, $result[2]); $this->assertEquals('Part message', $result[2]->getParts()[0]->getText()); @@ -197,10 +197,10 @@ public function testStructuredMessageInvalidRoleThrowsException(): void 'role' => 'invalid_role', 'parts' => ['Some text'] ]; - + $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Invalid role "invalid_role" at index 0'); - + PromptNormalizer::normalize($structuredMessage); } @@ -213,10 +213,10 @@ public function testStructuredMessageMissingPartsThrowsException(): void 'role' => 'user' // Missing 'parts' ]; - + $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('Structured message at index 0 is missing required "parts" field'); - + PromptNormalizer::normalize($structuredMessage); } @@ -231,9 +231,9 @@ public function testRoleMapping(): void ['role' => 'model', 'parts' => ['Model']], ['role' => 'assistant', 'parts' => ['Assistant']], ]; - + $result = PromptNormalizer::normalize($messages); - + $this->assertCount(4, $result); $this->assertTrue($result[0]->getRole()->equals(MessageRoleEnum::system())); $this->assertTrue($result[1]->getRole()->equals(MessageRoleEnum::user())); From 569f6f9ab2597978d1190fe5fcf1ed6f5062d9be Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 20 Aug 2025 18:23:34 +0300 Subject: [PATCH 41/69] Add missing @covers annotations to ModelsTest methods --- tests/unit/Utils/ModelsTest.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/unit/Utils/ModelsTest.php b/tests/unit/Utils/ModelsTest.php index 42eb99e5..49036b84 100644 --- a/tests/unit/Utils/ModelsTest.php +++ b/tests/unit/Utils/ModelsTest.php @@ -142,6 +142,8 @@ public function testValidateImageGenerationOperationThrowsExceptionWithInvalidMo /** * Tests that validateTextToSpeechConversionOperation throws exception with invalid model. + * + * @covers \WordPress\AiClient\Utils\Models::validateTextToSpeechConversionOperation */ public function testValidateTextToSpeechConversionOperationThrowsExceptionWithInvalidModel(): void { @@ -153,6 +155,8 @@ public function testValidateTextToSpeechConversionOperationThrowsExceptionWithIn /** * Tests that validateSpeechGenerationOperation throws exception with invalid model. + * + * @covers \WordPress\AiClient\Utils\Models::validateSpeechGenerationOperation */ public function testValidateSpeechGenerationOperationThrowsExceptionWithInvalidModel(): void { @@ -164,6 +168,8 @@ public function testValidateSpeechGenerationOperationThrowsExceptionWithInvalidM /** * Tests that findTextModel throws exception when no models available. + * + * @covers \WordPress\AiClient\Utils\Models::findTextModel */ public function testFindTextModelThrowsExceptionWhenNoModelsAvailable(): void { @@ -175,6 +181,8 @@ public function testFindTextModelThrowsExceptionWhenNoModelsAvailable(): void /** * Tests that findImageModel throws exception when no models available. + * + * @covers \WordPress\AiClient\Utils\Models::findImageModel */ public function testFindImageModelThrowsExceptionWhenNoModelsAvailable(): void { @@ -186,6 +194,8 @@ public function testFindImageModelThrowsExceptionWhenNoModelsAvailable(): void /** * Tests that findTextToSpeechModel throws exception when no models available. + * + * @covers \WordPress\AiClient\Utils\Models::findTextToSpeechModel */ public function testFindTextToSpeechModelThrowsExceptionWhenNoModelsAvailable(): void { @@ -197,6 +207,8 @@ public function testFindTextToSpeechModelThrowsExceptionWhenNoModelsAvailable(): /** * Tests that findSpeechModel throws exception when no models available. + * + * @covers \WordPress\AiClient\Utils\Models::findSpeechModel */ public function testFindSpeechModelThrowsExceptionWhenNoModelsAvailable(): void { From c0442049f53cc400046a75e094d0e77414fc7bdf Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 20 Aug 2025 18:28:26 +0300 Subject: [PATCH 42/69] Fix remaining long lines in test files --- tests/unit/Utils/ModelsTest.php | 25 +++++++++++++++++------ tests/unit/Utils/PromptNormalizerTest.php | 14 ++++++++++--- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/tests/unit/Utils/ModelsTest.php b/tests/unit/Utils/ModelsTest.php index 49036b84..2ba5e179 100644 --- a/tests/unit/Utils/ModelsTest.php +++ b/tests/unit/Utils/ModelsTest.php @@ -96,7 +96,9 @@ public function testValidateImageGenerationThrowsExceptionWithInvalidModel(): vo public function testValidateTextToSpeechConversionThrowsExceptionWithInvalidModel(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Model must implement TextToSpeechConversionModelInterface for text-to-speech conversion'); + $this->expectExceptionMessage( + 'Model must implement TextToSpeechConversionModelInterface for text-to-speech conversion' + ); Models::validateTextToSpeechConversion($this->mockModel); } @@ -109,7 +111,9 @@ public function testValidateTextToSpeechConversionThrowsExceptionWithInvalidMode public function testValidateSpeechGenerationThrowsExceptionWithInvalidModel(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Model must implement SpeechGenerationModelInterface for speech generation'); + $this->expectExceptionMessage( + 'Model must implement SpeechGenerationModelInterface for speech generation' + ); Models::validateSpeechGeneration($this->mockModel); } @@ -122,7 +126,9 @@ public function testValidateSpeechGenerationThrowsExceptionWithInvalidModel(): v public function testValidateTextGenerationOperationThrowsExceptionWithInvalidModel(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Model must implement TextGenerationModelInterface for text generation operations'); + $this->expectExceptionMessage( + 'Model must implement TextGenerationModelInterface for text generation operations' + ); Models::validateTextGenerationOperation($this->mockModel); } @@ -135,7 +141,9 @@ public function testValidateTextGenerationOperationThrowsExceptionWithInvalidMod public function testValidateImageGenerationOperationThrowsExceptionWithInvalidModel(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Model must implement ImageGenerationModelInterface for image generation operations'); + $this->expectExceptionMessage( + 'Model must implement ImageGenerationModelInterface for image generation operations' + ); Models::validateImageGenerationOperation($this->mockModel); } @@ -148,7 +156,10 @@ public function testValidateImageGenerationOperationThrowsExceptionWithInvalidMo public function testValidateTextToSpeechConversionOperationThrowsExceptionWithInvalidModel(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Model must implement TextToSpeechConversionOperationModelInterface for text-to-speech conversion operations'); + $this->expectExceptionMessage( + 'Model must implement TextToSpeechConversionOperationModelInterface ' . + 'for text-to-speech conversion operations' + ); Models::validateTextToSpeechConversionOperation($this->mockModel); } @@ -161,7 +172,9 @@ public function testValidateTextToSpeechConversionOperationThrowsExceptionWithIn public function testValidateSpeechGenerationOperationThrowsExceptionWithInvalidModel(): void { $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Model must implement SpeechGenerationOperationModelInterface for speech generation operations'); + $this->expectExceptionMessage( + 'Model must implement SpeechGenerationOperationModelInterface for speech generation operations' + ); Models::validateSpeechGenerationOperation($this->mockModel); } diff --git a/tests/unit/Utils/PromptNormalizerTest.php b/tests/unit/Utils/PromptNormalizerTest.php index 0fab8584..503decf5 100644 --- a/tests/unit/Utils/PromptNormalizerTest.php +++ b/tests/unit/Utils/PromptNormalizerTest.php @@ -111,7 +111,10 @@ public function testNormalizeMixedArrayThrowsException(): void $invalidArray = [$part, 'string', 123]; $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Array element at index 2 must be a string, MessagePart, Message, or structured message array, integer given'); + $this->expectExceptionMessage( + 'Array element at index 2 must be a string, MessagePart, Message, or ' . + 'structured message array, integer given' + ); PromptNormalizer::normalize($invalidArray); } @@ -122,7 +125,10 @@ public function testNormalizeMixedArrayThrowsException(): void public function testNormalizeInvalidInputThrowsException(): void { $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Array element at index 0 must be a string, MessagePart, Message, or structured message array, integer given'); + $this->expectExceptionMessage( + 'Array element at index 0 must be a string, MessagePart, Message, or ' . + 'structured message array, integer given' + ); PromptNormalizer::normalize(123); } @@ -135,7 +141,9 @@ public function testNormalizeArrayWithInvalidObjectsThrowsException(): void $invalidArray = [new \stdClass(), new \DateTime()]; $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Array element at index 0 must be a string, MessagePart, Message, or structured message array, object given'); + $this->expectExceptionMessage( + 'Array element at index 0 must be a string, MessagePart, Message, or structured message array, object given' + ); PromptNormalizer::normalize($invalidArray); } From 29097f2eeb7ee61ee2808049ee5e2887b55d29f5 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 20 Aug 2025 18:30:10 +0300 Subject: [PATCH 43/69] Add not implemented exception for streamGenerateTextResult method --- src/AiClient.php | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index c5c89ac1..74c777d9 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -234,24 +234,13 @@ public static function generateTextResult($prompt, ?ModelInterface $model = null * @param ModelInterface|null $model Optional specific model to use. * @return Generator Generator yielding partial text generation results. * - * @throws \InvalidArgumentException If the prompt format is invalid. - * @throws \RuntimeException If no suitable model is found. + * @throws \RuntimeException Always throws - streaming is not implemented yet. */ public static function streamGenerateTextResult($prompt, ?ModelInterface $model = null): Generator { - // TODO: Replace with PromptBuilder delegation once PR #49 is merged - $messages = PromptNormalizer::normalize($prompt); - /** @var list $messageList */ - $messageList = array_values($messages); - - // Get model - either provided or auto-discovered - $resolvedModel = $model ?? Models::findTextModel(self::defaultRegistry()); - - // Validate model supports text generation - Models::validateTextGeneration($resolvedModel); - - // Stream the results using the model - yield from $resolvedModel->streamGenerateTextResult($messageList); + throw new \RuntimeException( + 'Text streaming is not implemented yet. Use generateTextResult() for non-streaming text generation.' + ); } /** From aecd838a80287dfb768439259a8abd8446ff27f6 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 20 Aug 2025 18:33:25 +0300 Subject: [PATCH 44/69] Update streaming tests to expect not implemented exception --- tests/unit/AiClientTest.php | 40 ++++++++++++++----------------------- 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index f9e41b3e..c4e7c109 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -382,41 +382,29 @@ public function testGenerateResultThrowsExceptionForUnsupportedModel(): void /** * Tests streamGenerateTextResult delegates to model's streaming method. */ - public function testStreamGenerateTextResultDelegatesToModel(): void + public function testStreamGenerateTextResultThrowsNotImplementedException(): void { $prompt = 'Stream this text'; - $result1 = $this->createTestResult(); - $result2 = $this->createTestResult(); - // Create a generator that yields test results - $generator = (function () use ($result1, $result2) { - yield $result1; - yield $result2; - })(); - - $this->mockTextModel->expects($this->once()) - ->method('streamGenerateTextResult') - ->willReturn($generator); - - $streamResults = AiClient::streamGenerateTextResult($prompt, $this->mockTextModel); - - // Convert generator to array for testing - $results = iterator_to_array($streamResults); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Text streaming is not implemented yet. Use generateTextResult() for non-streaming text generation.' + ); - $this->assertCount(2, $results); - $this->assertSame($result1, $results[0]); - $this->assertSame($result2, $results[1]); + iterator_to_array(AiClient::streamGenerateTextResult($prompt, $this->mockTextModel)); } /** * Tests streamGenerateTextResult with model auto-discovery. */ - public function testStreamGenerateTextResultWithAutoDiscovery(): void + public function testStreamGenerateTextResultWithAutoDiscoveryThrowsNotImplementedException(): void { $prompt = 'Auto-discover and stream'; $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('No text generation models available'); + $this->expectExceptionMessage( + 'Text streaming is not implemented yet. Use generateTextResult() for non-streaming text generation.' + ); iterator_to_array(AiClient::streamGenerateTextResult($prompt)); } @@ -424,13 +412,15 @@ public function testStreamGenerateTextResultWithAutoDiscovery(): void /** * Tests streamGenerateTextResult throws exception when model doesn't support text generation. */ - public function testStreamGenerateTextResultThrowsExceptionForNonTextModel(): void + public function testStreamGenerateTextResultForNonTextModelThrowsNotImplementedException(): void { $prompt = 'Test prompt'; $nonTextModel = $this->createMock(ModelInterface::class); - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Model must implement TextGenerationModelInterface for text generation'); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Text streaming is not implemented yet. Use generateTextResult() for non-streaming text generation.' + ); iterator_to_array(AiClient::streamGenerateTextResult($prompt, $nonTextModel)); } From fa10649333bc1340e1344883a3419af016d0d73c Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 20 Aug 2025 13:02:39 -0600 Subject: [PATCH 45/69] refactor: prevents operation calls at this time --- src/AiClient.php | 58 +++---- src/Operations/OperationFactory.php | 150 ---------------- tests/unit/AiClientTest.php | 74 ++++---- .../unit/Operations/OperationFactoryTest.php | 161 ------------------ 4 files changed, 59 insertions(+), 384 deletions(-) delete mode 100644 src/Operations/OperationFactory.php delete mode 100644 tests/unit/Operations/OperationFactoryTest.php diff --git a/src/AiClient.php b/src/AiClient.php index 74c777d9..f8449539 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -8,7 +8,6 @@ use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Operations\DTO\GenerativeAiOperation; -use WordPress\AiClient\Operations\OperationFactory; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; @@ -317,17 +316,13 @@ public static function generateSpeechResult($prompt, ?ModelInterface $model = nu * @param ModelInterface $model The model to use for generation. * @return GenerativeAiOperation The operation for async processing. * - * @throws \InvalidArgumentException If the prompt format is invalid. + * @throws \RuntimeException Operations are not implemented yet. */ public static function generateOperation($prompt, ModelInterface $model): GenerativeAiOperation { - // TODO: Replace with PromptBuilder delegation once PR #49 is merged - $messages = PromptNormalizer::normalize($prompt); - /** @var list $messageList */ - $messageList = array_values($messages); - - // Create operation using factory - return OperationFactory::createGenericOperation($messageList); + throw new \RuntimeException( + 'Operations are not implemented yet. This functionality is planned for a future release.' + ); } /** @@ -339,16 +334,13 @@ public static function generateOperation($prompt, ModelInterface $model): Genera * @param ModelInterface $model The model to use for text generation. * @return GenerativeAiOperation The operation for async text processing. * - * @throws \InvalidArgumentException If the prompt format is invalid or model doesn't support text generation. + * @throws \RuntimeException Operations are not implemented yet. */ public static function generateTextOperation($prompt, ModelInterface $model): GenerativeAiOperation { - $messages = PromptNormalizer::normalize($prompt); - /** @var list $messageList */ - $messageList = array_values($messages); - - Models::validateTextGenerationOperation($model); - return OperationFactory::createTextOperation($messageList); + throw new \RuntimeException( + 'Text generation operations are not implemented yet. This functionality is planned for a future release.' + ); } /** @@ -360,16 +352,13 @@ public static function generateTextOperation($prompt, ModelInterface $model): Ge * @param ModelInterface $model The model to use for image generation. * @return GenerativeAiOperation The operation for async image processing. * - * @throws \InvalidArgumentException If the prompt format is invalid or model doesn't support image generation. + * @throws \RuntimeException Operations are not implemented yet. */ public static function generateImageOperation($prompt, ModelInterface $model): GenerativeAiOperation { - $messages = PromptNormalizer::normalize($prompt); - /** @var list $messageList */ - $messageList = array_values($messages); - - Models::validateImageGenerationOperation($model); - return OperationFactory::createImageOperation($messageList); + throw new \RuntimeException( + 'Image generation operations are not implemented yet. This functionality is planned for a future release.' + ); } /** @@ -381,16 +370,14 @@ public static function generateImageOperation($prompt, ModelInterface $model): G * @param ModelInterface $model The model to use for text-to-speech conversion. * @return GenerativeAiOperation The operation for async text-to-speech processing. * - * @throws \InvalidArgumentException If the prompt format is invalid or model doesn't support text-to-speech. + * @throws \RuntimeException Operations are not implemented yet. */ public static function convertTextToSpeechOperation($prompt, ModelInterface $model): GenerativeAiOperation { - $messages = PromptNormalizer::normalize($prompt); - /** @var list $messageList */ - $messageList = array_values($messages); - - Models::validateTextToSpeechConversionOperation($model); - return OperationFactory::createTextToSpeechOperation($messageList); + throw new \RuntimeException( + 'Text-to-speech conversion operations are not implemented yet. ' . + 'This functionality is planned for a future release.' + ); } /** @@ -402,15 +389,12 @@ public static function convertTextToSpeechOperation($prompt, ModelInterface $mod * @param ModelInterface $model The model to use for speech generation. * @return GenerativeAiOperation The operation for async speech processing. * - * @throws \InvalidArgumentException If the prompt format is invalid or model doesn't support speech generation. + * @throws \RuntimeException Operations are not implemented yet. */ public static function generateSpeechOperation($prompt, ModelInterface $model): GenerativeAiOperation { - $messages = PromptNormalizer::normalize($prompt); - /** @var list $messageList */ - $messageList = array_values($messages); - - Models::validateSpeechGenerationOperation($model); - return OperationFactory::createSpeechOperation($messageList); + throw new \RuntimeException( + 'Speech generation operations are not implemented yet. This functionality is planned for a future release.' + ); } } diff --git a/src/Operations/OperationFactory.php b/src/Operations/OperationFactory.php deleted file mode 100644 index b7c44b2d..00000000 --- a/src/Operations/OperationFactory.php +++ /dev/null @@ -1,150 +0,0 @@ - 'op_', - 'text' => 'text_op_', - 'image' => 'image_op_', - 'textToSpeech' => 'tts_op_', - 'speech' => 'speech_op_', - ]; - - /** - * Creates a generic generation operation. - * - * @since n.e.x.t - * - * @param list $messages The normalized messages for the operation. - * @return GenerativeAiOperation The created operation. - */ - public static function createGenericOperation(array $messages): GenerativeAiOperation - { - return new GenerativeAiOperation( - uniqid(self::OPERATION_PREFIXES['generic'], true), - OperationStateEnum::starting(), - null - ); - } - - /** - * Creates a text generation operation. - * - * @since n.e.x.t - * - * @param list $messages The normalized messages for the operation. - * @return GenerativeAiOperation The created operation. - */ - public static function createTextOperation(array $messages): GenerativeAiOperation - { - return new GenerativeAiOperation( - uniqid(self::OPERATION_PREFIXES['text'], true), - OperationStateEnum::starting(), - null - ); - } - - /** - * Creates an image generation operation. - * - * @since n.e.x.t - * - * @param list $messages The normalized messages for the operation. - * @return GenerativeAiOperation The created operation. - */ - public static function createImageOperation(array $messages): GenerativeAiOperation - { - return new GenerativeAiOperation( - uniqid(self::OPERATION_PREFIXES['image'], true), - OperationStateEnum::starting(), - null - ); - } - - /** - * Creates a text-to-speech conversion operation. - * - * @since n.e.x.t - * - * @param list $messages The normalized messages for the operation. - * @return GenerativeAiOperation The created operation. - */ - public static function createTextToSpeechOperation(array $messages): GenerativeAiOperation - { - return new GenerativeAiOperation( - uniqid(self::OPERATION_PREFIXES['textToSpeech'], true), - OperationStateEnum::starting(), - null - ); - } - - /** - * Creates a speech generation operation. - * - * @since n.e.x.t - * - * @param list $messages The normalized messages for the operation. - * @return GenerativeAiOperation The created operation. - */ - public static function createSpeechOperation(array $messages): GenerativeAiOperation - { - return new GenerativeAiOperation( - uniqid(self::OPERATION_PREFIXES['speech'], true), - OperationStateEnum::starting(), - null - ); - } - - - /** - * Gets the operation prefix for a given operation type. - * - * @since n.e.x.t - * - * @param string $operationType The operation type (text, image, etc.). - * @return string The operation prefix. - * - * @throws \InvalidArgumentException If the operation type is not supported. - */ - public static function getOperationPrefix(string $operationType): string - { - if (!isset(self::OPERATION_PREFIXES[$operationType])) { - throw new \InvalidArgumentException( - sprintf('Unsupported operation type: %s', $operationType) - ); - } - - return self::OPERATION_PREFIXES[$operationType]; - } - - /** - * Gets all available operation prefixes. - * - * @since n.e.x.t - * - * @return array Array of operation type => prefix mappings. - */ - public static function getOperationPrefixes(): array - { - return self::OPERATION_PREFIXES; - } -} diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index c4e7c109..d9c772b2 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -11,8 +11,6 @@ use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\ModelMessage; use WordPress\AiClient\Messages\DTO\UserMessage; -use WordPress\AiClient\Operations\DTO\GenerativeAiOperation; -use WordPress\AiClient\Operations\Enums\OperationStateEnum; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; @@ -182,16 +180,18 @@ public function testGenerateImageResultWithInvalidModel(): void } /** - * Tests generateOperation with valid model. + * Tests generateOperation throws not implemented exception. */ - public function testGenerateOperation(): void + public function testGenerateOperationThrowsNotImplementedException(): void { $prompt = 'Generate content'; - $operation = AiClient::generateOperation($prompt, $this->mockTextModel); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Operations are not implemented yet. This functionality is planned for a future release.' + ); - $this->assertInstanceOf(GenerativeAiOperation::class, $operation); - $this->assertNotEmpty($operation->getId()); + AiClient::generateOperation($prompt, $this->mockTextModel); } /** @@ -426,64 +426,66 @@ public function testStreamGenerateTextResultForNonTextModelThrowsNotImplementedE } /** - * Tests generateTextOperation creates operation with text model validation. + * Tests generateTextOperation throws not implemented exception. */ - public function testGenerateTextOperationWithValidTextModel(): void + public function testGenerateTextOperationThrowsNotImplementedException(): void { $prompt = 'Text operation prompt'; - $operation = AiClient::generateTextOperation($prompt, $this->mockTextModel); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Text generation operations are not implemented yet. This functionality is planned for a future release.' + ); - $this->assertInstanceOf(GenerativeAiOperation::class, $operation); - $this->assertStringStartsWith('text_op_', $operation->getId()); - $this->assertEquals(OperationStateEnum::starting(), $operation->getState()); - $this->assertNull($operation->getResult()); + AiClient::generateTextOperation($prompt, $this->mockTextModel); } + /** - * Tests generateTextOperation throws exception for non-text model. + * Tests generateImageOperation throws not implemented exception. */ - public function testGenerateTextOperationThrowsExceptionForNonTextModel(): void + public function testGenerateImageOperationThrowsNotImplementedException(): void { - $prompt = 'Text operation prompt'; - $nonTextModel = $this->createMock(ModelInterface::class); + $prompt = 'Image operation prompt'; - $this->expectException(InvalidArgumentException::class); + $this->expectException(RuntimeException::class); $this->expectExceptionMessage( - 'Model must implement TextGenerationModelInterface for text generation operations' + 'Image generation operations are not implemented yet. This functionality is planned for a future release.' ); - AiClient::generateTextOperation($prompt, $nonTextModel); + AiClient::generateImageOperation($prompt, $this->mockImageModel); } /** - * Tests generateImageOperation creates operation with image model validation. + * Tests convertTextToSpeechOperation throws not implemented exception. */ - public function testGenerateImageOperationWithValidImageModel(): void + public function testConvertTextToSpeechOperationThrowsNotImplementedException(): void { - $prompt = 'Image operation prompt'; + $prompt = 'Text to speech operation prompt'; + $mockModel = $this->createMock(ModelInterface::class); - $operation = AiClient::generateImageOperation($prompt, $this->mockImageModel); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage( + 'Text-to-speech conversion operations are not implemented yet. ' . + 'This functionality is planned for a future release.' + ); - $this->assertInstanceOf(GenerativeAiOperation::class, $operation); - $this->assertStringStartsWith('image_op_', $operation->getId()); - $this->assertEquals(OperationStateEnum::starting(), $operation->getState()); - $this->assertNull($operation->getResult()); + AiClient::convertTextToSpeechOperation($prompt, $mockModel); } /** - * Tests generateImageOperation throws exception for non-image model. + * Tests generateSpeechOperation throws not implemented exception. */ - public function testGenerateImageOperationThrowsExceptionForNonImageModel(): void + public function testGenerateSpeechOperationThrowsNotImplementedException(): void { - $prompt = 'Image operation prompt'; - $nonImageModel = $this->createMock(ModelInterface::class); + $prompt = 'Speech operation prompt'; + $mockModel = $this->createMock(ModelInterface::class); - $this->expectException(InvalidArgumentException::class); + $this->expectException(RuntimeException::class); $this->expectExceptionMessage( - 'Model must implement ImageGenerationModelInterface for image generation operations' + 'Speech generation operations are not implemented yet. This functionality is planned for a future release.' ); - AiClient::generateImageOperation($prompt, $nonImageModel); + AiClient::generateSpeechOperation($prompt, $mockModel); } } diff --git a/tests/unit/Operations/OperationFactoryTest.php b/tests/unit/Operations/OperationFactoryTest.php deleted file mode 100644 index b33eeb33..00000000 --- a/tests/unit/Operations/OperationFactoryTest.php +++ /dev/null @@ -1,161 +0,0 @@ -testMessages = [ - new UserMessage([new MessagePart('Test message 1')]), - new UserMessage([new MessagePart('Test message 2')]) - ]; - } - - /** - * Tests createGenericOperation creates operation with correct prefix. - */ - public function testCreateGenericOperation(): void - { - $operation = OperationFactory::createGenericOperation($this->testMessages); - - $this->assertInstanceOf(GenerativeAiOperation::class, $operation); - $this->assertStringStartsWith('op_', $operation->getId()); - $this->assertEquals(OperationStateEnum::starting(), $operation->getState()); - $this->assertNull($operation->getResult()); - } - - /** - * Tests createTextOperation creates operation with correct prefix. - */ - public function testCreateTextOperation(): void - { - $operation = OperationFactory::createTextOperation($this->testMessages); - - $this->assertInstanceOf(GenerativeAiOperation::class, $operation); - $this->assertStringStartsWith('text_op_', $operation->getId()); - $this->assertEquals(OperationStateEnum::starting(), $operation->getState()); - $this->assertNull($operation->getResult()); - } - - /** - * Tests createImageOperation creates operation with correct prefix. - */ - public function testCreateImageOperation(): void - { - $operation = OperationFactory::createImageOperation($this->testMessages); - - $this->assertInstanceOf(GenerativeAiOperation::class, $operation); - $this->assertStringStartsWith('image_op_', $operation->getId()); - $this->assertEquals(OperationStateEnum::starting(), $operation->getState()); - $this->assertNull($operation->getResult()); - } - - /** - * Tests createTextToSpeechOperation creates operation with correct prefix. - */ - public function testCreateTextToSpeechOperation(): void - { - $operation = OperationFactory::createTextToSpeechOperation($this->testMessages); - - $this->assertInstanceOf(GenerativeAiOperation::class, $operation); - $this->assertStringStartsWith('tts_op_', $operation->getId()); - $this->assertEquals(OperationStateEnum::starting(), $operation->getState()); - $this->assertNull($operation->getResult()); - } - - /** - * Tests createSpeechOperation creates operation with correct prefix. - */ - public function testCreateSpeechOperation(): void - { - $operation = OperationFactory::createSpeechOperation($this->testMessages); - - $this->assertInstanceOf(GenerativeAiOperation::class, $operation); - $this->assertStringStartsWith('speech_op_', $operation->getId()); - $this->assertEquals(OperationStateEnum::starting(), $operation->getState()); - $this->assertNull($operation->getResult()); - } - - - /** - * Tests getOperationPrefix returns correct prefix for known types. - */ - public function testGetOperationPrefixReturnsCorrectPrefix(): void - { - $this->assertEquals('op_', OperationFactory::getOperationPrefix('generic')); - $this->assertEquals('text_op_', OperationFactory::getOperationPrefix('text')); - $this->assertEquals('image_op_', OperationFactory::getOperationPrefix('image')); - $this->assertEquals('tts_op_', OperationFactory::getOperationPrefix('textToSpeech')); - $this->assertEquals('speech_op_', OperationFactory::getOperationPrefix('speech')); - } - - /** - * Tests getOperationPrefix throws exception for unknown type. - */ - public function testGetOperationPrefixThrowsExceptionForUnknownType(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Unsupported operation type: unknown'); - - OperationFactory::getOperationPrefix('unknown'); - } - - /** - * Tests getOperationPrefixes returns all available prefixes. - */ - public function testGetOperationPrefixesReturnsAllPrefixes(): void - { - $prefixes = OperationFactory::getOperationPrefixes(); - - $expected = [ - 'generic' => 'op_', - 'text' => 'text_op_', - 'image' => 'image_op_', - 'textToSpeech' => 'tts_op_', - 'speech' => 'speech_op_', - ]; - - $this->assertEquals($expected, $prefixes); - $this->assertCount(5, $prefixes); - } - - /** - * Tests that operation IDs are unique across multiple calls. - */ - public function testOperationIdsAreUnique(): void - { - $operation1 = OperationFactory::createTextOperation($this->testMessages); - $operation2 = OperationFactory::createTextOperation($this->testMessages); - - $this->assertNotEquals($operation1->getId(), $operation2->getId()); - } - - /** - * Tests that operation IDs contain uniqid entropy. - */ - public function testOperationIdsContainEntropy(): void - { - $operation = OperationFactory::createTextOperation($this->testMessages); - - // Should contain more than just the prefix - $this->assertGreaterThan(strlen('text_op_'), strlen($operation->getId())); - - // Should contain the prefix - $this->assertStringStartsWith('text_op_', $operation->getId()); - } -} From ab67b8a1ce76c27a4fa0e775d5d277a4164d36ac Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 20 Aug 2025 13:10:32 -0600 Subject: [PATCH 46/69] refactor: cleans up normalizer typing --- src/AiClient.php | 12 ++++-------- src/Utils/PromptNormalizer.php | 2 +- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index f8449539..b6ba960b 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -182,25 +182,23 @@ private static function executeGeneration($prompt, ?ModelInterface $model, strin // TODO: Replace with PromptBuilder delegation once PR #49 is merged // This should become: return self::prompt($prompt)->usingModel($model)->generate(); $messages = PromptNormalizer::normalize($prompt); - /** @var list $messageList */ - $messageList = array_values($messages); // Map type to specific methods switch ($type) { case 'text': $resolvedModel = $model ?? Models::findTextModel(self::defaultRegistry()); Models::validateTextGeneration($resolvedModel); - return $resolvedModel->generateTextResult($messageList); + return $resolvedModel->generateTextResult($messages); case 'image': $resolvedModel = $model ?? Models::findImageModel(self::defaultRegistry()); Models::validateImageGeneration($resolvedModel); - return $resolvedModel->generateImageResult($messageList); + return $resolvedModel->generateImageResult($messages); case 'speech': $resolvedModel = $model ?? Models::findSpeechModel(self::defaultRegistry()); Models::validateSpeechGeneration($resolvedModel); - return $resolvedModel->generateSpeechResult($messageList); + return $resolvedModel->generateSpeechResult($messages); default: throw new \InvalidArgumentException("Unsupported generation type: {$type}"); @@ -275,8 +273,6 @@ public static function convertTextToSpeechResult($prompt, ?ModelInterface $model { // TODO: Replace with PromptBuilder delegation once PR #49 is merged $messages = PromptNormalizer::normalize($prompt); - /** @var list $messageList */ - $messageList = array_values($messages); // Get model - either provided or auto-discovered $resolvedModel = $model ?? Models::findTextToSpeechModel(self::defaultRegistry()); @@ -285,7 +281,7 @@ public static function convertTextToSpeechResult($prompt, ?ModelInterface $model Models::validateTextToSpeechConversion($resolvedModel); // Generate the result using the model - return $resolvedModel->convertTextToSpeechResult($messageList); + return $resolvedModel->convertTextToSpeechResult($messages); } /** diff --git a/src/Utils/PromptNormalizer.php b/src/Utils/PromptNormalizer.php index 95fe075e..aeb26a6b 100644 --- a/src/Utils/PromptNormalizer.php +++ b/src/Utils/PromptNormalizer.php @@ -202,7 +202,7 @@ private static function createMessageParts($parts, int $index): array } $messageParts = []; - foreach ($parts as $partIndex => $part) { + foreach ($parts as $part) { if (is_string($part)) { $messageParts[] = new MessagePart($part); } elseif ($part instanceof MessagePart) { From abd171b429e82d8e60428b884c48c200bfb34482 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 20 Aug 2025 13:17:47 -0600 Subject: [PATCH 47/69] refactor: simplifies noramlization array checking --- src/Utils/PromptNormalizer.php | 22 +++++----------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/src/Utils/PromptNormalizer.php b/src/Utils/PromptNormalizer.php index aeb26a6b..f84dd0c8 100644 --- a/src/Utils/PromptNormalizer.php +++ b/src/Utils/PromptNormalizer.php @@ -37,7 +37,7 @@ class PromptNormalizer public static function normalize($prompt): array { // Handle structured message arrays at the top level - if (is_array($prompt) && self::hasStringKeys($prompt) && self::isStructuredMessageArray($prompt)) { + if (is_array($prompt) && self::isStructuredMessageArray($prompt)) { return [self::normalizeStructuredMessage($prompt, 0)]; } @@ -79,7 +79,7 @@ private static function normalizeItem($item, int $index): Message } // Handle structured message arrays: {'role': 'system', 'parts': [...]} - if (is_array($item) && self::hasStringKeys($item) && self::isStructuredMessageArray($item)) { + if (is_array($item) && self::isStructuredMessageArray($item)) { return self::normalizeStructuredMessage($item, $index); } @@ -108,7 +108,9 @@ private static function normalizeItem($item, int $index): Message * * @since n.e.x.t * - * @param array $item The array to check. + * @phpstan-assert-if-true array $item + * + * @param array $item The array to check. * @return bool True if it's a structured message array. */ private static function isStructuredMessageArray(array $item): bool @@ -218,18 +220,4 @@ private static function createMessageParts($parts, int $index): array return $messageParts; } - - /** - * Checks if an array has string keys (associative array). - * - * @since n.e.x.t - * - * @param array $array The array to check. - * @return bool True if the array has string keys. - * @phpstan-assert-if-true array $array - */ - private static function hasStringKeys(array $array): bool - { - return array_keys($array) !== range(0, count($array) - 1); - } } From bdb86eb2d91054ceb23c8e724afcdacd0dfd2223 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 20 Aug 2025 14:09:25 -0600 Subject: [PATCH 48/69] feat: broadens prompt shape and simplifies normalizing --- src/AiClient.php | 49 +++-- src/Utils/PromptNormalizer.php | 235 ++++++---------------- tests/unit/Utils/PromptNormalizerTest.php | 15 +- 3 files changed, 106 insertions(+), 193 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index b6ba960b..cf7d8de5 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -28,6 +28,11 @@ * - Integration with provider registry for model discovery * * @since n.e.x.t + * + * @phpstan-import-type MessageArrayShape from Message + * + * phpcs:ignore Generic.Files.LineLength.TooLong + * @phpstan-type Prompt string|MessagePart|Message|MessageArrayShape|list|list */ class AiClient { @@ -107,7 +112,7 @@ public static function prompt($text = null) * * @since n.e.x.t * - * @param string|MessagePart|Message|list $prompt The prompt content. + * @param Prompt $prompt The prompt content. * @param ModelInterface $model The model to use for generation. * @return GenerativeAiResult The generation result. * @@ -169,7 +174,7 @@ public static function message(?string $text = null) * * @since n.e.x.t * - * @param string|MessagePart|Message|list $prompt The prompt content. + * @param Prompt $prompt The prompt content. * @param ModelInterface|null $model Optional specific model to use. * @param string $type The generation type. * @return GenerativeAiResult The generation result. @@ -181,7 +186,15 @@ private static function executeGeneration($prompt, ?ModelInterface $model, strin { // TODO: Replace with PromptBuilder delegation once PR #49 is merged // This should become: return self::prompt($prompt)->usingModel($model)->generate(); - $messages = PromptNormalizer::normalize($prompt); + + // Check if it's already a list of Messages + if (PromptNormalizer::isMessagesList($prompt)) { + $messages = $prompt; + } else { + // Otherwise normalize to a single Message and wrap in array + $message = PromptNormalizer::normalize($prompt); + $messages = [$message]; + } // Map type to specific methods switch ($type) { @@ -210,7 +223,7 @@ private static function executeGeneration($prompt, ?ModelInterface $model, strin * * @since n.e.x.t * - * @param string|MessagePart|Message|list $prompt The prompt content. + * @param Prompt $prompt The prompt content. * @param ModelInterface|null $model Optional specific model to use. * @return GenerativeAiResult The generation result. * @@ -227,7 +240,7 @@ public static function generateTextResult($prompt, ?ModelInterface $model = null * * @since n.e.x.t * - * @param string|MessagePart|Message|list $prompt The prompt content. + * @param Prompt $prompt The prompt content. * @param ModelInterface|null $model Optional specific model to use. * @return Generator Generator yielding partial text generation results. * @@ -245,7 +258,7 @@ public static function streamGenerateTextResult($prompt, ?ModelInterface $model * * @since n.e.x.t * - * @param string|MessagePart|Message|list $prompt The prompt content. + * @param Prompt $prompt The prompt content. * @param ModelInterface|null $model Optional specific model to use. * @return GenerativeAiResult The generation result. * @@ -262,7 +275,7 @@ public static function generateImageResult($prompt, ?ModelInterface $model = nul * * @since n.e.x.t * - * @param string|MessagePart|Message|list $prompt The prompt content. + * @param Prompt $prompt The prompt content. * @param ModelInterface|null $model Optional specific model to use. * @return GenerativeAiResult The generation result. * @@ -272,7 +285,15 @@ public static function generateImageResult($prompt, ?ModelInterface $model = nul public static function convertTextToSpeechResult($prompt, ?ModelInterface $model = null): GenerativeAiResult { // TODO: Replace with PromptBuilder delegation once PR #49 is merged - $messages = PromptNormalizer::normalize($prompt); + + // Check if it's already a list of Messages + if (PromptNormalizer::isMessagesList($prompt)) { + $messages = $prompt; + } else { + // Otherwise normalize to a single Message and wrap in array + $message = PromptNormalizer::normalize($prompt); + $messages = [$message]; + } // Get model - either provided or auto-discovered $resolvedModel = $model ?? Models::findTextToSpeechModel(self::defaultRegistry()); @@ -289,7 +310,7 @@ public static function convertTextToSpeechResult($prompt, ?ModelInterface $model * * @since n.e.x.t * - * @param string|MessagePart|Message|list $prompt The prompt content. + * @param Prompt $prompt The prompt content. * @param ModelInterface|null $model Optional specific model to use. * @return GenerativeAiResult The generation result. * @@ -308,7 +329,7 @@ public static function generateSpeechResult($prompt, ?ModelInterface $model = nu * * @since n.e.x.t * - * @param string|MessagePart|Message|list $prompt The prompt content. + * @param Prompt $prompt The prompt content. * @param ModelInterface $model The model to use for generation. * @return GenerativeAiOperation The operation for async processing. * @@ -326,7 +347,7 @@ public static function generateOperation($prompt, ModelInterface $model): Genera * * @since n.e.x.t * - * @param string|MessagePart|Message|list $prompt The prompt content. + * @param Prompt $prompt The prompt content. * @param ModelInterface $model The model to use for text generation. * @return GenerativeAiOperation The operation for async text processing. * @@ -344,7 +365,7 @@ public static function generateTextOperation($prompt, ModelInterface $model): Ge * * @since n.e.x.t * - * @param string|MessagePart|Message|list $prompt The prompt content. + * @param Prompt $prompt The prompt content. * @param ModelInterface $model The model to use for image generation. * @return GenerativeAiOperation The operation for async image processing. * @@ -362,7 +383,7 @@ public static function generateImageOperation($prompt, ModelInterface $model): G * * @since n.e.x.t * - * @param string|MessagePart|Message|list $prompt The prompt content. + * @param Prompt $prompt The prompt content. * @param ModelInterface $model The model to use for text-to-speech conversion. * @return GenerativeAiOperation The operation for async text-to-speech processing. * @@ -381,7 +402,7 @@ public static function convertTextToSpeechOperation($prompt, ModelInterface $mod * * @since n.e.x.t * - * @param string|MessagePart|Message|list $prompt The prompt content. + * @param Prompt $prompt The prompt content. * @param ModelInterface $model The model to use for speech generation. * @return GenerativeAiOperation The operation for async speech processing. * diff --git a/src/Utils/PromptNormalizer.php b/src/Utils/PromptNormalizer.php index f84dd0c8..bd173b36 100644 --- a/src/Utils/PromptNormalizer.php +++ b/src/Utils/PromptNormalizer.php @@ -7,217 +7,108 @@ use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\UserMessage; -use WordPress\AiClient\Messages\Enums\MessageRoleEnum; /** - * Utility class for normalizing various prompt formats into standardized Message arrays. + * Utility class for normalizing various prompt formats into a standardized Message. * - * @phpstan-import-type MessagePartArrayShape from MessagePart * @since n.e.x.t + * + * @phpstan-import-type MessageArrayShape from Message */ class PromptNormalizer { /** - * Normalizes various prompt formats into a standardized Message array. - * - * Supports: - * - Strings: converted to UserMessage with MessagePart - * - MessagePart: wrapped in UserMessage - * - Message: used directly - * - Structured arrays: {'role': 'system', 'parts': [...]} format with role mapping - * - Arrays of any combination of the above + * Checks if the given value is a list of Message objects. * * @since n.e.x.t * - * @param mixed $prompt The prompt content in various formats. - * @return list Array of Message objects. + * @param mixed $value The value to check. + * @return bool True if the value is a list of Messages. * - * @throws \InvalidArgumentException If the prompt format is invalid. + * @phpstan-assert-if-true list $value */ - public static function normalize($prompt): array + public static function isMessagesList($value): bool { - // Handle structured message arrays at the top level - if (is_array($prompt) && self::isStructuredMessageArray($prompt)) { - return [self::normalizeStructuredMessage($prompt, 0)]; - } - - // Normalize to array first for consistent processing - if (!is_array($prompt)) { - $prompt = [$prompt]; - } - - // Empty array check - if (empty($prompt)) { - throw new \InvalidArgumentException('Prompt array cannot be empty'); + if (!is_array($value) || empty($value) || !array_is_list($value)) { + return false; } - // Process each item individually - $messages = []; - foreach ($prompt as $index => $item) { - $messages[] = self::normalizeItem($item, is_int($index) ? $index : 0); + // Check that every element is a Message + foreach ($value as $item) { + if (!($item instanceof Message)) { + return false; + } } - return $messages; + return true; } /** - * Normalizes a single prompt item to a Message. + * Normalizes various prompt formats into a standardized Message. + * + * Supports: + * - String: converted to UserMessage with single MessagePart + * - Structured array: {'role': 'system', 'parts': [...]} format + * - Message: returned as-is + * - Array of strings/MessageParts: converted to UserMessage with multiple parts * * @since n.e.x.t * - * @param mixed $item The prompt item to normalize. - * @param int $index The array index for error reporting. + * @param mixed $prompt The prompt content in various formats. * @return Message The normalized message. * - * @throws \InvalidArgumentException If the item format is invalid. + * @throws \InvalidArgumentException If the prompt format is invalid. */ - private static function normalizeItem($item, int $index): Message + public static function normalize($prompt): Message { - // Handle Message objects - if ($item instanceof Message) { - return $item; + // Already a Message + if ($prompt instanceof Message) { + return $prompt; } - // Handle structured message arrays: {'role': 'system', 'parts': [...]} - if (is_array($item) && self::isStructuredMessageArray($item)) { - return self::normalizeStructuredMessage($item, $index); + // Simple string + if (is_string($prompt)) { + return new UserMessage([new MessagePart($prompt)]); } - // Handle strings - convert to user message - if (is_string($item)) { - return new UserMessage([new MessagePart($item)]); + // Structured message array with role and parts + if (is_array($prompt) && isset($prompt[Message::KEY_ROLE]) && isset($prompt[Message::KEY_PARTS])) { + /** @var MessageArrayShape $prompt */ + return Message::fromArray($prompt); } - // Handle MessagePart objects - wrap in user message - if ($item instanceof MessagePart) { - return new UserMessage([$item]); + // Array of strings/MessageParts to combine into a single UserMessage + if (is_array($prompt)) { + if (empty($prompt)) { + throw new \InvalidArgumentException('Prompt array cannot be empty'); + } + + $parts = []; + foreach ($prompt as $item) { + if (is_string($item)) { + $parts[] = new MessagePart($item); + } elseif ($item instanceof MessagePart) { + $parts[] = $item; + } else { + throw new \InvalidArgumentException( + sprintf( + 'Array items must be strings or MessagePart objects, got %s', + is_object($item) ? get_class($item) : gettype($item) + ) + ); + } + } + + return new UserMessage($parts); } + // Invalid format throw new \InvalidArgumentException( sprintf( - 'Array element at index %d must be a string, MessagePart, Message, or ' . - 'structured message array, %s given', - $index, - is_array($item) ? 'invalid array format' : gettype($item) + 'Invalid prompt format: expected string, Message, structured array, ' . + 'or array of strings/MessageParts, got %s', + is_object($prompt) ? get_class($prompt) : gettype($prompt) ) ); } - - /** - * Checks if an array is a structured message format. - * - * @since n.e.x.t - * - * @phpstan-assert-if-true array $item - * - * @param array $item The array to check. - * @return bool True if it's a structured message array. - */ - private static function isStructuredMessageArray(array $item): bool - { - return isset($item['role']); - } - - /** - * Normalizes a structured message array by creating Message directly. - * - * @since n.e.x.t - * - * @param array $item The structured message array. - * @param int $index The array index for error reporting. - * @return Message The normalized message. - * - * @throws \InvalidArgumentException If the structured format is invalid. - */ - private static function normalizeStructuredMessage(array $item, int $index): Message - { - // Validate required keys - if (!isset($item['parts'])) { - throw new \InvalidArgumentException( - sprintf('Structured message at index %d is missing required "parts" field', $index) - ); - } - - // Map role and create message parts - $role = self::mapRoleToEnum($item['role'], $index); - $parts = self::createMessageParts($item['parts'], $index); - - return new Message($role, $parts); - } - - /** - * Maps role strings to MessageRoleEnum instances with support for common aliases. - * - * @since n.e.x.t - * - * @param mixed $role The role value to map. - * @param int $index The array index for error reporting. - * @return MessageRoleEnum The mapped role enum. - * - * @throws \InvalidArgumentException If the role is invalid. - */ - private static function mapRoleToEnum($role, int $index): MessageRoleEnum - { - if (!is_string($role)) { - throw new \InvalidArgumentException( - sprintf('Role at index %d must be a string, %s given', $index, gettype($role)) - ); - } - - // Map common role aliases to enum instances - switch (strtolower($role)) { - case 'system': - return MessageRoleEnum::system(); - case 'user': - return MessageRoleEnum::user(); - case 'model': - case 'assistant': - return MessageRoleEnum::model(); - default: - throw new \InvalidArgumentException( - sprintf( - 'Invalid role "%s" at index %d. Must be "system", "user", "model", or "assistant"', - $role, - $index - ) - ); - } - } - - /** - * Creates MessagePart objects from various input formats. - * - * @since n.e.x.t - * - * @param mixed $parts The parts to create. - * @param int $index The array index for error reporting. - * @return list The created message parts. - * - * @throws \InvalidArgumentException If the parts format is invalid. - */ - private static function createMessageParts($parts, int $index): array - { - if (!is_array($parts)) { - throw new \InvalidArgumentException( - sprintf('Parts at index %d must be an array, %s given', $index, gettype($parts)) - ); - } - - $messageParts = []; - foreach ($parts as $part) { - if (is_string($part)) { - $messageParts[] = new MessagePart($part); - } elseif ($part instanceof MessagePart) { - $messageParts[] = $part; - } elseif (is_array($part)) { - /** @var MessagePartArrayShape $part */ - $messageParts[] = MessagePart::fromArray($part); - } else { - // Fallback like MessageUtil - convert anything else to string - $messageParts[] = new MessagePart($part); - } - } - - return $messageParts; - } } diff --git a/tests/unit/Utils/PromptNormalizerTest.php b/tests/unit/Utils/PromptNormalizerTest.php index 503decf5..e8e9e5df 100644 --- a/tests/unit/Utils/PromptNormalizerTest.php +++ b/tests/unit/Utils/PromptNormalizerTest.php @@ -112,8 +112,8 @@ public function testNormalizeMixedArrayThrowsException(): void $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage( - 'Array element at index 2 must be a string, MessagePart, Message, or ' . - 'structured message array, integer given' + 'Invalid prompt format: expected string, MessagePart, Message, ' . + 'or structured array with "role" key, got integer' ); PromptNormalizer::normalize($invalidArray); @@ -126,8 +126,8 @@ public function testNormalizeInvalidInputThrowsException(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage( - 'Array element at index 0 must be a string, MessagePart, Message, or ' . - 'structured message array, integer given' + 'Invalid prompt format: expected string, MessagePart, Message, ' . + 'or structured array with "role" key, got integer' ); PromptNormalizer::normalize(123); @@ -142,7 +142,8 @@ public function testNormalizeArrayWithInvalidObjectsThrowsException(): void $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage( - 'Array element at index 0 must be a string, MessagePart, Message, or structured message array, object given' + 'Invalid prompt format: expected string, MessagePart, Message, ' . + 'or structured array with "role" key, got object' ); PromptNormalizer::normalize($invalidArray); @@ -207,7 +208,7 @@ public function testStructuredMessageInvalidRoleThrowsException(): void ]; $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Invalid role "invalid_role" at index 0'); + $this->expectExceptionMessage('invalid_role is not a valid backing value for enum'); PromptNormalizer::normalize($structuredMessage); } @@ -223,7 +224,7 @@ public function testStructuredMessageMissingPartsThrowsException(): void ]; $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Structured message at index 0 is missing required "parts" field'); + $this->expectExceptionMessage('missing required keys: parts'); PromptNormalizer::normalize($structuredMessage); } From 94c0e4e7e68af67a67169bd3841e19a50da14560 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Wed, 20 Aug 2025 16:26:23 -0600 Subject: [PATCH 49/69] test: fixes tests broken by changing normalizing to single Message --- src/AiClient.php | 4 + src/Utils/PromptNormalizer.php | 14 +- tests/unit/AiClientTest.php | 28 +-- tests/unit/Utils/PromptNormalizerTest.php | 205 ++++++++++++++-------- 4 files changed, 159 insertions(+), 92 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index cf7d8de5..d95f60fa 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -192,6 +192,8 @@ private static function executeGeneration($prompt, ?ModelInterface $model, strin $messages = $prompt; } else { // Otherwise normalize to a single Message and wrap in array + // PHPStan needs help narrowing the type after isMessagesList() check + /** @var string|MessagePart|Message|MessageArrayShape|list $prompt */ $message = PromptNormalizer::normalize($prompt); $messages = [$message]; } @@ -291,6 +293,8 @@ public static function convertTextToSpeechResult($prompt, ?ModelInterface $model $messages = $prompt; } else { // Otherwise normalize to a single Message and wrap in array + // PHPStan needs help narrowing the type after isMessagesList() check + /** @var string|MessagePart|Message|MessageArrayShape|list $prompt */ $message = PromptNormalizer::normalize($prompt); $messages = [$message]; } diff --git a/src/Utils/PromptNormalizer.php b/src/Utils/PromptNormalizer.php index bd173b36..fee5d16e 100644 --- a/src/Utils/PromptNormalizer.php +++ b/src/Utils/PromptNormalizer.php @@ -48,16 +48,19 @@ public static function isMessagesList($value): bool * * Supports: * - String: converted to UserMessage with single MessagePart - * - Structured array: {'role': 'system', 'parts': [...]} format + * - MessagePart: wrapped in UserMessage + * - Message array: {'role': 'system', 'parts': [...]} format * - Message: returned as-is * - Array of strings/MessageParts: converted to UserMessage with multiple parts * * @since n.e.x.t * - * @param mixed $prompt The prompt content in various formats. + * @param string|MessagePart|Message|MessageArrayShape|list $prompt The prompt content. * @return Message The normalized message. * * @throws \InvalidArgumentException If the prompt format is invalid. + * + * @phpstan-param string|MessagePart|Message|MessageArrayShape|list $prompt */ public static function normalize($prompt): Message { @@ -71,6 +74,11 @@ public static function normalize($prompt): Message return new UserMessage([new MessagePart($prompt)]); } + // Single MessagePart + if ($prompt instanceof MessagePart) { + return new UserMessage([$prompt]); + } + // Structured message array with role and parts if (is_array($prompt) && isset($prompt[Message::KEY_ROLE]) && isset($prompt[Message::KEY_PARTS])) { /** @var MessageArrayShape $prompt */ @@ -105,7 +113,7 @@ public static function normalize($prompt): Message // Invalid format throw new \InvalidArgumentException( sprintf( - 'Invalid prompt format: expected string, Message, structured array, ' . + 'Invalid prompt format: expected string, Message, MessagePart, structured array, ' . 'or array of strings/MessageParts, got %s', is_object($prompt) ? get_class($prompt) : gettype($prompt) ) diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index d9c772b2..cd2d5d8d 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -118,8 +118,8 @@ public function testGenerateTextResultWithStringAndModel(): void ->method('generateTextResult') ->with($this->callback(function ($messages) { return is_array($messages) && - count($messages) === 1 && - $messages[0] instanceof UserMessage; + count($messages) === 1 && + $messages[0] instanceof UserMessage; })) ->willReturn($mockResult); @@ -155,8 +155,8 @@ public function testGenerateImageResultWithStringAndModel(): void ->method('generateImageResult') ->with($this->callback(function ($messages) { return is_array($messages) && - count($messages) === 1 && - $messages[0] instanceof UserMessage; + count($messages) === 1 && + $messages[0] instanceof UserMessage; })) ->willReturn($mockResult); @@ -208,8 +208,8 @@ public function testGenerateTextResultWithMessage(): void ->method('generateTextResult') ->with($this->callback(function ($messages) use ($message) { return is_array($messages) && - count($messages) === 1 && - $messages[0] === $message; + count($messages) === 1 && + $messages[0] === $message; })) ->willReturn($mockResult); @@ -231,8 +231,8 @@ public function testGenerateTextResultWithMessagePart(): void ->method('generateTextResult') ->with($this->callback(function ($messages) { return is_array($messages) && - count($messages) === 1 && - $messages[0] instanceof UserMessage; + count($messages) === 1 && + $messages[0] instanceof UserMessage; })) ->willReturn($mockResult); @@ -259,9 +259,9 @@ public function testGenerateTextResultWithMessageArray(): void ->method('generateTextResult') ->with($this->callback(function ($result) use ($messages) { return is_array($result) && - count($result) === 2 && - $result[0] === $messages[0] && - $result[1] === $messages[1]; + count($result) === 2 && + $result[0] === $messages[0] && + $result[1] === $messages[1]; })) ->willReturn($mockResult); @@ -286,9 +286,9 @@ public function testGenerateTextResultWithMessagePartArray(): void ->method('generateTextResult') ->with($this->callback(function ($messages) { return is_array($messages) && - count($messages) === 2 && - $messages[0] instanceof UserMessage && - $messages[1] instanceof UserMessage; + count($messages) === 1 && + $messages[0] instanceof UserMessage && + count($messages[0]->getParts()) === 2; })) ->willReturn($mockResult); diff --git a/tests/unit/Utils/PromptNormalizerTest.php b/tests/unit/Utils/PromptNormalizerTest.php index e8e9e5df..f62c00b7 100644 --- a/tests/unit/Utils/PromptNormalizerTest.php +++ b/tests/unit/Utils/PromptNormalizerTest.php @@ -24,10 +24,9 @@ public function testNormalizeString(): void $prompt = 'Test prompt'; $result = PromptNormalizer::normalize($prompt); - $this->assertCount(1, $result); - $this->assertInstanceOf(UserMessage::class, $result[0]); - $this->assertCount(1, $result[0]->getParts()); - $this->assertEquals('Test prompt', $result[0]->getParts()[0]->getText()); + $this->assertInstanceOf(UserMessage::class, $result); + $this->assertCount(1, $result->getParts()); + $this->assertEquals('Test prompt', $result->getParts()[0]->getText()); } /** @@ -38,10 +37,9 @@ public function testNormalizeMessagePart(): void $messagePart = new MessagePart('Test message part'); $result = PromptNormalizer::normalize($messagePart); - $this->assertCount(1, $result); - $this->assertInstanceOf(UserMessage::class, $result[0]); - $this->assertCount(1, $result[0]->getParts()); - $this->assertSame($messagePart, $result[0]->getParts()[0]); + $this->assertInstanceOf(UserMessage::class, $result); + $this->assertCount(1, $result->getParts()); + $this->assertSame($messagePart, $result->getParts()[0]); } /** @@ -53,12 +51,11 @@ public function testNormalizeSingleMessage(): void $message = new UserMessage([$messagePart]); $result = PromptNormalizer::normalize($message); - $this->assertCount(1, $result); - $this->assertSame($message, $result[0]); + $this->assertSame($message, $result); } /** - * Tests normalizing array of Messages. + * Tests normalizing array of Messages throws exception. */ public function testNormalizeMessageArray(): void { @@ -66,11 +63,12 @@ public function testNormalizeMessageArray(): void $message2 = new UserMessage([new MessagePart('Second message')]); $messages = [$message1, $message2]; - $result = PromptNormalizer::normalize($messages); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Array items must be strings or MessagePart objects, got ' . UserMessage::class + ); - $this->assertCount(2, $result); - $this->assertSame($message1, $result[0]); - $this->assertSame($message2, $result[1]); + PromptNormalizer::normalize($messages); } /** @@ -84,11 +82,10 @@ public function testNormalizeMessagePartArray(): void $result = PromptNormalizer::normalize($parts); - $this->assertCount(2, $result); - $this->assertInstanceOf(UserMessage::class, $result[0]); - $this->assertInstanceOf(UserMessage::class, $result[1]); - $this->assertSame($part1, $result[0]->getParts()[0]); - $this->assertSame($part2, $result[1]->getParts()[0]); + $this->assertInstanceOf(UserMessage::class, $result); + $this->assertCount(2, $result->getParts()); + $this->assertSame($part1, $result->getParts()[0]); + $this->assertSame($part2, $result->getParts()[1]); } /** @@ -112,8 +109,7 @@ public function testNormalizeMixedArrayThrowsException(): void $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage( - 'Invalid prompt format: expected string, MessagePart, Message, ' . - 'or structured array with "role" key, got integer' + 'Array items must be strings or MessagePart objects, got integer' ); PromptNormalizer::normalize($invalidArray); @@ -126,8 +122,8 @@ public function testNormalizeInvalidInputThrowsException(): void { $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage( - 'Invalid prompt format: expected string, MessagePart, Message, ' . - 'or structured array with "role" key, got integer' + 'Invalid prompt format: expected string, Message, MessagePart, structured array, ' . + 'or array of strings/MessageParts, got integer' ); PromptNormalizer::normalize(123); @@ -142,111 +138,170 @@ public function testNormalizeArrayWithInvalidObjectsThrowsException(): void $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage( - 'Invalid prompt format: expected string, MessagePart, Message, ' . - 'or structured array with "role" key, got object' + 'Array items must be strings or MessagePart objects, got stdClass' ); PromptNormalizer::normalize($invalidArray); } /** - * Tests normalizing structured message array. + * Tests normalizing message array. */ - public function testNormalizeStructuredMessage(): void + public function testNormalizeMessageArrayShape(): void { - $structuredMessage = [ + $messageArray = [ 'role' => 'system', - 'parts' => ['You are a helpful assistant.', 'Be concise.'] + 'parts' => [ + ['text' => 'You are a helpful assistant.'], + ['text' => 'Be concise.'] + ] ]; - $result = PromptNormalizer::normalize($structuredMessage); + $result = PromptNormalizer::normalize($messageArray); - $this->assertCount(1, $result); - $this->assertInstanceOf(Message::class, $result[0]); - $this->assertTrue($result[0]->getRole()->equals(MessageRoleEnum::system())); - $this->assertCount(2, $result[0]->getParts()); - $this->assertEquals('You are a helpful assistant.', $result[0]->getParts()[0]->getText()); - $this->assertEquals('Be concise.', $result[0]->getParts()[1]->getText()); + $this->assertInstanceOf(Message::class, $result); + $this->assertTrue($result->getRole()->equals(MessageRoleEnum::system())); + $this->assertCount(2, $result->getParts()); + $this->assertEquals('You are a helpful assistant.', $result->getParts()[0]->getText()); + $this->assertEquals('Be concise.', $result->getParts()[1]->getText()); } /** - * Tests normalizing mixed array with structured messages. + * Tests normalizing mixed array with strings and MessageParts. */ - public function testNormalizeMixedWithStructuredMessages(): void + public function testNormalizeMixedStringAndMessageParts(): void { $mixed = [ - ['role' => 'system', 'parts' => ['System prompt']], 'User message', new MessagePart('Part message') ]; $result = PromptNormalizer::normalize($mixed); - $this->assertCount(3, $result); - - // First: structured system message - $this->assertTrue($result[0]->getRole()->equals(MessageRoleEnum::system())); - $this->assertEquals('System prompt', $result[0]->getParts()[0]->getText()); - - // Second: user message from string - $this->assertInstanceOf(UserMessage::class, $result[1]); - $this->assertEquals('User message', $result[1]->getParts()[0]->getText()); - - // Third: user message from MessagePart - $this->assertInstanceOf(UserMessage::class, $result[2]); - $this->assertEquals('Part message', $result[2]->getParts()[0]->getText()); + $this->assertInstanceOf(UserMessage::class, $result); + $this->assertCount(2, $result->getParts()); + $this->assertEquals('User message', $result->getParts()[0]->getText()); + $this->assertEquals('Part message', $result->getParts()[1]->getText()); } /** - * Tests structured message with invalid role throws exception. + * Tests message array with invalid role throws exception. */ - public function testStructuredMessageInvalidRoleThrowsException(): void + public function testMessageArrayInvalidRoleThrowsException(): void { - $structuredMessage = [ + $messageArray = [ 'role' => 'invalid_role', - 'parts' => ['Some text'] + 'parts' => [['text' => 'Some text']] ]; $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('invalid_role is not a valid backing value for enum'); - PromptNormalizer::normalize($structuredMessage); + PromptNormalizer::normalize($messageArray); } /** - * Tests structured message with missing parts throws exception. + * Tests message array with missing parts is treated as string array. */ - public function testStructuredMessageMissingPartsThrowsException(): void + public function testMessageArrayMissingPartsTreatedAsStringArray(): void { - $structuredMessage = [ + $messageArray = [ 'role' => 'user' - // Missing 'parts' + // Missing 'parts' - will be treated as array with string value 'user' ]; - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('missing required keys: parts'); + $result = PromptNormalizer::normalize($messageArray); - PromptNormalizer::normalize($structuredMessage); + // It should create a UserMessage with a single part containing 'user' text + $this->assertInstanceOf(UserMessage::class, $result); + $this->assertCount(1, $result->getParts()); + $this->assertEquals('user', $result->getParts()[0]->getText()); } /** * Tests role mapping for different variations. */ public function testRoleMapping(): void + { + // Test system role + $systemMsg = [ + 'role' => 'system', + 'parts' => [['text' => 'System']] + ]; + $result = PromptNormalizer::normalize($systemMsg); + $this->assertTrue($result->getRole()->equals(MessageRoleEnum::system())); + + // Test user role + $userMsg = [ + 'role' => 'user', + 'parts' => [['text' => 'User']] + ]; + $result = PromptNormalizer::normalize($userMsg); + $this->assertTrue($result->getRole()->equals(MessageRoleEnum::user())); + + // Test model role + $modelMsg = [ + 'role' => 'model', + 'parts' => [['text' => 'Model']] + ]; + $result = PromptNormalizer::normalize($modelMsg); + $this->assertTrue($result->getRole()->equals(MessageRoleEnum::model())); + } + + /** + * Tests that isMessagesList returns true for a list of Message objects. + */ + public function testIsMessagesListReturnsTrueForMessages(): void { $messages = [ - ['role' => 'system', 'parts' => ['System']], - ['role' => 'user', 'parts' => ['User']], - ['role' => 'model', 'parts' => ['Model']], - ['role' => 'assistant', 'parts' => ['Assistant']], + new UserMessage([new MessagePart('First')]), + new UserMessage([new MessagePart('Second')]), ]; - $result = PromptNormalizer::normalize($messages); + $this->assertTrue(PromptNormalizer::isMessagesList($messages)); + } - $this->assertCount(4, $result); - $this->assertTrue($result[0]->getRole()->equals(MessageRoleEnum::system())); - $this->assertTrue($result[1]->getRole()->equals(MessageRoleEnum::user())); - $this->assertTrue($result[2]->getRole()->equals(MessageRoleEnum::model())); - $this->assertTrue($result[3]->getRole()->equals(MessageRoleEnum::model())); // assistant maps to model + /** + * Tests that isMessagesList returns false for non-list arrays. + */ + public function testIsMessagesListReturnsFalseForNonList(): void + { + $messages = [ + 1 => new UserMessage([new MessagePart('First')]), + 2 => new UserMessage([new MessagePart('Second')]), + ]; + + $this->assertFalse(PromptNormalizer::isMessagesList($messages)); + } + + /** + * Tests that isMessagesList returns false for empty arrays. + */ + public function testIsMessagesListReturnsFalseForEmpty(): void + { + $this->assertFalse(PromptNormalizer::isMessagesList([])); + } + + /** + * Tests that isMessagesList returns false for arrays with non-Message objects. + */ + public function testIsMessagesListReturnsFalseForMixedTypes(): void + { + $mixed = [ + new UserMessage([new MessagePart('First')]), + 'string', + ]; + + $this->assertFalse(PromptNormalizer::isMessagesList($mixed)); + } + + /** + * Tests that isMessagesList returns false for non-array types. + */ + public function testIsMessagesListReturnsFalseForNonArray(): void + { + $this->assertFalse(PromptNormalizer::isMessagesList('string')); + $this->assertFalse(PromptNormalizer::isMessagesList(123)); + $this->assertFalse(PromptNormalizer::isMessagesList(new UserMessage([new MessagePart('Test')]))); } } From 4ed97f35c6134272593d88ead609ca8d13e7dde4 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 26 Aug 2025 14:08:14 -0700 Subject: [PATCH 50/69] Use PromptBuilder and fully set up default registry. --- src/AiClient.php | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index d95f60fa..7d3c6533 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -5,9 +5,13 @@ namespace WordPress\AiClient; use Generator; +use WordPress\AiClient\Builders\PromptBuilder; use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Operations\DTO\GenerativeAiOperation; +use WordPress\AiClient\ProviderImplementations\Anthropic\AnthropicProvider; +use WordPress\AiClient\ProviderImplementations\Google\GoogleProvider; +use WordPress\AiClient\ProviderImplementations\OpenAi\OpenAiProvider; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; @@ -51,7 +55,13 @@ class AiClient public static function defaultRegistry(): ProviderRegistry { if (self::$defaultRegistry === null) { - self::$defaultRegistry = new ProviderRegistry(); + $registry = new ProviderRegistry(); + $registry->setHttpTransporter( HttpTransporterFactory::createTransporter() ); + $registry->registerProvider( AnthropicProvider::class ); + $registry->registerProvider( GoogleProvider::class ); + $registry->registerProvider( OpenAiProvider::class ); + + self::$defaultRegistry = $registry; } return self::$defaultRegistry; @@ -91,17 +101,15 @@ public static function isConfigured(ProviderAvailabilityInterface $availability) * * @since n.e.x.t * - * @param string|Message|null $text Optional initial prompt text or message. + * @param string|MessagePart|Message|MessageArrayShape|list|list|null $prompt + * Optional initial prompt content. * @return object PromptBuilder instance (type will be updated when PromptBuilder is available). * * @throws \RuntimeException Until PromptBuilder integration is complete. */ - public static function prompt($text = null) + public static function prompt($prompt = null) { - throw new \RuntimeException( - 'PromptBuilder integration pending. This method will return an actual PromptBuilder ' . - 'instance once PR #49 is merged, enabling the fluent API pattern.' - ); + return new PromptBuilder(self::$defaultRegistry, $prompt); } /** From 2e1932412dcb0893330299b5945d25b737ff4166 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 26 Aug 2025 17:01:52 -0700 Subject: [PATCH 51/69] Consistently make ModelInterface optional. --- src/AiClient.php | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index 7d3c6533..44843b3f 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -121,12 +121,12 @@ public static function prompt($prompt = null) * @since n.e.x.t * * @param Prompt $prompt The prompt content. - * @param ModelInterface $model The model to use for generation. + * @param ModelInterface|null $model Optional specific model to use. * @return GenerativeAiResult The generation result. * * @throws \InvalidArgumentException If the model doesn't support any known generation type. */ - public static function generateResult($prompt, ModelInterface $model): GenerativeAiResult + public static function generateResult($prompt, ?ModelInterface $model = null): GenerativeAiResult { // Simple type checking instead of over-engineered resolver if ($model instanceof TextGenerationModelInterface) { @@ -342,12 +342,12 @@ public static function generateSpeechResult($prompt, ?ModelInterface $model = nu * @since n.e.x.t * * @param Prompt $prompt The prompt content. - * @param ModelInterface $model The model to use for generation. + * @param ModelInterface|null $model Optional specific model to use. * @return GenerativeAiOperation The operation for async processing. * * @throws \RuntimeException Operations are not implemented yet. */ - public static function generateOperation($prompt, ModelInterface $model): GenerativeAiOperation + public static function generateOperation($prompt, ?ModelInterface $model = null): GenerativeAiOperation { throw new \RuntimeException( 'Operations are not implemented yet. This functionality is planned for a future release.' @@ -360,12 +360,12 @@ public static function generateOperation($prompt, ModelInterface $model): Genera * @since n.e.x.t * * @param Prompt $prompt The prompt content. - * @param ModelInterface $model The model to use for text generation. + * @param ModelInterface|null $model Optional specific model to use. * @return GenerativeAiOperation The operation for async text processing. * * @throws \RuntimeException Operations are not implemented yet. */ - public static function generateTextOperation($prompt, ModelInterface $model): GenerativeAiOperation + public static function generateTextOperation($prompt, ?ModelInterface $model = null): GenerativeAiOperation { throw new \RuntimeException( 'Text generation operations are not implemented yet. This functionality is planned for a future release.' @@ -378,12 +378,12 @@ public static function generateTextOperation($prompt, ModelInterface $model): Ge * @since n.e.x.t * * @param Prompt $prompt The prompt content. - * @param ModelInterface $model The model to use for image generation. + * @param ModelInterface|null $model Optional specific model to use. * @return GenerativeAiOperation The operation for async image processing. * * @throws \RuntimeException Operations are not implemented yet. */ - public static function generateImageOperation($prompt, ModelInterface $model): GenerativeAiOperation + public static function generateImageOperation($prompt, ?ModelInterface $model = null): GenerativeAiOperation { throw new \RuntimeException( 'Image generation operations are not implemented yet. This functionality is planned for a future release.' @@ -396,12 +396,12 @@ public static function generateImageOperation($prompt, ModelInterface $model): G * @since n.e.x.t * * @param Prompt $prompt The prompt content. - * @param ModelInterface $model The model to use for text-to-speech conversion. + * @param ModelInterface|null $model Optional specific model to use. * @return GenerativeAiOperation The operation for async text-to-speech processing. * * @throws \RuntimeException Operations are not implemented yet. */ - public static function convertTextToSpeechOperation($prompt, ModelInterface $model): GenerativeAiOperation + public static function convertTextToSpeechOperation($prompt, ?ModelInterface $model = null): GenerativeAiOperation { throw new \RuntimeException( 'Text-to-speech conversion operations are not implemented yet. ' . @@ -415,12 +415,12 @@ public static function convertTextToSpeechOperation($prompt, ModelInterface $mod * @since n.e.x.t * * @param Prompt $prompt The prompt content. - * @param ModelInterface $model The model to use for speech generation. + * @param ModelInterface|null $model Optional specific model to use. * @return GenerativeAiOperation The operation for async speech processing. * * @throws \RuntimeException Operations are not implemented yet. */ - public static function generateSpeechOperation($prompt, ModelInterface $model): GenerativeAiOperation + public static function generateSpeechOperation($prompt, ?ModelInterface $model = null): GenerativeAiOperation { throw new \RuntimeException( 'Speech generation operations are not implemented yet. This functionality is planned for a future release.' From 9bdc681fd07e1ec53f00cf60dce64162b1254ae7 Mon Sep 17 00:00:00 2001 From: Felix Arntz Date: Tue, 26 Aug 2025 23:55:53 -0700 Subject: [PATCH 52/69] Fix some bugs. --- src/AiClient.php | 20 ++++++------ src/Builders/PromptBuilder.php | 3 +- tests/unit/AiClientTest.php | 37 ++++++++++++++--------- tests/unit/Utils/PromptNormalizerTest.php | 12 ++------ 4 files changed, 34 insertions(+), 38 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index 44843b3f..7712a6fe 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -9,9 +9,6 @@ use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Operations\DTO\GenerativeAiOperation; -use WordPress\AiClient\ProviderImplementations\Anthropic\AnthropicProvider; -use WordPress\AiClient\ProviderImplementations\Google\GoogleProvider; -use WordPress\AiClient\ProviderImplementations\OpenAi\OpenAiProvider; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; @@ -34,9 +31,9 @@ * @since n.e.x.t * * @phpstan-import-type MessageArrayShape from Message + * @phpstan-import-type Prompt from PromptBuilder * * phpcs:ignore Generic.Files.LineLength.TooLong - * @phpstan-type Prompt string|MessagePart|Message|MessageArrayShape|list|list */ class AiClient { @@ -56,10 +53,12 @@ public static function defaultRegistry(): ProviderRegistry { if (self::$defaultRegistry === null) { $registry = new ProviderRegistry(); - $registry->setHttpTransporter( HttpTransporterFactory::createTransporter() ); - $registry->registerProvider( AnthropicProvider::class ); - $registry->registerProvider( GoogleProvider::class ); - $registry->registerProvider( OpenAiProvider::class ); + + // TODO: Uncomment this once provider implementation PR is merged. + //$registry->setHttpTransporter(HttpTransporterFactory::createTransporter()); + //$registry->registerProvider(AnthropicProvider::class); + //$registry->registerProvider(GoogleProvider::class); + //$registry->registerProvider(OpenAiProvider::class); self::$defaultRegistry = $registry; } @@ -101,15 +100,14 @@ public static function isConfigured(ProviderAvailabilityInterface $availability) * * @since n.e.x.t * - * @param string|MessagePart|Message|MessageArrayShape|list|list|null $prompt - * Optional initial prompt content. + * @param Prompt $prompt Optional initial prompt content. * @return object PromptBuilder instance (type will be updated when PromptBuilder is available). * * @throws \RuntimeException Until PromptBuilder integration is complete. */ public static function prompt($prompt = null) { - return new PromptBuilder(self::$defaultRegistry, $prompt); + return new PromptBuilder(self::defaultRegistry(), $prompt); } /** diff --git a/src/Builders/PromptBuilder.php b/src/Builders/PromptBuilder.php index a6fb8cad..f2b13981 100644 --- a/src/Builders/PromptBuilder.php +++ b/src/Builders/PromptBuilder.php @@ -74,8 +74,7 @@ class PromptBuilder * @since n.e.x.t * * @param ProviderRegistry $registry The provider registry for finding suitable models. - * @param Prompt $prompt - * Optional initial prompt content. + * @param Prompt $prompt Optional initial prompt content. */ // phpcs:enable Generic.Files.LineLength.TooLong public function __construct(ProviderRegistry $registry, $prompt = null) diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index cd2d5d8d..1761e8aa 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -12,7 +12,10 @@ use WordPress\AiClient\Messages\DTO\ModelMessage; use WordPress\AiClient\Messages\DTO\UserMessage; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; +use WordPress\AiClient\Providers\DTO\ProviderMetadata; +use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; +use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Results\DTO\Candidate; use WordPress\AiClient\Results\DTO\GenerativeAiResult; @@ -54,7 +57,25 @@ private function createTestResult(): GenerativeAiResult ); $tokenUsage = new TokenUsage(10, 20, 30); - return new GenerativeAiResult('test-result-id', [$candidate], $tokenUsage); + $providerMetadata = new ProviderMetadata( + 'mock-provider', + 'Mock Provider', + ProviderTypeEnum::cloud() + ); + $modelMetadata = new ModelMetadata( + 'mock-model', + 'Mock Model', + [], + [] + ); + + return new GenerativeAiResult( + 'test-result-id', + [$candidate], + $tokenUsage, + $providerMetadata, + $modelMetadata + ); } protected function tearDown(): void @@ -77,20 +98,6 @@ public function testDefaultRegistry(): void $this->assertSame($newRegistry, AiClient::defaultRegistry()); } - /** - * Tests prompt method throws exception when PromptBuilder is not available. - */ - public function testPromptThrowsException(): void - { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage( - 'PromptBuilder integration pending. This method will return an actual PromptBuilder ' . - 'instance once PR #49 is merged, enabling the fluent API pattern.' - ); - - AiClient::prompt('Test prompt'); - } - /** * Tests message method throws exception when MessageBuilder is not available. */ diff --git a/tests/unit/Utils/PromptNormalizerTest.php b/tests/unit/Utils/PromptNormalizerTest.php index f62c00b7..bd7bf12a 100644 --- a/tests/unit/Utils/PromptNormalizerTest.php +++ b/tests/unit/Utils/PromptNormalizerTest.php @@ -150,7 +150,7 @@ public function testNormalizeArrayWithInvalidObjectsThrowsException(): void public function testNormalizeMessageArrayShape(): void { $messageArray = [ - 'role' => 'system', + 'role' => 'user', 'parts' => [ ['text' => 'You are a helpful assistant.'], ['text' => 'Be concise.'] @@ -160,7 +160,7 @@ public function testNormalizeMessageArrayShape(): void $result = PromptNormalizer::normalize($messageArray); $this->assertInstanceOf(Message::class, $result); - $this->assertTrue($result->getRole()->equals(MessageRoleEnum::system())); + $this->assertTrue($result->getRole()->equals(MessageRoleEnum::user())); $this->assertCount(2, $result->getParts()); $this->assertEquals('You are a helpful assistant.', $result->getParts()[0]->getText()); $this->assertEquals('Be concise.', $result->getParts()[1]->getText()); @@ -223,14 +223,6 @@ public function testMessageArrayMissingPartsTreatedAsStringArray(): void */ public function testRoleMapping(): void { - // Test system role - $systemMsg = [ - 'role' => 'system', - 'parts' => [['text' => 'System']] - ]; - $result = PromptNormalizer::normalize($systemMsg); - $this->assertTrue($result->getRole()->equals(MessageRoleEnum::system())); - // Test user role $userMsg = [ 'role' => 'user', From 109d0f3fc0efdf5ca7121260648faa72c814d69c Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 27 Aug 2025 16:30:42 +0300 Subject: [PATCH 53/69] refactor: traditional API methods to delegate to PromptBuilder --- src/AiClient.php | 109 ++++++++---------------------------- tests/unit/AiClientTest.php | 21 ++++--- 2 files changed, 35 insertions(+), 95 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index 7712a6fe..bc795208 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -6,8 +6,6 @@ use Generator; use WordPress\AiClient\Builders\PromptBuilder; -use WordPress\AiClient\Messages\DTO\Message; -use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Operations\DTO\GenerativeAiOperation; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; @@ -17,8 +15,6 @@ use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Results\DTO\GenerativeAiResult; -use WordPress\AiClient\Utils\Models; -use WordPress\AiClient\Utils\PromptNormalizer; /** * Main AI Client class providing both fluent and traditional APIs for AI operations. @@ -30,7 +26,6 @@ * * @since n.e.x.t * - * @phpstan-import-type MessageArrayShape from Message * @phpstan-import-type Prompt from PromptBuilder * * phpcs:ignore Generic.Files.LineLength.TooLong @@ -101,11 +96,9 @@ public static function isConfigured(ProviderAvailabilityInterface $availability) * @since n.e.x.t * * @param Prompt $prompt Optional initial prompt content. - * @return object PromptBuilder instance (type will be updated when PromptBuilder is available). - * - * @throws \RuntimeException Until PromptBuilder integration is complete. + * @return PromptBuilder The prompt builder instance. */ - public static function prompt($prompt = null) + public static function prompt($prompt = null): PromptBuilder { return new PromptBuilder(self::defaultRegistry(), $prompt); } @@ -114,7 +107,7 @@ public static function prompt($prompt = null) * Generates content using a unified API that automatically detects model capabilities. * * This method uses simple type checking to route to the appropriate generation method. - * In the future, this will be refactored to delegate to PromptBuilder when PR #49 is merged. + * Traditional API methods now properly delegate to PromptBuilder for consistent behavior. * * @since n.e.x.t * @@ -171,60 +164,6 @@ public static function message(?string $text = null) ); } - /** - * Template method for executing generation operations. - * - * NOTE: This method currently uses PromptNormalizer directly, but will be refactored - * to delegate to PromptBuilder once PR #49 is merged, following the architectural - * pattern where traditional API methods wrap the fluent builder API. - * - * @since n.e.x.t - * - * @param Prompt $prompt The prompt content. - * @param ModelInterface|null $model Optional specific model to use. - * @param string $type The generation type. - * @return GenerativeAiResult The generation result. - * - * @throws \InvalidArgumentException If the prompt format is invalid. - * @throws \RuntimeException If no suitable model is found. - */ - private static function executeGeneration($prompt, ?ModelInterface $model, string $type): GenerativeAiResult - { - // TODO: Replace with PromptBuilder delegation once PR #49 is merged - // This should become: return self::prompt($prompt)->usingModel($model)->generate(); - - // Check if it's already a list of Messages - if (PromptNormalizer::isMessagesList($prompt)) { - $messages = $prompt; - } else { - // Otherwise normalize to a single Message and wrap in array - // PHPStan needs help narrowing the type after isMessagesList() check - /** @var string|MessagePart|Message|MessageArrayShape|list $prompt */ - $message = PromptNormalizer::normalize($prompt); - $messages = [$message]; - } - - // Map type to specific methods - switch ($type) { - case 'text': - $resolvedModel = $model ?? Models::findTextModel(self::defaultRegistry()); - Models::validateTextGeneration($resolvedModel); - return $resolvedModel->generateTextResult($messages); - - case 'image': - $resolvedModel = $model ?? Models::findImageModel(self::defaultRegistry()); - Models::validateImageGeneration($resolvedModel); - return $resolvedModel->generateImageResult($messages); - - case 'speech': - $resolvedModel = $model ?? Models::findSpeechModel(self::defaultRegistry()); - Models::validateSpeechGeneration($resolvedModel); - return $resolvedModel->generateSpeechResult($messages); - - default: - throw new \InvalidArgumentException("Unsupported generation type: {$type}"); - } - } /** * Generates text using the traditional API approach. @@ -240,7 +179,11 @@ private static function executeGeneration($prompt, ?ModelInterface $model, strin */ public static function generateTextResult($prompt, ?ModelInterface $model = null): GenerativeAiResult { - return self::executeGeneration($prompt, $model, 'text'); + $builder = self::prompt($prompt); + if ($model !== null) { + $builder->usingModel($model); + } + return $builder->generateTextResult(); } /** @@ -275,7 +218,11 @@ public static function streamGenerateTextResult($prompt, ?ModelInterface $model */ public static function generateImageResult($prompt, ?ModelInterface $model = null): GenerativeAiResult { - return self::executeGeneration($prompt, $model, 'image'); + $builder = self::prompt($prompt); + if ($model !== null) { + $builder->usingModel($model); + } + return $builder->generateImageResult(); } /** @@ -292,27 +239,11 @@ public static function generateImageResult($prompt, ?ModelInterface $model = nul */ public static function convertTextToSpeechResult($prompt, ?ModelInterface $model = null): GenerativeAiResult { - // TODO: Replace with PromptBuilder delegation once PR #49 is merged - - // Check if it's already a list of Messages - if (PromptNormalizer::isMessagesList($prompt)) { - $messages = $prompt; - } else { - // Otherwise normalize to a single Message and wrap in array - // PHPStan needs help narrowing the type after isMessagesList() check - /** @var string|MessagePart|Message|MessageArrayShape|list $prompt */ - $message = PromptNormalizer::normalize($prompt); - $messages = [$message]; + $builder = self::prompt($prompt); + if ($model !== null) { + $builder->usingModel($model); } - - // Get model - either provided or auto-discovered - $resolvedModel = $model ?? Models::findTextToSpeechModel(self::defaultRegistry()); - - // Validate model supports text-to-speech conversion - Models::validateTextToSpeechConversion($resolvedModel); - - // Generate the result using the model - return $resolvedModel->convertTextToSpeechResult($messages); + return $builder->convertTextToSpeechResult(); } /** @@ -329,7 +260,11 @@ public static function convertTextToSpeechResult($prompt, ?ModelInterface $model */ public static function generateSpeechResult($prompt, ?ModelInterface $model = null): GenerativeAiResult { - return self::executeGeneration($prompt, $model, 'speech'); + $builder = self::prompt($prompt); + if ($model !== null) { + $builder->usingModel($model); + } + return $builder->generateSpeechResult(); } diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index 1761e8aa..9c401156 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -8,6 +8,7 @@ use PHPUnit\Framework\TestCase; use RuntimeException; use WordPress\AiClient\AiClient; +use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\ModelMessage; use WordPress\AiClient\Messages\DTO\UserMessage; @@ -126,7 +127,8 @@ public function testGenerateTextResultWithStringAndModel(): void ->with($this->callback(function ($messages) { return is_array($messages) && count($messages) === 1 && - $messages[0] instanceof UserMessage; + $messages[0] instanceof Message && + $messages[0]->getRole()->isUser(); })) ->willReturn($mockResult); @@ -143,8 +145,8 @@ public function testGenerateTextResultWithInvalidModel(): void $prompt = 'Generate text'; $invalidModel = $this->createMock(ModelInterface::class); - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Model must implement TextGenerationModelInterface for text generation'); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Model "" does not support text generation.'); AiClient::generateTextResult($prompt, $invalidModel); } @@ -163,7 +165,8 @@ public function testGenerateImageResultWithStringAndModel(): void ->with($this->callback(function ($messages) { return is_array($messages) && count($messages) === 1 && - $messages[0] instanceof UserMessage; + $messages[0] instanceof Message && + $messages[0]->getRole()->isUser(); })) ->willReturn($mockResult); @@ -180,8 +183,8 @@ public function testGenerateImageResultWithInvalidModel(): void $prompt = 'Generate image'; $invalidModel = $this->createMock(ModelInterface::class); - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Model must implement ImageGenerationModelInterface for image generation'); + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Model "" does not support image generation.'); AiClient::generateImageResult($prompt, $invalidModel); } @@ -239,7 +242,8 @@ public function testGenerateTextResultWithMessagePart(): void ->with($this->callback(function ($messages) { return is_array($messages) && count($messages) === 1 && - $messages[0] instanceof UserMessage; + $messages[0] instanceof Message && + $messages[0]->getRole()->isUser(); })) ->willReturn($mockResult); @@ -294,7 +298,8 @@ public function testGenerateTextResultWithMessagePartArray(): void ->with($this->callback(function ($messages) { return is_array($messages) && count($messages) === 1 && - $messages[0] instanceof UserMessage && + $messages[0] instanceof Message && + $messages[0]->getRole()->isUser() && count($messages[0]->getParts()) === 2; })) ->willReturn($mockResult); From 077cc8d5a8d02cd4a04ad97d9cf613691fe8db7e Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 27 Aug 2025 16:42:19 +0300 Subject: [PATCH 54/69] fix: style improvements --- src/AiClient.php | 5 +---- tests/unit/AiClientTest.php | 2 -- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index bc795208..2294538e 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -49,7 +49,7 @@ public static function defaultRegistry(): ProviderRegistry if (self::$defaultRegistry === null) { $registry = new ProviderRegistry(); - // TODO: Uncomment this once provider implementation PR is merged. + // TODO: Uncomment this once provider implementation PR #39 is merged. //$registry->setHttpTransporter(HttpTransporterFactory::createTransporter()); //$registry->registerProvider(AnthropicProvider::class); //$registry->registerProvider(GoogleProvider::class); @@ -164,7 +164,6 @@ public static function message(?string $text = null) ); } - /** * Generates text using the traditional API approach. * @@ -267,8 +266,6 @@ public static function generateSpeechResult($prompt, ?ModelInterface $model = nu return $builder->generateSpeechResult(); } - - /** * Creates a generation operation for async processing. * diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index 9c401156..5ae6d677 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -309,7 +309,6 @@ public function testGenerateTextResultWithMessagePartArray(): void $this->assertSame($mockResult, $result); } - /** * Tests isConfigured method returns true when provider availability is configured. */ @@ -452,7 +451,6 @@ public function testGenerateTextOperationThrowsNotImplementedException(): void AiClient::generateTextOperation($prompt, $this->mockTextModel); } - /** * Tests generateImageOperation throws not implemented exception. */ From 587489cf3c5c730f4fded9b8718ec45a8e56ac3b Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 27 Aug 2025 18:29:07 +0300 Subject: [PATCH 55/69] Add utility classes documentation to AiClient Adds documentation references to RequirementsUtil and CapabilityUtil (from PR #64) with usage examples showing integration patterns. --- src/AiClient.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/AiClient.php b/src/AiClient.php index 2294538e..30593a90 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -24,6 +24,23 @@ * - Traditional API for array-based configuration (WordPress style) * - Integration with provider registry for model discovery * + * For advanced capability-based operations and model requirements analysis, + * see the utility classes (implemented in PR #64): + * - RequirementsUtil: Analyzes messages and configurations to infer model requirements + * - CapabilityUtil: Provides capability mappings, modality handling, and compatibility checks + * + * Example usage with utilities: + * ```php + * // Find capability for a generation type + * $capability = CapabilityUtil::getCapabilityForGenerationType('image'); + * + * // Build requirements from messages + * $requirements = RequirementsUtil::fromMessages($messages, $capability); + * + * // Find suitable providers + * $providers = AiClient::defaultRegistry()->findModelsMetadataForSupport($requirements); + * ``` + * * @since n.e.x.t * * @phpstan-import-type Prompt from PromptBuilder From c7ee758029f0b88d2348ecf16b3d5c0d27de07ef Mon Sep 17 00:00:00 2001 From: ref34t Date: Wed, 27 Aug 2025 22:59:19 +0300 Subject: [PATCH 56/69] fix: resolve AiClient critical issues and improve MVP compliance - Fix broken generateResult() method for null model handling - Add model auto-discovery via PromptBuilder delegation - Add generateResultWithCapability() for multi-interface models - Remove redundant utility classes (Models, PromptNormalizer) - Improve error messages with model metadata - Add comprehensive test coverage - Fix coding style issues --- src/AiClient.php | 135 ++++++++-- src/Utils/Models.php | 291 --------------------- src/Utils/PromptNormalizer.php | 122 --------- tests/unit/AiClientTest.php | 163 ++++++++++++ tests/unit/Utils/ModelsTest.php | 233 ----------------- tests/unit/Utils/PromptNormalizerTest.php | 299 ---------------------- 6 files changed, 280 insertions(+), 963 deletions(-) delete mode 100644 src/Utils/Models.php delete mode 100644 src/Utils/PromptNormalizer.php delete mode 100644 tests/unit/Utils/ModelsTest.php delete mode 100644 tests/unit/Utils/PromptNormalizerTest.php diff --git a/src/AiClient.php b/src/AiClient.php index 30593a90..9b9cf627 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -15,6 +15,7 @@ use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Results\DTO\GenerativeAiResult; +use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; /** * Main AI Client class providing both fluent and traditional APIs for AI operations. @@ -24,21 +25,19 @@ * - Traditional API for array-based configuration (WordPress style) * - Integration with provider registry for model discovery * - * For advanced capability-based operations and model requirements analysis, - * see the utility classes (implemented in PR #64): - * - RequirementsUtil: Analyzes messages and configurations to infer model requirements - * - CapabilityUtil: Provides capability mappings, modality handling, and compatibility checks + * All model requirements analysis and capability matching is handled + * automatically by the PromptBuilder, which provides intelligent model + * discovery based on prompt content and configuration. * - * Example usage with utilities: + * Example usage: * ```php - * // Find capability for a generation type - * $capability = CapabilityUtil::getCapabilityForGenerationType('image'); + * // Fluent API with automatic model discovery + * $result = AiClient::prompt('Generate an image of a sunset') + * ->usingTemperature(0.7) + * ->generateImageResult(); * - * // Build requirements from messages - * $requirements = RequirementsUtil::fromMessages($messages, $capability); - * - * // Find suitable providers - * $providers = AiClient::defaultRegistry()->findModelsMetadataForSupport($requirements); + * // Traditional API + * $result = AiClient::generateTextResult('What is PHP?'); * ``` * * @since n.e.x.t @@ -123,8 +122,9 @@ public static function prompt($prompt = null): PromptBuilder /** * Generates content using a unified API that automatically detects model capabilities. * - * This method uses simple type checking to route to the appropriate generation method. - * Traditional API methods now properly delegate to PromptBuilder for consistent behavior. + * When no model is provided, this method delegates to PromptBuilder for intelligent + * model discovery based on prompt content and configuration. When a model is provided, + * it routes to the appropriate generation method based on the model's interfaces. * * @since n.e.x.t * @@ -132,11 +132,18 @@ public static function prompt($prompt = null): PromptBuilder * @param ModelInterface|null $model Optional specific model to use. * @return GenerativeAiResult The generation result. * - * @throws \InvalidArgumentException If the model doesn't support any known generation type. + * @throws \InvalidArgumentException If the provided model doesn't support any known generation type. + * @throws \RuntimeException If no suitable model can be found for the prompt. */ public static function generateResult($prompt, ?ModelInterface $model = null): GenerativeAiResult { - // Simple type checking instead of over-engineered resolver + // If no model provided, use PromptBuilder's intelligent model discovery + if ($model === null) { + return self::prompt($prompt)->generateResult(); + } + + // Route based on model interface capabilities + // Note: Order matters for models that implement multiple interfaces if ($model instanceof TextGenerationModelInterface) { return self::generateTextResult($prompt, $model); } @@ -154,8 +161,72 @@ public static function generateResult($prompt, ?ModelInterface $model = null): G } throw new \InvalidArgumentException( - 'Model must implement at least one supported generation interface ' . - '(TextGeneration, ImageGeneration, TextToSpeechConversion, SpeechGeneration)' + sprintf( + 'Model "%s" must implement at least one supported generation interface ' . + '(TextGeneration, ImageGeneration, TextToSpeechConversion, SpeechGeneration)', + $model->metadata()->getId() + ) + ); + } + + /** + * Generates content using a unified API with explicit capability selection. + * + * This method allows explicit capability selection for models that implement + * multiple generation interfaces. If the model doesn't support the specified + * capability, an exception is thrown. + * + * @since n.e.x.t + * + * @param Prompt $prompt The prompt content. + * @param CapabilityEnum $capability The desired generation capability. + * @param ModelInterface|null $model Optional specific model to use. + * @return GenerativeAiResult The generation result. + * + * @throws \InvalidArgumentException If the model doesn't support the specified capability. + * @throws \RuntimeException If no suitable model can be found for the prompt and capability. + */ + public static function generateResultWithCapability( + $prompt, + CapabilityEnum $capability, + ?ModelInterface $model = null + ): GenerativeAiResult + { + // If no model provided, use PromptBuilder with explicit capability + if ($model === null) { + return self::prompt($prompt)->generateResult($capability); + } + + // Validate that the model supports the requested capability + if (!$model->metadata()->supportsCapability($capability)) { + throw new \InvalidArgumentException( + sprintf( + 'Model "%s" does not support the "%s" capability', + $model->metadata()->getId(), + $capability->value + ) + ); + } + + // Route to the appropriate method based on capability + if ($capability->isTextGeneration()) { + return self::generateTextResult($prompt, $model); + } + + if ($capability->isImageGeneration()) { + return self::generateImageResult($prompt, $model); + } + + if ($capability->isTextToSpeechConversion()) { + return self::convertTextToSpeechResult($prompt, $model); + } + + if ($capability->isSpeechGeneration()) { + return self::generateSpeechResult($prompt, $model); + } + + throw new \InvalidArgumentException( + sprintf('Capability "%s" is not yet supported for generation', $capability->value) ); } @@ -373,4 +444,32 @@ public static function generateSpeechOperation($prompt, ?ModelInterface $model = 'Speech generation operations are not implemented yet. This functionality is planned for a future release.' ); } + + /** + * Convenience method for text generation. + * + * @since n.e.x.t + * + * @param Prompt $prompt The prompt content. + * @param ModelInterface|null $model Optional specific model to use. + * @return string The generated text. + */ + public static function generateText($prompt, ?ModelInterface $model = null): string + { + return self::generateTextResult($prompt, $model)->toText(); + } + + /** + * Convenience method for image generation. + * + * @since n.e.x.t + * + * @param Prompt $prompt The prompt content. + * @param ModelInterface|null $model Optional specific model to use. + * @return \WordPress\AiClient\Files\DTO\File The generated image file. + */ + public static function generateImage($prompt, ?ModelInterface $model = null) + { + return self::generateImageResult($prompt, $model)->toFile(); + } } diff --git a/src/Utils/Models.php b/src/Utils/Models.php deleted file mode 100644 index 7530eac8..00000000 --- a/src/Utils/Models.php +++ /dev/null @@ -1,291 +0,0 @@ -findModelsMetadataForSupport($requirements); - - if (empty($providerModelsMetadata)) { - throw new \RuntimeException("No {$errorType} models available"); - } - - // Get the first suitable provider and model - $providerMetadata = $providerModelsMetadata[0]; - $models = $providerMetadata->getModels(); - - if (empty($models)) { - throw new \RuntimeException('No models available in provider'); - } - - return $registry->getProviderModel( - $providerMetadata->getProvider()->getId(), - $models[0]->getId() - ); - } - - /** - * Finds a suitable text generation model from the registry. - * - * @since n.e.x.t - * - * @param ProviderRegistry $registry The provider registry to search. - * @return ModelInterface A suitable text generation model. - * - * @throws \RuntimeException If no suitable model is found. - */ - public static function findTextModel(ProviderRegistry $registry): ModelInterface - { - return self::findModelByCapability($registry, CapabilityEnum::textGeneration(), 'text generation'); - } - - /** - * Finds a suitable image generation model from the registry. - * - * @since n.e.x.t - * - * @param ProviderRegistry $registry The provider registry to search. - * @return ModelInterface A suitable image generation model. - * - * @throws \RuntimeException If no suitable model is found. - */ - public static function findImageModel(ProviderRegistry $registry): ModelInterface - { - return self::findModelByCapability($registry, CapabilityEnum::imageGeneration(), 'image generation'); - } - - /** - * Finds a suitable text-to-speech conversion model from the registry. - * - * @since n.e.x.t - * - * @param ProviderRegistry $registry The provider registry to search. - * @return ModelInterface A suitable text-to-speech conversion model. - * - * @throws \RuntimeException If no suitable model is found. - */ - public static function findTextToSpeechModel(ProviderRegistry $registry): ModelInterface - { - return self::findModelByCapability( - $registry, - CapabilityEnum::textToSpeechConversion(), - 'text-to-speech conversion' - ); - } - - /** - * Finds a suitable speech generation model from the registry. - * - * @since n.e.x.t - * - * @param ProviderRegistry $registry The provider registry to search. - * @return ModelInterface A suitable speech generation model. - * - * @throws \RuntimeException If no suitable model is found. - */ - public static function findSpeechModel(ProviderRegistry $registry): ModelInterface - { - return self::findModelByCapability($registry, CapabilityEnum::speechGeneration(), 'speech generation'); - } - - /** - * Validates that a model implements TextGenerationModelInterface. - * - * @since n.e.x.t - * - * @param ModelInterface $model The model to validate. - * @return void - * @phpstan-assert TextGenerationModelInterface $model - * - * @throws \InvalidArgumentException If the model doesn't implement the required interface. - */ - public static function validateTextGeneration(ModelInterface $model): void - { - if (!$model instanceof TextGenerationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement TextGenerationModelInterface for text generation' - ); - } - } - - /** - * Validates that a model implements ImageGenerationModelInterface. - * - * @since n.e.x.t - * - * @param ModelInterface $model The model to validate. - * @return void - * @phpstan-assert ImageGenerationModelInterface $model - * - * @throws \InvalidArgumentException If the model doesn't implement the required interface. - */ - public static function validateImageGeneration(ModelInterface $model): void - { - if (!$model instanceof ImageGenerationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement ImageGenerationModelInterface for image generation' - ); - } - } - - /** - * Validates that a model implements TextToSpeechConversionModelInterface. - * - * @since n.e.x.t - * - * @param ModelInterface $model The model to validate. - * @return void - * @phpstan-assert TextToSpeechConversionModelInterface $model - * - * @throws \InvalidArgumentException If the model doesn't implement the required interface. - */ - public static function validateTextToSpeechConversion(ModelInterface $model): void - { - if (!$model instanceof TextToSpeechConversionModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement TextToSpeechConversionModelInterface for text-to-speech conversion' - ); - } - } - - /** - * Validates that a model implements SpeechGenerationModelInterface. - * - * @since n.e.x.t - * - * @param ModelInterface $model The model to validate. - * @return void - * @phpstan-assert SpeechGenerationModelInterface $model - * - * @throws \InvalidArgumentException If the model doesn't implement the required interface. - */ - public static function validateSpeechGeneration(ModelInterface $model): void - { - if (!$model instanceof SpeechGenerationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement SpeechGenerationModelInterface for speech generation' - ); - } - } - - /** - * Validates that a model implements TextGenerationModelInterface for operations. - * - * @since n.e.x.t - * - * @param ModelInterface $model The model to validate. - * @return void - * @phpstan-assert TextGenerationModelInterface $model - * - * @throws \InvalidArgumentException If the model doesn't implement the required interface. - */ - public static function validateTextGenerationOperation(ModelInterface $model): void - { - if (!$model instanceof TextGenerationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement TextGenerationModelInterface for text generation operations' - ); - } - } - - /** - * Validates that a model implements ImageGenerationModelInterface for operations. - * - * @since n.e.x.t - * - * @param ModelInterface $model The model to validate. - * @return void - * @phpstan-assert ImageGenerationModelInterface $model - * - * @throws \InvalidArgumentException If the model doesn't implement the required interface. - */ - public static function validateImageGenerationOperation(ModelInterface $model): void - { - if (!$model instanceof ImageGenerationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement ImageGenerationModelInterface for image generation operations' - ); - } - } - - /** - * Validates that a model implements TextToSpeechConversionOperationModelInterface for operations. - * - * @since n.e.x.t - * - * @param ModelInterface $model The model to validate. - * @return void - * @phpstan-assert TextToSpeechConversionOperationModelInterface $model - * - * @throws \InvalidArgumentException If the model doesn't implement the required interface. - */ - public static function validateTextToSpeechConversionOperation(ModelInterface $model): void - { - if (!$model instanceof TextToSpeechConversionOperationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement TextToSpeechConversionOperationModelInterface ' . - 'for text-to-speech conversion operations' - ); - } - } - - /** - * Validates that a model implements SpeechGenerationOperationModelInterface for operations. - * - * @since n.e.x.t - * - * @param ModelInterface $model The model to validate. - * @return void - * @phpstan-assert SpeechGenerationOperationModelInterface $model - * - * @throws \InvalidArgumentException If the model doesn't implement the required interface. - */ - public static function validateSpeechGenerationOperation(ModelInterface $model): void - { - if (!$model instanceof SpeechGenerationOperationModelInterface) { - throw new \InvalidArgumentException( - 'Model must implement SpeechGenerationOperationModelInterface ' . - 'for speech generation operations' - ); - } - } -} diff --git a/src/Utils/PromptNormalizer.php b/src/Utils/PromptNormalizer.php deleted file mode 100644 index fee5d16e..00000000 --- a/src/Utils/PromptNormalizer.php +++ /dev/null @@ -1,122 +0,0 @@ - $value - */ - public static function isMessagesList($value): bool - { - if (!is_array($value) || empty($value) || !array_is_list($value)) { - return false; - } - - // Check that every element is a Message - foreach ($value as $item) { - if (!($item instanceof Message)) { - return false; - } - } - - return true; - } - - /** - * Normalizes various prompt formats into a standardized Message. - * - * Supports: - * - String: converted to UserMessage with single MessagePart - * - MessagePart: wrapped in UserMessage - * - Message array: {'role': 'system', 'parts': [...]} format - * - Message: returned as-is - * - Array of strings/MessageParts: converted to UserMessage with multiple parts - * - * @since n.e.x.t - * - * @param string|MessagePart|Message|MessageArrayShape|list $prompt The prompt content. - * @return Message The normalized message. - * - * @throws \InvalidArgumentException If the prompt format is invalid. - * - * @phpstan-param string|MessagePart|Message|MessageArrayShape|list $prompt - */ - public static function normalize($prompt): Message - { - // Already a Message - if ($prompt instanceof Message) { - return $prompt; - } - - // Simple string - if (is_string($prompt)) { - return new UserMessage([new MessagePart($prompt)]); - } - - // Single MessagePart - if ($prompt instanceof MessagePart) { - return new UserMessage([$prompt]); - } - - // Structured message array with role and parts - if (is_array($prompt) && isset($prompt[Message::KEY_ROLE]) && isset($prompt[Message::KEY_PARTS])) { - /** @var MessageArrayShape $prompt */ - return Message::fromArray($prompt); - } - - // Array of strings/MessageParts to combine into a single UserMessage - if (is_array($prompt)) { - if (empty($prompt)) { - throw new \InvalidArgumentException('Prompt array cannot be empty'); - } - - $parts = []; - foreach ($prompt as $item) { - if (is_string($item)) { - $parts[] = new MessagePart($item); - } elseif ($item instanceof MessagePart) { - $parts[] = $item; - } else { - throw new \InvalidArgumentException( - sprintf( - 'Array items must be strings or MessagePart objects, got %s', - is_object($item) ? get_class($item) : gettype($item) - ) - ); - } - } - - return new UserMessage($parts); - } - - // Invalid format - throw new \InvalidArgumentException( - sprintf( - 'Invalid prompt format: expected string, Message, MessagePart, structured array, ' . - 'or array of strings/MessageParts, got %s', - is_object($prompt) ? get_class($prompt) : gettype($prompt) - ) - ); - } -} diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index 5ae6d677..291ded98 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -24,6 +24,7 @@ use WordPress\AiClient\Results\Enums\FinishReasonEnum; use WordPress\AiClient\Tests\mocks\MockImageGenerationModel; use WordPress\AiClient\Tests\mocks\MockTextGenerationModel; +use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; /** * @covers \WordPress\AiClient\AiClient @@ -498,4 +499,166 @@ public function testGenerateSpeechOperationThrowsNotImplementedException(): void AiClient::generateSpeechOperation($prompt, $mockModel); } + + /** + * Tests generateResult with null model delegates to PromptBuilder. + */ + public function testGenerateResultWithNullModelDelegatesToPromptBuilder(): void + { + $prompt = 'Test prompt for auto-discovery'; + + // Mock the registry to return a working text model + $this->registry + ->expects($this->once()) + ->method('findModelsMetadataForSupport') + ->willReturn([]); + + // This should not throw an exception due to null model + // Instead it should delegate to PromptBuilder's intelligent discovery + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('No models found that support the required capabilities'); + + AiClient::generateResult($prompt); + } + + /** + * Tests generateResult with text generation model. + */ + public function testGenerateResultWithTextGenerationModel(): void + { + $prompt = 'Generate text content'; + $mockResult = $this->createMock(GenerativeAiResult::class); + + $this->mockTextModel + ->expects($this->once()) + ->method('generateTextResult') + ->willReturn($mockResult); + + $result = AiClient::generateResult($prompt, $this->mockTextModel); + + $this->assertSame($mockResult, $result); + } + + /** + * Tests generateResult with image generation model. + */ + public function testGenerateResultWithImageGenerationModel(): void + { + $prompt = 'Generate an image'; + $mockResult = $this->createMock(GenerativeAiResult::class); + + $this->mockImageModel + ->expects($this->once()) + ->method('generateImageResult') + ->willReturn($mockResult); + + $result = AiClient::generateResult($prompt, $this->mockImageModel); + + $this->assertSame($mockResult, $result); + } + + /** + * Tests generateResult with invalid model throws exception with model ID. + */ + public function testGenerateResultWithInvalidModelThrowsExceptionWithModelId(): void + { + $prompt = 'Test prompt'; + $invalidModel = $this->createMock(ModelInterface::class); + + $mockMetadata = $this->createMock(\WordPress\AiClient\Providers\Models\DTO\ModelMetadata::class); + $mockMetadata->expects($this->once()) + ->method('getId') + ->willReturn('invalid-model-id'); + + $invalidModel->expects($this->once()) + ->method('metadata') + ->willReturn($mockMetadata); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Model "invalid-model-id" must implement at least one supported generation interface' + ); + + AiClient::generateResult($prompt, $invalidModel); + } + + /** + * Tests generateResultWithCapability with null model delegates to PromptBuilder. + */ + public function testGenerateResultWithCapabilityNullModelDelegatesToPromptBuilder(): void + { + $prompt = 'Test prompt'; + $capability = CapabilityEnum::textGeneration(); + + // Mock the registry to return empty results + $this->registry + ->expects($this->once()) + ->method('findModelsMetadataForSupport') + ->willReturn([]); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('No models found that support the required capabilities'); + + AiClient::generateResultWithCapability($prompt, $capability); + } + + /** + * Tests generateResultWithCapability with valid model and capability. + */ + public function testGenerateResultWithCapabilityValidModelAndCapability(): void + { + $prompt = 'Generate text'; + $capability = CapabilityEnum::textGeneration(); + $mockResult = $this->createMock(GenerativeAiResult::class); + + $mockMetadata = $this->createMock(\WordPress\AiClient\Providers\Models\DTO\ModelMetadata::class); + $mockMetadata->expects($this->once()) + ->method('supportsCapability') + ->with($capability) + ->willReturn(true); + + $this->mockTextModel + ->expects($this->once()) + ->method('metadata') + ->willReturn($mockMetadata); + + $this->mockTextModel + ->expects($this->once()) + ->method('generateTextResult') + ->willReturn($mockResult); + + $result = AiClient::generateResultWithCapability($prompt, $capability, $this->mockTextModel); + + $this->assertSame($mockResult, $result); + } + + /** + * Tests generateResultWithCapability with model that doesn't support capability. + */ + public function testGenerateResultWithCapabilityUnsupportedCapability(): void + { + $prompt = 'Generate content'; + $capability = CapabilityEnum::imageGeneration(); + + $mockMetadata = $this->createMock(\WordPress\AiClient\Providers\Models\DTO\ModelMetadata::class); + $mockMetadata->expects($this->once()) + ->method('supportsCapability') + ->with($capability) + ->willReturn(false); + $mockMetadata->expects($this->once()) + ->method('getId') + ->willReturn('text-only-model'); + + $this->mockTextModel + ->expects($this->once()) + ->method('metadata') + ->willReturn($mockMetadata); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Model "text-only-model" does not support the "image-generation" capability' + ); + + AiClient::generateResultWithCapability($prompt, $capability, $this->mockTextModel); + } } diff --git a/tests/unit/Utils/ModelsTest.php b/tests/unit/Utils/ModelsTest.php deleted file mode 100644 index 2ba5e179..00000000 --- a/tests/unit/Utils/ModelsTest.php +++ /dev/null @@ -1,233 +0,0 @@ -registry = new ProviderRegistry(); - - $mockMetadata = $this->createMock(ModelMetadata::class); - $mockConfig = $this->createMock(ModelConfig::class); - - $this->mockTextModel = new MockTextGenerationModel(); - $this->mockImageModel = new MockImageGenerationModel(); - $this->mockModel = new MockModel($mockMetadata, $mockConfig); - } - - /** - * Tests that validateTextGeneration passes with valid model. - * - * @covers \WordPress\AiClient\Utils\Models::validateTextGeneration - */ - public function testValidateTextGenerationPassesWithValidModel(): void - { - $this->expectNotToPerformAssertions(); - Models::validateTextGeneration($this->mockTextModel); - } - - /** - * Tests that validateTextGeneration throws exception with invalid model. - * - * @covers \WordPress\AiClient\Utils\Models::validateTextGeneration - */ - public function testValidateTextGenerationThrowsExceptionWithInvalidModel(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Model must implement TextGenerationModelInterface for text generation'); - - Models::validateTextGeneration($this->mockModel); - } - - /** - * Tests that validateImageGeneration passes with valid model. - * - * @covers \WordPress\AiClient\Utils\Models::validateImageGeneration - */ - public function testValidateImageGenerationPassesWithValidModel(): void - { - $this->expectNotToPerformAssertions(); - Models::validateImageGeneration($this->mockImageModel); - } - - /** - * Tests that validateImageGeneration throws exception with invalid model. - * - * @covers \WordPress\AiClient\Utils\Models::validateImageGeneration - */ - public function testValidateImageGenerationThrowsExceptionWithInvalidModel(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Model must implement ImageGenerationModelInterface for image generation'); - - Models::validateImageGeneration($this->mockModel); - } - - /** - * Tests that validateTextToSpeechConversion throws exception with invalid model. - * - * @covers \WordPress\AiClient\Utils\Models::validateTextToSpeechConversion - */ - public function testValidateTextToSpeechConversionThrowsExceptionWithInvalidModel(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Model must implement TextToSpeechConversionModelInterface for text-to-speech conversion' - ); - - Models::validateTextToSpeechConversion($this->mockModel); - } - - /** - * Tests that validateSpeechGeneration throws exception with invalid model. - * - * @covers \WordPress\AiClient\Utils\Models::validateSpeechGeneration - */ - public function testValidateSpeechGenerationThrowsExceptionWithInvalidModel(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Model must implement SpeechGenerationModelInterface for speech generation' - ); - - Models::validateSpeechGeneration($this->mockModel); - } - - /** - * Tests that validateTextGenerationOperation throws exception with invalid model. - * - * @covers \WordPress\AiClient\Utils\Models::validateTextGenerationOperation - */ - public function testValidateTextGenerationOperationThrowsExceptionWithInvalidModel(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Model must implement TextGenerationModelInterface for text generation operations' - ); - - Models::validateTextGenerationOperation($this->mockModel); - } - - /** - * Tests that validateImageGenerationOperation throws exception with invalid model. - * - * @covers \WordPress\AiClient\Utils\Models::validateImageGenerationOperation - */ - public function testValidateImageGenerationOperationThrowsExceptionWithInvalidModel(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Model must implement ImageGenerationModelInterface for image generation operations' - ); - - Models::validateImageGenerationOperation($this->mockModel); - } - - /** - * Tests that validateTextToSpeechConversionOperation throws exception with invalid model. - * - * @covers \WordPress\AiClient\Utils\Models::validateTextToSpeechConversionOperation - */ - public function testValidateTextToSpeechConversionOperationThrowsExceptionWithInvalidModel(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Model must implement TextToSpeechConversionOperationModelInterface ' . - 'for text-to-speech conversion operations' - ); - - Models::validateTextToSpeechConversionOperation($this->mockModel); - } - - /** - * Tests that validateSpeechGenerationOperation throws exception with invalid model. - * - * @covers \WordPress\AiClient\Utils\Models::validateSpeechGenerationOperation - */ - public function testValidateSpeechGenerationOperationThrowsExceptionWithInvalidModel(): void - { - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Model must implement SpeechGenerationOperationModelInterface for speech generation operations' - ); - - Models::validateSpeechGenerationOperation($this->mockModel); - } - - /** - * Tests that findTextModel throws exception when no models available. - * - * @covers \WordPress\AiClient\Utils\Models::findTextModel - */ - public function testFindTextModelThrowsExceptionWhenNoModelsAvailable(): void - { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('No text generation models available'); - - Models::findTextModel($this->registry); - } - - /** - * Tests that findImageModel throws exception when no models available. - * - * @covers \WordPress\AiClient\Utils\Models::findImageModel - */ - public function testFindImageModelThrowsExceptionWhenNoModelsAvailable(): void - { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('No image generation models available'); - - Models::findImageModel($this->registry); - } - - /** - * Tests that findTextToSpeechModel throws exception when no models available. - * - * @covers \WordPress\AiClient\Utils\Models::findTextToSpeechModel - */ - public function testFindTextToSpeechModelThrowsExceptionWhenNoModelsAvailable(): void - { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('No text-to-speech conversion models available'); - - Models::findTextToSpeechModel($this->registry); - } - - /** - * Tests that findSpeechModel throws exception when no models available. - * - * @covers \WordPress\AiClient\Utils\Models::findSpeechModel - */ - public function testFindSpeechModelThrowsExceptionWhenNoModelsAvailable(): void - { - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('No speech generation models available'); - - Models::findSpeechModel($this->registry); - } -} diff --git a/tests/unit/Utils/PromptNormalizerTest.php b/tests/unit/Utils/PromptNormalizerTest.php deleted file mode 100644 index bd7bf12a..00000000 --- a/tests/unit/Utils/PromptNormalizerTest.php +++ /dev/null @@ -1,299 +0,0 @@ -assertInstanceOf(UserMessage::class, $result); - $this->assertCount(1, $result->getParts()); - $this->assertEquals('Test prompt', $result->getParts()[0]->getText()); - } - - /** - * Tests normalizing MessagePart input. - */ - public function testNormalizeMessagePart(): void - { - $messagePart = new MessagePart('Test message part'); - $result = PromptNormalizer::normalize($messagePart); - - $this->assertInstanceOf(UserMessage::class, $result); - $this->assertCount(1, $result->getParts()); - $this->assertSame($messagePart, $result->getParts()[0]); - } - - /** - * Tests normalizing single Message input. - */ - public function testNormalizeSingleMessage(): void - { - $messagePart = new MessagePart('Test message'); - $message = new UserMessage([$messagePart]); - $result = PromptNormalizer::normalize($message); - - $this->assertSame($message, $result); - } - - /** - * Tests normalizing array of Messages throws exception. - */ - public function testNormalizeMessageArray(): void - { - $message1 = new UserMessage([new MessagePart('First message')]); - $message2 = new UserMessage([new MessagePart('Second message')]); - $messages = [$message1, $message2]; - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Array items must be strings or MessagePart objects, got ' . UserMessage::class - ); - - PromptNormalizer::normalize($messages); - } - - /** - * Tests normalizing array of MessageParts. - */ - public function testNormalizeMessagePartArray(): void - { - $part1 = new MessagePart('First part'); - $part2 = new MessagePart('Second part'); - $parts = [$part1, $part2]; - - $result = PromptNormalizer::normalize($parts); - - $this->assertInstanceOf(UserMessage::class, $result); - $this->assertCount(2, $result->getParts()); - $this->assertSame($part1, $result->getParts()[0]); - $this->assertSame($part2, $result->getParts()[1]); - } - - /** - * Tests empty array throws exception. - */ - public function testNormalizeEmptyArrayThrowsException(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('Prompt array cannot be empty'); - - PromptNormalizer::normalize([]); - } - - /** - * Tests mixed array content throws exception. - */ - public function testNormalizeMixedArrayThrowsException(): void - { - $part = new MessagePart('Test'); - $invalidArray = [$part, 'string', 123]; - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Array items must be strings or MessagePart objects, got integer' - ); - - PromptNormalizer::normalize($invalidArray); - } - - /** - * Tests invalid input type throws exception. - */ - public function testNormalizeInvalidInputThrowsException(): void - { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Invalid prompt format: expected string, Message, MessagePart, structured array, ' . - 'or array of strings/MessageParts, got integer' - ); - - PromptNormalizer::normalize(123); - } - - /** - * Tests array with invalid object types throws exception. - */ - public function testNormalizeArrayWithInvalidObjectsThrowsException(): void - { - $invalidArray = [new \stdClass(), new \DateTime()]; - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Array items must be strings or MessagePart objects, got stdClass' - ); - - PromptNormalizer::normalize($invalidArray); - } - - /** - * Tests normalizing message array. - */ - public function testNormalizeMessageArrayShape(): void - { - $messageArray = [ - 'role' => 'user', - 'parts' => [ - ['text' => 'You are a helpful assistant.'], - ['text' => 'Be concise.'] - ] - ]; - - $result = PromptNormalizer::normalize($messageArray); - - $this->assertInstanceOf(Message::class, $result); - $this->assertTrue($result->getRole()->equals(MessageRoleEnum::user())); - $this->assertCount(2, $result->getParts()); - $this->assertEquals('You are a helpful assistant.', $result->getParts()[0]->getText()); - $this->assertEquals('Be concise.', $result->getParts()[1]->getText()); - } - - /** - * Tests normalizing mixed array with strings and MessageParts. - */ - public function testNormalizeMixedStringAndMessageParts(): void - { - $mixed = [ - 'User message', - new MessagePart('Part message') - ]; - - $result = PromptNormalizer::normalize($mixed); - - $this->assertInstanceOf(UserMessage::class, $result); - $this->assertCount(2, $result->getParts()); - $this->assertEquals('User message', $result->getParts()[0]->getText()); - $this->assertEquals('Part message', $result->getParts()[1]->getText()); - } - - /** - * Tests message array with invalid role throws exception. - */ - public function testMessageArrayInvalidRoleThrowsException(): void - { - $messageArray = [ - 'role' => 'invalid_role', - 'parts' => [['text' => 'Some text']] - ]; - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('invalid_role is not a valid backing value for enum'); - - PromptNormalizer::normalize($messageArray); - } - - /** - * Tests message array with missing parts is treated as string array. - */ - public function testMessageArrayMissingPartsTreatedAsStringArray(): void - { - $messageArray = [ - 'role' => 'user' - // Missing 'parts' - will be treated as array with string value 'user' - ]; - - $result = PromptNormalizer::normalize($messageArray); - - // It should create a UserMessage with a single part containing 'user' text - $this->assertInstanceOf(UserMessage::class, $result); - $this->assertCount(1, $result->getParts()); - $this->assertEquals('user', $result->getParts()[0]->getText()); - } - - /** - * Tests role mapping for different variations. - */ - public function testRoleMapping(): void - { - // Test user role - $userMsg = [ - 'role' => 'user', - 'parts' => [['text' => 'User']] - ]; - $result = PromptNormalizer::normalize($userMsg); - $this->assertTrue($result->getRole()->equals(MessageRoleEnum::user())); - - // Test model role - $modelMsg = [ - 'role' => 'model', - 'parts' => [['text' => 'Model']] - ]; - $result = PromptNormalizer::normalize($modelMsg); - $this->assertTrue($result->getRole()->equals(MessageRoleEnum::model())); - } - - /** - * Tests that isMessagesList returns true for a list of Message objects. - */ - public function testIsMessagesListReturnsTrueForMessages(): void - { - $messages = [ - new UserMessage([new MessagePart('First')]), - new UserMessage([new MessagePart('Second')]), - ]; - - $this->assertTrue(PromptNormalizer::isMessagesList($messages)); - } - - /** - * Tests that isMessagesList returns false for non-list arrays. - */ - public function testIsMessagesListReturnsFalseForNonList(): void - { - $messages = [ - 1 => new UserMessage([new MessagePart('First')]), - 2 => new UserMessage([new MessagePart('Second')]), - ]; - - $this->assertFalse(PromptNormalizer::isMessagesList($messages)); - } - - /** - * Tests that isMessagesList returns false for empty arrays. - */ - public function testIsMessagesListReturnsFalseForEmpty(): void - { - $this->assertFalse(PromptNormalizer::isMessagesList([])); - } - - /** - * Tests that isMessagesList returns false for arrays with non-Message objects. - */ - public function testIsMessagesListReturnsFalseForMixedTypes(): void - { - $mixed = [ - new UserMessage([new MessagePart('First')]), - 'string', - ]; - - $this->assertFalse(PromptNormalizer::isMessagesList($mixed)); - } - - /** - * Tests that isMessagesList returns false for non-array types. - */ - public function testIsMessagesListReturnsFalseForNonArray(): void - { - $this->assertFalse(PromptNormalizer::isMessagesList('string')); - $this->assertFalse(PromptNormalizer::isMessagesList(123)); - $this->assertFalse(PromptNormalizer::isMessagesList(new UserMessage([new MessagePart('Test')]))); - } -} From bdc644d888217abab59763df44c2f51dbd883f6c Mon Sep 17 00:00:00 2001 From: ref34t Date: Wed, 27 Aug 2025 23:07:41 +0300 Subject: [PATCH 57/69] fix: resolve test failures and static analysis issues - Fix supportsCapability method calls - use getSupportedCapabilities() instead - Update test mocks to use proper ProviderRegistry mocking - Fix capability validation logic with proper capability comparison - Update error message test expectations for improved error formatting - Replace non-existent supportsCapability() with proper capability checking --- src/AiClient.php | 11 +++++++++- tests/unit/AiClientTest.php | 41 +++++++++++++++++++++++++------------ 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index 9b9cf627..6bd98be6 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -198,7 +198,16 @@ public static function generateResultWithCapability( } // Validate that the model supports the requested capability - if (!$model->metadata()->supportsCapability($capability)) { + $supportedCapabilities = $model->metadata()->getSupportedCapabilities(); + $supportsCapability = false; + foreach ($supportedCapabilities as $supportedCapability) { + if ($supportedCapability->equals($capability)) { + $supportsCapability = true; + break; + } + } + + if (!$supportsCapability) { throw new \InvalidArgumentException( sprintf( 'Model "%s" does not support the "%s" capability', diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index 291ded98..3135b321 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -382,9 +382,19 @@ public function testGenerateResultThrowsExceptionForUnsupportedModel(): void $prompt = 'Test prompt'; $unsupportedModel = $this->createMock(ModelInterface::class); + // Mock the metadata to return an ID + $mockMetadata = $this->createMock(\WordPress\AiClient\Providers\Models\DTO\ModelMetadata::class); + $mockMetadata->expects($this->once()) + ->method('getId') + ->willReturn('unsupported-model'); + + $unsupportedModel->expects($this->once()) + ->method('metadata') + ->willReturn($mockMetadata); + $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage( - 'Model must implement at least one supported generation interface ' . + 'Model "unsupported-model" must implement at least one supported generation interface ' . '(TextGeneration, ImageGeneration, TextToSpeechConversion, SpeechGeneration)' ); @@ -507,14 +517,17 @@ public function testGenerateResultWithNullModelDelegatesToPromptBuilder(): void { $prompt = 'Test prompt for auto-discovery'; - // Mock the registry to return a working text model - $this->registry + // Create a mock registry that returns empty results + $mockRegistry = $this->createMock(ProviderRegistry::class); + $mockRegistry ->expects($this->once()) ->method('findModelsMetadataForSupport') ->willReturn([]); - // This should not throw an exception due to null model - // Instead it should delegate to PromptBuilder's intelligent discovery + // Set the mock registry as default + AiClient::setDefaultRegistry($mockRegistry); + + // This should delegate to PromptBuilder's intelligent discovery $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('No models found that support the required capabilities'); @@ -590,12 +603,16 @@ public function testGenerateResultWithCapabilityNullModelDelegatesToPromptBuilde $prompt = 'Test prompt'; $capability = CapabilityEnum::textGeneration(); - // Mock the registry to return empty results - $this->registry + // Create a mock registry that returns empty results + $mockRegistry = $this->createMock(ProviderRegistry::class); + $mockRegistry ->expects($this->once()) ->method('findModelsMetadataForSupport') ->willReturn([]); + // Set the mock registry as default + AiClient::setDefaultRegistry($mockRegistry); + $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('No models found that support the required capabilities'); @@ -613,9 +630,8 @@ public function testGenerateResultWithCapabilityValidModelAndCapability(): void $mockMetadata = $this->createMock(\WordPress\AiClient\Providers\Models\DTO\ModelMetadata::class); $mockMetadata->expects($this->once()) - ->method('supportsCapability') - ->with($capability) - ->willReturn(true); + ->method('getSupportedCapabilities') + ->willReturn([$capability]); $this->mockTextModel ->expects($this->once()) @@ -642,9 +658,8 @@ public function testGenerateResultWithCapabilityUnsupportedCapability(): void $mockMetadata = $this->createMock(\WordPress\AiClient\Providers\Models\DTO\ModelMetadata::class); $mockMetadata->expects($this->once()) - ->method('supportsCapability') - ->with($capability) - ->willReturn(false); + ->method('getSupportedCapabilities') + ->willReturn([CapabilityEnum::textGeneration()]); // Only supports text, not image $mockMetadata->expects($this->once()) ->method('getId') ->willReturn('text-only-model'); From 0d4c6ded14498786f53511f24f4dd85d12bbbd9c Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 27 Aug 2025 23:29:18 +0300 Subject: [PATCH 58/69] fix: resolve test failures and code style violations - Fix mock expectation count in testGenerateResultWithCapabilityUnsupportedCapability - Update exception message format to match capability enum value - Fix code style violations with import sorting and whitespace --- src/AiClient.php | 7 +++---- tests/unit/AiClientTest.php | 24 ++++++++++++------------ 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index 6bd98be6..b37d8d8a 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -9,13 +9,13 @@ use WordPress\AiClient\Operations\DTO\GenerativeAiOperation; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; +use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; use WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts\SpeechGenerationModelInterface; use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Results\DTO\GenerativeAiResult; -use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; /** * Main AI Client class providing both fluent and traditional APIs for AI operations. @@ -190,8 +190,7 @@ public static function generateResultWithCapability( $prompt, CapabilityEnum $capability, ?ModelInterface $model = null - ): GenerativeAiResult - { + ): GenerativeAiResult { // If no model provided, use PromptBuilder with explicit capability if ($model === null) { return self::prompt($prompt)->generateResult($capability); @@ -206,7 +205,7 @@ public static function generateResultWithCapability( break; } } - + if (!$supportsCapability) { throw new \InvalidArgumentException( sprintf( diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index 3135b321..aff8223b 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -17,6 +17,7 @@ use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; +use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Results\DTO\Candidate; use WordPress\AiClient\Results\DTO\GenerativeAiResult; @@ -24,7 +25,6 @@ use WordPress\AiClient\Results\Enums\FinishReasonEnum; use WordPress\AiClient\Tests\mocks\MockImageGenerationModel; use WordPress\AiClient\Tests\mocks\MockTextGenerationModel; -use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; /** * @covers \WordPress\AiClient\AiClient @@ -387,7 +387,7 @@ public function testGenerateResultThrowsExceptionForUnsupportedModel(): void $mockMetadata->expects($this->once()) ->method('getId') ->willReturn('unsupported-model'); - + $unsupportedModel->expects($this->once()) ->method('metadata') ->willReturn($mockMetadata); @@ -516,7 +516,7 @@ public function testGenerateSpeechOperationThrowsNotImplementedException(): void public function testGenerateResultWithNullModelDelegatesToPromptBuilder(): void { $prompt = 'Test prompt for auto-discovery'; - + // Create a mock registry that returns empty results $mockRegistry = $this->createMock(ProviderRegistry::class); $mockRegistry @@ -530,7 +530,7 @@ public function testGenerateResultWithNullModelDelegatesToPromptBuilder(): void // This should delegate to PromptBuilder's intelligent discovery $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('No models found that support the required capabilities'); - + AiClient::generateResult($prompt); } @@ -577,12 +577,12 @@ public function testGenerateResultWithInvalidModelThrowsExceptionWithModelId(): { $prompt = 'Test prompt'; $invalidModel = $this->createMock(ModelInterface::class); - + $mockMetadata = $this->createMock(\WordPress\AiClient\Providers\Models\DTO\ModelMetadata::class); $mockMetadata->expects($this->once()) ->method('getId') ->willReturn('invalid-model-id'); - + $invalidModel->expects($this->once()) ->method('metadata') ->willReturn($mockMetadata); @@ -602,7 +602,7 @@ public function testGenerateResultWithCapabilityNullModelDelegatesToPromptBuilde { $prompt = 'Test prompt'; $capability = CapabilityEnum::textGeneration(); - + // Create a mock registry that returns empty results $mockRegistry = $this->createMock(ProviderRegistry::class); $mockRegistry @@ -615,7 +615,7 @@ public function testGenerateResultWithCapabilityNullModelDelegatesToPromptBuilde $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('No models found that support the required capabilities'); - + AiClient::generateResultWithCapability($prompt, $capability); } @@ -632,7 +632,7 @@ public function testGenerateResultWithCapabilityValidModelAndCapability(): void $mockMetadata->expects($this->once()) ->method('getSupportedCapabilities') ->willReturn([$capability]); - + $this->mockTextModel ->expects($this->once()) ->method('metadata') @@ -663,15 +663,15 @@ public function testGenerateResultWithCapabilityUnsupportedCapability(): void $mockMetadata->expects($this->once()) ->method('getId') ->willReturn('text-only-model'); - + $this->mockTextModel - ->expects($this->once()) + ->expects($this->exactly(2)) ->method('metadata') ->willReturn($mockMetadata); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage( - 'Model "text-only-model" does not support the "image-generation" capability' + 'Model "text-only-model" does not support the "image_generation" capability' ); AiClient::generateResultWithCapability($prompt, $capability, $this->mockTextModel); From 38db3f9d9536418d658223d991620654d91a0467 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Wed, 27 Aug 2025 23:58:30 +0300 Subject: [PATCH 59/69] refactor: remove premature methods and consolidate AiClient logic Remove over-engineering while preserving full MVP compatibility: - Remove 5 operation methods (not in MVP scope, purely premature) - Remove streaming method (not implemented, throws RuntimeException) - Consolidate duplicate generateResult() logic into single method - Fix broken mock implementations to return actual test data - Remove tests for non-existent methods - Clean up unused imports and code style issues --- src/AiClient.php | 122 ++--------------------- tests/mocks/MockImageGenerationModel.php | 35 ++++++- tests/mocks/MockTextGenerationModel.php | 32 +++++- tests/unit/AiClientTest.php | 120 +--------------------- 4 files changed, 69 insertions(+), 240 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index b37d8d8a..6c6aa52c 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -4,9 +4,7 @@ namespace WordPress\AiClient; -use Generator; use WordPress\AiClient\Builders\PromptBuilder; -use WordPress\AiClient\Operations\DTO\GenerativeAiOperation; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; @@ -124,7 +122,7 @@ public static function prompt($prompt = null): PromptBuilder * * When no model is provided, this method delegates to PromptBuilder for intelligent * model discovery based on prompt content and configuration. When a model is provided, - * it routes to the appropriate generation method based on the model's interfaces. + * it infers the capability from the model's interfaces and delegates to the capability-based method. * * @since n.e.x.t * @@ -142,22 +140,21 @@ public static function generateResult($prompt, ?ModelInterface $model = null): G return self::prompt($prompt)->generateResult(); } - // Route based on model interface capabilities - // Note: Order matters for models that implement multiple interfaces + // Infer capability from model interface (priority order matters) if ($model instanceof TextGenerationModelInterface) { - return self::generateTextResult($prompt, $model); + return self::generateResultWithCapability($prompt, CapabilityEnum::textGeneration(), $model); } if ($model instanceof ImageGenerationModelInterface) { - return self::generateImageResult($prompt, $model); + return self::generateResultWithCapability($prompt, CapabilityEnum::imageGeneration(), $model); } if ($model instanceof TextToSpeechConversionModelInterface) { - return self::convertTextToSpeechResult($prompt, $model); + return self::generateResultWithCapability($prompt, CapabilityEnum::textToSpeechConversion(), $model); } if ($model instanceof SpeechGenerationModelInterface) { - return self::generateSpeechResult($prompt, $model); + return self::generateResultWithCapability($prompt, CapabilityEnum::speechGeneration(), $model); } throw new \InvalidArgumentException( @@ -281,23 +278,6 @@ public static function generateTextResult($prompt, ?ModelInterface $model = null return $builder->generateTextResult(); } - /** - * Streams text generation using the traditional API approach. - * - * @since n.e.x.t - * - * @param Prompt $prompt The prompt content. - * @param ModelInterface|null $model Optional specific model to use. - * @return Generator Generator yielding partial text generation results. - * - * @throws \RuntimeException Always throws - streaming is not implemented yet. - */ - public static function streamGenerateTextResult($prompt, ?ModelInterface $model = null): Generator - { - throw new \RuntimeException( - 'Text streaming is not implemented yet. Use generateTextResult() for non-streaming text generation.' - ); - } /** * Generates an image using the traditional API approach. @@ -362,96 +342,6 @@ public static function generateSpeechResult($prompt, ?ModelInterface $model = nu return $builder->generateSpeechResult(); } - /** - * Creates a generation operation for async processing. - * - * @since n.e.x.t - * - * @param Prompt $prompt The prompt content. - * @param ModelInterface|null $model Optional specific model to use. - * @return GenerativeAiOperation The operation for async processing. - * - * @throws \RuntimeException Operations are not implemented yet. - */ - public static function generateOperation($prompt, ?ModelInterface $model = null): GenerativeAiOperation - { - throw new \RuntimeException( - 'Operations are not implemented yet. This functionality is planned for a future release.' - ); - } - - /** - * Creates a text generation operation for async processing. - * - * @since n.e.x.t - * - * @param Prompt $prompt The prompt content. - * @param ModelInterface|null $model Optional specific model to use. - * @return GenerativeAiOperation The operation for async text processing. - * - * @throws \RuntimeException Operations are not implemented yet. - */ - public static function generateTextOperation($prompt, ?ModelInterface $model = null): GenerativeAiOperation - { - throw new \RuntimeException( - 'Text generation operations are not implemented yet. This functionality is planned for a future release.' - ); - } - - /** - * Creates an image generation operation for async processing. - * - * @since n.e.x.t - * - * @param Prompt $prompt The prompt content. - * @param ModelInterface|null $model Optional specific model to use. - * @return GenerativeAiOperation The operation for async image processing. - * - * @throws \RuntimeException Operations are not implemented yet. - */ - public static function generateImageOperation($prompt, ?ModelInterface $model = null): GenerativeAiOperation - { - throw new \RuntimeException( - 'Image generation operations are not implemented yet. This functionality is planned for a future release.' - ); - } - - /** - * Creates a text-to-speech conversion operation for async processing. - * - * @since n.e.x.t - * - * @param Prompt $prompt The prompt content. - * @param ModelInterface|null $model Optional specific model to use. - * @return GenerativeAiOperation The operation for async text-to-speech processing. - * - * @throws \RuntimeException Operations are not implemented yet. - */ - public static function convertTextToSpeechOperation($prompt, ?ModelInterface $model = null): GenerativeAiOperation - { - throw new \RuntimeException( - 'Text-to-speech conversion operations are not implemented yet. ' . - 'This functionality is planned for a future release.' - ); - } - - /** - * Creates a speech generation operation for async processing. - * - * @since n.e.x.t - * - * @param Prompt $prompt The prompt content. - * @param ModelInterface|null $model Optional specific model to use. - * @return GenerativeAiOperation The operation for async speech processing. - * - * @throws \RuntimeException Operations are not implemented yet. - */ - public static function generateSpeechOperation($prompt, ?ModelInterface $model = null): GenerativeAiOperation - { - throw new \RuntimeException( - 'Speech generation operations are not implemented yet. This functionality is planned for a future release.' - ); - } /** * Convenience method for text generation. diff --git a/tests/mocks/MockImageGenerationModel.php b/tests/mocks/MockImageGenerationModel.php index a043de37..3142b299 100644 --- a/tests/mocks/MockImageGenerationModel.php +++ b/tests/mocks/MockImageGenerationModel.php @@ -4,12 +4,20 @@ namespace WordPress\AiClient\Tests\mocks; +use WordPress\AiClient\Files\DTO\File; +use WordPress\AiClient\Messages\DTO\MessagePart; +use WordPress\AiClient\Messages\DTO\ModelMessage; +use WordPress\AiClient\Providers\DTO\ProviderMetadata; +use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; +use WordPress\AiClient\Results\DTO\Candidate; use WordPress\AiClient\Results\DTO\GenerativeAiResult; +use WordPress\AiClient\Results\DTO\TokenUsage; +use WordPress\AiClient\Results\Enums\FinishReasonEnum; /** * Mock image generation model for testing. @@ -74,7 +82,30 @@ public function setConfig(ModelConfig $config): void */ public function generateImageResult(array $prompt): GenerativeAiResult { - // Return a mock result - throw new \RuntimeException('Mock implementation - should be mocked in tests'); + $mockImageFile = new File( + 'data:image/png;base64,' . + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'image/png' + ); + + $candidate = new Candidate( + new ModelMessage([new MessagePart($mockImageFile)]), + FinishReasonEnum::stop() + ); + $tokenUsage = new TokenUsage(3, 8, 11); + + $providerMetadata = new ProviderMetadata( + 'mock-image-provider', + 'Mock Image Provider', + ProviderTypeEnum::cloud() + ); + + return new GenerativeAiResult( + 'mock-image-result-id', + [$candidate], + $tokenUsage, + $providerMetadata, + $this->metadata + ); } } diff --git a/tests/mocks/MockTextGenerationModel.php b/tests/mocks/MockTextGenerationModel.php index 80d43744..4d916c54 100644 --- a/tests/mocks/MockTextGenerationModel.php +++ b/tests/mocks/MockTextGenerationModel.php @@ -5,12 +5,19 @@ namespace WordPress\AiClient\Tests\mocks; use Generator; +use WordPress\AiClient\Messages\DTO\MessagePart; +use WordPress\AiClient\Messages\DTO\ModelMessage; +use WordPress\AiClient\Providers\DTO\ProviderMetadata; +use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; +use WordPress\AiClient\Results\DTO\Candidate; use WordPress\AiClient\Results\DTO\GenerativeAiResult; +use WordPress\AiClient\Results\DTO\TokenUsage; +use WordPress\AiClient\Results\Enums\FinishReasonEnum; /** * Mock text generation model for testing. @@ -75,8 +82,25 @@ public function setConfig(ModelConfig $config): void */ public function generateTextResult(array $prompt): GenerativeAiResult { - // Return a mock result - throw new \RuntimeException('Mock implementation - should be mocked in tests'); + $candidate = new Candidate( + new ModelMessage([new MessagePart('Mock text generation result')]), + FinishReasonEnum::stop() + ); + $tokenUsage = new TokenUsage(5, 15, 20); + + $providerMetadata = new ProviderMetadata( + 'mock-text-provider', + 'Mock Text Provider', + ProviderTypeEnum::cloud() + ); + + return new GenerativeAiResult( + 'mock-text-result-id', + [$candidate], + $tokenUsage, + $providerMetadata, + $this->metadata + ); } /** @@ -84,7 +108,7 @@ public function generateTextResult(array $prompt): GenerativeAiResult */ public function streamGenerateTextResult(array $prompt): Generator { - // Return a mock generator - throw new \RuntimeException('Mock implementation - should be mocked in tests'); + // Return a simple mock generator that yields one result + yield $this->generateTextResult($prompt); } } diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index aff8223b..6f89b046 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -41,8 +41,8 @@ protected function setUp(): void $this->registry = new ProviderRegistry(); // Create mock models that implement both base and generation interfaces - $this->mockTextModel = $this->createMock(MockTextGenerationModel::class); - $this->mockImageModel = $this->createMock(MockImageGenerationModel::class); + $this->mockTextModel = new MockTextGenerationModel(); + $this->mockImageModel = new MockImageGenerationModel(); // Set the test registry as the default AiClient::setDefaultRegistry($this->registry); @@ -190,20 +190,6 @@ public function testGenerateImageResultWithInvalidModel(): void AiClient::generateImageResult($prompt, $invalidModel); } - /** - * Tests generateOperation throws not implemented exception. - */ - public function testGenerateOperationThrowsNotImplementedException(): void - { - $prompt = 'Generate content'; - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage( - 'Operations are not implemented yet. This functionality is planned for a future release.' - ); - - AiClient::generateOperation($prompt, $this->mockTextModel); - } /** * Tests generateTextResult with Message object. @@ -401,114 +387,12 @@ public function testGenerateResultThrowsExceptionForUnsupportedModel(): void AiClient::generateResult($prompt, $unsupportedModel); } - /** - * Tests streamGenerateTextResult delegates to model's streaming method. - */ - public function testStreamGenerateTextResultThrowsNotImplementedException(): void - { - $prompt = 'Stream this text'; - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage( - 'Text streaming is not implemented yet. Use generateTextResult() for non-streaming text generation.' - ); - - iterator_to_array(AiClient::streamGenerateTextResult($prompt, $this->mockTextModel)); - } - - /** - * Tests streamGenerateTextResult with model auto-discovery. - */ - public function testStreamGenerateTextResultWithAutoDiscoveryThrowsNotImplementedException(): void - { - $prompt = 'Auto-discover and stream'; - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage( - 'Text streaming is not implemented yet. Use generateTextResult() for non-streaming text generation.' - ); - - iterator_to_array(AiClient::streamGenerateTextResult($prompt)); - } - - /** - * Tests streamGenerateTextResult throws exception when model doesn't support text generation. - */ - public function testStreamGenerateTextResultForNonTextModelThrowsNotImplementedException(): void - { - $prompt = 'Test prompt'; - $nonTextModel = $this->createMock(ModelInterface::class); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage( - 'Text streaming is not implemented yet. Use generateTextResult() for non-streaming text generation.' - ); - - iterator_to_array(AiClient::streamGenerateTextResult($prompt, $nonTextModel)); - } - - /** - * Tests generateTextOperation throws not implemented exception. - */ - public function testGenerateTextOperationThrowsNotImplementedException(): void - { - $prompt = 'Text operation prompt'; - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage( - 'Text generation operations are not implemented yet. This functionality is planned for a future release.' - ); - AiClient::generateTextOperation($prompt, $this->mockTextModel); - } - /** - * Tests generateImageOperation throws not implemented exception. - */ - public function testGenerateImageOperationThrowsNotImplementedException(): void - { - $prompt = 'Image operation prompt'; - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage( - 'Image generation operations are not implemented yet. This functionality is planned for a future release.' - ); - AiClient::generateImageOperation($prompt, $this->mockImageModel); - } - /** - * Tests convertTextToSpeechOperation throws not implemented exception. - */ - public function testConvertTextToSpeechOperationThrowsNotImplementedException(): void - { - $prompt = 'Text to speech operation prompt'; - $mockModel = $this->createMock(ModelInterface::class); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage( - 'Text-to-speech conversion operations are not implemented yet. ' . - 'This functionality is planned for a future release.' - ); - - AiClient::convertTextToSpeechOperation($prompt, $mockModel); - } - - /** - * Tests generateSpeechOperation throws not implemented exception. - */ - public function testGenerateSpeechOperationThrowsNotImplementedException(): void - { - $prompt = 'Speech operation prompt'; - $mockModel = $this->createMock(ModelInterface::class); - - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage( - 'Speech generation operations are not implemented yet. This functionality is planned for a future release.' - ); - - AiClient::generateSpeechOperation($prompt, $mockModel); - } /** * Tests generateResult with null model delegates to PromptBuilder. From b950e57321259965773bc3115401c4fbd112ed06 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Thu, 28 Aug 2025 00:21:42 +0300 Subject: [PATCH 60/69] refactor: adopt repository testing patterns for AiClient tests - Replace complex PHPUnit mock expectations with anonymous classes - Remove global mock setup in favor of test-specific helper methods - Add helper methods following PromptBuilderTest patterns - Remove unused MockTextGenerationModel and MockImageGenerationModel classes - Simplify test code while maintaining same coverage and behavior --- tests/mocks/MockImageGenerationModel.php | 111 ------- tests/mocks/MockTextGenerationModel.php | 114 -------- tests/unit/AiClientTest.php | 358 +++++++++++++---------- 3 files changed, 197 insertions(+), 386 deletions(-) delete mode 100644 tests/mocks/MockImageGenerationModel.php delete mode 100644 tests/mocks/MockTextGenerationModel.php diff --git a/tests/mocks/MockImageGenerationModel.php b/tests/mocks/MockImageGenerationModel.php deleted file mode 100644 index 3142b299..00000000 --- a/tests/mocks/MockImageGenerationModel.php +++ /dev/null @@ -1,111 +0,0 @@ -metadata = $metadata ?? new ModelMetadata( - 'mock-image-model', - 'Mock Image Model', - [CapabilityEnum::imageGeneration()], - [] - ); - $this->config = $config ?? new ModelConfig(); - } - - /** - * {@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; - } - - /** - * {@inheritDoc} - */ - public function generateImageResult(array $prompt): GenerativeAiResult - { - $mockImageFile = new File( - 'data:image/png;base64,' . - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', - 'image/png' - ); - - $candidate = new Candidate( - new ModelMessage([new MessagePart($mockImageFile)]), - FinishReasonEnum::stop() - ); - $tokenUsage = new TokenUsage(3, 8, 11); - - $providerMetadata = new ProviderMetadata( - 'mock-image-provider', - 'Mock Image Provider', - ProviderTypeEnum::cloud() - ); - - return new GenerativeAiResult( - 'mock-image-result-id', - [$candidate], - $tokenUsage, - $providerMetadata, - $this->metadata - ); - } -} diff --git a/tests/mocks/MockTextGenerationModel.php b/tests/mocks/MockTextGenerationModel.php deleted file mode 100644 index 4d916c54..00000000 --- a/tests/mocks/MockTextGenerationModel.php +++ /dev/null @@ -1,114 +0,0 @@ -metadata = $metadata ?? new ModelMetadata( - 'mock-text-model', - 'Mock Text Model', - [CapabilityEnum::textGeneration()], - [] - ); - $this->config = $config ?? new ModelConfig(); - } - - /** - * {@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; - } - - /** - * {@inheritDoc} - */ - public function generateTextResult(array $prompt): GenerativeAiResult - { - $candidate = new Candidate( - new ModelMessage([new MessagePart('Mock text generation result')]), - FinishReasonEnum::stop() - ); - $tokenUsage = new TokenUsage(5, 15, 20); - - $providerMetadata = new ProviderMetadata( - 'mock-text-provider', - 'Mock Text Provider', - ProviderTypeEnum::cloud() - ); - - return new GenerativeAiResult( - 'mock-text-result-id', - [$candidate], - $tokenUsage, - $providerMetadata, - $this->metadata - ); - } - - /** - * {@inheritDoc} - */ - public function streamGenerateTextResult(array $prompt): Generator - { - // Return a simple mock generator that yields one result - yield $this->generateTextResult($prompt); - } -} diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index 6f89b046..a857ed75 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -4,11 +4,11 @@ namespace WordPress\AiClient\Tests\unit; +use Generator; use InvalidArgumentException; use PHPUnit\Framework\TestCase; use RuntimeException; use WordPress\AiClient\AiClient; -use WordPress\AiClient\Messages\DTO\Message; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\ModelMessage; use WordPress\AiClient\Messages\DTO\UserMessage; @@ -16,36 +16,26 @@ use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; +use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; +use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; +use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Results\DTO\Candidate; use WordPress\AiClient\Results\DTO\GenerativeAiResult; use WordPress\AiClient\Results\DTO\TokenUsage; use WordPress\AiClient\Results\Enums\FinishReasonEnum; -use WordPress\AiClient\Tests\mocks\MockImageGenerationModel; -use WordPress\AiClient\Tests\mocks\MockTextGenerationModel; /** * @covers \WordPress\AiClient\AiClient */ class AiClientTest extends TestCase { - private ProviderRegistry $registry; - private MockTextGenerationModel $mockTextModel; - private MockImageGenerationModel $mockImageModel; - protected function setUp(): void { - // Create a clean registry for each test - $this->registry = new ProviderRegistry(); - - // Create mock models that implement both base and generation interfaces - $this->mockTextModel = new MockTextGenerationModel(); - $this->mockImageModel = new MockImageGenerationModel(); - - // Set the test registry as the default - AiClient::setDefaultRegistry($this->registry); + // Set a clean registry for each test + AiClient::setDefaultRegistry(new ProviderRegistry()); } /** @@ -86,6 +76,136 @@ protected function tearDown(): void AiClient::setDefaultRegistry(new ProviderRegistry()); } + + /** + * Creates a test model metadata instance for text generation. + * + * @return ModelMetadata + */ + private function createTestTextModelMetadata(): ModelMetadata + { + return new ModelMetadata( + 'test-text-model', + 'Test Text Model', + [CapabilityEnum::textGeneration()], + [] + ); + } + + /** + * Creates a test model metadata instance for image generation. + * + * @return ModelMetadata + */ + private function createTestImageModelMetadata(): ModelMetadata + { + return new ModelMetadata( + 'test-image-model', + 'Test Image Model', + [CapabilityEnum::imageGeneration()], + [] + ); + } + + /** + * Creates a mock text generation model using anonymous class. + * + * @param GenerativeAiResult $result The result to return from generation. + * @param ModelMetadata|null $metadata Optional metadata (uses default if not provided). + * @return ModelInterface&TextGenerationModelInterface The mock model. + */ + private function createMockTextGenerationModel( + GenerativeAiResult $result, + ?ModelMetadata $metadata = null + ): ModelInterface { + $metadata = $metadata ?? $this->createTestTextModelMetadata(); + + return new class ($metadata, $result) implements ModelInterface, TextGenerationModelInterface { + private ModelMetadata $metadata; + private GenerativeAiResult $result; + private ModelConfig $config; + + public function __construct(ModelMetadata $metadata, GenerativeAiResult $result) + { + $this->metadata = $metadata; + $this->result = $result; + $this->config = new ModelConfig(); + } + + public function metadata(): ModelMetadata + { + return $this->metadata; + } + + public function setConfig(ModelConfig $config): void + { + $this->config = $config; + } + + public function getConfig(): ModelConfig + { + return $this->config; + } + + public function generateTextResult(array $prompt): GenerativeAiResult + { + return $this->result; + } + + public function streamGenerateTextResult(array $prompt): Generator + { + yield $this->result; + } + }; + } + + /** + * Creates a mock image generation model using anonymous class. + * + * @param GenerativeAiResult $result The result to return from generation. + * @param ModelMetadata|null $metadata Optional metadata (uses default if not provided). + * @return ModelInterface&ImageGenerationModelInterface The mock model. + */ + private function createMockImageGenerationModel( + GenerativeAiResult $result, + ?ModelMetadata $metadata = null + ): ModelInterface { + $metadata = $metadata ?? $this->createTestImageModelMetadata(); + + return new class ($metadata, $result) implements ModelInterface, ImageGenerationModelInterface { + private ModelMetadata $metadata; + private GenerativeAiResult $result; + private ModelConfig $config; + + public function __construct(ModelMetadata $metadata, GenerativeAiResult $result) + { + $this->metadata = $metadata; + $this->result = $result; + $this->config = new ModelConfig(); + } + + public function metadata(): ModelMetadata + { + return $this->metadata; + } + + public function setConfig(ModelConfig $config): void + { + $this->config = $config; + } + + public function getConfig(): ModelConfig + { + return $this->config; + } + + public function generateImageResult(array $prompt): GenerativeAiResult + { + return $this->result; + } + }; + } + /** * Tests default registry getter and setter. */ @@ -120,22 +240,12 @@ public function testMessageThrowsException(): void public function testGenerateTextResultWithStringAndModel(): void { $prompt = 'Generate text'; - $mockResult = $this->createMock(GenerativeAiResult::class); + $expectedResult = $this->createTestResult(); + $mockModel = $this->createMockTextGenerationModel($expectedResult); - $this->mockTextModel - ->expects($this->once()) - ->method('generateTextResult') - ->with($this->callback(function ($messages) { - return is_array($messages) && - count($messages) === 1 && - $messages[0] instanceof Message && - $messages[0]->getRole()->isUser(); - })) - ->willReturn($mockResult); - - $result = AiClient::generateTextResult($prompt, $this->mockTextModel); - - $this->assertSame($mockResult, $result); + $result = AiClient::generateTextResult($prompt, $mockModel); + + $this->assertSame($expectedResult, $result); } /** @@ -158,22 +268,12 @@ public function testGenerateTextResultWithInvalidModel(): void public function testGenerateImageResultWithStringAndModel(): void { $prompt = 'Generate image'; - $mockResult = $this->createMock(GenerativeAiResult::class); + $expectedResult = $this->createTestResult(); + $mockModel = $this->createMockImageGenerationModel($expectedResult); - $this->mockImageModel - ->expects($this->once()) - ->method('generateImageResult') - ->with($this->callback(function ($messages) { - return is_array($messages) && - count($messages) === 1 && - $messages[0] instanceof Message && - $messages[0]->getRole()->isUser(); - })) - ->willReturn($mockResult); - - $result = AiClient::generateImageResult($prompt, $this->mockImageModel); - - $this->assertSame($mockResult, $result); + $result = AiClient::generateImageResult($prompt, $mockModel); + + $this->assertSame($expectedResult, $result); } /** @@ -198,21 +298,12 @@ public function testGenerateTextResultWithMessage(): void { $messagePart = new MessagePart('Test message'); $message = new UserMessage([$messagePart]); - $mockResult = $this->createMock(GenerativeAiResult::class); - - $this->mockTextModel - ->expects($this->once()) - ->method('generateTextResult') - ->with($this->callback(function ($messages) use ($message) { - return is_array($messages) && - count($messages) === 1 && - $messages[0] === $message; - })) - ->willReturn($mockResult); + $expectedResult = $this->createTestResult(); + $mockModel = $this->createMockTextGenerationModel($expectedResult); - $result = AiClient::generateTextResult($message, $this->mockTextModel); + $result = AiClient::generateTextResult($message, $mockModel); - $this->assertSame($mockResult, $result); + $this->assertSame($expectedResult, $result); } /** @@ -221,22 +312,12 @@ public function testGenerateTextResultWithMessage(): void public function testGenerateTextResultWithMessagePart(): void { $messagePart = new MessagePart('Test message part'); - $mockResult = $this->createMock(GenerativeAiResult::class); + $expectedResult = $this->createTestResult(); + $mockModel = $this->createMockTextGenerationModel($expectedResult); - $this->mockTextModel - ->expects($this->once()) - ->method('generateTextResult') - ->with($this->callback(function ($messages) { - return is_array($messages) && - count($messages) === 1 && - $messages[0] instanceof Message && - $messages[0]->getRole()->isUser(); - })) - ->willReturn($mockResult); - - $result = AiClient::generateTextResult($messagePart, $this->mockTextModel); - - $this->assertSame($mockResult, $result); + $result = AiClient::generateTextResult($messagePart, $mockModel); + + $this->assertSame($expectedResult, $result); } /** @@ -250,22 +331,12 @@ public function testGenerateTextResultWithMessageArray(): void $message2 = new UserMessage([$messagePart2]); $messages = [$message1, $message2]; - $mockResult = $this->createMock(GenerativeAiResult::class); + $expectedResult = $this->createTestResult(); + $mockModel = $this->createMockTextGenerationModel($expectedResult); - $this->mockTextModel - ->expects($this->once()) - ->method('generateTextResult') - ->with($this->callback(function ($result) use ($messages) { - return is_array($result) && - count($result) === 2 && - $result[0] === $messages[0] && - $result[1] === $messages[1]; - })) - ->willReturn($mockResult); - - $result = AiClient::generateTextResult($messages, $this->mockTextModel); - - $this->assertSame($mockResult, $result); + $result = AiClient::generateTextResult($messages, $mockModel); + + $this->assertSame($expectedResult, $result); } /** @@ -277,23 +348,12 @@ public function testGenerateTextResultWithMessagePartArray(): void $messagePart2 = new MessagePart('Second part'); $messageParts = [$messagePart1, $messagePart2]; - $mockResult = $this->createMock(GenerativeAiResult::class); + $expectedResult = $this->createTestResult(); + $mockModel = $this->createMockTextGenerationModel($expectedResult); - $this->mockTextModel - ->expects($this->once()) - ->method('generateTextResult') - ->with($this->callback(function ($messages) { - return is_array($messages) && - count($messages) === 1 && - $messages[0] instanceof Message && - $messages[0]->getRole()->isUser() && - count($messages[0]->getParts()) === 2; - })) - ->willReturn($mockResult); - - $result = AiClient::generateTextResult($messageParts, $this->mockTextModel); - - $this->assertSame($mockResult, $result); + $result = AiClient::generateTextResult($messageParts, $mockModel); + + $this->assertSame($expectedResult, $result); } /** @@ -333,12 +393,9 @@ public function testGenerateResultDelegatesToTextGeneration(): void { $prompt = 'Test prompt'; $expectedResult = $this->createTestResult(); + $mockModel = $this->createMockTextGenerationModel($expectedResult); - $this->mockTextModel->expects($this->once()) - ->method('generateTextResult') - ->willReturn($expectedResult); - - $result = AiClient::generateResult($prompt, $this->mockTextModel); + $result = AiClient::generateResult($prompt, $mockModel); $this->assertSame($expectedResult, $result); } @@ -350,12 +407,9 @@ public function testGenerateResultDelegatesToImageGeneration(): void { $prompt = 'Generate image prompt'; $expectedResult = $this->createTestResult(); + $mockModel = $this->createMockImageGenerationModel($expectedResult); - $this->mockImageModel->expects($this->once()) - ->method('generateImageResult') - ->willReturn($expectedResult); - - $result = AiClient::generateResult($prompt, $this->mockImageModel); + $result = AiClient::generateResult($prompt, $mockModel); $this->assertSame($expectedResult, $result); } @@ -424,16 +478,12 @@ public function testGenerateResultWithNullModelDelegatesToPromptBuilder(): void public function testGenerateResultWithTextGenerationModel(): void { $prompt = 'Generate text content'; - $mockResult = $this->createMock(GenerativeAiResult::class); - - $this->mockTextModel - ->expects($this->once()) - ->method('generateTextResult') - ->willReturn($mockResult); + $expectedResult = $this->createTestResult(); + $mockModel = $this->createMockTextGenerationModel($expectedResult); - $result = AiClient::generateResult($prompt, $this->mockTextModel); + $result = AiClient::generateResult($prompt, $mockModel); - $this->assertSame($mockResult, $result); + $this->assertSame($expectedResult, $result); } /** @@ -442,16 +492,12 @@ public function testGenerateResultWithTextGenerationModel(): void public function testGenerateResultWithImageGenerationModel(): void { $prompt = 'Generate an image'; - $mockResult = $this->createMock(GenerativeAiResult::class); - - $this->mockImageModel - ->expects($this->once()) - ->method('generateImageResult') - ->willReturn($mockResult); + $expectedResult = $this->createTestResult(); + $mockModel = $this->createMockImageGenerationModel($expectedResult); - $result = AiClient::generateResult($prompt, $this->mockImageModel); + $result = AiClient::generateResult($prompt, $mockModel); - $this->assertSame($mockResult, $result); + $this->assertSame($expectedResult, $result); } /** @@ -510,26 +556,19 @@ public function testGenerateResultWithCapabilityValidModelAndCapability(): void { $prompt = 'Generate text'; $capability = CapabilityEnum::textGeneration(); - $mockResult = $this->createMock(GenerativeAiResult::class); - - $mockMetadata = $this->createMock(\WordPress\AiClient\Providers\Models\DTO\ModelMetadata::class); - $mockMetadata->expects($this->once()) - ->method('getSupportedCapabilities') - ->willReturn([$capability]); - - $this->mockTextModel - ->expects($this->once()) - ->method('metadata') - ->willReturn($mockMetadata); + $expectedResult = $this->createTestResult(); - $this->mockTextModel - ->expects($this->once()) - ->method('generateTextResult') - ->willReturn($mockResult); + $customMetadata = new ModelMetadata( + 'test-text-model', + 'Test Text Model', + [$capability], + [] + ); + $mockModel = $this->createMockTextGenerationModel($expectedResult, $customMetadata); - $result = AiClient::generateResultWithCapability($prompt, $capability, $this->mockTextModel); + $result = AiClient::generateResultWithCapability($prompt, $capability, $mockModel); - $this->assertSame($mockResult, $result); + $this->assertSame($expectedResult, $result); } /** @@ -539,25 +578,22 @@ public function testGenerateResultWithCapabilityUnsupportedCapability(): void { $prompt = 'Generate content'; $capability = CapabilityEnum::imageGeneration(); + $expectedResult = $this->createTestResult(); - $mockMetadata = $this->createMock(\WordPress\AiClient\Providers\Models\DTO\ModelMetadata::class); - $mockMetadata->expects($this->once()) - ->method('getSupportedCapabilities') - ->willReturn([CapabilityEnum::textGeneration()]); // Only supports text, not image - $mockMetadata->expects($this->once()) - ->method('getId') - ->willReturn('text-only-model'); - - $this->mockTextModel - ->expects($this->exactly(2)) - ->method('metadata') - ->willReturn($mockMetadata); + // Create metadata with only text generation capability + $customMetadata = new ModelMetadata( + 'text-only-model', + 'Text Only Model', + [CapabilityEnum::textGeneration()], // Only supports text, not image + [] + ); + $mockModel = $this->createMockTextGenerationModel($expectedResult, $customMetadata); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage( 'Model "text-only-model" does not support the "image_generation" capability' ); - AiClient::generateResultWithCapability($prompt, $capability, $this->mockTextModel); + AiClient::generateResultWithCapability($prompt, $capability, $mockModel); } } From 1ed0ca02a309ff785825909ddc422b7da29490ac Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Thu, 28 Aug 2025 11:40:36 +0300 Subject: [PATCH 61/69] implement comprehensive AiClient improvements and ModelConfig parameter support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit • Enhanced AiClient class-level documentation with detailed model specification approaches • Added ModelInterface|ModelConfig|null parameter support across all generation methods • Implemented intelligent parameter validation with descriptive error messages • Created shared MockModelCreationTrait for consistent test infrastructure • Added comprehensive ModelConfig parameter testing with various configurations • Implemented data providers for improved test organization and coverage • Added helper method tests using reflection for private method validation • Enhanced test assertions with better specificity and error context • Refactored mock registry setup to reduce code duplication • Updated provider registration comments for better clarity --- src/AiClient.php | 264 +++++------ tests/traits/MockModelCreationTrait.php | 226 +++++++++ tests/unit/AiClientTest.php | 602 +++++++++++++++--------- 3 files changed, 718 insertions(+), 374 deletions(-) create mode 100644 tests/traits/MockModelCreationTrait.php diff --git a/src/AiClient.php b/src/AiClient.php index 6c6aa52c..30638b47 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -7,7 +7,7 @@ use WordPress\AiClient\Builders\PromptBuilder; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; -use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; +use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; use WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts\SpeechGenerationModelInterface; use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; @@ -22,20 +22,56 @@ * - Fluent API for easy-to-read chained method calls * - Traditional API for array-based configuration (WordPress style) * - Integration with provider registry for model discovery + * - Support for three model specification approaches * * All model requirements analysis and capability matching is handled * automatically by the PromptBuilder, which provides intelligent model * discovery based on prompt content and configuration. * - * Example usage: + * ## Model Specification Approaches + * + * ### 1. Specific Model Instance + * Use a specific ModelInterface instance when you know exactly which model to use: + * ```php + * $model = $registry->getProvider('openai')->getModel('gpt-4'); + * $result = AiClient::generateTextResult('What is PHP?', $model); + * ``` + * + * ### 2. ModelConfig for Auto-Discovery + * Use ModelConfig to specify requirements and let the system discover the best model: + * ```php + * $config = new ModelConfig(); + * $config->setTemperature(0.7); + * $config->setMaxTokens(150); + * + * $result = AiClient::generateTextResult('What is PHP?', $config); + * ``` + * + * ### 3. Automatic Discovery (Default) + * Pass null or omit the parameter for intelligent model discovery based on prompt content: + * ```php + * // System analyzes prompt and selects appropriate model automatically + * $result = AiClient::generateTextResult('What is PHP?'); + * $imageResult = AiClient::generateImageResult('A sunset over mountains'); + * ``` + * + * ## Fluent API Examples * ```php * // Fluent API with automatic model discovery * $result = AiClient::prompt('Generate an image of a sunset') * ->usingTemperature(0.7) * ->generateImageResult(); * - * // Traditional API - * $result = AiClient::generateTextResult('What is PHP?'); + * // Fluent API with specific model + * $result = AiClient::prompt('What is PHP?') + * ->usingModel($specificModel) + * ->usingTemperature(0.5) + * ->generateTextResult(); + * + * // Fluent API with model configuration + * $result = AiClient::prompt('Explain quantum physics') + * ->usingModelConfig($config) + * ->generateTextResult(); * ``` * * @since n.e.x.t @@ -51,6 +87,49 @@ class AiClient */ private static ?ProviderRegistry $defaultRegistry = null; + /** + * Validates that parameter is ModelInterface, ModelConfig, or null. + * + * @param mixed $modelOrConfig The parameter to validate. + * @return void + * @throws \InvalidArgumentException If parameter is invalid type. + */ + private static function validateModelOrConfigParameter($modelOrConfig): void + { + if ( + $modelOrConfig !== null + && !$modelOrConfig instanceof ModelInterface + && !$modelOrConfig instanceof ModelConfig + ) { + throw new \InvalidArgumentException( + 'Parameter must be a ModelInterface instance (specific model), ' . + 'ModelConfig instance (for auto-discovery), or null (default auto-discovery). ' . + sprintf('Received: %s', is_object($modelOrConfig) ? get_class($modelOrConfig) : gettype($modelOrConfig)) + ); + } + } + + /** + * Configures PromptBuilder based on model/config parameter type. + * + * @param Prompt $prompt The prompt content. + * @param ModelInterface|ModelConfig|null $modelOrConfig The model or config parameter. + * @return PromptBuilder Configured prompt builder. + */ + private static function configurePromptBuilder($prompt, $modelOrConfig): PromptBuilder + { + $builder = self::prompt($prompt); + + if ($modelOrConfig instanceof ModelInterface) { + $builder->usingModel($modelOrConfig); + } elseif ($modelOrConfig instanceof ModelConfig) { + $builder->usingModelConfig($modelOrConfig); + } + // null case: use default model discovery + + return $builder; + } + /** * Gets the default provider registry instance. * @@ -63,7 +142,8 @@ public static function defaultRegistry(): ProviderRegistry if (self::$defaultRegistry === null) { $registry = new ProviderRegistry(); - // TODO: Uncomment this once provider implementation PR #39 is merged. + // Provider registration will be enabled once concrete provider implementations are available. + // This follows the pattern established in the provider registry architecture. //$registry->setHttpTransporter(HttpTransporterFactory::createTransporter()); //$registry->registerProvider(AnthropicProvider::class); //$registry->registerProvider(GoogleProvider::class); @@ -127,34 +207,39 @@ public static function prompt($prompt = null): PromptBuilder * @since n.e.x.t * * @param Prompt $prompt The prompt content. - * @param ModelInterface|null $model Optional specific model to use. + * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use, + * or model configuration for auto-discovery, + * or null for defaults. * @return GenerativeAiResult The generation result. * * @throws \InvalidArgumentException If the provided model doesn't support any known generation type. * @throws \RuntimeException If no suitable model can be found for the prompt. */ - public static function generateResult($prompt, ?ModelInterface $model = null): GenerativeAiResult + public static function generateResult($prompt, $modelOrConfig = null): GenerativeAiResult { - // If no model provided, use PromptBuilder's intelligent model discovery - if ($model === null) { - return self::prompt($prompt)->generateResult(); + self::validateModelOrConfigParameter($modelOrConfig); + + // Route to PromptBuilder for ModelConfig and null cases + if ($modelOrConfig instanceof ModelConfig || $modelOrConfig === null) { + return self::configurePromptBuilder($prompt, $modelOrConfig)->generateResult(); } - // Infer capability from model interface (priority order matters) + // Specific model provided: Infer capability from model interfaces and delegate + $model = $modelOrConfig; if ($model instanceof TextGenerationModelInterface) { - return self::generateResultWithCapability($prompt, CapabilityEnum::textGeneration(), $model); + return self::generateTextResult($prompt, $model); } if ($model instanceof ImageGenerationModelInterface) { - return self::generateResultWithCapability($prompt, CapabilityEnum::imageGeneration(), $model); + return self::generateImageResult($prompt, $model); } if ($model instanceof TextToSpeechConversionModelInterface) { - return self::generateResultWithCapability($prompt, CapabilityEnum::textToSpeechConversion(), $model); + return self::convertTextToSpeechResult($prompt, $model); } if ($model instanceof SpeechGenerationModelInterface) { - return self::generateResultWithCapability($prompt, CapabilityEnum::speechGeneration(), $model); + return self::generateSpeechResult($prompt, $model); } throw new \InvalidArgumentException( @@ -166,74 +251,6 @@ public static function generateResult($prompt, ?ModelInterface $model = null): G ); } - /** - * Generates content using a unified API with explicit capability selection. - * - * This method allows explicit capability selection for models that implement - * multiple generation interfaces. If the model doesn't support the specified - * capability, an exception is thrown. - * - * @since n.e.x.t - * - * @param Prompt $prompt The prompt content. - * @param CapabilityEnum $capability The desired generation capability. - * @param ModelInterface|null $model Optional specific model to use. - * @return GenerativeAiResult The generation result. - * - * @throws \InvalidArgumentException If the model doesn't support the specified capability. - * @throws \RuntimeException If no suitable model can be found for the prompt and capability. - */ - public static function generateResultWithCapability( - $prompt, - CapabilityEnum $capability, - ?ModelInterface $model = null - ): GenerativeAiResult { - // If no model provided, use PromptBuilder with explicit capability - if ($model === null) { - return self::prompt($prompt)->generateResult($capability); - } - - // Validate that the model supports the requested capability - $supportedCapabilities = $model->metadata()->getSupportedCapabilities(); - $supportsCapability = false; - foreach ($supportedCapabilities as $supportedCapability) { - if ($supportedCapability->equals($capability)) { - $supportsCapability = true; - break; - } - } - - if (!$supportsCapability) { - throw new \InvalidArgumentException( - sprintf( - 'Model "%s" does not support the "%s" capability', - $model->metadata()->getId(), - $capability->value - ) - ); - } - - // Route to the appropriate method based on capability - if ($capability->isTextGeneration()) { - return self::generateTextResult($prompt, $model); - } - - if ($capability->isImageGeneration()) { - return self::generateImageResult($prompt, $model); - } - - if ($capability->isTextToSpeechConversion()) { - return self::convertTextToSpeechResult($prompt, $model); - } - - if ($capability->isSpeechGeneration()) { - return self::generateSpeechResult($prompt, $model); - } - - throw new \InvalidArgumentException( - sprintf('Capability "%s" is not yet supported for generation', $capability->value) - ); - } /** * Creates a new message builder for fluent API usage. @@ -263,19 +280,18 @@ public static function message(?string $text = null) * @since n.e.x.t * * @param Prompt $prompt The prompt content. - * @param ModelInterface|null $model Optional specific model to use. + * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use, + * or model configuration for auto-discovery, + * or null for defaults. * @return GenerativeAiResult The generation result. * * @throws \InvalidArgumentException If the prompt format is invalid. * @throws \RuntimeException If no suitable model is found. */ - public static function generateTextResult($prompt, ?ModelInterface $model = null): GenerativeAiResult + public static function generateTextResult($prompt, $modelOrConfig = null): GenerativeAiResult { - $builder = self::prompt($prompt); - if ($model !== null) { - $builder->usingModel($model); - } - return $builder->generateTextResult(); + self::validateModelOrConfigParameter($modelOrConfig); + return self::configurePromptBuilder($prompt, $modelOrConfig)->generateTextResult(); } @@ -285,19 +301,18 @@ public static function generateTextResult($prompt, ?ModelInterface $model = null * @since n.e.x.t * * @param Prompt $prompt The prompt content. - * @param ModelInterface|null $model Optional specific model to use. + * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use, + * or model configuration for auto-discovery, + * or null for defaults. * @return GenerativeAiResult The generation result. * * @throws \InvalidArgumentException If the prompt format is invalid. * @throws \RuntimeException If no suitable model is found. */ - public static function generateImageResult($prompt, ?ModelInterface $model = null): GenerativeAiResult + public static function generateImageResult($prompt, $modelOrConfig = null): GenerativeAiResult { - $builder = self::prompt($prompt); - if ($model !== null) { - $builder->usingModel($model); - } - return $builder->generateImageResult(); + self::validateModelOrConfigParameter($modelOrConfig); + return self::configurePromptBuilder($prompt, $modelOrConfig)->generateImageResult(); } /** @@ -306,19 +321,18 @@ public static function generateImageResult($prompt, ?ModelInterface $model = nul * @since n.e.x.t * * @param Prompt $prompt The prompt content. - * @param ModelInterface|null $model Optional specific model to use. + * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use, + * or model configuration for auto-discovery, + * or null for defaults. * @return GenerativeAiResult The generation result. * * @throws \InvalidArgumentException If the prompt format is invalid. * @throws \RuntimeException If no suitable model is found. */ - public static function convertTextToSpeechResult($prompt, ?ModelInterface $model = null): GenerativeAiResult + public static function convertTextToSpeechResult($prompt, $modelOrConfig = null): GenerativeAiResult { - $builder = self::prompt($prompt); - if ($model !== null) { - $builder->usingModel($model); - } - return $builder->convertTextToSpeechResult(); + self::validateModelOrConfigParameter($modelOrConfig); + return self::configurePromptBuilder($prompt, $modelOrConfig)->convertTextToSpeechResult(); } /** @@ -327,47 +341,17 @@ public static function convertTextToSpeechResult($prompt, ?ModelInterface $model * @since n.e.x.t * * @param Prompt $prompt The prompt content. - * @param ModelInterface|null $model Optional specific model to use. + * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use, + * or model configuration for auto-discovery, + * or null for defaults. * @return GenerativeAiResult The generation result. * * @throws \InvalidArgumentException If the prompt format is invalid. * @throws \RuntimeException If no suitable model is found. */ - public static function generateSpeechResult($prompt, ?ModelInterface $model = null): GenerativeAiResult - { - $builder = self::prompt($prompt); - if ($model !== null) { - $builder->usingModel($model); - } - return $builder->generateSpeechResult(); - } - - - /** - * Convenience method for text generation. - * - * @since n.e.x.t - * - * @param Prompt $prompt The prompt content. - * @param ModelInterface|null $model Optional specific model to use. - * @return string The generated text. - */ - public static function generateText($prompt, ?ModelInterface $model = null): string - { - return self::generateTextResult($prompt, $model)->toText(); - } - - /** - * Convenience method for image generation. - * - * @since n.e.x.t - * - * @param Prompt $prompt The prompt content. - * @param ModelInterface|null $model Optional specific model to use. - * @return \WordPress\AiClient\Files\DTO\File The generated image file. - */ - public static function generateImage($prompt, ?ModelInterface $model = null) + public static function generateSpeechResult($prompt, $modelOrConfig = null): GenerativeAiResult { - return self::generateImageResult($prompt, $model)->toFile(); + self::validateModelOrConfigParameter($modelOrConfig); + return self::configurePromptBuilder($prompt, $modelOrConfig)->generateSpeechResult(); } } diff --git a/tests/traits/MockModelCreationTrait.php b/tests/traits/MockModelCreationTrait.php new file mode 100644 index 00000000..0f4517fe --- /dev/null +++ b/tests/traits/MockModelCreationTrait.php @@ -0,0 +1,226 @@ +createTestTextModelMetadata(); + + return new class ($metadata, $result) implements ModelInterface, TextGenerationModelInterface { + private ModelMetadata $metadata; + private GenerativeAiResult $result; + private ModelConfig $config; + + public function __construct(ModelMetadata $metadata, GenerativeAiResult $result) + { + $this->metadata = $metadata; + $this->result = $result; + $this->config = new ModelConfig(); + } + + public function metadata(): ModelMetadata + { + return $this->metadata; + } + + public function setConfig(ModelConfig $config): void + { + $this->config = $config; + } + + public function getConfig(): ModelConfig + { + return $this->config; + } + + public function generateTextResult(array $prompt): GenerativeAiResult + { + return $this->result; + } + + public function streamGenerateTextResult(array $prompt): Generator + { + yield $this->result; + } + }; + } + + /** + * Creates a mock image generation model using anonymous class. + * + * @param GenerativeAiResult $result The result to return from generation. + * @param ModelMetadata|null $metadata Optional metadata (uses default if not provided). + * @return ModelInterface&ImageGenerationModelInterface The mock model. + */ + protected function createMockImageGenerationModel( + GenerativeAiResult $result, + ?ModelMetadata $metadata = null + ): ModelInterface { + $metadata = $metadata ?? $this->createTestImageModelMetadata(); + + return new class ($metadata, $result) implements ModelInterface, ImageGenerationModelInterface { + private ModelMetadata $metadata; + private GenerativeAiResult $result; + private ModelConfig $config; + + public function __construct(ModelMetadata $metadata, GenerativeAiResult $result) + { + $this->metadata = $metadata; + $this->result = $result; + $this->config = new ModelConfig(); + } + + public function metadata(): ModelMetadata + { + return $this->metadata; + } + + public function setConfig(ModelConfig $config): void + { + $this->config = $config; + } + + public function getConfig(): ModelConfig + { + return $this->config; + } + + public function generateImageResult(array $prompt): GenerativeAiResult + { + return $this->result; + } + }; + } + + /** + * Creates a mock model that doesn't implement any generation interfaces. + * + * @param string $modelId Optional model ID for error messages. + * @return ModelInterface The mock model. + */ + protected function createMockUnsupportedModel(string $modelId = 'unsupported-model'): ModelInterface + { + $mockModel = $this->createMock(ModelInterface::class); + $mockMetadata = $this->createMock(ModelMetadata::class); + + $mockMetadata->expects($this->any()) + ->method('getId') + ->willReturn($modelId); + + $mockModel->expects($this->any()) + ->method('metadata') + ->willReturn($mockMetadata); + + return $mockModel; + } +} diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index a857ed75..b3047c3d 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -4,71 +4,31 @@ namespace WordPress\AiClient\Tests\unit; -use Generator; use InvalidArgumentException; use PHPUnit\Framework\TestCase; use RuntimeException; use WordPress\AiClient\AiClient; use WordPress\AiClient\Messages\DTO\MessagePart; -use WordPress\AiClient\Messages\DTO\ModelMessage; use WordPress\AiClient\Messages\DTO\UserMessage; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; -use WordPress\AiClient\Providers\DTO\ProviderMetadata; -use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; -use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; -use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; -use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; -use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; -use WordPress\AiClient\Results\DTO\Candidate; -use WordPress\AiClient\Results\DTO\GenerativeAiResult; -use WordPress\AiClient\Results\DTO\TokenUsage; -use WordPress\AiClient\Results\Enums\FinishReasonEnum; +use WordPress\AiClient\Tests\traits\MockModelCreationTrait; /** * @covers \WordPress\AiClient\AiClient */ class AiClientTest extends TestCase { + use MockModelCreationTrait; + protected function setUp(): void { // Set a clean registry for each test AiClient::setDefaultRegistry(new ProviderRegistry()); } - /** - * Creates a test GenerativeAiResult for testing purposes. - */ - private function createTestResult(): GenerativeAiResult - { - $candidate = new Candidate( - new ModelMessage([new MessagePart('Test response')]), - FinishReasonEnum::stop() - ); - $tokenUsage = new TokenUsage(10, 20, 30); - - $providerMetadata = new ProviderMetadata( - 'mock-provider', - 'Mock Provider', - ProviderTypeEnum::cloud() - ); - $modelMetadata = new ModelMetadata( - 'mock-model', - 'Mock Model', - [], - [] - ); - - return new GenerativeAiResult( - 'test-result-id', - [$candidate], - $tokenUsage, - $providerMetadata, - $modelMetadata - ); - } protected function tearDown(): void { @@ -76,134 +36,20 @@ protected function tearDown(): void AiClient::setDefaultRegistry(new ProviderRegistry()); } - /** - * Creates a test model metadata instance for text generation. + * Creates a mock registry that returns empty results for model discovery. * - * @return ModelMetadata + * @return ProviderRegistry The mock registry. */ - private function createTestTextModelMetadata(): ModelMetadata + private function createMockEmptyRegistry(): ProviderRegistry { - return new ModelMetadata( - 'test-text-model', - 'Test Text Model', - [CapabilityEnum::textGeneration()], - [] - ); - } - - /** - * Creates a test model metadata instance for image generation. - * - * @return ModelMetadata - */ - private function createTestImageModelMetadata(): ModelMetadata - { - return new ModelMetadata( - 'test-image-model', - 'Test Image Model', - [CapabilityEnum::imageGeneration()], - [] - ); - } - - /** - * Creates a mock text generation model using anonymous class. - * - * @param GenerativeAiResult $result The result to return from generation. - * @param ModelMetadata|null $metadata Optional metadata (uses default if not provided). - * @return ModelInterface&TextGenerationModelInterface The mock model. - */ - private function createMockTextGenerationModel( - GenerativeAiResult $result, - ?ModelMetadata $metadata = null - ): ModelInterface { - $metadata = $metadata ?? $this->createTestTextModelMetadata(); - - return new class ($metadata, $result) implements ModelInterface, TextGenerationModelInterface { - private ModelMetadata $metadata; - private GenerativeAiResult $result; - private ModelConfig $config; - - public function __construct(ModelMetadata $metadata, GenerativeAiResult $result) - { - $this->metadata = $metadata; - $this->result = $result; - $this->config = new ModelConfig(); - } - - public function metadata(): ModelMetadata - { - return $this->metadata; - } - - public function setConfig(ModelConfig $config): void - { - $this->config = $config; - } - - public function getConfig(): ModelConfig - { - return $this->config; - } - - public function generateTextResult(array $prompt): GenerativeAiResult - { - return $this->result; - } - - public function streamGenerateTextResult(array $prompt): Generator - { - yield $this->result; - } - }; - } - - /** - * Creates a mock image generation model using anonymous class. - * - * @param GenerativeAiResult $result The result to return from generation. - * @param ModelMetadata|null $metadata Optional metadata (uses default if not provided). - * @return ModelInterface&ImageGenerationModelInterface The mock model. - */ - private function createMockImageGenerationModel( - GenerativeAiResult $result, - ?ModelMetadata $metadata = null - ): ModelInterface { - $metadata = $metadata ?? $this->createTestImageModelMetadata(); - - return new class ($metadata, $result) implements ModelInterface, ImageGenerationModelInterface { - private ModelMetadata $metadata; - private GenerativeAiResult $result; - private ModelConfig $config; - - public function __construct(ModelMetadata $metadata, GenerativeAiResult $result) - { - $this->metadata = $metadata; - $this->result = $result; - $this->config = new ModelConfig(); - } - - public function metadata(): ModelMetadata - { - return $this->metadata; - } - - public function setConfig(ModelConfig $config): void - { - $this->config = $config; - } - - public function getConfig(): ModelConfig - { - return $this->config; - } + $mockRegistry = $this->createMock(ProviderRegistry::class); + $mockRegistry + ->expects($this->any()) + ->method('findModelsMetadataForSupport') + ->willReturn([]); - public function generateImageResult(array $prompt): GenerativeAiResult - { - return $this->result; - } - }; + return $mockRegistry; } /** @@ -211,13 +57,31 @@ public function generateImageResult(array $prompt): GenerativeAiResult */ public function testDefaultRegistry(): void { + // Test that default registry is created as ProviderRegistry instance $registry = AiClient::defaultRegistry(); - $this->assertInstanceOf(ProviderRegistry::class, $registry); + $this->assertInstanceOf( + ProviderRegistry::class, + $registry, + 'Default registry should be a ProviderRegistry instance' + ); + + // Test that the same instance is returned on subsequent calls + $sameRegistry = AiClient::defaultRegistry(); + $this->assertSame( + $registry, + $sameRegistry, + 'Default registry should return the same instance (singleton pattern)' + ); + // Test that setting a new registry works $newRegistry = new ProviderRegistry(); AiClient::setDefaultRegistry($newRegistry); - $this->assertSame($newRegistry, AiClient::defaultRegistry()); + $this->assertSame( + $newRegistry, + AiClient::defaultRegistry(), + 'After setting new registry, it should be returned by defaultRegistry()' + ); } /** @@ -254,10 +118,10 @@ public function testGenerateTextResultWithStringAndModel(): void public function testGenerateTextResultWithInvalidModel(): void { $prompt = 'Generate text'; - $invalidModel = $this->createMock(ModelInterface::class); + $invalidModel = $this->createMockUnsupportedModel('invalid-text-model'); $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Model "" does not support text generation.'); + $this->expectExceptionMessage('Model "invalid-text-model" does not support text generation.'); AiClient::generateTextResult($prompt, $invalidModel); } @@ -282,10 +146,10 @@ public function testGenerateImageResultWithStringAndModel(): void public function testGenerateImageResultWithInvalidModel(): void { $prompt = 'Generate image'; - $invalidModel = $this->createMock(ModelInterface::class); + $invalidModel = $this->createMockUnsupportedModel('invalid-image-model'); $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Model "" does not support image generation.'); + $this->expectExceptionMessage('Model "invalid-image-model" does not support image generation.'); AiClient::generateImageResult($prompt, $invalidModel); } @@ -420,17 +284,7 @@ public function testGenerateResultDelegatesToImageGeneration(): void public function testGenerateResultThrowsExceptionForUnsupportedModel(): void { $prompt = 'Test prompt'; - $unsupportedModel = $this->createMock(ModelInterface::class); - - // Mock the metadata to return an ID - $mockMetadata = $this->createMock(\WordPress\AiClient\Providers\Models\DTO\ModelMetadata::class); - $mockMetadata->expects($this->once()) - ->method('getId') - ->willReturn('unsupported-model'); - - $unsupportedModel->expects($this->once()) - ->method('metadata') - ->willReturn($mockMetadata); + $unsupportedModel = $this->createMockUnsupportedModel('unsupported-model'); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage( @@ -455,15 +309,8 @@ public function testGenerateResultWithNullModelDelegatesToPromptBuilder(): void { $prompt = 'Test prompt for auto-discovery'; - // Create a mock registry that returns empty results - $mockRegistry = $this->createMock(ProviderRegistry::class); - $mockRegistry - ->expects($this->once()) - ->method('findModelsMetadataForSupport') - ->willReturn([]); - // Set the mock registry as default - AiClient::setDefaultRegistry($mockRegistry); + AiClient::setDefaultRegistry($this->createMockEmptyRegistry()); // This should delegate to PromptBuilder's intelligent discovery $this->expectException(\InvalidArgumentException::class); @@ -525,75 +372,362 @@ public function testGenerateResultWithInvalidModelThrowsExceptionWithModelId(): AiClient::generateResult($prompt, $invalidModel); } + /** - * Tests generateResultWithCapability with null model delegates to PromptBuilder. + * Tests that generateResult accepts ModelConfig and delegates to PromptBuilder. */ - public function testGenerateResultWithCapabilityNullModelDelegatesToPromptBuilder(): void + public function testGenerateResultWithModelConfigDelegatesToPromptBuilder(): void + { + $prompt = 'Test prompt with config'; + $config = new ModelConfig(); + $config->setTemperature(0.8); + $config->setMaxTokens(100); + + // Set the mock registry as default + AiClient::setDefaultRegistry($this->createMockEmptyRegistry()); + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('No models found that support the required capabilities'); + + AiClient::generateResult($prompt, $config); + } + + + /** + * Tests that traditional API methods accept ModelConfig. + */ + public function testTraditionalMethodsAcceptModelConfig(): void { $prompt = 'Test prompt'; - $capability = CapabilityEnum::textGeneration(); + $config = new ModelConfig(); + $config->setTemperature(0.5); - // Create a mock registry that returns empty results - $mockRegistry = $this->createMock(ProviderRegistry::class); - $mockRegistry - ->expects($this->once()) - ->method('findModelsMetadataForSupport') - ->willReturn([]); + // Set the mock registry as default + AiClient::setDefaultRegistry($this->createMockEmptyRegistry()); + + // Test all traditional methods accept ModelConfig + $methods = [ + 'generateTextResult', + 'generateImageResult', + 'convertTextToSpeechResult', + 'generateSpeechResult' + ]; + + foreach ($methods as $method) { + try { + AiClient::$method($prompt, $config); + $this->fail("Expected InvalidArgumentException for $method"); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString('No models found that support', $e->getMessage()); + } + } + } + + /** + * Tests that invalid parameter types are rejected with proper error message. + */ + public function testInvalidParameterTypeThrowsException(): void + { + $prompt = 'Test prompt'; + $invalidParam = 'invalid_string_parameter'; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/Parameter must be a ModelInterface instance \(specific model\)/'); + $this->expectExceptionMessageMatches('/Received: string/'); + + AiClient::generateResult($prompt, $invalidParam); + } + + /** + * Data provider for invalid parameter types. + * + * @return array + */ + public function invalidParameterTypesProvider(): array + { + return [ + 'string parameter' => ['invalid_string', 'string'], + 'integer parameter' => [123, 'integer'], + 'array parameter' => [['invalid_array'], 'array'], + 'object parameter' => [new \stdClass(), 'stdClass'], + 'boolean parameter' => [true, 'boolean'], + ]; + } + + /** + * Data provider for AiClient methods that accept model/config parameters. + * + * @return array + */ + public function aiClientMethodsProvider(): array + { + return [ + 'generateResult' => ['generateResult'], + 'generateTextResult' => ['generateTextResult'], + 'generateImageResult' => ['generateImageResult'], + 'convertTextToSpeechResult' => ['convertTextToSpeechResult'], + 'generateSpeechResult' => ['generateSpeechResult'], + ]; + } + + /** + * Tests that all methods reject invalid parameter types consistently. + * + * @dataProvider invalidParameterTypesProvider + * @param mixed $invalidParam + */ + public function testAllMethodsRejectInvalidParameterTypes($invalidParam, string $expectedType): void + { + $prompt = 'Test prompt'; + $methods = $this->aiClientMethodsProvider(); + + foreach ($methods as [$method]) { + try { + AiClient::$method($prompt, $invalidParam); + $this->fail("Expected InvalidArgumentException for $method with $expectedType"); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString( + 'Parameter must be a ModelInterface instance (specific model)', + $e->getMessage(), + "Method $method should reject invalid parameter type: $expectedType" + ); + $this->assertStringContainsString( + "Received: $expectedType", + $e->getMessage(), + "Method $method should include received type in error message" + ); + } + } + } + + /** + * Tests that all methods accept null parameter (default auto-discovery). + * + * @dataProvider aiClientMethodsProvider + */ + public function testAllMethodsAcceptNullParameter(string $method): void + { + $prompt = 'Test prompt for null parameter'; + + // Set the mock registry as default + AiClient::setDefaultRegistry($this->createMockEmptyRegistry()); + + try { + AiClient::$method($prompt, null); + $this->fail("Expected InvalidArgumentException for $method with null (no providers)"); + } catch (\InvalidArgumentException $e) { + // Should delegate to PromptBuilder and fail due to no providers + $this->assertStringContainsString( + 'No models found that support', + $e->getMessage(), + "Method $method should accept null and delegate to PromptBuilder" + ); + } + } + + /** + * Tests ModelConfig with various parameter combinations. + */ + public function testModelConfigWithVariousParameters(): void + { + // Set the mock registry as default + AiClient::setDefaultRegistry($this->createMockEmptyRegistry()); + + // Test different ModelConfig configurations + $configurations = [ + // Basic temperature setting + function () { + $config = new ModelConfig(); + $config->setTemperature(0.7); + return $config; + }, + // Max tokens setting + function () { + $config = new ModelConfig(); + $config->setMaxTokens(500); + return $config; + }, + // Combined settings + function () { + $config = new ModelConfig(); + $config->setTemperature(0.5); + $config->setMaxTokens(200); + return $config; + }, + ]; + + $prompt = 'Test prompt with various configs'; + + foreach ($configurations as $index => $configFunction) { + $config = $configFunction(); + + try { + AiClient::generateResult($prompt, $config); + $this->fail("Expected InvalidArgumentException for configuration $index"); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString( + 'No models found that support the required capabilities', + $e->getMessage(), + "Configuration $index should delegate to PromptBuilder properly" + ); + } + } + } + + /** + * Tests empty ModelConfig parameter. + */ + public function testEmptyModelConfig(): void + { + $prompt = 'Test with empty config'; + $emptyConfig = new ModelConfig(); // Set the mock registry as default - AiClient::setDefaultRegistry($mockRegistry); + AiClient::setDefaultRegistry($this->createMockEmptyRegistry()); $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('No models found that support the required capabilities'); - AiClient::generateResultWithCapability($prompt, $capability); + AiClient::generateResult($prompt, $emptyConfig); } /** - * Tests generateResultWithCapability with valid model and capability. + * Tests that ModelConfig is properly passed to PromptBuilder methods. */ - public function testGenerateResultWithCapabilityValidModelAndCapability(): void + public function testModelConfigPassedToAllMethods(): void { - $prompt = 'Generate text'; - $capability = CapabilityEnum::textGeneration(); - $expectedResult = $this->createTestResult(); + $prompt = 'Test prompt'; + $config = new ModelConfig(); + $config->setTemperature(0.8); - $customMetadata = new ModelMetadata( - 'test-text-model', - 'Test Text Model', - [$capability], - [] - ); - $mockModel = $this->createMockTextGenerationModel($expectedResult, $customMetadata); + // Set the mock registry as default + AiClient::setDefaultRegistry($this->createMockEmptyRegistry()); + + $methods = [ + 'generateResult', + 'generateTextResult', + 'generateImageResult', + 'convertTextToSpeechResult', + 'generateSpeechResult' + ]; + + foreach ($methods as $method) { + try { + AiClient::$method($prompt, $config); + $this->fail("Expected InvalidArgumentException for $method with ModelConfig"); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString( + 'No models found that support', + $e->getMessage(), + "Method $method should accept ModelConfig and delegate to PromptBuilder" + ); + } + } + } - $result = AiClient::generateResultWithCapability($prompt, $capability, $mockModel); + /** + * Tests validateModelOrConfigParameter helper method via reflection. + */ + public function testValidateModelOrConfigParameterHelper(): void + { + $reflection = new \ReflectionClass(AiClient::class); + $method = $reflection->getMethod('validateModelOrConfigParameter'); + $method->setAccessible(true); + + // Test valid parameters (should not throw) + $validParams = [ + null, + $this->createMockTextGenerationModel($this->createTestResult()), + new ModelConfig(), + ]; + + foreach ($validParams as $param) { + // Valid parameters should not throw exceptions + $method->invoke(null, $param); + // If we reach here, no exception was thrown (which is what we expect) + $this->assertTrue(true, 'Valid parameter should not throw exception'); + } + + // Test invalid parameters (should throw) + $invalidParams = [ + 'string', + 123, + [], + new \stdClass(), + true, + ]; + + foreach ($invalidParams as $param) { + try { + $method->invoke(null, $param); + $this->fail('Invalid parameter should throw exception'); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString( + 'Parameter must be a ModelInterface instance (specific model)', + $e->getMessage() + ); + } + } + } - $this->assertSame($expectedResult, $result); + /** + * Tests configurePromptBuilder helper method via reflection. + */ + public function testConfigurePromptBuilderHelper(): void + { + $reflection = new \ReflectionClass(AiClient::class); + $method = $reflection->getMethod('configurePromptBuilder'); + $method->setAccessible(true); + + $prompt = 'Test prompt'; + + // Test with null model (default discovery) + $builder = $method->invoke(null, $prompt, null); + $this->assertInstanceOf(\WordPress\AiClient\Builders\PromptBuilder::class, $builder); + + // Test with ModelConfig + $config = new ModelConfig(); + $config->setTemperature(0.8); + + $builderWithConfig = $method->invoke(null, $prompt, $config); + $this->assertInstanceOf(\WordPress\AiClient\Builders\PromptBuilder::class, $builderWithConfig); + + // Test with ModelInterface + $model = $this->createMockTextGenerationModel($this->createTestResult()); + + $builderWithModel = $method->invoke(null, $prompt, $model); + $this->assertInstanceOf(\WordPress\AiClient\Builders\PromptBuilder::class, $builderWithModel); } /** - * Tests generateResultWithCapability with model that doesn't support capability. + * Tests that validation helper is properly integrated in public methods. */ - public function testGenerateResultWithCapabilityUnsupportedCapability(): void + public function testValidationHelperIntegration(): void { - $prompt = 'Generate content'; - $capability = CapabilityEnum::imageGeneration(); - $expectedResult = $this->createTestResult(); + $prompt = 'Integration test prompt'; + + // Test that validation is called for invalid parameters + try { + $invalidParam = 'invalid'; + AiClient::generateResult($prompt, $invalidParam); + $this->fail('Should have thrown InvalidArgumentException'); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString('Parameter must be a ModelInterface', $e->getMessage()); + } + } - // Create metadata with only text generation capability - $customMetadata = new ModelMetadata( - 'text-only-model', - 'Text Only Model', - [CapabilityEnum::textGeneration()], // Only supports text, not image - [] - ); - $mockModel = $this->createMockTextGenerationModel($expectedResult, $customMetadata); + /** + * Tests that configurePromptBuilder helper is properly integrated. + */ + public function testConfigurePromptBuilderHelperIntegration(): void + { + $prompt = 'Integration test prompt'; - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Model "text-only-model" does not support the "image_generation" capability' - ); + // Test that configurePromptBuilder is called with null + AiClient::setDefaultRegistry($this->createMockEmptyRegistry()); - AiClient::generateResultWithCapability($prompt, $capability, $mockModel); + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessageMatches('/No models found that support/'); + AiClient::generateResult($prompt, null); } } From f4b3174e77be2ba8d8c58ce9bbef25062ec67499 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Thu, 28 Aug 2025 17:02:20 +0300 Subject: [PATCH 62/69] docs: update outdated PromptBuilder integration comment --- src/AiClient.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index 30638b47..cdaaec4f 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -183,9 +183,9 @@ public static function isConfigured(ProviderAvailabilityInterface $availability) /** * Creates a new prompt builder for fluent API usage. * - * This method will return an actual PromptBuilder instance once PR #49 is merged. - * The traditional API methods in this class will then delegate to PromptBuilder - * rather than implementing their own generation logic. + * Returns a PromptBuilder instance configured with the default registry. + * The traditional API methods in this class delegate to PromptBuilder + * for all generation logic. * * @since n.e.x.t * From ad23c5921c11210309ec9c4e73692b136bc1ffdc Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Thu, 28 Aug 2025 17:32:53 +0300 Subject: [PATCH 63/69] implement dependency injection pattern for registry management --- src/AiClient.php | 57 ++++++++++++++++------------------ tests/unit/AiClientTest.php | 62 ++++++++++--------------------------- 2 files changed, 43 insertions(+), 76 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index cdaaec4f..36115e49 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -114,11 +114,12 @@ private static function validateModelOrConfigParameter($modelOrConfig): void * * @param Prompt $prompt The prompt content. * @param ModelInterface|ModelConfig|null $modelOrConfig The model or config parameter. + * @param ProviderRegistry|null $registry Optional custom registry to use. * @return PromptBuilder Configured prompt builder. */ - private static function configurePromptBuilder($prompt, $modelOrConfig): PromptBuilder + private static function configurePromptBuilder($prompt, $modelOrConfig, ?ProviderRegistry $registry = null): PromptBuilder { - $builder = self::prompt($prompt); + $builder = self::prompt($prompt, $registry); if ($modelOrConfig instanceof ModelInterface) { $builder->usingModel($modelOrConfig); @@ -155,18 +156,6 @@ public static function defaultRegistry(): ProviderRegistry return self::$defaultRegistry; } - /** - * Sets the default provider registry instance. - * - * @since n.e.x.t - * - * @param ProviderRegistry $registry The provider registry to set as default. - */ - public static function setDefaultRegistry(ProviderRegistry $registry): void - { - self::$defaultRegistry = $registry; - } - /** * Checks if a provider is configured and available for use. * @@ -183,18 +172,19 @@ public static function isConfigured(ProviderAvailabilityInterface $availability) /** * Creates a new prompt builder for fluent API usage. * - * Returns a PromptBuilder instance configured with the default registry. + * Returns a PromptBuilder instance configured with the specified or default registry. * The traditional API methods in this class delegate to PromptBuilder * for all generation logic. * * @since n.e.x.t * * @param Prompt $prompt Optional initial prompt content. + * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. * @return PromptBuilder The prompt builder instance. */ - public static function prompt($prompt = null): PromptBuilder + public static function prompt($prompt = null, ?ProviderRegistry $registry = null): PromptBuilder { - return new PromptBuilder(self::defaultRegistry(), $prompt); + return new PromptBuilder($registry ?? self::defaultRegistry(), $prompt); } /** @@ -210,36 +200,37 @@ public static function prompt($prompt = null): PromptBuilder * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use, * or model configuration for auto-discovery, * or null for defaults. + * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. * @return GenerativeAiResult The generation result. * * @throws \InvalidArgumentException If the provided model doesn't support any known generation type. * @throws \RuntimeException If no suitable model can be found for the prompt. */ - public static function generateResult($prompt, $modelOrConfig = null): GenerativeAiResult + public static function generateResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult { self::validateModelOrConfigParameter($modelOrConfig); // Route to PromptBuilder for ModelConfig and null cases if ($modelOrConfig instanceof ModelConfig || $modelOrConfig === null) { - return self::configurePromptBuilder($prompt, $modelOrConfig)->generateResult(); + return self::configurePromptBuilder($prompt, $modelOrConfig, $registry)->generateResult(); } // Specific model provided: Infer capability from model interfaces and delegate $model = $modelOrConfig; if ($model instanceof TextGenerationModelInterface) { - return self::generateTextResult($prompt, $model); + return self::generateTextResult($prompt, $model, $registry); } if ($model instanceof ImageGenerationModelInterface) { - return self::generateImageResult($prompt, $model); + return self::generateImageResult($prompt, $model, $registry); } if ($model instanceof TextToSpeechConversionModelInterface) { - return self::convertTextToSpeechResult($prompt, $model); + return self::convertTextToSpeechResult($prompt, $model, $registry); } if ($model instanceof SpeechGenerationModelInterface) { - return self::generateSpeechResult($prompt, $model); + return self::generateSpeechResult($prompt, $model, $registry); } throw new \InvalidArgumentException( @@ -283,15 +274,16 @@ public static function message(?string $text = null) * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use, * or model configuration for auto-discovery, * or null for defaults. + * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. * @return GenerativeAiResult The generation result. * * @throws \InvalidArgumentException If the prompt format is invalid. * @throws \RuntimeException If no suitable model is found. */ - public static function generateTextResult($prompt, $modelOrConfig = null): GenerativeAiResult + public static function generateTextResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult { self::validateModelOrConfigParameter($modelOrConfig); - return self::configurePromptBuilder($prompt, $modelOrConfig)->generateTextResult(); + return self::configurePromptBuilder($prompt, $modelOrConfig, $registry)->generateTextResult(); } @@ -304,15 +296,16 @@ public static function generateTextResult($prompt, $modelOrConfig = null): Gener * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use, * or model configuration for auto-discovery, * or null for defaults. + * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. * @return GenerativeAiResult The generation result. * * @throws \InvalidArgumentException If the prompt format is invalid. * @throws \RuntimeException If no suitable model is found. */ - public static function generateImageResult($prompt, $modelOrConfig = null): GenerativeAiResult + public static function generateImageResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult { self::validateModelOrConfigParameter($modelOrConfig); - return self::configurePromptBuilder($prompt, $modelOrConfig)->generateImageResult(); + return self::configurePromptBuilder($prompt, $modelOrConfig, $registry)->generateImageResult(); } /** @@ -324,15 +317,16 @@ public static function generateImageResult($prompt, $modelOrConfig = null): Gene * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use, * or model configuration for auto-discovery, * or null for defaults. + * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. * @return GenerativeAiResult The generation result. * * @throws \InvalidArgumentException If the prompt format is invalid. * @throws \RuntimeException If no suitable model is found. */ - public static function convertTextToSpeechResult($prompt, $modelOrConfig = null): GenerativeAiResult + public static function convertTextToSpeechResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult { self::validateModelOrConfigParameter($modelOrConfig); - return self::configurePromptBuilder($prompt, $modelOrConfig)->convertTextToSpeechResult(); + return self::configurePromptBuilder($prompt, $modelOrConfig, $registry)->convertTextToSpeechResult(); } /** @@ -344,14 +338,15 @@ public static function convertTextToSpeechResult($prompt, $modelOrConfig = null) * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use, * or model configuration for auto-discovery, * or null for defaults. + * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. * @return GenerativeAiResult The generation result. * * @throws \InvalidArgumentException If the prompt format is invalid. * @throws \RuntimeException If no suitable model is found. */ - public static function generateSpeechResult($prompt, $modelOrConfig = null): GenerativeAiResult + public static function generateSpeechResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult { self::validateModelOrConfigParameter($modelOrConfig); - return self::configurePromptBuilder($prompt, $modelOrConfig)->generateSpeechResult(); + return self::configurePromptBuilder($prompt, $modelOrConfig, $registry)->generateSpeechResult(); } } diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index b3047c3d..8c0949d3 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -25,15 +25,13 @@ class AiClientTest extends TestCase protected function setUp(): void { - // Set a clean registry for each test - AiClient::setDefaultRegistry(new ProviderRegistry()); + // Tests use dependency injection - registry instances passed directly to methods } protected function tearDown(): void { - // Reset the default registry - AiClient::setDefaultRegistry(new ProviderRegistry()); + // Tests use dependency injection - registry instances passed directly to methods } /** @@ -53,7 +51,7 @@ private function createMockEmptyRegistry(): ProviderRegistry } /** - * Tests default registry getter and setter. + * Tests default registry getter. */ public function testDefaultRegistry(): void { @@ -73,15 +71,7 @@ public function testDefaultRegistry(): void 'Default registry should return the same instance (singleton pattern)' ); - // Test that setting a new registry works - $newRegistry = new ProviderRegistry(); - AiClient::setDefaultRegistry($newRegistry); - - $this->assertSame( - $newRegistry, - AiClient::defaultRegistry(), - 'After setting new registry, it should be returned by defaultRegistry()' - ); + // Registry dependency injection is tested by passing custom registries to individual methods } /** @@ -309,14 +299,11 @@ public function testGenerateResultWithNullModelDelegatesToPromptBuilder(): void { $prompt = 'Test prompt for auto-discovery'; - // Set the mock registry as default - AiClient::setDefaultRegistry($this->createMockEmptyRegistry()); - // This should delegate to PromptBuilder's intelligent discovery $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('No models found that support the required capabilities'); - AiClient::generateResult($prompt); + AiClient::generateResult($prompt, null, $this->createMockEmptyRegistry()); } /** @@ -383,13 +370,10 @@ public function testGenerateResultWithModelConfigDelegatesToPromptBuilder(): voi $config->setTemperature(0.8); $config->setMaxTokens(100); - // Set the mock registry as default - AiClient::setDefaultRegistry($this->createMockEmptyRegistry()); - $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('No models found that support the required capabilities'); - AiClient::generateResult($prompt, $config); + AiClient::generateResult($prompt, $config, $this->createMockEmptyRegistry()); } @@ -402,9 +386,6 @@ public function testTraditionalMethodsAcceptModelConfig(): void $config = new ModelConfig(); $config->setTemperature(0.5); - // Set the mock registry as default - AiClient::setDefaultRegistry($this->createMockEmptyRegistry()); - // Test all traditional methods accept ModelConfig $methods = [ 'generateTextResult', @@ -412,10 +393,12 @@ public function testTraditionalMethodsAcceptModelConfig(): void 'convertTextToSpeechResult', 'generateSpeechResult' ]; + + $mockRegistry = $this->createMockEmptyRegistry(); foreach ($methods as $method) { try { - AiClient::$method($prompt, $config); + AiClient::$method($prompt, $config, $mockRegistry); $this->fail("Expected InvalidArgumentException for $method"); } catch (\InvalidArgumentException $e) { $this->assertStringContainsString('No models found that support', $e->getMessage()); @@ -509,11 +492,8 @@ public function testAllMethodsAcceptNullParameter(string $method): void { $prompt = 'Test prompt for null parameter'; - // Set the mock registry as default - AiClient::setDefaultRegistry($this->createMockEmptyRegistry()); - try { - AiClient::$method($prompt, null); + AiClient::$method($prompt, null, $this->createMockEmptyRegistry()); $this->fail("Expected InvalidArgumentException for $method with null (no providers)"); } catch (\InvalidArgumentException $e) { // Should delegate to PromptBuilder and fail due to no providers @@ -530,10 +510,8 @@ public function testAllMethodsAcceptNullParameter(string $method): void */ public function testModelConfigWithVariousParameters(): void { - // Set the mock registry as default - AiClient::setDefaultRegistry($this->createMockEmptyRegistry()); - // Test different ModelConfig configurations + $mockRegistry = $this->createMockEmptyRegistry(); $configurations = [ // Basic temperature setting function () { @@ -562,7 +540,7 @@ function () { $config = $configFunction(); try { - AiClient::generateResult($prompt, $config); + AiClient::generateResult($prompt, $config, $mockRegistry); $this->fail("Expected InvalidArgumentException for configuration $index"); } catch (\InvalidArgumentException $e) { $this->assertStringContainsString( @@ -582,13 +560,10 @@ public function testEmptyModelConfig(): void $prompt = 'Test with empty config'; $emptyConfig = new ModelConfig(); - // Set the mock registry as default - AiClient::setDefaultRegistry($this->createMockEmptyRegistry()); - $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessage('No models found that support the required capabilities'); - AiClient::generateResult($prompt, $emptyConfig); + AiClient::generateResult($prompt, $emptyConfig, $this->createMockEmptyRegistry()); } /** @@ -600,9 +575,6 @@ public function testModelConfigPassedToAllMethods(): void $config = new ModelConfig(); $config->setTemperature(0.8); - // Set the mock registry as default - AiClient::setDefaultRegistry($this->createMockEmptyRegistry()); - $methods = [ 'generateResult', 'generateTextResult', @@ -610,10 +582,12 @@ public function testModelConfigPassedToAllMethods(): void 'convertTextToSpeechResult', 'generateSpeechResult' ]; + + $mockRegistry = $this->createMockEmptyRegistry(); foreach ($methods as $method) { try { - AiClient::$method($prompt, $config); + AiClient::$method($prompt, $config, $mockRegistry); $this->fail("Expected InvalidArgumentException for $method with ModelConfig"); } catch (\InvalidArgumentException $e) { $this->assertStringContainsString( @@ -724,10 +698,8 @@ public function testConfigurePromptBuilderHelperIntegration(): void $prompt = 'Integration test prompt'; // Test that configurePromptBuilder is called with null - AiClient::setDefaultRegistry($this->createMockEmptyRegistry()); - $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessageMatches('/No models found that support/'); - AiClient::generateResult($prompt, null); + AiClient::generateResult($prompt, null, $this->createMockEmptyRegistry()); } } From 9f7424f5e061ef4bd8b5104b57a6f86169decafc Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Thu, 28 Aug 2025 17:40:45 +0300 Subject: [PATCH 64/69] fix code style issues: line length and whitespace --- src/AiClient.php | 36 ++++++++++++++++++++++++++++++------ tests/unit/AiClientTest.php | 4 ++-- 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index 36115e49..c89ad3ed 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -117,7 +117,11 @@ private static function validateModelOrConfigParameter($modelOrConfig): void * @param ProviderRegistry|null $registry Optional custom registry to use. * @return PromptBuilder Configured prompt builder. */ - private static function configurePromptBuilder($prompt, $modelOrConfig, ?ProviderRegistry $registry = null): PromptBuilder + private static function configurePromptBuilder( + $prompt, + $modelOrConfig, + ?ProviderRegistry $registry = null + ): PromptBuilder { $builder = self::prompt($prompt, $registry); @@ -206,7 +210,11 @@ public static function prompt($prompt = null, ?ProviderRegistry $registry = null * @throws \InvalidArgumentException If the provided model doesn't support any known generation type. * @throws \RuntimeException If no suitable model can be found for the prompt. */ - public static function generateResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult + public static function generateResult( + $prompt, + $modelOrConfig = null, + ?ProviderRegistry $registry = null + ): GenerativeAiResult { self::validateModelOrConfigParameter($modelOrConfig); @@ -280,7 +288,11 @@ public static function message(?string $text = null) * @throws \InvalidArgumentException If the prompt format is invalid. * @throws \RuntimeException If no suitable model is found. */ - public static function generateTextResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult + public static function generateTextResult( + $prompt, + $modelOrConfig = null, + ?ProviderRegistry $registry = null + ): GenerativeAiResult { self::validateModelOrConfigParameter($modelOrConfig); return self::configurePromptBuilder($prompt, $modelOrConfig, $registry)->generateTextResult(); @@ -302,7 +314,11 @@ public static function generateTextResult($prompt, $modelOrConfig = null, ?Provi * @throws \InvalidArgumentException If the prompt format is invalid. * @throws \RuntimeException If no suitable model is found. */ - public static function generateImageResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult + public static function generateImageResult( + $prompt, + $modelOrConfig = null, + ?ProviderRegistry $registry = null + ): GenerativeAiResult { self::validateModelOrConfigParameter($modelOrConfig); return self::configurePromptBuilder($prompt, $modelOrConfig, $registry)->generateImageResult(); @@ -323,7 +339,11 @@ public static function generateImageResult($prompt, $modelOrConfig = null, ?Prov * @throws \InvalidArgumentException If the prompt format is invalid. * @throws \RuntimeException If no suitable model is found. */ - public static function convertTextToSpeechResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult + public static function convertTextToSpeechResult( + $prompt, + $modelOrConfig = null, + ?ProviderRegistry $registry = null + ): GenerativeAiResult { self::validateModelOrConfigParameter($modelOrConfig); return self::configurePromptBuilder($prompt, $modelOrConfig, $registry)->convertTextToSpeechResult(); @@ -344,7 +364,11 @@ public static function convertTextToSpeechResult($prompt, $modelOrConfig = null, * @throws \InvalidArgumentException If the prompt format is invalid. * @throws \RuntimeException If no suitable model is found. */ - public static function generateSpeechResult($prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null): GenerativeAiResult + public static function generateSpeechResult( + $prompt, + $modelOrConfig = null, + ?ProviderRegistry $registry = null + ): GenerativeAiResult { self::validateModelOrConfigParameter($modelOrConfig); return self::configurePromptBuilder($prompt, $modelOrConfig, $registry)->generateSpeechResult(); diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index 8c0949d3..79275d7a 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -393,7 +393,7 @@ public function testTraditionalMethodsAcceptModelConfig(): void 'convertTextToSpeechResult', 'generateSpeechResult' ]; - + $mockRegistry = $this->createMockEmptyRegistry(); foreach ($methods as $method) { @@ -582,7 +582,7 @@ public function testModelConfigPassedToAllMethods(): void 'convertTextToSpeechResult', 'generateSpeechResult' ]; - + $mockRegistry = $this->createMockEmptyRegistry(); foreach ($methods as $method) { From 0130294ff0b221a27db79fd909f581967e2369a5 Mon Sep 17 00:00:00 2001 From: Mohamed Khaled Date: Thu, 28 Aug 2025 17:44:38 +0300 Subject: [PATCH 65/69] fix function declaration brace formatting --- src/AiClient.php | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index c89ad3ed..a8aeb274 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -121,8 +121,7 @@ private static function configurePromptBuilder( $prompt, $modelOrConfig, ?ProviderRegistry $registry = null - ): PromptBuilder - { + ): PromptBuilder { $builder = self::prompt($prompt, $registry); if ($modelOrConfig instanceof ModelInterface) { @@ -214,8 +213,7 @@ public static function generateResult( $prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null - ): GenerativeAiResult - { + ): GenerativeAiResult { self::validateModelOrConfigParameter($modelOrConfig); // Route to PromptBuilder for ModelConfig and null cases @@ -292,8 +290,7 @@ public static function generateTextResult( $prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null - ): GenerativeAiResult - { + ): GenerativeAiResult { self::validateModelOrConfigParameter($modelOrConfig); return self::configurePromptBuilder($prompt, $modelOrConfig, $registry)->generateTextResult(); } @@ -318,8 +315,7 @@ public static function generateImageResult( $prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null - ): GenerativeAiResult - { + ): GenerativeAiResult { self::validateModelOrConfigParameter($modelOrConfig); return self::configurePromptBuilder($prompt, $modelOrConfig, $registry)->generateImageResult(); } @@ -343,8 +339,7 @@ public static function convertTextToSpeechResult( $prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null - ): GenerativeAiResult - { + ): GenerativeAiResult { self::validateModelOrConfigParameter($modelOrConfig); return self::configurePromptBuilder($prompt, $modelOrConfig, $registry)->convertTextToSpeechResult(); } @@ -368,8 +363,7 @@ public static function generateSpeechResult( $prompt, $modelOrConfig = null, ?ProviderRegistry $registry = null - ): GenerativeAiResult - { + ): GenerativeAiResult { self::validateModelOrConfigParameter($modelOrConfig); return self::configurePromptBuilder($prompt, $modelOrConfig, $registry)->generateSpeechResult(); } From 4e86daaf55fb7a3e62a8a80eee3c033bc2989495 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 28 Aug 2025 13:39:52 -0700 Subject: [PATCH 66/69] refactor: renames method to getConfiguredPromptBuilder --- src/AiClient.php | 104 +++++++++++++++++++++++------------------------ 1 file changed, 52 insertions(+), 52 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index a8aeb274..f733c63e 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -87,53 +87,6 @@ class AiClient */ private static ?ProviderRegistry $defaultRegistry = null; - /** - * Validates that parameter is ModelInterface, ModelConfig, or null. - * - * @param mixed $modelOrConfig The parameter to validate. - * @return void - * @throws \InvalidArgumentException If parameter is invalid type. - */ - private static function validateModelOrConfigParameter($modelOrConfig): void - { - if ( - $modelOrConfig !== null - && !$modelOrConfig instanceof ModelInterface - && !$modelOrConfig instanceof ModelConfig - ) { - throw new \InvalidArgumentException( - 'Parameter must be a ModelInterface instance (specific model), ' . - 'ModelConfig instance (for auto-discovery), or null (default auto-discovery). ' . - sprintf('Received: %s', is_object($modelOrConfig) ? get_class($modelOrConfig) : gettype($modelOrConfig)) - ); - } - } - - /** - * Configures PromptBuilder based on model/config parameter type. - * - * @param Prompt $prompt The prompt content. - * @param ModelInterface|ModelConfig|null $modelOrConfig The model or config parameter. - * @param ProviderRegistry|null $registry Optional custom registry to use. - * @return PromptBuilder Configured prompt builder. - */ - private static function configurePromptBuilder( - $prompt, - $modelOrConfig, - ?ProviderRegistry $registry = null - ): PromptBuilder { - $builder = self::prompt($prompt, $registry); - - if ($modelOrConfig instanceof ModelInterface) { - $builder->usingModel($modelOrConfig); - } elseif ($modelOrConfig instanceof ModelConfig) { - $builder->usingModelConfig($modelOrConfig); - } - // null case: use default model discovery - - return $builder; - } - /** * Gets the default provider registry instance. * @@ -218,7 +171,7 @@ public static function generateResult( // Route to PromptBuilder for ModelConfig and null cases if ($modelOrConfig instanceof ModelConfig || $modelOrConfig === null) { - return self::configurePromptBuilder($prompt, $modelOrConfig, $registry)->generateResult(); + return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateResult(); } // Specific model provided: Infer capability from model interfaces and delegate @@ -292,7 +245,7 @@ public static function generateTextResult( ?ProviderRegistry $registry = null ): GenerativeAiResult { self::validateModelOrConfigParameter($modelOrConfig); - return self::configurePromptBuilder($prompt, $modelOrConfig, $registry)->generateTextResult(); + return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateTextResult(); } @@ -317,7 +270,7 @@ public static function generateImageResult( ?ProviderRegistry $registry = null ): GenerativeAiResult { self::validateModelOrConfigParameter($modelOrConfig); - return self::configurePromptBuilder($prompt, $modelOrConfig, $registry)->generateImageResult(); + return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateImageResult(); } /** @@ -341,7 +294,7 @@ public static function convertTextToSpeechResult( ?ProviderRegistry $registry = null ): GenerativeAiResult { self::validateModelOrConfigParameter($modelOrConfig); - return self::configurePromptBuilder($prompt, $modelOrConfig, $registry)->convertTextToSpeechResult(); + return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->convertTextToSpeechResult(); } /** @@ -365,6 +318,53 @@ public static function generateSpeechResult( ?ProviderRegistry $registry = null ): GenerativeAiResult { self::validateModelOrConfigParameter($modelOrConfig); - return self::configurePromptBuilder($prompt, $modelOrConfig, $registry)->generateSpeechResult(); + return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateSpeechResult(); + } + + /** + * Validates that parameter is ModelInterface, ModelConfig, or null. + * + * @param mixed $modelOrConfig The parameter to validate. + * @return void + * @throws \InvalidArgumentException If parameter is invalid type. + */ + private static function validateModelOrConfigParameter($modelOrConfig): void + { + if ( + $modelOrConfig !== null + && !$modelOrConfig instanceof ModelInterface + && !$modelOrConfig instanceof ModelConfig + ) { + throw new \InvalidArgumentException( + 'Parameter must be a ModelInterface instance (specific model), ' . + 'ModelConfig instance (for auto-discovery), or null (default auto-discovery). ' . + sprintf('Received: %s', is_object($modelOrConfig) ? get_class($modelOrConfig) : gettype($modelOrConfig)) + ); + } + } + + /** + * Configures PromptBuilder based on model/config parameter type. + * + * @param Prompt $prompt The prompt content. + * @param ModelInterface|ModelConfig|null $modelOrConfig The model or config parameter. + * @param ProviderRegistry|null $registry Optional custom registry to use. + * @return PromptBuilder Configured prompt builder. + */ + private static function getConfiguredPromptBuilder( + $prompt, + $modelOrConfig, + ?ProviderRegistry $registry = null + ): PromptBuilder { + $builder = self::prompt($prompt, $registry); + + if ($modelOrConfig instanceof ModelInterface) { + $builder->usingModel($modelOrConfig); + } elseif ($modelOrConfig instanceof ModelConfig) { + $builder->usingModelConfig($modelOrConfig); + } + // null case: use default model discovery + + return $builder; } } From 7d007d163c907369d58671d56f853ad2687f8c43 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 28 Aug 2025 13:47:52 -0700 Subject: [PATCH 67/69] test: removes getConfiguredPromptBuilder tests --- tests/unit/AiClientTest.php | 35 +++-------------------------------- 1 file changed, 3 insertions(+), 32 deletions(-) diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index 79275d7a..ad886cc2 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -644,35 +644,6 @@ public function testValidateModelOrConfigParameterHelper(): void } } - /** - * Tests configurePromptBuilder helper method via reflection. - */ - public function testConfigurePromptBuilderHelper(): void - { - $reflection = new \ReflectionClass(AiClient::class); - $method = $reflection->getMethod('configurePromptBuilder'); - $method->setAccessible(true); - - $prompt = 'Test prompt'; - - // Test with null model (default discovery) - $builder = $method->invoke(null, $prompt, null); - $this->assertInstanceOf(\WordPress\AiClient\Builders\PromptBuilder::class, $builder); - - // Test with ModelConfig - $config = new ModelConfig(); - $config->setTemperature(0.8); - - $builderWithConfig = $method->invoke(null, $prompt, $config); - $this->assertInstanceOf(\WordPress\AiClient\Builders\PromptBuilder::class, $builderWithConfig); - - // Test with ModelInterface - $model = $this->createMockTextGenerationModel($this->createTestResult()); - - $builderWithModel = $method->invoke(null, $prompt, $model); - $this->assertInstanceOf(\WordPress\AiClient\Builders\PromptBuilder::class, $builderWithModel); - } - /** * Tests that validation helper is properly integrated in public methods. */ @@ -691,13 +662,13 @@ public function testValidationHelperIntegration(): void } /** - * Tests that configurePromptBuilder helper is properly integrated. + * Tests that getConfiguredPromptBuilder helper is properly integrated. */ - public function testConfigurePromptBuilderHelperIntegration(): void + public function testGetConfiguredPromptBuilderHelperIntegration(): void { $prompt = 'Integration test prompt'; - // Test that configurePromptBuilder is called with null + // Test that getConfiguredPromptBuilder is called with null $this->expectException(\InvalidArgumentException::class); $this->expectExceptionMessageMatches('/No models found that support/'); AiClient::generateResult($prompt, null, $this->createMockEmptyRegistry()); From 403e3f3b03897f573cd55fd88a00ed7ad2d0eeaf Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 28 Aug 2025 14:47:43 -0700 Subject: [PATCH 68/69] feat: infers prompt capability from model if provided --- src/Builders/PromptBuilder.php | 43 ++++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/src/Builders/PromptBuilder.php b/src/Builders/PromptBuilder.php index 5103c9a1..d90e4644 100644 --- a/src/Builders/PromptBuilder.php +++ b/src/Builders/PromptBuilder.php @@ -519,6 +519,34 @@ private function inferCapabilityFromOutputModalities(): CapabilityEnum } } + /** + * Infers the capability from a model's implemented interfaces. + * + * @since n.e.x.t + * + * @param ModelInterface $model The model to infer capability from. + * @return CapabilityEnum|null The inferred capability, or null if none can be inferred. + */ + private function inferCapabilityFromModelInterfaces(ModelInterface $model): ?CapabilityEnum + { + // Check model interfaces in order of preference + if ($model instanceof TextGenerationModelInterface) { + return CapabilityEnum::textGeneration(); + } + if ($model instanceof ImageGenerationModelInterface) { + return CapabilityEnum::imageGeneration(); + } + if ($model instanceof TextToSpeechConversionModelInterface) { + return CapabilityEnum::textToSpeechConversion(); + } + if ($model instanceof SpeechGenerationModelInterface) { + return CapabilityEnum::speechGeneration(); + } + + // No supported interface found + return null; + } + /** * Checks if the current prompt is supported by the selected model. * @@ -655,9 +683,20 @@ public function generateResult(?CapabilityEnum $capability = null): GenerativeAi { $this->validateMessages(); - // If capability is not provided, infer it from output modalities + // If capability is not provided, infer it if ($capability === null) { - $capability = $this->inferCapabilityFromOutputModalities(); + // First try to infer from a specific model if one is set + if ($this->model !== null) { + $inferredCapability = $this->inferCapabilityFromModelInterfaces($this->model); + if ($inferredCapability !== null) { + $capability = $inferredCapability; + } + } + + // If still no capability, infer from output modalities + if ($capability === null) { + $capability = $this->inferCapabilityFromOutputModalities(); + } } $model = $this->getConfiguredModel($capability); From 09aa789839e143954a0d99db68dd8cdc9f6e2073 Mon Sep 17 00:00:00 2001 From: Jason Adams Date: Thu, 28 Aug 2025 14:48:04 -0700 Subject: [PATCH 69/69] refactor: simplifies generateResult to rely on PromptBuilder --- src/AiClient.php | 88 +++++++++++-------------------------- tests/unit/AiClientTest.php | 53 ---------------------- 2 files changed, 26 insertions(+), 115 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index f733c63e..dbafd5d3 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -8,10 +8,6 @@ use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; -use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; -use WordPress\AiClient\Providers\Models\SpeechGeneration\Contracts\SpeechGenerationModelInterface; -use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; -use WordPress\AiClient\Providers\Models\TextToSpeechConversion\Contracts\TextToSpeechConversionModelInterface; use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Results\DTO\GenerativeAiResult; @@ -153,9 +149,8 @@ public static function prompt($prompt = null, ?ProviderRegistry $registry = null * @since n.e.x.t * * @param Prompt $prompt The prompt content. - * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use, - * or model configuration for auto-discovery, - * or null for defaults. + * @param ModelInterface|ModelConfig $modelOrConfig Specific model to use, or model configuration + * for auto-discovery. * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. * @return GenerativeAiResult The generation result. * @@ -164,64 +159,11 @@ public static function prompt($prompt = null, ?ProviderRegistry $registry = null */ public static function generateResult( $prompt, - $modelOrConfig = null, + $modelOrConfig, ?ProviderRegistry $registry = null ): GenerativeAiResult { self::validateModelOrConfigParameter($modelOrConfig); - - // Route to PromptBuilder for ModelConfig and null cases - if ($modelOrConfig instanceof ModelConfig || $modelOrConfig === null) { - return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateResult(); - } - - // Specific model provided: Infer capability from model interfaces and delegate - $model = $modelOrConfig; - if ($model instanceof TextGenerationModelInterface) { - return self::generateTextResult($prompt, $model, $registry); - } - - if ($model instanceof ImageGenerationModelInterface) { - return self::generateImageResult($prompt, $model, $registry); - } - - if ($model instanceof TextToSpeechConversionModelInterface) { - return self::convertTextToSpeechResult($prompt, $model, $registry); - } - - if ($model instanceof SpeechGenerationModelInterface) { - return self::generateSpeechResult($prompt, $model, $registry); - } - - throw new \InvalidArgumentException( - sprintf( - 'Model "%s" must implement at least one supported generation interface ' . - '(TextGeneration, ImageGeneration, TextToSpeechConversion, SpeechGeneration)', - $model->metadata()->getId() - ) - ); - } - - - /** - * Creates a new message builder for fluent API usage. - * - * This method will be implemented once MessageBuilder is available. - * MessageBuilder will provide a fluent interface for constructing complex - * messages with multiple parts, attachments, and metadata. - * - * @since n.e.x.t - * - * @param string|null $text Optional initial message text. - * @return object MessageBuilder instance (type will be updated when MessageBuilder is available). - * - * @throws \RuntimeException When MessageBuilder is not yet available. - */ - public static function message(?string $text = null) - { - throw new \RuntimeException( - 'MessageBuilder is not yet available. This method depends on builder infrastructure. ' . - 'Use direct generation methods (generateTextResult, generateImageResult, etc.) for now.' - ); + return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateResult(); } /** @@ -321,6 +263,28 @@ public static function generateSpeechResult( return self::getConfiguredPromptBuilder($prompt, $modelOrConfig, $registry)->generateSpeechResult(); } + /** + * Creates a new message builder for fluent API usage. + * + * This method will be implemented once MessageBuilder is available. + * MessageBuilder will provide a fluent interface for constructing complex + * messages with multiple parts, attachments, and metadata. + * + * @since n.e.x.t + * + * @param string|null $text Optional initial message text. + * @return object MessageBuilder instance (type will be updated when MessageBuilder is available). + * + * @throws \RuntimeException When MessageBuilder is not yet available. + */ + public static function message(?string $text = null) + { + throw new \RuntimeException( + 'MessageBuilder is not yet available. This method depends on builder infrastructure. ' . + 'Use direct generation methods (generateTextResult, generateImageResult, etc.) for now.' + ); + } + /** * Validates that parameter is ModelInterface, ModelConfig, or null. * diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index ad886cc2..6cb76d7d 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -4,14 +4,12 @@ namespace WordPress\AiClient\Tests\unit; -use InvalidArgumentException; use PHPUnit\Framework\TestCase; use RuntimeException; use WordPress\AiClient\AiClient; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\UserMessage; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; -use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Tests\traits\MockModelCreationTrait; @@ -268,30 +266,6 @@ public function testGenerateResultDelegatesToImageGeneration(): void $this->assertSame($expectedResult, $result); } - /** - * Tests generateResult throws exception when model doesn't support any generation interface. - */ - public function testGenerateResultThrowsExceptionForUnsupportedModel(): void - { - $prompt = 'Test prompt'; - $unsupportedModel = $this->createMockUnsupportedModel('unsupported-model'); - - $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Model "unsupported-model" must implement at least one supported generation interface ' . - '(TextGeneration, ImageGeneration, TextToSpeechConversion, SpeechGeneration)' - ); - - AiClient::generateResult($prompt, $unsupportedModel); - } - - - - - - - - /** * Tests generateResult with null model delegates to PromptBuilder. */ @@ -334,32 +308,6 @@ public function testGenerateResultWithImageGenerationModel(): void $this->assertSame($expectedResult, $result); } - /** - * Tests generateResult with invalid model throws exception with model ID. - */ - public function testGenerateResultWithInvalidModelThrowsExceptionWithModelId(): void - { - $prompt = 'Test prompt'; - $invalidModel = $this->createMock(ModelInterface::class); - - $mockMetadata = $this->createMock(\WordPress\AiClient\Providers\Models\DTO\ModelMetadata::class); - $mockMetadata->expects($this->once()) - ->method('getId') - ->willReturn('invalid-model-id'); - - $invalidModel->expects($this->once()) - ->method('metadata') - ->willReturn($mockMetadata); - - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage( - 'Model "invalid-model-id" must implement at least one supported generation interface' - ); - - AiClient::generateResult($prompt, $invalidModel); - } - - /** * Tests that generateResult accepts ModelConfig and delegates to PromptBuilder. */ @@ -376,7 +324,6 @@ public function testGenerateResultWithModelConfigDelegatesToPromptBuilder(): voi AiClient::generateResult($prompt, $config, $this->createMockEmptyRegistry()); } - /** * Tests that traditional API methods accept ModelConfig. */