diff --git a/src/Providers/AiProviderRegistry.php b/src/Providers/AiProviderRegistry.php new file mode 100644 index 0000000..366ca64 --- /dev/null +++ b/src/Providers/AiProviderRegistry.php @@ -0,0 +1,229 @@ + Mapping of provider IDs to class names. + */ + private array $providerClassNames = []; + + + /** + * Registers a provider class with the registry. + * + * @since n.e.x.t + * + * @param string $className The fully qualified provider class name. + * @throws InvalidArgumentException If the class doesn't exist or implement required interface. + */ + public function registerProvider(string $className): void + { + if (!class_exists($className)) { + throw new InvalidArgumentException( + sprintf('Provider class does not exist: %s', $className) + ); + } + + // Validate that class implements ProviderInterface + if (!is_subclass_of($className, ProviderInterface::class)) { + throw new InvalidArgumentException( + sprintf('Provider class must implement %s: %s', ProviderInterface::class, $className) + ); + } + + // Get provider metadata to extract ID (using static method from interface) + /** @var class-string $className */ + $metadata = $className::metadata(); + + if (!$metadata instanceof ProviderMetadata) { + throw new InvalidArgumentException( + sprintf('Provider must return ProviderMetadata from metadata() method: %s', $className) + ); + } + + $this->providerClassNames[$metadata->getId()] = $className; + } + + /** + * Checks if a provider is registered. + * + * @since n.e.x.t + * + * @param string $idOrClassName The provider ID or class name to check. + * @return bool True if the provider is registered. + */ + public function hasProvider(string $idOrClassName): bool + { + return isset($this->providerClassNames[$idOrClassName]) || + in_array($idOrClassName, $this->providerClassNames, true); + } + + /** + * Gets the class name for a registered provider. + * + * @since n.e.x.t + * + * @param string $id The provider ID. + * @return string The provider class name. + * @throws InvalidArgumentException If the provider is not registered. + */ + public function getProviderClassName(string $id): string + { + if (!isset($this->providerClassNames[$id])) { + throw new InvalidArgumentException( + sprintf('Provider not registered: %s', $id) + ); + } + + return $this->providerClassNames[$id]; + } + + /** + * Checks if a provider is properly configured. + * + * @since n.e.x.t + * + * @param string $idOrClassName The provider ID or class name. + * @return bool True if the provider is configured and ready to use. + */ + public function isProviderConfigured(string $idOrClassName): bool + { + try { + $className = $this->resolveProviderClassName($idOrClassName); + + // Use static method from ProviderInterface + /** @var class-string $className */ + $availability = $className::availability(); + + return $availability->isConfigured(); + } catch (InvalidArgumentException $e) { + return false; + } + } + + /** + * Finds models across all providers that support the given requirements. + * + * @since n.e.x.t + * + * @param ModelRequirements $modelRequirements The requirements to match against. + * @return list List of provider models metadata that match requirements. + */ + public function findModelsMetadataForSupport(ModelRequirements $modelRequirements): array + { + $results = []; + + foreach ($this->providerClassNames as $providerId => $className) { + $providerResults = $this->findProviderModelsMetadataForSupport($providerId, $modelRequirements); + if (!empty($providerResults)) { + // Use static method from ProviderInterface + /** @var class-string $className */ + $providerMetadata = $className::metadata(); + + $results[] = new ProviderModelsMetadata( + $providerMetadata, + $providerResults + ); + } + } + + return $results; + } + + /** + * Finds models within a specific provider that support the given requirements. + * + * @since n.e.x.t + * + * @param string $idOrClassName The provider ID or class name. + * @param ModelRequirements $modelRequirements The requirements to match against. + * @return list List of model metadata that match requirements. + */ + public function findProviderModelsMetadataForSupport( + string $idOrClassName, + ModelRequirements $modelRequirements + ): array { + $className = $this->resolveProviderClassName($idOrClassName); + + // Use static method from ProviderInterface + /** @var class-string $className */ + $modelMetadataDirectory = $className::modelMetadataDirectory(); + + // Filter models that meet requirements + $matchingModels = []; + foreach ($modelMetadataDirectory->listModelMetadata() as $modelMetadata) { + if ($modelMetadata->meetsRequirements($modelRequirements)) { + $matchingModels[] = $modelMetadata; + } + } + + return $matchingModels; + } + + /** + * Gets a configured model instance from a provider. + * + * @since n.e.x.t + * + * @param string $idOrClassName The provider ID or class name. + * @param string $modelId The model identifier. + * @param ModelConfig|null $modelConfig The model configuration. + * @return ModelInterface The configured model instance. + * @throws InvalidArgumentException If provider or model is not found. + */ + public function getProviderModel( + string $idOrClassName, + string $modelId, + ?ModelConfig $modelConfig = null + ): ModelInterface { + $className = $this->resolveProviderClassName($idOrClassName); + + // Use static method from ProviderInterface + /** @var class-string $className */ + return $className::model($modelId, $modelConfig); + } + + /** + * Gets the class name for a registered provider (handles both ID and class name input). + * + * @param string $idOrClassName The provider ID or class name. + * @return string The provider class name. + * @throws InvalidArgumentException If provider is not registered. + */ + private function resolveProviderClassName(string $idOrClassName): string + { + // Handle both ID and class name + $className = $this->providerClassNames[$idOrClassName] ?? $idOrClassName; + + if (!$this->hasProvider($idOrClassName)) { + throw new InvalidArgumentException( + sprintf('Provider not registered: %s', $idOrClassName) + ); + } + + return $className; + } +} diff --git a/src/Providers/Models/Contracts/ModelInterface.php b/src/Providers/Models/Contracts/ModelInterface.php index e0448e0..18b2ccc 100644 --- a/src/Providers/Models/Contracts/ModelInterface.php +++ b/src/Providers/Models/Contracts/ModelInterface.php @@ -10,38 +10,37 @@ /** * Interface for AI models. * - * Models represent specific AI models from providers and define - * their capabilities, configuration, and execution methods. + * All models must implement this interface to provide + * metadata access and configuration capabilities. * * @since n.e.x.t */ interface ModelInterface { /** - * Gets model metadata. + * Gets the model's metadata. * * @since n.e.x.t * - * @return ModelMetadata Model metadata. + * @return ModelMetadata The model metadata. */ - public function metadata(): ModelMetadata; + public function getMetadata(): ModelMetadata; /** - * Sets model configuration. + * Gets the current model configuration. * * @since n.e.x.t * - * @param ModelConfig $config Model configuration. - * @return void + * @return ModelConfig The model configuration. */ - public function setConfig(ModelConfig $config): void; + public function getConfig(): ModelConfig; /** - * Gets model configuration. + * Sets the model configuration. * * @since n.e.x.t * - * @return ModelConfig Current model configuration. + * @param ModelConfig $config The model configuration. */ - public function getConfig(): ModelConfig; + public function setConfig(ModelConfig $config): void; } diff --git a/src/Providers/Models/DTO/ModelMetadata.php b/src/Providers/Models/DTO/ModelMetadata.php index a542910..83904dd 100644 --- a/src/Providers/Models/DTO/ModelMetadata.php +++ b/src/Providers/Models/DTO/ModelMetadata.php @@ -150,6 +150,41 @@ public function getSupportedOptions(): array return $this->supportedOptions; } + /** + * Checks whether this model meets the specified requirements. + * + * @since n.e.x.t + * + * @param ModelRequirements $requirements The requirements to check against. + * @return bool True if the model meets all requirements, false otherwise. + */ + public function meetsRequirements(ModelRequirements $requirements): bool + { + // Check if all required capabilities are supported using map lookup + foreach ($requirements->getRequiredCapabilities() as $requiredCapability) { + if (!isset($this->capabilitiesMap[$requiredCapability->value])) { + return false; + } + } + + // Check if all required options are supported with the specified values + foreach ($requirements->getRequiredOptions() as $requiredOption) { + // Use map lookup instead of linear search + if (!isset($this->optionsMap[$requiredOption->getName()])) { + return false; + } + + $supportedOption = $this->optionsMap[$requiredOption->getName()]; + + // Check if the required value is supported by this option + if (!$supportedOption->isSupportedValue($requiredOption->getValue())) { + return false; + } + } + + return true; + } + /** * {@inheritDoc} * @@ -209,42 +244,6 @@ public function toArray(): array ]; } - /** - * Checks whether this model meets the specified requirements. - * - * @since n.e.x.t - * - * @param ModelRequirements $requirements The requirements to check against. - * @return bool True if the model meets all requirements, false otherwise. - */ - public function meetsRequirements(ModelRequirements $requirements): bool - { - // Check if all required capabilities are supported using map lookup - foreach ($requirements->getRequiredCapabilities() as $requiredCapability) { - if (!isset($this->capabilitiesMap[$requiredCapability->value])) { - return false; - } - } - - // Check if all required options are supported with the specified values - foreach ($requirements->getRequiredOptions() as $requiredOption) { - // Use map lookup instead of linear search - if (!isset($this->optionsMap[$requiredOption->getName()])) { - return false; - } - - $supportedOption = $this->optionsMap[$requiredOption->getName()]; - - // Check if the required value is supported by this option - if (!$supportedOption->isSupportedValue($requiredOption->getValue())) { - return false; - } - } - - return true; - } - - /** * {@inheritDoc} * diff --git a/tests/unit/Files/DTO/FileTest.php b/tests/unit/Files/DTO/FileTest.php index 9ed4e6d..7d93c13 100644 --- a/tests/unit/Files/DTO/FileTest.php +++ b/tests/unit/Files/DTO/FileTest.php @@ -211,10 +211,6 @@ public function testMimeTypeMethods(): void $this->assertFalse($file->isImage()); $this->assertFalse($file->isAudio()); $this->assertFalse($file->isText()); - $this->assertTrue($file->isMimeType('video')); - $this->assertFalse($file->isMimeType('image')); - $this->assertFalse($file->isMimeType('audio')); - $this->assertFalse($file->isMimeType('text')); } /** @@ -237,10 +233,7 @@ public function testJsonSchema(): void $this->assertArrayHasKey(File::KEY_FILE_TYPE, $remoteSchema['properties']); $this->assertArrayHasKey(File::KEY_MIME_TYPE, $remoteSchema['properties']); $this->assertArrayHasKey(File::KEY_URL, $remoteSchema['properties']); - $this->assertEquals( - [File::KEY_FILE_TYPE, File::KEY_MIME_TYPE, File::KEY_URL], - $remoteSchema['required'] - ); + $this->assertEquals([File::KEY_FILE_TYPE, File::KEY_MIME_TYPE, File::KEY_URL], $remoteSchema['required']); // Check inline file schema $inlineSchema = $schema['oneOf'][1]; diff --git a/tests/unit/Providers/AiProviderRegistryTest.php b/tests/unit/Providers/AiProviderRegistryTest.php new file mode 100644 index 0000000..d9f7693 --- /dev/null +++ b/tests/unit/Providers/AiProviderRegistryTest.php @@ -0,0 +1,211 @@ +registry = new AiProviderRegistry(); + } + + /** + * Tests provider registration with valid provider. + * + * @return void + */ + public function testRegisterProviderWithValidProvider(): void + { + $this->registry->registerProvider(MockProvider::class); + + $this->assertTrue($this->registry->hasProvider('mock')); + $this->assertTrue($this->registry->hasProvider(MockProvider::class)); + $this->assertEquals(MockProvider::class, $this->registry->getProviderClassName('mock')); + } + + /** + * Tests provider registration with non-existent class. + * + * @return void + */ + public function testRegisterProviderWithNonExistentClass(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Provider class does not exist: NonExistentProvider'); + + $this->registry->registerProvider('NonExistentProvider'); + } + + /** + * Tests hasProvider with unregistered provider. + * + * @return void + */ + public function testHasProviderWithUnregisteredProvider(): void + { + $this->assertFalse($this->registry->hasProvider('nonexistent')); + $this->assertFalse($this->registry->hasProvider('NonExistentClass')); + } + + /** + * Tests getProviderClassName with unregistered provider. + * + * @return void + */ + public function testGetProviderClassNameWithUnregisteredProvider(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Provider not registered: nonexistent'); + + $this->registry->getProviderClassName('nonexistent'); + } + + /** + * Tests isProviderConfigured with registered provider. + * + * @return void + */ + public function testIsProviderConfiguredWithRegisteredProvider(): void + { + $this->registry->registerProvider(MockProvider::class); + + $this->assertTrue($this->registry->isProviderConfigured('mock')); + $this->assertTrue($this->registry->isProviderConfigured(MockProvider::class)); + } + + /** + * Tests isProviderConfigured with unregistered provider. + * + * @return void + */ + public function testIsProviderConfiguredWithUnregisteredProvider(): void + { + $this->assertFalse($this->registry->isProviderConfigured('nonexistent')); + } + + /** + * Tests findModelsMetadataForSupport with no registered providers. + * + * @return void + */ + public function testFindModelsMetadataForSupportWithNoProviders(): void + { + $requirements = new ModelRequirements([CapabilityEnum::textGeneration()], []); + $results = $this->registry->findModelsMetadataForSupport($requirements); + + $this->assertIsArray($results); + $this->assertEmpty($results); + } + + /** + * Tests findModelsMetadataForSupport with registered provider. + * + * @return void + */ + public function testFindModelsMetadataForSupportWithRegisteredProvider(): void + { + $this->registry->registerProvider(MockProvider::class); + + $requirements = new ModelRequirements([CapabilityEnum::textGeneration()], []); + $results = $this->registry->findModelsMetadataForSupport($requirements); + + $this->assertIsArray($results); + // Should now find models that match the text generation requirement + $this->assertNotEmpty($results); + $this->assertCount(1, $results); + } + + /** + * Tests findProviderModelsMetadataForSupport with registered provider. + * + * @return void + */ + public function testFindProviderModelsMetadataForSupportWithRegisteredProvider(): void + { + $this->registry->registerProvider(MockProvider::class); + + $requirements = new ModelRequirements([CapabilityEnum::textGeneration()], []); + $results = $this->registry->findProviderModelsMetadataForSupport('mock', $requirements); + + $this->assertIsArray($results); + // Should now find models that match the text generation requirement + $this->assertNotEmpty($results); + $this->assertCount(1, $results); + } + + /** + * Tests findProviderModelsMetadataForSupport with unregistered provider. + * + * @return void + */ + public function testFindProviderModelsMetadataForSupportWithUnregisteredProvider(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Provider not registered: nonexistent'); + + $requirements = new ModelRequirements([CapabilityEnum::textGeneration()], []); + $this->registry->findProviderModelsMetadataForSupport('nonexistent', $requirements); + } + + /** + * Tests getProviderModel throws exception for non-existent model. + * + * @return void + */ + public function testGetProviderModelThrowsException(): void + { + $this->registry->registerProvider(MockProvider::class); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Model not found: test-model'); + + $modelConfig = new \WordPress\AiClient\Providers\Models\DTO\ModelConfig([]); + $this->registry->getProviderModel('mock', 'test-model', $modelConfig); + } + + /** + * Tests multiple provider registration. + * + * @return void + */ + public function testMultipleProviderRegistration(): void + { + $this->registry->registerProvider(MockProvider::class); + + // Register another instance of the same provider (should update) + $this->registry->registerProvider(MockProvider::class); + + $this->assertTrue($this->registry->hasProvider('mock')); + $this->assertEquals(MockProvider::class, $this->registry->getProviderClassName('mock')); + } + + /** + * Tests provider instance caching. + * + * @return void + */ + public function testProviderInstanceCaching(): void + { + $this->registry->registerProvider(MockProvider::class); + + // Call methods that create instances + $this->assertTrue($this->registry->isProviderConfigured('mock')); + $this->assertTrue($this->registry->isProviderConfigured('mock')); + + // Should not throw any errors and should reuse cached instance + $this->addToAssertionCount(1); + } +} diff --git a/tests/unit/Providers/MockModel.php b/tests/unit/Providers/MockModel.php new file mode 100644 index 0000000..238218f --- /dev/null +++ b/tests/unit/Providers/MockModel.php @@ -0,0 +1,63 @@ +metadata = $metadata; + $this->config = $config; + } + + /** + * {@inheritDoc} + */ + public function getMetadata(): ModelMetadata + { + return $this->metadata; + } + + /** + * {@inheritDoc} + */ + public function getConfig(): ModelConfig + { + return $this->config; + } + + /** + * {@inheritDoc} + */ + public function setConfig(ModelConfig $config): void + { + $this->config = $config; + } +} diff --git a/tests/unit/Providers/MockModelMetadataDirectory.php b/tests/unit/Providers/MockModelMetadataDirectory.php new file mode 100644 index 0000000..e7e99e4 --- /dev/null +++ b/tests/unit/Providers/MockModelMetadataDirectory.php @@ -0,0 +1,62 @@ + Available models. + */ + private array $models = []; + + /** + * Constructor. + * + * @param array $models Available models. + */ + public function __construct(array $models = []) + { + $this->models = $models; + } + + /** + * {@inheritDoc} + */ + public function listModelMetadata(): array + { + return array_values($this->models); + } + + /** + * {@inheritDoc} + */ + public function hasModelMetadata(string $modelId): bool + { + return isset($this->models[$modelId]); + } + + /** + * {@inheritDoc} + */ + public function getModelMetadata(string $modelId): ModelMetadata + { + if (!isset($this->models[$modelId])) { + throw new InvalidArgumentException( + sprintf('Model not found: %s', $modelId) + ); + } + + return $this->models[$modelId]; + } +} diff --git a/tests/unit/Providers/MockProvider.php b/tests/unit/Providers/MockProvider.php new file mode 100644 index 0000000..ac7abd6 --- /dev/null +++ b/tests/unit/Providers/MockProvider.php @@ -0,0 +1,119 @@ +getModelMetadata($modelId); + + $config = $modelConfig ?? new ModelConfig(); + + return new MockModel($modelMetadata, $config); + } + + /** + * {@inheritDoc} + */ + public static function availability(): ProviderAvailabilityInterface + { + if (static::$availability === null) { + static::$availability = new MockProviderAvailability(true); + } + + return static::$availability; + } + + /** + * {@inheritDoc} + */ + public static function modelMetadataDirectory(): ModelMetadataDirectoryInterface + { + if (static::$modelMetadataDirectory === null) { + // Create some mock models for testing + $mockModels = [ + 'mock-text-model' => new \WordPress\AiClient\Providers\Models\DTO\ModelMetadata( + 'mock-text-model', + 'Mock Text Model', + [CapabilityEnum::textGeneration()], + [] + ) + ]; + + static::$modelMetadataDirectory = new MockModelMetadataDirectory($mockModels); + } + + return static::$modelMetadataDirectory; + } + + /** + * Sets the availability checker for testing. + * + * @param MockProviderAvailability $availability The availability checker. + */ + public static function setAvailability(MockProviderAvailability $availability): void + { + static::$availability = $availability; + } + + /** + * Sets the model metadata directory for testing. + * + * @param MockModelMetadataDirectory $directory The model metadata directory. + */ + public static function setModelMetadataDirectory(MockModelMetadataDirectory $directory): void + { + static::$modelMetadataDirectory = $directory; + } + + /** + * Resets static state for testing. + */ + public static function reset(): void + { + static::$availability = null; + static::$modelMetadataDirectory = null; + } +} diff --git a/tests/unit/Providers/MockProviderAvailability.php b/tests/unit/Providers/MockProviderAvailability.php new file mode 100644 index 0000000..d768492 --- /dev/null +++ b/tests/unit/Providers/MockProviderAvailability.php @@ -0,0 +1,38 @@ +configured = $configured; + } + + /** + * {@inheritDoc} + */ + public function isConfigured(): bool + { + return $this->configured; + } +} diff --git a/tests/unit/Providers/Models/DTO/ModelConfigTest.php b/tests/unit/Providers/Models/DTO/ModelConfigTest.php index 2a4ef07..8270ed5 100644 --- a/tests/unit/Providers/Models/DTO/ModelConfigTest.php +++ b/tests/unit/Providers/Models/DTO/ModelConfigTest.php @@ -160,21 +160,13 @@ public function testGetJsonSchema(): void // Check all properties exist $expectedProperties = [ - ModelConfig::KEY_OUTPUT_MODALITIES, - ModelConfig::KEY_SYSTEM_INSTRUCTION, - ModelConfig::KEY_CANDIDATE_COUNT, - ModelConfig::KEY_MAX_TOKENS, - ModelConfig::KEY_TEMPERATURE, - ModelConfig::KEY_TOP_P, - ModelConfig::KEY_TOP_K, - ModelConfig::KEY_STOP_SEQUENCES, - ModelConfig::KEY_PRESENCE_PENALTY, - ModelConfig::KEY_FREQUENCY_PENALTY, - ModelConfig::KEY_LOGPROBS, - ModelConfig::KEY_TOP_LOGPROBS, - ModelConfig::KEY_TOOLS, - ModelConfig::KEY_OUTPUT_MIME_TYPE, - ModelConfig::KEY_OUTPUT_SCHEMA, + ModelConfig::KEY_OUTPUT_MODALITIES, ModelConfig::KEY_SYSTEM_INSTRUCTION, + ModelConfig::KEY_CANDIDATE_COUNT, ModelConfig::KEY_MAX_TOKENS, + ModelConfig::KEY_TEMPERATURE, ModelConfig::KEY_TOP_P, ModelConfig::KEY_TOP_K, + ModelConfig::KEY_STOP_SEQUENCES, ModelConfig::KEY_PRESENCE_PENALTY, + ModelConfig::KEY_FREQUENCY_PENALTY, ModelConfig::KEY_LOGPROBS, + ModelConfig::KEY_TOP_LOGPROBS, ModelConfig::KEY_TOOLS, + ModelConfig::KEY_OUTPUT_MIME_TYPE, ModelConfig::KEY_OUTPUT_SCHEMA, ModelConfig::KEY_CUSTOM_OPTIONS ]; diff --git a/tests/unit/Results/DTO/GenerativeAiResultTest.php b/tests/unit/Results/DTO/GenerativeAiResultTest.php index e58f43a..5e7a1b7 100644 --- a/tests/unit/Results/DTO/GenerativeAiResultTest.php +++ b/tests/unit/Results/DTO/GenerativeAiResultTest.php @@ -37,7 +37,7 @@ public function testCreateWithSingleCandidate(): void $message = new ModelMessage([ new MessagePart('This is the AI response.') ]); - $candidate = new Candidate($message, FinishReasonEnum::stop(), 10); + $candidate = new Candidate($message, FinishReasonEnum::stop()); $tokenUsage = new TokenUsage(20, 10, 30); $result = new GenerativeAiResult( @@ -65,7 +65,7 @@ public function testCreateWithMultipleCandidates(): void $message = new ModelMessage([ new MessagePart("Response variant $i") ]); - $candidates[] = new Candidate($message, FinishReasonEnum::stop(), $i * 10); + $candidates[] = new Candidate($message, FinishReasonEnum::stop()); } $tokenUsage = new TokenUsage(20, 90, 110); @@ -88,7 +88,7 @@ public function testCreateWithMultipleCandidates(): void public function testCreateWithProviderMetadata(): void { $message = new ModelMessage([new MessagePart('Response')]); - $candidate = new Candidate($message, FinishReasonEnum::stop(), 5); + $candidate = new Candidate($message, FinishReasonEnum::stop()); $tokenUsage = new TokenUsage(10, 5, 15); $metadata = [ 'model' => 'gpt-4', @@ -133,7 +133,7 @@ public function testToText(): void $message = new ModelMessage([ new MessagePart($text) ]); - $candidate = new Candidate($message, FinishReasonEnum::stop(), 8); + $candidate = new Candidate($message, FinishReasonEnum::stop()); $tokenUsage = new TokenUsage(10, 8, 18); $result = new GenerativeAiResult( @@ -156,7 +156,7 @@ public function testToTextThrowsExceptionWhenNoTextContent(): void $message = new ModelMessage([ new MessagePart($file) ]); - $candidate = new Candidate($message, FinishReasonEnum::stop(), 5); + $candidate = new Candidate($message, FinishReasonEnum::stop()); $tokenUsage = new TokenUsage(10, 5, 15); $result = new GenerativeAiResult( @@ -187,7 +187,7 @@ public function testToFile(): void new MessagePart('Here is the generated image:'), new MessagePart($file) ]); - $candidate = new Candidate($message, FinishReasonEnum::stop(), 20); + $candidate = new Candidate($message, FinishReasonEnum::stop()); $tokenUsage = new TokenUsage(15, 20, 35); $result = new GenerativeAiResult( @@ -209,7 +209,7 @@ public function testToFileThrowsExceptionWhenNoFileContent(): void $message = new ModelMessage([ new MessagePart('Just text, no file.') ]); - $candidate = new Candidate($message, FinishReasonEnum::stop(), 5); + $candidate = new Candidate($message, FinishReasonEnum::stop()); $tokenUsage = new TokenUsage(10, 5, 15); $result = new GenerativeAiResult( @@ -235,7 +235,7 @@ public function testToImageFile(): void $message = new ModelMessage([ new MessagePart($imageFile) ]); - $candidate = new Candidate($message, FinishReasonEnum::stop(), 10); + $candidate = new Candidate($message, FinishReasonEnum::stop()); $tokenUsage = new TokenUsage(5, 10, 15); $result = new GenerativeAiResult( @@ -258,7 +258,7 @@ public function testToImageFileThrowsExceptionForNonImageFile(): void $message = new ModelMessage([ new MessagePart($pdfFile) ]); - $candidate = new Candidate($message, FinishReasonEnum::stop(), 10); + $candidate = new Candidate($message, FinishReasonEnum::stop()); $tokenUsage = new TokenUsage(5, 10, 15); $result = new GenerativeAiResult( @@ -284,7 +284,7 @@ public function testToAudioFile(): void $message = new ModelMessage([ new MessagePart($audioFile) ]); - $candidate = new Candidate($message, FinishReasonEnum::stop(), 10); + $candidate = new Candidate($message, FinishReasonEnum::stop()); $tokenUsage = new TokenUsage(5, 10, 15); $result = new GenerativeAiResult( @@ -307,7 +307,7 @@ public function testToVideoFile(): void $message = new ModelMessage([ new MessagePart($videoFile) ]); - $candidate = new Candidate($message, FinishReasonEnum::stop(), 10); + $candidate = new Candidate($message, FinishReasonEnum::stop()); $tokenUsage = new TokenUsage(5, 10, 15); $result = new GenerativeAiResult( @@ -329,7 +329,7 @@ public function testToMessage(): void $message = new ModelMessage([ new MessagePart('Response message') ]); - $candidate = new Candidate($message, FinishReasonEnum::stop(), 3); + $candidate = new Candidate($message, FinishReasonEnum::stop()); $tokenUsage = new TokenUsage(5, 3, 8); $result = new GenerativeAiResult( @@ -355,7 +355,7 @@ public function testToTextsWithMultipleCandidates(): void $message = new ModelMessage([ new MessagePart($text) ]); - $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 5); + $candidates[] = new Candidate($message, FinishReasonEnum::stop()); } $tokenUsage = new TokenUsage(20, 15, 35); @@ -385,7 +385,7 @@ public function testToFilesWithMultipleCandidates(): void new MessagePart('Generated file:'), new MessagePart($file) ]); - $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 10); + $candidates[] = new Candidate($message, FinishReasonEnum::stop()); } $tokenUsage = new TokenUsage(30, 30, 60); @@ -416,7 +416,7 @@ public function testToImageFilesFiltersOnlyImages(): void $candidates = []; foreach ([$imageFile1, $pdfFile, $imageFile2] as $file) { $message = new ModelMessage([new MessagePart($file)]); - $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 10); + $candidates[] = new Candidate($message, FinishReasonEnum::stop()); } $tokenUsage = new TokenUsage(30, 30, 60); @@ -446,7 +446,7 @@ public function testToAudioFilesFiltersOnlyAudio(): void $candidates = []; foreach ([$audioFile1, $imageFile, $audioFile2] as $file) { $message = new ModelMessage([new MessagePart($file)]); - $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 10); + $candidates[] = new Candidate($message, FinishReasonEnum::stop()); } $tokenUsage = new TokenUsage(30, 30, 60); @@ -476,7 +476,7 @@ public function testToVideoFilesFiltersOnlyVideo(): void $candidates = []; foreach ([$videoFile1, $imageFile, $videoFile2] as $file) { $message = new ModelMessage([new MessagePart($file)]); - $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 10); + $candidates[] = new Candidate($message, FinishReasonEnum::stop()); } $tokenUsage = new TokenUsage(30, 30, 60); @@ -507,7 +507,7 @@ public function testToMessages(): void new MessagePart("Message $i") ]); $messages[] = $message; - $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 5); + $candidates[] = new Candidate($message, FinishReasonEnum::stop()); } $tokenUsage = new TokenUsage(15, 15, 30); @@ -572,7 +572,7 @@ public function testJsonSchema(): void public function testImplementsResultInterface(): void { $message = new ModelMessage([new MessagePart('Test')]); - $candidate = new Candidate($message, FinishReasonEnum::stop(), 1); + $candidate = new Candidate($message, FinishReasonEnum::stop()); $tokenUsage = new TokenUsage(1, 1, 2); $result = new GenerativeAiResult( @@ -595,7 +595,7 @@ public function testImplementsResultInterface(): void public function testHasMultipleCandidatesReturnsFalseForSingle(): void { $message = new ModelMessage([new MessagePart('Single response')]); - $candidate = new Candidate($message, FinishReasonEnum::stop(), 3); + $candidate = new Candidate($message, FinishReasonEnum::stop()); $tokenUsage = new TokenUsage(5, 3, 8); $result = new GenerativeAiResult( @@ -619,7 +619,7 @@ public function testToArray(): void new MessagePart('AI generated response'), new MessagePart('with multiple parts') ]); - $candidate = new Candidate($message, FinishReasonEnum::stop(), 15); + $candidate = new Candidate($message, FinishReasonEnum::stop()); $tokenUsage = new TokenUsage(10, 15, 25); $metadata = ['model' => 'test-model', 'version' => '1.0']; @@ -707,7 +707,7 @@ public function testArrayRoundTripWithMultipleCandidates(): void new MessagePart("Response $i"), new MessagePart(new FunctionCall("call_$i", "func$i", ['arg' => $i])) ]); - $candidates[] = new Candidate($message, FinishReasonEnum::toolCalls(), 25 * $i); + $candidates[] = new Candidate($message, FinishReasonEnum::toolCalls()); } $this->assertArrayRoundTrip( @@ -749,7 +749,7 @@ function ($original, $restored) { public function testToArrayWithoutProviderMetadata(): void { $message = new ModelMessage([new MessagePart('Simple response')]); - $candidate = new Candidate($message, FinishReasonEnum::stop(), 5); + $candidate = new Candidate($message, FinishReasonEnum::stop()); $tokenUsage = new TokenUsage(3, 5, 8); $result = new GenerativeAiResult( @@ -780,7 +780,7 @@ public function testToArrayWithoutProviderMetadata(): void public function testImplementsWithArrayTransformationInterface(): void { $message = new ModelMessage([new MessagePart('test')]); - $candidate = new Candidate($message, FinishReasonEnum::stop(), 1); + $candidate = new Candidate($message, FinishReasonEnum::stop()); $tokenUsage = new TokenUsage(1, 1, 2); $result = new GenerativeAiResult('test', [$candidate], $tokenUsage); diff --git a/tests/unit/Results/DTO/GenerativeAiResultTest.php.bak b/tests/unit/Results/DTO/GenerativeAiResultTest.php.bak new file mode 100644 index 0000000..e58f43a --- /dev/null +++ b/tests/unit/Results/DTO/GenerativeAiResultTest.php.bak @@ -0,0 +1,789 @@ +assertEquals('result_123', $result->getId()); + $this->assertCount(1, $result->getCandidates()); + $this->assertSame($candidate, $result->getCandidates()[0]); + $this->assertSame($tokenUsage, $result->getTokenUsage()); + $this->assertEquals([], $result->getProviderMetadata()); + } + + /** + * Tests creating result with multiple candidates. + * + * @return void + */ + public function testCreateWithMultipleCandidates(): void + { + $candidates = []; + for ($i = 1; $i <= 3; $i++) { + $message = new ModelMessage([ + new MessagePart("Response variant $i") + ]); + $candidates[] = new Candidate($message, FinishReasonEnum::stop(), $i * 10); + } + $tokenUsage = new TokenUsage(20, 90, 110); + + $result = new GenerativeAiResult( + 'result_multi', + $candidates, + $tokenUsage + ); + + $this->assertCount(3, $result->getCandidates()); + $this->assertEquals(3, $result->getCandidateCount()); + $this->assertTrue($result->hasMultipleCandidates()); + } + + /** + * Tests creating result with provider metadata. + * + * @return void + */ + public function testCreateWithProviderMetadata(): void + { + $message = new ModelMessage([new MessagePart('Response')]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 5); + $tokenUsage = new TokenUsage(10, 5, 15); + $metadata = [ + 'model' => 'gpt-4', + 'temperature' => 0.7, + 'max_tokens' => 1000, + 'custom_data' => ['key' => 'value'] + ]; + + $result = new GenerativeAiResult( + 'result_meta', + [$candidate], + $tokenUsage, + $metadata + ); + + $this->assertEquals($metadata, $result->getProviderMetadata()); + } + + /** + * Tests result rejects empty candidates array. + * + * @return void + */ + public function testRejectsEmptyCandidatesArray(): void + { + $tokenUsage = new TokenUsage(0, 0, 0); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('At least one candidate must be provided'); + + new GenerativeAiResult('result_empty', [], $tokenUsage); + } + + /** + * Tests toText method. + * + * @return void + */ + public function testToText(): void + { + $text = 'This is the extracted text content.'; + $message = new ModelMessage([ + new MessagePart($text) + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 8); + $tokenUsage = new TokenUsage(10, 8, 18); + + $result = new GenerativeAiResult( + 'result_text', + [$candidate], + $tokenUsage + ); + + $this->assertEquals($text, $result->toText()); + } + + /** + * Tests toText throws exception when no text content. + * + * @return void + */ + public function testToTextThrowsExceptionWhenNoTextContent(): void + { + $file = new File('https://example.com/image.jpg', 'image/jpeg'); + $message = new ModelMessage([ + new MessagePart($file) + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 5); + $tokenUsage = new TokenUsage(10, 5, 15); + + $result = new GenerativeAiResult( + 'result_no_text', + [$candidate], + $tokenUsage + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No text content found in first candidate'); + + $result->toText(); + } + + /** + * Tests toFile method. + * + * @return void + */ + public function testToFile(): void + { + $base64Data = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAAAAAA6fptVAAAACklEQVQI12P4DwABAQEAG7buVgAAAABJRU5ErkJggg=='; + $file = new File( + 'data:image/png;base64,' . $base64Data, + 'image/png' + ); + $message = new ModelMessage([ + new MessagePart('Here is the generated image:'), + new MessagePart($file) + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 20); + $tokenUsage = new TokenUsage(15, 20, 35); + + $result = new GenerativeAiResult( + 'result_file', + [$candidate], + $tokenUsage + ); + + $this->assertSame($file, $result->toFile()); + } + + /** + * Tests toFile throws exception when no file content. + * + * @return void + */ + public function testToFileThrowsExceptionWhenNoFileContent(): void + { + $message = new ModelMessage([ + new MessagePart('Just text, no file.') + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 5); + $tokenUsage = new TokenUsage(10, 5, 15); + + $result = new GenerativeAiResult( + 'result_no_file', + [$candidate], + $tokenUsage + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('No file content found in first candidate'); + + $result->toFile(); + } + + /** + * Tests toImageFile method. + * + * @return void + */ + public function testToImageFile(): void + { + $imageFile = new File('https://example.com/photo.jpg', 'image/jpeg'); + $message = new ModelMessage([ + new MessagePart($imageFile) + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 10); + $tokenUsage = new TokenUsage(5, 10, 15); + + $result = new GenerativeAiResult( + 'result_image', + [$candidate], + $tokenUsage + ); + + $this->assertSame($imageFile, $result->toImageFile()); + } + + /** + * Tests toImageFile throws exception for non-image file. + * + * @return void + */ + public function testToImageFileThrowsExceptionForNonImageFile(): void + { + $pdfFile = new File('https://example.com/document.pdf', 'application/pdf'); + $message = new ModelMessage([ + new MessagePart($pdfFile) + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 10); + $tokenUsage = new TokenUsage(5, 10, 15); + + $result = new GenerativeAiResult( + 'result_pdf', + [$candidate], + $tokenUsage + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('File is not an image. MIME type: application/pdf'); + + $result->toImageFile(); + } + + /** + * Tests toAudioFile method. + * + * @return void + */ + public function testToAudioFile(): void + { + $audioFile = new File('https://example.com/song.mp3', 'audio/mpeg'); + $message = new ModelMessage([ + new MessagePart($audioFile) + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 10); + $tokenUsage = new TokenUsage(5, 10, 15); + + $result = new GenerativeAiResult( + 'result_audio', + [$candidate], + $tokenUsage + ); + + $this->assertSame($audioFile, $result->toAudioFile()); + } + + /** + * Tests toVideoFile method. + * + * @return void + */ + public function testToVideoFile(): void + { + $videoFile = new File('https://example.com/video.mp4', 'video/mp4'); + $message = new ModelMessage([ + new MessagePart($videoFile) + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 10); + $tokenUsage = new TokenUsage(5, 10, 15); + + $result = new GenerativeAiResult( + 'result_video', + [$candidate], + $tokenUsage + ); + + $this->assertSame($videoFile, $result->toVideoFile()); + } + + /** + * Tests toMessage method. + * + * @return void + */ + public function testToMessage(): void + { + $message = new ModelMessage([ + new MessagePart('Response message') + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 3); + $tokenUsage = new TokenUsage(5, 3, 8); + + $result = new GenerativeAiResult( + 'result_msg', + [$candidate], + $tokenUsage + ); + + $this->assertSame($message, $result->toMessage()); + } + + /** + * Tests toTexts method with multiple candidates. + * + * @return void + */ + public function testToTextsWithMultipleCandidates(): void + { + $texts = ['First response', 'Second response', 'Third response']; + $candidates = []; + + foreach ($texts as $text) { + $message = new ModelMessage([ + new MessagePart($text) + ]); + $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 5); + } + + $tokenUsage = new TokenUsage(20, 15, 35); + $result = new GenerativeAiResult( + 'result_texts', + $candidates, + $tokenUsage + ); + + $this->assertEquals($texts, $result->toTexts()); + } + + /** + * Tests toFiles method with multiple candidates. + * + * @return void + */ + public function testToFilesWithMultipleCandidates(): void + { + $file1 = new File('https://example.com/image1.jpg', 'image/jpeg'); + $file2 = new File('https://example.com/image2.png', 'image/png'); + $file3 = new File('https://example.com/doc.pdf', 'application/pdf'); + + $candidates = []; + foreach ([$file1, $file2, $file3] as $file) { + $message = new ModelMessage([ + new MessagePart('Generated file:'), + new MessagePart($file) + ]); + $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 10); + } + + $tokenUsage = new TokenUsage(30, 30, 60); + $result = new GenerativeAiResult( + 'result_files', + $candidates, + $tokenUsage + ); + + $files = $result->toFiles(); + $this->assertCount(3, $files); + $this->assertSame($file1, $files[0]); + $this->assertSame($file2, $files[1]); + $this->assertSame($file3, $files[2]); + } + + /** + * Tests toImageFiles filters only image files. + * + * @return void + */ + public function testToImageFilesFiltersOnlyImages(): void + { + $imageFile1 = new File('https://example.com/image1.jpg', 'image/jpeg'); + $pdfFile = new File('https://example.com/doc.pdf', 'application/pdf'); + $imageFile2 = new File('https://example.com/image2.png', 'image/png'); + + $candidates = []; + foreach ([$imageFile1, $pdfFile, $imageFile2] as $file) { + $message = new ModelMessage([new MessagePart($file)]); + $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 10); + } + + $tokenUsage = new TokenUsage(30, 30, 60); + $result = new GenerativeAiResult( + 'result_mixed', + $candidates, + $tokenUsage + ); + + $images = $result->toImageFiles(); + $this->assertCount(2, $images); + $this->assertSame($imageFile1, $images[0]); + $this->assertSame($imageFile2, $images[1]); + } + + /** + * Tests toAudioFiles filters only audio files. + * + * @return void + */ + public function testToAudioFilesFiltersOnlyAudio(): void + { + $audioFile1 = new File('https://example.com/song.mp3', 'audio/mpeg'); + $imageFile = new File('https://example.com/image.jpg', 'image/jpeg'); + $audioFile2 = new File('https://example.com/podcast.wav', 'audio/wav'); + + $candidates = []; + foreach ([$audioFile1, $imageFile, $audioFile2] as $file) { + $message = new ModelMessage([new MessagePart($file)]); + $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 10); + } + + $tokenUsage = new TokenUsage(30, 30, 60); + $result = new GenerativeAiResult( + 'result_audio_mix', + $candidates, + $tokenUsage + ); + + $audioFiles = $result->toAudioFiles(); + $this->assertCount(2, $audioFiles); + $this->assertSame($audioFile1, $audioFiles[0]); + $this->assertSame($audioFile2, $audioFiles[1]); + } + + /** + * Tests toVideoFiles filters only video files. + * + * @return void + */ + public function testToVideoFilesFiltersOnlyVideo(): void + { + $videoFile1 = new File('https://example.com/movie.mp4', 'video/mp4'); + $imageFile = new File('https://example.com/image.jpg', 'image/jpeg'); + $videoFile2 = new File('https://example.com/clip.webm', 'video/webm'); + + $candidates = []; + foreach ([$videoFile1, $imageFile, $videoFile2] as $file) { + $message = new ModelMessage([new MessagePart($file)]); + $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 10); + } + + $tokenUsage = new TokenUsage(30, 30, 60); + $result = new GenerativeAiResult( + 'result_video_mix', + $candidates, + $tokenUsage + ); + + $videoFiles = $result->toVideoFiles(); + $this->assertCount(2, $videoFiles); + $this->assertSame($videoFile1, $videoFiles[0]); + $this->assertSame($videoFile2, $videoFiles[1]); + } + + /** + * Tests toMessages method. + * + * @return void + */ + public function testToMessages(): void + { + $messages = []; + $candidates = []; + + for ($i = 1; $i <= 3; $i++) { + $message = new ModelMessage([ + new MessagePart("Message $i") + ]); + $messages[] = $message; + $candidates[] = new Candidate($message, FinishReasonEnum::stop(), 5); + } + + $tokenUsage = new TokenUsage(15, 15, 30); + $result = new GenerativeAiResult( + 'result_messages', + $candidates, + $tokenUsage + ); + + $extractedMessages = $result->toMessages(); + $this->assertCount(3, $extractedMessages); + foreach ($messages as $index => $message) { + $this->assertSame($message, $extractedMessages[$index]); + } + } + + /** + * Tests JSON schema. + * + * @return void + */ + public function testJsonSchema(): void + { + $schema = GenerativeAiResult::getJsonSchema(); + + $this->assertIsArray($schema); + $this->assertEquals('object', $schema['type']); + + // Check properties + $this->assertArrayHasKey('properties', $schema); + $this->assertArrayHasKey(GenerativeAiResult::KEY_ID, $schema['properties']); + $this->assertArrayHasKey(GenerativeAiResult::KEY_CANDIDATES, $schema['properties']); + $this->assertArrayHasKey(GenerativeAiResult::KEY_TOKEN_USAGE, $schema['properties']); + $this->assertArrayHasKey(GenerativeAiResult::KEY_PROVIDER_METADATA, $schema['properties']); + + // Check id property + $this->assertEquals('string', $schema['properties'][GenerativeAiResult::KEY_ID]['type']); + + // Check candidates property + $candidatesSchema = $schema['properties'][GenerativeAiResult::KEY_CANDIDATES]; + $this->assertEquals('array', $candidatesSchema['type']); + $this->assertEquals(1, $candidatesSchema['minItems']); + + // Check providerMetadata property + $metadataSchema = $schema['properties'][GenerativeAiResult::KEY_PROVIDER_METADATA]; + $this->assertEquals('object', $metadataSchema['type']); + $this->assertTrue($metadataSchema['additionalProperties']); + + // Check required fields + $this->assertArrayHasKey('required', $schema); + $this->assertContains(GenerativeAiResult::KEY_ID, $schema['required']); + $this->assertContains(GenerativeAiResult::KEY_CANDIDATES, $schema['required']); + $this->assertContains(GenerativeAiResult::KEY_TOKEN_USAGE, $schema['required']); + $this->assertNotContains(GenerativeAiResult::KEY_PROVIDER_METADATA, $schema['required']); + } + + /** + * Tests result implements ResultInterface. + * + * @return void + */ + public function testImplementsResultInterface(): void + { + $message = new ModelMessage([new MessagePart('Test')]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 1); + $tokenUsage = new TokenUsage(1, 1, 2); + + $result = new GenerativeAiResult( + 'result_interface', + [$candidate], + $tokenUsage + ); + + $this->assertInstanceOf( + \WordPress\AiClient\Results\Contracts\ResultInterface::class, + $result + ); + } + + /** + * Tests hasMultipleCandidates returns false for single candidate. + * + * @return void + */ + public function testHasMultipleCandidatesReturnsFalseForSingle(): void + { + $message = new ModelMessage([new MessagePart('Single response')]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 3); + $tokenUsage = new TokenUsage(5, 3, 8); + + $result = new GenerativeAiResult( + 'result_single', + [$candidate], + $tokenUsage + ); + + $this->assertFalse($result->hasMultipleCandidates()); + $this->assertEquals(1, $result->getCandidateCount()); + } + + /** + * Tests array transformation. + * + * @return void + */ + public function testToArray(): void + { + $message = new ModelMessage([ + new MessagePart('AI generated response'), + new MessagePart('with multiple parts') + ]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 15); + $tokenUsage = new TokenUsage(10, 15, 25); + $metadata = ['model' => 'test-model', 'version' => '1.0']; + + $result = new GenerativeAiResult( + 'result_json_123', + [$candidate], + $tokenUsage, + $metadata + ); + + $json = $this->assertToArrayReturnsArray($result); + + $this->assertArrayHasKeys( + $json, + [ + GenerativeAiResult::KEY_ID, + GenerativeAiResult::KEY_CANDIDATES, + GenerativeAiResult::KEY_TOKEN_USAGE, + GenerativeAiResult::KEY_PROVIDER_METADATA + ] + ); + $this->assertEquals('result_json_123', $json[GenerativeAiResult::KEY_ID]); + $this->assertIsArray($json[GenerativeAiResult::KEY_CANDIDATES]); + $this->assertCount(1, $json[GenerativeAiResult::KEY_CANDIDATES]); + $this->assertIsArray($json[GenerativeAiResult::KEY_TOKEN_USAGE]); + $this->assertEquals($metadata, $json[GenerativeAiResult::KEY_PROVIDER_METADATA]); + } + + /** + * Tests fromJson method. + * + * @return void + */ + public function testFromArray(): void + { + $json = [ + GenerativeAiResult::KEY_ID => 'result_from_json', + GenerativeAiResult::KEY_CANDIDATES => [ + [ + Candidate::KEY_MESSAGE => [ + Message::KEY_ROLE => MessageRoleEnum::model()->value, + Message::KEY_PARTS => [ + [ + MessagePart::KEY_TYPE => MessagePartTypeEnum::text()->value, + MessagePart::KEY_TEXT => 'First part' + ], + [ + MessagePart::KEY_TYPE => MessagePartTypeEnum::text()->value, + MessagePart::KEY_TEXT => 'Second part' + ] + ] + ], + Candidate::KEY_FINISH_REASON => FinishReasonEnum::stop()->value, + ] + ], + GenerativeAiResult::KEY_TOKEN_USAGE => [ + TokenUsage::KEY_PROMPT_TOKENS => 8, + TokenUsage::KEY_COMPLETION_TOKENS => 20, + TokenUsage::KEY_TOTAL_TOKENS => 28 + ], + GenerativeAiResult::KEY_PROVIDER_METADATA => ['provider' => 'test'] + ]; + + $result = GenerativeAiResult::fromArray($json); + + $this->assertInstanceOf(GenerativeAiResult::class, $result); + $this->assertEquals('result_from_json', $result->getId()); + $this->assertCount(1, $result->getCandidates()); + $this->assertEquals(8, $result->getTokenUsage()->getPromptTokens()); + $this->assertEquals(20, $result->getTokenUsage()->getCompletionTokens()); + $this->assertEquals(28, $result->getTokenUsage()->getTotalTokens()); + $this->assertEquals(['provider' => 'test'], $result->getProviderMetadata()); + } + + /** + * Tests round-trip array transformation with multiple candidates. + * + * @return void + */ + public function testArrayRoundTripWithMultipleCandidates(): void + { + $candidates = []; + for ($i = 1; $i <= 2; $i++) { + $message = new ModelMessage([ + new MessagePart("Response $i"), + new MessagePart(new FunctionCall("call_$i", "func$i", ['arg' => $i])) + ]); + $candidates[] = new Candidate($message, FinishReasonEnum::toolCalls(), 25 * $i); + } + + $this->assertArrayRoundTrip( + new GenerativeAiResult( + 'result_roundtrip', + $candidates, + new TokenUsage(30, 75, 105), + ['test_meta' => true] + ), + function ($original, $restored) { + $this->assertEquals($original->getId(), $restored->getId()); + $this->assertCount(count($original->getCandidates()), $restored->getCandidates()); + $this->assertEquals( + $original->getTokenUsage()->getTotalTokens(), + $restored->getTokenUsage()->getTotalTokens() + ); + $this->assertEquals($original->getProviderMetadata(), $restored->getProviderMetadata()); + + // Check first candidate details + $originalFirst = $original->getCandidates()[0]; + $restoredFirst = $restored->getCandidates()[0]; + $this->assertEquals( + $originalFirst->getMessage()->getParts()[0]->getText(), + $restoredFirst->getMessage()->getParts()[0]->getText() + ); + $this->assertEquals( + $originalFirst->getMessage()->getParts()[1]->getFunctionCall()->getId(), + $restoredFirst->getMessage()->getParts()[1]->getFunctionCall()->getId() + ); + } + ); + } + + /** + * Tests array transformation without provider metadata. + * + * @return void + */ + public function testToArrayWithoutProviderMetadata(): void + { + $message = new ModelMessage([new MessagePart('Simple response')]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 5); + $tokenUsage = new TokenUsage(3, 5, 8); + + $result = new GenerativeAiResult( + 'result_no_meta', + [$candidate], + $tokenUsage + ); + + $json = $this->assertToArrayReturnsArray($result); + + $this->assertArrayHasKeys( + $json, + [ + GenerativeAiResult::KEY_ID, + GenerativeAiResult::KEY_CANDIDATES, + GenerativeAiResult::KEY_TOKEN_USAGE, + GenerativeAiResult::KEY_PROVIDER_METADATA + ] + ); + $this->assertEquals([], $json[GenerativeAiResult::KEY_PROVIDER_METADATA]); + } + + /** + * Tests GenerativeAiResult implements WithArrayTransformationInterface. + * + * @return void + */ + public function testImplementsWithArrayTransformationInterface(): void + { + $message = new ModelMessage([new MessagePart('test')]); + $candidate = new Candidate($message, FinishReasonEnum::stop(), 1); + $tokenUsage = new TokenUsage(1, 1, 2); + + $result = new GenerativeAiResult('test', [$candidate], $tokenUsage); + $this->assertImplementsArrayTransformation($result); + } +}