diff --git a/src/Builders/PromptBuilder.php b/src/Builders/PromptBuilder.php index da08aa81..40d4505d 100644 --- a/src/Builders/PromptBuilder.php +++ b/src/Builders/PromptBuilder.php @@ -15,6 +15,7 @@ use WordPress\AiClient\Messages\Enums\ModalityEnum; 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\DTO\ModelRequirements; use WordPress\AiClient\Providers\Models\DTO\RequiredOption; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; @@ -59,6 +60,11 @@ class PromptBuilder */ protected ?ModelInterface $model = null; + /** + * @var list Ordered list of preference keys to check when selecting a model. + */ + protected array $modelPreferenceKeys = []; + /** * @var string|null The provider ID or class name. */ @@ -216,9 +222,73 @@ public function usingModel(ModelInterface $model): self } /** - * Sets the model configuration. + * Sets preferred models to evaluate in order. + * + * @since n.e.x.t * - * Merges the provided configuration with the builder's configuration, + * @param string|ModelInterface|array{0:string,1:string} ...$preferredModels The preferred models as model IDs, + * model instances, or [model ID, provider ID] tuples. + * @return self + * + * @throws InvalidArgumentException When a preferred model has an invalid type or identifier. + */ + public function usingModelPreference(...$preferredModels): self + { + if ($preferredModels === []) { + throw new InvalidArgumentException('At least one model preference must be provided.'); + } + + $preferenceKeys = []; + + foreach ($preferredModels as $preferredModel) { + if (is_array($preferredModel)) { + // [model identifier, provider ID] tuple + if (!array_is_list($preferredModel) || count($preferredModel) !== 2) { + throw new InvalidArgumentException( + 'Model preference tuple must contain model identifier and provider ID.' + ); + } + + [$modelIdentifier, $providerId] = $preferredModel; + + $modelId = $this->normalizePreferenceIdentifier($modelIdentifier); + $providerId = $this->normalizePreferenceIdentifier( + $providerId, + 'Model preference provider identifiers cannot be empty.' + ); + + $preferenceKey = $this->createProviderModelPreferenceKey($providerId, $modelId); + } elseif ($preferredModel instanceof ModelInterface) { + // Model instance + $modelId = $preferredModel->metadata()->getId(); + $providerId = $preferredModel->providerMetadata()->getId(); + + $preferenceKey = $this->createProviderModelPreferenceKey($providerId, $modelId); + } elseif (is_string($preferredModel)) { + // Model ID + $modelId = $this->normalizePreferenceIdentifier($preferredModel); + + $preferenceKey = $this->createModelPreferenceKey($modelId); + } else { + // Invalid type + throw new InvalidArgumentException( + 'Model preferences must be model identifiers, instances of ModelInterface, ' . + 'or provider/model tuples.' + ); + } + + $preferenceKeys[] = $preferenceKey; + } + + $this->modelPreferenceKeys = $preferenceKeys; + + return $this; + } + + /** + * Sets the model configuration. + * + * Merges the provided configuration with the builder's configuration, * with builder configuration taking precedence. * * @since 0.1.0 @@ -1040,67 +1110,174 @@ private function getConfiguredModel(CapabilityEnum $capability): ModelInterface { $requirements = ModelRequirements::fromPromptData($capability, $this->messages, $this->modelConfig); - // If a model has been explicitly set, return it if ($this->model !== null) { + // Explicit model was provided via usingModel(); just update config and bind dependencies. $this->model->setConfig($this->modelConfig); $this->registry->bindModelDependencies($this->model); return $this->model; } - // Find a suitable model based on requirements - if ($this->providerIdOrClassName === null) { - $providerModelsMetadata = $this->registry->findModelsMetadataForSupport($requirements); + // Retrieve the candidate models map which satisfies the requirements. + $candidateMap = $this->getCandidateModelsMap($requirements); - if (empty($providerModelsMetadata)) { - throw new InvalidArgumentException( - sprintf( - 'No models found that support the required capabilities and options for this prompt. ' . - 'Required capabilities: %s. Required options: %s', - implode(', ', array_map(function ($cap) { - return $cap->value; - }, $requirements->getRequiredCapabilities())), - implode(', ', array_map(function ($opt) { - return $opt->getName()->value . '=' . json_encode($opt->getValue()); - }, $requirements->getRequiredOptions())) - ) + if (empty($candidateMap)) { + $message = sprintf( + 'No models found that support %s for this prompt.', + $capability->value + ); + + if ($this->providerIdOrClassName !== null) { + $message = sprintf( + 'No models found for provider "%s" that support %s for this prompt.', + $this->providerIdOrClassName, + $capability->value ); } - $firstProviderModels = $providerModelsMetadata[0]; - $provider = $firstProviderModels->getProvider()->getId(); - $modelMetadata = $firstProviderModels->getModels()[0]; - } else { - $modelsMetadata = $this->registry->findProviderModelsMetadataForSupport( - $this->providerIdOrClassName, - $requirements + throw new InvalidArgumentException($message); + } + + // Check if any preferred models match the candidates, in priority order. + if (!empty($this->modelPreferenceKeys)) { + // Find preferences that match available candidates, preserving preference order. + $matchingPreferences = array_intersect_key( + array_flip($this->modelPreferenceKeys), + $candidateMap ); - if (empty($modelsMetadata)) { - throw new InvalidArgumentException( - sprintf( - 'No models found for %s that support the required capabilities and options for this prompt. ' . - 'Required capabilities: %s. Required options: %s', - $this->providerIdOrClassName, - implode(', ', array_map(function ($cap) { - return $cap->value; - }, $requirements->getRequiredCapabilities())), - implode(', ', array_map(function ($opt) { - return $opt->getName()->value . '=' . json_encode($opt->getValue()); - }, $requirements->getRequiredOptions())) - ) - ); + if (!empty($matchingPreferences)) { + // Get the first matching preference key + $firstMatchKey = key($matchingPreferences); + [$providerId, $modelId] = $candidateMap[$firstMatchKey]; + + return $this->registry->getProviderModel($providerId, $modelId, $this->modelConfig); } + } + + // No preference matched; fall back to the first candidate discovered. + [$providerId, $modelId] = reset($candidateMap); - $provider = $this->providerIdOrClassName; - $modelMetadata = $modelsMetadata[0]; + return $this->registry->getProviderModel($providerId, $modelId, $this->modelConfig); + } + + /** + * Builds a map of candidate models that satisfy the requirements for efficient lookup. + * + * @since n.e.x.t + * + * @param ModelRequirements $requirements The requirements derived from the prompt. + * @return array Map of preference keys to [providerId, modelId] tuples. + */ + private function getCandidateModelsMap(ModelRequirements $requirements): array + { + if ($this->providerIdOrClassName === null) { + // No provider locked in, gather all models across providers that meet requirements. + $providerModelsMetadata = $this->registry->findModelsMetadataForSupport($requirements); + + $candidateMap = []; + foreach ($providerModelsMetadata as $providerModels) { + $providerId = $providerModels->getProvider()->getId(); + $providerMap = $this->generateMapFromCandidates($providerId, $providerModels->getModels()); + + // Use + operator to merge, preserving keys from $candidateMap (first provider wins for model-only keys) + $candidateMap = $candidateMap + $providerMap; + } + + return $candidateMap; } - // Get the model instance from the provider - return $this->registry->getProviderModel( - $provider, - $modelMetadata->getId(), - $this->modelConfig + // Provider set, only consider models from that provider. + $modelsMetadata = $this->registry->findProviderModelsMetadataForSupport( + $this->providerIdOrClassName, + $requirements ); + + // Ensure we pass the provider ID, not the class name + $providerId = $this->registry->getProviderId($this->providerIdOrClassName); + + return $this->generateMapFromCandidates($providerId, $modelsMetadata); + } + + /** + * Generates a candidate map from model metadata with both provider-specific and model-only keys. + * + * @since n.e.x.t + * + * @param string $providerId The provider ID. + * @param list $modelsMetadata The models metadata to map. + * @return array Map of preference keys to [providerId, modelId] tuples. + */ + private function generateMapFromCandidates(string $providerId, array $modelsMetadata): array + { + $map = []; + + foreach ($modelsMetadata as $modelMetadata) { + $modelId = $modelMetadata->getId(); + + // Add provider-specific key + $providerModelKey = $this->createProviderModelPreferenceKey($providerId, $modelId); + $map[$providerModelKey] = [$providerId, $modelId]; + + // Add model-only key + $modelKey = $this->createModelPreferenceKey($modelId); + $map[$modelKey] = [$providerId, $modelId]; + } + + return $map; + } + + /** + * Normalizes and validates a preference identifier string. + * + * @since n.e.x.t + * + * @param mixed $value The value to normalize. + * @param string $emptyMessage The message for empty or invalid values. + * @return string The normalized identifier. + * + * @throws InvalidArgumentException If the value is not a non-empty string. + */ + private function normalizePreferenceIdentifier( + $value, + string $emptyMessage = 'Model preference identifiers cannot be empty.' + ): string { + if (!is_string($value)) { + throw new InvalidArgumentException($emptyMessage); + } + + $trimmed = trim($value); + if ($trimmed === '') { + throw new InvalidArgumentException($emptyMessage); + } + + return $trimmed; + } + + /** + * Creates a preference key for a provider/model combination. + * + * @since n.e.x.t + * + * @param string $providerId The provider identifier. + * @param string $modelId The model identifier. + * @return string The generated preference key. + */ + private function createProviderModelPreferenceKey(string $providerId, string $modelId): string + { + return 'providerModel::' . $providerId . '::' . $modelId; + } + + /** + * Creates a preference key for a model identifier. + * + * @since n.e.x.t + * + * @param string $modelId The model identifier. + * @return string The generated preference key. + */ + private function createModelPreferenceKey(string $modelId): string + { + return 'model::' . $modelId; } /** diff --git a/src/Providers/ProviderRegistry.php b/src/Providers/ProviderRegistry.php index 0312fb01..00935042 100644 --- a/src/Providers/ProviderRegistry.php +++ b/src/Providers/ProviderRegistry.php @@ -159,6 +159,37 @@ public function getProviderClassName(string $id): string return $this->providerClassNames[$id]; } + /** + * Gets the provider ID for a registered provider. + * + * @since n.e.x.t + * + * @param string|class-string $idOrClassName The provider ID or class name. + * @return string The provider ID. + * @throws InvalidArgumentException If the provider is not registered. + */ + public function getProviderId(string $idOrClassName): string + { + // If it's already an ID, return it + if (isset($this->providerClassNames[$idOrClassName])) { + return $idOrClassName; + } + + // If it's a class name, find its ID + if (isset($this->registeredClassNames[$idOrClassName])) { + foreach ($this->providerClassNames as $id => $className) { + if ($className === $idOrClassName) { + return $id; + } + } + } + + // Not found + throw new InvalidArgumentException( + sprintf('Provider not registered: %s', $idOrClassName) + ); + } + /** * Checks if a provider is properly configured. * diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index dc4b3c81..8aef3f0b 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -395,7 +395,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'); + $this->expectExceptionMessage('No models found that support text_generation for this prompt.'); AiClient::generateResult($prompt, null, $this->createMockEmptyRegistry()); } @@ -441,7 +441,7 @@ public function testGenerateResultWithModelConfigDelegatesToPromptBuilder(): voi $config->setMaxTokens(100); $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('No models found that support the required capabilities'); + $this->expectExceptionMessage('No models found that support text_generation for this prompt.'); AiClient::generateResult($prompt, $config, $this->createMockEmptyRegistry()); } @@ -613,7 +613,7 @@ function () { $this->fail("Expected InvalidArgumentException for configuration $index"); } catch (\InvalidArgumentException $e) { $this->assertStringContainsString( - 'No models found that support the required capabilities', + 'No models found that support text_generation for this prompt.', $e->getMessage(), "Configuration $index should delegate to PromptBuilder properly" ); @@ -630,7 +630,7 @@ public function testEmptyModelConfig(): void $emptyConfig = new ModelConfig(); $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('No models found that support the required capabilities'); + $this->expectExceptionMessage('No models found that support text_generation for this prompt.'); AiClient::generateResult($prompt, $emptyConfig, $this->createMockEmptyRegistry()); } diff --git a/tests/unit/Builders/PromptBuilderTest.php b/tests/unit/Builders/PromptBuilderTest.php index b2d699a6..dceb8bdb 100644 --- a/tests/unit/Builders/PromptBuilderTest.php +++ b/tests/unit/Builders/PromptBuilderTest.php @@ -18,6 +18,7 @@ use WordPress\AiClient\Messages\Enums\MessageRoleEnum; use WordPress\AiClient\Messages\Enums\ModalityEnum; use WordPress\AiClient\Providers\DTO\ProviderMetadata; +use WordPress\AiClient\Providers\DTO\ProviderModelsMetadata; use WordPress\AiClient\Providers\Enums\ProviderTypeEnum; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; @@ -61,6 +62,25 @@ private function createTestProviderMetadata(): ProviderMetadata return new ProviderMetadata('test-provider', 'Test Provider', ProviderTypeEnum::cloud()); } + /** + * Creates text model metadata supporting any input modalities. + * + * @param string $id The model identifier. + * @return ModelMetadata + */ + private function createTextModelMetadataWithInputSupport(string $id): ModelMetadata + { + return new ModelMetadata( + $id, + 'Test Text Model', + [CapabilityEnum::textGeneration()], + [ + new SupportedOption(OptionEnum::inputModalities()), + new SupportedOption(OptionEnum::outputModalities()), + ] + ); + } + /** * Creates a mock model that implements both ModelInterface and SpeechGenerationModelInterface. * @@ -587,6 +607,367 @@ public function testUsingModel(): void $this->assertSame($model, $actualModel); } + /** + * Tests usingModelPreference selects provided model instance when requirements are met. + * + * @return void + */ + public function testUsingModelPreferenceWithModelInstance(): void + { + $result = $this->createTestResult('Preferred model result'); + $metadata = $this->createTextModelMetadataWithInputSupport('preferred-model'); + $model = $this->createMockTextGenerationModel($result, $metadata); + $providerMetadata = $model->providerMetadata(); + + $this->registry->expects($this->once()) + ->method('findModelsMetadataForSupport') + ->with($this->isInstanceOf(ModelRequirements::class)) + ->willReturn([new ProviderModelsMetadata($providerMetadata, [$metadata])]); + + $this->registry->expects($this->once()) + ->method('getProviderModel') + ->with($providerMetadata->getId(), 'preferred-model', $this->isInstanceOf(ModelConfig::class)) + ->willReturn($model); + + $this->registry->expects($this->never()) + ->method('findProviderModelsMetadataForSupport'); + + $builder = new PromptBuilder($this->registry, 'Test prompt'); + $builder->usingModelPreference($model); + + $actualResult = $builder->generateTextResult(); + + $this->assertSame($result, $actualResult); + + $reflection = new \ReflectionClass($builder); + $modelProperty = $reflection->getProperty('model'); + $modelProperty->setAccessible(true); + + $this->assertNull($modelProperty->getValue($builder)); + } + + /** + * Tests usingModelPreference supports provider/model tuple with string identifier. + * + * @return void + */ + public function testUsingModelPreferenceWithProviderTupleSelectsModel(): void + { + $result = $this->createTestResult('Tuple preferred result'); + $preferredMetadata = $this->createTextModelMetadataWithInputSupport('preferred-model'); + $otherMetadata = $this->createTextModelMetadataWithInputSupport('other-model'); + $model = $this->createMockTextGenerationModel($result, $preferredMetadata); + + $preferredProviderMetadata = new ProviderMetadata( + 'preferred-provider', + 'Preferred Provider', + ProviderTypeEnum::cloud() + ); + + $otherProviderMetadata = new ProviderMetadata( + 'other-provider', + 'Other Provider', + ProviderTypeEnum::cloud() + ); + + $this->registry->expects($this->once()) + ->method('findModelsMetadataForSupport') + ->with($this->isInstanceOf(ModelRequirements::class)) + ->willReturn([ + new ProviderModelsMetadata($preferredProviderMetadata, [$preferredMetadata]), + new ProviderModelsMetadata($otherProviderMetadata, [$otherMetadata]), + ]); + + $this->registry->expects($this->once()) + ->method('getProviderModel') + ->with('preferred-provider', 'preferred-model', $this->isInstanceOf(ModelConfig::class)) + ->willReturn($model); + + $this->registry->expects($this->never()) + ->method('findProviderModelsMetadataForSupport'); + + $builder = new PromptBuilder($this->registry, 'Test prompt'); + $builder->usingModelPreference(['preferred-model', 'preferred-provider']); + + $actualResult = $builder->generateTextResult(); + + $this->assertSame($result, $actualResult); + } + + /** + * Tests usingModelPreference rejects provider/model tuples that contain a model instance. + * + * @return void + */ + public function testUsingModelPreferenceWithProviderTupleModelInstanceThrowsException(): void + { + $metadata = $this->createTextModelMetadataWithInputSupport('preferred-model'); + $model = $this->createMockTextGenerationModel($this->createTestResult(), $metadata); + + $builder = new PromptBuilder($this->registry, 'Test prompt'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Model preference provider identifiers cannot be empty.'); + + $builder->usingModelPreference(['mock', $model]); + } + + /** + * Tests usingModelPreference selects the first available model ID for the configured provider. + * + * @return void + */ + public function testUsingModelPreferencePrefersFirstAvailableModelId(): void + { + $result = $this->createTestResult('Preferred by ID'); + $metadata = $this->createTextModelMetadataWithInputSupport('preferred-id'); + $model = $this->createMockTextGenerationModel($result, $metadata); + + $this->registry->expects($this->once()) + ->method('getProviderId') + ->with('test-provider') + ->willReturn('test-provider'); + + $this->registry->expects($this->once()) + ->method('findProviderModelsMetadataForSupport') + ->with('test-provider', $this->isInstanceOf(ModelRequirements::class)) + ->willReturn([$metadata]); + + $this->registry->expects($this->once()) + ->method('getProviderModel') + ->with('test-provider', 'preferred-id', $this->isInstanceOf(ModelConfig::class)) + ->willReturn($model); + + $this->registry->expects($this->never()) + ->method('findModelsMetadataForSupport'); + + $builder = new PromptBuilder($this->registry, 'Test prompt'); + $builder->usingProvider('test-provider'); + $builder->usingModelPreference('preferred-id', 'secondary-id'); + + $actualResult = $builder->generateTextResult(); + + $this->assertSame($result, $actualResult); + } + + /** + * Tests usingModelPreference with provider class name instead of ID. + * + * @return void + */ + public function testUsingModelPreferenceWithProviderClassName(): void + { + $result = $this->createTestResult('Preferred with class name'); + $metadata = $this->createTextModelMetadataWithInputSupport('preferred-id'); + $model = $this->createMockTextGenerationModel($result, $metadata); + + $this->registry->expects($this->once()) + ->method('getProviderId') + ->with('WordPress\AiClient\TestProvider') + ->willReturn('test-provider'); + + $this->registry->expects($this->once()) + ->method('findProviderModelsMetadataForSupport') + ->with('WordPress\AiClient\TestProvider', $this->isInstanceOf(ModelRequirements::class)) + ->willReturn([$metadata]); + + $this->registry->expects($this->once()) + ->method('getProviderModel') + ->with('test-provider', 'preferred-id', $this->isInstanceOf(ModelConfig::class)) + ->willReturn($model); + + $this->registry->expects($this->never()) + ->method('findModelsMetadataForSupport'); + + $builder = new PromptBuilder($this->registry, 'Test prompt'); + $builder->usingProvider('WordPress\AiClient\TestProvider'); + $builder->usingModelPreference('preferred-id', 'secondary-id'); + + $actualResult = $builder->generateTextResult(); + + $this->assertSame($result, $actualResult); + } + + /** + * Tests usingModelPreference skips unavailable model IDs and falls back to the next preference. + * + * @return void + */ + public function testUsingModelPreferenceSkipsUnavailableModelId(): void + { + $result = $this->createTestResult('Fallback model result'); + $metadata = $this->createTextModelMetadataWithInputSupport('fallback-id'); + $model = $this->createMockTextGenerationModel($result, $metadata); + + $this->registry->expects($this->once()) + ->method('getProviderId') + ->with('test-provider') + ->willReturn('test-provider'); + + $this->registry->expects($this->once()) + ->method('findProviderModelsMetadataForSupport') + ->with('test-provider', $this->isInstanceOf(ModelRequirements::class)) + ->willReturn([$metadata]); + + $this->registry->expects($this->once()) + ->method('getProviderModel') + ->with('test-provider', 'fallback-id', $this->isInstanceOf(ModelConfig::class)) + ->willReturn($model); + + $this->registry->expects($this->never()) + ->method('findModelsMetadataForSupport'); + + $builder = new PromptBuilder($this->registry, 'Test prompt'); + $builder->usingProvider('test-provider'); + $builder->usingModelPreference('missing-id', 'fallback-id'); + + $actualResult = $builder->generateTextResult(); + + $this->assertSame($result, $actualResult); + } + + /** + * Tests usingModelPreference falls back to discovery when no preferences are available. + * + * @return void + */ + public function testUsingModelPreferenceFallsBackToDiscovery(): void + { + $result = $this->createTestResult('Discovered model result'); + $metadata = $this->createTextModelMetadataWithInputSupport('discovered-id'); + $providerMetadata = $this->createTestProviderMetadata(); + $providerModelsMetadata = new ProviderModelsMetadata($providerMetadata, [$metadata]); + + $model = $this->createMockTextGenerationModel($result, $metadata); + + $this->registry->expects($this->once()) + ->method('findModelsMetadataForSupport') + ->with($this->isInstanceOf(ModelRequirements::class)) + ->willReturn([$providerModelsMetadata]); + + $this->registry->expects($this->once()) + ->method('getProviderModel') + ->with($providerMetadata->getId(), 'discovered-id', $this->isInstanceOf(ModelConfig::class)) + ->willReturn($model); + + $this->registry->expects($this->never()) + ->method('findProviderModelsMetadataForSupport'); + + $builder = new PromptBuilder($this->registry, 'Test prompt'); + $builder->usingModelPreference('unavailable-model'); + + $actualResult = $builder->generateTextResult(); + + $this->assertSame($result, $actualResult); + } + + /** + * Tests usingModelPreference respects priority order when multiple preferred models are available. + * + * @return void + */ + public function testUsingModelPreferenceRespectsOrderWhenMultipleAvailable(): void + { + $result = $this->createTestResult('Second choice result'); + $secondChoiceMetadata = $this->createTextModelMetadataWithInputSupport('second-choice'); + $thirdChoiceMetadata = $this->createTextModelMetadataWithInputSupport('third-choice'); + $providerMetadata = $this->createTestProviderMetadata(); + + $model = $this->createMockTextGenerationModel($result, $secondChoiceMetadata); + + // Make both second-choice and third-choice available (but not first-choice) + $providerModelsMetadata = new ProviderModelsMetadata( + $providerMetadata, + [$thirdChoiceMetadata, $secondChoiceMetadata] // Order shouldn't matter + ); + + $this->registry->expects($this->once()) + ->method('findModelsMetadataForSupport') + ->with($this->isInstanceOf(ModelRequirements::class)) + ->willReturn([$providerModelsMetadata]); + + // Should select 'second-choice' (respecting preference order), not 'third-choice' + $this->registry->expects($this->once()) + ->method('getProviderModel') + ->with($providerMetadata->getId(), 'second-choice', $this->isInstanceOf(ModelConfig::class)) + ->willReturn($model); + + $this->registry->expects($this->never()) + ->method('findProviderModelsMetadataForSupport'); + + $builder = new PromptBuilder($this->registry, 'Test prompt'); + // Preferences in order: first-choice, second-choice, third-choice + // Available: second-choice, third-choice + // Expected: second-choice (respects priority) + $builder->usingModelPreference('first-choice', 'second-choice', 'third-choice'); + + $actualResult = $builder->generateTextResult(); + + $this->assertSame($result, $actualResult); + } + + /** + * Tests usingModelPreference rejects invalid preference types. + * + * @return void + */ + public function testUsingModelPreferenceWithInvalidTypeThrowsException(): void + { + $builder = new PromptBuilder($this->registry); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Model preferences must be model identifiers, instances of ModelInterface, or provider/model tuples.' + ); + + $builder->usingModelPreference(123); + } + + /** + * Tests usingModelPreference rejects malformed preference tuples. + * + * @return void + */ + public function testUsingModelPreferenceWithInvalidTupleThrowsException(): void + { + $builder = new PromptBuilder($this->registry); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Model preference tuple must contain model identifier and provider ID.'); + + $builder->usingModelPreference(['provider' => 'test', 'model' => 'id']); + } + + /** + * Tests usingModelPreference rejects empty preference identifier strings. + * + * @return void + */ + public function testUsingModelPreferenceWithEmptyIdentifierThrowsException(): void + { + $builder = new PromptBuilder($this->registry); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Model preference identifiers cannot be empty.'); + + $builder->usingModelPreference(' '); + } + + /** + * Tests usingModelPreference rejects calls without preferences. + * + * @return void + */ + public function testUsingModelPreferenceWithoutArgumentsThrowsException(): void + { + $builder = new PromptBuilder($this->registry); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('At least one model preference must be provided.'); + + $builder->usingModelPreference(); + } + /** * Tests usingModelConfig method. * @@ -2386,25 +2767,6 @@ public function testGenerateImageResultCreatesProperOperation(): void $this->assertTrue($outputModalities[0]->isImage()); } - /** - * Tests generateAudioResult method creates proper operation. - * - * @return void - */ - public function testGenerateAudioResultCreatesProperOperation(): void - { - $this->markTestSkipped('generateAudioResult method does not exist yet'); - } - - /** - * Tests generateVideoResult method creates proper operation. - * - * @return void - */ - public function testGenerateVideoResultCreatesProperOperation(): void - { - $this->markTestSkipped('generateVideoResult method does not exist yet'); - } /** * Tests generateImage shorthand method returns file directly. @@ -2439,45 +2801,7 @@ public function testGenerateImageReturnsFileDirectly(): void $this->assertSame($file, $generatedFile); } - /** - * Tests generateAudio shorthand method returns file directly. - * - * @return void - */ - public function testGenerateAudioReturnsFileDirectly(): void - { - $this->markTestSkipped('generateAudio method does not exist yet'); - } - - /** - * Tests generateVideo shorthand method returns file directly. - * - * @return void - */ - public function testGenerateVideoReturnsFileDirectly(): void - { - $this->markTestSkipped('generateVideo method does not exist yet'); - } - - /** - * Tests generation method with multiple output modalities. - * - * @return void - */ - public function testGenerationWithMultipleOutputModalities(): void - { - $this->markTestSkipped('Operations-based generation not implemented yet'); - } - /** - * Tests streaming generation methods. - * - * @return void - */ - public function testStreamingGenerationMethods(): void - { - $this->markTestSkipped('Streaming methods do not exist yet'); - } /** * Tests generateText with no candidates throws exception. @@ -2539,16 +2863,6 @@ public function testGenerateTextWithNonStringPartThrowsException(): void $builder->generateText(); } - /** - * Tests chain generation with multiple prompts. - * - * @return void - */ - public function testChainGenerationWithMultiplePrompts(): void - { - $this->markTestSkipped('Complex chaining with model response methods not fully implemented yet'); - } - /** * Tests isSupportedForText convenience method. @@ -2681,6 +2995,11 @@ public function testGenerateResultWithProvider(): void $model = $this->createMockTextGenerationModel($result, $modelMetadata); // Mock the registry to return the model when provider is specified + $this->registry->expects($this->once()) + ->method('getProviderId') + ->with('test-provider') + ->willReturn('test-provider'); + $this->registry->expects($this->once()) ->method('findProviderModelsMetadataForSupport') ->with('test-provider', $this->isInstanceOf(ModelRequirements::class)) @@ -2698,6 +3017,43 @@ public function testGenerateResultWithProvider(): void $this->assertSame($result, $actualResult); } + /** + * Tests generateResult with provider class name specified. + * + * @return void + */ + public function testGenerateResultWithProviderClassName(): void + { + $result = $this->createMock(GenerativeAiResult::class); + + $modelMetadata = $this->createMock(ModelMetadata::class); + $modelMetadata->method('getId')->willReturn('provider-model'); + + $model = $this->createMockTextGenerationModel($result, $modelMetadata); + + // Mock the registry to return the provider ID when given a class name + $this->registry->expects($this->once()) + ->method('getProviderId') + ->with('WordPress\AiClient\TestProvider') + ->willReturn('test-provider'); + + $this->registry->expects($this->once()) + ->method('findProviderModelsMetadataForSupport') + ->with('WordPress\AiClient\TestProvider', $this->isInstanceOf(ModelRequirements::class)) + ->willReturn([$modelMetadata]); + + $this->registry->expects($this->once()) + ->method('getProviderModel') + ->with('test-provider', 'provider-model', $this->isInstanceOf(ModelConfig::class)) + ->willReturn($model); + + $builder = new PromptBuilder($this->registry, 'Test prompt'); + $builder->usingProvider('WordPress\AiClient\TestProvider'); + + $actualResult = $builder->generateResult(); + $this->assertSame($result, $actualResult); + } + /** * Tests generateResult with provider but no suitable models. * @@ -2715,7 +3071,9 @@ public function testGenerateResultWithProviderNoModelsThrowsException(): void $builder->usingProvider('test-provider'); $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('No models found for test-provider that support the required capabilities'); + $this->expectExceptionMessage( + 'No models found for provider "test-provider" that support text_generation for this prompt.' + ); $builder->generateResult(); }