diff --git a/src/Providers/ApiBasedImplementation/AbstractApiBasedModel.php b/src/Providers/ApiBasedImplementation/AbstractApiBasedModel.php index c2124c66..9b4f3f65 100644 --- a/src/Providers/ApiBasedImplementation/AbstractApiBasedModel.php +++ b/src/Providers/ApiBasedImplementation/AbstractApiBasedModel.php @@ -4,6 +4,7 @@ namespace WordPress\AiClient\Providers\ApiBasedImplementation; +use WordPress\AiClient\Messages\Enums\ModalityEnum; use WordPress\AiClient\Providers\ApiBasedImplementation\Contracts\ApiBasedModelInterface; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Http\Contracts\WithHttpTransporterInterface; @@ -13,6 +14,7 @@ use WordPress\AiClient\Providers\Http\Traits\WithRequestAuthenticationTrait; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; +use WordPress\AiClient\Providers\Models\Enums\OptionEnum; /** * Base class for an API-based model for a provider. @@ -124,4 +126,85 @@ final public function getRequestOptions(): ?RequestOptions { return $this->requestOptions; } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + final public function getCapabilities(): array + { + return [ + 'input' => $this->extractModalities(OptionEnum::inputModalities()), + 'output' => $this->extractModalities(OptionEnum::outputModalities()), + ]; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + final public function supportsInput(ModalityEnum $modality): bool + { + foreach ($this->extractModalities(OptionEnum::inputModalities()) as $supported) { + if ($supported->value === $modality->value) { + return true; + } + } + return false; + } + + /** + * {@inheritDoc} + * + * @since n.e.x.t + */ + final public function supportsOutput(ModalityEnum $modality): bool + { + foreach ($this->extractModalities(OptionEnum::outputModalities()) as $supported) { + if ($supported->value === $modality->value) { + return true; + } + } + return false; + } + + /** + * Extracts a list of ModalityEnum values from the model metadata for a given option key. + * + * Looks up the SupportedOption matching $optionKey in the model's metadata and converts + * each stored value to a ModalityEnum instance. Values that are not valid ModalityEnum + * values are silently skipped. + * + * @since n.e.x.t + * + * @param OptionEnum $optionKey The option key to look up (e.g. inputModalities, outputModalities). + * @return list The list of modalities, or an empty list if none are defined. + */ + private function extractModalities(OptionEnum $optionKey): array + { + foreach ($this->metadata->getSupportedOptions() as $supportedOption) { + if ($supportedOption->getName()->value !== $optionKey->value) { + continue; + } + + $values = $supportedOption->getSupportedValues(); + if ($values === null) { + return []; + } + + $modalities = []; + foreach ($values as $value) { + if ($value instanceof ModalityEnum) { + $modalities[] = $value; + } elseif (is_string($value)) { + $modalities[] = ModalityEnum::from($value); + } + } + return $modalities; + } + + return []; + } } diff --git a/src/Providers/Models/Contracts/ModelInterface.php b/src/Providers/Models/Contracts/ModelInterface.php index c0a48830..8b943399 100644 --- a/src/Providers/Models/Contracts/ModelInterface.php +++ b/src/Providers/Models/Contracts/ModelInterface.php @@ -4,6 +4,7 @@ namespace WordPress\AiClient\Providers\Models\Contracts; +use WordPress\AiClient\Messages\Enums\ModalityEnum; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; @@ -54,4 +55,37 @@ public function setConfig(ModelConfig $config): void; * @return ModelConfig Current model configuration. */ public function getConfig(): ModelConfig; + + /** + * Returns the supported input and output modalities for this model. + * + * The returned array contains two keys: 'input' and 'output', each + * containing a list of supported ModalityEnum values. An empty list + * means no modalities of that kind are explicitly declared. + * + * @since n.e.x.t + * + * @return array{input: list, output: list} Supported modalities. + */ + public function getCapabilities(): array; + + /** + * Checks whether the model supports the given input modality. + * + * @since n.e.x.t + * + * @param ModalityEnum $modality The modality to check. + * @return bool True if the input modality is supported, false otherwise. + */ + public function supportsInput(ModalityEnum $modality): bool; + + /** + * Checks whether the model supports the given output modality. + * + * @since n.e.x.t + * + * @param ModalityEnum $modality The modality to check. + * @return bool True if the output modality is supported, false otherwise. + */ + public function supportsOutput(ModalityEnum $modality): bool; } diff --git a/tests/mocks/MockModel.php b/tests/mocks/MockModel.php index 13d4a9a2..ba0a068b 100644 --- a/tests/mocks/MockModel.php +++ b/tests/mocks/MockModel.php @@ -4,6 +4,7 @@ namespace WordPress\AiClient\Tests\mocks; +use WordPress\AiClient\Messages\Enums\ModalityEnum; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Http\Contracts\WithHttpTransporterInterface; use WordPress\AiClient\Providers\Http\Contracts\WithRequestAuthenticationInterface; @@ -12,6 +13,7 @@ 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\OptionEnum; /** * Mock model for testing. @@ -75,4 +77,73 @@ public function setConfig(ModelConfig $config): void { $this->config = $config; } + + /** + * {@inheritDoc} + */ + public function getCapabilities(): array + { + return [ + 'input' => $this->extractModalities(OptionEnum::inputModalities()), + 'output' => $this->extractModalities(OptionEnum::outputModalities()), + ]; + } + + /** + * {@inheritDoc} + */ + public function supportsInput(ModalityEnum $modality): bool + { + foreach ($this->extractModalities(OptionEnum::inputModalities()) as $supported) { + if ($supported->value === $modality->value) { + return true; + } + } + return false; + } + + /** + * {@inheritDoc} + */ + public function supportsOutput(ModalityEnum $modality): bool + { + foreach ($this->extractModalities(OptionEnum::outputModalities()) as $supported) { + if ($supported->value === $modality->value) { + return true; + } + } + return false; + } + + /** + * Extracts modality instances from the metadata's supported options. + * + * @param OptionEnum $optionKey The option key to look up. + * @return list The list of modalities. + */ + private function extractModalities(OptionEnum $optionKey): array + { + foreach ($this->metadata->getSupportedOptions() as $supportedOption) { + if ($supportedOption->getName()->value !== $optionKey->value) { + continue; + } + + $values = $supportedOption->getSupportedValues(); + if ($values === null) { + return []; + } + + $modalities = []; + foreach ($values as $value) { + if ($value instanceof ModalityEnum) { + $modalities[] = $value; + } elseif (is_string($value)) { + $modalities[] = ModalityEnum::from($value); + } + } + return $modalities; + } + + return []; + } } diff --git a/tests/traits/MockModelCreationTrait.php b/tests/traits/MockModelCreationTrait.php index d330c518..64bb0564 100644 --- a/tests/traits/MockModelCreationTrait.php +++ b/tests/traits/MockModelCreationTrait.php @@ -6,6 +6,7 @@ use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\ModelMessage; +use WordPress\AiClient\Messages\Enums\ModalityEnum; use WordPress\AiClient\Providers\DTO\ProviderMetadata; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; @@ -193,6 +194,21 @@ public function getConfig(): ModelConfig return $this->config; } + public function getCapabilities(): array + { + return ['input' => [], 'output' => []]; + } + + public function supportsInput(ModalityEnum $modality): bool + { + return false; + } + + public function supportsOutput(ModalityEnum $modality): bool + { + return false; + } + public function generateTextResult(array $prompt): GenerativeAiResult { return $this->result; @@ -260,6 +276,21 @@ public function getConfig(): ModelConfig return $this->config; } + public function getCapabilities(): array + { + return ['input' => [], 'output' => []]; + } + + public function supportsInput(ModalityEnum $modality): bool + { + return false; + } + + public function supportsOutput(ModalityEnum $modality): bool + { + return false; + } + public function generateImageResult(array $prompt): GenerativeAiResult { return $this->result; @@ -327,6 +358,21 @@ public function getConfig(): ModelConfig return $this->config; } + public function getCapabilities(): array + { + return ['input' => [], 'output' => []]; + } + + public function supportsInput(ModalityEnum $modality): bool + { + return false; + } + + public function supportsOutput(ModalityEnum $modality): bool + { + return false; + } + public function generateVideoResult(array $prompt): GenerativeAiResult { return $this->result; diff --git a/tests/unit/Builders/PromptBuilderTest.php b/tests/unit/Builders/PromptBuilderTest.php index ce68a223..5667717e 100644 --- a/tests/unit/Builders/PromptBuilderTest.php +++ b/tests/unit/Builders/PromptBuilderTest.php @@ -138,6 +138,21 @@ public function getConfig(): ModelConfig return $this->config; } + public function getCapabilities(): array + { + return ['input' => [], 'output' => []]; + } + + public function supportsInput(ModalityEnum $modality): bool + { + return false; + } + + public function supportsOutput(ModalityEnum $modality): bool + { + return false; + } + public function generateSpeechResult(array $prompt): GenerativeAiResult { return $this->result; @@ -201,6 +216,21 @@ public function getConfig(): ModelConfig return $this->config; } + public function getCapabilities(): array + { + return ['input' => [], 'output' => []]; + } + + public function supportsInput(ModalityEnum $modality): bool + { + return false; + } + + public function supportsOutput(ModalityEnum $modality): bool + { + return false; + } + public function generateVideoResult(array $prompt): GenerativeAiResult { return $this->result; @@ -264,6 +294,21 @@ public function getConfig(): ModelConfig return $this->config; } + public function getCapabilities(): array + { + return ['input' => [], 'output' => []]; + } + + public function supportsInput(ModalityEnum $modality): bool + { + return false; + } + + public function supportsOutput(ModalityEnum $modality): bool + { + return false; + } + public function convertTextToSpeechResult(array $prompt): GenerativeAiResult { return $this->result; @@ -2130,6 +2175,21 @@ public function getConfig(): ModelConfig return $this->config; } + public function getCapabilities(): array + { + return ['input' => [], 'output' => []]; + } + + public function supportsInput(ModalityEnum $modality): bool + { + return false; + } + + public function supportsOutput(ModalityEnum $modality): bool + { + return false; + } + public function generateTextResult(array $prompt): GenerativeAiResult { throw new RuntimeException('No candidates were generated'); @@ -2319,6 +2379,21 @@ public function getConfig(): ModelConfig return $this->config; } + public function getCapabilities(): array + { + return ['input' => [], 'output' => []]; + } + + public function supportsInput(ModalityEnum $modality): bool + { + return false; + } + + public function supportsOutput(ModalityEnum $modality): bool + { + return false; + } + public function generateTextResult(array $prompt): GenerativeAiResult { throw new RuntimeException('No text was generated from any candidates'); diff --git a/tests/unit/Providers/ApiBasedImplementation/ModelCapabilitiesTest.php b/tests/unit/Providers/ApiBasedImplementation/ModelCapabilitiesTest.php new file mode 100644 index 00000000..78b1c38e --- /dev/null +++ b/tests/unit/Providers/ApiBasedImplementation/ModelCapabilitiesTest.php @@ -0,0 +1,467 @@ +providerMetadata = $this->createStub(ProviderMetadata::class); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Builds a MockApiBasedModel with the given supported options. + * + * @param list $options + * @return MockApiBasedModel + */ + private function makeModel(array $options): MockApiBasedModel + { + $metadata = new ModelMetadata( + 'test-model', + 'Test Model', + [CapabilityEnum::textGeneration()], + $options + ); + return new MockApiBasedModel($metadata, $this->providerMetadata); + } + + // ------------------------------------------------------------------------- + // getCapabilities() + // ------------------------------------------------------------------------- + + /** + * Tests that getCapabilities() returns the correct structure when both + * input and output modalities are defined using ModalityEnum instances. + * + * @return void + */ + public function testGetCapabilitiesReturnsBothModalities(): void + { + $model = $this->makeModel([ + new SupportedOption( + OptionEnum::inputModalities(), + [ModalityEnum::text(), ModalityEnum::image()] + ), + new SupportedOption( + OptionEnum::outputModalities(), + [ModalityEnum::text()] + ), + ]); + + $capabilities = $model->getCapabilities(); + + $this->assertArrayHasKey('input', $capabilities); + $this->assertArrayHasKey('output', $capabilities); + + $this->assertCount(2, $capabilities['input']); + $this->assertSame(ModalityEnum::TEXT, $capabilities['input'][0]->value); + $this->assertSame(ModalityEnum::IMAGE, $capabilities['input'][1]->value); + + $this->assertCount(1, $capabilities['output']); + $this->assertSame(ModalityEnum::TEXT, $capabilities['output'][0]->value); + } + + /** + * Tests that getCapabilities() returns empty lists when no modality options are defined. + * + * @return void + */ + public function testGetCapabilitiesReturnsEmptyListsWhenNoModalitiesDefined(): void + { + $model = $this->makeModel([]); + + $capabilities = $model->getCapabilities(); + + $this->assertArrayHasKey('input', $capabilities); + $this->assertArrayHasKey('output', $capabilities); + $this->assertSame([], $capabilities['input']); + $this->assertSame([], $capabilities['output']); + } + + /** + * Tests that getCapabilities() returns an empty input list when only + * output modalities are present. + * + * @return void + */ + public function testGetCapabilitiesWithOnlyOutputModalitiesDefined(): void + { + $model = $this->makeModel([ + new SupportedOption( + OptionEnum::outputModalities(), + [ModalityEnum::audio()] + ), + ]); + + $capabilities = $model->getCapabilities(); + + $this->assertSame([], $capabilities['input']); + $this->assertCount(1, $capabilities['output']); + $this->assertSame(ModalityEnum::AUDIO, $capabilities['output'][0]->value); + } + + /** + * Tests that getCapabilities() returns an empty output list when only + * input modalities are present. + * + * @return void + */ + public function testGetCapabilitiesWithOnlyInputModalitiesDefined(): void + { + $model = $this->makeModel([ + new SupportedOption( + OptionEnum::inputModalities(), + [ModalityEnum::text(), ModalityEnum::audio()] + ), + ]); + + $capabilities = $model->getCapabilities(); + + $this->assertCount(2, $capabilities['input']); + $this->assertSame([], $capabilities['output']); + } + + /** + * Tests that getCapabilities() converts string values (e.g. from + * deserialized metadata) to ModalityEnum instances. + * + * @return void + */ + public function testGetCapabilitiesConvertsStringValuesToModalityEnum(): void + { + $model = $this->makeModel([ + new SupportedOption( + OptionEnum::inputModalities(), + [ModalityEnum::TEXT, ModalityEnum::IMAGE] // plain strings + ), + new SupportedOption( + OptionEnum::outputModalities(), + [ModalityEnum::TEXT] + ), + ]); + + $capabilities = $model->getCapabilities(); + + $this->assertInstanceOf(ModalityEnum::class, $capabilities['input'][0]); + $this->assertSame(ModalityEnum::TEXT, $capabilities['input'][0]->value); + $this->assertInstanceOf(ModalityEnum::class, $capabilities['output'][0]); + } + + /** + * Tests that getCapabilities() returns empty lists when the supported + * values for a modality option is null (i.e. "any value accepted"). + * + * @return void + */ + public function testGetCapabilitiesReturnsEmptyListWhenSupportedValuesIsNull(): void + { + $model = $this->makeModel([ + new SupportedOption(OptionEnum::inputModalities(), null), + new SupportedOption(OptionEnum::outputModalities(), null), + ]); + + $capabilities = $model->getCapabilities(); + + $this->assertSame([], $capabilities['input']); + $this->assertSame([], $capabilities['output']); + } + + /** + * Tests getCapabilities() with all available ModalityEnum values. + * + * @return void + */ + public function testGetCapabilitiesWithAllModalityValues(): void + { + $allModalities = [ + ModalityEnum::text(), + ModalityEnum::document(), + ModalityEnum::image(), + ModalityEnum::audio(), + ModalityEnum::video(), + ]; + + $model = $this->makeModel([ + new SupportedOption(OptionEnum::inputModalities(), $allModalities), + new SupportedOption(OptionEnum::outputModalities(), $allModalities), + ]); + + $capabilities = $model->getCapabilities(); + + $this->assertCount(5, $capabilities['input']); + $this->assertCount(5, $capabilities['output']); + } + + // ------------------------------------------------------------------------- + // supportsInput() + // ------------------------------------------------------------------------- + + /** + * Tests that supportsInput() returns true for a modality the model supports. + * + * @return void + */ + public function testSupportsInputReturnsTrueForSupportedModality(): void + { + $model = $this->makeModel([ + new SupportedOption( + OptionEnum::inputModalities(), + [ModalityEnum::text(), ModalityEnum::image()] + ), + ]); + + $this->assertTrue($model->supportsInput(ModalityEnum::text())); + $this->assertTrue($model->supportsInput(ModalityEnum::image())); + } + + /** + * Tests that supportsInput() returns false for a modality not in the list. + * + * @return void + */ + public function testSupportsInputReturnsFalseForUnsupportedModality(): void + { + $model = $this->makeModel([ + new SupportedOption( + OptionEnum::inputModalities(), + [ModalityEnum::text()] + ), + ]); + + $this->assertFalse($model->supportsInput(ModalityEnum::audio())); + $this->assertFalse($model->supportsInput(ModalityEnum::video())); + $this->assertFalse($model->supportsInput(ModalityEnum::image())); + } + + /** + * Tests that supportsInput() returns false when no input modalities are defined. + * + * @return void + */ + public function testSupportsInputReturnsFalseWhenNoModalitiesDefined(): void + { + $model = $this->makeModel([]); + + $this->assertFalse($model->supportsInput(ModalityEnum::text())); + } + + /** + * Tests that supportsInput() does NOT check output modalities. + * + * @return void + */ + public function testSupportsInputDoesNotCheckOutputModalities(): void + { + $model = $this->makeModel([ + new SupportedOption( + OptionEnum::outputModalities(), + [ModalityEnum::audio()] + ), + ]); + + // audio is only in output, supportsInput should return false + $this->assertFalse($model->supportsInput(ModalityEnum::audio())); + } + + // ------------------------------------------------------------------------- + // supportsOutput() + // ------------------------------------------------------------------------- + + /** + * Tests that supportsOutput() returns true for a modality the model supports. + * + * @return void + */ + public function testSupportsOutputReturnsTrueForSupportedModality(): void + { + $model = $this->makeModel([ + new SupportedOption( + OptionEnum::outputModalities(), + [ModalityEnum::text(), ModalityEnum::audio()] + ), + ]); + + $this->assertTrue($model->supportsOutput(ModalityEnum::text())); + $this->assertTrue($model->supportsOutput(ModalityEnum::audio())); + } + + /** + * Tests that supportsOutput() returns false for a modality not in the list. + * + * @return void + */ + public function testSupportsOutputReturnsFalseForUnsupportedModality(): void + { + $model = $this->makeModel([ + new SupportedOption( + OptionEnum::outputModalities(), + [ModalityEnum::text()] + ), + ]); + + $this->assertFalse($model->supportsOutput(ModalityEnum::image())); + $this->assertFalse($model->supportsOutput(ModalityEnum::video())); + $this->assertFalse($model->supportsOutput(ModalityEnum::audio())); + } + + /** + * Tests that supportsOutput() returns false when no output modalities are defined. + * + * @return void + */ + public function testSupportsOutputReturnsFalseWhenNoModalitiesDefined(): void + { + $model = $this->makeModel([]); + + $this->assertFalse($model->supportsOutput(ModalityEnum::text())); + } + + /** + * Tests that supportsOutput() does NOT check input modalities. + * + * @return void + */ + public function testSupportsOutputDoesNotCheckInputModalities(): void + { + $model = $this->makeModel([ + new SupportedOption( + OptionEnum::inputModalities(), + [ModalityEnum::image()] + ), + ]); + + // image is only in input, supportsOutput should return false + $this->assertFalse($model->supportsOutput(ModalityEnum::image())); + } + + // ------------------------------------------------------------------------- + // ModelInterface contract + // ------------------------------------------------------------------------- + + /** + * Tests that the model instance correctly implements ModelInterface. + * + * @return void + */ + public function testImplementsModelInterface(): void + { + $model = $this->makeModel([]); + + $this->assertIsArray($model->getCapabilities()); + $this->assertArrayHasKey('input', $model->getCapabilities()); + $this->assertArrayHasKey('output', $model->getCapabilities()); + } + + /** + * Tests a realistic text-generation model scenario (text+image → text). + * + * @return void + */ + public function testRealisticTextGenerationModel(): void + { + $model = $this->makeModel([ + new SupportedOption( + OptionEnum::inputModalities(), + [ModalityEnum::text(), ModalityEnum::image()] + ), + new SupportedOption( + OptionEnum::outputModalities(), + [ModalityEnum::text()] + ), + ]); + + $capabilities = $model->getCapabilities(); + + // Verify full capabilities shape + $this->assertCount(2, $capabilities['input']); + $this->assertCount(1, $capabilities['output']); + + // Input: text, image + $this->assertTrue($model->supportsInput(ModalityEnum::text())); + $this->assertTrue($model->supportsInput(ModalityEnum::image())); + $this->assertFalse($model->supportsInput(ModalityEnum::audio())); + $this->assertFalse($model->supportsInput(ModalityEnum::video())); + + // Output: text only + $this->assertTrue($model->supportsOutput(ModalityEnum::text())); + $this->assertFalse($model->supportsOutput(ModalityEnum::image())); + $this->assertFalse($model->supportsOutput(ModalityEnum::audio())); + } + + /** + * Tests a realistic text-to-speech model (text → audio). + * + * @return void + */ + public function testRealisticTextToSpeechModel(): void + { + $model = $this->makeModel([ + new SupportedOption( + OptionEnum::inputModalities(), + [ModalityEnum::text()] + ), + new SupportedOption( + OptionEnum::outputModalities(), + [ModalityEnum::audio()] + ), + ]); + + $this->assertTrue($model->supportsInput(ModalityEnum::text())); + $this->assertFalse($model->supportsInput(ModalityEnum::audio())); + + $this->assertTrue($model->supportsOutput(ModalityEnum::audio())); + $this->assertFalse($model->supportsOutput(ModalityEnum::text())); + } + + /** + * Tests a realistic audio transcription model (audio → text). + * + * @return void + */ + public function testRealisticAudioTranscriptionModel(): void + { + $model = $this->makeModel([ + new SupportedOption( + OptionEnum::inputModalities(), + [ModalityEnum::audio()] + ), + new SupportedOption( + OptionEnum::outputModalities(), + [ModalityEnum::text()] + ), + ]); + + $this->assertTrue($model->supportsInput(ModalityEnum::audio())); + $this->assertFalse($model->supportsInput(ModalityEnum::text())); + + $this->assertTrue($model->supportsOutput(ModalityEnum::text())); + $this->assertFalse($model->supportsOutput(ModalityEnum::audio())); + } +}