From 2101882f2d6d734643a09dc214f83a9acec7837b Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Wed, 12 Aug 2026 16:49:37 -0600 Subject: [PATCH 01/11] Add a new getUnmetRequirements method that will tell us which requirements are unmet so we can provide a more specific error message to a user --- .../Models/DTO/ModelRequirements.php | 48 +++-- .../Models/DTO/ModelRequirementsTest.php | 193 ++++++++++++++++++ 2 files changed, 229 insertions(+), 12 deletions(-) diff --git a/src/Providers/Models/DTO/ModelRequirements.php b/src/Providers/Models/DTO/ModelRequirements.php index 492e5fd8..2441b2e4 100644 --- a/src/Providers/Models/DTO/ModelRequirements.php +++ b/src/Providers/Models/DTO/ModelRequirements.php @@ -27,6 +27,11 @@ * requiredOptions: list * } * + * @phpstan-type UnmetModelRequirementsShape array{ + * capabilities: list, + * options: list + * } + * * @extends AbstractDataTransferObject */ class ModelRequirements extends AbstractDataTransferObject @@ -101,6 +106,25 @@ public function getRequiredOptions(): array * @return bool True if the model meets all requirements, false otherwise. */ public function areMetBy(ModelMetadata $metadata): bool + { + $unmetRequirements = $this->getUnmetRequirements($metadata); + + return $unmetRequirements['capabilities'] === [] && $unmetRequirements['options'] === []; + } + + /** + * Determines which of these requirements the given model metadata does not meet. + * + * Unlike {@see self::areMetBy()}, this method reports the specific capabilities and options that + * are unsupported, so that calling code can explain why a model is unsuitable. + * + * @since n.e.x.t + * + * @param ModelMetadata $metadata The model metadata to check against. + * @return UnmetModelRequirementsShape The unsupported capabilities and options. Both lists are empty if the + * model meets all requirements. + */ + public function getUnmetRequirements(ModelMetadata $metadata): array { // Create lookup maps for better performance (instead of nested foreach loops) $capabilitiesMap = []; @@ -113,29 +137,29 @@ public function areMetBy(ModelMetadata $metadata): bool $optionsMap[$option->getName()->value] = $option; } - // Check if all required capabilities are supported using map lookup + // Collect required capabilities that are not supported, using map lookup + $unmetCapabilities = []; foreach ($this->requiredCapabilities as $requiredCapability) { if (!isset($capabilitiesMap[$requiredCapability->value])) { - return false; + $unmetCapabilities[] = $requiredCapability; } } - // Check if all required options are supported with the specified values + // Collect required options that are either unsupported or unsupported with the required value + $unmetOptions = []; foreach ($this->requiredOptions as $requiredOption) { // Use map lookup instead of linear search - if (!isset($optionsMap[$requiredOption->getName()->value])) { - return false; - } - - $supportedOption = $optionsMap[$requiredOption->getName()->value]; + $supportedOption = $optionsMap[$requiredOption->getName()->value] ?? null; - // Check if the required value is supported by this option - if (!$supportedOption->isSupportedValue($requiredOption->getValue())) { - return false; + if ($supportedOption === null || !$supportedOption->isSupportedValue($requiredOption->getValue())) { + $unmetOptions[] = $requiredOption; } } - return true; + return [ + 'capabilities' => $unmetCapabilities, + 'options' => $unmetOptions, + ]; } /** diff --git a/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php b/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php index ddc8f157..16efdddd 100644 --- a/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php +++ b/tests/unit/Providers/Models/DTO/ModelRequirementsTest.php @@ -493,6 +493,199 @@ public function testAreMetByWithNoRequirements(): void $this->assertTrue($requirements->areMetBy($metadata)); } + /** + * Tests areMetBy method with an option the model does not support at all. + * + * @return void + */ + public function testAreMetByWithUnsupportedOptionName(): void + { + $requirements = new ModelRequirements( + [CapabilityEnum::embeddingGeneration()], + [new RequiredOption(OptionEnum::dimensions(), 256)] + ); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getSupportedCapabilities')->willReturn([ + CapabilityEnum::embeddingGeneration() + ]); + $metadata->method('getSupportedOptions')->willReturn([]); + + $this->assertFalse($requirements->areMetBy($metadata)); + } + + /** + * Tests getUnmetRequirements method with matching capabilities and options. + * + * @return void + */ + public function testGetUnmetRequirementsWithMatchingRequirements(): void + { + $requirements = new ModelRequirements( + [CapabilityEnum::textGeneration(), CapabilityEnum::chatHistory()], + [new RequiredOption(OptionEnum::temperature(), 0.7)] + ); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getSupportedCapabilities')->willReturn([ + CapabilityEnum::textGeneration(), + CapabilityEnum::chatHistory() + ]); + $metadata->method('getSupportedOptions')->willReturn([ + new SupportedOption(OptionEnum::temperature(), [0.1, 0.7, 1.0]) + ]); + + $unmetRequirements = $requirements->getUnmetRequirements($metadata); + + $this->assertSame([], $unmetRequirements['capabilities']); + $this->assertSame([], $unmetRequirements['options']); + $this->assertTrue($requirements->areMetBy($metadata)); + } + + /** + * Tests getUnmetRequirements method with no requirements. + * + * @return void + */ + public function testGetUnmetRequirementsWithNoRequirements(): void + { + $requirements = new ModelRequirements([], []); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getSupportedCapabilities')->willReturn([]); + $metadata->method('getSupportedOptions')->willReturn([]); + + $unmetRequirements = $requirements->getUnmetRequirements($metadata); + + $this->assertSame([], $unmetRequirements['capabilities']); + $this->assertSame([], $unmetRequirements['options']); + } + + /** + * Tests getUnmetRequirements method reports a capability the model does not support. + * + * @return void + */ + public function testGetUnmetRequirementsWithMissingCapability(): void + { + $requirements = new ModelRequirements( + [CapabilityEnum::textGeneration(), CapabilityEnum::imageGeneration()], + [] + ); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getSupportedCapabilities')->willReturn([ + CapabilityEnum::textGeneration() + ]); + $metadata->method('getSupportedOptions')->willReturn([]); + + $unmetRequirements = $requirements->getUnmetRequirements($metadata); + + $this->assertCount(1, $unmetRequirements['capabilities']); + $this->assertSame( + CapabilityEnum::IMAGE_GENERATION, + $unmetRequirements['capabilities'][0]->value + ); + $this->assertSame([], $unmetRequirements['options']); + $this->assertFalse($requirements->areMetBy($metadata)); + } + + /** + * Tests getUnmetRequirements method reports an option the model does not support at all. + * + * @return void + */ + public function testGetUnmetRequirementsWithUnsupportedOptionName(): void + { + $requirements = new ModelRequirements( + [CapabilityEnum::embeddingGeneration()], + [new RequiredOption(OptionEnum::dimensions(), 256)] + ); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getSupportedCapabilities')->willReturn([ + CapabilityEnum::embeddingGeneration() + ]); + $metadata->method('getSupportedOptions')->willReturn([]); + + $unmetRequirements = $requirements->getUnmetRequirements($metadata); + + $this->assertSame([], $unmetRequirements['capabilities']); + $this->assertCount(1, $unmetRequirements['options']); + $this->assertTrue($unmetRequirements['options'][0]->getName()->isDimensions()); + $this->assertSame(256, $unmetRequirements['options'][0]->getValue()); + $this->assertFalse($requirements->areMetBy($metadata)); + } + + /** + * Tests getUnmetRequirements method reports an option the model does not support with the required value. + * + * @return void + */ + public function testGetUnmetRequirementsWithUnsupportedOptionValue(): void + { + $requirements = new ModelRequirements( + [CapabilityEnum::textGeneration()], + [new RequiredOption(OptionEnum::temperature(), 0.5)] + ); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getSupportedCapabilities')->willReturn([ + CapabilityEnum::textGeneration() + ]); + $metadata->method('getSupportedOptions')->willReturn([ + new SupportedOption(OptionEnum::temperature(), [0.1, 0.7, 1.0]) + ]); + + $unmetRequirements = $requirements->getUnmetRequirements($metadata); + + $this->assertSame([], $unmetRequirements['capabilities']); + $this->assertCount(1, $unmetRequirements['options']); + $this->assertTrue($unmetRequirements['options'][0]->getName()->isTemperature()); + $this->assertSame(0.5, $unmetRequirements['options'][0]->getValue()); + $this->assertFalse($requirements->areMetBy($metadata)); + } + + /** + * Tests getUnmetRequirements method reports every unmet requirement rather than stopping at the first. + * + * @return void + */ + public function testGetUnmetRequirementsReportsAllUnmetRequirements(): void + { + $requirements = new ModelRequirements( + [CapabilityEnum::embeddingGeneration(), CapabilityEnum::chatHistory()], + [ + new RequiredOption(OptionEnum::dimensions(), 256), + new RequiredOption(OptionEnum::temperature(), 0.5), + new RequiredOption(OptionEnum::maxTokens(), 1000) + ] + ); + + $metadata = $this->createMock(ModelMetadata::class); + $metadata->method('getSupportedCapabilities')->willReturn([]); + $metadata->method('getSupportedOptions')->willReturn([ + new SupportedOption(OptionEnum::maxTokens(), null) + ]); + + $unmetRequirements = $requirements->getUnmetRequirements($metadata); + + $this->assertCount(2, $unmetRequirements['capabilities']); + $this->assertSame( + [CapabilityEnum::EMBEDDING_GENERATION, CapabilityEnum::CHAT_HISTORY], + array_map( + fn($capability) => $capability->value, + $unmetRequirements['capabilities'] + ) + ); + + // maxTokens is supported with any value, so only dimensions and temperature are reported. + $this->assertCount(2, $unmetRequirements['options']); + $this->assertTrue($unmetRequirements['options'][0]->getName()->isDimensions()); + $this->assertTrue($unmetRequirements['options'][1]->getName()->isTemperature()); + $this->assertFalse($requirements->areMetBy($metadata)); + } + /** * Tests fromPromptData method with simple text generation. * From f74749fd2aab5974ea78dcabacf4e35594f3b746 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Wed, 12 Aug 2026 16:59:23 -0600 Subject: [PATCH 02/11] Add new ModelConfigurationTrait that pulls some functionality out of ModelResolutionTrait to better support changes we need in the EmbeddingBuilder --- .../Traits/ModelConfigurationTrait.php | 62 +++++++++++++++++++ src/Builders/Traits/ModelResolutionTrait.php | 49 +++------------ 2 files changed, 70 insertions(+), 41 deletions(-) create mode 100644 src/Builders/Traits/ModelConfigurationTrait.php diff --git a/src/Builders/Traits/ModelConfigurationTrait.php b/src/Builders/Traits/ModelConfigurationTrait.php new file mode 100644 index 00000000..242bcd26 --- /dev/null +++ b/src/Builders/Traits/ModelConfigurationTrait.php @@ -0,0 +1,62 @@ +mergeModelConfig($config); + + return $this; + } + + /** + * Merges the given configuration into the builder's configuration. + * + * The builder's own configuration takes precedence for any overlapping settings, so that + * explicitly configured values are never overwritten by defaults from another source. + * + * @since n.e.x.t + * + * @param ModelConfig $config The model configuration to merge. + * @return void + */ + protected function mergeModelConfig(ModelConfig $config): void + { + // Merge arrays with builder config taking precedence + $mergedArray = array_merge($config->toArray(), $this->modelConfig->toArray()); + + // Create new config from merged array + $this->modelConfig = ModelConfig::fromArray($mergedArray); + } +} diff --git a/src/Builders/Traits/ModelResolutionTrait.php b/src/Builders/Traits/ModelResolutionTrait.php index 08780903..831833bb 100644 --- a/src/Builders/Traits/ModelResolutionTrait.php +++ b/src/Builders/Traits/ModelResolutionTrait.php @@ -8,24 +8,21 @@ use WordPress\AiClient\Providers\Http\DTO\RequestOptions; use WordPress\AiClient\Providers\ModelResolver; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; -use WordPress\AiClient\Providers\Models\DTO\ModelConfig; /** - * Provides shared model selection and configuration methods for builders. + * Provides shared model selection methods for builders. * - * Builders that generate results from a model (e.g. {@see \WordPress\AiClient\Builders\PromptBuilder} - * and {@see \WordPress\AiClient\Builders\EmbeddingBuilder}) use this trait to expose a consistent - * fluent API for choosing a model, provider, preferences, request options, and model configuration. - * Model selection state and logic live on the {@see ModelResolver} owned by the builder. + * Builders that resolve a model from the available providers (e.g. + * {@see \WordPress\AiClient\Builders\PromptBuilder}) use this trait to expose a consistent fluent API + * for choosing a model, provider, preferences, and request options. Model selection state and logic + * live on the {@see ModelResolver} owned by the builder. Model configuration is handled by the + * composed {@see ModelConfigurationTrait}. * * @since 1.4.0 */ trait ModelResolutionTrait { - /** - * @var ModelConfig The model configuration. - */ - protected ModelConfig $modelConfig; + use ModelConfigurationTrait; /** * @var ModelResolver The resolver that owns model selection state and logic. @@ -48,11 +45,7 @@ public function usingModel(ModelInterface $model): self $this->modelResolver->setModel($model); // Merge model's config with builder's config, with builder's config taking precedence - $modelConfigArray = $model->getConfig()->toArray(); - $builderConfigArray = $this->modelConfig->toArray(); - $mergedConfigArray = array_merge($modelConfigArray, $builderConfigArray); - - $this->modelConfig = ModelConfig::fromArray($mergedConfigArray); + $this->mergeModelConfig($model->getConfig()); return $this; } @@ -77,32 +70,6 @@ public function usingModelPreference(...$preferredModels): self return $this; } - /** - * Sets the model configuration. - * - * Merges the provided configuration with the builder's configuration, - * with builder configuration taking precedence. - * - * @since 0.1.0 - * - * @param ModelConfig $config The model configuration to merge. - * @return self - */ - public function usingModelConfig(ModelConfig $config): self - { - // Convert both configs to arrays - $builderConfigArray = $this->modelConfig->toArray(); - $providedConfigArray = $config->toArray(); - - // Merge arrays with builder config taking precedence - $mergedArray = array_merge($providedConfigArray, $builderConfigArray); - - // Create new config from merged array - $this->modelConfig = ModelConfig::fromArray($mergedArray); - - return $this; - } - /** * Sets the provider to use for generation. * From 98e4d4443dd4a1c4544113b672cc5b016065f93b Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Wed, 12 Aug 2026 17:12:07 -0600 Subject: [PATCH 03/11] Remove model resolution out of the EmbeddingBuilder. Add new methods that will ensure the model provided is valid and will work for the request. Update our helpers in the AiClient to require a model be passed --- src/AiClient.php | 103 ++++-- src/Builders/EmbeddingBuilder.php | 360 +++++++++++++++++-- tests/traits/MockModelCreationTrait.php | 19 +- tests/unit/AiClientTest.php | 71 +++- tests/unit/Builders/EmbeddingBuilderTest.php | 293 ++++++++++++--- 5 files changed, 736 insertions(+), 110 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index 066bfa64..0119554e 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -250,6 +250,10 @@ public static function prompt($prompt = null, ?ProviderRegistry $registry = null * embedding generation method such as generateEmbedding() or generateEmbeddings() to produce * one vector per input. * + * A model must be specified on the returned builder via usingModel() or usingProviderModel(). + * Embedding vectors are only comparable to other vectors from the same model, so no model is + * selected automatically. + * * @since 1.4.0 * * @param EmbeddingInput|list|null $input Optional initial input(s) to embed. @@ -415,67 +419,80 @@ public static function generateVideoResult( /** * Generates embeddings using the traditional API approach. * + * A model is required. Embedding vectors are only comparable to other vectors from the same + * model, so no model is selected automatically. + * * @since 1.4.0 * * @param EmbeddingInput|list $input The input(s) to embed. - * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use, - * or model configuration for auto-discovery, - * or null for defaults. + * @param ModelInterface|array{0: string, 1: string} $model The model to use, either as an + * instance or as a + * [provider ID, model ID] tuple. + * @param ModelConfig|null $modelConfig Optional model configuration to apply. * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. * @return EmbeddingResult The embedding result. * - * @throws \InvalidArgumentException If the input format is invalid. - * @throws \RuntimeException If no suitable model is found. + * @throws \InvalidArgumentException If the input or model format is invalid, or if the model + * cannot fulfill the request. */ public static function generateEmbeddingResult( $input, - $modelOrConfig = null, + $model, + ?ModelConfig $modelConfig = null, ?ProviderRegistry $registry = null ): EmbeddingResult { - self::validateModelOrConfigParameter($modelOrConfig); - return self::applyModelOrConfig(self::input($input, $registry), $modelOrConfig) + return self::getConfiguredEmbeddingBuilder($input, $model, $modelConfig, $registry) ->generateEmbeddingResult(); } /** * Generates an embedding using the traditional API approach. * + * A model is required. Embedding vectors are only comparable to other vectors from the same + * model, so no model is selected automatically. + * * @since 1.4.0 * * @param EmbeddingInput $input The input to embed. - * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use, - * or model configuration for auto-discovery, - * or null for defaults. + * @param ModelInterface|array{0: string, 1: string} $model The model to use, either as an + * instance or as a + * [provider ID, model ID] tuple. + * @param ModelConfig|null $modelConfig Optional model configuration to apply. * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. * @return Embedding The generated embedding vector. */ public static function generateEmbedding( $input, - $modelOrConfig = null, + $model, + ?ModelConfig $modelConfig = null, ?ProviderRegistry $registry = null ): Embedding { - return self::generateEmbeddingResult($input, $modelOrConfig, $registry)->getEmbedding(); + return self::generateEmbeddingResult($input, $model, $modelConfig, $registry)->getEmbedding(); } /** * Generates embeddings for a list of inputs using the traditional API approach. * + * A model is required. Embedding vectors are only comparable to other vectors from the same + * model, so no model is selected automatically. + * * @since 1.4.0 * * @param list $inputs The inputs to embed. - * @param ModelInterface|ModelConfig|null $modelOrConfig Optional specific model to use, - * or model configuration for auto-discovery, - * or null for defaults. + * @param ModelInterface|array{0: string, 1: string} $model The model to use, either as an + * instance or as a + * [provider ID, model ID] tuple. + * @param ModelConfig|null $modelConfig Optional model configuration to apply. * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. * @return list The generated embedding vectors. */ public static function generateEmbeddings( array $inputs, - $modelOrConfig = null, + $model, + ?ModelConfig $modelConfig = null, ?ProviderRegistry $registry = null ): array { - self::validateModelOrConfigParameter($modelOrConfig); - return self::applyModelOrConfig(self::input($inputs, $registry), $modelOrConfig) + return self::getConfiguredEmbeddingBuilder($inputs, $model, $modelConfig, $registry) ->generateEmbeddings(); } @@ -539,13 +556,59 @@ private static function getConfiguredPromptBuilder( return self::applyModelOrConfig(self::prompt($prompt, $registry), $modelOrConfig); } + /** + * Configures an EmbeddingBuilder with the required model and optional configuration. + * + * @param EmbeddingInput|list $input The input(s) to embed. + * @param mixed $model The model to use, either as a ModelInterface instance or as a + * [provider ID, model ID] tuple. + * @param ModelConfig|null $modelConfig Optional model configuration to apply. + * @param ProviderRegistry|null $registry Optional custom registry to use. + * @return EmbeddingBuilder Configured embedding builder. + * @throws InvalidArgumentException If the model parameter is not of a supported type. + */ + private static function getConfiguredEmbeddingBuilder( + $input, + $model, + ?ModelConfig $modelConfig = null, + ?ProviderRegistry $registry = null + ): EmbeddingBuilder { + $builder = self::input($input, $registry); + + // Apply the configuration first, so that it takes precedence over the model's own config. + if ($modelConfig !== null) { + $builder->usingModelConfig($modelConfig); + } + + if ($model instanceof ModelInterface) { + return $builder->usingModel($model); + } + + if ( + is_array($model) + && array_is_list($model) + && count($model) === 2 + && is_string($model[0]) + && is_string($model[1]) + ) { + return $builder->usingProviderModel($model[0], $model[1]); + } + + throw new InvalidArgumentException( + 'Model must be a ModelInterface instance or a [provider ID, model ID] tuple. ' . + 'Embeddings are only comparable to other embeddings from the same model, so a model ' . + 'is required. ' . + sprintf('Received: %s', is_object($model) ? get_class($model) : gettype($model)) + ); + } + /** * Applies a model or model configuration to a builder. * * Works with any builder that exposes the shared model resolution methods * (see {@see \WordPress\AiClient\Builders\Traits\ModelResolutionTrait}). * - * @template T of PromptBuilder|EmbeddingBuilder + * @template T of PromptBuilder * * @param T $builder The builder to configure. * @param ModelInterface|ModelConfig|null $modelOrConfig Specific model, model configuration, diff --git a/src/Builders/EmbeddingBuilder.php b/src/Builders/EmbeddingBuilder.php index 2cca2a02..51f39641 100644 --- a/src/Builders/EmbeddingBuilder.php +++ b/src/Builders/EmbeddingBuilder.php @@ -5,16 +5,20 @@ namespace WordPress\AiClient\Builders; use Psr\EventDispatcher\EventDispatcherInterface; -use WordPress\AiClient\Builders\Traits\ModelResolutionTrait; +use WordPress\AiClient\Builders\Traits\ModelConfigurationTrait; +use WordPress\AiClient\Common\AbstractEnum; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Events\AfterGenerateEmbeddingEvent; use WordPress\AiClient\Events\BeforeGenerateEmbeddingEvent; use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\DTO\MessagePart; -use WordPress\AiClient\Providers\ModelResolver; +use WordPress\AiClient\Providers\ApiBasedImplementation\Contracts\ApiBasedModelInterface; +use WordPress\AiClient\Providers\Http\DTO\RequestOptions; +use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelRequirements; +use WordPress\AiClient\Providers\Models\DTO\RequiredOption; use WordPress\AiClient\Providers\Models\EmbeddingGeneration\Contracts\EmbeddingGenerationModelInterface; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; use WordPress\AiClient\Providers\ProviderRegistry; @@ -26,24 +30,51 @@ * * Embeddings transform inputs into vector representations rather than generating a conversational * response. Each input is embedded independently, and the builder produces one embedding vector per - * input. Model selection and configuration are shared with {@see PromptBuilder} via the - * {@see ModelResolutionTrait}. + * input. + * + * Unlike {@see PromptBuilder}, this builder never selects a model on the caller's behalf. Embedding + * vectors are only comparable to other vectors produced by the same model, so a stored corpus is + * permanently bound to the model that created it. Automatically choosing a model could therefore + * silently invalidate existing vectors, for example when the registered providers change. The model + * must be specified via {@see self::usingModel()} or {@see self::usingProviderModel()}; the builder + * verifies that the given model can fulfill the request instead of searching for one that can. * * @since 1.4.0 * * @phpstan-import-type MessagePartArrayShape from MessagePart + * @phpstan-import-type UnmetModelRequirementsShape from ModelRequirements * * @phpstan-type EmbeddingInput string|MessagePart|File|MessagePartArrayShape */ class EmbeddingBuilder { - use ModelResolutionTrait; + use ModelConfigurationTrait; + + /** + * @var ProviderRegistry The provider registry used to prepare the model. + */ + protected ProviderRegistry $registry; /** * @var list The inputs to embed. */ protected array $inputs = []; + /** + * @var ModelInterface|null The explicitly provided model, if any. + */ + protected ?ModelInterface $model = null; + + /** + * @var array{0: string, 1: string}|null The provider ID or class name and model ID, if any. + */ + protected ?array $providerModel = null; + + /** + * @var RequestOptions|null The request options for HTTP transport. + */ + protected ?RequestOptions $requestOptions = null; + /** * @var EventDispatcherInterface|null The event dispatcher for embedding lifecycle events. */ @@ -54,7 +85,7 @@ class EmbeddingBuilder * * @since 1.4.0 * - * @param ProviderRegistry $registry The provider registry for finding suitable models. + * @param ProviderRegistry $registry The provider registry used to prepare the model. * @param EmbeddingInput|list|null $input Optional initial input(s) to embed. * @param EventDispatcherInterface|null $eventDispatcher Optional event dispatcher for lifecycle events. */ @@ -63,8 +94,8 @@ public function __construct( $input = null, ?EventDispatcherInterface $eventDispatcher = null ) { + $this->registry = $registry; $this->modelConfig = new ModelConfig(); - $this->modelResolver = new ModelResolver($registry); $this->eventDispatcher = $eventDispatcher; if ($input === null) { @@ -84,8 +115,8 @@ public function __construct( /** * Creates a deep clone of this builder. * - * Clones the inputs and model configuration. Service objects (registry, event dispatcher) are - * intentionally NOT cloned as they are shared dependencies. + * Clones the inputs, model configuration, and request options. Service objects (registry, model, + * event dispatcher) are intentionally NOT cloned as they are shared dependencies. * * @since 1.4.0 */ @@ -98,10 +129,13 @@ public function __clone() $this->inputs = $clonedInputs; $this->modelConfig = clone $this->modelConfig; - $this->modelResolver = clone $this->modelResolver; - // Note: $eventDispatcher is a service object and is intentionally NOT - // cloned - it should be a shared reference. + if ($this->requestOptions !== null) { + $this->requestOptions = clone $this->requestOptions; + } + + // Note: $registry, $model, and $eventDispatcher are service objects and are intentionally + // NOT cloned - they should be shared references. } /** @@ -126,6 +160,73 @@ public function withInput(...$input): self return $this; } + /** + * Sets the model to use for embedding generation. + * + * The model's configuration will be merged with the builder's configuration, + * with the builder's configuration taking precedence for any overlapping settings. + * + * @since 1.4.0 + * + * @param ModelInterface $model The model to use. + * @return self + */ + public function usingModel(ModelInterface $model): self + { + $this->model = $model; + $this->providerModel = null; + + // Merge model's config with builder's config, with builder's config taking precedence + $this->mergeModelConfig($model->getConfig()); + + return $this; + } + + /** + * Sets the model to use for embedding generation, by provider and model identifier. + * + * This is an alternative to {@see self::usingModel()} that does not require the caller to + * reference a provider class directly. The model is retrieved from the registry when the + * embeddings are generated, so it receives any configuration set afterwards. + * + * @since n.e.x.t + * + * @param string $providerIdOrClassName The provider ID or class name. + * @param string $modelId The model identifier. + * @return self + * @throws InvalidArgumentException If the provider or model identifier is empty. + */ + public function usingProviderModel(string $providerIdOrClassName, string $modelId): self + { + if (trim($providerIdOrClassName) === '') { + throw new InvalidArgumentException('Provider identifier cannot be empty.'); + } + + if (trim($modelId) === '') { + throw new InvalidArgumentException('Model identifier cannot be empty.'); + } + + $this->providerModel = [$providerIdOrClassName, $modelId]; + $this->model = null; + + return $this; + } + + /** + * Sets the request options for HTTP transport. + * + * @since 1.4.0 + * + * @param RequestOptions $requestOptions The request options. + * @return self + */ + public function usingRequestOptions(RequestOptions $requestOptions): self + { + $this->requestOptions = $requestOptions; + + return $this; + } + /** * Sets the embedding dimensions. * @@ -141,17 +242,28 @@ public function usingDimensions(int $dimensions): self } /** - * Checks whether the current inputs and configuration are supported by an available model. + * Checks whether the specified model supports the current inputs and configuration. + * + * As of version 1.5.0 this reports whether the model set via {@see self::usingModel()} or + * {@see self::usingProviderModel()} can fulfill the request, rather than whether any available + * model can. Because a model is mandatory, this method throws if none was specified. * * @since 1.4.0 * - * @return bool True if a suitable embedding model is available. + * @return bool True if the specified model supports embedding generation for the current + * inputs and configuration. + * @throws InvalidArgumentException If no model was specified, the model's provider is not + * configured, or the model could not be retrieved. */ public function isSupported(): bool { - $requirements = ModelRequirements::fromEmbeddingData($this->inputs, $this->modelConfig); + $model = $this->prepareModel(); + + if (!$model instanceof EmbeddingGenerationModelInterface) { + return false; + } - return $this->modelResolver->isSupported($requirements); + return $this->describeUnmetRequirements($model) === null; } /** @@ -160,9 +272,10 @@ public function isSupported(): bool * @since 1.4.0 * * @return EmbeddingResult The generated embedding result. - * @throws InvalidArgumentException If no inputs are configured or model validation fails. - * @throws RuntimeException If the model doesn't support embedding generation, or returns an - * embedding count that does not match the number of inputs. + * @throws InvalidArgumentException If no inputs are configured, no model was specified, or the + * specified model cannot fulfill the request. + * @throws RuntimeException If the model returns an embedding count that does not match the + * number of inputs. */ public function generateEmbeddingResult(): EmbeddingResult { @@ -173,17 +286,7 @@ public function generateEmbeddingResult(): EmbeddingResult } $capability = CapabilityEnum::embeddingGeneration(); - $requirements = ModelRequirements::fromEmbeddingData($this->inputs, $this->modelConfig); - $model = $this->modelResolver->resolve($requirements, $this->modelConfig); - - if (!$model instanceof EmbeddingGenerationModelInterface) { - throw new RuntimeException( - sprintf( - 'Model "%s" does not support embedding generation.', - $model->metadata()->getId() - ) - ); - } + $model = $this->resolveModel(); $this->dispatchEvent(new BeforeGenerateEmbeddingEvent($this->inputs, $model, $capability)); @@ -211,8 +314,9 @@ public function generateEmbeddingResult(): EmbeddingResult * @since 1.4.0 * * @return Embedding The generated embedding vector. - * @throws InvalidArgumentException If no inputs are configured, model validation fails, or - * multiple inputs were provided. + * @throws InvalidArgumentException If no inputs are configured, no model was specified, the + * specified model cannot fulfill the request, or multiple + * inputs were provided. */ public function generateEmbedding(): Embedding { @@ -231,15 +335,201 @@ public function generateEmbedding(): Embedding * @since 1.4.0 * * @return list The generated embedding vectors. - * @throws InvalidArgumentException If no inputs are configured or model validation fails. - * @throws RuntimeException If the model doesn't support embedding generation, or returns an - * embedding count that does not match the number of inputs. + * @throws InvalidArgumentException If no inputs are configured, no model was specified, or the + * specified model cannot fulfill the request. + * @throws RuntimeException If the model returns an embedding count that does not match the + * number of inputs. */ public function generateEmbeddings(): array { return $this->generateEmbeddingResult()->getEmbeddings(); } + /** + * Resolves the specified model and verifies it can fulfill the request. + * + * @since n.e.x.t + * + * @return ModelInterface&EmbeddingGenerationModelInterface The verified model. + * @throws InvalidArgumentException If no model was specified, the model's provider is not + * configured, or the model cannot fulfill the request. + */ + private function resolveModel(): ModelInterface + { + $model = $this->prepareModel(); + + if (!$model instanceof EmbeddingGenerationModelInterface) { + throw new InvalidArgumentException( + sprintf( + 'Model "%s" from provider "%s" does not support embedding generation.', + $model->metadata()->getId(), + $model->providerMetadata()->getId() + ) + ); + } + + $unmetRequirements = $this->describeUnmetRequirements($model); + if ($unmetRequirements !== null) { + throw new InvalidArgumentException( + sprintf( + 'Model "%s" from provider "%s" cannot fulfill this embedding request. %s', + $model->metadata()->getId(), + $model->providerMetadata()->getId(), + $unmetRequirements + ) + ); + } + + return $model; + } + + /** + * Prepares the specified model for use, without verifying that it can fulfill the request. + * + * @since n.e.x.t + * + * @return ModelInterface The prepared model, with its dependencies and configuration bound. + * @throws InvalidArgumentException If no model was specified, the model's provider is not + * configured, or the model could not be retrieved. + */ + private function prepareModel(): ModelInterface + { + if ($this->providerModel !== null) { + [$providerIdOrClassName, $modelId] = $this->providerModel; + + $this->assertProviderConfigured($providerIdOrClassName); + + // Retrieving the model also binds its provider dependencies. + $model = $this->registry->getProviderModel($providerIdOrClassName, $modelId, $this->modelConfig); + } elseif ($this->model !== null) { + $model = $this->model; + + $this->assertProviderConfigured($model->providerMetadata()->getId()); + + $model->setConfig($this->modelConfig); + $this->registry->bindModelDependencies($model); + } else { + throw new InvalidArgumentException( + 'An embedding model must be specified. Embeddings are only comparable to other ' + . 'embeddings from the same model, so no model is selected automatically. ' + . 'Use usingModel() or usingProviderModel().' + ); + } + + // Request options are only applicable to API-based models that make HTTP requests. + if ($this->requestOptions !== null && $model instanceof ApiBasedModelInterface) { + $model->setRequestOptions($this->requestOptions); + } + + return $model; + } + + /** + * Asserts that the given provider is configured and therefore usable. + * + * @since n.e.x.t + * + * @param string $providerIdOrClassName The provider ID or class name. + * @return void + * @throws InvalidArgumentException If the provider is not registered or not configured. + */ + private function assertProviderConfigured(string $providerIdOrClassName): void + { + if (!$this->registry->isProviderConfigured($providerIdOrClassName)) { + throw new InvalidArgumentException( + sprintf( + 'Provider "%s" is not registered or not configured. Ensure the provider is ' + . 'registered and its credentials are available before generating embeddings.', + $providerIdOrClassName + ) + ); + } + } + + /** + * Describes the requirements of the current request that the given model does not meet. + * + * @since n.e.x.t + * + * @param ModelInterface $model The model to check. + * @return string|null A description of the unmet requirements, or null if the model meets them all. + */ + private function describeUnmetRequirements(ModelInterface $model): ?string + { + $requirements = ModelRequirements::fromEmbeddingData($this->inputs, $this->modelConfig); + + /** @var UnmetModelRequirementsShape $unmetRequirements */ + $unmetRequirements = $requirements->getUnmetRequirements($model->metadata()); + + $descriptions = []; + + if ($unmetRequirements['capabilities'] !== []) { + $descriptions[] = sprintf( + 'Unsupported capabilities: %s.', + implode(', ', array_map( + static fn (CapabilityEnum $capability): string => $capability->value, + $unmetRequirements['capabilities'] + )) + ); + } + + if ($unmetRequirements['options'] !== []) { + $descriptions[] = sprintf( + 'Unsupported options: %s.', + implode(', ', array_map( + fn (RequiredOption $option): string => sprintf( + '%s (%s)', + $option->getName()->value, + $this->describeOptionValue($option->getValue()) + ), + $unmetRequirements['options'] + )) + ); + } + + if ($descriptions === []) { + return null; + } + + return implode(' ', $descriptions); + } + + /** + * Describes an option value for use in an error message. + * + * @since n.e.x.t + * + * @param mixed $value The option value to describe. + * @return string The human readable description. + */ + private function describeOptionValue($value): string + { + if ($value === null) { + return 'null'; + } + + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + + if ($value instanceof AbstractEnum) { + return $value->value; + } + + if (is_array($value)) { + return '[' . implode(', ', array_map( + fn ($item): string => $this->describeOptionValue($item), + $value + )) . ']'; + } + + if (is_scalar($value)) { + return (string) $value; + } + + return is_object($value) ? get_class($value) : gettype($value); + } + /** * Parses a single input into a message part. * diff --git a/tests/traits/MockModelCreationTrait.php b/tests/traits/MockModelCreationTrait.php index 17119f81..e4fb2c80 100644 --- a/tests/traits/MockModelCreationTrait.php +++ b/tests/traits/MockModelCreationTrait.php @@ -6,13 +6,16 @@ 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; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; +use WordPress\AiClient\Providers\Models\DTO\SupportedOption; use WordPress\AiClient\Providers\Models\EmbeddingGeneration\Contracts\EmbeddingGenerationModelInterface; use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; +use WordPress\AiClient\Providers\Models\Enums\OptionEnum; use WordPress\AiClient\Providers\Models\ImageGeneration\Contracts\ImageGenerationModelInterface; use WordPress\AiClient\Providers\Models\TextGeneration\Contracts\TextGenerationModelInterface; use WordPress\AiClient\Providers\Models\VideoGeneration\Contracts\VideoGenerationModelInterface; @@ -170,19 +173,31 @@ protected function createTestVideoModelMetadata( /** * Creates a test model metadata instance for embedding generation. * + * The default supported options mirror what the Google and OpenAI providers declare for their + * embedding models: text-only input, any dimensions, and any custom options. Pass an explicit + * list to simulate a model with narrower support. + * * @param string $id Optional model ID. * @param string $name Optional model name. + * @param list|null $supportedOptions Optional supported options. * @return ModelMetadata */ protected function createTestEmbeddingModelMetadata( string $id = 'test-embedding-model', - string $name = 'Test Embedding Model' + string $name = 'Test Embedding Model', + ?array $supportedOptions = null ): ModelMetadata { + $supportedOptions = $supportedOptions ?? [ + new SupportedOption(OptionEnum::inputModalities(), [[ModalityEnum::text()]]), + new SupportedOption(OptionEnum::dimensions()), + new SupportedOption(OptionEnum::customOptions()), + ]; + return new ModelMetadata( $id, $name, [CapabilityEnum::embeddingGeneration()], - [] + $supportedOptions ); } diff --git a/tests/unit/AiClientTest.php b/tests/unit/AiClientTest.php index 8eed1337..c19c882d 100644 --- a/tests/unit/AiClientTest.php +++ b/tests/unit/AiClientTest.php @@ -8,6 +8,7 @@ use RuntimeException; use WordPress\AiClient\AiClient; use WordPress\AiClient\Builders\EmbeddingBuilder; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\DTO\UserMessage; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; @@ -177,11 +178,67 @@ public function testGenerateEmbeddingResultWithStringAndModel(): void $mockModel = $this->createMockEmbeddingGenerationModel($expectedResult); $registry = $this->createRegistryWithMockProvider(); - $result = AiClient::generateEmbeddingResult($prompt, $mockModel, $registry); + $result = AiClient::generateEmbeddingResult($prompt, $mockModel, null, $registry); $this->assertSame($expectedResult, $result); } + /** + * Tests generateEmbeddingResult accepts a [provider ID, model ID] tuple. + */ + public function testGenerateEmbeddingResultWithProviderModelTuple(): void + { + $expectedResult = $this->createTestEmbeddingResult(); + $mockModel = $this->createMockEmbeddingGenerationModel($expectedResult); + + $registry = $this->createMock(ProviderRegistry::class); + $registry->method('isProviderConfigured')->willReturn(true); + $registry->expects($this->once()) + ->method('getProviderModel') + ->with('mock', 'test-embedding-model', $this->isInstanceOf(ModelConfig::class)) + ->willReturn($mockModel); + + $result = AiClient::generateEmbeddingResult( + 'Generate embedding', + ['mock', 'test-embedding-model'], + null, + $registry + ); + + $this->assertSame($expectedResult, $result); + } + + /** + * Tests generateEmbeddingResult applies the optional model configuration. + */ + public function testGenerateEmbeddingResultAppliesModelConfig(): void + { + $expectedResult = $this->createTestEmbeddingResult(); + $mockModel = $this->createMockEmbeddingGenerationModel($expectedResult); + $registry = $this->createRegistryWithMockProvider(); + + $modelConfig = new ModelConfig(); + $modelConfig->setDimensions(3); + + AiClient::generateEmbeddingResult('Generate embedding', $mockModel, $modelConfig, $registry); + + $this->assertSame(3, $mockModel->getConfig()->getDimensions()); + } + + /** + * Tests generateEmbeddingResult rejects a model parameter of an unsupported type. + */ + public function testGenerateEmbeddingResultWithInvalidModelParameter(): void + { + $registry = $this->createRegistryWithMockProvider(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Model must be a ModelInterface instance or a [provider ID, model ID] tuple.'); + + // A ModelConfig cannot identify a model, so it is no longer accepted in the model position. + AiClient::generateEmbeddingResult('Generate embedding', new ModelConfig(), null, $registry); + } + /** * Tests generateEmbedding returns the first vector. */ @@ -191,7 +248,7 @@ public function testGenerateEmbeddingReturnsFirstVector(): void $mockModel = $this->createMockEmbeddingGenerationModel($expectedResult); $registry = $this->createRegistryWithMockProvider(); - $embedding = AiClient::generateEmbedding('Generate embedding', $mockModel, $registry); + $embedding = AiClient::generateEmbedding('Generate embedding', $mockModel, null, $registry); $this->assertSame([0.1, 0.2], $embedding->getValues()); } @@ -206,7 +263,7 @@ public function testGenerateEmbeddingsReturnsBatchVectors(): void $mockModel = $this->createMockEmbeddingGenerationModel($expectedResult); $registry = $this->createRegistryWithMockProvider(); - $embeddings = AiClient::generateEmbeddings(['First prompt', 'Second prompt'], $mockModel, $registry); + $embeddings = AiClient::generateEmbeddings(['First prompt', 'Second prompt'], $mockModel, null, $registry); $this->assertSame($expectedEmbeddings, array_map( static fn ($embedding): array => $embedding->getValues(), @@ -223,10 +280,12 @@ public function testGenerateEmbeddingResultWithInvalidModel(): void $invalidModel = $this->createMockUnsupportedModel('invalid-embedding-model'); $registry = $this->createRegistryWithMockProvider(); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Model "invalid-embedding-model" does not support embedding generation.'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Model "invalid-embedding-model" from provider "mock" does not support embedding generation.' + ); - AiClient::generateEmbeddingResult($prompt, $invalidModel, $registry); + AiClient::generateEmbeddingResult($prompt, $invalidModel, null, $registry); } /** diff --git a/tests/unit/Builders/EmbeddingBuilderTest.php b/tests/unit/Builders/EmbeddingBuilderTest.php index bb40f658..2c33385b 100644 --- a/tests/unit/Builders/EmbeddingBuilderTest.php +++ b/tests/unit/Builders/EmbeddingBuilderTest.php @@ -11,13 +11,9 @@ use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\DTO\MessagePart; 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; -use WordPress\AiClient\Providers\Models\DTO\ModelMetadata; -use WordPress\AiClient\Providers\Models\DTO\ModelRequirements; +use WordPress\AiClient\Providers\Models\DTO\SupportedOption; +use WordPress\AiClient\Providers\Models\Enums\OptionEnum; use WordPress\AiClient\Providers\ProviderRegistry; use WordPress\AiClient\Tests\traits\MockModelCreationTrait; use WordPress\AiClient\Tools\DTO\FunctionResponse; @@ -38,6 +34,10 @@ protected function setUp(): void { parent::setUp(); $this->registry = $this->createMock(ProviderRegistry::class); + + // A model is mandatory, and its provider must be configured to be usable. Tests that need an + // unconfigured provider create their own registry mock. + $this->registry->method('isProviderConfigured')->willReturn(true); } /** @@ -369,79 +369,204 @@ public function testGenerateEmbeddingResultThrowsOnCountMismatch(): void */ public function testGenerateEmbeddingResultThrowsForUnsupportedModel(): void { - $metadata = $this->createMock(ModelMetadata::class); - $metadata->method('getId')->willReturn('test-model'); - - $model = $this->createMock(ModelInterface::class); - $model->method('metadata')->willReturn($metadata); + $model = $this->createMockUnsupportedModel('test-model'); $builder = new EmbeddingBuilder($this->registry, 'Generate embedding'); $builder->usingModel($model); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('Model "test-model" does not support embedding generation'); + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Model "test-model" from provider "mock" does not support embedding generation.' + ); $builder->generateEmbeddingResult(); } /** - * Tests model selection derives text input modality from the inputs. + * Tests generateEmbeddingResult throws when no model was specified. * * @return void */ - public function testModelSelectionUsesInputModalities(): void + public function testGenerateEmbeddingResultThrowsWhenNoModelSpecified(): void { - $result = $this->createTestEmbeddingResult([[0.1, 0.2], [0.3, 0.4]]); + $builder = new EmbeddingBuilder($this->registry, 'Embed this'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('An embedding model must be specified.'); + + $builder->generateEmbeddingResult(); + } + + /** + * Tests generateEmbeddingResult throws when the model's provider is not configured. + * + * @return void + */ + public function testGenerateEmbeddingResultThrowsWhenProviderNotConfigured(): void + { + $registry = $this->createMock(ProviderRegistry::class); + $registry->method('isProviderConfigured')->willReturn(false); + + $result = $this->createTestEmbeddingResult([[0.1, 0.2]]); $model = $this->createMockEmbeddingGenerationModel($result); - $modelMetadata = $this->createTestEmbeddingModelMetadata('batch-embedding-model'); - $providerMetadata = new ProviderMetadata('mock', 'Mock Provider', ProviderTypeEnum::cloud()); - $this->registry->expects($this->once()) - ->method('findModelsMetadataForSupport') - ->with($this->callback(static function (ModelRequirements $requirements): bool { - foreach ($requirements->getRequiredOptions() as $requiredOption) { - if ($requiredOption->getName()->isInputModalities()) { - return [ModalityEnum::text()] === $requiredOption->getValue(); - } - } - - return false; - })) - ->willReturn([new ProviderModelsMetadata($providerMetadata, [$modelMetadata])]); + $builder = new EmbeddingBuilder($registry, 'Embed this'); + $builder->usingModel($model); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Provider "mock" is not registered or not configured.'); + + $builder->generateEmbeddingResult(); + } + + /** + * Tests generateEmbeddingResult throws when the model does not support the configured dimensions. + * + * @return void + */ + public function testGenerateEmbeddingResultThrowsForUnsupportedOption(): void + { + // A model that supports text input, but does not advertise support for dimensions. + $metadata = $this->createTestEmbeddingModelMetadata( + 'fixed-dimensions-model', + 'Fixed Dimensions Model', + [new SupportedOption(OptionEnum::inputModalities(), [[ModalityEnum::text()]])] + ); + $model = $this->createMockEmbeddingGenerationModel( + $this->createTestEmbeddingResult([[0.1, 0.2]]), + $metadata + ); + + $builder = new EmbeddingBuilder($this->registry, 'Embed this'); + $builder->usingModel($model); + $builder->usingDimensions(256); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'Model "fixed-dimensions-model" from provider "mock" cannot fulfill this embedding request. ' + . 'Unsupported options: dimensions (256).' + ); + + $builder->generateEmbeddingResult(); + } + + /** + * Tests generateEmbeddingResult throws when the model does not support the input modality. + * + * @return void + */ + public function testGenerateEmbeddingResultThrowsForUnsupportedInputModality(): void + { + // The default embedding metadata supports text input only. + $model = $this->createMockEmbeddingGenerationModel($this->createTestEmbeddingResult([[0.1, 0.2]])); + + $builder = new EmbeddingBuilder($this->registry); + $builder->withInput(new File('https://example.com/image.jpg', 'image/jpeg')); + $builder->usingModel($model); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported options: inputModalities ([image]).'); + + $builder->generateEmbeddingResult(); + } + + /** + * Tests usingProviderModel retrieves the model from the registry. + * + * @return void + */ + public function testUsingProviderModelResolvesModelFromRegistry(): void + { + $result = $this->createTestEmbeddingResult([[0.1, 0.2]]); + $model = $this->createMockEmbeddingGenerationModel($result); $this->registry->expects($this->once()) ->method('getProviderModel') - ->with('mock', 'batch-embedding-model', $this->isInstanceOf(ModelConfig::class)) + ->with('mock', 'test-embedding-model', $this->isInstanceOf(ModelConfig::class)) ->willReturn($model); - $builder = new EmbeddingBuilder($this->registry, ['First input', 'Second input']); + $builder = new EmbeddingBuilder($this->registry, 'Embed this'); + $builder->usingProviderModel('mock', 'test-embedding-model'); - $this->assertCount(2, $builder->generateEmbeddings()); + $this->assertCount(1, $builder->generateEmbeddings()); } /** - * Tests model preferences are honored through the shared trait. + * Tests usingProviderModel defers resolution so later configuration still reaches the model. * * @return void */ - public function testModelPreferenceSelectsModel(): void + public function testUsingProviderModelAppliesLaterConfiguration(): void { $result = $this->createTestEmbeddingResult([[0.1, 0.2]]); - $metadata = $this->createTestEmbeddingModelMetadata('preferred-embedding-model'); - $model = $this->createMockEmbeddingGenerationModel($result, $metadata); - $providerMetadata = new ProviderMetadata('mock', 'Mock Provider', ProviderTypeEnum::cloud()); - - $this->registry->expects($this->once()) - ->method('findModelsMetadataForSupport') - ->willReturn([new ProviderModelsMetadata($providerMetadata, [$metadata])]); + $model = $this->createMockEmbeddingGenerationModel($result); $this->registry->expects($this->once()) ->method('getProviderModel') - ->with('mock', 'preferred-embedding-model', $this->isInstanceOf(ModelConfig::class)) + ->with( + 'mock', + 'test-embedding-model', + $this->callback( + static fn (ModelConfig $config): bool => $config->getDimensions() === 256 + ) + ) ->willReturn($model); $builder = new EmbeddingBuilder($this->registry, 'Embed this'); - $builder->usingModelPreference('preferred-embedding-model'); + $builder->usingProviderModel('mock', 'test-embedding-model'); + + // Configured after the model, so resolution must be deferred until generation. + $builder->usingDimensions(256); + + $this->assertCount(1, $builder->generateEmbeddings()); + } + + /** + * Tests usingProviderModel rejects empty identifiers. + * + * @return void + */ + public function testUsingProviderModelRejectsEmptyProviderIdentifier(): void + { + $builder = new EmbeddingBuilder($this->registry, 'Embed this'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Provider identifier cannot be empty.'); + + $builder->usingProviderModel(' ', 'test-embedding-model'); + } + + /** + * Tests usingProviderModel rejects an empty model identifier. + * + * @return void + */ + public function testUsingProviderModelRejectsEmptyModelIdentifier(): void + { + $builder = new EmbeddingBuilder($this->registry, 'Embed this'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Model identifier cannot be empty.'); + + $builder->usingProviderModel('mock', ' '); + } + + /** + * Tests usingModel supersedes a previously set provider model. + * + * @return void + */ + public function testUsingModelSupersedesProviderModel(): void + { + $result = $this->createTestEmbeddingResult([[0.1, 0.2]]); + $model = $this->createMockEmbeddingGenerationModel($result); + + // The registry must not be asked for a model once an instance is provided. + $this->registry->expects($this->never())->method('getProviderModel'); + + $builder = new EmbeddingBuilder($this->registry, 'Embed this'); + $builder->usingProviderModel('mock', 'test-embedding-model'); + $builder->usingModel($model); $this->assertCount(1, $builder->generateEmbeddings()); } @@ -460,19 +585,93 @@ public function testUsingDimensionsIsAppliedToConfig(): void } /** - * Tests isSupported delegates to the resolver. + * Tests the model configuration is applied to the specified model during generation. + * + * @return void + */ + public function testModelConfigIsAppliedToSpecifiedModel(): void + { + $result = $this->createTestEmbeddingResult([[0.1, 0.2]]); + $model = $this->createMockEmbeddingGenerationModel($result); + + $this->registry->expects($this->once()) + ->method('bindModelDependencies') + ->with($model); + + $builder = new EmbeddingBuilder($this->registry, 'Embed this'); + $builder->usingModel($model); + $builder->usingDimensions(256); + + $builder->generateEmbeddingResult(); + + $this->assertSame(256, $model->getConfig()->getDimensions()); + } + + /** + * Tests isSupported returns true when the specified model supports the request. * * @return void */ - public function testIsSupportedReturnsFalseWhenNoModels(): void + public function testIsSupportedReturnsTrueForSupportedModel(): void { - $this->registry->method('findModelsMetadataForSupport')->willReturn([]); + $model = $this->createMockEmbeddingGenerationModel($this->createTestEmbeddingResult()); $builder = new EmbeddingBuilder($this->registry, 'Embed this'); + $builder->usingModel($model); + $builder->usingDimensions(256); + + $this->assertTrue($builder->isSupported()); + } + + /** + * Tests isSupported returns false when the specified model does not support an option. + * + * @return void + */ + public function testIsSupportedReturnsFalseForUnsupportedOption(): void + { + $metadata = $this->createTestEmbeddingModelMetadata( + 'fixed-dimensions-model', + 'Fixed Dimensions Model', + [new SupportedOption(OptionEnum::inputModalities(), [[ModalityEnum::text()]])] + ); + $model = $this->createMockEmbeddingGenerationModel($this->createTestEmbeddingResult(), $metadata); + + $builder = new EmbeddingBuilder($this->registry, 'Embed this'); + $builder->usingModel($model); + $builder->usingDimensions(256); $this->assertFalse($builder->isSupported()); } + /** + * Tests isSupported returns false when the specified model cannot generate embeddings. + * + * @return void + */ + public function testIsSupportedReturnsFalseForNonEmbeddingModel(): void + { + $builder = new EmbeddingBuilder($this->registry, 'Embed this'); + $builder->usingModel($this->createMockUnsupportedModel('test-model')); + + $this->assertFalse($builder->isSupported()); + } + + /** + * Tests isSupported throws when no model was specified. + * + * @return void + */ + public function testIsSupportedThrowsWhenNoModelSpecified(): void + { + $builder = new EmbeddingBuilder($this->registry, 'Embed this'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('An embedding model must be specified.'); + + $builder->isSupported(); + } + /** * Tests cloning deep-copies inputs and configuration. * From 6825e03aad9b0b38c312661ee78115acac7385e1 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 13 Aug 2026 10:21:26 -0600 Subject: [PATCH 04/11] Add integration and unit tests. Update the error message when a provider isn't configured --- src/Builders/EmbeddingBuilder.php | 9 +- .../EmbeddingGenerationIntegrationTest.php | 242 ++++++++++++++++++ .../EmbeddingGenerationIntegrationTest.php | 116 ++++++++- tests/unit/Builders/EmbeddingBuilderTest.php | 4 +- 4 files changed, 361 insertions(+), 10 deletions(-) create mode 100644 tests/integration/Google/EmbeddingGenerationIntegrationTest.php diff --git a/src/Builders/EmbeddingBuilder.php b/src/Builders/EmbeddingBuilder.php index 51f39641..91732961 100644 --- a/src/Builders/EmbeddingBuilder.php +++ b/src/Builders/EmbeddingBuilder.php @@ -431,15 +431,18 @@ private function prepareModel(): ModelInterface * * @param string $providerIdOrClassName The provider ID or class name. * @return void - * @throws InvalidArgumentException If the provider is not registered or not configured. + * @throws InvalidArgumentException If the provider is not registered or not usable. */ private function assertProviderConfigured(string $providerIdOrClassName): void { + // A provider reports itself unconfigured for any reason it cannot be used, including + // credentials that are missing, invalid, or rejected, so the message covers all of them. if (!$this->registry->isProviderConfigured($providerIdOrClassName)) { throw new InvalidArgumentException( sprintf( - 'Provider "%s" is not registered or not configured. Ensure the provider is ' - . 'registered and its credentials are available before generating embeddings.', + 'Provider "%s" is not registered, or is not configured with valid credentials. ' + . 'Ensure the provider is registered and its credentials are present and valid ' + . 'before generating embeddings.', $providerIdOrClassName ) ); diff --git a/tests/integration/Google/EmbeddingGenerationIntegrationTest.php b/tests/integration/Google/EmbeddingGenerationIntegrationTest.php new file mode 100644 index 00000000..ccf141c5 --- /dev/null +++ b/tests/integration/Google/EmbeddingGenerationIntegrationTest.php @@ -0,0 +1,242 @@ +requireApiKey('GOOGLE_API_KEY'); + } + + /** + * Tests generating a single embedding from a string prompt. + */ + public function testSingleEmbeddingGeneration(): void + { + $embedding = AiClient::input('PHP powers a large part of the web.') + ->usingProviderModel('google', self::MODEL_ID) + ->generateEmbedding(); + + $this->assertInstanceOf(Embedding::class, $embedding); + $this->assertGreaterThan(0, count($embedding)); + $this->assertSame(count($embedding), $embedding->getDimensions()); + $this->assertIsFloat($embedding->getValues()[0]); + } + + /** + * Tests generating a single embedding from a list of inputs. + */ + public function testSingleEmbeddingGenerationInputs(): void + { + $embedding = AiClient::input([ + 'PHP powers a large part of the web.', + ]) + ->usingProviderModel('google', self::MODEL_ID) + ->generateEmbedding(); + + $this->assertInstanceOf(Embedding::class, $embedding); + $this->assertGreaterThan(0, count($embedding)); + $this->assertSame(count($embedding), $embedding->getDimensions()); + $this->assertIsFloat($embedding->getValues()[0]); + } + + /** + * Tests generating a single embedding using withInput(). + */ + public function testSingleEmbeddingGenerationWithInput(): void + { + $embedding = AiClient::input() + ->withInput(...[ + 'PHP powers a large part of the web.', + ]) + ->usingProviderModel('google', self::MODEL_ID) + ->generateEmbedding(); + + $this->assertInstanceOf(Embedding::class, $embedding); + $this->assertGreaterThan(0, count($embedding)); + $this->assertSame(count($embedding), $embedding->getDimensions()); + $this->assertIsFloat($embedding->getValues()[0]); + } + + /** + * Tests generating an embedding from a model instance. + * + * A model created by the provider directly has no HTTP transporter or authentication, so this + * also verifies the builder binds those dependencies. + */ + public function testEmbeddingGenerationWithModelInstance(): void + { + $embedding = AiClient::input('PHP powers a large part of the web.') + ->usingModel(GoogleProvider::model(self::MODEL_ID)) + ->generateEmbedding(); + + $this->assertInstanceOf(Embedding::class, $embedding); + $this->assertGreaterThan(0, count($embedding)); + } + + /** + * Tests generating an embedding with an explicit dimension count. + */ + public function testEmbeddingGenerationWithDimensions(): void + { + $embedding = AiClient::input('PHP powers a large part of the web.') + ->usingProviderModel('google', self::MODEL_ID) + ->usingDimensions(256) + ->generateEmbedding(); + + $this->assertSame(256, $embedding->getDimensions()); + $this->assertCount(256, $embedding->getValues()); + } + + /** + * Tests generating embeddings for a batch of inputs. + */ + public function testBatchEmbeddingGeneration(): void + { + $embeddings = AiClient::input([ + 'PHP powers a large part of the web.', + 'WordPress makes publishing accessible.', + ]) + ->usingProviderModel('google', self::MODEL_ID) + ->usingDimensions(256) + ->generateEmbeddings(); + + // Exercises the positional batch count guard: one vector must be returned per input. + $this->assertCount(2, $embeddings); + $this->assertContainsOnlyInstancesOf(Embedding::class, $embeddings); + $this->assertSame( + $embeddings[0]->getDimensions(), + $embeddings[1]->getDimensions() + ); + } + + /** + * Tests generating an embedding through the traditional API. + */ + public function testTraditionalApiWithProviderModelTuple(): void + { + $embedding = AiClient::generateEmbedding( + 'PHP powers a large part of the web.', + ['google', self::MODEL_ID] + ); + + $this->assertInstanceOf(Embedding::class, $embedding); + $this->assertGreaterThan(0, count($embedding)); + } + + /** + * Tests that the embedding result exposes provider metadata and token usage. + * + * Unlike OpenAI, Google's embedding endpoint does not return usage metadata, + * so token counts are expected to be zero rather than positive. + */ + public function testEmbeddingResultMetadataAndTokenUsage(): void + { + $result = AiClient::input('PHP powers a large part of the web.') + ->usingProviderModel('google', self::MODEL_ID) + ->generateEmbeddingResult(); + + $this->assertInstanceOf(EmbeddingResult::class, $result); + $this->assertCount(1, $result->getEmbeddings()); + $this->assertSame('google', $result->getProviderMetadata()->getId()); + $this->assertSame(self::MODEL_ID, $result->getModelMetadata()->getId()); + $this->assertGreaterThanOrEqual(0, $result->getTokenUsage()->getPromptTokens()); + $this->assertGreaterThanOrEqual(0, $result->getTokenUsage()->getTotalTokens()); + } + + /** + * Tests that omitting the model is rejected before any request is made. + */ + public function testGenerationWithoutModelIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('An embedding model must be specified.'); + + AiClient::input('PHP powers a large part of the web.')->generateEmbedding(); + } + + /** + * Tests that a text generation model is rejected for embedding generation. + */ + public function testTextGenerationModelIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + sprintf( + 'Model "%s" from provider "google" does not support embedding generation.', + self::TEXT_MODEL_ID + ) + ); + + AiClient::input('PHP powers a large part of the web.') + ->usingProviderModel('google', self::TEXT_MODEL_ID) + ->generateEmbedding(); + } + + /** + * Tests that a file input is rejected for a text-only embedding model. + * + * The failure is local: Google's embedding models advertise text input only, so this never + * reaches the API. + */ + public function testFileInputIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported options: inputModalities ([image]).'); + + AiClient::input(new File('https://example.com/image.jpg', 'image/jpeg')) + ->usingProviderModel('google', self::MODEL_ID) + ->generateEmbedding(); + } + + /** + * Tests that an unknown model identifier is rejected. + */ + public function testUnknownModelIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + AiClient::input('PHP powers a large part of the web.') + ->usingProviderModel('google', 'definitely-not-a-real-model') + ->generateEmbedding(); + } +} diff --git a/tests/integration/OpenAi/EmbeddingGenerationIntegrationTest.php b/tests/integration/OpenAi/EmbeddingGenerationIntegrationTest.php index 2970238b..2c994263 100644 --- a/tests/integration/OpenAi/EmbeddingGenerationIntegrationTest.php +++ b/tests/integration/OpenAi/EmbeddingGenerationIntegrationTest.php @@ -6,9 +6,12 @@ use PHPUnit\Framework\TestCase; use WordPress\AiClient\AiClient; +use WordPress\AiClient\Common\Exception\InvalidArgumentException; +use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Results\DTO\Embedding; use WordPress\AiClient\Results\DTO\EmbeddingResult; use WordPress\AiClient\Tests\integration\traits\IntegrationTestTrait; +use WordPress\OpenAiAiProvider\Provider\OpenAiProvider; /** * Integration tests for OpenAI embedding generation. @@ -16,6 +19,9 @@ * These tests make real API calls to OpenAI and require the OPENAI_API_KEY * environment variable to be set. * + * An embedding model must always be named explicitly, since embedding vectors are only comparable + * to other vectors produced by the same model. + * * @group integration * @group openai * @@ -25,6 +31,16 @@ class EmbeddingGenerationIntegrationTest extends TestCase { use IntegrationTestTrait; + /** + * The embedding model used by these tests. It supports a configurable output dimensionality. + */ + private const MODEL_ID = 'text-embedding-3-small'; + + /** + * An OpenAI model that generates text rather than embeddings. + */ + private const TEXT_MODEL_ID = 'gpt-4o-mini'; + protected function setUp(): void { parent::setUp(); @@ -37,7 +53,7 @@ protected function setUp(): void public function testSingleEmbeddingGeneration(): void { $embedding = AiClient::input('PHP powers a large part of the web.') - ->usingProvider('openai') + ->usingProviderModel('openai', self::MODEL_ID) ->generateEmbedding(); $this->assertInstanceOf(Embedding::class, $embedding); @@ -54,7 +70,7 @@ public function testSingleEmbeddingGenerationInputs(): void $embedding = AiClient::input([ 'PHP powers a large part of the web.', ]) - ->usingProvider('openai') + ->usingProviderModel('openai', self::MODEL_ID) ->generateEmbedding(); $this->assertInstanceOf(Embedding::class, $embedding); @@ -70,7 +86,7 @@ public function testSingleEmbeddingGenerationWithInput(): void { $embedding = AiClient::input() ->withInput(...['PHP powers a large part of the web.']) - ->usingProvider('openai') + ->usingProviderModel('openai', self::MODEL_ID) ->generateEmbedding(); $this->assertInstanceOf(Embedding::class, $embedding); @@ -79,13 +95,29 @@ public function testSingleEmbeddingGenerationWithInput(): void $this->assertIsFloat($embedding->getValues()[0]); } + /** + * Tests generating an embedding from a model instance. + * + * A model created by the provider directly has no HTTP transporter or authentication, so this + * also verifies the builder binds those dependencies. + */ + public function testEmbeddingGenerationWithModelInstance(): void + { + $embedding = AiClient::input('PHP powers a large part of the web.') + ->usingModel(OpenAiProvider::model(self::MODEL_ID)) + ->generateEmbedding(); + + $this->assertInstanceOf(Embedding::class, $embedding); + $this->assertGreaterThan(0, count($embedding)); + } + /** * Tests generating an embedding with an explicit dimension count. */ public function testEmbeddingGenerationWithDimensions(): void { $embedding = AiClient::input('PHP powers a large part of the web.') - ->usingProvider('openai') + ->usingProviderModel('openai', self::MODEL_ID) ->usingDimensions(256) ->generateEmbedding(); @@ -102,7 +134,7 @@ public function testBatchEmbeddingGeneration(): void 'PHP powers a large part of the web.', 'WordPress makes publishing accessible.', ]) - ->usingProvider('openai') + ->usingProviderModel('openai', self::MODEL_ID) ->usingDimensions(256) ->generateEmbeddings(); @@ -115,19 +147,91 @@ public function testBatchEmbeddingGeneration(): void ); } + /** + * Tests generating an embedding through the traditional API. + */ + public function testTraditionalApiWithProviderModelTuple(): void + { + $embedding = AiClient::generateEmbedding( + 'PHP powers a large part of the web.', + ['openai', self::MODEL_ID] + ); + + $this->assertInstanceOf(Embedding::class, $embedding); + $this->assertGreaterThan(0, count($embedding)); + } + /** * Tests that the embedding result exposes provider metadata and token usage. */ public function testEmbeddingResultMetadataAndTokenUsage(): void { $result = AiClient::input('PHP powers a large part of the web.') - ->usingProvider('openai') + ->usingProviderModel('openai', self::MODEL_ID) ->generateEmbeddingResult(); $this->assertInstanceOf(EmbeddingResult::class, $result); $this->assertCount(1, $result->getEmbeddings()); $this->assertSame('openai', $result->getProviderMetadata()->getId()); + $this->assertSame(self::MODEL_ID, $result->getModelMetadata()->getId()); $this->assertGreaterThan(0, $result->getTokenUsage()->getPromptTokens()); $this->assertGreaterThan(0, $result->getTokenUsage()->getTotalTokens()); } + + /** + * Tests that omitting the model is rejected before any request is made. + */ + public function testGenerationWithoutModelIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('An embedding model must be specified.'); + + AiClient::input('PHP powers a large part of the web.')->generateEmbedding(); + } + + /** + * Tests that a text generation model is rejected for embedding generation. + */ + public function testTextGenerationModelIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + sprintf( + 'Model "%s" from provider "openai" does not support embedding generation.', + self::TEXT_MODEL_ID + ) + ); + + AiClient::input('PHP powers a large part of the web.') + ->usingProviderModel('openai', self::TEXT_MODEL_ID) + ->generateEmbedding(); + } + + /** + * Tests that a file input is rejected for a text-only embedding model. + * + * The failure is local: OpenAI's embedding models advertise text input only, so this never + * reaches the API. + */ + public function testFileInputIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported options: inputModalities ([image]).'); + + AiClient::input(new File('https://example.com/image.jpg', 'image/jpeg')) + ->usingProviderModel('openai', self::MODEL_ID) + ->generateEmbedding(); + } + + /** + * Tests that an unknown model identifier is rejected. + */ + public function testUnknownModelIsRejected(): void + { + $this->expectException(InvalidArgumentException::class); + + AiClient::input('PHP powers a large part of the web.') + ->usingProviderModel('openai', 'definitely-not-a-real-model') + ->generateEmbedding(); + } } diff --git a/tests/unit/Builders/EmbeddingBuilderTest.php b/tests/unit/Builders/EmbeddingBuilderTest.php index 2c33385b..540cd105 100644 --- a/tests/unit/Builders/EmbeddingBuilderTest.php +++ b/tests/unit/Builders/EmbeddingBuilderTest.php @@ -414,7 +414,9 @@ public function testGenerateEmbeddingResultThrowsWhenProviderNotConfigured(): vo $builder->usingModel($model); $this->expectException(InvalidArgumentException::class); - $this->expectExceptionMessage('Provider "mock" is not registered or not configured.'); + $this->expectExceptionMessage( + 'Provider "mock" is not registered, or is not configured with valid credentials.' + ); $builder->generateEmbeddingResult(); } From 8168ac18e0ae137feebab35b0d6e92b739c1b809 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 13 Aug 2026 10:33:27 -0600 Subject: [PATCH 05/11] Update docs --- README.md | 45 +++++++++++++++++++++++++++++++++++++--- docs/ARCHITECTURE.md | 49 +++++++++++++++++++++++++++++--------------- 2 files changed, 74 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 60e4574b..795264e7 100644 --- a/README.md +++ b/README.md @@ -88,17 +88,31 @@ $imageFile = AiClient::prompt('Generate an illustration of the PHP elephant in t ->generateImage(); ``` -### Embedding generation using any compatible model +### Embedding generation using a specific model + +Unlike the other capabilities, embedding generation always requires you to name the model. Embedding vectors are only comparable to other vectors produced by the same model, so a stored set of embeddings is permanently tied to the model that created it. If the library picked a model for you, that choice could change between requests — for example when the registered providers change — silently making new vectors incomparable to the ones you already stored. Omitting the model therefore raises an error rather than falling back to a default. ```php use WordPress\AiClient\AiClient; $embedding = AiClient::input('PHP powers a large part of the web.') + ->usingProviderModel('google', 'gemini-embedding-001') ->generateEmbedding(); $values = $embedding->getValues(); ``` +You can also pass a model instance, which is useful when you already have one or want to reference the provider class directly: + +```php +use WordPress\AiClient\AiClient; +use WordPress\GoogleAiProvider\Provider\GoogleProvider; + +$embedding = AiClient::input('PHP powers a large part of the web.') + ->usingModel(GoogleProvider::model('gemini-embedding-001')) + ->generateEmbedding(); +``` + ### Batch embedding generation ```php @@ -108,7 +122,7 @@ $embeddings = AiClient::input([ 'PHP powers a large part of the web.', 'WordPress makes publishing accessible.', ]) - ->usingProvider('openai') + ->usingProviderModel('openai', 'text-embedding-3-small') ->generateEmbeddings(); ``` @@ -118,11 +132,34 @@ $embeddings = AiClient::input([ use WordPress\AiClient\AiClient; $embedding = AiClient::input('PHP powers a large part of the web.') + ->usingProviderModel('openai', 'text-embedding-3-small') ->usingDimensions(512) ->generateEmbedding(); ``` -Embedding inputs are independent [`MessagePart`](https://github.com/WordPress/php-ai-client/blob/trunk/src/Messages/DTO/MessagePart.php) values, not a conversation. `input()` accepts one input or a list of inputs. Variadic `withInput()` accepts one or more arguments, and lists can be passed using PHP's spread syntax (`withInput(...$inputs)`). Each input may be a string, `MessagePart`, `File`, or message-part array shape. Model selection accounts for their input modalities and embedding configuration. +Embedding inputs are independent [`MessagePart`](https://github.com/WordPress/php-ai-client/blob/trunk/src/Messages/DTO/MessagePart.php) values, not a conversation. `input()` accepts one input or a list of inputs. Variadic `withInput()` accepts one or more arguments, and lists can be passed using PHP's spread syntax (`withInput(...$inputs)`). Each input may be a string, `MessagePart`, `File`, or message-part array shape. + +The model you name is verified before any request is sent: it must support embedding generation, accept the input modalities of your inputs, and support every configuration option you set. If it does not, an `InvalidArgumentException` explains which capability or option is unsupported. To check without triggering an exception, call `isSupported()`. + +### Discovering available embedding models + +Since no model is chosen for you, you may need to find out which embedding models the configured providers offer: + +```php +use WordPress\AiClient\AiClient; +use WordPress\AiClient\Providers\Models\DTO\ModelRequirements; +use WordPress\AiClient\Providers\Models\Enums\CapabilityEnum; + +$requirements = new ModelRequirements([CapabilityEnum::embeddingGeneration()], []); + +foreach (AiClient::defaultRegistry()->findModelsMetadataForSupport($requirements) as $providerModels) { + $providerId = $providerModels->getProvider()->getId(); + + foreach ($providerModels->getModels() as $modelMetadata) { + echo $providerId . ' / ' . $modelMetadata->getId() . "\n"; + } +} +``` See the [`PromptBuilder` class](https://github.com/WordPress/php-ai-client/blob/trunk/src/Builders/PromptBuilder.php) and the [`EmbeddingBuilder` class](https://github.com/WordPress/php-ai-client/blob/trunk/src/Builders/EmbeddingBuilder.php) and their public methods for all the ways you can configure generation. @@ -136,6 +173,8 @@ The AI Client supports PSR-14 event dispatching for prompt lifecycle events. Thi - `BeforeGenerateResultEvent` - Dispatched before a prompt is sent to the model - `AfterGenerateResultEvent` - Dispatched after a result is received from the model +- `BeforeGenerateEmbeddingEvent` - Dispatched before embedding inputs are sent to the model +- `AfterGenerateEmbeddingEvent` - Dispatched after an embedding result is received from the model **Important:** Event listeners should not return a value, as they will be ignored. In order to modify data that is passed with the event object, you need to rely on setters on the event object. Any event data for which there are no setters on the event object is meant to be immutable or, in other words, read-only for the event listener. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3d2f1a02..07cf44b6 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -13,7 +13,14 @@ For the implementer facing API surface, two alternative APIs are available: - A fluent API is used as the primary means of using the AI client SDK, for easy-to-read code by chaining declarative methods. - A traditional method based API inspired by the Vercel AI SDK, which is more aligned with traditional WordPress patterns such as passing an array of arguments. -The fluent API is exposed through builders. Content generation (text, image, speech, video, ...) uses the `PromptBuilder`, while embedding generation uses a dedicated `EmbeddingBuilder`. Embeddings are kept on a separate builder because they transform inputs into vectors rather than generating a conversational response, so most of `PromptBuilder`'s prompt-oriented parameters (system instruction, temperature, output modalities, chat history, ...) do not apply. Both builders share the logic for selecting a provider/model — including model preferences and request options — through a common `ModelResolver` (owned by each builder) and a shared `ModelResolutionTrait` that exposes the `usingModel()`, `usingModelPreference()`, `usingModelConfig()`, `usingProvider()`, and `usingRequestOptions()` methods. +The fluent API is exposed through builders. Content generation (text, image, speech, video, ...) uses the `PromptBuilder`, while embedding generation uses a dedicated `EmbeddingBuilder`. Embeddings are kept on a separate builder because they transform inputs into vectors rather than generating a conversational response, so most of `PromptBuilder`'s prompt-oriented parameters (system instruction, temperature, output modalities, chat history, ...) do not apply. + +The two builders differ fundamentally in how they arrive at a model: + +- `PromptBuilder` **resolves** a model. If none is named, it discovers a suitable one across the configured providers, honoring model preferences and any provider constraint. This state and logic live on a `ModelResolver` owned by the builder, exposed through the shared `ModelResolutionTrait` (`usingModel()`, `usingModelPreference()`, `usingProvider()`, `usingRequestOptions()`). +- `EmbeddingBuilder` **verifies** a model, and never selects one. Embedding vectors are only comparable to other vectors produced by the same model, so a stored corpus is permanently bound to the model that created it; automatically choosing a model could silently invalidate existing vectors when the registered providers change. The model must be named via `usingModel()` or `usingProviderModel()`, and omitting it is an error. The builder then checks, before any request is made, that the model supports embedding generation, accepts the inputs' modalities, supports every configured option, and belongs to a configured provider. + +Both builders accumulate model configuration through the shared `ModelConfigurationTrait`, which provides `usingModelConfig()`. ### Code examples @@ -293,15 +300,18 @@ $jsonString = AiClient::generateTextResult( )->toText(); ``` -#### Generate an embedding using any suitable model from any provider +#### Generate an embedding using a specific model _Note: Embeddings use the dedicated `EmbeddingBuilder` (via `AiClient::input()`) rather than the `PromptBuilder`. Each input is embedded independently, producing one embedding vector per input._ +_Note: Unlike every other capability, embedding generation requires the model to be named. No model is selected automatically, because embeddings from different models are not comparable. The model may be given as a `[provider ID, model ID]` pair or as a model instance._ + ##### Fluent API ```php // Single input. $embedding = AiClient::input('PHP powers a large part of the web.') + ->usingProviderModel('google', 'gemini-embedding-001') ->generateEmbedding(); // Multiple inputs, embedded as a batch. @@ -309,18 +319,25 @@ $embeddings = AiClient::input([ 'PHP powers a large part of the web.', 'WordPress makes publishing accessible.', ]) + ->usingModel(GoogleProvider::model('gemini-embedding-001')) ->generateEmbeddings(); ``` ##### Traditional API ```php -$embedding = AiClient::generateEmbedding('PHP powers a large part of the web.'); - -$embeddings = AiClient::generateEmbeddings([ +$embedding = AiClient::generateEmbedding( 'PHP powers a large part of the web.', - 'WordPress makes publishing accessible.', -]); + ['google', 'gemini-embedding-001'] +); + +$embeddings = AiClient::generateEmbeddings( + [ + 'PHP powers a large part of the web.', + 'WordPress makes publishing accessible.', + ], + ['google', 'gemini-embedding-001'] +); ``` ## Class diagrams @@ -409,9 +426,8 @@ direction LR class EmbeddingBuilder { +withInput(...$input) self +usingModel(ModelInterface $model) self - +usingModelPreference(...$preferredModels) self + +usingProviderModel(string $providerIdOrClassName, string $modelId) self +usingModelConfig(ModelConfig $config) self - +usingProvider(string $providerIdOrClassName) self +usingRequestOptions(RequestOptions $requestOptions) self +usingDimensions(int $dimensions) self +isSupported() bool @@ -460,9 +476,9 @@ direction LR +convertTextToSpeechResult(string|MessagePart|MessagePart[]|Message|Message[] $prompt, ModelInterface $model) GenerativeAiResult$ +generateSpeechResult(string|MessagePart|MessagePart[]|Message|Message[] $prompt, ModelInterface $model) GenerativeAiResult$ +generateVideoResult(string|MessagePart|MessagePart[]|Message|Message[] $prompt, ModelInterface $model) GenerativeAiResult$ - +generateEmbeddingResult(string|MessagePart|File $input, ModelInterface $model) EmbeddingResult$ - +generateEmbedding(string|MessagePart|File $input, ModelInterface $model) Embedding$ - +generateEmbeddings(string[]|MessagePart[] $inputs, ModelInterface $model) Embedding[]$ + +generateEmbeddingResult(string|MessagePart|File $input, ModelInterface|array $model, ModelConfig $modelConfig) EmbeddingResult$ + +generateEmbedding(string|MessagePart|File $input, ModelInterface|array $model, ModelConfig $modelConfig) Embedding$ + +generateEmbeddings(string[]|MessagePart[] $inputs, ModelInterface|array $model, ModelConfig $modelConfig) Embedding[]$ +generateTextOperation(string|MessagePart|MessagePart[]|Message|Message[] $prompt, ModelInterface $model) GenerativeAiOperation$ +generateImageOperation(string|MessagePart|MessagePart[]|Message|Message[] $prompt, ModelInterface $model) GenerativeAiOperation$ +convertTextToSpeechOperation(string|MessagePart|MessagePart[]|Message|Message[] $prompt, ModelInterface $model) GenerativeAiOperation$ @@ -532,9 +548,9 @@ direction LR +convertTextToSpeechResult(string|MessagePart|MessagePart[]|Message|Message[] $prompt, ModelInterface $model) GenerativeAiResult$ +generateSpeechResult(string|MessagePart|MessagePart[]|Message|Message[] $prompt, ModelInterface $model) GenerativeAiResult$ +generateVideoResult(string|MessagePart|MessagePart[]|Message|Message[] $prompt, ModelInterface $model) GenerativeAiResult$ - +generateEmbeddingResult(string|MessagePart|File $input, ModelInterface $model) EmbeddingResult$ - +generateEmbedding(string|MessagePart|File $input, ModelInterface $model) Embedding$ - +generateEmbeddings(string[]|MessagePart[] $inputs, ModelInterface $model) Embedding[]$ + +generateEmbeddingResult(string|MessagePart|File $input, ModelInterface|array $model, ModelConfig $modelConfig) EmbeddingResult$ + +generateEmbedding(string|MessagePart|File $input, ModelInterface|array $model, ModelConfig $modelConfig) Embedding$ + +generateEmbeddings(string[]|MessagePart[] $inputs, ModelInterface|array $model, ModelConfig $modelConfig) Embedding[]$ +generateTextOperation(string|MessagePart|MessagePart[]|Message|Message[] $prompt, ModelInterface $model) GenerativeAiOperation$ +generateImageOperation(string|MessagePart|MessagePart[]|Message|Message[] $prompt, ModelInterface $model) GenerativeAiOperation$ +convertTextToSpeechOperation(string|MessagePart|MessagePart[]|Message|Message[] $prompt, ModelInterface $model) GenerativeAiOperation$ @@ -599,9 +615,8 @@ direction LR class EmbeddingBuilder { +withInput(...$input) self +usingModel(ModelInterface $model) self - +usingModelPreference(...$preferredModels) self + +usingProviderModel(string $providerIdOrClassName, string $modelId) self +usingModelConfig(ModelConfig $config) self - +usingProvider(string $providerIdOrClassName) self +usingRequestOptions(RequestOptions $requestOptions) self +usingDimensions(int $dimensions) self +isSupported() bool From c5fea20a5d4383499722ed68310c6db3bf2a70a8 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 13 Aug 2026 10:33:44 -0600 Subject: [PATCH 06/11] Update our CLI script to work properly with embeddings and to load our providers --- cli.php | 87 +++++++++++++++++++++++++++++++++++++++------------------ 1 file changed, 60 insertions(+), 27 deletions(-) diff --git a/cli.php b/cli.php index 72def79a..2c3b1c09 100755 --- a/cli.php +++ b/cli.php @@ -8,9 +8,13 @@ * Usage: * GOOGLE_API_KEY=123456 php cli.php 'Your prompt here' --providerId=google --modelId=gemini-2.5-flash * OPENAI_API_KEY=123456 php cli.php 'Your prompt here' --providerId=openai - * OPENAI_API_KEY=123456 php cli.php 'Your prompt here' --providerId=openai --outputFormat=embedding-json * GOOGLE_API_KEY=123456 OPENAI_API_KEY=123456 php cli.php 'Your prompt here' * + * Embedding output formats require both --providerId and --modelId, because embeddings are only + * comparable to other embeddings from the same model: + * OPENAI_API_KEY=123456 php cli.php 'Your text here' --providerId=openai \ + * --modelId=text-embedding-3-small --outputFormat=embedding-json + * * For large prompts (e.g., with images), use stdin or file input: * cat prompt.json | php cli.php - --providerId=openai --modelId=gpt-4o * php cli.php @prompt.json --providerId=openai --modelId=gpt-4o @@ -24,6 +28,19 @@ require_once __DIR__ . '/vendor/autoload.php'; +// Register the provider packages that are installed as development dependencies. +foreach ( + [ + 'WordPress\AnthropicAiProvider\Provider\AnthropicProvider', + 'WordPress\GoogleAiProvider\Provider\GoogleProvider', + 'WordPress\OpenAiAiProvider\Provider\OpenAiProvider', + ] as $providerClassName +) { + if (class_exists($providerClassName)) { + AiClient::defaultRegistry()->registerProvider($providerClassName); + } +} + /** * Prints the output to stdout. * @@ -162,44 +179,60 @@ function logError(string $message, int $exit_code = 1): void $isEmbedding = $outputFormat === 'embedding-json' || $outputFormat === 'embedding-result-json'; +if ($isEmbedding && (!$providerId || !$modelId)) { + logError( + 'Embedding output formats require both --providerId and --modelId. Embeddings are only comparable ' + . 'to other embeddings from the same model, so no model is selected automatically.' + ); +} + +if ($isEmbedding && $modelPreference) { + logWarning('The --modelPreference argument is ignored for embedding output formats.'); +} + try { $modelConfig = ModelConfig::fromArray($model_config_data); - // Embeddings use a dedicated builder; other output formats use the prompt builder. - $promptBuilder = $isEmbedding ? AiClient::input($promptInput) : AiClient::prompt($promptInput); - $promptBuilder = $promptBuilder->usingModelConfig($modelConfig); - if ($providerId && $modelId) { - $providerClassName = AiClient::defaultRegistry()->getProviderClassName($providerId); - $promptBuilder = $promptBuilder->usingModel($providerClassName::model($modelId)); - } elseif ($providerId) { - $promptBuilder = $promptBuilder->usingProvider($providerId); - } - if ($modelPreference) { - $modelPreference = array_map( - static function ($item) { - $item = trim($item); - if (str_contains($item, '::')) { - return explode('::', $item, 2); - } - return $item; - }, - explode(',', $modelPreference) - ); - $promptBuilder = $promptBuilder->usingModelPreference(...$modelPreference); + if ($isEmbedding) { + // Embeddings use a dedicated builder, which requires an explicit model. + $builder = AiClient::input($promptInput) + ->usingModelConfig($modelConfig) + ->usingProviderModel($providerId, $modelId); + } else { + $builder = AiClient::prompt($promptInput)->usingModelConfig($modelConfig); + if ($providerId && $modelId) { + $providerClassName = AiClient::defaultRegistry()->getProviderClassName($providerId); + $builder = $builder->usingModel($providerClassName::model($modelId)); + } elseif ($providerId) { + $builder = $builder->usingProvider($providerId); + } + if ($modelPreference) { + $modelPreference = array_map( + static function ($item) { + $item = trim($item); + if (str_contains($item, '::')) { + return explode('::', $item, 2); + } + return $item; + }, + explode(',', $modelPreference) + ); + $builder = $builder->usingModelPreference(...$modelPreference); + } } } catch (InvalidArgumentException $e) { - logError('Invalid arguments while trying to set up prompt builder: ' . $e->getMessage()); + logError('Invalid arguments while trying to set up the builder: ' . $e->getMessage()); } catch (ResponseException $e) { - logError('Request failed while trying to set up prompt builder: ' . $e->getMessage()); + logError('Request failed while trying to set up the builder: ' . $e->getMessage()); } try { if ($isEmbedding) { - $result = $promptBuilder->generateEmbeddingResult(); + $result = $builder->generateEmbeddingResult(); } elseif ($outputFormat === 'image-json' || $outputFormat === 'image-base64') { - $result = $promptBuilder->generateImageResult(); + $result = $builder->generateImageResult(); } else { - $result = $promptBuilder->generateTextResult(); + $result = $builder->generateTextResult(); } } catch (InvalidArgumentException $e) { logError('Invalid arguments while trying to generate result: ' . $e->getMessage()); From 1dff5aea88cfe712d238dab5450bd9a05eadc92d Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 13 Aug 2026 13:15:13 -0600 Subject: [PATCH 07/11] Update the isSupported check to only throw an error if no model was specified, not if an invalid model was provided. This matches what the README claims, where someone can run isSupported to see if a model is supported without worrying about catching exceptions --- README.md | 2 +- src/Builders/EmbeddingBuilder.php | 36 ++++++++++++++------ tests/unit/Builders/EmbeddingBuilderTest.php | 36 ++++++++++++++++++++ 3 files changed, 63 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 795264e7..c0894821 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ $embedding = AiClient::input('PHP powers a large part of the web.') Embedding inputs are independent [`MessagePart`](https://github.com/WordPress/php-ai-client/blob/trunk/src/Messages/DTO/MessagePart.php) values, not a conversation. `input()` accepts one input or a list of inputs. Variadic `withInput()` accepts one or more arguments, and lists can be passed using PHP's spread syntax (`withInput(...$inputs)`). Each input may be a string, `MessagePart`, `File`, or message-part array shape. -The model you name is verified before any request is sent: it must support embedding generation, accept the input modalities of your inputs, and support every configuration option you set. If it does not, an `InvalidArgumentException` explains which capability or option is unsupported. To check without triggering an exception, call `isSupported()`. +The model you name is verified before any request is sent: it must support embedding generation, accept the input modalities of your inputs, and support every configuration option you set. If it does not, an `InvalidArgumentException` explains which capability or option is unsupported. To check without triggering an exception, call `isSupported()`, which returns `false` for any model that cannot fulfill the request — including one whose provider is not registered or configured, or a model ID the provider does not offer. Only omitting the model entirely still throws, since that is a programming error rather than an unsupported model. ### Discovering available embedding models diff --git a/src/Builders/EmbeddingBuilder.php b/src/Builders/EmbeddingBuilder.php index 91732961..cc3ef4c4 100644 --- a/src/Builders/EmbeddingBuilder.php +++ b/src/Builders/EmbeddingBuilder.php @@ -50,6 +50,13 @@ class EmbeddingBuilder { use ModelConfigurationTrait; + /** + * @var string Message used when no model was specified. + */ + private const NO_MODEL_MESSAGE = 'An embedding model must be specified. Embeddings are only comparable to ' + . 'other embeddings from the same model, so no model is selected automatically. ' + . 'Use usingModel() or usingProviderModel().'; + /** * @var ProviderRegistry The provider registry used to prepare the model. */ @@ -244,20 +251,33 @@ public function usingDimensions(int $dimensions): self /** * Checks whether the specified model supports the current inputs and configuration. * - * As of version 1.5.0 this reports whether the model set via {@see self::usingModel()} or + * This reports whether the model set via {@see self::usingModel()} or * {@see self::usingProviderModel()} can fulfill the request, rather than whether any available - * model can. Because a model is mandatory, this method throws if none was specified. + * model can. + * + * Any reason the specified model cannot fulfill the request is reported as `false`, including an + * unregistered or unconfigured provider and a model ID the provider does not offer. Only failing + * to specify a model at all is treated as a programming error and throws. * * @since 1.4.0 * * @return bool True if the specified model supports embedding generation for the current * inputs and configuration. - * @throws InvalidArgumentException If no model was specified, the model's provider is not - * configured, or the model could not be retrieved. + * @throws InvalidArgumentException If no model was specified. */ public function isSupported(): bool { - $model = $this->prepareModel(); + if ($this->providerModel === null && $this->model === null) { + throw new InvalidArgumentException(self::NO_MODEL_MESSAGE); + } + + try { + $model = $this->prepareModel(); + } catch (InvalidArgumentException $e) { + // The model is unusable: its provider is not registered or configured, or the provider + // has no model with the given ID. Either way it cannot fulfill the request. + return false; + } if (!$model instanceof EmbeddingGenerationModelInterface) { return false; @@ -409,11 +429,7 @@ private function prepareModel(): ModelInterface $model->setConfig($this->modelConfig); $this->registry->bindModelDependencies($model); } else { - throw new InvalidArgumentException( - 'An embedding model must be specified. Embeddings are only comparable to other ' - . 'embeddings from the same model, so no model is selected automatically. ' - . 'Use usingModel() or usingProviderModel().' - ); + throw new InvalidArgumentException(self::NO_MODEL_MESSAGE); } // Request options are only applicable to API-based models that make HTTP requests. diff --git a/tests/unit/Builders/EmbeddingBuilderTest.php b/tests/unit/Builders/EmbeddingBuilderTest.php index 540cd105..43258a13 100644 --- a/tests/unit/Builders/EmbeddingBuilderTest.php +++ b/tests/unit/Builders/EmbeddingBuilderTest.php @@ -659,6 +659,42 @@ public function testIsSupportedReturnsFalseForNonEmbeddingModel(): void $this->assertFalse($builder->isSupported()); } + /** + * Tests isSupported returns false when the model's provider is not configured. + * + * @return void + */ + public function testIsSupportedReturnsFalseWhenProviderNotConfigured(): void + { + $registry = $this->createMock(ProviderRegistry::class); + $registry->method('isProviderConfigured')->willReturn(false); + + $model = $this->createMockEmbeddingGenerationModel($this->createTestEmbeddingResult()); + + $builder = new EmbeddingBuilder($registry, 'Embed this'); + $builder->usingModel($model); + + $this->assertFalse($builder->isSupported()); + } + + /** + * Tests isSupported returns false when the provider has no model with the given ID. + * + * @return void + */ + public function testIsSupportedReturnsFalseForUnknownProviderModel(): void + { + $this->registry->method('getProviderModel') + ->willThrowException( + new InvalidArgumentException('No model with ID no-such-model was found in the provider') + ); + + $builder = new EmbeddingBuilder($this->registry, 'Embed this'); + $builder->usingProviderModel('mock', 'no-such-model'); + + $this->assertFalse($builder->isSupported()); + } + /** * Tests isSupported throws when no model was specified. * From 604a714e098501e23c29f165110687441b938f61 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Thu, 13 Aug 2026 13:25:14 -0600 Subject: [PATCH 08/11] Add a new locateModel method used to determine the model to use. Separate this out from prepareModel so we only touch model config prior to making a request, not verifying things --- src/AiClient.php | 2 +- src/Builders/EmbeddingBuilder.php | 76 ++++++++++++++---- tests/unit/Builders/EmbeddingBuilderTest.php | 83 ++++++++++++++++++++ 3 files changed, 146 insertions(+), 15 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index 0119554e..08d30070 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -575,7 +575,7 @@ private static function getConfiguredEmbeddingBuilder( ): EmbeddingBuilder { $builder = self::input($input, $registry); - // Apply the configuration first, so that it takes precedence over the model's own config. + // The builder's configuration takes precedence over the model's own configuration. if ($modelConfig !== null) { $builder->usingModelConfig($modelConfig); } diff --git a/src/Builders/EmbeddingBuilder.php b/src/Builders/EmbeddingBuilder.php index cc3ef4c4..2fa26bad 100644 --- a/src/Builders/EmbeddingBuilder.php +++ b/src/Builders/EmbeddingBuilder.php @@ -171,7 +171,9 @@ public function withInput(...$input): self * Sets the model to use for embedding generation. * * The model's configuration will be merged with the builder's configuration, - * with the builder's configuration taking precedence for any overlapping settings. + * with the builder's configuration taking precedence for any overlapping settings. The merge + * happens when the model is used rather than here, so replacing the model replaces its + * configuration along with it. * * @since 1.4.0 * @@ -183,9 +185,6 @@ public function usingModel(ModelInterface $model): self $this->model = $model; $this->providerModel = null; - // Merge model's config with builder's config, with builder's config taking precedence - $this->mergeModelConfig($model->getConfig()); - return $this; } @@ -259,6 +258,10 @@ public function usingDimensions(int $dimensions): self * unregistered or unconfigured provider and a model ID the provider does not offer. Only failing * to specify a model at all is treated as a programming error and throws. * + * A model instance provided via {@see self::usingModel()} is left untouched, so that checking + * for support does not alter it. Determining whether the model's provider is configured may + * require a request to the provider, which is cached for the remainder of the request. + * * @since 1.4.0 * * @return bool True if the specified model supports embedding generation for the current @@ -272,7 +275,7 @@ public function isSupported(): bool } try { - $model = $this->prepareModel(); + $model = $this->locateModel(); } catch (InvalidArgumentException $e) { // The model is unusable: its provider is not registered or configured, or the provider // has no model with the given ID. Either way it cannot fulfill the request. @@ -404,15 +407,18 @@ private function resolveModel(): ModelInterface } /** - * Prepares the specified model for use, without verifying that it can fulfill the request. + * Locates the specified model, without preparing it for use. + * + * Unlike {@see self::prepareModel()}, this leaves a model instance provided by the caller + * untouched, so that it can be inspected without altering it. * * @since n.e.x.t * - * @return ModelInterface The prepared model, with its dependencies and configuration bound. + * @return ModelInterface The located model. * @throws InvalidArgumentException If no model was specified, the model's provider is not * configured, or the model could not be retrieved. */ - private function prepareModel(): ModelInterface + private function locateModel(): ModelInterface { if ($this->providerModel !== null) { [$providerIdOrClassName, $modelId] = $this->providerModel; @@ -420,16 +426,38 @@ private function prepareModel(): ModelInterface $this->assertProviderConfigured($providerIdOrClassName); // Retrieving the model also binds its provider dependencies. - $model = $this->registry->getProviderModel($providerIdOrClassName, $modelId, $this->modelConfig); - } elseif ($this->model !== null) { + return $this->registry->getProviderModel($providerIdOrClassName, $modelId, $this->modelConfig); + } + + if ($this->model !== null) { $model = $this->model; $this->assertProviderConfigured($model->providerMetadata()->getId()); - $model->setConfig($this->modelConfig); + return $model; + } + + throw new InvalidArgumentException(self::NO_MODEL_MESSAGE); + } + + /** + * Prepares the specified model for use, without verifying that it can fulfill the request. + * + * @since n.e.x.t + * + * @return ModelInterface The prepared model, with its dependencies and configuration bound. + * @throws InvalidArgumentException If no model was specified, the model's provider is not + * configured, or the model could not be retrieved. + */ + private function prepareModel(): ModelInterface + { + $model = $this->locateModel(); + + // A model retrieved from the registry is already bound and configured, so only a model + // instance provided by the caller needs its dependencies and configuration bound here. + if ($model === $this->model) { + $model->setConfig($this->effectiveModelConfig($model)); $this->registry->bindModelDependencies($model); - } else { - throw new InvalidArgumentException(self::NO_MODEL_MESSAGE); } // Request options are only applicable to API-based models that make HTTP requests. @@ -440,6 +468,26 @@ private function prepareModel(): ModelInterface return $model; } + /** + * Combines a model's own configuration with the configuration set on this builder. + * + * The builder's configuration takes precedence for any overlapping settings. Combining happens + * here, when the model is used, rather than when the model is set, so that the configuration of + * a model that was subsequently replaced is not applied to its replacement. + * + * @since n.e.x.t + * + * @param ModelInterface $model The model whose configuration to combine with the builder's. + * @return ModelConfig The effective configuration for the given model. + */ + private function effectiveModelConfig(ModelInterface $model): ModelConfig + { + return ModelConfig::fromArray(array_merge( + $model->getConfig()->toArray(), + $this->modelConfig->toArray() + )); + } + /** * Asserts that the given provider is configured and therefore usable. * @@ -475,7 +523,7 @@ private function assertProviderConfigured(string $providerIdOrClassName): void */ private function describeUnmetRequirements(ModelInterface $model): ?string { - $requirements = ModelRequirements::fromEmbeddingData($this->inputs, $this->modelConfig); + $requirements = ModelRequirements::fromEmbeddingData($this->inputs, $this->effectiveModelConfig($model)); /** @var UnmetModelRequirementsShape $unmetRequirements */ $unmetRequirements = $requirements->getUnmetRequirements($model->metadata()); diff --git a/tests/unit/Builders/EmbeddingBuilderTest.php b/tests/unit/Builders/EmbeddingBuilderTest.php index 43258a13..e6d734f7 100644 --- a/tests/unit/Builders/EmbeddingBuilderTest.php +++ b/tests/unit/Builders/EmbeddingBuilderTest.php @@ -609,6 +609,89 @@ public function testModelConfigIsAppliedToSpecifiedModel(): void $this->assertSame(256, $model->getConfig()->getDimensions()); } + /** + * Tests the model's own configuration is applied unless the builder overrides it. + * + * @return void + */ + public function testSpecifiedModelKeepsItsOwnConfigUnlessOverridden(): void + { + $modelConfig = new ModelConfig(); + $modelConfig->setDimensions(1024); + $modelConfig->setCustomOption('encodingFormat', 'float'); + + $result = $this->createTestEmbeddingResult([[0.1, 0.2]]); + $model = $this->createMockEmbeddingGenerationModel($result); + $model->setConfig($modelConfig); + + $builder = new EmbeddingBuilder($this->registry, 'Embed this'); + $builder->usingModel($model); + $builder->usingDimensions(256); + + $builder->generateEmbeddingResult(); + + // The builder's dimensions win, while the model's other configuration is retained. + $this->assertSame(256, $model->getConfig()->getDimensions()); + $this->assertSame(['encodingFormat' => 'float'], $model->getConfig()->getCustomOptions()); + } + + /** + * Tests replacing a model does not leave its configuration behind for the replacement. + * + * @return void + */ + public function testUsingProviderModelDiscardsReplacedModelConfig(): void + { + $replacedConfig = new ModelConfig(); + $replacedConfig->setDimensions(3072); + + $replaced = $this->createMockEmbeddingGenerationModel($this->createTestEmbeddingResult()); + $replaced->setConfig($replacedConfig); + + $replacement = $this->createMockEmbeddingGenerationModel($this->createTestEmbeddingResult([[0.1, 0.2]])); + + $this->registry->expects($this->once()) + ->method('getProviderModel') + ->with( + 'mock', + 'test-embedding-model', + $this->callback( + static fn (ModelConfig $config): bool => $config->getDimensions() === null + ) + ) + ->willReturn($replacement); + + $builder = new EmbeddingBuilder($this->registry, 'Embed this'); + $builder->usingModel($replaced); + $builder->usingProviderModel('mock', 'test-embedding-model'); + + $this->assertCount(1, $builder->generateEmbeddings()); + } + + /** + * Tests isSupported leaves a caller-provided model instance untouched. + * + * @return void + */ + public function testIsSupportedDoesNotAlterSpecifiedModel(): void + { + $modelConfig = new ModelConfig(); + $modelConfig->setDimensions(1024); + + $model = $this->createMockEmbeddingGenerationModel($this->createTestEmbeddingResult()); + $model->setConfig($modelConfig); + + $this->registry->expects($this->never())->method('bindModelDependencies'); + + $builder = new EmbeddingBuilder($this->registry, 'Embed this'); + $builder->usingModel($model); + $builder->usingDimensions(256); + + $this->assertTrue($builder->isSupported()); + $this->assertSame($modelConfig, $model->getConfig()); + $this->assertSame(1024, $model->getConfig()->getDimensions()); + } + /** * Tests isSupported returns true when the specified model supports the request. * From 0b886ec50d23b59a2aab495badc9580ee16a3cf4 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Mon, 24 Aug 2026 15:36:08 -0600 Subject: [PATCH 09/11] Add a ProviderModelTuple type and use that instead of directly saying array{0: string, 1: string} --- src/AiClient.php | 18 +++++++----------- src/Builders/EmbeddingBuilder.php | 4 +++- src/Builders/Traits/ModelResolutionTrait.php | 4 +++- src/Providers/ModelResolver.php | 8 +++++--- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/src/AiClient.php b/src/AiClient.php index 08d30070..feee25e2 100644 --- a/src/AiClient.php +++ b/src/AiClient.php @@ -12,6 +12,7 @@ use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Providers\Contracts\ProviderAvailabilityInterface; use WordPress\AiClient\Providers\Contracts\ProviderInterface; +use WordPress\AiClient\Providers\ModelResolver; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\ProviderRegistry; @@ -82,6 +83,7 @@ * * @phpstan-import-type Prompt from PromptBuilder * @phpstan-import-type EmbeddingInput from EmbeddingBuilder + * @phpstan-import-type ProviderModelTuple from ModelResolver * * phpcs:ignore Generic.Files.LineLength.TooLong */ @@ -425,9 +427,7 @@ public static function generateVideoResult( * @since 1.4.0 * * @param EmbeddingInput|list $input The input(s) to embed. - * @param ModelInterface|array{0: string, 1: string} $model The model to use, either as an - * instance or as a - * [provider ID, model ID] tuple. + * @param ModelInterface|ProviderModelTuple $model The model to use. * @param ModelConfig|null $modelConfig Optional model configuration to apply. * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. * @return EmbeddingResult The embedding result. @@ -454,9 +454,7 @@ public static function generateEmbeddingResult( * @since 1.4.0 * * @param EmbeddingInput $input The input to embed. - * @param ModelInterface|array{0: string, 1: string} $model The model to use, either as an - * instance or as a - * [provider ID, model ID] tuple. + * @param ModelInterface|ProviderModelTuple $model The model to use. * @param ModelConfig|null $modelConfig Optional model configuration to apply. * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. * @return Embedding The generated embedding vector. @@ -479,9 +477,7 @@ public static function generateEmbedding( * @since 1.4.0 * * @param list $inputs The inputs to embed. - * @param ModelInterface|array{0: string, 1: string} $model The model to use, either as an - * instance or as a - * [provider ID, model ID] tuple. + * @param ModelInterface|ProviderModelTuple $model The model to use. * @param ModelConfig|null $modelConfig Optional model configuration to apply. * @param ProviderRegistry|null $registry Optional custom registry. If null, uses default. * @return list The generated embedding vectors. @@ -560,8 +556,8 @@ private static function getConfiguredPromptBuilder( * Configures an EmbeddingBuilder with the required model and optional configuration. * * @param EmbeddingInput|list $input The input(s) to embed. - * @param mixed $model The model to use, either as a ModelInterface instance or as a - * [provider ID, model ID] tuple. + * @param mixed $model The model to use, expected to be a ModelInterface instance or a + * ProviderModelTuple; any other value is rejected. * @param ModelConfig|null $modelConfig Optional model configuration to apply. * @param ProviderRegistry|null $registry Optional custom registry to use. * @return EmbeddingBuilder Configured embedding builder. diff --git a/src/Builders/EmbeddingBuilder.php b/src/Builders/EmbeddingBuilder.php index 2fa26bad..d170e438 100644 --- a/src/Builders/EmbeddingBuilder.php +++ b/src/Builders/EmbeddingBuilder.php @@ -15,6 +15,7 @@ use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Providers\ApiBasedImplementation\Contracts\ApiBasedModelInterface; use WordPress\AiClient\Providers\Http\DTO\RequestOptions; +use WordPress\AiClient\Providers\ModelResolver; use WordPress\AiClient\Providers\Models\Contracts\ModelInterface; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\ModelRequirements; @@ -43,6 +44,7 @@ * * @phpstan-import-type MessagePartArrayShape from MessagePart * @phpstan-import-type UnmetModelRequirementsShape from ModelRequirements + * @phpstan-import-type ProviderModelTuple from ModelResolver * * @phpstan-type EmbeddingInput string|MessagePart|File|MessagePartArrayShape */ @@ -73,7 +75,7 @@ class EmbeddingBuilder protected ?ModelInterface $model = null; /** - * @var array{0: string, 1: string}|null The provider ID or class name and model ID, if any. + * @var ProviderModelTuple|null The provider ID or class name and model ID, if any. */ protected ?array $providerModel = null; diff --git a/src/Builders/Traits/ModelResolutionTrait.php b/src/Builders/Traits/ModelResolutionTrait.php index 831833bb..8401c00b 100644 --- a/src/Builders/Traits/ModelResolutionTrait.php +++ b/src/Builders/Traits/ModelResolutionTrait.php @@ -19,6 +19,8 @@ * composed {@see ModelConfigurationTrait}. * * @since 1.4.0 + * + * @phpstan-import-type ProviderModelTuple from ModelResolver */ trait ModelResolutionTrait { @@ -55,7 +57,7 @@ public function usingModel(ModelInterface $model): self * * @since 0.2.0 * - * @param string|ModelInterface|array{0:string,1:string} ...$preferredModels The preferred models as model IDs, + * @param string|ModelInterface|ProviderModelTuple ...$preferredModels The preferred models as model IDs, * model instances, or [provider ID, model ID] tuples. For broader compatibility, it is recommended you specify * only model IDs or model instances, as that will allow for different providers that expose the same model to be * considered. diff --git a/src/Providers/ModelResolver.php b/src/Providers/ModelResolver.php index c8aeefe5..ee03147f 100644 --- a/src/Providers/ModelResolver.php +++ b/src/Providers/ModelResolver.php @@ -21,6 +21,8 @@ * selection behaves identically regardless of what is being generated. * * @since 1.4.0 + * + * @phpstan-type ProviderModelTuple array{0: string, 1: string} */ class ModelResolver { @@ -107,7 +109,7 @@ public function getModel(): ?ModelInterface * * @since 1.4.0 * - * @param string|ModelInterface|array{0:string,1:string} ...$preferredModels The preferred models as model IDs, + * @param string|ModelInterface|ProviderModelTuple ...$preferredModels The preferred models as model IDs, * model instances, or [provider ID, model ID] tuples. For broader compatibility, it is recommended you specify * only model IDs or model instances, as that will allow for different providers that expose the same model to be * considered. @@ -349,7 +351,7 @@ private function bindModelRequestOptions(ModelInterface $model): void * @since 1.4.0 * * @param ModelRequirements $requirements The requirements derived from the prompt. - * @return array Map of preference keys to [providerId, modelId] tuples. + * @return array Map of preference keys to [providerId, modelId] tuples. */ private function getCandidateModelsMap(ModelRequirements $requirements): array { @@ -388,7 +390,7 @@ private function getCandidateModelsMap(ModelRequirements $requirements): array * * @param string $providerId The provider ID. * @param list $modelsMetadata The models metadata to map. - * @return array Map of preference keys to [providerId, modelId] tuples. + * @return array Map of preference keys to [providerId, modelId] tuples. */ private function generateMapFromCandidates(string $providerId, array $modelsMetadata): array { From ec5fdbc747bb380066fa95dfe55d53a2a62684ef Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Mon, 24 Aug 2026 15:40:49 -0600 Subject: [PATCH 10/11] Switch to catching a AiClientExceptionInterface exception to have more broad support. Add a test to cover this --- src/Builders/EmbeddingBuilder.php | 13 +++++++----- tests/unit/Builders/EmbeddingBuilderTest.php | 22 ++++++++++++++++++++ 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/src/Builders/EmbeddingBuilder.php b/src/Builders/EmbeddingBuilder.php index d170e438..bd1108cd 100644 --- a/src/Builders/EmbeddingBuilder.php +++ b/src/Builders/EmbeddingBuilder.php @@ -7,6 +7,7 @@ use Psr\EventDispatcher\EventDispatcherInterface; use WordPress\AiClient\Builders\Traits\ModelConfigurationTrait; use WordPress\AiClient\Common\AbstractEnum; +use WordPress\AiClient\Common\Contracts\AiClientExceptionInterface; use WordPress\AiClient\Common\Exception\InvalidArgumentException; use WordPress\AiClient\Common\Exception\RuntimeException; use WordPress\AiClient\Events\AfterGenerateEmbeddingEvent; @@ -257,8 +258,9 @@ public function usingDimensions(int $dimensions): self * model can. * * Any reason the specified model cannot fulfill the request is reported as `false`, including an - * unregistered or unconfigured provider and a model ID the provider does not offer. Only failing - * to specify a model at all is treated as a programming error and throws. + * unregistered or unconfigured provider, a model ID the provider does not offer, and a provider + * that could not be reached. Only failing to specify a model at all is treated as a programming + * error and throws. * * A model instance provided via {@see self::usingModel()} is left untouched, so that checking * for support does not alter it. Determining whether the model's provider is configured may @@ -278,9 +280,10 @@ public function isSupported(): bool try { $model = $this->locateModel(); - } catch (InvalidArgumentException $e) { - // The model is unusable: its provider is not registered or configured, or the provider - // has no model with the given ID. Either way it cannot fulfill the request. + } catch (AiClientExceptionInterface $e) { + // The model is unusable: its provider is not registered or configured, the provider has + // no model with the given ID, or the provider could not be reached. Either way it cannot + // fulfill the request. return false; } diff --git a/tests/unit/Builders/EmbeddingBuilderTest.php b/tests/unit/Builders/EmbeddingBuilderTest.php index e6d734f7..4f1dbde8 100644 --- a/tests/unit/Builders/EmbeddingBuilderTest.php +++ b/tests/unit/Builders/EmbeddingBuilderTest.php @@ -11,6 +11,7 @@ use WordPress\AiClient\Files\DTO\File; use WordPress\AiClient\Messages\DTO\MessagePart; use WordPress\AiClient\Messages\Enums\ModalityEnum; +use WordPress\AiClient\Providers\Http\Exception\ResponseException; use WordPress\AiClient\Providers\Models\DTO\ModelConfig; use WordPress\AiClient\Providers\Models\DTO\SupportedOption; use WordPress\AiClient\Providers\Models\Enums\OptionEnum; @@ -778,6 +779,27 @@ public function testIsSupportedReturnsFalseForUnknownProviderModel(): void $this->assertFalse($builder->isSupported()); } + /** + * Tests isSupported returns false when the provider cannot be reached. + * + * Retrieving a model by provider and model ID can trigger the provider's list-models request, + * which fails with a ResponseException rather than an InvalidArgumentException. + * + * @return void + */ + public function testIsSupportedReturnsFalseWhenProviderRequestFails(): void + { + $this->registry->method('getProviderModel') + ->willThrowException( + ResponseException::fromMissingData('Mock', 'data') + ); + + $builder = new EmbeddingBuilder($this->registry, 'Embed this'); + $builder->usingProviderModel('mock', 'mock-embedding-model'); + + $this->assertFalse($builder->isSupported()); + } + /** * Tests isSupported throws when no model was specified. * From 275fb4735b730534e23ca867880e404d78d41623 Mon Sep 17 00:00:00 2001 From: Darin Kotter Date: Tue, 25 Aug 2026 16:35:29 -0600 Subject: [PATCH 11/11] Add comment providing more description around the ProviderModelTuple type --- src/Providers/ModelResolver.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Providers/ModelResolver.php b/src/Providers/ModelResolver.php index ee03147f..33a7557e 100644 --- a/src/Providers/ModelResolver.php +++ b/src/Providers/ModelResolver.php @@ -22,6 +22,10 @@ * * @since 1.4.0 * + * A ProviderModelTuple is a two-element list identifying a model by its provider: index 0 is + * the provider ID or provider class name, index 1 is the model ID. For example, + * ['google', 'gemini-embedding-001']. + * * @phpstan-type ProviderModelTuple array{0: string, 1: string} */ class ModelResolver